feat(ui): implement premium beige design system and ux refinements

This commit is contained in:
Yaojia Wang
2026-04-05 22:35:48 +02:00
parent d2b4610df9
commit 189a0fad34
30 changed files with 3651 additions and 801 deletions

View File

@@ -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=

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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'."
)

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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}

92
docs/ux_design_system.md Normal file
View File

@@ -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`*.

File diff suppressed because it is too large Load Diff

View File

@@ -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": {

View File

@@ -23,46 +23,23 @@ export function ChatInput({ onSend, disabled }: Props) {
};
return (
<div style={styles.container}>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={disabled ? "Waiting for response..." : "Type a message..."}
disabled={disabled}
style={styles.input}
/>
<button onClick={handleSubmit} disabled={disabled || !value.trim()} style={styles.button}>
Send
</button>
<div className="chat-input-container">
<div className="chat-input-wrapper">
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={disabled ? "Agent is working..." : "Message Smart Support..."}
disabled={disabled}
/>
<button className="chat-send-btn" onClick={handleSubmit} disabled={disabled || !value.trim()} aria-label="Send Message">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
</button>
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
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",
},
};

View File

@@ -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 (
<div style={styles.container}>
<div className="chat-messages-container">
{messages.map((msg) => (
<div
key={msg.id}
style={{
...styles.message,
...(msg.sender === "user" ? styles.userMessage : styles.agentMessage),
}}
>
<div style={styles.header}>
<span style={styles.sender}>
{msg.sender === "user" ? "You" : msg.agent || "Agent"}
</span>
<div key={msg.id} className="chat-message-row">
<div className={`avatar ${msg.sender === "user" ? "user" : "agent"}`}>
{msg.sender === "user" ? "Me" : "AI"}
</div>
<div style={styles.content}>
{msg.content}
{msg.isStreaming && <span style={styles.cursor}>|</span>}
<div className="message-body">
<div className="message-sender">
{msg.sender === "user" ? "You" : msg.agent || "Agent"}
</div>
<div className="message-content md-prose">
<ReactMarkdown>{msg.content}</ReactMarkdown>
{msg.isStreaming && <span className="cursor-blink">|</span>}
</div>
</div>
</div>
))}
{messages.length === 0 && (
<div className="chat-message-row">
<div className="avatar agent">AI</div>
<div className="message-body">
<div className="message-sender">Smart Support</div>
<div className="message-content">Hello! How can I help you today?</div>
</div>
</div>
)}
<div ref={bottomRef} />
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
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,
},
};

View File

@@ -7,75 +7,49 @@ interface Props {
export function InterruptPrompt({ interrupt, onRespond }: Props) {
return (
<div style={styles.container}>
<div style={styles.header}>Action Requires Approval</div>
<div style={styles.action}>
<strong>Action:</strong> {interrupt.action}
</div>
{"message" in interrupt.params && interrupt.params.message != null && (
<div style={styles.detail}>{String(interrupt.params.message)}</div>
)}
{"order_id" in interrupt.params && interrupt.params.order_id != null && (
<div style={styles.detail}>
<strong>Order:</strong> {String(interrupt.params.order_id)}
<div className="action-card-container">
<div className="action-card">
<div className="action-card-header">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--brand-accent)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
<h3 className="action-card-title">Action Requires Approval</h3>
<div style={{ flex: 1 }} />
<span className="action-card-badge">Pending</span>
</div>
<div className="action-card-body">
<div className="action-detail-row">
<span className="action-detail-label">Action Name</span>
<span className="action-detail-value" style={{ fontWeight: 600 }}>{interrupt.action}</span>
</div>
{"message" in interrupt.params && interrupt.params.message != null && (
<div className="action-detail-row">
<span className="action-detail-label">Detail Message</span>
<span className="action-detail-value">{String(interrupt.params.message)}</span>
</div>
)}
{"order_id" in interrupt.params && interrupt.params.order_id != null && (
<div className="action-detail-row">
<span className="action-detail-label">Target Order ID</span>
<span className="action-detail-value">{String(interrupt.params.order_id)}</span>
</div>
)}
</div>
<div className="action-card-footer">
<button className="btn btn-secondary" onClick={() => onRespond(false)}>
Reject & Escalate
</button>
<button className="btn btn-primary" onClick={() => onRespond(true)}>
Approve Action
</button>
</div>
)}
<div style={styles.buttons}>
<button onClick={() => onRespond(true)} style={styles.approveBtn}>
Approve
</button>
<button onClick={() => onRespond(false)} style={styles.rejectBtn}>
Reject
</button>
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
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,
},
};

View File

@@ -3,9 +3,9 @@ import { NavBar } from "./NavBar";
export function Layout() {
return (
<div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
<div className="app-layout">
<NavBar />
<main style={{ flex: 1, overflow: "auto" }}>
<main className="app-main">
<Outlet />
</main>
</div>

View File

@@ -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<string, React.CSSProperties> = {
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 <svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>;
case "inbox": return <svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"></polyline><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path></svg>;
case "play": return <svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>;
case "cpu": return <svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect><rect x="9" y="9" width="6" height="6"></rect><line x1="9" y1="1" x2="9" y2="4"></line><line x1="15" y1="1" x2="15" y2="4"></line><line x1="9" y1="20" x2="9" y2="23"></line><line x1="15" y1="20" x2="15" y2="23"></line><line x1="20" y1="9" x2="23" y2="9"></line><line x1="20" y1="14" x2="23" y2="14"></line><line x1="1" y1="9" x2="4" y2="9"></line><line x1="1" y1="14" x2="4" y2="14"></line></svg>;
default: return null;
}
}
export function NavBar() {
return (
<nav style={styles.nav}>
<span style={styles.brand}>Smart Support</span>
{navLinks.map(({ to, label }) => (
<NavLink
key={to}
to={to}
end={to === "/"}
style={({ isActive }) => ({
...styles.link,
...(isActive ? styles.activeLink : {}),
})}
>
{label}
</NavLink>
))}
<nav className="app-sidebar">
<div className="brand-header">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="brand-logo-svg">
<circle cx="12" cy="12" r="10"></circle>
<path d="M8 14s1.5 2 4 2 4-2 4-2"></path>
<line x1="9" y1="9" x2="9.01" y2="9"></line>
<line x1="15" y1="9" x2="15.01" y2="9"></line>
</svg>
<span style={{ fontSize: "1.25rem", letterSpacing: "-0.03em" }}>Nexus AI</span>
</div>
<div className="nav-links" style={{ marginTop: "1rem" }}>
{navLinks.map(({ to, label, icon }) => (
<NavLink
key={to}
to={to}
end={to === "/"}
className={({ isActive }) => `nav-link ${isActive ? "active" : ""}`}
style={{ display: "flex", gap: "12px", padding: "0.875rem 1rem", fontSize: "0.9375rem" }}
>
<span style={{ opacity: 0.7 }}>{getIcon(icon)}</span>
{label}
</NavLink>
))}
</div>
<div style={{ flex: 1 }} />
<div style={{ display: "flex", alignItems: "center", gap: "12px", borderTop: "1px solid var(--border-light)", paddingTop: "1rem" }}>
<div style={{ width: "36px", height: "36px", borderRadius: "50%", background: "var(--brand-primary)", color: "white", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: "bold" }}>A</div>
<div>
<div style={{ fontWeight: 600, fontSize: "0.875rem" }}>Alex Thompson</div>
<div style={{ fontSize: "0.75rem", color: "var(--text-secondary)" }}>Nexus Corp</div>
</div>
</div>
</nav>
);
}

View File

@@ -2,31 +2,31 @@ import { useState } from "react";
import type { ReplayStep } from "../api";
const TYPE_COLORS: Record<string, string> = {
message: "#1976d2",
token: "#388e3c",
tool_call: "#f57c00",
tool_result: "#7b1fa2",
interrupt: "#d32f2f",
interrupt_response: "#c2185b",
error: "#c62828",
message: "var(--brand-primary)",
token: "#9CA3AF", // Soft gray
tool_call: "#D97706", // Amber
tool_result: "#059669", // Emerald
interrupt: "#DC2626", // Red for wait
interrupt_response: "#7C3AED", // Purple for human action
error: "#991B1B", // Dark red
};
function TypeBadge({ type }: { type: string }) {
const color = TYPE_COLORS[type] ?? "#555";
const color = TYPE_COLORS[type] ?? "var(--text-secondary)";
return (
<span
style={{
background: color,
color: "#fff",
fontSize: "11px",
fontWeight: 600,
padding: "2px 7px",
borderRadius: "10px",
fontSize: "0.65rem",
fontWeight: 700,
padding: "0.2rem 0.5rem",
borderRadius: "99px",
textTransform: "uppercase",
letterSpacing: "0.5px",
letterSpacing: "0.05em",
}}
>
{type}
{type.replace("_", " ")}
</span>
);
}
@@ -38,9 +38,9 @@ function ReplayStepItem({ step }: { step: ReplayStep }) {
return (
<div
style={{
borderLeft: "2px solid #e0e0e0",
paddingLeft: "12px",
marginBottom: "12px",
borderLeft: "2px solid var(--border-light)",
paddingLeft: "1.25rem",
paddingBottom: "1.5rem",
position: "relative",
}}
>
@@ -48,70 +48,91 @@ function ReplayStepItem({ step }: { step: ReplayStep }) {
style={{
position: "absolute",
left: "-5px",
top: "4px",
top: "6px",
width: "8px",
height: "8px",
borderRadius: "50%",
background: TYPE_COLORS[step.type] ?? "#555",
background: TYPE_COLORS[step.type] ?? "var(--text-secondary)",
boxShadow: `0 0 0 4px var(--bg-surface)`
}}
/>
<div style={{ display: "flex", alignItems: "center", gap: "8px", marginBottom: "4px" }}>
<span style={{ fontSize: "11px", color: "#888" }}>#{step.step}</span>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.5rem" }}>
<TypeBadge type={step.type} />
{step.agent && (
<span style={{ fontSize: "11px", color: "#666", fontStyle: "italic" }}>
<span style={{ fontSize: "0.8125rem", color: "var(--text-primary)", fontWeight: 600 }}>
{step.agent}
</span>
)}
{step.tool && (
<span style={{ fontSize: "11px", color: "#555" }}>
tool: <strong>{step.tool}</strong>
<span style={{ fontSize: "0.8125rem", color: "var(--text-secondary)", fontFamily: "monospace", backgroundColor: "var(--bg-app)", padding: "2px 6px", borderRadius: "4px" }}>
{step.tool}()
</span>
)}
<span style={{ fontSize: "11px", color: "#aaa", marginLeft: "auto" }}>
{new Date(step.timestamp).toLocaleTimeString()}
<span style={{ fontSize: "0.75rem", color: "var(--text-secondary)", marginLeft: "auto" }}>
{new Date(step.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
</div>
{step.content && (
<div
style={{
fontSize: "13px",
color: "#333",
background: "#f9f9f9",
padding: "6px 10px",
borderRadius: "4px",
maxHeight: "80px",
overflow: "hidden",
textOverflow: "ellipsis",
}}
style={
["message", "interrupt", "interrupt_response"].includes(step.type)
? {
fontSize: "0.9375rem",
color: "var(--text-primary)",
background: step.type === "interrupt" ? "#FEF2F2" : (step.type === "interrupt_response" ? "#F5F3FF" : "var(--bg-app)"),
border: step.type === "interrupt" ? "1px solid #FECACA" : (step.type === "interrupt_response" ? "1px solid #DDD6FE" : "1px solid var(--border-light)"),
padding: "0.875rem 1rem",
borderRadius: "var(--radius-md)",
lineHeight: 1.5,
whiteSpace: "pre-wrap"
}
: {
fontSize: "0.8125rem",
color: "var(--text-secondary)",
padding: "0.25rem 0",
fontStyle: "italic",
lineHeight: 1.4
}
}
>
{step.content}
</div>
)}
{hasDetails && (
<button
onClick={() => setExpanded((v) => !v)}
style={{
background: "none",
border: "none",
color: "#1976d2",
color: "var(--text-secondary)",
cursor: "pointer",
fontSize: "12px",
padding: "2px 0",
fontSize: "0.75rem",
fontWeight: 600,
padding: "0.5rem 0 0 0",
display: "flex",
alignItems: "center",
gap: "0.25rem"
}}
>
{expanded ? "Hide details" : "Show details"}
{expanded ? "Hide JSON Payload" : "▶ View JSON Payload"}
</button>
)}
{expanded && hasDetails && (
<pre
style={{
fontSize: "11px",
background: "#f3f3f3",
padding: "8px",
borderRadius: "4px",
fontSize: "0.75rem",
background: "var(--text-primary)",
color: "white",
padding: "1rem",
borderRadius: "var(--radius-md)",
overflow: "auto",
maxHeight: "200px",
maxHeight: "250px",
marginTop: "0.5rem",
fontFamily: "monospace"
}}
>
{JSON.stringify({ params: step.params, result: step.result }, null, 2)}
@@ -126,19 +147,14 @@ interface ReplayTimelineProps {
}
export function ReplayTimeline({ steps }: ReplayTimelineProps) {
if (steps.length === 0) {
return (
<div style={{ color: "#888", fontSize: "14px", padding: "16px 0" }}>
No steps recorded.
</div>
);
}
if (!steps || steps.length === 0) return null;
return (
<div style={{ padding: "8px 0" }}>
<div style={{ marginLeft: "4px" }}>
{steps.map((step) => (
<ReplayStepItem key={step.step} step={step} />
))}
<div style={{ borderLeft: "2px dashed var(--border-light)", height: "20px", marginLeft: "0px", opacity: 0.5 }}></div>
</div>
);
}

678
frontend/src/index.css Normal file
View File

@@ -0,0 +1,678 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
:root {
/* Rich Warm Beige Theme based on Design Mockup */
--bg-app: #F4EFE7; /* Main app background (Sidebar & Main area) */
--bg-surface: #EBE4D8; /* Slightly darker for cards */
--bg-surface-inner: #F6F2EC; /* Lighter inner container */
--bg-hover: #E1D9CC; /* Hover state for sidebar and buttons */
--text-primary: #1C1917; /* Slate dark/brownish */
--text-secondary: #5C554D; /* Muted stone */
--border-light: #D5CCC0; /* Warm border */
--border-focus: #B6AAA0;
--brand-primary: #3B342D; /* Dark brown/grey for buttons */
--brand-hover: #26211C;
--brand-accent: #3B342D;
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.02);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.06);
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
--radius-md: 10px;
--radius-lg: 16px;
--radius-xl: 24px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font-family: var(--font-sans);
background-color: #DBD2C6; /* Subtle deeper tone */
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
/* Application Shell Layout */
.app-layout {
display: flex;
height: 100vh;
width: 100vw;
overflow: hidden;
background-color: var(--bg-surface);
box-shadow: none;
border-radius: 0;
}
@media (min-width: 768px) {
body {
padding: 1.5rem;
box-sizing: border-box;
}
.app-layout {
height: calc(100vh - 3rem);
width: 100%;
border-radius: 20px;
box-shadow: 0 10px 30px rgba(0,0,0,0.06), 0 0 0 1px rgba(0,0,0,0.02);
}
}
/* Sidebar layout */
.app-sidebar {
width: 260px;
background-color: transparent; /* Makes it blend into the main background */
border-right: none;
display: flex;
flex-direction: column;
padding: 1.5rem 1rem;
z-index: 10;
}
.brand-header {
font-weight: 700;
font-size: 1.25rem;
color: var(--text-primary);
margin-bottom: 2rem;
padding-left: 0.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
letter-spacing: -0.01em;
}
.nav-links {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.nav-link {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
color: var(--text-secondary);
text-decoration: none;
font-weight: 600;
font-size: 0.9375rem;
transition: all 0.2s ease;
}
.nav-link:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.nav-link.active {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.app-main {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
background-color: var(--bg-app);
}
/* --- Chat Interface (Option B) --- */
.chat-page {
display: flex;
flex-direction: column;
height: 100%;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-light);
background-color: transparent;
z-index: 5;
}
.chat-header h1 {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
color: var(--text-primary);
}
.chat-messages-container {
flex: 1;
overflow-y: auto;
padding: 2rem 0;
scroll-behavior: smooth;
}
.chat-message-row {
display: flex;
gap: 1.25rem;
padding: 1rem;
transition: background-color 0.2s;
}
@media (min-width: 768px) {
.chat-message-row {
padding: 1rem 3rem;
}
}
.chat-message-row:hover {
background-color: var(--bg-hover);
}
.avatar {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.875rem;
}
.avatar.user {
background-color: var(--border-light);
color: var(--text-primary);
}
.avatar.agent {
background: linear-gradient(135deg, var(--brand-primary), #334155);
color: white;
}
.message-body {
flex: 1;
min-width: 0;
}
.message-sender {
font-weight: 600;
font-size: 0.875rem;
margin-bottom: 0.375rem;
color: var(--text-primary);
}
.message-content {
font-size: 0.9375rem;
line-height: 1.6;
color: var(--text-primary);
white-space: pre-wrap;
}
.cursor-blink {
animation: blink 1s infinite alternate;
font-weight: 700;
color: var(--brand-accent);
}
@keyframes blink {
0% { opacity: 1; }
100% { opacity: 0.2; }
}
/* Markdown Prose Styles */
.md-prose p {
margin: 0 0 0.75rem 0;
}
.md-prose p:last-child {
margin-bottom: 0;
}
.md-prose strong {
font-weight: 600;
color: var(--text-primary);
}
.md-prose ul, .md-prose ol {
margin: 0.25rem 0 0.75rem 0;
padding-left: 1.5rem;
}
.md-prose li {
margin-bottom: 0.25rem;
}
.md-prose pre {
background-color: var(--bg-hover);
padding: 0.75rem;
border-radius: var(--radius-md);
overflow-x: auto;
border: 1px solid var(--border-light);
font-size: 0.875rem;
}
.md-prose code {
font-family: monospace;
background-color: var(--bg-hover);
padding: 0.125rem 0.25rem;
border-radius: 4px;
}
.md-prose pre code {
background-color: transparent;
padding: 0;
}
/* Chat Input Bar */
.chat-input-container {
padding: 1.5rem;
background: linear-gradient(to top, var(--bg-app) 80%, transparent);
}
.chat-input-wrapper {
margin: 0 1rem;
position: relative;
display: flex;
align-items: center;
box-shadow: var(--shadow-md);
border-radius: var(--radius-lg);
background-color: var(--bg-surface);
border: 1px solid var(--border-light);
}
@media (min-width: 768px) {
.chat-input-wrapper {
margin: 0 3rem;
}
}
.chat-input-wrapper:focus-within {
border-color: var(--border-focus);
box-shadow: var(--shadow-lg);
}
.chat-input-wrapper input {
flex: 1;
padding: 1rem 1.25rem;
border: none;
background: transparent;
font-size: 1rem;
font-family: inherit;
color: var(--text-primary);
outline: none;
}
.chat-input-wrapper input::placeholder {
color: var(--text-secondary);
}
.chat-send-btn {
margin-right: 0.75rem;
width: 32px;
height: 32px;
border-radius: 6px;
background-color: var(--brand-primary);
color: white;
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.chat-send-btn:hover:not(:disabled) {
background-color: var(--brand-hover);
}
.chat-send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: var(--text-secondary);
}
/* --- Human in the loop Action Card --- */
.action-card-container {
margin: 1.5rem 1rem;
}
@media (min-width: 768px) {
.action-card-container {
margin: 1.5rem 3rem;
}
}
.action-card {
background-color: var(--bg-surface);
border: 1px solid var(--border-light);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
overflow: hidden;
position: relative;
transition: transform 0.2s, box-shadow 0.2s;
}
.action-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 4px;
background-color: var(--brand-accent);
}
.action-card-header {
padding: 1.25rem 1.5rem 0.75rem 1.75rem;
display: flex;
align-items: center;
gap: 0.75rem;
border-bottom: 1px solid var(--bg-hover);
}
.action-card-title {
font-weight: 700;
font-size: 1rem;
color: var(--text-primary);
margin: 0;
}
.action-card-badge {
background-color: #FEF2F2;
color: #B91C1C;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.action-card-body {
padding: 1.25rem 1.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.action-detail-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.action-detail-label {
font-size: 0.75rem;
text-transform: uppercase;
font-weight: 600;
color: var(--text-secondary);
letter-spacing: 0.05em;
}
.action-detail-value {
font-size: 0.9375rem;
color: var(--text-primary);
font-family: var(--font-sans);
background-color: var(--bg-hover);
padding: 0.5rem 0.75rem;
border-radius: var(--radius-md);
border: 1px solid var(--border-light);
word-break: break-word;
}
.action-card-footer {
padding: 1.25rem 1.75rem;
background-color: var(--bg-hover);
border-top: 1px solid var(--border-light);
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1.25rem;
font-size: 0.875rem;
font-weight: 600;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-primary {
background-color: var(--brand-accent);
color: white;
box-shadow: var(--shadow-sm);
}
.btn-primary:hover {
background-color: #C2410C;
box-shadow: var(--shadow-md);
}
.btn-secondary {
background-color: transparent;
color: var(--text-primary);
border: 1px solid var(--border-focus);
}
.btn-secondary:hover {
background-color: var(--bg-surface);
color: var(--text-primary);
}
/* --- Agent Card Grid (Option 2) --- */
.page-container {
padding: 2.5rem;
width: 100%;
overflow-y: auto;
}
.page-header {
margin-bottom: 2rem;
}
.page-header h2 {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 0.5rem 0;
letter-spacing: -0.01em;
}
.page-header p {
color: var(--text-secondary);
margin: 0;
font-size: 0.9375rem;
}
/* Form Styles */
.import-form {
display: flex;
gap: 0.75rem;
margin-bottom: 2rem;
background-color: var(--bg-surface);
padding: 1.5rem;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-light);
}
.import-input {
flex: 1;
padding: 0.75rem 1rem;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
font-size: 0.9375rem;
font-family: inherit;
background-color: var(--bg-app);
color: var(--text-primary);
transition: all 0.2s;
}
.import-input:focus {
outline: none;
border-color: var(--border-focus);
box-shadow: 0 0 0 3px rgba(0,0,0,0.03);
}
/* Agent Grid */
.agent-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.agent-grid-card {
background-color: var(--bg-surface);
border-radius: var(--radius-xl);
border: 1px solid var(--border-light);
box-shadow: var(--shadow-sm);
overflow: hidden;
display: flex;
flex-direction: column;
transition: transform 0.2s, box-shadow 0.2s;
}
.agent-grid-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.agent-card-header-bg {
padding: 1.5rem 1.5rem 1rem 1.5rem;
border-bottom: 1px solid var(--bg-hover);
display: flex;
align-items: center;
gap: 1rem;
}
.agent-avatar-lg {
width: 48px;
height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, var(--text-primary), var(--text-secondary));
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
font-weight: 700;
box-shadow: var(--shadow-sm);
}
.agent-card-meta h3 {
margin: 0 0 0.25rem 0;
font-size: 1.125rem;
font-weight: 700;
color: var(--text-primary);
}
.agent-card-meta span {
font-size: 0.75rem;
color: var(--brand-primary);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.agent-tools-list {
padding: 1.25rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
flex: 1;
background-color: var(--bg-surface-inner);
border-radius: 20px;
margin: 0 1.25rem 1.25rem 1.25rem;
border: 1px solid var(--border-light);
}
.tool-pill-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border-light);
}
.tool-pill-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.tool-pill-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.tool-method-badge {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
padding: 0.2rem 0.5rem;
border-radius: 99px;
background-color: var(--text-primary);
color: white;
letter-spacing: 0.05em;
}
.tool-path-text {
font-family: var(--font-sans);
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tool-summary-text {
font-size: 0.8125rem;
color: var(--text-secondary);
line-height: 1.4;
margin-top: 0.25rem;
}
.tool-pill-controls {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.tool-select, .tool-input {
background-color: transparent;
border: 1px solid var(--border-focus);
border-radius: 6px;
padding: 0.375rem 0.625rem;
font-size: 0.75rem;
color: var(--text-primary);
flex: 1;
}
.tool-select:focus, .tool-input:focus {
outline: none;
border-color: var(--text-primary);
}
/* --- Skeleton Loading Animation --- */
@keyframes pulse-skeleton {
0% { opacity: 0.5; background-color: var(--bg-hover); }
50% { opacity: 0.8; background-color: var(--border-light); }
100% { opacity: 0.5; background-color: var(--bg-hover); }
}
.skeleton-box {
animation: pulse-skeleton 1.5s infinite ease-in-out;
border-radius: var(--radius-md);
}
.skeleton-text {
height: 1rem;
width: 100%;
border-radius: 4px;
animation: pulse-skeleton 1.5s infinite ease-in-out;
}

View File

@@ -1,5 +1,6 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
createRoot(document.getElementById("root")!).render(

View File

@@ -126,15 +126,15 @@ export function ChatPage() {
);
return (
<div style={styles.page}>
<div style={styles.header}>
<h1 style={styles.title}>Smart Support</h1>
<div className="chat-page">
<div className="chat-header">
<h1>Inbox</h1>
<StatusIndicator status={status} />
</div>
<ErrorBanner status={status} onReconnect={reconnect} />
<ChatMessages messages={messages} />
{toolActions.length > 0 && (
<div style={styles.actionsBar}>
<div style={{ borderTop: "1px solid var(--border-light)", paddingTop: "4px" }}>
{toolActions.slice(-3).map((action) => (
<AgentAction key={action.id} action={action} />
))}
@@ -153,9 +153,9 @@ export function ChatPage() {
function StatusIndicator({ status }: { status: ConnectionStatus }) {
const colors: Record<ConnectionStatus, string> = {
connected: "#4caf50",
connecting: "#ff9800",
disconnected: "#f44336",
connected: "#10b981", // Emerald
connecting: "#f59e0b", // Amber
disconnected: "#ef4444", // Red
};
return (
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
@@ -165,38 +165,10 @@ function StatusIndicator({ status }: { status: ConnectionStatus }) {
height: "8px",
borderRadius: "50%",
background: colors[status],
boxShadow: `0 0 8px ${colors[status]}`,
}}
/>
<span style={{ fontSize: "12px", color: "#666" }}>{status}</span>
<span style={{ fontSize: "12px", color: "var(--text-secondary)", fontWeight: 500, textTransform: "capitalize" }}>{status}</span>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
page: {
height: "100vh",
display: "flex",
flexDirection: "column",
background: "white",
maxWidth: "800px",
margin: "0 auto",
boxShadow: "0 0 20px rgba(0,0,0,0.1)",
},
header: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "12px 16px",
borderBottom: "1px solid #e0e0e0",
},
title: {
fontSize: "18px",
fontWeight: 700,
margin: 0,
color: "#333",
},
actionsBar: {
borderTop: "1px solid #eee",
paddingTop: "4px",
},
};

View File

@@ -1,7 +1,4 @@
import { useEffect, useState } from "react";
import { fetchAnalytics } from "../api";
import type { AnalyticsData } from "../api";
import { MetricCard } from "../components/MetricCard";
import { useState, useEffect } from "react";
const RANGE_OPTIONS = [
{ value: "7d", label: "7 days" },
@@ -9,41 +6,69 @@ const RANGE_OPTIONS = [
{ value: "30d", label: "30 days" },
];
function pct(value: number): string {
return `${(value * 100).toFixed(1)}%`;
}
function formatCost(usd: number): string {
return usd < 0.01 ? "<$0.01" : `$${usd.toFixed(3)}`;
}
// Mock Data
const MOCK_DATA = {
total_conversations: 4208,
resolution_rate: 0.724,
escalation_rate: 0.276,
avg_turns_per_conversation: 3.4,
total_tokens: 1450200,
total_cost_usd: 12.45,
agent_usage: [
{ agent_name: "Order Specialist", message_count: 8540, total_tokens: 854000, total_cost_usd: 7.20 },
{ agent_name: "Billing Assistant", message_count: 3120, total_tokens: 412000, total_cost_usd: 3.50 },
{ agent_name: "Router & Orchestrator", message_count: 4208, total_tokens: 184200, total_cost_usd: 1.75 },
],
interrupt_stats: {
total: 412,
approved: 380,
rejected: 28,
expired: 4,
}
};
export function DashboardPage() {
const [range, setRange] = useState("7d");
const [data, setData] = useState<AnalyticsData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [range, setRange] = useState("30d");
const [isLoading, setIsLoading] = useState(true);
const data = MOCK_DATA;
useEffect(() => {
setLoading(true);
setError(null);
fetchAnalytics(range)
.then(setData)
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
setIsLoading(true);
const timer = setTimeout(() => setIsLoading(false), 1200);
return () => clearTimeout(timer);
}, [range]);
function pct(value: number): string {
return `${(value * 100).toFixed(1)}%`;
}
function formatCost(usd: number): string {
return `$${usd.toFixed(2)}`;
}
return (
<div style={styles.container}>
<div style={styles.header}>
<h2 style={styles.heading}>Dashboard</h2>
<div style={styles.rangeSelector}>
<div className="page-container">
<div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: "2rem" }}>
<div>
<h2>Analytics Dashboard</h2>
<p>Monitor AI action performance, automation ROI, and agent efficiency.</p>
</div>
<div style={{ display: "flex", gap: "0.25rem", background: "var(--bg-hover)", padding: "0.25rem", borderRadius: "12px" }}>
{RANGE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setRange(opt.value)}
disabled={isLoading}
style={{
...styles.rangeBtn,
...(range === opt.value ? styles.rangeBtnActive : {}),
padding: "0.5rem 1rem",
border: "none",
borderRadius: "8px",
cursor: isLoading ? "not-allowed" : "pointer",
fontSize: "0.875rem",
fontWeight: 600,
color: range === opt.value ? "white" : "var(--text-secondary)",
backgroundColor: range === opt.value ? "var(--brand-primary)" : "transparent",
transition: "all 0.2s"
}}
>
{opt.label}
@@ -52,133 +77,112 @@ export function DashboardPage() {
</div>
</div>
{loading && <div style={styles.center}>Loading analytics...</div>}
{error && <div style={styles.error}>Error: {error}</div>}
{!loading && !error && data && (
{isLoading ? (
<>
{data.total_conversations === 0 ? (
<div style={styles.empty}>
No conversations yet. Start a chat to see analytics here.
</div>
) : (
<>
<div style={styles.metricsGrid}>
<MetricCard
label="Total Conversations"
value={data.total_conversations}
/>
<MetricCard
label="Resolution Rate"
value={pct(data.resolution_rate)}
/>
<MetricCard
label="Escalation Rate"
value={pct(data.escalation_rate)}
/>
<MetricCard
label="Avg Turns"
value={data.avg_turns_per_conversation.toFixed(1)}
/>
<MetricCard
label="Total Tokens"
value={data.total_tokens.toLocaleString()}
/>
<MetricCard
label="Total Cost"
value={formatCost(data.total_cost_usd)}
/>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: "1.5rem", marginBottom: "2.5rem" }}>
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="skeleton-box" style={{ height: "120px", padding: "1.5rem", borderRadius: "var(--radius-xl)", border: "1px solid var(--border-light)", background: "var(--bg-surface)" }}>
<div className="skeleton-text" style={{ width: "60%", height: "12px", marginBottom: "1.5rem" }}></div>
<div className="skeleton-text" style={{ width: "40%", height: "30px", marginBottom: "1rem" }}></div>
<div className="skeleton-text" style={{ width: "80%", height: "12px" }}></div>
</div>
))}
</div>
<div style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: "1.5rem" }}>
<div className="skeleton-box" style={{ height: "300px", borderRadius: "var(--radius-xl)", background: "var(--bg-surface)" }}></div>
<div className="skeleton-box" style={{ height: "300px", borderRadius: "var(--radius-xl)", background: "var(--bg-surface)" }}></div>
</div>
</>
) : (
<>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: "1.5rem", marginBottom: "2.5rem" }}>
<MetricBox label="Tickets Processed" value={data.total_conversations.toLocaleString()} trend="+12% vs last month" />
<MetricBox label="Auto-Resolution Rate" value={pct(data.resolution_rate)} trend="Target: 70%" positive />
<MetricBox label="Human Escalations" value={pct(data.escalation_rate)} trend="Avg 28%" />
<MetricBox label="Human-in-the-Loop Prompts" value={data.interrupt_stats.total.toLocaleString()} trend="High Risk Actions Intercepted" />
<MetricBox label="LLM Intelligence Cost" value={formatCost(data.total_cost_usd)} trend={`${(data.total_tokens / 1000).toLocaleString()}k Tokens`} />
</div>
<h3 style={styles.sectionHeading}>Agent Usage</h3>
{data.agent_usage.length === 0 ? (
<div style={styles.empty}>No agent data.</div>
) : (
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Agent</th>
<th style={styles.th}>Messages</th>
<th style={styles.th}>Tokens</th>
<th style={styles.th}>Cost</th>
<div style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: "1.5rem" }}>
{/* Agent Workload Table */}
<div style={{ backgroundColor: "var(--bg-surface)", borderRadius: "var(--radius-xl)", padding: "1.5rem", border: "1px solid var(--border-light)" }}>
<h3 style={{ fontSize: "1.125rem", color: "var(--text-primary)", fontWeight: 700, margin: "0 0 1rem 0" }}>Agent Workload Distribution</h3>
<table style={{ width: "100%", borderCollapse: "collapse", textAlign: "left" }}>
<thead>
<tr style={{ borderBottom: "2px solid var(--border-light)" }}>
<th style={{ padding: "0.75rem 0", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)" }}>Agent Name</th>
<th style={{ padding: "0.75rem 0", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)" }}>Actions Handled</th>
<th style={{ padding: "0.75rem 0", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)" }}>Cost Footprint</th>
</tr>
</thead>
<tbody>
{data.agent_usage.map((a) => (
<tr key={a.agent_name} style={{ borderBottom: "1px solid var(--bg-hover)", transition: "background-color 0.2s" }} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--bg-hover)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}>
<td style={{ padding: "1rem 0 1rem 1rem", fontWeight: 600, fontSize: "0.9375rem" }}>{a.agent_name}</td>
<td style={{ padding: "1rem 0", fontSize: "0.9375rem" }}>{a.message_count.toLocaleString()}</td>
<td style={{ padding: "1rem 1rem 1rem 0", fontSize: "0.9375rem" }}>{formatCost(a.total_cost_usd)}</td>
</tr>
</thead>
<tbody>
{data.agent_usage.map((a) => (
<tr key={a.agent_name}>
<td style={styles.td}>{a.agent_name}</td>
<td style={styles.td}>{a.message_count}</td>
<td style={styles.td}>{a.total_tokens.toLocaleString()}</td>
<td style={styles.td}>{formatCost(a.total_cost_usd)}</td>
</tr>
))}
</tbody>
</table>
)}
))}
</tbody>
</table>
</div>
<h3 style={styles.sectionHeading}>Interrupt Stats</h3>
<div style={styles.metricsGrid}>
<MetricCard label="Total Interrupts" value={data.interrupt_stats.total} />
<MetricCard label="Approved" value={data.interrupt_stats.approved} />
<MetricCard label="Rejected" value={data.interrupt_stats.rejected} />
<MetricCard label="Expired" value={data.interrupt_stats.expired} />
{/* Human in the loop card */}
<div style={{ backgroundColor: "var(--bg-surface)", borderRadius: "var(--radius-xl)", padding: "1.5rem", border: "1px solid var(--border-light)" }}>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "1rem" }}>
<h3 style={{ fontSize: "1.125rem", color: "var(--text-primary)", fontWeight: 700, margin: 0 }}>Security Approvals</h3>
<span title="Actions requiring human review before proceeding" style={{ cursor: "help", color: "var(--text-secondary)", fontSize: "0.875rem", display: "inline-flex", alignItems: "center", justifyContent: "center", width: "18px", height: "18px", borderRadius: "50%", border: "1px solid var(--border-light)" }}>?</span>
</div>
</>
)}
<p style={{ fontSize: "0.875rem", color: "var(--text-secondary)", marginBottom: "1.5rem", lineHeight: 1.5 }}>
Breakdown of supervisor responses to High-Risk Action Cards dynamically requested by Agents.
</p>
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 600, fontSize: "0.875rem" }}>Action Approved</span>
<span style={{ color: "#059669", fontWeight: 700 }}>{data.interrupt_stats.approved}</span>
</div>
<div style={{ height: "6px", background: "var(--bg-hover)", borderRadius: "3px", overflow: "hidden" }}>
<div style={{ width: `${(data.interrupt_stats.approved / data.interrupt_stats.total) * 100}%`, height: "100%", background: "#059669" }} />
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: "0.5rem" }}>
<span style={{ fontWeight: 600, fontSize: "0.875rem" }}>Action Rejected (Escalated)</span>
<span style={{ color: "#DC2626", fontWeight: 700 }}>{data.interrupt_stats.rejected}</span>
</div>
<div style={{ height: "6px", background: "var(--bg-hover)", borderRadius: "3px", overflow: "hidden" }}>
<div style={{ width: `${(data.interrupt_stats.rejected / data.interrupt_stats.total) * 100}%`, height: "100%", background: "#DC2626" }} />
</div>
</div>
</div>
</div>
</>
)}
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: { padding: "24px", maxWidth: "1000px", margin: "0 auto" },
header: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "20px",
},
heading: { fontSize: "20px", fontWeight: 700, margin: 0 },
rangeSelector: { display: "flex", gap: "4px" },
rangeBtn: {
padding: "5px 14px",
border: "1px solid #e0e0e0",
borderRadius: "4px",
background: "#fff",
cursor: "pointer",
fontSize: "13px",
color: "#555",
},
rangeBtnActive: {
background: "#1976d2",
color: "#fff",
borderColor: "#1976d2",
},
metricsGrid: {
display: "flex",
flexWrap: "wrap" as const,
gap: "12px",
marginBottom: "24px",
},
sectionHeading: {
fontSize: "15px",
fontWeight: 600,
marginBottom: "12px",
color: "#333",
},
table: { width: "100%", borderCollapse: "collapse", fontSize: "13px", marginBottom: "24px" },
th: {
textAlign: "left",
padding: "8px 12px",
borderBottom: "2px solid #e0e0e0",
color: "#555",
fontWeight: 600,
textTransform: "uppercase",
fontSize: "11px",
},
td: { padding: "10px 12px", borderBottom: "1px solid #f0f0f0" },
center: { padding: "48px", textAlign: "center", color: "#888" },
error: { padding: "24px", color: "#c62828" },
empty: { color: "#888", fontSize: "14px", padding: "16px 0" },
};
function MetricBox({ label, value, trend, positive }: { label: string, value: string | number, trend: string, positive?: boolean }) {
return (
<div style={{
backgroundColor: "var(--bg-surface)",
padding: "1.5rem",
borderRadius: "var(--radius-xl)",
border: "1px solid var(--border-light)",
display: "flex",
flexDirection: "column",
gap: "0.5rem"
}}>
<div style={{ fontSize: "0.8125rem", color: "var(--text-secondary)", textTransform: "uppercase", letterSpacing: "0.05em", fontWeight: 600 }}>
{label}
</div>
<div style={{ fontSize: "2rem", fontWeight: 700, color: "var(--text-primary)" }}>
{value}
</div>
<div style={{ fontSize: "0.8125rem", color: positive ? "#059669" : "var(--text-secondary)", fontWeight: positive ? 600 : 400 }}>
{trend}
</div>
</div>
);
}

View File

@@ -1,133 +1,130 @@
import { useEffect, useState } from "react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { fetchConversations } from "../api";
import type { ConversationSummary } from "../api";
// Mock Data
const MOCK_CONVERSATIONS = [
{ thread_id: "th_9281ja8s9", user: "Maria G.", intent: "Cancel Order #8921", date: "2 mins ago", turns: 4, agents: ["Router", "Order Specialist"], status: "Resolved", cost: "$0.02" },
{ thread_id: "th_1092jf8u1", user: "David C.", intent: "Apply Discount to previous order", date: "15 mins ago", turns: 9, agents: ["Router", "Billing Assistant"], status: "Escalated", cost: "$0.08", hitl: true },
{ thread_id: "th_0099ab7x2", user: "Sarah L.", intent: "Where is my package?", date: "1 hour ago", turns: 2, agents: ["Router", "Order Specialist"], status: "Resolved", cost: "$0.01" },
{ thread_id: "th_5518kc3p0", user: "John M.", intent: "Change shipping address", date: "4 hours ago", turns: 6, agents: ["Router", "Order Specialist"], status: "Resolved", cost: "$0.04" },
{ thread_id: "th_1102po9m4", user: "Elena P.", intent: "Defective item return", date: "Yesterday", turns: 12, agents: ["Router", "Order Specialist", "Billing Assistant"], status: "Escalated", cost: "$0.15", hitl: true },
];
export function ReplayListPage() {
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const perPage = 20;
useEffect(() => {
setLoading(true);
setError(null);
fetchConversations(page, perPage)
.then((data) => {
setConversations(data.conversations);
setTotal(data.total);
})
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, [page]);
if (loading) {
return <div style={styles.center}>Loading conversations...</div>;
}
if (error) {
return <div style={styles.error}>Error: {error}</div>;
}
const totalPages = Math.ceil(total / perPage);
const [page, setPage] = useState(1);
const totalPages = 24;
return (
<div style={styles.container}>
<h2 style={styles.heading}>Conversations</h2>
{conversations.length === 0 ? (
<div style={styles.empty}>No conversations yet.</div>
) : (
<>
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Thread ID</th>
<th style={styles.th}>Started</th>
<th style={styles.th}>Turns</th>
<th style={styles.th}>Agents</th>
<th style={styles.th}>Resolution</th>
<div className="page-container">
<div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: "2rem" }}>
<div>
<h2>Conversation Replay</h2>
<p>Review autonomous agent sessions and audit MCP action execution trails.</p>
</div>
<div style={{ position: "relative" }}>
<input
type="text"
placeholder="Search by Order ID, Thread ID..."
style={{
padding: "0.625rem 1rem",
borderRadius: "8px",
border: "1px solid var(--border-light)",
backgroundColor: "var(--bg-surface)",
color: "var(--text-primary)",
fontSize: "0.875rem",
width: "280px"
}}
/>
</div>
</div>
<div style={{ backgroundColor: "var(--bg-surface)", borderRadius: "var(--radius-xl)", overflow: "hidden", border: "1px solid var(--border-light)" }}>
<table style={{ width: "100%", borderCollapse: "collapse", textAlign: "left" }}>
<thead>
<tr style={{ backgroundColor: "var(--bg-surface-inner)", borderBottom: "1px solid var(--border-light)" }}>
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Thread</th>
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Detected Intent</th>
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Agents Invoked</th>
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Outcome</th>
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Performance</th>
</tr>
</thead>
<tbody>
{MOCK_CONVERSATIONS.map((c, i) => (
<tr
key={c.thread_id}
onClick={() => navigate(`/replay/${c.thread_id}`)}
style={{
borderBottom: i === MOCK_CONVERSATIONS.length - 1 ? "none" : "1px solid var(--border-light)",
cursor: "pointer",
transition: "background-color 0.2s"
}}
className="replay-row-hover"
>
<td style={{ padding: "1.25rem 1.5rem" }}>
<div style={{ fontWeight: 600, color: "var(--text-primary)", fontSize: "0.9375rem" }}>{c.user}</div>
<div style={{ fontSize: "0.75rem", color: "var(--text-secondary)", fontFamily: "monospace", marginTop: "4px" }}>{c.thread_id}</div>
</td>
<td style={{ padding: "1.25rem 1.5rem" }}>
<div style={{ fontWeight: 500, color: "var(--text-primary)", fontSize: "0.9375rem" }}>{c.intent}</div>
<div style={{ fontSize: "0.75rem", color: "var(--text-secondary)", marginTop: "4px" }}>{c.date}</div>
</td>
<td style={{ padding: "1.25rem 1.5rem" }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px" }}>
{c.agents.map(a => (
<span key={a} style={{ fontSize: "0.65rem", padding: "2px 8px", backgroundColor: "var(--bg-app)", border: "1px solid var(--border-light)", borderRadius: "99px", color: "var(--text-secondary)", fontWeight: 600 }}>
{a}
</span>
))}
</div>
</td>
<td style={{ padding: "1.25rem 1.5rem" }}>
<span style={{
fontSize: "0.75rem",
padding: "4px 10px",
borderRadius: "6px",
fontWeight: 600,
backgroundColor: c.status === "Resolved" ? "#DEF7EC" : "#FDE8E8",
color: c.status === "Resolved" ? "#03543F" : "#9B1C1C",
}}>
{c.status}
</span>
{c.hitl && <span style={{ marginLeft: "8px", fontSize: "1.25rem" }} title="Human in the loop invoked">🔒</span>}
</td>
<td style={{ padding: "1.25rem 1.5rem", fontSize: "0.875rem", color: "var(--text-secondary)" }}>
{c.turns} turns {c.cost}
</td>
</tr>
</thead>
<tbody>
{conversations.map((c) => (
<tr
key={c.thread_id}
onClick={() => navigate(`/replay/${c.thread_id}`)}
style={styles.row}
>
<td style={styles.td}>
<span style={styles.threadId}>{c.thread_id}</span>
</td>
<td style={styles.td}>
{new Date(c.started_at).toLocaleString()}
</td>
<td style={styles.td}>{c.turn_count}</td>
<td style={styles.td}>{c.agents_used.join(", ") || "—"}</td>
<td style={styles.td}>{c.resolution_type ?? "open"}</td>
</tr>
))}
</tbody>
</table>
<div style={styles.pagination}>
))}
</tbody>
</table>
<div style={{ padding: "1.25rem 1.5rem", borderTop: "1px solid var(--border-light)", display: "flex", justifyContent: "space-between", alignItems: "center", backgroundColor: "var(--bg-surface-inner)" }}>
<span style={{ fontSize: "0.875rem", color: "var(--text-secondary)" }}>Showing 1-5 of 120 sessions</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
onClick={(e) => { e.stopPropagation(); setPage(p => Math.max(1, p - 1)) }}
disabled={page === 1}
style={styles.pageBtn}
className="btn btn-secondary"
>
Previous
</button>
<span style={{ fontSize: "13px", color: "#555" }}>
Page {page} of {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
onClick={(e) => { e.stopPropagation(); setPage(p => Math.min(totalPages, p + 1)) }}
disabled={page >= totalPages}
style={styles.pageBtn}
className="btn btn-secondary"
>
Next
</button>
</div>
</>
)}
</div>
</div>
<style>{`
.replay-row-hover:hover {
background-color: var(--bg-hover) !important;
}
`}</style>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: { padding: "24px", maxWidth: "1000px", margin: "0 auto" },
heading: { fontSize: "20px", fontWeight: 700, marginBottom: "16px" },
center: { padding: "48px", textAlign: "center", color: "#888" },
error: { padding: "24px", color: "#c62828" },
empty: { color: "#888", fontSize: "14px" },
table: { width: "100%", borderCollapse: "collapse", fontSize: "13px" },
th: {
textAlign: "left",
padding: "8px 12px",
borderBottom: "2px solid #e0e0e0",
color: "#555",
fontWeight: 600,
textTransform: "uppercase",
fontSize: "11px",
letterSpacing: "0.5px",
},
td: { padding: "10px 12px", borderBottom: "1px solid #f0f0f0" },
row: { cursor: "pointer", transition: "background 0.1s" },
threadId: { fontFamily: "monospace", fontSize: "12px", color: "#1976d2" },
pagination: {
display: "flex",
alignItems: "center",
gap: "12px",
marginTop: "16px",
},
pageBtn: {
padding: "6px 14px",
border: "1px solid #e0e0e0",
borderRadius: "4px",
background: "#fff",
cursor: "pointer",
fontSize: "13px",
},
};

View File

@@ -1,89 +1,70 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { fetchReplay } from "../api";
import type { ReplayStep } from "../api";
import { useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { ReplayTimeline } from "../components/ReplayTimeline";
const MOCK_STEPS = [
{ step: 1, type: "message", timestamp: "2026-04-05T10:00:00Z", agent: "Customer", content: "My laptop arrived with a shattered screen. I need a replacement immediately! Order #8921." },
{ step: 2, type: "token", timestamp: "2026-04-05T10:00:02Z", agent: "Router", content: "Intent detected: 'return_request'. Routing to Order Specialist." },
{ step: 3, type: "tool_call", timestamp: "2026-04-05T10:00:03Z", agent: "Order Specialist", tool: "get_order_details", params: { order_id: "8921" } },
{ step: 4, type: "tool_result", timestamp: "2026-04-05T10:00:04Z", tool: "get_order_details", result: { status: "Delivered", items: ["MacBook Pro 16", "USB-C Hub"], total_value: 2499.00 } },
{ step: 5, type: "tool_call", timestamp: "2026-04-05T10:00:06Z", agent: "Order Specialist", tool: "initiate_return", params: { order_id: "8921", reason: "Damaged in transit", replacement: true } },
{ step: 6, type: "interrupt", timestamp: "2026-04-05T10:00:06Z", agent: "System", content: "SECURITY POLICY TRIGGERED: High-Value Return (>$1000). Human approval required before initiating RMS workflow." },
{ step: 7, type: "interrupt_response", timestamp: "2026-04-05T10:15:22Z", agent: "Alex Thompson (Supervisor)", content: "REJECTED. Standard policy for shattered screens requires photo evidence before dispatching replacement unit." },
{ step: 8, type: "message", timestamp: "2026-04-05T10:15:25Z", agent: "Order Specialist", content: "I'm so sorry to hear your laptop screen was shattered! Because this is a high-value item, our policy requires a photo of the damage before we can dispatch your replacement unit. Could you please take a quick picture and upload it here?" }
];
export function ReplayPage() {
const { threadId } = useParams<{ threadId: string }>();
const [steps, setSteps] = useState<ReplayStep[]>([]);
const [total, setTotal] = useState(0);
const navigate = useNavigate();
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const perPage = 20;
useEffect(() => {
if (!threadId) return;
setLoading(true);
setError(null);
fetchReplay(threadId, page, perPage)
.then((data) => {
setSteps(data.steps);
setTotal(data.total);
})
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, [threadId, page]);
if (!threadId) {
return <div style={styles.error}>No thread ID provided.</div>;
}
const totalPages = Math.ceil(total / perPage);
if (!threadId) return null;
return (
<div style={styles.container}>
<h2 style={styles.heading}>
Replay:{" "}
<span style={styles.threadId}>{threadId}</span>
</h2>
{loading && <div style={styles.center}>Loading replay...</div>}
{error && <div style={styles.error}>Error: {error}</div>}
{!loading && !error && <ReplayTimeline steps={steps} />}
{!loading && totalPages > 1 && (
<div style={styles.pagination}>
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
style={styles.pageBtn}
<div className="page-container">
<div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: "2rem" }}>
<div>
<button
onClick={() => navigate("/replay")}
style={{ background: "none", border: "none", color: "var(--text-secondary)", fontSize: "0.875rem", cursor: "pointer", padding: "0 0 0.5rem 0", display: "flex", alignItems: "center", gap: "0.25rem" }}
>
Previous
</button>
<span style={{ fontSize: "13px", color: "#555" }}>
Page {page} of {totalPages} ({total} steps)
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
style={styles.pageBtn}
>
Next
Back to All Replays
</button>
<h2>Audit Trail: <span style={{ fontFamily: "monospace", color: "var(--brand-primary)" }}>{threadId}</span></h2>
<p>Detailed temporal log of agent reflections, MCP tool calls, and human overrides.</p>
</div>
)}
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 3fr", gap: "2rem" }}>
{/* Sidebar Summary Info */}
<div style={{ backgroundColor: "var(--bg-surface)", padding: "1.5rem", borderRadius: "var(--radius-xl)", border: "1px solid var(--border-light)", alignSelf: "start" }}>
<h3 style={{ fontSize: "1rem", marginBottom: "1.25rem", color: "var(--text-primary)" }}>Session Context</h3>
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div>
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Customer</div>
<div style={{ fontWeight: 600, fontSize: "0.9375rem" }}>Maria G.</div>
</div>
<div>
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Final Outcome</div>
<div style={{ display: "inline-block", backgroundColor: "#FDE8E8", color: "#9B1C1C", padding: "4px 8px", borderRadius: "6px", fontSize: "0.75rem", fontWeight: 700, marginTop: "4px" }}>ESCALATED 🔒</div>
</div>
<div>
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Time Elapsed</div>
<div style={{ fontSize: "0.9375rem" }}>15m 25s</div>
</div>
<div>
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Total Tokens</div>
<div style={{ fontSize: "0.9375rem" }}>3,402 ($0.15)</div>
</div>
</div>
</div>
{/* Timeline */}
<div style={{ backgroundColor: "var(--bg-surface)", padding: "2rem", borderRadius: "var(--radius-xl)", border: "1px solid var(--border-light)" }}>
<ReplayTimeline steps={MOCK_STEPS as any} />
</div>
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: { padding: "24px", maxWidth: "800px", margin: "0 auto" },
heading: { fontSize: "20px", fontWeight: 700, marginBottom: "20px" },
threadId: { fontFamily: "monospace", fontSize: "16px", color: "#1976d2" },
center: { padding: "48px", textAlign: "center", color: "#888" },
error: { padding: "24px", color: "#c62828" },
pagination: {
display: "flex",
alignItems: "center",
gap: "12px",
marginTop: "20px",
},
pageBtn: {
padding: "6px 14px",
border: "1px solid #e0e0e0",
borderRadius: "4px",
background: "#fff",
cursor: "pointer",
fontSize: "13px",
},
};

View File

@@ -26,7 +26,43 @@ export function ReviewPage() {
const [result, setResult] = useState<JobResult | null>(null);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [classifications, setClassifications] = useState<EndpointClassification[]>([]);
const [classifications, setClassifications] = useState<EndpointClassification[]>([
{
path: "/api/v1/orders/{order_id}/cancel",
method: "post",
summary: "Cancel an active Shopify order",
access_type: "write",
agent_group: "Order Specialist",
},
{
path: "/api/v1/orders/{order_id}",
method: "get",
summary: "Retrieve detailed information about an order",
access_type: "read",
agent_group: "Order Specialist",
},
{
path: "/api/v1/payments/{charge_id}/refund",
method: "post",
summary: "Issue a full or partial refund for a charge",
access_type: "admin",
agent_group: "Billing Assistant",
},
{
path: "/api/v1/customers/{email}/discounts",
method: "post",
summary: "Apply a loyalty discount to a customer account",
access_type: "write",
agent_group: "Billing Assistant",
},
{
path: "/api/v1/inventory/check",
method: "get",
summary: "Query realtime stock levels across warehouses",
access_type: "read",
agent_group: "Unassigned",
}
]);
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
@@ -105,173 +141,104 @@ export function ReviewPage() {
});
}
return (
<div style={styles.container}>
<h2 style={styles.heading}>OpenAPI Import & Review</h2>
const groupedByAgent = classifications.reduce((acc, c, idx) => {
const group = c.agent_group || "Unassigned";
if (!acc[group]) acc[group] = [];
acc[group].push({ ...c, originalIdx: idx });
return acc;
}, {} as Record<string, (EndpointClassification & { originalIdx: number })[]>);
<form onSubmit={handleSubmit} style={styles.form}>
return (
<div className="page-container">
<div className="page-header">
<h2>Agents & Tools Registry</h2>
<p>Import OpenAPI schema and assign endpoint capabilities to specific agents.</p>
</div>
<form onSubmit={handleSubmit} className="import-form">
<input
type="url"
placeholder="https://example.com/openapi.yaml"
value={url}
onChange={(e) => setUrl(e.target.value)}
style={styles.input}
className="import-input"
required
/>
<button type="submit" disabled={submitting} style={styles.submitBtn}>
{submitting ? "Importing..." : "Import"}
<button type="submit" disabled={submitting} className="btn btn-primary">
{submitting ? "Importing..." : "Scan Tools"}
</button>
</form>
{submitError && <div style={styles.error}>Error: {submitError}</div>}
{submitError && <div style={{ color: "var(--brand-accent)", marginBottom: "1rem" }}>Error: {submitError}</div>}
{job && (
<div style={styles.statusBox}>
<div style={{ padding: "1rem", background: "var(--bg-surface)", border: "1px solid var(--border-light)", borderRadius: "var(--radius-md)", marginBottom: "1.5rem" }}>
<strong>Job:</strong> {job.job_id} &mdash; Status:{" "}
<span
style={{
color:
job.status === "done"
? "#388e3c"
: job.status === "error"
? "#c62828"
: "#f57c00",
fontWeight: 600,
}}
>
<span style={{ fontWeight: 600, color: job.status === "done" ? "#10b981" : job.status === "error" ? "var(--brand-accent)" : "#f59e0b" }}>
{job.status}
</span>
{job.error && (
<div style={{ color: "#c62828", marginTop: "4px" }}>{job.error}</div>
)}
{job.error && <div style={{ marginTop: "4px", color: "var(--brand-accent)" }}>{job.error}</div>}
</div>
)}
{result && classifications.length > 0 && (
{classifications.length > 0 && (
<>
<h3 style={styles.sectionHeading}>
Endpoint Classifications ({classifications.length})
</h3>
<p style={styles.hint}>
Review and edit the access_type and agent_group before approving.
</p>
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Method</th>
<th style={styles.th}>Path</th>
<th style={styles.th}>Summary</th>
<th style={styles.th}>Access Type</th>
<th style={styles.th}>Agent Group</th>
</tr>
</thead>
<tbody>
{classifications.map((c, idx) => (
<tr key={`${c.method}-${c.path}`}>
<td style={styles.td}>
<span style={{ fontWeight: 600, fontSize: "11px" }}>
{c.method.toUpperCase()}
</span>
</td>
<td style={{ ...styles.td, fontFamily: "monospace", fontSize: "12px" }}>
{c.path}
</td>
<td style={styles.td}>{c.summary}</td>
<td style={styles.td}>
<select
value={c.access_type}
onChange={(e) => handleFieldChange(idx, "access_type", e.target.value)}
style={styles.select}
>
<option value="read">read</option>
<option value="write">write</option>
<option value="admin">admin</option>
</select>
</td>
<td style={styles.td}>
<input
type="text"
value={c.agent_group}
onChange={(e) => handleFieldChange(idx, "agent_group", e.target.value)}
style={styles.textInput}
/>
</td>
</tr>
))}
</tbody>
</table>
<button onClick={handleApprove} style={styles.approveBtn}>
Approve & Save
</button>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
<div>
<h3 style={{ margin: 0, fontSize: "1.25rem", color: "var(--text-primary)" }}>Assigned Capabilities ({classifications.length})</h3>
<p style={{ margin: "0.25rem 0 0 0", fontSize: "0.875rem", color: "var(--text-secondary)" }}>Grouped by target Agent.</p>
</div>
<button onClick={handleApprove} className="btn btn-primary">
Save Configuration
</button>
</div>
<div className="agent-grid">
{Object.entries(groupedByAgent).map(([groupName, tools]) => (
<div key={groupName} className="agent-grid-card">
<div className="agent-card-header-bg">
<div className="agent-avatar-lg">{groupName === "Unassigned" ? "?" : groupName.charAt(0).toUpperCase()}</div>
<div className="agent-card-meta">
<h3>{groupName}</h3>
<span>{tools.length} Attached Tools</span>
</div>
</div>
<div className="agent-tools-list">
{tools.map((t) => (
<div key={t.originalIdx} className="tool-pill-item">
<div className="tool-pill-header">
<span className="tool-method-badge" style={{ background: t.method === "get" ? "#3b82f6" : t.method === "post" ? "#10b981" : t.method === "delete" ? "#ef4444" : "#f59e0b" }}>
{t.method}
</span>
<span className="tool-path-text" title={t.path}>{t.path}</span>
</div>
<div className="tool-summary-text">{t.summary}</div>
<div className="tool-pill-controls">
<select
value={t.access_type}
onChange={(e) => handleFieldChange(t.originalIdx, "access_type", e.target.value)}
className="tool-select"
>
<option value="read">Read Only</option>
<option value="write">Write (Confirm)</option>
<option value="admin">Admin</option>
</select>
<input
type="text"
value={t.agent_group}
onChange={(e) => handleFieldChange(t.originalIdx, "agent_group", e.target.value)}
className="tool-input"
placeholder="Agent Name"
/>
</div>
</div>
))}
</div>
</div>
))}
</div>
</>
)}
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: { padding: "24px", maxWidth: "1000px", margin: "0 auto" },
heading: { fontSize: "20px", fontWeight: 700, marginBottom: "16px" },
form: { display: "flex", gap: "8px", marginBottom: "16px" },
input: {
flex: 1,
padding: "8px 12px",
border: "1px solid #e0e0e0",
borderRadius: "4px",
fontSize: "14px",
},
submitBtn: {
padding: "8px 20px",
background: "#1976d2",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "14px",
fontWeight: 600,
},
error: { color: "#c62828", marginBottom: "12px" },
statusBox: {
background: "#f9f9f9",
border: "1px solid #e0e0e0",
padding: "10px 14px",
borderRadius: "4px",
marginBottom: "16px",
fontSize: "13px",
},
sectionHeading: { fontSize: "15px", fontWeight: 600, marginBottom: "8px" },
hint: { fontSize: "12px", color: "#888", marginBottom: "12px" },
table: { width: "100%", borderCollapse: "collapse", fontSize: "13px", marginBottom: "16px" },
th: {
textAlign: "left",
padding: "8px 10px",
borderBottom: "2px solid #e0e0e0",
fontSize: "11px",
textTransform: "uppercase",
color: "#555",
},
td: { padding: "8px 10px", borderBottom: "1px solid #f0f0f0" },
select: {
padding: "3px 6px",
border: "1px solid #e0e0e0",
borderRadius: "3px",
fontSize: "12px",
},
textInput: {
padding: "3px 6px",
border: "1px solid #e0e0e0",
borderRadius: "3px",
fontSize: "12px",
width: "100%",
},
approveBtn: {
padding: "8px 20px",
background: "#388e3c",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "14px",
fontWeight: 600,
},
};

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -7,11 +7,11 @@ export default defineConfig({
port: 5173,
proxy: {
"/ws": {
target: "http://localhost:8000",
target: "http://localhost:8001",
ws: true,
},
"/api": {
target: "http://localhost:8000",
target: "http://localhost:8001",
},
},
},