commit bc4199aed226cdba92affd47bbe16406b8431baf Author: Jordan Date: Wed Apr 1 23:16:45 2026 +0100 Initial commit diff --git a/.env b/.env new file mode 100644 index 0000000..daf9b9b --- /dev/null +++ b/.env @@ -0,0 +1,23 @@ +# Required (at least one) +#ANTHROPIC_API_KEY=sk-ant-... +AGENTIC_OPENAI_API_KEY=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 + +# Provider selection: "claude" or "openai" +AGENTIC_DEFAULT_MODEL_PROVIDER=openai +AGENTIC_DEFAULT_MODEL_ID=gpt-4o + +# Redis +REDIS_PORT=6380 +AGENTIC_REDIS_HOST=localhost +AGENTIC_REDIS_PORT=6380 +AGENTIC_REDIS_DB=0 +# AGENTIC_REDIS_PASSWORD= + +# MCP Server (Acai stdio) +# AGENTIC_MCP_SERVER_COMMAND=node +# AGENTIC_MCP_SERVER_ARGS=["mcp-server/stdio.js"] + +# Variables que necesita tu MCP server +# ACAI_WEB_URL=http://localhost:8080 +# ACAI_WEBSITE=tu-sitio +# ACAI_PROJECT_DIR=/ruta/a/tu/proyecto-acai diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..64b3613 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# Required (at least one) +ANTHROPIC_API_KEY=sk-ant-... +# AGENTIC_OPENAI_API_KEY=sk-... + +# Provider selection: "claude" or "openai" +# AGENTIC_DEFAULT_MODEL_PROVIDER=claude + +# Optional overrides +# AGENTIC_REDIS_HOST=localhost +# AGENTIC_REDIS_PORT=6379 +# AGENTIC_REDIS_DB=0 +# AGENTIC_REDIS_PASSWORD= +# AGENTIC_DEFAULT_MODEL_ID=claude-sonnet-4-20250514 +# AGENTIC_DEFAULT_MODEL_ID=gpt-4o # if using openai +# AGENTIC_MAX_TOKENS=4096 +# AGENTIC_DEBUG=false + +# --- Acai MCP Server (stdio) --- +AGENTIC_MCP_SERVER_COMMAND=node +AGENTIC_MCP_SERVER_ARGS=["mcp-server/stdio.js"] + +# Acai MCP environment variables (required for the MCP server) +ACAI_WEB_URL=http://localhost:8080 +ACAI_WEBSITE=my-site +ACAI_PROJECT_DIR=/path/to/your/acai-project +# ACAI_TOKEN= (auto-read from .acai file) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dcc39e1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12-slim AS base + +WORKDIR /app + +# Install system deps +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# Install Python deps +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy source +COPY src/ ./src/ + +# Non-root user +RUN useradd -m appuser && chown -R appuser:appuser /app +USER appuser + +EXPOSE 8000 + +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce29043 --- /dev/null +++ b/README.md @@ -0,0 +1,335 @@ +# Agentic Microservice + +Microservicio de orquestación agéntica con context engineering avanzado, multi-agent orchestration, integración MCP, SSE streaming y knowledge base. Diseñado como backend para Acai Code. + +## Arquitectura + +``` +Client → API (FastAPI) → Orchestrator → Subagents (planner/coder/collector/reviewer) + ↓ ↓ + Context Engine Model Adapter (Claude / OpenAI) + ↓ ↓ + Memory Store + KB MCP Client (stdio) + ↓ + Redis (sessions, events, knowledge) +``` + +**Principios:** +- **Context over chat history** — el modelo recibe estado estructurado, nunca raw tool outputs +- **Knowledge filtering** — solo los docs relevantes al task entran en contexto (keyword scoring) +- **Compaction** — extracción de facts, eliminación de redundancia, prioridades por sección +- **Artifact summarization** — outputs de tools resumidos antes de entrar al contexto +- **Error recovery** — steps fallidos se saltan, timeout global, sesión nunca se queda en executing +- **Concurrency lock** — Redis SETNX impide ejecución simultánea en la misma sesión + +## Quick Start + +### Con Docker Compose + +```bash +# 1. Configurar +cp .env.example .env +# Editar .env — al menos una API key (ANTHROPIC o OPENAI) + +# 2. Levantar todo (Redis + microservicio) +docker compose up --build + +# 3. Solo Redis (para desarrollo local) +docker compose up redis -d +``` + +### Desarrollo local + +```bash +# 1. Redis +docker compose up redis -d + +# 2. Dependencias +pip install -r requirements.txt + +# 3. Configurar .env +AGENTIC_OPENAI_API_KEY=sk-... +AGENTIC_DEFAULT_MODEL_PROVIDER=openai +AGENTIC_DEFAULT_MODEL_ID=gpt-4o +AGENTIC_REDIS_PORT=6380 + +# 4. Arrancar +python3 -m uvicorn src.main:app --reload --port 8001 + +# 5. Dashboard en http://localhost:8001/dashboard/ +``` + +### Cargar Knowledge Base + +```bash +# Cargar docs desde el directorio docs/ +curl -X POST http://localhost:8001/api/v1/knowledge/load \ + -H "Content-Type: application/json" \ + -d '{"docs_path": "docs"}' +``` + +## Dashboard + +Interfaz web para testing integrada en el microservicio. Se accede en `/dashboard/`. + +**Features:** +- Gestión de sesiones (crear, eliminar, seleccionar) +- Chat con envío sync (Send) o streaming (Stream) +- Event Log en tiempo real con filtros por categoría y export JSON +- Inspector de estado (task, plan, facts, constraints, timeline) +- Context Debug — muestra exactamente qué recibe cada agente (secciones, tokens, previews) +- Dark/light mode +- Responsive (3 columnas → 1 columna en móvil) + +**Atajos de teclado:** +- `Enter` → Send +- `Ctrl+Enter` → Stream +- `Shift+Enter` → Nueva línea + +## API Reference + +Base URL: `/api/v1` + +### Sesiones + +```bash +# Crear sesión +curl -X POST http://localhost:8001/api/v1/sessions \ + -H "Content-Type: application/json" \ + -d '{ + "project_profile": {"name": "mi-proyecto", "tech_stack": ["python", "twig"]}, + "immutable_rules": ["Responde siempre en español", "Usa Tailwind CSS"], + "metadata": {} + }' +# → {"session_id": "abc123...", "status": "idle"} + +# Obtener estado +curl http://localhost:8001/api/v1/sessions/{session_id} + +# Eliminar +curl -X DELETE http://localhost:8001/api/v1/sessions/{session_id} +``` + +### Mensajes + +```bash +# Sync — espera la respuesta completa +curl -X POST http://localhost:8001/api/v1/sessions/{session_id}/messages \ + -H "Content-Type: application/json" \ + -d '{"message": "Crea un módulo FAQ con acordeón"}' + +# Streaming — retorna inmediatamente, resultados vía SSE +curl -X POST http://localhost:8001/api/v1/sessions/{session_id}/messages \ + -H "Content-Type: application/json" \ + -d '{"message": "Crea un módulo FAQ con acordeón", "stream": true}' +``` + +Respuesta sync: +```json +{ + "session_id": "...", + "task_id": "...", + "content": "### Step 1\n...\n### Review\n...", + "steps_completed": 5, + "steps_failed": [], + "artifacts_count": 0, + "review": "...", + "status": "completed" +} +``` + +### SSE Streaming + +```bash +curl -N http://localhost:8001/api/v1/sessions/{session_id}/stream +``` + +Tipos de evento: +| Evento | Cuándo | +|--------|--------| +| `session.created` | Sesión inicializada | +| `execution.started` | Pipeline arranca | +| `subagent.assigned` | Step ruteado a un agente | +| `agent.delta` | Chunk de texto del agente | +| `tool.started` | Herramienta MCP ejecutándose | +| `tool.completed` | Herramienta terminó | +| `execution.completed` | Task completado | +| `error` | Error en step, planning, o timeout | + +### Knowledge Base + +```bash +# Cargar docs desde directorio +curl -X POST http://localhost:8001/api/v1/knowledge/load \ + -H "Content-Type: application/json" \ + -d '{"docs_path": "docs"}' + +# Cargar desde ruta absoluta +curl -X POST http://localhost:8001/api/v1/knowledge/load \ + -H "Content-Type: application/json" \ + -d '{"docs_path": "/ruta/a/mis/docs"}' + +# Listar docs cargados +curl http://localhost:8001/api/v1/knowledge + +# Eliminar un doc +curl -X DELETE http://localhost:8001/api/v1/knowledge/{doc_id} +``` + +Los docs se cargan como `*.md` del directorio. Se sobreescriben si ya existen. No requiere reinicio — la siguiente conversación usa los docs actualizados. + +### Debug + +```bash +# Historial de eventos (persistidos en Redis) +curl http://localhost:8001/api/v1/sessions/{session_id}/events + +# Context debug — qué recibió cada agente +curl http://localhost:8001/api/v1/sessions/{session_id}/context-debug + +# Health check +curl http://localhost:8001/health +``` + +## Context Engine + +El componente más crítico del sistema. Ensambla el prompt que recibe el modelo con secciones priorizadas: + +| Sección | Prioridad | Contenido | +|---------|-----------|-----------| +| `immutable_rules` | 100 | System prompt del agente + reglas de sesión. Nunca se recorta | +| `project_profile` | 80 | Perfil del proyecto (nombre, tech stack) | +| `knowledge_base` | 60 | Docs relevantes filtrados por keywords del task | +| `task_state` | 70 | Objetivo, plan, step actual, facts, constraints | +| `artifact_memory` | 50 | Resúmenes de outputs de herramientas | +| `working_context` | 30 | Items de trabajo recientes entre steps | + +**Knowledge filtering:** no carga todos los docs. Extrae keywords del objetivo y step actual, puntúa cada doc (título ×10, tags ×5, contenido ×1), y selecciona los más relevantes dentro de un budget de 15k tokens. Los docs que no caben entran como summary de una línea. + +**Token counting:** usa `tiktoken` (encoding `cl100k_base`) para conteo real. Fallback a estimación si tiktoken no está instalado. + +**Compaction:** si el total excede el context window (120k tokens default), las secciones de menor prioridad se comprimen o eliminan. `immutable_rules` nunca se toca. + +## Orchestrator Pipeline + +``` +mensaje → planner → [step₁ → step₂ → ... → stepₙ] → reviewer → respuesta +``` + +1. **Planner** descompone el mensaje en pasos con agent role asignado +2. **Router** rutea cada step al agente apropiado (keyword matching: implement→coder, buscar→collector, etc.) +3. **Subagent** ejecuta el step con su propio contexto controlado +4. Si un step **falla**, se marca como failed y el pipeline continúa con el siguiente +5. **Reviewer** valida el trabajo si hubo >1 step +6. **Timeout global** de 5 min (configurable) — si se excede, la sesión pasa a error + +**Concurrency:** Redis lock (SETNX) con TTL de 5 min. Si llega un segundo mensaje mientras uno se ejecuta → `{"status": "busy"}`. El lock se libera automáticamente si el proceso muere. + +## MCP (Model Context Protocol) + +Cliente stdio que se conecta a un servidor MCP al arrancar: + +```bash +# En .env +AGENTIC_MCP_SERVER_COMMAND=node +AGENTIC_MCP_SERVER_ARGS=["mcp-server/stdio.js"] + +# Variables del MCP server (se heredan al subproceso) +ACAI_WEB_URL=http://localhost:8080 +ACAI_WEBSITE=mi-sitio +ACAI_PROJECT_DIR=/ruta/al/proyecto +``` + +El cliente descubre tools automáticamente vía `tools/list`, y los agentes las usan durante la ejecución. Los resultados de tools **nunca** entran al contexto como raw output — se resumen como artifacts. + +## Configuración + +Variables de entorno con prefijo `AGENTIC_`: + +| Variable | Default | Descripción | +|----------|---------|-------------| +| `AGENTIC_ANTHROPIC_API_KEY` | — | API key de Anthropic | +| `AGENTIC_OPENAI_API_KEY` | — | API key de OpenAI | +| `AGENTIC_DEFAULT_MODEL_PROVIDER` | `claude` | `claude` o `openai` | +| `AGENTIC_DEFAULT_MODEL_ID` | `claude-sonnet-4-20250514` | Modelo por defecto | +| `AGENTIC_REDIS_HOST` | `localhost` | Host de Redis | +| `AGENTIC_REDIS_PORT` | `6379` | Puerto de Redis | +| `AGENTIC_REDIS_DB` | `0` | Base de datos Redis | +| `AGENTIC_REDIS_PASSWORD` | — | Password de Redis | +| `AGENTIC_MAX_TOKENS` | `4096` | Max tokens de salida por llamada | +| `AGENTIC_CONTEXT_MAX_TOKENS` | `120000` | Max context window | +| `AGENTIC_MAX_EXECUTION_STEPS` | `25` | Max steps por task | +| `AGENTIC_MAX_EXECUTION_TIMEOUT_SECONDS` | `300` | Timeout global (5 min) | +| `AGENTIC_SUBAGENT_MAX_STEPS` | `10` | Max iterations por subagent | +| `AGENTIC_MCP_SERVER_COMMAND` | — | Comando del servidor MCP | +| `AGENTIC_MCP_SERVER_ARGS` | `[]` | Argumentos del servidor MCP | +| `AGENTIC_MCP_TIMEOUT_SECONDS` | `30` | Timeout por tool call | +| `AGENTIC_DEBUG` | `false` | Logging verbose | + +## Redis Key Structure + +``` +agentic:session:{id} → SessionState JSON (TTL 24h) +agentic:session:{id}:artifacts → Hash de ArtifactSummary +agentic:session:{id}:events → Lista de SSE events (cap 500) +agentic:session:{id}:lock → Execution lock (SETNX, TTL 5min) +agentic:sessions:index → Set de session IDs activos +agentic:memory:knowledge:{doc_id} → MemoryDocument JSON +agentic:memory:knowledge:_index → Set de doc IDs +agentic:memory:_tag:{tag} → Set de doc IDs por tag +agentic:memory:_type:{type} → Set de doc IDs por tipo +``` + +## Project Structure + +``` +. +├── src/ +│ ├── main.py # FastAPI app, lifecycle, wiring +│ ├── config.py # Pydantic settings +│ ├── models/ # Pydantic v2 data models +│ │ ├── session.py # SessionState, TaskState, TaskStep +│ │ ├── context.py # ContextPackage, MemoryDocument +│ │ ├── agent.py # AgentProfile, SubAgentDefinition +│ │ ├── artifacts.py # ArtifactSummary +│ │ └── tools.py # ToolExecution, ToolDefinition +│ ├── context/ # Context Engine (core) +│ │ ├── engine.py # Prompt assembly, knowledge filtering, debug +│ │ └── compactor.py # Compaction, summarization, tiktoken +│ ├── memory/ # Persistent memory +│ │ └── store.py # Redis-backed memory + embeddings +│ ├── adapters/ # Model provider adapters +│ │ ├── base.py # ModelAdapter interface +│ │ ├── claude_adapter.py # Anthropic Claude (streaming) +│ │ └── openai_adapter.py # OpenAI GPT (streaming) +│ ├── mcp/ # MCP client +│ │ └── client.py # stdio transport, tool registry +│ ├── orchestrator/ # Agent orchestration +│ │ ├── engine.py # Pipeline + error recovery + timeout +│ │ ├── router.py # Step-to-agent routing +│ │ └── agents/ +│ │ ├── base.py # Shared execution loop +│ │ ├── planner.py # Plan decomposition +│ │ ├── coder.py # Implementation +│ │ ├── collector.py # Context gathering +│ │ └── reviewer.py # Validation +│ ├── streaming/ # SSE streaming +│ │ └── sse.py # Event emitter + Redis persistence +│ ├── storage/ # Persistence +│ │ └── redis.py # Sessions, events, locks +│ └── api/ # REST endpoints +│ └── routes.py # Sessions, messages, knowledge, debug +├── dashboard/ # Testing UI (vanilla JS, zero build) +│ ├── index.html +│ ├── css/main.css +│ └── js/ +│ ├── app.js # State, SSE handlers +│ ├── api.js # Fetch + EventSource +│ └── components/ # Sidebar, chat, event log, inspector, timeline +├── mcp-server/ # Acai MCP server (Node.js, stdio) +├── docs/ # Knowledge base documents (*.md) +├── Dockerfile +├── docker-compose.yml +├── requirements.txt +└── .env.example +``` diff --git a/dashboard/css/main.css b/dashboard/css/main.css new file mode 100644 index 0000000..de78e72 --- /dev/null +++ b/dashboard/css/main.css @@ -0,0 +1,884 @@ +/* ============================================================ + Agentic Dashboard — Design System + ============================================================ */ + +/* --- CSS Custom Properties --- */ +:root { + /* Backgrounds */ + --bg-primary: #0d1117; + --bg-secondary: #161b22; + --bg-surface: #1c2128; + --bg-hover: #252c35; + --bg-input: #0d1117; + + /* Text */ + --text-primary: #e6edf3; + --text-secondary: #8b949e; + --text-muted: #6e7681; + --text-inverse: #0d1117; + + /* Accent */ + --accent: #58a6ff; + --accent-hover: #79c0ff; + --accent-muted: #1f3a5f; + + /* Status */ + --status-success: #3fb950; + --status-warning: #d29922; + --status-error: #f85149; + --status-info: #58a6ff; + + /* Agent roles */ + --agent-planner: #bc8cff; + --agent-coder: #3fb950; + --agent-collector: #d29922; + --agent-reviewer: #f778ba; + --agent-orchestrator: #58a6ff; + + /* Event types */ + --event-lifecycle: #58a6ff; + --event-content: #3fb950; + --event-tool: #d29922; + --event-orchestration: #bc8cff; + --event-error: #f85149; + --event-keepalive: #6e7681; + + /* Borders */ + --border: #30363d; + --border-subtle: #21262d; + + /* Misc */ + --radius: 8px; + --radius-sm: 4px; + --radius-lg: 12px; + --shadow: 0 2px 8px rgba(0,0,0,.3); + --transition: 150ms ease; + --font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif; + + /* Layout */ + --sidebar-width: 260px; + --inspector-width: 340px; + --toolbar-height: 48px; +} + +/* --- Light theme --- */ +[data-theme="light"] { + --bg-primary: #ffffff; + --bg-secondary: #f6f8fa; + --bg-surface: #ffffff; + --bg-hover: #f3f4f6; + --bg-input: #f6f8fa; + --text-primary: #1f2328; + --text-secondary: #656d76; + --text-muted: #8b949e; + --text-inverse: #ffffff; + --accent: #0969da; + --accent-hover: #0550ae; + --accent-muted: #ddf4ff; + --border: #d0d7de; + --border-subtle: #e1e4e8; + --shadow: 0 1px 3px rgba(0,0,0,.12); + --bg-surface: #f6f8fa; +} + +/* --- Reset & Base --- */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { + height: 100%; + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.5; + color: var(--text-primary); + background: var(--bg-primary); + overflow: hidden; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { color: var(--accent-hover); } + +/* --- Scrollbar --- */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } + +/* ============================================================ + Layout — 3-column grid + ============================================================ */ +#app { + display: grid; + grid-template-rows: var(--toolbar-height) 1fr; + grid-template-columns: var(--sidebar-width) 1fr var(--inspector-width); + grid-template-areas: + "toolbar toolbar toolbar" + "sidebar main inspector"; + height: 100vh; +} + +#toolbar { grid-area: toolbar; } +#sidebar { grid-area: sidebar; } +#main { grid-area: main; } +#inspector { grid-area: inspector; } + +/* ============================================================ + Toolbar + ============================================================ */ +#toolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 0 16px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + z-index: 10; +} + +.toolbar-brand { + font-weight: 600; + font-size: 15px; + color: var(--text-primary); + white-space: nowrap; +} + +.toolbar-separator { + width: 1px; + height: 20px; + background: var(--border); +} + +.toolbar-session-id { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-secondary); + cursor: pointer; + padding: 2px 8px; + border-radius: var(--radius-sm); + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.toolbar-session-id:hover { background: var(--bg-hover); } + +.toolbar-spacer { flex: 1; } + +.toolbar-actions { + display: flex; + gap: 6px; + align-items: center; +} + +/* ============================================================ + Sidebar + ============================================================ */ +#sidebar { + display: flex; + flex-direction: column; + background: var(--bg-secondary); + border-right: 1px solid var(--border); + overflow: hidden; +} + +.sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border-bottom: 1px solid var(--border-subtle); +} + +.sidebar-header h3 { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .5px; + color: var(--text-secondary); +} + +.session-list { + flex: 1; + overflow-y: auto; + padding: 6px; +} + +.session-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: var(--radius); + cursor: pointer; + transition: background var(--transition); + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-secondary); +} +.session-item:hover { background: var(--bg-hover); } +.session-item.active { + background: var(--accent-muted); + color: var(--accent); +} + +.session-item .delete-btn { + margin-left: auto; + opacity: 0; + transition: opacity var(--transition); + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 14px; + padding: 2px 4px; + border-radius: var(--radius-sm); +} +.session-item:hover .delete-btn { opacity: 1; } +.session-item .delete-btn:hover { color: var(--status-error); } + +.sidebar-footer { + padding: 10px 14px; + border-top: 1px solid var(--border-subtle); + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-muted); +} + +/* ============================================================ + Status Dot + ============================================================ */ +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.status-dot.idle { background: var(--status-success); } +.status-dot.active { background: var(--status-success); } +.status-dot.executing { background: var(--status-warning); animation: pulse 1.5s infinite; } +.status-dot.completed { background: var(--text-muted); } +.status-dot.error { background: var(--status-error); } +.status-dot.connected { background: var(--status-success); } +.status-dot.disconnected { background: var(--status-error); } + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: .4; } +} + +/* ============================================================ + Main Panel + ============================================================ */ +#main { + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--bg-primary); +} + +/* --- Chat messages --- */ +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 20px 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.chat-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + color: var(--text-muted); + gap: 8px; +} +.chat-empty .icon { font-size: 40px; opacity: .3; } +.chat-empty p { font-size: 13px; } + +.message { + max-width: 85%; + padding: 12px 16px; + border-radius: var(--radius-lg); + font-size: 14px; + line-height: 1.6; + word-wrap: break-word; +} +.message.user { + align-self: flex-end; + background: var(--accent); + color: var(--text-inverse); + border-bottom-right-radius: 4px; +} +.message.assistant { + align-self: flex-start; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-bottom-left-radius: 4px; +} +.message.assistant .agent-badge { + display: inline-block; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .5px; + padding: 1px 6px; + border-radius: 3px; + margin-bottom: 6px; +} +.message.system { + align-self: center; + background: transparent; + color: var(--text-muted); + font-size: 12px; + padding: 4px 12px; + border: 1px dashed var(--border); + border-radius: var(--radius); +} + +/* Markdown inside messages */ +.message h1, .message h2, .message h3 { + margin: 8px 0 4px; + font-size: 14px; + font-weight: 700; +} +.message h3 { font-size: 13px; } +.message p { margin: 4px 0; } +.message ul, .message ol { padding-left: 20px; margin: 4px 0; } +.message pre { + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 12px; + margin: 8px 0; + overflow-x: auto; + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.5; +} +.message code { + font-family: var(--font-mono); + font-size: 12px; + background: var(--bg-primary); + padding: 1px 5px; + border-radius: 3px; +} +.message pre code { background: none; padding: 0; } +.message strong { font-weight: 700; } + +/* --- Execution indicator --- */ +.execution-indicator { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius); + font-size: 12px; + color: var(--text-secondary); + align-self: flex-start; +} +.execution-indicator .spinner { + width: 14px; + height: 14px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin .8s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* --- Chat input --- */ +.chat-input-area { + padding: 12px 24px 16px; + border-top: 1px solid var(--border); + background: var(--bg-secondary); +} + +.chat-input-wrapper { + display: flex; + gap: 8px; + align-items: flex-end; +} + +.chat-input-wrapper textarea { + flex: 1; + resize: none; + border: 1px solid var(--border); + background: var(--bg-input); + color: var(--text-primary); + border-radius: var(--radius); + padding: 10px 14px; + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.5; + min-height: 42px; + max-height: 160px; + outline: none; + transition: border-color var(--transition); +} +.chat-input-wrapper textarea:focus { border-color: var(--accent); } +.chat-input-wrapper textarea::placeholder { color: var(--text-muted); } +.chat-input-wrapper textarea:disabled { + opacity: .5; + cursor: not-allowed; +} + +/* ============================================================ + Event Log (collapsible, below chat) + ============================================================ */ +.event-log-panel { + border-top: 1px solid var(--border); + background: var(--bg-secondary); + display: flex; + flex-direction: column; + max-height: 0; + overflow: hidden; + transition: max-height .3s ease; +} +.event-log-panel.open { max-height: 280px; } + +.event-log-header { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 16px; + cursor: pointer; + border-bottom: 1px solid var(--border-subtle); + user-select: none; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: .5px; +} +.event-log-header .chevron { + transition: transform var(--transition); + font-size: 10px; +} +.event-log-panel.open .event-log-header .chevron { transform: rotate(180deg); } + +.event-log-filters { + display: flex; + gap: 4px; + padding: 6px 12px; + flex-wrap: wrap; + border-bottom: 1px solid var(--border-subtle); +} + +.event-filter-btn { + font-size: 10px; + padding: 2px 8px; + border-radius: 10px; + border: 1px solid var(--border); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition); +} +.event-filter-btn.active { + background: var(--accent-muted); + color: var(--accent); + border-color: var(--accent); +} + +.event-log-entries { + flex: 1; + overflow-y: auto; + padding: 4px 8px; + font-family: var(--font-mono); + font-size: 11px; +} + +.event-entry { + display: flex; + gap: 8px; + padding: 3px 6px; + border-radius: var(--radius-sm); + align-items: flex-start; +} +.event-entry:hover { background: var(--bg-hover); } + +.event-time { color: var(--text-muted); white-space: nowrap; flex-shrink: 0; } +.event-type-badge { + font-size: 10px; + padding: 0 6px; + border-radius: 3px; + font-weight: 600; + white-space: nowrap; + flex-shrink: 0; +} +.event-data { + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + cursor: pointer; +} +.event-data.expanded { + white-space: pre-wrap; + word-break: break-all; +} + +/* Event type colors */ +.event-type-badge.lifecycle { background: rgba(88,166,255,.15); color: var(--event-lifecycle); } +.event-type-badge.content { background: rgba(63,185,80,.15); color: var(--event-content); } +.event-type-badge.tool { background: rgba(210,153,34,.15); color: var(--event-tool); } +.event-type-badge.orchestration { background: rgba(188,140,255,.15); color: var(--event-orchestration); } +.event-type-badge.error { background: rgba(248,81,73,.15); color: var(--event-error); } +.event-type-badge.keepalive { background: rgba(110,118,129,.1); color: var(--event-keepalive); } + +/* ============================================================ + Inspector (right panel) + ============================================================ */ +#inspector { + display: flex; + flex-direction: column; + background: var(--bg-secondary); + border-left: 1px solid var(--border); + overflow-y: auto; +} + +.inspector-empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-muted); + font-size: 13px; +} + +.inspector-section { + padding: 12px 14px; + border-bottom: 1px solid var(--border-subtle); +} + +.inspector-section-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .5px; + color: var(--text-muted); + margin-bottom: 8px; +} + +.inspector-field { + display: flex; + justify-content: space-between; + align-items: center; + padding: 3px 0; + font-size: 13px; +} +.inspector-field .label { + color: var(--text-secondary); +} +.inspector-field .value { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-primary); +} + +/* --- Status badge --- */ +.badge { + display: inline-block; + font-size: 11px; + font-weight: 600; + padding: 1px 8px; + border-radius: 10px; + text-transform: capitalize; +} +.badge.idle { background: rgba(63,185,80,.15); color: var(--status-success); } +.badge.active { background: rgba(63,185,80,.15); color: var(--status-success); } +.badge.executing { background: rgba(210,153,34,.15); color: var(--status-warning); } +.badge.completed { background: rgba(110,118,129,.15); color: var(--text-muted); } +.badge.error { background: rgba(248,81,73,.15); color: var(--status-error); } +.badge.pending { background: rgba(110,118,129,.1); color: var(--text-muted); } +.badge.planning { background: rgba(188,140,255,.15); color: var(--agent-planner); } +.badge.reviewing { background: rgba(247,120,186,.15); color: var(--agent-reviewer); } +.badge.failed { background: rgba(248,81,73,.15); color: var(--status-error); } + +/* --- Agent timeline --- */ +.timeline { + position: relative; + padding-left: 20px; +} +.timeline::before { + content: ''; + position: absolute; + left: 7px; + top: 0; + bottom: 0; + width: 2px; + background: var(--border); +} + +.timeline-step { + position: relative; + padding: 6px 0 12px; +} +.timeline-step::before { + content: ''; + position: absolute; + left: -17px; + top: 10px; + width: 10px; + height: 10px; + border-radius: 50%; + border: 2px solid var(--border); + background: var(--bg-secondary); +} +.timeline-step.active::before { + border-color: var(--status-warning); + background: var(--status-warning); + animation: pulse 1.5s infinite; +} +.timeline-step.completed::before { + border-color: var(--status-success); + background: var(--status-success); +} +.timeline-step.failed::before { + border-color: var(--status-error); + background: var(--status-error); +} + +.timeline-step-header { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; +} +.timeline-step-desc { + font-size: 12px; + color: var(--text-secondary); + margin-top: 2px; + line-height: 1.4; +} +.timeline-tools { + margin-top: 4px; + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.tool-chip { + font-size: 10px; + font-family: var(--font-mono); + padding: 1px 6px; + border-radius: 3px; + background: rgba(210,153,34,.1); + color: var(--event-tool); + border: 1px solid rgba(210,153,34,.2); +} + +/* --- Agent role badge --- */ +.role-badge { + font-size: 10px; + font-weight: 600; + padding: 1px 6px; + border-radius: 3px; + text-transform: uppercase; + letter-spacing: .3px; +} +.role-badge.planner { background: rgba(188,140,255,.15); color: var(--agent-planner); } +.role-badge.coder { background: rgba(63,185,80,.15); color: var(--agent-coder); } +.role-badge.collector { background: rgba(210,153,34,.15); color: var(--agent-collector); } +.role-badge.reviewer { background: rgba(247,120,186,.15); color: var(--agent-reviewer); } +.role-badge.orchestrator { background: rgba(88,166,255,.15); color: var(--agent-orchestrator); } + +/* ============================================================ + Buttons + ============================================================ */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg-surface); + color: var(--text-primary); + font-size: 13px; + font-family: var(--font-sans); + cursor: pointer; + transition: all var(--transition); + white-space: nowrap; +} +.btn:hover { background: var(--bg-hover); } +.btn:disabled { opacity: .4; cursor: not-allowed; } + +.btn-primary { + background: var(--accent); + color: var(--text-inverse); + border-color: var(--accent); +} +.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); } + +.btn-danger { + color: var(--status-error); + border-color: transparent; + background: transparent; +} +.btn-danger:hover { background: rgba(248,81,73,.1); } + +.btn-sm { padding: 3px 10px; font-size: 12px; } +.btn-icon { + padding: 4px 8px; + border: none; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + border-radius: var(--radius-sm); + font-size: 16px; +} +.btn-icon:hover { background: var(--bg-hover); color: var(--text-primary); } + +/* ============================================================ + Modal + ============================================================ */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + opacity: 0; + pointer-events: none; + transition: opacity .2s ease; +} +.modal-overlay.open { opacity: 1; pointer-events: auto; } + +.modal { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + width: 520px; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 8px 30px rgba(0,0,0,.4); +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + border-bottom: 1px solid var(--border-subtle); +} +.modal-header h2 { font-size: 15px; font-weight: 600; } + +.modal-body { padding: 18px; } + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 18px; + border-top: 1px solid var(--border-subtle); +} + +/* --- Form fields --- */ +.form-group { + margin-bottom: 14px; +} +.form-group label { + display: block; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 4px; +} +.form-group textarea, +.form-group input { + width: 100%; + border: 1px solid var(--border); + background: var(--bg-input); + color: var(--text-primary); + border-radius: var(--radius); + padding: 8px 12px; + font-family: var(--font-mono); + font-size: 12px; + outline: none; + transition: border-color var(--transition); +} +.form-group textarea:focus, +.form-group input:focus { border-color: var(--accent); } +.form-group textarea { min-height: 80px; resize: vertical; } +.form-group .error-text { + font-size: 11px; + color: var(--status-error); + margin-top: 3px; +} + +.rules-list { display: flex; flex-direction: column; gap: 6px; } +.rule-row { + display: flex; + gap: 6px; + align-items: center; +} +.rule-row input { flex: 1; } + +/* ============================================================ + JSON tree viewer + ============================================================ */ +.json-tree details { margin-left: 14px; } +.json-tree summary { + cursor: pointer; + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 12px; + user-select: none; +} +.json-tree summary:hover { color: var(--text-primary); } +.json-tree .json-key { color: var(--accent); } +.json-tree .json-string { color: var(--status-success); } +.json-tree .json-number { color: var(--agent-planner); } +.json-tree .json-bool { color: var(--status-warning); } +.json-tree .json-null { color: var(--text-muted); } +.json-tree .json-leaf { + margin-left: 14px; + font-family: var(--font-mono); + font-size: 12px; + padding: 1px 0; +} + +/* ============================================================ + Responsive + ============================================================ */ +@media (max-width: 1200px) { + #app { + grid-template-columns: var(--sidebar-width) 1fr; + grid-template-areas: + "toolbar toolbar" + "sidebar main"; + } + #inspector { display: none; } +} +@media (max-width: 768px) { + #app { + grid-template-columns: 1fr; + grid-template-areas: + "toolbar" + "main"; + } + #sidebar { display: none; } +} + +/* ============================================================ + Utility + ============================================================ */ +.hidden { display: none !important; } +.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.mono { font-family: var(--font-mono); } +.text-muted { color: var(--text-muted); } +.text-sm { font-size: 12px; } +.mt-2 { margin-top: 8px; } +.gap-4 { gap: 4px; } diff --git a/dashboard/index.html b/dashboard/index.html new file mode 100644 index 0000000..edd18e2 --- /dev/null +++ b/dashboard/index.html @@ -0,0 +1,33 @@ + + + + + + Agentic Dashboard + + + + +
+
+ +
+
+
+
+

