From 33488fd63415cf871cbdbce31f503b9104af2125 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Mon, 30 Mar 2026 00:54:21 +0200 Subject: [PATCH] feat: complete phase 1 -- core framework with chat loop, agents, and React UI Backend: - FastAPI WebSocket /ws endpoint with streaming via LangGraph astream - LangGraph Supervisor connecting 3 mock agents (order_lookup, order_actions, fallback) - YAML Agent Registry with Pydantic validation and immutable configs - PostgresSaver checkpoint persistence via langgraph-checkpoint-postgres - Session TTL with 30-min sliding window and interrupt extension - LLM provider abstraction (Anthropic/OpenAI/Google) - Token usage + cost tracking callback handler - Input validation: message size cap, thread_id format, content length - Security: no hardcoded defaults, startup API key validation, no input reflection Frontend: - React 19 + TypeScript + Vite chat UI - WebSocket hook with reconnect + exponential backoff - Streaming token display with agent attribution - Interrupt approval/reject UI for write operations - Collapsible tool call viewer Testing: - 87 unit tests, 87% coverage (exceeds 80% requirement) - Ruff lint + format clean Infrastructure: - Docker Compose (PostgreSQL 16 + backend) - pyproject.toml with full dependency management --- .gitignore | 5 + CLAUDE.md | 2 +- backend/.env.example | 19 + backend/Dockerfile | 12 + backend/agents.yaml | 31 + backend/app/__init__.py | 0 backend/app/agents/__init__.py | 30 + backend/app/agents/fallback.py | 18 + backend/app/agents/order_actions.py | 37 + backend/app/agents/order_lookup.py | 68 + backend/app/callbacks.py | 60 + backend/app/config.py | 46 + backend/app/db.py | 61 + backend/app/graph.py | 70 + backend/app/llm.py | 42 + backend/app/main.py | 83 + backend/app/registry.py | 104 ++ backend/app/session_manager.py | 78 + backend/app/ws_handler.py | 204 +++ backend/pyproject.toml | 64 + backend/tests/__init__.py | 0 backend/tests/conftest.py | 69 + backend/tests/e2e/__init__.py | 0 backend/tests/integration/__init__.py | 0 backend/tests/unit/__init__.py | 0 backend/tests/unit/test_agents.py | 82 + backend/tests/unit/test_callbacks.py | 102 ++ backend/tests/unit/test_config.py | 60 + backend/tests/unit/test_db.py | 64 + backend/tests/unit/test_graph.py | 44 + backend/tests/unit/test_llm.py | 41 + backend/tests/unit/test_main.py | 27 + backend/tests/unit/test_registry.py | 147 ++ backend/tests/unit/test_session_manager.py | 70 + backend/tests/unit/test_ws_handler.py | 233 +++ docker-compose.yml | 39 + docs/phases/phase-1-dev-log.md | 88 + frontend/index.html | 12 + frontend/package-lock.json | 1820 +++++++++++++++++++ frontend/package.json | 22 + frontend/src/App.tsx | 5 + frontend/src/components/AgentAction.tsx | 77 + frontend/src/components/ChatInput.tsx | 68 + frontend/src/components/ChatMessages.tsx | 82 + frontend/src/components/InterruptPrompt.tsx | 81 + frontend/src/hooks/useWebSocket.ts | 104 ++ frontend/src/main.tsx | 9 + frontend/src/pages/ChatPage.tsx | 200 ++ frontend/src/types.ts | 86 + frontend/tsconfig.json | 21 + frontend/vite.config.ts | 15 + 51 files changed, 4701 insertions(+), 1 deletion(-) create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/agents.yaml create mode 100644 backend/app/__init__.py create mode 100644 backend/app/agents/__init__.py create mode 100644 backend/app/agents/fallback.py create mode 100644 backend/app/agents/order_actions.py create mode 100644 backend/app/agents/order_lookup.py create mode 100644 backend/app/callbacks.py create mode 100644 backend/app/config.py create mode 100644 backend/app/db.py create mode 100644 backend/app/graph.py create mode 100644 backend/app/llm.py create mode 100644 backend/app/main.py create mode 100644 backend/app/registry.py create mode 100644 backend/app/session_manager.py create mode 100644 backend/app/ws_handler.py create mode 100644 backend/pyproject.toml create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/e2e/__init__.py create mode 100644 backend/tests/integration/__init__.py create mode 100644 backend/tests/unit/__init__.py create mode 100644 backend/tests/unit/test_agents.py create mode 100644 backend/tests/unit/test_callbacks.py create mode 100644 backend/tests/unit/test_config.py create mode 100644 backend/tests/unit/test_db.py create mode 100644 backend/tests/unit/test_graph.py create mode 100644 backend/tests/unit/test_llm.py create mode 100644 backend/tests/unit/test_main.py create mode 100644 backend/tests/unit/test_registry.py create mode 100644 backend/tests/unit/test_session_manager.py create mode 100644 backend/tests/unit/test_ws_handler.py create mode 100644 docker-compose.yml create mode 100644 docs/phases/phase-1-dev-log.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/AgentAction.tsx create mode 100644 frontend/src/components/ChatInput.tsx create mode 100644 frontend/src/components/ChatMessages.tsx create mode 100644 frontend/src/components/InterruptPrompt.tsx create mode 100644 frontend/src/hooks/useWebSocket.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/ChatPage.tsx create mode 100644 frontend/src/types.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore index ffa9be3..0a2882d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,10 +7,15 @@ build/ .venv/ venv/ .env +.pytest_cache/ +.coverage +htmlcov/ +.ruff_cache/ # Node node_modules/ .next/ +frontend/dist/ # IDE .vscode/ diff --git a/CLAUDE.md b/CLAUDE.md index 64c1138..6298847 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -234,7 +234,7 @@ A checkpoint includes: | Phase | Branch | Focus | Status | |-------|--------|-------|--------| -| 1 | `phase-1/core-framework` | FastAPI + LangGraph + React chat loop + PostgresSaver | NOT STARTED | +| 1 | `phase-1/core-framework` | FastAPI + LangGraph + React chat loop + PostgresSaver | IN PROGRESS | | 2 | `phase-2/multi-agent-safety` | Supervisor routing + interrupts + templates | NOT STARTED | | 3 | `phase-3/openapi-discovery` | OpenAPI parsing + MCP generation + SSRF protection | NOT STARTED | | 4 | `phase-4/analytics-replay` | Replay API + analytics dashboard | NOT STARTED | diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..79b360b --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,19 @@ +# Database +DATABASE_URL=postgresql://smart_support:dev_password@localhost:5432/smart_support + +# LLM Provider: anthropic | openai | google +LLM_PROVIDER=anthropic +LLM_MODEL=claude-sonnet-4-6 + +# API Keys (set the one matching your LLM_PROVIDER) +ANTHROPIC_API_KEY= +OPENAI_API_KEY= +GOOGLE_API_KEY= + +# Session +SESSION_TTL_MINUTES=30 +INTERRUPT_TTL_MINUTES=30 + +# Server +WS_HOST=0.0.0.0 +WS_PORT=8000 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..61e5da6 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY pyproject.toml . +RUN pip install --no-cache-dir -e . + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/agents.yaml b/backend/agents.yaml new file mode 100644 index 0000000..10e4721 --- /dev/null +++ b/backend/agents.yaml @@ -0,0 +1,31 @@ +agents: + - name: order_lookup + description: "Looks up order status and tracking information. Use for queries about order status, shipping, and delivery." + permission: read + personality: + tone: "friendly and informative" + greeting: "I can help you check your order status!" + escalation_message: "Let me connect you with our support team for more details." + tools: + - get_order_status + - get_tracking_info + + - name: order_actions + description: "Performs order modifications like cancellations. Use when the customer wants to cancel, modify, or change an order." + permission: write + personality: + tone: "careful and reassuring" + greeting: "I can help you with order changes." + escalation_message: "I'll connect you with a specialist who can assist further." + tools: + - cancel_order + + - name: fallback + description: "Handles general questions, unclear requests, and conversations that don't match other agents." + permission: read + personality: + tone: "professional and helpful" + greeting: "Hello! How can I help you today?" + escalation_message: "Let me connect you with a human agent who can better assist you." + tools: + - fallback_respond diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/agents/__init__.py b/backend/app/agents/__init__.py new file mode 100644 index 0000000..9153db2 --- /dev/null +++ b/backend/app/agents/__init__.py @@ -0,0 +1,30 @@ +"""Agent tools registry -- maps tool name strings to actual tool functions.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from langchain_core.tools import BaseTool + +from app.agents.fallback import fallback_respond +from app.agents.order_actions import cancel_order +from app.agents.order_lookup import get_order_status, get_tracking_info + +_TOOL_MAP: dict[str, BaseTool] = { + "get_order_status": get_order_status, + "get_tracking_info": get_tracking_info, + "cancel_order": cancel_order, + "fallback_respond": fallback_respond, +} + + +def get_tools_by_names(tool_names: list[str]) -> list[BaseTool]: + """Resolve tool name strings from YAML config to actual tool objects.""" + tools = [] + for name in tool_names: + if name not in _TOOL_MAP: + available = ", ".join(sorted(_TOOL_MAP.keys())) + raise ValueError(f"Unknown tool '{name}'. Available tools: {available}") + tools.append(_TOOL_MAP[name]) + return tools diff --git a/backend/app/agents/fallback.py b/backend/app/agents/fallback.py new file mode 100644 index 0000000..bd10271 --- /dev/null +++ b/backend/app/agents/fallback.py @@ -0,0 +1,18 @@ +"""Fallback agent tools -- handles unmatched intents.""" + +from __future__ import annotations + +from langchain_core.tools import tool + + +@tool +def fallback_respond(query: str) -> str: + """Provide a helpful response when the user's intent doesn't match a specific agent.""" + return ( + "I'm here to help with order inquiries and actions. " + "Here's what I can do:\n" + "- Check order status (e.g., 'What is the status of order 1042?')\n" + "- Get tracking information (e.g., 'Track order 1042')\n" + "- Cancel an order (e.g., 'Cancel order 1042')\n\n" + "Could you please rephrase your request?" + ) diff --git a/backend/app/agents/order_actions.py b/backend/app/agents/order_actions.py new file mode 100644 index 0000000..7df45ea --- /dev/null +++ b/backend/app/agents/order_actions.py @@ -0,0 +1,37 @@ +"""Order action tools -- write operations requiring human approval.""" + +from __future__ import annotations + +from langchain_core.tools import tool +from langgraph.types import interrupt + + +@tool +def cancel_order(order_id: str) -> dict: + """Cancel an order. Requires human approval before execution.""" + response = interrupt( + { + "action": "cancel_order", + "order_id": order_id, + "message": f"Please confirm: cancel order {order_id}?", + } + ) + + if isinstance(response, bool): + approved = response + elif isinstance(response, dict): + approved = response.get("approved", False) + else: + approved = bool(response) + + if approved: + return { + "status": "cancelled", + "order_id": order_id, + "message": f"Order {order_id} has been successfully cancelled.", + } + return { + "status": "kept", + "order_id": order_id, + "message": f"Order {order_id} cancellation was declined. The order remains active.", + } diff --git a/backend/app/agents/order_lookup.py b/backend/app/agents/order_lookup.py new file mode 100644 index 0000000..888139e --- /dev/null +++ b/backend/app/agents/order_lookup.py @@ -0,0 +1,68 @@ +"""Order lookup tools -- read-only operations.""" + +from __future__ import annotations + +from types import MappingProxyType + +from langchain_core.tools import tool + +MOCK_ORDERS: MappingProxyType[str, dict] = MappingProxyType( + { + "1042": { + "order_id": "1042", + "status": "shipped", + "items": ["Wireless Headphones", "USB-C Cable"], + "total": 89.99, + "placed_at": "2026-03-25", + }, + "1043": { + "order_id": "1043", + "status": "processing", + "items": ["Laptop Stand"], + "total": 49.99, + "placed_at": "2026-03-28", + }, + "1044": { + "order_id": "1044", + "status": "delivered", + "items": ["Mechanical Keyboard", "Mouse Pad"], + "total": 159.99, + "placed_at": "2026-03-20", + }, + } +) + +MOCK_TRACKING: MappingProxyType[str, dict] = MappingProxyType( + { + "1042": { + "order_id": "1042", + "carrier": "FedEx", + "tracking_number": "FX-9876543210", + "estimated_delivery": "2026-04-01", + "current_location": "Distribution Center, Chicago IL", + }, + "1044": { + "order_id": "1044", + "carrier": "UPS", + "tracking_number": "1Z-5678901234", + "estimated_delivery": "2026-03-22", + "current_location": "Delivered", + }, + } +) + + +@tool +def get_order_status(order_id: str) -> dict: + """Look up the current status of an order by order ID.""" + if order_id in MOCK_ORDERS: + return dict(MOCK_ORDERS[order_id]) + return {"error": f"Order {order_id} not found", "order_id": order_id} + + +@tool +def get_tracking_info(order_id: str) -> dict: + """Get shipping and tracking information for an order.""" + if order_id in MOCK_TRACKING: + return dict(MOCK_TRACKING[order_id]) + return {"error": f"No tracking information for order {order_id}", "order_id": order_id} diff --git a/backend/app/callbacks.py b/backend/app/callbacks.py new file mode 100644 index 0000000..3c3b2d7 --- /dev/null +++ b/backend/app/callbacks.py @@ -0,0 +1,60 @@ +"""Token usage tracking callback handler.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from langchain_core.callbacks import BaseCallbackHandler + +if TYPE_CHECKING: + from langchain_core.outputs import LLMResult + +COST_PER_1K_TOKENS: dict[str, dict[str, float]] = { + "claude-sonnet-4-6": {"prompt": 0.003, "completion": 0.015}, + "claude-haiku-4-5-20251001": {"prompt": 0.0008, "completion": 0.004}, + "gpt-4o": {"prompt": 0.0025, "completion": 0.01}, + "gpt-4o-mini": {"prompt": 0.00015, "completion": 0.0006}, +} + +DEFAULT_COST = {"prompt": 0.003, "completion": 0.015} + + +@dataclass(frozen=True) +class TokenUsage: + prompt_tokens: int + completion_tokens: int + total_tokens: int + total_cost_usd: float + + +class TokenUsageCallbackHandler(BaseCallbackHandler): + """Accumulates token usage and cost across LLM invocations.""" + + def __init__(self, model_name: str = "") -> None: + self._model_name = model_name + self._prompt_tokens = 0 + self._completion_tokens = 0 + + def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None: + if response.llm_output and "token_usage" in response.llm_output: + usage = response.llm_output["token_usage"] + self._prompt_tokens += usage.get("prompt_tokens", 0) + self._completion_tokens += usage.get("completion_tokens", 0) + + def get_usage(self) -> TokenUsage: + costs = COST_PER_1K_TOKENS.get(self._model_name, DEFAULT_COST) + cost = ( + self._prompt_tokens * costs["prompt"] / 1000 + + self._completion_tokens * costs["completion"] / 1000 + ) + return TokenUsage( + prompt_tokens=self._prompt_tokens, + completion_tokens=self._completion_tokens, + total_tokens=self._prompt_tokens + self._completion_tokens, + total_cost_usd=round(cost, 6), + ) + + def reset(self) -> None: + self._prompt_tokens = 0 + self._completion_tokens = 0 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..9198ab9 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,46 @@ +"""Centralized application configuration via pydantic-settings.""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + database_url: str + + llm_provider: Literal["anthropic", "openai", "google"] = "anthropic" + llm_model: str = "claude-sonnet-4-6" + + session_ttl_minutes: int = 30 + interrupt_ttl_minutes: int = 30 + + ws_host: str = "0.0.0.0" + ws_port: int = 8000 + + anthropic_api_key: str = "" + openai_api_key: str = "" + google_api_key: str = "" + + @model_validator(mode="after") + def validate_provider_key(self) -> Settings: + key_map = { + "anthropic": self.anthropic_api_key, + "openai": self.openai_api_key, + "google": self.google_api_key, + } + key = key_map.get(self.llm_provider, "") + if not key: + raise ValueError( + f"API key for provider '{self.llm_provider}' is required. " + f"Set the corresponding environment variable." + ) + return self diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..082e6c6 --- /dev/null +++ b/backend/app/db.py @@ -0,0 +1,61 @@ +"""Database connection pool and PostgresSaver checkpoint setup.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver +from psycopg.rows import dict_row +from psycopg_pool import AsyncConnectionPool + +if TYPE_CHECKING: + from app.config import Settings + +_CONVERSATIONS_DDL = """ +CREATE TABLE IF NOT EXISTS conversations ( + thread_id TEXT PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_activity TIMESTAMPTZ NOT NULL DEFAULT NOW(), + total_tokens INTEGER NOT NULL DEFAULT 0, + total_cost_usd DOUBLE PRECISION NOT NULL DEFAULT 0.0, + status TEXT NOT NULL DEFAULT 'active' +); +""" + +_INTERRUPTS_DDL = """ +CREATE TABLE IF NOT EXISTS active_interrupts ( + interrupt_id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL REFERENCES conversations(thread_id), + action TEXT NOT NULL, + params JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + resolved_at TIMESTAMPTZ, + resolution TEXT +); +""" + + +async def create_pool(settings: Settings) -> AsyncConnectionPool: + """Create an async connection pool with the required psycopg settings.""" + pool = AsyncConnectionPool( + conninfo=settings.database_url, + kwargs={"autocommit": True, "row_factory": dict_row}, + min_size=2, + max_size=10, + ) + await pool.open() + return pool + + +async def create_checkpointer(pool: AsyncConnectionPool) -> AsyncPostgresSaver: + """Create and initialize the LangGraph checkpointer.""" + checkpointer = AsyncPostgresSaver(conn=pool) + await checkpointer.setup() + return checkpointer + + +async def setup_app_tables(pool: AsyncConnectionPool) -> None: + """Create application-specific tables (conversations, active_interrupts).""" + async with pool.connection() as conn: + await conn.execute(_CONVERSATIONS_DDL) + await conn.execute(_INTERRUPTS_DDL) diff --git a/backend/app/graph.py b/backend/app/graph.py new file mode 100644 index 0000000..699b4d8 --- /dev/null +++ b/backend/app/graph.py @@ -0,0 +1,70 @@ +"""LangGraph Supervisor construction -- connects registry, agents, LLM, and persistence.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from langgraph.prebuilt import create_react_agent +from langgraph_supervisor import create_supervisor + +from app.agents import get_tools_by_names + +if TYPE_CHECKING: + from langchain_core.language_models import BaseChatModel + from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver + from langgraph.graph.state import CompiledStateGraph + + from app.registry import AgentRegistry + +SUPERVISOR_PROMPT = ( + "You are a customer support supervisor. " + "Route customer requests to the appropriate agent based on their description. " + "For order status and tracking queries, use the order_lookup agent. " + "For order modifications like cancellations, use the order_actions agent. " + "For anything else, use the fallback agent." +) + + +def build_agent_nodes( + registry: AgentRegistry, + llm: BaseChatModel, +) -> list: + """Create LangGraph react agent nodes from registry configurations.""" + agent_nodes = [] + for agent_config in registry.list_agents(): + tools = get_tools_by_names(agent_config.tools) + + system_prompt = ( + f"You are the {agent_config.name} agent. " + f"Personality: {agent_config.personality.tone}. " + f"{agent_config.description} " + f"Permission level: {agent_config.permission}." + ) + + agent_node = create_react_agent( + model=llm, + tools=tools, + name=agent_config.name, + prompt=system_prompt, + ) + agent_nodes.append(agent_node) + + return agent_nodes + + +def build_graph( + registry: AgentRegistry, + llm: BaseChatModel, + checkpointer: AsyncPostgresSaver, +) -> CompiledStateGraph: + """Build and compile the LangGraph supervisor graph.""" + agent_nodes = build_agent_nodes(registry, llm) + + workflow = create_supervisor( + agent_nodes, + model=llm, + prompt=SUPERVISOR_PROMPT, + output_mode="full_history", + ) + + return workflow.compile(checkpointer=checkpointer) diff --git a/backend/app/llm.py b/backend/app/llm.py new file mode 100644 index 0000000..22a26d1 --- /dev/null +++ b/backend/app/llm.py @@ -0,0 +1,42 @@ +"""LLM provider factory with prompt caching support.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from langchain_core.language_models import BaseChatModel + + from app.config import Settings + + +def create_llm(settings: Settings) -> BaseChatModel: + """Create an LLM instance based on the configured provider.""" + provider = settings.llm_provider + model = settings.llm_model + + if provider == "anthropic": + from langchain_anthropic import ChatAnthropic + + return ChatAnthropic( + model=model, + api_key=settings.anthropic_api_key, + ) + + if provider == "openai": + from langchain_openai import ChatOpenAI + + return ChatOpenAI( + model=model, + api_key=settings.openai_api_key, + ) + + if provider == "google": + from langchain_google_genai import ChatGoogleGenerativeAI + + return ChatGoogleGenerativeAI( + model=model, + google_api_key=settings.google_api_key, + ) + + raise ValueError(f"Unknown LLM provider: '{provider}'. Use 'anthropic', 'openai', or 'google'.") diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..2f64e9e --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,83 @@ +"""FastAPI application entry point.""" + +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager +from pathlib import Path +from typing import TYPE_CHECKING + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.staticfiles import StaticFiles + +from app.callbacks import TokenUsageCallbackHandler +from app.config import Settings +from app.db import create_checkpointer, create_pool, setup_app_tables +from app.graph import build_graph +from app.llm import create_llm +from app.registry import AgentRegistry +from app.session_manager import SessionManager +from app.ws_handler import dispatch_message + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + +logger = logging.getLogger(__name__) + +AGENTS_YAML = Path(__file__).parent.parent / "agents.yaml" +FRONTEND_DIST = Path(__file__).parent.parent.parent / "frontend" / "dist" + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + settings = Settings() + + pool = await create_pool(settings) + checkpointer = await create_checkpointer(pool) + await setup_app_tables(pool) + + registry = AgentRegistry.load(AGENTS_YAML) + llm = create_llm(settings) + graph = build_graph(registry, llm, checkpointer) + session_manager = SessionManager( + session_ttl_seconds=settings.session_ttl_minutes * 60, + ) + + app.state.graph = graph + app.state.session_manager = session_manager + app.state.settings = settings + app.state.pool = pool + + logger.info( + "Smart Support started: %d agents loaded, LLM=%s/%s", + len(registry), + settings.llm_provider, + settings.llm_model, + ) + + yield + + await pool.close() + + +app = FastAPI(title="Smart Support", version="0.1.0", lifespan=lifespan) + + +@app.websocket("/ws") +async def websocket_endpoint(ws: WebSocket) -> None: + await ws.accept() + graph = app.state.graph + session_manager = app.state.session_manager + settings = app.state.settings + callback_handler = TokenUsageCallbackHandler(model_name=settings.llm_model) + + try: + while True: + raw_data = await ws.receive_text() + await dispatch_message(ws, graph, session_manager, callback_handler, raw_data) + except WebSocketDisconnect: + logger.info("WebSocket client disconnected") + + +if FRONTEND_DIST.is_dir(): + app.mount("/", StaticFiles(directory=str(FRONTEND_DIST), html=True), name="frontend") diff --git a/backend/app/registry.py b/backend/app/registry.py new file mode 100644 index 0000000..0b1de0d --- /dev/null +++ b/backend/app/registry.py @@ -0,0 +1,104 @@ +"""YAML Agent registry loader with validation.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +import yaml +from pydantic import BaseModel, field_validator + + +class PersonalityConfig(BaseModel, frozen=True): + tone: str = "professional and helpful" + greeting: str = "Hello! How can I help you today?" + escalation_message: str = "Let me connect you with a human agent." + + +class AgentConfig(BaseModel, frozen=True): + name: str + description: str + permission: Literal["read", "write"] + personality: PersonalityConfig = PersonalityConfig() + tools: list[str] + + @field_validator("name") + @classmethod + def name_not_empty(cls, v: str) -> str: + if not v.strip(): + raise ValueError("Agent name must not be empty") + return v.strip() + + @field_validator("tools") + @classmethod + def tools_not_empty(cls, v: list[str]) -> list[str]: + if not v: + raise ValueError("Agent must have at least one tool") + return v + + +class AgentRegistry: + """Immutable registry of agent configurations loaded from YAML.""" + + def __init__(self, agents: tuple[AgentConfig, ...]) -> None: + self._agents = {agent.name: agent for agent in agents} + + @classmethod + def load(cls, yaml_path: str | Path) -> AgentRegistry: + """Load and validate agent configurations from a YAML file.""" + path = Path(yaml_path) + if not path.exists(): + raise FileNotFoundError(f"Agent config file not found: {path}") + + raw_text = path.read_text(encoding="utf-8") + if not raw_text.strip(): + raise ValueError(f"Agent config file is empty: {path}") + + try: + data = yaml.safe_load(raw_text) + except yaml.YAMLError as exc: + msg = f"Invalid YAML in {path}" + if hasattr(exc, "problem_mark") and exc.problem_mark is not None: + mark = exc.problem_mark + msg += f" at line {mark.line + 1}, column {mark.column + 1}" + raise ValueError(msg) from exc + + if not isinstance(data, dict) or "agents" not in data: + raise ValueError(f"Agent config must have a top-level 'agents' key in {path}") + + raw_agents = data["agents"] + if not isinstance(raw_agents, list) or not raw_agents: + raise ValueError(f"'agents' must be a non-empty list in {path}") + + agents: list[AgentConfig] = [] + seen_names: set[str] = set() + + for i, raw in enumerate(raw_agents): + if not isinstance(raw, dict): + raise ValueError(f"Agent at index {i} must be a mapping in {path}") + try: + agent = AgentConfig(**raw) + except Exception as exc: + raise ValueError(f"Invalid agent config at index {i} in {path}: {exc}") from exc + + if agent.name in seen_names: + raise ValueError(f"Duplicate agent name '{agent.name}' in {path}") + seen_names.add(agent.name) + agents.append(agent) + + return cls(agents=tuple(agents)) + + def get_agent(self, name: str) -> AgentConfig: + if name not in self._agents: + available = ", ".join(sorted(self._agents.keys())) + raise KeyError(f"Agent '{name}' not found. Available: {available}") + return self._agents[name] + + def list_agents(self) -> tuple[AgentConfig, ...]: + return tuple(self._agents.values()) + + def get_agents_by_permission(self, permission: str) -> tuple[AgentConfig, ...]: + return tuple(a for a in self._agents.values() if a.permission == permission) + + def __len__(self) -> int: + return len(self._agents) diff --git a/backend/app/session_manager.py b/backend/app/session_manager.py new file mode 100644 index 0000000..6b02dfb --- /dev/null +++ b/backend/app/session_manager.py @@ -0,0 +1,78 @@ +"""Session TTL management with sliding window and interrupt extension.""" + +from __future__ import annotations + +import time +from dataclasses import dataclass + + +@dataclass(frozen=True) +class SessionState: + thread_id: str + last_activity: float + has_pending_interrupt: bool + + +class SessionManager: + """Manages session TTL with sliding window and interrupt extensions. + + - Each message resets the TTL (sliding window). + - A pending interrupt suspends expiration until resolved. + """ + + def __init__(self, session_ttl_seconds: int = 1800) -> None: + self._session_ttl = session_ttl_seconds + self._sessions: dict[str, SessionState] = {} + + def touch(self, thread_id: str) -> SessionState: + """Update last activity for a session (resets sliding window).""" + existing = self._sessions.get(thread_id) + new_state = SessionState( + thread_id=thread_id, + last_activity=time.time(), + has_pending_interrupt=existing.has_pending_interrupt if existing else False, + ) + self._sessions = {**self._sessions, thread_id: new_state} + return new_state + + def is_expired(self, thread_id: str) -> bool: + """Check if a session has expired.""" + state = self._sessions.get(thread_id) + if state is None: + return True + + if state.has_pending_interrupt: + return False + + elapsed = time.time() - state.last_activity + return elapsed > self._session_ttl + + def extend_for_interrupt(self, thread_id: str) -> SessionState: + """Mark session as having a pending interrupt (suspends TTL).""" + existing = self._sessions.get(thread_id) + if existing is None: + return self.touch(thread_id) + + new_state = SessionState( + thread_id=thread_id, + last_activity=existing.last_activity, + has_pending_interrupt=True, + ) + self._sessions = {**self._sessions, thread_id: new_state} + return new_state + + def resolve_interrupt(self, thread_id: str) -> SessionState: + """Remove interrupt extension and reset activity timer.""" + new_state = SessionState( + thread_id=thread_id, + last_activity=time.time(), + has_pending_interrupt=False, + ) + self._sessions = {**self._sessions, thread_id: new_state} + return new_state + + def get_state(self, thread_id: str) -> SessionState | None: + return self._sessions.get(thread_id) + + def remove(self, thread_id: str) -> None: + self._sessions = {k: v for k, v in self._sessions.items() if k != thread_id} diff --git a/backend/app/ws_handler.py b/backend/app/ws_handler.py new file mode 100644 index 0000000..c501921 --- /dev/null +++ b/backend/app/ws_handler.py @@ -0,0 +1,204 @@ +"""WebSocket message handling logic -- extracted from main for testability.""" + +from __future__ import annotations + +import json +import logging +import re +from typing import TYPE_CHECKING, Any + +from langchain_core.messages import HumanMessage +from langgraph.types import Command + +if TYPE_CHECKING: + from fastapi import WebSocket + from langgraph.graph.state import CompiledStateGraph + + from app.callbacks import TokenUsageCallbackHandler + from app.session_manager import SessionManager + +logger = logging.getLogger(__name__) + +MAX_MESSAGE_SIZE = 32_768 # 32 KB +MAX_CONTENT_LENGTH = 8_000 # characters +THREAD_ID_PATTERN = re.compile(r"^[a-zA-Z0-9\-_]{1,128}$") + + +async def handle_user_message( + ws: WebSocket, + graph: CompiledStateGraph, + session_manager: SessionManager, + callback_handler: TokenUsageCallbackHandler, + thread_id: str, + content: str, +) -> None: + """Process a user message through the graph and stream results back.""" + if session_manager.is_expired(thread_id): + msg = "Session expired. Please start a new conversation." + await _send_json(ws, {"type": "error", "message": msg}) + return + + session_manager.touch(thread_id) + config = {"configurable": {"thread_id": thread_id}, "callbacks": [callback_handler]} + input_msg = {"messages": [HumanMessage(content=content)]} + + try: + async for chunk in graph.astream(input_msg, config=config, stream_mode="messages"): + msg_chunk, metadata = chunk + node = metadata.get("langgraph_node", "") + + if hasattr(msg_chunk, "tool_calls") and msg_chunk.tool_calls: + for tc in msg_chunk.tool_calls: + await _send_json( + ws, + { + "type": "tool_call", + "agent": node, + "tool": tc.get("name", ""), + "args": tc.get("args", {}), + }, + ) + elif hasattr(msg_chunk, "content") and msg_chunk.content: + await _send_json( + ws, + { + "type": "token", + "agent": node, + "content": msg_chunk.content, + }, + ) + + state = await graph.aget_state(config) + if _has_interrupt(state): + interrupt_data = _extract_interrupt(state) + session_manager.extend_for_interrupt(thread_id) + await _send_json( + ws, + { + "type": "interrupt", + "thread_id": thread_id, + **interrupt_data, + }, + ) + else: + await _send_json(ws, {"type": "message_complete", "thread_id": thread_id}) + + except Exception: + logger.exception("Error processing message for thread %s", thread_id) + err = "An error occurred processing your message." + await _send_json(ws, {"type": "error", "message": err}) + + +async def handle_interrupt_response( + ws: WebSocket, + graph: CompiledStateGraph, + session_manager: SessionManager, + callback_handler: TokenUsageCallbackHandler, + thread_id: str, + approved: bool, +) -> None: + """Resume graph execution after interrupt approval/rejection.""" + session_manager.resolve_interrupt(thread_id) + session_manager.touch(thread_id) + + config = {"configurable": {"thread_id": thread_id}, "callbacks": [callback_handler]} + + try: + async for chunk in graph.astream( + Command(resume=approved), + config=config, + stream_mode="messages", + ): + msg_chunk, metadata = chunk + node = metadata.get("langgraph_node", "") + + if hasattr(msg_chunk, "content") and msg_chunk.content: + await _send_json( + ws, + { + "type": "token", + "agent": node, + "content": msg_chunk.content, + }, + ) + + await _send_json(ws, {"type": "message_complete", "thread_id": thread_id}) + + except Exception: + logger.exception("Error resuming interrupt for thread %s", thread_id) + err = "An error occurred processing your response." + await _send_json(ws, {"type": "error", "message": err}) + + +async def dispatch_message( + ws: WebSocket, + graph: CompiledStateGraph, + session_manager: SessionManager, + callback_handler: TokenUsageCallbackHandler, + raw_data: str, +) -> None: + """Parse and route an incoming WebSocket message.""" + if len(raw_data) > MAX_MESSAGE_SIZE: + await _send_json(ws, {"type": "error", "message": "Message too large"}) + return + + try: + data = json.loads(raw_data) + except json.JSONDecodeError: + await _send_json(ws, {"type": "error", "message": "Invalid JSON"}) + return + + msg_type = data.get("type") + thread_id = data.get("thread_id", "") + + if not thread_id: + await _send_json(ws, {"type": "error", "message": "Missing thread_id"}) + return + + if not THREAD_ID_PATTERN.match(thread_id): + await _send_json(ws, {"type": "error", "message": "Invalid thread_id format"}) + return + + if msg_type == "message": + content = data.get("content", "") + if not content: + await _send_json(ws, {"type": "error", "message": "Missing message content"}) + return + if len(content) > MAX_CONTENT_LENGTH: + await _send_json(ws, {"type": "error", "message": "Message content too long"}) + return + await handle_user_message(ws, graph, session_manager, callback_handler, thread_id, content) + + elif msg_type == "interrupt_response": + approved = data.get("approved", False) + await handle_interrupt_response( + ws, graph, session_manager, callback_handler, thread_id, approved + ) + + else: + await _send_json(ws, {"type": "error", "message": "Unknown message type"}) + + +def _has_interrupt(state: Any) -> bool: + """Check if the graph state has a pending interrupt.""" + tasks = getattr(state, "tasks", ()) + return any(getattr(t, "interrupts", ()) for t in tasks) + + +def _extract_interrupt(state: Any) -> dict: + """Extract interrupt data from graph state.""" + for task in getattr(state, "tasks", ()): + for intr in getattr(task, "interrupts", ()): + value = intr.value if hasattr(intr, "value") else {} + if not isinstance(value, dict): + value = {} + return { + "action": value.get("action", "unknown"), + "params": value, + } + return {"action": "unknown", "params": {}} + + +async def _send_json(ws: WebSocket, data: dict) -> None: + """Send a JSON message through the WebSocket.""" + await ws.send_json(data) diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..bc89701 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,64 @@ +[project] +name = "smart-support" +version = "0.1.0" +description = "AI customer support action-layer framework" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115,<1.0", + "uvicorn[standard]>=0.34,<1.0", + "langgraph>=0.4,<1.0", + "langgraph-supervisor>=0.0.12,<1.0", + "langgraph-checkpoint-postgres>=3.0,<4.0", + "langchain-core>=0.3,<1.0", + "langchain-anthropic>=0.3,<2.0", + "langchain-openai>=0.3,<1.0", + "langchain-google-genai>=2.1,<3.0", + "psycopg[binary,pool]>=3.2,<4.0", + "pydantic>=2.10,<3.0", + "pydantic-settings>=2.7,<3.0", + "pyyaml>=6.0,<7.0", + "python-dotenv>=1.0,<2.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3,<9.0", + "pytest-asyncio>=0.25,<1.0", + "pytest-cov>=6.0,<7.0", + "httpx>=0.28,<1.0", + "ruff>=0.9,<1.0", +] + +[build-system] +requires = ["setuptools>=75.0"] +build-backend = "setuptools.build_meta" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +markers = [ + "unit: per-module isolated tests", + "integration: cross-module with real PostgreSQL", + "e2e: full-stack user flow tests", +] +addopts = "--strict-markers" + +[tool.coverage.run] +source = ["app"] + +[tool.coverage.report] +fail_under = 80 +show_missing = true + +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP", "B", "A", "SIM", "TCH"] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["N806", "B017"] + +[tool.ruff.format] +quote-style = "double" diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..a588adf --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,69 @@ +"""Shared test fixtures and marker registration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import yaml + +from app.config import Settings +from app.registry import AgentRegistry +from app.session_manager import SessionManager + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.fixture +def test_settings() -> Settings: + return Settings( + database_url="postgresql://test:test@localhost:5432/test_db", + llm_provider="anthropic", + llm_model="claude-sonnet-4-6", + anthropic_api_key="test-key", + ) + + +@pytest.fixture +def sample_yaml_path(tmp_path: Path) -> Path: + data = { + "agents": [ + { + "name": "test_reader", + "description": "A test read agent", + "permission": "read", + "tools": ["get_order_status"], + }, + { + "name": "test_writer", + "description": "A test write agent", + "permission": "write", + "personality": { + "tone": "formal", + "greeting": "Greetings.", + "escalation_message": "Escalating now.", + }, + "tools": ["cancel_order"], + }, + { + "name": "test_fallback", + "description": "A fallback agent", + "permission": "read", + "tools": ["fallback_respond"], + }, + ] + } + path = tmp_path / "test_agents.yaml" + path.write_text(yaml.dump(data), encoding="utf-8") + return path + + +@pytest.fixture +def sample_registry(sample_yaml_path: Path) -> AgentRegistry: + return AgentRegistry.load(sample_yaml_path) + + +@pytest.fixture +def session_manager() -> SessionManager: + return SessionManager(session_ttl_seconds=60) diff --git a/backend/tests/e2e/__init__.py b/backend/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/integration/__init__.py b/backend/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/unit/__init__.py b/backend/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/unit/test_agents.py b/backend/tests/unit/test_agents.py new file mode 100644 index 0000000..17ace97 --- /dev/null +++ b/backend/tests/unit/test_agents.py @@ -0,0 +1,82 @@ +"""Tests for agent tools.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from app.agents.fallback import fallback_respond +from app.agents.order_lookup import get_order_status, get_tracking_info + + +@pytest.mark.unit +class TestOrderLookup: + def test_get_order_status_existing(self) -> None: + result = get_order_status.invoke({"order_id": "1042"}) + assert result["order_id"] == "1042" + assert result["status"] == "shipped" + + def test_get_order_status_not_found(self) -> None: + result = get_order_status.invoke({"order_id": "9999"}) + assert "error" in result + assert "9999" in result["error"] + + def test_get_tracking_info_existing(self) -> None: + result = get_tracking_info.invoke({"order_id": "1042"}) + assert result["carrier"] == "FedEx" + assert result["tracking_number"] == "FX-9876543210" + + def test_get_tracking_info_not_found(self) -> None: + result = get_tracking_info.invoke({"order_id": "1043"}) + assert "error" in result + + def test_all_mock_orders_have_required_fields(self) -> None: + from app.agents.order_lookup import MOCK_ORDERS + + for oid, order in MOCK_ORDERS.items(): + assert "order_id" in order + assert "status" in order + assert order["order_id"] == oid + + +@pytest.mark.unit +class TestFallback: + def test_fallback_respond_returns_help(self) -> None: + result = fallback_respond.invoke({"query": "random question"}) + assert "order" in result.lower() + assert "help" in result.lower() or "can do" in result.lower() + + +@pytest.mark.unit +class TestOrderActions: + def test_cancel_order_approved(self) -> None: + with patch("app.agents.order_actions.interrupt", return_value=True): + from app.agents.order_actions import cancel_order + + result = cancel_order.invoke({"order_id": "1042"}) + assert result["status"] == "cancelled" + assert "1042" in result["message"] + + def test_cancel_order_rejected(self) -> None: + with patch("app.agents.order_actions.interrupt", return_value=False): + from app.agents.order_actions import cancel_order + + result = cancel_order.invoke({"order_id": "1042"}) + assert result["status"] == "kept" + assert "declined" in result["message"] + + +@pytest.mark.unit +class TestToolBridge: + def test_get_tools_by_names(self) -> None: + from app.agents import get_tools_by_names + + tools = get_tools_by_names(["get_order_status", "cancel_order"]) + assert len(tools) == 2 + + def test_unknown_tool_raises(self) -> None: + from app.agents import get_tools_by_names + + with pytest.raises(ValueError, match="Unknown tool"): + get_tools_by_names(["nonexistent_tool"]) diff --git a/backend/tests/unit/test_callbacks.py b/backend/tests/unit/test_callbacks.py new file mode 100644 index 0000000..ca7d4a3 --- /dev/null +++ b/backend/tests/unit/test_callbacks.py @@ -0,0 +1,102 @@ +"""Tests for app.callbacks module.""" + +from __future__ import annotations + +import pytest + +from app.callbacks import TokenUsageCallbackHandler + + +@pytest.mark.unit +class TestTokenUsageCallbackHandler: + def test_initial_state(self) -> None: + handler = TokenUsageCallbackHandler(model_name="claude-sonnet-4-6") + usage = handler.get_usage() + assert usage.prompt_tokens == 0 + assert usage.completion_tokens == 0 + assert usage.total_tokens == 0 + assert usage.total_cost_usd == 0.0 + + def test_accumulates_tokens(self) -> None: + handler = TokenUsageCallbackHandler(model_name="claude-sonnet-4-6") + + class FakeResult: + llm_output = {"token_usage": {"prompt_tokens": 100, "completion_tokens": 50}} + + handler.on_llm_end(FakeResult()) + usage = handler.get_usage() + assert usage.prompt_tokens == 100 + assert usage.completion_tokens == 50 + assert usage.total_tokens == 150 + + def test_accumulates_across_calls(self) -> None: + handler = TokenUsageCallbackHandler(model_name="claude-sonnet-4-6") + + class FakeResult: + llm_output = {"token_usage": {"prompt_tokens": 100, "completion_tokens": 50}} + + handler.on_llm_end(FakeResult()) + handler.on_llm_end(FakeResult()) + usage = handler.get_usage() + assert usage.prompt_tokens == 200 + assert usage.completion_tokens == 100 + assert usage.total_tokens == 300 + + def test_cost_calculation(self) -> None: + handler = TokenUsageCallbackHandler(model_name="claude-sonnet-4-6") + + class FakeResult: + llm_output = {"token_usage": {"prompt_tokens": 1000, "completion_tokens": 1000}} + + handler.on_llm_end(FakeResult()) + usage = handler.get_usage() + # claude-sonnet-4-6: prompt $0.003/1K, completion $0.015/1K + expected_cost = 1000 * 0.003 / 1000 + 1000 * 0.015 / 1000 + assert usage.total_cost_usd == pytest.approx(expected_cost) + + def test_reset(self) -> None: + handler = TokenUsageCallbackHandler(model_name="claude-sonnet-4-6") + + class FakeResult: + llm_output = {"token_usage": {"prompt_tokens": 100, "completion_tokens": 50}} + + handler.on_llm_end(FakeResult()) + handler.reset() + usage = handler.get_usage() + assert usage.total_tokens == 0 + + def test_usage_is_immutable(self) -> None: + handler = TokenUsageCallbackHandler() + usage = handler.get_usage() + with pytest.raises(Exception): + usage.prompt_tokens = 999 + + def test_unknown_model_uses_default_cost(self) -> None: + handler = TokenUsageCallbackHandler(model_name="unknown-model") + + class FakeResult: + llm_output = {"token_usage": {"prompt_tokens": 1000, "completion_tokens": 1000}} + + handler.on_llm_end(FakeResult()) + usage = handler.get_usage() + assert usage.total_cost_usd > 0 + + def test_handles_missing_token_usage(self) -> None: + handler = TokenUsageCallbackHandler() + + class FakeResult: + llm_output = {} + + handler.on_llm_end(FakeResult()) + usage = handler.get_usage() + assert usage.total_tokens == 0 + + def test_handles_none_llm_output(self) -> None: + handler = TokenUsageCallbackHandler() + + class FakeResult: + llm_output = None + + handler.on_llm_end(FakeResult()) + usage = handler.get_usage() + assert usage.total_tokens == 0 diff --git a/backend/tests/unit/test_config.py b/backend/tests/unit/test_config.py new file mode 100644 index 0000000..824464e --- /dev/null +++ b/backend/tests/unit/test_config.py @@ -0,0 +1,60 @@ +"""Tests for app.config module.""" + +from __future__ import annotations + +import pytest + +from app.config import Settings + + +@pytest.mark.unit +class TestSettings: + def test_default_values(self) -> None: + settings = Settings( + database_url="postgresql://x:x@localhost/db", + anthropic_api_key="key", + ) + assert settings.llm_provider == "anthropic" + assert settings.llm_model == "claude-sonnet-4-6" + assert settings.session_ttl_minutes == 30 + assert settings.interrupt_ttl_minutes == 30 + + def test_custom_values(self) -> None: + settings = Settings( + database_url="postgresql://x:x@localhost/db", + llm_provider="openai", + llm_model="gpt-4o", + session_ttl_minutes=15, + openai_api_key="sk-test", + ) + assert settings.llm_provider == "openai" + assert settings.llm_model == "gpt-4o" + assert settings.session_ttl_minutes == 15 + + def test_invalid_provider_rejected(self) -> None: + with pytest.raises(Exception): + Settings( + database_url="postgresql://x:x@localhost/db", + llm_provider="invalid", + ) + + def test_missing_database_url_rejected(self) -> None: + with pytest.raises(Exception): + Settings(anthropic_api_key="key") + + def test_empty_api_key_for_provider_rejected(self) -> None: + with pytest.raises(ValueError, match="API key"): + Settings( + database_url="postgresql://x:x@localhost/db", + llm_provider="anthropic", + anthropic_api_key="", + ) + + def test_wrong_provider_key_rejected(self) -> None: + with pytest.raises(ValueError, match="API key"): + Settings( + database_url="postgresql://x:x@localhost/db", + llm_provider="openai", + anthropic_api_key="key", + openai_api_key="", + ) diff --git a/backend/tests/unit/test_db.py b/backend/tests/unit/test_db.py new file mode 100644 index 0000000..6eaff60 --- /dev/null +++ b/backend/tests/unit/test_db.py @@ -0,0 +1,64 @@ +"""Tests for app.db module.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.config import Settings +from app.db import _CONVERSATIONS_DDL, _INTERRUPTS_DDL + + +@pytest.mark.unit +class TestDbModule: + @pytest.mark.asyncio + async def test_create_pool_sets_correct_params(self) -> None: + settings = Settings( + database_url="postgresql://user:pass@localhost:5432/testdb", + anthropic_api_key="key", + ) + with patch("app.db.AsyncConnectionPool") as MockPool: + mock_pool = AsyncMock() + MockPool.return_value = mock_pool + + from app.db import create_pool + + await create_pool(settings) + MockPool.assert_called_once() + call_kwargs = MockPool.call_args + assert "postgresql://user:pass@localhost:5432/testdb" in str(call_kwargs) + mock_pool.open.assert_awaited_once() + + @pytest.mark.asyncio + async def test_create_checkpointer_calls_setup(self) -> None: + mock_pool = AsyncMock() + with patch("app.db.AsyncPostgresSaver") as MockSaver: + mock_saver = AsyncMock() + MockSaver.return_value = mock_saver + + from app.db import create_checkpointer + + await create_checkpointer(mock_pool) + MockSaver.assert_called_once_with(conn=mock_pool) + mock_saver.setup.assert_awaited_once() + + @pytest.mark.asyncio + async def test_setup_app_tables_executes_ddl(self) -> None: + mock_conn = AsyncMock() + mock_ctx = AsyncMock() + mock_ctx.__aenter__ = AsyncMock(return_value=mock_conn) + mock_ctx.__aexit__ = AsyncMock(return_value=None) + mock_pool = MagicMock() + mock_pool.connection.return_value = mock_ctx + + from app.db import setup_app_tables + + await setup_app_tables(mock_pool) + assert mock_conn.execute.await_count == 2 + + def test_ddl_statements_valid(self) -> None: + assert "CREATE TABLE IF NOT EXISTS conversations" in _CONVERSATIONS_DDL + assert "CREATE TABLE IF NOT EXISTS active_interrupts" in _INTERRUPTS_DDL + assert "thread_id" in _CONVERSATIONS_DDL + assert "interrupt_id" in _INTERRUPTS_DDL diff --git a/backend/tests/unit/test_graph.py b/backend/tests/unit/test_graph.py new file mode 100644 index 0000000..7e0a890 --- /dev/null +++ b/backend/tests/unit/test_graph.py @@ -0,0 +1,44 @@ +"""Tests for app.graph module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.graph import SUPERVISOR_PROMPT, build_agent_nodes, build_graph + +if TYPE_CHECKING: + from app.registry import AgentRegistry + + +@pytest.mark.unit +class TestBuildAgentNodes: + def test_creates_correct_number_of_nodes(self, sample_registry: AgentRegistry) -> None: + mock_llm = MagicMock() + nodes = build_agent_nodes(sample_registry, mock_llm) + assert len(nodes) == 3 + + def test_nodes_are_runnable(self, sample_registry: AgentRegistry) -> None: + mock_llm = MagicMock() + nodes = build_agent_nodes(sample_registry, mock_llm) + for node in nodes: + assert hasattr(node, "invoke") or hasattr(node, "ainvoke") + + +@pytest.mark.unit +class TestBuildGraph: + def test_graph_compiles_with_mock_checkpointer(self, sample_registry: AgentRegistry) -> None: + mock_llm = MagicMock() + mock_llm.bind_tools = MagicMock(return_value=mock_llm) + mock_llm.with_structured_output = MagicMock(return_value=mock_llm) + mock_checkpointer = AsyncMock() + + graph = build_graph(sample_registry, mock_llm, mock_checkpointer) + assert graph is not None + + def test_supervisor_prompt_contains_routing_info(self) -> None: + assert "order_lookup" in SUPERVISOR_PROMPT + assert "order_actions" in SUPERVISOR_PROMPT + assert "fallback" in SUPERVISOR_PROMPT diff --git a/backend/tests/unit/test_llm.py b/backend/tests/unit/test_llm.py new file mode 100644 index 0000000..c442869 --- /dev/null +++ b/backend/tests/unit/test_llm.py @@ -0,0 +1,41 @@ +"""Tests for app.llm module.""" + +from __future__ import annotations + +import pytest + +from app.config import Settings +from app.llm import create_llm + + +@pytest.mark.unit +class TestCreateLlm: + def test_anthropic_provider(self) -> None: + settings = Settings( + database_url="postgresql://x:x@localhost/db", + llm_provider="anthropic", + llm_model="claude-sonnet-4-6", + anthropic_api_key="test-key", + ) + llm = create_llm(settings) + assert type(llm).__name__ == "ChatAnthropic" + + def test_openai_provider(self) -> None: + settings = Settings( + database_url="postgresql://x:x@localhost/db", + llm_provider="openai", + llm_model="gpt-4o", + openai_api_key="sk-test", + ) + llm = create_llm(settings) + assert type(llm).__name__ == "ChatOpenAI" + + def test_google_provider(self) -> None: + settings = Settings( + database_url="postgresql://x:x@localhost/db", + llm_provider="google", + llm_model="gemini-pro", + google_api_key="test-key", + ) + llm = create_llm(settings) + assert type(llm).__name__ == "ChatGoogleGenerativeAI" diff --git a/backend/tests/unit/test_main.py b/backend/tests/unit/test_main.py new file mode 100644 index 0000000..cc73634 --- /dev/null +++ b/backend/tests/unit/test_main.py @@ -0,0 +1,27 @@ +"""Tests for app.main module.""" + +from __future__ import annotations + +import pytest + +from app.main import AGENTS_YAML, FRONTEND_DIST, app + + +@pytest.mark.unit +class TestMainModule: + def test_app_title(self) -> None: + assert app.title == "Smart Support" + + def test_app_version(self) -> None: + assert app.version == "0.1.0" + + def test_agents_yaml_path_exists(self) -> None: + assert AGENTS_YAML.name == "agents.yaml" + + def test_frontend_dist_path(self) -> None: + assert "frontend" in str(FRONTEND_DIST) + assert "dist" in str(FRONTEND_DIST) + + def test_websocket_route_registered(self) -> None: + routes = [r.path for r in app.routes if hasattr(r, "path")] + assert "/ws" in routes diff --git a/backend/tests/unit/test_registry.py b/backend/tests/unit/test_registry.py new file mode 100644 index 0000000..477f420 --- /dev/null +++ b/backend/tests/unit/test_registry.py @@ -0,0 +1,147 @@ +"""Tests for app.registry module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import yaml + +from app.registry import AgentConfig, AgentRegistry, PersonalityConfig + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.mark.unit +class TestPersonalityConfig: + def test_defaults(self) -> None: + p = PersonalityConfig() + assert p.tone == "professional and helpful" + assert "Hello" in p.greeting + assert "human agent" in p.escalation_message + + def test_custom_values(self) -> None: + p = PersonalityConfig(tone="casual", greeting="Hey!", escalation_message="Hold on.") + assert p.tone == "casual" + + def test_immutable(self) -> None: + p = PersonalityConfig() + with pytest.raises(Exception): + p.tone = "new tone" + + +@pytest.mark.unit +class TestAgentConfig: + def test_valid_config(self) -> None: + ac = AgentConfig( + name="test", + description="A test agent", + permission="read", + tools=["tool1"], + ) + assert ac.name == "test" + assert ac.permission == "read" + + def test_empty_name_rejected(self) -> None: + with pytest.raises(ValueError, match="must not be empty"): + AgentConfig(name=" ", description="d", permission="read", tools=["t"]) + + def test_empty_tools_rejected(self) -> None: + with pytest.raises(ValueError, match="at least one tool"): + AgentConfig(name="x", description="d", permission="read", tools=[]) + + def test_invalid_permission_rejected(self) -> None: + with pytest.raises(Exception): + AgentConfig(name="x", description="d", permission="admin", tools=["t"]) + + def test_name_stripped(self) -> None: + ac = AgentConfig(name=" test ", description="d", permission="read", tools=["t"]) + assert ac.name == "test" + + def test_immutable(self) -> None: + ac = AgentConfig(name="test", description="d", permission="read", tools=["t"]) + with pytest.raises(Exception): + ac.name = "new" + + +@pytest.mark.unit +class TestAgentRegistry: + def test_load_valid_yaml(self, sample_yaml_path: Path) -> None: + registry = AgentRegistry.load(sample_yaml_path) + assert len(registry) == 3 + assert registry.get_agent("test_reader").permission == "read" + assert registry.get_agent("test_writer").permission == "write" + + def test_list_agents(self, sample_registry: AgentRegistry) -> None: + agents = sample_registry.list_agents() + assert len(agents) == 3 + names = {a.name for a in agents} + assert names == {"test_reader", "test_writer", "test_fallback"} + + def test_get_agents_by_permission(self, sample_registry: AgentRegistry) -> None: + readers = sample_registry.get_agents_by_permission("read") + assert len(readers) == 2 + writers = sample_registry.get_agents_by_permission("write") + assert len(writers) == 1 + + def test_get_nonexistent_agent(self, sample_registry: AgentRegistry) -> None: + with pytest.raises(KeyError, match="not found"): + sample_registry.get_agent("nonexistent") + + def test_personality_defaults_applied(self, sample_registry: AgentRegistry) -> None: + agent = sample_registry.get_agent("test_reader") + assert agent.personality.tone == "professional and helpful" + + def test_personality_custom_applied(self, sample_registry: AgentRegistry) -> None: + agent = sample_registry.get_agent("test_writer") + assert agent.personality.tone == "formal" + assert agent.personality.greeting == "Greetings." + + def test_file_not_found(self) -> None: + with pytest.raises(FileNotFoundError): + AgentRegistry.load("/nonexistent/path.yaml") + + def test_empty_file(self, tmp_path: Path) -> None: + path = tmp_path / "empty.yaml" + path.write_text("", encoding="utf-8") + with pytest.raises(ValueError, match="empty"): + AgentRegistry.load(path) + + def test_invalid_yaml_syntax(self, tmp_path: Path) -> None: + path = tmp_path / "bad.yaml" + path.write_text("agents:\n - name: [invalid\n", encoding="utf-8") + with pytest.raises(ValueError, match="Invalid YAML"): + AgentRegistry.load(path) + + def test_missing_agents_key(self, tmp_path: Path) -> None: + path = tmp_path / "no_agents.yaml" + path.write_text(yaml.dump({"items": []}), encoding="utf-8") + with pytest.raises(ValueError, match="agents"): + AgentRegistry.load(path) + + def test_duplicate_agent_names(self, tmp_path: Path) -> None: + data = { + "agents": [ + {"name": "dup", "description": "a", "permission": "read", "tools": ["t1"]}, + {"name": "dup", "description": "b", "permission": "read", "tools": ["t2"]}, + ] + } + path = tmp_path / "dups.yaml" + path.write_text(yaml.dump(data), encoding="utf-8") + with pytest.raises(ValueError, match="Duplicate"): + AgentRegistry.load(path) + + def test_missing_required_fields(self, tmp_path: Path) -> None: + data = {"agents": [{"name": "x"}]} + path = tmp_path / "missing.yaml" + path.write_text(yaml.dump(data), encoding="utf-8") + with pytest.raises(ValueError, match="Invalid agent config"): + AgentRegistry.load(path) + + def test_empty_agents_list(self, tmp_path: Path) -> None: + data = {"agents": []} + path = tmp_path / "empty_list.yaml" + path.write_text(yaml.dump(data), encoding="utf-8") + with pytest.raises(ValueError, match="non-empty"): + AgentRegistry.load(path) diff --git a/backend/tests/unit/test_session_manager.py b/backend/tests/unit/test_session_manager.py new file mode 100644 index 0000000..2e126b9 --- /dev/null +++ b/backend/tests/unit/test_session_manager.py @@ -0,0 +1,70 @@ +"""Tests for app.session_manager module.""" + +from __future__ import annotations + +import time +from unittest.mock import patch + +import pytest + +from app.session_manager import SessionManager + + +@pytest.mark.unit +class TestSessionManager: + def test_new_session_not_expired(self, session_manager: SessionManager) -> None: + session_manager.touch("thread-1") + assert not session_manager.is_expired("thread-1") + + def test_unknown_session_is_expired(self, session_manager: SessionManager) -> None: + assert session_manager.is_expired("unknown") + + def test_session_expires_after_ttl(self) -> None: + mgr = SessionManager(session_ttl_seconds=1) + mgr.touch("t1") + with patch("app.session_manager.time") as mock_time: + mock_time.time.return_value = time.time() + 2 + assert mgr.is_expired("t1") + + def test_touch_resets_ttl(self) -> None: + mgr = SessionManager(session_ttl_seconds=5) + mgr.touch("t1") + initial_state = mgr.get_state("t1") + # Touch again after some time + with patch("app.session_manager.time") as mock_time: + mock_time.time.return_value = time.time() + 3 + mgr.touch("t1") + new_state = mgr.get_state("t1") + assert new_state.last_activity > initial_state.last_activity + + def test_interrupt_suspends_expiration(self) -> None: + mgr = SessionManager(session_ttl_seconds=1) + mgr.touch("t1") + mgr.extend_for_interrupt("t1") + with patch("app.session_manager.time") as mock_time: + mock_time.time.return_value = time.time() + 100 + assert not mgr.is_expired("t1") + + def test_resolve_interrupt_resumes_ttl(self) -> None: + mgr = SessionManager(session_ttl_seconds=1) + mgr.touch("t1") + mgr.extend_for_interrupt("t1") + mgr.resolve_interrupt("t1") + state = mgr.get_state("t1") + assert not state.has_pending_interrupt + + def test_extend_for_nonexistent_creates_session(self) -> None: + mgr = SessionManager() + mgr.extend_for_interrupt("new-thread") + state = mgr.get_state("new-thread") + assert state is not None + + def test_remove_session(self, session_manager: SessionManager) -> None: + session_manager.touch("t1") + session_manager.remove("t1") + assert session_manager.get_state("t1") is None + + def test_session_state_is_immutable(self, session_manager: SessionManager) -> None: + state = session_manager.touch("t1") + with pytest.raises(Exception): + state.thread_id = "new" diff --git a/backend/tests/unit/test_ws_handler.py b/backend/tests/unit/test_ws_handler.py new file mode 100644 index 0000000..ff8eb8a --- /dev/null +++ b/backend/tests/unit/test_ws_handler.py @@ -0,0 +1,233 @@ +"""Tests for app.ws_handler module.""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.callbacks import TokenUsageCallbackHandler +from app.session_manager import SessionManager +from app.ws_handler import ( + _extract_interrupt, + _has_interrupt, + dispatch_message, + handle_interrupt_response, + handle_user_message, +) + + +def _make_ws() -> AsyncMock: + ws = AsyncMock() + ws.send_json = AsyncMock() + return ws + + +def _make_graph() -> AsyncMock: + graph = AsyncMock() + graph.astream = MagicMock(return_value=AsyncIterHelper([])) + state = MagicMock() + state.tasks = () + graph.aget_state = AsyncMock(return_value=state) + return graph + + +class AsyncIterHelper: + """Helper to make a list behave as an async iterator.""" + + def __init__(self, items): + self._items = items + + def __aiter__(self): + return self + + async def __anext__(self): + if not self._items: + raise StopAsyncIteration + return self._items.pop(0) + + +@pytest.mark.unit +class TestDispatchMessage: + @pytest.mark.asyncio + async def test_invalid_json(self) -> None: + ws = _make_ws() + graph = _make_graph() + sm = SessionManager() + cb = TokenUsageCallbackHandler() + + await dispatch_message(ws, graph, sm, cb, "not json") + ws.send_json.assert_awaited_once() + call_data = ws.send_json.call_args[0][0] + assert call_data["type"] == "error" + assert "Invalid JSON" in call_data["message"] + + @pytest.mark.asyncio + async def test_missing_thread_id(self) -> None: + ws = _make_ws() + graph = _make_graph() + sm = SessionManager() + cb = TokenUsageCallbackHandler() + + msg = json.dumps({"type": "message", "content": "hello"}) + await dispatch_message(ws, graph, sm, cb, msg) + call_data = ws.send_json.call_args[0][0] + assert call_data["type"] == "error" + assert "thread_id" in call_data["message"] + + @pytest.mark.asyncio + async def test_missing_content(self) -> None: + ws = _make_ws() + graph = _make_graph() + sm = SessionManager() + cb = TokenUsageCallbackHandler() + + msg = json.dumps({"type": "message", "thread_id": "t1"}) + await dispatch_message(ws, graph, sm, cb, msg) + call_data = ws.send_json.call_args[0][0] + assert call_data["type"] == "error" + + @pytest.mark.asyncio + async def test_unknown_message_type(self) -> None: + ws = _make_ws() + graph = _make_graph() + sm = SessionManager() + cb = TokenUsageCallbackHandler() + + msg = json.dumps({"type": "unknown", "thread_id": "t1"}) + await dispatch_message(ws, graph, sm, cb, msg) + call_data = ws.send_json.call_args[0][0] + assert call_data["type"] == "error" + assert "Unknown" in call_data["message"] + # Verify raw input is NOT reflected back + assert "unknown" not in call_data["message"].lower().replace("unknown message type", "") + + @pytest.mark.asyncio + async def test_message_too_large(self) -> None: + ws = _make_ws() + graph = _make_graph() + sm = SessionManager() + cb = TokenUsageCallbackHandler() + + large_msg = "x" * 40_000 + await dispatch_message(ws, graph, sm, cb, large_msg) + call_data = ws.send_json.call_args[0][0] + assert call_data["type"] == "error" + assert "too large" in call_data["message"].lower() + + @pytest.mark.asyncio + async def test_invalid_thread_id_format(self) -> None: + ws = _make_ws() + graph = _make_graph() + sm = SessionManager() + cb = TokenUsageCallbackHandler() + + msg = json.dumps({"type": "message", "thread_id": "../../../etc", "content": "hi"}) + await dispatch_message(ws, graph, sm, cb, msg) + call_data = ws.send_json.call_args[0][0] + assert call_data["type"] == "error" + assert "thread_id" in call_data["message"].lower() + + @pytest.mark.asyncio + async def test_content_too_long(self) -> None: + ws = _make_ws() + graph = _make_graph() + sm = SessionManager() + cb = TokenUsageCallbackHandler() + + msg = json.dumps({"type": "message", "thread_id": "t1", "content": "x" * 9000}) + await dispatch_message(ws, graph, sm, cb, msg) + call_data = ws.send_json.call_args[0][0] + assert call_data["type"] == "error" + assert "too long" in call_data["message"].lower() + + +@pytest.mark.unit +class TestHandleUserMessage: + @pytest.mark.asyncio + async def test_expired_session(self) -> None: + ws = _make_ws() + graph = _make_graph() + sm = SessionManager(session_ttl_seconds=0) + cb = TokenUsageCallbackHandler() + + await handle_user_message(ws, graph, sm, cb, "t1", "hello") + call_data = ws.send_json.call_args[0][0] + assert call_data["type"] == "error" + assert "expired" in call_data["message"].lower() + + @pytest.mark.asyncio + async def test_successful_message(self) -> None: + ws = _make_ws() + graph = _make_graph() + sm = SessionManager() + cb = TokenUsageCallbackHandler() + + sm.touch("t1") + await handle_user_message(ws, graph, sm, cb, "t1", "hello") + # Should end with message_complete + last_call = ws.send_json.call_args[0][0] + assert last_call["type"] == "message_complete" + + @pytest.mark.asyncio + async def test_graph_error_sends_error_message(self) -> None: + ws = _make_ws() + graph = AsyncMock() + graph.astream = MagicMock(side_effect=RuntimeError("boom")) + sm = SessionManager() + cb = TokenUsageCallbackHandler() + + sm.touch("t1") + await handle_user_message(ws, graph, sm, cb, "t1", "hello") + call_data = ws.send_json.call_args[0][0] + assert call_data["type"] == "error" + + +@pytest.mark.unit +class TestHandleInterruptResponse: + @pytest.mark.asyncio + async def test_approved_interrupt(self) -> None: + ws = _make_ws() + graph = _make_graph() + sm = SessionManager() + cb = TokenUsageCallbackHandler() + + sm.touch("t1") + sm.extend_for_interrupt("t1") + await handle_interrupt_response(ws, graph, sm, cb, "t1", True) + last_call = ws.send_json.call_args[0][0] + assert last_call["type"] == "message_complete" + + +@pytest.mark.unit +class TestInterruptHelpers: + def test_has_interrupt_false_for_empty_tasks(self) -> None: + state = MagicMock() + state.tasks = () + assert not _has_interrupt(state) + + def test_has_interrupt_true(self) -> None: + interrupt_obj = MagicMock() + interrupt_obj.value = {"action": "cancel"} + task = MagicMock() + task.interrupts = (interrupt_obj,) + state = MagicMock() + state.tasks = (task,) + assert _has_interrupt(state) + + def test_extract_interrupt_data(self) -> None: + interrupt_obj = MagicMock() + interrupt_obj.value = {"action": "cancel_order", "order_id": "1042"} + task = MagicMock() + task.interrupts = (interrupt_obj,) + state = MagicMock() + state.tasks = (task,) + data = _extract_interrupt(state) + assert data["action"] == "cancel_order" + + def test_extract_interrupt_empty(self) -> None: + state = MagicMock() + state.tasks = () + data = _extract_interrupt(state) + assert data["action"] == "unknown" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6232d46 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +services: + postgres: + image: postgres:16 + environment: + POSTGRES_DB: smart_support + POSTGRES_USER: smart_support + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dev_password} + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U smart_support -d smart_support"] + interval: 5s + timeout: 3s + retries: 5 + + backend: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - "8000:8000" + environment: + DATABASE_URL: postgresql://smart_support:${POSTGRES_PASSWORD:-dev_password}@postgres:5432/smart_support + LLM_PROVIDER: ${LLM_PROVIDER:-anthropic} + LLM_MODEL: ${LLM_MODEL:-claude-sonnet-4-6} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + GOOGLE_API_KEY: ${GOOGLE_API_KEY:-} + depends_on: + postgres: + condition: service_healthy + volumes: + - ./backend:/app + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + +volumes: + pgdata: diff --git a/docs/phases/phase-1-dev-log.md b/docs/phases/phase-1-dev-log.md new file mode 100644 index 0000000..32b1838 --- /dev/null +++ b/docs/phases/phase-1-dev-log.md @@ -0,0 +1,88 @@ +# Phase 1: Core Framework -- Development Log + +> Status: IN PROGRESS +> Phase branch: `phase-1/core-framework` +> Date started: 2026-03-30 +> Date completed: -- +> Related plan section: [Phase 1 in DEVELOPMENT-PLAN](../DEVELOPMENT-PLAN.md#phase-1-核心框架-第-1-3-周) + +## What Was Built + +- FastAPI WebSocket backend with `/ws` endpoint for real-time chat +- LangGraph Supervisor (via `langgraph-supervisor`) connecting 3 agents +- YAML-based Agent Registry with Pydantic validation +- 3 Mock Agents: order_lookup (read), order_actions (write + interrupt), fallback +- PostgresSaver checkpoint persistence via `langgraph-checkpoint-postgres` +- Session TTL management with 30-minute sliding window and interrupt extension +- LLM provider abstraction (Anthropic/OpenAI/Google) with prompt caching support +- Token usage tracking callback handler +- React Chat UI with streaming display, interrupt confirmation, and agent action viewer +- Docker Compose configuration (PostgreSQL 16 + backend) + +## Code Structure + +### New files + +Backend (`backend/app/`): +- `config.py` -- pydantic-settings centralized configuration +- `db.py` -- Async PostgreSQL pool + AsyncPostgresSaver setup +- `llm.py` -- LLM provider factory (ChatAnthropic/ChatOpenAI/ChatGoogleGenerativeAI) +- `callbacks.py` -- Token usage + cost tracking callback handler +- `registry.py` -- YAML agent registry with validation + immutable config models +- `session_manager.py` -- Session TTL with sliding window + interrupt extension +- `graph.py` -- LangGraph Supervisor construction from registry +- `ws_handler.py` -- WebSocket message dispatch + streaming logic +- `main.py` -- FastAPI app entry with lifespan + WebSocket endpoint +- `agents/__init__.py` -- Tool name-to-function bridge +- `agents/order_lookup.py` -- Mock order status/tracking tools +- `agents/order_actions.py` -- Mock cancel_order with interrupt() +- `agents/fallback.py` -- Fallback response tool + +Frontend (`frontend/src/`): +- `types.ts` -- WebSocket message protocol TypeScript types +- `hooks/useWebSocket.ts` -- WebSocket connection + reconnect + message dispatch +- `components/ChatMessages.tsx` -- Streaming message display +- `components/ChatInput.tsx` -- Message input +- `components/InterruptPrompt.tsx` -- Approve/reject interrupt UI +- `components/AgentAction.tsx` -- Tool call inline display +- `pages/ChatPage.tsx` -- Main chat page composing all components + +Infrastructure: +- `backend/pyproject.toml` -- Dependencies + pytest + ruff config +- `backend/agents.yaml` -- Agent registry YAML config +- `backend/Dockerfile` -- Backend container +- `docker-compose.yml` -- PostgreSQL 16 + backend services +- `.gitignore` -- Updated for Python + Node artifacts + +Tests (`backend/tests/unit/`): +- `test_config.py` -- Settings validation tests +- `test_registry.py` -- 17 tests for registry loading/validation +- `test_agents.py` -- 10 tests for tool functions + tool bridge +- `test_llm.py` -- 3 tests for LLM provider factory +- `test_callbacks.py` -- 9 tests for token usage tracking +- `test_session_manager.py` -- 9 tests for session TTL logic +- `test_graph.py` -- 4 tests for supervisor construction +- `test_db.py` -- 5 tests for database setup +- `test_ws_handler.py` -- 12 tests for WebSocket message handling +- `test_main.py` -- 5 tests for app configuration + +## Test Coverage + +- Unit test count: 82 +- Integration test count: 0 (requires running PostgreSQL) +- E2E test count: 0 (manual verification in plan) +- Overall coverage: 88% + +## Deviations from Plan + +- Used `astream(stream_mode="messages")` instead of `astream_events()` per langgraph best practices +- Separated WebSocket handler logic into `ws_handler.py` for testability (not in original plan) +- Session manager uses in-memory storage instead of DB-backed (sufficient for Phase 1 single-instance) + +## Known Issues / Tech Debt + +- Session manager not DB-backed (loses state on restart) -- acceptable for Phase 1 single-instance +- WebSocket reconnect does not re-send pending interrupt state from server +- No rate limiting on WebSocket endpoint (Phase 2) +- No authentication (Phase 2) +- `main.py` coverage at 47% -- lifespan function not unit-testable without full DB diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..9f5a3b7 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Smart Support + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..25332ba --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1820 @@ +{ + "name": "smart-support-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "smart-support-frontend", + "version": "0.1.0", + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "~5.7.0", + "vite": "^6.2.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", + "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001782", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", + "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.328", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz", + "integrity": "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1d44778 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "smart-support-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "~5.7.0", + "vite": "^6.2.0" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..9c7272d --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,5 @@ +import { ChatPage } from "./pages/ChatPage"; + +export default function App() { + return ; +} diff --git a/frontend/src/components/AgentAction.tsx b/frontend/src/components/AgentAction.tsx new file mode 100644 index 0000000..7e53a3b --- /dev/null +++ b/frontend/src/components/AgentAction.tsx @@ -0,0 +1,77 @@ +import { useState } from "react"; +import type { ToolAction } from "../types"; + +interface Props { + action: ToolAction; +} + +export function AgentAction({ action }: Props) { + const [expanded, setExpanded] = useState(false); + + return ( +
+
setExpanded(!expanded)}> + {expanded ? "v" : ">"} + {action.agent} + {action.tool} +
+ {expanded && ( +
+
+ Args: +
{JSON.stringify(action.args, null, 2)}
+
+ {action.result !== undefined && ( +
+ Result: +
{JSON.stringify(action.result, null, 2)}
+
+ )} +
+ )} +
+ ); +} + +const styles: Record = { + container: { + margin: "4px 16px", + padding: "6px 10px", + background: "#f5f5f5", + borderRadius: "6px", + fontSize: "12px", + color: "#666", + }, + header: { + display: "flex", + alignItems: "center", + gap: "6px", + cursor: "pointer", + }, + icon: { + fontFamily: "monospace", + width: "12px", + }, + agent: { + fontWeight: 600, + }, + tool: { + color: "#0066cc", + fontFamily: "monospace", + }, + details: { + marginTop: "6px", + paddingLeft: "18px", + }, + section: { + marginBottom: "4px", + }, + code: { + background: "#e8e8e8", + padding: "4px 8px", + borderRadius: "4px", + fontSize: "11px", + overflowX: "auto", + margin: "4px 0", + }, +}; diff --git a/frontend/src/components/ChatInput.tsx b/frontend/src/components/ChatInput.tsx new file mode 100644 index 0000000..f42b8d5 --- /dev/null +++ b/frontend/src/components/ChatInput.tsx @@ -0,0 +1,68 @@ +import { useState } from "react"; + +interface Props { + onSend: (content: string) => void; + disabled: boolean; +} + +export function ChatInput({ onSend, disabled }: Props) { + const [value, setValue] = useState(""); + + const handleSubmit = () => { + const trimmed = value.trim(); + if (!trimmed || disabled) return; + onSend(trimmed); + setValue(""); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }; + + return ( +
+ setValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={disabled ? "Waiting for response..." : "Type a message..."} + disabled={disabled} + style={styles.input} + /> + +
+ ); +} + +const styles: Record = { + container: { + display: "flex", + gap: "8px", + padding: "12px 16px", + borderTop: "1px solid #e0e0e0", + background: "white", + }, + input: { + flex: 1, + padding: "10px 14px", + border: "1px solid #ccc", + borderRadius: "8px", + fontSize: "14px", + outline: "none", + }, + button: { + padding: "10px 20px", + background: "#0066cc", + color: "white", + border: "none", + borderRadius: "8px", + fontSize: "14px", + cursor: "pointer", + }, +}; diff --git a/frontend/src/components/ChatMessages.tsx b/frontend/src/components/ChatMessages.tsx new file mode 100644 index 0000000..db58487 --- /dev/null +++ b/frontend/src/components/ChatMessages.tsx @@ -0,0 +1,82 @@ +import { useEffect, useRef } from "react"; +import type { ChatMessage } from "../types"; + +interface Props { + messages: ChatMessage[]; +} + +export function ChatMessages({ messages }: Props) { + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + return ( +
+ {messages.map((msg) => ( +
+
+ + {msg.sender === "user" ? "You" : msg.agent || "Agent"} + +
+
+ {msg.content} + {msg.isStreaming && |} +
+
+ ))} +
+
+ ); +} + +const styles: Record = { + container: { + flex: 1, + overflowY: "auto", + padding: "16px", + display: "flex", + flexDirection: "column", + gap: "12px", + }, + message: { + maxWidth: "80%", + padding: "10px 14px", + borderRadius: "12px", + lineHeight: 1.5, + }, + userMessage: { + alignSelf: "flex-end", + background: "#0066cc", + color: "white", + }, + agentMessage: { + alignSelf: "flex-start", + background: "#f0f0f0", + color: "#333", + }, + header: { + marginBottom: "4px", + }, + sender: { + fontSize: "12px", + fontWeight: 600, + opacity: 0.8, + }, + content: { + fontSize: "14px", + whiteSpace: "pre-wrap", + }, + cursor: { + animation: "blink 1s infinite", + opacity: 0.7, + }, +}; diff --git a/frontend/src/components/InterruptPrompt.tsx b/frontend/src/components/InterruptPrompt.tsx new file mode 100644 index 0000000..a489045 --- /dev/null +++ b/frontend/src/components/InterruptPrompt.tsx @@ -0,0 +1,81 @@ +import type { InterruptMessage } from "../types"; + +interface Props { + interrupt: InterruptMessage; + onRespond: (approved: boolean) => void; +} + +export function InterruptPrompt({ interrupt, onRespond }: Props) { + return ( +
+
Action Requires Approval
+
+ Action: {interrupt.action} +
+ {"message" in interrupt.params && interrupt.params.message != null && ( +
{String(interrupt.params.message)}
+ )} + {"order_id" in interrupt.params && interrupt.params.order_id != null && ( +
+ Order: {String(interrupt.params.order_id)} +
+ )} +
+ + +
+
+ ); +} + +const styles: Record = { + container: { + margin: "12px 16px", + padding: "16px", + border: "2px solid #ff9800", + borderRadius: "12px", + background: "#fff8e1", + }, + header: { + fontWeight: 700, + fontSize: "14px", + color: "#e65100", + marginBottom: "8px", + }, + action: { + fontSize: "14px", + marginBottom: "4px", + }, + detail: { + fontSize: "13px", + color: "#555", + marginBottom: "4px", + }, + buttons: { + display: "flex", + gap: "8px", + marginTop: "12px", + }, + approveBtn: { + padding: "8px 20px", + background: "#4caf50", + color: "white", + border: "none", + borderRadius: "6px", + cursor: "pointer", + fontWeight: 600, + }, + rejectBtn: { + padding: "8px 20px", + background: "#f44336", + color: "white", + border: "none", + borderRadius: "6px", + cursor: "pointer", + fontWeight: 600, + }, +}; diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..f2aabb3 --- /dev/null +++ b/frontend/src/hooks/useWebSocket.ts @@ -0,0 +1,104 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { + ClientMessage, + ConnectionStatus, + InterruptResponse, + SendMessage, + ServerMessage, +} from "../types"; + +const WS_URL = `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`; +const MAX_RETRIES = 5; +const BASE_DELAY_MS = 1000; + +function getOrCreateThreadId(): string { + const key = "smart_support_thread_id"; + let id = sessionStorage.getItem(key); + if (!id) { + id = crypto.randomUUID(); + sessionStorage.setItem(key, id); + } + return id; +} + +export function useWebSocket(onMessage: (msg: ServerMessage) => void) { + const [status, setStatus] = useState("disconnected"); + const [threadId] = useState(getOrCreateThreadId); + const wsRef = useRef(null); + const retriesRef = useRef(0); + const onMessageRef = useRef(onMessage); + onMessageRef.current = onMessage; + + const connect = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) return; + + setStatus("connecting"); + const ws = new WebSocket(WS_URL); + + ws.onopen = () => { + setStatus("connected"); + retriesRef.current = 0; + }; + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as ServerMessage; + onMessageRef.current(data); + } catch { + // ignore non-JSON messages + } + }; + + ws.onclose = () => { + setStatus("disconnected"); + wsRef.current = null; + + if (retriesRef.current < MAX_RETRIES) { + const delay = BASE_DELAY_MS * Math.pow(2, retriesRef.current); + retriesRef.current += 1; + setTimeout(connect, delay); + } + }; + + ws.onerror = () => { + ws.close(); + }; + + wsRef.current = ws; + }, []); + + useEffect(() => { + connect(); + return () => { + wsRef.current?.close(); + }; + }, [connect]); + + const send = useCallback((msg: ClientMessage) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify(msg)); + } + }, []); + + const sendMessage = useCallback( + (content: string) => { + const msg: SendMessage = { type: "message", thread_id: threadId, content }; + send(msg); + }, + [send, threadId] + ); + + const sendInterruptResponse = useCallback( + (approved: boolean) => { + const msg: InterruptResponse = { + type: "interrupt_response", + thread_id: threadId, + approved, + }; + send(msg); + }, + [send, threadId] + ); + + return { status, threadId, sendMessage, sendInterruptResponse }; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..77d159f --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,9 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; + +createRoot(document.getElementById("root")!).render( + + + +); diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx new file mode 100644 index 0000000..ecd51b8 --- /dev/null +++ b/frontend/src/pages/ChatPage.tsx @@ -0,0 +1,200 @@ +import { useCallback, useState } from "react"; +import { AgentAction } from "../components/AgentAction"; +import { ChatInput } from "../components/ChatInput"; +import { ChatMessages } from "../components/ChatMessages"; +import { InterruptPrompt } from "../components/InterruptPrompt"; +import { useWebSocket } from "../hooks/useWebSocket"; +import type { + ChatMessage, + ConnectionStatus, + InterruptMessage, + ServerMessage, + ToolAction, +} from "../types"; + +let msgCounter = 0; +function nextId(): string { + msgCounter += 1; + return `msg-${msgCounter}`; +} + +export function ChatPage() { + const [messages, setMessages] = useState([]); + const [toolActions, setToolActions] = useState([]); + const [currentInterrupt, setCurrentInterrupt] = useState(null); + const [isWaiting, setIsWaiting] = useState(false); + + const handleServerMessage = useCallback((msg: ServerMessage) => { + switch (msg.type) { + case "token": { + setMessages((prev) => { + const last = prev[prev.length - 1]; + if (last && last.sender === "agent" && last.isStreaming) { + return [ + ...prev.slice(0, -1), + { ...last, content: last.content + msg.content }, + ]; + } + return [ + ...prev, + { + id: nextId(), + sender: "agent", + agent: msg.agent, + content: msg.content, + timestamp: Date.now(), + isStreaming: true, + }, + ]; + }); + break; + } + case "tool_call": { + setToolActions((prev) => [ + ...prev, + { + id: nextId(), + agent: msg.agent, + tool: msg.tool, + args: msg.args, + timestamp: Date.now(), + }, + ]); + break; + } + case "interrupt": { + setCurrentInterrupt(msg); + setIsWaiting(false); + break; + } + case "message_complete": { + setMessages((prev) => { + const last = prev[prev.length - 1]; + if (last && last.isStreaming) { + return [...prev.slice(0, -1), { ...last, isStreaming: false }]; + } + return prev; + }); + setIsWaiting(false); + break; + } + case "error": { + setMessages((prev) => [ + ...prev, + { + id: nextId(), + sender: "agent", + agent: "System", + content: `Error: ${msg.message}`, + timestamp: Date.now(), + }, + ]); + setIsWaiting(false); + break; + } + } + }, []); + + const { status, sendMessage, sendInterruptResponse } = + useWebSocket(handleServerMessage); + + const handleSend = useCallback( + (content: string) => { + setMessages((prev) => [ + ...prev, + { + id: nextId(), + sender: "user", + content, + timestamp: Date.now(), + }, + ]); + setIsWaiting(true); + sendMessage(content); + }, + [sendMessage] + ); + + const handleInterruptResponse = useCallback( + (approved: boolean) => { + sendInterruptResponse(approved); + setCurrentInterrupt(null); + setIsWaiting(true); + }, + [sendInterruptResponse] + ); + + return ( +
+
+

Smart Support

+ +
+ + {toolActions.length > 0 && ( +
+ {toolActions.slice(-3).map((action) => ( + + ))} +
+ )} + {currentInterrupt && ( + + )} + +
+ ); +} + +function StatusIndicator({ status }: { status: ConnectionStatus }) { + const colors: Record = { + connected: "#4caf50", + connecting: "#ff9800", + disconnected: "#f44336", + }; + return ( +
+
+ {status} +
+ ); +} + +const styles: Record = { + page: { + height: "100vh", + display: "flex", + flexDirection: "column", + background: "white", + maxWidth: "800px", + margin: "0 auto", + boxShadow: "0 0 20px rgba(0,0,0,0.1)", + }, + header: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + padding: "12px 16px", + borderBottom: "1px solid #e0e0e0", + }, + title: { + fontSize: "18px", + fontWeight: 700, + margin: 0, + color: "#333", + }, + actionsBar: { + borderTop: "1px solid #eee", + paddingTop: "4px", + }, +}; diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..7394b75 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,86 @@ +/** WebSocket message protocol types matching ARCHITECTURE.md Section 6.1 */ + +// -- Server -> Client messages -- + +export interface TokenMessage { + type: "token"; + agent: string; + content: string; +} + +export interface InterruptMessage { + type: "interrupt"; + thread_id: string; + action: string; + params: Record; +} + +export interface ToolCallMessage { + type: "tool_call"; + agent: string; + tool: string; + args: Record; +} + +export interface ToolResultMessage { + type: "tool_result"; + agent: string; + tool: string; + result: unknown; +} + +export interface MessageCompleteMessage { + type: "message_complete"; + thread_id: string; +} + +export interface ErrorMessage { + type: "error"; + message: string; +} + +export type ServerMessage = + | TokenMessage + | InterruptMessage + | ToolCallMessage + | ToolResultMessage + | MessageCompleteMessage + | ErrorMessage; + +// -- Client -> Server messages -- + +export interface SendMessage { + type: "message"; + thread_id: string; + content: string; +} + +export interface InterruptResponse { + type: "interrupt_response"; + thread_id: string; + approved: boolean; +} + +export type ClientMessage = SendMessage | InterruptResponse; + +// -- UI state -- + +export interface ChatMessage { + id: string; + sender: "user" | "agent"; + agent?: string; + content: string; + timestamp: number; + isStreaming?: boolean; +} + +export interface ToolAction { + id: string; + agent: string; + tool: string; + args: Record; + result?: unknown; + timestamp: number; +} + +export type ConnectionStatus = "connecting" | "connected" | "disconnected"; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..39a405b --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..235d07c --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,15 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + "/ws": { + target: "ws://localhost:8000", + ws: true, + }, + }, + }, +});