diff --git a/examples/starter/.env.example b/examples/starter/.env.example index 5e8733aa..7d045b75 100644 --- a/examples/starter/.env.example +++ b/examples/starter/.env.example @@ -21,3 +21,5 @@ OPENAI_BASE_URL=http://host.docker.internal:11434/v1 # Optional — read by the bank_name resolver. # BANK_NAME=Northwind Bank + +# CONTEXT_MAX_TOKENS=2000 diff --git a/package-lock.json b/package-lock.json index 5fa7f024..2aa4dc56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -835,7 +835,6 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -988,7 +987,6 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz", "integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -1389,7 +1387,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -3009,7 +3006,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3019,7 +3015,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, diff --git a/src/agent_engine/core/validator.py b/src/agent_engine/core/validator.py index f4fba741..6d5adfb1 100644 --- a/src/agent_engine/core/validator.py +++ b/src/agent_engine/core/validator.py @@ -32,4 +32,3 @@ def _validate_node( message="plugins/access.py is required for protected nodes", ) ) - diff --git a/src/agent_manager/api/routes.py b/src/agent_manager/api/routes.py index 445b2f9b..70e26c5e 100644 --- a/src/agent_manager/api/routes.py +++ b/src/agent_manager/api/routes.py @@ -13,8 +13,12 @@ from agent_engine.runtime.streaming import RunStreamEvent from agent_manager.api.deps import get_service from agent_manager.api.schemas import ( + ContextUsageResponse, + ConversationSummary, CreateConversationRequest, CreateConversationResponse, + FeedbackRequest, + FeedbackResponse, MessageOut, SendMessageRequest, SendMessageResponse, @@ -25,6 +29,7 @@ ConversationNotFound, ConversationService, ConversationTokenBudgetExceeded, + MessageNotFound, ) router = APIRouter() @@ -41,13 +46,49 @@ async def create_conversation( return CreateConversationResponse(conversation_id=session_id, session_id=session_id) +@router.get("/conversations", response_model=list[ConversationSummary]) +async def list_conversations(service: Service, user_id: str) -> list[ConversationSummary]: + sessions = await service.list_conversations(user_id) + return [ + ConversationSummary( + conversation_id=s.session_id, + title=s.title, + last_message_at=s.last_message_at, + ) + for s in sessions + ] + + @router.get("/conversations/{conversation_id}/messages", response_model=list[MessageOut]) async def list_messages(conversation_id: str, service: Service) -> list[MessageOut]: try: msgs = await service.history(conversation_id) except ConversationNotFound as exc: raise HTTPException(status_code=404, detail="conversation not found") from exc - return [MessageOut(role=m.role, content=m.content, created_at=m.created_at) for m in msgs] + return [ + MessageOut( + message_id=m.message_id, + role=m.role, + content=m.content, + created_at=m.created_at, + feedback=m.feedback, + ) + for m in msgs + ] + + +@router.get("/conversations/{conversation_id}/usage", response_model=ContextUsageResponse) +async def get_usage(conversation_id: str, service: Service) -> ContextUsageResponse: + try: + usage = await service.usage(conversation_id) + except ConversationNotFound as exc: + raise HTTPException(status_code=404, detail="conversation not found") from exc + return ContextUsageResponse( + used_tokens=usage.used_tokens, + max_tokens=usage.max_tokens, + percent=usage.percent, + severity=usage.severity, + ) @router.post("/conversations/{conversation_id}/messages", response_model=SendMessageResponse) @@ -55,7 +96,7 @@ async def send_message( conversation_id: str, body: SendMessageRequest, service: Service ) -> SendMessageResponse: try: - result = await service.send(conversation_id, body.message, user_id=body.user_id) + result, msg = await service.send(conversation_id, body.message, user_id=body.user_id) except ConversationNotFound as exc: raise HTTPException(status_code=404, detail="conversation not found") from exc except ConversationTokenBudgetExceeded: @@ -66,10 +107,30 @@ async def send_message( answer=result.answer, visited=list(result.visited), used_tools=[ToolRecord(**dataclasses.asdict(t)) for t in result.used_tools], + message_id=msg.message_id if msg else None, ) -def _to_stream_event(event: RunStreamEvent) -> StreamEventOut: +@router.post( + "/conversations/{conversation_id}/messages/{message_id}/feedback", + response_model=FeedbackResponse, +) +async def record_feedback( + conversation_id: str, + message_id: str, + body: FeedbackRequest, + service: Service, +) -> FeedbackResponse: + try: + msg = await service.record_feedback(conversation_id, message_id, body.feedback) + except ConversationNotFound as exc: + raise HTTPException(status_code=404, detail="conversation not found") from exc + except MessageNotFound as exc: + raise HTTPException(status_code=404, detail="message not found") from exc + return FeedbackResponse(message_id=msg.message_id, feedback=msg.feedback) + + +def _to_stream_event(event: RunStreamEvent, message_id: str | None = None) -> StreamEventOut: return StreamEventOut( type=event.type, content=event.content, @@ -85,6 +146,7 @@ def _to_stream_event(event: RunStreamEvent) -> StreamEventOut: if event.used_tools else None ), + message_id=message_id, ) @@ -106,10 +168,11 @@ async def stream_message( async def event_source() -> AsyncIterator[str]: try: if first is not None: - payload = _to_stream_event(first).model_dump(exclude_none=True) - yield f"event: {first.type}\ndata: {json.dumps(payload)}\n\n" - async for event in stream: - payload = _to_stream_event(event).model_dump(exclude_none=True) + first_event, first_msg_id = first + payload = _to_stream_event(first_event, first_msg_id).model_dump(exclude_none=True) + yield f"event: {first_event.type}\ndata: {json.dumps(payload)}\n\n" + async for event, msg_id in stream: + payload = _to_stream_event(event, msg_id).model_dump(exclude_none=True) yield f"event: {event.type}\ndata: {json.dumps(payload)}\n\n" except Exception as exc: yield f"event: error\ndata: {json.dumps({'type': 'error', 'error': str(exc)})}\n\n" diff --git a/src/agent_manager/api/schemas.py b/src/agent_manager/api/schemas.py index 3777ee5c..e454b342 100644 --- a/src/agent_manager/api/schemas.py +++ b/src/agent_manager/api/schemas.py @@ -10,7 +10,7 @@ from pydantic import BaseModel -from agent_manager.domain import Role +from agent_manager.domain import ContextSeverity, Role class CreateConversationRequest(BaseModel): @@ -23,10 +23,27 @@ class CreateConversationResponse(BaseModel): session_id: str +class ConversationSummary(BaseModel): + conversation_id: str + title: str | None = None + last_message_at: datetime | None = None + + class MessageOut(BaseModel): + message_id: str | None = None role: Role content: str created_at: datetime + feedback: str | None = None + + +class FeedbackRequest(BaseModel): + feedback: str | None = None + + +class FeedbackResponse(BaseModel): + message_id: str + feedback: str | None = None class SendMessageRequest(BaseModel): @@ -47,6 +64,14 @@ class SendMessageResponse(BaseModel): answer: str visited: list[str] used_tools: list[ToolRecord] + message_id: str | None = None + + +class ContextUsageResponse(BaseModel): + used_tokens: int + max_tokens: int | None = None + percent: float = 0.0 + severity: ContextSeverity = ContextSeverity.NORMAL class StreamEventOut(BaseModel): @@ -60,3 +85,4 @@ class StreamEventOut(BaseModel): error: str | None = None system_name: str | None = None used_tools: list[ToolRecord] | None = None + message_id: str | None = None diff --git a/src/agent_manager/api/static/demo.html b/src/agent_manager/api/static/demo.html index db9313bc..852e0161 100644 --- a/src/agent_manager/api/static/demo.html +++ b/src/agent_manager/api/static/demo.html @@ -21,16 +21,15 @@

Drop-in chat widget

your product, paste these two lines — pointing at your backend:

<script type="module" src="https://your-backend/widget.js"></script>
-<agent-chat title="Home Assistant" color="#2563eb"></agent-chat>
+<agent-chat title="Support" color="#18181b"></agent-chat> -

↘ The launcher is in the bottom-right corner. Click it and try - “turn on the kitchen lights”, then “now turn it off”.

+

↘ The launcher is in the bottom-right corner. Click it and ask a question.

