feat(ui): implement premium beige design system and ux refinements
This commit is contained in:
@@ -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=
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'."
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
219
backend/tests/e2e/conftest.py
Normal file
219
backend/tests/e2e/conftest.py
Normal 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()
|
||||
384
backend/tests/e2e/test_chat_flows.py
Normal file
384
backend/tests/e2e/test_chat_flows.py
Normal 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
|
||||
201
backend/tests/e2e/test_openapi_import.py
Normal file
201
backend/tests/e2e/test_openapi_import.py
Normal 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
|
||||
214
backend/tests/e2e/test_replay_analytics.py
Normal file
214
backend/tests/e2e/test_replay_analytics.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
92
docs/ux_design_system.md
Normal 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`*.
|
||||
1180
frontend/package-lock.json
generated
1180
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
678
frontend/src/index.css
Normal 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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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} — 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
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user