diff --git a/.env.example b/.env.example index 2fb3cd2..1a070ed 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,13 @@ APP_HOST=0.0.0.0 APP_PORT=8000 APP_RELOAD=true APP_ORIGIN=http://localhost:5173 +ENABLE_LIVE_LOGS=false +LIVE_LOGS_MAX_ROWS=5000 +KAFKA_BOOTSTRAP_SERVERS=localhost:9092 +KAFKA_LOGS_TOPIC=application-logs +KAFKA_GROUP_ID=ai-observability-agent GROQ_API_KEY= +GOOGLE_API_KEY= PINECONE_API_KEY= PINECONE_INDEX_NAME=ai-observability-agent PINECONE_NAMESPACE=observability-docs @@ -11,4 +17,6 @@ LANGCHAIN_API_KEY= LANGCHAIN_TRACING_V2=true LANGCHAIN_PROJECT=ai-observability-agent EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2 +LLM_PROVIDER=groq GROQ_MODEL=llama-3.1-8b-instant +GOOGLE_MODEL=gemini-3-flash-preview diff --git a/.gitignore b/.gitignore index d7a1e7f..8847f73 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ __pycache__/ node_modules/ dist/ docs +logs/ diff --git a/README.md b/README.md index 8475800..85f2d22 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,25 @@ Small agentic AI project scaffold for learning: uvicorn backend.main:app --reload --app-dir . ``` +The backend now writes structured JSON logs to `logs/backend.log` and mirrors them to stdout. +Each HTTP response also includes an `X-Request-ID` header, and chat responses include observability metadata such as intent, tools used, retrieval source, and per-stage latency. + +To switch LLM providers, configure `.env` like this: + +```bash +LLM_PROVIDER=google +GOOGLE_API_KEY=your_google_api_key +GOOGLE_MODEL=gemini-3-flash-preview +``` + +Groq remains available with: + +```bash +LLM_PROVIDER=groq +GROQ_API_KEY=your_groq_api_key +GROQ_MODEL=llama-3.1-8b-instant +``` + 5. Ingest documents: ```bash @@ -85,8 +104,31 @@ Then verify: ```bash curl http://localhost:8000/api/health +curl http://localhost:8000/api/debug/status ``` +### Runtime modes + +Optional environment flags: + +- `MOCK_MODE=true` disables live LLM usage and keeps responses deterministic for demos. +- `USE_PINECONE=false` forces local document fallback retrieval. +- `ENABLE_MCP=false` disables the MCP server startup path. + +### Evaluation dataset + +Sample regression-style prompts live in: + +- [evaluation/sample_queries.json](/Users/suraj/Documents/Codex/ai-observability-agent/evaluation/sample_queries.json) + +They capture expected intent, tool path, and evidence type so you can measure changes over time. + +### MCP config + +A starter MCP client configuration is included at: + +- [mcp.json](/Users/suraj/Documents/Codex/ai-observability-agent/mcp.json) + ### CI CI runs on pull requests and pushes to `main`: diff --git a/backend/README.md b/backend/README.md index 8aaaded..1b7fb63 100644 --- a/backend/README.md +++ b/backend/README.md @@ -11,3 +11,18 @@ python scripts/test_mcp_client.py --list-tools python scripts/test_mcp_client.py --tool search_logs --args '{"query":"error","limit":3}' python scripts/test_mcp_client.py --tool get_metrics --args '{"service_name":"checkout-service"}' ``` + +## MCP client config + +This repo now includes a starter [mcp.json](/Users/suraj/Documents/Codex/ai-observability-agent/mcp.json) file at the repository root. +It shows one way an MCP-aware client can launch the server with: + +```bash +python -m backend.mcp.server +``` + +If you do not want the MCP server enabled, set: + +```bash +ENABLE_MCP=false +``` diff --git a/backend/agent/graph.py b/backend/agent/graph.py index 50d2c35..f8d92bf 100644 --- a/backend/agent/graph.py +++ b/backend/agent/graph.py @@ -2,6 +2,7 @@ from __future__ import annotations +from time import perf_counter from typing import Any, TypedDict from langchain_groq import ChatGroq @@ -10,6 +11,11 @@ from backend.agent.nodes import classify_query, execute_tools, generate_answer, retrieve_context from backend.config import get_settings +try: + from langchain_google_genai import ChatGoogleGenerativeAI +except ImportError: # pragma: no cover + ChatGoogleGenerativeAI = None + class AgentState(TypedDict, total=False): query: str @@ -17,16 +23,46 @@ class AgentState(TypedDict, total=False): retrieved_context: str tool_context: str answer: str + tools_used: list[str] + retrieval_source: str + retrieval_hit: bool + evidence_found: bool + llm_enabled: bool + error: str | None + stage_latencies_ms: dict[str, float] + total_latency_ms: float -def _build_llm() -> ChatGroq | None: +def _build_llm() -> Any: settings = get_settings() - if not settings.groq_api_key: + if settings.mock_mode: return None - return ChatGroq( - api_key=settings.groq_api_key, - model=settings.groq_model, - temperature=0.2, + + provider = settings.llm_provider.lower() + if provider == "groq": + if not settings.groq_api_key: + return None + return ChatGroq( + api_key=settings.groq_api_key, + model=settings.groq_model, + temperature=0.2, + ) + + if provider == "google": + if ChatGoogleGenerativeAI is None: + raise RuntimeError( + "Install `langchain-google-genai` to use Gemini models." + ) + if not settings.google_api_key: + return None + return ChatGoogleGenerativeAI( + google_api_key=settings.google_api_key, + model=settings.google_model, + temperature=0.2, + ) + + raise RuntimeError( + f"Unsupported LLM_PROVIDER `{settings.llm_provider}`. Use `groq` or `google`." ) @@ -51,5 +87,8 @@ def build_graph() -> Any: def run_agent(query: str) -> dict[str, Any]: """Run the full agent graph for a user query.""" + started_at = perf_counter() app = build_graph() - return app.invoke({"query": query}) + result = app.invoke({"query": query}) + result["total_latency_ms"] = round((perf_counter() - started_at) * 1000, 2) + return result diff --git a/backend/agent/nodes.py b/backend/agent/nodes.py index 11b039d..0dae5a1 100644 --- a/backend/agent/nodes.py +++ b/backend/agent/nodes.py @@ -2,18 +2,54 @@ from __future__ import annotations +from time import perf_counter from typing import Any from langchain_core.messages import HumanMessage, SystemMessage from backend.agent.prompts import ANSWER_PROMPT, SYSTEM_PROMPT +from backend.rag.retriever import get_retrieval_source from backend.tools.doc_tool import retrieve_docs_tool from backend.tools.log_tool import analyze_logs_tool, search_logs_tool from backend.tools.metrics_tool import get_metrics_tool +def _updated_latencies(state: dict[str, Any], stage_name: str, started_at: float) -> dict[str, float]: + latencies = dict(state.get("stage_latencies_ms", {})) + latencies[stage_name] = round((perf_counter() - started_at) * 1000, 2) + return latencies + + +def _normalize_llm_content(content: Any) -> str: + """Flatten provider-specific content blocks into plain text.""" + if isinstance(content, str): + return content + + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict): + if item.get("type") == "text": + parts.append(str(item.get("text", ""))) + else: + parts.append(str(item)) + else: + text_value = getattr(item, "text", None) + parts.append(str(text_value if text_value is not None else item)) + return "\n".join(part for part in parts if part).strip() + + text_value = getattr(content, "text", None) + if text_value is not None: + return str(text_value) + + return str(content) + + def classify_query(state: dict[str, Any]) -> dict[str, Any]: """Route queries to the right tooling using lightweight heuristics.""" + started_at = perf_counter() query = state["query"].lower() if any( keyword in query @@ -34,43 +70,79 @@ def classify_query(state: dict[str, Any]) -> dict[str, Any]: intent = "metrics" else: intent = "docs" - return {"intent": intent} + return { + "intent": intent, + "stage_latencies_ms": _updated_latencies(state, "classify_query", started_at), + } def retrieve_context(state: dict[str, Any]) -> dict[str, Any]: """Fetch top matching RAG documents.""" + started_at = perf_counter() query = state["query"] docs = retrieve_docs_tool.invoke({"query": query, "k": 4}) - return {"retrieved_context": docs} + retrieval_source = get_retrieval_source() + retrieval_hit = "No matching observability documentation found." not in docs + return { + "retrieved_context": docs, + "retrieval_source": retrieval_source, + "retrieval_hit": retrieval_hit, + "stage_latencies_ms": _updated_latencies(state, "retrieve_context", started_at), + } def execute_tools(state: dict[str, Any]) -> dict[str, Any]: """Run intent-specific tools.""" + started_at = perf_counter() query = state["query"] intent = state["intent"] + tools_used: list[str] if intent == "logs": matches = search_logs_tool.invoke({"query": query, "limit": 5}) tool_output = analyze_logs_tool.invoke({"log_text": matches}) + tools_used = ["search_logs", "analyze_logs"] elif intent == "metrics": tool_output = get_metrics_tool.invoke({"service_name": "checkout-service"}) + tools_used = ["get_metrics"] else: tool_output = "Documentation-first query. No runtime tool needed." + tools_used = [] + + evidence_missing_markers = ( + "No sample log file found.", + "No matching log lines found.", + "No matching observability documentation found.", + "No runtime tool needed.", + ) + evidence_found = not any(marker in tool_output for marker in evidence_missing_markers) - return {"tool_context": tool_output} + return { + "tool_context": tool_output, + "tools_used": tools_used, + "evidence_found": evidence_found, + "stage_latencies_ms": _updated_latencies(state, "execute_tools", started_at), + } def generate_answer(state: dict[str, Any], llm: Any) -> dict[str, Any]: """Use the LLM to produce the final answer.""" + started_at = perf_counter() if llm is None: answer = ( f"Diagnosis for `{state['query']}`\n\n" f"Intent: {state['intent']}\n" + f"Tools used: {', '.join(state.get('tools_used', [])) or 'None'}\n" + f"Retrieval source: {state.get('retrieval_source', 'unknown')}\n" f"Evidence: {state.get('tool_context', 'No tool output.')}\n" f"Docs: {state.get('retrieved_context', 'No docs found.')}\n\n" "Next actions: configure `GROQ_API_KEY` to enable the full LLM reasoning step." ) - return {"answer": answer} + return { + "answer": answer, + "llm_enabled": False, + "stage_latencies_ms": _updated_latencies(state, "generate_answer", started_at), + } prompt = ANSWER_PROMPT.format( query=state["query"], @@ -84,5 +156,9 @@ def generate_answer(state: dict[str, Any], llm: Any) -> dict[str, Any]: HumanMessage(content=prompt), ] ) - content = getattr(response, "content", str(response)) - return {"answer": content} + content = _normalize_llm_content(getattr(response, "content", str(response))) + return { + "answer": content, + "llm_enabled": True, + "stage_latencies_ms": _updated_latencies(state, "generate_answer", started_at), + } diff --git a/backend/api/routes.py b/backend/api/routes.py index e17f399..d609d4f 100644 --- a/backend/api/routes.py +++ b/backend/api/routes.py @@ -2,14 +2,88 @@ from __future__ import annotations -from fastapi import APIRouter -from pydantic import BaseModel +import logging +from time import perf_counter +from uuid import uuid4 + +from fastapi import APIRouter, Request +from pydantic import BaseModel, Field from backend.agent.graph import run_agent from backend.config import get_settings router = APIRouter(tags=["agent"]) +logger = logging.getLogger(__name__) + + +def _classify_agent_error(exc: Exception, provider: str, model: str) -> tuple[str, str]: + """Turn provider/network failures into user-friendly responses.""" + message = str(exc).strip() + lowered = message.lower() + + connectivity_markers = ( + "connection error", + "connecterror", + "connect timeout", + "timed out", + "timeout", + "temporary failure in name resolution", + "name or service not known", + "nodename nor servname provided", + "network is unreachable", + "connection refused", + "failed to establish a new connection", + "dns", + ) + auth_markers = ( + "401", + "403", + "unauthorized", + "forbidden", + "invalid api key", + "api key not valid", + "permission denied", + "authentication", + ) + quota_markers = ("429", "quota", "rate limit", "resource exhausted") + + if any(marker in lowered for marker in connectivity_markers): + return ( + ( + f"The agent could not reach the {provider.title()} model `{model}`. " + "This usually means there is no internet connection, DNS resolution failed, " + "or the provider endpoint is temporarily unavailable. Please check network " + "connectivity and retry." + ), + "llm_connection_failed", + ) + + if any(marker in lowered for marker in auth_markers): + return ( + ( + f"The agent could not authenticate with the {provider.title()} model `{model}`. " + "Please verify the API key and provider configuration in your environment." + ), + "llm_auth_failed", + ) + + if any(marker in lowered for marker in quota_markers): + return ( + ( + f"The {provider.title()} model `{model}` rejected the request because of quota " + "or rate limiting. Please wait and retry, or check your provider usage limits." + ), + "llm_quota_failed", + ) + + return ( + ( + f"The agent was unable to complete the request with the {provider.title()} model " + f"`{model}`. Please retry, and check backend logs if the problem continues." + ), + "agent_execution_failed", + ) class ChatRequest(BaseModel): @@ -21,6 +95,33 @@ class ChatResponse(BaseModel): intent: str | None = None retrieved_context: str | None = None tool_context: str | None = None + request_id: str | None = None + tools_used: list[str] = Field(default_factory=list) + retrieval_source: str | None = None + retrieval_hit: bool = False + evidence_found: bool = False + llm_enabled: bool = False + llm_provider: str | None = None + llm_model: str | None = None + latency_ms: float | None = None + stage_latencies_ms: dict[str, float] = Field(default_factory=dict) + error: str | None = None + + +class DebugStatusResponse(BaseModel): + app_name: str + app_env: str + mock_mode: bool + use_pinecone: bool + enable_mcp: bool + llm_provider: str + llm_model: str + docs_path_exists: bool + sample_logs_path_exists: bool + pinecone_configured: bool + groq_configured: bool + google_configured: bool + langchain_tracing_enabled: bool @router.get("/health") @@ -29,12 +130,91 @@ def healthcheck() -> dict[str, str]: return {"status": "ok", "app": settings.app_name} +@router.get("/debug/status", response_model=DebugStatusResponse) +def debug_status() -> DebugStatusResponse: + settings = get_settings() + provider = settings.llm_provider.lower() + active_model = settings.google_model if provider == "google" else settings.groq_model + return DebugStatusResponse( + app_name=settings.app_name, + app_env=settings.app_env, + mock_mode=settings.mock_mode, + use_pinecone=settings.use_pinecone, + enable_mcp=settings.enable_mcp, + llm_provider=provider, + llm_model=active_model, + docs_path_exists=settings.docs_path.exists(), + sample_logs_path_exists=settings.sample_logs_path.exists(), + pinecone_configured=bool(settings.pinecone_api_key), + groq_configured=bool(settings.groq_api_key), + google_configured=bool(settings.google_api_key), + langchain_tracing_enabled=settings.langchain_tracing_v2, + ) + + @router.post("/chat", response_model=ChatResponse) -def chat(payload: ChatRequest) -> ChatResponse: - result = run_agent(payload.query) - return ChatResponse( +def chat(payload: ChatRequest, request: Request) -> ChatResponse: + settings = get_settings() + provider = settings.llm_provider.lower() + active_model = settings.google_model if provider == "google" else settings.groq_model + request_id = getattr(request.state, "request_id", str(uuid4())) + started_at = perf_counter() + + try: + result = run_agent(payload.query) + except Exception as exc: # pragma: no cover - exercised via route tests + duration_ms = round((perf_counter() - started_at) * 1000, 2) + answer, error_code = _classify_agent_error(exc, provider, active_model) + logger.exception( + "chat request failed", + extra={ + "request_id": request_id, + "path": str(request.url.path), + "method": request.method, + "duration_ms": duration_ms, + "error": str(exc), + }, + ) + return ChatResponse( + answer=answer, + request_id=request_id, + llm_provider=provider, + llm_model=active_model, + latency_ms=duration_ms, + error=error_code, + ) + + duration_ms = round((perf_counter() - started_at) * 1000, 2) + response = ChatResponse( answer=result.get("answer", ""), intent=result.get("intent"), retrieved_context=result.get("retrieved_context"), tool_context=result.get("tool_context"), + request_id=request_id, + tools_used=result.get("tools_used", []), + retrieval_source=result.get("retrieval_source"), + retrieval_hit=result.get("retrieval_hit", False), + evidence_found=result.get("evidence_found", False), + llm_enabled=result.get("llm_enabled", False), + llm_provider=provider, + llm_model=active_model, + latency_ms=result.get("total_latency_ms", duration_ms), + stage_latencies_ms=result.get("stage_latencies_ms", {}), + error=result.get("error"), + ) + + logger.info( + "chat request completed", + extra={ + "request_id": request_id, + "path": str(request.url.path), + "method": request.method, + "status_code": 200, + "duration_ms": response.latency_ms, + "intent": response.intent, + "tools_used": response.tools_used, + "retrieval_source": response.retrieval_source, + "error": response.error, + }, ) + return response diff --git a/backend/config.py b/backend/config.py index 6d07f1a..8e0abb1 100644 --- a/backend/config.py +++ b/backend/config.py @@ -20,9 +20,18 @@ class Settings(BaseSettings): app_port: int = 8000 app_reload: bool = True app_origin: str = "http://localhost:5173" + app_log_level: str = "INFO" + app_log_path: Path = ROOT_DIR / "logs" / "backend.log" + mock_mode: bool = Field(default=False, alias="MOCK_MODE") + use_pinecone: bool = Field(default=True, alias="USE_PINECONE") + enable_mcp: bool = Field(default=True, alias="ENABLE_MCP") + enable_live_logs: bool = Field(default=False, alias="ENABLE_LIVE_LOGS") + llm_provider: str = Field(default="groq", alias="LLM_PROVIDER") groq_api_key: str = Field(default="", alias="GROQ_API_KEY") groq_model: str = Field(default="llama-3.1-8b-instant", alias="GROQ_MODEL") + google_api_key: str = Field(default="", alias="GOOGLE_API_KEY") + google_model: str = Field(default="gemini-3-flash-preview", alias="GOOGLE_MODEL") pinecone_api_key: str = Field(default="", alias="PINECONE_API_KEY") pinecone_index_name: str = Field( @@ -42,6 +51,11 @@ class Settings(BaseSettings): docs_path: Path = DATA_DIR / "docs" sample_logs_path: Path = DATA_DIR / "sample_logs" / "app.log" + live_logs_db_path: Path = DATA_DIR / "live_logs" / "recent_logs.db" + live_logs_max_rows: int = Field(default=5000, alias="LIVE_LOGS_MAX_ROWS") + kafka_bootstrap_servers: str = Field(default="localhost:9092", alias="KAFKA_BOOTSTRAP_SERVERS") + kafka_logs_topic: str = Field(default="application-logs", alias="KAFKA_LOGS_TOPIC") + kafka_group_id: str = Field(default="ai-observability-agent", alias="KAFKA_GROUP_ID") model_config = SettingsConfigDict( env_file=ROOT_DIR / ".env", diff --git a/backend/live_logs/__init__.py b/backend/live_logs/__init__.py new file mode 100644 index 0000000..c5a5334 --- /dev/null +++ b/backend/live_logs/__init__.py @@ -0,0 +1 @@ +"""Live log ingestion utilities.""" diff --git a/backend/live_logs/consumer.py b/backend/live_logs/consumer.py new file mode 100644 index 0000000..7dc2e52 --- /dev/null +++ b/backend/live_logs/consumer.py @@ -0,0 +1,52 @@ +"""Kafka consumer that stores a recent rolling window of live logs.""" + +from __future__ import annotations + +import logging + +from backend.config import get_settings +from backend.live_logs.store import LiveLogStore, parse_kafka_log_event + +try: + from kafka import KafkaConsumer +except ImportError: # pragma: no cover + KafkaConsumer = None + + +logger = logging.getLogger(__name__) + + +def consume_kafka_logs() -> None: + """Consume structured JSON logs from Kafka into the local recent-log store.""" + settings = get_settings() + if KafkaConsumer is None: + raise RuntimeError( + "Install `kafka-python` to run the live log consumer." + ) + + store = LiveLogStore() + consumer = KafkaConsumer( + settings.kafka_logs_topic, + bootstrap_servers=settings.kafka_bootstrap_servers, + group_id=settings.kafka_group_id, + auto_offset_reset="latest", + enable_auto_commit=True, + value_deserializer=lambda value: value.decode("utf-8"), + ) + + logger.info( + "starting kafka log consumer", + extra={ + "topic": settings.kafka_logs_topic, + "bootstrap_servers": settings.kafka_bootstrap_servers, + "group_id": settings.kafka_group_id, + }, + ) + + for message in consumer: + event = parse_kafka_log_event(message.value) + store.append(event) + + +if __name__ == "__main__": # pragma: no cover + consume_kafka_logs() diff --git a/backend/live_logs/store.py b/backend/live_logs/store.py new file mode 100644 index 0000000..fffaab2 --- /dev/null +++ b/backend/live_logs/store.py @@ -0,0 +1,165 @@ +"""Recent live log storage backed by SQLite.""" + +from __future__ import annotations + +import json +import sqlite3 +from dataclasses import dataclass +from pathlib import Path + +from backend.config import get_settings + + +@dataclass(slots=True) +class LogEvent: + timestamp: str + service: str + level: str + message: str + trace_id: str | None = None + logger: str | None = None + method: str | None = None + exception: str | None = None + raw: str | None = None + + def to_line(self) -> str: + parts = [self.timestamp, self.level, self.service, self.message] + if self.trace_id: + parts.append(f"trace_id={self.trace_id}") + if self.method: + parts.append(f"method={self.method}") + if self.exception: + parts.append(f"exception={self.exception}") + return " ".join(part for part in parts if part).strip() + + +class LiveLogStore: + """Store a rolling window of recent logs for exact search.""" + + def __init__(self, db_path: Path | None = None, max_rows: int | None = None) -> None: + settings = get_settings() + self.db_path = Path(db_path or settings.live_logs_db_path) + self.max_rows = max_rows or settings.live_logs_max_rows + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._initialize() + + def _connect(self) -> sqlite3.Connection: + return sqlite3.connect(self.db_path) + + def _initialize(self) -> None: + with self._connect() as connection: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS live_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + service TEXT NOT NULL, + level TEXT NOT NULL, + message TEXT NOT NULL, + trace_id TEXT, + logger TEXT, + method TEXT, + exception TEXT, + raw TEXT + ) + """ + ) + connection.commit() + + def append(self, event: LogEvent) -> None: + with self._connect() as connection: + connection.execute( + """ + INSERT INTO live_logs ( + timestamp, service, level, message, trace_id, logger, method, exception, raw + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + event.timestamp, + event.service, + event.level, + event.message, + event.trace_id, + event.logger, + event.method, + event.exception, + event.raw or event.to_line(), + ), + ) + self._trim(connection) + connection.commit() + + def _trim(self, connection: sqlite3.Connection) -> None: + connection.execute( + """ + DELETE FROM live_logs + WHERE id NOT IN ( + SELECT id + FROM live_logs + ORDER BY id DESC + LIMIT ? + ) + """, + (self.max_rows,), + ) + + def search(self, query_terms: list[str], limit: int = 5) -> list[str]: + with self._connect() as connection: + rows = connection.execute( + """ + SELECT timestamp, service, level, message, trace_id, logger, method, exception, raw + FROM live_logs + ORDER BY id DESC + LIMIT ? + """, + (self.max_rows,), + ).fetchall() + + scored_lines: list[tuple[int, str]] = [] + for row in rows: + event = LogEvent( + timestamp=row[0], + service=row[1], + level=row[2], + message=row[3], + trace_id=row[4], + logger=row[5], + method=row[6], + exception=row[7], + raw=row[8], + ) + rendered = event.raw or event.to_line() + lower_line = rendered.lower() + score = sum(1 for term in query_terms if term in lower_line) + if score > 0: + scored_lines.append((score, rendered)) + + return [line for _, line in sorted(scored_lines, reverse=True)[:limit]] + + def count(self) -> int: + with self._connect() as connection: + row = connection.execute("SELECT COUNT(*) FROM live_logs").fetchone() + return int(row[0]) if row else 0 + + +def parse_kafka_log_event(payload: str) -> LogEvent: + """Parse a JSON log event produced by a sample Spring Boot app.""" + data = json.loads(payload) + return LogEvent( + timestamp=str(data.get("timestamp", "")), + service=str(data.get("service", "unknown-service")), + level=str(data.get("level", "INFO")), + message=str(data.get("message", "")), + trace_id=_optional_string(data.get("trace_id")), + logger=_optional_string(data.get("logger")), + method=_optional_string(data.get("method")), + exception=_optional_string(data.get("exception")), + raw=payload, + ) + + +def _optional_string(value: object) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None diff --git a/backend/main.py b/backend/main.py index 94c9016..4f7e91f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,15 +1,25 @@ """FastAPI entrypoint for the AI Observability Agent.""" +import logging +from time import perf_counter +from uuid import uuid4 + from fastapi import FastAPI +from fastapi import Request from fastapi.middleware.cors import CORSMiddleware from backend.api.routes import router from backend.config import get_settings +from backend.observability import configure_logging + + +logger = logging.getLogger(__name__) def create_app() -> FastAPI: """Build the FastAPI application.""" settings = get_settings() + configure_logging() app = FastAPI( title=settings.app_name, @@ -25,6 +35,29 @@ def create_app() -> FastAPI: allow_headers=["*"], ) app.include_router(router, prefix="/api") + + @app.middleware("http") + async def add_request_context(request: Request, call_next): + request_id = request.headers.get("x-request-id", str(uuid4())) + request.state.request_id = request_id + started_at = perf_counter() + + response = await call_next(request) + + duration_ms = round((perf_counter() - started_at) * 1000, 2) + response.headers["X-Request-ID"] = request_id + logger.info( + "http request completed", + extra={ + "request_id": request_id, + "path": str(request.url.path), + "method": request.method, + "status_code": response.status_code, + "duration_ms": duration_ms, + }, + ) + return response + return app diff --git a/backend/mcp/server.py b/backend/mcp/server.py index 338fac2..466f09e 100644 --- a/backend/mcp/server.py +++ b/backend/mcp/server.py @@ -3,6 +3,7 @@ from __future__ import annotations from backend.tools.doc_tool import retrieve_docs_tool +from backend.config import get_settings from backend.tools.log_tool import analyze_logs_tool, search_logs_tool from backend.tools.metrics_tool import get_metrics_tool @@ -14,6 +15,9 @@ def create_mcp_server(): """Create an MCP server if the dependency is installed.""" + settings = get_settings() + if not settings.enable_mcp: + raise RuntimeError("MCP server is disabled. Set ENABLE_MCP=true to run it.") if FastMCP is None: raise RuntimeError("Install `mcp` to run the MCP server.") diff --git a/backend/observability.py b/backend/observability.py new file mode 100644 index 0000000..28395a0 --- /dev/null +++ b/backend/observability.py @@ -0,0 +1,69 @@ +"""Logging and request observability utilities.""" + +from __future__ import annotations + +import json +import logging +from datetime import UTC, datetime +from pathlib import Path + +from backend.config import get_settings + + +class JsonFormatter(logging.Formatter): + """Format logs as compact JSON for easy filtering.""" + + def format(self, record: logging.LogRecord) -> str: + payload = { + "timestamp": datetime.now(UTC).isoformat(), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + + for field in ( + "request_id", + "path", + "method", + "status_code", + "duration_ms", + "intent", + "tools_used", + "retrieval_source", + "error", + ): + value = getattr(record, field, None) + if value is not None: + payload[field] = value + + if record.exc_info: + payload["exception"] = self.formatException(record.exc_info) + + return json.dumps(payload, default=str) + + +def configure_logging() -> None: + """Set up console and file logging once per process.""" + settings = get_settings() + log_path = Path(settings.app_log_path) + log_path.parent.mkdir(parents=True, exist_ok=True) + + root_logger = logging.getLogger() + if getattr(root_logger, "_ai_observability_configured", False): + return + + formatter = JsonFormatter() + handlers: list[logging.Handler] = [ + logging.StreamHandler(), + logging.FileHandler(log_path, encoding="utf-8"), + ] + + for handler in handlers: + handler.setFormatter(formatter) + + root_logger.handlers.clear() + root_logger.setLevel(settings.app_log_level.upper()) + for handler in handlers: + root_logger.addHandler(handler) + + root_logger._ai_observability_configured = True # type: ignore[attr-defined] diff --git a/backend/rag/retriever.py b/backend/rag/retriever.py index af7fb28..960261a 100644 --- a/backend/rag/retriever.py +++ b/backend/rag/retriever.py @@ -38,7 +38,10 @@ def get_retriever(search_kwargs: dict | None = None): def search_documents(query: str, k: int = 4) -> str: settings = get_settings() - if not settings.pinecone_api_key: + if settings.mock_mode: + return _search_local_documents(query, k=k) + + if not settings.use_pinecone or not settings.pinecone_api_key: return _search_local_documents(query, k=k) try: @@ -77,3 +80,13 @@ def _search_local_documents(query: str, k: int = 4) -> str: content = path.read_text(encoding="utf-8") results.append(f"Source: {path.name}\n{content}") return "\n\n".join(results) + + +def get_retrieval_source() -> str: + """Describe which retrieval path is currently configured.""" + settings = get_settings() + if settings.mock_mode: + return "mock" + if settings.use_pinecone and settings.pinecone_api_key: + return "pinecone" + return "local" diff --git a/backend/requirements.txt b/backend/requirements.txt index a9f6027..6feced6 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,6 +6,7 @@ langchain>=0.3.0 langchain-core>=0.3.0 langchain-community>=0.3.0 langchain-groq>=0.2.0 +langchain-google-genai>=2.0.0 langgraph>=0.2.0 langchain-pinecone>=0.2.0 langchain-huggingface>=0.1.0 @@ -15,3 +16,4 @@ langsmith>=0.1.0 mcp>=1.0.0 pytest>=8.0.0 httpx>=0.27.0 +kafka-python>=2.0.2 diff --git a/backend/tests/test_smoke.py b/backend/tests/test_smoke.py index b6464d9..88a6113 100644 --- a/backend/tests/test_smoke.py +++ b/backend/tests/test_smoke.py @@ -1,9 +1,8 @@ -from fastapi.testclient import TestClient - -from backend.main import app +def test_healthcheck() -> None: + from fastapi.testclient import TestClient + from backend.main import app -def test_healthcheck() -> None: client = TestClient(app) response = client.get("/api/health") assert response.status_code == 200 diff --git a/backend/tools/log_tool.py b/backend/tools/log_tool.py index 0ad4d86..7d609d9 100644 --- a/backend/tools/log_tool.py +++ b/backend/tools/log_tool.py @@ -8,6 +8,7 @@ from langchain_core.tools import tool from backend.config import get_settings +from backend.live_logs.store import LiveLogStore ERROR_PATTERNS = { @@ -27,14 +28,30 @@ def _read_log_file() -> str: return path.read_text(encoding="utf-8") +def _read_live_logs(query_terms: list[str], limit: int) -> list[str]: + settings = get_settings() + if not settings.enable_live_logs: + return [] + + store = LiveLogStore() + if store.count() == 0: + return [] + + return store.search(query_terms, limit=limit) + + @tool("search_logs") def search_logs_tool(query: str, limit: int = 5) -> str: """Search local sample logs for lines matching the query.""" + query_terms = _expand_query_terms(query) + live_lines = _read_live_logs(query_terms, limit) + if live_lines: + return "\n".join(live_lines) + log_text = _read_log_file() if not log_text: return "No sample log file found." - query_terms = _expand_query_terms(query) scored_lines: list[tuple[int, str]] = [] for line in log_text.splitlines(): lower_line = line.lower() diff --git a/evaluation/sample_queries.json b/evaluation/sample_queries.json new file mode 100644 index 0000000..b02c043 --- /dev/null +++ b/evaluation/sample_queries.json @@ -0,0 +1,38 @@ +[ + { + "id": "logs-oom-001", + "query": "Do you see any out of memory error in the logs? If yes, at what time?", + "expected_intent": "logs", + "expected_tool_path": [ + "search_logs", + "analyze_logs" + ], + "expected_evidence_type": "log_lines" + }, + { + "id": "logs-trace-002", + "query": "Do you see any trace_id in the recent errors?", + "expected_intent": "logs", + "expected_tool_path": [ + "search_logs", + "analyze_logs" + ], + "expected_evidence_type": "trace_id" + }, + { + "id": "metrics-cpu-003", + "query": "Why is CPU high in my Java service?", + "expected_intent": "metrics", + "expected_tool_path": [ + "get_metrics" + ], + "expected_evidence_type": "synthetic_metrics" + }, + { + "id": "docs-jvm-004", + "query": "How do I troubleshoot JVM memory pressure?", + "expected_intent": "docs", + "expected_tool_path": [], + "expected_evidence_type": "documentation" + } +] diff --git a/frontend/react-chat/package-lock.json b/frontend/react-chat/package-lock.json new file mode 100644 index 0000000..d78462a --- /dev/null +++ b/frontend/react-chat/package-lock.json @@ -0,0 +1,1716 @@ +{ + "name": "react-chat", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "react-chat", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.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.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "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/@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.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", + "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "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.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001784", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", + "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", + "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/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.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "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/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==", + "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/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "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.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "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/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": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "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.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "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/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": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.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", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "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/react-chat/src/App.jsx b/frontend/react-chat/src/App.jsx index 3b5dde8..db00921 100644 --- a/frontend/react-chat/src/App.jsx +++ b/frontend/react-chat/src/App.jsx @@ -1,51 +1,430 @@ -import { useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import ChatWindow from "./components/ChatWindow"; import Home from "./pages/Home"; const API_BASE_URL = "http://localhost:8000/api"; +const STORAGE_KEY = "observability-agent-state"; +const LOADING_STAGES = [ + "Classifying your question", + "Checking docs and retrieval context", + "Scanning runtime evidence", + "Generating a final answer", +]; -export default function App() { - const [messages, setMessages] = useState([ - { - role: "assistant", - content: - "Ask about logs, metrics, JVM issues, or incident debugging and I will walk through it.", +function createStarterMessage() { + return { + id: crypto.randomUUID(), + role: "assistant", + content: + "Ask about logs, metrics, JVM issues, or incident debugging and I will walk through it.", + createdAt: new Date().toISOString(), + metadata: { + isStarter: true, }, - ]); + }; +} + +function createConversation(title = "New Chat") { + return { + id: crypto.randomUUID(), + title, + updatedAt: new Date().toISOString(), + messages: [createStarterMessage()], + pinned: false, + }; +} + +function createDefaultState() { + const conversation = createConversation(); + return { + conversations: [conversation], + activeConversationId: conversation.id, + debugMode: false, + theme: "light", + }; +} + +function loadInitialState() { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) { + return createDefaultState(); + } + + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed.conversations) || !parsed.activeConversationId) { + throw new Error("Invalid conversation state"); + } + + return { + debugMode: Boolean(parsed.debugMode), + theme: parsed.theme === "dark" ? "dark" : "light", + conversations: parsed.conversations.map((conversation) => ({ + pinned: false, + ...conversation, + })), + activeConversationId: parsed.activeConversationId, + }; + } catch { + return createDefaultState(); + } +} + +export default function App() { + const [state, setState] = useState(loadInitialState); const [loading, setLoading] = useState(false); + const [loadingStageIndex, setLoadingStageIndex] = useState(0); + const [requestStartedAt, setRequestStartedAt] = useState(null); + const [elapsedMs, setElapsedMs] = useState(0); + const [sessionQuery, setSessionQuery] = useState(""); + const [backendStatus, setBackendStatus] = useState({ + state: "checking", + message: "Checking backend connection...", + }); + const abortControllerRef = useRef(null); + const lastSubmittedQueryRef = useRef(""); + + const conversations = state.conversations; + const theme = state.theme || "light"; + const activeConversation = + conversations.find((conversation) => conversation.id === state.activeConversationId) ?? + conversations[0]; + const activeMessages = activeConversation?.messages ?? []; + + useEffect(() => { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + }, [state]); + + useEffect(() => { + document.documentElement.dataset.theme = theme; + }, [theme]); + + useEffect(() => { + let cancelled = false; + + async function checkBackend() { + try { + const response = await fetch(`${API_BASE_URL}/health`); + if (!response.ok) { + throw new Error("Backend unhealthy"); + } + if (!cancelled) { + setBackendStatus({ + state: "online", + message: "Backend connected", + }); + } + } catch { + if (!cancelled) { + setBackendStatus({ + state: "offline", + message: "Backend offline or unreachable", + }); + } + } + } + + checkBackend(); + const intervalId = window.setInterval(checkBackend, 20000); + return () => { + cancelled = true; + window.clearInterval(intervalId); + }; + }, []); + + useEffect(() => { + if (!loading) { + setElapsedMs(0); + setLoadingStageIndex(0); + return undefined; + } + + const stageTimer = window.setInterval(() => { + setLoadingStageIndex((index) => Math.min(index + 1, LOADING_STAGES.length - 1)); + }, 1800); + const elapsedTimer = window.setInterval(() => { + if (requestStartedAt) { + setElapsedMs(Date.now() - requestStartedAt); + } + }, 200); + + return () => { + window.clearInterval(stageTimer); + window.clearInterval(elapsedTimer); + }; + }, [loading, requestStartedAt]); + + const sessionSummaries = useMemo(() => { + const filtered = conversations.filter((conversation) => { + if (!sessionQuery.trim()) { + return true; + } + const query = sessionQuery.toLowerCase(); + return ( + conversation.title.toLowerCase().includes(query) || + conversation.messages.some((message) => message.content.toLowerCase().includes(query)) + ); + }); + + return filtered + .slice() + .sort((left, right) => { + if (left.pinned !== right.pinned) { + return left.pinned ? -1 : 1; + } + return new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(); + }) + .map((conversation) => ({ + id: conversation.id, + title: conversation.title, + updatedAt: conversation.updatedAt, + preview: conversation.messages.at(-1)?.content ?? "", + pinned: conversation.pinned, + })); + }, [conversations, sessionQuery]); + + function updateConversation(conversationId, updater) { + setState((current) => ({ + ...current, + conversations: current.conversations.map((conversation) => + conversation.id === conversationId ? updater(conversation) : conversation + ), + })); + } + + function setActiveConversationId(conversationId) { + setState((current) => ({ + ...current, + activeConversationId: conversationId, + conversations: current.conversations.map((conversation) => + conversation.id === conversationId + ? { ...conversation, hasUnread: false } + : conversation + ), + })); + } + + function handleNewChat() { + const conversation = createConversation(); + setState((current) => ({ + ...current, + conversations: [conversation, ...current.conversations], + activeConversationId: conversation.id, + })); + } + + function handleClearChat() { + if (!activeConversation) { + return; + } + + updateConversation(activeConversation.id, (conversation) => ({ + ...conversation, + title: "New Chat", + updatedAt: new Date().toISOString(), + messages: [createStarterMessage()], + })); + } + + function handleDeleteConversation(conversationId) { + setState((current) => { + const remaining = current.conversations.filter((conversation) => conversation.id !== conversationId); + if (remaining.length === 0) { + const conversation = createConversation(); + return { + ...current, + conversations: [conversation], + activeConversationId: conversation.id, + }; + } + const nextActiveId = + current.activeConversationId === conversationId ? remaining[0].id : current.activeConversationId; + return { + ...current, + conversations: remaining, + activeConversationId: nextActiveId, + }; + }); + } + + function handleRenameConversation(conversationId) { + const conversation = conversations.find((item) => item.id === conversationId); + const nextTitle = window.prompt("Rename conversation", conversation?.title ?? "New Chat"); + if (!nextTitle?.trim()) { + return; + } + updateConversation(conversationId, (currentConversation) => ({ + ...currentConversation, + title: nextTitle.trim(), + })); + } + + function handleTogglePin(conversationId) { + updateConversation(conversationId, (conversation) => ({ + ...conversation, + pinned: !conversation.pinned, + })); + } + + function handleToggleDebugMode() { + setState((current) => ({ + ...current, + debugMode: !current.debugMode, + })); + } + + function handleToggleTheme() { + setState((current) => ({ + ...current, + theme: current.theme === "dark" ? "light" : "dark", + })); + } async function handleSendMessage(input) { - const nextMessages = [...messages, { role: "user", content: input }]; - setMessages(nextMessages); + if (!activeConversation || loading) { + return; + } + + lastSubmittedQueryRef.current = input; + const timestamp = new Date().toISOString(); + const userMessage = { + id: crypto.randomUUID(), + role: "user", + content: input, + createdAt: timestamp, + }; + + updateConversation(activeConversation.id, (conversation) => ({ + ...conversation, + title: conversation.title === "New Chat" ? buildConversationTitle(input) : conversation.title, + updatedAt: timestamp, + messages: [...conversation.messages, userMessage], + })); + + const controller = new AbortController(); + abortControllerRef.current = controller; setLoading(true); + setRequestStartedAt(Date.now()); + setLoadingStageIndex(0); try { const response = await fetch(`${API_BASE_URL}/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: input }), + signal: controller.signal, }); const data = await response.json(); - setMessages([ - ...nextMessages, - { role: "assistant", content: data.answer || "No answer returned." }, - ]); + const assistantMessage = createAssistantMessage(data); + + updateConversation(activeConversation.id, (conversation) => ({ + ...conversation, + updatedAt: assistantMessage.createdAt, + messages: [...conversation.messages, assistantMessage], + })); } catch (error) { - setMessages([ - ...nextMessages, - { - role: "assistant", - content: "The backend is not reachable yet. Start FastAPI and try again.", - }, - ]); + const assistantMessage = + error?.name === "AbortError" + ? createLocalAssistantMessage( + "The in-flight request was cancelled. You can retry the same question or start a fresh chat." + ) + : createLocalAssistantMessage( + "The backend is not reachable yet. Start FastAPI and try again.", + "backend_unreachable" + ); + + updateConversation(activeConversation.id, (conversation) => ({ + ...conversation, + updatedAt: assistantMessage.createdAt, + messages: [...conversation.messages, assistantMessage], + })); } finally { + abortControllerRef.current = null; setLoading(false); + setRequestStartedAt(null); + setElapsedMs(0); + } + } + + function handleCancelRequest() { + abortControllerRef.current?.abort(); + } + + function handleRetryLastRequest() { + if (lastSubmittedQueryRef.current && !loading) { + handleSendMessage(lastSubmittedQueryRef.current); } } return ( - - + + ); } + +function createAssistantMessage(data) { + return { + id: crypto.randomUUID(), + role: "assistant", + content: data.answer || "No answer returned.", + createdAt: new Date().toISOString(), + metadata: { + requestId: data.request_id, + intent: data.intent, + toolsUsed: data.tools_used || [], + retrievalSource: data.retrieval_source, + retrievalHit: data.retrieval_hit, + evidenceFound: data.evidence_found, + llmEnabled: data.llm_enabled, + llmProvider: data.llm_provider, + llmModel: data.llm_model, + latencyMs: data.latency_ms, + stageLatenciesMs: data.stage_latencies_ms || {}, + error: data.error, + retrievedContext: data.retrieved_context, + toolContext: data.tool_context, + }, + }; +} + +function createLocalAssistantMessage(content, error = "request_cancelled") { + return { + id: crypto.randomUUID(), + role: "assistant", + content, + createdAt: new Date().toISOString(), + metadata: { + error, + }, + }; +} + +function buildConversationTitle(input) { + const title = input.trim().replace(/\s+/g, " "); + if (!title) { + return "New Chat"; + } + return title.length > 40 ? `${title.slice(0, 40)}...` : title; +} diff --git a/frontend/react-chat/src/components/ChatWindow.jsx b/frontend/react-chat/src/components/ChatWindow.jsx index 51363b9..05b5129 100644 --- a/frontend/react-chat/src/components/ChatWindow.jsx +++ b/frontend/react-chat/src/components/ChatWindow.jsx @@ -1,7 +1,62 @@ -import { useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; -export default function ChatWindow({ messages, loading, onSend }) { +export default function ChatWindow({ + conversations, + activeConversationId, + debugMode, + loading, + loadingStage, + elapsedMs, + backendStatus, + messages, + sessionQuery, + onCancelRequest, + onClearChat, + onDeleteConversation, + onNewChat, + onRenameConversation, + onRetryLastRequest, + onSelectConversation, + onSend, + onSessionQueryChange, + onToggleDebugMode, + onTogglePin, +}) { const [input, setInput] = useState(""); + const [showDebugPanels, setShowDebugPanels] = useState(debugMode); + const logRef = useRef(null); + const latestAssistantMessage = [...messages] + .reverse() + .find((message) => message.role === "assistant" && message.metadata); + const isNearBottomRef = useRef(true); + + useEffect(() => { + setShowDebugPanels(debugMode); + }, [debugMode]); + + useEffect(() => { + const node = logRef.current; + if (!node) { + return undefined; + } + + function handleScroll() { + const distanceFromBottom = node.scrollHeight - node.scrollTop - node.clientHeight; + isNearBottomRef.current = distanceFromBottom < 80; + } + + node.addEventListener("scroll", handleScroll); + handleScroll(); + return () => node.removeEventListener("scroll", handleScroll); + }, []); + + useEffect(() => { + if (logRef.current && isNearBottomRef.current) { + logRef.current.scrollTop = logRef.current.scrollHeight; + } + }, [messages, loading]); + + const groupedMessages = useMemo(() => groupMessages(messages), [messages]); function handleSubmit(event) { event.preventDefault(); @@ -13,29 +68,584 @@ export default function ChatWindow({ messages, loading, onSend }) { setInput(""); } + function handleKeyDown(event) { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + handleSubmit(event); + } + } + + return ( +
+ + +
+
+
+

