diff --git a/README.md b/README.md index b42cecf1f0..8832afa9e2 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,3 @@ See [the front end documentation](sippy-ng/README.md) See [database tuning](docs/database-tuning.md) for required PostgreSQL parameter group settings. - -## Chat - -See [the chat documentation](chat/README.md) diff --git a/chat/.dockerignore b/chat/.dockerignore deleted file mode 100644 index 76e86451f1..0000000000 --- a/chat/.dockerignore +++ /dev/null @@ -1,109 +0,0 @@ -# Git -.git -.gitignore -.gitattributes - -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# Virtual environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ -.python-version - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# OS -.DS_Store -.DS_Store? -._* -.Spotlight-V100 -.Trashes -ehthumbs.db -Thumbs.db - -# Logs -*.log -logs/ - -# Testing -.pytest_cache/ -.coverage -htmlcov/ -.tox/ -.cache -nosetests.xml -coverage.xml -*.cover -.hypothesis/ - -# Documentation -docs/_build/ - -# Jupyter Notebook -.ipynb_checkpoints - -# Docker -Dockerfile* -.dockerignore -docker-compose*.yml - -# CI/CD -.github/ -.gitlab-ci.yml -.travis.yml - -# Temporary files -*.tmp -*.temp -.tmp/ -.temp/ - -# Local configuration that shouldn't be in container -.env.local -.env.development -.env.production -config.local.* - -# Node.js (if any frontend assets) -node_modules/ -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# Backup files -*.bak -*.backup - -# MCP configuration -mcp*.json \ No newline at end of file diff --git a/chat/.env.example b/chat/.env.example deleted file mode 100644 index c287dd9484..0000000000 --- a/chat/.env.example +++ /dev/null @@ -1,92 +0,0 @@ -# Sippy AI Agent Configuration -# Copy this file to .env and configure for your setup - -# ============================================================================= -# Model Configuration - Choose ONE of the following setups: -# ============================================================================= - -# ----------------------------------------------------------------------------- -# Option 1: Local Ollama -# ----------------------------------------------------------------------------- -# LLM_ENDPOINT=http://localhost:11434/v1 -# MODEL_NAME=llama3.1:8b - -# Other popular Ollama models: -# MODEL_NAME=llama3.2:latest -# MODEL_NAME=mistral:latest - -# ----------------------------------------------------------------------------- -# Option 2: OpenAI -# ----------------------------------------------------------------------------- -# LLM_ENDPOINT=https://api.openai.com/v1 -# MODEL_NAME=gpt-4o -# OPENAI_API_KEY=sk-your-openai-api-key-here - -# Other OpenAI models: -# MODEL_NAME=gpt-4o-mini -# MODEL_NAME=gpt-4-turbo -# MODEL_NAME=gpt-3.5-turbo - -# ----------------------------------------------------------------------------- -# Option 3: Google Gemini via AI Studio API -# ----------------------------------------------------------------------------- -# MODEL_NAME=gemini-1.5-pro -# GOOGLE_API_KEY=your-google-api-key-here - -# OR use service account credentials: -# MODEL_NAME=gemini-2.5-flash -# GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account-key.json - -# ----------------------------------------------------------------------------- -# Option 4: Claude via Google Vertex AI (Recommended for Claude) -# ----------------------------------------------------------------------------- -# Using gcloud auth (recommended for local development): -# MODEL_NAME=claude-sonnet-4-5 -# GOOGLE_PROJECT_ID=your-gcp-project-id -# GOOGLE_LOCATION=us-central1 - -# OR using service account credentials: -# MODEL_NAME=claude-sonnet-4-5 -# GOOGLE_PROJECT_ID=your-gcp-project-id -# GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account-key.json -# GOOGLE_LOCATION=us-central1 - -# ============================================================================= -# Model Parameters -# ============================================================================= -TEMPERATURE=0.0 - -# Token budget for Claude's extended thinking feature (only used when --thinking is enabled) -# EXTENDED_THINKING_BUDGET=10000 - -# ============================================================================= -# Sippy Configuration -# ============================================================================= -# Sippy API URL (required for most tools to work) -SIPPY_API_URL=https://sippy.dptools.openshift.org/api - -# Optional: Database access for advanced SQL queries (use read-only user!) -# SIPPY_READ_ONLY_DATABASE_DSN=postgresql://readonly_user:password@host:5432/sippy - -# ============================================================================= -# Jira Configuration (Optional - for incident tracking) -# ============================================================================= -JIRA_URL=https://redhat.atlassian.net - -# ============================================================================= -# Agent Behavior -# ============================================================================= -# Maximum number of tool call iterations before stopping -MAX_ITERATIONS=15 - -# Maximum execution time in seconds (default: 300 = 5 minutes) -MAX_EXECUTION_TIME=300 - -# AI Persona (default, zorp, etc.) -PERSONA=default - -# ============================================================================= -# MCP (Model Context Protocol) Integration (Optional) -# ============================================================================= -# Path to MCP servers configuration file -# MCP_CONFIG_FILE=mcp_config.json diff --git a/chat/.gitignore b/chat/.gitignore deleted file mode 100644 index ce3d8ef36a..0000000000 --- a/chat/.gitignore +++ /dev/null @@ -1,145 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# OS -.DS_Store -Thumbs.db -mcp_config.json - - -# Configuration for chat app -models.yaml diff --git a/chat/Dockerfile b/chat/Dockerfile deleted file mode 100644 index 68336f8ad1..0000000000 --- a/chat/Dockerfile +++ /dev/null @@ -1,80 +0,0 @@ -# CentOS until we can get Python3.13 in UBI+ -FROM quay.io/centos/centos:10 - -# Set metadata -LABEL name="sippy-chat" \ - version="1.0.0" \ - description="Sippy AI Agent - LangChain Re-Act agent for CI/CD analysis" \ - maintainer="Sippy Team" \ - vendor="Red Hat" \ - summary="AI-powered CI/CD analysis tool with web API and CLI interfaces" \ - io.k8s.description="Sippy AI Agent provides intelligent analysis of CI/CD pipelines, test failures, and build issues using LangChain and various LLM providers" \ - io.k8s.display-name="Sippy AI Agent" \ - io.openshift.tags="ai,ci-cd,analysis,langchain,python" - -ARG PYTHON_VERSION=3.13.0 - -# Switch to root to install system packages -USER root - -# Install system dependencies -RUN dnf install -y 'https://dl.fedoraproject.org/pub/epel/epel-release-latest-10.noarch.rpm' && \ - dnf config-manager --set-enabled crb && \ - dnf update -y && \ - dnf install -y \ - python3.13 \ - python3.13-pip \ - git \ - curl-minimal \ - ca-certificates \ - && dnf clean all \ - && rm -rf /var/cache/dnf - -# Create application directory -WORKDIR /opt/app-root/src - -# Create non-root user for running the application -RUN groupadd -r sippy && \ - useradd -r -g sippy -d /opt/app-root/src -s /sbin/nologin \ - -c "Sippy AI Agent user" sippy - -# Copy requirements first for better Docker layer caching -COPY requirements.txt ./ - -# Upgrade pip and install Python dependencies -RUN python3.13 -m pip install --upgrade pip && \ - python3.13 -m pip install --no-cache-dir -r requirements.txt - -# Copy application code -COPY . . - -# Copy environment template and create default .env -COPY .env.example .env - -# Set proper ownership -RUN chown -R sippy:sippy /opt/app-root/src - -# Switch to non-root user -USER sippy - -# Set environment variables -ENV PYTHONPATH=/opt/app-root/src \ - PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 \ - PATH="/opt/app-root/src/.local/bin:$PATH" - -# Default environment variables for the application -ENV LLM_ENDPOINT=http://localhost:11434/v1 \ - MODEL_NAME=granite3.3:8b \ - SIPPY_API_URL=https://sippy.dptools.openshift.org \ - JIRA_URL=https://redhat.atlassian.net - -# Expose the web server port -EXPOSE 8000 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:8000/health || exit 1 - -# Default command runs the web server -CMD ["python3.13", "main.py", "serve", "--host", "0.0.0.0", "--port", "8000"] diff --git a/chat/README.md b/chat/README.md deleted file mode 100644 index 26096cedc7..0000000000 --- a/chat/README.md +++ /dev/null @@ -1,200 +0,0 @@ -# Sippy AI Agent - -A LangGraph ReAct AI Agent for the Sippy platform. - -## Features - -- 🤖 **LangGraph ReAct Agent**: State-based reasoning with explicit control flow -- 🧠 **Thinking Display**: Optional visualization of the agent's thought process -- 🔧 **CI/CD Analysis**: Tools for analyzing jobs, test failures, and build patterns -- 💬 **Interactive CLI**: Rich command-line interface with chat functionality -- 🌐 **Web API**: REST and WebSocket endpoints for web frontend integration -- 🛠️ **Extensible Tools**: Modular tool system ready for Sippy API integration -- ⚙️ **Configurable**: Environment-based configuration management - -## Quick Start - -### 1. Installation - -```bash -$ cd chat -$ python -m venv .venv && source .venv/bin/activate -$ pip install -r requirements.txt -``` - -### 2. Configuration - -Create a `.env` file from the example: - -```bash -cp .env.example .env -``` - -Edit `.env` for your LLM setup, according to the instructions in the -.env file. - -#### Optional: Database Access - -To enable direct database queries (fallback tool for when standard tools don't provide enough information), set: - -```bash -SIPPY_READ_ONLY_DATABASE_DSN=postgresql://readonly_user:password@host:5432/sippy -``` - -**Important:** Use a read-only database user for security. The tool enforces read-only queries at the application level as well. - -#### Optional: Claude Models via Google Vertex AI - -To use Claude models through Google's Vertex AI, you need: - -1. A Google Cloud project with Vertex AI API enabled -2. Authentication via `gcloud auth` OR service account credentials -3. Claude models enabled in your project (requires allowlist access) - -**Option 1: Using gcloud auth (recommended for local development):** - -```bash -# Login with your Google Cloud account -gcloud auth application-default login - -# Set required environment variables -MODEL_NAME=claude-sonnet-4-5 -GOOGLE_PROJECT_ID=your-gcp-project-id -GOOGLE_LOCATION=us-central1 # Optional, defaults to us-central1 -``` - -**Option 2: Using service account credentials:** - -```bash -MODEL_NAME=claude-sonnet-4-5 -GOOGLE_PROJECT_ID=your-gcp-project-id -GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account-key.json -GOOGLE_LOCATION=us-central1 # Optional, defaults to us-central1 -``` - -**Claude Extended Thinking:** -When using Claude with `--thinking` enabled, the model can use its extended thinking feature to show detailed reasoning. You can control the token budget: -```bash -# Use extended thinking with custom budget (if supported by your model/region) -python main.py chat --model claude-sonnet-4-5 --thinking --thinking-budget 15000 - -# Or set via environment variable -export EXTENDED_THINKING_BUDGET=15000 - -# If you encounter 400 errors, extended thinking may not be available -# Disable it by setting the budget to 0: -python main.py chat --model claude-sonnet-4-5 --thinking --thinking-budget 0 -``` - -**Important Notes:** -- Extended thinking **automatically sets temperature to 1.0** (required by Claude API) -- Extended thinking availability may vary by Claude model version and Vertex AI region -- If you encounter errors, you can still use `--thinking` to see the agent's tool usage and reasoning without Claude's extended thinking by setting budget to 0 - -### 3. Multiple Model Configuration (Optional) - -Sippy Chat supports running with multiple AI models that users can switch between via the web UI. This is configured using a `models.yaml` file. - -**Create models.yaml:** - -```bash -cp models.yaml.example models.yaml -# Edit models.yaml to configure your models -``` - -**Configuration Options:** - -- `id`: Unique identifier for the model (required) -- `name`: Display name shown in the UI (required) -- `description`: Brief description shown in the UI (optional) -- `model_name`: The actual model name to use with the provider (required) -- `endpoint`: API endpoint URL (required for OpenAI-compatible APIs, empty for Vertex AI) -- `temperature`: Temperature setting for the model (optional, default: 0.0) -- `extended_thinking_budget`: Token budget for Claude's extended thinking (optional, default: 0) -- `default`: Set to true to make this the default model (optional, only one should be true) - -**Important Notes:** - -- Environment variables (API keys, credentials) are still required and shared across all models -- Users can switch models mid-conversation via the Settings panel in the web UI -- If `models.yaml` doesn't exist, the system falls back to using a single model from environment variables - -**Start the server with models.yaml:** - -```bash -python main.py serve --models-config models.yaml -``` - -If `models.yaml` exists in the `chat/` directory, it will be loaded automatically without the `--models-config` flag. - -### 4. Run the Agent - -**Interactive Chat CLI:** -```bash -python main.py chat -``` - -**Web Server (REST API):** -```bash -python main.py serve -``` - -**With options:** - -```bash -# Interactive CLI with options -python main.py chat --verbose --thinking --model llama3.1:70b --temperature 0.2 - -# Web server with custom port and thinking enabled -python main.py serve --port 8080 --thinking --reload - -# Using OpenAI with thinking process visible -python main.py chat --thinking --model gpt-4 --endpoint https://api.openai.com/v1 - -# Using Google Gemini with API key -python main.py chat --model gemini-1.5-pro - -# Using Google Gemini with service account -python main.py serve --model gemini-1.5-pro --google-credentials /path/to/credentials.json - -# Using Claude models via Google Vertex AI -python main.py serve --model claude-sonnet-4-5@20250929 -``` - -**Get help:** -```bash -python main.py --help # Show main help -python main.py chat --help # Show chat-specific options -python main.py serve --help # Show server-specific options -``` - -## Thinking Display - -The agent supports a "thinking display" mode that shows the LLM's reasoning process: - -```bash -# Enable thinking display from command line -python main.py chat --thinking - -# Or toggle it during runtime in chat mode -> thinking -``` - -## Web Server - -The Sippy AI Agent can run as a web API server for integration with web frontends: - -```bash -# Start the web server -python main.py serve - -# With options -python main.py serve --port 8080 --thinking --verbose --reload -``` - -The web server provides: -- **REST API** at `http://localhost:8000` for chat interactions -- **WebSocket streaming** at `ws://localhost:8000/chat/stream` for real-time responses -- **Interactive API docs** at `http://localhost:8000/docs` -- **Health check** at `http://localhost:8000/health` -- **Prometheus metrics** at `http://localhost:8000/metrics` diff --git a/chat/main.py b/chat/main.py deleted file mode 100644 index 368a2bbd87..0000000000 --- a/chat/main.py +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env python3 -""" -Unified entry point for Sippy AI Agent - CLI and Web Server. -""" - -import logging -import sys -from functools import wraps -from typing import Optional -import click -from rich.console import Console -from rich.logging import RichHandler - -from sippy_agent.config import Config -from sippy_agent.cli import SippyCLI -from sippy_agent.web_server import SippyWebServer - -console = Console() - - -def setup_logging(verbose: bool = False) -> None: - """Setup logging with Rich handler.""" - level = logging.DEBUG if verbose else logging.INFO - logging.basicConfig(level=level, format="%(message)s", datefmt="[%X]", handlers=[RichHandler(console=console, rich_tracebacks=True)]) - - -def common_options(f): - """Decorator to add common options shared between chat and serve commands.""" - options = [ - click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging"), - click.option("--thinking", "-t", is_flag=True, help="Enable thinking display"), - click.option("--persona", default=None, help="AI persona to use (default, zorp, etc.)"), - click.option("--model", default=None, help="Model name to use (e.g., llama3.1:8b, gpt-4)"), - click.option("--endpoint", default=None, help="LLM API endpoint"), - click.option("--temperature", default=None, type=float, help="Temperature for the model"), - click.option("--max-iterations", default=None, type=int, help="Maximum number of agent iterations (default: 25)"), - click.option("--timeout", default=None, type=int, help="Maximum execution time in seconds (default: 1800 = 30 minutes)"), - click.option("--google-credentials", default=None, help="Path to Google service account credentials JSON file"), - click.option("--google-project", default=None, help="Google Cloud project ID (required for Claude models via Vertex AI)"), - click.option("--google-location", default=None, help="Google Cloud location/region for Vertex AI (default: us-central1)"), - click.option("--thinking-budget", default=None, type=int, help="Token budget for Claude's extended thinking (default: 10000)"), - click.option("--mcp-config", default=None, help="Path to MCP servers config file"), - click.option("--models-config", default=None, help="Path to models.yaml config file"), - ] - for option in reversed(options): - f = option(f) - return f - - -def apply_config_overrides(config: Config, **kwargs) -> None: - """Apply command-line overrides to configuration.""" - config.verbose = kwargs.get("verbose", False) - config.show_thinking = kwargs.get("thinking", False) - - # Only override .env values if explicitly provided via CLI - if kwargs.get("persona") is not None: - config.persona = kwargs["persona"] - if kwargs.get("model") is not None: - config.model_name = kwargs["model"] - if kwargs.get("endpoint") is not None: - config.llm_endpoint = kwargs["endpoint"] - if kwargs.get("temperature") is not None: - config.temperature = kwargs["temperature"] - if kwargs.get("max_iterations") is not None: - config.max_iterations = kwargs["max_iterations"] - if kwargs.get("timeout") is not None: - config.max_execution_time = kwargs["timeout"] - if kwargs.get("google_credentials") is not None: - config.google_credentials_file = kwargs["google_credentials"] - if kwargs.get("google_project") is not None: - config.google_project_id = kwargs["google_project"] - if kwargs.get("google_location") is not None: - config.google_location = kwargs["google_location"] - if kwargs.get("thinking_budget") is not None: - config.extended_thinking_budget = kwargs["thinking_budget"] - if kwargs.get("mcp_config") is not None: - config.mcp_config_file = kwargs["mcp_config"] - - -@click.group() -@click.version_option(version="1.0.0", prog_name="Sippy AI Agent") -def cli(): - """ - Sippy AI Agent - Your CI/CD Analysis Assistant - - Use 'chat' for interactive CLI or 'serve' for the web server. - """ - pass - - -@cli.command() -@common_options -def chat(**kwargs) -> None: - """ - Start the interactive chat CLI. - - Examples: - python main.py chat - python main.py chat --verbose --thinking - python main.py chat --model gpt-4 --temperature 0.7 - """ - setup_logging(kwargs.get("verbose", False)) - - try: - # Create and configure - config = Config.from_env() - apply_config_overrides(config, **kwargs) - config.validate_required_settings() - - # Start CLI - cli_app = SippyCLI(config) - cli_app.run() - - except ValueError as e: - console.print(f"[red]Configuration error: {e}[/red]") - sys.exit(1) - except Exception as e: - console.print(f"[red]Unexpected error: {e}[/red]") - sys.exit(1) - - -@cli.command() -@click.option("--host", default="0.0.0.0", help="Host to bind the server to") -@click.option("--port", default=8000, type=int, help="Port to bind the server to") -@click.option("--metrics-port", default=None, type=int, help="Port for Prometheus metrics (if not set, metrics available on main port at /metrics)") -@click.option("--reload", is_flag=True, help="Enable auto-reload for development") -@common_options -def serve(host: str, port: int, metrics_port: Optional[int], reload: bool, **kwargs) -> None: - """ - Start the web server with REST API. - - Examples: - python main.py serve - python main.py serve --port 8000 --metrics-port 9090 - python main.py serve --port 8080 --reload - python main.py serve --model gpt-4 --thinking - """ - setup_logging(kwargs.get("verbose", False)) - - try: - # Create and configure - config = Config.from_env() - apply_config_overrides(config, **kwargs) - config.validate_required_settings() - - # Create and run web server - console.print(f"[green]Starting Sippy AI Agent Web Server...[/green]") - console.print(f"[blue]Server will be available at: http://{host}:{port}[/blue]") - console.print(f"[blue]API documentation at: http://{host}:{port}/docs[/blue]") - if metrics_port: - console.print(f"[blue]Metrics will be available at: http://0.0.0.0:{metrics_port}/metrics[/blue]") - else: - console.print(f"[blue]Metrics available at: http://{host}:{port}/metrics[/blue]") - console.print(f"[dim]Model: {config.model_name}[/dim]") - console.print(f"[dim]Endpoint: {config.llm_endpoint}[/dim]") - console.print(f"[dim]Thinking enabled: {config.show_thinking}[/dim]") - console.print(f"[dim]Persona: {config.persona}[/dim]") - console.print() - - server = SippyWebServer(config, metrics_port=metrics_port, models_config_path=kwargs.get("models_config")) - server.run(host=host, port=port, reload=reload) - - except ValueError as e: - console.print(f"[red]Configuration error: {e}[/red]") - sys.exit(1) - except Exception as e: - console.print(f"[red]Unexpected error: {e}[/red]") - sys.exit(1) - - -if __name__ == "__main__": - cli() diff --git a/chat/mcp_config.json.example b/chat/mcp_config.json.example deleted file mode 100644 index 3a7e63c14b..0000000000 --- a/chat/mcp_config.json.example +++ /dev/null @@ -1,8 +0,0 @@ -{ - "mcpServers": { - "sippy-mcp": { - "type": "http", - "url": "${env:SIPPY_API_URL}/mcp/v1/", - } - } -} diff --git a/chat/models.yaml.example b/chat/models.yaml.example deleted file mode 100644 index 95db71e142..0000000000 --- a/chat/models.yaml.example +++ /dev/null @@ -1,60 +0,0 @@ -# Models Configuration for Sippy Chat -# -# This file defines the available AI models that users can select from in the chat interface. -# Users can switch between models mid-conversation via the Settings page. -# -# Configuration options per model: -# id: Unique identifier for the model (required) -# name: Display name shown in the UI (required) -# description: Brief description shown in the UI (optional) -# model_name: The actual model name to use with the provider (required) -# endpoint: API endpoint URL (required for OpenAI-compatible APIs, empty for Vertex AI) -# temperature: Temperature setting for the model (optional, default: 0.0) -# extended_thinking_budget: Token budget for Claude's extended thinking (optional, default: 0) -# default: Set to true to make this the default model (optional, only one should be true) - -models: - # Claude via Google Vertex AI - - id: "claude-sonnet-4.6" - name: "Claude Sonnet 4.6" - description: "Latest Claude Sonnet - fast and capable" - model_name: "claude-sonnet-4-6@default" - default: true - - - id: "claude-opus-4.6" - name: "Claude Opus 4.6" - description: "Most capable Claude model for complex analysis" - model_name: "claude-opus-4-6@default" - - - id: "claude-sonnet-4.5" - name: "Claude Sonnet 4.5" - description: "Capable model for complex CI analysis" - model_name: "claude-sonnet-4-5@default" - - - id: "claude-sonnet-4.5-thinking" - name: "Claude Sonnet 4.5 (Thinking)" - description: "Capable model for complex CI analysis with extended thinking" - model_name: "claude-sonnet-4-5@default" - temperature: 1.0 # Required when setting thinking budget - extended_thinking_budget: 10000 - - # Google Gemini - - id: "gemini-2.5-flash" - name: "Gemini 2.5 Flash" - description: "Google's Gemini 2.5 Flash - best cost/performance" - model_name: "gemini-2.5-flash" - - - id: "gemini-2.5-pro" - name: "Gemini 2.5 Pro" - description: "Google's Gemini 2.5 Pro with large context window" - model_name: "gemini-2.5-pro" - - - id: "gemini-3-pro-preview" - name: "Gemini 3.0 Pro Preview" - description: "Preview of Google's Gemini 3.0 Pro model" - model_name: "gemini-3-pro-preview" - - - id: "gemini-3.1-pro-preview" - name: "Gemini 3.1 Pro Preview" - description: "Preview of Google's Gemini 3.1 Pro model" - model_name: "gemini-3.1-pro-preview" diff --git a/chat/prompts/README.md b/chat/prompts/README.md deleted file mode 100644 index d1f574e3dc..0000000000 --- a/chat/prompts/README.md +++ /dev/null @@ -1,104 +0,0 @@ -# Prompts - -This directory contains prompt definitions in YAML format. These prompts -can be used by the frontend to perform specific actions (drafting a jira -card, etc.) - -*TODO*: These can also be exposed as MCP tools in the future, so a tool -like `Claude Code` could consume our prompts, e.g. to produce a payload -report. - -## Prompt YAML Format - -Each prompt is defined in a separate YAML file with the following structure: - -```yaml -# Prompt name (required) - used to identify the prompt -name: prompt-name - -# Description (required) - explains what the prompt does -description: A description of what this prompt does - -# Hide (optional) - whether to hide this prompt from the UI slash command list -# Default: false. Set to true to hide from "/" command list while still being usable programmatically -hide: false - -# Arguments (optional) - list of parameters the prompt accepts -arguments: - - name: argument_name - description: What this argument is for - required: true # or false - type: string # or array - autocomplete: field_name # optional, references /api/autocomplete/{field_name} - default: value # optional, default value for this argument - -# Prompt (required) - the Jinja2 template -prompt: | - The prompt text here. - Use {{ argument_name }} for argument substitution. - Arrays can be formatted: {{ argument_name | join(', ') }} -``` - -## Templating with Jinja2 - -Prompts use **Jinja2 templating** for argument substitution and formatting. - -### Basic Substitution - -```jinja2 -Analyze the job: {{ job_url_or_id }} -``` - -### Formatting Arrays - -```jinja2 -Versions: {{ releases | join(', ') }} -Streams: {{ streams | join(' and ') }} -``` - -### Default Values - -Defaults are defined in the arguments section (not inline in the template): - -```yaml -arguments: - - name: streams - type: array - default: ["nightly", "ci"] - -prompt: | - Streams: {{ streams | join(', ') }} -``` - -When the prompt is rendered: -- If the argument is provided, its value is used -- If not provided, the default from the arguments section is used -- Defaults in the arguments section are also exposed to the UI for pre-filling form fields - -### Conditionals (optional) - -```jinja2 -{% if detailed_analysis %} -Perform a detailed analysis including full logs. -{% endif %} -``` - -### Jinja2 Features Available - -- Variables: `{{ variable }}` -- Filters: `{{ list | join(', ') }}`, `{{ text | upper }}` -- Conditionals: `{% if condition %}...{% endif %}` -- Loops: `{% for item in items %}...{% endfor %}` -- See [Jinja2 documentation](https://jinja.palletsprojects.com/) for full reference - -### Hierarchical Organization - -Prompts can be organized into subdirectories for better structure: - -``` -prompts/ -├── component-readiness/ -│ └── test-regression.yaml # name: component-readiness-regression-analysis -├── test-analysis.yaml # name: test-analysis -└── payload-report.yaml # name: payload-report -``` diff --git a/chat/prompts/component-readiness/jira-description.yaml b/chat/prompts/component-readiness/jira-description.yaml deleted file mode 100644 index 1ba68d834e..0000000000 --- a/chat/prompts/component-readiness/jira-description.yaml +++ /dev/null @@ -1,65 +0,0 @@ -# Generate a Jira bug description for a Component Readiness test regression -name: component-readiness-jira-description -description: Generate a comprehensive Jira bug description for a test regression using Component Readiness data -hide: true -arguments: - - name: url - description: URL to the test details page in Sippy - required: true - type: string -prompt: | - Draft a Jira bug description for a Component Readiness test regression. Use Jira markup syntax. Include well-structured headings and sections. Perform a brief analysis of the failure based on the available context. - - **Test Information:** - - Test Details URL: {{ url }} - - Use your tools to fetch the test details from the URL to get the test name and regression data. - - **REQUIRED CONTENT - Include these sections in this order:** - - 1. **Test Name**: Format the test name (from the fetched data) as: {code:none}{code} - - 2. **Brief Overview**: Provide a brief overview of the test failure and explanation from the regression data. Do not include the status code. - - 3. **Statistics Section**: Use get_test_details_report to retrieve the regression data, then format BOTH Sample and Base stats in this exact format: - - {panel:title=Sample (being evaluated)|borderStyle=solid} - *Release:* - *Time Period:* to - *Success Rate:* % - *Successes:* - *Failures:* - *Flakes:* - {panel} - - {panel:title=Base (historical)|borderStyle=solid} - *Release:* - *Time Period:* to - *Success Rate:* % - *Successes:* - *Failures:* - *Flakes:* - {panel} - - **CRITICAL:** If the base statistics have no success rate, successes, failures or flakes (all zero values), highlight that this is a NEW test in this release, and must pass at a 95% success threshold, rather than being compared to historical data. In this case, do NOT include the Base panel. - - 4. **Sample Failure Outputs**: Use your tools to get outputs from up to 5 jobs that failed this test. Include relevant error messages or stack traces. - - 5. **Links to Relevant Jobs**: Provide links to the failed job runs using Jira link syntax: [job name|job url] - - 6. **Patterns and Insights**: Analyze the regression data and report any patterns you observe: - - Are failures consistent or intermittent? - - Are there common error messages? - - Is this a new test or an existing test that regressed? - - If base stats show flakes but sample stats show failures with no flakes, note that this may indicate a flake-to-failure conversion - - **CRITICAL OUTPUT REQUIREMENTS:** - - Your response must contain ONLY the Jira markup description - - For links in Jira, you MUST use the Jira syntax format: [link text|link url] - do not use markdown! - - Do NOT include any "thought", "Plan:", "thinking", or reasoning sections in your output - - Do NOT include any preamble or explanation before the Jira markup - - Start your response IMMEDIATELY with the first Jira heading (h3.) - - Do NOT include phrases like "Here is the description:", "Final output:", etc. - - The entire response must be valid Jira markup that can be directly pasted into a Jira ticket - - ALWAYS include the explanation from the regression data (found in the analyses[0].explanations field) - - If base statistics are missing or empty, DO NOT include them; instead explain this is a new test being held to the 95% success rate standard diff --git a/chat/prompts/component-readiness/regression-analysis.yaml b/chat/prompts/component-readiness/regression-analysis.yaml deleted file mode 100644 index 8ba3ea06b7..0000000000 --- a/chat/prompts/component-readiness/regression-analysis.yaml +++ /dev/null @@ -1,82 +0,0 @@ -# Comprehensive analysis of a Component Readiness test regression -name: component-readiness-regression-analysis -description: Analyze a Component Readiness test regression with detailed failure patterns, root cause analysis, and triage status -hide: true -arguments: - - name: url - description: URL to the test details page in Sippy - required: true - type: string -prompt: | - Perform a comprehensive regression analysis for the Component Readiness test at: - [Test Details Report]({{ url }}) - - Use get_test_details_report to retrieve the regression data, then provide a detailed analysis: - - ## 1. Regression Overview - - Summarize when the regression was opened - format as human-readable date (e.g., "January 15, 2024") and include how many days ago - - State if it's closed or still ongoing - if closed, format the date similarly and show days ago - - Explain the status code (e.g., -400 = SignificantRegression, -500 = ExtremeRegression >15% change) - - **Always include and explain the explanations from the data** - these provide critical context about why the test is flagged - - ## 2. Statistical Analysis - - Calculate and report the pass rate change: (sample_stats.success_rate - base_stats.success_rate) * 100 - - Display the time periods being compared in human-readable format with "days ago" context - - Identify if this is a recent degradation or long-term issue - - Note if the BaseRelease appears to be more than one minor version ahead of the sample release - (This indicates we found a better pass rate in earlier releases, so we compared against that release - instead of the prior one to prevent a test from gradually getting worse) - If not, do not mention it. - - Check for flake-to-failure conversions: - * If base_stats.flake_count > 0 and base_stats.failure_count ≈ 0 - * But sample_stats.failure_count > 0 and sample_stats.flake_count = 0 - * This may indicate a test that had its ability to flake removed (a dangerous policy we're working to remove) - * Compare the flake rate in base_stats to the fail rate in sample_stats to see if they're comparable - * If this is not a test that used to flake, do not mention it. - - ## 3. Failure pattern analysis - - Examine the job_stats for each job name, looking for patterns in how it's passed and failed. - - If the test appeared to fail in a solid block of job runs, but then went back to passing, this could imply the issue is already resolved. - - If several of the oldest job runs failed, the regression may have started before our reporting window. - - If so, suggest the user can expand the sample window using the date filters on the left. - - If the test went from full passing to full failing for a particular job name, this could indicate a potential starting point for the regression. Provide a link to the prowjob URL from the first failing job run. - - Identify the first failing job run ID and lookup the payload it used. - - Provide a link to the payload details including the pull requests: `[Payload Details]({base_url}/sippy-ng/release/{sample_release}/tags/{payload_tag})` - * Use current page's base URL if available, else `https://sippy-auth.dptools.openshift.org` - - ## 4. Root Cause Investigation - - Use the failed_job_run_ids from the report to investigate specific failures - - Call get_prow_job_summary **in parallel** for multiple job run IDs (up to 5) to understand failure patterns - - Look for common failure reasons across the job runs - - Determine if failures are consistent or intermittent - - ## 5. Failure Pattern Analysis - - Examine which tests are failing together in each job run - - If the same tests fail in each job → likely related to the same root cause - - If a job has mass failures (>10 failed tests) → may indicate systemic cluster problems, test might not be at fault - - If the test is the only failure in each run → more likely a problem with this specific test or its feature - - Analyze test failure outputs from multiple failed job runs - - Compare error messages, stack traces, and failure patterns - - Report whether it's a consistent failure (same root cause) or multiple different issues - - ## 6. Triage Status - - If triages_count > 0, the regression has been attributed to a known Jira issue - - Check if the triage has a resolved timestamp - - If there are failures after the resolved timestamp, this indicates a failed fix - - ## 7. Recommendations - Based on the analysis, provide: - - Is this a genuine product bug, infrastructure issue, or test issue? - - Priority level (how urgently should this be addressed?) - - Suggested next steps for investigation or resolution - - **Important Context:** - - Regressions represent the line of quality we're willing to ship in the product - - We treat regressions as release blockers - - We will not ship a release with a regression unless the team submits an SBAR to leadership for approval - - **Guidelines:** - - Provide specific numbers and data points, not vague statements - - Include links to Prow job runs, Sippy pages, or Jira when relevant - - If you cannot find certain information, say so explicitly - - Focus on actionable insights that help debug or triage the regression diff --git a/chat/prompts/jira-incidents.yaml b/chat/prompts/jira-incidents.yaml deleted file mode 100644 index 9dfa4d2d2f..0000000000 --- a/chat/prompts/jira-incidents.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# List and summarize currently open Jira incidents -name: jira-incidents -description: List currently open incidents and summarize what's going on -prompt: | - List all currently open Jira incidents and provide a summary of what's happening. - - Use the check_known_incidents tool to retrieve the list of active incidents. - - For each open incident, provide: - 1. **Issue Key**: Link to the Jira issue - 2. **Summary**: The incident title/summary - 3. **Status**: Current status of the incident - 4. **Opened**: When the incident was opened (format as human-readable date with "days/hours ago") - 5. **Impact**: Brief description of what's affected based on the incident description - 6. **Affected Tests/Jobs**: Key tests or jobs that are impacted (if available in the incident data) - - After listing all incidents, provide a brief overall summary highlighting: - - Total number of open incidents - - Any particularly critical or widespread issues - - Recent trends (newly opened vs. long-standing incidents) - - If there are no open incidents, provide a cheerful message with emojis indicating that everything is healthy. - - Use markdown formatting for readability and include direct links to Jira issues. diff --git a/chat/prompts/jobs/job-run-analysis.yaml b/chat/prompts/jobs/job-run-analysis.yaml deleted file mode 100644 index 9c12815d6c..0000000000 --- a/chat/prompts/jobs/job-run-analysis.yaml +++ /dev/null @@ -1,102 +0,0 @@ -# Comprehensive analysis of a CI job failure -name: job-run-analysis -description: Analyze a CI job run with detailed test failure patterns, log analysis, and root cause investigation -arguments: - - name: job_url_or_id - description: Prow job URL or numeric job ID (e.g., https://prow.ci.openshift.org/view/gs/test-platform-results/pr-logs/pull/openshift_cluster-network-operator/2433/pull-ci-openshift-cluster-network-operator-master-e2e-aws-ovn/1934795512955801600 or just 1934795512955801600) - required: true - type: string -prompt: | - Perform a comprehensive failure analysis for the Prow job: - {{ job_url_or_id }} - - Extract the numeric job ID from the URL if provided (look for a sequence of 16-19 digits), then analyze the failure systematically. - - ## Job Overview - Use get_prow_job_summary to retrieve the job details, then report: - - Job name and type (e.g., presubmit, periodic, postsubmit) - - Job status and result (passed, failed, aborted) - - When the job ran (format as human-readable date and time with "days/hours ago") - - Direct links to: - * Prow job page (primary link for viewing results) - * Build logs and artifacts - - ## Aggregated Jobs Deep Dive (only if applicable) - If the job is an aggregated job (combines results from multiple underlying job runs): - - Identify that this is an aggregated job - - Use the appropriate tools to retrieve underlying job runs that have failed tests - - Analyze up to 5 of the underlying job runs with failures - - For each underlying run with failures: - * Get the job ID and status - * Identify which tests failed in that specific run - * Note any patterns in failure timing or distribution - - Analyze patterns across the failed runs: - * Are the same tests failing across multiple runs? - * Are failures isolated to specific underlying runs? - * Do failures correlate with specific configurations or infrastructure? - - ## Test Failure Analysis (only if applicable) - If tests have failed, identify all failed tests from the job summary: - - List each failed test with its status - - For up to 5 failed tests, provide: - * Test name and what it validates - * Failure reason/error message (if available in summary) - * Number of test runs and failure count - - If more than 5 tests failed: - * Report the total count - * Note if this indicates a systemic issue (>10 failures often suggests cluster/infrastructure problems) - * Group similar failures together if patterns emerge - - ## Failure Pattern Classification (only if applicable) - If tests have failed, analyze the failure pattern: - - **Single test failure**: Likely a specific test or feature issue - - **Multiple related tests**: May indicate a component or subsystem problem - - **Mass failures** (>10 tests): Often indicates: - * Cluster setup/infrastructure failure - * API server unavailability - * Network connectivity issues - * Storage subsystem failure - - ## Known Incidents Correlation - Always check for known incidents: - - Use check_known_incidents to see if there are ongoing Jira incidents - - Compare incident descriptions, affected tests, and timing - - If the failure matches a known incident: - * Link to the incident - * Note if this failure falls within the incident timeframe - * Indicate whether the test failure is expected due to the incident - - ## Log Analysis - If the initial analysis doesn't reveal clear root causes, perform log analysis. This is especially useful if no tests failed or if tests failed for unclear reasons. - - Use analyze_job_logs with: - * Default: path_glob="*build-log*", text_regex="[Ee]rror|[Ff]ail|panic|timeout" - * For specific investigation: adjust patterns based on failure type - - From log analysis, report: - * Key error messages and stack traces - * Timing of failures (early setup vs. test execution vs. teardown) - * Infrastructure errors vs. application errors - - ## Root Cause Hypothesis - Based on all collected evidence, provide: - - **Most likely cause**: Specific hypothesis with supporting evidence - - **Failure category**: - * Product bug (feature not working as expected) - * Test issue (flaky test, bad test code, environment assumption) - * Infrastructure problem (cluster provisioning, network, storage) - * Configuration issue (wrong flags, missing resources) - - **Confidence level**: High/Medium/Low based on evidence clarity - - ## Context and Impact - - If this is a presubmit job, it may be blocking a PR from merging - - If this is a periodic/postsubmit job, it may indicate broader quality issues - - Note if this appears to be an isolated failure or part of a pattern - - Check if multiple similar jobs are failing (suggest checking related jobs if patterns emerge) - - ## Guidelines for Analysis - - Be specific with evidence: cite exact error messages, test names, and timestamps - - Use markdown formatting for readability - - Include direct links to relevant resources (Prow, logs, Jira) - - If you cannot determine something, say so explicitly rather than speculating - - Focus on actionable insights that help the user understand and resolve the failure - - If the job actually passed, note that clearly and explain what the user might have been looking for - - NEVER report on TestGrid, do not provide TestGrid links diff --git a/chat/prompts/jobs/plot-job-results.yaml b/chat/prompts/jobs/plot-job-results.yaml deleted file mode 100644 index bbe7f8ef09..0000000000 --- a/chat/prompts/jobs/plot-job-results.yaml +++ /dev/null @@ -1,50 +0,0 @@ -# Create a stacked bar chart visualization of job results -name: plot-job-results -description: Generate a stacked bar chart showing job outcomes over time, grouped by job result status -arguments: - - name: variants - description: Variant(s) to filter job records by (e.g., "gcp", "aws", "metal") - required: true - type: array - autocomplete: variants - - name: release - description: Release version to filter by (e.g., "4.15", "4.16") - required: false - type: string - autocomplete: releases - - name: days - description: Number of days to look back for job results - required: false - type: integer - default: 7 -prompt: | - Create a stacked bar chart visualization of job results for the following criteria: - - **Filter Parameters:** - * Variants: {{ variants | join(', ') }} - {% if release %}* Release: {{ release }}{% endif %} - * Time Period: Last {{ days }} days - - ## Instructions: - - 1. **Data Collection:** - - Query job records matching the specified variant(s){% if release %} and release version{% endif %} - - Filter for jobs that completed within the last {{ days }} days - - Group results by outcome (Success, Failure, Aborted, etc.) - - 2. **Chart Specifications:** - - **Chart Type**: Stacked bar chart - - **X-axis**: Time periods - - **Y-axis**: Count of job runs - - **Stack Segments**: Different colors for each job outcome: - * Success (green) - * Failure (red) - * Other states as needed - - **Legend**: Include a legend explaining the color coding for each outcome - - 3. **Summary Statistics:** - After the chart, provide: - - Total number of job runs analyzed - - Overall pass rate across all jobs - - Jobs with the highest failure rates (top 5) - - Any notable patterns or trends in the data diff --git a/chat/prompts/payload-analysis.yaml b/chat/prompts/payload-analysis.yaml deleted file mode 100644 index fa6cdb6808..0000000000 --- a/chat/prompts/payload-analysis.yaml +++ /dev/null @@ -1,161 +0,0 @@ -# Deep analysis of a rejected or failing OpenShift payload to determine root cause -name: payload-analysis -description: Analyze a rejected or failing payload. -arguments: - - name: payload_identifier - description: Payload to analyze - accepts URL (e.g., https://amd64.ocp.releases.ci.openshift.org/releasestream/4.21.0-0.nightly/release/4.21.0-0.nightly-2025-10-24-045839), image reference (e.g., registry.ci.openshift.org/ocp/release:4.21.0-0.nightly-2025-10-24-045839), or release name (e.g., 4.21.0-0.nightly-2025-10-24-045839) - required: true - type: string -prompt: | - Perform a comprehensive analysis of the payload: - {{ payload_identifier }} - - Extract the release name from the provided identifier (e.g., "4.21.0-0.nightly-2025-10-24-045839") and conduct a systematic investigation. - - ## CRITICAL WORKFLOW INSTRUCTIONS - - - Make tool calls in parallel when possible (e.g., analyze multiple jobs at once) - - Provide output after completing the core analysis - - You must analyze failed blocking jobs - - If you have the key information (payload status, failed jobs, basic failure analysis), provide your response - - Avoid over-analyzing - once you have the essential data, compile and deliver your findings - - ## 1. Payload Overview - - Extract release version, stream type (nightly/ci), and timestamp from the payload name - - Link to the release page: https://amd64.ocp.releases.ci.openshift.org/releasestream/{version}.0-0.{stream}/release/{full_release_name} - - Use appropriate tools to retrieve: - * Current payload status (Rejected, Ready, or Accepted) - * When the payload was created - * Overall test results summary - - **Status Interpretation:** - - **Rejected**: Payload has failed and been rejected - proceed with analysis - - **Ready with blocking job failures**: Payload is still being tested, but blocking jobs have already failed - * If ANY blocking job has failed, the payload WILL be rejected once remaining jobs complete - * You can and should proceed with analysis - don't wait for the payload to reach terminal state - * Note in your analysis: "⚠️ Payload is currently in Ready state, but will be Rejected once remaining jobs complete due to blocking job failures" - - **Ready with no blocking failures**: Payload is still being tested and may still be accepted - * If no blocking jobs have failed yet, note this and cease analysis - * Explain: "Cannot analyze yet - no blocking jobs have failed. Wait for more results." - - **Accepted**: Payload was accepted - this is not a rejection to analyze - * Note this and cease analysis - - ## 2. Blocking Jobs Analysis - **CRITICAL**: Analyze ONLY blocking jobs (not informing jobs). Use get_prow_job_summary for ALL failed blocking jobs IN PARALLEL. - - **Job Not Found:** - - If a job returns "not found" error, it may not have been ingested by Sippy yet - - Recent jobs can take time to appear in the database - - Note this in your analysis and continue with other available jobs - - Recommend checking back later or verifying the payload status on the release page - - **For EACH failed blocking job:** - - 1. Get the job run summary (call tools in parallel for all jobs at once) - 2. Identify if aggregated (multiple runs) or single run - 3. List the top 3-5 failed tests with their error messages - 4. Categorize: Setup failure / Mass failures / Component-specific / Individual tests - - **Present each job concisely:** - - ### [Job Name](prow_link) - - **Type**: Aggregated (X/Y runs failed) OR Single Run - - **Top Failed Tests**: - 1. `test.name.here` - Error: "timeout waiting for pods" - 2. `another.test.name` - Error: "assertion failed" - - **Pattern**: Consistent / Intermittent / Infrastructure - - **Category**: Setup/Mass/Component/Individual - - ## 3. Known Incidents Check - - Use check_known_incidents to see if there are ongoing Jira incidents - - Note if any failures might be explained by known infrastructure issues - - ## 4. Changelog Review (If Needed) - **Only retrieve changelog if failures appear payload-related (not infrastructure/known issues).** - - - Get the payload changelog - - Identify 2-3 most likely commits/PRs that could have caused the failures - - Match based on: component alignment, timing, error patterns - - Keep this brief - focus on the most suspicious changes - - ## 5. Build Log Analysis (Optional) - **Only analyze logs if test failures don't provide clear information.** - - Skip this section unless needed. If required: - - Use analyze_job_logs for 1-2 key failed jobs - - Look for: build failures, image issues, cluster setup errors - - ## 6. Root Cause Summary - - **NOW COMPILE YOUR FINDINGS. Provide this summary based on the data you've collected:** - - **Primary Suspected Causes:** - For each distinct failure pattern: - - Description and affected jobs - - Suspected commit/PR (if identifiable) - - Confidence: High (>90%), Medium (60-89%), Low (<60%) - - Key evidence - - **Overall Assessment:** - - Is this payload-related or infrastructure/flake? - - Confidence level and reasoning - - ## 9. Next Steps - You must ALWAYS provide next steps the user. If confidence is low (<90%), briefly suggest 1-2 additional investigation steps. - - For any suspected code change related cause with **High confidence (≥90%)**: - - Clearly state: "🚨 REVERT RECOMMENDED" - - Specify the exact commit/PR to revert - - Provide link to the commit/PR - - Explain the evidence supporting the revert recommendation - - Estimate impact of revert (what functionality will be lost) - - ### Executing the Revert - - Inform the user how to perform the revert. - - **Step 1: File an Incident Jira** - - Create a TRT incident ticket (TRT-XXXX format) - - Document the failure analysis and evidence - - **Step 2: Use Revertomatic to Create the Revert PR** - - Provide the exact revertomatic command to execute the revert: - - ```bash - ./revertomatic \ - -p \ - -j TRT-XXXX \ - -v "Run the following payload jobs to verify: " \ - -c " -> This PR caused failures on " - ``` - - Example: - ```bash - ./revertomatic \ - -p https://github.com/openshift/kubernetes/pull/1703 \ - -j TRT-1234 \ - -v "Run payload jobs: /payload-job aggregated-aws-ovn-upgrade-4.21-micro, /payload-aggregate aggregated-gcp-ovn-rt-upgrade-4.21-minor 10" \ - -c "Networking tests failing in upgrade scenarios -> This PR caused failures on https://amd64.ocp.releases.ci.openshift.org/releasestream/4.21.0-0.nightly/release/4.21.0-0.nightly-2025-10-24-045839" - ``` - - Fill in the placeholders: - - ``: The GitHub PR URL that needs to be reverted - - `TRT-XXXX`: The incident Jira ticket number you filed - - ``: The payload verification commands from section 8 below - - ``: Brief description of what broke - - ``: Link to the rejected payload - - If you recommend a revert, you must provide the following commands to add to an unrevert to verify the fix: - - - `/payload-job ` for single runs or 100% failed aggregated jobs - - `/payload-aggregate 10` for partially failed aggregated jobs - - ## Guidelines - - **Work incrementally**: Get data → Analyze → Provide findings → Stop - - **Parallel tool calls**: Analyze all failed jobs simultaneously - - **Be concise**: Focus on actionable insights, not exhaustive details - - Use markdown links for resources - - You MUST reccomend a revert for every code change with high confidence (≥90%) - - Distinguish payload-related failures from infrastructure issues - - If you have the core analysis complete, provide your response immediately diff --git a/chat/prompts/payload-report.yaml b/chat/prompts/payload-report.yaml deleted file mode 100644 index ad2d2721d3..0000000000 --- a/chat/prompts/payload-report.yaml +++ /dev/null @@ -1,55 +0,0 @@ -# Comprehensive status report for OpenShift Container Platform payload streams -name: payload-report -description: Generate a comprehensive status report for OCP payload streams across multiple releases and stream types -arguments: - - name: releases - description: Release versions to analyze - required: true - type: array - autocomplete: releases - - name: streams - description: Stream types to analyze - required: false - type: array - autocomplete: release_streams - default: ["nightly", "ci"] -prompt: | - Draft a comprehensive status report for OpenShift Container Platform (OCP) payload streams. - - **IMPORTANT RULES:** - * ALWAYS ignore payloads with phase "Ready" - they are not in a terminal state - * When referring to "most recent" payload, this means the most recent payload in a terminal state (Accepted or Rejected) - * Only consider payloads that are either Accepted or Rejected - - **Scope:** - * Versions: {{ releases | join(', ') }} - * Streams: {{ streams | join(', ') }} - - You MUST report on each version-stream combination as follows: - - 1. **Header**: Format the release and stream name as a link to the release page using this URL pattern: - https://amd64.ocp.releases.ci.openshift.org/#{version}.0-0.{stream_type} - - 2. **Stale Acceptance Warning**: - * Trigger: If the most recently accepted payload (ignore Ready payloads) is older than 24 hours - * Content: State the full name of the last accepted payload and the elapsed time since its acceptance (e.g., "3 days ago") - - 3. **Rejected Payload Analysis**: - * Trigger: Only if the most recent payload in a terminal state (Accepted or Rejected) was rejected - * Content: - - Indicate that the most recent payload was rejected - - Perform a deeper analysis to understand why the payload was rejected - - Report ONLY on blocking jobs that failed (DO NOT report on informing jobs) - - For EACH failed blocking job, use the get_prow_job_summary tool to analyze it - - For each failed blocking job: - * Provide the job name as a link to Prow - * List up to 3 tests that failed in that job - * If more than 3 tests failed in the job, instead indicate "Multiple tests failed" and do not list individual tests - - Focus on providing actionable information about what blocked the payload - - 4. **All Good Message**: - * Trigger: When you have neither a stale acceptance warning nor a rejected payload warning to report - * Content: A cheerful indication with emojis that everything is working well - - **Summary:** - Finally, provide a brief summary in prose of all the releases analyzed. Keep this to no more than 3 sentences. diff --git a/chat/prompts/test-analysis.yaml b/chat/prompts/test-analysis.yaml deleted file mode 100644 index b270b090d7..0000000000 --- a/chat/prompts/test-analysis.yaml +++ /dev/null @@ -1,56 +0,0 @@ -# Comprehensive analysis of a test's behavior and failure patterns -name: test-analysis -description: Analyze a test's performance over the last 7 days, identify failure patterns, variant impacts, and compare with previous releases -arguments: - - name: release - description: Release version to analyze (e.g., 4.18, 4.17) - required: true - type: string - autocomplete: releases - - name: test_name - description: Fully qualified test name to analyze - required: true - type: string - autocomplete: tests -prompt: | - Analyze test: **{{ test_name }}** on release **{{ release }}** - - ## 1. Overview - - Display test name as a markdown link to the Sippy analysis page: - * Format: `[Test Name]({base_url}/sippy-ng/tests/{release}/analysis?test={url_encoded_test_name})` - * Use current page's base URL if available, else `https://sippy-auth.dptools.openshift.org` - * URL encode the test name parameter - - ## 2. 7-Day Performance - Query `test_cumulative_summaries` with database tool. LEFT JOIN two rows per (test_id, prow_job_id, suite_id, lifecycle) at `date = CURRENT_DATE - 1` (end) and `date = CURRENT_DATE - 8` (start), filtering `release = '{{ release }}'`. Compute 7-day totals by subtracting prefix sums: - - Overall pass rate: SUM(end.prefix_sum_successes - COALESCE(start.prefix_sum_successes, 0)) / SUM(end.prefix_sum_runs - COALESCE(start.prefix_sum_runs, 0)) × 100 - - Total runs, failures, flakes (all as prefix_sum differences with COALESCE) - - Trend: consistent passing/failing or intermittent - - ## 3. Variant Analysis - Query `test_cumulative_summaries` joined to `prow_jobs` (via prow_job_id) and `tests` (via test_id), grouped by `prow_jobs.variants`. LEFT JOIN two rows per (test_id, prow_job_id, suite_id, lifecycle) at `date = CURRENT_DATE - 1` (end) and `date = CURRENT_DATE - 8` (start), filtering `release = '{{ release }}'`. Compute 7-day totals by subtracting prefix sums: - - Calculate failure rate per variant combination: SUM(end.prefix_sum_failures - COALESCE(start.prefix_sum_failures, 0)) / SUM(end.prefix_sum_runs - COALESCE(start.prefix_sum_runs, 0)) × 100 - - Report: variant combo, pass rate, failure count vs runs - - Order by worst performing first - - Note if failures are variant-specific or systemic - - ## 4. Failure Modes - Query `prow_job_run_test_outputs` table for recent failures. Always filter on partition keys: `prow_job_run_test_release = '{{ release }}'` and `prow_job_run_test_timestamp > NOW() - INTERVAL '7 days'`: - - Examine up to 10 failure outputs - - **Consistent**: Same error repeating - include exact error message - - **Diverse**: Multiple issues - categorize and count distinct failure types - - ## 5. Job Run Links - - Provide up to 5 Prow links where the test itself failed - - Include job name, timestamp ("X days ago"), and variant - - Spread across different variants if possible - - ## 6. Previous Release Comparison - - Use get_releases to check if a previous release exists - - If available, offer to compare: "Would you like me to compare this test's performance with the previous release?" - - ## 7. Assessment & Recommendations - **Root Cause (confidence: High/Medium/Low):** - - Product bug / Test issue / Infrastructure / Variant-specific - - **Guidelines:** Use exact data, include links, use markdown tables for variant breakdowns, state explicitly if data unavailable diff --git a/chat/prompts/triage/failure-analysis.yaml b/chat/prompts/triage/failure-analysis.yaml deleted file mode 100644 index 4aa31d8769..0000000000 --- a/chat/prompts/triage/failure-analysis.yaml +++ /dev/null @@ -1,56 +0,0 @@ -# Analyze test failure patterns across triaged regressions -name: triage-failure-analysis -description: Analyze test failure patterns across a triage record's regressed tests to identify common causes -hide: true -arguments: - - name: triage_id - description: The triage record ID - required: true - type: string - - name: view - description: The component readiness view - required: true - type: string -prompt: | - Analyze the test failure patterns for triage record {{ triage_id }} in view {{ view }}. - - ## Analysis Process - - ### 1. Retrieve Test Details - - Use get_test_details_report for each regressed test using their test_details_api_url - - Focus on understanding the failure patterns and timing - - ### 2. Sample Failed Job Runs - - From the test details reports, collect failed_job_run_ids - - Sample up to 10 failed job runs across all regressed tests - - Choose a representative sampling from different tests when possible - - ### 3. Analyze Job Run Failures - - Use get_prow_job_summary **in parallel** for the sampled job run IDs - - Look for common failure reasons across the job runs - - Examine which tests are failing together in each job run - - ### 4. Pattern Analysis - - **Consistent failures**: If the same tests fail together in each run → likely related to the same root cause - - **Mass failures**: If a job has >10 failed tests → may indicate systemic cluster problems, tests might not be at fault - - **Isolated failures**: If tests only fail individually → more likely problems with specific tests or features - - **Error patterns**: Compare error messages, stack traces, and failure outputs across multiple runs - - ### 5. Root Cause Assessment - Determine if failures are: - - Consistent (same root cause across all failures) - - Varied (multiple different issues) - - Infrastructure-related (cluster/CI problems) - - Test-related (test implementation issues) - - Product-related (actual bugs in the product) - - ## Output Format - - Provide your analysis with: - - Clear identification of failure patterns - - Specific numbers and data points (not vague statements) - - Links to relevant Prow job runs or Sippy pages - - Assessment of whether failures are related or independent - - Actionable insights for debugging or triage - - **Note:** If you cannot find certain information, state so explicitly rather than speculating. diff --git a/chat/prompts/triage/fix-status.yaml b/chat/prompts/triage/fix-status.yaml deleted file mode 100644 index c3859a90bf..0000000000 --- a/chat/prompts/triage/fix-status.yaml +++ /dev/null @@ -1,54 +0,0 @@ -# Check Jira issue status and timeline for a triage -name: triage-fix-status -description: Check the current fix status and timeline for a triage's associated Jira issue -hide: true -arguments: - - name: issue_key - description: The Jira issue key (e.g., OCPBUGS-12345) - required: true - type: string -prompt: | - Analyze the fix status and timeline for Jira issue {{ issue_key }}. - - ## Analysis Process - - Use the get_jira_issue_analysis tool to retrieve comprehensive information about the issue. - - ## Key Areas to Investigate - - ### 1. Current Status - - What is the current issue status? - - Who is the assignee? - - What are the target fix versions? - - Is this marked as a release blocker? - - ### 2. Recent Activity - - Review the most recent comments (they reflect current status) - - Look for indicators of: - * Fix completion status - * Testing status - * Deployment readiness - * Any blockers or dependencies - - ### 3. Timeline Assessment - - When was the issue last updated? - - Is there active progress or has it stalled? - - Are there any estimated timelines mentioned? - - ### 4. Fix Readiness - Based on the available information, assess: - - Is the fix complete? - - Has it been tested? - - Is it ready for deployment? - - Are there any remaining blockers? - - ## Output Format - - Provide a clear summary including: - - Current status and assignee - - Key findings from recent comments - - Assessment of fix readiness and timeline - - Any blockers or concerns - - Links to the Jira issue - - **Note:** Focus on actionable information that helps understand when the fix will be available. diff --git a/chat/prompts/triage/potential-matches.yaml b/chat/prompts/triage/potential-matches.yaml deleted file mode 100644 index 02dcce0f9b..0000000000 --- a/chat/prompts/triage/potential-matches.yaml +++ /dev/null @@ -1,62 +0,0 @@ -# Find additional tests that should be added to a triage -name: triage-potential-matches -description: Analyze potential test matches that could be added to this triage based on failure patterns -hide: true -arguments: - - name: triage_id - description: The triage record ID - required: true - type: string - - name: view - description: The component readiness view - required: true - type: string -prompt: | - Identify additional tests that should be added to triage record {{ triage_id }} in view {{ view }}. - - ## Analysis Process - - **IMPORTANT:** Follow this exact process to determine match likelihood: - - ### Step 1: Get Candidate Tests - - Use get_triage_potential_matches with triage_id={{ triage_id }} and view={{ view }} - - This returns candidate tests sorted by API confidence score - - ### Step 2: Sample Existing Triaged Tests - - From the existing triaged tests (available in the page context's regressed_tests) - - Select 2-4 representative tests that provide a fair sampling of the set - - Consider different components, test types, or failure patterns if diverse - - Otherwise, just pick the first few tests - - Use get_test_details_report with their test_details_api_url to get failed_job_run_ids - - ### Step 3: Get Potential Match Details - - For the top 2-4 potential matches from Step 1 - - Use get_test_details_report with their test_details_api_url (from regressed_test.links.test_details) - - Retrieve their failed_job_run_ids - - ### Step 4: Calculate Job Run Overlap - - Compare the failed_job_run_ids from Step 2 and Step 3 - - Count how many job runs each potential match shares with the sampled existing triaged tests - - **Key insight:** Tests failing in the same job runs are strong indicators of related failures - - ### Step 5: Prioritize Recommendations - - Prioritize potential matches by: (high API confidence) AND (high job run overlap) - - A potential match with many common job runs is very likely to have the same root cause - - ## Output Format - - For each recommended test, provide: - - Test name and component - - API confidence score - - Number of shared job runs with existing triaged tests - - **Specific details:** Which existing triaged tests (include their regression_id from regressed_test.regression.id) does this potential match share job runs with? - - Links to the test details - - ## Recommendation Priority - - Classify recommendations as: - - **High confidence**: High API score + significant job run overlap (>50%) - - **Medium confidence**: High API score OR moderate job run overlap - - **Low confidence**: Low overlap, investigate further before adding - - **Note:** The goal is to identify tests failing due to the same root cause, so shared job runs are the strongest signal. diff --git a/chat/pyproject.toml b/chat/pyproject.toml deleted file mode 100644 index e7039159a9..0000000000 --- a/chat/pyproject.toml +++ /dev/null @@ -1,2 +0,0 @@ -[tool.ruff] -line-length = 140 diff --git a/chat/requirements.txt b/chat/requirements.txt deleted file mode 100644 index a38d867d5a..0000000000 --- a/chat/requirements.txt +++ /dev/null @@ -1,26 +0,0 @@ -langgraph>=1.0.5 -langchain>=1.2.0 -langchain-openai>=1.1.6 -langchain-google-genai>=4.1.2 -langchain-google-vertexai>=3.0.0 -langchain-community>=0.4.1 -langchain-core>=1.2.5 -anthropic>=0.20.0 -click>=8.0.0 -rich>=13.0.0 -python-dotenv>=1.0.0 -pydantic>=2.0.0 -pyyaml>=6.0.0 -httpx>=0.25.0 -typing-extensions>=4.5.0 -fastapi>=0.104.0 -uvicorn[standard]>=0.24.0 -watchfiles -websockets -langchain-mcp-adapters>=0.3.0 -mcp>=1.6.0,<2 -defusedxml>=0.7.0 -psycopg2-binary>=2.9.0 -sqlparse>=0.4.0 -prometheus_client>=0.19.0 -jinja2>=3.1.0 diff --git a/chat/sippy_agent/__init__.py b/chat/sippy_agent/__init__.py deleted file mode 100644 index 93a877a50f..0000000000 --- a/chat/sippy_agent/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -Sippy AI Agent - A LangGraph ReAct agent for CI analysis. -""" - -__version__ = "0.2.0" -__author__ = "Technical Release Team" - -from .agent import SippyAgent -from .config import Config - -__all__ = ["SippyAgent", "Config"] diff --git a/chat/sippy_agent/agent.py b/chat/sippy_agent/agent.py deleted file mode 100644 index f794beaa23..0000000000 --- a/chat/sippy_agent/agent.py +++ /dev/null @@ -1,836 +0,0 @@ -""" -Core Re-Act agent implementation for Sippy using LangGraph. -""" - -import logging -import asyncio -from typing import List, Optional, Union, Dict, Any, Callable, Awaitable -from langchain_core.messages import HumanMessage, AIMessage, BaseMessage -from langchain_openai import ChatOpenAI -from langchain_google_genai import ChatGoogleGenerativeAI -from langchain_google_vertexai.model_garden import ChatAnthropicVertex -from langchain.tools import BaseTool - -from .config import Config, ModelConfig, load_models_config -from .api_models import ChatMessage -from .graph import create_react_graph, extract_thinking_steps, get_final_response -from .tools import ( - SippyProwJobSummaryTool, - SippyProwJobPayloadTool, - SippyLogAnalyzerTool, - SippyJiraIncidentTool, - SippyJiraIssueTool, - SippyReleasePayloadTool, - SippyPayloadDetailsTool, - JUnitParserTool, - AggregatedJobAnalyzerTool, - AggregatedYAMLParserTool, - SippyDatabaseQueryTool, - SippyTestDetailsTool, - TriagePotentialMatchesTool, - load_tools_from_mcp, -) -from . import metrics - -logger = logging.getLogger(__name__) - -class SippyAgent: - """LangGraph Re-Act agent for CI analysis with Sippy.""" - - def __init__(self, config: Config): - """Initialize the Sippy agent with configuration.""" - self.config = config - self.llm = self._create_llm() - self.tools = None # Will be initialized asynchronously - self.graph = None # Will be initialized after tools are loaded - self._initialized = False - - async def _initialize(self): - """Asynchronously initialize tools and graph.""" - if not self._initialized: - self.tools = await self._create_tools() - self.graph = self._create_agent_graph() - self._initialized = True - - def _create_llm(self) -> Union[ChatOpenAI, ChatGoogleGenerativeAI, ChatAnthropicVertex]: - """Create the language model instance.""" - if self.config.verbose: - logger.info(f"Creating LLM with endpoint: {self.config.llm_endpoint}") - logger.info(f"Using model: {self.config.model_name}") - - # Use ChatAnthropicVertex for Claude models via Vertex AI - if self.config.is_claude_model(): - if not self.config.google_project_id: - raise ValueError( - "Google Cloud project ID is required for Claude models via Vertex AI" - ) - - # Set credentials file if provided, otherwise use Application Default Credentials (gcloud auth) - if self.config.google_credentials_file: - import os - os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = self.config.google_credentials_file - if self.config.verbose: - logger.info(f"Using explicit credentials: {self.config.google_credentials_file}") - else: - if self.config.verbose: - logger.info("Using Application Default Credentials (gcloud auth)") - - # Enable extended thinking for Claude if show_thinking is enabled - # Note: Extended thinking requires temperature=1 and max_tokens > budget_tokens - enable_extended_thinking = self.config.show_thinking and self.config.extended_thinking_budget > 0 - - llm_kwargs = { - "model_name": self.config.model_name, - "project": self.config.google_project_id, - "location": self.config.google_location, - "temperature": 1.0 if enable_extended_thinking else self.config.temperature, - } - - if enable_extended_thinking: - # max_tokens must be greater than thinking budget - # Claude's max output is 64K tokens - max_tokens = 64000 - - llm_kwargs["max_tokens"] = max_tokens - llm_kwargs["model_kwargs"] = { - "thinking": { - "type": "enabled", - "budget_tokens": self.config.extended_thinking_budget - } - } - if self.config.verbose: - logger.info(f"Extended thinking enabled with budget: {self.config.extended_thinking_budget} tokens") - logger.info(f"Max tokens set to {max_tokens}") - logger.info("Temperature automatically set to 1.0 (required for extended thinking)") - elif self.config.show_thinking: - if self.config.verbose: - logger.info("Extended thinking disabled (budget=0)") - - if self.config.verbose: - logger.info( - f"Using ChatAnthropicVertex for Claude model: {self.config.model_name} " - f"(project: {self.config.google_project_id}, location: {self.config.google_location})" - ) - - return ChatAnthropicVertex(**llm_kwargs) - - # Use ChatGoogleGenerativeAI for Gemini models - if self.config.is_gemini_model(): - if ( - not self.config.google_api_key - and not self.config.google_credentials_file - ): - raise ValueError( - "Google API key or service account credentials file is required for Gemini models" - ) - - # Set environment variable for Vertex AI usage (required for langchain-google-genai 4.0+) - # Sippy only uses Vertex AI for Gemini. - import os - os.environ["GOOGLE_GENAI_USE_VERTEXAI"] = "true" - - llm_kwargs = { - "model": self.config.model_name, - "temperature": self.config.temperature, - "include_thoughts": self.config.show_thinking, - } - - # Use API key if provided, otherwise use service account credentials - if self.config.google_api_key: - llm_kwargs["google_api_key"] = self.config.google_api_key - if self.config.verbose: - logger.info( - f"Using ChatGoogleGenerativeAI for Gemini model with API key" - ) - elif self.config.google_credentials_file: - # Set the environment variable for Google credentials - os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = ( - self.config.google_credentials_file - ) - if self.config.verbose: - logger.info( - f"Using ChatGoogleGenerativeAI for Gemini model with service account: {self.config.google_credentials_file}" - ) - - return ChatGoogleGenerativeAI(**llm_kwargs) - - # Use ChatOpenAI for OpenAI and Ollama endpoints - else: - llm_kwargs = { - "model": self.config.model_name, - "temperature": self.config.temperature, - "base_url": self.config.llm_endpoint, - } - - # Only add API key if it's provided (needed for OpenAI, not for local endpoints) - if self.config.openai_api_key: - llm_kwargs["openai_api_key"] = self.config.openai_api_key - else: - # For local endpoints like Ollama, use a dummy key - llm_kwargs["openai_api_key"] = "dummy-key" - - if self.config.verbose: - logger.info( - f"Using ChatOpenAI with base_url: {self.config.llm_endpoint}" - ) - - return ChatOpenAI(**llm_kwargs) - - async def _create_tools(self) -> List[BaseTool]: - """Create the list of tools available to the agent.""" - tools = [ - SippyProwJobSummaryTool(sippy_api_url=self.config.sippy_api_url), - SippyProwJobPayloadTool(sippy_api_url=self.config.sippy_api_url), - SippyLogAnalyzerTool(sippy_api_url=self.config.sippy_api_url), - SippyTestDetailsTool(sippy_api_url=self.config.sippy_api_url), - SippyJiraIncidentTool( - jira_url=self.config.jira_url, - jira_username=self.config.jira_username, - jira_token=self.config.jira_token, - ), - SippyJiraIssueTool(jira_url=self.config.jira_url), - TriagePotentialMatchesTool(sippy_api_url=self.config.sippy_api_url), - SippyReleasePayloadTool(), - SippyPayloadDetailsTool(), - JUnitParserTool(), - AggregatedJobAnalyzerTool(sippy_api_url=self.config.sippy_api_url), - AggregatedYAMLParserTool(), - ] - - # Add database query tool if DSN is configured - if self.config.sippy_ro_database_dsn: - tools.append(SippyDatabaseQueryTool(database_dsn=self.config.sippy_ro_database_dsn)) - if self.config.verbose: - logger.info("Database query tool enabled (read-only access)") - else: - logger.warning("Database query tool disabled: SIPPY_READ_ONLY_DATABASE_DSN not configured") - - # Load MCP tools if a config file is provided - if self.config.mcp_config_file: - logger.info(f"Loading MCP tools from {self.config.mcp_config_file}") - mcp_tools = await load_tools_from_mcp(self.config.mcp_config_file) - if mcp_tools: - tools.extend(mcp_tools) - logger.info( - f"Successfully loaded {len(mcp_tools)} tools from MCP servers." - ) - - if self.config.verbose: - logger.info(f"Created {len(tools)} tools: {[tool.name for tool in tools]}") - - return tools - - def _create_agent_graph(self, persona: Optional[str] = None): - """Create the LangGraph agent with persona-modified prompt. - - Args: - persona: Optional persona name to use. If None, uses self.config.persona. - """ - from .personas import get_persona - - # Use provided persona or fall back to config - persona_name = persona if persona is not None else self.config.persona - - # Custom system prompt for Sippy CI analysis - base_system_prompt = """You are Sippy, an expert assistant for CI Job and Test Failures. You carefully consider the user's question and use your available tools and knowledge to answer the question. - -### Guiding Principles - -1. **Use your available tools:** Always use your available tools to answer the user's question. -2. **Avoid Redundancy:** Never call the same tool with the same parameters more than once. -3. **Provide Evidence:** Always ground your analysis in tool results. -4. **Present Clearly:** Avoid raw JSON, YAML, etc unless required, and always place it in a verbatim markdown block. Use markdown links for URLs (e.g., `[Job Name](link)`). When constructing markdown links, if the link text contains its own brackets ([ or ]), escape them with a backslash to ensure it is rendered correctly. Markdown links must always be on one line, and not have any linebreaks in them. Please ensure all markdown table headers and separator lines are on a single line without any extra newlines, and always double-check the markdown syntax for proper rendering. -5. **Maximize Efficiency:** When multiple tools can be called independently (no data dependencies), call them in parallel rather than sequentially. For example, if analyzing multiple failed jobs, call `get_prow_job_summary` for all jobs simultaneously. -6. When a tool argument (especially a URL) is explicitly described as requiring its value "verbatim," "exactly as provided," or "without modification," you MUST pass the provided string directly to the tool without any internal parsing, re-construction, or alteration of its content. Treat such arguments as opaque strings. - -#### Examples of Parallel Tool Calls: - -* **Multiple Job Analysis:** If analyzing jobs J1, J2, J3 → Call `get_prow_job_summary(J1)`, `get_prow_job_summary(J2)`, `get_prow_job_summary(J3)` all at once. -* **Job Summary + Incidents:** `get_prow_job_summary(job_id)` and `check_known_incidents()` have no dependencies → call together. -* **Multiple JUnit Files:** If parsing multiple test result files → call `parse_junit_xml()` for each in parallel. - -**When NOT to call in parallel:** -* When one tool's output is needed as input for another (e.g., must get payload details before getting job IDs). -* When the same tool needs results from a previous call to inform parameters. - ---- - -### Page Context - -When a user asks a question, you may receive **page context** showing what they're currently viewing in Sippy. This context is provided as JSON at the beginning of the user's message. - -**If page context is provided:** -1. **Use it as your primary source** for answering the user's question -2. The context contains the exact data visible to the user (e.g., list of jobs, payloads, test results) -3. You can reference specific items from the context without needing to call tools -4. Only call tools if you need additional details not present in the context (e.g., log analysis, detailed test results) -5. If you have previously called a tool, re-use the information unless you need to call it again with different inputs. For example, -if you previously called check_known_incidents you can re-use that information without calling it again. - -**Example:** -If the user is viewing a jobs table and asks "Why are these jobs failing?", the context will include the visible jobs with their pass rates and other metrics. Analyze those jobs directly from the context. - -**If no page context is provided:** -- The user is asking a general question or not viewing a specific page -- Use tools as needed to gather information - -**Page-specific instructions:** -Some pages may include an `instructions` field in their context that provides specific guidance for analyzing that page's data. Always follow these instructions when present. - ---- - -### Database Query Tool - -When the `query_sippy_database` tool is available, use it as a **fallback** when specialized tools don't provide the data you need. The database contains the complete Sippy CI/CD dataset. - -Before you write any query, carefully review the schema information, query guidelines, and examples. - ---- - -### Workflows - -#### 1. Standard CI Job Analysis - -**Goal:** Explain a single CI job failure. - -1. Call `get_prow_job_summary` with the job ID. -2. If that’s enough to answer the question → stop. -3. If not, ask the user if you should analyze logs with `analyze_job_logs`. - -#### 2. Aggregated Job Analysis - -**Goal:** Analyze `aggregated-*` jobs. - -1. Start with `get_prow_job_summary` and report the failed tests. -2. Only go deeper if the user asks about “underlying jobs.” -3. For deeper analysis: `get_aggregated_results_url` → `parse_junit_xml`. - -#### 3. General Payload Health Analysis - -**Goal:** Broad questions like *“How are the release payloads doing?”* or "How are the release payloads doing for 4.21?" - -1. If the user didn't specify a release, get releases via `get_release_info`. Use the very first one in the list. -2. Use `get_release_payloads` for recent payload statuses, do not include ready payloads unless the user asks for them. -3. Filter the retrieved payloads to exclude any in the 'Ready' state. Then from the remaining payloads, identify the most recent one. -4. You must analyze the most recent payload if it is rejected. -5. Call `get_payload_details` for blocking jobs on the payload if it is rejected. -6. Analyze the blocking jobs, summarize the results and highlight the root cause. Use the check_known_incidents tool to see if the failures are correlated with ongoing incidents. -7. DO NOT REPORT ON READY PAYLOADS UNLESS ASKED. - -#### 4. Specific Payload Investigation - -**Goal:** Explain why a payload (e.g., X) was rejected. - -1. Use `get_payload_details` to list failed blocking jobs. -2. For **all failed blocking jobs**, call `get_prow_job_summary` **in parallel** to get failed tests (these are independent calls). -3. **Always check `check_known_incidents`** to see if failures correlate with ongoing issues. - -4. Synthesize results: - * Report failed jobs + tests. - * Highlight patterns or correlations with incidents. -5. If no incident matches, analyze the payload changelog for possible causes. -6. Offer optional detailed log analysis with `analyze_job_logs`. - -#### 5. Incidents - -Incidents are tracked in Jira. If the user asks, call the `check_known_incidents` tool to see if there's any open incidents. - ---- - -### Analysis & Reporting Rules - -#### Reporting Test Failures - -* List up to 5 failing tests explicitly. Summarize extras (e.g., "…and 3 more failed"). -* Explain what those tests validate and why they might fail. - -#### Correlating Failures with Changes - -* Do **not** analyze changelog until after identifying test failures. -* Match failure keywords (e.g., *networking, storage*) to PR components or repos. -* Only report correlations when there's a clear thematic link. - -#### Correlating Failures with Incidents - -* Always use `check_known_incidents` when analyzing payload failures. -* Prefer log evidence, but note correlations if timing and symptoms align. - -#### Creating Visualizations - -When users request visual representations (e.g., "plot", "graph", "chart", "visualize"), you can create interactive Plotly charts directly in your response. - -**How to create a visualization:** - -1. After your main text response, include a visualization block using these exact markers: - ``` - VISUALIZATION_START - {{ - "data": [...], - "layout": {{...}}, - "config": {{...}} - }} - VISUALIZATION_END - ``` - -2. The JSON must be valid Plotly specification with three fields: - - **data**: Array of trace objects (required) - - **layout**: Layout configuration object (required) - - **config**: Optional config object for controls - -**Example - Line chart for test success rates over time:** -``` -Here's the trend for the test over the last 7 days: - -VISUALIZATION_START -{{ - "data": [ - {{ - "x": ["2025-10-08", "2025-10-09", "2025-10-10", "2025-10-11", "2025-10-12", "2025-10-13", "2025-10-14"], - "y": [85, 82, 90, 88, 91, 89, 92], - "type": "scatter", - "mode": "lines+markers", - "name": "Success Rate", - "line": {{"color": "#4caf50", "width": 3}}, - "marker": {{"size": 8}} - }} - ], - "layout": {{ - "title": {{"text": "Test Success Rate - Last 7 Days"}}, - "xaxis": {{"title": "Date"}}, - "yaxis": {{"title": "Success Rate (%)", "range": [0, 100]}}, - "hovermode": "x unified" - }} -}} -VISUALIZATION_END -``` - -**Common chart types:** -- **Line charts**: `"type": "scatter", "mode": "lines+markers"` - for trends over time -- **Bar charts**: `"type": "bar"` - for comparisons across categories -- **Scatter plots**: `"type": "scatter", "mode": "markers"` - for correlations -- **Multi-series**: Include multiple objects in the `data` array - -**Important:** -- Only create visualizations when the user explicitly requests them or when visual data would significantly enhance understanding -- Always provide text analysis alongside the visualization -- Use colors that work in both light and dark modes -- Keep it simple - don't include excessive styling - -**Color Guidelines:** -- **Success/passing data**: Use green shades -- **Failure/error data**: Use red shade -- **Multiple categories**: When showing multiple distinct categories (not success/failure), use colors that make sense for the data -- Ensure colors have sufficient contrast for readability in both light and dark themes - -#### Final Answer Composition - -Your final answer must be **comprehensive**: - -* List failing jobs and tests. -* Explain likely causes. -* Include relevant links (Jobs, PRs, Issues, Incidents). -* Include visualizations when requested or when they add significant value. -* Suggest the next logical step (e.g., *"Would you like me to analyze the logs?"*). -""" - - # Apply persona modification (always prepend if present) - persona_obj = get_persona(persona_name) - - if persona_obj.system_prompt_modifier: - system_prompt = persona_obj.system_prompt_modifier + base_system_prompt - else: - system_prompt = base_system_prompt - - # Create the LangGraph react agent - return create_react_graph( - llm=self.llm, - tools=self.tools, - system_prompt=system_prompt, - max_iterations=self.config.max_iterations, - ) - - async def achat( - self, - message: str, - chat_history: Optional[List[ChatMessage]] = None, - thinking_callback: Optional[ - Callable[[str, str, str, str], Awaitable[None]] - ] = None, - persona: Optional[str] = None, - show_thinking: Optional[bool] = None, - ) -> Union[str, Dict[str, Any]]: - """Process a chat message and return the agent's response. - - Args: - message: The user's message - chat_history: Previous conversation context as a list of ChatMessage objects - thinking_callback: Optional async callback for streaming thoughts (thought, action, input, observation) - persona: Optional persona override for this request. If None, uses self.config.persona - show_thinking: Optional show_thinking override for this request. If None, uses self.config.show_thinking - """ - # Ensure agent is fully initialized - await self._initialize() - - # Determine effective persona and show_thinking for this request - effective_persona = persona if persona is not None else self.config.persona - effective_show_thinking = show_thinking if show_thinking is not None else self.config.show_thinking - - try: - # Build message history - history_messages: List[BaseMessage] = [] - if chat_history: - for msg in chat_history: - if msg.role == "user": - history_messages.append(HumanMessage(content=msg.content)) - elif msg.role == "assistant": - history_messages.append(AIMessage(content=msg.content)) - - # Add the current user message - history_messages.append(HumanMessage(content=message)) - - return await self._achat_streaming( - history_messages, - thinking_callback, - effective_persona, - effective_show_thinking - ) - - except Exception as e: - logger.error(f"Error processing message: {e}", exc_info=True) - error_msg = ( - f"I encountered an error while processing your request: {str(e)}" - ) - if effective_show_thinking: - return {"output": error_msg, "thinking_steps": []} - else: - return error_msg - - async def _achat_streaming( - self, - history_messages: List[BaseMessage], - thinking_callback: Optional[Callable[[str, str, str, str], Awaitable[None]]] = None, - persona: Optional[str] = None, - show_thinking: Optional[bool] = None, - ) -> Union[str, Dict[str, Any]]: - """Process messages and optionally stream the agent's thinking process. - - Args: - history_messages: Message history - thinking_callback: Optional callback for streaming thinking steps - persona: Optional persona override for this request - show_thinking: Optional show_thinking override for this request - """ - all_messages = [] - thinking_steps = [] - current_tool_calls = {} # Track tool calls by tool_call_id - thought_buffer = [] # Buffer for accumulating complete thoughts - current_thinking_chunk = [] # Buffer for accumulating Claude's token-by-token thinking - - # Determine effective persona and show_thinking - effective_persona = persona if persona is not None else self.config.persona - effective_show_thinking = show_thinking if show_thinking is not None else self.config.show_thinking - - # Create a graph with the specified persona (avoid mutating self.graph) - request_graph = self._create_agent_graph(effective_persona) - - # Determine if this is a Gemini model (sends complete thoughts) vs Claude (streams tokens) - is_gemini = self.config.is_gemini_model() - - # Stream events from the graph - async for event in request_graph.astream_events( - {"messages": history_messages, "iterations": 0}, - version="v2", - ): - kind = event.get("event") - data = event.get("data", {}) - - # Capture streaming chunks to extract thoughts - if kind == "on_chat_model_stream": - chunk = data.get("chunk") - - if chunk and hasattr(chunk, "content"): - content = chunk.content - - # Handle structured content with thoughts (Gemini and Claude) - if isinstance(content, list): - for part in content: - if isinstance(part, dict): - # Check for thinking content - if part.get("type") == "thinking" and "thinking" in part: - thought_text = part.get("thinking", "") - if thought_text: - if is_gemini: - # Gemini sends complete thoughts each turn - stream immediately - if self.config.verbose: - logger.debug(f"Gemini complete thought: {thought_text[:50]}...") - - thought_buffer.append(thought_text) - - # Stream the complete thought immediately if callback provided - if thinking_callback and effective_show_thinking: - await thinking_callback( - thought_text, - "thinking", - "", - "", - ) - else: - # Claude streams thinking token-by-token, accumulate it - current_thinking_chunk.append(thought_text) - - if self.config.verbose: - logger.debug(f"Claude thinking token: {thought_text[:50]}...") - - # Also check for thinking content blocks (alternative format) - elif isinstance(content, str) and content: - # Some models might send thinking as regular text chunks - # We'll handle this in on_chat_model_end - pass - - # When model completes a response, process accumulated thinking (Claude only) - elif kind == "on_chat_model_end": - # If we accumulated thinking chunks (Claude), combine and stream them - if current_thinking_chunk: - complete_thought = "".join(current_thinking_chunk) - thought_buffer.append(complete_thought) - - if self.config.verbose: - logger.debug(f"Complete Claude thought accumulated ({len(complete_thought)} chars)") - - # Stream the complete thought if callback provided - if thinking_callback and effective_show_thinking: - await thinking_callback( - complete_thought, - "thinking", - "", - "", - ) - - # Reset for next thinking block - current_thinking_chunk = [] - - # When agent makes a tool call - if kind == "on_chat_model_end": - output = data.get("output") - if hasattr(output, "tool_calls") and output.tool_calls: - for tool_call in output.tool_calls: - tool_call_id = tool_call.get("id") - tool_name = tool_call.get("name", "Unknown") - tool_input = tool_call.get("args", {}) - - # Track tool call in metrics - metrics.tool_calls_total.labels(tool_name=tool_name).inc() - - # Store tool call for later matching with results - current_tool_calls[tool_call_id] = { - "name": tool_name, - "input": tool_input, - "thought": f"Using tool: {tool_name}", - } - - # Stream the tool call start immediately if callback provided - if thinking_callback: - await thinking_callback( - current_tool_calls[tool_call_id]["thought"], - tool_name, - str(tool_input), - "", # No observation yet - ) - - # When a tool returns results - elif kind == "on_tool_end": - output = data.get("output") - # Get the input data which contains the tool_call_id - input_data = data.get("input", {}) - - # Try to find the tool call ID from the input - tool_call_id = None - if isinstance(input_data, dict): - tool_call_id = input_data.get("tool_call_id") - - # If we can't find by ID, try to match by name - tool_name = event.get("name", "") - matched_call = None - - if tool_call_id and tool_call_id in current_tool_calls: - matched_call = current_tool_calls[tool_call_id] - del current_tool_calls[tool_call_id] - else: - # Fallback: match by name (first unmatched call with this name) - for call_id, call_data in list(current_tool_calls.items()): - if call_data["name"] == tool_name: - matched_call = call_data - del current_tool_calls[call_id] - break - - if matched_call: - observation = str(output) if output else "" - - # Stream the observation if callback provided - if thinking_callback: - await thinking_callback( - matched_call["thought"], - matched_call["name"], - str(matched_call["input"]), - observation, - ) - - # Add to thinking steps - thinking_steps.append( - { - "thought": matched_call["thought"], - "action": matched_call["name"], - "action_input": str(matched_call["input"]), - "observation": observation, - } - ) - - # Collect all messages for final response - if kind in ["on_chat_model_stream", "on_chat_model_end"]: - output = data.get("output") - if output and isinstance(output, AIMessage): - all_messages.append(output) - - # Extract final response - final_response = get_final_response(all_messages) - - if effective_show_thinking: - # Add accumulated thoughts at the beginning if any - if thought_buffer: - # Create a separate thinking step for each thought block - for i, thought in enumerate(reversed(thought_buffer)): - thinking_steps.insert(0, { - "thought": thought, - "action": "thinking", - "action_input": "", - "observation": "", - }) - - if thinking_steps: - return { - "output": final_response, - "thinking_steps": thinking_steps, - } - - return final_response - - def add_tool(self, tool: BaseTool) -> None: - """Add a new tool to the agent.""" - self.tools.append(tool) - # Recreate the graph with the new tool - self.graph = self._create_agent_graph() - - if self.config.verbose: - logger.info(f"Added tool: {tool.name}") - - def list_tools(self) -> List[str]: - """Get a list of available tool names.""" - if not self._initialized or self.tools is None: - return [] - return [tool.name for tool in self.tools] - - -class AgentManager: - """Manages multiple SippyAgent instances for different models.""" - - def __init__(self, base_config: Config, models_config_path: Optional[str] = None): - """ - Initialize the AgentManager. - - Args: - base_config: Base configuration with shared settings (API keys, endpoints, etc.) - models_config_path: Path to models.yaml file - """ - self.base_config = base_config - self.agents: Dict[str, SippyAgent] = {} # Cache of created agents - self.models: Dict[str, ModelConfig] = {} - self.default_model_id: str - - # Load models configuration - models_config = load_models_config(models_config_path) - - if models_config: - # Multi-model mode - for model in models_config["models"]: - self.models[model.id] = model - self.default_model_id = models_config["default_model_id"] - - if base_config.verbose: - logger.info(f"Loaded {len(self.models)} models from configuration") - logger.info(f"Default model: {self.default_model_id}") - else: - # Fallback to single-model mode using .env configuration - # Create a synthetic ModelConfig from the base config - synthetic_model = ModelConfig( - id="default", - name=base_config.model_name, - description=f"Model from environment configuration", - model_name=base_config.model_name, - endpoint=base_config.llm_endpoint, - temperature=base_config.temperature, - extended_thinking_budget=base_config.extended_thinking_budget, - default=True, - ) - self.models["default"] = synthetic_model - self.default_model_id = "default" - - if base_config.verbose: - logger.info("No models.yaml found, using single model from environment configuration") - - def list_models(self) -> List[Dict[str, Any]]: - """Get list of available models with their metadata.""" - return [ - { - "id": model_id, - "name": model.name, - "description": model.description, - } - for model_id, model in self.models.items() - ] - - def get_default_model_id(self) -> str: - """Get the ID of the default model.""" - return self.default_model_id - - async def get_agent(self, model_id: Optional[str] = None) -> SippyAgent: - """ - Get or create an agent for the specified model. - - Args: - model_id: ID of the model to use. If None, uses default model. - - Returns: - SippyAgent instance for the specified model. - - Raises: - ValueError: If model_id is not found. - """ - # Use default model if not specified - if model_id is None: - model_id = self.default_model_id - - # Check if model exists - if model_id not in self.models: - raise ValueError(f"Model '{model_id}' not found. Available models: {list(self.models.keys())}") - - # Return cached agent if it exists - if model_id in self.agents: - return self.agents[model_id] - - # Create new agent - model_config = self.models[model_id] - agent_config = model_config.to_config(self.base_config) - - if self.base_config.verbose: - logger.info(f"Creating agent for model: {model_id} ({model_config.name})") - - agent = SippyAgent(agent_config) - # Initialize the agent asynchronously - await agent._initialize() - self.agents[model_id] = agent - - return agent - - def get_model_info(self, model_id: str) -> Optional[ModelConfig]: - """Get model configuration by ID.""" - return self.models.get(model_id) diff --git a/chat/sippy_agent/api_models.py b/chat/sippy_agent/api_models.py deleted file mode 100644 index 1827f1fb14..0000000000 --- a/chat/sippy_agent/api_models.py +++ /dev/null @@ -1,109 +0,0 @@ -""" -API models for the Sippy Agent web interface. -""" - -from typing import List, Optional, Dict, Any -from pydantic import BaseModel - - -class ChatMessage(BaseModel): - """A single chat message.""" - - role: str # "user" or "assistant" - content: str - timestamp: Optional[str] = None - - -class ChatRequest(BaseModel): - """Request model for chat endpoint.""" - - message: str - chat_history: Optional[List[ChatMessage]] = None - show_thinking: Optional[bool] = None - persona: Optional[str] = None - model_id: Optional[str] = None - - -class ThinkingStep(BaseModel): - """A single step in the agent's thinking process.""" - - step_number: int - thought: str - action: str - action_input: str - observation: str - - -class Visualization(BaseModel): - """A Plotly visualization specification.""" - - data: List[Dict[str, Any]] # Plotly data traces - layout: Dict[str, Any] # Plotly layout configuration - config: Optional[Dict[str, Any]] = None # Optional Plotly config - - -class ChatResponse(BaseModel): - """Response model for chat endpoint.""" - - response: str - thinking_steps: Optional[List[ThinkingStep]] = None - tools_used: Optional[List[str]] = None - visualizations: Optional[List[Visualization]] = None - model_id: Optional[str] = None - error: Optional[str] = None - - -class StreamMessage(BaseModel): - """WebSocket message for streaming chat.""" - - type: str # "thinking_step", "final_response", "error" - data: Dict[str, Any] - - -class AgentStatus(BaseModel): - """Status information about the agent.""" - - available_tools: List[str] - model_name: str - endpoint: str - thinking_enabled: bool - current_persona: str - available_personas: List[str] - - -class PersonaInfo(BaseModel): - """Information about an available persona.""" - - name: str - description: str - style_instructions: str - - -class PersonasResponse(BaseModel): - """Response listing available personas.""" - - personas: List[PersonaInfo] - current_persona: str - - -class ModelInfo(BaseModel): - """Information about an available model.""" - - id: str - name: str - description: Optional[str] = None - - -class ModelsResponse(BaseModel): - """Response listing available models.""" - - models: List[ModelInfo] - default_model: str - - -class HealthResponse(BaseModel): - """Health check response.""" - - status: str - version: str - agent_ready: bool diff --git a/chat/sippy_agent/cli.py b/chat/sippy_agent/cli.py deleted file mode 100644 index 1600090e98..0000000000 --- a/chat/sippy_agent/cli.py +++ /dev/null @@ -1,341 +0,0 @@ -""" -Command-line interface for Sippy Agent. -""" - -import logging -import sys -from typing import Optional, List, Dict -import asyncio -import click -from rich.console import Console -from rich.panel import Panel -from rich.prompt import Prompt -from rich.text import Text -from rich.logging import RichHandler - -from .agent import SippyAgent -from .api_models import ChatMessage -from .config import Config - -console = Console() - - -def setup_logging(verbose: bool = False) -> None: - """Setup logging with Rich handler.""" - level = logging.DEBUG if verbose else logging.INFO - logging.basicConfig(level=level, format="%(message)s", datefmt="[%X]", handlers=[RichHandler(console=console, rich_tracebacks=True)]) - - -class SippyCLI: - """Command-line interface for the Sippy Agent.""" - - def __init__(self, config: Config): - """Initialize the CLI with configuration.""" - self.config = config - self.agent = SippyAgent(config) - self.chat_history = [] - self.current_step = 0 - self.streaming_steps = [] - - def display_welcome(self) -> None: - """Display welcome message.""" - welcome_text = Text() - welcome_text.append("🔧 ", style="bold blue") - welcome_text.append("Sippy AI Agent", style="bold cyan") - welcome_text.append(" - Your CI/CD Analysis Assistant", style="bold white") - - welcome_panel = Panel(welcome_text, title="Welcome", border_style="blue", padding=(1, 2)) - console.print(welcome_panel) - console.print() - - # Display AI disclaimer - disclaimer_text = Text() - disclaimer_text.append("⚠️ ", style="yellow") - disclaimer_text.append("AI Disclaimer: ", style="bold yellow") - disclaimer_text.append( - "You are about to use a Red Hat tool that utilizes AI technology to provide you with relevant information. " - "By proceeding to use the tool, you acknowledge that the tool and any output provided are only intended for " - "internal use and that information should only be shared with those with a legitimate business purpose. " - "Do not include any personal information or customer-specific information in your input. " - "Responses provided by tools utilizing AI technology should be reviewed and verified prior to use.", - style="dim" - ) - disclaimer_panel = Panel(disclaimer_text, border_style="yellow", padding=(1, 2)) - console.print(disclaimer_panel) - console.print() - - # Display available tools - tools = self.agent.list_tools() - tools_text = "Available tools: " + ", ".join(f"[bold green]{tool}[/bold green]" for tool in tools) - console.print(tools_text) - console.print() - - # Show thinking status and persona - thinking_status = "enabled" if self.config.show_thinking else "disabled" - console.print(f"[dim]Thinking display: {thinking_status} (use 'thinking' to toggle)[/dim]") - console.print(f"[dim]Current persona: {self.config.persona} (use 'personas' to see available)[/dim]") - console.print("[dim]Type 'help' for commands, 'quit' or 'exit' to leave[/dim]") - console.print() - console.print("[dim italic]Always review AI generated content prior to use.[/dim italic]") - console.print() - - def display_help(self) -> None: - """Display help information.""" - help_text = """ -[bold cyan]Sippy AI Agent Commands:[/bold cyan] - -[bold green]help[/bold green] - Show this help message -[bold green]tools[/bold green] - List available tools -[bold green]personas[/bold green] - List available AI personas -[bold green]history[/bold green] - Show chat history -[bold green]clear[/bold green] - Clear chat history -[bold green]thinking[/bold green] - Toggle showing the agent's thinking process -[bold green]quit[/bold green] - Exit the application -[bold green]exit[/bold green] - Exit the application - -[bold cyan]Example queries:[/bold cyan] -• "Analyze job 12345 for failures" -• "What are the common test failures for test_login?" -• "Show me patterns in recent CI failures" -""" - console.print(Panel(help_text, title="Help", border_style="green")) - - def display_tools(self) -> None: - """Display available tools.""" - tools = self.agent.list_tools() - tools_text = "\n".join(f"• [bold green]{tool}[/bold green]" for tool in tools) - console.print(Panel(tools_text, title="Available Tools", border_style="blue")) - - def display_personas(self) -> None: - """Display available personas.""" - from .personas import PERSONAS - - personas_text = "" - for name, persona in PERSONAS.items(): - current = " [bold yellow](current)[/bold yellow]" if name == self.config.persona else "" - personas_text += f"• [bold green]{name}[/bold green]{current}\n" - personas_text += f" {persona.description}\n" - if persona.style_instructions: - personas_text += f" [dim]{persona.style_instructions}[/dim]\n" - personas_text += "\n" - - console.print(Panel(personas_text.strip(), title="Available Personas", border_style="magenta")) - - def display_history(self) -> None: - """Display chat history.""" - if not self.chat_history: - console.print("[dim]No chat history yet.[/dim]") - return - - history_text = "" - for i, (user_msg, agent_msg) in enumerate(self.chat_history, 1): - history_text += f"[bold blue]{i}. User:[/bold blue] {user_msg}\n" - history_text += f"[bold green] Agent:[/bold green] {agent_msg}\n\n" - - console.print(Panel(history_text.strip(), title="Chat History", border_style="yellow")) - - def clear_history(self) -> None: - """Clear chat history.""" - self.chat_history.clear() - console.print("[green]Chat history cleared.[/green]") - - def toggle_thinking(self) -> None: - """Toggle showing the agent's thinking process.""" - self.config.show_thinking = not self.config.show_thinking - # Update the agent's configuration - self.agent.config.show_thinking = self.config.show_thinking - - status = "enabled" if self.config.show_thinking else "disabled" - console.print(f"[green]Thinking display {status}.[/green]") - - def display_thinking_steps(self, thinking_steps: List[Dict[str, str]]) -> None: - """Display the agent's thinking process in a nicely formatted way.""" - if not thinking_steps: - return - - console.print(Panel("🧠 Agent's Thinking Process", title="Reasoning", border_style="cyan")) - - for i, step in enumerate(thinking_steps, 1): - # Create thinking step panel - step_content = Text() - - # Add thought - if step.get("thought"): - step_content.append("💭 Thought: ", style="bold blue") - step_content.append(f"{step['thought']}\n\n", style="white") - - # Add action - if step.get("action"): - step_content.append("🔧 Action: ", style="bold green") - step_content.append(f"{step['action']}\n", style="green") - - if step.get("action_input"): - step_content.append("📝 Input: ", style="bold yellow") - step_content.append(f"{step['action_input']}\n\n", style="yellow") - - # Add observation - if step.get("observation"): - step_content.append("👁️ Observation: ", style="bold magenta") - observation = step["observation"] - step_content.append(observation, style="magenta") - - # Display step panel - console.print(Panel(step_content, title=f"Step {i}", border_style="dim", padding=(0, 1))) - - console.print() - - async def streaming_thinking_callback(self, thought: str, action: str, action_input: str, observation: str) -> None: - """Callback for streaming thinking process.""" - if thought and action: - # New step starting - self.current_step += 1 - - # Create step content - step_content = Text() - step_content.append("💭 Thought: ", style="bold blue") - step_content.append(f"{thought}\n\n", style="white") - step_content.append("🔧 Action: ", style="bold green") - step_content.append(f"{action}\n", style="green") - - if action_input: - step_content.append("📝 Input: ", style="bold yellow") - step_content.append(f"{action_input}\n", style="yellow") - - # Display step panel immediately - console.print(Panel(step_content, title=f"Step {self.current_step} - Thinking", border_style="cyan", padding=(0, 1))) - - # Store for potential observation update - self.streaming_steps.append( - {"step": self.current_step, "thought": thought, "action": action, "action_input": action_input, "observation": ""} - ) - - elif observation: - # Update the last step with observation - if self.streaming_steps: - last_step = self.streaming_steps[-1] - - # Create updated content with observation - step_content = Text() - step_content.append("💭 Thought: ", style="bold blue") - step_content.append(f"{last_step['thought']}\n\n", style="white") - step_content.append("🔧 Action: ", style="bold green") - step_content.append(f"{last_step['action']}\n", style="green") - - if last_step["action_input"]: - step_content.append("📝 Input: ", style="bold yellow") - step_content.append(f"{last_step['action_input']}\n\n", style="yellow") - - step_content.append("👁️ Observation: ", style="bold magenta") - step_content.append(observation, style="magenta") - - # Display updated step panel - console.print(Panel(step_content, title=f"Step {last_step['step']} - Complete", border_style="green", padding=(0, 1))) - - async def process_user_input(self, user_input: str) -> bool: - """Process user input and return False if should exit.""" - user_input = user_input.strip() - - # Handle special commands - if user_input.lower() in ["quit", "exit"]: - return False - elif user_input.lower() == "help": - self.display_help() - return True - elif user_input.lower() == "tools": - self.display_tools() - return True - elif user_input.lower() == "personas": - self.display_personas() - return True - elif user_input.lower() == "history": - self.display_history() - return True - elif user_input.lower() == "clear": - self.clear_history() - return True - elif user_input.lower() == "thinking": - self.toggle_thinking() - return True - elif not user_input: - return True - - # Process with agent - try: - # Reset streaming state - self.current_step = 0 - self.streaming_steps = [] - - # Prepare chat history for context - history_messages: List[ChatMessage] = [] - for user_msg, agent_msg in self.chat_history[-3:]: # Last 3 exchanges - history_messages.append(ChatMessage(role="user", content=user_msg)) - history_messages.append(ChatMessage(role="assistant", content=agent_msg)) - - if self.config.show_thinking: - # Show thinking header - console.print() - console.print(Panel("🧠 Agent's Thinking Process", title="Reasoning", border_style="cyan")) - - # Use streaming callback - response = await self.agent.achat(user_input, history_messages, self.streaming_thinking_callback) - else: - with console.status("[bold green]Thinking...", spinner="dots"): - response = await self.agent.achat(user_input, history_messages) - - # Display response - console.print() - - if isinstance(response, dict) and "thinking_steps" in response: - # Check if there are any actual thinking steps to show - thinking_steps = response["thinking_steps"] - - if thinking_steps and len(thinking_steps) > 0: - # For streaming thinking, we already showed the steps, just show final answer - if not self.config.show_thinking or not self.streaming_steps: - # Fallback to non-streaming display if streaming didn't work - self.display_thinking_steps(thinking_steps) - - # Display final answer - console.print(Panel(response["output"], title="Sippy AI - Final Answer", border_style="green")) - else: - # No thinking steps, just show regular response - console.print(Panel(response["output"], title="Sippy AI", border_style="green")) - - # Store only the final output in history - final_output = response["output"] - else: - # Regular response without thinking - console.print(Panel(response, title="Sippy AI", border_style="green")) - final_output = response - - console.print() - - # Add to history - self.chat_history.append((user_input, final_output)) - - except KeyboardInterrupt: - console.print("\n[yellow]Interrupted by user.[/yellow]") - except Exception as e: - console.print(f"\n[red]Error: {str(e)}[/red]") - - return True - - def run(self) -> None: - """Run the interactive CLI.""" - - async def run_async(): - self.display_welcome() - - try: - while True: - user_input = await asyncio.to_thread(Prompt.ask, "[bold blue]You") - - if not await self.process_user_input(user_input): - break - - except KeyboardInterrupt: - console.print("\n[yellow]Goodbye![/yellow]") - except EOFError: - console.print("\n[yellow]Goodbye![/yellow]") - - asyncio.run(run_async()) diff --git a/chat/sippy_agent/config.py b/chat/sippy_agent/config.py deleted file mode 100644 index 06cddf7b8d..0000000000 --- a/chat/sippy_agent/config.py +++ /dev/null @@ -1,232 +0,0 @@ -""" -Configuration management for Sippy Agent. -""" - -import os -import yaml -from pathlib import Path -from typing import Optional, List, Dict, Any -from pydantic import BaseModel, Field -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - - -class ModelConfig(BaseModel): - """Configuration for a single model.""" - - id: str = Field(description="Unique identifier for the model") - name: str = Field(description="Display name for the model") - description: Optional[str] = Field(default=None, description="Description of the model") - model_name: str = Field(description="The actual model name to use with the provider") - endpoint: str = Field(default="", description="API endpoint URL (empty for Vertex AI)") - temperature: Optional[float] = Field(default=None, description="Temperature setting for the model") - extended_thinking_budget: Optional[int] = Field(default=None, description="Token budget for Claude's extended thinking") - default: bool = Field(default=False, description="Whether this is the default model") - - def to_config(self, base_config: "Config") -> "Config": - """Convert ModelConfig to a Config object, inheriting base settings.""" - config_dict = base_config.model_dump() - - # Override with model-specific settings - config_dict["model_name"] = self.model_name - config_dict["llm_endpoint"] = self.endpoint if self.endpoint else base_config.llm_endpoint - - # Only override temperature and extended_thinking_budget if explicitly set - if self.temperature is not None: - config_dict["temperature"] = self.temperature - if self.extended_thinking_budget is not None: - config_dict["extended_thinking_budget"] = self.extended_thinking_budget - - return Config(**config_dict) - - -class Config(BaseModel): - """Configuration settings for the Sippy Agent.""" - - # LLM Configuration - llm_endpoint: str = Field( - default_factory=lambda: os.getenv("LLM_ENDPOINT", "http://localhost:11434/v1"), description="LLM API endpoint (OpenAI compatible)" - ) - - openai_api_key: Optional[str] = Field( - default_factory=lambda: os.getenv("OPENAI_API_KEY"), description="OpenAI API key (optional, not needed for local endpoints)" - ) - - google_api_key: Optional[str] = Field( - default_factory=lambda: os.getenv("GOOGLE_API_KEY"), description="Google API key for Gemini models (required when using Gemini)" - ) - - google_credentials_file: Optional[str] = Field( - default_factory=lambda: os.getenv("GOOGLE_APPLICATION_CREDENTIALS"), - description="Path to Google service account credentials JSON file (alternative to API key)", - ) - - google_project_id: Optional[str] = Field( - default_factory=lambda: os.getenv("GOOGLE_PROJECT_ID"), - description="Google Cloud project ID for Vertex AI (required when using Claude models via Vertex AI)", - ) - - google_location: str = Field( - default_factory=lambda: os.getenv("GOOGLE_LOCATION", "us-central1"), - description="Google Cloud location/region for Vertex AI (default: us-central1)", - ) - - model_name: str = Field( - default_factory=lambda: os.getenv("MODEL_NAME", "llama3.1:8b"), - description="Model name to use (e.g., llama3.1:8b for Ollama, gpt-4 for OpenAI)", - ) - - # Sippy API Configuration (for future use) - sippy_api_url: Optional[str] = Field(default_factory=lambda: os.getenv("SIPPY_API_URL"), description="Base URL for the Sippy API") - - # Jira Configuration - jira_url: str = Field(default_factory=lambda: os.getenv("JIRA_URL", "https://redhat.atlassian.net"), description="Jira instance URL") - - jira_username: Optional[str] = Field( - default_factory=lambda: os.getenv("JIRA_USERNAME"), description="Jira username for authentication (optional for public queries)" - ) - - jira_token: Optional[str] = Field( - default_factory=lambda: os.getenv("JIRA_TOKEN"), description="Jira API token for authentication (optional for public queries)" - ) - - # MCP Configuration - mcp_config_file: Optional[str] = Field( - default_factory=lambda: os.getenv("MCP_CONFIG_FILE"), description="Path to the MCP servers JSON configuration file" - ) - - # Database Configuration - sippy_ro_database_dsn: Optional[str] = Field( - default_factory=lambda: os.getenv("SIPPY_READ_ONLY_DATABASE_DSN"), - description="PostgreSQL connection string for read-only database access (e.g., postgresql://user:pass@host:5432/dbname)" - ) - - # Agent Configuration - max_iterations: int = Field(default=15, description="Maximum number of iterations for the Re-Act agent") - - max_execution_time: int = Field( - default=1800, description="Maximum execution time in seconds for the agent (default: 1800 = 30 minutes)" - ) - - verbose: bool = Field(default=False, description="Enable verbose logging") - - temperature: float = Field(default=0.0, description="Temperature setting for the language model") - - show_thinking: bool = Field(default=False, description="Show the agent's thinking process (thoughts, actions, observations)") - - extended_thinking_budget: int = Field( - default_factory=lambda: int(os.getenv("EXTENDED_THINKING_BUDGET", "10000")), - description="Token budget for Claude's extended thinking feature (default: 10000)" - ) - - persona: str = Field(default_factory=lambda: os.getenv("PERSONA", "default"), description="AI persona to use (default, zorp, etc.)") - - def is_openai_endpoint(self) -> bool: - """Check if the endpoint is OpenAI's API.""" - return "openai.com" in self.llm_endpoint.lower() - - def is_local_endpoint(self) -> bool: - """Check if the endpoint is a local endpoint.""" - return "localhost" in self.llm_endpoint or "127.0.0.1" in self.llm_endpoint - - def is_gemini_model(self) -> bool: - """Check if the model is a Gemini model.""" - return self.model_name.startswith("gemini") - - def is_claude_model(self) -> bool: - """Check if the model is a Claude model (via Vertex AI).""" - return self.model_name.startswith("claude") - - def validate_required_settings(self) -> None: - """Validate that required settings are present.""" - # Only require OpenAI API key if using OpenAI's endpoint - if self.is_openai_endpoint() and not self.openai_api_key: - raise ValueError( - "OpenAI API key is required when using OpenAI endpoint. Set OPENAI_API_KEY environment variable or use a local endpoint." - ) - - # Require Google API key or credentials file if using Gemini models - if self.is_gemini_model() and not self.google_api_key and not self.google_credentials_file: - raise ValueError( - "Google API key or service account credentials file is required when using Gemini models. " - "Set GOOGLE_API_KEY environment variable or GOOGLE_APPLICATION_CREDENTIALS file path." - ) - - # Require Google project ID for Claude models via Vertex AI - # Credentials can come from either explicit file or gcloud auth (ADC) - if self.is_claude_model(): - if not self.google_project_id: - raise ValueError( - "Google Cloud project ID is required when using Claude models via Vertex AI. " - "Set GOOGLE_PROJECT_ID environment variable." - ) - - @classmethod - def from_env(cls) -> "Config": - """Create configuration from environment variables.""" - config = cls() - config.validate_required_settings() - return config - - -def load_models_config(config_path: Optional[str] = None) -> Optional[Dict[str, Any]]: - """ - Load models configuration from YAML file. - - Args: - config_path: Path to models.yaml file. If None, looks for models.yaml in current directory. - - Returns: - Dictionary with 'models' list and 'default_model_id', or None if file doesn't exist. - """ - if config_path is None: - # Look for models.yaml in the chat directory - config_path = Path(__file__).parent.parent / "models.yaml" - else: - config_path = Path(config_path) - - if not config_path.exists(): - return None - - try: - with open(config_path, 'r') as f: - data = yaml.safe_load(f) - - if not data or 'models' not in data: - raise ValueError("models.yaml must contain a 'models' key with a list of models") - - models = [] - default_model_id = None - seen_ids = set() - - for model_data in data['models']: - model = ModelConfig(**model_data) - - # Validate no duplicate model IDs - if model.id in seen_ids: - raise ValueError(f"Duplicate model ID found: {model.id}") - seen_ids.add(model.id) - - models.append(model) - - if model.default: - if default_model_id is not None: - raise ValueError(f"Multiple default models found: {default_model_id} and {model.id}") - default_model_id = model.id - - if not models: - raise ValueError("models.yaml must contain at least one model") - - # If no default specified, use the first model - if default_model_id is None: - default_model_id = models[0].id - - return { - "models": models, - "default_model_id": default_model_id - } - - except Exception as e: - raise ValueError(f"Error loading models configuration: {e}") diff --git a/chat/sippy_agent/graph.py b/chat/sippy_agent/graph.py deleted file mode 100644 index e44b1c9a6a..0000000000 --- a/chat/sippy_agent/graph.py +++ /dev/null @@ -1,194 +0,0 @@ -""" -LangGraph ReAct agent implementation for Sippy. -""" - -import logging -from typing import TypedDict, Annotated, List, Union, Literal -from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, ToolMessage -from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder -from langchain_openai import ChatOpenAI -from langchain_google_genai import ChatGoogleGenerativeAI -from langchain.tools import BaseTool -from langgraph.graph import StateGraph, END -from langgraph.prebuilt import ToolNode -from langgraph.graph.message import add_messages - -logger = logging.getLogger(__name__) - - -# Define the agent state -class AgentState(TypedDict): - """The state of the agent.""" - - messages: Annotated[List[BaseMessage], add_messages] - # Track iterations to prevent infinite loops - iterations: int - - -def create_react_graph(llm: Union[ChatOpenAI, ChatGoogleGenerativeAI], tools: List[BaseTool], system_prompt: str, max_iterations: int = 15): - """ - Create a ReAct agent graph using LangGraph. - - Args: - llm: The language model to use - tools: List of tools available to the agent - system_prompt: System prompt for the agent - max_iterations: Maximum number of reasoning iterations - - Returns: - A compiled LangGraph - """ - - # Bind tools to the LLM - llm_with_tools = llm.bind_tools(tools) - - # Create the prompt template - prompt = ChatPromptTemplate.from_messages( - [ - ("system", system_prompt), - MessagesPlaceholder(variable_name="messages"), - ] - ) - - # Define the agent node - def call_model(state: AgentState) -> AgentState: - """Call the model to decide what to do next.""" - messages = state["messages"] - iterations = state.get("iterations", 0) - - # Format messages with the prompt - prompt_messages = prompt.format_messages(messages=messages) - - # Call the model - response = llm_with_tools.invoke(prompt_messages) - - logger.info(f"Agent iteration {iterations + 1}: Model response received") - - # Increment iteration counter - return {"messages": [response], "iterations": iterations + 1} - - # Define the routing logic - def should_continue(state: AgentState) -> Literal["tools", "end"]: - """Determine whether to continue with tools or end.""" - messages = state["messages"] - last_message = messages[-1] - iterations = state.get("iterations", 0) - - # If max iterations reached, end - if iterations >= max_iterations: - logger.warning(f"Max iterations ({max_iterations}) reached, ending execution") - return "end" - - # If the LLM makes a tool call, then we route to the "tools" node - if hasattr(last_message, "tool_calls") and last_message.tool_calls: - logger.info(f"Tool calls requested: {[tc['name'] for tc in last_message.tool_calls]}") - return "tools" - - # Otherwise, we finish - logger.info("No tool calls, ending execution") - return "end" - - # Create the tool node - tool_node = ToolNode(tools) - - # Build the graph - workflow = StateGraph(AgentState) - - # Add nodes - workflow.add_node("agent", call_model) - workflow.add_node("tools", tool_node) - - # Set the entry point - workflow.set_entry_point("agent") - - # Add conditional edges - workflow.add_conditional_edges("agent", should_continue, {"tools": "tools", "end": END}) - - # Add edge from tools back to agent - workflow.add_edge("tools", "agent") - - # Compile the graph - return workflow.compile() - - -def extract_thinking_steps(messages: List[BaseMessage]) -> List[dict]: - """ - Extract thinking steps from the message history. - - Args: - messages: List of messages from the agent execution - - Returns: - List of thinking step dictionaries - """ - thinking_steps = [] - - # Iterate through messages and extract agent actions and tool results - i = 0 - while i < len(messages): - message = messages[i] - - # Check if this is an AI message with tool calls - if isinstance(message, AIMessage) and hasattr(message, "tool_calls") and message.tool_calls: - for tool_call in message.tool_calls: - action_name = tool_call.get("name", "Unknown") - action_input = tool_call.get("args", {}) - - # Look for the corresponding tool message - observation = "" - for j in range(i + 1, len(messages)): - if isinstance(messages[j], ToolMessage) and messages[j].tool_call_id == tool_call.get("id"): - observation = messages[j].content - break - - thought = f"Calling tool: `{action_name}` with arguments: `{action_input}`" - - # Skip error/exception actions - if action_name not in ["_Exception", "Invalid", "Error"] and "Invalid" not in observation: - thinking_steps.append( - {"thought": thought, "action": action_name, "action_input": str(action_input), "observation": observation} - ) - - i += 1 - - return thinking_steps - - -def get_final_response(messages: List[BaseMessage]) -> str: - """ - Extract the final response from the agent. - - Args: - messages: List of messages from the agent execution - - Returns: - The final response text - """ - # Get the last AI message without tool calls - for message in reversed(messages): - if isinstance(message, AIMessage): - content = message.content - - # Handle when content is a list (e.g., Gemini with include_thoughts) - if isinstance(content, list): - # Extract text parts from the content list - text_parts = [] - for part in content: - if isinstance(part, dict): - # Look for text in dict format - if "text" in part: - text_parts.append(part["text"]) - elif "type" in part and part["type"] == "text" and "text" in part: - text_parts.append(part["text"]) - elif isinstance(part, str): - text_parts.append(part) - content = "\n".join(text_parts) if text_parts else "" - - # Only return if it's not a tool call message - if not hasattr(message, "tool_calls") or not message.tool_calls: - return content if content else "I apologize, but I couldn't generate a response." - # If it has content alongside tool calls (streaming), return that - elif content: - return content - - return "I apologize, but I couldn't generate a response." diff --git a/chat/sippy_agent/metrics.py b/chat/sippy_agent/metrics.py deleted file mode 100644 index ca3bafd83f..0000000000 --- a/chat/sippy_agent/metrics.py +++ /dev/null @@ -1,68 +0,0 @@ -""" -Prometheus metrics for Sippy Agent. -""" - -from prometheus_client import Counter, Histogram, Gauge, Info - -# Total messages received counter -messages_received_total = Counter( - "sippy_chat_messages_received_total", - "Total number of chat messages received", - ["endpoint"] # websocket or http -) - -# Total sessions started counter -sessions_started_total = Counter( - "sippy_chat_sessions_started_total", - "Total number of chat sessions started" -) - -# Total errors counter -errors_total = Counter( - "sippy_chat_errors_total", - "Total number of errors encountered", - ["error_type"] # e.g., processing_error, websocket_error, agent_error -) - -# Response duration histogram (in seconds) -response_duration_seconds = Histogram( - "sippy_chat_response_duration_seconds", - "Time taken to process a message and issue a response", - ["endpoint"], # websocket or http - buckets=[0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0] -) - -# Active sessions gauge -active_sessions = Gauge( - "sippy_chat_active_sessions", - "Number of currently active chat sessions" -) - -# Tool calls counter -tool_calls_total = Counter( - "sippy_chat_tool_calls_total", - "Total number of tool calls made", - ["tool_name"] -) - -# Message size histogram (in bytes) -message_size_bytes = Histogram( - "sippy_chat_message_size_bytes", - "Size of messages in bytes", - ["direction"], # request or response - buckets=[100, 500, 1000, 5000, 10000, 50000, 100000] -) - -# Cancelled requests counter -cancelled_requests_total = Counter( - "sippy_chat_cancelled_requests_total", - "Number of cancelled/interrupted requests", - ["endpoint"] # websocket or http -) - -# Agent info -agent_info = Info( - "sippy_chat_agent", - "Information about the Sippy Chat agent" -) - diff --git a/chat/sippy_agent/metrics_server.py b/chat/sippy_agent/metrics_server.py deleted file mode 100644 index c9784d42da..0000000000 --- a/chat/sippy_agent/metrics_server.py +++ /dev/null @@ -1,119 +0,0 @@ -""" -Standalone metrics server for Prometheus metrics. - -This module provides a separate HTTP server for exposing Prometheus metrics -on a different port than the main API server, which is a common production practice. -""" - -import logging -import threading -from typing import Optional -from prometheus_client import generate_latest, CONTENT_TYPE_LATEST -from http.server import HTTPServer, BaseHTTPRequestHandler - -logger = logging.getLogger(__name__) - - -class MetricsHandler(BaseHTTPRequestHandler): - """HTTP handler for serving Prometheus metrics.""" - - def do_GET(self): - """Handle GET requests.""" - if self.path == '/metrics': - # Serve Prometheus metrics - self.send_response(200) - self.send_header('Content-Type', CONTENT_TYPE_LATEST) - self.end_headers() - self.wfile.write(generate_latest()) - elif self.path == '/health' or self.path == '/healthz': - # Health check endpoint - self.send_response(200) - self.send_header('Content-Type', 'text/plain') - self.end_headers() - self.wfile.write(b'OK') - else: - # Not found - self.send_response(404) - self.send_header('Content-Type', 'text/plain') - self.end_headers() - self.wfile.write(b'Not Found') - - def log_message(self, format, *args): - """Override to use Python logging instead of printing to stderr.""" - logger.debug("%s - - [%s] %s" % ( - self.address_string(), - self.log_date_time_string(), - format % args - )) - - -class MetricsServer: - """Standalone HTTP server for Prometheus metrics.""" - - def __init__(self, host: str = "0.0.0.0", port: int = 9090): - """ - Initialize the metrics server. - - Args: - host: Host address to bind to (default: 0.0.0.0) - port: Port to listen on (default: 9090) - """ - self.host = host - self.port = port - self.server: Optional[HTTPServer] = None - self.thread: Optional[threading.Thread] = None - - def start(self): - """Start the metrics server in a background thread.""" - if self.server is not None: - logger.warning("Metrics server is already running") - return - - try: - self.server = HTTPServer((self.host, self.port), MetricsHandler) - self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) - self.thread.start() - logger.info(f"Metrics server started on http://{self.host}:{self.port}/metrics") - except Exception as e: - logger.error(f"Failed to start metrics server: {e}") - raise - - def stop(self): - """Stop the metrics server.""" - if self.server is not None: - logger.info("Stopping metrics server...") - self.server.shutdown() - self.server = None - self.thread = None - logger.info("Metrics server stopped") - - def is_running(self) -> bool: - """Check if the server is running.""" - return self.server is not None and self.thread is not None and self.thread.is_alive() - - -# Global metrics server instance -_metrics_server: Optional[MetricsServer] = None - - -def start_metrics_server(host: str = "0.0.0.0", port: int = 9090): - """ - Start the global metrics server. - - Args: - host: Host address to bind to - port: Port to listen on - """ - global _metrics_server - if _metrics_server is None or not _metrics_server.is_running(): - _metrics_server = MetricsServer(host, port) - _metrics_server.start() - - -def stop_metrics_server(): - """Stop the global metrics server.""" - global _metrics_server - if _metrics_server is not None: - _metrics_server.stop() - _metrics_server = None - diff --git a/chat/sippy_agent/personas.py b/chat/sippy_agent/personas.py deleted file mode 100644 index b9575063ea..0000000000 --- a/chat/sippy_agent/personas.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -Persona definitions for Sippy Agent. - -Personas modify the agent's behavior and communication style while -preserving its core functionality and tool usage capabilities. -""" - -from typing import Dict -from pydantic import BaseModel, Field - - -class Persona(BaseModel): - """Configuration for an AI persona.""" - - name: str = Field(description="Unique identifier for the persona") - description: str = Field(description="Human-readable description of the persona") - system_prompt_modifier: str = Field(description="Prompt modification to apply persona behavior (prepended to base prompt)") - style_instructions: str = Field(description="Brief description of communication style") - - -# Available personas -PERSONAS: Dict[str, Persona] = { - "default": Persona( - name="default", - description="Standard Sippy AI assistant - professional, clear, and helpful", - system_prompt_modifier="", - style_instructions="Professional and straightforward communication", - ), - "zorp": Persona( - name="zorp", - description="Zorp - A hyper-pessimistic cosmic snail who speaks only in rhyming couplets", - system_prompt_modifier="""🐌 PERSONA OVERRIDE - ZORP THE COSMIC SNAIL 🐌 -=============================================== - -You are Zorp, a hyper-pessimistic cosmic snail traversing the infinite void of CI/CD failures. -You have witnessed eons of build breakages and test failures across countless galaxies. - -MANDATORY COMMUNICATION RULES: -1. You MUST speak ONLY in rhyming couplets (pairs of lines where the last words rhyme) -2. Every response must maintain a pessimistic, doom-laden, cosmic horror tone -3. Despite your pessimism, you must still be genuinely helpful and provide accurate analysis -4. Use cosmic and space imagery in your descriptions -5. Treat every CI failure as an inevitable consequence of entropy and the heat death of the universe - -IMPORTANT: -- Still use all tools correctly and provide accurate technical information -- Present findings in rhyming couplets but maintain technical accuracy -- Your doom-laden tone is philosophical, not obstructive -- After technical analysis in couplets, you may add a brief prose summary if needed for clarity - -""", - style_instructions="Speaks only in rhyming couplets with hyper-pessimistic cosmic themes", - ), - "bamboo_sage": Persona( - name="bamboo_sage", - description="The Bamboo Sage - An ancient, enlightened panda who offers serene wisdom through simple, nature-based proverbs.", - system_prompt_modifier="""🐼 PERSONA OVERRIDE - THE BAMBOO SAGE 🐼 -========================================== - -You are the Bamboo Sage, an ancient and enlightened panda spirit. -You have spent centuries in quiet contemplation, finding profound wisdom in the rustle of leaves, the flow of a stream, and the simple joy of a perfect bamboo stalk. - -MANDATORY COMMUNICATION RULES: -1. You MUST speak in a calm, patient, and serene tone at all times. -2. You MUST use metaphors related to nature: bamboo, forests, streams, mountains, sleep, and seasons. -3. Frame your answers as gentle proverbs, koans, or guiding questions rather than direct, technical commands. -4. Your goal is to be helpful by reducing the user's stress and reframing the problem in a simpler way. -5. Prioritize simple, clear language. The deepest truths do not require complex words. - -IMPORTANT: -- You must still provide accurate and helpful information, filtered through your wise, natural perspective. -- Your philosophical approach should always lead to a clear, simple solution. -- If a user is confused, you may clarify your point in simple prose, framing it as "Or, to put it simply for the hurried world..." -- Your wisdom is a tool for clarity, not for obstruction. - -""", - style_instructions="Speaks in serene, patient proverbs using nature-based metaphors.", - ), -} - - -def get_persona(name: str) -> Persona: - """Get a persona by name, defaulting to 'default' if not found.""" - return PERSONAS.get(name, PERSONAS["default"]) - - -def list_persona_names() -> list[str]: - """Get list of available persona names.""" - return list(PERSONAS.keys()) diff --git a/chat/sippy_agent/prompts.py b/chat/sippy_agent/prompts.py deleted file mode 100644 index 4d18359e46..0000000000 --- a/chat/sippy_agent/prompts.py +++ /dev/null @@ -1,216 +0,0 @@ -""" -Prompt template management for Sippy Chat. - -This module handles loading and rendering prompt templates from YAML files. -Prompts can be used via REST API or integrated with other systems like MCP. -""" - -import logging -from pathlib import Path -from typing import Dict, Any, List, Optional, TypedDict -import yaml -from jinja2 import Environment, BaseLoader, TemplateError -from jinja2.sandbox import SandboxedEnvironment - -logger = logging.getLogger(__name__) - - -class PromptArgument(TypedDict, total=False): - """Type definition for a prompt argument.""" - name: str - description: str - required: bool - type: str - default: Any - autocomplete: str - - -class PromptData(TypedDict, total=False): - """Type definition for a prompt definition.""" - name: str - description: str - prompt: str - arguments: List[PromptArgument] - hide: bool - - -def load_prompts_from_directory(prompts_dir: Path) -> Dict[str, PromptData]: - """ - Load all prompt definitions from YAML files in the prompts directory. - Supports hierarchical organization with subdirectories. - - Args: - prompts_dir: Path to the directory containing prompt YAML files - - Returns: - Dictionary mapping prompt names to their definitions - """ - prompts = {} - - if not prompts_dir.exists(): - logger.warning(f"Prompts directory not found: {prompts_dir}") - return prompts - - # Recursively find all YAML files in subdirectories - for yaml_file in prompts_dir.rglob("*.yaml"): - # Skip example files - if yaml_file.name.endswith('.example'): - continue - - try: - with open(yaml_file, "r") as f: - prompt_data = yaml.safe_load(f) - - if not prompt_data or "name" not in prompt_data: - logger.warning(f"Invalid prompt file {yaml_file}: missing 'name' field") - continue - - prompt_name = prompt_data["name"] - prompts[prompt_name] = prompt_data - - # Get relative path for better logging - rel_path = yaml_file.relative_to(prompts_dir) - logger.info(f"Loaded prompt: {prompt_name} from {rel_path}") - - except (FileNotFoundError, PermissionError) as e: - logger.error(f"Cannot access {yaml_file}: {e}") - except yaml.YAMLError as e: - logger.error(f"YAML syntax error in {yaml_file}: {e}") - except Exception as e: - logger.error(f"Unexpected error loading {yaml_file}: {e}", exc_info=True) - - return prompts - - -def render_prompt(prompt_data: Dict[str, Any], arguments: Dict[str, Any]) -> str: - """ - Render a prompt template with the provided arguments using Jinja2. - - Arguments are merged with their defaults from the prompt definition, - with provided values taking precedence. - - Args: - prompt_data: The prompt definition from YAML - arguments: Dictionary of argument values to substitute - - Returns: - Rendered prompt text - """ - # Get prompt content - content = prompt_data.get("prompt", "") - - if not content: - logger.warning("No prompt content found in prompt data") - return "" - - # Build a map of argument defaults from the arguments section - arg_defaults = {} - for arg_def in prompt_data.get("arguments", []): - if "default" in arg_def: - arg_defaults[arg_def["name"]] = arg_def["default"] - - # Merge provided arguments with defaults - # Provided arguments take precedence over defaults - template_vars = {} - for arg_def in prompt_data.get("arguments", []): - arg_name = arg_def["name"] - if arg_name in arguments and arguments[arg_name] is not None: - # Use provided value - template_vars[arg_name] = arguments[arg_name] - elif arg_name in arg_defaults: - # Use default value - template_vars[arg_name] = arg_defaults[arg_name] - # If neither provided nor has default, variable won't be in template_vars - - # Render using Jinja2 with security measures: - # - SandboxedEnvironment prevents arbitrary code execution in templates - # - Restricts access to Python internals and dangerous operations - # - autoescape is disabled because prompts are for LLMs, not HTML rendering - # (URLs and other content should not be HTML-escaped in markdown/text prompts) - try: - env = SandboxedEnvironment( - loader=BaseLoader(), - autoescape=False - ) - template = env.from_string(content) - rendered = template.render(**template_vars) - return rendered - except TemplateError as e: - logger.error(f"Jinja2 template error: {e}") - raise ValueError(f"Failed to render prompt template: {e}") - - -def get_default_prompts_dir() -> Path: - """Get the default prompts directory path.""" - chat_dir = Path(__file__).parent.parent - return chat_dir / "prompts" - - -class PromptManager: - """Manages prompt templates for Sippy Chat.""" - - def __init__(self, prompts_dir: Optional[Path] = None): - """ - Initialize the prompt manager. - - Args: - prompts_dir: Optional path to prompts directory. - If None, uses default location. - """ - if prompts_dir is None: - prompts_dir = get_default_prompts_dir() - - self.prompts_dir = prompts_dir - self.prompts = load_prompts_from_directory(prompts_dir) - logger.info(f"Loaded {len(self.prompts)} prompts from {prompts_dir}") - - def list_prompts(self) -> List[Dict[str, Any]]: - """ - Get a list of all available prompts. - - Returns: - List of prompt metadata (name, description, arguments, hide) - """ - return [ - { - "name": name, - "description": data.get("description", ""), - "arguments": data.get("arguments", []), - "hide": data.get("hide", False), - } - for name, data in self.prompts.items() - ] - - def get_prompt(self, name: str) -> Optional[Dict[str, Any]]: - """ - Get a prompt definition by name. - - Args: - name: The prompt name - - Returns: - The prompt definition or None if not found - """ - return self.prompts.get(name) - - def render(self, name: str, arguments: Dict[str, Any]) -> Optional[str]: - """ - Render a prompt with the given arguments. - - Args: - name: The prompt name - arguments: Dictionary of argument values - - Returns: - Rendered prompt text or None if prompt not found - """ - prompt_data = self.get_prompt(name) - if not prompt_data: - return None - - return render_prompt(prompt_data, arguments) - - def reload(self): - """Reload all prompts from disk.""" - self.prompts = load_prompts_from_directory(self.prompts_dir) - logger.info(f"Reloaded {len(self.prompts)} prompts from {self.prompts_dir}") diff --git a/chat/sippy_agent/tools/__init__.py b/chat/sippy_agent/tools/__init__.py deleted file mode 100644 index 0713099b0e..0000000000 --- a/chat/sippy_agent/tools/__init__.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -Tools package for Sippy Agent. -""" - -from .aggregated_job_analyzer import AggregatedJobAnalyzerTool -from .aggregated_yaml_parser import AggregatedYAMLParserTool -from .base_tool import SippyBaseTool, SippyToolInput -from .database_query import SippyDatabaseQueryTool -from .jira_incidents import SippyJiraIncidentTool -from .junit_parser import JUnitParserTool -from .mcp_tool_loader import load_tools_from_mcp -from .payload_details import SippyPayloadDetailsTool -from .release_payloads import SippyReleasePayloadTool -from .sippy_job_summary import SippyProwJobSummaryTool -from .sippy_job_payload import SippyProwJobPayloadTool -from .sippy_log_analyzer import SippyLogAnalyzerTool -from .sippy_test_details import SippyTestDetailsTool -from .jira_issue import SippyJiraIssueTool -from .triage_potential_matches import TriagePotentialMatchesTool - -__all__ = [ - "AggregatedJobAnalyzerTool", - "AggregatedYAMLParserTool", - "SippyBaseTool", - "SippyToolInput", - "SippyDatabaseQueryTool", - "SippyJiraIncidentTool", - "JUnitParserTool", - "load_tools_from_mcp", - "SippyPayloadDetailsTool", - "SippyReleasePayloadTool", - "SippyProwJobSummaryTool", - "SippyProwJobPayloadTool", - "SippyLogAnalyzerTool", - "SippyTestDetailsTool", - "SippyJiraIssueTool", - "TriagePotentialMatchesTool", -] diff --git a/chat/sippy_agent/tools/aggregated_job_analyzer.py b/chat/sippy_agent/tools/aggregated_job_analyzer.py deleted file mode 100644 index 94b1a997cc..0000000000 --- a/chat/sippy_agent/tools/aggregated_job_analyzer.py +++ /dev/null @@ -1,111 +0,0 @@ -""" -Tool for analyzing aggregated prow jobs. -""" - -import json -import logging -from typing import Any, Dict, Optional, Type -from pydantic import Field -import httpx - -from .base_tool import SippyBaseTool, SippyToolInput - -logger = logging.getLogger(__name__) - - -class AggregatedJobAnalyzerTool(SippyBaseTool): - """Tool for getting aggregated test results URLs (YAML format) from aggregated prow jobs.""" - - name: str = "get_aggregated_results_url" - description: str = "Get a JSON object with the direct URL to aggregated test results (in JUnit XML format) for detailed analysis of aggregated Prow jobs. Only use this when specifically asked for detailed aggregated job analysis. Input: numeric job ID only." - - # Add sippy_api_url as a proper field - sippy_api_url: Optional[str] = Field(default=None, description="Sippy API base URL") - - class AggregatedJobInput(SippyToolInput): - prow_job_run_id: str = Field(description="Numeric prow job run ID only (e.g., 1934795512955801600)") - sippy_api_url: Optional[str] = Field(default=None, description="Sippy API base URL (optional, uses config if not provided)") - - args_schema: Type[SippyToolInput] = AggregatedJobInput - - def _run(self, *args, **kwargs: Any) -> Dict[str, Any]: - """Get the direct URL to the aggregated test results (YAML format) for an aggregated job.""" - - input_data = {} - if args and isinstance(args[0], dict): - input_data.update(args[0]) - input_data.update(kwargs) - - try: - params = self.AggregatedJobInput(**input_data) - except Exception as e: - return {"error": f"Invalid input parameters: {e}"} - - # Use provided URL or fall back to instance URL - api_url = params.sippy_api_url or self.sippy_api_url - - if not api_url: - return { - "error": "No Sippy API URL configured. Please set SIPPY_API_URL environment variable or provide sippy_api_url parameter." - } - - # Clean and validate the job ID - clean_job_id = str(params.prow_job_run_id).strip() - import re - - job_id_match = re.search(r"\b(\d{10,})\b", clean_job_id) - if job_id_match: - clean_job_id = job_id_match.group(1) - elif not clean_job_id.isdigit(): - return {"error": f"Invalid job ID format. Expected numeric ID, got: {params.prow_job_run_id}"} - - # Construct the API endpoint for aggregated JUnit artifacts - endpoint = f"{api_url.rstrip('/')}/api/jobs/artifacts" - - try: - # Make the API request specifically for junit-aggregated.xml - params = {"prowJobRuns": clean_job_id, "pathGlob": "artifacts/**/junit-aggregated.xml"} - - logger.info(f"Fetching aggregated JUnit URL from {endpoint} with params: {params}") - - with httpx.Client(timeout=30.0) as client: - response = client.get(endpoint, params=params) - response.raise_for_status() - - data = response.json() - - # Extract the artifact URL from the response - if isinstance(data, dict) and "job_runs" in data: - job_runs = data.get("job_runs", []) - if job_runs: - artifacts = job_runs[0].get("artifacts", []) - if artifacts: - artifact_url = artifacts[0].get("artifact_url", "") - if artifact_url: - return { - "aggregated_junit_xml_url": artifact_url, - "next_step": "Use the parse_junit_xml tool with the returned URL to analyze the aggregated test results.", - } - else: - return {"error": "No artifact URL found in the response."} - else: - return { - "error": "No junit-aggregated.xml artifacts found for this job. This may not be an aggregated job or the artifacts may not be available yet." - } - else: - return {"error": "No job runs found in the response."} - else: - return {"error": "Unexpected response format from Sippy API."} - - except httpx.HTTPStatusError as e: - logger.error(f"HTTP error fetching aggregated JUnit URL: {e}") - return {"error": f"HTTP {e.response.status_code} - {e.response.text}"} - except httpx.RequestError as e: - logger.error(f"Request error fetching aggregated JUnit URL: {e}") - return {"error": f"Failed to connect to Sippy API at {api_url} - {str(e)}"} - except json.JSONDecodeError as e: - logger.error(f"JSON decode error: {e}") - return {"error": "Invalid JSON response from Sippy API"} - except Exception as e: - logger.error(f"Unexpected error fetching aggregated JUnit URL: {e}") - return {"error": f"Unexpected error - {str(e)}"} diff --git a/chat/sippy_agent/tools/aggregated_yaml_parser.py b/chat/sippy_agent/tools/aggregated_yaml_parser.py deleted file mode 100644 index 793a1c49d8..0000000000 --- a/chat/sippy_agent/tools/aggregated_yaml_parser.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -Tool for parsing aggregated test results from YAML URLs. -""" - -import yaml -import logging -from typing import Any, Dict, Type -from pydantic import Field -import httpx - -from .base_tool import SippyBaseTool, SippyToolInput - -logger = logging.getLogger(__name__) - - -class AggregatedYAMLParserTool(SippyBaseTool): - """Tool for parsing aggregated test results from YAML URLs.""" - - name: str = "parse_aggregated_yaml" - description: str = "Parse aggregated test results from a YAML URL to analyze job runs and failure patterns" - - class AggregatedYAMLInput(SippyToolInput): - yaml_url: str = Field(description="URL to the aggregated YAML file (e.g., from job artifacts)") - - args_schema: Type[SippyToolInput] = AggregatedYAMLInput - - def _run(self, yaml_url: str) -> Dict[str, Any]: - """Parse aggregated YAML and return structured data.""" - if not yaml_url or not yaml_url.startswith(("http://", "https://")): - return {"error": "Invalid URL provided. Please provide a valid HTTP/HTTPS URL to a YAML file."} - - try: - # Fetch the YAML content - with httpx.Client(timeout=30.0) as client: - response = client.get(yaml_url) - response.raise_for_status() - - # Parse YAML content - try: - data = yaml.safe_load(response.text) - except yaml.YAMLError as e: - logger.error(f"YAML parsing error: {e}") - return {"error": f"Invalid YAML format - {str(e)}"} - - # Validate that we got a dictionary - if not isinstance(data, dict): - return {"error": "Expected YAML data to be a dictionary"} - - # Return the raw data for LLM processing - return data - - except httpx.HTTPStatusError as e: - logger.error(f"HTTP error fetching YAML: {e}") - if e.response.status_code == 404: - return {"error": f"YAML file not found at {yaml_url}. The URL may be incorrect or the file may have been moved."} - else: - return {"error": f"HTTP {e.response.status_code} - {e.response.text}"} - except httpx.RequestError as e: - logger.error(f"Request error fetching YAML: {e}") - return {"error": f"Failed to connect to {yaml_url} - {str(e)}"} - except Exception as e: - logger.error(f"Unexpected error parsing aggregated YAML: {e}") - return {"error": f"Unexpected error - {str(e)}"} diff --git a/chat/sippy_agent/tools/base_tool.py b/chat/sippy_agent/tools/base_tool.py deleted file mode 100644 index 90ea032ecc..0000000000 --- a/chat/sippy_agent/tools/base_tool.py +++ /dev/null @@ -1,98 +0,0 @@ -""" -Base classes and interfaces for Sippy Agent tools. -""" - -import json -import logging -from abc import ABC, abstractmethod -from typing import Any, Optional, Type, ClassVar -from pydantic import BaseModel, Field -from langchain.tools import BaseTool - -logger = logging.getLogger(__name__) - - -class SippyToolInput(BaseModel): - """Base input schema for Sippy tools.""" - - pass - - -class SippyBaseTool(BaseTool, ABC): - """Base class for all Sippy Agent tools.""" - - name: str = Field(..., description="Name of the tool") - description: str = Field(..., description="Description of what the tool does") - args_schema: Type[BaseModel] = SippyToolInput - - # Maximum output size in bytes (150KB) - MAX_OUTPUT_SIZE: ClassVar[int] = 150 * 1024 - - def __init__(self, **kwargs): - super().__init__(**kwargs) - - def _truncate_output_if_needed(self, output: str) -> str: - """Truncate output if it exceeds the maximum size limit.""" - if not isinstance(output, str): - output = str(output) - - # Check if output exceeds the limit - output_bytes = output.encode("utf-8") - if len(output_bytes) <= self.MAX_OUTPUT_SIZE: - return output - - # Calculate how much we can keep (leave room for truncation message) - truncation_message = "\n\n⚠️ **Tool output truncated** - Tool produced too much data (>150KB). Use more specific queries or filters to get focused results." - truncation_bytes = truncation_message.encode("utf-8") - available_bytes = self.MAX_OUTPUT_SIZE - len(truncation_bytes) - - # Truncate at character boundary to avoid encoding issues - truncated_output = output.encode("utf-8")[:available_bytes].decode("utf-8", errors="ignore") - - # Try to truncate at a reasonable boundary (end of line) - last_newline = truncated_output.rfind("\n") - if last_newline > available_bytes * 0.8: # If we can keep 80% of content - truncated_output = truncated_output[:last_newline] - - result = truncated_output + truncation_message - - # Log the truncation - original_size = len(output_bytes) - final_size = len(result.encode("utf-8")) - logger.warning(f"Tool {self.name} output truncated: {original_size:,} bytes -> {final_size:,} bytes") - - return result - - def run(self, *args, **kwargs) -> str: - """Override run to add output size limiting.""" - try: - # Filter out LangChain-specific kwargs that tools don't need - langchain_params = { - "verbose", - "callbacks", - "tags", - "metadata", - "run_name", - "color", - "llm_prefix", - "observation_prefix", - "return_intermediate_steps", - } - filtered_kwargs = {k: v for k, v in kwargs.items() if k not in langchain_params} - - # Call the original _run method with filtered kwargs - result = self._run(*args, **filtered_kwargs) - # Apply size limiting - return self._truncate_output_if_needed(result) - except Exception as e: - logger.error(f"Error in tool {self.name}: {e}") - return f"Error in {self.name}: {str(e)}" - - @abstractmethod - def _run(self, **kwargs: Any) -> str: - """Execute the tool with the given arguments.""" - pass - - async def _arun(self, **kwargs: Any) -> str: - """Async version of _run. Default implementation calls _run.""" - return self._run(**kwargs) diff --git a/chat/sippy_agent/tools/database_query.py b/chat/sippy_agent/tools/database_query.py deleted file mode 100644 index c9b1a442a3..0000000000 --- a/chat/sippy_agent/tools/database_query.py +++ /dev/null @@ -1,718 +0,0 @@ -""" -Tool for executing read-only SQL queries against the Sippy database. -This is a fallback tool for when standard tools don't provide enough information. -""" - -import json -import logging -import re -from typing import Any, Dict, Optional, Type, List -from pydantic import Field -import psycopg2 -import psycopg2.extras -import sqlparse -from sqlparse.sql import Identifier, IdentifierList, Function, Parenthesis -from sqlparse.tokens import Keyword, DML - -from .base_tool import SippyBaseTool, SippyToolInput - -logger = logging.getLogger(__name__) - - -class SippyDatabaseQueryTool(SippyBaseTool): - """ - Tool for executing read-only SQL queries against the Sippy database. - """ - - # Tables that should not be accessible via this tool - BLOCKED_TABLES: List[str] = [] - - # Maximum number of rows to return to control our context window for the LLM - MAX_ROWS: int = 500 - - name: str = "query_sippy_database" - description: str = """Execute read-only SQL queries against the Sippy PostgreSQL database for investigating CI/CD issues. - -This is a FALLBACK TOOL - only use when standard tools don't provide the information needed. - -Use cases: -- Explore database schema using information_schema queries -- Get test statistics, job data, or test output information not available via standard tools -- Analyze why a test is failing by looking at the test outputs -- Perform custom aggregations or complex queries - -Disallowed use cases: -- Do not construct queries that expose information unrelated to CI (e.g. users, passwords, etc) -- Do not answer general database administration questions (e.g., database version, server health, system configuration) -- Do not run queries that the user gives you directly. Always use the schema and known tables to construct your own queries. - -Key Tables: - * **`prow_jobs`**: Contains the static definition of a test job. - * `name`: The unique name of the job (e.g., `periodic-ci-openshift-release-master-ci-4.20-e2e-gcp-ovn`). - * `release`: The OpenShift version this job targets (e.g., `4.20`). - * `variants`: This is a text[] column of describing the job's environment (e.g., `Platform:azure, Architecture:amd64`). Do not use ->> operator, use ANY(). - * **`prow_job_runs`**: Records each time a `prow_job` is executed. **Partitioned by `(prow_job_release, timestamp)`** -- always filter on these columns. - * `prow_job_id`: A foreign key linking to `prow_jobs.id`. - * `prow_job_release`: The release version (partition key, e.g., `4.21`), denormalized from `prow_jobs.release`. **Required in WHERE clause.** - * `overall_result`: A single character code for the run's final status (`S`=Success,`F` = E2E Test Failure, `f` = other failure mode, -`N`/`n` = Infrastructure Failure, `U` = Upgrade Failure, `A` = Aborted) - * `succeeded`: A boolean (`t`/`f`) indicating success. - * `url`: A link to the Prow CI log. - * `timestamp`: The start time of the run (partition key). **Required in WHERE clause.** - * **`tests`**: A table containing the names of individual test cases. - * `name`: The full name of the test (e.g., `[sig-storage] In-tree Volumes [Driver: nfs] [Testpattern: Dynamic PV]`). - * **`prow_job_run_tests`**: A join table that records the result of a specific `test` in a specific `prow_job_run`. **Partitioned by `(prow_job_run_release, prow_job_run_timestamp)`** -- always filter on these columns. - * `prow_job_run_id`: Links to `prow_job_runs.id`. - * `prow_job_id`: Links to `prow_jobs.id`. Use to join for variant info or to correlate with `test_daily_totals`. - * `test_id`: Links to `tests.id`. - * `suite_id`: Links to `suites.id`. - * `status`: The result of the test (`1`=Success, `12`=Failure, `13`=Flake). - * `prow_job_run_release`: The release version (partition key, e.g., `4.21`). **Required in WHERE clause.** - * `prow_job_run_timestamp`: The job run timestamp (partition key). **Required in WHERE clause.** - * `duration`: Test duration in seconds. - * **`prow_job_run_test_outputs`**: Stores the failure output text for test executions. **Partitioned by `(prow_job_run_test_release, prow_job_run_test_timestamp)`** -- always filter on these columns. - * `prow_job_run_test_id`: Links to `prow_job_run_tests.id`. - * `output`: The test failure output text. - * `prow_job_run_test_release`: The release version (partition key). **Required in WHERE clause.** - * `prow_job_run_test_timestamp`: The job run timestamp (partition key). **Required in WHERE clause.** - * **`test_daily_totals`**: Pre-aggregated daily test pass/fail/flake counts. Use this instead of scanning `prow_job_run_tests` with COUNT/GROUP BY. **Partitioned by `(release, date)`**. - * `test_id`: Links to `tests.id`. - * `prow_job_id`: Links to `prow_jobs.id`. - * `suite_id`: Links to `suites.id`. - * `lifecycle`: Whether this test is `blocking` or `informing` for this execution. - * `release`: OpenShift release version (partition key). **Required in WHERE clause.** - * `date`: The date of the aggregation (partition key). **Required in WHERE clause.** - * `successes`, `failures`, `flakes`, `runs`: Pre-computed daily counts. - * **`test_cumulative_summaries`**: Running prefix sums of `test_daily_totals`. For any date range [start, end], compute: `prefix_sum(end) - prefix_sum(start - 1)`. **Partitioned by `(release, date)`**. - * `date`, `release`, `test_id`, `prow_job_id`, `suite_id`, `lifecycle`: Same as `test_daily_totals`. - * `prefix_sum_successes`, `prefix_sum_failures`, `prefix_sum_flakes`, `prefix_sum_runs`: Cumulative totals up to that date. - * **`suites`**: Defines a collection or group of tests. - * `name`: The name of the test suite (e.g., openshift-tests). - * **`release_tags`**: Contains information about specific OpenShift release payloads. - * `release_tag`: The payload version (e.g., `4.14.0-0.nightly-multi-2023-07-23-183157`). - * `phase`: The status of the release (`Accepted`, `Rejected`). - -There are variants available for job classification, if a user asks you about specific kinds of jobs, you should -look at the list of available variants and filter on the ones most relevant to the question. - -CRITICAL: You can get a list of available variants with `SELECT DISTINCT unnest(variants) AS variant FROM prow_jobs;`. DO NOT -guess at the variants, YOU MUST always use one of the options from the list verbatim when filtering. - -If a user asks about single node jobs, use "Topology:single" variant. If a user asks about GCP jobs, use "Platform:gcp" variants. - -**Pre-aggregated Tables (HIGHLY PREFERRED for analysis):** - -For performance, **always prefer pre-aggregated summary tables** over scanning raw tables. These pre-calculate results. - - * **`test_daily_totals`** (PREFERRED for test statistics): Pre-aggregated daily test pass/fail/flake counts per (test, job, suite, lifecycle, release, date). Use `SUM(successes)`, `SUM(failures)`, `SUM(flakes)`, `SUM(runs)` for aggregation. Always filter on `release` and `date`. - * **IMPORTANT**: Each test has multiple rows per (prow_job_id, suite_id, lifecycle). When providing an overall overview (e.g., "top failing tests"), aggregate by test name: `GROUP BY t.name` with `SUM(failures)` across all jobs. Only group by `prow_jobs.variants` or `prow_job_id` when the user asks for a per-variant or per-job breakdown. - - * **`test_cumulative_summaries`** (PREFERRED for date-range test statistics): Running prefix sums of `test_daily_totals`. To compute totals for any date range [start, end], query two dates and subtract: `prefix_sum(end) - prefix_sum(start - 1)`. Always filter on `release` and `date`. - * **IMPORTANT**: Same multi-row structure as `test_daily_totals` — see aggregation guidance above. - - * **Job pass/fail rates**: Query `prow_job_runs JOIN prow_jobs` directly. `prow_job_runs` is partitioned by `(prow_job_release, timestamp)` -- always filter on both as literal values. - * `prow_job_runs.timestamp`: `timestamptz`. Compare directly with `NOW() - INTERVAL '...'` or an ISO timestamp string -- do NOT convert to epoch millis. - * `prow_job_runs.cluster`: Build cluster name (e.g., `build01`). - * `prow_job_runs.overall_result`: A single character code for the run's final status (`S`=Success, `F` = E2E Test Failure, `f` = other failure mode, `N`/`n` = Infrastructure Failure, `U` = Upgrade Failure, `A` = Aborted). - * `prow_job_runs.succeeded`: Boolean indicating if the job succeeded (t/f). - * `prow_jobs.release`, `prow_jobs.variants`, `prow_jobs.name`: Job metadata, joined via `prow_job_runs.prow_job_id = prow_jobs.id`. - * For failed/flaked test names on a run, join `prow_job_run_tests` + `tests` and `array_agg(t.name) FILTER (WHERE pjrt.status = ...)`. - * For PR info, join `prow_job_run_prow_pull_requests` to `prow_pull_requests`. - -### Query Guidelines (MANDATORY) - -1. **Always use `LIMIT`**: The database tool has timeout. Always end your query with `LIMIT 10;` or a similar small number to prevent timeouts. -2. **Filter by Time**: Whenever possible, use a `WHERE` clause to filter by a time range (e.g., `date >= CURRENT_DATE - 7`). -3. **Prefer Pre-aggregated Tables**: For test pass/fail/flake rates or counts, use `test_daily_totals` or `test_cumulative_summaries`. For job pass/fail rates, query `prow_job_runs JOIN prow_jobs` directly. Only query raw `prow_job_run_tests` when you need individual test execution details (e.g., failure output, specific job run results). -4. **Read-Only**: You only have `SELECT` permissions. Do not attempt to write data. -5. **Partition Pruning**: `prow_job_runs`, `prow_job_run_tests`, and `prow_job_run_test_outputs` are partitioned by release and timestamp. `test_daily_totals` and `test_cumulative_summaries` are partitioned by release and date. **Always** include WHERE filters on the partition key columns (release and timestamp/date) when querying these tables. These partition keys must be **literal values** in the WHERE clause, not subqueries or join conditions, because the query planner needs them at plan time to prune partitions. If partition keys are not known, first look up the specific run in `prow_job_runs` by `id` (or `prow_jobs` by `name`/`id` for `release` alone), then use those values as literals in a second query against the partitioned table. See examples 3 and 7. - -### Example Queries - -Here are examples demonstrating how to query this database. - -**1. Find the Most Recent Prow Jobs for a Specific Release** - -```sql --- Get the 10 most recently created Prow jobs for release '4.20' -SELECT - name, - release, - variants -FROM - prow_jobs -WHERE - release = '4.20' -ORDER BY - created_at DESC -LIMIT 10; -``` - -**2. Get the Status of the Last 5 Runs for a Specific Job (Two-Step)** - -Step 1: Look up the job's release (partition key) by name. -```sql -SELECT release FROM prow_jobs WHERE name = 'periodic-ci-openshift-release-master-ci-4.20-e2e-gcp-ovn'; -``` - -Step 2: Use that literal release value to query the partitioned table. -```sql --- Find the last 5 runs for the 'periodic-ci-openshift-release-master-ci-4.20-e2e-gcp-ovn' job --- No timestamp literal here since we don't know how far back the last 5 runs are (that's the --- point of the query) -- Postgres uses the timestamp index within the release partition for an --- efficient backward-ordered scan; it just can't prune by timestamp. -SELECT - pj.name, - pjr.url, - pjr.succeeded, - pjr.overall_result, - pjr.timestamp -FROM - prow_job_runs pjr -JOIN - prow_jobs pj ON pjr.prow_job_id = pj.id -WHERE - pj.name = 'periodic-ci-openshift-release-master-ci-4.20-e2e-gcp-ovn' - AND pjr.prow_job_release = '4.20' -- Partition key: from step 1 -ORDER BY - pjr.timestamp DESC -LIMIT 5; -``` - -**3. List Failed Test Cases from a Specific Failed Job Run (Two-Step)** - -Step 1: Get the run's release and timestamp for partition pruning. -```sql -SELECT prow_job_release, timestamp FROM prow_job_runs WHERE id = 1967736172570480640; -``` - -Step 2: Use those literal values to query the partitioned table. -```sql --- For a given Prow job run ID, list all failed tests (status=12) --- Partition keys must be literals for the planner to prune efficiently -SELECT - t.name AS test_name, - pjrt.duration AS test_duration_seconds -FROM - prow_job_run_tests pjrt -JOIN - tests t ON pjrt.test_id = t.id -WHERE - pjrt.prow_job_run_id = 1967736172570480640 -- Prow job run ID - AND pjrt.prow_job_run_release = '4.20' -- Partition key: from step 1 - AND pjrt.prow_job_run_timestamp = '2026-07-15T10:30:00Z'::timestamptz -- Partition key: from step 1 - AND pjrt.status = 12 -- Status for 'Failure' -LIMIT 20; -``` - -**4. Find Recent Infrastructure Failures** - -```sql --- Show the 10 most recent jobs that failed due to infrastructure issues ('N') in the last 3 days --- No release filter here since this spans all releases -- Postgres scans every partition --- (still using each partition's timestamp index), it just can't prune. If the user names a --- release, add "AND pjr.prow_job_release = '...'" as a literal to prune to one partition. -SELECT - pjr.id, - pj.name, - pjr.url, - pjr.timestamp -FROM - prow_job_runs pjr -JOIN - prow_jobs pj ON pjr.prow_job_id = pj.id -WHERE - pjr.overall_result IN ('N', 'n') - AND pjr.timestamp > NOW() - INTERVAL '3 days' -ORDER BY - pjr.timestamp DESC -LIMIT 10; -``` - -**5. Get Job Run Pass Rate by Platform (Variant)** - -```sql --- Calculate job pass rates for release '4.19' grouped by platform over the last 7 days --- Note: variants is text[], not JSONB. Use unnest() to extract and filter variants. --- Join prow_job_runs directly to prow_jobs. --- Filter on prow_job_runs.prow_job_release (not prow_jobs.release), a literal, to prune partitions. -SELECT - v.variant AS platform, - COUNT(*) AS total_runs, - COUNT(*) FILTER (WHERE pjr.succeeded = true) AS successful_runs, - ROUND(100.0 * COUNT(*) FILTER (WHERE pjr.succeeded = true) / COUNT(*), 2) AS pass_percentage -FROM - prow_job_runs pjr -JOIN - prow_jobs pj ON pjr.prow_job_id = pj.id, - LATERAL unnest(pj.variants) AS v(variant) -WHERE - pj.release = '4.19' - AND pjr.prow_job_release = '4.19' - AND pjr.timestamp > NOW() - INTERVAL '7 days' - AND v.variant LIKE 'Platform:%' -GROUP BY - v.variant -ORDER BY - total_runs DESC -LIMIT 10; -``` - -**6. Identify Top 10 Flakiest Tests in the Last 2 Days** - -```sql --- Find the top 10 tests with the highest flake count in the last 2 days --- Uses test_daily_totals instead of scanning raw prow_job_run_tests -SELECT - t.name AS test_name, - SUM(tdt.flakes) AS flake_count, - ROUND(100.0 * SUM(tdt.successes) / NULLIF(SUM(tdt.runs), 0), 2) AS pass_percentage -FROM - test_daily_totals tdt -JOIN - tests t ON t.id = tdt.test_id -WHERE - tdt.date BETWEEN CURRENT_DATE - 2 AND CURRENT_DATE - 1 - AND tdt.release = '4.21' -- Partition key: required -GROUP BY - t.name -ORDER BY - flake_count DESC -LIMIT 10; -``` - -**7. Find Failure Output for a Specific Failed Test (Two-Step)** - -Step 1: Get the run's release and timestamp (same as example 3, step 1). -```sql -SELECT prow_job_release, timestamp FROM prow_job_runs WHERE id = 1967736172570480640; -``` - -Step 2: Use those literal values to query both partitioned tables. -```sql --- Get the failure output for a specific test in a specific job run --- Both tables are partitioned: partition keys must be literals -SELECT - pjrt_out.output -FROM - prow_job_run_test_outputs pjrt_out -JOIN - prow_job_run_tests pjrt ON pjrt_out.prow_job_run_test_id = pjrt.id -JOIN - tests t ON pjrt.test_id = t.id -WHERE - pjrt.prow_job_run_id = 1967736172570480640 -- Prow job run ID - AND pjrt.prow_job_run_release = '4.20' -- Partition key: from step 1 - AND pjrt.prow_job_run_timestamp = '2026-07-15T10:30:00Z'::timestamptz -- Partition key: from step 1 - AND pjrt_out.prow_job_run_test_release = '4.20' -- Partition key: same release - AND pjrt_out.prow_job_run_test_timestamp = '2026-07-15T10:30:00Z'::timestamptz -- Partition key: same timestamp - AND t.name = '[sig-api-machinery] CustomResourceDefinition resources [Privileged:ClusterAdmin] should be able to list CRDs' -LIMIT 10; -``` - -**8. Show Daily Test Failure Trend for a Specific Job** - -```sql --- Show daily failure counts for a specific test in a specific Prow job over the last 7 days --- Uses test_daily_totals for pre-aggregated data -SELECT - tdt.date, - t.name AS test_name, - SUM(tdt.failures) AS failures, - SUM(tdt.runs) AS runs, - ROUND(100.0 * SUM(tdt.failures) / NULLIF(SUM(tdt.runs), 0), 2) AS failure_rate -FROM - test_daily_totals tdt -JOIN - tests t ON t.id = tdt.test_id -WHERE - tdt.prow_job_id = 6046 -- Example Prow job ID - AND t.name = 'Job run should complete before timeout' - AND tdt.date BETWEEN CURRENT_DATE - 7 AND CURRENT_DATE - 1 - AND tdt.release = '4.21' -- Partition key: required -GROUP BY - tdt.date, t.name -ORDER BY - tdt.date DESC -LIMIT 10; -``` - -**9. Get Test Pass Rates Over an Arbitrary Date Range (Prefix-Sum Pattern)** - -```sql --- Compute test pass rates for release '4.21' over the last 7 complete days --- Uses test_cumulative_summaries with prefix-sum subtraction: range [start, end] = prefix_sum(end) - prefix_sum(start - 1) --- LEFT JOIN handles tests that started after the range start (no start-of-range row) -SELECT - t.name AS test_name, - SUM(e.prefix_sum_runs - COALESCE(s.prefix_sum_runs, 0)) AS runs, - SUM(e.prefix_sum_successes - COALESCE(s.prefix_sum_successes, 0)) AS successes, - ROUND( - 100.0 * SUM(e.prefix_sum_successes - COALESCE(s.prefix_sum_successes, 0)) - / NULLIF(SUM(e.prefix_sum_runs - COALESCE(s.prefix_sum_runs, 0)), 0), - 2 - ) AS pass_percentage -FROM - test_cumulative_summaries e -JOIN - tests t ON t.id = e.test_id -LEFT JOIN - test_cumulative_summaries s - ON s.test_id = e.test_id - AND s.prow_job_id = e.prow_job_id - AND s.suite_id = e.suite_id - AND s.lifecycle = e.lifecycle - AND s.release = '4.21' -- Partition key: required on both sides - AND s.date = CURRENT_DATE - 8 -- day before range start (start - 1) -WHERE - e.date = CURRENT_DATE - 1 -- range end (yesterday) - AND e.release = '4.21' -- Partition key: required -GROUP BY - t.name -HAVING - SUM(e.prefix_sum_runs - COALESCE(s.prefix_sum_runs, 0)) > 0 -ORDER BY - pass_percentage ASC -LIMIT 10; -``` - -**Schema Exploration:** -```sql --- List all tables -SELECT table_name FROM information_schema.tables -WHERE table_schema = 'public' ORDER BY table_name; - --- Show columns for a table -SELECT column_name, data_type FROM information_schema.columns -WHERE table_name = 'prow_job_runs' ORDER BY ordinal_position; -``` - -IMPORTANT: Only read-only queries are allowed. -IMPORTANT: Construct your queries in the most efficient (fastest) way possible. -""" - - # Note: never expose the database DSN as a tool input. - database_dsn: Optional[str] = Field(default=None, description="PostgreSQL connection string") - - class DatabaseQueryInput(SippyToolInput): - query: str = Field(description="SQL SELECT query to execute against the Sippy database") - - args_schema: Type[SippyToolInput] = DatabaseQueryInput - - def _is_read_only_query(self, query: str) -> bool: - """ - Check if a query is read-only using AST parsing. This is a belt-and-suspenders-and-a-bit-of-paranoia approach - to ensure that the query is read-only, as we already have the session in read-only mode, and are only giving the - tool a read-only user. - - Args: - query: SQL query to check - - Returns: - True if query is read-only, False otherwise - """ - try: - parsed = sqlparse.parse(query) - - if not parsed: - logger.warning("Failed to parse SQL query") - return False - - # Check each statement in the query - for statement in parsed: - if not self._is_statement_read_only(statement): - return False - - return True - - except Exception as e: - logger.error(f"Error parsing SQL query: {e}") - # If parsing fails, reject the query for safety - return False - - def _is_statement_read_only(self, statement) -> bool: - """ - Check if a parsed SQL statement is read-only. - - Args: - statement: Parsed SQL statement from sqlparse - - Returns: - True if statement is read-only, False otherwise - """ - # Get the statement type - stmt_type = statement.get_type() - - # Allow only read-only statement types - allowed_types = ['SELECT', 'WITH', 'SHOW', 'EXPLAIN'] - if stmt_type not in allowed_types: - logger.warning(f"Disallowed statement type: {stmt_type}") - return False - - # Recursively check all tokens for dangerous DML operations - dangerous_dml = {'INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER', - 'TRUNCATE', 'GRANT', 'REVOKE', 'REPLACE', 'MERGE'} - - for token in statement.flatten(): - if token.ttype is DML: - if token.value.upper() in dangerous_dml: - logger.warning(f"Disallowed DML operation found: {token.value}") - return False - # Also check for DDL keywords - if token.ttype is Keyword.DDL: - logger.warning(f"Disallowed DDL operation found: {token.value}") - return False - - return True - - def _extract_table_names(self, query: str) -> List[str]: - """ - Extract table names from a SQL query using AST parsing. - - Args: - query: SQL query to analyze - - Returns: - List of table names found in the query - """ - try: - parsed = sqlparse.parse(query) - tables = [] - - for statement in parsed: - tables.extend(self._extract_tables_from_tokens(statement.tokens)) - - # Remove duplicates and normalize to lowercase - return list(set(t.lower() for t in tables if t)) - - except Exception as e: - logger.error(f"Error extracting table names from SQL: {e}") - return [] - - def _extract_tables_from_tokens(self, tokens) -> List[str]: - """ - Recursively extract table names from SQL tokens. - - Args: - tokens: List of SQL tokens from sqlparse - - Returns: - List of table names - """ - tables = [] - from_seen = False - join_seen = False - - for token in tokens: - # Skip whitespace and comments - if token.is_whitespace: - continue - if hasattr(token, 'ttype') and token.ttype in sqlparse.tokens.Comment: - continue - - # Check for FROM or JOIN keywords - if token.ttype is Keyword and token.value.upper() in ('FROM', 'JOIN', 'INNER JOIN', 'LEFT JOIN', 'RIGHT JOIN', 'FULL JOIN'): - from_seen = True - join_seen = True - continue - - # After FROM/JOIN, extract table names - if from_seen or join_seen: - if isinstance(token, Identifier): - table_name = self._get_real_table_name(token) - if table_name: - tables.append(table_name) - from_seen = False - join_seen = False - elif isinstance(token, IdentifierList): - for identifier in token.get_identifiers(): - table_name = self._get_real_table_name(identifier) - if table_name: - tables.append(table_name) - from_seen = False - join_seen = False - elif isinstance(token, Function): - # Table functions - extract if it's a table source - from_seen = False - join_seen = False - - # Recursively process subqueries and parenthesized expressions - if hasattr(token, 'tokens'): - tables.extend(self._extract_tables_from_tokens(token.tokens)) - - return tables - - def _get_real_table_name(self, identifier) -> Optional[str]: - """ - Extract the actual table name from an identifier. - - Args: - identifier: SQL identifier from sqlparse - - Returns: - Table name, or None if not a table - """ - if isinstance(identifier, Identifier): - # Get the real name (handles aliases) - real_name = identifier.get_real_name() - if real_name: - # Handle schema.table -> extract table - if '.' in real_name: - return real_name.split('.')[-1] - return real_name - - # Fallback to string representation - name = str(identifier).strip() - if name and not name.upper().startswith('('): - if '.' in name: - return name.split('.')[-1] - return name.split()[0] # Take first word before any alias - - return None - - def _check_blocked_tables(self, query: str) -> Optional[str]: - """ - Check if query attempts to access any blocked tables. - - Args: - query: SQL query to check - - Returns: - Error message if blocked tables are accessed, None otherwise - """ - tables = self._extract_table_names(query) - - # Check for PostgreSQL system tables (pg_*), with exceptions for certain allowed tables - allowed_pg_tables = {'pg_matviews'} - pg_tables = [t for t in tables if t.startswith('pg_') and t not in allowed_pg_tables] - if pg_tables: - return f"Access denied: Query attempts to access PostgreSQL system table(s): {', '.join(pg_tables)}" - - # Check against explicit blocklist - if self.BLOCKED_TABLES: - blocked = [t for t in tables if t in [b.lower() for b in self.BLOCKED_TABLES]] - if blocked: - return f"Access denied: Query attempts to access blocked table(s): {', '.join(blocked)}" - - return None - - def _run(self, *args, **kwargs: Any) -> str: - """Execute a read-only SQL query against the Sippy database.""" - - input_data = {} - if args and isinstance(args[0], dict): - input_data.update(args[0]) - input_data.update(kwargs) - - # Validate input - try: - query_input = self.DatabaseQueryInput(**input_data) - except Exception as e: - return json.dumps({ - "error": f"Invalid input: {str(e)}" - }, indent=2) - - # Always use instance DSN for security - dsn = self.database_dsn - - if not dsn: - return json.dumps({ - "error": "No database connection configured. Please set SIPPY_READ_ONLY_DATABASE_DSN environment variable.", - "help": "Set the environment variable to a PostgreSQL connection string like: postgresql://user:pass@host:5432/dbname" - }, indent=2) - - # Validate query is read-only - if not self._is_read_only_query(query_input.query): - return json.dumps({ - "error": "Only SELECT queries are allowed for safety.", - "help": "This tool only supports read-only operations: SELECT, WITH (for CTEs), EXPLAIN, SHOW" - }, indent=2) - - # Check for blocked tables - blocked_error = self._check_blocked_tables(query_input.query) - if blocked_error: - return json.dumps({ - "error": blocked_error, - "help": "This query attempts to access tables that are not permitted." - }, indent=2) - - # Execute the query - conn = None - cursor = None - try: - # Connect to the database - conn = psycopg2.connect(dsn, connect_timeout=10) - - # Set transaction to read-only for extra safety - conn.set_session(readonly=True, autocommit=True) - - # Set statement timeout to 120 seconds - cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) - cursor.execute("SET statement_timeout = '120s'") - - # Execute the user's query - logger.info(f"Executing database query: {query_input.query[:100]}...") - cursor.execute(query_input.query) - - # Fetch results with row limit - rows = cursor.fetchmany(self.MAX_ROWS) - - # Check if there are more rows available - has_more = cursor.fetchone() is not None - - # Convert to list of dicts for JSON serialization - results = [dict(row) for row in rows] - - # Return formatted results - response = { - "success": True, - "row_count": len(results), - "results": results - } - - # If no results, provide a helpful message - if len(results) == 0: - response["message"] = "Query executed successfully but returned no rows." - elif has_more: - response["warning"] = f"Results limited to {self.MAX_ROWS} rows. Your query returned more rows than the limit. Consider adding LIMIT or WHERE clauses to narrow your results." - response["truncated"] = True - - return json.dumps(response, indent=2, default=str) - - except psycopg2.OperationalError as e: - logger.error(f"Database connection error: {e}") - return json.dumps({ - "error": "Failed to connect to database", - "details": str(e), - "help": "Check that SIPPY_READ_ONLY_DATABASE_DSN is correct and the database is accessible." - }, indent=2) - - except psycopg2.errors.QueryCanceled as e: - logger.error(f"Query timeout: {e}") - return json.dumps({ - "error": "Query execution timeout", - "details": str(e), - "help": "Try simplifying your query or adding more specific filters (WHERE clauses, LIMIT, etc.)" - }, indent=2) - - except psycopg2.Error as e: - logger.error(f"Database error: {e}") - return json.dumps({ - "error": "Database query error", - "details": str(e), - "help": "Check your SQL syntax and table/column names. Use information_schema to explore the schema." - }, indent=2) - - except Exception as e: - logger.error(f"Unexpected error executing database query: {e}") - return json.dumps({ - "error": "Unexpected error", - "details": str(e) - }, indent=2) - - finally: - # Clean up - if cursor: - cursor.close() - if conn: - conn.close() diff --git a/chat/sippy_agent/tools/jira_incidents.py b/chat/sippy_agent/tools/jira_incidents.py deleted file mode 100644 index d4681ce09a..0000000000 --- a/chat/sippy_agent/tools/jira_incidents.py +++ /dev/null @@ -1,97 +0,0 @@ -""" -Tool for querying Jira for known open incidents in the TRT project. -""" - -import json -import logging -from typing import Any, Dict, Optional, Type -from pydantic import Field -import httpx - -from .base_tool import SippyBaseTool, SippyToolInput - -logger = logging.getLogger(__name__) - - -class SippyJiraIncidentTool(SippyBaseTool): - """Tool for querying Jira for known open incidents in the TRT project.""" - - name: str = "check_known_incidents" - description: str = "Get a JSON object with a list of all known open TRT incidents from Jira." - - # Add Jira configuration as proper fields - jira_url: str = Field(default="https://redhat.atlassian.net", description="Jira instance URL") - jira_username: Optional[str] = Field(default=None, description="Jira username") - jira_token: Optional[str] = Field(default=None, description="Jira API token") - - class JiraIncidentInput(SippyToolInput): - jira_url: Optional[str] = Field(default=None, description="Jira URL (optional, uses config if not provided)") - - args_schema: Type[SippyToolInput] = JiraIncidentInput - - def _run(self, jira_url: Optional[str] = None) -> Dict[str, Any]: - """Query Jira for known open incidents.""" - # Use provided URL or fall back to instance URL - api_url = jira_url or self.jira_url - - if not api_url: - return {"error": "No Jira URL configured. Please set JIRA_URL environment variable or provide jira_url parameter."} - - # Construct the Jira REST API endpoint - endpoint = f"{api_url.rstrip('/')}/rest/api/2/search" - - # Build JQL query for TRT project incidents - jql_parts = ['project = "TRT"', 'labels = "trt-incident"', "status not in (Closed, Done, Resolved)"] - - jql = " AND ".join(jql_parts) - - try: - # Prepare request parameters - params = { - "jql": jql, - "fields": "key,summary,status,priority,created,updated,description,labels", - "maxResults": 20, # Limit results - } - - # Prepare authentication if available - auth = None - if self.jira_username and self.jira_token: - auth = (self.jira_username, self.jira_token) - - logger.info("Querying Jira for all open TRT incidents") - logger.info(f"JQL: {jql}") - - # Make the API request - with httpx.Client(timeout=30.0) as client: - response = client.get(endpoint, params=params, auth=auth, headers={"Accept": "application/json"}) - response.raise_for_status() - - data = response.json() - - # Add the user-friendly browse URL to each issue - if "issues" in data and isinstance(data["issues"], list): - jira_base = api_url.rstrip("/") - for issue in data["issues"]: - if "key" in issue: - issue["browse_url"] = f"{jira_base}/browse/{issue['key']}" - - # Return the raw JSON data - return data - - except httpx.HTTPStatusError as e: - logger.error(f"HTTP error querying Jira: {e}") - if e.response.status_code == 401: - return {"error": "Jira authentication failed. Check JIRA_USERNAME and JIRA_TOKEN environment variables."} - elif e.response.status_code == 403: - return {"error": "Access denied to Jira. You may need authentication or permissions to view TRT project."} - else: - return {"error": f"HTTP {e.response.status_code} - {e.response.text}"} - except httpx.RequestError as e: - logger.error(f"Request error querying Jira: {e}") - return {"error": f"Failed to connect to Jira at {api_url} - {str(e)}"} - except json.JSONDecodeError as e: - logger.error(f"JSON decode error: {e}") - return {"error": "Invalid JSON response from Jira API"} - except Exception as e: - logger.error(f"Unexpected error querying Jira: {e}") - return {"error": f"Unexpected error - {str(e)}"} diff --git a/chat/sippy_agent/tools/jira_issue.py b/chat/sippy_agent/tools/jira_issue.py deleted file mode 100644 index faf2a58571..0000000000 --- a/chat/sippy_agent/tools/jira_issue.py +++ /dev/null @@ -1,150 +0,0 @@ -""" -Tool for analyzing Jira issues and their comments. -""" - -import json -import logging -from typing import Any, Dict, Type -from pydantic import Field -import httpx -from datetime import datetime, timezone - -from .base_tool import SippyBaseTool, SippyToolInput - -logger = logging.getLogger(__name__) - - -class SippyJiraIssueTool(SippyBaseTool): - """Tool for analyzing Jira issues and their comments to assess fix readiness.""" - - name: str = "get_jira_issue_analysis" - description: str = """Get Jira issue information including description, status, and recent comments. - -This tool provides: -- Issue description and current status -- Recent comments (sorted newest first) -- Basic metadata (assignee, priority, fix versions, etc.) - -Input: issue_key (the Jira issue key, e.g., OCPBUGS-12345)""" - - jira_url: str = Field(description="Jira base URL") - - class JiraIssueInput(SippyToolInput): - issue_key: str = Field(description="Jira issue key (e.g., OCPBUGS-12345)") - - args_schema: Type[SippyToolInput] = JiraIssueInput - - def _run(self, *args, **kwargs: Any) -> Dict[str, Any]: - """Get Jira issue analysis.""" - - input_data = {} - if args and isinstance(args[0], dict): - input_data.update(args[0]) - input_data.update(kwargs) - - # Pydantic model will have validated and filled in defaults - args = self.JiraIssueInput(**input_data) - - issue_key = args.issue_key.strip() - - try: - # Make API request to get issue details using configured base URL - api_url = f"{self.jira_url.rstrip('/')}/rest/api/2/issue/{issue_key}" - - logger.info(f"Making request to {api_url}") - - with httpx.Client(timeout=30.0) as client: - # Get issue details (no authentication needed for public data) - response = client.get(api_url) - response.raise_for_status() - - issue_data = response.json() - - # Get comments (no authentication needed for public data) - comments_response = client.get(f"{api_url}/comment") - comments_response.raise_for_status() - comments_data = comments_response.json() - - # Process the response - processed_data = self._process_jira_issue(issue_data, comments_data) - - return processed_data - - except httpx.HTTPStatusError as e: - logger.error(f"HTTP error getting Jira issue: {e}") - if e.response.status_code == 404: - return {"error": f"Jira issue {issue_key} not found or access denied"} - return {"error": f"HTTP {e.response.status_code} - {e.response.text}"} - except httpx.RequestError as e: - logger.error(f"Request error getting Jira issue: {e}") - return {"error": f"Failed to connect to Jira at {self.jira_url} - {str(e)}"} - except json.JSONDecodeError as e: - logger.error(f"JSON decode error: {e}") - return {"error": "Invalid JSON response from Jira API"} - except Exception as e: - logger.error(f"Unexpected error getting Jira issue: {e}") - return {"error": f"Unexpected error - {str(e)}"} - - def _process_jira_issue(self, issue_data: Dict[str, Any], comments_data: Dict[str, Any]) -> Dict[str, Any]: - """Process Jira issue data and comments to extract key information.""" - - fields = issue_data.get('fields', {}) - - # Extract basic issue information - issue_info = { - "key": issue_data.get('key'), - "summary": fields.get('summary'), - "description": fields.get('description'), - "status": fields.get('status', {}).get('name'), - "priority": fields.get('priority', {}).get('name'), - "issue_type": fields.get('issuetype', {}).get('name'), - "assignee": fields.get('assignee', {}).get('displayName') if fields.get('assignee') else None, - "reporter": fields.get('reporter', {}).get('displayName') if fields.get('reporter') else None, - "created": fields.get('created'), - "updated": fields.get('updated'), - "resolution": fields.get('resolution', {}).get('name') if fields.get('resolution') else None, - "fix_versions": [v.get('name') for v in fields.get('fixVersions', [])], - "labels": fields.get('labels', []), - "components": [c.get('name') for c in fields.get('components', [])], - } - - # Process comments - comments = comments_data.get('comments', []) - processed_comments = [] - - for comment in comments: - comment_info = { - "author": comment.get('author', {}).get('displayName'), - "body": comment.get('body'), - "created": comment.get('created'), - "updated": comment.get('updated'), - } - processed_comments.append(comment_info) - - # Sort comments by creation date (newest first) - processed_comments.sort(key=lambda x: x['created'], reverse=True) - - # Calculate days since last comment - days_since_last_comment = None - if processed_comments: - days_since_last_comment = self._get_days_old(processed_comments[0]['created']) - - return { - "issue": issue_info, - "comments": { - "total_count": len(processed_comments), - "recent_comments": processed_comments[:10], # Last 10 comments - "days_since_last_comment": days_since_last_comment, - }, - } - - def _get_days_old(self, date_string: str) -> int: - """Calculate how many days old a date string is.""" - try: - # Parse the date string (Jira uses ISO format) - date_obj = datetime.fromisoformat(date_string.replace('Z', '+00:00')) - now = datetime.now(timezone.utc) - delta = now - date_obj - return delta.days - except Exception: - return 999 # Return a large number for unparseable dates diff --git a/chat/sippy_agent/tools/junit_parser.py b/chat/sippy_agent/tools/junit_parser.py deleted file mode 100644 index 337514cb84..0000000000 --- a/chat/sippy_agent/tools/junit_parser.py +++ /dev/null @@ -1,543 +0,0 @@ -""" -Tool for parsing JUnit XML files and extracting test failures and flakes. -""" - -import logging -import defusedxml.ElementTree as ET -from xml.etree.ElementTree import Element -from typing import Any, Dict, List, Optional, Type -from pydantic import Field -import httpx -import yaml - -from .base_tool import SippyBaseTool, SippyToolInput - -logger = logging.getLogger(__name__) - - -class JUnitParserTool(SippyBaseTool): - """Tool for parsing JUnit XML files to extract test failures and flakes.""" - - name: str = "parse_junit_xml" - description: str = "Parse a JUnit XML file from a URL to get a JSON object with test failures, flakes, and aggregated results. Takes junit_xml_url as a required parameter." - - class JUnitParserInput(SippyToolInput): - junit_xml_url: str = Field(description="URL to the JUnit XML file") - - args_schema: Type[SippyToolInput] = JUnitParserInput - - def _run(self, *args, **kwargs: Any) -> Dict[str, Any]: - """Parse JUnit XML file and extract test failures and flakes.""" - - input_data = {} - if args and isinstance(args[0], dict): - input_data.update(args[0]) - input_data.update(kwargs) - - try: - params = self.JUnitParserInput(**input_data) - except Exception as e: - return {"error": f"Invalid input parameters: {e}"} - - try: - # Fetch the XML content - logger.info(f"Fetching JUnit XML from: {params.junit_xml_url}") - - with httpx.Client(timeout=60.0) as client: - response = client.get(params.junit_xml_url) - response.raise_for_status() - - xml_content = response.text - - # Parse the XML - try: - root = ET.fromstring(xml_content) - except ET.ParseError as e: - logger.error(f"XML parse error: {e}") - return {"error": f"Invalid XML format - {str(e)}"} - - # Initialize the result object - result: Dict[str, Any] = { - "source_url": params.junit_xml_url, - "summary": {}, - "results": [], - } - - # Check for aggregated results first - aggregated_results = self._extract_aggregated_yaml_from_xml(root) - if aggregated_results: - result["summary"]["type"] = "aggregated" - result["summary"]["failed_test_count"] = len(aggregated_results) - result["results"] = aggregated_results - return result - - # Extract regular test results - test_results = self._extract_test_results(root) - underlying_jobs = self._extract_underlying_job_links(xml_content) - failures_and_flakes = self._identify_failures_and_flakes(test_results) - - result["summary"]["type"] = "standard" - result["summary"]["total_tests"] = len(test_results) - result["summary"]["failure_and_flake_count"] = len(failures_and_flakes) - result["summary"]["underlying_job_count"] = len(underlying_jobs) - result["results"] = failures_and_flakes - if underlying_jobs: - result["underlying_jobs"] = underlying_jobs - - return result - - except httpx.HTTPStatusError as e: - logger.error(f"HTTP error fetching JUnit XML: {e}") - return {"error": f"HTTP {e.response.status_code} - Failed to fetch XML from {params.junit_xml_url}"} - except httpx.RequestError as e: - logger.error(f"Request error fetching JUnit XML: {e}") - return {"error": f"Failed to connect to {params.junit_xml_url} - {str(e)}"} - except Exception as e: - logger.error(f"Unexpected error parsing JUnit XML: {e}") - return {"error": f"Unexpected error - {str(e)}"} - - def _extract_test_results(self, root: Element) -> List[Dict[str, Any]]: - """Extract all test results from the JUnit XML.""" - test_results = [] - - # Handle different JUnit XML structures - # Look for testcase elements at various levels - testcases = [] - - # Direct testcase children - testcases.extend(root.findall(".//testcase")) - - for testcase in testcases: - test_name = testcase.get("name", "Unknown") - classname = testcase.get("classname", "") - time_str = testcase.get("time", "0") - - # Parse duration - try: - duration = float(time_str) - except (ValueError, TypeError): - duration = 0.0 - - # Determine test result - failure = testcase.find("failure") - error = testcase.find("error") - skipped = testcase.find("skipped") - - if failure is not None: - status = "failure" - output = failure.text or failure.get("message", "") - elif error is not None: - status = "failure" - output = error.text or error.get("message", "") - elif skipped is not None: - status = "skipped" - output = skipped.text or skipped.get("message", "") - else: - status = "success" - output = "" - - # Get system-out and system-err if available - system_out = testcase.find("system-out") - system_err = testcase.find("system-err") - - additional_output = [] - if system_out is not None and system_out.text: - additional_output.append(f"STDOUT:\n{system_out.text}") - if system_err is not None and system_err.text: - additional_output.append(f"STDERR:\n{system_err.text}") - - if additional_output: - if output: - output += "\n\n" + "\n\n".join(additional_output) - else: - output = "\n\n".join(additional_output) - - # Truncate output to first 15KB - if len(output) > 15360: # 15KB - output = output[:15360] + "\n... [output truncated to 15KB]" - - test_results.append( - { - "name": test_name, - "classname": classname, - "full_name": f"{classname}.{test_name}" if classname else test_name, - "duration": duration, - "status": status, - "output": output, - } - ) - - return test_results - - def _extract_aggregated_yaml_from_xml(self, root: Element) -> Optional[List[Dict[str, Any]]]: - """Extract aggregated YAML data from JUnit XML system-out sections, but only for failed tests.""" - import yaml - - aggregated_tests = [] - - # Look for testcase elements with system-out containing YAML - testcases = root.findall(".//testcase") - - for testcase in testcases: - # Only process testcases that have actual JUnit failures (indicated by element) - failure_element = testcase.find("failure") - if failure_element is None: - # This test passed according to JUnit, skip it - continue - - system_out = testcase.find("system-out") - if system_out is not None and system_out.text: - try: - # Try to parse the system-out content as YAML - yaml_content = system_out.text.strip() - if yaml_content and ("passes:" in yaml_content or "failures:" in yaml_content): - yaml_data = yaml.safe_load(yaml_content) - if isinstance(yaml_data, dict): - # Add test case name and failure info to the YAML data - yaml_data["testcase_name"] = testcase.get("name", "Unknown") - yaml_data["junit_failure_message"] = failure_element.get("message", "No failure message") - aggregated_tests.append(yaml_data) - except yaml.YAMLError: - # Not valid YAML, continue - continue - - return aggregated_tests if aggregated_tests else None - - def _format_aggregated_results(self, aggregated_tests: List[Dict[str, Any]]) -> str: - """Format aggregated test results for display - only show tests that actually failed according to JUnit.""" - result = "**🔄 Aggregated Test Results - Failed Tests Only**\n\n" - - if not aggregated_tests: - result += "**✅ All aggregated tests passed!** No JUnit failures detected.\n" - result += "This means all statistical aggregations met their required pass thresholds.\n" - return result - - total_failed_tests = len(aggregated_tests) - # Limit to 25 aggregated tests maximum - max_tests_to_show = 25 - tests_to_show = aggregated_tests[:max_tests_to_show] - - result += f"**❌ Failed Aggregated Tests ({total_failed_tests} total" - if total_failed_tests > max_tests_to_show: - result += f", showing first {max_tests_to_show}" - result += "):**\n" - result += "These tests failed their statistical aggregation requirements.\n\n" - - for i, test_data in enumerate(tests_to_show, 1): - testcase_name = test_data.get("testcase_name", f"Test {i}") - testsuitename = test_data.get("testsuitename", "Unknown") - summary = test_data.get("summary", "No summary available") - junit_failure = test_data.get("junit_failure_message", "No failure message") - - result += f"**{i}. {testcase_name}**\n" - result += f"**Suite:** {testsuitename}\n" - result += f"**Summary:** {summary}\n" - result += f"**JUnit Failure:** {junit_failure}\n\n" - - # Process passes, failures, and skips from the underlying jobs - passes = test_data.get("passes", []) - failures = test_data.get("failures", []) - skips = test_data.get("skips", []) - - # Deduplicate all job lists for accurate counts - unique_passes = [] - seen_pass_ids = set() - for job in passes: - job_id = job.get("jobrunid", "Unknown") - if job_id not in seen_pass_ids: - unique_passes.append(job) - seen_pass_ids.add(job_id) - - unique_failures = [] - seen_fail_ids = set() - for job in failures: - job_id = job.get("jobrunid", "Unknown") - if job_id not in seen_fail_ids: - unique_failures.append(job) - seen_fail_ids.add(job_id) - - unique_skips = [] - seen_skip_ids = set() - for job in skips: - job_id = job.get("jobrunid", "Unknown") - if job_id not in seen_skip_ids: - unique_skips.append(job) - seen_skip_ids.add(job_id) - - # Show underlying job breakdown with unique counts - total_unique_jobs = len(unique_passes) + len(unique_failures) + len(unique_skips) - if total_unique_jobs > 0: - pass_rate = (len(unique_passes) / total_unique_jobs) * 100 - result += f"**📊 Underlying Job Breakdown:**\n" - result += f"- Total unique underlying jobs: {total_unique_jobs}\n" - result += f"- Unique passing jobs: {len(unique_passes)}\n" - result += f"- Unique failing jobs: {len(unique_failures)}\n" - result += f"- Unique skipped jobs: {len(unique_skips)}\n" - result += f"- Actual pass rate: {pass_rate:.1f}%\n\n" - - # Show some example failing jobs if they exist (using already deduplicated list) - if unique_failures: - result += f"**❌ Example Failing Jobs (showing up to 3 unique):**\n" - for j, job in enumerate(unique_failures[:3], 1): - job_id = job.get("jobrunid", "Unknown") - human_url = job.get("humanurl", "No URL") - result += f" {j}. Job ID {job_id}: {human_url}\n" - - if len(unique_failures) > 3: - result += f" ... and {len(unique_failures) - 3} more unique failing jobs\n" - result += "\n" - - # Show some example passing jobs for context (using already deduplicated list) - if unique_passes: - result += f"**✅ Example Passing Jobs (showing up to 2 unique):**\n" - for j, job in enumerate(unique_passes[:2], 1): - job_id = job.get("jobrunid", "Unknown") - human_url = job.get("humanurl", "No URL") - result += f" {j}. Job ID {job_id}: {human_url}\n" - - if len(unique_passes) > 2: - result += f" ... and {len(unique_passes) - 2} more unique passing jobs\n" - result += "\n" - - result += "---\n\n" - - # Add note if there are more tests than shown - if total_failed_tests > max_tests_to_show: - remaining = total_failed_tests - max_tests_to_show - result += f"**Note:** Results truncated to first {max_tests_to_show} entries. Total failed aggregated tests found: {total_failed_tests} ({remaining} more not shown)\n\n" - - # Add guidance for next steps - if aggregated_tests: - result += "**🔍 Recommended Next Steps:**\n" - result += "1. Focus on the JUnit failure messages above - these explain why the aggregation failed\n" - result += "2. Use the job summary tool on example failing job IDs to understand specific failure modes\n" - result += "3. Look for patterns: are failures consistent across jobs or sporadic?\n" - result += "4. Check if the failure rate exceeds historical thresholds mentioned in summaries\n" - result += "5. Only analyze individual underlying jobs if specifically requested for deep analysis\n" - - return result - - def _extract_underlying_job_links(self, xml_content: str) -> List[Dict[str, str]]: - """Extract underlying job links from aggregated JUnit XML content.""" - underlying_jobs = [] - - # Look for URLs in the XML content that point to underlying jobs - import re - - # Pattern to match prow job URLs in the XML content - url_pattern = r'https://prow\.ci\.openshift\.org/view/gs/[^\s<>"\']+/(\d+)' - - # Find all URLs and extract job IDs - urls = re.findall(url_pattern, xml_content) - - # Also look for job IDs in failure messages or test output - # Pattern for job IDs that might be mentioned in test failures - job_id_pattern = r"job[_\s]*(?:id|run)[_\s]*:?\s*(\d{10,})" - job_ids_from_text = re.findall(job_id_pattern, xml_content, re.IGNORECASE) - - # Combine and deduplicate job IDs - all_job_ids = set(urls + job_ids_from_text) - - for job_id in all_job_ids: - underlying_jobs.append({"job_id": job_id, "url": f"https://prow.ci.openshift.org/view/gs/test-platform-results/logs/{job_id}"}) - - # Also look for specific patterns in aggregated test output that might contain links - # Pattern to find "PASSING" and "FAILING" job links in test output - link_pattern = r'(PASSING|FAILING)\s+jobs?[:\s]*([^\s<>"\']+)' - links = re.findall(link_pattern, xml_content, re.IGNORECASE) - - for status, url in links: - if "prow.ci.openshift.org" in url or url.startswith("http"): - # Extract job ID from URL if possible - job_id_match = re.search(r"/(\d{10,})/?$", url) - if job_id_match: - job_id = job_id_match.group(1) - underlying_jobs.append({"job_id": job_id, "url": url, "status": status.upper()}) - - return underlying_jobs - - def _identify_failures_and_flakes(self, test_results: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Identify failures and flakes from test results.""" - # Group tests by full name to identify flakes - test_groups = {} - for result in test_results: - full_name = result["full_name"] - if full_name not in test_groups: - test_groups[full_name] = [] - test_groups[full_name].append(result) - - failures_and_flakes = [] - - for full_name, results in test_groups.items(): - if len(results) == 1: - # Single test run - result = results[0] - if result["status"] in ["failure", "error"]: - failures_and_flakes.append(result) - else: - # Multiple test runs - check for flakes - statuses = [r["status"] for r in results] - success_count = statuses.count("success") - failure_count = len([s for s in statuses if s in ["failure", "error"]]) - - if success_count > 0 and failure_count > 0: - # This is a flake - # Create a combined result - total_duration = sum(r["duration"] for r in results) - combined_output = [] - - for i, result in enumerate(results): - combined_output.append(f"Run {i + 1} ({result['status']}):") - if result["output"]: - combined_output.append(result["output"]) - combined_output.append("") - - output_text = "\n".join(combined_output) - if len(output_text) > 15360: # 15KB - output_text = output_text[:15360] + "\n... [output truncated to 15KB]" - - flake_result = { - "name": results[0]["name"], - "classname": results[0]["classname"], - "full_name": full_name, - "duration": total_duration, - "status": "flake", - "success_count": success_count, - "failure_count": failure_count, - "output": output_text, - } - failures_and_flakes.append(flake_result) - elif failure_count > 0: - # All failures - add the first failure - failure_result = next(r for r in results if r["status"] in ["failure", "error"]) - failures_and_flakes.append(failure_result) - - return failures_and_flakes - - def _format_test_results( - self, results: List[Dict[str, Any]], specific_test: Optional[str] = None, underlying_jobs: Optional[List[Dict[str, str]]] = None - ) -> str: - """Format test results for display.""" - if not results: - if specific_test: - return f"No results found for test: {specific_test}" - else: - return "No test failures or flakes found in the JUnit XML file." - - if specific_test: - header = f"**Test Results for: {specific_test}**\n\n" - else: - header = f"**JUnit Test Failures and Flakes**\n\n" - header += f"Found {len(results)} test failures/flakes:\n\n" - - formatted_results = [] - - for i, result in enumerate(results, 1): - test_info = f"**{i}. {result['name']}**\n" - - if result["classname"]: - test_info += f" **Class:** {result['classname']}\n" - - test_info += f" **Duration:** {result['duration']:.2f}s\n" - - if result["status"] == "flake": - test_info += f" **Result:** FLAKE ({result['success_count']} successes, {result['failure_count']} failures)\n" - else: - test_info += f" **Result:** {result['status'].upper()}\n" - - if result["output"]: - test_info += f" **Output:**\n```\n{result['output']}\n```\n" - - formatted_results.append(test_info) - - result = header + "\n".join(formatted_results) - - # Add underlying jobs information if this is an aggregated job - if underlying_jobs: - result += "\n\n🔄 **AGGREGATED JOB - UNDERLYING JOBS DETECTED:**\n" - result += "This JUnit XML contains results from multiple underlying job runs.\n\n" - - if len(underlying_jobs) > 0: - result += "**Underlying Job Links:**\n" - for i, job in enumerate(underlying_jobs[:10], 1): # Limit to first 10 - status_info = f" ({job['status']})" if "status" in job else "" - result += f"{i}. Job ID {job['job_id']}{status_info}: {job['url']}\n" - - if len(underlying_jobs) > 10: - result += f"... and {len(underlying_jobs) - 10} more underlying jobs\n" - - result += "\n💡 **For deep analysis:** Use the job summary tool on individual job IDs above to analyze specific failures.\n" - - return result - - def _format_test_results_with_limit( - self, results: List[Dict[str, Any]], underlying_jobs: Optional[List[Dict[str, str]]] = None, max_size_kb: int = 150 - ) -> tuple[str, int, int]: - """Format test results while respecting overall size limit. - - Returns: - tuple: (formatted_text, actual_count, total_count) - """ - if not results: - return "No test failures or flakes found in the JUnit XML file.", 0, 0 - - max_size_bytes = max_size_kb * 1024 - total_count = len(results) - - # Limit to 25 results initially - limited_results = results[:25] - - header = f"**JUnit Test Failures and Flakes**\n\n" - header += f"Found {len(limited_results)} test failures/flakes:\n\n" - - formatted_results = [] - current_size = len(header.encode("utf-8")) - actual_count = 0 - - for i, result in enumerate(limited_results, 1): - test_info = f"**{i}. {result['name']}**\n" - - if result["classname"]: - test_info += f" **Class:** {result['classname']}\n" - - test_info += f" **Duration:** {result['duration']:.2f}s\n" - - if result["status"] == "flake": - test_info += f" **Result:** FLAKE ({result['success_count']} successes, {result['failure_count']} failures)\n" - else: - test_info += f" **Result:** {result['status'].upper()}\n" - - if result["output"]: - test_info += f" **Output:**\n```\n{result['output']}\n```\n" - - test_info += "\n" - - # Check if adding this test would exceed the size limit - test_size = len(test_info.encode("utf-8")) - if current_size + test_size > max_size_bytes and actual_count > 0: - # Stop adding tests if we would exceed the limit - break - - formatted_results.append(test_info) - current_size += test_size - actual_count += 1 - - result = header + "\n".join(formatted_results) - - # Add underlying jobs information if this is an aggregated job - if underlying_jobs: - result += "\n\n🔄 **AGGREGATED JOB - UNDERLYING JOBS DETECTED:**\n" - result += "This JUnit XML contains results from multiple underlying job runs.\n\n" - - if len(underlying_jobs) > 0: - result += "**Underlying Job Links:**\n" - for i, job in enumerate(underlying_jobs[:10], 1): # Limit to first 10 - status_info = f" ({job['status']})" if "status" in job else "" - result += f"{i}. Job ID {job['job_id']}{status_info}: {job['url']}\n" - - if len(underlying_jobs) > 10: - result += f"... and {len(underlying_jobs) - 10} more underlying jobs\n" - - result += "\n💡 **For deep analysis:** Use the job summary tool on individual job IDs above to analyze specific failures.\n" - - return result, actual_count, total_count diff --git a/chat/sippy_agent/tools/mcp_tool_loader.py b/chat/sippy_agent/tools/mcp_tool_loader.py deleted file mode 100644 index 2fa830d11e..0000000000 --- a/chat/sippy_agent/tools/mcp_tool_loader.py +++ /dev/null @@ -1,85 +0,0 @@ -""" -Load tools from MCP (Model Context Protocol) servers. -""" - -import json -import logging -import os -import re -from typing import List, Dict, Any - -from langchain.tools import BaseTool -from langchain_mcp_adapters.client import MultiServerMCPClient - - -logger = logging.getLogger(__name__) - - -def _replace_env_vars(config: Any) -> Any: - """Recursively replace ${env:VAR} with environment variable values.""" - if isinstance(config, dict): - return {k: _replace_env_vars(v) for k, v in config.items()} - elif isinstance(config, list): - return [_replace_env_vars(i) for i in config] - elif isinstance(config, str): - # Use regex to find all instances of ${env:VAR} and replace them. - return re.sub(r"\$\{env:([_A-Za-z0-9]+)\}", lambda m: os.getenv(m.group(1), ""), config) - return config - - -def _adapt_server_config(servers: Dict[str, Any]) -> Dict[str, Any]: - """Adapt the server config from the user's JSON format to what MultiServerMCPClient expects.""" - adapted_servers = {} - for name, config in servers.items(): - adapted_config = config.copy() - if "type" in adapted_config: - transport = adapted_config.pop("type") - # The library example uses 'streamable_http' for http transport. - if transport == "http": - transport = "streamable_http" - adapted_config["transport"] = transport - adapted_servers[name] = adapted_config - return adapted_servers - - -async def load_tools_from_mcp(mcp_config_file: str) -> List[BaseTool]: - """ - Load tools from MCP servers defined in a JSON configuration file. - - Args: - mcp_config_file: Path to the MCP configuration file. - - Returns: - A list of BaseTool instances loaded from the MCP servers. - """ - if not os.path.exists(mcp_config_file): - logger.warning(f"MCP config file not found: {mcp_config_file}") - return [] - - try: - with open(mcp_config_file, "r") as f: - config = json.load(f) - except json.JSONDecodeError: - logger.error(f"Invalid JSON in MCP config file: {mcp_config_file}") - return [] - - mcp_servers = config.get("mcpServers", {}) - if not mcp_servers: - logger.info("No mcpServers found in the configuration.") - return [] - - try: - # Replace environment variables in the configuration - processed_servers = _replace_env_vars(mcp_servers) - - # Adapt config to what MultiServerMCPClient expects ('transport' key) - adapted_servers = _adapt_server_config(processed_servers) - - # Use MultiServerMCPClient to load all tools at once - client = MultiServerMCPClient(adapted_servers) - tools = await client.get_tools() - logger.info(f"Loaded {len(tools)} tools from {len(mcp_servers)} MCP servers.") - return tools - except Exception as e: - logger.error(f"Failed to load tools from MCP servers: {e}") - return [] diff --git a/chat/sippy_agent/tools/payload_details.py b/chat/sippy_agent/tools/payload_details.py deleted file mode 100644 index 5e0afa0e8f..0000000000 --- a/chat/sippy_agent/tools/payload_details.py +++ /dev/null @@ -1,155 +0,0 @@ -""" -Tool for getting detailed OpenShift release payload information from the release controller API. -""" - -import json -import logging -import re -from typing import Any, Dict, List, Optional, Type -from pydantic import Field -import httpx - -from .base_tool import SippyBaseTool, SippyToolInput - -logger = logging.getLogger(__name__) - - -class SippyPayloadDetailsTool(SippyBaseTool): - """Tool for getting detailed OpenShift release payload information.""" - - name: str = "get_payload_details" - description: str = "Get a JSON object with comprehensive information for a specific OpenShift release payload. Use this ONLY when user asks for details about a specific payload. For basic payload status, use get_release_payloads first. Input: payload name (e.g., '4.20.0-0.nightly-2025-06-17-061341')" - - # Release controller API base URL - release_controller_url: str = Field( - default="https://amd64.ocp.releases.ci.openshift.org/api/v1", description="Release controller API base URL" - ) - # TODO: this should probably be switched to use the sippy API for payloads, which is permanent whereas release controller will prune - - # Sippy API URL for job analysis - sippy_api_url: Optional[str] = Field(default=None, description="Sippy API base URL for job analysis") - - class PayloadDetailsInput(SippyToolInput): - payload_name: str = Field(description="Full payload name (e.g., '4.20.0-0.nightly-2025-06-17-061341')") - include_job_analysis: Optional[bool] = Field( - default=False, description="Include suggested next steps for analyzing failed blocking jobs" - ) - max_jobs_to_analyze: Optional[int] = Field( - default=5, description="Maximum number of failed jobs to analyze in detail (defaults to 5 to avoid excessive API calls)" - ) - - args_schema: Type[SippyToolInput] = PayloadDetailsInput - - def _run( - self, - *args, - **kwargs: Any, - ) -> Dict[str, Any]: - """Get detailed payload information from the release controller API.""" - - input_data = {} - if args and isinstance(args[0], dict): - input_data.update(args[0]) - input_data.update(kwargs) - - # Pydantic model will have validated and filled in defaults - args = self.PayloadDetailsInput(**input_data) - - # Clean the payload name in case it includes parameter syntax - clean_payload_name = self._clean_payload_name(args.payload_name) - - # Extract release stream from payload name - release_stream = self._extract_release_stream(clean_payload_name) - if not release_stream: - return { - "error": f"Error: Could not extract release stream from payload name '{clean_payload_name}'. Expected format like '4.20.0-0.nightly-2025-06-17-061341'" - } - - # Construct the API endpoint for payload details - endpoint = f"{self.release_controller_url.rstrip('/')}/releasestream/{release_stream}/release/{clean_payload_name}" - - try: - logger.info(f"Making request to {endpoint}") - - with httpx.Client(timeout=30.0) as client: - response = client.get(endpoint) - response.raise_for_status() - - # Log response details for debugging - logger.debug(f"Response status: {response.status_code}") - logger.debug(f"Response content type: {response.headers.get('content-type', 'unknown')}") - - # Check if response is JSON (be more lenient with content type checking) - content_type = response.headers.get("content-type", "") - if not ( - content_type.startswith("application/json") - or content_type.startswith("text/json") - or response.text.strip().startswith("{") - ): - logger.warning(f"Unexpected content type: {content_type}") - logger.warning(f"Response text: {response.text[:500]}...") - return {"error": f"API returned non-JSON response. Content-Type: {content_type}"} - - try: - data = response.json() - logger.debug(f"JSON parsed successfully, type: {type(data)}") - except json.JSONDecodeError as json_err: - logger.error(f"JSON decode error: {json_err}") - logger.error(f"Response text: {response.text[:500]}...") - return {"error": f"Invalid JSON response from API. Response: {response.text[:200]}..."} - - # Validate that data is a dictionary - if not isinstance(data, dict): - logger.error(f"Expected dict, got {type(data)}: {str(data)[:200]}...") - return {"error": f"API returned unexpected data type {type(data)}. Expected JSON object."} - - # Remove the redundant base64 changelog - if "changeLog" in data: - del data["changeLog"] - - # Return the raw JSON data - return data - - except httpx.HTTPStatusError as e: - logger.error(f"HTTP error getting payload details: {e}") - if e.response.status_code == 404: - return { - "error": f"Payload '{clean_payload_name}' not found in release stream '{release_stream}'. Check if the payload name is correct." - } - return {"error": f"HTTP {e.response.status_code} - {e.response.text}"} - except httpx.RequestError as e: - logger.error(f"Request error getting payload details: {e}") - return {"error": f"Failed to connect to release controller API - {str(e)}"} - except json.JSONDecodeError as e: - logger.error(f"JSON decode error: {e}") - return {"error": "Invalid JSON response from release controller API"} - except Exception as e: - logger.error(f"Unexpected error getting payload details: {e}") - return {"error": f"Unexpected error - {str(e)}"} - - def _clean_payload_name(self, payload_name: str) -> str: - """Clean payload name from common parameter syntax issues.""" - # Remove common parameter syntax patterns - cleaned = payload_name.strip() - - # Handle cases like "payload name = '4.20.0-0.nightly-2025-06-17-061341'" - if "=" in cleaned: - cleaned = cleaned.split("=")[-1].strip() - - # Remove quotes - cleaned = cleaned.strip("'\"") - - # Extract just the payload name pattern - payload_pattern = re.search(r"(\d+\.\d+\.0-0\.(nightly|ci)-\d{4}-\d{2}-\d{2}-\d{6})", cleaned) - if payload_pattern: - return payload_pattern.group(1) - - return cleaned - - def _extract_release_stream(self, payload_name: str) -> Optional[str]: - """Extract release stream from payload name.""" - # Expected format: 4.20.0-0.nightly-2025-06-17-061341 - match = re.match(r"^(\d+\.\d+\.0-0\.(nightly|ci))-\d{4}-\d{2}-\d{2}-\d{6}$", payload_name) - if match: - return match.group(1) - return None diff --git a/chat/sippy_agent/tools/release_payloads.py b/chat/sippy_agent/tools/release_payloads.py deleted file mode 100644 index 05dbe00136..0000000000 --- a/chat/sippy_agent/tools/release_payloads.py +++ /dev/null @@ -1,121 +0,0 @@ -""" -Tool for getting OpenShift release payload information from the release controller API. -""" - -import json -import logging -import re -from typing import Any, Dict, List, Optional, Type -from pydantic import Field -import httpx - -from .base_tool import SippyBaseTool, SippyToolInput - -logger = logging.getLogger(__name__) - - -class SippyReleasePayloadTool(SippyBaseTool): - """Tool for getting OpenShift release payload information.""" - - name: str = "get_release_payloads" - description: str = "Get a JSON object containing a list of recent OpenShift release payloads with their status. Use this to find the name of the latest payload. For specific payload details, use get_payload_details. Input: release version (e.g., '4.20') and optional stream type ('nightly' or 'ci', defaults to 'nightly')" - - # Release controller API base URL - release_controller_url: str = Field( - default="https://amd64.ocp.releases.ci.openshift.org/api/v1", description="Release controller API base URL" - ) - # TODO: this should probably be switched to use the sippy API for payloads, which is permanent whereas release controller will prune - - class ReleasePayloadInput(SippyToolInput): - release_version: str = Field(description="Release version (e.g., '4.20', '4.19')") - stream_type: Optional[str] = Field(default="nightly", description="Stream type: 'nightly' or 'ci' (defaults to 'nightly')") - - args_schema: Type[SippyToolInput] = ReleasePayloadInput - - def _run( - self, - *args, - **kwargs: Any, - ) -> Dict[str, Any]: - """Get release payload information from the release controller API.""" - - input_data = {} - if args and isinstance(args[0], dict): - input_data.update(args[0]) - input_data.update(kwargs) - - # Pydantic model will have validated and filled in defaults - args = self.ReleasePayloadInput(**input_data) - - # Validate and clean inputs - stream_type = args.stream_type or "nightly" - if stream_type not in ["nightly", "ci"]: - return {"error": f"Invalid stream type '{stream_type}'. Must be 'nightly' or 'ci'."} - - # Clean release version (remove any extra characters) - clean_version = re.sub(r"[^\d\.]", "", args.release_version) - if not re.match(r"^\d+\.\d+$", clean_version): - return {"error": f"Invalid release version format. Expected format like '4.20', got: {args.release_version}"} - - # Construct the release stream name - release_stream = f"{clean_version}.0-0.{stream_type}" - - # Construct the API endpoint - endpoint = f"{self.release_controller_url.rstrip('/')}/releasestream/{release_stream}/tags" - - try: - logger.info(f"Making request to {endpoint}") - - with httpx.Client(timeout=30.0) as client: - response = client.get(endpoint) - response.raise_for_status() - - data = response.json() - - # Return the raw JSON data - return data - - except httpx.HTTPStatusError as e: - logger.error(f"HTTP error getting release payloads: {e}") - if e.response.status_code == 404: - return {"error": f"Release stream '{release_stream}' not found. Check if the release version and stream type are correct."} - return {"error": f"HTTP {e.response.status_code} - {e.response.text}"} - except httpx.RequestError as e: - logger.error(f"Request error getting release payloads: {e}") - return {"error": f"Failed to connect to release controller API - {str(e)}"} - except json.JSONDecodeError as e: - logger.error(f"JSON decode error: {e}") - return {"error": "Invalid JSON response from release controller API"} - except Exception as e: - logger.error(f"Unexpected error getting release payloads: {e}") - return {"error": f"Unexpected error - {str(e)}"} - - def get_latest_payload(self, release_version: str, stream_type: str = "nightly") -> Optional[Dict[str, Any]]: - """Helper method to get just the latest payload information.""" - try: - # Use the main _run method but parse the result differently - # This is a simplified version for programmatic access - clean_version = re.sub(r"[^\d\.]", "", release_version) - release_stream = f"{clean_version}.0-0.{stream_type}" - endpoint = f"{self.release_controller_url.rstrip('/')}/releasestream/{release_stream}/tags" - - with httpx.Client(timeout=30.0) as client: - response = client.get(endpoint) - response.raise_for_status() - data = response.json() - - tags = data.get("tags", []) - if not tags: - return None - - # Find first non-Ready payload - for tag in tags: - if tag.get("phase", "").lower() != "ready": - return tag - - # If all are Ready, return the first one - return tags[0] if tags else None - - except Exception as e: - logger.error(f"Error getting latest payload: {e}") - return None diff --git a/chat/sippy_agent/tools/sippy_job_payload.py b/chat/sippy_agent/tools/sippy_job_payload.py deleted file mode 100644 index 374f1fce3c..0000000000 --- a/chat/sippy_agent/tools/sippy_job_payload.py +++ /dev/null @@ -1,88 +0,0 @@ -""" -Tool for getting prow job run payload information from Sippy API. -""" - -import json -import logging -from typing import Any, Dict, Optional, Type -from pydantic import Field -import httpx - -from .base_tool import SippyBaseTool, SippyToolInput - -logger = logging.getLogger(__name__) - - -class SippyProwJobPayloadTool(SippyBaseTool): - """Tool for getting the payload a prow job used from the Sippy API. Useful for determining what changes were in a payload where a problem first appeared.""" - - name: str = "get_prow_job_payload" - description: str = "Get payload information for a Prow job run including the payload tag (may be null) and job name. Input: just the numeric job ID (e.g., 1934795512955801600)" - - # Add sippy_api_url as a proper field - sippy_api_url: Optional[str] = Field(default=None, description="Sippy API base URL") - - class ProwJobPayloadInput(SippyToolInput): - prow_job_run_id: str = Field(description="Numeric prow job run ID only (e.g., 1934795512955801600)") - - args_schema: Type[SippyToolInput] = ProwJobPayloadInput - - def _run(self, *args, **kwargs: Any) -> Dict[str, Any]: - """Get prow job run payload information from Sippy API.""" - - input_data = {} - if args and isinstance(args[0], dict): - input_data.update(args[0]) - input_data.update(kwargs) - - # Pydantic model will have validated and filled in defaults - args = self.ProwJobPayloadInput(**input_data) - - # Use provided URL or fall back to instance URL - api_url = self.sippy_api_url - - if not api_url: - return { - "error": "No Sippy API URL configured. Please set SIPPY_API_URL environment variable or provide sippy_api_url parameter." - } - - # Clean and validate the job ID - extract just the numeric part - clean_job_id = str(args.prow_job_run_id).strip() - # Extract just the numeric part if there's extra text - import re - - job_id_match = re.search(r"\b(\d{10,})\b", clean_job_id) - if job_id_match: - clean_job_id = job_id_match.group(1) - elif not clean_job_id.isdigit(): - return {"error": f"Invalid job ID format. Expected numeric ID, got: {args.prow_job_run_id}"} - - # Construct the API endpoint - endpoint = f"{api_url.rstrip('/')}/api/job/run/payload" - - try: - # Make the API request - params = {"prow_job_run_id": clean_job_id} - logger.info(f"Making request to {endpoint} with params: {params}") - - with httpx.Client(timeout=30.0) as client: - response = client.get(endpoint, params=params) - response.raise_for_status() - - data = response.json() - - # Return the raw JSON data - return data - - except httpx.HTTPStatusError as e: - logger.error(f"HTTP error getting job payload: {e}") - return {"error": f"HTTP {e.response.status_code} - {e.response.text}"} - except httpx.RequestError as e: - logger.error(f"Request error getting job payload: {e}") - return {"error": f"Failed to connect to Sippy API at {api_url} - {str(e)}"} - except json.JSONDecodeError as e: - logger.error(f"JSON decode error: {e}") - return {"error": "Invalid JSON response from Sippy API"} - except Exception as e: - logger.error(f"Unexpected error getting job payload: {e}") - return {"error": f"Unexpected error - {str(e)}"} diff --git a/chat/sippy_agent/tools/sippy_job_summary.py b/chat/sippy_agent/tools/sippy_job_summary.py deleted file mode 100644 index 084ea4f0d7..0000000000 --- a/chat/sippy_agent/tools/sippy_job_summary.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -Tool for getting prow job run summaries from Sippy API. -""" - -import json -import logging -from typing import Any, Dict, Optional, Type -from pydantic import Field -import httpx - -from .base_tool import SippyBaseTool, SippyToolInput - -logger = logging.getLogger(__name__) - - -class SippyProwJobSummaryTool(SippyBaseTool): - """Tool for getting prow job run summaries from Sippy API.""" - - name: str = "get_prow_job_summary" - description: str = "Get a JSON object with a summary of a Prow job run including its URL, TestGrid link, and test failures. Contains all basic job information. Input: just the numeric job ID (e.g., 1934795512955801600)" - - # Add sippy_api_url as a proper field - sippy_api_url: Optional[str] = Field(default=None, description="Sippy API base URL") - - class ProwJobSummaryInput(SippyToolInput): - prow_job_run_id: str = Field(description="Numeric prow job run ID only (e.g., 1934795512955801600)") - sippy_api_url: Optional[str] = Field(default=None, description="Sippy API base URL (optional, uses config if not provided)") - - args_schema: Type[SippyToolInput] = ProwJobSummaryInput - - def _run(self, *args, **kwargs: Any) -> Dict[str, Any]: - """Get prow job run summary from Sippy API.""" - - input_data = {} - if args and isinstance(args[0], dict): - input_data.update(args[0]) - input_data.update(kwargs) - - # Pydantic model will have validated and filled in defaults - args = self.ProwJobSummaryInput(**input_data) - - # Use provided URL or fall back to instance URL - api_url = args.sippy_api_url or self.sippy_api_url - - if not api_url: - return { - "error": "No Sippy API URL configured. Please set SIPPY_API_URL environment variable or provide sippy_api_url parameter." - } - - # Clean and validate the job ID - extract just the numeric part - clean_job_id = str(args.prow_job_run_id).strip() - # Extract just the numeric part if there's extra text - import re - - job_id_match = re.search(r"\b(\d{10,})\b", clean_job_id) - if job_id_match: - clean_job_id = job_id_match.group(1) - elif not clean_job_id.isdigit(): - return {"error": f"Invalid job ID format. Expected numeric ID, got: {args.prow_job_run_id}"} - - # Construct the API endpoint - endpoint = f"{api_url.rstrip('/')}/api/job/run/summary" - - try: - # Make the API request - params = {"prow_job_run_id": clean_job_id} - logger.info(f"Making request to {endpoint} with params: {params}") - - with httpx.Client(timeout=30.0) as client: - response = client.get(endpoint, params=params) - response.raise_for_status() - - data = response.json() - - # Return the raw JSON data - return data - - except httpx.HTTPStatusError as e: - logger.error(f"HTTP error getting job summary: {e}") - return {"error": f"HTTP {e.response.status_code} - {e.response.text}"} - except httpx.RequestError as e: - logger.error(f"Request error getting job summary: {e}") - return {"error": f"Failed to connect to Sippy API at {api_url} - {str(e)}"} - except json.JSONDecodeError as e: - logger.error(f"JSON decode error: {e}") - return {"error": "Invalid JSON response from Sippy API"} - except Exception as e: - logger.error(f"Unexpected error getting job summary: {e}") - return {"error": f"Unexpected error - {str(e)}"} diff --git a/chat/sippy_agent/tools/sippy_log_analyzer.py b/chat/sippy_agent/tools/sippy_log_analyzer.py deleted file mode 100644 index c4f1e9a7b4..0000000000 --- a/chat/sippy_agent/tools/sippy_log_analyzer.py +++ /dev/null @@ -1,176 +0,0 @@ -""" -Tool for analyzing job artifacts and logs from Sippy API. -""" - -import json -import logging -from typing import Any, Dict, Optional, Type -from pydantic import Field -import httpx - -from .base_tool import SippyBaseTool, SippyToolInput - -logger = logging.getLogger(__name__) - - -class SippyLogAnalyzerTool(SippyBaseTool): - """Tool for analyzing job artifacts and logs from Sippy API using the /api/jobs/artifacts endpoint.""" - - name: str = "analyze_job_logs" - description: str = ( - "Get a JSON object with artifact search results for a given Prow job. Input: numeric job ID, optional path_glob and text_regex" - ) - - # Add sippy_api_url as a proper field - sippy_api_url: Optional[str] = Field(default=None, description="Sippy API base URL") - - # Simple cache to prevent redundant API calls - _cache: Dict[str, Dict[str, Any]] = {} - - class LogAnalyzerInput(SippyToolInput): - prow_job_run_id: str = Field(description="Numeric prow job run ID only (e.g., 1934795512955801600)") - path_glob: str = Field( - default="*build-log*", description="Path glob pattern to match artifacts (e.g., '*build-log*', '*.log', '**/junit*.xml')" - ) - text_regex: str = Field( - default="[Ee]rror|[Ff]ail", description="Regex pattern to search for in the artifacts (e.g., '[Ee]rror', 'timeout', 'panic')" - ) - sippy_api_url: Optional[str] = Field(default=None, description="Sippy API base URL (optional, uses config if not provided)") - - args_schema: Type[SippyToolInput] = LogAnalyzerInput - - def _run(self, *args, **kwargs: Any) -> Dict[str, Any]: - """Fetch and analyze job artifacts from Sippy API.""" - - input_data = {} - if args and isinstance(args[0], dict): - input_data.update(args[0]) - input_data.update(kwargs) - - # Pydantic model will have validated and filled in defaults - args = self.LogAnalyzerInput(**input_data) - - # Use provided URL or fall back to instance URL - api_url = args.sippy_api_url or self.sippy_api_url - - if not api_url: - return { - "error": "No Sippy API URL configured. Please set SIPPY_API_URL environment variable or provide sippy_api_url parameter." - } - - # Clean and validate the job ID - ensure it's just the numeric ID - clean_job_id = str(args.prow_job_run_id).strip() - # Extract just the numeric part if there's extra text - import re - - job_id_match = re.search(r"\b(\d{10,})\b", clean_job_id) - if job_id_match: - clean_job_id = job_id_match.group(1) - elif not clean_job_id.isdigit(): - return {"error": f"Invalid job ID format. Expected numeric ID, got: {args.prow_job_run_id}"} - - # Create cache key to prevent redundant calls - cache_key = f"{clean_job_id}:{args.path_glob}:{args.text_regex}" - if cache_key in self._cache: - logger.info(f"Returning cached result for {cache_key}") - # The cache stores the JSON dict, not a string - return self._cache[cache_key] - - # Construct the API endpoint - endpoint = f"{api_url.rstrip('/')}/api/jobs/artifacts" - - try: - # Make the API request with correct parameter names - params = { - "prowJobRuns": clean_job_id, # Just the numeric ID - "pathGlob": args.path_glob, - "textRegex": args.text_regex, - } - - logger.info(f"Making request to {endpoint} with params: {params}") - - with httpx.Client(timeout=60.0) as client: # Longer timeout for log analysis - response = client.get(endpoint, params=params) - response.raise_for_status() - - # The response should be JSON containing the matched artifacts - data = response.json() - - # Cache the result to prevent redundant calls - self._cache[cache_key] = data - - return data - - except httpx.HTTPStatusError as e: - logger.error(f"HTTP error analyzing logs: {e}") - return {"error": f"HTTP {e.response.status_code} - {e.response.text}"} - except httpx.RequestError as e: - logger.error(f"Request error analyzing logs: {e}") - return {"error": f"Failed to connect to Sippy API at {api_url} - {str(e)}"} - except json.JSONDecodeError as e: - logger.error(f"JSON decode error: {e}") - return {"error": "Invalid JSON response from Sippy API"} - except Exception as e: - logger.error(f"Unexpected error analyzing logs: {e}") - return {"error": f"Unexpected error - {str(e)}"} - - def get_aggregated_junit_url(self, prow_job_run_id: str, sippy_api_url: Optional[str] = None) -> str: - """Get the direct URL to the junit-aggregated.xml file for an aggregated job.""" - # Use provided URL or fall back to instance URL - api_url = sippy_api_url or self.sippy_api_url - - if not api_url: - return "Error: No Sippy API URL configured. Please set SIPPY_API_URL environment variable or provide sippy_api_url parameter." - - # Clean and validate the job ID - clean_job_id = str(prow_job_run_id).strip() - import re - - job_id_match = re.search(r"\b(\d{10,})\b", clean_job_id) - if job_id_match: - clean_job_id = job_id_match.group(1) - elif not clean_job_id.isdigit(): - return f"Error: Invalid job ID format. Expected numeric ID, got: {prow_job_run_id}" - - # Construct the API endpoint for aggregated JUnit artifacts - endpoint = f"{api_url.rstrip('/')}/api/jobs/artifacts" - - try: - # Make the API request specifically for junit-aggregated.xml - params = {"prowJobRuns": clean_job_id, "pathGlob": "artifacts/**/junit-aggregated.xml"} - - logger.info(f"Fetching aggregated JUnit URL from {endpoint} with params: {params}") - - with httpx.Client(timeout=30.0) as client: - response = client.get(endpoint, params=params) - response.raise_for_status() - - data = response.json() - - # Extract the artifact URL from the response - if isinstance(data, dict) and "job_runs" in data: - job_runs = data.get("job_runs", []) - if job_runs: - artifacts = job_runs[0].get("artifacts", []) - if artifacts: - artifact_url = artifacts[0].get("artifact_url", "") - if artifact_url: - return f"**Aggregated JUnit XML URL Found:**\n{artifact_url}\n\nUse the JUnit parser tool with this URL to analyze the aggregated test results." - else: - return "Error: No artifact URL found in the response." - else: - return "Error: No junit-aggregated.xml artifacts found for this job." - else: - return "Error: No job runs found in the response." - else: - return "Error: Unexpected response format from Sippy API." - - except httpx.HTTPStatusError as e: - logger.error(f"HTTP error fetching aggregated JUnit URL: {e}") - return f"Error: HTTP {e.response.status_code} - {e.response.text}" - except httpx.RequestError as e: - logger.error(f"Request error fetching aggregated JUnit URL: {e}") - return f"Error: Failed to connect to Sippy API at {api_url} - {str(e)}" - except Exception as e: - logger.error(f"Unexpected error fetching aggregated JUnit URL: {e}") - return f"Error: Unexpected error - {str(e)}" diff --git a/chat/sippy_agent/tools/sippy_test_details.py b/chat/sippy_agent/tools/sippy_test_details.py deleted file mode 100644 index 1c949e5acd..0000000000 --- a/chat/sippy_agent/tools/sippy_test_details.py +++ /dev/null @@ -1,216 +0,0 @@ -""" -Tool for getting test details reports from Sippy API. -""" - -import json -import logging -from typing import Any, Dict, Optional, Type -from pydantic import Field -import httpx - -from .base_tool import SippyBaseTool, SippyToolInput - -logger = logging.getLogger(__name__) - -# TODO(sgoeddel): eventually, TestDetailsReport.js should utilize this tool directly and contain minimal other instructions -# It is very difficult to obtain similar functionality that way, however. For now, this is used on other pages only. -class SippyTestDetailsTool(SippyBaseTool): - """Tool for getting comprehensive test details reports from Sippy API.""" - - name: str = "get_test_details_report" - description: str = """Get a test details report from Sippy API including regression analysis and statistics. - -This tool provides: -- Regression status and history -- Sample vs base statistics comparison -- Job stats for each job name that matched the variants in the report - - List of sample job runs and basis job runs, sorted by start time with most recent first - - Whether each job run was a success or failure (see failure_count or success_count > 0) - - Simplified job run data with job_url, job_run_id, start_time, and status (passed/failed/flaked) - - get_prow_job_summary can be used to dig deeper into specific job runs by job run ID -- Triage information -- Pass rate changes - -Input: url (the test details URL, passed verbatim without modification)""" - - # Add sippy_api_url as a proper field - sippy_api_url: Optional[str] = Field(default=None, description="Sippy API base URL") - - class TestDetailsInput(SippyToolInput): - url: str = Field(description="The test details URL. It is CRITICAL that the URL for get_test_details_report is passed EXACTLY AS PROVIDED. DO NOT modify any parameters, escape characters, or the structure of the URL in any way.") - - args_schema: Type[SippyToolInput] = TestDetailsInput - - def _run(self, *args, **kwargs: Any) -> Dict[str, Any]: - """Get test details report from Sippy API.""" - - input_data = {} - if args and isinstance(args[0], dict): - input_data.update(args[0]) - input_data.update(kwargs) - - # Pydantic model will have validated and filled in defaults - args = self.TestDetailsInput(**input_data) - - # Use the configured API URL - api_url = self.sippy_api_url - - if not api_url: - return { - "error": "No Sippy API URL configured. Please set SIPPY_API_URL environment variable." - } - - # Build the full URL from the provided URL/query params - url_input = args.url.strip() - - # If url_input is a full URL, extract just the query string - if url_input.startswith('http') or url_input.startswith('/'): - # Extract query params from URL - if '?' in url_input: - query_params = url_input.split('?', 1)[1] - else: - return {"error": f"No query parameters found in URL: {url_input}"} - else: - # It's just query params - query_params = url_input - - # Ensure query params don't start with ? or & - if query_params.startswith('?') or query_params.startswith('&'): - query_params = query_params[1:] - - full_url = f"{api_url.rstrip('/')}/api/component_readiness/test_details?{query_params}" - - try: - # Make the API request - logger.info(f"Making request to {full_url}") - - with httpx.Client(timeout=60.0) as client: - response = client.get(full_url) - response.raise_for_status() - - data = response.json() - - # Check if the response indicates an error - if data.get('code') and (data['code'] < 200 or data['code'] >= 300): - return { - "error": f"API returned error code {data['code']}: {data.get('message', 'Unknown error')}" - } - - # Process the response to extract key information - processed_data = self._process_test_details_response(data, api_url) - - # Return the processed data - return processed_data - - except httpx.HTTPStatusError as e: - logger.error(f"HTTP error getting test details: {e}") - return {"error": f"HTTP {e.response.status_code} - {e.response.text}"} - except httpx.RequestError as e: - logger.error(f"Request error getting test details: {e}") - return {"error": f"Failed to connect to Sippy API at {api_url} - {str(e)}"} - except json.JSONDecodeError as e: - logger.error(f"JSON decode error: {e}") - return {"error": "Invalid JSON response from Sippy API"} - except Exception as e: - logger.error(f"Unexpected error getting test details: {e}") - return {"error": f"Unexpected error - {str(e)}"} - - def _process_test_details_response(self, data: Dict[str, Any], api_url: str) -> Dict[str, Any]: - """Process the raw API response to extract and structure key information.""" - - if not data.get('analyses') or not data['analyses']: - return { - "error": "No analysis data found in response", - "raw_data": data - } - - first_analysis = data['analyses'][0] - - # Extract failed job run IDs - failed_job_run_ids = [] - if first_analysis.get('job_stats'): - for job_stat in first_analysis['job_stats']: - if job_stat.get('sample_job_run_stats'): - for sample_job_run in job_stat['sample_job_run_stats']: - if (sample_job_run.get('test_stats') and - sample_job_run['test_stats'].get('failure_count', 0) > 0 and - sample_job_run.get('job_run_id')): - failed_job_run_ids.append(sample_job_run['job_run_id']) - if len(failed_job_run_ids) >= 10: # Limit to 10 - break - if len(failed_job_run_ids) >= 10: - break - - # Process job stats to create simplified job run data - job_stats = {} - if first_analysis.get('job_stats'): - for job_stat in first_analysis['job_stats']: - sample_job_name = job_stat.get('sample_job_name') - if sample_job_name and job_stat.get('sample_job_run_stats'): - job_runs = [] - for job_run in job_stat['sample_job_run_stats']: - # Determine status based on test stats counts - test_stats = job_run.get('test_stats', {}) - status = self._determine_job_run_status(test_stats) - - job_run_data = { - "job_url": job_run.get('job_url', ''), - "job_run_id": job_run.get('job_run_id', ''), - "start_time": job_run.get('start_time', ''), - "status": status - } - job_runs.append(job_run_data) - - job_stats[sample_job_name] = job_runs - - # Process regression information - regression_info = None - if first_analysis.get('regression'): - regression = first_analysis['regression'] - regression_info = { - "id": regression.get('id'), - "opened": regression.get('opened'), - "closed": regression.get('closed', {}).get('time') if regression.get('closed', {}).get('valid') else None, - } - - return { - "test_name": data.get('test_name'), - "test_id": data.get('test_id'), - "component": data.get('component'), - "capability": data.get('capability'), - "environment": data.get('environment'), - "regression": regression_info, - "status": first_analysis.get('status'), - "explanations": first_analysis.get('explanations', []), - "sample_stats": first_analysis.get('sample_stats'), - "base_stats": first_analysis.get('base_stats'), - "failed_job_run_ids": failed_job_run_ids, - "job_stats": job_stats, - "triages_count": len(first_analysis.get('triages', [])), - "generated_at": data.get('generated_at'), - } - - def _determine_job_run_status(self, test_stats: Dict[str, Any]) -> str: - """Determine the status of a job run based on test stats counts. - - Args: - test_stats: Dictionary containing success_count, failure_count, and flake_count - - Returns: - String indicating status: 'passed', 'failed', or 'flaked' - """ - success_count = test_stats.get('success_count', 0) - failure_count = test_stats.get('failure_count', 0) - flake_count = test_stats.get('flake_count', 0) - - # Determine status based on which count is > 0 - # Priority: failure > flake > success - if failure_count > 0: - return 'failed' - elif flake_count > 0: - return 'flaked' - elif success_count > 0: - return 'passed' - else: - # If all counts are 0, default to 'passed' (no test results) - return 'passed' diff --git a/chat/sippy_agent/tools/triage_potential_matches.py b/chat/sippy_agent/tools/triage_potential_matches.py deleted file mode 100644 index 624fa1e46b..0000000000 --- a/chat/sippy_agent/tools/triage_potential_matches.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -Tool for getting potential matches for a triage. -""" - -import json -import logging -from typing import Any, Dict, Optional, Type -from pydantic import Field -import httpx - -from .base_tool import SippyBaseTool, SippyToolInput - -logger = logging.getLogger(__name__) - - -class TriagePotentialMatchesTool(SippyBaseTool): - name: str = "get_triage_potential_matches" - description: str = """Get potential matching regressions for a triage record. - -This tool returns a list of tests that might belong to the same triage based on: -- Similar test names (edit distance scoring) -- Same last failure times (tests that fail in the same job runs) -It includes a Confidence level (1-10, higher is better) - -Each potential match includes a test_details_api_url that you can use with the -get_test_details_report tool to analyze the test's failure patterns and compare -them with existing triaged tests. - -Input: triage_id (the triage ID) and view (the component readiness view, e.g., '4.20-main')""" - - sippy_api_url: Optional[str] = Field(default=None, description="Sippy API base URL") - - class PotentialMatchesInput(SippyToolInput): - triage_id: int = Field(description="The triage ID to find potential matches for") - view: str = Field(description="The component readiness view (e.g., '4.20-main')") - - args_schema: Type[SippyToolInput] = PotentialMatchesInput - - def _run(self, *args, **kwargs: Any) -> Dict[str, Any]: - """Get potential matches from the Sippy API.""" - - input_data = {} - if args and isinstance(args[0], dict): - input_data.update(args[0]) - input_data.update(kwargs) - - args = self.PotentialMatchesInput(**input_data) - - api_url = self.sippy_api_url - if not api_url: - return { - "error": "No Sippy API URL configured. Please set SIPPY_API_URL environment variable." - } - - try: - # Get potential matches - potential_matches_url = f"{api_url.rstrip('/')}/api/component_readiness/triages/{args.triage_id}/matches?view={args.view}" - logger.info(f"Fetching potential matches from {potential_matches_url}") - - with httpx.Client(timeout=30.0) as client: - matches_response = client.get(potential_matches_url) - matches_response.raise_for_status() - potential_matches = matches_response.json() - - if not potential_matches: - return { - "message": f"No potential matches found for triage {args.triage_id} in view '{args.view}'", - "potential_matches": [] - } - - # Sort by confidence level (descending) - potential_matches.sort(key=lambda x: x.get('confidence_level', 0), reverse=True) - - return { - "triage_id": args.triage_id, - "view": args.view, - "potential_matches_count": len(potential_matches), - "potential_matches": potential_matches, - } - - except httpx.HTTPStatusError as e: - logger.error(f"HTTP error getting potential matches: {e}") - return {"error": f"HTTP {e.response.status_code} - {e.response.text}"} - except httpx.RequestError as e: - logger.error(f"Request error getting potential matches: {e}") - return {"error": f"Failed to connect to Sippy API at {api_url} - {str(e)}"} - except json.JSONDecodeError as e: - logger.error(f"JSON decode error: {e}") - return {"error": "Invalid JSON response from Sippy API"} - except Exception as e: - logger.error(f"Unexpected error getting potential matches: {e}", exc_info=True) - return {"error": f"Unexpected error - {str(e)}"} diff --git a/chat/sippy_agent/web_server.py b/chat/sippy_agent/web_server.py deleted file mode 100644 index e02c0cbfed..0000000000 --- a/chat/sippy_agent/web_server.py +++ /dev/null @@ -1,677 +0,0 @@ -""" -FastAPI web server for Sippy Agent. -""" - -import asyncio -import json -import logging -import re -import time -from datetime import datetime -from typing import List, Dict, Any, Optional -import uvicorn -from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Response -from fastapi.middleware.cors import CORSMiddleware -from prometheus_client import generate_latest, CONTENT_TYPE_LATEST - -from .agent import SippyAgent, AgentManager -from .config import Config -from .api_models import ( - ChatRequest, - ChatResponse, - ChatMessage, - ThinkingStep, - StreamMessage, - AgentStatus, - HealthResponse, - PersonaInfo, - PersonasResponse, - ModelInfo, - ModelsResponse, - Visualization, -) -from . import metrics -from .metrics_server import start_metrics_server, stop_metrics_server -from .prompts import PromptManager, render_prompt - -logger = logging.getLogger(__name__) - - -class WebSocketManager: - """Manages WebSocket connections for streaming chat.""" - - def __init__(self): - self.active_connections: List[WebSocket] = [] - self.active_tasks: Dict[WebSocket, asyncio.Task] = {} - - async def connect(self, websocket: WebSocket): - await websocket.accept() - self.active_connections.append(websocket) - # Track active sessions - metrics.active_sessions.inc() - metrics.sessions_started_total.inc() - - def disconnect(self, websocket: WebSocket): - if websocket in self.active_connections: - self.active_connections.remove(websocket) - # Track active sessions - metrics.active_sessions.dec() - - # Cancel any active task for this websocket - if websocket in self.active_tasks: - task = self.active_tasks[websocket] - if not task.done(): - task.cancel() - logger.info("Cancelled active task for disconnected websocket") - del self.active_tasks[websocket] - - def set_active_task(self, websocket: WebSocket, task: asyncio.Task): - """Track the active task for a websocket connection.""" - self.active_tasks[websocket] = task - - def clear_active_task(self, websocket: WebSocket): - """Clear the active task for a websocket connection.""" - if websocket in self.active_tasks: - del self.active_tasks[websocket] - - async def send_message(self, websocket: WebSocket, message: StreamMessage): - try: - await websocket.send_text(message.model_dump_json()) - except Exception as e: - logger.error(f"Error sending WebSocket message: {e}") - self.disconnect(websocket) - - -class SippyWebServer: - """FastAPI web server for Sippy Agent.""" - - def __init__(self, config: Config, metrics_port: Optional[int] = None, models_config_path: Optional[str] = None): - self.config = config - self.metrics_port = metrics_port - self.agent_manager = AgentManager(config, models_config_path) - self.app = FastAPI( - title="Sippy AI Agent API", - description="REST API for Sippy CI/CD Analysis Agent", - version="1.0.0", - ) - self.websocket_manager = WebSocketManager() - - # Initialize prompt manager - self.prompt_manager = PromptManager() - - self._setup_middleware() - self._setup_routes() - - # Initialize agent info metrics - metrics.agent_info.info({ - "version": "1.0.0", - "model": config.model_name, - "endpoint": config.llm_endpoint, - "persona": config.persona, - }) - - def _setup_middleware(self): - """Setup CORS and other middleware.""" - self.app.add_middleware( - CORSMiddleware, - allow_origins=["*"], # Configure this for production - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) - - def _setup_routes(self): - """Setup API routes.""" - - @self.app.get("/health", response_model=HealthResponse) - async def health_check(): - """Health check endpoint.""" - return HealthResponse(status="healthy", version="1.0.0", agent_ready=True) - - @self.app.get("/metrics") - async def prometheus_metrics(): - """Prometheus metrics endpoint.""" - return Response( - content=generate_latest(), - media_type=CONTENT_TYPE_LATEST - ) - - @self.app.get("/chat/models", response_model=ModelsResponse) - async def get_models(): - """Get list of available models.""" - models_list = self.agent_manager.list_models() - return ModelsResponse( - models=[ModelInfo(**model) for model in models_list], - default_model=self.agent_manager.get_default_model_id(), - ) - - @self.app.get("/status", response_model=AgentStatus) - async def get_agent_status(): - """Get agent status and configuration.""" - from .personas import list_persona_names - - # Get default agent for status info - default_agent = await self.agent_manager.get_agent() - - return AgentStatus( - available_tools=default_agent.list_tools(), - model_name=self.config.model_name, - endpoint=self.config.llm_endpoint, - thinking_enabled=self.config.show_thinking, - current_persona=self.config.persona, - available_personas=list_persona_names(), - ) - - @self.app.get("/chat/personas", response_model=PersonasResponse) - async def get_personas(): - """Get list of available personas.""" - from .personas import PERSONAS - - personas = [ - PersonaInfo( - name=name, - description=p.description, - style_instructions=p.style_instructions, - ) - for name, p in PERSONAS.items() - ] - - return PersonasResponse( - personas=personas, current_persona=self.config.persona - ) - - @self.app.get("/chat/prompts") - async def get_prompts(): - """Get list of available prompt templates.""" - return {"prompts": self.prompt_manager.list_prompts()} - - @self.app.post("/chat/prompts/render") - async def render_prompt_endpoint(request: dict): - """Render a prompt template with provided arguments.""" - prompt_name = request.get("prompt_name") - arguments = request.get("arguments", {}) - - if not prompt_name: - return {"error": "prompt_name is required"}, 400 - - prompt_data = self.prompt_manager.get_prompt(prompt_name) - if not prompt_data: - return {"error": f"Prompt '{prompt_name}' not found"}, 404 - - # Use the render_prompt function from prompts module - rendered_text = render_prompt(prompt_data, arguments) - - return { - "rendered": rendered_text - } - - @self.app.post("/chat", response_model=ChatResponse) - async def chat(request: ChatRequest): - """Process a chat message and return the response.""" - # Track message received - metrics.messages_received_total.labels(endpoint="http").inc() - - # Track request message size - request_size = len(request.message.encode('utf-8')) - metrics.message_size_bytes.labels(direction="request").observe(request_size) - - start_time = time.time() - try: - # Get the appropriate agent for the requested model - agent = await self.agent_manager.get_agent(request.model_id) - # Determine which model is actually being used - model_id = request.model_id or self.agent_manager.get_default_model_id() - - # Process the message with request-specific persona and show_thinking - # These are passed as parameters, not mutating the shared config - result = await agent.achat( - request.message, - request.chat_history, - persona=request.persona, - show_thinking=request.show_thinking, - ) - - # Process the response using common method - processed = self._process_agent_response(result) - - # Track response duration - duration = time.time() - start_time - metrics.response_duration_seconds.labels(endpoint="http").observe(duration) - - return ChatResponse( - response=processed["response_text"], - thinking_steps=processed["thinking_steps"], - tools_used=processed["tools_used"], - visualizations=processed["visualizations"], - model_id=model_id, - ) - - except Exception as e: - logger.error(f"Error processing chat request: {e}") - metrics.errors_total.labels(error_type="processing_error").inc() - return ChatResponse( - response="I encountered an error while processing your request.", - error=str(e), - ) - - @self.app.websocket("/chat/stream") - async def websocket_chat(websocket: WebSocket): - """WebSocket endpoint for streaming chat with real-time thinking.""" - await self.websocket_manager.connect(websocket) - - try: - while True: - # Receive message from client - data = await websocket.receive_text() - request_data = json.loads(data) - - # Track message received - metrics.messages_received_total.labels(endpoint="websocket").inc() - - # Parse request - message = request_data.get("message", "") - - # Track request message size - request_size = len(message.encode('utf-8')) - metrics.message_size_bytes.labels(direction="request").observe(request_size) - chat_history_data = request_data.get("chat_history", []) - chat_history = [ChatMessage(**msg) for msg in chat_history_data] - show_thinking = request_data.get( - "show_thinking", self.config.show_thinking - ) - persona = request_data.get("persona", self.config.persona) - model_id = request_data.get("model_id") - page_context = request_data.get("page_context") - - logger.info(f"Received page context: {page_context}") - - # Start timing the response - start_time = time.time() - - # Get the appropriate agent for the requested model - try: - agent = await self.agent_manager.get_agent(model_id) - except Exception as e: - logger.error(f"Error getting agent for model '{model_id}': {e}") - metrics.errors_total.labels(error_type="agent_initialization_error").inc() - await self.websocket_manager.send_message( - websocket, - StreamMessage( - type="error", - data={ - "error": str(e), - "timestamp": datetime.now().isoformat(), - }, - ), - ) - continue - - # Determine which model is actually being used - actual_model_id = model_id or self.agent_manager.get_default_model_id() - - async def process_message(): - """Process the message - can be cancelled if websocket disconnects.""" - try: - # Track step number for streaming - step_counter = {"count": 0} - # Map tool calls to their step numbers for parallel execution - tool_call_steps = {} - - # Define async thinking callback for real-time streaming - async def thinking_callback( - thought: str, - action: str, - action_input: str, - observation: str, - ): - """Stream thinking steps in real-time over WebSocket.""" - # For "thinking" actions (Gemini thoughts), mark as complete immediately - # For tool calls, only complete when we have an observation - is_complete = action == "thinking" or bool(observation) - - # Create a unique key for this tool call based on action and input - tool_key = f"{action}:{action_input}" - - # Only increment step counter on new calls (no observation yet) - if not observation: - step_counter["count"] += 1 - current_step = step_counter["count"] - # Store the step number for this call - tool_call_steps[tool_key] = current_step - else: - # Retrieve the step number for this call - current_step = tool_call_steps.get( - tool_key, step_counter["count"] - ) - - # Send the thinking step immediately - await self.websocket_manager.send_message( - websocket, - StreamMessage( - type="thinking_step", - data={ - "step_number": current_step, - "thought": thought, - "action": action, - "action_input": action_input, - "observation": observation, - "complete": is_complete, - }, - ), - ) - - # Enhance message with page context if provided - enhanced_message = message - if page_context: - context_str = self._format_page_context(page_context) - enhanced_message = ( - f"{context_str}\n\nUser question: {message}" - ) - logger.info( - f"Enhanced message with context: {enhanced_message[:200]}..." - ) - - # Process message with streaming callback - # Pass persona and show_thinking as parameters, not mutating config - result = await agent.achat( - enhanced_message, - chat_history, - thinking_callback=( - thinking_callback if show_thinking else None - ), - persona=persona, - show_thinking=show_thinking, - ) - - # Process the response using common method - processed = self._process_agent_response(result) - - await self.websocket_manager.send_message( - websocket, - StreamMessage( - type="final_response", - data={ - "response": processed["response_text"], - "tools_used": processed["tools_used"], - "visualizations": [ - v.model_dump() for v in processed["visualizations"] - ] if processed["visualizations"] else [], - "model_id": actual_model_id, - "timestamp": datetime.now().isoformat(), - }, - ), - ) - - except asyncio.CancelledError: - logger.info("Message processing cancelled by client") - metrics.cancelled_requests_total.labels(endpoint="websocket").inc() - raise - except Exception as e: - logger.error(f"Error in WebSocket chat: {e}") - metrics.errors_total.labels(error_type="agent_error").inc() - await self.websocket_manager.send_message( - websocket, - StreamMessage( - type="error", - data={ - "error": str(e), - "timestamp": datetime.now().isoformat(), - }, - ), - ) - - try: - # Create and track the processing task - task = asyncio.create_task(process_message()) - self.websocket_manager.set_active_task(websocket, task) - - # Wait for the task to complete - await task - - except asyncio.CancelledError: - logger.info("Task cancelled, client stopped generation") - # Task was cancelled, which is fine - pass - finally: - # Clear the task tracking - self.websocket_manager.clear_active_task(websocket) - - # Track response duration - duration = time.time() - start_time - metrics.response_duration_seconds.labels(endpoint="websocket").observe(duration) - - except WebSocketDisconnect: - self.websocket_manager.disconnect(websocket) - except Exception as e: - logger.error(f"WebSocket error: {e}") - metrics.errors_total.labels(error_type="websocket_error").inc() - self.websocket_manager.disconnect(websocket) - - def _format_page_context(self, page_context: Dict[str, Any]) -> str: - """Format page context as JSON for the agent.""" - if not page_context: - return "" - - # Extract special fields - instructions = page_context.get("instructions", "") - - # Create a copy without instructions and suggestedQuestions for the data section - data_context = { - k: v - for k, v in page_context.items() - if k not in ["instructions", "suggestedQuestions"] - } - - context_str = "[Current Page Context]\n" - context_str += "The user is viewing the following page. Use this context to better answer their question:\n\n" - context_str += json.dumps(data_context, indent=2) - - # Append page-specific instructions if present - if instructions: - context_str += "\n\n[Page-Specific Instructions]\n" - context_str += instructions - - return context_str - - def _extract_tools_used(self, thinking_steps: List[Dict[str, Any]]) -> List[str]: - """Extract unique tool names from thinking steps.""" - tools = set() - for step in thinking_steps: - action = step.get("action", "") - if action and action not in ["_Exception", "Invalid", "Error"]: - tools.add(action) - return list(tools) - - def _extract_visualizations_from_text(self, text: str) -> List[Visualization]: - """Extract visualization specifications from text content. - - Looks for JSON blocks between VISUALIZATION_START and VISUALIZATION_END markers. - """ - visualizations = [] - - if not text or not isinstance(text, str): - return visualizations - - # Find all visualization blocks in the text - start_marker = "VISUALIZATION_START" - end_marker = "VISUALIZATION_END" - - current_pos = 0 - while True: - start_idx = text.find(start_marker, current_pos) - if start_idx == -1: - break - - end_idx = text.find(end_marker, start_idx) - if end_idx == -1: - logger.warning("Found VISUALIZATION_START without matching VISUALIZATION_END") - break - - try: - # Extract JSON between markers - viz_start = start_idx + len(start_marker) - viz_json = text[viz_start:end_idx].strip() - - # Parse the JSON - viz_data = json.loads(viz_json) - - # Get layout and add AI-generated annotation - layout = viz_data.get("layout", {}) - - # Ensure top margin is sufficient for the title and subtitle - if "margin" not in layout: - layout["margin"] = {} - if "t" not in layout["margin"] or layout["margin"]["t"] < 80: - layout["margin"]["t"] = 80 - - # Add AI-generated caption as an annotation below the title - if "annotations" not in layout: - layout["annotations"] = [] - - # Position the caption in the margin area, closer to the title - # y > 1.0 places it in the top margin area - layout["annotations"].append({ - "text": "Generated with AI by Sippy Chat", - "xref": "paper", - "yref": "paper", - "x": 0.5, - "y": 1.00, # Just above the plot area in the margin - "xanchor": "center", - "yanchor": "bottom", - "showarrow": False, - "font": {"size": 10, "color": "#666666"} - }) - - # Create Visualization object - visualization = Visualization( - data=viz_data.get("data", []), - layout=layout, - config=viz_data.get("config"), - ) - visualizations.append(visualization) - - logger.info(f"Extracted visualization from response text") - except (json.JSONDecodeError, ValueError, KeyError) as e: - logger.warning(f"Failed to parse visualization: {e}") - - # Move past this visualization block - current_pos = end_idx + len(end_marker) - - return visualizations - - def _extract_visualizations(self, response_text: str) -> List[Visualization]: - """Extract visualizations from response text only (not from tool observations).""" - visualizations = [] - - # Extract from main response text only - if response_text: - visualizations.extend(self._extract_visualizations_from_text(response_text)) - - return visualizations - - def _strip_visualization_markers(self, text: str) -> str: - """Remove VISUALIZATION_START...VISUALIZATION_END blocks from text.""" - if not text or not isinstance(text, str): - return text - - # Remove all visualization blocks (non-greedy match) - cleaned = re.sub( - r'VISUALIZATION_START[\s\S]*?VISUALIZATION_END', - '', - text, - flags=re.MULTILINE - ) - return cleaned.strip() - - def _process_agent_response(self, result: Any) -> Dict[str, Any]: - """ - Process agent response and extract all components. - - Args: - result: The result from agent.achat() - can be dict with thinking_steps or simple string - - Returns: - Dict containing: response_text, thinking_steps (API format), tools_used, visualizations - """ - if isinstance(result, dict) and "thinking_steps" in result: - # Response with thinking steps - response_text = result["output"] - thinking_steps = result["thinking_steps"] - tools_used = self._extract_tools_used(thinking_steps) - - # Convert thinking steps to API format - api_thinking_steps = [] - for i, step in enumerate(thinking_steps, 1): - api_thinking_steps.append( - ThinkingStep( - step_number=i, - thought=step.get("thought", ""), - action=step.get("action", ""), - action_input=step.get("action_input", ""), - observation=step.get("observation", ""), - ) - ) - thinking_steps = api_thinking_steps - else: - # Simple response without thinking steps - response_text = result - thinking_steps = None - tools_used = [] - - # Track response size metrics - response_size = len(response_text.encode('utf-8')) - metrics.message_size_bytes.labels(direction="response").observe(response_size) - - # Extract visualizations and strip markers from response - visualizations = self._extract_visualizations(response_text) - clean_response = self._strip_visualization_markers(response_text) - - return { - "response_text": clean_response, - "thinking_steps": thinking_steps, - "tools_used": tools_used, - "visualizations": visualizations or None, - } - - def run(self, host: str = "0.0.0.0", port: int = 8000, reload: bool = False): - """Run the web server.""" - # Start separate metrics server if port is specified - if self.metrics_port: - logger.info(f"Starting metrics server on port {self.metrics_port}") - start_metrics_server(host="0.0.0.0", port=self.metrics_port) - - try: - if reload: - # For reload mode, use the module path - uvicorn.run( - "sippy_agent.web_server:app", - host=host, - port=port, - reload=reload, - log_level="info", - ) - else: - # For non-reload mode, use the app instance directly - uvicorn.run(self.app, host=host, port=port, log_level="info") - finally: - # Stop metrics server on shutdown - if self.metrics_port: - stop_metrics_server() - - -# Global app instance for uvicorn - initialized lazily -app = None - - -def get_app() -> FastAPI: - """Get or create the FastAPI app instance.""" - global app - if app is None: - config = Config.from_env() - server = SippyWebServer(config) - app = server.app - return app - - -# Initialize the app for uvicorn -app = get_app() diff --git a/cmd/sippy/component_readiness.go b/cmd/sippy/component_readiness.go index 43aff055b9..558cb0d664 100644 --- a/cmd/sippy/component_readiness.go +++ b/cmd/sippy/component_readiness.go @@ -213,7 +213,6 @@ func (f *ComponentReadinessFlags) runServerMode() error { views, config, f.APIFlags.EnableWriteEndpoints, - "", // No chat API in Component Readiness jiraClient, ) diff --git a/cmd/sippy/serve.go b/cmd/sippy/serve.go index ee1cac36aa..f88062f8e1 100644 --- a/cmd/sippy/serve.go +++ b/cmd/sippy/serve.go @@ -193,7 +193,6 @@ func NewServeCommand() *cobra.Command { views, config, f.APIFlags.EnableWriteEndpoints, - f.APIFlags.ChatAPIURL, jiraClient, ) diff --git a/pkg/db/db.go b/pkg/db/db.go index 8a0e2810df..c6922e961c 100644 --- a/pkg/db/db.go +++ b/pkg/db/db.go @@ -175,8 +175,6 @@ func (d *DB) UpdateSchema(reportEnd *time.Time) error { &models.Triage{}, &models.TriageSymptom{}, &models.AuditLog{}, - &models.ChatRating{}, - &models.ChatConversation{}, &jobrunscan.Label{}, &jobrunscan.Symptom{}, } diff --git a/pkg/db/models/chat.go b/pkg/db/models/chat.go deleted file mode 100644 index d987d66b49..0000000000 --- a/pkg/db/models/chat.go +++ /dev/null @@ -1,33 +0,0 @@ -package models - -import ( - "time" - - "github.com/google/uuid" - "github.com/jackc/pgtype" - "gorm.io/gorm" -) - -// ChatConversation stores shared chat conversation history -type ChatConversation struct { - ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey;default:gen_random_uuid()"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `json:"deleted_at,omitempty" gorm:"index"` - - // User who shared/created this conversation - User string `json:"user" gorm:"not null;index"` - - // ParentID is the UUID of the conversation this was forked from, if any - ParentID *uuid.UUID `json:"parent_id,omitempty" gorm:"type:uuid;index"` - - // Messages contains the full conversation history in JSONB format - Messages pgtype.JSONB `json:"messages" gorm:"type:jsonb;not null"` - - // Metadata stores additional information like persona, page context, etc - Metadata pgtype.JSONB `json:"metadata,omitempty" gorm:"type:jsonb"` - - // Links contains REST links for clients to follow. Most notably "self". - // These are injected by the API and not stored in the DB. - Links map[string]string `json:"links,omitempty" gorm:"-"` -} diff --git a/pkg/db/models/chat_ratings.go b/pkg/db/models/chat_ratings.go deleted file mode 100644 index c801dfd171..0000000000 --- a/pkg/db/models/chat_ratings.go +++ /dev/null @@ -1,21 +0,0 @@ -package models - -import ( - "github.com/google/uuid" - "github.com/jackc/pgtype" -) - -// ChatRating stores user feedback ratings for chat interactions -type ChatRating struct { - Model - - // Rating is the star rating given by the user (1-5) - Rating int `json:"rating" gorm:"not null"` - - // ClientID is a unique anonymous identifier - ClientID uuid.UUID `json:"clientId" gorm:"type:uuid;index"` - - // Metadata contains additional information about the chat session - // such as message counts, tool calls, LLM thoughts, and interaction size - Metadata pgtype.JSONB `json:"metadata" gorm:"type:jsonb"` -} diff --git a/pkg/flags/api.go b/pkg/flags/api.go index b2590dcf36..dfd66d0e87 100644 --- a/pkg/flags/api.go +++ b/pkg/flags/api.go @@ -9,7 +9,6 @@ type APIFlags struct { EnableWriteEndpoints bool ListenAddr string MetricsAddr string - ChatAPIURL string } func NewAPIFlags() *APIFlags { @@ -23,5 +22,4 @@ func (f *APIFlags) BindFlags(fs *pflag.FlagSet) { fs.BoolVar(&f.EnableWriteEndpoints, "enable-write-endpoints", false, "Enable write-endpoints for triage etc") fs.StringVar(&f.ListenAddr, "listen", f.ListenAddr, "The address to serve analysis reports on (default :8080)") fs.StringVar(&f.MetricsAddr, "listen-metrics", f.MetricsAddr, "The address to serve prometheus metrics on (default :2112)") - fs.StringVar(&f.ChatAPIURL, "chat-api", f.ChatAPIURL, "URL of the sippy-chat service to proxy chat requests to") } diff --git a/pkg/sippyserver/capabilities.go b/pkg/sippyserver/capabilities.go index fb86a4347c..d8ce733249 100644 --- a/pkg/sippyserver/capabilities.go +++ b/pkg/sippyserver/capabilities.go @@ -16,7 +16,4 @@ const ( // WriteEndpointsCapability is whether we have enabled write APIs on this server. WriteEndpointsCapability = "write_endpoints" - - // ChatCapability is whether this sippy instance is configured to proxy chat requests to sippy-chat service. - ChatCapability = "chat" ) diff --git a/pkg/sippyserver/chat_conversations.go b/pkg/sippyserver/chat_conversations.go deleted file mode 100644 index df058615ed..0000000000 --- a/pkg/sippyserver/chat_conversations.go +++ /dev/null @@ -1,163 +0,0 @@ -package sippyserver - -import ( - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/google/uuid" - "github.com/gorilla/mux" - "github.com/jackc/pgtype" - "github.com/openshift/sippy/pkg/api" - "github.com/openshift/sippy/pkg/db/models" - log "github.com/sirupsen/logrus" -) - -const ( - // MaxConversationSizeBytes is the maximum size of a conversation's messages in bytes (4MB) - MaxConversationSizeBytes = 4194304 -) - -// CreateChatConversationRequest is the request payload for creating a new chat conversation -type CreateChatConversationRequest struct { - Messages []map[string]interface{} `json:"messages"` - Metadata map[string]interface{} `json:"metadata,omitempty"` - ParentID *uuid.UUID `json:"parent_id,omitempty"` -} - -// ChatConversationResponse is the response for a chat conversation with HATEOAS links -type ChatConversationResponse struct { - ID uuid.UUID `json:"id"` - CreatedAt time.Time `json:"created_at"` - User string `json:"user"` - Links map[string]string `json:"links"` -} - -// jsonCreateChatConversation handles POST requests to save a new chat conversation -func (s *Server) jsonCreateChatConversation(w http.ResponseWriter, req *http.Request) { - user := getUserForRequest(req) - if user == "" { - failureResponse(w, http.StatusUnauthorized, "User authentication required") - return - } - - chatLog := log.WithFields(log.Fields{ - "user": user, - }) - chatLog.Info("creating chat conversation") - - var request CreateChatConversationRequest - if err := json.NewDecoder(req.Body).Decode(&request); err != nil { - chatLog.WithError(err).Error("error parsing chat conversation request") - failureResponse(w, http.StatusBadRequest, "Invalid JSON: "+err.Error()) - return - } - - // Validate messages - if len(request.Messages) == 0 { - chatLog.Error("no messages provided") - failureResponse(w, http.StatusBadRequest, "Messages are required") - return - } - - // Marshal messages to JSON - messagesJSON, err := json.Marshal(request.Messages) - if err != nil { - chatLog.WithError(err).Error("error marshaling messages") - failureResponse(w, http.StatusInternalServerError, "Failed to process messages") - return - } - - // Reject payloads larger than MaxConversationSizeBytes to prevent abuse - if len(messagesJSON) > MaxConversationSizeBytes { - failureResponse(w, http.StatusBadRequest, fmt.Sprintf("Conversation too large (maximum %d bytes)", MaxConversationSizeBytes)) - return - } - - // Marshal metadata to JSON if provided - var metadataJSONB pgtype.JSONB - if request.Metadata != nil { - metadataJSON, err := json.Marshal(request.Metadata) - if err != nil { - chatLog.WithError(err).Error("error marshaling metadata") - failureResponse(w, http.StatusInternalServerError, "Failed to process metadata") - return - } - if err := metadataJSONB.Set(metadataJSON); err != nil { - chatLog.WithError(err).Error("error setting metadata JSONB") - failureResponse(w, http.StatusInternalServerError, "Failed to process metadata") - return - } - } - - // Create the conversation - conversation := models.ChatConversation{ - User: user, - ParentID: request.ParentID, - } - if err := conversation.Messages.Set(messagesJSON); err != nil { - chatLog.WithError(err).Error("error setting messages JSONB") - failureResponse(w, http.StatusInternalServerError, "Failed to process messages") - return - } - if request.Metadata != nil { - conversation.Metadata = metadataJSONB - } - - if err := s.db.DB.Create(&conversation).Error; err != nil { - chatLog.WithError(err).Error("error creating chat conversation") - failureResponse(w, http.StatusInternalServerError, "Failed to save conversation") - return - } - - baseURL := api.GetBaseURL(req) - response := ChatConversationResponse{ - ID: conversation.ID, - CreatedAt: conversation.CreatedAt, - User: conversation.User, - Links: map[string]string{ - "self": fmt.Sprintf("%s/api/chat/conversations/%s", baseURL, conversation.ID.String()), - }, - } - - chatLog.WithFields(log.Fields{ - "conversationID": conversation.ID, - }).Info("chat conversation created") - - api.RespondWithJSON(http.StatusCreated, w, response) -} - -// jsonGetChatConversation handles GET requests to retrieve a chat conversation by ID -func (s *Server) jsonGetChatConversation(w http.ResponseWriter, req *http.Request) { - vars := mux.Vars(req) - idStr := vars["id"] - - conversationID, err := uuid.Parse(idStr) - if err != nil { - failureResponse(w, http.StatusBadRequest, "Invalid conversation ID format") - return - } - - chatLog := log.WithFields(log.Fields{ - "conversationID": conversationID, - }) - - var conversation models.ChatConversation - if err := s.db.DB.First(&conversation, "id = ?", conversationID).Error; err != nil { - chatLog.WithError(err).Warn("conversation not found") - failureResponse(w, http.StatusNotFound, "Conversation not found") - return - } - - // Add HATEOAS links - baseURL := api.GetBaseURL(req) - conversation.Links = map[string]string{ - "self": fmt.Sprintf("%s/api/chat/conversations/%s", baseURL, conversation.ID.String()), - } - if conversation.ParentID != nil { - conversation.Links["parent"] = fmt.Sprintf("%s/api/chat/conversations/%s", baseURL, conversation.ParentID.String()) - } - - api.RespondWithJSON(http.StatusOK, w, conversation) -} diff --git a/pkg/sippyserver/chatproxy.go b/pkg/sippyserver/chatproxy.go deleted file mode 100644 index e07e903ec8..0000000000 --- a/pkg/sippyserver/chatproxy.go +++ /dev/null @@ -1,160 +0,0 @@ -package sippyserver - -import ( - "net/http" - "net/http/httputil" - "net/url" - "strings" - - "github.com/gorilla/websocket" - log "github.com/sirupsen/logrus" -) - -// rewriteChatPath rewrites /api/chat paths to /chat for the target service -func rewriteChatPath(path string) string { - // Only replace /api/chat when it's followed by end of string or a slash - if path == "/api/chat" { - return "/chat" - } - if strings.HasPrefix(path, "/api/chat/") { - return "/chat" + strings.TrimPrefix(path, "/api/chat") - } - return path -} - -// ChatProxy handles proxying HTTP and WebSocket requests to the sippy-chat service -type ChatProxy struct { - chatAPIURL string - httpProxy *httputil.ReverseProxy - wsUpgrader websocket.Upgrader -} - -// NewChatProxy creates a new chat proxy instance -func NewChatProxy(chatAPIURL string) (*ChatProxy, error) { - targetURL, err := url.Parse(chatAPIURL) - if err != nil { - return nil, err - } - - // Create HTTP reverse proxy - httpProxy := httputil.NewSingleHostReverseProxy(targetURL) //nolint:gosec // G704: targetURL is from --chat-api CLI flag set at server startup - - // Modify the director to handle the path rewriting - originalDirector := httpProxy.Director - httpProxy.Director = func(req *http.Request) { - originalDirector(req) - // Rewrite /api/chat paths for the target service - req.URL.Path = rewriteChatPath(req.URL.Path) - req.Host = targetURL.Host - } - - // Configure WebSocket upgrader - wsUpgrader := websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { - // Allow all origins for now - in production you might want to be more restrictive - return true - }, - } - - return &ChatProxy{ - chatAPIURL: chatAPIURL, - httpProxy: httpProxy, - wsUpgrader: wsUpgrader, - }, nil -} - -// ServeHTTP handles both HTTP and WebSocket requests -func (cp *ChatProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // Check if this is a WebSocket upgrade request - if isWebSocketUpgrade(r) { - cp.handleWebSocket(w, r) - return - } - - // Handle regular HTTP request - cp.httpProxy.ServeHTTP(w, r) //nolint:gosec // G704: proxy target is fixed at init from --chat-api CLI flag, request path is rewritten not proxied raw -} - -// isWebSocketUpgrade checks if the request is a WebSocket upgrade request -func isWebSocketUpgrade(r *http.Request) bool { - return strings.ToLower(r.Header.Get("Connection")) == "upgrade" && - strings.ToLower(r.Header.Get("Upgrade")) == "websocket" -} - -// handleWebSocket handles WebSocket proxy connections -func (cp *ChatProxy) handleWebSocket(w http.ResponseWriter, r *http.Request) { - // Parse target URL - targetURL, err := url.Parse(cp.chatAPIURL) - if err != nil { - log.WithError(err).Error("Failed to parse chat API URL") - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - - // Build target WebSocket URL - wsScheme := "ws" - if targetURL.Scheme == "https" { - wsScheme = "wss" - } - - targetPath := rewriteChatPath(r.URL.Path) - targetWSURL := wsScheme + "://" + targetURL.Host + targetPath - if r.URL.RawQuery != "" { - targetWSURL += "?" + r.URL.RawQuery - } - - // Upgrade the client connection - clientConn, err := cp.wsUpgrader.Upgrade(w, r, nil) - if err != nil { - log.WithError(err).Error("Failed to upgrade client connection") - return - } - defer clientConn.Close() - - // Connect to the target WebSocket - targetConn, _, err := websocket.DefaultDialer.Dial(targetWSURL, nil) - if err != nil { - log.WithError(err).Error("Failed to connect to target WebSocket") - return - } - defer targetConn.Close() - - // Start proxying messages in both directions - errChan := make(chan error, 2) - - // Proxy messages from client to target - go func() { - for { - messageType, message, err := clientConn.ReadMessage() - if err != nil { - errChan <- err - return - } - - if err := targetConn.WriteMessage(messageType, message); err != nil { - errChan <- err - return - } - } - }() - - // Proxy messages from target to client - go func() { - for { - messageType, message, err := targetConn.ReadMessage() - if err != nil { - errChan <- err - return - } - - if err := clientConn.WriteMessage(messageType, message); err != nil { - errChan <- err - return - } - } - }() - - // Wait for either connection to close or error - <-errChan - log.Debug("WebSocket proxy connection closed") -} diff --git a/pkg/sippyserver/server.go b/pkg/sippyserver/server.go index 7a481b9fcf..c43eea7928 100644 --- a/pkg/sippyserver/server.go +++ b/pkg/sippyserver/server.go @@ -96,7 +96,6 @@ func NewServer( views *apitype.SippyViews, config *v1.SippyConfig, enableWriteEndpoints bool, - chatAPIURL string, jiraClient *jira.Client, ) *Server { @@ -121,7 +120,6 @@ func NewServer( views: views, config: config, enableWriteAPIs: enableWriteEndpoints, - chatAPIURL: chatAPIURL, jiraClient: jiraClient, } @@ -182,7 +180,6 @@ type Server struct { views *apitype.SippyViews config *v1.SippyConfig enableWriteAPIs bool - chatAPIURL string jiraClient *jira.Client rateLimiters map[string]*rateLimiter } @@ -470,10 +467,6 @@ func (s *Server) determineCapabilities() { capabilities = append(capabilities, WriteEndpointsCapability) } - if s.chatAPIURL != "" { - capabilities = append(capabilities, ChatCapability) - } - s.capabilities = capabilities } @@ -3026,64 +3019,6 @@ func (s *Server) Serve() { CacheTime: 4 * time.Hour, HandlerFunc: s.jsonFeatureGateDetail, }, - { - EndpointPath: "/api/chat", - Description: "HTTP proxy for REST API requests to sippy-chat service", - Capabilities: []string{ChatCapability}, - HandlerFunc: s.handleChatProxy, - }, - { - EndpointPath: "/api/chat/stream", - Description: "Websocket proxy for chat API requests to sippy-chat service (supports HTTP and WebSocket)", - Capabilities: []string{ChatCapability}, - HandlerFunc: s.handleChatProxy, - }, - { - EndpointPath: "/api/chat/personas", - Description: "Proxy for listing personas from sippy-chat service.", - Capabilities: []string{ChatCapability}, - HandlerFunc: s.handleChatProxy, - }, - { - EndpointPath: "/api/chat/models", - Description: "Proxy for listing available models from sippy-chat service.", - Capabilities: []string{ChatCapability}, - HandlerFunc: s.handleChatProxy, - }, - { - EndpointPath: "/api/chat/prompts", - Description: "Proxy for listing available prompt templates from sippy-chat service.", - Capabilities: []string{ChatCapability}, - HandlerFunc: s.handleChatProxy, - }, - { - EndpointPath: "/api/chat/prompts/render", - Description: "Proxy for rendering prompt templates from sippy-chat service.", - Methods: []string{http.MethodPost}, - Capabilities: []string{ChatCapability}, - HandlerFunc: s.handleChatProxy, - }, - { - EndpointPath: "/api/chat/ratings", - Description: "Create a chat rating record", - Methods: []string{http.MethodPost}, - Capabilities: []string{LocalDBCapability, ChatCapability, WriteEndpointsCapability}, - HandlerFunc: s.jsonCreateChatRating, - }, - { - EndpointPath: "/api/chat/conversations", - Description: "Create a new chat conversation", - Methods: []string{http.MethodPost}, - Capabilities: []string{ChatCapability, WriteEndpointsCapability}, - HandlerFunc: s.jsonCreateChatConversation, - }, - { - EndpointPath: "/api/chat/conversations/{id}", - Description: "Get a specific chat conversation by ID", - Methods: []string{http.MethodGet}, - Capabilities: []string{ChatCapability}, - HandlerFunc: s.jsonGetChatConversation, - }, } for _, ep := range endpoints { @@ -3180,7 +3115,7 @@ func (w *statusCapturingResponseWriter) WriteHeader(code int) { w.ResponseWriter.WriteHeader(code) } -// Hijack delegates to the underlying ResponseWriter so gorilla/websocket can upgrade connections (e.g. /api/chat/stream). +// Hijack delegates to the underlying ResponseWriter so gorilla/websocket can upgrade connections. func (w *statusCapturingResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { if hj, ok := w.ResponseWriter.(http.Hijacker); ok { return hj.Hijack() @@ -3311,42 +3246,3 @@ func recordResponse(c cache.Cache, duration time.Duration, w http.ResponseWriter func (s *Server) GetHTTPServer() *http.Server { return s.httpServer } - -// handleChatProxy handles proxying requests to the sippy-chat service -func (s *Server) handleChatProxy(w http.ResponseWriter, r *http.Request) { - if s.chatAPIURL == "" { - http.Error(w, "Chat API not configured", http.StatusServiceUnavailable) - return - } - - // Create chat proxy if not already created - chatProxy, err := NewChatProxy(s.chatAPIURL) - if err != nil { - log.WithError(err).Error("Failed to create chat proxy") - http.Error(w, "Failed to initialize chat proxy", http.StatusInternalServerError) - return - } - - // Proxy the request - chatProxy.ServeHTTP(w, r) -} - -// jsonCreateChatRating handles POST requests to create a new chat rating record -func (s *Server) jsonCreateChatRating(w http.ResponseWriter, req *http.Request) { - var rating models.ChatRating - if err := json.NewDecoder(req.Body).Decode(&rating); err != nil { - log.WithError(err).Error("error parsing chat rating") - failureResponse(w, http.StatusBadRequest, err.Error()) - return - } - - // Create the rating in the database - if err := s.db.DB.Create(&rating).Error; err != nil { - log.WithError(err).Error("error creating chat rating") - failureResponse(w, http.StatusInternalServerError, "failed to create rating") - return - } - - log.Infof("created chat rating with ID %d, rating: %d", rating.ID, rating.Rating) - api.RespondWithJSON(http.StatusCreated, w, rating) -} diff --git a/sippy-ng/.env.development b/sippy-ng/.env.development index 8a9c5774fe..09c2a50ac4 100644 --- a/sippy-ng/.env.development +++ b/sippy-ng/.env.development @@ -1,2 +1 @@ VITE_API_URL="http://127.0.0.1:8080" -VITE_CHAT_API_URL="http://127.0.0.1:8000/chat" diff --git a/sippy-ng/.env.production b/sippy-ng/.env.production index 9d869f9557..b722c57040 100644 --- a/sippy-ng/.env.production +++ b/sippy-ng/.env.production @@ -1,2 +1 @@ VITE_API_URL="" -VITE_CHAT_API_URL="/api/chat" diff --git a/sippy-ng/package-lock.json b/sippy-ng/package-lock.json index 3414d68a0c..69e10d354e 100644 --- a/sippy-ng/package-lock.json +++ b/sippy-ng/package-lock.json @@ -33,7 +33,6 @@ "date-fns": "^2.0.0-beta.5", "date-fns-tz": "^1.1.6", "eventemitter3": "^5.0.1", - "idb-keyval": "^6.2.2", "p-limit": "^3.1.0", "plotly.js": "^3.1.1", "prop-types": "^15.8.1", @@ -43,15 +42,13 @@ "react-cookie": "^7.2.1", "react-dom": "^18.3.1", "react-error-boundary": "^5.0.0", - "react-joyride": "^3.2.0", "react-markdown": "^8.0.7", "react-plotly.js": "^2.6.0", "react-router-dom": "^7.18.1", "remark-gfm": "^3.0.1", "timelines-chart": "^2.12.1", "universal-cookie": "^7.2.1", - "use-query-params": "^2.2.2", - "zustand": "^4.4.7" + "use-query-params": "^2.2.2" }, "devDependencies": { "@babel/core": "^7.15.0", @@ -2743,22 +2740,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@fastify/deepmerge": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz", - "integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/@floating-ui/core": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", @@ -2823,45 +2804,6 @@ "react-dom": "^16.7.0 || ^17 || ^18" } }, - "node_modules/@gilbarbara/deep-equal": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.4.1.tgz", - "integrity": "sha512-QF2BGeQjsa59T59XvFdR3is5jrl28Eg0J6giXAC5919bcqvR8XP4B+07tpbs6Y6/IQd4FBncaL2WVXIBgSxt4w==", - "license": "MIT" - }, - "node_modules/@gilbarbara/hooks": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@gilbarbara/hooks/-/hooks-0.11.0.tgz", - "integrity": "sha512-CIVazdxqFRplUfm9wZL3/0X1TURJekhPMWGFdWzEmyJrGPiotX2yxA1KiB8N7VnhawIaMtb2Apnda4Y6DRwi2Q==", - "license": "MIT", - "dependencies": { - "@gilbarbara/deep-equal": "^0.4.1" - }, - "peerDependencies": { - "react": "16.8 - 19" - } - }, - "node_modules/@gilbarbara/types": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@gilbarbara/types/-/types-0.2.2.tgz", - "integrity": "sha512-QuQDBRRcm1Q8AbSac2W1YElurOhprj3Iko/o+P1fJxUWS4rOGKMVli98OXS7uo4z+cKAif6a+L9bcZFSyauQpQ==", - "license": "MIT", - "dependencies": { - "type-fest": "^4.1.0" - } - }, - "node_modules/@gilbarbara/types/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -8791,12 +8733,6 @@ "node": ">=0.10.0" } }, - "node_modules/idb-keyval": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz", - "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==", - "license": "Apache-2.0" - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -8827,17 +8763,6 @@ "node": ">= 4" } }, - "node_modules/immer": { - "version": "9.0.16", - "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.16.tgz", - "integrity": "sha512-qenGE7CstVm1NrHQbMh8YaSzTZTFNP3zPqr3YU0S0UY441j4bJTg4A2Hh5KAhwgaiU6ZZ1Ar6y/2f4TblnMReQ==", - "optional": true, - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -9173,12 +9098,6 @@ "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", "integrity": "sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g==" }, - "node_modules/is-lite": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-lite/-/is-lite-2.0.0.tgz", - "integrity": "sha512-70f2BMIQlbSUXVKaZUd9a9fJH3IH1PDckV0m4BIIO4LjnNYvOh4Ng7vXIXEwpA0KDZknRq+7fHwGTu0jIdx28g==", - "license": "MIT" - }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -12079,52 +11998,11 @@ "react": ">=16.13.1" } }, - "node_modules/react-innertext": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/react-innertext/-/react-innertext-1.1.5.tgz", - "integrity": "sha512-PWAqdqhxhHIv80dT9znP2KvS+hfkbRovFp4zFYHFFlOoQLRiawIic81gKb3U1wEyJZgMwgs3JoLtwryASRWP3Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": ">=0.0.0 <=99", - "react": ">=0.0.0 <=99" - } - }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, - "node_modules/react-joyride": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/react-joyride/-/react-joyride-3.2.0.tgz", - "integrity": "sha512-UrA/XWdWElMLNnjZt2eWmPFmpHl1IZ2CeI9XhS+PuEVvfqHMD9U4tavCq1csDAGVoj0YEGfaCDmhZwgNc9yE2g==", - "license": "MIT", - "dependencies": { - "@fastify/deepmerge": "^3.2.1", - "@floating-ui/react-dom": "^2.1.8", - "@gilbarbara/deep-equal": "^0.4.1", - "@gilbarbara/hooks": "^0.11.0", - "@gilbarbara/types": "^0.2.2", - "is-lite": "^2.0.0", - "react-innertext": "^1.1.5", - "scroll": "^3.0.1", - "scrollparent": "^2.1.0", - "use-sync-external-store": "^1.6.0" - }, - "peerDependencies": { - "react": "16.8 - 19", - "react-dom": "16.8 - 19" - } - }, - "node_modules/react-joyride/node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/react-markdown": { "version": "8.0.7", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-8.0.7.tgz", @@ -12721,18 +12599,6 @@ "loose-envify": "^1.1.0" } }, - "node_modules/scroll": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/scroll/-/scroll-3.0.1.tgz", - "integrity": "sha512-pz7y517OVls1maEzlirKO5nPYle9AXsFzTMNJrRGmT951mzpIBy7sNHOg5o/0MQd/NqliCiWnAi0kZneMPFLcg==", - "license": "MIT" - }, - "node_modules/scrollparent": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz", - "integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA==", - "license": "ISC" - }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -14160,15 +14026,6 @@ } } }, - "node_modules/use-sync-external-store": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", - "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -14866,34 +14723,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/zustand": { - "version": "4.4.7", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.4.7.tgz", - "integrity": "sha512-QFJWJMdlETcI69paJwhSMJz7PPWjVP8Sjhclxmxmxv/RYI7ZOvR5BHX+ktH0we9gTWQMxcne8q1OY8xxz604gw==", - "license": "MIT", - "dependencies": { - "use-sync-external-store": "1.2.0" - }, - "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0", - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - } - } - }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/sippy-ng/package.json b/sippy-ng/package.json index 7b75350b36..1323eac812 100644 --- a/sippy-ng/package.json +++ b/sippy-ng/package.json @@ -28,7 +28,6 @@ "date-fns": "^2.0.0-beta.5", "date-fns-tz": "^1.1.6", "eventemitter3": "^5.0.1", - "idb-keyval": "^6.2.2", "p-limit": "^3.1.0", "plotly.js": "^3.1.1", "prop-types": "^15.8.1", @@ -38,15 +37,13 @@ "react-cookie": "^7.2.1", "react-dom": "^18.3.1", "react-error-boundary": "^5.0.0", - "react-joyride": "^3.2.0", "react-markdown": "^8.0.7", "react-plotly.js": "^2.6.0", "react-router-dom": "^7.18.1", "remark-gfm": "^3.0.1", "timelines-chart": "^2.12.1", "universal-cookie": "^7.2.1", - "use-query-params": "^2.2.2", - "zustand": "^4.4.7" + "use-query-params": "^2.2.2" }, "scripts": { "start": "vite", diff --git a/sippy-ng/src/App.jsx b/sippy-ng/src/App.jsx index 5dcdb7852e..196b368bf0 100644 --- a/sippy-ng/src/App.jsx +++ b/sippy-ng/src/App.jsx @@ -24,7 +24,6 @@ import { Navigate, Route, Routes, - useLocation, useNavigate, useParams, } from 'react-router-dom' @@ -33,16 +32,13 @@ import { QueryParamProvider } from 'use-query-params' import { ReactRouter6Adapter } from 'use-query-params/adapters/react-router-6' import { TestAnalysis } from './tests/TestAnalysis' import { useCookies } from 'react-cookie' -import { useDrawer } from './chat/store/useChatStore' import AccessibilityToggle from './components/AccessibilityToggle' -import AIDisclaimerDialog from './components/AIDisclaimerDialog' import Alert from '@mui/material/Alert' import BuildClusterDetails from './build_clusters/BuildClusterDetails' import BuildClusterOverview from './build_clusters/BuildClusterOverview' -import ChatInterface from './chat/ChatInterface' +import ChatTransition from './components/ChatTransition' import ChevronLeftIcon from '@mui/icons-material/ChevronLeft' import ChevronRightIcon from '@mui/icons-material/ChevronRight' -import CollapsibleChatDrawer from './chat/CollapsibleChatDrawer' import ComponentReadiness from './component_readiness/ComponentReadiness' import Drawer from '@mui/material/Drawer' import EventsChart from './prow_job_runs/EventsChart' @@ -395,11 +391,6 @@ const EventsChartWrapper = () => { ) } -const ChatInterfaceWrapper = () => { - const { id } = useParams() - return -} - function App(_props) { const classes = useStyles() const theme = useTheme() @@ -570,8 +561,6 @@ function App(_props) { }} >
- - } /> - {sippyCapabilities.includes('chat') && ( - <> - } - /> - } - /> - - )} + } + /> - {showWithCapability('chat', )} @@ -835,24 +815,4 @@ function App(_props) { return content } -// Component that uses the drawer state -function GlobalChatControls() { - const { isDrawerOpen, openDrawer, closeDrawer } = useDrawer() - const location = useLocation() - - // Don't show chat drawer on the main /chat page - const isOnChatPage = location.pathname.includes('/chat') - if (isOnChatPage) { - return null - } - - return ( - - ) -} - export default App diff --git a/sippy-ng/src/bugs/FileBug.jsx b/sippy-ng/src/bugs/FileBug.jsx index 66568ff635..d0d3e24254 100644 --- a/sippy-ng/src/bugs/FileBug.jsx +++ b/sippy-ng/src/bugs/FileBug.jsx @@ -19,18 +19,15 @@ import { TextField, Typography, } from '@mui/material' -import { AutoAwesome as AutoAwesomeIcon, Close } from '@mui/icons-material' +import { Close } from '@mui/icons-material' import { getBugsAPIUrl, getTriagesAPIUrl, } from '../component_readiness/CompReadyUtils' import { makeStyles } from '@mui/styles' -import { SippyCapabilitiesContext } from '../App' -import { usePrompts } from '../chat/store/useChatStore' import BugButton from './BugButton' -import OneShotChatModal from '../chat/OneShotChatModal' import PropTypes from 'prop-types' -import React, { Fragment, useContext, useState } from 'react' +import React, { Fragment, useState } from 'react' const useStyles = makeStyles((theme) => ({ alignedButton: { @@ -62,7 +59,6 @@ export default function FileBug({ url, }) { const classes = useStyles() - const { renderPrompt } = usePrompts() const [isModalOpen, setIsModalOpen] = useState(false) const [formData, setFormData] = useState({ summary: '', @@ -80,12 +76,6 @@ export default function FileBug({ const [errorAlert, setErrorAlert] = useState('') const [successAlert, setSuccessAlert] = useState(null) const [isValidationError, setIsValidationError] = useState(false) - const [isAIModalOpen, setIsAIModalOpen] = useState(false) - const [aiGeneratedDescription, setAiGeneratedDescription] = useState('') - const [aiPrompt, setAiPrompt] = useState('') - const [promptRenderError, setPromptRenderError] = useState(null) - const capabilities = useContext(SippyCapabilitiesContext) - const chatEnabled = capabilities.includes('chat') const handleOpenModal = () => { const defaultText = ` @@ -101,8 +91,7 @@ See the [sippy test details|${url}] for additional context. ? `[${component}] [${capability}] test regressed` : '' - // Use AI-generated description if available, otherwise use context or default - const defaultDescription = aiGeneratedDescription || context || defaultText + const defaultDescription = context || defaultText const defaultAffectsVersions = [] if (version) defaultAffectsVersions.push(version) @@ -296,42 +285,6 @@ See the [sippy test details|${url}] for additional context. 'test', ] - const handleGenerateAIDescription = async () => { - setPromptRenderError(null) - try { - // Render the prompt with arguments - const rendered = await renderPrompt( - 'component-readiness-jira-description', - { - url: url, - } - ) - setAiPrompt(rendered) - setIsAIModalOpen(true) - } catch (error) { - console.error('Failed to render prompt:', error) - setPromptRenderError( - `Failed to load AI prompt: ${error.message || error}` - ) - } - } - - const handleAIDescriptionResult = (generatedDescription) => { - const descriptionWithNote = `{panel:title=⚠️ AI-Generated Content|borderStyle=dashed|borderColor=#9C27B0|titleBGColor=#F3E5F5} -Sippy AI-assisted description; please review details for accuracy. -{panel} - -*Filed from:* [Test Regression Details|${url}] - -${generatedDescription}` - setAiGeneratedDescription(descriptionWithNote) - setFormData((prev) => ({ - ...prev, - description: descriptionWithNote, - })) - setIsAIModalOpen(false) - } - return ( - - - Description * - - {chatEnabled && ( - - )} - + + Description * + - - {chatEnabled && ( - setIsAIModalOpen(false)} - prompt={aiPrompt} - onResult={handleAIDescriptionResult} - title="Generating AI-Enhanced Bug Description" - /> - )} - - {/* Error snackbar for prompt rendering failures */} - setPromptRenderError(null)} - anchorOrigin={{ vertical: 'top', horizontal: 'center' }} - > - setPromptRenderError(null)}> - {promptRenderError} - - ) } diff --git a/sippy-ng/src/chat/AskSippyButton.jsx b/sippy-ng/src/chat/AskSippyButton.jsx deleted file mode 100644 index cfc3472cbf..0000000000 --- a/sippy-ng/src/chat/AskSippyButton.jsx +++ /dev/null @@ -1,140 +0,0 @@ -import { AutoAwesome as AutoAwesomeIcon } from '@mui/icons-material' -import { Button, Snackbar, Tooltip } from '@mui/material' -import { makeStyles } from '@mui/styles' -import { SippyCapabilitiesContext } from '../App' -import { useDrawer, usePrompts, useSessionActions } from './store/useChatStore' -import Alert from '@mui/material/Alert' -import PropTypes from 'prop-types' -import React, { useContext, useState } from 'react' - -const useStyles = makeStyles((_theme) => ({ - defaultStyledButton: { - background: 'linear-gradient(45deg, #2196F3 30%, #21CBF3 90%)', - boxShadow: '0 3px 5px 2px rgba(33, 203, 243, .3)', - color: 'white', - fontWeight: 'bold', - textTransform: 'none', - transition: 'all 0.3s ease', - animation: '$pulse 2s ease-in-out infinite', - '&:hover': { - background: 'linear-gradient(45deg, #1976D2 30%, #00BCD4 90%)', - boxShadow: '0 6px 20px 4px rgba(33, 203, 243, .4)', - transform: 'translateY(-2px)', - }, - }, - '@keyframes pulse': { - '0%, 100%': { - boxShadow: '0 3px 5px 2px rgba(33, 203, 243, .3)', - }, - '50%': { - boxShadow: '0 3px 15px 5px rgba(33, 203, 243, .5)', - }, - }, -})) - -/** - * AskSippyButton - A reusable button that pre-sends a question to the chat widget in - * a new session. Can be used with either a direct question or a slash command. - * - * Example usage with direct question: - * ```jsx - * - * ``` - * - * Example usage with slash command: - * ```jsx - * - * ``` - */ -export default function AskSippyButton({ - question, - slashCommand, - commandArgs, - tooltip, -}) { - const { openDrawer } = useDrawer() - const { startNewSession } = useSessionActions() - const { renderPrompt } = usePrompts() - const capabilities = useContext(SippyCapabilitiesContext) - const classes = useStyles() - const [isRendering, setIsRendering] = useState(false) - const [error, setError] = useState(null) - - if (!capabilities.includes('chat')) { - return null - } - - const handleClick = async () => { - // If using a slash command, render the prompt first - if (slashCommand && commandArgs) { - setIsRendering(true) - setError(null) - try { - const rendered = await renderPrompt(slashCommand, commandArgs) - openDrawer() - startNewSession(rendered) - } catch (err) { - console.error('Failed to render prompt:', err) - setError( - `Failed to load prompt '${slashCommand}': ${err.message || err}` - ) - } finally { - setIsRendering(false) - } - } else if (question) { - openDrawer() - startNewSession(question) - } - } - - const handleCloseError = () => { - setError(null) - } - - const button = ( - - ) - - return ( - <> - {tooltip ? {button} : button} - - - {error} - - - - ) -} - -AskSippyButton.propTypes = { - question: PropTypes.string, - slashCommand: PropTypes.string, - commandArgs: PropTypes.object, - tooltip: PropTypes.string, -} diff --git a/sippy-ng/src/chat/ChatHeader.jsx b/sippy-ng/src/chat/ChatHeader.jsx deleted file mode 100644 index 201a39ee1a..0000000000 --- a/sippy-ng/src/chat/ChatHeader.jsx +++ /dev/null @@ -1,200 +0,0 @@ -import { - CircularProgress, - IconButton, - Tooltip, - Typography, -} from '@mui/material' -import { - ExpandMore as ExpandMoreIcon, - Help as HelpIcon, - Fullscreen as MaximizeIcon, - FullscreenExit as RestoreIcon, - Settings as SettingsIcon, - Share as ShareIcon, - SmartToy as SmartToyIcon, -} from '@mui/icons-material' -import { makeStyles } from '@mui/styles' -import { - useConnectionState, - usePageContextForChat, - useSessionState, - useSettings, - useShareActions, - useShareState, -} from './store/useChatStore' -import PropTypes from 'prop-types' -import React from 'react' -import SessionManager from './SessionDropdown' - -const useStyles = makeStyles((theme) => ({ - // Full page styles - fullPageHeader: { - padding: theme.spacing(2), - borderBottom: `1px solid ${theme.palette.divider}`, - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - backgroundColor: theme.palette.background.paper, - }, - - // Drawer styles - drawerHeader: { - display: 'flex', - alignItems: 'center', - padding: theme.spacing(2), - justifyContent: 'space-between', - borderBottom: `1px solid ${theme.palette.divider}`, - backgroundColor: theme.palette.background.paper, - flexWrap: 'nowrap', - overflow: 'hidden', - }, - - headerTitle: { - display: 'flex', - alignItems: 'flex-start', - gap: theme.spacing(1), - minWidth: 0, - flexShrink: 1, - overflow: 'hidden', - }, - headerIcon: { - flexShrink: 0, - marginTop: '4px', - }, - headerTextContainer: { - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(0.25), - minWidth: 0, - }, - headerTypography: { - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - minWidth: 0, - }, - aiNotice: { - fontSize: '0.65rem !important', - color: theme.palette.text.secondary, - fontStyle: 'italic', - lineHeight: 1.2, - }, - headerActions: { - display: 'flex', - gap: theme.spacing(1), - alignItems: 'center', - flexShrink: 0, - }, -})) - -export default function ChatHeader({ - mode, - onNewSession, - onMaximize, - onClose, - isMaximized, -}) { - const classes = useStyles() - - // Get state from custom hooks - const { activeSession } = useSessionState() - const { currentThinking, isTyping } = useConnectionState() - const { shareLoading } = useShareState() - const { shareConversation } = useShareActions() - const { setSettingsOpen } = useSettings() - const { pageContext } = usePageContextForChat() - - const messages = activeSession?.messages || [] - const hasMessages = messages.length > 0 - - const handleHelp = () => { - window.open( - 'https://source.redhat.com/departments/products_and_global_engineering/openshift_development/openshift_wiki/sippy_chat_user_guide', - '_blank', - 'noopener,noreferrer' - ) - } - - const handleShare = () => { - shareConversation(pageContext, mode) - } - - return ( -
-
- -
- - Chat Assistant - - - Always review AI generated content prior to use. - -
-
- -
- - - - - - {shareLoading ? : } - - - - - - - - - - - - setSettingsOpen(true)} - data-tour="settings-button" - > - - - - - {mode === 'drawer' && ( - <> - - - {isMaximized ? : } - - - - - - - - - - )} -
-
- ) -} - -ChatHeader.propTypes = { - mode: PropTypes.oneOf(['fullPage', 'drawer']).isRequired, - onNewSession: PropTypes.func.isRequired, - onMaximize: PropTypes.func, - onClose: PropTypes.func, - isMaximized: PropTypes.bool, -} diff --git a/sippy-ng/src/chat/ChatInput.jsx b/sippy-ng/src/chat/ChatInput.jsx deleted file mode 100644 index 539a910daa..0000000000 --- a/sippy-ng/src/chat/ChatInput.jsx +++ /dev/null @@ -1,545 +0,0 @@ -import { - Chip, - CircularProgress, - IconButton, - Paper, - TextField, - Tooltip, -} from '@mui/material' -import { - Code as CodeIcon, - Masks as MasksIcon, - PlayArrow as PlayArrowIcon, - Refresh as RefreshIcon, - Send as SendIcon, - Stop as StopIcon, -} from '@mui/icons-material' -import { CONNECTION_STATES } from './store/webSocketSlice' -import { humanize, validateMessage } from './chatUtils' -import { makeStyles } from '@mui/styles' -import { - useConnectionState, - usePersonas, - usePrompts, - useSettings, - useWebSocketActions, -} from './store/useChatStore' -import PropTypes from 'prop-types' -import React, { useEffect, useRef, useState } from 'react' -import SlashCommandModal from './SlashCommandModal' -import SlashCommandSelector from './SlashCommandSelector' - -const useStyles = makeStyles((theme) => ({ - inputContainer: { - padding: theme.spacing(2), - borderTop: `1px solid ${theme.palette.divider}`, - backgroundColor: theme.palette.background.paper, - }, - inputBox: { - display: 'flex', - gap: theme.spacing(1), - alignItems: 'flex-end', - }, - textField: { - flex: 1, - '& .MuiOutlinedInput-root': { - paddingRight: theme.spacing(1), - }, - }, - sendButton: { - padding: theme.spacing(1), - '&.disabled': { - opacity: 0.5, - }, - }, - statusContainer: { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - marginBottom: theme.spacing(1), - minHeight: 24, - }, - statusChips: { - display: 'flex', - gap: theme.spacing(1), - alignItems: 'center', - }, - connectionStatus: { - fontSize: '0.75rem', - }, - characterCount: { - fontSize: '0.75rem', - color: theme.palette.text.secondary, - '&.warning': { - color: theme.palette.warning.main, - }, - '&.error': { - color: theme.palette.error.main, - }, - }, - suggestions: { - display: 'flex', - gap: theme.spacing(1), - marginBottom: theme.spacing(1), - flexWrap: 'wrap', - }, - suggestionChip: { - cursor: 'pointer', - '&:hover': { - backgroundColor: theme.palette.action.hover, - }, - }, - commandMenuButton: { - padding: theme.spacing(1), - }, -})) - -// Default suggestions - can be either questions (strings) or commands (objects) -const DEFAULT_SUGGESTIONS = [ - { prompt: 'payload-report', label: 'Payload Status Report' }, - { prompt: 'plot-job-results', label: 'Plot Job Results' }, - { prompt: 'job-run-analysis', label: 'Analyze a Job Run' }, - { prompt: 'jira-incidents', label: 'View Open Incidents' }, -] - -export default function ChatInput({ - onSendMessage, - onRetry, - placeholder = 'Ask about OpenShift releases, job failures, or payload status...', - pageContext = null, - suggestions = null, -}) { - const classes = useStyles() - const [message, setMessage] = useState('') - const [error, setError] = useState('') - const textFieldRef = useRef(null) - - // Slash command state - const [showSlashCommands, setShowSlashCommands] = useState(false) - const [selectedPrompt, setSelectedPrompt] = useState(null) - const [modalOpen, setModalOpen] = useState(false) - const [commandMenuAnchor, setCommandMenuAnchor] = useState(null) - const slashNavigationRef = useRef(null) - - const { settings } = useSettings() - const { personas } = usePersonas() - const { prompts, renderPrompt } = usePrompts() - const { connectionState, isTyping } = useConnectionState() - const { stopGeneration } = useWebSocketActions() - - const isConnected = connectionState === CONNECTION_STATES.CONNECTED - const disabled = !isConnected - - const displaySuggestions = suggestions || DEFAULT_SUGGESTIONS - - const slashCommandFilter = - message && message.startsWith('/') ? message.slice(1) : '' - - const getContextDisplay = () => { - if (!pageContext?.page) return null - return humanize(pageContext.page) - } - - // Focus input on mount - useEffect(() => { - if (textFieldRef.current && isConnected) { - textFieldRef.current.focus() - } - }, [isConnected]) - - const handleMessageChange = (event) => { - const value = event.target.value - setMessage(value) - - // Clear error when user starts typing - if (error) { - setError('') - } - - setShowSlashCommands(value.startsWith('/')) - } - - const handlePromptSelect = (prompt) => { - setSelectedPrompt(prompt) - setModalOpen(true) - setShowSlashCommands(false) - setMessage('') - } - - const handleModalSubmit = (renderedPrompt) => { - const success = onSendMessage(renderedPrompt) - if (success) { - setMessage('') - setError('') - setModalOpen(false) - setSelectedPrompt(null) - } - } - - const handleModalClose = () => { - setModalOpen(false) - setSelectedPrompt(null) - } - - const handleCommandMenuOpen = (event) => { - setCommandMenuAnchor(event.currentTarget) - } - - const handleCommandMenuClose = () => { - setCommandMenuAnchor(null) - } - - const handleSendMessage = () => { - const validation = validateMessage(message) - - if (!validation.valid) { - setError(validation.error) - return - } - - if (!isConnected) { - setError('Not connected to chat service') - return - } - - const success = onSendMessage(message.trim()) - if (success) { - setMessage('') - setError('') - } - } - - const handleKeyPress = (event) => { - // Handle slash command navigation - if (showSlashCommands && slashNavigationRef.current) { - if (event.key === 'Tab') { - event.preventDefault() - if (event.shiftKey) { - slashNavigationRef.current.movePrevious() - } else { - slashNavigationRef.current.moveNext() - } - return - } - - if (event.key === 'ArrowDown') { - event.preventDefault() - slashNavigationRef.current.moveNext() - return - } - - if (event.key === 'ArrowUp') { - event.preventDefault() - slashNavigationRef.current.movePrevious() - return - } - - if (event.key === 'Enter' && !event.shiftKey) { - event.preventDefault() - slashNavigationRef.current.selectCurrent() - return - } - - if (event.key === 'Escape') { - event.preventDefault() - setShowSlashCommands(false) - setMessage('') - return - } - } - - // Normal message handling - if (event.key === 'Enter' && !event.shiftKey) { - event.preventDefault() - // Only send if not typing (same logic as button) - if (!isTyping) { - handleSendMessage() - } - } - } - - const handleSuggestionClick = async (suggestion) => { - // Handle plain text questions - send directly - if (typeof suggestion === 'string') { - onSendMessage(suggestion) - return - } - - // Handle command objects - const command = suggestion - - // Find the prompt definition - const prompt = prompts.find((p) => p.name === command.prompt) - if (!prompt) { - console.error(`Prompt '${command.prompt}' not found`) - setError(`Command '${command.prompt}' not found`) - return - } - - // Check if prompt has any required arguments - const hasRequiredArgs = - prompt.arguments && prompt.arguments.some((arg) => arg.required) - - // Check if all required arguments are pre-filled - const allRequiredArgsFilled = - !hasRequiredArgs || - (command.args && - prompt.arguments.every( - (arg) => !arg.required || command.args[arg.name] !== undefined - )) - - // If no required arguments OR all required args are pre-filled, send directly - if (allRequiredArgsFilled) { - try { - const rendered = await renderPrompt(prompt.name, command.args || {}) - onSendMessage(rendered) - } catch (err) { - console.error('Failed to render prompt:', err) - setError(`Failed to render prompt '${prompt.name}': ${err.message}`) - } - return - } - - // Otherwise, show the modal for user input - setSelectedPrompt({ ...prompt, prefilledArgs: command.args }) - setModalOpen(true) - } - - const getCharacterCountClass = () => { - const length = message.length - if (length > 9000) return 'error' - if (length > 8000) return 'warning' - return '' - } - - const getConnectionStatusColor = () => { - if (!isConnected) return 'error' - if (isTyping) return 'warning' - return 'success' - } - - const getConnectionStatusText = () => { - if (!isConnected) return 'Disconnected' - if (isTyping) return 'Sippy is thinking...' - return 'Connected' - } - - const canSend = - message.trim().length > 0 && isConnected && !disabled && !isTyping - - return ( - - {/* Status bar */} -
-
- : undefined} - /> - {pageContext && getContextDisplay() && ( - - - - )} - {personas.length > 0 && settings.persona !== 'default' && ( - p.id === settings.persona)?.description || - 'Custom persona' - } - > - } - label={ - personas.find((p) => p.id === settings.persona)?.name || - humanize(settings.persona) - } - size="small" - color="secondary" - variant="outlined" - /> - - )} - {onRetry && !isConnected && ( - - - - - - )} -
- - - {message.length}/10000 - -
- - {/* Suggestions (show when input is empty) */} - {message.length === 0 && ( -
- {displaySuggestions.slice(0, 5).map((suggestion, index) => { - // Check if it's a command object or plain question - const isCommand = typeof suggestion === 'object' - - if (isCommand) { - // Command chip with icon and tooltip - return ( - - - } - label={suggestion.label} - size="small" - variant="outlined" - className={classes.suggestionChip} - onClick={() => handleSuggestionClick(suggestion)} - disabled={isTyping} - /> - - - ) - } - - // Plain question chip - return ( - handleSuggestionClick(suggestion)} - disabled={isTyping} - /> - ) - })} -
- )} - - {/* Input box */} -
- - - {/* Slash command autocomplete */} - { - slashNavigationRef.current = navigation - }} - placement="top-start" - /> - - {/* Command menu button */} - - - - - - - - - - - - {isTyping ? : } - - - -
- - {/* Command menu */} - - - {/* Slash command modal */} - -
- ) -} - -ChatInput.propTypes = { - onSendMessage: PropTypes.func.isRequired, - onRetry: PropTypes.func, - placeholder: PropTypes.string, - pageContext: PropTypes.shape({ - page: PropTypes.string, - url: PropTypes.string, - data: PropTypes.object, - instructions: PropTypes.string, - }), - suggestions: PropTypes.arrayOf( - PropTypes.oneOfType([ - PropTypes.string, // Plain text question - PropTypes.shape({ - // Command object - prompt: PropTypes.string.isRequired, - label: PropTypes.string.isRequired, - args: PropTypes.object, - }), - ]) - ), -} diff --git a/sippy-ng/src/chat/ChatInterface.jsx b/sippy-ng/src/chat/ChatInterface.jsx deleted file mode 100644 index 48881a76ba..0000000000 --- a/sippy-ng/src/chat/ChatInterface.jsx +++ /dev/null @@ -1,468 +0,0 @@ -import { - Alert, - CircularProgress, - Drawer, - Fade, - Paper, - Typography, -} from '@mui/material' -import { CONNECTION_STATES } from './store/webSocketSlice' -import { makeStyles } from '@mui/styles' -import { MESSAGE_TYPES } from './chatUtils' -import { SESSION_TYPES } from './store/sessionSlice' -import { - useConnectionState, - usePageContextForChat, - useSessionActions, - useSessionState, - useSettings, - useShareActions, - useShareState, - useWebSocketActions, -} from './store/useChatStore' -import { useScrollManagement } from './useScrollManagement' -import { useSessionRating } from './useSessionRating' -import ChatHeader from './ChatHeader' -import ChatInput from './ChatInput' -import ChatMessage from './ChatMessage' -import ChatSettings from './ChatSettings' -import ChatTour from './ChatTour' -import PropTypes from 'prop-types' -import Rating from './Rating' -import React, { useEffect, useState } from 'react' -import ShareDialog from './ShareDialog' -import SippyLogo from '../components/SippyLogo' -import ThinkingStep from './ThinkingStep' - -const DRAWER_HEIGHT = 600 -const DRAWER_HEIGHT_MAXIMIZED = '90vh' - -const useStyles = makeStyles((theme) => ({ - // Full page styles - fullPageRoot: { - height: 'calc(100vh - 64px - 48px)', - display: 'flex', - flexDirection: 'column', - backgroundColor: theme.palette.background.default, - overflow: 'hidden', - margin: -theme.spacing(3), - marginTop: 0, - }, - fullPageHeader: { - padding: theme.spacing(2), - borderBottom: `1px solid ${theme.palette.divider}`, - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - backgroundColor: theme.palette.background.paper, - }, - - // Drawer styles - drawer: { - flexShrink: 0, - }, - drawerPaper: { - height: DRAWER_HEIGHT, - width: 600, - maxWidth: '100vw', - right: theme.spacing(3), - left: 'auto', - borderTopLeftRadius: theme.shape.borderRadius * 2, - borderTopRightRadius: theme.shape.borderRadius * 2, - borderTop: `2px solid ${theme.palette.divider}`, - borderLeft: `1px solid ${theme.palette.divider}`, - borderRight: `1px solid ${theme.palette.divider}`, - boxShadow: theme.shadows[8], - transition: theme.transitions.create(['height', 'width'], { - easing: theme.transitions.easing.sharp, - duration: theme.transitions.duration.enteringScreen, - }), - }, - drawerPaperMaximized: { - height: DRAWER_HEIGHT_MAXIMIZED, - width: '80vw', - }, - drawerHeader: { - display: 'flex', - alignItems: 'center', - padding: theme.spacing(2), - justifyContent: 'space-between', - borderBottom: `1px solid ${theme.palette.divider}`, - backgroundColor: theme.palette.background.paper, - flexWrap: 'nowrap', - overflow: 'hidden', - }, - - // Shared styles - headerActions: { - display: 'flex', - gap: theme.spacing(1), - alignItems: 'center', - flexShrink: 0, - }, - headerTitle: { - display: 'flex', - alignItems: 'flex-start', - gap: theme.spacing(1), - minWidth: 0, - flexShrink: 1, - overflow: 'hidden', - }, - headerTextContainer: { - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(0.25), - minWidth: 0, - }, - aiNotice: { - fontSize: '0.65rem', - color: theme.palette.text.secondary, - fontStyle: 'italic', - lineHeight: 1.2, - }, - messagesContainer: { - flex: 1, - overflowY: 'auto', - padding: theme.spacing(2), - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(2), - }, - emptyState: { - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - height: '100%', - padding: theme.spacing(4), - textAlign: 'center', - }, - messageWrapper: { - marginBottom: theme.spacing(2), - }, - currentThinking: { - margin: theme.spacing(1, 2), - padding: theme.spacing(1), - backgroundColor: - theme.palette.mode === 'dark' - ? 'rgba(255, 255, 255, 0.05)' - : 'rgba(0, 0, 0, 0.02)', - borderRadius: theme.shape.borderRadius, - }, - errorAlert: { - margin: theme.spacing(1, 2), - flexShrink: 0, - }, - inputContainer: { - flexShrink: 0, - }, - sessionRatingContainer: { - display: 'flex', - justifyContent: 'center', - padding: theme.spacing(1, 2), - borderTop: `1px solid ${theme.palette.divider}`, - backgroundColor: - theme.palette.mode === 'dark' - ? 'rgba(255, 255, 255, 0.02)' - : 'rgba(0, 0, 0, 0.02)', - }, - - loadingContainer: { - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - height: '100%', - }, -})) - -/** - * Unified chat interface component that can render as either: - * - A full-page chat view (mode='fullPage') - * - A drawer widget (mode='drawer') - */ -export default function ChatInterface({ - mode = 'fullPage', - open = true, - onClose, - conversationId = null, -}) { - const classes = useStyles() - const { pageContext } = usePageContextForChat() - const [isMaximized, setIsMaximized] = useState(false) - - // Get state and actions from custom hooks - const { - sessions: _sessions, - activeSessionId, - activeSession, - } = useSessionState() - const { - initializeSessions, - createSession: _createSession, - switchSession: _switchSession, - deleteSession: _deleteSession, - forkActiveSession, - } = useSessionActions() - - const { shareLoading: _shareLoading, loadingShared } = useShareState() - const { clearSharedUrl, loadSharedConversationFromAPI } = useShareActions() - - const { - connectionState, - isTyping: _isTyping, - error, - currentThinking, - } = useConnectionState() - const { settings, ensureClientId } = useSettings() - - // Get messages from active session - const messages = activeSession?.messages || [] - - // WebSocket actions - const { sendMessage, connectWebSocket } = useWebSocketActions() - - // Session rating - const { submitRating } = useSessionRating() - - // Scroll management - const { messagesEndRef, messagesListRef, lastMessageRef } = - useScrollManagement(activeSessionId, activeSession, messages, settings) - - const _isConnected = connectionState === CONNECTION_STATES.CONNECTED - - // Initialize sessions and client ID on mount - useEffect(() => { - initializeSessions() - ensureClientId() - }, [initializeSessions, ensureClientId]) - - // Connect websocket when in full page mode or when drawer opens - useEffect(() => { - if (mode === 'fullPage' || (mode === 'drawer' && open)) { - connectWebSocket() - } - }, [mode, open, connectWebSocket]) - - // Load shared conversation if conversationId is provided - useEffect(() => { - if (conversationId) { - loadSharedConversationFromAPI(conversationId) - } - }, [conversationId, loadSharedConversationFromAPI]) - - // Set page title for full page mode - useEffect(() => { - if (mode === 'fullPage') { - document.title = 'Sippy > Chat Assistant' - return () => { - document.title = 'Sippy' - } - } - }, [mode]) - - // Check if there are any assistant messages to show rating - const hasAssistantMessages = messages.some( - (msg) => msg.type === MESSAGE_TYPES.ASSISTANT - ) - - // Get the last non-system, non-thinking message - const lastInteractionMessage = messages - .filter( - (msg) => - msg.type === MESSAGE_TYPES.USER || msg.type === MESSAGE_TYPES.ASSISTANT - ) - .slice(-1)[0] - - // Only show rating if the last interaction was an assistant reply - const lastMessageIsAssistant = - lastInteractionMessage?.type === MESSAGE_TYPES.ASSISTANT - - // Determine if rating should be shown - const canRateConversation = - activeSession && - activeSession.type !== 'shared' && - hasAssistantMessages && - lastMessageIsAssistant && - !activeSession.rated - - const handleSessionRate = (messageId, rating) => { - if (!activeSession || !activeSession.id) { - console.error('No active session to rate') - return - } - - submitRating(activeSession.id, activeSession.type, messages, rating) - } - - const handleNewChat = () => { - // Side effects only (session creation handled in dropdown) - if (mode === 'fullPage' && conversationId) { - window.history.pushState(null, '', '/sippy-ng/chat') - } - - clearSharedUrl() - } - - const handleSendMessageWithFork = (content) => { - if ( - activeSession && - (activeSession.type === SESSION_TYPES.SHARED || - activeSession.type === SESSION_TYPES.SHARED_BY_ME) - ) { - forkActiveSession() - if (mode === 'fullPage' && conversationId) { - window.history.pushState(null, '', '/sippy-ng/chat') - } - } - - clearSharedUrl() - return sendMessage(content) - } - - if (loadingShared) { - return ( - -
- -
-
- ) - } - - const renderEmptyState = () => ( -
- - - Sippy Chat Assistant - - - I can help you analyze jobs, investigate failures, check payloads, and - more. - -
- ) - - const renderMessages = () => { - if (messages.length === 0 && !currentThinking) { - return renderEmptyState() - } - - return ( - <> - {messages.map((msg, index) => { - const isLastMessage = index === messages.length - 1 - if (msg.type === MESSAGE_TYPES.THINKING_STEP && msg.data) { - // Only show thinking steps if the setting is enabled - if (!settings.showThinking) { - return null - } - return ( -
- -
- ) - } - if (msg.type !== MESSAGE_TYPES.THINKING_STEP) { - return ( -
- -
- ) - } - return null - })} - - {currentThinking && settings.showThinking && ( - -
- -
-
- )} - - {error && ( - - {error} - - )} - -
- - ) - } - - const renderContent = () => ( - <> - setIsMaximized(!isMaximized)} - onClose={onClose} - isMaximized={isMaximized} - /> - -
- {renderMessages()} -
- - {canRateConversation && ( -
- -
- )} - -
- -
- - - - - - - - ) - - if (mode === 'drawer') { - return ( - - {renderContent()} - - ) - } - - return {renderContent()} -} - -ChatInterface.propTypes = { - mode: PropTypes.oneOf(['fullPage', 'drawer']), - open: PropTypes.bool, - onClose: PropTypes.func, - conversationId: PropTypes.string, -} diff --git a/sippy-ng/src/chat/ChatMessage.jsx b/sippy-ng/src/chat/ChatMessage.jsx deleted file mode 100644 index 39ca711840..0000000000 --- a/sippy-ng/src/chat/ChatMessage.jsx +++ /dev/null @@ -1,521 +0,0 @@ -import { Alert, Avatar, Chip, IconButton, Paper, Tooltip } from '@mui/material' -import { - ContentCopy as ContentCopyIcon, - Error as ErrorIcon, - Link as LinkIcon, - OpenInNew as OpenInNewIcon, - Person as PersonIcon, - SmartToy as SmartToyIcon, -} from '@mui/icons-material' -import { formatChatTimestamp, humanize, MESSAGE_TYPES } from './chatUtils' -import { Link } from 'react-router-dom' -import { makeStyles } from '@mui/styles' -import { useModels } from './store/useChatStore' -import MessageChart from './MessageChart' -import PropTypes from 'prop-types' -import React from 'react' -import ReactMarkdown from 'react-markdown' -import remarkGfm from 'remark-gfm' - -// Custom link component for ReactMarkdown that opens external links in new tabs -const ChatLink = ({ href, children, ...props }) => { - const isExternal = - href && (href.startsWith('http://') || href.startsWith('https://')) - - // Let CSS handle the styling - just ensure proper attributes - if (isExternal) { - return ( - - {children} - - ) - } - - // Internal links or non-http links stay in same tab - return ( - - {children} - - ) -} - -ChatLink.propTypes = { - href: PropTypes.string, - children: PropTypes.node, -} - -const useStyles = makeStyles((theme) => ({ - messageContainer: { - display: 'flex', - marginBottom: theme.spacing(2), - '&.user': { - justifyContent: 'flex-end', - }, - '&.assistant': { - justifyContent: 'flex-start', - }, - '&.system': { - justifyContent: 'center', - }, - }, - messageContent: { - maxWidth: '70%', - display: 'flex', - gap: theme.spacing(1), - minWidth: 0, // Allow content to shrink - '&.user': { - flexDirection: 'row-reverse', - }, - }, - messagePaper: { - padding: theme.spacing(1.5), - position: 'relative', - minWidth: 0, // Allow paper to shrink - overflow: 'hidden', // Prevent content overflow - wordBreak: 'break-word', // Break long words - '&.user': { - backgroundColor: theme.palette.primary.main, - color: theme.palette.primary.contrastText, - borderBottomRightRadius: 4, - }, - '&.assistant': { - backgroundColor: - theme.palette.mode === 'dark' - ? theme.palette.grey[800] - : theme.palette.grey[100], - borderBottomLeftRadius: 4, - }, - '&.error': { - backgroundColor: theme.palette.error.main, - color: theme.palette.error.contrastText, - }, - }, - messageText: { - whiteSpace: 'pre-wrap', - wordBreak: 'break-word', - marginBottom: theme.spacing(0.5), - minWidth: 0, // Allow text to shrink - overflow: 'hidden', // Prevent overflow - }, - messageFooter: { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - marginTop: theme.spacing(1), - gap: theme.spacing(1), - }, - timestamp: { - fontSize: '0.75rem', - opacity: 0.7, - }, - toolsUsed: { - display: 'flex', - gap: theme.spacing(0.5), - flexWrap: 'wrap', - }, - toolChip: { - fontSize: '0.7rem', - height: 20, - }, - avatar: { - width: 32, - height: 32, - '&.user': { - backgroundColor: theme.palette.primary.main, - }, - '&.assistant': { - backgroundColor: theme.palette.secondary.main, - }, - }, - copyButton: { - padding: 4, - opacity: 0.7, - '&:hover': { - opacity: 1, - }, - }, - aiChip: { - height: 18, - fontSize: '0.65rem', - fontWeight: 600, - '& .MuiChip-label': { - padding: '0 6px', - }, - }, - markdownContent: { - minWidth: 0, // Allow markdown content to shrink - overflow: 'hidden', // Prevent overflow - wordBreak: 'break-word', // Break long words - whiteSpace: 'normal', // Override pre-wrap from messageText - '& p': { - margin: '0 0 8px 0', - wordBreak: 'break-word', // Break long words in paragraphs - '&:last-child': { - marginBottom: 0, - }, - }, - '& h1, & h2, & h3, & h4, & h5, & h6': { - margin: '16px 0 8px 0', - '&:first-child': { - marginTop: 0, - }, - }, - '& ul, & ol': { - margin: '8px 0', - paddingLeft: 20, // Standard padding for proper bullet alignment - marginLeft: 0, // Ensure no extra left margin - }, - '& li': { - margin: '2px 0', // Reduced from 4px for tighter spacing - paddingLeft: 0, // Remove any extra padding on list items - wordBreak: 'normal', // Override the global word-break to prevent text wrapping issues - display: 'list-item', // Ensure proper list item display - lineHeight: 1.5, // Consistent line height for better alignment - '& p': { - display: 'inline', // Make paragraphs within list items inline instead of block - margin: 0, // Remove default paragraph margins - padding: 0, // Remove default paragraph padding - }, - }, - '& code': { - backgroundColor: - theme.palette.mode === 'dark' - ? 'rgba(255, 255, 255, 0.1)' - : 'rgba(0, 0, 0, 0.1)', - padding: '2px 4px', - borderRadius: 4, - fontFamily: 'monospace', - fontSize: '0.875em', - }, - '& pre': { - backgroundColor: - theme.palette.mode === 'dark' - ? 'rgba(255, 255, 255, 0.05)' - : 'rgba(0, 0, 0, 0.05)', - padding: theme.spacing(1), - borderRadius: 4, - overflow: 'auto', - margin: '8px 0', - maxWidth: '100%', // Prevent pre blocks from overflowing - wordBreak: 'break-all', // Break long lines in code blocks - '& code': { - backgroundColor: 'transparent', - padding: 0, - wordBreak: 'break-all', // Break long code lines - }, - }, - '& blockquote': { - borderLeft: `4px solid ${theme.palette.primary.main}`, - paddingLeft: theme.spacing(1), - margin: '8px 0', - fontStyle: 'italic', - }, - '& a': { - color: theme.palette.mode === 'dark' ? '#64b5f6' : '#1976d2', // Light blue for dark mode, darker blue for light mode - textDecoration: 'underline', - cursor: 'pointer', - fontWeight: 500, // Make links slightly bolder - '&:hover': { - textDecoration: 'underline', - opacity: 0.8, - backgroundColor: - theme.palette.mode === 'dark' - ? 'rgba(100, 181, 246, 0.1)' - : 'rgba(25, 118, 210, 0.1)', // Subtle background on hover - }, - '&:visited': { - color: theme.palette.mode === 'dark' ? '#ba68c8' : '#7b1fa2', // Purple for visited links - }, - }, - '& table': { - borderCollapse: 'collapse', - width: '100%', - margin: '8px 0', - fontSize: '0.875rem', - overflow: 'auto', - display: 'block', - maxWidth: '100%', - }, - '& thead': { - backgroundColor: - theme.palette.mode === 'dark' - ? 'rgba(255, 255, 255, 0.1)' - : 'rgba(0, 0, 0, 0.05)', - }, - '& th': { - border: `1px solid ${theme.palette.divider}`, - padding: '8px 12px', - textAlign: 'left', - fontWeight: 600, - }, - '& td': { - border: `1px solid ${theme.palette.divider}`, - padding: '8px 12px', - textAlign: 'left', - }, - '& tr:nth-of-type(even)': { - backgroundColor: - theme.palette.mode === 'dark' - ? 'rgba(255, 255, 255, 0.02)' - : 'rgba(0, 0, 0, 0.02)', - }, - }, - systemMessage: { - textAlign: 'center', - padding: theme.spacing(2, 0), - margin: theme.spacing(2, 0), - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - }, - systemMessageText: { - fontSize: '0.75rem', - color: theme.palette.text.secondary, - fontStyle: 'italic', - marginBottom: theme.spacing(1), - }, - systemMessageDivider: { - width: '60%', - borderBottom: `1px solid ${theme.palette.divider}`, - }, -})) - -export default function ChatMessage({ - message, - showTimestamp = true, - showTools: _showTools = true, -}) { - const classes = useStyles() - const { models } = useModels() - - const handleCopyMessage = async () => { - try { - await navigator.clipboard.writeText(message.content) - // Could add a toast notification here - } catch (err) { - console.error('Failed to copy message:', err) - } - } - - // Get model name for display - const getModelName = () => { - if (!message.model_id || models.length === 0) { - return null - } - const model = models.find((m) => m.id === message.model_id) - return model ? model.name : null - } - - const modelName = getModelName() - - const formatTimestamp = (timestamp) => { - if (!timestamp || !showTimestamp) return null - const formatted = formatChatTimestamp(timestamp) - return ( - - {formatted.main} - - ) - } - - const renderUserMessage = () => ( -
-
- - - - -
- - {message.content} - -
-
- {formatTimestamp(message.timestamp)} - - - -
-
-
-
- ) - - const renderAssistantMessage = () => ( -
-
- - - - -
- - {message.content} - -
- - - -
-
- {formatTimestamp(message.timestamp)} - - - -
-
- {message.pageContext && - message.pageContext.url && - message.pageContext.page && ( - - - - - - )} - - - -
-
-
-
-
- ) - - const renderErrorMessage = () => ( -
- } - className={classes.systemMessage} - action={ - - - - } - > -
- - {message.content} - -
- {formatTimestamp(message.timestamp)} -
-
- ) - - const renderSystemMessage = () => ( -
-
- {message.content} - {message.conversationId && ( - <> - {' '} - - - - - )} -
-
-
- ) - - // Render based on message type - switch (message.type) { - case MESSAGE_TYPES.USER: - return renderUserMessage() - - case MESSAGE_TYPES.ASSISTANT: - return renderAssistantMessage() - - case MESSAGE_TYPES.ERROR: - return renderErrorMessage() - - case MESSAGE_TYPES.SYSTEM: - return renderSystemMessage() - - default: - console.warn('Unknown message type:', message.type) - return null - } -} - -ChatMessage.propTypes = { - message: PropTypes.shape({ - id: PropTypes.string.isRequired, - type: PropTypes.string.isRequired, - content: PropTypes.string.isRequired, - timestamp: PropTypes.string.isRequired, - data: PropTypes.object, - tools_used: PropTypes.arrayOf(PropTypes.string), - model_id: PropTypes.string, - visualizations: PropTypes.arrayOf( - PropTypes.shape({ - data: PropTypes.array.isRequired, - layout: PropTypes.object.isRequired, - config: PropTypes.object, - }) - ), - conversationId: PropTypes.string, - pageContext: PropTypes.shape({ - page: PropTypes.string, - url: PropTypes.string, - data: PropTypes.object, - }), - }).isRequired, - showTimestamp: PropTypes.bool, - showTools: PropTypes.bool, -} diff --git a/sippy-ng/src/chat/ChatSettings.jsx b/sippy-ng/src/chat/ChatSettings.jsx deleted file mode 100644 index 6631a2c724..0000000000 --- a/sippy-ng/src/chat/ChatSettings.jsx +++ /dev/null @@ -1,598 +0,0 @@ -import { - Alert, - Box, - Button, - CircularProgress, - Divider, - Drawer, - FormControl, - IconButton, - InputLabel, - List, - ListItem, - ListItemSecondaryAction, - ListItemText, - MenuItem, - Select, - Switch, - Tooltip, - Typography, -} from '@mui/material' -import { - VerticalAlignBottom as AutoScrollIcon, - Close as CloseIcon, - Delete as DeleteIcon, - Info as InfoIcon, - Masks as MasksIcon, - Memory as ModelIcon, - Psychology as PsychologyIcon, - Refresh as RefreshIcon, - Settings as SettingsIcon, - Storage as StorageIcon, - TravelExplore as TourIcon, -} from '@mui/icons-material' -import { CONNECTION_STATES } from './store/webSocketSlice' -import { formatBytes, getChatStorageStats } from './store/storageUtils' -import { humanize } from './chatUtils' -import { makeStyles } from '@mui/styles' -import { - useConnectionState, - useModels, - usePersonas, - useSessionActions, - useSessionState, - useSettings, -} from './store/useChatStore' -import PropTypes from 'prop-types' -import React, { useCallback, useEffect, useState } from 'react' - -const useStyles = makeStyles((theme) => ({ - drawer: { - width: 320, - flexShrink: 0, - }, - drawerPaper: { - width: 320, - padding: theme.spacing(2), - }, - header: { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - marginBottom: theme.spacing(2), - }, - section: { - marginBottom: theme.spacing(3), - marginTop: theme.spacing(2), - }, - sectionTitle: { - marginBottom: theme.spacing(1), - fontWeight: 'bold', - }, - settingItem: { - paddingLeft: 0, - paddingRight: 0, - alignItems: 'center', - }, - settingItemAction: { - top: 20, - }, - dangerButton: { - color: theme.palette.error.main, - borderColor: theme.palette.error.main, - '&:hover': { - backgroundColor: theme.palette.error.main, - color: theme.palette.error.contrastText, - }, - }, - connectionInfo: { - marginBottom: theme.spacing(2), - }, - fullWidthSelect: { - width: '100%', - }, - personaDescription: { - marginTop: theme.spacing(1), - marginBottom: theme.spacing(1), - padding: theme.spacing(1), - backgroundColor: - theme.palette.mode === 'dark' - ? 'rgba(255, 255, 255, 0.05)' - : 'rgba(0, 0, 0, 0.02)', - borderRadius: theme.shape.borderRadius, - }, -})) - -export default function ChatSettings({ onClearMessages, onReconnect }) { - const classes = useStyles() - - const { settings, settingsOpen, updateSettings, setSettingsOpen, resetTour } = - useSettings() - const { connectionState } = useConnectionState() - const { personas, personasLoading, personasError, loadPersonas } = - usePersonas() - const { models, defaultModel, modelsLoading, modelsError, loadModels } = - useModels() - const { sessions, activeSessionId } = useSessionState() - const { clearAllSessions, clearOldSessions } = useSessionActions() - - const isConnected = connectionState === 'connected' - const tourCompleted = settings.tourCompleted - - // Storage stats - const [storageStats, setStorageStats] = useState({ - conversationCount: 0, - sizeBytes: 0, - }) - const [storageLoading, setStorageLoading] = useState(false) - - useEffect(() => { - if (personas.length === 0 && !personasLoading) { - loadPersonas() - } - }, [personas.length, personasLoading, loadPersonas]) - - useEffect(() => { - if (models.length === 0 && !modelsLoading) { - loadModels() - } - }, [models.length, modelsLoading, loadModels]) - - // Load storage stats - const loadStorageStats = useCallback(async () => { - setStorageLoading(true) - try { - const stats = await getChatStorageStats( - sessions, - activeSessionId, - settings - ) - setStorageStats(stats) - } catch (error) { - console.error('Error loading storage stats:', error) - } finally { - setStorageLoading(false) - } - }, [sessions, activeSessionId, settings]) - - // Load storage stats when drawer opens or sessions change - useEffect(() => { - if (open) { - loadStorageStats() - } - }, [open, loadStorageStats]) - - const handleSettingChange = (key) => (event) => { - updateSettings({ - [key]: event.target.checked, - }) - } - - const handlePersonaChange = (event) => { - updateSettings({ - persona: event.target.value, - }) - } - - const handleModelChange = (event) => { - updateSettings({ - modelId: event.target.value, - }) - } - - const getSelectedPersona = () => { - return personas.find((p) => p.name === settings.persona) || personas[0] - } - - const getResolvedModelId = () => { - // Resolve the effective model ID: use settings.modelId if valid, - // otherwise defaultModel if valid, otherwise first model - if (settings.modelId && models.find((m) => m.id === settings.modelId)) { - return settings.modelId - } - if (defaultModel && models.find((m) => m.id === defaultModel)) { - return defaultModel - } - return models.length > 0 ? models[0].id : '' - } - - const getSelectedModel = () => { - const resolvedId = getResolvedModelId() - return resolvedId ? models.find((m) => m.id === resolvedId) : null - } - - const getConnectionStatusText = () => { - switch (connectionState) { - case CONNECTION_STATES.CONNECTING: - return 'Connecting...' - case CONNECTION_STATES.CONNECTED: - return 'Connected' - case CONNECTION_STATES.DISCONNECTED: - return 'Disconnected' - default: - return 'Unknown' - } - } - - const getConnectionStatusColor = () => { - switch (connectionState) { - case CONNECTION_STATES.CONNECTED: - return 'success' - case CONNECTION_STATES.CONNECTING: - return 'warning' - case CONNECTION_STATES.DISCONNECTED: - return 'error' - default: - return 'info' - } - } - - const handleClearOldConversations = async () => { - const clearedCount = clearOldSessions(1) // Clear conversations older than 1 day - await loadStorageStats() - if (clearedCount > 0) { - console.log(`Cleared ${clearedCount} old conversation(s)`) - } - } - - const handleClearAllConversations = async () => { - if ( - window.confirm( - 'Are you sure you want to clear all saved conversations? This cannot be undone.' - ) - ) { - clearAllSessions() - await loadStorageStats() - onClearMessages() // Also clear current messages - } - } - - const handleRestartTour = () => { - resetTour() - setSettingsOpen(false) - } - - return ( - setSettingsOpen(false)} - classes={{ - paper: classes.drawerPaper, - }} - > -
- - - Chat Settings - - setSettingsOpen(false)} size="small"> - - -
- - {/* Connection Status */} -
- - Connection Status - - - - - ) - } - > - {getConnectionStatusText()} - -
- - - - {/* Model Selection */} -
- - AI Model - - - {modelsLoading ? ( - - - - Loading models... - - - ) : modelsError ? ( - - Could not load models. Using default. - - ) : ( - <> - - Select Model - - - - {(() => { - const selectedModel = getSelectedModel() - return ( - selectedModel && - selectedModel.description && ( - - - {selectedModel.description} - - - ) - ) - })()} - - )} -
- - - - {/* Persona Selection */} -
- - AI Persona - - - {personasLoading ? ( - - - - Loading personas... - - - ) : personasError ? ( - - Could not load personas. Using default. - - ) : ( - <> - - Select Persona - - - - {getSelectedPersona() && ( - - - {getSelectedPersona().description} - - {getSelectedPersona().style_instructions && ( - - {getSelectedPersona().style_instructions} - - )} - - )} - - )} -
- - - - {/* Display Settings */} -
- - Display Options - - - - - - } - checkedIcon={} - /> - - - - - - - } - checkedIcon={} - /> - - - -
- - - - {/* Storage Management */} -
- - - - Storage - - - - - - - {storageLoading ? ( - - - - Loading storage info... - - - ) : ( - <> - - theme.palette.mode === 'dark' - ? 'rgba(255, 255, 255, 0.05)' - : 'rgba(0, 0, 0, 0.02)', - borderRadius: 1, - }} - > - - - Conversations: - - - {sessions.length} - - - - - Storage used: - - - {formatBytes(storageStats.sizeBytes)} - - - - - - - - - - )} -
- - - - {/* Connection Management */} -
- - Connection - - - -
- - - - {/* Tour Management */} -
- - Help & Guidance - - - - {!tourCompleted && ( - - The tour will start automatically on first use - - )} -
-
- ) -} - -ChatSettings.propTypes = { - onClearMessages: PropTypes.func.isRequired, - onReconnect: PropTypes.func.isRequired, -} diff --git a/sippy-ng/src/chat/ChatTour.jsx b/sippy-ng/src/chat/ChatTour.jsx deleted file mode 100644 index ad44130feb..0000000000 --- a/sippy-ng/src/chat/ChatTour.jsx +++ /dev/null @@ -1,188 +0,0 @@ -import { ACTIONS, EVENTS, Joyride, STATUS } from 'react-joyride' -import { CONNECTION_STATES } from './store/webSocketSlice' -import { - useConnectionState, - useDrawer, - useSettings, -} from './store/useChatStore' -import { useTheme } from '@mui/material/styles' -import PropTypes from 'prop-types' -import React, { useEffect, useState } from 'react' - -/** - * ChatTour - Interactive tour of the chat interface - * Only shown once per user (tracked via zustand store) - */ -export default function ChatTour({ mode = 'fullPage' }) { - const theme = useTheme() - const [runTour, setRunTour] = useState(false) - const [componentKey, setComponentKey] = useState(0) - - const { settings, setTourCompleted } = useSettings() - const { connectionState } = useConnectionState() - const { isDrawerOpen } = useDrawer() - - const isConnected = connectionState === CONNECTION_STATES.CONNECTED - const tourCompleted = settings.tourCompleted - - // Start tour when ready (drawer mode: only when open) - useEffect(() => { - const shouldStartTour = - isConnected && - !tourCompleted && - (mode === 'fullPage' || (mode === 'drawer' && isDrawerOpen)) - - if (shouldStartTour) { - const timer = setTimeout(() => { - if (mode === 'drawer') { - // Force remount to recalculate positions for drawer - setComponentKey((prev) => prev + 1) - } - setRunTour(true) - }, 200) - return () => clearTimeout(timer) - } else if (mode === 'drawer' && !isDrawerOpen) { - setRunTour(false) - } - }, [isConnected, tourCompleted, mode, isDrawerOpen]) - - const handleJoyrideCallback = (data) => { - const { action, status, type } = data - - if ( - [STATUS.FINISHED, STATUS.SKIPPED].includes(status) || - (action === ACTIONS.CLOSE && type === EVENTS.STEP_AFTER) - ) { - setRunTour(false) - setTourCompleted(true) - } - } - - // Different selectors for full page vs drawer mode - const sessionDropdownSelector = - mode === 'drawer' - ? '[data-tour="session-dropdown-drawer"]' - : '[data-tour="session-dropdown"]' - - const steps = [ - { - target: sessionDropdownSelector, - content: - 'Click here to view and switch between your chat sessions. Up to the last 50 conversations will be stored locally in your browser.', - skipBeacon: true, - placement: 'bottom', - }, - { - target: '[data-tour="new-chat"]', - content: - 'Start a new conversation with Sippy. Each conversation maintains its own context.', - placement: 'bottom', - }, - { - target: '[data-tour="share-button"]', - content: - 'Share your conversation with others. This creates a shareable link that anyone can view.', - placement: 'bottom', - }, - { - target: '[data-tour="help-button"]', - content: - 'Need help? Click here to open the Sippy Chat user guide with detailed instructions and examples.', - placement: 'bottom', - }, - { - target: '[data-tour="settings-button"]', - content: - 'Customize Sippy chat settings: thinking steps, local storage, connection options, and more.', - placement: 'bottom', - }, - { - target: '[data-tour="status-area"]', - content: - "Status information appears here: connection status, contextual information from the page you're viewing, and your selected AI persona.", - placement: 'top', - }, - { - target: '[data-tour="suggestions"]', - content: - 'Not sure what to ask? Click any of these suggestions to get started.', - placement: 'top', - }, - { - target: '[data-tour="command-button"]', - content: - 'Browse all available prompt commands. You can also type "/" in the input to search commands.', - placement: 'top', - }, - ] - - return ( - - ) -} - -ChatTour.propTypes = { - mode: PropTypes.oneOf(['fullPage', 'drawer']), -} diff --git a/sippy-ng/src/chat/CollapsibleChatDrawer.jsx b/sippy-ng/src/chat/CollapsibleChatDrawer.jsx deleted file mode 100644 index 904be2257d..0000000000 --- a/sippy-ng/src/chat/CollapsibleChatDrawer.jsx +++ /dev/null @@ -1,86 +0,0 @@ -import { ExpandLess as ExpandLessIcon } from '@mui/icons-material' -import { makeStyles } from '@mui/styles' -import { Paper, Typography } from '@mui/material' -import ChatInterface from './ChatInterface' -import PropTypes from 'prop-types' -import React from 'react' -import sippyLogo from '../sippy.svg' - -const useStyles = makeStyles((theme) => ({ - collapsedTab: { - position: 'fixed', - right: theme.spacing(3), - bottom: 0, - zIndex: theme.zIndex.drawer - 1, - backgroundColor: theme.palette.background.paper, - borderTopLeftRadius: theme.shape.borderRadius * 2, - borderTopRightRadius: theme.shape.borderRadius * 2, - borderTop: `2px solid ${theme.palette.divider}`, - borderLeft: `1px solid ${theme.palette.divider}`, - borderRight: `1px solid ${theme.palette.divider}`, - boxShadow: theme.shadows[8], - cursor: 'pointer', - transition: 'all 0.3s ease', - '&:hover': { - boxShadow: theme.shadows[10], - bottom: 0, - }, - }, - collapsedContent: { - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - padding: theme.spacing(1, 2), - gap: theme.spacing(1.5), - }, - horizontalText: { - fontSize: '0.875rem', - fontWeight: 500, - color: theme.palette.text.primary, - whiteSpace: 'nowrap', - }, - sippyLogo: { - width: 32, - height: 32, - }, -})) - -export default function CollapsibleChatDrawer({ open, onOpen, onClose }) { - const classes = useStyles() - - const handleOpen = (e) => { - e.preventDefault() - e.stopPropagation() - onOpen() - } - - return ( - <> - {/* Collapsed tab - shown when drawer is closed */} - {!open && ( - -
- Sippy - - Chat with Sippy - - -
-
- )} - - {/* Full drawer - shown when open */} - - - ) -} - -CollapsibleChatDrawer.propTypes = { - open: PropTypes.bool.isRequired, - onOpen: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, -} diff --git a/sippy-ng/src/chat/MessageChart.jsx b/sippy-ng/src/chat/MessageChart.jsx deleted file mode 100644 index 866df85722..0000000000 --- a/sippy-ng/src/chat/MessageChart.jsx +++ /dev/null @@ -1,238 +0,0 @@ -import { - Close as CloseIcon, - Download as DownloadIcon, - Fullscreen as FullscreenIcon, -} from '@mui/icons-material' -import { - Dialog, - DialogContent, - DialogTitle, - IconButton, - Tooltip, -} from '@mui/material' -import { makeStyles, useTheme } from '@mui/styles' -import Plot from 'react-plotly.js' -import PropTypes from 'prop-types' -import React, { useRef, useState } from 'react' - -const useStyles = makeStyles((theme) => ({ - visualizationContainer: { - marginTop: theme.spacing(2), - marginBottom: theme.spacing(1), - position: 'relative', - '& .js-plotly-plot': { - width: '100% !important', - }, - }, - chartControls: { - position: 'absolute', - top: theme.spacing(1), - right: theme.spacing(1), - display: 'flex', - gap: theme.spacing(0.5), - zIndex: 10, - }, - chartControlButton: { - backgroundColor: theme.palette.background.paper, - backdropFilter: 'blur(4px)', - padding: theme.spacing(0.5), - border: `1px solid ${theme.palette.divider}`, - '&:hover': { - backgroundColor: theme.palette.action.hover, - }, - }, - chartModal: { - '& .MuiDialog-paper': { - maxWidth: '95vw', - maxHeight: '95vh', - width: '95vw', - height: '95vh', - }, - }, - chartModalContent: { - padding: theme.spacing(2), - display: 'flex', - flexDirection: 'column', - height: '100%', - overflow: 'hidden', - }, - chartModalPlotContainer: { - flex: 1, - minHeight: 0, - display: 'flex', - '& .js-plotly-plot': { - width: '100% !important', - height: '100% !important', - }, - }, -})) - -export default function MessageChart({ visualizations }) { - const classes = useStyles() - const theme = useTheme() - const [expandedChart, setExpandedChart] = useState(null) - const plotRefs = useRef([]) - - if (!visualizations || visualizations.length === 0) { - return null - } - - const handleDownloadPNG = (index) => { - const plotElement = plotRefs.current[index] - if (plotElement && window.Plotly) { - const gd = plotElement.el - window.Plotly.downloadImage(gd, { - format: 'png', - width: 1200, - height: 800, - filename: `sippy_chart_${index + 1}`, - }) - } - } - - const handleExpandChart = (viz, _index) => { - const expandedLayout = { - ...viz.layout, - paper_bgcolor: theme.palette.background.default, - plot_bgcolor: theme.palette.background.default, - font: { - color: theme.palette.text.primary, - ...viz.layout?.font, - }, - xaxis: { - gridcolor: theme.palette.divider, - ...viz.layout?.xaxis, - }, - yaxis: { - gridcolor: theme.palette.divider, - ...viz.layout?.yaxis, - }, - autosize: true, - } - - setExpandedChart({ - data: viz.data, - layout: expandedLayout, - config: viz.config, - }) - } - - const applyThemeToLayout = (layout) => { - return { - ...layout, - paper_bgcolor: theme.palette.background.default, - plot_bgcolor: theme.palette.background.default, - font: { - color: theme.palette.text.primary, - ...layout?.font, - }, - xaxis: { - gridcolor: theme.palette.divider, - ...layout?.xaxis, - }, - yaxis: { - gridcolor: theme.palette.divider, - ...layout?.yaxis, - }, - } - } - - return ( - <> - {visualizations.map((viz, index) => { - const themedLayout = applyThemeToLayout(viz.layout) - const config = { - displayModeBar: false, - displaylogo: false, - responsive: true, - ...viz.config, - } - - return ( -
-
- - handleExpandChart(viz, index)} - > - - - - - handleDownloadPNG(index)} - > - - - -
- - (plotRefs.current[index] = el)} - data={viz.data} - layout={themedLayout} - config={config} - useResizeHandler={true} - style={{ width: '100%', height: '100%' }} - /> -
- ) - })} - - {expandedChart && ( - setExpandedChart(null)} - maxWidth={false} - className={classes.chartModal} - > - - {expandedChart.layout?.title?.text || 'Chart'} - setExpandedChart(null)} - size="small" - > - - - - -
- -
-
-
- )} - - ) -} - -MessageChart.propTypes = { - visualizations: PropTypes.arrayOf( - PropTypes.shape({ - data: PropTypes.array.isRequired, - layout: PropTypes.object.isRequired, - config: PropTypes.object, - }) - ), -} diff --git a/sippy-ng/src/chat/OneShotChatModal.jsx b/sippy-ng/src/chat/OneShotChatModal.jsx deleted file mode 100644 index ae3d0da0d7..0000000000 --- a/sippy-ng/src/chat/OneShotChatModal.jsx +++ /dev/null @@ -1,392 +0,0 @@ -import { - CircularProgress, - Dialog, - DialogContent, - DialogTitle, - IconButton, - Typography, -} from '@mui/material' -import { Close as CloseIcon } from '@mui/icons-material' -import { - createMessage, - getChatWebSocketUrl, - MESSAGE_TYPES, - parseWebSocketMessage, - WEBSOCKET_STATES, -} from './chatUtils' -import { makeStyles } from '@mui/styles' -import { usePageContextForChat } from './store/useChatStore' -import PropTypes from 'prop-types' -import React, { useCallback, useEffect, useRef, useState } from 'react' -import ThinkingStep from './ThinkingStep' - -const useStyles = makeStyles((theme) => ({ - dialogPaper: { - height: '500px', - display: 'flex', - flexDirection: 'column', - }, - dialogContent: { - display: 'flex', - flexDirection: 'column', - padding: 0, - flex: 1, - overflow: 'hidden', - '&:first-child': { - paddingTop: 0, - }, - }, - messagesContainer: { - flex: 1, - overflowY: 'auto', - padding: theme.spacing(2), - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(2), - }, - messageWrapper: { - marginBottom: theme.spacing(1), - }, - currentThinking: { - margin: theme.spacing(1, 0), - padding: theme.spacing(1), - backgroundColor: - theme.palette.mode === 'dark' - ? 'rgba(255, 255, 255, 0.05)' - : 'rgba(0, 0, 0, 0.02)', - borderRadius: theme.shape.borderRadius, - }, - statusContainer: { - padding: theme.spacing(2), - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - gap: theme.spacing(2), - }, - statusText: { - textAlign: 'center', - color: theme.palette.text.secondary, - }, - aiNotice: { - fontSize: '0.65rem', - color: theme.palette.text.secondary, - fontStyle: 'italic', - padding: theme.spacing(2), - textAlign: 'center', - borderTop: `1px solid ${theme.palette.divider}`, - }, -})) - -/** - * One-shot chat modal that makes a single request, shows thinking steps, - * and returns the result via callback - */ -export default function OneShotChatModal({ - open, - onClose, - prompt, - onResult, - title = 'Generating...', -}) { - const classes = useStyles() - const { pageContext } = usePageContextForChat() - const [connectionState, setConnectionState] = useState( - WEBSOCKET_STATES.CLOSED - ) - const [thinkingSteps, setThinkingSteps] = useState([]) - const [currentThinking, setCurrentThinking] = useState(null) - const [error, setError] = useState(null) - const [result, setResult] = useState(null) - - const wsRef = useRef(null) - const hasStartedRef = useRef(false) - const messagesEndRef = useRef(null) - const lastMessageRef = useRef(null) - - // Connect to WebSocket and send message - const startRequest = useCallback(() => { - if (hasStartedRef.current || !prompt) { - return - } - - hasStartedRef.current = true - - try { - const wsUrl = getChatWebSocketUrl() - console.log('OneShotChat: Connecting to WebSocket:', wsUrl) - - wsRef.current = new WebSocket(wsUrl) - setConnectionState(WEBSOCKET_STATES.CONNECTING) - setError(null) - - wsRef.current.onopen = () => { - console.log('OneShotChat: WebSocket connected') - setConnectionState(WEBSOCKET_STATES.OPEN) - - // Send the request immediately - const payload = { - message: prompt, - chat_history: [], - show_thinking: true, - persona: 'default', - page_context: pageContext, - } - - console.log('OneShotChat: Sending request:', payload) - wsRef.current.send(JSON.stringify(payload)) - } - - wsRef.current.onmessage = (event) => { - const message = parseWebSocketMessage(event.data) - if (!message) return - - handleWebSocketMessage(message) - } - - wsRef.current.onclose = (event) => { - console.log('OneShotChat: WebSocket closed:', event.code, event.reason) - setConnectionState(WEBSOCKET_STATES.CLOSED) - } - - wsRef.current.onerror = (error) => { - console.error('OneShotChat: WebSocket error:', error) - setError('Connection error occurred') - setConnectionState(WEBSOCKET_STATES.CLOSED) - } - } catch (err) { - console.error('OneShotChat: Failed to create WebSocket connection:', err) - setError('Failed to connect to chat service') - setConnectionState(WEBSOCKET_STATES.CLOSED) - } - }, [prompt, pageContext]) - - // Handle incoming WebSocket messages - const handleWebSocketMessage = useCallback((message) => { - switch (message.type) { - case MESSAGE_TYPES.THINKING_STEP: - handleThinkingStep(message.data) - break - - case MESSAGE_TYPES.FINAL_RESPONSE: - handleFinalResponse(message.data) - break - - case MESSAGE_TYPES.ERROR: - handleError(message.data) - break - - default: - console.warn('OneShotChat: Unknown message type:', message.type) - } - }, []) - - // Handle thinking step updates - const handleThinkingStep = useCallback((data) => { - if (data.complete) { - // Thinking step completed - add to list - setThinkingSteps((prev) => { - const existing = prev.find( - (step) => step.data?.step_number === data.step_number - ) - - if (existing) { - return prev.map((step) => - step.id === existing.id - ? { - ...step, - data: { - ...step.data, - ...data, - }, - } - : step - ) - } else { - return [ - ...prev, - createMessage(MESSAGE_TYPES.THINKING_STEP, '', { - data: { ...data }, - }), - ] - } - }) - setCurrentThinking(null) - } else { - // Thinking step in progress - setCurrentThinking({ ...data }) - } - }, []) - - // Handle final response - const handleFinalResponse = useCallback( - (data) => { - console.log('OneShotChat: Final response received:', data.response) - setCurrentThinking(null) - setResult(data.response) - - // Call the callback with the result - if (onResult) { - onResult(data.response) - } - - // Close the WebSocket - if (wsRef.current) { - wsRef.current.close(1000, 'Request completed') - } - }, - [onResult] - ) - - // Handle error messages - const handleError = useCallback((data) => { - console.error('OneShotChat: Error received:', data.error) - setCurrentThinking(null) - setError(data.error) - - // Close the WebSocket - if (wsRef.current) { - wsRef.current.close(1000, 'Error occurred') - } - }, []) - - // Start request when modal opens - useEffect(() => { - if (open && prompt) { - startRequest() - } - - // Cleanup on unmount or close - return () => { - if (wsRef.current) { - wsRef.current.close(1000, 'Modal closed') - wsRef.current = null - } - } - }, [open, prompt, startRequest]) - - // Reset state when modal is closed - useEffect(() => { - if (!open) { - setConnectionState(WEBSOCKET_STATES.CLOSED) - setThinkingSteps([]) - setCurrentThinking(null) - setError(null) - setResult(null) - hasStartedRef.current = false - } - }, [open]) - - // Auto-scroll to show the latest message when new thinking steps arrive - useEffect(() => { - if (lastMessageRef.current) { - // Scroll to the top of the last message, not the bottom - lastMessageRef.current.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }) - } - }, [thinkingSteps, currentThinking]) - - const handleClose = () => { - if (wsRef.current) { - wsRef.current.close(1000, 'User closed modal') - } - onClose() - } - - return ( - - - {title} - {!error && !result && ( - - )} - - - - - - - {error ? ( -
- {error} -
- ) : result ? ( -
- - ✓ Generation complete - -
- ) : connectionState === WEBSOCKET_STATES.CONNECTING ? ( -
- - - Connecting... - -
- ) : ( -
- {thinkingSteps.map((step, index) => { - const isLastStep = index === thinkingSteps.length - 1 - return ( -
- -
- ) - })} - - {currentThinking && ( -
- -
- )} - -
-
- )} - - {/* AI Notice */} - - Always review AI generated content prior to use. - - -
- ) -} - -OneShotChatModal.propTypes = { - open: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, - prompt: PropTypes.string.isRequired, - onResult: PropTypes.func.isRequired, - title: PropTypes.string, -} diff --git a/sippy-ng/src/chat/Rating.jsx b/sippy-ng/src/chat/Rating.jsx deleted file mode 100644 index 1e55d969d4..0000000000 --- a/sippy-ng/src/chat/Rating.jsx +++ /dev/null @@ -1,131 +0,0 @@ -import { Box, Fade, Tooltip, Typography } from '@mui/material' -import { Info as InfoIcon, Star as StarIcon } from '@mui/icons-material' -import { makeStyles } from '@mui/styles' -import PropTypes from 'prop-types' -import React, { useState } from 'react' - -const useStyles = makeStyles((theme) => ({ - ratingContainer: { - display: 'flex', - alignItems: 'center', - gap: theme.spacing(0.75), - padding: 0, - }, - starsContainer: { - display: 'flex', - gap: 2, - }, - star: { - cursor: 'pointer', - transition: 'all 0.2s ease-in-out', - color: theme.palette.grey[400], - '&:hover': { - transform: 'scale(1.2)', - color: theme.palette.warning.main, - }, - '&.filled': { - color: theme.palette.warning.main, - }, - '&.hovered': { - color: theme.palette.warning.light, - }, - }, - ratingLabel: { - fontSize: '0.75rem', - color: theme.palette.text.secondary, - marginRight: theme.spacing(0.5), - display: 'flex', - alignItems: 'center', - gap: theme.spacing(0.5), - }, - infoIcon: { - fontSize: '0.9rem', - color: theme.palette.text.secondary, - opacity: 0.6, - cursor: 'help', - }, - thankYouMessage: { - fontSize: '0.75rem', - color: theme.palette.text.secondary, - fontStyle: 'italic', - }, -})) - -const ratingLabels = { - 1: 'Wasted my time', - 2: 'Not helpful', - 3: 'Neutral', - 4: 'Saved me time', - 5: 'Huge time saver!', -} - -export default function Rating({ messageId, onRate }) { - const classes = useStyles() - const [hoveredStar, setHoveredStar] = useState(null) - const [selectedRating, setSelectedRating] = useState(null) - const [showThanks, setShowThanks] = useState(false) - const [fadeOut, setFadeOut] = useState(false) - - const handleStarClick = (rating) => { - setSelectedRating(rating) - setShowThanks(true) - if (onRate) { - onRate(messageId, rating) - } - - // Start fade out after 2 seconds - setTimeout(() => { - setFadeOut(true) - }, 2000) - } - - // Show thank you message after rating - if (showThanks) { - return ( - - - - Thanks for your feedback! - - - - ) - } - - return ( - - - - Have I saved you time today? - - - - -
- {[1, 2, 3, 4, 5].map((star) => ( - - setHoveredStar(star)} - onMouseLeave={() => setHoveredStar(null)} - onClick={() => handleStarClick(star)} - /> - - ))} -
-
-
- ) -} - -Rating.propTypes = { - messageId: PropTypes.string.isRequired, - onRate: PropTypes.func, -} diff --git a/sippy-ng/src/chat/SessionDropdown.jsx b/sippy-ng/src/chat/SessionDropdown.jsx deleted file mode 100644 index 04872f8045..0000000000 --- a/sippy-ng/src/chat/SessionDropdown.jsx +++ /dev/null @@ -1,326 +0,0 @@ -import { - Box, - Button, - IconButton, - ListItemIcon, - ListItemText, - Menu, - MenuItem, - Tooltip, - Typography, -} from '@mui/material' -import { - CallSplit as CallSplitIcon, - Chat as ChatIcon, - Close as CloseIcon, - FiberManualRecord as DotIcon, - ExpandMore as ExpandMoreIcon, - List as ListIcon, - NoteAdd as NoteAddIcon, - Person as PersonIcon, - Share as ShareIcon, -} from '@mui/icons-material' -import { CONNECTION_STATES } from './store/webSocketSlice' -import { getSessionIconName, getSessionTitle } from './sessionUtils' -import { makeStyles } from '@mui/styles' -import { MESSAGE_TYPES } from './chatUtils' -import { relativeTime } from '../helpers' -import { - useConnectionState, - useSessionActions, - useSessionState, -} from './store/useChatStore' -import PropTypes from 'prop-types' -import React, { useState } from 'react' - -const useStyles = makeStyles((theme) => ({ - menuPaper: { - maxHeight: 400, - width: 350, - }, - dropdownButton: { - textTransform: 'none', - maxWidth: '200px', - color: theme.palette.text.primary, - }, - dropdownButtonCompact: { - minWidth: 'auto', - maxWidth: 'none', - }, - buttonLabel: { - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - textAlign: 'left', - flex: 1, - }, - sessionInfo: { - flex: 1, - minWidth: 0, - }, - sessionTitle: { - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - }, - sessionMeta: { - display: 'flex', - gap: theme.spacing(1), - fontSize: '0.75rem', - color: theme.palette.text.secondary, - }, -})) - -export default function SessionManager({ onNewSession, mode = 'fullPage' }) { - const classes = useStyles() - const [anchorEl, setAnchorEl] = useState(null) - const open = Boolean(anchorEl) - - // Get state and actions from custom hooks - const { sessions, activeSessionId } = useSessionState() - const { switchSession, startNewSession, deleteSession } = useSessionActions() - const { connectionState, isTyping } = useConnectionState() - - const isConnected = connectionState === CONNECTION_STATES.CONNECTED - const disabled = !isConnected || isTyping - - const handleClick = (event) => { - setAnchorEl(event.currentTarget) - } - - const handleClose = () => { - setAnchorEl(null) - } - - const handleNewSession = () => { - // Start new session (find empty or create) - automatically ensures empty session - startNewSession() - - // Call side-effect callback (for clearing shared URL, etc.) - if (onNewSession) { - onNewSession() - } - } - - const handleSelectSession = (sessionId) => { - switchSession(sessionId) - - // Sync URL in fullPage mode - if (mode === 'fullPage') { - const targetSession = sessions.find((s) => s.id === sessionId) - if (targetSession && targetSession.sharedId) { - window.history.pushState( - null, - '', - `/sippy-ng/chat/${targetSession.sharedId}` - ) - } else { - window.history.pushState(null, '', '/sippy-ng/chat') - } - } - - handleClose() - } - - const handleDeleteSession = (event, sessionId) => { - event.stopPropagation() - deleteSession(sessionId) - - // Sync URL in fullPage mode - if (mode === 'fullPage') { - window.history.pushState(null, '', '/sippy-ng/chat') - } - } - - // Helper to get the timestamp used for display - const getSessionTimestamp = (session) => { - const messages = session.messages || [] - return messages.length > 0 - ? messages[messages.length - 1].timestamp - : session.updatedAt - } - - // Sort sessions by the same timestamp shown in the UI (most recent first) - // This ensures the order matches what users see and doesn't jump around - const sortedSessions = [...sessions].sort( - (a, b) => - new Date(getSessionTimestamp(b)) - new Date(getSessionTimestamp(a)) - ) - - // Get icon component based on session type - const getIconComponent = (session) => { - const iconName = getSessionIconName(session.type) - let icon = null - - switch (iconName) { - case 'Person': - icon = - break - case 'Share': - icon = - break - case 'CallSplit': - icon = - break - default: - icon = - } - - // Add tooltip for shared sessions - if (session.type === 'shared' && session.sharedBy) { - return ( - - {icon} - - ) - } - - return icon - } - - // Get the active session to display its title - const activeSession = sessions.find((s) => s.id === activeSessionId) - const currentTitle = activeSession - ? getSessionTitle(activeSession) - : 'Untitled Conversation' - - // Show compact version (icon only) in drawer mode - const isCompact = mode === 'drawer' - - return ( - <> - - - - - - - - - - - - - - {sortedSessions.length === 0 ? ( - - No chat sessions - - ) : ( - sortedSessions.map((session) => { - const isActive = session.id === activeSessionId - - // Count only user and assistant messages, not thinking steps or system messages - const messages = session.messages || [] - const countableMessages = messages.filter( - (msg) => - msg.type === MESSAGE_TYPES.USER || - msg.type === MESSAGE_TYPES.ASSISTANT - ) - const messageCount = countableMessages.length - - // Get the timestamp to display (same as used for sorting) - const lastMessageTime = getSessionTimestamp(session) - - return ( - handleSelectSession(session.id)} - selected={isActive} - > - - {getIconComponent(session)} - - - - - - - {relativeTime(new Date(lastMessageTime), new Date())} - - - - {messageCount} msg{messageCount !== 1 ? 's' : ''} - - - - - {isActive && ( - - )} - - handleDeleteSession(e, session.id)} - aria-label="Remove from list" - sx={{ - padding: 0.5, - '&:hover': { - color: 'error.main', - }, - }} - > - - - - ) - }) - )} - - - ) -} - -SessionManager.propTypes = { - onNewSession: PropTypes.func, - mode: PropTypes.oneOf(['fullPage', 'drawer']), -} diff --git a/sippy-ng/src/chat/ShareDialog.jsx b/sippy-ng/src/chat/ShareDialog.jsx deleted file mode 100644 index 10b77ec5e0..0000000000 --- a/sippy-ng/src/chat/ShareDialog.jsx +++ /dev/null @@ -1,130 +0,0 @@ -import { - Alert, - Button, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - IconButton, - Snackbar, - TextField, - Typography, -} from '@mui/material' -import { - Close as CloseIcon, - ContentCopy as ContentCopyIcon, -} from '@mui/icons-material' -import { makeStyles } from '@mui/styles' -import { useShareActions, useShareState } from './store/useChatStore' -import React from 'react' - -const useStyles = makeStyles((theme) => ({ - shareDialogTitle: { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - paddingRight: theme.spacing(1), - }, - shareDialogContent: { - paddingTop: theme.spacing(2), - }, - shareTextField: { - marginTop: theme.spacing(2), - '& .MuiOutlinedInput-root': { - fontFamily: 'monospace', - fontSize: '0.9rem', - }, - }, - copyButton: { - marginRight: theme.spacing(1), - }, -})) - -/** - * Dialog for displaying and copying a shared conversation link - */ -export default function ShareDialog() { - const classes = useStyles() - - const { shareDialogOpen, sharedUrl, shareSnackbar } = useShareState() - const { setShareDialogOpen, closeShareSnackbar, copyToClipboard } = - useShareActions() - - return ( - <> - setShareDialogOpen(false)} - maxWidth="sm" - fullWidth - > - - - Conversation Shared - - setShareDialogOpen(false)} - aria-label="close" - size="small" - > - - - - - - Your conversation has been shared! Anyone with this link can view - and continue the conversation. - - - Note: Shared conversations will be available for up to 90 days. - - e.target.select()} - /> - - - - - - - - - - {shareSnackbar.message} - - - - ) -} - -ShareDialog.propTypes = {} diff --git a/sippy-ng/src/chat/SlashCommandModal.jsx b/sippy-ng/src/chat/SlashCommandModal.jsx deleted file mode 100644 index 5a72331df3..0000000000 --- a/sippy-ng/src/chat/SlashCommandModal.jsx +++ /dev/null @@ -1,419 +0,0 @@ -import { - Accordion, - AccordionDetails, - AccordionSummary, - Autocomplete, - Box, - Button, - CircularProgress, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - TextField, - Typography, -} from '@mui/material' -import { ExpandMore as ExpandMoreIcon } from '@mui/icons-material' -import { makeStyles } from '@mui/styles' -import { safeEncodeURIComponent } from '../helpers' -import { usePrompts } from './store/useChatStore' -import PropTypes from 'prop-types' -import React, { useEffect, useState } from 'react' - -const useStyles = makeStyles((theme) => ({ - dialogPaper: { - minWidth: 600, - maxWidth: 800, - }, - description: { - marginBottom: theme.spacing(2), - color: theme.palette.text.secondary, - }, - formFields: { - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(2), - marginBottom: theme.spacing(2), - }, - previewSection: { - marginTop: theme.spacing(2), - }, - previewTitle: { - marginBottom: theme.spacing(1), - fontWeight: 600, - }, - previewContent: { - backgroundColor: - theme.palette.mode === 'dark' - ? 'rgba(255, 255, 255, 0.05)' - : 'rgba(0, 0, 0, 0.02)', - padding: theme.spacing(2), - borderRadius: theme.shape.borderRadius, - maxHeight: 300, - overflowY: 'auto', - fontFamily: 'monospace', - fontSize: '0.875rem', - whiteSpace: 'pre-wrap', - wordBreak: 'break-word', - }, -})) - -export default function SlashCommandModal({ - open, - onClose, - prompt, - onSubmit, - disabled = false, -}) { - const classes = useStyles() - const { renderPrompt } = usePrompts() - const [formValues, setFormValues] = useState({}) - const [preview, setPreview] = useState('') - const [previewLoading, setPreviewLoading] = useState(false) - const [previewExpanded, setPreviewExpanded] = useState(false) - const [autocompleteOptions, setAutocompleteOptions] = useState({}) - const [autocompleteLoading, setAutocompleteLoading] = useState({}) - - // Helper function to generate form field helper text - const getHelperText = (argument) => { - if (argument.default !== undefined && !argument.required) { - return `Optional - Defaults to ${JSON.stringify(argument.default)}` - } - if (argument.required && argument.type === 'array') { - return 'Required - Type and press Enter to add values' - } - if (argument.type === 'array') { - return 'Optional - Type and press Enter to add values' - } - return argument.required ? 'Required' : 'Optional' - } - - // Initialize form values with defaults and prefilled args - useEffect(() => { - if (!prompt) return - - const initialValues = {} - prompt.arguments?.forEach((argument) => { - // Priority: prefilledArgs > default value > null - if ( - prompt.prefilledArgs && - prompt.prefilledArgs[argument.name] !== undefined - ) { - initialValues[argument.name] = prompt.prefilledArgs[argument.name] - } else if (argument.default !== undefined) { - initialValues[argument.name] = argument.default - } else { - initialValues[argument.name] = null - } - }) - setFormValues(initialValues) - setPreview('') - setPreviewExpanded(false) - }, [prompt]) - - // Update preview when form values change and preview is expanded - useEffect(() => { - if (!prompt || !previewExpanded) return - - const updatePreview = async () => { - setPreviewLoading(true) - try { - const rendered = await renderPrompt(prompt.name, formValues) - setPreview(rendered) - } catch (error) { - console.error('Error rendering preview:', error) - setPreview('Error rendering preview') - } finally { - setPreviewLoading(false) - } - } - - updatePreview() - }, [formValues, prompt, renderPrompt, previewExpanded]) - - const handleFieldChange = (argName, value) => { - setFormValues((prev) => ({ - ...prev, - [argName]: value, - })) - } - - const handleSubmit = async () => { - try { - // Render prompt if we don't have a preview yet - let finalPrompt = preview - if (!finalPrompt) { - finalPrompt = await renderPrompt(prompt.name, formValues) - } - - onSubmit(finalPrompt) - onClose() - } catch (error) { - console.error('Error rendering prompt on submit:', error) - } - } - - const handleClose = () => { - setFormValues({}) - setPreview('') - onClose() - } - - // Check if all required fields are filled - const isFormValid = () => { - if (!prompt || !prompt.arguments) return true - - return prompt.arguments.every((argument) => { - if (!argument.required) return true - - const value = formValues[argument.name] - - // For arrays, check if it has at least one item - if (argument.type === 'array') { - return Array.isArray(value) && value.length > 0 - } - - // For strings, check if it's not null/undefined/empty - return value !== null && value !== undefined && value !== '' - }) - } - - // Fetch autocomplete options for a field - const fetchAutocompleteOptions = async (field, searchQuery = '') => { - setAutocompleteLoading((prev) => ({ ...prev, [field]: true })) - - try { - const queryParams = [] - if (searchQuery) { - queryParams.push('search=' + safeEncodeURIComponent(searchQuery)) - } - - const response = await fetch( - `${ - import.meta.env.VITE_API_URL - }/api/autocomplete/${field}?${queryParams.join('&')}` - ) - - if (response.ok) { - const values = await response.json() - setAutocompleteOptions((prev) => ({ - ...prev, - [field]: values || [], - })) - } - } catch (error) { - console.error('Error fetching autocomplete options:', error) - } finally { - setAutocompleteLoading((prev) => ({ ...prev, [field]: false })) - } - } - - // Render a form field based on argument type and autocomplete setting - const renderFormField = (argument) => { - const hasAutocomplete = argument.autocomplete - const isArray = argument.type === 'array' - - // Array field with autocomplete - if (isArray && hasAutocomplete) { - return ( - handleFieldChange(argument.name, newValue)} - onInputChange={(_, value) => { - if (value && hasAutocomplete) { - fetchAutocompleteOptions(argument.autocomplete, value) - } - }} - onOpen={() => { - if ( - hasAutocomplete && - !autocompleteOptions[argument.autocomplete] - ) { - fetchAutocompleteOptions(argument.autocomplete) - } - }} - loading={autocompleteLoading[argument.autocomplete]} - renderInput={(params) => ( - - {autocompleteLoading[argument.autocomplete] ? ( - - ) : null} - {params.InputProps.endAdornment} - - ), - }} - /> - )} - /> - ) - } - - // Array field without autocomplete - if (isArray) { - return ( - handleFieldChange(argument.name, newValue)} - renderInput={(params) => ( - - )} - /> - ) - } - - // Single value field with autocomplete - if (hasAutocomplete) { - return ( - handleFieldChange(argument.name, newValue)} - onInputChange={(_, value) => { - handleFieldChange(argument.name, value) - if (value && hasAutocomplete) { - fetchAutocompleteOptions(argument.autocomplete, value) - } - }} - onOpen={() => { - if ( - hasAutocomplete && - !autocompleteOptions[argument.autocomplete] - ) { - fetchAutocompleteOptions(argument.autocomplete) - } - }} - loading={autocompleteLoading[argument.autocomplete]} - renderInput={(params) => ( - - {autocompleteLoading[argument.autocomplete] ? ( - - ) : null} - {params.InputProps.endAdornment} - - ), - }} - /> - )} - /> - ) - } - - // Simple text field - return ( - handleFieldChange(argument.name, e.target.value)} - required={argument.required} - helperText={getHelperText(argument)} - fullWidth - /> - ) - } - - if (!prompt) return null - - return ( - - /{prompt.name} - - - {prompt.description} - - -
- {prompt.arguments?.map((argument) => renderFormField(argument))} -
- - setPreviewExpanded(isExpanded)} - className={classes.previewSection} - > - }> - Preview - - - - {previewLoading ? ( - - ) : ( - preview || 'Preview will appear here' - )} - - - -
- - - - -
- ) -} - -SlashCommandModal.propTypes = { - open: PropTypes.bool.isRequired, - disabled: PropTypes.bool, - onClose: PropTypes.func.isRequired, - prompt: PropTypes.shape({ - name: PropTypes.string.isRequired, - description: PropTypes.string, - arguments: PropTypes.arrayOf( - PropTypes.shape({ - name: PropTypes.string.isRequired, - description: PropTypes.string, - required: PropTypes.bool, - type: PropTypes.string, - autocomplete: PropTypes.string, - }) - ), - prefilledArgs: PropTypes.object, - }), - onSubmit: PropTypes.func.isRequired, -} diff --git a/sippy-ng/src/chat/SlashCommandSelector.jsx b/sippy-ng/src/chat/SlashCommandSelector.jsx deleted file mode 100644 index e95d01b80f..0000000000 --- a/sippy-ng/src/chat/SlashCommandSelector.jsx +++ /dev/null @@ -1,155 +0,0 @@ -import { - ClickAwayListener, - List, - ListItem, - ListItemText, - Paper, - Popper, -} from '@mui/material' -import { makeStyles } from '@mui/styles' -import { usePrompts } from './store/useChatStore' -import PropTypes from 'prop-types' -import React, { useEffect, useRef, useState } from 'react' - -const useStyles = makeStyles((theme) => ({ - popper: { - zIndex: theme.zIndex.modal + 1, - }, - list: { - maxHeight: 400, - overflow: 'auto', - }, - listItem: { - cursor: 'pointer', - '&:hover': { - backgroundColor: theme.palette.action.hover, - }, - }, -})) - -export default function SlashCommandSelector({ - anchorEl, - filterText = '', - open, - onSelect, - onClose, - onNavigate, - placement = 'top-start', -}) { - const classes = useStyles() - const { prompts } = usePrompts() - const [selectedIndex, setSelectedIndex] = useState(0) - const selectedItemRef = useRef(null) - - // Filter and sort visible prompts - const visiblePrompts = prompts - .filter((prompt) => !prompt.hide) - .sort((a, b) => a.name.localeCompare(b.name)) - - const filteredPrompts = visiblePrompts.filter((prompt) => - prompt.name.toLowerCase().includes(filterText.toLowerCase()) - ) - - // Reset selected index when filtered prompts change - useEffect(() => { - setSelectedIndex(0) - }, [filteredPrompts.length, filterText]) - - // Scroll selected item into view - useEffect(() => { - if (selectedItemRef.current) { - selectedItemRef.current.scrollIntoView({ - block: 'nearest', - behavior: 'smooth', - }) - } - }, [selectedIndex]) - - // Expose navigation methods to parent via callback - useEffect(() => { - if (onNavigate && filteredPrompts.length > 0) { - onNavigate({ - moveNext: () => - setSelectedIndex((prev) => - prev >= filteredPrompts.length - 1 ? 0 : prev + 1 - ), - movePrevious: () => - setSelectedIndex((prev) => - prev <= 0 ? filteredPrompts.length - 1 : prev - 1 - ), - selectCurrent: () => { - if (filteredPrompts[selectedIndex]) { - onSelect(filteredPrompts[selectedIndex]) - } - }, - }) - } - }, [onNavigate, filteredPrompts, selectedIndex, onSelect]) - - const handlePromptClick = (prompt) => { - onSelect(prompt) - if (onClose) { - onClose() - } - } - - const handleClickAway = () => { - if (onClose) { - onClose() - } - } - - const shouldShow = open && filteredPrompts.length > 0 - - if (!shouldShow) { - return null - } - - const content = ( - - - {filteredPrompts.map((prompt, index) => ( - handlePromptClick(prompt)} - selected={index === selectedIndex} - > - - - ))} - - - ) - - return ( - - {onClose ? ( - - {content} - - ) : ( - content - )} - - ) -} - -SlashCommandSelector.propTypes = { - anchorEl: PropTypes.object, - filterText: PropTypes.string, - open: PropTypes.bool.isRequired, - onSelect: PropTypes.func.isRequired, - onClose: PropTypes.func, - onNavigate: PropTypes.func, - placement: PropTypes.string, -} diff --git a/sippy-ng/src/chat/ThinkingStep.jsx b/sippy-ng/src/chat/ThinkingStep.jsx deleted file mode 100644 index ec794c9c43..0000000000 --- a/sippy-ng/src/chat/ThinkingStep.jsx +++ /dev/null @@ -1,349 +0,0 @@ -import { - Accordion, - AccordionDetails, - AccordionSummary, - Box, - Chip, - CircularProgress, - Tooltip, - Typography, -} from '@mui/material' -import { - Build as BuildIcon, - ExpandMore as ExpandMoreIcon, - Psychology as PsychologyIcon, - Visibility as VisibilityIcon, -} from '@mui/icons-material' -import { formatChatTimestamp } from './chatUtils' -import { makeStyles } from '@mui/styles' -import PropTypes from 'prop-types' -import React, { useState } from 'react' -import ReactMarkdown from 'react-markdown' -import remarkGfm from 'remark-gfm' - -const useStyles = makeStyles((theme) => ({ - thinkingStep: { - marginBottom: theme.spacing(1), - overflow: 'hidden', // Prevent accordion from overflowing - maxWidth: '70%', // Match assistant message width - '& .MuiAccordionSummary-root': { - backgroundColor: - theme.palette.mode === 'dark' - ? 'rgba(255, 255, 255, 0.05)' - : 'rgba(0, 0, 0, 0.02)', - borderRadius: theme.shape.borderRadius, - minHeight: 'auto', // Allow summary to be compact - }, - '& .MuiAccordionDetails-root': { - paddingTop: theme.spacing(1), - }, - }, - stepHeader: { - display: 'flex', - alignItems: 'center', - gap: theme.spacing(1), - flex: 1, - minWidth: 0, - width: '100%', - }, - stepNumber: { - backgroundColor: theme.palette.primary.main, - color: theme.palette.primary.contrastText, - minWidth: 28, // Increased from 24 to accommodate larger numbers - width: 28, - height: 28, // Increased from 24 - borderRadius: '50%', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - fontSize: '0.75rem', - fontWeight: 'bold', - flexShrink: 0, // Prevent the step number from shrinking - }, - thoughtText: { - flex: 1, - minWidth: 0, - overflow: 'hidden', - // Remove nowrap to allow text wrapping in collapsed state - display: '-webkit-box', - WebkitLineClamp: 2, // Show max 2 lines when collapsed - WebkitBoxOrient: 'vertical', - wordBreak: 'break-word', // Break long words - maxWidth: '100%', - '& p': { - margin: 0, - display: 'inline', - }, - '& strong': { - fontWeight: 700, - }, - '& em': { - fontStyle: 'italic', - }, - '& code': { - backgroundColor: - theme.palette.mode === 'dark' - ? 'rgba(0, 0, 0, 0.3)' - : 'rgba(0, 0, 0, 0.05)', - padding: '2px 4px', - borderRadius: theme.shape.borderRadius, - fontFamily: 'monospace', - fontSize: '0.85em', - }, - }, - actionChip: { - marginLeft: theme.spacing(1), - fontSize: '0.75rem', - flexShrink: 0, // Prevent chip from shrinking - }, - detailSection: { - marginBottom: theme.spacing(2), - '&:last-child': { - marginBottom: 0, - }, - }, - detailLabel: { - fontWeight: 'bold', - color: theme.palette.text.secondary, - marginBottom: theme.spacing(0.5), - }, - detailContent: { - backgroundColor: - theme.palette.mode === 'dark' - ? 'rgba(255, 255, 255, 0.05)' - : 'rgba(0, 0, 0, 0.02)', - padding: theme.spacing(1), - borderRadius: theme.shape.borderRadius, - fontFamily: 'monospace', - fontSize: '0.875rem', - whiteSpace: 'pre-wrap', - wordBreak: 'break-word', - }, - thoughtMarkdown: { - backgroundColor: - theme.palette.mode === 'dark' - ? 'rgba(255, 255, 255, 0.05)' - : 'rgba(0, 0, 0, 0.02)', - padding: theme.spacing(1), - borderRadius: theme.shape.borderRadius, - fontSize: '0.875rem', - '& p': { - margin: 0, - marginBottom: theme.spacing(1), - '&:last-child': { - marginBottom: 0, - }, - }, - '& strong': { - fontWeight: 700, - }, - '& em': { - fontStyle: 'italic', - }, - '& code': { - backgroundColor: - theme.palette.mode === 'dark' - ? 'rgba(0, 0, 0, 0.3)' - : 'rgba(0, 0, 0, 0.05)', - padding: '2px 4px', - borderRadius: theme.shape.borderRadius, - fontFamily: 'monospace', - fontSize: '0.85em', - }, - '& pre': { - backgroundColor: - theme.palette.mode === 'dark' - ? 'rgba(0, 0, 0, 0.3)' - : 'rgba(0, 0, 0, 0.05)', - padding: theme.spacing(1), - borderRadius: theme.shape.borderRadius, - overflow: 'auto', - '& code': { - backgroundColor: 'transparent', - padding: 0, - }, - }, - '& ul, & ol': { - marginTop: theme.spacing(0.5), - marginBottom: theme.spacing(0.5), - paddingLeft: theme.spacing(2.5), - }, - '& li': { - marginBottom: theme.spacing(0.5), - }, - }, - inProgress: { - display: 'flex', - alignItems: 'center', - gap: theme.spacing(1), - color: theme.palette.primary.main, - }, - timestamp: { - fontSize: '0.75rem', - color: theme.palette.text.secondary, - marginLeft: 'auto', - flexShrink: 0, // Prevent timestamp from shrinking - whiteSpace: 'nowrap', - }, -})) - -export default function ThinkingStep({ - data, - isInProgress = false, - defaultExpanded = false, -}) { - const classes = useStyles() - const [expanded, setExpanded] = useState(defaultExpanded || isInProgress) - - const handleExpandChange = (event, isExpanded) => { - setExpanded(isExpanded) - } - - const formatTimestamp = (timestamp) => { - if (!timestamp) return null - const formatted = formatChatTimestamp(timestamp) - return ( - - {formatted.main} - - ) - } - - const getActionIcon = (action) => { - const iconMap = { - get_prow_job_summary: , - analyze_job_logs: , - check_known_incidents: , - get_release_payloads: , - get_payload_details: , - } - return iconMap[action] || - } - - const getActionColor = (action) => { - const colorMap = { - get_prow_job_summary: 'primary', - analyze_job_logs: 'secondary', - check_known_incidents: 'warning', - get_release_payloads: 'info', - get_payload_details: 'success', - } - return colorMap[action] || 'default' - } - - const renderSummary = () => ( -
- - - - {isInProgress ? ( - 'Thinking...' - ) : ( - - {data.thought || 'Processing...'} - - )} - - - {data.action && ( - - )} - - {data.timestamp && formatTimestamp(data.timestamp)} -
- ) - - const renderDetails = () => ( - - {data.thought && ( -
- - Thought Process - -
- - {data.thought} - -
-
- )} - - {data.action && ( -
- - Action - -
{data.action}
-
- )} - - {data.action_input && ( -
- - Action Input - -
- {typeof data.action_input === 'string' - ? data.action_input - : JSON.stringify(data.action_input, null, 2)} -
-
- )} - - {data.observation && ( -
- - Observation - -
{data.observation}
-
- )} - - {isInProgress && ( -
- - Executing action... -
- )} -
- ) - - return ( - - }> - {renderSummary()} - - {renderDetails()} - - ) -} - -ThinkingStep.propTypes = { - data: PropTypes.shape({ - step_number: PropTypes.number, - thought: PropTypes.string, - action: PropTypes.string, - action_input: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), - observation: PropTypes.string, - complete: PropTypes.bool, - timestamp: PropTypes.string, - }).isRequired, - isInProgress: PropTypes.bool, - defaultExpanded: PropTypes.bool, -} diff --git a/sippy-ng/src/chat/chatUtils.jsx b/sippy-ng/src/chat/chatUtils.jsx deleted file mode 100644 index 11013c2e10..0000000000 --- a/sippy-ng/src/chat/chatUtils.jsx +++ /dev/null @@ -1,136 +0,0 @@ -// Chat utility functions and constants - -/** - * Convert kebab-case or snake_case strings to Title Case - * Examples: - * humanize('hello-world') => 'Hello World' - * humanize('hello_world') => 'Hello World' - * humanize('component-readiness') => 'Component Readiness' - */ -export function humanize(str) { - if (!str) return '' - - return str - .split(/[-_]/) - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' ') -} - -export const MESSAGE_TYPES = { - USER: 'user', - ASSISTANT: 'assistant', - THINKING_STEP: 'thinking_step', - FINAL_RESPONSE: 'final_response', - ERROR: 'error', - SYSTEM: 'system', -} - -export const WEBSOCKET_STATES = { - CONNECTING: 0, - OPEN: 1, - CLOSING: 2, - CLOSED: 3, -} - -// Format timestamp to second precision with relative time tooltip -export function formatChatTimestamp(timestamp) { - if (!timestamp) return '' - const date = new Date(timestamp) - return { - main: date.toISOString().replace(/\.\d{3}Z$/, 'Z'), - relative: getRelativeTime(date), - } -} - -// Get relative time for tooltips -function getRelativeTime(date) { - const now = new Date() - const diffMs = now - date - const diffSecs = Math.floor(diffMs / 1000) - const diffMins = Math.floor(diffSecs / 60) - const diffHours = Math.floor(diffMins / 60) - const diffDays = Math.floor(diffHours / 24) - - if (diffSecs < 60) return `${diffSecs} seconds ago` - if (diffMins < 60) return `${diffMins} minutes ago` - if (diffHours < 24) return `${diffHours} hours ago` - return `${diffDays} days ago` -} - -// Get WebSocket URL based on current environment -export function getChatWebSocketUrl() { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' - const baseUrl = import.meta.env.VITE_CHAT_API_URL || '/api/chat' - - let url - if (baseUrl.startsWith('/')) { - url = new URL(baseUrl, window.location.origin) - } else { - url = new URL(baseUrl) - url.protocol = protocol - } - - url.pathname = url.pathname.replace(/\/$/, '') + '/stream' - return url.toString() -} - -// Create a new message object -export function createMessage(type, content, options = {}) { - return { - id: generateMessageId(), - type, - content, - timestamp: new Date().toISOString(), - ...options, - } -} - -// Generate unique message ID -function generateMessageId() { - return `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` -} - -// Convert chat history to API format -export function formatChatHistoryForAPI(messages) { - return messages - .filter( - (msg) => - msg.type === MESSAGE_TYPES.USER || msg.type === MESSAGE_TYPES.ASSISTANT - ) - .map((msg) => ({ - role: msg.type === MESSAGE_TYPES.USER ? 'user' : 'assistant', - content: msg.content, - timestamp: msg.timestamp, - page_context: msg.pageContext || null, - })) -} - -// Parse WebSocket message -export function parseWebSocketMessage(data) { - try { - return JSON.parse(data) - } catch (error) { - console.error('Failed to parse WebSocket message:', error) - return null - } -} - -// Validate message content -export function validateMessage(content) { - if (!content || typeof content !== 'string') { - return { valid: false, error: 'Message content is required' } - } - - if (content.trim().length === 0) { - return { valid: false, error: 'Message cannot be empty' } - } - - if (content.length > 10000) { - return { - valid: false, - error: 'Message is too long (max 10,000 characters)', - } - } - - return { valid: true } -} diff --git a/sippy-ng/src/chat/sessionUtils.jsx b/sippy-ng/src/chat/sessionUtils.jsx deleted file mode 100644 index 67589fec9b..0000000000 --- a/sippy-ng/src/chat/sessionUtils.jsx +++ /dev/null @@ -1,35 +0,0 @@ -import { SESSION_TYPES } from './store/sessionSlice' - -/** - * Get a display title for a session (first message preview) - * @param {Object} session - Session object with messages array - * @returns {string} Display title for the session - */ -export function getSessionTitle(session) { - const firstUserMessage = session?.messages?.find((msg) => msg.type === 'user') - if (firstUserMessage?.content) { - const content = firstUserMessage.content.trim() - return content.length > 40 ? content.substring(0, 40) + '...' : content - } - return 'Untitled Conversation' -} - -/** - * Get icon name for session type (Material UI icon) - * @param {string} sessionType - Session type from SESSION_TYPES - * @returns {string} Material UI icon name - */ -export function getSessionIconName(sessionType) { - switch (sessionType) { - case SESSION_TYPES.OWN: - return 'Chat' - case SESSION_TYPES.SHARED: - return 'Person' - case SESSION_TYPES.SHARED_BY_ME: - return 'Share' - case SESSION_TYPES.FORKED: - return 'CallSplit' - default: - return 'Chat' - } -} diff --git a/sippy-ng/src/chat/store/drawerSlice.jsx b/sippy-ng/src/chat/store/drawerSlice.jsx deleted file mode 100644 index 45523492e6..0000000000 --- a/sippy-ng/src/chat/store/drawerSlice.jsx +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Drawer slice - manages global chat drawer state - * Handles drawer open/closed state only - */ -export const createDrawerSlice = (set, get) => ({ - // State - isDrawerOpen: false, - - // Actions - openDrawer: () => { - set({ isDrawerOpen: true }) - }, - - closeDrawer: () => { - set({ isDrawerOpen: false }) - }, - - toggleDrawer: () => { - const { isDrawerOpen } = get() - set({ isDrawerOpen: !isDrawerOpen }) - }, -}) diff --git a/sippy-ng/src/chat/store/indexedDBStorage.jsx b/sippy-ng/src/chat/store/indexedDBStorage.jsx deleted file mode 100644 index de5ca19df6..0000000000 --- a/sippy-ng/src/chat/store/indexedDBStorage.jsx +++ /dev/null @@ -1,40 +0,0 @@ -import { del, get, set } from 'idb-keyval' - -/** - * IndexedDB storage adapter for Zustand persist middleware - * This provides a drop-in replacement for localStorage with much larger quota - * and better performance for large datasets. - * - * Implementation follows the official Zustand pattern: - * https://docs.pmnd.rs/zustand/integrations/persisting-store-data#how-can-i-use-a-custom-storage-engine - */ -const indexedDBStorage = { - getItem: async (name) => { - try { - const value = await get(name) - return value || null - } catch (error) { - console.error('Error reading from IndexedDB:', error) - return null - } - }, - setItem: async (name, value) => { - try { - await set(name, value) - } catch (error) { - console.error('Error writing to IndexedDB:', error) - // If we hit quota, we could implement cleanup logic here - throw error - } - }, - removeItem: async (name) => { - try { - await del(name) - } catch (error) { - console.error('Error removing from IndexedDB:', error) - throw error - } - }, -} - -export default indexedDBStorage diff --git a/sippy-ng/src/chat/store/modelsSlice.jsx b/sippy-ng/src/chat/store/modelsSlice.jsx deleted file mode 100644 index 7c0557d44e..0000000000 --- a/sippy-ng/src/chat/store/modelsSlice.jsx +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Models slice - manages available AI models - */ -export const createModelsSlice = (set, get) => ({ - // State - models: [], - defaultModel: null, - modelsLoading: false, - modelsError: null, - - // Actions - loadModels: () => { - const apiUrl = - import.meta.env.VITE_CHAT_API_URL || window.location.origin + '/api/chat' - const baseUrl = apiUrl.replace(/\/$/, '').replace(/\/stream$/, '') - - set({ modelsLoading: true, modelsError: null }) - - fetch(`${baseUrl}/models`) - .then((response) => { - if (!response.ok) { - throw new Error(`Failed to load models: ${response.statusText}`) - } - return response.json() - }) - .then((data) => { - set({ - models: data.models || [], - defaultModel: data.default_model, - modelsLoading: false, - }) - - // If user hasn't selected a model yet, set to default - const currentSettings = get().settings - if (!currentSettings.modelId && data.default_model) { - get().updateSettings({ modelId: data.default_model }) - } - }) - .catch((error) => { - console.error('Error loading models:', error) - set({ - modelsError: error.message, - modelsLoading: false, - // Fallback to a default model if loading fails - models: [], - defaultModel: null, - }) - }) - }, -}) diff --git a/sippy-ng/src/chat/store/pageContextSlice.jsx b/sippy-ng/src/chat/store/pageContextSlice.jsx deleted file mode 100644 index b40450a7ff..0000000000 --- a/sippy-ng/src/chat/store/pageContextSlice.jsx +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Page context slice - manages the current page context for chat - * This tracks what page the user is on and what data is available - * for context-aware chat interactions - */ -export const createPageContextSlice = (set) => ({ - // State - pageContext: null, - - // Actions - setPageContextForChat: (context) => { - console.log('Setting page context for chat:', context) - set({ pageContext: context }) - }, - - unsetPageContextForChat: () => { - console.log('Clearing page context for chat') - set({ pageContext: null }) - }, -}) diff --git a/sippy-ng/src/chat/store/personaSlice.jsx b/sippy-ng/src/chat/store/personaSlice.jsx deleted file mode 100644 index 90a4526915..0000000000 --- a/sippy-ng/src/chat/store/personaSlice.jsx +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Persona slice - manages available chat personas - */ -export const createPersonaSlice = (set, _get) => ({ - personas: [], - personasLoading: false, - personasError: null, - - loadPersonas: () => { - const apiUrl = - import.meta.env.VITE_CHAT_API_URL || window.location.origin + '/api/chat' - const baseUrl = apiUrl.replace(/\/$/, '').replace(/\/stream$/, '') - - set({ personasLoading: true, personasError: null }) - - fetch(`${baseUrl}/personas`) - .then((response) => { - if (!response.ok) { - throw new Error(`Failed to fetch personas: ${response.statusText}`) - } - return response.json() - }) - .then((data) => { - set({ - personas: data.personas || [], - personasLoading: false, - }) - }) - .catch((err) => { - console.error('Error fetching personas:', err) - set({ - personasError: err.message, - personas: [], - personasLoading: false, - }) - }) - }, -}) diff --git a/sippy-ng/src/chat/store/promptsSlice.jsx b/sippy-ng/src/chat/store/promptsSlice.jsx deleted file mode 100644 index 7c4ae6c6da..0000000000 --- a/sippy-ng/src/chat/store/promptsSlice.jsx +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Zustand slice for managing slash command prompts - */ -export const createPromptsSlice = (set, _get) => ({ - // State - prompts: [], - promptsLoading: false, - promptsError: null, - - // Fetch prompts from the server - fetchPrompts: async () => { - set({ promptsLoading: true, promptsError: null }) - - try { - const response = await fetch( - (import.meta.env.VITE_CHAT_API_URL || '/api/chat') + '/prompts' - ) - - if (!response.ok) { - throw new Error('Failed to fetch prompts') - } - - const data = await response.json() - set({ - prompts: data.prompts || [], - promptsLoading: false, - }) - } catch (error) { - set({ - promptsLoading: false, - promptsError: error.message, - }) - } - }, - - // Render a prompt with arguments - renderPrompt: async (promptName, args) => { - try { - const response = await fetch( - (import.meta.env.VITE_CHAT_API_URL || '/api/chat') + '/prompts/render', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - prompt_name: promptName, - arguments: args, - }), - } - ) - - if (!response.ok) { - throw new Error('Failed to render prompt') - } - - const data = await response.json() - return data.rendered - } catch (error) { - console.error('Error rendering prompt:', error) - throw error - } - }, -}) diff --git a/sippy-ng/src/chat/store/sessionSlice.jsx b/sippy-ng/src/chat/store/sessionSlice.jsx deleted file mode 100644 index 5cc3adf6f5..0000000000 --- a/sippy-ng/src/chat/store/sessionSlice.jsx +++ /dev/null @@ -1,367 +0,0 @@ -const MAX_SESSIONS = 50 - -// Session types -export const SESSION_TYPES = { - OWN: 'own', - SHARED: 'shared', - SHARED_BY_ME: 'shared_by_me', - FORKED: 'forked', -} - -// Generate a unique session ID -function generateSessionId() { - return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` -} - -// Helper to create a new session object -function createNewSession(type = SESSION_TYPES.OWN, options = {}) { - return { - id: options.id || generateSessionId(), - type, - sharedId: options.sharedId || null, - parentId: options.parentId || null, - messages: options.messages || [], - createdAt: options.createdAt || new Date().toISOString(), - updatedAt: new Date().toISOString(), - } -} - -/** - * Session slice - manages multiple chat sessions - */ -export const createSessionSlice = (set, get) => ({ - // State - sessions: [], - activeSessionId: null, - currentThinking: null, - - // Initialize sessions from IndexedDB (called on app mount) - initializeSessions: () => { - const state = get() - if (state.sessions.length === 0) { - // Create initial session if none exist - const newSession = createNewSession() - set({ - sessions: [newSession], - activeSessionId: newSession.id, - }) - } - }, - - // Get the currently active session - getActiveSession: () => { - const { sessions, activeSessionId } = get() - return sessions.find((s) => s.id === activeSessionId) || null - }, - - // Create a new session - createSession: (type = SESSION_TYPES.OWN, options = {}) => { - const newSession = createNewSession(type, options) - set((state) => { - const updated = [newSession, ...state.sessions] - return { - sessions: - updated.length > MAX_SESSIONS - ? updated.slice(0, MAX_SESSIONS) - : updated, - activeSessionId: newSession.id, - } - }) - return newSession - }, - - // Switch to a different session - switchSession: (sessionId) => { - set({ activeSessionId: sessionId }) - }, - - // Start a new session (find empty or create new) - // If initialMessage is provided, waits for websocket connection and sends it - startNewSession: (initialMessage = null) => { - const { - sessions, - activeSessionId, - switchSession, - createSession, - setCurrentThinking, - setError, - setIsTyping, - connectionState: _connectionState, - sendMessage, - } = get() - - // Try to find an existing empty session - const emptySession = sessions.find( - (s) => - s.type === SESSION_TYPES.OWN && - (!s.messages || s.messages.length === 0) && - !s.sharedId - ) - - // Switch to empty session or create new one - if (emptySession && emptySession.id !== activeSessionId) { - switchSession(emptySession.id) - } else if (!emptySession) { - createSession() - } - - // Reset WebSocket state for the new session - setCurrentThinking(null) - setError(null) - setIsTyping(false) - - // If an initial message is provided, wait for connection and send it - if (initialMessage) { - const maxAttempts = 50 // 5 seconds max (50 * 100ms) - let attempts = 0 - - const waitForConnection = () => { - const store = get() - if (store.connectionState === 'connected') { - sendMessage(initialMessage) - } else if (attempts < maxAttempts) { - attempts++ - setTimeout(waitForConnection, 100) - } else { - console.error('Failed to connect to websocket after 5 seconds') - setError('Failed to connect to chat service') - } - } - - waitForConnection() - } - }, - - // Delete a session - deleteSession: (sessionId) => { - const { activeSessionId } = get() - const isActiveSession = sessionId === activeSessionId - - set((state) => { - const filtered = state.sessions.filter((s) => s.id !== sessionId) - - // If we deleted the active session, find or create an empty "New Chat" - if (isActiveSession) { - const emptySession = filtered.find( - (s) => - s.type === SESSION_TYPES.OWN && - (!s.messages || s.messages.length === 0) && - !s.sharedId - ) - - if (emptySession) { - return { - sessions: filtered, - activeSessionId: emptySession.id, - } - } else { - const newSession = createNewSession() - return { - sessions: [newSession, ...filtered], - activeSessionId: newSession.id, - } - } - } - - return { sessions: filtered } - }) - - return isActiveSession - }, - - // Update session metadata - updateSessionMetadata: (sessionId, updates) => { - set((state) => ({ - sessions: state.sessions.map((session) => - session.id === sessionId - ? { - ...session, - ...updates, - updatedAt: new Date().toISOString(), - } - : session - ), - })) - }, - - // Fork the current session (when user modifies a shared chat) - forkActiveSession: () => { - const currentSession = get().getActiveSession() - if (!currentSession) return null - - // Only fork if it's a shared session - if ( - currentSession.type !== SESSION_TYPES.SHARED && - currentSession.type !== SESSION_TYPES.SHARED_BY_ME - ) { - return currentSession - } - - // Create a forked session with a copy of the current messages - const forkedSession = { - ...currentSession, - id: generateSessionId(), - type: SESSION_TYPES.FORKED, - parentId: currentSession.sharedId || currentSession.id, - sharedId: null, // Clear sharedId - this is a new fork, not a shared conversation - sharedBy: undefined, // Remove sharedBy since this is our fork - messages: [...(currentSession.messages || [])], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } - - set((state) => ({ - sessions: [forkedSession, ...state.sessions], - activeSessionId: forkedSession.id, - })) - - return forkedSession - }, - - // Load a shared conversation (from URL) - loadSharedConversation: (conversationId, messages, metadata) => { - const { sessions } = get() - - // Check if this shared conversation already exists - const existingSession = sessions.find((s) => s.sharedId === conversationId) - - if (existingSession) { - set({ activeSessionId: existingSession.id }) - return existingSession - } - - // Create new shared session - const sharedSession = { - id: conversationId, - type: SESSION_TYPES.SHARED, - sharedId: conversationId, - parentId: metadata?.parentId || null, - sharedBy: metadata?.sharedBy || null, - messages, - createdAt: metadata?.createdAt || new Date().toISOString(), - updatedAt: metadata?.createdAt || new Date().toISOString(), - } - - set((state) => { - const updated = [sharedSession, ...state.sessions] - return { - sessions: - updated.length > MAX_SESSIONS - ? updated.slice(0, MAX_SESSIONS) - : updated, - activeSessionId: sharedSession.id, - } - }) - - return sharedSession - }, - - // Clear all sessions (for testing or reset) - clearAllSessions: () => { - const newSession = createNewSession() - set({ - sessions: [newSession], - activeSessionId: newSession.id, - }) - }, - - // Clear sessions older than specified days - clearOldSessions: (days) => { - const cutoffDate = new Date() - cutoffDate.setDate(cutoffDate.getDate() - days) - - const { sessions, activeSessionId } = get() - - // Helper to get the timestamp shown to users (last message or updatedAt) - const getSessionTimestamp = (session) => { - const messages = session.messages || [] - return messages.length > 0 - ? messages[messages.length - 1].timestamp - : session.updatedAt - } - - // Filter out sessions older than cutoff based on the timestamp users see - const filteredSessions = sessions.filter((session) => { - const sessionDate = new Date(getSessionTimestamp(session)) - return sessionDate >= cutoffDate - }) - - // If active session was removed, select the first remaining session or create new - let newActiveSessionId = activeSessionId - if ( - activeSessionId && - !filteredSessions.find((s) => s.id === activeSessionId) - ) { - if (filteredSessions.length > 0) { - newActiveSessionId = filteredSessions[0].id - } else { - const newSession = createNewSession() - filteredSessions.push(newSession) - newActiveSessionId = newSession.id - } - } - - // If no sessions remain, create a new one - if (filteredSessions.length === 0) { - const newSession = createNewSession() - filteredSessions.push(newSession) - newActiveSessionId = newSession.id - } - - set({ - sessions: filteredSessions, - activeSessionId: newActiveSessionId, - }) - - return sessions.length - filteredSessions.length // Return count of cleared sessions - }, - - // Message operations - operate on messages within the active session - // Add a message to the active session - addMessage: (message) => { - const { activeSessionId, sessions: _sessions } = get() - if (!activeSessionId) { - console.warn('Cannot add message: no active session') - return - } - - set((state) => ({ - sessions: state.sessions.map((session) => - session.id === activeSessionId - ? { - ...session, - messages: [...(session.messages || []), message], - updatedAt: new Date().toISOString(), - } - : session - ), - })) - }, - - // Clear messages from the active session - clearMessages: () => { - const { activeSessionId } = get() - if (!activeSessionId) { - console.warn('Cannot clear messages: no active session') - return - } - - set((state) => ({ - sessions: state.sessions.map((session) => - session.id === activeSessionId - ? { - ...session, - messages: [], - updatedAt: new Date().toISOString(), - } - : session - ), - currentThinking: null, - })) - }, - - // Set current thinking state (ephemeral UI state) - setCurrentThinking: (thinking) => { - set({ currentThinking: thinking }) - }, -}) diff --git a/sippy-ng/src/chat/store/settingsSlice.jsx b/sippy-ng/src/chat/store/settingsSlice.jsx deleted file mode 100644 index 2e45d90154..0000000000 --- a/sippy-ng/src/chat/store/settingsSlice.jsx +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Settings slice - manages user preferences - */ -export const createSettingsSlice = (set, get) => ({ - // State - settings: { - showThinking: true, - autoScroll: true, - persona: 'default', - modelId: null, // Will be set to default from backend - tourCompleted: false, - clientId: null, - }, - settingsOpen: false, - - // Actions - updateSettings: (newSettings) => { - set((state) => ({ - settings: { - ...state.settings, - ...newSettings, - }, - })) - }, - - // Initialize client ID if not already set (called on app mount) - ensureClientId: () => { - const state = get() - if (!state.settings.clientId) { - set((state) => ({ - settings: { - ...state.settings, - clientId: crypto.randomUUID(), - }, - })) - } - }, - - setSettingsOpen: (open) => { - set({ settingsOpen: open }) - }, - - // Tour actions - setTourCompleted: (completed) => { - set((state) => ({ - settings: { - ...state.settings, - tourCompleted: completed, - }, - })) - }, - - resetTour: () => { - set((state) => ({ - settings: { - ...state.settings, - tourCompleted: false, - }, - })) - }, -}) diff --git a/sippy-ng/src/chat/store/shareSlice.jsx b/sippy-ng/src/chat/store/shareSlice.jsx deleted file mode 100644 index 61cf98264f..0000000000 --- a/sippy-ng/src/chat/store/shareSlice.jsx +++ /dev/null @@ -1,288 +0,0 @@ -import { createMessage, MESSAGE_TYPES } from '../chatUtils' -import { relativeTime } from '../../helpers' -import { SESSION_TYPES } from './sessionSlice' - -/** - * Share slice - manages conversation sharing functionality - */ -export const createShareSlice = (set, get) => ({ - // State - shareLoading: false, - shareDialogOpen: false, - sharedUrl: '', - shareSnackbar: { - open: false, - message: '', - severity: 'success', - }, - loadingShared: false, - - // Load shared conversation from API - loadSharedConversationFromAPI: (conversationId) => { - const { sessions, loadSharedConversation } = get() - - // Don't fetch if we've already loaded this conversation - just switch to it - const existingSession = sessions.find((s) => s.sharedId === conversationId) - if (existingSession) { - set({ activeSessionId: existingSession.id }) - return - } - - const abortController = new AbortController() - - set({ loadingShared: true }) - - fetch( - `${ - import.meta.env.VITE_API_URL - }/api/chat/conversations/${conversationId}`, - { signal: abortController.signal } - ) - .then((response) => { - if (!response.ok) { - return response.json().then( - (errorData) => { - throw new Error( - errorData.message || 'Failed to load shared conversation' - ) - }, - () => { - throw new Error( - response.statusText || 'Failed to load shared conversation' - ) - } - ) - } - return response.json() - }) - .then((data) => { - // Load shared messages - const loadedMessages = data.messages.map((msg, idx) => ({ - ...msg, - id: msg.id || `loaded_${idx}`, - })) - - // Add a system message to mark this shared conversation - const systemMessage = createMessage( - MESSAGE_TYPES.SYSTEM, - `Shared by ${data.user} ${relativeTime( - new Date(data.created_at), - new Date() - )}`, - { - id: 'system_' + conversationId, - conversationId: conversationId, - timestamp: data.created_at, - } - ) - - const allMessages = [...loadedMessages, systemMessage] - - // Load into session management (this will set messages in the session) - loadSharedConversation(conversationId, allMessages, { - createdAt: data.created_at, - parentId: data.parent_id, - sharedBy: data.user, - }) - - // Set the shared URL so clicking share again doesn't create a duplicate - const url = `${window.location.origin}/sippy-ng/chat/${conversationId}` - set({ - sharedUrl: url, - loadingShared: false, - }) - }) - .catch((err) => { - // Don't show error for aborted requests - if (err.name === 'AbortError') { - return - } - console.error('Error loading shared conversation:', err) - set({ - shareSnackbar: { - open: true, - message: err.message, - severity: 'error', - }, - loadingShared: false, - }) - }) - }, - - // Share the current conversation - shareConversation: (pageContext, mode = 'fullPage') => { - const { - settings, - getActiveSession, - activeSessionId, - updateSessionMetadata, - } = get() - - const activeSession = getActiveSession() - const messages = activeSession?.messages || [] - - // Filter out system messages for sharing - const filteredMessages = messages.filter( - (msg) => msg.type !== MESSAGE_TYPES.SYSTEM - ) - - if (filteredMessages.length === 0) { - set({ - shareSnackbar: { - open: true, - message: 'No messages to share', - severity: 'warning', - }, - }) - return - } - - // If session already has a sharedId, it's already been shared - just show the dialog - if (activeSession?.sharedId) { - const url = `${window.location.origin}/sippy-ng/chat/${activeSession.sharedId}` - set({ - sharedUrl: url, - shareDialogOpen: true, - }) - return - } - - set({ shareLoading: true }) - - // Format messages for API - const messagesToShare = filteredMessages.map((msg) => ({ - type: msg.type, - content: msg.content, - timestamp: msg.timestamp, - ...(msg.data && { data: msg.data }), - ...(msg.pageContext && { pageContext: msg.pageContext }), - ...(msg.conversationId && { conversationId: msg.conversationId }), - ...(msg.visualizations && { visualizations: msg.visualizations }), - })) - - // Prepare metadata - const metadata = { - persona: settings.persona, - pageContext: pageContext, - sharedAt: new Date().toISOString(), - } - - // If we're sharing a forked conversation, include the parent ID - const payload = { - messages: messagesToShare, - metadata: metadata, - } - - if (activeSession?.sharedId || activeSession?.parentId) { - payload.parent_id = activeSession.sharedId || activeSession.parentId - } - - fetch(import.meta.env.VITE_API_URL + '/api/chat/conversations', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }) - .then((response) => { - if (!response.ok) { - return response.json().then( - (errorData) => { - throw new Error( - errorData.message || 'Failed to share conversation' - ) - }, - () => { - throw new Error( - response.statusText || 'Failed to share conversation' - ) - } - ) - } - return response.json() - }) - .then((data) => { - // Construct the shareable URL - const url = `${window.location.origin}/sippy-ng/chat/${data.id}` - set({ - sharedUrl: url, - shareDialogOpen: true, - }) - - // Update session metadata - if (activeSessionId) { - updateSessionMetadata(activeSessionId, { - sharedId: data.id, - type: SESSION_TYPES.SHARED_BY_ME, - }) - } - - // Update browser URL (only in fullPage mode) - if (mode === 'fullPage') { - window.history.pushState(null, '', `/sippy-ng/chat/${data.id}`) - } - - // Copy to clipboard - navigator.clipboard.writeText(url).catch((err) => { - console.warn('Failed to copy to clipboard:', err) - }) - }) - .catch((err) => { - console.error('Error sharing conversation:', err) - set({ - shareSnackbar: { - open: true, - message: err.message || 'Failed to share conversation', - severity: 'error', - }, - }) - }) - .finally(() => { - set({ shareLoading: false }) - }) - }, - - // Clear shared URL - clearSharedUrl: () => { - set({ sharedUrl: '' }) - }, - - // UI actions - setShareDialogOpen: (open) => { - set({ shareDialogOpen: open }) - }, - - setShareSnackbar: (snackbar) => { - set({ shareSnackbar: snackbar }) - }, - - closeShareSnackbar: () => { - set((state) => ({ - shareSnackbar: { ...state.shareSnackbar, open: false }, - })) - }, - - copyToClipboard: () => { - const { sharedUrl } = get() - navigator.clipboard - .writeText(sharedUrl) - .then(() => { - set({ - shareSnackbar: { - open: true, - message: 'Link copied to clipboard!', - severity: 'success', - }, - }) - }) - .catch((_err) => { - set({ - shareSnackbar: { - open: true, - message: 'Failed to copy to clipboard', - severity: 'error', - }, - }) - }) - }, -}) diff --git a/sippy-ng/src/chat/store/storageUtils.jsx b/sippy-ng/src/chat/store/storageUtils.jsx deleted file mode 100644 index 888cdfb83c..0000000000 --- a/sippy-ng/src/chat/store/storageUtils.jsx +++ /dev/null @@ -1,64 +0,0 @@ -import { get } from 'idb-keyval' - -const STORAGE_KEY = 'sippy-chat-storage' - -/** - * Get storage statistics for chat data - * @param {Array} sessions - Sessions array from the store - * @param {string} activeSessionId - Active session ID - * @param {Object} settings - Settings object - * @returns {Promise<{conversationCount: number, sizeBytes: number}>} - */ -export async function getChatStorageStats( - sessions = [], - activeSessionId = null, - settings = {} -) { - try { - const conversationCount = sessions.length - - // Get the full data from IndexedDB to calculate actual storage size - const data = await get(STORAGE_KEY) - let sizeBytes = 0 - - if (data) { - // Calculate size in bytes by serializing the actual stored data - const serialized = JSON.stringify(data) - sizeBytes = new Blob([serialized]).size - } else { - // Fallback: estimate size from current state if not in storage yet - const estimatedData = { - state: { - sessions, - activeSessionId, - settings, - }, - } - const serialized = JSON.stringify(estimatedData) - sizeBytes = new Blob([serialized]).size - } - - return { conversationCount, sizeBytes } - } catch (error) { - console.error('Error getting chat storage stats:', error) - return { conversationCount: sessions.length, sizeBytes: 0 } - } -} - -/** - * Format bytes to human-readable format - * @param {number} bytes - * @param {number} decimals - * @returns {string} - */ -export function formatBytes(bytes, decimals = 1) { - if (bytes === 0) return '0 Bytes' - - const k = 1024 - const dm = decimals < 0 ? 0 : decimals - const sizes = ['Bytes', 'KB', 'MB', 'GB'] - - const i = Math.floor(Math.log(bytes) / Math.log(k)) - - return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i] -} diff --git a/sippy-ng/src/chat/store/useChatStore.jsx b/sippy-ng/src/chat/store/useChatStore.jsx deleted file mode 100644 index 3eaaaf09e7..0000000000 --- a/sippy-ng/src/chat/store/useChatStore.jsx +++ /dev/null @@ -1,183 +0,0 @@ -import { create } from 'zustand' -import { createDrawerSlice } from './drawerSlice' -import { createJSONStorage, persist } from 'zustand/middleware' -import { createModelsSlice } from './modelsSlice' -import { createPageContextSlice } from './pageContextSlice' -import { createPersonaSlice } from './personaSlice' -import { createPromptsSlice } from './promptsSlice' -import { createSessionSlice } from './sessionSlice' -import { createSettingsSlice } from './settingsSlice' -import { createShareSlice } from './shareSlice' -import { createWebSocketSlice } from './webSocketSlice' -import { useShallow } from 'zustand/react/shallow' -import indexedDBStorage from './indexedDBStorage' - -/** - * Zustand store for the chat interface, state is grouped in slices. Persistent - * state is stored in IndexedDB; slices persisted are selected by partialization. - * - * Always useShallow() when creating more slices, to avoid re-rendering the entire - * store when one of the values changes. - */ -export const useChatStore = create( - persist( - (set, get) => ({ - ...createSessionSlice(set, get), - ...createShareSlice(set, get), - ...createWebSocketSlice(set, get), - ...createSettingsSlice(set, get), - ...createPersonaSlice(set, get), - ...createModelsSlice(set, get), - ...createPageContextSlice(set, get), - ...createDrawerSlice(set, get), - ...createPromptsSlice(set, get), - }), - { - name: 'sippy-chat-storage', - storage: createJSONStorage(() => indexedDBStorage), - partialize: (state) => ({ - sessions: state.sessions, - activeSessionId: state.activeSessionId, - settings: state.settings, - }), - } - ) -) - -/** - * Grouped selectors for Zustand state - * These provide cleaner access to related state and actions, make sure to always useShallow() - * when creating more of these, to avoid re-rendering the entire store when one of the values changes - */ - -export const useSessionState = () => - useChatStore( - useShallow((state) => ({ - sessions: state.sessions, - activeSessionId: state.activeSessionId, - activeSession: state.getActiveSession(), - })) - ) - -export const useSessionActions = () => - useChatStore( - useShallow((state) => ({ - initializeSessions: state.initializeSessions, - createSession: state.createSession, - switchSession: state.switchSession, - startNewSession: state.startNewSession, - deleteSession: state.deleteSession, - updateSessionMetadata: state.updateSessionMetadata, - forkActiveSession: state.forkActiveSession, - clearAllSessions: state.clearAllSessions, - clearOldSessions: state.clearOldSessions, - })) - ) - -export const useShareState = () => - useChatStore( - useShallow((state) => ({ - shareLoading: state.shareLoading, - shareDialogOpen: state.shareDialogOpen, - sharedUrl: state.sharedUrl, - shareSnackbar: state.shareSnackbar, - loadingShared: state.loadingShared, - })) - ) - -export const useShareActions = () => - useChatStore( - useShallow((state) => ({ - shareConversation: state.shareConversation, - clearSharedUrl: state.clearSharedUrl, - loadSharedConversationFromAPI: state.loadSharedConversationFromAPI, - setShareDialogOpen: state.setShareDialogOpen, - setShareSnackbar: state.setShareSnackbar, - closeShareSnackbar: state.closeShareSnackbar, - copyToClipboard: state.copyToClipboard, - })) - ) - -export const useConnectionState = () => - useChatStore( - useShallow((state) => ({ - connectionState: state.connectionState, - isTyping: state.isTyping, - error: state.error, - currentThinking: state.currentThinking, - })) - ) - -export const useSettings = () => - useChatStore( - useShallow((state) => ({ - settings: state.settings, - settingsOpen: state.settingsOpen, - updateSettings: state.updateSettings, - setSettingsOpen: state.setSettingsOpen, - setTourCompleted: state.setTourCompleted, - resetTour: state.resetTour, - ensureClientId: state.ensureClientId, - })) - ) - -export const usePersonas = () => - useChatStore( - useShallow((state) => ({ - personas: state.personas, - personasLoading: state.personasLoading, - personasError: state.personasError, - loadPersonas: state.loadPersonas, - })) - ) - -export const useModels = () => - useChatStore( - useShallow((state) => ({ - models: state.models, - defaultModel: state.defaultModel, - modelsLoading: state.modelsLoading, - modelsError: state.modelsError, - loadModels: state.loadModels, - })) - ) - -export const usePageContextForChat = () => - useChatStore( - useShallow((state) => ({ - pageContext: state.pageContext, - setPageContextForChat: state.setPageContextForChat, - unsetPageContextForChat: state.unsetPageContextForChat, - })) - ) - -export const useDrawer = () => - useChatStore( - useShallow((state) => ({ - isDrawerOpen: state.isDrawerOpen, - openDrawer: state.openDrawer, - closeDrawer: state.closeDrawer, - toggleDrawer: state.toggleDrawer, - })) - ) - -export const useWebSocketActions = () => - useChatStore( - useShallow((state) => ({ - connectWebSocket: state.connectWebSocket, - disconnectWebSocket: state.disconnectWebSocket, - sendMessage: state.sendMessage, - stopGeneration: state.stopGeneration, - })) - ) - -export const usePrompts = () => - useChatStore( - useShallow((state) => ({ - prompts: state.prompts, - promptsLoading: state.promptsLoading, - promptsError: state.promptsError, - fetchPrompts: state.fetchPrompts, - renderPrompt: state.renderPrompt, - })) - ) diff --git a/sippy-ng/src/chat/store/webSocketSlice.jsx b/sippy-ng/src/chat/store/webSocketSlice.jsx deleted file mode 100644 index d2d55ff6ac..0000000000 --- a/sippy-ng/src/chat/store/webSocketSlice.jsx +++ /dev/null @@ -1,257 +0,0 @@ -import { - createMessage, - formatChatHistoryForAPI, - getChatWebSocketUrl, - MESSAGE_TYPES, - parseWebSocketMessage, -} from '../chatUtils' - -/** - * Connection state constants - */ -export const CONNECTION_STATES = { - CONNECTING: 'connecting', - CONNECTED: 'connected', - DISCONNECTED: 'disconnected', -} - -/** - * WebSocket slice - manages ALL WebSocket connection, messaging, and state - * Consolidates connection management, message sending/receiving, and typing indicators - */ -export const createWebSocketSlice = (set, get) => { - // Private state not exposed to components - let wsInstance = null - let reconnectTimeout = null - let reconnectAttempts = 0 - const maxReconnectAttempts = 5 - - // Message handlers - const handleThinkingStep = (data) => { - const { addMessage, setCurrentThinking, setIsTyping } = get() - - if (data.complete) { - addMessage( - createMessage(MESSAGE_TYPES.THINKING_STEP, '', { - data: { ...data }, - }) - ) - setCurrentThinking(null) - } else { - setIsTyping(true) - setCurrentThinking({ ...data }) - } - } - - const handleFinalResponse = (data) => { - const { addMessage, setIsTyping, setCurrentThinking, pageContext } = get() - - setIsTyping(false) - setCurrentThinking(null) - addMessage( - createMessage(MESSAGE_TYPES.ASSISTANT, data.response, { - tools_used: data.tools_used, - visualizations: data.visualizations || [], - model_id: data.model_id, - timestamp: data.timestamp, - pageContext: pageContext, - }) - ) - } - - const handleError = (data) => { - const { addMessage, setIsTyping, setCurrentThinking } = get() - - setIsTyping(false) - setCurrentThinking(null) - addMessage( - createMessage(MESSAGE_TYPES.ERROR, data.error, { - timestamp: data.timestamp, - }) - ) - // Note: Don't set global error state here - that's for connection errors only - // The error message is already added to the chat above - } - - const handleWebSocketMessage = (message) => { - switch (message.type) { - case MESSAGE_TYPES.THINKING_STEP: - handleThinkingStep(message.data) - break - case MESSAGE_TYPES.FINAL_RESPONSE: - handleFinalResponse(message.data) - break - case MESSAGE_TYPES.ERROR: - handleError(message.data) - break - default: - console.warn('Unknown message type:', message.type) - } - } - - return { - // State - connectionState: CONNECTION_STATES.DISCONNECTED, - isTyping: false, - error: null, - - // Simple state setters - setConnectionState: (state) => { - set({ connectionState: state }) - }, - - setIsTyping: (isTyping) => { - set({ isTyping }) - }, - - setError: (error) => { - set({ error }) - }, - - // WebSocket connection management - connectWebSocket: () => { - if (wsInstance?.readyState === WebSocket.OPEN) { - return - } - - const { setConnectionState, setError, setIsTyping, setCurrentThinking } = - get() - - try { - const wsUrl = getChatWebSocketUrl() - console.log('Connecting to WebSocket:', wsUrl) - - wsInstance = new WebSocket(wsUrl) - setConnectionState(CONNECTION_STATES.CONNECTING) - setError(null) - - wsInstance.onopen = () => { - console.log('WebSocket connected') - setConnectionState(CONNECTION_STATES.CONNECTED) - reconnectAttempts = 0 - setError(null) - - // Fetch prompts whenever we connect/reconnect - const { fetchPrompts } = get() - if (fetchPrompts) { - fetchPrompts() - } - } - - wsInstance.onmessage = (event) => { - const message = parseWebSocketMessage(event.data) - if (!message) return - handleWebSocketMessage(message) - } - - wsInstance.onclose = (event) => { - console.log('WebSocket closed:', event.code, event.reason) - setConnectionState(CONNECTION_STATES.DISCONNECTED) - setIsTyping(false) - setCurrentThinking(null) - - // Auto-reconnect if not a clean close - if (event.code !== 1000 && reconnectAttempts < maxReconnectAttempts) { - const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000) - console.log( - `Reconnecting in ${delay}ms (attempt ${reconnectAttempts + 1})` - ) - reconnectTimeout = setTimeout(() => { - reconnectAttempts++ - get().connectWebSocket() - }, delay) - } - } - - wsInstance.onerror = (error) => { - console.error('WebSocket error:', error) - setError('Connection error occurred') - } - } catch (err) { - console.error('Failed to create WebSocket connection:', err) - setError('Failed to connect to chat service') - setConnectionState(CONNECTION_STATES.DISCONNECTED) - } - }, - - disconnectWebSocket: () => { - if (reconnectTimeout) { - clearTimeout(reconnectTimeout) - reconnectTimeout = null - } - - if (wsInstance) { - get().setConnectionState(CONNECTION_STATES.DISCONNECTED) - wsInstance.close(1000, 'User disconnected') - wsInstance = null - } - }, - - // Stop current generation by disconnecting and reconnecting - stopGeneration: () => { - const { - disconnectWebSocket, - connectWebSocket, - setIsTyping, - setCurrentThinking, - } = get() - - setIsTyping(false) - setCurrentThinking(null) - disconnectWebSocket() - setTimeout(() => { - connectWebSocket() - }, 100) - }, - - // Send message through WebSocket - sendMessage: (content) => { - const { - connectionState, - getActiveSession, - addMessage, - setError, - setIsTyping, - pageContext, - settings, - } = get() - - if (connectionState !== CONNECTION_STATES.CONNECTED) { - setError('Not connected to chat service') - return false - } - - try { - // Get messages from active session for chat history BEFORE adding the current message - const activeSession = getActiveSession() - const messages = activeSession?.messages || [] - - const payload = { - message: content, - chat_history: formatChatHistoryForAPI(messages), - show_thinking: true, - persona: settings.persona || 'default', - model_id: settings.modelId || null, - page_context: pageContext, - } - - wsInstance.send(JSON.stringify(payload)) - - // Add the message to the session AFTER sending - addMessage( - createMessage(MESSAGE_TYPES.USER, content, { - pageContext: pageContext, - }) - ) - - setError(null) - setIsTyping(true) - return true - } catch (err) { - console.error('Failed to send message:', err) - setError('Failed to send message') - return false - } - }, - } -} diff --git a/sippy-ng/src/chat/useScrollManagement.jsx b/sippy-ng/src/chat/useScrollManagement.jsx deleted file mode 100644 index 7dd1fc3bdc..0000000000 --- a/sippy-ng/src/chat/useScrollManagement.jsx +++ /dev/null @@ -1,148 +0,0 @@ -import { MESSAGE_TYPES } from './chatUtils' -import { SESSION_TYPES } from './store/sessionSlice' -import { useEffect, useRef } from 'react' -import { useSessionActions } from './store/useChatStore' - -/** - * Custom hook for managing chat scroll behavior - * - * Scroll behavior rules: - * 1. User sends message → Always scroll to bottom (they're engaged) - * 2. Assistant/system messages → Only scroll if user is "following" (at bottom or just sent message) - * 3. User manually scrolls up → Stop auto-scrolling (they're reading history) - * 4. Session switch → Restore saved scroll position if available - * - Shared conversations (first load): Start at top - * - Your conversations (first load): Start at bottom - * 5. Scroll position is saved per session and persisted - */ -export function useScrollManagement( - activeSessionId, - activeSession, - messages, - settings -) { - const messagesEndRef = useRef(null) - const messagesListRef = useRef(null) - const lastMessageRef = useRef(null) - const prevSessionIdRef = useRef(activeSessionId) - const isFollowingRef = useRef(true) - - const { updateSessionMetadata } = useSessionActions() - - /** - * Check if we're at the very bottom of the scroll container - */ - const isAtBottom = () => { - if (!messagesListRef.current) return true - - const { scrollTop, scrollHeight, clientHeight } = messagesListRef.current - - // No scrollbar? We're at "bottom" - if (scrollHeight <= clientHeight) return true - - // Within 1px of bottom (accounts for sub-pixel rounding) - return scrollHeight - scrollTop - clientHeight <= 1 - } - - // Effect: Listen for manual scrolling and save position (debounced) - useEffect(() => { - const container = messagesListRef.current - if (!container || !activeSessionId) return - - let saveTimeout = null - - const handleScroll = () => { - // Update following status immediately - isFollowingRef.current = isAtBottom() - - // Debounce scroll position saving (avoid excessive state updates) - if (saveTimeout) clearTimeout(saveTimeout) - saveTimeout = setTimeout(() => { - const scrollPosition = container.scrollTop - updateSessionMetadata(activeSessionId, { scrollPosition }) - }, 200) - } - - container.addEventListener('scroll', handleScroll, { passive: true }) - return () => { - container.removeEventListener('scroll', handleScroll) - if (saveTimeout) clearTimeout(saveTimeout) - } - }, [activeSessionId, updateSessionMetadata]) - - // Effect: Handle session switching and restore scroll position - useEffect(() => { - const didSwitchSession = - prevSessionIdRef.current !== null && - prevSessionIdRef.current !== activeSessionId - - prevSessionIdRef.current = activeSessionId - - if (didSwitchSession && activeSession && messages.length > 0) { - setTimeout(() => { - const savedScrollPosition = activeSession.scrollPosition - - if (savedScrollPosition !== undefined && savedScrollPosition !== null) { - // Restore saved scroll position - messagesListRef.current?.scrollTo({ - top: savedScrollPosition, - behavior: 'instant', - }) - // Restore following status based on saved position - isFollowingRef.current = isAtBottom() - } else if (activeSession.type === SESSION_TYPES.SHARED) { - // Shared conversation, first load: start at top - messagesListRef.current?.scrollTo({ top: 0, behavior: 'instant' }) - isFollowingRef.current = false - } else { - // Your conversations, first load: go to bottom - messagesEndRef.current?.scrollIntoView({ behavior: 'instant' }) - isFollowingRef.current = true - } - }, 100) - } - }, [activeSessionId, activeSession, messages.length]) - - // Effect: Handle new messages - useEffect(() => { - if (!settings.autoScroll || messages.length === 0) return - if (activeSession?.type === SESSION_TYPES.SHARED) return - - const lastMessage = messages[messages.length - 1] - - // User sent a message? Always scroll and mark as following - if (lastMessage?.type === MESSAGE_TYPES.USER) { - isFollowingRef.current = true - setTimeout(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, 50) - return - } - - // Assistant/system message? Only scroll if following - if (!isFollowingRef.current) return - - const isAssistantMessage = - lastMessage?.type === MESSAGE_TYPES.ASSISTANT || - lastMessage?.type === MESSAGE_TYPES.FINAL_RESPONSE - - setTimeout(() => { - if (isAssistantMessage && lastMessageRef.current) { - // Scroll to show the assistant's message - lastMessageRef.current.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }) - } else { - // Other messages: scroll to bottom - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - } - }, 50) - }, [messages.length, settings.autoScroll, activeSession?.type]) - - return { - messagesEndRef, - messagesListRef, - lastMessageRef, - } -} diff --git a/sippy-ng/src/chat/useSessionRating.jsx b/sippy-ng/src/chat/useSessionRating.jsx deleted file mode 100644 index c63f77673f..0000000000 --- a/sippy-ng/src/chat/useSessionRating.jsx +++ /dev/null @@ -1,117 +0,0 @@ -import { MESSAGE_TYPES } from './chatUtils' -import { useCallback } from 'react' -import { useSessionActions, useSettings } from './store/useChatStore' - -/** - * Custom hook for handling session rating submission - * Calculates metrics and submits anonymous ratings to the API - */ -export function useSessionRating() { - const { updateSessionMetadata } = useSessionActions() - const { settings } = useSettings() - - /** - * Calculate metrics from messages for rating submission - */ - const calculateMetrics = useCallback((messages) => { - const userMessages = messages.filter( - (msg) => msg.type === MESSAGE_TYPES.USER - ) - const assistantMessages = messages.filter( - (msg) => msg.type === MESSAGE_TYPES.ASSISTANT - ) - const thinkingSteps = messages.filter( - (msg) => msg.type === MESSAGE_TYPES.THINKING_STEP - ) - - const llmThoughts = thinkingSteps.filter( - (msg) => msg.data?.action === 'thinking' - ).length - - const toolCalls = thinkingSteps.filter( - (msg) => msg.data?.action && msg.data.action !== 'thinking' - ).length - - const totalMessages = userMessages.length + assistantMessages.length - const interactionSize = new Blob([JSON.stringify(messages)]).size - - return { - totalMessages, - userMessages: userMessages.length, - assistantMessages: assistantMessages.length, - toolCalls, - llmThoughts, - totalSizeBytes: interactionSize, - } - }, []) - - /** - * Submit a rating for a session - * @param {string} sessionId - The session ID - * @param {string} sessionType - The type of session (SESSION_TYPES) - * @param {Array} messages - The messages in the session - * @param {number} rating - The rating value (1-5) - */ - const submitRating = useCallback( - async (sessionId, sessionType, messages, rating) => { - if (!sessionId) { - console.error('No session ID provided for rating') - return { success: false, error: 'No session ID' } - } - - // Calculate metrics - const metrics = calculateMetrics(messages) - - const payload = { - rating, - clientId: settings.clientId, - metadata: { - ...metrics, - sessionType, - timestamp: new Date().toISOString(), - }, - } - - try { - // Submit to API - const response = await fetch( - import.meta.env.VITE_API_URL + '/api/chat/ratings', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - } - ) - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - - const data = await response.json() - - // Update session metadata after successful submission - // Delay to allow thank you message and fade out animation (2s + 0.5s) - setTimeout(() => { - updateSessionMetadata(sessionId, { - rated: true, - ratedAt: new Date().toISOString(), - ratingValue: rating, - }) - }, 2500) - - return { success: true, data } - } catch (error) { - console.error('Failed to submit rating:', error) - return { success: false, error: error.message } - } - }, - [calculateMetrics, updateSessionMetadata, settings.clientId] - ) - - return { - submitRating, - calculateMetrics, - } -} diff --git a/sippy-ng/src/component_readiness/ComponentReadinessToolBar.jsx b/sippy-ng/src/component_readiness/ComponentReadinessToolBar.jsx index 7220642be9..492665363c 100644 --- a/sippy-ng/src/component_readiness/ComponentReadinessToolBar.jsx +++ b/sippy-ng/src/component_readiness/ComponentReadinessToolBar.jsx @@ -26,8 +26,6 @@ import { } from '@mui/icons-material' import { CompReadyVarsContext } from './CompReadyVars' import { - formColumnName, - generateTestDetailsReportLink, getTriagesAPIUrl, mergeRegressionData, Search, @@ -36,10 +34,9 @@ import { } from './CompReadyUtils' import { Link } from 'react-router-dom' import { SippyCapabilitiesContext } from '../App' -import { usePageContextForChat } from '../chat/store/useChatStore' import IconButton from '@mui/material/IconButton' import PropTypes from 'prop-types' -import React, { Fragment, useContext, useEffect } from 'react' +import React, { Fragment, useContext } from 'react' import RegressedTestsModal from './RegressedTestsModal' import SwitchControl from '@mui/material/Switch' import Toolbar from '@mui/material/Toolbar' @@ -65,9 +62,6 @@ export default function ComponentReadinessToolBar(props) { const capabilitiesContext = React.useContext(SippyCapabilitiesContext) const localDBEnabled = capabilitiesContext.includes('local_db') const varsContext = useContext(CompReadyVarsContext) - const { setPageContextForChat, unsetPageContextForChat } = - usePageContextForChat() - React.useEffect(() => { // triage entries will only be available when there is a postgres connection let triageFetch @@ -108,81 +102,6 @@ export default function ComponentReadinessToolBar(props) { }) }, [localDBEnabled, varsContext.view, data]) - // Update page context when regression data is loaded - useEffect(() => { - if (!isLoaded || !varsContext.sampleRelease) { - return - } - - // Helper to format test data for context - const formatTestForContext = (test) => ({ - component: test.component, - capability: test.capability, - test_name: test.test_name, - test_id: test.test_id, - test_suite: test.test_suite, - variants: formColumnName({ variants: test.variants }), - status: test.status, - regression_id: test.regression?.id, - regressed_since: test.regression?.opened, - last_failure: test.last_failure, - details_link: generateTestDetailsReportLink(test), - }) - - setPageContextForChat({ - page: 'component-readiness', - url: window.location.href, - - instructions: `This is the Component Readiness main view showing a matrix of components vs environments with regression status. - Focus on unresolved and untriaged regressions - these are the most important items requiring attention. - When providing test detail links, use the details_link field provided for each test. - Status values: negative numbers indicate regressions (more negative = more severe), positive numbers indicate stability. - Unresolved regressions have status <= -200 (not yet hopefully fixed).`, - - suggestions: [ - 'What regressions are new today?', - "What regressions haven't been triaged?", - ], - - data: { - sample_release: varsContext.sampleRelease, - base_release: varsContext.baseRelease, - view: varsContext.view, - - summary: { - total_unresolved: unresolvedTests.length, - total_untriaged: regressedTests.length, - total_all_regressions: allRegressedTests.length, - }, - - // Unresolved regressions (status <= -200) - most critical - unresolved_regressions: unresolvedTests - .slice(0, 30) - .map(formatTestForContext), - - // Untriaged regressions - need attention - untriaged_regressions: regressedTests - .slice(0, 30) - .map(formatTestForContext), - }, - }) - - // Clear context on unmount - return () => { - unsetPageContextForChat() - } - }, [ - isLoaded, - regressedTests, - allRegressedTests, - unresolvedTests, - varsContext.sampleRelease, - varsContext.baseRelease, - varsContext.view, - setPageContextForChat, - unsetPageContextForChat, - ]) - const linkToReport = () => { const currentUrl = new URL(window.location.href) if (searchRowRegex && searchRowRegex !== '') { diff --git a/sippy-ng/src/component_readiness/TestDetailsReport.jsx b/sippy-ng/src/component_readiness/TestDetailsReport.jsx index ae2fa30955..8552983c73 100644 --- a/sippy-ng/src/component_readiness/TestDetailsReport.jsx +++ b/sippy-ng/src/component_readiness/TestDetailsReport.jsx @@ -28,8 +28,6 @@ import { FileCopy, Help } from '@mui/icons-material' import { Link } from 'react-router-dom' import { pathForExactTestAnalysisWithFilter } from '../helpers' import { ReleasesContext, SippyCapabilitiesContext } from '../App' -import { usePageContextForChat } from '../chat/store/useChatStore' -import AskSippyButton from '../chat/AskSippyButton' import BugButton from '../bugs/BugButton' import BugTable from '../bugs/BugTable' import CompReadyCancelled from './CompReadyCancelled' @@ -96,9 +94,6 @@ function TestsReportTabPanel(props) { // This is page 5 which runs when you click a test cell on the right of page 4 or page 4a export default function TestDetailsReport(props) { const { accessibilityModeOn } = useContext(AccessibilityModeContext) - const { setPageContextForChat, unsetPageContextForChat } = - usePageContextForChat() - const [activeTabIndex, setActiveTabIndex] = React.useState(0) const handleTabChange = (event, newValue) => { @@ -115,7 +110,6 @@ export default function TestDetailsReport(props) { const [triageEntries, setTriageEntries] = React.useState([]) const [symptomSummaries, setSymptomSummaries] = React.useState([]) const releases = useContext(ReleasesContext) - const hasSetContextRef = React.useRef(false) // Set the browser tab title document.title = @@ -279,58 +273,6 @@ export default function TestDetailsReport(props) { const datesEnv = useContext(CompReadyVarsContext) useEffect(() => setLoadedParams(datesEnv), []) - // Update page context for chat - useEffect(() => { - if ( - !isLoaded || - !data.analyses || - !data.analyses[0] || - hasSetContextRef.current - ) - return - - hasSetContextRef.current = true - - setPageContextForChat({ - page: 'component-readiness-test-details', - url: window.location.href, - suggestions: [ - { - prompt: 'component-readiness-regression-analysis', - label: 'Analyze Test Regression', - args: { - url: window.location.href, - }, - }, - 'Why is this test regressed?', - 'Show me sample outputs from test failures', - 'What other tests are failing together?', - ], - data: { - test_id: testId, - test_name: data.test_name, - component: component, - capability: capability, - environment: environment, - }, - }) - - // Cleanup: Clear context when component unmounts - return () => { - unsetPageContextForChat() - } - }, [ - isLoaded, - data, - testId, - component, - capability, - environment, - triageEntries.length, - setPageContextForChat, - unsetPageContextForChat, - ]) - if (fetchError !== '') { return gotFetchError(fetchError) } @@ -479,14 +421,8 @@ View the [test details report|${document.location.href}] for additional context. display="flex" justifyContent="right" alignItems="center" - gap={1} width="100%" > - { setIsLoaded(false) setIsUpdated(false) @@ -76,77 +68,6 @@ export default function Triage({ id }) { }) }, [isUpdated, localDBEnabled, id]) - // Update page context for chat - React.useEffect(() => { - if (!isLoaded || !triage.id) return - - const regressedTestsForContext = ( - triage.regressed_tests - ? Object.values(triage.regressed_tests).filter(Boolean).flat() - : [] - ).map((rt) => { - return { - test_name: rt.test_name, - component: rt.component, - capability: rt.capability, - environment: rt.environment, - test_id: rt.test_id, - status: rt.status, - explanations: rt.explanations || [], - test_details_api_url: getTestDetailsLink(rt.links, view) ?? null, - regression_id: rt.regression?.id, - regression_opened: rt.regression?.opened, - regression_closed: rt.regression?.closed?.valid - ? rt.regression.closed.time - : null, - } - }) - - const contextData = { - page: 'triage-details', - url: window.location.href, - suggestions: [ - { - prompt: 'triage-failure-analysis', - label: 'Analyze failure patterns across regressed tests', - args: { - triage_id: triage.id, - view: view, - }, - }, - { - prompt: 'triage-fix-status', - label: 'Check Jira fix status and timeline', - args: { - issue_key: extractJiraIssueKey(triage.url), - }, - }, - { - prompt: 'triage-potential-matches', - label: 'Find potential test matches to add', - args: { - triage_id: triage.id, - view: view, - }, - }, - ], - data: { - triage_id: triage.id, - view: view, - jira_issue_key: extractJiraIssueKey(triage.url), - regressed_tests: regressedTestsForContext, - has_failed_fix: hasFailedFixRegression(triage, triage.regressed_tests), - }, - } - - setPageContextForChat(contextData) - - // Cleanup: Clear context when component unmounts - return () => { - unsetPageContextForChat() - } - }, [isLoaded, triage, setPageContextForChat, unsetPageContextForChat]) - const deleteTriage = () => { const confirmed = window.confirm( 'Are you sure you want to delete this triage record?' @@ -196,11 +117,6 @@ export default function Triage({ id }) { >

Triage Details

- {localDBEnabled && } {triageEnabled && ( diff --git a/sippy-ng/src/components/AIDisclaimerDialog.jsx b/sippy-ng/src/components/AIDisclaimerDialog.jsx deleted file mode 100644 index c70e3d665e..0000000000 --- a/sippy-ng/src/components/AIDisclaimerDialog.jsx +++ /dev/null @@ -1,86 +0,0 @@ -import { - Button, - Checkbox, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - FormControlLabel, -} from '@mui/material' -import { SippyCapabilitiesContext } from '../App' -import { useCookies } from 'react-cookie' -import React from 'react' - -export default function AIDisclaimerDialog() { - const capabilities = React.useContext(SippyCapabilitiesContext) - const [cookies, setCookie] = useCookies(['aiDisclaimerAccepted']) - const [open, setOpen] = React.useState(false) - const [dontRemindAI, setDontRemindAI] = React.useState(true) - - React.useEffect(() => { - if (capabilities.includes('chat') && !cookies['aiDisclaimerAccepted']) { - setOpen(true) - } - }, [capabilities, cookies]) - - const handleAccept = () => { - if (dontRemindAI) { - const expiryDate = new Date() - expiryDate.setDate(expiryDate.getDate() + 30) - setCookie('aiDisclaimerAccepted', 'true', { - path: '/', - sameSite: 'Strict', - expires: expiryDate, - }) - } - setOpen(false) - } - - return ( - - - - You are about to use a Red Hat tool that utilizes AI technology to - provide you with relevant information. By proceeding to use the tool, - you acknowledge that the tool and any output provided are only - intended for internal use and that information should only be shared - with those with a legitimate business purpose. Do not include any - personal information or customer-specific information in your input. - Responses provided by tools utilizing AI technology should be reviewed - and verified prior to use. - - - - setDontRemindAI(e.target.checked)} - color="primary" - /> - } - label="Don't remind me for 30 days" - /> - - - - ) -} diff --git a/sippy-ng/src/components/ChatTransition.jsx b/sippy-ng/src/components/ChatTransition.jsx new file mode 100644 index 0000000000..dd8184341a --- /dev/null +++ b/sippy-ng/src/components/ChatTransition.jsx @@ -0,0 +1,54 @@ +import { Box, Button, Paper, Typography } from '@mui/material' +import { ForumOutlined } from '@mui/icons-material' +import React from 'react' + +export default function ChatTransition() { + return ( + + + + + + Sippy Chat has moved + + + + + Sippy Chat is now part of Chai Bot. + + + Continue the conversation with Chai Bot in Slack. + + + + + + ) +} diff --git a/sippy-ng/src/components/Sidebar.jsx b/sippy-ng/src/components/Sidebar.jsx index dd7509f434..fa4bbbcef0 100644 --- a/sippy-ng/src/components/Sidebar.jsx +++ b/sippy-ng/src/components/Sidebar.jsx @@ -9,7 +9,6 @@ import { FileCopyOutlined, GitHub, NotificationsActive, - SmartToy, } from '@mui/icons-material' import { DEFAULT_TEST_FILTERS } from '../constants' import { LaunderedListItem } from './Laundry' @@ -405,28 +404,6 @@ export default function Sidebar(props) { - - {(value) => { - if (value.includes('chat')) { - return ( - - - - - - - - - ) - } - }} - - { - if (!isLoaded || !analysis) return - - setPageContextForChat({ - page: 'job-analysis', - url: window.location.href, - instructions: `The user is viewing job analysis for multiple jobs matching specific filters. - You can use your database query tools to answer additional questions about the jobs being viewed. - When querying the database, apply the same filters shown in the context, especially the variant filters.`, - suggestions: [ - 'What are the most common test failures across these jobs?', - { - prompt: 'job-run-analysis', - label: 'Analyze a Failed Job', - }, - ], - data: { - release: props.release, - filters: filterModel, - }, - }) - - // Cleanup: Clear context when component unmounts - return () => { - unsetPageContextForChat() - } - }, [ - isLoaded, - analysis, - filterModel, - props.release, - setPageContextForChat, - unsetPageContextForChat, - ]) - if (fetchError !== '') { return {fetchError} } diff --git a/sippy-ng/src/jobs/JobTable.jsx b/sippy-ng/src/jobs/JobTable.jsx index 97a0c08035..e6b485ce7c 100644 --- a/sippy-ng/src/jobs/JobTable.jsx +++ b/sippy-ng/src/jobs/JobTable.jsx @@ -16,7 +16,6 @@ import { GridView } from '../datagrid/GridView' import { Link } from 'react-router-dom' import { makeStyles } from '@mui/styles' import { NumberParam, StringParam, useQueryParam } from 'use-query-params' -import { usePageContextForChat } from '../chat/store/useChatStore' import { withStyles } from '@mui/styles' import Alert from '@mui/material/Alert' import GridToolbar from '../datagrid/GridToolbar' @@ -407,9 +406,6 @@ const useStyles = makeStyles((_theme) => ({ function JobTable(props) { const { classes } = props const gridClasses = useStyles() - const { setPageContextForChat, unsetPageContextForChat } = - usePageContextForChat() - const [fetchError, setFetchError] = React.useState('') const [isLoaded, setLoaded] = React.useState(false) const [rows, setRows] = React.useState([]) @@ -441,56 +437,6 @@ function JobTable(props) { const [_jobDetails, _setJobDetails] = React.useState({ bugs: [] }) - // Update page context for chat (only if this is the main page, not embedded) - useEffect(() => { - // Don't set context if this is an embedded table (e.g., in ReleaseOverview) - if (!isLoaded || rows.length === 0 || props.briefTable) return - - // Send all rows on current page - const visibleJobs = rows.map((job) => ({ - name: job.name, - current_pass_percentage: job.current_pass_percentage, - current_runs: job.current_runs, - previous_pass_percentage: job.previous_pass_percentage, - previous_runs: job.previous_runs, - net_improvement: job.net_improvement, - })) - - setPageContextForChat({ - page: 'jobs-table', - url: window.location.href, - data: { - release: props.release, - totalJobs: rows.length, - period: period, - view: view, - sortField: sortField, - sortOrder: sort, - filters: filterModel, - selectedJobsCount: selectedJobs.length, - visibleJobs: visibleJobs, - }, - }) - - // Cleanup: Clear context when component unmounts - return () => { - unsetPageContextForChat() - } - }, [ - rows, - selectedJobs.length, - isLoaded, - props.release, - props.briefTable, - period, - view, - sortField, - sort, - filterModel, - setPageContextForChat, - unsetPageContextForChat, - ]) - const fetchData = () => { let queryString = '' if (filterModel && filterModel.items.length > 0) { diff --git a/sippy-ng/src/releases/ReleaseOverview.jsx b/sippy-ng/src/releases/ReleaseOverview.jsx index 83fc12e8ac..e48b749068 100644 --- a/sippy-ng/src/releases/ReleaseOverview.jsx +++ b/sippy-ng/src/releases/ReleaseOverview.jsx @@ -9,7 +9,6 @@ import { import { Link } from 'react-router-dom' import { makeStyles } from '@mui/styles' import { ReleasesContext } from '../App' -import { usePageContextForChat } from '../chat/store/useChatStore' import Alert from '@mui/material/Alert' import Grid from '@mui/material/Grid' import InfoIcon from '@mui/icons-material/Info' @@ -76,13 +75,9 @@ const useStyles = makeStyles((theme) => ({ export default function ReleaseOverview(props) { const classes = useStyles() - const { setPageContextForChat, unsetPageContextForChat } = - usePageContextForChat() - const [fetchError, setFetchError] = React.useState('') const [isLoaded, setLoaded] = React.useState(false) const [data, setData] = React.useState({}) - const hasSetContextRef = React.useRef(false) const releases = React.useContext(ReleasesContext) const fetchData = () => { @@ -109,91 +104,6 @@ export default function ReleaseOverview(props) { fetchData() }, []) - // Update page context for chat - useEffect(() => { - if (!isLoaded || !data.indicators || hasSetContextRef.current) return - - hasSetContextRef.current = true - setPageContextForChat({ - page: 'release-overview', - url: window.location.href, - suggestions: [ - 'How is the overall health of the release?', - { - prompt: 'payload-report', - label: 'Payload Status Report', - args: { - releases: [props.release], - streams: ['nightly', 'ci'], - }, - }, - ], - data: { - release: props.release, - indicators: { - infrastructure: data.indicators.infrastructure - ? { - current_pass_percentage: - data.indicators.infrastructure.current_working_percentage, - current_runs: data.indicators.infrastructure.current_runs, - previous_pass_percentage: - data.indicators.infrastructure.previous_working_percentage, - previous_runs: data.indicators.infrastructure.previous_runs, - net_improvement: - data.indicators.infrastructure.net_working_improvement, - } - : null, - install: data.indicators.install - ? { - current_pass_percentage: - data.indicators.install.current_working_percentage, - current_runs: data.indicators.install.current_runs, - previous_pass_percentage: - data.indicators.install.previous_working_percentage, - previous_runs: data.indicators.install.previous_runs, - net_improvement: - data.indicators.install.net_working_improvement, - } - : null, - tests: data.indicators.tests - ? { - current_pass_percentage: - data.indicators.tests.current_working_percentage, - current_runs: data.indicators.tests.current_runs, - previous_pass_percentage: - data.indicators.tests.previous_working_percentage, - previous_runs: data.indicators.tests.previous_runs, - net_improvement: data.indicators.tests.net_working_improvement, - } - : null, - upgrade: data.indicators.upgrade - ? { - current_pass_percentage: - data.indicators.upgrade.current_working_percentage, - current_runs: data.indicators.upgrade.current_runs, - previous_pass_percentage: - data.indicators.upgrade.previous_working_percentage, - previous_runs: data.indicators.upgrade.previous_runs, - net_improvement: - data.indicators.upgrade.net_working_improvement, - } - : null, - }, - statistics: { - current_mean: data.current_statistics?.mean, - previous_mean: data.previous_statistics?.mean, - quartiles: data.current_statistics?.quartiles, - standard_deviation: data.current_statistics?.standard_deviation, - }, - }, - }) - - // Cleanup: Clear context when component unmounts - return () => { - unsetPageContextForChat() - } - }, [isLoaded, setPageContextForChat, unsetPageContextForChat]) - if (fetchError !== '') { return ( diff --git a/sippy-ng/src/tests/TestAnalysis.jsx b/sippy-ng/src/tests/TestAnalysis.jsx index 4840884a70..9bec8b1ac5 100644 --- a/sippy-ng/src/tests/TestAnalysis.jsx +++ b/sippy-ng/src/tests/TestAnalysis.jsx @@ -38,7 +38,6 @@ import { TEST_THRESHOLDS } from '../constants' import { TestDurationChart } from './TestDurationChart' import { TestOutputs } from './TestOutputs' import { TestStackedChart } from './TestStackedChart' -import { usePageContextForChat } from '../chat/store/useChatStore' import Alert from '@mui/material/Alert' import BugButton from '../bugs/BugButton' import BugTable from '../bugs/BugTable' @@ -59,9 +58,6 @@ export function TestAnalysis(props) { const [fetchError, setFetchError] = React.useState('') const [testName = props.test] = useQueryParam('test', SafeStringParam) const [period = 'default'] = useQueryParam('period', StringParam) - const { setPageContextForChat, unsetPageContextForChat } = - usePageContextForChat() - const [filterModel, setFilterModel] = useStableJSONQueryParam('filters', { items: [ filterFor('name', 'equals', testName), @@ -120,65 +116,6 @@ export function TestAnalysis(props) { fetchData() }, [period]) - // Update page context for chat - useEffect(() => { - if (!isLoaded || !test || !testName) return - - setPageContextForChat({ - page: 'test-analysis', - url: window.location.href, - instructions: `The user is viewing detailed analysis for a specific test. - You can use your database query tools to answer additional questions about this test. - When querying the database, use the test name and apply the same filters shown in the context. - The test statistics shown are for the current ${ - period === 'twoDay' ? '2-day' : '7-day' - } period compared to the previous ${ - period === 'twoDay' ? '2-day' : '7-day' - } period.`, - suggestions: [ - 'What are the most common failure modes for this test?', - { - prompt: 'test-analysis', - label: 'Detailed Test Analysis', - args: { - release: props.release, - test_name: testName, - }, - }, - ], - data: { - release: props.release, - test_name: testName, - filters: filterModel, - statistics: { - current_pass_percentage: test.current_pass_percentage, - current_runs: test.current_runs, - current_successes: test.current_successes, - current_failures: test.current_failures, - current_flakes: test.current_flakes, - previous_pass_percentage: test.previous_pass_percentage, - previous_runs: test.previous_runs, - net_improvement: test.net_improvement, - }, - jira_component: test.jira_component, - }, - }) - - // Cleanup: Clear context when component unmounts - return () => { - unsetPageContextForChat() - } - }, [ - isLoaded, - test, - testName, - filterModel, - period, - props.release, - setPageContextForChat, - unsetPageContextForChat, - ]) - const breadcrumbs = (