diff --git a/.env.example b/.env.example index 3dd1ac3..9acf1e9 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ # PostgreSQL password (used by both postgres and backend services) POSTGRES_PASSWORD=dev_password -# LLM provider: anthropic | openai | google +# LLM provider: anthropic | openai | azure_openai | google LLM_PROVIDER=anthropic LLM_MODEL=claude-sonnet-4-6 @@ -13,6 +13,12 @@ ANTHROPIC_API_KEY= OPENAI_API_KEY= GOOGLE_API_KEY= +# Azure OpenAI (required when LLM_PROVIDER=azure_openai) +AZURE_OPENAI_API_KEY= +AZURE_OPENAI_ENDPOINT= +AZURE_OPENAI_DEPLOYMENT= +AZURE_OPENAI_API_VERSION=2024-12-01-preview + # Optional: webhook URL for escalation notifications WEBHOOK_URL= diff --git a/backend/app/analytics/event_recorder.py b/backend/app/analytics/event_recorder.py index e7f7c23..a69051d 100644 --- a/backend/app/analytics/event_recorder.py +++ b/backend/app/analytics/event_recorder.py @@ -4,6 +4,8 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable +from psycopg.types.json import Json + if TYPE_CHECKING: from psycopg_pool import AsyncConnectionPool @@ -89,7 +91,7 @@ class PostgresAnalyticsRecorder: "duration_ms": duration_ms, "success": success, "error_message": error_message, - "metadata": metadata or {}, + "metadata": Json(metadata or {}), } async with self._pool.connection() as conn: await conn.execute(_INSERT_SQL, params) diff --git a/backend/app/config.py b/backend/app/config.py index 319e152..56c3f0a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -17,7 +17,7 @@ class Settings(BaseSettings): database_url: str - llm_provider: Literal["anthropic", "openai", "google"] = "anthropic" + llm_provider: Literal["anthropic", "openai", "azure_openai", "google"] = "anthropic" llm_model: str = "claude-sonnet-4-6" session_ttl_minutes: int = 30 @@ -34,6 +34,10 @@ class Settings(BaseSettings): anthropic_api_key: str = "" openai_api_key: str = "" + azure_openai_api_key: str = "" + azure_openai_endpoint: str = "" + azure_openai_api_version: str = "2024-12-01-preview" + azure_openai_deployment: str = "" google_api_key: str = "" @model_validator(mode="after") @@ -41,6 +45,7 @@ class Settings(BaseSettings): key_map = { "anthropic": self.anthropic_api_key, "openai": self.openai_api_key, + "azure_openai": self.azure_openai_api_key, "google": self.google_api_key, } key = key_map.get(self.llm_provider, "") @@ -49,4 +54,13 @@ class Settings(BaseSettings): f"API key for provider '{self.llm_provider}' is required. " f"Set the corresponding environment variable." ) + if self.llm_provider == "azure_openai": + if not self.azure_openai_endpoint: + raise ValueError( + "AZURE_OPENAI_ENDPOINT is required for azure_openai provider." + ) + if not self.azure_openai_deployment: + raise ValueError( + "AZURE_OPENAI_DEPLOYMENT is required for azure_openai provider." + ) return self diff --git a/backend/app/conversation_tracker.py b/backend/app/conversation_tracker.py index 7560266..d1043b2 100644 --- a/backend/app/conversation_tracker.py +++ b/backend/app/conversation_tracker.py @@ -9,7 +9,7 @@ if TYPE_CHECKING: _ENSURE_SQL = """ INSERT INTO conversations - (thread_id, started_at, last_activity) + (thread_id, created_at, last_activity) VALUES (%(thread_id)s, NOW(), NOW()) ON CONFLICT (thread_id) DO NOTHING diff --git a/backend/app/llm.py b/backend/app/llm.py index 22a26d1..a3af604 100644 --- a/backend/app/llm.py +++ b/backend/app/llm.py @@ -31,6 +31,16 @@ def create_llm(settings: Settings) -> BaseChatModel: api_key=settings.openai_api_key, ) + if provider == "azure_openai": + from langchain_openai import AzureChatOpenAI + + return AzureChatOpenAI( + azure_deployment=settings.azure_openai_deployment, + azure_endpoint=settings.azure_openai_endpoint, + api_key=settings.azure_openai_api_key, + api_version=settings.azure_openai_api_version, + ) + if provider == "google": from langchain_google_genai import ChatGoogleGenerativeAI @@ -39,4 +49,7 @@ def create_llm(settings: Settings) -> BaseChatModel: google_api_key=settings.google_api_key, ) - raise ValueError(f"Unknown LLM provider: '{provider}'. Use 'anthropic', 'openai', or 'google'.") + raise ValueError( + f"Unknown LLM provider: '{provider}'. " + "Use 'anthropic', 'openai', 'azure_openai', or 'google'." + ) diff --git a/backend/app/ws_handler.py b/backend/app/ws_handler.py index 8c6f383..81ddfde 100644 --- a/backend/app/ws_handler.py +++ b/backend/app/ws_handler.py @@ -54,7 +54,10 @@ async def handle_user_message( interrupt_manager: InterruptManager | None = None, ) -> None: """Process a user message through the graph and stream results back.""" - if session_manager.is_expired(thread_id): + # Touch first so new sessions are created before expiry check. + # For existing sessions, touch resets the sliding window. + existing = session_manager.get_state(thread_id) + if existing is not None and session_manager.is_expired(thread_id): msg = "Session expired. Please start a new conversation." await _send_json(ws, {"type": "error", "message": msg}) return diff --git a/backend/tests/e2e/conftest.py b/backend/tests/e2e/conftest.py new file mode 100644 index 0000000..421fd08 --- /dev/null +++ b/backend/tests/e2e/conftest.py @@ -0,0 +1,219 @@ +"""E2E test fixtures -- full FastAPI app with mocked LLM and database.""" + +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from httpx import ASGITransport, AsyncClient + +from app.analytics.api import router as analytics_router +from app.callbacks import TokenUsageCallbackHandler +from app.interrupt_manager import InterruptManager +from app.openapi.review_api import _job_store, router as openapi_router +from app.replay.api import router as replay_router +from app.session_manager import SessionManager +from app.ws_handler import dispatch_message + + +# --------------------------------------------------------------------------- +# Graph helpers -- simulate LangGraph streaming behaviour +# --------------------------------------------------------------------------- + + +class AsyncIterHelper: + """Make a list behave as an async iterator.""" + + def __init__(self, items: list) -> None: + self._items = list(items) + + def __aiter__(self): + return self + + async def __anext__(self): + if not self._items: + raise StopAsyncIteration + return self._items.pop(0) + + +def make_chunk(content: str, node: str = "order_lookup") -> tuple: + c = MagicMock() + c.content = content + c.tool_calls = [] + return (c, {"langgraph_node": node}) + + +def make_tool_chunk(name: str, args: dict, node: str = "order_lookup") -> tuple: + c = MagicMock() + c.content = "" + c.tool_calls = [{"name": name, "args": args}] + return (c, {"langgraph_node": node}) + + +def make_state(*, interrupt: bool = False, data: dict | None = None) -> Any: + s = MagicMock() + if interrupt: + obj = MagicMock() + obj.value = data or {"action": "cancel_order", "order_id": "1042"} + t = MagicMock() + t.interrupts = (obj,) + s.tasks = (t,) + else: + s.tasks = () + return s + + +def make_graph( + chunks: list | None = None, + state: Any = None, + resume_chunks: list | None = None, +) -> MagicMock: + """Build a mock LangGraph CompiledStateGraph.""" + g = MagicMock() + g.intent_classifier = None + g.agent_registry = None + + if state is None: + state = make_state() + + streams = [chunks or [], resume_chunks or []] + idx = {"n": 0} + + def astream_side_effect(*a, **kw): + i = min(idx["n"], len(streams) - 1) + idx["n"] += 1 + return AsyncIterHelper(list(streams[i])) + + g.astream = MagicMock(side_effect=astream_side_effect) + g.aget_state = AsyncMock(return_value=state) + return g + + +# --------------------------------------------------------------------------- +# Fake database pool +# --------------------------------------------------------------------------- + + +class FakeCursor: + """Minimal async cursor returning pre-configured rows.""" + + def __init__(self, rows: list[dict]) -> None: + self._rows = rows + + async def fetchall(self) -> list[dict]: + return self._rows + + +class FakeConnection: + """Fake async connection that returns a FakeCursor.""" + + def __init__(self, rows: list[dict]) -> None: + self._rows = rows + + async def execute(self, query: str, params: dict | None = None) -> FakeCursor: + return FakeCursor(self._rows) + + +class FakePool: + """Minimal pool that yields a fake connection.""" + + def __init__(self, rows: list[dict] | None = None) -> None: + self._rows = rows or [] + + @asynccontextmanager + async def connection(self): + yield FakeConnection(self._rows) + + async def close(self) -> None: + pass + + +# --------------------------------------------------------------------------- +# App factory +# --------------------------------------------------------------------------- + + +def create_e2e_app( + graph: MagicMock | None = None, + pool: FakePool | None = None, + session_ttl: int = 3600, + interrupt_ttl: int = 1800, +) -> FastAPI: + """Create a FastAPI app wired with mocked dependencies for E2E testing.""" + g = graph or make_graph() + p = pool or FakePool() + sm = SessionManager(session_ttl_seconds=session_ttl) + im = InterruptManager(ttl_seconds=interrupt_ttl) + + app = FastAPI(title="Smart Support E2E Test") + app.include_router(openapi_router) + app.include_router(replay_router) + app.include_router(analytics_router) + + app.state.graph = g + app.state.session_manager = sm + app.state.interrupt_manager = im + app.state.pool = p + app.state.settings = MagicMock(llm_model="test-model") + app.state.analytics_recorder = AsyncMock() + app.state.conversation_tracker = AsyncMock() + + @app.get("/api/health") + def health_check() -> dict: + return {"status": "ok", "version": "test"} + + @app.websocket("/ws") + async def websocket_endpoint(ws: WebSocket) -> None: + await ws.accept() + try: + while True: + raw_data = await ws.receive_text() + await dispatch_message( + ws, + app.state.graph, + app.state.session_manager, + TokenUsageCallbackHandler(model_name="test-model"), + raw_data, + interrupt_manager=app.state.interrupt_manager, + analytics_recorder=app.state.analytics_recorder, + conversation_tracker=app.state.conversation_tracker, + pool=app.state.pool, + ) + except WebSocketDisconnect: + pass + + return app + + +@pytest.fixture +def e2e_graph(): + """Default graph fixture -- returns tokens and message_complete.""" + return make_graph( + chunks=[make_chunk("Order 1042 is "), make_chunk("shipped.")] + ) + + +@pytest.fixture +def e2e_app(e2e_graph): + """Default E2E app fixture.""" + return create_e2e_app(graph=e2e_graph) + + +@pytest.fixture +async def e2e_client(e2e_app): + """Async HTTP client for E2E tests.""" + transport = ASGITransport(app=e2e_app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + yield client + + +@pytest.fixture(autouse=True) +def clear_openapi_job_store(): + """Clear the in-memory job store between tests.""" + _job_store.clear() + yield + _job_store.clear() diff --git a/backend/tests/e2e/test_chat_flows.py b/backend/tests/e2e/test_chat_flows.py new file mode 100644 index 0000000..06f578d --- /dev/null +++ b/backend/tests/e2e/test_chat_flows.py @@ -0,0 +1,384 @@ +"""E2E tests for critical chat user flows (flows 1-4). + +Flow 1: Happy path -- query order, get answer +Flow 2: Approval flow -- write operation, interrupt, approve, execute +Flow 3: Rejection flow -- write operation, interrupt, reject, no execution +Flow 4: Multi-turn context -- sequential messages in same session +""" + +from __future__ import annotations + +import json + +import pytest +from starlette.testclient import TestClient + +from tests.e2e.conftest import ( + create_e2e_app, + make_chunk, + make_graph, + make_state, + make_tool_chunk, +) + +pytestmark = pytest.mark.e2e + + +class TestFlow1HappyPath: + """Flow 1: query order -> get answer with streaming tokens.""" + + def test_websocket_happy_path_order_query(self) -> None: + graph = make_graph( + chunks=[ + make_tool_chunk("get_order_status", {"order_id": "1042"}), + make_chunk("Order 1042 has been shipped and is on its way."), + ], + ) + app = create_e2e_app(graph=graph) + + with TestClient(app) as client: + with client.websocket_connect("/ws") as ws: + ws.send_json({ + "type": "message", + "thread_id": "e2e-happy-1", + "content": "What is the status of order 1042?", + }) + + messages = [] + while True: + msg = ws.receive_json() + messages.append(msg) + if msg["type"] in ("message_complete", "error"): + break + + tool_calls = [m for m in messages if m["type"] == "tool_call"] + assert len(tool_calls) == 1 + assert tool_calls[0]["tool"] == "get_order_status" + assert tool_calls[0]["args"] == {"order_id": "1042"} + + tokens = [m for m in messages if m["type"] == "token"] + assert len(tokens) == 1 + assert "shipped" in tokens[0]["content"] + + completes = [m for m in messages if m["type"] == "message_complete"] + assert len(completes) == 1 + assert completes[0]["thread_id"] == "e2e-happy-1" + + def test_websocket_multiple_token_stream(self) -> None: + """Verify streaming returns multiple token chunks.""" + graph = make_graph( + chunks=[ + make_chunk("Your order "), + make_chunk("1042 "), + make_chunk("was delivered "), + make_chunk("yesterday."), + ], + ) + app = create_e2e_app(graph=graph) + + with TestClient(app) as client: + with client.websocket_connect("/ws") as ws: + ws.send_json({ + "type": "message", + "thread_id": "e2e-stream-1", + "content": "Where is my order?", + }) + + messages = _collect_until_complete(ws) + + tokens = [m for m in messages if m["type"] == "token"] + assert len(tokens) == 4 + full_text = "".join(t["content"] for t in tokens) + assert "1042" in full_text + assert "delivered" in full_text + + +class TestFlow2ApprovalFlow: + """Flow 2: write operation -> interrupt -> approve -> execute.""" + + def test_interrupt_approve_executes_action(self) -> None: + interrupt_state = make_state( + interrupt=True, + data={"action": "cancel_order", "order_id": "1042"}, + ) + graph = make_graph( + chunks=[], + state=interrupt_state, + resume_chunks=[ + make_chunk("Order 1042 has been cancelled successfully.", "order_actions"), + ], + ) + app = create_e2e_app(graph=graph) + + with TestClient(app) as client: + with client.websocket_connect("/ws") as ws: + # Step 1: Send cancel request + ws.send_json({ + "type": "message", + "thread_id": "e2e-approve-1", + "content": "Cancel order 1042", + }) + + messages = _collect_until_type(ws, "interrupt") + + interrupts = [m for m in messages if m["type"] == "interrupt"] + assert len(interrupts) == 1 + assert interrupts[0]["action"] == "cancel_order" + assert interrupts[0]["thread_id"] == "e2e-approve-1" + + # Step 2: Approve the interrupt + ws.send_json({ + "type": "interrupt_response", + "thread_id": "e2e-approve-1", + "approved": True, + }) + + resume_messages = _collect_until_complete(ws) + + tokens = [m for m in resume_messages if m["type"] == "token"] + assert len(tokens) == 1 + assert "cancelled" in tokens[0]["content"] + assert tokens[0]["agent"] == "order_actions" + + completes = [m for m in resume_messages if m["type"] == "message_complete"] + assert len(completes) == 1 + + +class TestFlow3RejectionFlow: + """Flow 3: write operation -> interrupt -> reject -> no execution.""" + + def test_interrupt_reject_does_not_execute(self) -> None: + interrupt_state = make_state( + interrupt=True, + data={"action": "cancel_order", "order_id": "1042"}, + ) + graph = make_graph( + chunks=[], + state=interrupt_state, + resume_chunks=[ + make_chunk("Understood. Order 1042 will remain active.", "order_actions"), + ], + ) + app = create_e2e_app(graph=graph) + + with TestClient(app) as client: + with client.websocket_connect("/ws") as ws: + # Step 1: Trigger interrupt + ws.send_json({ + "type": "message", + "thread_id": "e2e-reject-1", + "content": "Cancel order 1042", + }) + + messages = _collect_until_type(ws, "interrupt") + assert any(m["type"] == "interrupt" for m in messages) + + # Step 2: Reject + ws.send_json({ + "type": "interrupt_response", + "thread_id": "e2e-reject-1", + "approved": False, + }) + + resume_messages = _collect_until_complete(ws) + + tokens = [m for m in resume_messages if m["type"] == "token"] + assert len(tokens) == 1 + assert "remain active" in tokens[0]["content"] + + # Verify graph.astream was called with resume=False + resume_call = graph.astream.call_args_list[-1] + command = resume_call[0][0] + assert command.resume is False + + +class TestFlow4MultiTurnContext: + """Flow 4: multi-turn conversation in the same session.""" + + def test_multi_turn_messages_share_session(self) -> None: + """Multiple messages in the same thread_id maintain session context.""" + graph = make_graph( + chunks=[make_chunk("Order 1042 status: shipped.")], + ) + app = create_e2e_app(graph=graph) + + with TestClient(app) as client: + with client.websocket_connect("/ws") as ws: + # Turn 1: Query order + ws.send_json({ + "type": "message", + "thread_id": "e2e-multi-1", + "content": "What is the status of order 1042?", + }) + turn1 = _collect_until_complete(ws) + assert any(m["type"] == "message_complete" for m in turn1) + + # Turn 2: Follow-up in same thread + ws.send_json({ + "type": "message", + "thread_id": "e2e-multi-1", + "content": "When will it arrive?", + }) + turn2 = _collect_until_complete(ws) + assert any(m["type"] == "message_complete" for m in turn2) + + # Turn 3: Another follow-up + ws.send_json({ + "type": "message", + "thread_id": "e2e-multi-1", + "content": "Can you track it?", + }) + turn3 = _collect_until_complete(ws) + assert any(m["type"] == "message_complete" for m in turn3) + + # Verify all turns used the same thread_id in graph calls + for call in graph.astream.call_args_list: + config = call[1].get("config", call[0][1] if len(call[0]) > 1 else {}) + assert config["configurable"]["thread_id"] == "e2e-multi-1" + + def test_separate_threads_are_independent(self) -> None: + """Different thread_ids have independent sessions.""" + graph = make_graph( + chunks=[make_chunk("Response.")], + ) + app = create_e2e_app(graph=graph) + + with TestClient(app) as client: + with client.websocket_connect("/ws") as ws: + # Thread A + ws.send_json({ + "type": "message", + "thread_id": "e2e-thread-a", + "content": "Hello from thread A", + }) + _collect_until_complete(ws) + + # Thread B + ws.send_json({ + "type": "message", + "thread_id": "e2e-thread-b", + "content": "Hello from thread B", + }) + _collect_until_complete(ws) + + # Both threads should exist as separate sessions + sm = app.state.session_manager + assert sm.get_state("e2e-thread-a") is not None + assert sm.get_state("e2e-thread-b") is not None + + +class TestChatEdgeCases: + """Edge cases and error handling for the chat WebSocket.""" + + def test_invalid_json_returns_error(self) -> None: + app = create_e2e_app() + + with TestClient(app) as client: + with client.websocket_connect("/ws") as ws: + ws.send_text("not valid json") + msg = ws.receive_json() + assert msg["type"] == "error" + assert "Invalid JSON" in msg["message"] + + def test_missing_thread_id_returns_error(self) -> None: + app = create_e2e_app() + + with TestClient(app) as client: + with client.websocket_connect("/ws") as ws: + ws.send_json({"type": "message", "content": "hello"}) + msg = ws.receive_json() + assert msg["type"] == "error" + assert "thread_id" in msg["message"] + + def test_empty_content_returns_error(self) -> None: + app = create_e2e_app() + + with TestClient(app) as client: + with client.websocket_connect("/ws") as ws: + ws.send_json({ + "type": "message", + "thread_id": "e2e-err-1", + "content": "", + }) + msg = ws.receive_json() + assert msg["type"] == "error" + + def test_expired_session_returns_error(self) -> None: + graph = make_graph(chunks=[make_chunk("Response.")]) + app = create_e2e_app(graph=graph, session_ttl=0) + + with TestClient(app) as client: + with client.websocket_connect("/ws") as ws: + # First message creates the session (TTL=0) + ws.send_json({ + "type": "message", + "thread_id": "e2e-expired-1", + "content": "hello", + }) + _collect_until_complete_or_error(ws) + + # Second message finds the session expired (TTL=0) + ws.send_json({ + "type": "message", + "thread_id": "e2e-expired-1", + "content": "hello again", + }) + messages = _collect_until_complete_or_error(ws) + errors = [m for m in messages if m["type"] == "error"] + assert len(errors) >= 1 + assert "expired" in errors[0]["message"].lower() + + def test_oversized_message_returns_error(self) -> None: + app = create_e2e_app() + + with TestClient(app) as client: + with client.websocket_connect("/ws") as ws: + ws.send_text("x" * 40_000) + msg = ws.receive_json() + assert msg["type"] == "error" + assert "too large" in msg["message"].lower() + + def test_health_endpoint(self) -> None: + app = create_e2e_app() + with TestClient(app) as client: + resp = client.get("/api/health") + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _collect_until_complete(ws, *, max_messages: int = 50) -> list[dict]: + """Receive WebSocket messages until message_complete or error.""" + messages = [] + for _ in range(max_messages): + msg = ws.receive_json() + messages.append(msg) + if msg["type"] in ("message_complete", "error"): + break + return messages + + +def _collect_until_type(ws, msg_type: str, *, max_messages: int = 50) -> list[dict]: + """Receive until a specific message type is received.""" + messages = [] + for _ in range(max_messages): + msg = ws.receive_json() + messages.append(msg) + if msg["type"] == msg_type: + break + return messages + + +def _collect_until_complete_or_error(ws, *, max_messages: int = 50) -> list[dict]: + """Receive until message_complete or error.""" + messages = [] + for _ in range(max_messages): + msg = ws.receive_json() + messages.append(msg) + if msg["type"] in ("message_complete", "error"): + break + return messages diff --git a/backend/tests/e2e/test_openapi_import.py b/backend/tests/e2e/test_openapi_import.py new file mode 100644 index 0000000..ceebb00 --- /dev/null +++ b/backend/tests/e2e/test_openapi_import.py @@ -0,0 +1,201 @@ +"""E2E tests for OpenAPI import flow (flow 5). + +Flow 5: paste OpenAPI spec URL -> import job -> classify endpoints -> + review classifications -> approve -> tool generation. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest +from starlette.testclient import TestClient + +from app.openapi.models import ClassificationResult, EndpointInfo +from app.openapi.review_api import _job_store +from tests.e2e.conftest import create_e2e_app + +pytestmark = pytest.mark.e2e + + +def _fake_endpoint( + path: str = "/orders/{id}", + method: str = "GET", + operation_id: str = "getOrder", + summary: str = "Get order details", +) -> EndpointInfo: + return EndpointInfo( + path=path, + method=method, + operation_id=operation_id, + summary=summary, + description="", + parameters=(), + request_body_schema=None, + response_schema=None, + ) + + +def _fake_classification( + endpoint: EndpointInfo | None = None, + access_type: str = "read", + needs_interrupt: bool = False, + agent_group: str = "order_lookup", +) -> ClassificationResult: + return ClassificationResult( + endpoint=endpoint or _fake_endpoint(), + access_type=access_type, + customer_params=["order_id"], + agent_group=agent_group, + confidence=0.95, + needs_interrupt=needs_interrupt, + ) + + +class TestFlow5OpenAPIImport: + """Flow 5: full OpenAPI import lifecycle.""" + + def test_import_job_lifecycle(self) -> None: + """Start import -> check status -> review classifications -> approve.""" + app = create_e2e_app() + + with TestClient(app) as client: + # Step 1: Start import job + resp = client.post( + "/api/openapi/import", + json={"url": "https://api.example.com/openapi.json"}, + ) + assert resp.status_code == 202 + body = resp.json() + assert body["status"] == "pending" + job_id = body["job_id"] + + # Step 2: Check job status (still pending since background task hasn't run) + resp = client.get(f"/api/openapi/jobs/{job_id}") + assert resp.status_code == 200 + assert resp.json()["job_id"] == job_id + + def test_import_job_with_classifications(self) -> None: + """Simulate completed import and review classified endpoints.""" + app = create_e2e_app() + + # Seed a completed job directly + ep_read = _fake_endpoint("/orders/{id}", "GET", "getOrder", "Get order") + ep_write = _fake_endpoint("/orders/{id}/cancel", "POST", "cancelOrder", "Cancel order") + + clf_read = _fake_classification(ep_read, "read", False, "order_lookup") + clf_write = _fake_classification(ep_write, "write", True, "order_actions") + + job_id = "test-job-001" + _job_store[job_id] = { + "job_id": job_id, + "status": "done", + "spec_url": "https://api.example.com/openapi.json", + "total_endpoints": 2, + "classified_count": 2, + "error_message": None, + "classifications": [clf_read, clf_write], + } + + with TestClient(app) as client: + # Step 1: Get classifications + resp = client.get(f"/api/openapi/jobs/{job_id}/classifications") + assert resp.status_code == 200 + classifications = resp.json() + assert len(classifications) == 2 + + # Verify read endpoint + read_clf = classifications[0] + assert read_clf["access_type"] == "read" + assert read_clf["needs_interrupt"] is False + assert read_clf["endpoint"]["path"] == "/orders/{id}" + + # Verify write endpoint + write_clf = classifications[1] + assert write_clf["access_type"] == "write" + assert write_clf["needs_interrupt"] is True + assert write_clf["endpoint"]["path"] == "/orders/{id}/cancel" + + # Step 2: Update a classification + resp = client.put( + f"/api/openapi/jobs/{job_id}/classifications/0", + json={ + "access_type": "write", + "needs_interrupt": True, + "agent_group": "order_actions", + }, + ) + assert resp.status_code == 200 + updated = resp.json() + assert updated["access_type"] == "write" + assert updated["needs_interrupt"] is True + assert updated["agent_group"] == "order_actions" + + # Step 3: Approve the job + resp = client.post(f"/api/openapi/jobs/{job_id}/approve") + assert resp.status_code == 200 + assert resp.json()["status"] == "approved" + + def test_import_nonexistent_job_returns_404(self) -> None: + app = create_e2e_app() + + with TestClient(app) as client: + resp = client.get("/api/openapi/jobs/nonexistent") + assert resp.status_code == 404 + + def test_import_invalid_url_returns_422(self) -> None: + app = create_e2e_app() + + with TestClient(app) as client: + resp = client.post("/api/openapi/import", json={"url": "not-a-url"}) + assert resp.status_code == 422 + + def test_classification_index_out_of_range(self) -> None: + app = create_e2e_app() + + job_id = "test-job-range" + _job_store[job_id] = { + "job_id": job_id, + "status": "done", + "spec_url": "https://example.com/spec.json", + "total_endpoints": 1, + "classified_count": 1, + "error_message": None, + "classifications": [_fake_classification()], + } + + with TestClient(app) as client: + resp = client.put( + f"/api/openapi/jobs/{job_id}/classifications/99", + json={ + "access_type": "read", + "needs_interrupt": False, + "agent_group": "order_lookup", + }, + ) + assert resp.status_code == 404 + + def test_update_classification_invalid_agent_group(self) -> None: + app = create_e2e_app() + + job_id = "test-job-invalid" + _job_store[job_id] = { + "job_id": job_id, + "status": "done", + "spec_url": "https://example.com/spec.json", + "total_endpoints": 1, + "classified_count": 1, + "error_message": None, + "classifications": [_fake_classification()], + } + + with TestClient(app) as client: + resp = client.put( + f"/api/openapi/jobs/{job_id}/classifications/0", + json={ + "access_type": "read", + "needs_interrupt": False, + "agent_group": "invalid group!", # spaces and special chars + }, + ) + assert resp.status_code == 422 diff --git a/backend/tests/e2e/test_replay_analytics.py b/backend/tests/e2e/test_replay_analytics.py new file mode 100644 index 0000000..919e041 --- /dev/null +++ b/backend/tests/e2e/test_replay_analytics.py @@ -0,0 +1,214 @@ +"""E2E tests for replay and analytics flows (flow 6). + +Flow 6: list conversations -> select one -> step-by-step replay. +Also tests the analytics dashboard endpoint. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from starlette.testclient import TestClient + +from tests.e2e.conftest import FakePool, create_e2e_app + +pytestmark = pytest.mark.e2e + + +# --------------------------------------------------------------------------- +# Custom pool that returns specific data per query +# --------------------------------------------------------------------------- + + +class ReplayPool(FakePool): + """Pool that returns different data depending on the SQL query.""" + + def __init__( + self, + conversations: list[dict] | None = None, + checkpoints: list[dict] | None = None, + analytics_rows: list[dict] | None = None, + ) -> None: + super().__init__() + self._conversations = conversations or [] + self._checkpoints = checkpoints or [] + self._analytics = analytics_rows or [] + + class _Conn: + def __init__(self, convos, checkpoints, analytics): + self._convos = convos + self._checkpoints = checkpoints + self._analytics = analytics + + async def execute(self, query: str, params=None): + from tests.e2e.conftest import FakeCursor + + if "conversations" in query and "SELECT" in query: + return FakeCursor(self._convos) + if "checkpoints" in query: + return FakeCursor(self._checkpoints) + # Analytics queries + return FakeCursor(self._analytics) + + def connection(self): + from contextlib import asynccontextmanager + + conn = self._Conn(self._conversations, self._checkpoints, self._analytics) + + @asynccontextmanager + async def _ctx(): + yield conn + + return _ctx() + + +class TestFlow6ReplayConversation: + """Flow 6: list conversations -> select one -> step replay.""" + + def test_list_conversations(self) -> None: + now = datetime.now(tz=timezone.utc).isoformat() + conversations = [ + { + "thread_id": "conv-001", + "created_at": now, + "last_activity": now, + "status": "active", + "total_tokens": 150, + "total_cost_usd": 0.003, + }, + { + "thread_id": "conv-002", + "created_at": now, + "last_activity": now, + "status": "completed", + "total_tokens": 300, + "total_cost_usd": 0.006, + }, + ] + pool = ReplayPool(conversations=conversations) + app = create_e2e_app(pool=pool) + + with TestClient(app) as client: + resp = client.get("/api/conversations") + assert resp.status_code == 200 + body = resp.json() + assert body["success"] is True + assert len(body["data"]) == 2 + assert body["data"][0]["thread_id"] == "conv-001" + assert body["data"][1]["thread_id"] == "conv-002" + + def test_list_conversations_pagination(self) -> None: + conversations = [ + { + "thread_id": f"conv-{i:03d}", + "created_at": "2026-04-01T00:00:00Z", + "last_activity": "2026-04-01T00:00:00Z", + "status": "active", + "total_tokens": 100, + "total_cost_usd": 0.001, + } + for i in range(5) + ] + pool = ReplayPool(conversations=conversations) + app = create_e2e_app(pool=pool) + + with TestClient(app) as client: + resp = client.get("/api/conversations", params={"page": 1, "per_page": 2}) + assert resp.status_code == 200 + body = resp.json() + assert body["success"] is True + + def test_replay_thread_not_found(self) -> None: + pool = ReplayPool(checkpoints=[]) + app = create_e2e_app(pool=pool) + + with TestClient(app) as client: + resp = client.get("/api/replay/nonexistent-thread") + assert resp.status_code == 404 + + def test_replay_invalid_thread_id_format(self) -> None: + app = create_e2e_app() + + with TestClient(app) as client: + # Thread ID with special chars fails regex validation + resp = client.get("/api/replay/invalid%20thread%21%40") + assert resp.status_code == 400 + + +class TestAnalyticsDashboard: + """Analytics endpoint tests.""" + + def test_analytics_invalid_range_format(self) -> None: + app = create_e2e_app() + + with TestClient(app) as client: + resp = client.get("/api/analytics", params={"range": "invalid"}) + assert resp.status_code == 400 + + def test_analytics_range_too_large(self) -> None: + app = create_e2e_app() + + with TestClient(app) as client: + resp = client.get("/api/analytics", params={"range": "999d"}) + assert resp.status_code == 400 + + def test_analytics_range_zero_rejected(self) -> None: + app = create_e2e_app() + + with TestClient(app) as client: + resp = client.get("/api/analytics", params={"range": "0d"}) + assert resp.status_code == 400 + + +class TestFullUserJourney: + """End-to-end journey: chat -> then check replay list shows the conversation.""" + + def test_chat_then_check_conversations_endpoint(self) -> None: + """After chatting via WebSocket, the conversations endpoint is reachable.""" + from tests.e2e.conftest import make_chunk, make_graph + + graph = make_graph(chunks=[make_chunk("Your order is shipped.")]) + now = datetime.now(tz=timezone.utc).isoformat() + pool = ReplayPool( + conversations=[ + { + "thread_id": "e2e-journey-1", + "created_at": now, + "last_activity": now, + "status": "active", + "total_tokens": 50, + "total_cost_usd": 0.001, + }, + ], + ) + app = create_e2e_app(graph=graph, pool=pool) + + with TestClient(app) as client: + # Step 1: Chat via WebSocket + with client.websocket_connect("/ws") as ws: + ws.send_json({ + "type": "message", + "thread_id": "e2e-journey-1", + "content": "Where is my order?", + }) + messages = [] + for _ in range(20): + msg = ws.receive_json() + messages.append(msg) + if msg["type"] in ("message_complete", "error"): + break + assert any(m["type"] == "message_complete" for m in messages) + + # Step 2: Check conversations endpoint + resp = client.get("/api/conversations") + assert resp.status_code == 200 + body = resp.json() + assert body["success"] is True + assert any( + c["thread_id"] == "e2e-journey-1" for c in body["data"] + ) + + # Step 3: Health check still works + resp = client.get("/api/health") + assert resp.status_code == 200 diff --git a/backend/tests/unit/test_ws_handler.py b/backend/tests/unit/test_ws_handler.py index f88c4b5..446589d 100644 --- a/backend/tests/unit/test_ws_handler.py +++ b/backend/tests/unit/test_ws_handler.py @@ -168,7 +168,10 @@ class TestHandleUserMessage: sm = SessionManager(session_ttl_seconds=0) cb = TokenUsageCallbackHandler() + # First call creates the session (TTL=0) await handle_user_message(ws, graph, sm, cb, "t1", "hello") + # Second call finds it expired + await handle_user_message(ws, graph, sm, cb, "t1", "hello again") call_data = ws.send_json.call_args[0][0] assert call_data["type"] == "error" assert "expired" in call_data["message"].lower() diff --git a/docker-compose.yml b/docker-compose.yml index 4172462..f997c62 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,10 @@ services: LLM_MODEL: ${LLM_MODEL:-claude-sonnet-4-6} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-} + AZURE_OPENAI_API_KEY: ${AZURE_OPENAI_API_KEY:-} + AZURE_OPENAI_ENDPOINT: ${AZURE_OPENAI_ENDPOINT:-} + AZURE_OPENAI_DEPLOYMENT: ${AZURE_OPENAI_DEPLOYMENT:-} + AZURE_OPENAI_API_VERSION: ${AZURE_OPENAI_API_VERSION:-2024-12-01-preview} GOOGLE_API_KEY: ${GOOGLE_API_KEY:-} WEBHOOK_URL: ${WEBHOOK_URL:-} SESSION_TTL_MINUTES: ${SESSION_TTL_MINUTES:-30} diff --git a/docs/ux_design_system.md b/docs/ux_design_system.md new file mode 100644 index 0000000..136799f --- /dev/null +++ b/docs/ux_design_system.md @@ -0,0 +1,92 @@ +# Smart Support UX Design System + +This document outlines the core User Experience (UX) and User Interface (UI) design standards for the Smart Support platform. Our visual identity departs from the generic "tech cold blue/white" default, leaning into a premium, trustworthy, and organic "Warm Beige" aesthetic targeted at high-end B2B SaaS buyers. + +## 1. Core Philosophy + +* **Trust Through Warmth:** Customer support tools need to inspire confidence. We use an organic "Rich Warm Beige" canvas paired with "Deep Slate/Walnut" typography to feel more like a premium workspace (e.g., Notion, high-end interior design) rather than a sterile terminal. +* **Action over Text:** This is an *Action Layer*, not just a chatbot. Destructive or high-risk actions (refunds, cancellations) must visually "jump out" from the conversation flow via elevated cards. +* **Expansive Workspace:** Leverage horizontal screen space. Instead of a narrow 800px ChatGPT-style centered column, our workspace flows fluidly to the edges, similar to Slack or Zendesk. + +--- + +## 2. Color Palette (Design Tokens) + +All colors are strictly mapped to CSS Variables in `index.css`. **Do not use hardcoded hex values in components.** + +### Backgrounds & Surfaces +| Token | Hex | Usage | +| :--- | :--- | :--- | +| `App Wrapper` | `#DBD2C6` | The absolute outermost canvas (the "Dribbble presentation frame"). Visible only on large screens as a dark beige border. | +| `--bg-app` | `#F4EFE7` | The primary background color for the application shell and main content areas. | +| `--bg-surface` | `#EBE4D8` | Slightly darker beige. Used for elevated cards, the sidebar, and inputs to create depth. | +| `--bg-surface-inner` | `#F6F2EC` | A lighter inner container fill, often used as table headers or secondary nested boxes. | +| `--bg-hover` | `#E1D9CC` | Hover state backgrounds, active navigation item pills, and disabled button states. | + +### Typography & Ink +| Token | Hex | Usage | +| :--- | :--- | :--- | +| `--text-primary` | `#1C1917` | Primary text (Headings, body copy). A deep brownish-slate, entirely avoiding harsh #000000 black. | +| `--text-secondary` | `#5C554D` | Secondary UI text, metadata, table column headers, and timestamps. | + +### Brand & Interactive Elements +| Token | Hex | Usage | +| :--- | :--- | :--- | +| `--brand-primary` | `#3B342D` | Primary buttons, brand icons, and active UI states. | +| `--brand-hover` | `#26211C` | Hover states for primary interactive elements. | +| `--border-light` | `#D5CCC0` | Dividers, subtle borders around cards and tables. | + +--- + +## 3. Typography + +* **Font Family:** `'Inter', system-ui, -apple-system, sans-serif` +* **Scale:** We rely on sharp, structural typography rather than excess lines to create hierarchy. + * **Headers (h2/h3):** `700` (Bold), tight letter-spacing (`-0.01em`). + * **Nav & Buttons:** `600` (Semi-bold), `0.9375rem` (15px) or `0.875rem` (14px). + * **Micro-text (Badges/Labels):** `0.75rem` (12px), uppercase, generous letter-spacing (`0.05em`). + +--- + +## 4. The "Framed Window" Layout Paradigm + +Rather than a UI that bleeds indefinitely to the edges of an ultrawide monitor, the Smart Support UI employs a **Responsive Window Frame**, while maintaining a flat visual hierarchy: + +* **Small Screens / Mobile (< 768px):** The `.app-layout` merges with the browser edges (`100vw/100vh`, `0px` border-radius). +* **Large Screens (>= 768px):** The App shrinks slightly, creating a `1.5rem` (24px) margin on all sides against a slightly darker background. The app window gets a luxury `20px` border-radius and a soft, diffused drop shadow. +* **Flat Visual Hierarchy:** The Sidebar background is slightly darker (`--bg-surface`) than the main work area (`--bg-app`). They sit adjacent to each other without inner dividing boxed margins. The border line is implicitly created by the tone difference. +* **Content Alignment:** The main `app-main` area does *not* center its content in a narrow channel. It uses full-width fluid layouts with standard left and right paddings (e.g., `3rem`). + +--- + +## 5. Component Signatures + +### Micro-interactions & Loading States (New) +* **Skeleton Loading:** Never use harsh unstyled "Loading..." text strings. Utilize the `.skeleton-box` and `.skeleton-text` CSS classes which provide a smooth 1.5s pulse animation looping between `--bg-hover` and `--border-light`. +* **Graceful Rendering:** Content blocks should be replaced fully by matching structured skeletons outlining the UI during any data fetch or mock delay. + +### Information Visual Hierarchy & Audit Trails (New) +* **Visual Noise Reduction:** Do not treat all logs equally. On Audit or Timeline screens (e.g. Conversation Replay), raw system logs like Tool Calls or Intent extractions must be rendered quietly as muted, italic text without background bubbles. +* **Focus Highlighting:** The highest visual weight in logs is reserved strictly for Human-to-AI interaction messages, Human-in-the-Loop Interventions, and critical overrides. Use distinctive background panels (e.g. pale red, soft lavender) only for these elevated actions. + +### The Sidebar (Nav) +* **Tone-on-Tone:** Active navigation item pills should rely strictly on capsule background fills (`--bg-hover`) rather than font color switches or jarring left-bars. + +### Action Cards (Human-in-the-Loop) +When an agent stops to ask for human confirmation (e.g., "This refund is >$1,000"): +1. **Isolate:** It must render as a distinct UI card (`.action-card`), jumping out from the standard Markdown text flow. +2. **Color Stripe:** It uses a high-contrast left border (e.g., Red `#DC2626` for security approvals) to signal importance. +3. **Shadows:** Elevated using `box-shadow: var(--shadow-lg)` to hover above the conversation. + +### Data Tables & Analytics +* **No Vertical Borders:** Tables should only use horizontal lines (`border-bottom`) to separate rows. Vertical lines feel too rigid and clunky. +* **Hover Rows:** Wrap standard rows in a hover transition (`background-color: var(--bg-hover)`) to help the eye track long data strings. +* **Metric Boxes:** Important KPI statistics (like those on Dashboard) are housed in thick, rounded boxes (`--radius-xl`) to look like physical widgets. + +--- + +## 6. CSS Best Practices for the Project + +1. **Avoid Inline Styles:** All recurring UI patterns (like `btn`, `page-header`, `metricsGrid`) should map to CSS classes in `index.css`. +2. **Use REM for Spacing/Sizing:** Prefer `rem` over `px` for paddings, margins, and font sizes to ensure accessibility scaling. +3. **Soft Shadows Only:** Shadows should have high blur radiuses and low opacity. *Bad: `rgba(0,0,0,0.5) 0px 5px`.* *Good: `rgba(0,0,0,0.06) 0px 10px 30px`*. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8f9e1dd..982d5f4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "react": "^19.0.0", "react-dom": "^19.0.0", + "react-markdown": "^10.1.0", "react-router-dom": "^7.13.2" }, "devDependencies": { @@ -1196,18 +1197,58 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1223,6 +1264,18 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1244,6 +1297,16 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.12", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", @@ -1312,6 +1375,66 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1336,14 +1459,12 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1357,6 +1478,41 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.328", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz", @@ -1416,6 +1572,22 @@ "node": ">=6" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1459,6 +1631,118 @@ "node": ">=6.9.0" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1492,6 +1776,16 @@ "node": ">=6" } }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1502,11 +1796,605 @@ "yallist": "^3.0.2" } }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "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": { @@ -1535,6 +2423,31 @@ "dev": true, "license": "MIT" }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1584,6 +2497,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -1605,6 +2528,33 @@ "react": "^19.2.4" } }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -1653,6 +2603,39 @@ "react-dom": ">=18" } }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rollup": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", @@ -1730,6 +2713,48 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1747,6 +2772,26 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/typescript": { "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", @@ -1761,6 +2806,93 @@ "node": ">=14.17" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -1792,6 +2924,34 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", @@ -1873,6 +3033,16 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 4934b16..7b52305 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "dependencies": { "react": "^19.0.0", "react-dom": "^19.0.0", + "react-markdown": "^10.1.0", "react-router-dom": "^7.13.2" }, "devDependencies": { diff --git a/frontend/src/components/ChatInput.tsx b/frontend/src/components/ChatInput.tsx index f42b8d5..7e557d3 100644 --- a/frontend/src/components/ChatInput.tsx +++ b/frontend/src/components/ChatInput.tsx @@ -23,46 +23,23 @@ export function ChatInput({ onSend, disabled }: Props) { }; return ( -
- setValue(e.target.value)} - onKeyDown={handleKeyDown} - placeholder={disabled ? "Waiting for response..." : "Type a message..."} - disabled={disabled} - style={styles.input} - /> - +
+
+ setValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={disabled ? "Agent is working..." : "Message Smart Support..."} + disabled={disabled} + /> + +
); } - -const styles: Record = { - container: { - display: "flex", - gap: "8px", - padding: "12px 16px", - borderTop: "1px solid #e0e0e0", - background: "white", - }, - input: { - flex: 1, - padding: "10px 14px", - border: "1px solid #ccc", - borderRadius: "8px", - fontSize: "14px", - outline: "none", - }, - button: { - padding: "10px 20px", - background: "#0066cc", - color: "white", - border: "none", - borderRadius: "8px", - fontSize: "14px", - cursor: "pointer", - }, -}; diff --git a/frontend/src/components/ChatMessages.tsx b/frontend/src/components/ChatMessages.tsx index db58487..326d1d5 100644 --- a/frontend/src/components/ChatMessages.tsx +++ b/frontend/src/components/ChatMessages.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from "react"; +import ReactMarkdown from "react-markdown"; import type { ChatMessage } from "../types"; interface Props { @@ -13,70 +14,33 @@ export function ChatMessages({ messages }: Props) { }, [messages]); return ( -
+
{messages.map((msg) => ( -
-
- - {msg.sender === "user" ? "You" : msg.agent || "Agent"} - +
+
+ {msg.sender === "user" ? "Me" : "AI"}
-
- {msg.content} - {msg.isStreaming && |} +
+
+ {msg.sender === "user" ? "You" : msg.agent || "Agent"} +
+
+ {msg.content} + {msg.isStreaming && |} +
))} + {messages.length === 0 && ( +
+
AI
+
+
Smart Support
+
Hello! How can I help you today?
+
+
+ )}
); } - -const styles: Record = { - container: { - flex: 1, - overflowY: "auto", - padding: "16px", - display: "flex", - flexDirection: "column", - gap: "12px", - }, - message: { - maxWidth: "80%", - padding: "10px 14px", - borderRadius: "12px", - lineHeight: 1.5, - }, - userMessage: { - alignSelf: "flex-end", - background: "#0066cc", - color: "white", - }, - agentMessage: { - alignSelf: "flex-start", - background: "#f0f0f0", - color: "#333", - }, - header: { - marginBottom: "4px", - }, - sender: { - fontSize: "12px", - fontWeight: 600, - opacity: 0.8, - }, - content: { - fontSize: "14px", - whiteSpace: "pre-wrap", - }, - cursor: { - animation: "blink 1s infinite", - opacity: 0.7, - }, -}; diff --git a/frontend/src/components/InterruptPrompt.tsx b/frontend/src/components/InterruptPrompt.tsx index a489045..a8d4963 100644 --- a/frontend/src/components/InterruptPrompt.tsx +++ b/frontend/src/components/InterruptPrompt.tsx @@ -7,75 +7,49 @@ interface Props { export function InterruptPrompt({ interrupt, onRespond }: Props) { return ( -
-
Action Requires Approval
-
- Action: {interrupt.action} -
- {"message" in interrupt.params && interrupt.params.message != null && ( -
{String(interrupt.params.message)}
- )} - {"order_id" in interrupt.params && interrupt.params.order_id != null && ( -
- Order: {String(interrupt.params.order_id)} +
+
+
+ + + + + +

Action Requires Approval

+
+ Pending +
+ +
+
+ Action Name + {interrupt.action} +
+ + {"message" in interrupt.params && interrupt.params.message != null && ( +
+ Detail Message + {String(interrupt.params.message)} +
+ )} + + {"order_id" in interrupt.params && interrupt.params.order_id != null && ( +
+ Target Order ID + {String(interrupt.params.order_id)} +
+ )} +
+ +
+ +
- )} -
- -
); } - -const styles: Record = { - container: { - margin: "12px 16px", - padding: "16px", - border: "2px solid #ff9800", - borderRadius: "12px", - background: "#fff8e1", - }, - header: { - fontWeight: 700, - fontSize: "14px", - color: "#e65100", - marginBottom: "8px", - }, - action: { - fontSize: "14px", - marginBottom: "4px", - }, - detail: { - fontSize: "13px", - color: "#555", - marginBottom: "4px", - }, - buttons: { - display: "flex", - gap: "8px", - marginTop: "12px", - }, - approveBtn: { - padding: "8px 20px", - background: "#4caf50", - color: "white", - border: "none", - borderRadius: "6px", - cursor: "pointer", - fontWeight: 600, - }, - rejectBtn: { - padding: "8px 20px", - background: "#f44336", - color: "white", - border: "none", - borderRadius: "6px", - cursor: "pointer", - fontWeight: 600, - }, -}; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index f6d6410..4b75134 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -3,9 +3,9 @@ import { NavBar } from "./NavBar"; export function Layout() { return ( -
+
-
+
diff --git a/frontend/src/components/NavBar.tsx b/frontend/src/components/NavBar.tsx index 5eb9d70..7b1a217 100644 --- a/frontend/src/components/NavBar.tsx +++ b/frontend/src/components/NavBar.tsx @@ -1,64 +1,56 @@ import { NavLink } from "react-router-dom"; const navLinks = [ - { to: "/", label: "Chat", exact: true }, - { to: "/replay", label: "Replay" }, - { to: "/dashboard", label: "Dashboard" }, - { to: "/review", label: "API Review" }, + { to: "/dashboard", label: "Dashboard", icon: "grid" }, + { to: "/", label: "Inbox", icon: "inbox" }, + { to: "/replay", label: "Conversation Replay", icon: "play" }, + { to: "/review", label: "Agents & Tools", icon: "cpu" }, ]; -const styles: Record = { - nav: { - display: "flex", - alignItems: "center", - gap: "0", - padding: "0 16px", - borderBottom: "1px solid #e0e0e0", - background: "#fff", - height: "48px", - boxShadow: "0 1px 4px rgba(0,0,0,0.06)", - }, - brand: { - fontWeight: 700, - fontSize: "16px", - color: "#1a1a1a", - marginRight: "24px", - textDecoration: "none", - }, - link: { - padding: "0 14px", - height: "48px", - display: "flex", - alignItems: "center", - fontSize: "14px", - color: "#555", - textDecoration: "none", - borderBottom: "2px solid transparent", - transition: "color 0.15s, border-color 0.15s", - }, - activeLink: { - color: "#1976d2", - borderBottom: "2px solid #1976d2", - }, -}; +function getIcon(name: string) { + switch (name) { + case "grid": return ; + case "inbox": return ; + case "play": return ; + case "cpu": return ; + default: return null; + } +} export function NavBar() { return ( -