Current Context

+

Active Chat

+
+
+ + + +
+
+
+ {groupedMessages.map((group) => ( +
+ {group.messages.map((message) => ( +
+
+ {message.role} + {message.createdAt ? ( + + ) : null} +
+ + {message.role === "assistant" ? ( + + ) : ( +

{message.content}

+ )} + + {message.role === "assistant" && message.metadata ? ( + <> + {message.metadata.requestId || message.metadata.error ? ( +
+ {message.metadata.requestId ? ( + + ) : null} + {message.metadata.error ? ( + <> +
+ Attention + {mapErrorToHint(message.metadata.error)} +
+ + + ) : null} +
+ ) : null} + + {showDebugPanels ? ( +
+ + + + + + + + {message.metadata.requestId ? ( + + ) : null} + {message.metadata.error ? ( + + ) : null} + {Object.keys(message.metadata.stageLatenciesMs || {}).length ? ( + + ) : null} + {(message.metadata.retrievedContext || message.metadata.toolContext) && ( +
+ Raw Context + {message.metadata.toolContext ? ( +
{message.metadata.toolContext}
+ ) : null} + {message.metadata.retrievedContext ? ( +
{message.metadata.retrievedContext}
+ ) : null} +
+ )} +
+ ) : null} + +
+

Ask a follow-up

+
+ {buildFollowUps(message.metadata).map((suggestion) => ( + + ))} +
+
+ + ) : null} +
+ ))} +
+ ))} + + {loading ? ( +
+
+
+ {loadingStage} + {Math.max(1, Math.round(elapsedMs / 1000))}s elapsed +
+
+ {buildLoadingSteps(loadingStage).map((step, index) => ( + + {step} + + ))} +
+
+ ) : null} +
+ +
+ {loading ? ( + + ) : ( + + )} +
+ +
+