Loading...

+
+
+
+
+
No session selected
+
+
+ + + + + + + + diff --git a/dashboard/js/api.js b/dashboard/js/api.js new file mode 100644 index 0000000..5f5b319 --- /dev/null +++ b/dashboard/js/api.js @@ -0,0 +1,133 @@ +/** + * API Client — all fetch calls + EventSource wrapper + */ + +const BASE = '/api/v1'; + +class ApiClient { + constructor() { + this._eventSources = new Map(); + this._bus = new EventTarget(); + } + + // --- Event bus --- + on(event, fn) { this._bus.addEventListener(event, fn); } + off(event, fn) { this._bus.removeEventListener(event, fn); } + _emit(event, detail) { + this._bus.dispatchEvent(new CustomEvent(event, { detail })); + } + + // --- HTTP helpers --- + async _fetch(path, opts = {}) { + try { + const res = await fetch(`${BASE}${path}`, { + headers: { 'Content-Type': 'application/json', ...opts.headers }, + ...opts, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(err.detail || `HTTP ${res.status}`); + } + return res.json(); + } catch (e) { + this._emit('error', { message: e.message, path }); + throw e; + } + } + + // --- Health --- + async checkHealth() { + try { + const data = await fetch('/health').then(r => r.json()); + this._emit('health', data); + return data; + } catch { + this._emit('health', { status: 'disconnected' }); + return { status: 'disconnected' }; + } + } + + // --- Sessions --- + async createSession(body) { + const data = await this._fetch('/sessions', { + method: 'POST', + body: JSON.stringify(body), + }); + this._emit('session:created', data); + return data; + } + + async getSession(sessionId) { + const data = await this._fetch(`/sessions/${sessionId}`); + this._emit('session:state', data); + return data; + } + + async deleteSession(sessionId) { + const data = await this._fetch(`/sessions/${sessionId}`, { method: 'DELETE' }); + this._emit('session:deleted', { session_id: sessionId }); + return data; + } + + async getEvents(sessionId) { + return this._fetch(`/sessions/${sessionId}/events`); + } + + // --- Messages --- + async sendMessage(sessionId, message, stream = false) { + return this._fetch(`/sessions/${sessionId}/messages`, { + method: 'POST', + body: JSON.stringify({ message, stream }), + }); + } + + // --- SSE --- + subscribeSSE(sessionId) { + this.unsubscribeSSE(sessionId); + + const url = `${BASE}/sessions/${sessionId}/stream`; + const es = new EventSource(url); + + const eventTypes = [ + 'session.created', 'execution.started', 'agent.delta', + 'tool.started', 'tool.completed', 'subagent.assigned', + 'execution.completed', 'error', 'keepalive', + ]; + + for (const type of eventTypes) { + es.addEventListener(type, (e) => { + try { + const payload = JSON.parse(e.data); + this._emit('sse', { type, ...payload }); + this._emit(`sse:${type}`, payload); + } catch { + this._emit('sse', { type, raw: e.data }); + } + }); + } + + es.onerror = () => { + this._emit('sse:error', { sessionId }); + }; + + this._eventSources.set(sessionId, es); + return es; + } + + unsubscribeSSE(sessionId) { + const es = this._eventSources.get(sessionId); + if (es) { + es.close(); + this._eventSources.delete(sessionId); + } + } + + unsubscribeAll() { + for (const [id, es] of this._eventSources) { + es.close(); + } + this._eventSources.clear(); + } +} + +export const api = new ApiClient(); diff --git a/dashboard/js/app.js b/dashboard/js/app.js new file mode 100644 index 0000000..529b4f0 --- /dev/null +++ b/dashboard/js/app.js @@ -0,0 +1,252 @@ +/** + * Application bootstrap — state management, health polling, routing + */ + +import { api } from './api.js'; +import { initSidebar, renderSessions } from './components/sidebar.js'; +import { initSessionForm } from './components/session-form.js'; +import { initToolbar, updateToolbar } from './components/toolbar.js'; +import { initChat, addMessage, setStreamingContent, clearChat, setInputEnabled, showExecutionIndicator, hideExecutionIndicator } from './components/chat.js'; +import { initEventLog, addEvent, clearEvents } from './components/event-log.js'; +import { initInspector, updateInspector, clearInspector } from './components/session-inspector.js'; +import { initTimeline, addTimelineStep, updateTimelineStep, clearTimeline } from './components/agent-timeline.js'; + +// --- Global state --- +export const state = { + sessions: JSON.parse(localStorage.getItem('agentic_sessions') || '[]'), + activeSessionId: null, + sessionState: null, + health: null, + isExecuting: false, + streamingContent: '', + currentAgent: '', +}; + +function saveSessions() { + localStorage.setItem('agentic_sessions', JSON.stringify(state.sessions)); +} + +// --- Session management --- +export async function selectSession(sessionId) { + api.unsubscribeAll(); + state.activeSessionId = sessionId; + state.streamingContent = ''; + state.currentAgent = ''; + clearChat(); + clearEvents(); + clearTimeline(); + clearInspector(); + + if (!sessionId) { + updateToolbar(null); + renderSessions(); + return; + } + + location.hash = `session=${sessionId}`; + renderSessions(); + + try { + const session = await api.getSession(sessionId); + state.sessionState = session; + updateToolbar(session); + updateInspector(session); + + const events = await api.getEvents(sessionId); + for (const ev of events) addEvent(ev); + + api.subscribeSSE(sessionId); + setInputEnabled(true); + } catch (e) { + addMessage('system', `Error loading session: ${e.message}`); + } +} + +export async function createSession(body) { + const data = await api.createSession(body); + if (!state.sessions.includes(data.session_id)) { + state.sessions.unshift(data.session_id); + saveSessions(); + } + await selectSession(data.session_id); + return data; +} + +export async function deleteSession(sessionId) { + try { + await api.deleteSession(sessionId); + } catch { /* ignore 404 */ } + api.unsubscribeSSE(sessionId); + state.sessions = state.sessions.filter(s => s !== sessionId); + saveSessions(); + if (state.activeSessionId === sessionId) { + state.activeSessionId = null; + state.sessionState = null; + location.hash = ''; + clearChat(); + clearEvents(); + clearTimeline(); + clearInspector(); + updateToolbar(null); + } + renderSessions(); +} + +export async function sendMessage(message, stream = false) { + if (!state.activeSessionId || state.isExecuting) return; + + state.isExecuting = true; + state.streamingContent = ''; + setInputEnabled(false); + addMessage('user', message); + showExecutionIndicator(); + + try { + if (stream) { + await api.sendMessage(state.activeSessionId, message, true); + // Response comes via SSE — handled by event listeners + } else { + const result = await api.sendMessage(state.activeSessionId, message, false); + hideExecutionIndicator(); + addMessage('assistant', result.content || JSON.stringify(result, null, 2)); + state.isExecuting = false; + setInputEnabled(true); + // Refresh state + const session = await api.getSession(state.activeSessionId); + state.sessionState = session; + updateToolbar(session); + updateInspector(session); + } + } catch (e) { + hideExecutionIndicator(); + addMessage('system', `Error: ${e.message}`); + state.isExecuting = false; + setInputEnabled(true); + } +} + +// --- SSE event handlers --- +function setupSSEListeners() { + api.on('sse', (e) => { + addEvent(e.detail); + }); + + api.on('sse:execution.started', () => { + state.isExecuting = true; + showExecutionIndicator('Starting execution...'); + }); + + api.on('sse:subagent.assigned', (e) => { + const d = e.detail.data || e.detail; + state.currentAgent = d.agent || ''; + showExecutionIndicator(`Step ${d.step}/${d.total_steps} — ${d.agent}`); + addTimelineStep({ + step: d.step, + totalSteps: d.total_steps, + agent: d.agent, + description: d.description, + status: 'executing', + }); + }); + + api.on('sse:agent.delta', (e) => { + const d = e.detail.data || e.detail; + state.streamingContent += d.delta || ''; + setStreamingContent(state.streamingContent, state.currentAgent); + }); + + api.on('sse:tool.started', (e) => { + const d = e.detail.data || e.detail; + showExecutionIndicator(`Running tool: ${d.tool}`); + }); + + api.on('sse:tool.completed', (e) => { + const d = e.detail.data || e.detail; + if (state.currentAgent) { + updateTimelineStep(state.currentAgent, { tool: d.tool, status: d.status }); + } + }); + + api.on('sse:execution.completed', async (e) => { + hideExecutionIndicator(); + if (state.streamingContent) { + // Finalize the streaming message + addMessage('assistant', state.streamingContent); + state.streamingContent = ''; + } + state.isExecuting = false; + state.currentAgent = ''; + setInputEnabled(true); + + // Refresh session state + if (state.activeSessionId) { + try { + const session = await api.getSession(state.activeSessionId); + state.sessionState = session; + updateToolbar(session); + updateInspector(session); + } catch { /* ignore */ } + } + }); + + api.on('sse:error', (e) => { + const d = e.detail.data || e.detail; + addMessage('system', `Error: ${JSON.stringify(d)}`); + hideExecutionIndicator(); + state.isExecuting = false; + setInputEnabled(true); + }); +} + +// --- Health polling --- +let healthInterval; +function startHealthPolling() { + const poll = async () => { + state.health = await api.checkHealth(); + }; + poll(); + healthInterval = setInterval(poll, 10000); +} + +// --- Theme --- +export function toggleTheme() { + const current = document.documentElement.getAttribute('data-theme'); + const next = current === 'light' ? 'dark' : 'light'; + document.documentElement.setAttribute('data-theme', next); + localStorage.setItem('agentic_theme', next); +} + +// --- Init --- +function init() { + // Restore theme + const savedTheme = localStorage.getItem('agentic_theme') || 'dark'; + if (savedTheme === 'light') { + document.documentElement.setAttribute('data-theme', 'light'); + } + + // Init components + initToolbar(); + initSidebar(); + initSessionForm(); + initChat(); + initEventLog(); + initInspector(); + initTimeline(); + + // SSE listeners + setupSSEListeners(); + + // Health + startHealthPolling(); + + // Restore session from hash + const hash = location.hash; + const match = hash.match(/session=([a-f0-9]+)/); + if (match && state.sessions.includes(match[1])) { + selectSession(match[1]); + } + + renderSessions(); +} + +document.addEventListener('DOMContentLoaded', init); diff --git a/dashboard/js/components/agent-timeline.js b/dashboard/js/components/agent-timeline.js new file mode 100644 index 0000000..76c6d78 --- /dev/null +++ b/dashboard/js/components/agent-timeline.js @@ -0,0 +1,101 @@ +/** + * Agent Timeline — visual execution timeline built from SSE events + */ + +let containerEl; +let steps = []; + +export function initTimeline() { + // The timeline renders inside the inspector; this module manages the data + containerEl = null; + steps = []; +} + +export function addTimelineStep({ step, totalSteps, agent, description, status }) { + steps.push({ + step, + totalSteps, + agent, + description, + status: status || 'executing', + tools: [], + }); + + renderTimeline(); +} + +export function updateTimelineStep(agent, { tool, status }) { + // Find the last step for this agent + for (let i = steps.length - 1; i >= 0; i--) { + if (steps[i].agent === agent) { + if (tool) { + steps[i].tools.push({ name: tool, status: status || 'completed' }); + } + break; + } + } + renderTimeline(); +} + +export function clearTimeline() { + steps = []; + renderTimeline(); +} + +function renderTimeline() { + // Find or create the timeline container in the inspector + const inspector = document.getElementById('inspector'); + if (!inspector) return; + + // Remove existing timeline section + let existing = document.getElementById('live-timeline-section'); + if (existing) existing.remove(); + + if (steps.length === 0) return; + + const section = document.createElement('div'); + section.className = 'inspector-section'; + section.id = 'live-timeline-section'; + + let html = '
Live Timeline
'; + html += '
'; + + for (const s of steps) { + const stepClass = s.status === 'executing' ? 'active' : s.status === 'completed' ? 'completed' : s.status === 'failed' ? 'failed' : ''; + + html += ` +
+
+ ${s.agent} + Step ${s.step}/${s.totalSteps} +
+
${escapeHtml(s.description)}
+ `; + + if (s.tools.length > 0) { + html += '
'; + for (const t of s.tools) { + html += `${escapeHtml(t.name)}`; + } + html += '
'; + } + + html += '
'; + } + + html += '
'; + section.innerHTML = html; + + // Insert at the top of inspector (after session info if present) + const firstSection = inspector.querySelector('.inspector-section'); + if (firstSection && firstSection.nextSibling) { + inspector.insertBefore(section, firstSection.nextSibling); + } else { + inspector.appendChild(section); + } +} + +function escapeHtml(str) { + if (!str) return ''; + return str.replace(/&/g, '&').replace(//g, '>'); +} diff --git a/dashboard/js/components/chat.js b/dashboard/js/components/chat.js new file mode 100644 index 0000000..da948a0 --- /dev/null +++ b/dashboard/js/components/chat.js @@ -0,0 +1,180 @@ +/** + * Chat — message input + conversation view + streaming + */ + +import { sendMessage } from '../app.js'; + +let messagesEl; +let inputEl; +let sendBtn; +let streamBtn; +let indicatorEl; +let streamingBubble = null; + +// Minimal markdown renderer +function renderMarkdown(text) { + let html = text + // Code blocks + .replace(/```(\w*)\n([\s\S]*?)```/g, '
$2
') + // Inline code + .replace(/`([^`]+)`/g, '$1') + // Headers + .replace(/^### (.+)$/gm, '

$1

') + .replace(/^## (.+)$/gm, '

$1

') + .replace(/^# (.+)$/gm, '

$1

') + // Bold + .replace(/\*\*(.+?)\*\*/g, '$1') + // Lists + .replace(/^\- (.+)$/gm, '
  • $1
  • ') + .replace(/^\* (.+)$/gm, '
  • $1
  • ') + // Paragraphs (double newlines) + .replace(/\n\n/g, '

    ') + // Single newlines + .replace(/\n/g, '
    '); + + // Wrap loose

  • in