diff --git a/src/agent_manager/api/static/widget.js b/src/agent_manager/api/static/widget.js index a69e7455..2984287e 100644 --- a/src/agent_manager/api/static/widget.js +++ b/src/agent_manager/api/static/widget.js @@ -52060,7 +52060,8 @@ var DEFAULT_CONFIG = { greeting: "", position: "bottom-right", avatar: "", - mode: "floating" + mode: "floating", + user: "" }; var HEX_COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; function normalizeEndpoint(value) { @@ -52084,7 +52085,8 @@ function parseConfig(element7, scriptOrigin) { greeting: element7.getAttribute("greeting") || DEFAULT_CONFIG.greeting, position: safePosition(element7.getAttribute("position")), avatar: element7.getAttribute("avatar") || DEFAULT_CONFIG.avatar, - mode: safeMode(element7.getAttribute("mode")) + mode: safeMode(element7.getAttribute("mode")), + user: element7.getAttribute("user") || DEFAULT_CONFIG.user }; } function applyConfigAttributes(element7, config) { @@ -52110,14 +52112,32 @@ var AgentChatClient = class { constructor(endpoint) { this.endpoint = endpoint; } - async createConversation() { - const response = await fetch(`${this.endpoint}/conversations`, { method: "POST" }); + async createConversation(userId) { + const response = await fetch(`${this.endpoint}/conversations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user_id: userId }) + }); if (!response.ok) { throw new AgentChatHttpError(response.status); } const data = await response.json(); return String(data.conversation_id); } + async listConversations(userId) { + const url = `${this.endpoint}/conversations?user_id=${encodeURIComponent(userId)}`; + const response = await fetch(url); + if (!response.ok) { + throw new AgentChatHttpError(response.status); + } + const data = await response.json(); + if (!Array.isArray(data)) return []; + return data.map((thread) => ({ + conversation_id: String(thread.conversation_id), + title: thread.title ?? null, + last_message_at: thread.last_message_at ?? null + })); + } async getMessages(conversationId) { const response = await fetch(`${this.endpoint}/conversations/${conversationId}/messages`); if (!response.ok) { @@ -52138,9 +52158,36 @@ var AgentChatClient = class { return { answer: String(data.answer || ""), visited: Array.isArray(data.visited) ? data.visited : void 0, - used_tools: Array.isArray(data.used_tools) ? data.used_tools : void 0 + used_tools: Array.isArray(data.used_tools) ? data.used_tools : void 0, + message_id: data.message_id ? String(data.message_id) : void 0 + }; + } + async getUsage(conversationId) { + const response = await fetch(`${this.endpoint}/conversations/${conversationId}/usage`); + if (!response.ok) { + throw new AgentChatHttpError(response.status); + } + const data = await response.json(); + return { + used_tokens: Number(data.used_tokens) || 0, + max_tokens: data.max_tokens == null ? null : Number(data.max_tokens), + percent: Number(data.percent) || 0, + severity: data.severity ?? "normal" }; } + async recordFeedback(conversationId, messageId, feedback) { + const response = await fetch( + `${this.endpoint}/conversations/${conversationId}/messages/${messageId}/feedback`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ feedback }) + } + ); + if (!response.ok) { + throw new AgentChatHttpError(response.status); + } + } async *streamMessage(conversationId, message) { const response = await fetch(`${this.endpoint}/conversations/${conversationId}/messages/stream`, { method: "POST", @@ -52333,8 +52380,16 @@ var __iconNode7 = [ ]; var Copy = createLucideIcon("copy", __iconNode7); -// node_modules/lucide-react/dist/esm/icons/send.mjs +// node_modules/lucide-react/dist/esm/icons/history.mjs var __iconNode8 = [ + ["path", { d: "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8", key: "1357e3" }], + ["path", { d: "M3 3v5h5", key: "1xhq8a" }], + ["path", { d: "M12 7v5l4 2", key: "1fdv2h" }] +]; +var History = createLucideIcon("history", __iconNode8); + +// node_modules/lucide-react/dist/esm/icons/send.mjs +var __iconNode9 = [ [ "path", { @@ -52344,10 +52399,49 @@ var __iconNode8 = [ ], ["path", { d: "m21.854 2.147-10.94 10.939", key: "12cjpa" }] ]; -var Send = createLucideIcon("send", __iconNode8); +var Send = createLucideIcon("send", __iconNode9); + +// node_modules/lucide-react/dist/esm/icons/square-pen.mjs +var __iconNode10 = [ + ["path", { d: "M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7", key: "1m0v6g" }], + [ + "path", + { + d: "M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z", + key: "ohrbg2" + } + ] +]; +var SquarePen = createLucideIcon("square-pen", __iconNode10); + +// node_modules/lucide-react/dist/esm/icons/thumbs-down.mjs +var __iconNode11 = [ + [ + "path", + { + d: "M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z", + key: "m61m77" + } + ], + ["path", { d: "M17 14V2", key: "8ymqnk" }] +]; +var ThumbsDown = createLucideIcon("thumbs-down", __iconNode11); + +// node_modules/lucide-react/dist/esm/icons/thumbs-up.mjs +var __iconNode12 = [ + [ + "path", + { + d: "M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z", + key: "emmmcr" + } + ], + ["path", { d: "M7 10v12", key: "1qc93n" }] +]; +var ThumbsUp = createLucideIcon("thumbs-up", __iconNode12); // node_modules/lucide-react/dist/esm/icons/wrench.mjs -var __iconNode9 = [ +var __iconNode13 = [ [ "path", { @@ -52356,18 +52450,41 @@ var __iconNode9 = [ } ] ]; -var Wrench = createLucideIcon("wrench", __iconNode9); +var Wrench = createLucideIcon("wrench", __iconNode13); // node_modules/lucide-react/dist/esm/icons/x.mjs -var __iconNode10 = [ +var __iconNode14 = [ ["path", { d: "M18 6 6 18", key: "1bl5f8" }], ["path", { d: "m6 6 12 12", key: "d8bk6v" }] ]; -var X = createLucideIcon("x", __iconNode10); +var X = createLucideIcon("x", __iconNode14); // src/agent_manager/api/static/widget/react/AgentChatApp.tsx var import_react10 = __toESM(require_react(), 1); +// src/agent_manager/api/static/widget/storage/conversationStorage.ts +function conversationStorageKey(endpoint) { + return `agent-chat:${endpoint}`; +} +function getStoredConversationId(endpoint, storage = localStorage) { + return storage.getItem(conversationStorageKey(endpoint)); +} +function setStoredConversationId(endpoint, conversationId, storage = localStorage) { + storage.setItem(conversationStorageKey(endpoint), conversationId); +} +function removeStoredConversationId(endpoint, storage = localStorage) { + storage.removeItem(conversationStorageKey(endpoint)); +} +function getOrCreateUserId(endpoint, storage = localStorage) { + const key = `agent-chat:user:${endpoint}`; + let id = storage.getItem(key); + if (!id) { + id = crypto.randomUUID(); + storage.setItem(key, id); + } + return id; +} + // src/agent_manager/api/static/widget/react/shadcnAiElements.tsx var import_react8 = __toESM(require_react(), 1); @@ -52853,6 +52970,35 @@ var MessageResponse = (0, import_react8.memo)(function MessageResponse2({ }) { return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Qs, { className: cn3("message-response", className), ...props }); }); +function MessageFeedback({ + feedback, + onFeedback +}) { + return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "feedback-actions", role: "group", "aria-label": "Was this response helpful?", children: [ + /* @__PURE__ */ (0, import_jsx_runtime3.jsx)( + "button", + { + "aria-label": "Thumbs up", + "aria-pressed": feedback === "thumbs_up", + className: cn3("feedback-btn", feedback === "thumbs_up" && "active"), + onClick: () => onFeedback(feedback === "thumbs_up" ? null : "thumbs_up"), + type: "button", + children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ThumbsUp, { "aria-hidden": "true" }) + } + ), + /* @__PURE__ */ (0, import_jsx_runtime3.jsx)( + "button", + { + "aria-label": "Thumbs down", + "aria-pressed": feedback === "thumbs_down", + className: cn3("feedback-btn", feedback === "thumbs_down" && "active"), + onClick: () => onFeedback(feedback === "thumbs_down" ? null : "thumbs_down"), + type: "button", + children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ThumbsDown, { "aria-hidden": "true" }) + } + ) + ] }); +} function PromptInput({ children: children2, className, @@ -52950,20 +53096,19 @@ var TOOL_STATUS = { function reduceStreamEvent(entry, event) { switch (event.type) { case "answer_delta": - return { ...entry, text: entry.text + (event.content ?? ""), typing: false }; + return { ...entry, text: entry.text + (event.content ?? "") }; case "route": - return { ...entry, route: event.route ?? entry.route, typing: false }; + return { ...entry, route: event.route ?? entry.route }; case "tool_started": case "tool_succeeded": case "tool_failed": - return { ...entry, tools: upsertTool(entry.tools ?? [], toToolRecord(event)), typing: false }; + return { ...entry, tools: upsertTool(entry.tools ?? [], toToolRecord(event)) }; case "final": return { ...entry, text: event.content ?? entry.text, route: event.route ?? entry.route, - tools: event.used_tools ?? entry.tools, - typing: false + tools: event.used_tools ?? entry.tools }; case "error": throw new Error(event.error || "stream failed"); @@ -52988,29 +53133,14 @@ function upsertTool(tools, next2) { // src/agent_manager/api/static/widget/react/useConversation.ts var import_react9 = __toESM(require_react(), 1); - -// src/agent_manager/api/static/widget/storage/conversationStorage.ts -function conversationStorageKey(endpoint) { - return `agent-chat:${endpoint}`; -} -function getStoredConversationId(endpoint, storage = localStorage) { - return storage.getItem(conversationStorageKey(endpoint)); -} -function setStoredConversationId(endpoint, conversationId, storage = localStorage) { - storage.setItem(conversationStorageKey(endpoint), conversationId); -} -function removeStoredConversationId(endpoint, storage = localStorage) { - storage.removeItem(conversationStorageKey(endpoint)); -} - -// src/agent_manager/api/static/widget/react/useConversation.ts var isMissingConversation = (error) => error instanceof AgentChatHttpError && error.status === 404; -function useConversation(client, endpoint) { +function useConversation(client, endpoint, userId) { const startConversation = (0, import_react9.useCallback)(async () => { - const created = await client.createConversation(); + const created = await client.createConversation(userId); setStoredConversationId(endpoint, created); return created; - }, [client, endpoint]); + }, [client, endpoint, userId]); + const peekId = (0, import_react9.useCallback)(() => getStoredConversationId(endpoint), [endpoint]); const ensureId = (0, import_react9.useCallback)( async () => getStoredConversationId(endpoint) ?? startConversation(), [endpoint, startConversation] @@ -53051,7 +53181,38 @@ function useConversation(client, endpoint) { return []; } }, [client, endpoint]); - return (0, import_react9.useMemo)(() => ({ send, stream, loadHistory }), [send, stream, loadHistory]); + const loadUsage = (0, import_react9.useCallback)(async () => { + const stored = getStoredConversationId(endpoint); + if (!stored) return null; + try { + return await client.getUsage(stored); + } catch { + return null; + } + }, [client, endpoint]); + const listThreads = (0, import_react9.useCallback)( + () => client.listConversations(userId).catch(() => []), + [client, userId] + ); + const switchTo = (0, import_react9.useCallback)( + (conversationId) => setStoredConversationId(endpoint, conversationId), + [endpoint] + ); + const startNew = (0, import_react9.useCallback)(() => removeStoredConversationId(endpoint), [endpoint]); + return (0, import_react9.useMemo)( + () => ({ + peekId, + ensureId, + send, + stream, + loadHistory, + loadUsage, + listThreads, + switchTo, + startNew + }), + [peekId, ensureId, send, stream, loadHistory, loadUsage, listThreads, switchTo, startNew] + ); } // src/agent_manager/api/static/widget/react/AgentChatApp.tsx @@ -53061,25 +53222,53 @@ var GENERIC_ERROR = "Something went wrong. Please try again."; var COPIED_RESET_MS = 2e3; var newId = () => crypto.randomUUID(); var toEntry = (message) => ({ - id: newId(), + id: message.message_id || newId(), + message_id: message.message_id, role: message.role === "user" ? "user" : "ai", - text: message.content + text: message.content, + feedback: message.feedback }); function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { const inline = config.mode === "inline"; - const conversation = useConversation(client, config.endpoint); + const userId = (0, import_react10.useMemo)( + () => config.user || getOrCreateUserId(config.endpoint), + [config.user, config.endpoint] + ); + const conversation = useConversation(client, config.endpoint, userId); const [open, setOpen] = (0, import_react10.useState)(inline); const [loaded, setLoaded] = (0, import_react10.useState)(false); const [sending, setSending] = (0, import_react10.useState)(false); - const [entries, setEntries] = (0, import_react10.useState)([]); + const [entriesById, setEntriesById] = (0, import_react10.useState)({}); + const [activeId, setActiveId] = (0, import_react10.useState)(""); + const entries = entriesById[activeId] ?? []; + const [usage, setUsage] = (0, import_react10.useState)(null); + const [threads, setThreads] = (0, import_react10.useState)([]); + const [threadsOpen, setThreadsOpen] = (0, import_react10.useState)(false); const launcherRef = (0, import_react10.useRef)(null); const inputRef = (0, import_react10.useRef)(null); + const refreshUsage = (0, import_react10.useCallback)(async () => { + setUsage(await conversation.loadUsage()); + }, [conversation]); + const putEntries = (0, import_react10.useCallback)( + (cid, update) => setEntriesById((prev) => ({ ...prev, [cid]: update(prev[cid] ?? []) })), + [] + ); + const loadThread = (0, import_react10.useCallback)( + async (cid) => { + const history = await conversation.loadHistory(); + putEntries(cid, () => history.map(toEntry)); + }, + [conversation, putEntries] + ); const loadHistory = (0, import_react10.useCallback)(async () => { if (loaded) return; setLoaded(true); - const history = await conversation.loadHistory(); - if (history.length) setEntries(history.map(toEntry)); - }, [conversation, loaded]); + const cid = conversation.peekId(); + if (!cid) return; + setActiveId(cid); + await loadThread(cid); + await refreshUsage(); + }, [conversation, loaded, loadThread, refreshUsage]); (0, import_react10.useEffect)(() => { if (inline) void loadHistory(); }, [inline, loadHistory]); @@ -53096,15 +53285,54 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { setOpen(false); launcherRef.current?.focus({ preventScroll: true }); }, [inline]); - const replaceEntry = (0, import_react10.useCallback)((id, entry) => { - setEntries((prev) => prev.map((current) => current.id === id ? entry : current)); - }, []); + const openThreads = (0, import_react10.useCallback)(async () => { + setThreads(await conversation.listThreads()); + setThreadsOpen(true); + }, [conversation]); + const openThread = (0, import_react10.useCallback)( + async (conversationId) => { + conversation.switchTo(conversationId); + setThreadsOpen(false); + setActiveId(conversationId); + if (!(conversationId in entriesById)) await loadThread(conversationId); + await refreshUsage(); + inputRef.current?.focus({ preventScroll: true }); + }, + [conversation, entriesById, loadThread, refreshUsage] + ); + const startNewThread = (0, import_react10.useCallback)(() => { + conversation.startNew(); + setThreadsOpen(false); + setActiveId(""); + setUsage(null); + inputRef.current?.focus({ preventScroll: true }); + }, [conversation]); + const replaceEntry = (0, import_react10.useCallback)( + (cid, id, entry) => putEntries(cid, (prev) => prev.map((current) => current.id === id ? entry : current)), + [putEntries] + ); + const handleFeedback = (0, import_react10.useCallback)( + async (messageId, feedback) => { + const convId = activeId || getStoredConversationId(config.endpoint); + if (!convId) return; + putEntries( + convId, + (prev) => prev.map((entry) => entry.message_id === messageId ? { ...entry, feedback } : entry) + ); + try { + await client.recordFeedback(convId, messageId, feedback); + } catch { + } + }, + [activeId, client, config.endpoint, putEntries] + ); const sendWithoutStreaming = (0, import_react10.useCallback)( - async (text10, entryId) => { + async (cid, text10, entryId) => { try { const answer = await conversation.send(text10); - replaceEntry(entryId, { + replaceEntry(cid, entryId, { id: entryId, + message_id: answer.message_id, role: "ai", text: answer.answer, route: answer.visited, @@ -53112,31 +53340,34 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { }); onAnswer({ visited: answer.visited ?? [], used_tools: answer.used_tools ?? [] }); } catch { - replaceEntry(entryId, { id: entryId, role: "ai", text: GENERIC_ERROR, error: true }); + replaceEntry(cid, entryId, { id: entryId, role: "ai", text: GENERIC_ERROR, error: true }); } }, [conversation, onAnswer, replaceEntry] ); const submit = (0, import_react10.useCallback)( async (text10) => { + const cid = await conversation.ensureId(); + setActiveId(cid); const pending = { id: newId(), role: "ai", text: "", typing: true }; - setEntries((prev) => [...prev, { id: newId(), role: "user", text: text10 }, pending]); + putEntries(cid, (prev) => [...prev, { id: newId(), role: "user", text: text10 }, pending]); setSending(true); try { let entry = pending; for await (const event of conversation.stream(text10)) { entry = reduceStreamEvent(entry, event); - replaceEntry(pending.id, entry); + replaceEntry(cid, pending.id, entry); } - replaceEntry(pending.id, { ...entry, typing: false }); + replaceEntry(cid, pending.id, { ...entry, typing: false }); onAnswer({ visited: entry.route ?? [], used_tools: entry.tools ?? [] }); } catch { - await sendWithoutStreaming(text10, pending.id); + await sendWithoutStreaming(cid, text10, pending.id); } finally { setSending(false); + void refreshUsage(); } }, - [conversation, onAnswer, replaceEntry, sendWithoutStreaming] + [conversation, onAnswer, putEntries, refreshUsage, replaceEntry, sendWithoutStreaming] ); const toggle = () => void (open ? closeChat() : openChat()); return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)( @@ -53163,14 +53394,45 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { role: inline ? "region" : "dialog", children: [ /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("header", { className: "header", children: [ + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)( + "button", + { + "aria-label": "Conversations", + className: "header-btn", + onClick: () => void openThreads(), + type: "button", + children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(History, { "aria-hidden": true }) + } + ), /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "dot", style: avatarStyle(config.avatar) }), /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "title", id: titleId, children: config.title }), + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)( + "button", + { + "aria-label": "New chat", + className: "header-btn", + onClick: startNewThread, + type: "button", + children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(SquarePen, { "aria-hidden": true }) + } + ), !inline ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { "aria-label": "Close chat", className: "close", onClick: closeChat, type: "button", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(X, { "aria-hidden": true }) }) : null ] }), /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "body", children: [ + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)( + ThreadDrawer, + { + open: threadsOpen, + threads, + activeId: getStoredConversationId(config.endpoint), + onSelect: openThread, + onNew: startNewThread, + onClose: () => setThreadsOpen(false) + } + ), /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Conversation, { children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(ConversationContent, { children: [ entries.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Welcome, { title: config.greeting || DEFAULT_GREETING }) : null, - entries.map((entry) => /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ChatMessage, { entry }, entry.id)) + entries.map((entry) => /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ChatMessage, { entry, onFeedback: handleFeedback }, entry.id)) ] }) }), /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(PromptInput, { onSubmit: (message) => void submit(message.text), children: [ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)( @@ -53184,7 +53446,10 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { } ), /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(PromptInputFooter, { children: [ - /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "prompt-hint", children: "Enter to send \xB7 Shift+Enter for a new line" }), + /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "footer-start", children: [ + usage ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ContextMeter, { usage }) : null, + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "prompt-hint", children: "Enter to send \xB7 Shift+Enter for a new line" }) + ] }), /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(PromptInputSubmit, { disabled: sending }) ] }) ] }), @@ -53226,25 +53491,48 @@ function Launcher({ } ); } -function ChatMessage({ entry }) { +function ChatMessage({ + entry, + onFeedback +}) { const from = entry.role === "user" ? "user" : "assistant"; - if (entry.typing) { - return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Message, { from, typing: true, children: "..." }); - } if (entry.error) { return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Message, { from, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "msg-error", role: "alert", children: entry.text }) }); } if (entry.role === "user") { return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Message, { from: "user", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(MessageContent, { children: entry.text }) }); } - return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(Message, { from: "assistant", children: [ + const thinking = Boolean(entry.typing) && !entry.text.trim(); + return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(Message, { from: "assistant", typing: thinking, children: [ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(AgentActivity, { route: entry.route, tools: entry.tools }), - /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(MessageContent, { children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(MessageResponse, { children: entry.text }) }), - entry.text.trim() ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(MessageActions, { text: entry.text }) : null + thinking ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ThinkingDots, {}) : /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_jsx_runtime4.Fragment, { children: [ + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(MessageContent, { children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(MessageResponse, { children: entry.text }) }), + entry.text.trim() ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(MessageActions, { text: entry.text, entry, onFeedback }) : null + ] }) + ] }); +} +function ThinkingDots() { + return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("span", { className: "thinking", "aria-hidden": true, children: [ + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "thinking-dot" }), + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "thinking-dot" }), + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "thinking-dot" }) ] }); } -function MessageActions({ text: text10 }) { - return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "msg-actions", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(CopyButton, { text: text10 }) }); +function MessageActions({ + text: text10, + entry, + onFeedback +}) { + return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "msg-actions", children: [ + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(CopyButton, { text: text10 }), + onFeedback ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)( + MessageFeedback, + { + feedback: entry.feedback, + onFeedback: (fb) => onFeedback(entry.message_id || entry.id, fb) + } + ) : null + ] }); } function CopyButton({ text: text10 }) { const [copied, setCopied] = (0, import_react10.useState)(false); @@ -53280,6 +53568,104 @@ function Welcome({ title }) { /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: "welcome-title", children: title }) ] }); } +function ThreadDrawer({ + open, + threads, + activeId, + onSelect, + onNew, + onClose +}) { + return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: `thread-drawer${open ? " open" : ""}`, inert: !open, children: [ + /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "thread-drawer-head", children: [ + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { children: "Chats" }), + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { "aria-label": "Close conversations", className: "header-btn", onClick: onClose, type: "button", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(X, { "aria-hidden": true }) }) + ] }), + /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("button", { className: "thread-new", onClick: onNew, type: "button", children: [ + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(SquarePen, { "aria-hidden": true }), + "New chat" + ] }), + /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "thread-list", children: [ + threads.map((thread) => /* @__PURE__ */ (0, import_jsx_runtime4.jsx)( + "button", + { + className: `thread-item${thread.conversation_id === activeId ? " active" : ""}`, + "aria-current": thread.conversation_id === activeId, + onClick: () => onSelect(thread.conversation_id), + type: "button", + children: thread.title || "New chat" + }, + thread.conversation_id + )), + threads.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: "thread-empty", children: "No conversations yet" }) : null + ] }) + ] }); +} +var RING_SIZE = 18; +var RING_STROKE = 2.5; +var RING_RADIUS = (RING_SIZE - RING_STROKE) / 2; +var RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; +function ContextMeter({ usage }) { + const { used_tokens: used, max_tokens: max, percent, severity } = usage; + if (!max) return null; + const center = RING_SIZE / 2; + const ring = { cx: center, cy: center, r: RING_RADIUS, fill: "none", strokeWidth: RING_STROKE }; + const rounded = Math.round(percent); + return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)( + "span", + { + className: `context-meter ${severity}`, + role: "img", + "aria-label": `Context ${rounded}% used`, + tabIndex: 0, + children: [ + /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)( + "svg", + { + className: "context-ring", + width: RING_SIZE, + height: RING_SIZE, + viewBox: `0 0 ${RING_SIZE} ${RING_SIZE}`, + "aria-hidden": true, + children: [ + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("circle", { className: "context-ring-track", ...ring }), + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)( + "circle", + { + className: "context-ring-value", + ...ring, + strokeLinecap: "round", + strokeDasharray: RING_CIRCUMFERENCE, + strokeDashoffset: RING_CIRCUMFERENCE * (1 - percent / 100) + } + ) + ] + } + ), + /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("span", { className: "context-percent", children: [ + rounded, + "%" + ] }), + /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("span", { className: "context-popover", role: "tooltip", children: [ + /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("span", { className: "context-popover-head", children: [ + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { children: "Context usage" }), + /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("span", { className: "context-popover-count", children: [ + formatTokens(Math.min(used, max)), + " of ", + formatTokens(max) + ] }) + ] }), + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "context-bar", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "context-bar-fill", style: { width: `${percent}%` } }) }) + ] }) + ] + } + ); +} +function formatTokens(tokens) { + if (tokens >= 1e6) return `${(tokens / 1e6).toFixed(1).replace(/\.0$/, "")}M`; + if (tokens >= 1e3) return `${(tokens / 1e3).toFixed(1).replace(/\.0$/, "")}k`; + return `${tokens}`; +} function avatarStyle(avatar) { if (!avatar) return void 0; return { backgroundImage: `url("${avatar.replace(/"/g, "%22")}")` }; @@ -53332,18 +53718,48 @@ function styles(config) { from { opacity: 0; transform: translateY(8px); } } .panel.inline { position: static; opacity: 1; transform: none; pointer-events: auto; box-shadow: 0 4px 18px rgba(0,0,0,.12); } - .header { background: #fff; color: #18181b; padding: 14px 18px; font-weight: 600; - font-size: 14px; display: flex; align-items: center; gap: 10px; + .header { background: #fff; color: #18181b; padding: 12px 12px 12px 14px; font-weight: 600; + font-size: 14px; display: flex; align-items: center; gap: 6px; border-bottom: 1px solid #f0f0f1; } .header .dot { width: 22px; height: 22px; border-radius: 50%; flex: 0 0 auto; background: ${config.color}; background-size: cover; background-position: center; } .header .title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .header-btn { background: transparent; border: 0; color: #71717a; cursor: pointer; + padding: 0; width: 30px; height: 30px; border-radius: 8px; flex: 0 0 auto; + display: flex; align-items: center; justify-content: center; + transition: background .12s, color .12s; } + .header-btn:hover { background: #f4f4f5; color: #18181b; } + .header-btn svg { width: 17px; height: 17px; } .close { background: transparent; border: 0; color: #71717a; cursor: pointer; padding: 0; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: background .12s; } .close:hover { background: #f4f4f5; color: #18181b; } .close svg { width: 16px; height: 16px; } - .body { flex: 1; min-height: 0; display: flex; flex-direction: column; background: #fff; } + .body { flex: 1; min-height: 0; position: relative; display: flex; flex-direction: column; background: #fff; } + .thread-drawer { position: absolute; inset: 0; z-index: 3; background: #fff; + display: flex; flex-direction: column; transform: translateX(-100%); + opacity: 0; pointer-events: none; + transition: transform .25s cubic-bezier(.32,.72,0,1), opacity .25s ease; } + .thread-drawer.open { transform: none; opacity: 1; pointer-events: auto; } + .thread-drawer-head { display: flex; align-items: center; justify-content: space-between; + padding: 10px 12px 10px 18px; font-weight: 600; font-size: 14px; color: #18181b; + border-bottom: 1px solid #f0f0f1; } + .thread-new { display: flex; align-items: center; gap: 8px; margin: 12px 14px 4px; + padding: 10px 14px; border: 1px solid #e4e4e7; border-radius: 12px; background: #fff; + color: #18181b; font-size: 14px; font-weight: 500; cursor: pointer; font-family: inherit; + transition: background .12s; } + .thread-new:hover { background: #f4f4f5; } + .thread-new svg { width: 16px; height: 16px; flex: 0 0 auto; } + .thread-list { flex: 1; min-height: 0; overflow-y: auto; padding: 6px 10px 14px; + display: flex; flex-direction: column; gap: 2px; + scrollbar-width: thin; scrollbar-color: #d4d4d8 transparent; } + .thread-item { text-align: left; border: 0; background: transparent; cursor: pointer; + padding: 10px 12px; border-radius: 10px; font-size: 13.5px; color: #3f3f46; + font-family: inherit; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + transition: background .12s; } + .thread-item:hover { background: #f4f4f5; } + .thread-item.active { background: #f4f4f5; color: #18181b; font-weight: 600; } + .thread-empty { color: #a1a1aa; font-size: 13px; text-align: center; padding: 24px 12px; margin: 0; } .messages { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #d4d4d8 transparent; } .messages::-webkit-scrollbar { width: 10px; } @@ -53372,7 +53788,7 @@ function styles(config) { .msg code { background: #f4f4f5; border-radius: 4px; padding: 1px 5px; font-size: 13px; } .msg pre { background: #f4f4f5; border-radius: 10px; padding: 10px 12px; overflow-x: auto; margin: 0; } .msg pre code { background: none; padding: 0; white-space: pre-wrap; } - .msg-actions { display: flex; gap: 4px; margin-top: 6px; + .msg-actions { display: flex; align-items: center; gap: 4px; margin-top: 6px; opacity: 0; transition: opacity .15s ease; } .msg.ai:hover .msg-actions, .msg-actions:focus-within { opacity: 1; } .msg-action { display: inline-flex; align-items: center; justify-content: center; @@ -53380,6 +53796,13 @@ function styles(config) { color: #71717a; cursor: pointer; transition: background .12s, color .12s; } .msg-action:hover { background: #f4f4f5; color: #18181b; } .msg-action svg { width: 15px; height: 15px; animation: aui-icon-in .15s ease; } + .feedback-actions { display: flex; align-items: center; gap: 2px; } + .feedback-btn { background: transparent; border: 0; color: #a1a1aa; cursor: pointer; + padding: 3px; border-radius: 6px; display: flex; align-items: center; justify-content: center; + transition: color .12s, background .12s; } + .feedback-btn:hover { color: #52525b; background: #f4f4f5; } + .feedback-btn.active { color: ${config.color}; background: #f4f4f5; } + .feedback-btn svg { width: 14px; height: 14px; } @keyframes aui-icon-in { from { opacity: 0; transform: scale(.75); } } .tool-list { margin-bottom: 10px; display: flex; flex-direction: column; gap: 8px; } .agent-meta { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; color: #71717a; @@ -53403,6 +53826,15 @@ function styles(config) { color: #b91c1c; border-radius: 8px; padding: 10px 12px; font-size: 13.5px; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } + .thinking { display: inline-flex; gap: 4px; color: #a1a1aa; } + .thinking-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; + animation: aui-dot 1.4s ease-in-out infinite; } + .thinking-dot:nth-child(2) { animation-delay: .2s; } + .thinking-dot:nth-child(3) { animation-delay: .4s; } + @keyframes aui-dot { + 0%, 100% { transform: scale(.8); opacity: .5; } + 50% { transform: scale(1.2); opacity: 1; } + } .composer { display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 8px; padding: 12px 14px; border-top: 1px solid #f0f0f1; } .input-wrap { min-width: 0; display: flex; border-radius: 20px; background: #fff; @@ -53428,6 +53860,34 @@ function styles(config) { .prompt-footer { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; gap: 10px; color: #a1a1aa; font-size: 11.5px; } .prompt-hint { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .footer-start { display: flex; align-items: center; gap: 8px; min-width: 0; } + .context-meter { position: relative; display: inline-flex; align-items: center; gap: 5px; + flex: 0 0 auto; color: #71717a; font-size: 11.5px; cursor: default; outline: none; } + .context-ring { transform: rotate(-90deg); } + .context-ring-track { stroke: #e4e4e7; } + .context-ring-value { stroke: #3f3f46; + transition: stroke-dashoffset .3s ease, stroke .3s ease; } + .context-percent { font-variant-numeric: tabular-nums; } + .context-meter.warning .context-ring-value { stroke: #f59e0b; } + .context-meter.warning .context-percent { color: #b45309; } + .context-meter.critical .context-ring-value { stroke: #ef4444; } + .context-meter.critical .context-percent { color: #b91c1c; } + .context-popover { position: absolute; bottom: calc(100% + 8px); left: 0; width: 200px; + background: #fff; color: #18181b; border: 1px solid #e4e4e7; border-radius: 10px; + padding: 10px 12px; box-shadow: 0 10px 25px rgba(0,0,0,.12); + opacity: 0; transform: translateY(4px); pointer-events: none; + transition: opacity .15s ease, transform .15s ease; z-index: 5; } + .context-meter:hover .context-popover, .context-meter:focus-visible .context-popover { + opacity: 1; transform: none; } + .context-popover-head { display: flex; align-items: baseline; justify-content: space-between; + gap: 12px; font-size: 12px; font-weight: 600; white-space: nowrap; } + .context-popover-count { color: #71717a; font-weight: 400; font-variant-numeric: tabular-nums; } + .context-bar { display: block; margin-top: 8px; height: 4px; background: #f4f4f5; + border-radius: 999px; overflow: hidden; } + .context-bar-fill { display: block; height: 100%; background: #3f3f46; + border-radius: 999px; transition: width .3s ease; } + .context-meter.warning .context-bar-fill { background: #f59e0b; } + .context-meter.critical .context-bar-fill { background: #ef4444; } .powered { text-align: center; padding: 0 14px 10px; color: #a1a1aa; font-size: 11px; letter-spacing: .01em; } @media (prefers-reduced-motion: reduce) { @@ -53442,6 +53902,9 @@ function styles(config) { .panel.open .composer { animation: none; } .welcome { animation: none; } .msg-action svg { animation: none; } + .context-ring-value, .context-bar-fill, .context-popover { transition: none; } + .thread-drawer { transition: none; } + .thinking-dot { animation: none; } } @media (max-width: 480px) { .panel:not(.inline) { width: 100vw; height: 100dvh; max-height: 100dvh; @@ -53464,7 +53927,7 @@ var AgentChatElement = class extends HTMLElement { this.titleId = `${this.widgetId}-title`; } static get observedAttributes() { - return ["endpoint", "title", "color", "greeting", "position", "avatar", "mode"]; + return ["endpoint", "title", "color", "greeting", "position", "avatar", "mode", "user"]; } connectedCallback() { if (this.connected) return; @@ -53785,6 +54248,14 @@ lucide-react/dist/esm/icons/copy.mjs: * See the LICENSE file in the root directory of this source tree. *) +lucide-react/dist/esm/icons/history.mjs: + (** + * @license lucide-react v1.22.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + *) + lucide-react/dist/esm/icons/send.mjs: (** * @license lucide-react v1.22.0 - ISC @@ -53793,6 +54264,30 @@ lucide-react/dist/esm/icons/send.mjs: * See the LICENSE file in the root directory of this source tree. *) +lucide-react/dist/esm/icons/square-pen.mjs: + (** + * @license lucide-react v1.22.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + *) + +lucide-react/dist/esm/icons/thumbs-down.mjs: + (** + * @license lucide-react v1.22.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + *) + +lucide-react/dist/esm/icons/thumbs-up.mjs: + (** + * @license lucide-react v1.22.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + *) + lucide-react/dist/esm/icons/wrench.mjs: (** * @license lucide-react v1.22.0 - ISC diff --git a/src/agent_manager/api/static/widget.test.mjs b/src/agent_manager/api/static/widget.test.mjs index c84172c3..d9c72009 100644 --- a/src/agent_manager/api/static/widget.test.mjs +++ b/src/agent_manager/api/static/widget.test.mjs @@ -260,6 +260,7 @@ assert.equal(customElements.defineCount, definesBefore, "defineAgentChat is idem position: "bottom-right", avatar: "", mode: "floating", + user: "", }); } @@ -340,4 +341,16 @@ client = new AgentChatClient("https://api.example"); assert.ok(client); await assert.rejects(() => client.sendMessage("conv-2", "break"), /HTTP 500/); +resetPage(); +const feedbackCalls = []; +globalThis.fetch = async (url, options = {}) => { + feedbackCalls.push({ url, options }); + if (url.endsWith("/feedback")) return jsonResponse({ message_id: "msg-123", feedback: "thumbs_up" }); + throw new Error(`unexpected fetch: ${url}`); +}; +client = new AgentChatClient("https://api.example"); +await client.recordFeedback("conv-1", "msg-123", "thumbs_up"); +assert.equal(feedbackCalls[0].url, "https://api.example/conversations/conv-1/messages/msg-123/feedback"); +assert.equal(JSON.parse(feedbackCalls[0].options.body).feedback, "thumbs_up"); + console.log("widget self-check: OK"); diff --git a/src/agent_manager/api/static/widget/api/AgentChatClient.ts b/src/agent_manager/api/static/widget/api/AgentChatClient.ts index 2566307b..e5a4346e 100644 --- a/src/agent_manager/api/static/widget/api/AgentChatClient.ts +++ b/src/agent_manager/api/static/widget/api/AgentChatClient.ts @@ -1,4 +1,10 @@ -import type { ChatMessage, SendMessageResponse, StreamEvent } from "../types"; +import type { + ChatMessage, + ContextUsage, + SendMessageResponse, + StreamEvent, + ThreadSummary, +} from "../types"; export class AgentChatHttpError extends Error { constructor(readonly status: number) { @@ -10,8 +16,12 @@ export class AgentChatHttpError extends Error { export class AgentChatClient { constructor(private readonly endpoint: string) {} - async createConversation(): Promise { - const response = await fetch(`${this.endpoint}/conversations`, { method: "POST" }); + async createConversation(userId: string): Promise { + const response = await fetch(`${this.endpoint}/conversations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user_id: userId }), + }); if (!response.ok) { throw new AgentChatHttpError(response.status); } @@ -19,6 +29,21 @@ export class AgentChatClient { return String(data.conversation_id); } + async listConversations(userId: string): Promise { + const url = `${this.endpoint}/conversations?user_id=${encodeURIComponent(userId)}`; + const response = await fetch(url); + if (!response.ok) { + throw new AgentChatHttpError(response.status); + } + const data = await response.json(); + if (!Array.isArray(data)) return []; + return data.map((thread) => ({ + conversation_id: String(thread.conversation_id), + title: thread.title ?? null, + last_message_at: thread.last_message_at ?? null, + })); + } + async getMessages(conversationId: string): Promise { const response = await fetch(`${this.endpoint}/conversations/${conversationId}/messages`); if (!response.ok) { @@ -41,9 +66,42 @@ export class AgentChatClient { answer: String(data.answer || ""), visited: Array.isArray(data.visited) ? (data.visited as string[]) : undefined, used_tools: Array.isArray(data.used_tools) ? data.used_tools : undefined, + message_id: data.message_id ? String(data.message_id) : undefined, + }; + } + + async getUsage(conversationId: string): Promise { + const response = await fetch(`${this.endpoint}/conversations/${conversationId}/usage`); + if (!response.ok) { + throw new AgentChatHttpError(response.status); + } + const data = await response.json(); + return { + used_tokens: Number(data.used_tokens) || 0, + max_tokens: data.max_tokens == null ? null : Number(data.max_tokens), + percent: Number(data.percent) || 0, + severity: data.severity ?? "normal", }; } + async recordFeedback( + conversationId: string, + messageId: string, + feedback: "thumbs_up" | "thumbs_down" | null, + ): Promise { + const response = await fetch( + `${this.endpoint}/conversations/${conversationId}/messages/${messageId}/feedback`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ feedback }), + }, + ); + if (!response.ok) { + throw new AgentChatHttpError(response.status); + } + } + async *streamMessage(conversationId: string, message: string): AsyncGenerator { const response = await fetch(`${this.endpoint}/conversations/${conversationId}/messages/stream`, { method: "POST", diff --git a/src/agent_manager/api/static/widget/config/parseConfig.ts b/src/agent_manager/api/static/widget/config/parseConfig.ts index 24425c22..d9ec742b 100644 --- a/src/agent_manager/api/static/widget/config/parseConfig.ts +++ b/src/agent_manager/api/static/widget/config/parseConfig.ts @@ -7,6 +7,7 @@ export const DEFAULT_CONFIG: Omit = { position: "bottom-right", avatar: "", mode: "floating", + user: "", }; const HEX_COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; @@ -37,6 +38,7 @@ export function parseConfig(element: HTMLElement, scriptOrigin: string): AgentCh position: safePosition(element.getAttribute("position")), avatar: element.getAttribute("avatar") || DEFAULT_CONFIG.avatar, mode: safeMode(element.getAttribute("mode")), + user: element.getAttribute("user") || DEFAULT_CONFIG.user, }; } diff --git a/src/agent_manager/api/static/widget/element/AgentChatElement.tsx b/src/agent_manager/api/static/widget/element/AgentChatElement.tsx index 510d265d..94237ecf 100644 --- a/src/agent_manager/api/static/widget/element/AgentChatElement.tsx +++ b/src/agent_manager/api/static/widget/element/AgentChatElement.tsx @@ -10,7 +10,7 @@ let nextWidgetId = 0; export class AgentChatElement extends HTMLElement { static get observedAttributes(): string[] { - return ["endpoint", "title", "color", "greeting", "position", "avatar", "mode"]; + return ["endpoint", "title", "color", "greeting", "position", "avatar", "mode", "user"]; } private config!: AgentChatConfig; diff --git a/src/agent_manager/api/static/widget/react/AgentChatApp.tsx b/src/agent_manager/api/static/widget/react/AgentChatApp.tsx index 0428b3eb..f1afaac3 100644 --- a/src/agent_manager/api/static/widget/react/AgentChatApp.tsx +++ b/src/agent_manager/api/static/widget/react/AgentChatApp.tsx @@ -1,12 +1,23 @@ -import { BotIcon, CheckIcon, ChevronDownIcon, CopyIcon, XIcon } from "lucide-react"; -import { type Ref, useCallback, useEffect, useRef, useState } from "react"; +import { + BotIcon, + CheckIcon, + ChevronDownIcon, + CopyIcon, + HistoryIcon, + SquarePenIcon, + XIcon, +} from "lucide-react"; +import { type Ref, useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { AgentChatClient } from "../api/AgentChatClient"; +import { getOrCreateUserId, getStoredConversationId } from "../storage/conversationStorage"; import type { AgentChatAnswerDetail, AgentChatConfig, ChatMessage, + ContextUsage, MessageEntry, + ThreadSummary, ToolRecord, } from "../types"; import { @@ -14,6 +25,7 @@ import { ConversationContent, Message, MessageContent, + MessageFeedback, MessageResponse, PromptInput, PromptInputFooter, @@ -35,9 +47,11 @@ const COPIED_RESET_MS = 2000; const newId = () => crypto.randomUUID(); const toEntry = (message: ChatMessage): MessageEntry => ({ - id: newId(), + id: message.message_id || newId(), + message_id: message.message_id, role: message.role === "user" ? "user" : "ai", text: message.content, + feedback: message.feedback as "thumbs_up" | "thumbs_down" | null | undefined, }); export interface AgentChatAppProps { @@ -50,20 +64,50 @@ export interface AgentChatAppProps { export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: AgentChatAppProps) { const inline = config.mode === "inline"; - const conversation = useConversation(client, config.endpoint); + const userId = useMemo( + () => config.user || getOrCreateUserId(config.endpoint), + [config.user, config.endpoint], + ); + const conversation = useConversation(client, config.endpoint, userId); const [open, setOpen] = useState(inline); const [loaded, setLoaded] = useState(false); const [sending, setSending] = useState(false); - const [entries, setEntries] = useState([]); + const [entriesById, setEntriesById] = useState>({}); + const [activeId, setActiveId] = useState(""); + const entries = entriesById[activeId] ?? []; + const [usage, setUsage] = useState(null); + const [threads, setThreads] = useState([]); + const [threadsOpen, setThreadsOpen] = useState(false); const launcherRef = useRef(null); const inputRef = useRef(null); + const refreshUsage = useCallback(async () => { + setUsage(await conversation.loadUsage()); + }, [conversation]); + + const putEntries = useCallback( + (cid: string, update: (prev: MessageEntry[]) => MessageEntry[]) => + setEntriesById((prev) => ({ ...prev, [cid]: update(prev[cid] ?? []) })), + [], + ); + + const loadThread = useCallback( + async (cid: string) => { + const history = await conversation.loadHistory(); + putEntries(cid, () => history.map(toEntry)); + }, + [conversation, putEntries], + ); + const loadHistory = useCallback(async () => { if (loaded) return; setLoaded(true); - const history = await conversation.loadHistory(); - if (history.length) setEntries(history.map(toEntry)); - }, [conversation, loaded]); + const cid = conversation.peekId(); + if (!cid) return; + setActiveId(cid); + await loadThread(cid); + await refreshUsage(); + }, [conversation, loaded, loadThread, refreshUsage]); useEffect(() => { if (inline) void loadHistory(); @@ -85,16 +129,61 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age launcherRef.current?.focus({ preventScroll: true }); }, [inline]); - const replaceEntry = useCallback((id: string, entry: MessageEntry) => { - setEntries((prev) => prev.map((current) => (current.id === id ? entry : current))); - }, []); + const openThreads = useCallback(async () => { + setThreads(await conversation.listThreads()); + setThreadsOpen(true); + }, [conversation]); + + const openThread = useCallback( + async (conversationId: string) => { + conversation.switchTo(conversationId); + setThreadsOpen(false); + setActiveId(conversationId); + // ponytail: in-session map owns in-flight streams, so only cold-load a thread we haven't opened yet. + if (!(conversationId in entriesById)) await loadThread(conversationId); + await refreshUsage(); + inputRef.current?.focus({ preventScroll: true }); + }, + [conversation, entriesById, loadThread, refreshUsage], + ); + + const startNewThread = useCallback(() => { + conversation.startNew(); + setThreadsOpen(false); + setActiveId(""); + setUsage(null); + inputRef.current?.focus({ preventScroll: true }); + }, [conversation]); + + const replaceEntry = useCallback( + (cid: string, id: string, entry: MessageEntry) => + putEntries(cid, (prev) => prev.map((current) => (current.id === id ? entry : current))), + [putEntries], + ); + + const handleFeedback = useCallback( + async (messageId: string, feedback: "thumbs_up" | "thumbs_down" | null) => { + const convId = activeId || getStoredConversationId(config.endpoint); + if (!convId) return; + putEntries(convId, (prev) => + prev.map((entry) => (entry.message_id === messageId ? { ...entry, feedback } : entry)), + ); + try { + await client.recordFeedback(convId, messageId, feedback); + } catch { + // Non-fatal feedback recording error + } + }, + [activeId, client, config.endpoint, putEntries], + ); const sendWithoutStreaming = useCallback( - async (text: string, entryId: string) => { + async (cid: string, text: string, entryId: string) => { try { const answer = await conversation.send(text); - replaceEntry(entryId, { + replaceEntry(cid, entryId, { id: entryId, + message_id: answer.message_id, role: "ai", text: answer.answer, route: answer.visited, @@ -102,7 +191,7 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age }); onAnswer({ visited: answer.visited ?? [], used_tools: answer.used_tools ?? [] }); } catch { - replaceEntry(entryId, { id: entryId, role: "ai", text: GENERIC_ERROR, error: true }); + replaceEntry(cid, entryId, { id: entryId, role: "ai", text: GENERIC_ERROR, error: true }); } }, [conversation, onAnswer, replaceEntry], @@ -110,24 +199,27 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age const submit = useCallback( async (text: string) => { + const cid = await conversation.ensureId(); + setActiveId(cid); const pending: MessageEntry = { id: newId(), role: "ai", text: "", typing: true }; - setEntries((prev) => [...prev, { id: newId(), role: "user", text }, pending]); + putEntries(cid, (prev) => [...prev, { id: newId(), role: "user", text }, pending]); setSending(true); try { let entry = pending; for await (const event of conversation.stream(text)) { entry = reduceStreamEvent(entry, event); - replaceEntry(pending.id, entry); + replaceEntry(cid, pending.id, entry); } - replaceEntry(pending.id, { ...entry, typing: false }); + replaceEntry(cid, pending.id, { ...entry, typing: false }); onAnswer({ visited: entry.route ?? [], used_tools: entry.tools ?? [] }); } catch { - await sendWithoutStreaming(text, pending.id); + await sendWithoutStreaming(cid, text, pending.id); } finally { setSending(false); + void refreshUsage(); } }, - [conversation, onAnswer, replaceEntry, sendWithoutStreaming], + [conversation, onAnswer, putEntries, refreshUsage, replaceEntry, sendWithoutStreaming], ); const toggle = () => void (open ? closeChat() : openChat()); @@ -156,10 +248,26 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age role={inline ? "region" : "dialog"} >
+ {config.title} + {!inline ? (
+ setThreadsOpen(false)} + /> {entries.length === 0 ? ( ) : null} {entries.map((entry) => ( - + ))} @@ -187,7 +303,10 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age placeholder="Message..." /> - Enter to send · Shift+Enter for a new line +
+ {usage ? : null} + Enter to send · Shift+Enter for a new line +
@@ -231,17 +350,15 @@ function Launcher({ ); } -function ChatMessage({ entry }: { entry: MessageEntry }) { +function ChatMessage({ + entry, + onFeedback, +}: { + entry: MessageEntry; + onFeedback?: (messageId: string, feedback: "thumbs_up" | "thumbs_down" | null) => void; +}) { const from = entry.role === "user" ? "user" : "assistant"; - if (entry.typing) { - return ( - - ... - - ); - } - if (entry.error) { return ( @@ -260,21 +377,55 @@ function ChatMessage({ entry }: { entry: MessageEntry }) { ); } + const thinking = Boolean(entry.typing) && !entry.text.trim(); + return ( - + - - {entry.text} - - {entry.text.trim() ? : null} + {thinking ? ( + + ) : ( + <> + + {entry.text} + + {entry.text.trim() ? ( + + ) : null} + + )} ); } -function MessageActions({ text }: { text: string }) { +function ThinkingDots() { + return ( + + + + + + ); +} + +function MessageActions({ + text, + entry, + onFeedback, +}: { + text: string; + entry: MessageEntry; + onFeedback?: (messageId: string, feedback: "thumbs_up" | "thumbs_down" | null) => void; +}) { return (
+ {onFeedback ? ( + onFeedback(entry.message_id || entry.id, fb)} + /> + ) : null}
); } @@ -337,6 +488,109 @@ function Welcome({ title }: { title: string }) { ); } +function ThreadDrawer({ + open, + threads, + activeId, + onSelect, + onNew, + onClose, +}: { + open: boolean; + threads: ThreadSummary[]; + activeId: string | null; + onSelect: (conversationId: string) => void; + onNew: () => void; + onClose: () => void; +}) { + return ( +
+
+ Chats + +
+ +
+ {threads.map((thread) => ( + + ))} + {threads.length === 0 ?

No conversations yet

: null} +
+
+ ); +} + +const RING_SIZE = 18; +const RING_STROKE = 2.5; +const RING_RADIUS = (RING_SIZE - RING_STROKE) / 2; +const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; + +function ContextMeter({ usage }: { usage: ContextUsage }) { + const { used_tokens: used, max_tokens: max, percent, severity } = usage; + if (!max) return null; + + const center = RING_SIZE / 2; + const ring = { cx: center, cy: center, r: RING_RADIUS, fill: "none", strokeWidth: RING_STROKE }; + const rounded = Math.round(percent); + + return ( + + + + + + {rounded}% + + + Context usage + + {formatTokens(Math.min(used, max))} of {formatTokens(max)} + + + + + + + + ); +} + +function formatTokens(tokens: number): string { + if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; + if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1).replace(/\.0$/, "")}k`; + return `${tokens}`; +} + function avatarStyle(avatar: string) { if (!avatar) return undefined; return { backgroundImage: `url("${avatar.replace(/"/g, "%22")}")` }; diff --git a/src/agent_manager/api/static/widget/react/shadcnAiElements.tsx b/src/agent_manager/api/static/widget/react/shadcnAiElements.tsx index bb62192e..dc0b35b2 100644 --- a/src/agent_manager/api/static/widget/react/shadcnAiElements.tsx +++ b/src/agent_manager/api/static/widget/react/shadcnAiElements.tsx @@ -1,4 +1,12 @@ -import { CheckCircleIcon, CircleIcon, SendIcon, WrenchIcon, XCircleIcon } from "lucide-react"; +import { + CheckCircleIcon, + CircleIcon, + SendIcon, + ThumbsDownIcon, + ThumbsUpIcon, + WrenchIcon, + XCircleIcon, +} from "lucide-react"; import type { ComponentProps, FormEvent, @@ -71,6 +79,37 @@ export const MessageResponse = memo(function MessageResponse({ return ; }); +export function MessageFeedback({ + feedback, + onFeedback, +}: { + feedback?: "thumbs_up" | "thumbs_down" | null; + onFeedback: (nextFeedback: "thumbs_up" | "thumbs_down" | null) => void; +}) { + return ( +
+ + +
+ ); +} + export interface PromptInputMessage { text: string; } diff --git a/src/agent_manager/api/static/widget/react/streamReducer.ts b/src/agent_manager/api/static/widget/react/streamReducer.ts index adc0f825..7c798fd5 100644 --- a/src/agent_manager/api/static/widget/react/streamReducer.ts +++ b/src/agent_manager/api/static/widget/react/streamReducer.ts @@ -9,20 +9,19 @@ const TOOL_STATUS: Record = { export function reduceStreamEvent(entry: MessageEntry, event: StreamEvent): MessageEntry { switch (event.type) { case "answer_delta": - return { ...entry, text: entry.text + (event.content ?? ""), typing: false }; + return { ...entry, text: entry.text + (event.content ?? "") }; case "route": - return { ...entry, route: event.route ?? entry.route, typing: false }; + return { ...entry, route: event.route ?? entry.route }; case "tool_started": case "tool_succeeded": case "tool_failed": - return { ...entry, tools: upsertTool(entry.tools ?? [], toToolRecord(event)), typing: false }; + return { ...entry, tools: upsertTool(entry.tools ?? [], toToolRecord(event)) }; case "final": return { ...entry, text: event.content ?? entry.text, route: event.route ?? entry.route, tools: event.used_tools ?? entry.tools, - typing: false, }; case "error": throw new Error(event.error || "stream failed"); diff --git a/src/agent_manager/api/static/widget/react/useConversation.ts b/src/agent_manager/api/static/widget/react/useConversation.ts index 9d4c0338..d4bec6e5 100644 --- a/src/agent_manager/api/static/widget/react/useConversation.ts +++ b/src/agent_manager/api/static/widget/react/useConversation.ts @@ -6,23 +6,41 @@ import { removeStoredConversationId, setStoredConversationId, } from "../storage/conversationStorage"; -import type { ChatMessage, SendMessageResponse, StreamEvent } from "../types"; +import type { + ChatMessage, + ContextUsage, + SendMessageResponse, + StreamEvent, + ThreadSummary, +} from "../types"; export interface Conversation { + peekId(): string | null; + ensureId(): Promise; send(text: string): Promise; stream(text: string): AsyncGenerator; loadHistory(): Promise; + loadUsage(): Promise; + listThreads(): Promise; + switchTo(conversationId: string): void; + startNew(): void; } const isMissingConversation = (error: unknown): boolean => error instanceof AgentChatHttpError && error.status === 404; -export function useConversation(client: AgentChatClient, endpoint: string): Conversation { +export function useConversation( + client: AgentChatClient, + endpoint: string, + userId: string, +): Conversation { const startConversation = useCallback(async () => { - const created = await client.createConversation(); + const created = await client.createConversation(userId); setStoredConversationId(endpoint, created); return created; - }, [client, endpoint]); + }, [client, endpoint, userId]); + + const peekId = useCallback(() => getStoredConversationId(endpoint), [endpoint]); const ensureId = useCallback( async () => getStoredConversationId(endpoint) ?? startConversation(), @@ -69,5 +87,40 @@ export function useConversation(client: AgentChatClient, endpoint: string): Conv } }, [client, endpoint]); - return useMemo(() => ({ send, stream, loadHistory }), [send, stream, loadHistory]); + const loadUsage = useCallback(async () => { + const stored = getStoredConversationId(endpoint); + if (!stored) return null; + try { + return await client.getUsage(stored); + } catch { + return null; + } + }, [client, endpoint]); + + const listThreads = useCallback( + () => client.listConversations(userId).catch(() => []), + [client, userId], + ); + + const switchTo = useCallback( + (conversationId: string) => setStoredConversationId(endpoint, conversationId), + [endpoint], + ); + + const startNew = useCallback(() => removeStoredConversationId(endpoint), [endpoint]); + + return useMemo( + () => ({ + peekId, + ensureId, + send, + stream, + loadHistory, + loadUsage, + listThreads, + switchTo, + startNew, + }), + [peekId, ensureId, send, stream, loadHistory, loadUsage, listThreads, switchTo, startNew], + ); } diff --git a/src/agent_manager/api/static/widget/storage/conversationStorage.ts b/src/agent_manager/api/static/widget/storage/conversationStorage.ts index c18262d7..7604ff82 100644 --- a/src/agent_manager/api/static/widget/storage/conversationStorage.ts +++ b/src/agent_manager/api/static/widget/storage/conversationStorage.ts @@ -17,3 +17,13 @@ export function setStoredConversationId( export function removeStoredConversationId(endpoint: string, storage: Storage = localStorage): void { storage.removeItem(conversationStorageKey(endpoint)); } + +export function getOrCreateUserId(endpoint: string, storage: Storage = localStorage): string { + const key = `agent-chat:user:${endpoint}`; + let id = storage.getItem(key); + if (!id) { + id = crypto.randomUUID(); + storage.setItem(key, id); + } + return id; +} diff --git a/src/agent_manager/api/static/widget/styles/styles.ts b/src/agent_manager/api/static/widget/styles/styles.ts index e461fd16..14ae5197 100644 --- a/src/agent_manager/api/static/widget/styles/styles.ts +++ b/src/agent_manager/api/static/widget/styles/styles.ts @@ -41,18 +41,48 @@ export function styles(config: AgentChatConfig): string { from { opacity: 0; transform: translateY(8px); } } .panel.inline { position: static; opacity: 1; transform: none; pointer-events: auto; box-shadow: 0 4px 18px rgba(0,0,0,.12); } - .header { background: #fff; color: #18181b; padding: 14px 18px; font-weight: 600; - font-size: 14px; display: flex; align-items: center; gap: 10px; + .header { background: #fff; color: #18181b; padding: 12px 12px 12px 14px; font-weight: 600; + font-size: 14px; display: flex; align-items: center; gap: 6px; border-bottom: 1px solid #f0f0f1; } .header .dot { width: 22px; height: 22px; border-radius: 50%; flex: 0 0 auto; background: ${config.color}; background-size: cover; background-position: center; } .header .title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .header-btn { background: transparent; border: 0; color: #71717a; cursor: pointer; + padding: 0; width: 30px; height: 30px; border-radius: 8px; flex: 0 0 auto; + display: flex; align-items: center; justify-content: center; + transition: background .12s, color .12s; } + .header-btn:hover { background: #f4f4f5; color: #18181b; } + .header-btn svg { width: 17px; height: 17px; } .close { background: transparent; border: 0; color: #71717a; cursor: pointer; padding: 0; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: background .12s; } .close:hover { background: #f4f4f5; color: #18181b; } .close svg { width: 16px; height: 16px; } - .body { flex: 1; min-height: 0; display: flex; flex-direction: column; background: #fff; } + .body { flex: 1; min-height: 0; position: relative; display: flex; flex-direction: column; background: #fff; } + .thread-drawer { position: absolute; inset: 0; z-index: 3; background: #fff; + display: flex; flex-direction: column; transform: translateX(-100%); + opacity: 0; pointer-events: none; + transition: transform .25s cubic-bezier(.32,.72,0,1), opacity .25s ease; } + .thread-drawer.open { transform: none; opacity: 1; pointer-events: auto; } + .thread-drawer-head { display: flex; align-items: center; justify-content: space-between; + padding: 10px 12px 10px 18px; font-weight: 600; font-size: 14px; color: #18181b; + border-bottom: 1px solid #f0f0f1; } + .thread-new { display: flex; align-items: center; gap: 8px; margin: 12px 14px 4px; + padding: 10px 14px; border: 1px solid #e4e4e7; border-radius: 12px; background: #fff; + color: #18181b; font-size: 14px; font-weight: 500; cursor: pointer; font-family: inherit; + transition: background .12s; } + .thread-new:hover { background: #f4f4f5; } + .thread-new svg { width: 16px; height: 16px; flex: 0 0 auto; } + .thread-list { flex: 1; min-height: 0; overflow-y: auto; padding: 6px 10px 14px; + display: flex; flex-direction: column; gap: 2px; + scrollbar-width: thin; scrollbar-color: #d4d4d8 transparent; } + .thread-item { text-align: left; border: 0; background: transparent; cursor: pointer; + padding: 10px 12px; border-radius: 10px; font-size: 13.5px; color: #3f3f46; + font-family: inherit; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + transition: background .12s; } + .thread-item:hover { background: #f4f4f5; } + .thread-item.active { background: #f4f4f5; color: #18181b; font-weight: 600; } + .thread-empty { color: #a1a1aa; font-size: 13px; text-align: center; padding: 24px 12px; margin: 0; } .messages { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #d4d4d8 transparent; } .messages::-webkit-scrollbar { width: 10px; } @@ -81,7 +111,7 @@ export function styles(config: AgentChatConfig): string { .msg code { background: #f4f4f5; border-radius: 4px; padding: 1px 5px; font-size: 13px; } .msg pre { background: #f4f4f5; border-radius: 10px; padding: 10px 12px; overflow-x: auto; margin: 0; } .msg pre code { background: none; padding: 0; white-space: pre-wrap; } - .msg-actions { display: flex; gap: 4px; margin-top: 6px; + .msg-actions { display: flex; align-items: center; gap: 4px; margin-top: 6px; opacity: 0; transition: opacity .15s ease; } .msg.ai:hover .msg-actions, .msg-actions:focus-within { opacity: 1; } .msg-action { display: inline-flex; align-items: center; justify-content: center; @@ -89,6 +119,13 @@ export function styles(config: AgentChatConfig): string { color: #71717a; cursor: pointer; transition: background .12s, color .12s; } .msg-action:hover { background: #f4f4f5; color: #18181b; } .msg-action svg { width: 15px; height: 15px; animation: aui-icon-in .15s ease; } + .feedback-actions { display: flex; align-items: center; gap: 2px; } + .feedback-btn { background: transparent; border: 0; color: #a1a1aa; cursor: pointer; + padding: 3px; border-radius: 6px; display: flex; align-items: center; justify-content: center; + transition: color .12s, background .12s; } + .feedback-btn:hover { color: #52525b; background: #f4f4f5; } + .feedback-btn.active { color: ${config.color}; background: #f4f4f5; } + .feedback-btn svg { width: 14px; height: 14px; } @keyframes aui-icon-in { from { opacity: 0; transform: scale(.75); } } .tool-list { margin-bottom: 10px; display: flex; flex-direction: column; gap: 8px; } .agent-meta { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; color: #71717a; @@ -112,6 +149,15 @@ export function styles(config: AgentChatConfig): string { color: #b91c1c; border-radius: 8px; padding: 10px 12px; font-size: 13.5px; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } + .thinking { display: inline-flex; gap: 4px; color: #a1a1aa; } + .thinking-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; + animation: aui-dot 1.4s ease-in-out infinite; } + .thinking-dot:nth-child(2) { animation-delay: .2s; } + .thinking-dot:nth-child(3) { animation-delay: .4s; } + @keyframes aui-dot { + 0%, 100% { transform: scale(.8); opacity: .5; } + 50% { transform: scale(1.2); opacity: 1; } + } .composer { display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 8px; padding: 12px 14px; border-top: 1px solid #f0f0f1; } .input-wrap { min-width: 0; display: flex; border-radius: 20px; background: #fff; @@ -137,6 +183,34 @@ export function styles(config: AgentChatConfig): string { .prompt-footer { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; gap: 10px; color: #a1a1aa; font-size: 11.5px; } .prompt-hint { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .footer-start { display: flex; align-items: center; gap: 8px; min-width: 0; } + .context-meter { position: relative; display: inline-flex; align-items: center; gap: 5px; + flex: 0 0 auto; color: #71717a; font-size: 11.5px; cursor: default; outline: none; } + .context-ring { transform: rotate(-90deg); } + .context-ring-track { stroke: #e4e4e7; } + .context-ring-value { stroke: #3f3f46; + transition: stroke-dashoffset .3s ease, stroke .3s ease; } + .context-percent { font-variant-numeric: tabular-nums; } + .context-meter.warning .context-ring-value { stroke: #f59e0b; } + .context-meter.warning .context-percent { color: #b45309; } + .context-meter.critical .context-ring-value { stroke: #ef4444; } + .context-meter.critical .context-percent { color: #b91c1c; } + .context-popover { position: absolute; bottom: calc(100% + 8px); left: 0; width: 200px; + background: #fff; color: #18181b; border: 1px solid #e4e4e7; border-radius: 10px; + padding: 10px 12px; box-shadow: 0 10px 25px rgba(0,0,0,.12); + opacity: 0; transform: translateY(4px); pointer-events: none; + transition: opacity .15s ease, transform .15s ease; z-index: 5; } + .context-meter:hover .context-popover, .context-meter:focus-visible .context-popover { + opacity: 1; transform: none; } + .context-popover-head { display: flex; align-items: baseline; justify-content: space-between; + gap: 12px; font-size: 12px; font-weight: 600; white-space: nowrap; } + .context-popover-count { color: #71717a; font-weight: 400; font-variant-numeric: tabular-nums; } + .context-bar { display: block; margin-top: 8px; height: 4px; background: #f4f4f5; + border-radius: 999px; overflow: hidden; } + .context-bar-fill { display: block; height: 100%; background: #3f3f46; + border-radius: 999px; transition: width .3s ease; } + .context-meter.warning .context-bar-fill { background: #f59e0b; } + .context-meter.critical .context-bar-fill { background: #ef4444; } .powered { text-align: center; padding: 0 14px 10px; color: #a1a1aa; font-size: 11px; letter-spacing: .01em; } @media (prefers-reduced-motion: reduce) { @@ -151,6 +225,9 @@ export function styles(config: AgentChatConfig): string { .panel.open .composer { animation: none; } .welcome { animation: none; } .msg-action svg { animation: none; } + .context-ring-value, .context-bar-fill, .context-popover { transition: none; } + .thread-drawer { transition: none; } + .thinking-dot { animation: none; } } @media (max-width: 480px) { .panel:not(.inline) { width: 100vw; height: 100dvh; max-height: 100dvh; diff --git a/src/agent_manager/api/static/widget/types.ts b/src/agent_manager/api/static/widget/types.ts index 7eaa9b0b..5a03d75e 100644 --- a/src/agent_manager/api/static/widget/types.ts +++ b/src/agent_manager/api/static/widget/types.ts @@ -10,6 +10,7 @@ export interface AgentChatConfig { position: AgentChatPosition; avatar: string; mode: AgentChatMode; + user: string; } export interface AgentChatConfigInput { @@ -20,22 +21,42 @@ export interface AgentChatConfigInput { position?: string; avatar?: string; mode?: string; + user?: string; +} + +export interface ThreadSummary { + conversation_id: string; + title: string | null; + last_message_at: string | null; } export interface ChatMessage { role: ChatRole; content: string; created_at?: string; + message_id?: string; + feedback?: "thumbs_up" | "thumbs_down" | null; +} + +export type ContextSeverity = "normal" | "warning" | "critical"; + +export interface ContextUsage { + used_tokens: number; + max_tokens: number | null; + percent: number; + severity: ContextSeverity; } export interface MessageEntry { id: string; + message_id?: string; role: "user" | "ai"; text: string; typing?: boolean; error?: boolean; route?: string[]; tools?: ToolRecord[]; + feedback?: "thumbs_up" | "thumbs_down" | null; } export interface ToolRecord { @@ -53,6 +74,7 @@ export interface SendMessageResponse { visited?: string[]; /** Tools observed during the run. */ used_tools?: ToolRecord[]; + message_id?: string; } export interface StreamEvent { @@ -73,6 +95,7 @@ export interface StreamEvent { error?: string; system_name?: string; used_tools?: ToolRecord[]; + message_id?: string; } /** Detail of the `agent-chat:answer` event a host page can listen for. */ diff --git a/src/agent_manager/application/__init__.py b/src/agent_manager/application/__init__.py index 291c1a27..dfe01055 100644 --- a/src/agent_manager/application/__init__.py +++ b/src/agent_manager/application/__init__.py @@ -4,6 +4,7 @@ ConversationNotFound, ConversationService, ConversationTokenBudgetExceeded, + MessageNotFound, PreparedConversationTurn, ) @@ -11,5 +12,6 @@ "ConversationNotFound", "ConversationService", "ConversationTokenBudgetExceeded", + "MessageNotFound", "PreparedConversationTurn", ] diff --git a/src/agent_manager/application/service.py b/src/agent_manager/application/service.py index e044c2c4..0eb7c480 100644 --- a/src/agent_manager/application/service.py +++ b/src/agent_manager/application/service.py @@ -17,13 +17,25 @@ from agent_engine.runtime.hooks import RunContext from agent_engine.runtime.streaming import RunStreamEvent from agent_manager.application.context import build_history -from agent_manager.domain import ConversationMessage, Message, Repository, Role +from agent_manager.domain import ( + ContextUsage, + ConversationMessage, + ConversationSession, + Message, + Repository, + Role, + thread_title, +) class ConversationNotFound(Exception): """Raised when an operation targets a conversation id that does not exist.""" +class MessageNotFound(Exception): + """Raised when an operation targets a message id that does not exist in a session.""" + + class ConversationTokenBudgetExceeded(Exception): """Raised when a conversation's lifetime token budget is exhausted.""" @@ -76,9 +88,19 @@ async def history(self, conversation_id: str) -> list[Message]: await self._require(conversation_id) return await self._repository.list_messages(conversation_id) + async def usage(self, conversation_id: str) -> ContextUsage: + await self._require(conversation_id) + used = await self._repository.get_token_usage(conversation_id) + return ContextUsage.from_totals(used, self._max_tokens) + + async def list_conversations( + self, user_id: str, *, limit: int = 50 + ) -> list[ConversationSession]: + return await self._repository.list_sessions(user_id, limit=limit) + async def send( self, conversation_id: str, text: str, *, user_id: str | None = None - ) -> RunResult: + ) -> tuple[RunResult, ConversationMessage | None]: turn = await self.prepare_turn(conversation_id, text, user_id=user_id) result = await self._engine.run( turn.message, @@ -89,9 +111,10 @@ async def send( user_id=turn.user_id, ), ) + msg: ConversationMessage | None = None if result.pending_approval is None: - await self.complete_turn(turn, result) - return result + msg = await self.complete_turn(turn, result) + return result, msg async def prepare_turn( self, @@ -116,6 +139,8 @@ async def prepare_turn( max_messages=self._window, max_chars=self._max_chars, ) + if not prior_context.messages: + await self._repository.rename_session(conversation_id, thread_title(text)) run_id = uuid.uuid4().hex now = datetime.now(UTC) await self._repository.append_message( @@ -143,32 +168,34 @@ async def complete_turn( self, turn: PreparedConversationTurn, result: RunResult, - ) -> None: + ) -> ConversationMessage: """Persist the final assistant response after any approval resumes finish.""" if result.pending_approval is not None: raise ValueError("cannot complete a conversation turn while approval is pending") + msg = ConversationMessage( + message_id=uuid.uuid4().hex, + session_id=turn.session_id, + run_id=turn.run_id, + user_id=turn.user_id, + role=Role.ASSISTANT, + content=result.answer, + created_at=datetime.now(UTC), + input_tokens=result.input_tokens, + output_tokens=result.output_tokens, + metadata={ + "visited": list(result.visited), + "used_tools": [dataclasses.asdict(tool) for tool in result.used_tools], + }, + ) await self._repository.append_message( - ConversationMessage( - message_id=uuid.uuid4().hex, - session_id=turn.session_id, - run_id=turn.run_id, - user_id=turn.user_id, - role=Role.ASSISTANT, - content=result.answer, - created_at=datetime.now(UTC), - input_tokens=result.input_tokens, - output_tokens=result.output_tokens, - metadata={ - "visited": list(result.visited), - "used_tools": [dataclasses.asdict(tool) for tool in result.used_tools], - }, - ), + msg, snapshot_ttl_seconds=self._snapshot_ttl_seconds, ) + return msg async def stream( self, conversation_id: str, text: str, *, user_id: str | None = None - ) -> AsyncIterator[RunStreamEvent]: + ) -> AsyncIterator[tuple[RunStreamEvent, str | None]]: turn = await self.prepare_turn(conversation_id, text, user_id=user_id) final: RunStreamEvent | None = None @@ -184,30 +211,45 @@ async def stream( ): if event.type == "final": final = event - yield event + else: + yield event, None except Exception: if final is None: raise if final is not None: + msg = ConversationMessage( + message_id=uuid.uuid4().hex, + session_id=turn.session_id, + run_id=turn.run_id, + user_id=turn.user_id, + role=Role.ASSISTANT, + content=final.content or "", + created_at=datetime.now(UTC), + input_tokens=final.input_tokens, + output_tokens=final.output_tokens, + metadata={ + "visited": list(final.route or ()), + "used_tools": [dataclasses.asdict(tool) for tool in final.used_tools], + }, + ) await self._repository.append_message( - ConversationMessage( - message_id=uuid.uuid4().hex, - session_id=turn.session_id, - run_id=turn.run_id, - user_id=turn.user_id, - role=Role.ASSISTANT, - content=final.content or "", - created_at=datetime.now(UTC), - input_tokens=final.input_tokens, - output_tokens=final.output_tokens, - metadata={ - "visited": list(final.route or ()), - "used_tools": [dataclasses.asdict(tool) for tool in final.used_tools], - }, - ), + msg, snapshot_ttl_seconds=self._snapshot_ttl_seconds, ) + yield final, msg.message_id + + async def record_feedback( + self, conversation_id: str, message_id: str, feedback: str | None + ) -> ConversationMessage: + """Record user feedback ('thumbs_up', 'thumbs_down', or None) on a message.""" + await self._require(conversation_id) + msg = await self._repository.update_message_feedback(conversation_id, message_id, feedback) + if msg is None: + raise MessageNotFound( + f"message '{message_id}' not found in conversation '{conversation_id}'" + ) + return msg async def _require(self, conversation_id: str) -> None: if not await self._repository.conversation_exists(conversation_id): diff --git a/src/agent_manager/domain/__init__.py b/src/agent_manager/domain/__init__.py index e8857caa..1849203b 100644 --- a/src/agent_manager/domain/__init__.py +++ b/src/agent_manager/domain/__init__.py @@ -1,6 +1,8 @@ """Domain layer: value objects and ports. Pure Python, no frameworks.""" from agent_manager.domain.models import ( + ContextSeverity, + ContextUsage, ConversationContext, ConversationMessage, ConversationSession, @@ -8,10 +10,13 @@ Message, Role, User, + thread_title, ) from agent_manager.domain.repository import Repository __all__ = [ + "ContextSeverity", + "ContextUsage", "ConversationContext", "ConversationMessage", "ConversationSession", @@ -20,4 +25,5 @@ "Repository", "Role", "User", + "thread_title", ] diff --git a/src/agent_manager/domain/models.py b/src/agent_manager/domain/models.py index 361de7d9..aa9c16e2 100644 --- a/src/agent_manager/domain/models.py +++ b/src/agent_manager/domain/models.py @@ -7,6 +7,9 @@ from enum import StrEnum from typing import Any +CONTEXT_WARNING_PERCENT = 65.0 +CONTEXT_CRITICAL_PERCENT = 85.0 + class Role(StrEnum): USER = "user" @@ -22,6 +25,8 @@ class Message: role: Role content: str created_at: datetime + message_id: str | None = None + feedback: str | None = None @dataclass(frozen=True) @@ -72,6 +77,7 @@ class ConversationMessage: status: str = "succeeded" error_type: str | None = None metadata: dict[str, Any] = field(default_factory=dict) + feedback: str | None = None @dataclass(frozen=True) @@ -94,3 +100,37 @@ class ConversationContext: message_count: int source: str snapshot: ConversationSnapshot | None = None + + +def thread_title(content: str, *, limit: int = 48) -> str: + text = " ".join(content.split()) + if not text: + return "New chat" + return text if len(text) <= limit else text[: limit - 1].rstrip() + "…" + + +class ContextSeverity(StrEnum): + NORMAL = "normal" + WARNING = "warning" + CRITICAL = "critical" + + +@dataclass(frozen=True) +class ContextUsage: + used_tokens: int + max_tokens: int | None + percent: float + severity: ContextSeverity + + @classmethod + def from_totals(cls, used_tokens: int, max_tokens: int | None) -> ContextUsage: + if not max_tokens: + return cls(used_tokens, max_tokens, 0.0, ContextSeverity.NORMAL) + percent = min(used_tokens / max_tokens * 100, 100.0) + if percent > CONTEXT_CRITICAL_PERCENT: + severity = ContextSeverity.CRITICAL + elif percent >= CONTEXT_WARNING_PERCENT: + severity = ContextSeverity.WARNING + else: + severity = ContextSeverity.NORMAL + return cls(used_tokens, max_tokens, percent, severity) diff --git a/src/agent_manager/domain/repository.py b/src/agent_manager/domain/repository.py index 108042ef..6c00461f 100644 --- a/src/agent_manager/domain/repository.py +++ b/src/agent_manager/domain/repository.py @@ -48,6 +48,15 @@ async def create_session( @abstractmethod async def get_session(self, session_id: str) -> ConversationSession | None: ... + @abstractmethod + async def list_sessions( + self, user_id: str, *, limit: int = 50 + ) -> list[ConversationSession]: + """A user's sessions, most-recently-active first.""" + + @abstractmethod + async def rename_session(self, session_id: str, title: str) -> None: ... + @abstractmethod async def append_message( self, @@ -63,6 +72,12 @@ async def list_conversation_messages( """Messages oldest-first. With `limit`, the most recent `limit`, still oldest-first.""" + @abstractmethod + async def update_message_feedback( + self, session_id: str, message_id: str, feedback: str | None + ) -> ConversationMessage | None: + """Update feedback on a specific message in a conversation session.""" + @abstractmethod async def get_snapshot(self, session_id: str) -> ConversationSnapshot | None: ... diff --git a/src/agent_manager/infrastructure/persistence/memory_repository.py b/src/agent_manager/infrastructure/persistence/memory_repository.py index 1e13c0ff..5ee179b1 100644 --- a/src/agent_manager/infrastructure/persistence/memory_repository.py +++ b/src/agent_manager/infrastructure/persistence/memory_repository.py @@ -4,6 +4,7 @@ import uuid from copy import deepcopy +from dataclasses import replace from datetime import UTC, datetime from typing import Any @@ -18,6 +19,8 @@ User, ) +_EPOCH = datetime(1970, 1, 1, tzinfo=UTC) + class MemoryRepository(Repository): def __init__(self) -> None: @@ -94,6 +97,18 @@ async def create_session( async def get_session(self, session_id: str) -> ConversationSession | None: return self._sessions.get(session_id) + async def list_sessions( + self, user_id: str, *, limit: int = 50 + ) -> list[ConversationSession]: + sessions = [s for s in self._sessions.values() if s.user_id == user_id] + sessions.sort(key=lambda s: s.last_message_at or s.created_at or _EPOCH, reverse=True) + return sessions[:limit] + + async def rename_session(self, session_id: str, title: str) -> None: + session = self._sessions.get(session_id) + if session is not None: + self._sessions[session_id] = replace(session, title=title) + async def create_conversation(self) -> str: return (await self.create_session()).session_id @@ -137,6 +152,43 @@ async def add_message(self, conversation_id: str, role: Role, content: str) -> N ) ) + async def update_message_feedback( + self, session_id: str, message_id: str, feedback: str | None + ) -> ConversationMessage | None: + msgs = self._messages.get(session_id) + if not msgs: + return None + for i, msg in enumerate(msgs): + if msg.message_id == message_id: + updated = ConversationMessage( + message_id=msg.message_id, + session_id=msg.session_id, + role=msg.role, + content=msg.content, + created_at=msg.created_at, + run_id=msg.run_id, + user_id=msg.user_id, + node_id=msg.node_id, + agent_id=msg.agent_id, + parent_message_id=msg.parent_message_id, + content_type=msg.content_type, + tool_name=msg.tool_name, + provider=msg.provider, + model_provider=msg.model_provider, + model_name=msg.model_name, + input_tokens=msg.input_tokens, + output_tokens=msg.output_tokens, + latency_ms=msg.latency_ms, + status=msg.status, + error_type=msg.error_type, + metadata=msg.metadata, + feedback=feedback, + ) + msgs[i] = updated + self._snapshots[session_id] = self._build_snapshot(session_id, None) + return updated + return None + async def list_conversation_messages( self, session_id: str, limit: int | None = None ) -> list[ConversationMessage]: @@ -145,7 +197,16 @@ async def list_conversation_messages( async def list_messages(self, conversation_id: str, limit: int | None = None) -> list[Message]: msgs = await self.list_conversation_messages(conversation_id, limit) - return [Message(role=m.role, content=m.content, created_at=m.created_at) for m in msgs] + return [ + Message( + role=m.role, + content=m.content, + created_at=m.created_at, + message_id=m.message_id, + feedback=m.feedback, + ) + for m in msgs + ] async def get_snapshot(self, session_id: str) -> ConversationSnapshot | None: return self._snapshots.get(session_id) @@ -213,6 +274,7 @@ def _build_snapshot( "role": msg.role.value, "content": msg.content, "content_type": msg.content_type, + "feedback": msg.feedback, "created_at": msg.created_at.isoformat(), "metadata": deepcopy(msg.metadata), } diff --git a/src/agent_manager/infrastructure/persistence/migrations/versions/0004_add_message_feedback.py b/src/agent_manager/infrastructure/persistence/migrations/versions/0004_add_message_feedback.py new file mode 100644 index 00000000..7ee24e52 --- /dev/null +++ b/src/agent_manager/infrastructure/persistence/migrations/versions/0004_add_message_feedback.py @@ -0,0 +1,26 @@ +"""add feedback column to conversation_messages + +Revision ID: 0004 +Revises: 0003 +Create Date: 2026-07-27 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision: str = "0004" +down_revision: str | None = "0003" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "conversation_messages", sa.Column("feedback", sa.String(length=32), nullable=True) + ) + + +def downgrade() -> None: + op.drop_column("conversation_messages", "feedback") diff --git a/src/agent_manager/infrastructure/persistence/sql_repository.py b/src/agent_manager/infrastructure/persistence/sql_repository.py index 7d746d93..f7589f83 100644 --- a/src/agent_manager/infrastructure/persistence/sql_repository.py +++ b/src/agent_manager/infrastructure/persistence/sql_repository.py @@ -123,6 +123,27 @@ async def get_session(self, session_id: str) -> ConversationSession | None: row = await session.get(ConversationSessionRow, session_id) return _session(row) if row else None + async def list_sessions( + self, user_id: str, *, limit: int = 50 + ) -> list[ConversationSession]: + stmt = ( + select(ConversationSessionRow) + .where(ConversationSessionRow.user_id == user_id) + .order_by(col(ConversationSessionRow.last_message_at).desc()) + .limit(limit) + ) + async with self._sessions() as session: + rows = (await session.exec(stmt)).all() + return [_session(row) for row in rows] + + async def rename_session(self, session_id: str, title: str) -> None: + async with self._sessions() as session: + row = await session.get(ConversationSessionRow, session_id) + if row is not None: + row.title = title + session.add(row) + await session.commit() + # `create_conversation`/`add_message` are not overridden here: the # `Repository` base class already provides them as thin aliases over # `create_session`/`append_message` (see domain/repository.py), which is @@ -169,6 +190,23 @@ async def append_message( assert snapshot is not None return snapshot + async def update_message_feedback( + self, session_id: str, message_id: str, feedback: str | None + ) -> ConversationMessage | None: + async with self._sessions() as session, session.begin(): + stmt = select(ConversationMessageRow).where( + ConversationMessageRow.session_id == session_id, + ConversationMessageRow.message_id == message_id, + ) + result = await session.exec(stmt) + row = result.first() + if row is None: + return None + row.feedback = feedback + await session.flush() + await self._rebuild_snapshot_in_session(session, session_id, snapshot_ttl_seconds=None) + return _message(row) + async def list_conversation_messages( self, session_id: str, limit: int | None = None ) -> list[ConversationMessage]: @@ -179,7 +217,14 @@ async def list_conversation_messages( async def list_messages(self, conversation_id: str, limit: int | None = None) -> list[Message]: rows = await self.list_conversation_messages(conversation_id, limit) return [ - Message(role=row.role, content=row.content, created_at=row.created_at) for row in rows + Message( + role=row.role, + content=row.content, + created_at=row.created_at, + message_id=row.message_id, + feedback=row.feedback, + ) + for row in rows ] async def get_snapshot(self, session_id: str) -> ConversationSnapshot | None: @@ -335,6 +380,7 @@ def _message_row(message: ConversationMessage) -> ConversationMessageRow: status=message.status, error_type=message.error_type, metadata_json=dict(message.metadata), + feedback=message.feedback, created_at=message.created_at, ) @@ -361,6 +407,7 @@ def _message(row: ConversationMessageRow) -> ConversationMessage: status=row.status, error_type=row.error_type, metadata=dict(row.metadata_json or {}), + feedback=row.feedback, created_at=_utc(row.created_at) or row.created_at, ) @@ -377,6 +424,7 @@ def _message_json(row: ConversationMessageRow) -> dict[str, Any]: "tool_name": row.tool_name, "provider": row.provider, "status": row.status, + "feedback": row.feedback, "created_at": (_utc(row.created_at) or row.created_at).isoformat(), "metadata": dict(row.metadata_json or {}), } diff --git a/src/agent_manager/infrastructure/persistence/tables.py b/src/agent_manager/infrastructure/persistence/tables.py index e7e85c20..794996ed 100644 --- a/src/agent_manager/infrastructure/persistence/tables.py +++ b/src/agent_manager/infrastructure/persistence/tables.py @@ -76,6 +76,7 @@ class ConversationMessageRow(SQLModel, table=True): status: str = Field(default="succeeded", max_length=64) error_type: str | None = Field(default=None, max_length=256) metadata_json: dict = Field(default_factory=dict, sa_column=Column(JSON, nullable=False)) + feedback: str | None = Field(default=None, max_length=32) created_at: datetime = Field(sa_column=Column(DateTime(timezone=True), nullable=False)) diff --git a/tests/agent_manager/test_api.py b/tests/agent_manager/test_api.py index 1e79b894..5cc02f27 100644 --- a/tests/agent_manager/test_api.py +++ b/tests/agent_manager/test_api.py @@ -14,6 +14,7 @@ from agent_engine.runtime.streaming import RunStreamEvent from agent_manager.api.routes import router from agent_manager.application import ConversationService +from agent_manager.domain import ContextUsage, thread_title from agent_manager.infrastructure.persistence.memory_repository import MemoryRepository from tests.agent_manager.conftest import RecordingEngine @@ -40,9 +41,84 @@ def test_create_send_history_round_trip(client: TestClient) -> None: ] +def test_list_conversations_returns_titled_threads_scoped_to_user(client: TestClient) -> None: + a = client.post("/conversations", json={"user_id": "u1"}).json()["conversation_id"] + client.post(f"/conversations/{a}/messages", json={"message": "first thread"}) + b = client.post("/conversations", json={"user_id": "u1"}).json()["conversation_id"] + client.post(f"/conversations/{b}/messages", json={"message": "second thread"}) + + threads = client.get("/conversations", params={"user_id": "u1"}).json() + assert {t["conversation_id"]: t["title"] for t in threads} == { + a: "first thread", + b: "second thread", + } + assert client.get("/conversations", params={"user_id": "u2"}).json() == [] + + +def test_thread_title_collapses_whitespace_and_truncates() -> None: + assert thread_title(" hi there ") == "hi there" + assert thread_title("") == "New chat" + truncated = thread_title("x" * 60) + assert len(truncated) == 48 and truncated.endswith("…") + + def test_unknown_conversation_returns_404(client: TestClient) -> None: assert client.get("/conversations/nope/messages").status_code == 404 assert client.post("/conversations/nope/messages", json={"message": "x"}).status_code == 404 + assert client.get("/conversations/nope/usage").status_code == 404 + + +def test_usage_reports_null_budget_when_unset(client: TestClient) -> None: + cid = client.post("/conversations").json()["conversation_id"] + assert client.get(f"/conversations/{cid}/usage").json() == { + "used_tokens": 0, + "max_tokens": None, + "percent": 0.0, + "severity": "normal", + } + + +def test_usage_reports_accumulated_tokens_and_severity_against_budget() -> None: + class TokenEngine(RecordingEngine): + async def run( + self, + message: str, + *, + history: Sequence[ChatMessage] = (), + context: RunContext | None = None, + ) -> RunResult: + return RunResult( + system_name="stub", + visited=["agent"], + answer="ok", + input_tokens=600, + output_tokens=100, + ) + + app = FastAPI() + app.state.service = ConversationService( + TokenEngine(), MemoryRepository(), max_tokens=1000 + ) + app.include_router(router) + client = TestClient(app) + + cid = client.post("/conversations").json()["conversation_id"] + client.post(f"/conversations/{cid}/messages", json={"message": "hi"}) + + body = client.get(f"/conversations/{cid}/usage").json() + assert body["used_tokens"] == 700 + assert body["max_tokens"] == 1000 + assert body["percent"] == pytest.approx(70.0) + assert body["severity"] == "warning" + + +def test_context_usage_severity_thresholds() -> None: + assert ContextUsage.from_totals(0, None).severity == "normal" + assert ContextUsage.from_totals(640, 1000).severity == "normal" + assert ContextUsage.from_totals(650, 1000).severity == "warning" + assert ContextUsage.from_totals(850, 1000).severity == "warning" + assert ContextUsage.from_totals(851, 1000).severity == "critical" + assert ContextUsage.from_totals(5000, 1000).percent == 100.0 class _SubAgentEngine(Engine): @@ -148,7 +224,9 @@ def test_stream_ignores_cleanup_error_after_final() -> None: text = "".join(response.iter_text()) assert response.status_code == 200 - assert 'event: final\ndata: {"type": "final", "content": "done", "route": ["agent"]}' in text + assert "event: final" in text + assert '"type": "final"' in text + assert '"content": "done"' in text assert "cleanup after final" not in text messages = client.get(f"/conversations/{cid}/messages").json() assert [(m["role"], m["content"]) for m in messages] == [ @@ -165,3 +243,24 @@ def test_create_accepts_stable_session_and_send_accepts_user(client: TestClient) sent = client.post("/conversations/sess-1/messages", json={"message": "hello", "user_id": "u1"}) assert sent.status_code == 200 + + +def test_feedback_endpoint_records_and_returns_feedback(client: TestClient) -> None: + cid = client.post("/conversations").json()["conversation_id"] + send_resp = client.post(f"/conversations/{cid}/messages", json={"message": "hello"}).json() + assert "message_id" in send_resp + msg_id = send_resp["message_id"] + assert msg_id is not None + + # Submit feedback + fb_resp = client.post( + f"/conversations/{cid}/messages/{msg_id}/feedback", + json={"feedback": "thumbs_up"}, + ) + assert fb_resp.status_code == 200 + assert fb_resp.json() == {"message_id": msg_id, "feedback": "thumbs_up"} + + # Retrieve messages and verify feedback is included + history = client.get(f"/conversations/{cid}/messages").json() + assistant_msg = next(m for m in history if m["message_id"] == msg_id) + assert assistant_msg["feedback"] == "thumbs_up" diff --git a/tests/agent_manager/test_repository_contract.py b/tests/agent_manager/test_repository_contract.py index 2fe3877a..16e0c764 100644 --- a/tests/agent_manager/test_repository_contract.py +++ b/tests/agent_manager/test_repository_contract.py @@ -190,6 +190,29 @@ async def test_different_run_ids_can_share_one_session(repo: Repository) -> None assert [m.run_id for m in messages] == ["run-1", "run-2"] +async def test_update_message_feedback(repo: Repository) -> None: + await repo.create_session("sess-1") + msg = _message("sess-1", Role.ASSISTANT, "answer") + await repo.append_message(msg) + + # Initially no feedback + msgs = await repo.list_messages("sess-1") + assert msgs[0].feedback is None + + # Record positive feedback + updated = await repo.update_message_feedback("sess-1", msg.message_id, "thumbs_up") + assert updated is not None + assert updated.feedback == "thumbs_up" + + msgs = await repo.list_messages("sess-1") + assert msgs[0].feedback == "thumbs_up" + + # Reset feedback to None + updated_reset = await repo.update_message_feedback("sess-1", msg.message_id, None) + assert updated_reset is not None + assert updated_reset.feedback is None + + async def test_sql_schema_has_cold_and_snapshot_rows() -> None: engine = create_db_engine("sqlite+aiosqlite:///:memory:") async with engine.begin() as conn: diff --git a/tests/agent_manager/test_service.py b/tests/agent_manager/test_service.py index fcb1a25c..5e3dbf6b 100644 --- a/tests/agent_manager/test_service.py +++ b/tests/agent_manager/test_service.py @@ -130,3 +130,21 @@ async def test_concurrent_sessions_do_not_leak_history() -> None: "second private context", "answer:second private context", ) + + +async def test_record_feedback_updates_message() -> None: + service, _ = _service() + cid = await service.create() + await service.send(cid, "hello") + + msgs = await service.history(cid) + assistant_msg = next(m for m in msgs if m.role == Role.ASSISTANT) + assert assistant_msg.message_id is not None + + updated = await service.record_feedback(cid, assistant_msg.message_id, "thumbs_down") + assert updated.feedback == "thumbs_down" + + history = await service.history(cid) + updated_in_history = next(m for m in history if m.message_id == assistant_msg.message_id) + assert updated_in_history.feedback == "thumbs_down" + diff --git a/tests/e2e/widget.spec.ts b/tests/e2e/widget.spec.ts index 4b3be11c..58d28ef1 100644 --- a/tests/e2e/widget.spec.ts +++ b/tests/e2e/widget.spec.ts @@ -2,9 +2,24 @@ import { expect, test, type Page, type Route } from "@playwright/test"; const history: Record> = {}; -async function mockConversationApi(page: Page, options: { failSend?: boolean } = {}) { +async function mockConversationApi( + page: Page, + options: { + failSend?: boolean; + threads?: Array<{ conversation_id: string; title: string | null; last_message_at: string | null }>; + } = {}, +) { const calls: string[] = []; + await page.route(/\/conversations\?/, async (route) => { + calls.push(`GET ${new URL(route.request().url()).pathname}`); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(options.threads ?? []), + }); + }); + await page.route("**/conversations", async (route) => { calls.push(`${route.request().method()} ${new URL(route.request().url()).pathname}`); await route.fulfill({ @@ -241,6 +256,17 @@ async function shadowClick(page: Page, selector: string, index = 0) { }, selector); } +async function shadowClickText(page: Page, selector: string, text: string, index = 0) { + const handle = await widget(page, index); + await handle.evaluate( + (element, { selector, text }) => { + const targets = Array.from(element.shadowRoot?.querySelectorAll(selector) ?? []); + targets.find((node) => node.textContent?.includes(text))?.click(); + }, + { selector, text }, + ); +} + async function shadowFocus(page: Page, selector: string, index = 0) { const handle = await widget(page, index); await handle.evaluate((element, selector) => { @@ -432,6 +458,134 @@ test("backend error renders a user-friendly message", async ({ page }) => { await expect.poll(() => shadowText(page, ".messages")).toContain("Something went wrong. Please try again."); }); +test("context meter shows token usage against the budget after a turn", async ({ page }) => { + await mockConversationApi(page); + await page.route("**/conversations/*/usage", async (route: Route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + used_tokens: 900, + max_tokens: 1000, + percent: 90, + severity: "critical", + }), + }); + }); + await page.goto("/widget-demo.html"); + await shadowClick(page, ".launcher"); + + await shadowFill(page, ".input", "hello"); + await page.keyboard.press("Enter"); + + await expect.poll(() => shadowText(page, ".messages")).toContain("Echo: hello"); + await expect.poll(() => shadowExists(page, ".context-meter")).toBe(true); + await expect.poll(() => shadowText(page, ".context-percent")).toBe("90%"); + await expect.poll(() => shadowClassContains(page, ".context-meter", "critical")).toBe(true); +}); + +test("context meter stays hidden when no budget is configured", async ({ page }) => { + await mockConversationApi(page); + await page.route("**/conversations/*/usage", async (route: Route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ used_tokens: 0, max_tokens: null, percent: 0, severity: "normal" }), + }); + }); + await page.goto("/widget-demo.html"); + await shadowClick(page, ".launcher"); + await shadowFill(page, ".input", "hello"); + await page.keyboard.press("Enter"); + + await expect.poll(() => shadowText(page, ".messages")).toContain("Echo: hello"); + await expect.poll(() => shadowExists(page, ".context-meter")).toBe(false); +}); + +test("thread drawer lists conversations, switches to one, and starts a new chat", async ({ page }) => { + await mockConversationApi(page, { + threads: [ + { conversation_id: "conv-old", title: "Older chat", last_message_at: "2026-06-01T00:00:00Z" }, + ], + }); + await page.route("**/conversations/conv-old/messages", async (route: Route) => { + if (route.request().method() !== "GET") return route.fallback(); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { role: "user", content: "old question", created_at: "2026-06-01T00:00:00Z" }, + { role: "assistant", content: "old answer", created_at: "2026-06-01T00:00:00Z" }, + ]), + }); + }); + + await page.goto("/widget-demo.html"); + await shadowClick(page, ".launcher"); + + await shadowClick(page, '.header-btn[aria-label="Conversations"]'); + await expect.poll(() => shadowClassContains(page, ".thread-drawer", "open")).toBe(true); + await expect.poll(() => shadowText(page, ".thread-item")).toContain("Older chat"); + + await shadowClick(page, ".thread-item"); + await expect.poll(() => shadowText(page, ".messages")).toContain("old answer"); + await expect.poll(() => shadowClassContains(page, ".thread-drawer", "open")).toBe(false); + + await shadowClick(page, '.header-btn[aria-label="New chat"]'); + await expect.poll(() => shadowText(page, ".messages")).toContain("How can I help you today?"); +}); + +test("thinking dots persist when switching away from an in-flight thread and back", async ({ + page, +}) => { + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + + await mockConversationApi(page, { + threads: [ + { conversation_id: "conv-other", title: "Other chat", last_message_at: "2026-06-01T00:00:00Z" }, + { conversation_id: "conv-smoke", title: "Current chat", last_message_at: "2026-06-28T00:00:00Z" }, + ], + }); + await page.route("**/conversations/conv-other/messages", async (route: Route) => { + if (route.request().method() !== "GET") return route.fallback(); + await route.fulfill({ status: 200, contentType: "application/json", body: "[]" }); + }); + await page.route("**/conversations/conv-smoke/messages/stream", async (route: Route) => { + await pending; + await route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: [ + `event: final\ndata: ${JSON.stringify({ type: "final", content: "done", route: [], used_tools: [] })}`, + "event: done\ndata: [DONE]", + "", + ].join("\n\n"), + }); + }); + + await page.goto("/widget-demo.html"); + await shadowClick(page, ".launcher"); + await shadowFill(page, ".input", "hello"); + await shadowClick(page, ".send"); + + await expect.poll(() => shadowExists(page, ".thinking")).toBe(true); + + await shadowClick(page, '.header-btn[aria-label="Conversations"]'); + await shadowClickText(page, ".thread-item", "Other chat"); + await expect.poll(() => shadowExists(page, ".thinking")).toBe(false); + + await shadowClick(page, '.header-btn[aria-label="Conversations"]'); + await shadowClickText(page, ".thread-item", "Current chat"); + await expect.poll(() => shadowExists(page, ".thinking")).toBe(true); + + release(); + await expect.poll(() => shadowText(page, ".messages")).toContain("done"); + await expect.poll(() => shadowExists(page, ".thinking")).toBe(false); +}); + test("stale stored conversation is replaced before sending to the agent", async ({ page }) => { const calls = await mockConversationApiWithStaleConversation(page); await page.goto("/widget-demo.html");