From 2093eda5a00e6f81bdb65cde3759fa8d1233501d Mon Sep 17 00:00:00 2001 From: Amit Avital Date: Sun, 26 Jul 2026 18:19:04 +0300 Subject: [PATCH 1/6] feat(widget): context-usage meter backed by the agent manager Surface per-conversation token usage against the configured context_max_tokens budget so users see how full the context is before they hit the 429 wall. Backend (source of truth): - ContextUsage domain value object with a ContextSeverity StrEnum and a from_totals factory that owns the warning/critical thresholds - GET /conversations/{id}/usage returns used_tokens, max_tokens, percent and severity; ConversationService.usage sums stored token counts - schema reuses the domain ContextSeverity enum (no duplicated literals) Widget (stateless renderer): - AgentChatClient.getUsage + useConversation.loadUsage fetch usage on open and after each turn; no client-side token math or thresholds - ContextMeter ring (severity-coloured, hover popover) shown only when a budget is set; hidden and non-breaking against backends without /usage Also: neutral demo copy and a documented CONTEXT_MAX_TOKENS in the starter example. Tests: usage endpoint, severity thresholds, and two widget e2e cases. Co-Authored-By: Claude Opus 4.8 --- examples/starter/.env.example | 2 + src/agent_manager/api/routes.py | 15 ++ src/agent_manager/api/schemas.py | 9 +- src/agent_manager/api/static/demo.html | 11 +- src/agent_manager/api/static/widget.js | 136 +++++++++++++++++- .../api/static/widget/api/AgentChatClient.ts | 16 ++- .../api/static/widget/react/AgentChatApp.tsx | 75 +++++++++- .../static/widget/react/useConversation.ts | 18 ++- .../api/static/widget/styles/styles.ts | 29 ++++ src/agent_manager/api/static/widget/types.ts | 9 ++ src/agent_manager/application/service.py | 13 +- src/agent_manager/domain/__init__.py | 4 + src/agent_manager/domain/models.py | 30 ++++ tests/agent_manager/test_api.py | 55 +++++++ tests/e2e/widget.spec.ts | 44 ++++++ 15 files changed, 448 insertions(+), 18 deletions(-) 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/src/agent_manager/api/routes.py b/src/agent_manager/api/routes.py index 445b2f9b..0e7fc015 100644 --- a/src/agent_manager/api/routes.py +++ b/src/agent_manager/api/routes.py @@ -13,6 +13,7 @@ from agent_engine.runtime.streaming import RunStreamEvent from agent_manager.api.deps import get_service from agent_manager.api.schemas import ( + ContextUsageResponse, CreateConversationRequest, CreateConversationResponse, MessageOut, @@ -50,6 +51,20 @@ async def list_messages(conversation_id: str, service: Service) -> list[MessageO return [MessageOut(role=m.role, content=m.content, created_at=m.created_at) 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) async def send_message( conversation_id: str, body: SendMessageRequest, service: Service diff --git a/src/agent_manager/api/schemas.py b/src/agent_manager/api/schemas.py index 3777ee5c..69f7ec3b 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): @@ -49,6 +49,13 @@ class SendMessageResponse(BaseModel): used_tools: list[ToolRecord] +class ContextUsageResponse(BaseModel): + used_tokens: int + max_tokens: int | None = None + percent: float = 0.0 + severity: ContextSeverity = ContextSeverity.NORMAL + + class StreamEventOut(BaseModel): type: str content: 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..f357e6fd 100644 --- a/src/agent_manager/api/static/widget.js +++ b/src/agent_manager/api/static/widget.js @@ -52141,6 +52141,19 @@ var AgentChatClient = class { used_tools: Array.isArray(data.used_tools) ? data.used_tools : 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 *streamMessage(conversationId, message) { const response = await fetch(`${this.endpoint}/conversations/${conversationId}/messages/stream`, { method: "POST", @@ -53051,7 +53064,19 @@ 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]); + return (0, import_react9.useMemo)( + () => ({ send, stream, loadHistory, loadUsage }), + [send, stream, loadHistory, loadUsage] + ); } // src/agent_manager/api/static/widget/react/AgentChatApp.tsx @@ -53072,14 +53097,19 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { const [loaded, setLoaded] = (0, import_react10.useState)(false); const [sending, setSending] = (0, import_react10.useState)(false); const [entries, setEntries] = (0, import_react10.useState)([]); + const [usage, setUsage] = (0, import_react10.useState)(null); 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 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]); + await refreshUsage(); + }, [conversation, loaded, refreshUsage]); (0, import_react10.useEffect)(() => { if (inline) void loadHistory(); }, [inline, loadHistory]); @@ -53134,9 +53164,10 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { await sendWithoutStreaming(text10, pending.id); } finally { setSending(false); + void refreshUsage(); } }, - [conversation, onAnswer, replaceEntry, sendWithoutStreaming] + [conversation, onAnswer, refreshUsage, replaceEntry, sendWithoutStreaming] ); const toggle = () => void (open ? closeChat() : openChat()); return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)( @@ -53184,7 +53215,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 }) ] }) ] }), @@ -53280,6 +53314,71 @@ function Welcome({ title }) { /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: "welcome-title", children: title }) ] }); } +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")}")` }; @@ -53428,6 +53527,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 +53569,7 @@ 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; } } @media (max-width: 480px) { .panel:not(.inline) { width: 100vw; height: 100dvh; max-height: 100dvh; diff --git a/src/agent_manager/api/static/widget/api/AgentChatClient.ts b/src/agent_manager/api/static/widget/api/AgentChatClient.ts index 2566307b..05d52453 100644 --- a/src/agent_manager/api/static/widget/api/AgentChatClient.ts +++ b/src/agent_manager/api/static/widget/api/AgentChatClient.ts @@ -1,4 +1,4 @@ -import type { ChatMessage, SendMessageResponse, StreamEvent } from "../types"; +import type { ChatMessage, ContextUsage, SendMessageResponse, StreamEvent } from "../types"; export class AgentChatHttpError extends Error { constructor(readonly status: number) { @@ -44,6 +44,20 @@ export class AgentChatClient { }; } + 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 *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/react/AgentChatApp.tsx b/src/agent_manager/api/static/widget/react/AgentChatApp.tsx index 0428b3eb..0d4d4560 100644 --- a/src/agent_manager/api/static/widget/react/AgentChatApp.tsx +++ b/src/agent_manager/api/static/widget/react/AgentChatApp.tsx @@ -6,6 +6,7 @@ import type { AgentChatAnswerDetail, AgentChatConfig, ChatMessage, + ContextUsage, MessageEntry, ToolRecord, } from "../types"; @@ -55,15 +56,21 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age const [loaded, setLoaded] = useState(false); const [sending, setSending] = useState(false); const [entries, setEntries] = useState([]); + const [usage, setUsage] = useState(null); const launcherRef = useRef(null); const inputRef = useRef(null); + const refreshUsage = useCallback(async () => { + setUsage(await conversation.loadUsage()); + }, [conversation]); + const loadHistory = useCallback(async () => { if (loaded) return; setLoaded(true); const history = await conversation.loadHistory(); if (history.length) setEntries(history.map(toEntry)); - }, [conversation, loaded]); + await refreshUsage(); + }, [conversation, loaded, refreshUsage]); useEffect(() => { if (inline) void loadHistory(); @@ -125,9 +132,10 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age await sendWithoutStreaming(text, pending.id); } finally { setSending(false); + void refreshUsage(); } }, - [conversation, onAnswer, replaceEntry, sendWithoutStreaming], + [conversation, onAnswer, refreshUsage, replaceEntry, sendWithoutStreaming], ); const toggle = () => void (open ? closeChat() : openChat()); @@ -187,7 +195,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 +
@@ -337,6 +348,64 @@ function Welcome({ title }: { title: string }) { ); } +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/useConversation.ts b/src/agent_manager/api/static/widget/react/useConversation.ts index 9d4c0338..73559c8a 100644 --- a/src/agent_manager/api/static/widget/react/useConversation.ts +++ b/src/agent_manager/api/static/widget/react/useConversation.ts @@ -6,12 +6,13 @@ import { removeStoredConversationId, setStoredConversationId, } from "../storage/conversationStorage"; -import type { ChatMessage, SendMessageResponse, StreamEvent } from "../types"; +import type { ChatMessage, ContextUsage, SendMessageResponse, StreamEvent } from "../types"; export interface Conversation { send(text: string): Promise; stream(text: string): AsyncGenerator; loadHistory(): Promise; + loadUsage(): Promise; } const isMissingConversation = (error: unknown): boolean => @@ -69,5 +70,18 @@ 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]); + + return useMemo( + () => ({ send, stream, loadHistory, loadUsage }), + [send, stream, loadHistory, loadUsage], + ); } diff --git a/src/agent_manager/api/static/widget/styles/styles.ts b/src/agent_manager/api/static/widget/styles/styles.ts index e461fd16..c5e68b1e 100644 --- a/src/agent_manager/api/static/widget/styles/styles.ts +++ b/src/agent_manager/api/static/widget/styles/styles.ts @@ -137,6 +137,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 +179,7 @@ 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; } } @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..7aa613f4 100644 --- a/src/agent_manager/api/static/widget/types.ts +++ b/src/agent_manager/api/static/widget/types.ts @@ -28,6 +28,15 @@ export interface ChatMessage { created_at?: string; } +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; role: "user" | "ai"; diff --git a/src/agent_manager/application/service.py b/src/agent_manager/application/service.py index e044c2c4..289d1791 100644 --- a/src/agent_manager/application/service.py +++ b/src/agent_manager/application/service.py @@ -17,7 +17,13 @@ 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, + Message, + Repository, + Role, +) class ConversationNotFound(Exception): @@ -76,6 +82,11 @@ 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 send( self, conversation_id: str, text: str, *, user_id: str | None = None ) -> RunResult: diff --git a/src/agent_manager/domain/__init__.py b/src/agent_manager/domain/__init__.py index e8857caa..e1d2f930 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, @@ -12,6 +14,8 @@ from agent_manager.domain.repository import Repository __all__ = [ + "ContextSeverity", + "ContextUsage", "ConversationContext", "ConversationMessage", "ConversationSession", diff --git a/src/agent_manager/domain/models.py b/src/agent_manager/domain/models.py index 361de7d9..8c690b95 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" @@ -94,3 +97,30 @@ class ConversationContext: message_count: int source: str snapshot: ConversationSnapshot | None = None + + +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/tests/agent_manager/test_api.py b/tests/agent_manager/test_api.py index 1e79b894..07d341a9 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 from agent_manager.infrastructure.persistence.memory_repository import MemoryRepository from tests.agent_manager.conftest import RecordingEngine @@ -43,6 +44,60 @@ def test_create_send_history_round_trip(client: TestClient) -> None: 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): diff --git a/tests/e2e/widget.spec.ts b/tests/e2e/widget.spec.ts index 4b3be11c..473f0335 100644 --- a/tests/e2e/widget.spec.ts +++ b/tests/e2e/widget.spec.ts @@ -432,6 +432,50 @@ 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("stale stored conversation is replaced before sending to the agent", async ({ page }) => { const calls = await mockConversationApiWithStaleConversation(page); await page.goto("/widget-demo.html"); From eaaea0341ba3d6eb2bc7355cdd8ac4864b3ebae4 Mon Sep 17 00:00:00 2001 From: Amit Avital Date: Mon, 27 Jul 2026 18:50:49 +0300 Subject: [PATCH 2/6] =?UTF-8?q?feat(widget):=20conversation=20thread=20lis?= =?UTF-8?q?t=20=E2=80=94=20list,=20new,=20switch,=20auto-title?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let a user see their past conversations, start a new one, and switch between them, backed by the agent manager as the source of truth. Backend: - Repository.list_sessions(user_id) + rename_session (port, memory, sql) - GET /conversations?user_id=... returns id, title, last_message_at (most-recently-active first); ConversationService.list_conversations - thread_title() derives a title from the first user message; the service sets it on the first turn of a conversation Widget (stateless; BE owns the list and titles): - anonymous per-browser user id in localStorage, sent on create/list; an optional attribute overrides it, leaving the seam for real host identity later - AgentChatClient.listConversations + useConversation listThreads/ switchTo/startNew - header history + new-chat buttons and a slide-over thread drawer; switching loads that thread's messages and usage; drawer is inert when closed. Degrades to empty list against a backend without the endpoint Tests: listing scoped by user, auto-title, and a widget e2e for the drawer (list, switch, new chat). Co-Authored-By: Claude Opus 4.8 --- src/agent_manager/api/routes.py | 14 + src/agent_manager/api/schemas.py | 6 + src/agent_manager/api/static/widget.js | 268 +++++++++++++++--- src/agent_manager/api/static/widget.test.mjs | 1 + .../api/static/widget/api/AgentChatClient.ts | 31 +- .../api/static/widget/config/parseConfig.ts | 2 + .../widget/element/AgentChatElement.tsx | 2 +- .../api/static/widget/react/AgentChatApp.tsx | 116 +++++++- .../static/widget/react/useConversation.ts | 37 ++- .../widget/storage/conversationStorage.ts | 10 + .../api/static/widget/styles/styles.ts | 37 ++- src/agent_manager/api/static/widget/types.ts | 8 + src/agent_manager/application/service.py | 9 + src/agent_manager/domain/__init__.py | 2 + src/agent_manager/domain/models.py | 7 + src/agent_manager/domain/repository.py | 9 + .../persistence/memory_repository.py | 15 + .../persistence/sql_repository.py | 21 ++ tests/agent_manager/test_api.py | 23 +- tests/e2e/widget.spec.ts | 50 +++- 20 files changed, 614 insertions(+), 54 deletions(-) diff --git a/src/agent_manager/api/routes.py b/src/agent_manager/api/routes.py index 0e7fc015..2bf0296a 100644 --- a/src/agent_manager/api/routes.py +++ b/src/agent_manager/api/routes.py @@ -14,6 +14,7 @@ from agent_manager.api.deps import get_service from agent_manager.api.schemas import ( ContextUsageResponse, + ConversationSummary, CreateConversationRequest, CreateConversationResponse, MessageOut, @@ -42,6 +43,19 @@ 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: diff --git a/src/agent_manager/api/schemas.py b/src/agent_manager/api/schemas.py index 69f7ec3b..6c6e69f1 100644 --- a/src/agent_manager/api/schemas.py +++ b/src/agent_manager/api/schemas.py @@ -23,6 +23,12 @@ 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): role: Role content: str diff --git a/src/agent_manager/api/static/widget.js b/src/agent_manager/api/static/widget.js index f357e6fd..3317e83b 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) { @@ -52346,8 +52366,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", { @@ -52357,10 +52385,23 @@ 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/wrench.mjs -var __iconNode9 = [ +var __iconNode11 = [ [ "path", { @@ -52369,18 +52410,41 @@ var __iconNode9 = [ } ] ]; -var Wrench = createLucideIcon("wrench", __iconNode9); +var Wrench = createLucideIcon("wrench", __iconNode11); // node_modules/lucide-react/dist/esm/icons/x.mjs -var __iconNode10 = [ +var __iconNode12 = [ ["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", __iconNode12); // 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); @@ -53001,29 +53065,13 @@ 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 ensureId = (0, import_react9.useCallback)( async () => getStoredConversationId(endpoint) ?? startConversation(), [endpoint, startConversation] @@ -53073,9 +53121,18 @@ function useConversation(client, endpoint) { 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)( - () => ({ send, stream, loadHistory, loadUsage }), - [send, stream, loadHistory, loadUsage] + () => ({ send, stream, loadHistory, loadUsage, listThreads, switchTo, startNew }), + [send, stream, loadHistory, loadUsage, listThreads, switchTo, startNew] ); } @@ -53092,12 +53149,18 @@ var toEntry = (message) => ({ }); 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 [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 () => { @@ -53126,6 +53189,28 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { setOpen(false); launcherRef.current?.focus({ preventScroll: true }); }, [inline]); + 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); + const history = await conversation.loadHistory(); + setEntries(history.map(toEntry)); + await refreshUsage(); + inputRef.current?.focus({ preventScroll: true }); + }, + [conversation, refreshUsage] + ); + const startNewThread = (0, import_react10.useCallback)(() => { + conversation.startNew(); + setThreadsOpen(false); + setEntries([]); + setUsage(null); + inputRef.current?.focus({ preventScroll: true }); + }, [conversation]); const replaceEntry = (0, import_react10.useCallback)((id, entry) => { setEntries((prev) => prev.map((current) => current.id === id ? entry : current)); }, []); @@ -53194,11 +53279,42 @@ 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)) @@ -53314,6 +53430,39 @@ 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; @@ -53431,18 +53580,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; } @@ -53570,6 +53749,7 @@ function styles(config) { .welcome { animation: none; } .msg-action svg { animation: none; } .context-ring-value, .context-bar-fill, .context-popover { transition: none; } + .thread-drawer { transition: none; } } @media (max-width: 480px) { .panel:not(.inline) { width: 100vw; height: 100dvh; max-height: 100dvh; @@ -53592,7 +53772,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; @@ -53913,6 +54093,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 @@ -53921,6 +54109,14 @@ 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/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..d5fd1039 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: "", }); } diff --git a/src/agent_manager/api/static/widget/api/AgentChatClient.ts b/src/agent_manager/api/static/widget/api/AgentChatClient.ts index 05d52453..da33fbfb 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, ContextUsage, 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) { 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 0d4d4560..5b71a4cf 100644 --- a/src/agent_manager/api/static/widget/react/AgentChatApp.tsx +++ b/src/agent_manager/api/static/widget/react/AgentChatApp.tsx @@ -1,13 +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 { @@ -51,12 +61,18 @@ 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 [usage, setUsage] = useState(null); + const [threads, setThreads] = useState([]); + const [threadsOpen, setThreadsOpen] = useState(false); const launcherRef = useRef(null); const inputRef = useRef(null); @@ -92,6 +108,31 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age launcherRef.current?.focus({ preventScroll: true }); }, [inline]); + const openThreads = useCallback(async () => { + setThreads(await conversation.listThreads()); + setThreadsOpen(true); + }, [conversation]); + + const openThread = useCallback( + async (conversationId: string) => { + conversation.switchTo(conversationId); + setThreadsOpen(false); + const history = await conversation.loadHistory(); + setEntries(history.map(toEntry)); + await refreshUsage(); + inputRef.current?.focus({ preventScroll: true }); + }, + [conversation, refreshUsage], + ); + + const startNewThread = useCallback(() => { + conversation.startNew(); + setThreadsOpen(false); + setEntries([]); + setUsage(null); + inputRef.current?.focus({ preventScroll: true }); + }, [conversation]); + const replaceEntry = useCallback((id: string, entry: MessageEntry) => { setEntries((prev) => prev.map((current) => (current.id === id ? entry : current))); }, []); @@ -164,10 +205,26 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age role={inline ? "region" : "dialog"} >
+ {config.title} + {!inline ? (
+ setThreadsOpen(false)} + /> {entries.length === 0 ? ( @@ -348,6 +413,51 @@ 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; diff --git a/src/agent_manager/api/static/widget/react/useConversation.ts b/src/agent_manager/api/static/widget/react/useConversation.ts index 73559c8a..a5a5f566 100644 --- a/src/agent_manager/api/static/widget/react/useConversation.ts +++ b/src/agent_manager/api/static/widget/react/useConversation.ts @@ -6,24 +6,37 @@ import { removeStoredConversationId, setStoredConversationId, } from "../storage/conversationStorage"; -import type { ChatMessage, ContextUsage, SendMessageResponse, StreamEvent } from "../types"; +import type { + ChatMessage, + ContextUsage, + SendMessageResponse, + StreamEvent, + ThreadSummary, +} from "../types"; export interface Conversation { 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 ensureId = useCallback( async () => getStoredConversationId(endpoint) ?? startConversation(), @@ -80,8 +93,20 @@ export function useConversation(client: AgentChatClient, endpoint: string): Conv } }, [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( - () => ({ send, stream, loadHistory, loadUsage }), - [send, stream, loadHistory, loadUsage], + () => ({ send, stream, loadHistory, loadUsage, listThreads, switchTo, startNew }), + [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 c5e68b1e..70843c7f 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; } @@ -180,6 +210,7 @@ export function styles(config: AgentChatConfig): string { .welcome { animation: none; } .msg-action svg { animation: none; } .context-ring-value, .context-bar-fill, .context-popover { transition: none; } + .thread-drawer { transition: 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 7aa613f4..d3989fbf 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,6 +21,13 @@ 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 { diff --git a/src/agent_manager/application/service.py b/src/agent_manager/application/service.py index 289d1791..12735207 100644 --- a/src/agent_manager/application/service.py +++ b/src/agent_manager/application/service.py @@ -20,9 +20,11 @@ from agent_manager.domain import ( ContextUsage, ConversationMessage, + ConversationSession, Message, Repository, Role, + thread_title, ) @@ -87,6 +89,11 @@ async def usage(self, conversation_id: str) -> ContextUsage: 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: @@ -127,6 +134,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( diff --git a/src/agent_manager/domain/__init__.py b/src/agent_manager/domain/__init__.py index e1d2f930..1849203b 100644 --- a/src/agent_manager/domain/__init__.py +++ b/src/agent_manager/domain/__init__.py @@ -10,6 +10,7 @@ Message, Role, User, + thread_title, ) from agent_manager.domain.repository import Repository @@ -24,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 8c690b95..59bfa993 100644 --- a/src/agent_manager/domain/models.py +++ b/src/agent_manager/domain/models.py @@ -99,6 +99,13 @@ class ConversationContext: 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" diff --git a/src/agent_manager/domain/repository.py b/src/agent_manager/domain/repository.py index 108042ef..6d4131a8 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, diff --git a/src/agent_manager/infrastructure/persistence/memory_repository.py b/src/agent_manager/infrastructure/persistence/memory_repository.py index 1e13c0ff..de221fda 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 diff --git a/src/agent_manager/infrastructure/persistence/sql_repository.py b/src/agent_manager/infrastructure/persistence/sql_repository.py index 7d746d93..8fc93fdb 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 diff --git a/tests/agent_manager/test_api.py b/tests/agent_manager/test_api.py index 07d341a9..67a48603 100644 --- a/tests/agent_manager/test_api.py +++ b/tests/agent_manager/test_api.py @@ -14,7 +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 +from agent_manager.domain import ContextUsage, thread_title from agent_manager.infrastructure.persistence.memory_repository import MemoryRepository from tests.agent_manager.conftest import RecordingEngine @@ -41,6 +41,27 @@ 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 diff --git a/tests/e2e/widget.spec.ts b/tests/e2e/widget.spec.ts index 473f0335..85b2ef25 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({ @@ -476,6 +491,39 @@ test("context meter stays hidden when no budget is configured", async ({ page }) 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("stale stored conversation is replaced before sending to the agent", async ({ page }) => { const calls = await mockConversationApiWithStaleConversation(page); await page.goto("/widget-demo.html"); From e3688f3c724f2f6668a5a41793ff924fd4a58a93 Mon Sep 17 00:00:00 2001 From: Amit Avital Date: Mon, 27 Jul 2026 20:06:01 +0300 Subject: [PATCH 3/6] feat(widget): bouncing-dots loading that survives thread switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show a bouncing-dots indicator while the assistant has no answer text yet, and keep it visible when the user leaves a streaming thread and returns. Messages now live in a per-conversation map keyed by the id captured at submit time, so switching threads only swaps the view — the in-flight stream keeps writing to its own bucket. `typing` means "stream in flight" (set at submit, cleared at completion); reduceStreamEvent is reduced to content accumulation only, fixing dots that could otherwise linger on an empty final answer. Co-Authored-By: Claude Opus 4.8 --- src/agent_manager/api/static/widget.js | 109 +++++++++++++----- .../api/static/widget/react/AgentChatApp.tsx | 97 +++++++++++----- .../api/static/widget/react/streamReducer.ts | 7 +- .../static/widget/react/useConversation.ts | 18 ++- .../api/static/widget/styles/styles.ts | 10 ++ tests/e2e/widget.spec.ts | 62 ++++++++++ 6 files changed, 233 insertions(+), 70 deletions(-) diff --git a/src/agent_manager/api/static/widget.js b/src/agent_manager/api/static/widget.js index 3317e83b..09650506 100644 --- a/src/agent_manager/api/static/widget.js +++ b/src/agent_manager/api/static/widget.js @@ -53027,20 +53027,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"); @@ -53072,6 +53071,7 @@ function useConversation(client, endpoint, userId) { setStoredConversationId(endpoint, created); return created; }, [client, endpoint, userId]); + const peekId = (0, import_react9.useCallback)(() => getStoredConversationId(endpoint), [endpoint]); const ensureId = (0, import_react9.useCallback)( async () => getStoredConversationId(endpoint) ?? startConversation(), [endpoint, startConversation] @@ -53131,8 +53131,18 @@ function useConversation(client, endpoint, userId) { ); const startNew = (0, import_react9.useCallback)(() => removeStoredConversationId(endpoint), [endpoint]); return (0, import_react9.useMemo)( - () => ({ send, stream, loadHistory, loadUsage, listThreads, switchTo, startNew }), - [send, stream, loadHistory, loadUsage, listThreads, switchTo, startNew] + () => ({ + peekId, + ensureId, + send, + stream, + loadHistory, + loadUsage, + listThreads, + switchTo, + startNew + }), + [peekId, ensureId, send, stream, loadHistory, loadUsage, listThreads, switchTo, startNew] ); } @@ -53157,7 +53167,9 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { 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); @@ -53166,13 +53178,26 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { 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)); + const cid = conversation.peekId(); + if (!cid) return; + setActiveId(cid); + await loadThread(cid); await refreshUsage(); - }, [conversation, loaded, refreshUsage]); + }, [conversation, loaded, loadThread, refreshUsage]); (0, import_react10.useEffect)(() => { if (inline) void loadHistory(); }, [inline, loadHistory]); @@ -53197,28 +53222,29 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { async (conversationId) => { conversation.switchTo(conversationId); setThreadsOpen(false); - const history = await conversation.loadHistory(); - setEntries(history.map(toEntry)); + setActiveId(conversationId); + if (!(conversationId in entriesById)) await loadThread(conversationId); await refreshUsage(); inputRef.current?.focus({ preventScroll: true }); }, - [conversation, refreshUsage] + [conversation, entriesById, loadThread, refreshUsage] ); const startNewThread = (0, import_react10.useCallback)(() => { conversation.startNew(); setThreadsOpen(false); - setEntries([]); + setActiveId(""); setUsage(null); inputRef.current?.focus({ preventScroll: true }); }, [conversation]); - const replaceEntry = (0, import_react10.useCallback)((id, entry) => { - setEntries((prev) => prev.map((current) => current.id === id ? entry : current)); - }, []); + const replaceEntry = (0, import_react10.useCallback)( + (cid, id, entry) => putEntries(cid, (prev) => prev.map((current) => current.id === id ? entry : current)), + [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, role: "ai", text: answer.answer, @@ -53227,32 +53253,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, refreshUsage, replaceEntry, sendWithoutStreaming] + [conversation, onAnswer, putEntries, refreshUsage, replaceEntry, sendWithoutStreaming] ); const toggle = () => void (open ? closeChat() : openChat()); return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)( @@ -53378,19 +53406,26 @@ function Launcher({ } function ChatMessage({ entry }) { 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 }) : 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 }) { @@ -53681,6 +53716,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; @@ -53750,6 +53794,7 @@ function styles(config) { .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/react/AgentChatApp.tsx b/src/agent_manager/api/static/widget/react/AgentChatApp.tsx index 5b71a4cf..20043b95 100644 --- a/src/agent_manager/api/static/widget/react/AgentChatApp.tsx +++ b/src/agent_manager/api/static/widget/react/AgentChatApp.tsx @@ -69,7 +69,9 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age 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); @@ -80,13 +82,29 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age 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)); + const cid = conversation.peekId(); + if (!cid) return; + setActiveId(cid); + await loadThread(cid); await refreshUsage(); - }, [conversation, loaded, refreshUsage]); + }, [conversation, loaded, loadThread, refreshUsage]); useEffect(() => { if (inline) void loadHistory(); @@ -117,31 +135,34 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age async (conversationId: string) => { conversation.switchTo(conversationId); setThreadsOpen(false); - const history = await conversation.loadHistory(); - setEntries(history.map(toEntry)); + 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, refreshUsage], + [conversation, entriesById, loadThread, refreshUsage], ); const startNewThread = useCallback(() => { conversation.startNew(); setThreadsOpen(false); - setEntries([]); + setActiveId(""); setUsage(null); inputRef.current?.focus({ preventScroll: true }); }, [conversation]); - const replaceEntry = useCallback((id: string, entry: MessageEntry) => { - setEntries((prev) => prev.map((current) => (current.id === id ? entry : current))); - }, []); + const replaceEntry = useCallback( + (cid: string, id: string, entry: MessageEntry) => + putEntries(cid, (prev) => prev.map((current) => (current.id === id ? entry : current))), + [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, role: "ai", text: answer.answer, @@ -150,7 +171,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], @@ -158,25 +179,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, refreshUsage, replaceEntry, sendWithoutStreaming], + [conversation, onAnswer, putEntries, refreshUsage, replaceEntry, sendWithoutStreaming], ); const toggle = () => void (open ? closeChat() : openChat()); @@ -310,14 +333,6 @@ function Launcher({ function ChatMessage({ entry }: { entry: MessageEntry }) { const from = entry.role === "user" ? "user" : "assistant"; - if (entry.typing) { - return ( - - ... - - ); - } - if (entry.error) { return ( @@ -336,17 +351,35 @@ 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 ThinkingDots() { + return ( + + + + + + ); +} + function MessageActions({ text }: { text: string }) { return (
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 a5a5f566..d4bec6e5 100644 --- a/src/agent_manager/api/static/widget/react/useConversation.ts +++ b/src/agent_manager/api/static/widget/react/useConversation.ts @@ -15,6 +15,8 @@ import type { } from "../types"; export interface Conversation { + peekId(): string | null; + ensureId(): Promise; send(text: string): Promise; stream(text: string): AsyncGenerator; loadHistory(): Promise; @@ -38,6 +40,8 @@ export function useConversation( return created; }, [client, endpoint, userId]); + const peekId = useCallback(() => getStoredConversationId(endpoint), [endpoint]); + const ensureId = useCallback( async () => getStoredConversationId(endpoint) ?? startConversation(), [endpoint, startConversation], @@ -106,7 +110,17 @@ export function useConversation( const startNew = useCallback(() => removeStoredConversationId(endpoint), [endpoint]); return useMemo( - () => ({ send, stream, loadHistory, loadUsage, listThreads, switchTo, startNew }), - [send, stream, loadHistory, loadUsage, listThreads, switchTo, startNew], + () => ({ + 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/styles/styles.ts b/src/agent_manager/api/static/widget/styles/styles.ts index 70843c7f..86389d1c 100644 --- a/src/agent_manager/api/static/widget/styles/styles.ts +++ b/src/agent_manager/api/static/widget/styles/styles.ts @@ -142,6 +142,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; @@ -211,6 +220,7 @@ export function styles(config: AgentChatConfig): string { .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/tests/e2e/widget.spec.ts b/tests/e2e/widget.spec.ts index 85b2ef25..58d28ef1 100644 --- a/tests/e2e/widget.spec.ts +++ b/tests/e2e/widget.spec.ts @@ -256,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) => { @@ -524,6 +535,57 @@ test("thread drawer lists conversations, switches to one, and starts a 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"); From 646519b51baf8f5eb92be9f4219b5922a29ae764 Mon Sep 17 00:00:00 2001 From: Sameer Ahamad S Date: Mon, 27 Jul 2026 17:01:25 +0530 Subject: [PATCH 4/6] feat: user feedback capture (widget buttons, persistence, API) (#43) --- package-lock.json | 5 - src/agent_engine/runtime/streaming.py | 1 + src/agent_manager/api/routes.py | 38 ++++- src/agent_manager/api/schemas.py | 13 ++ src/agent_manager/api/static/widget.js | 152 ++++++++++++++++-- src/agent_manager/api/static/widget.test.mjs | 12 ++ .../api/static/widget/api/AgentChatClient.ts | 19 +++ .../api/static/widget/react/AgentChatApp.tsx | 52 +++++- .../static/widget/react/shadcnAiElements.tsx | 41 ++++- .../api/static/widget/styles/styles.ts | 9 +- src/agent_manager/api/static/widget/types.ts | 6 + src/agent_manager/application/__init__.py | 2 + src/agent_manager/application/service.py | 82 ++++++---- src/agent_manager/domain/models.py | 3 + src/agent_manager/domain/repository.py | 6 + .../persistence/memory_repository.py | 49 +++++- .../versions/0004_add_message_feedback.py | 26 +++ .../persistence/sql_repository.py | 29 +++- .../infrastructure/persistence/tables.py | 1 + tests/agent_manager/test_api.py | 21 +++ .../agent_manager/test_repository_contract.py | 23 +++ tests/agent_manager/test_service.py | 18 +++ 22 files changed, 549 insertions(+), 59 deletions(-) create mode 100644 src/agent_manager/infrastructure/persistence/migrations/versions/0004_add_message_feedback.py 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/runtime/streaming.py b/src/agent_engine/runtime/streaming.py index c61434d8..254adbcd 100644 --- a/src/agent_engine/runtime/streaming.py +++ b/src/agent_engine/runtime/streaming.py @@ -59,3 +59,4 @@ class RunStreamEvent: approval_id: str | None = None agent_id: str | None = None description: str | None = None + message_id: str | None = None diff --git a/src/agent_manager/api/routes.py b/src/agent_manager/api/routes.py index 2bf0296a..e255e9bd 100644 --- a/src/agent_manager/api/routes.py +++ b/src/agent_manager/api/routes.py @@ -17,6 +17,8 @@ ConversationSummary, CreateConversationRequest, CreateConversationResponse, + FeedbackRequest, + FeedbackResponse, MessageOut, SendMessageRequest, SendMessageResponse, @@ -27,7 +29,9 @@ ConversationNotFound, ConversationService, ConversationTokenBudgetExceeded, + MessageNotFound, ) +from agent_manager.domain import Role router = APIRouter() @@ -62,7 +66,16 @@ async def list_messages(conversation_id: str, service: Service) -> list[MessageO 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) @@ -85,6 +98,8 @@ async def send_message( ) -> SendMessageResponse: try: result = await service.send(conversation_id, body.message, user_id=body.user_id) + msgs = await service.history(conversation_id) + last_msg = msgs[-1] if msgs and msgs[-1].role == Role.ASSISTANT else None except ConversationNotFound as exc: raise HTTPException(status_code=404, detail="conversation not found") from exc except ConversationTokenBudgetExceeded: @@ -95,9 +110,29 @@ 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=last_msg.message_id if last_msg else None, ) +@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) -> StreamEventOut: return StreamEventOut( type=event.type, @@ -114,6 +149,7 @@ def _to_stream_event(event: RunStreamEvent) -> StreamEventOut: if event.used_tools else None ), + message_id=event.message_id, ) diff --git a/src/agent_manager/api/schemas.py b/src/agent_manager/api/schemas.py index 6c6e69f1..e454b342 100644 --- a/src/agent_manager/api/schemas.py +++ b/src/agent_manager/api/schemas.py @@ -30,9 +30,20 @@ class ConversationSummary(BaseModel): 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): @@ -53,6 +64,7 @@ class SendMessageResponse(BaseModel): answer: str visited: list[str] used_tools: list[ToolRecord] + message_id: str | None = None class ContextUsageResponse(BaseModel): @@ -73,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/widget.js b/src/agent_manager/api/static/widget.js index 09650506..2984287e 100644 --- a/src/agent_manager/api/static/widget.js +++ b/src/agent_manager/api/static/widget.js @@ -52158,7 +52158,8 @@ 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) { @@ -52174,6 +52175,19 @@ var AgentChatClient = class { 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", @@ -52400,8 +52414,34 @@ var __iconNode10 = [ ]; var SquarePen = createLucideIcon("square-pen", __iconNode10); -// node_modules/lucide-react/dist/esm/icons/wrench.mjs +// 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 __iconNode13 = [ [ "path", { @@ -52410,14 +52450,14 @@ var __iconNode11 = [ } ] ]; -var Wrench = createLucideIcon("wrench", __iconNode11); +var Wrench = createLucideIcon("wrench", __iconNode13); // node_modules/lucide-react/dist/esm/icons/x.mjs -var __iconNode12 = [ +var __iconNode14 = [ ["path", { d: "M18 6 6 18", key: "1bl5f8" }], ["path", { d: "m6 6 12 12", key: "d8bk6v" }] ]; -var X = createLucideIcon("x", __iconNode12); +var X = createLucideIcon("x", __iconNode14); // src/agent_manager/api/static/widget/react/AgentChatApp.tsx var import_react10 = __toESM(require_react(), 1); @@ -52930,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, @@ -53153,9 +53222,11 @@ 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"; @@ -53240,12 +53311,28 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { (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 (cid, text10, entryId) => { try { const answer = await conversation.send(text10); replaceEntry(cid, entryId, { id: entryId, + message_id: answer.message_id, role: "ai", text: answer.answer, route: answer.visited, @@ -53345,7 +53432,7 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { ), /* @__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)( @@ -53404,7 +53491,10 @@ function Launcher({ } ); } -function ChatMessage({ entry }) { +function ChatMessage({ + entry, + onFeedback +}) { const from = entry.role === "user" ? "user" : "assistant"; 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 }) }); @@ -53417,7 +53507,7 @@ function ChatMessage({ entry }) { /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(AgentActivity, { route: entry.route, tools: entry.tools }), 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 }) : null + entry.text.trim() ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(MessageActions, { text: entry.text, entry, onFeedback }) : null ] }) ] }); } @@ -53428,8 +53518,21 @@ function ThinkingDots() { /* @__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); @@ -53685,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; @@ -53693,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; @@ -54162,6 +54272,22 @@ lucide-react/dist/esm/icons/square-pen.mjs: * 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 d5fd1039..d9c72009 100644 --- a/src/agent_manager/api/static/widget.test.mjs +++ b/src/agent_manager/api/static/widget.test.mjs @@ -341,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 da33fbfb..e5a4346e 100644 --- a/src/agent_manager/api/static/widget/api/AgentChatClient.ts +++ b/src/agent_manager/api/static/widget/api/AgentChatClient.ts @@ -66,6 +66,7 @@ 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, }; } @@ -83,6 +84,24 @@ export class AgentChatClient { }; } + 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/react/AgentChatApp.tsx b/src/agent_manager/api/static/widget/react/AgentChatApp.tsx index 20043b95..f1afaac3 100644 --- a/src/agent_manager/api/static/widget/react/AgentChatApp.tsx +++ b/src/agent_manager/api/static/widget/react/AgentChatApp.tsx @@ -25,6 +25,7 @@ import { ConversationContent, Message, MessageContent, + MessageFeedback, MessageResponse, PromptInput, PromptInputFooter, @@ -46,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 { @@ -158,12 +161,29 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age [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 (cid: string, text: string, entryId: string) => { try { const answer = await conversation.send(text); replaceEntry(cid, entryId, { id: entryId, + message_id: answer.message_id, role: "ai", text: answer.answer, route: answer.visited, @@ -270,7 +290,7 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age ) : null} {entries.map((entry) => ( - + ))} @@ -330,7 +350,13 @@ 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.error) { @@ -363,7 +389,9 @@ function ChatMessage({ entry }: { entry: MessageEntry }) { {entry.text} - {entry.text.trim() ? : null} + {entry.text.trim() ? ( + + ) : null} )} @@ -380,10 +408,24 @@ function ThinkingDots() { ); } -function MessageActions({ text }: { text: string }) { +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}
); } 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/styles/styles.ts b/src/agent_manager/api/static/widget/styles/styles.ts index 86389d1c..14ae5197 100644 --- a/src/agent_manager/api/static/widget/styles/styles.ts +++ b/src/agent_manager/api/static/widget/styles/styles.ts @@ -111,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; @@ -119,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; diff --git a/src/agent_manager/api/static/widget/types.ts b/src/agent_manager/api/static/widget/types.ts index d3989fbf..5a03d75e 100644 --- a/src/agent_manager/api/static/widget/types.ts +++ b/src/agent_manager/api/static/widget/types.ts @@ -34,6 +34,8 @@ 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"; @@ -47,12 +49,14 @@ export interface ContextUsage { 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 { @@ -70,6 +74,7 @@ export interface SendMessageResponse { visited?: string[]; /** Tools observed during the run. */ used_tools?: ToolRecord[]; + message_id?: string; } export interface StreamEvent { @@ -90,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 12735207..b9e10bfa 100644 --- a/src/agent_manager/application/service.py +++ b/src/agent_manager/application/service.py @@ -32,6 +32,10 @@ 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.""" @@ -163,28 +167,30 @@ 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 @@ -210,24 +216,38 @@ async def stream( 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 dataclasses.replace(final, message_id=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/models.py b/src/agent_manager/domain/models.py index 59bfa993..aa9c16e2 100644 --- a/src/agent_manager/domain/models.py +++ b/src/agent_manager/domain/models.py @@ -25,6 +25,8 @@ class Message: role: Role content: str created_at: datetime + message_id: str | None = None + feedback: str | None = None @dataclass(frozen=True) @@ -75,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) diff --git a/src/agent_manager/domain/repository.py b/src/agent_manager/domain/repository.py index 6d4131a8..6c00461f 100644 --- a/src/agent_manager/domain/repository.py +++ b/src/agent_manager/domain/repository.py @@ -72,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 de221fda..5ee179b1 100644 --- a/src/agent_manager/infrastructure/persistence/memory_repository.py +++ b/src/agent_manager/infrastructure/persistence/memory_repository.py @@ -152,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]: @@ -160,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) @@ -228,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 8fc93fdb..f7589f83 100644 --- a/src/agent_manager/infrastructure/persistence/sql_repository.py +++ b/src/agent_manager/infrastructure/persistence/sql_repository.py @@ -190,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]: @@ -200,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: @@ -356,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, ) @@ -382,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, ) @@ -398,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 67a48603..832389a0 100644 --- a/tests/agent_manager/test_api.py +++ b/tests/agent_manager/test_api.py @@ -241,3 +241,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" + From 79718c8555710ec501a15d5d02850672ccf8492e Mon Sep 17 00:00:00 2001 From: Sameer Ahamad S Date: Mon, 27 Jul 2026 17:32:58 +0530 Subject: [PATCH 5/6] refactor: eliminate redundant history lookup in send_message route --- src/agent_engine/core/validator.py | 1 - src/agent_manager/api/routes.py | 7 ++----- src/agent_manager/application/service.py | 7 ++++--- 3 files changed, 6 insertions(+), 9 deletions(-) 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 e255e9bd..cf5ed408 100644 --- a/src/agent_manager/api/routes.py +++ b/src/agent_manager/api/routes.py @@ -31,7 +31,6 @@ ConversationTokenBudgetExceeded, MessageNotFound, ) -from agent_manager.domain import Role router = APIRouter() @@ -97,9 +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) - msgs = await service.history(conversation_id) - last_msg = msgs[-1] if msgs and msgs[-1].role == Role.ASSISTANT else None + 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: @@ -110,7 +107,7 @@ 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=last_msg.message_id if last_msg else None, + message_id=msg.message_id if msg else None, ) diff --git a/src/agent_manager/application/service.py b/src/agent_manager/application/service.py index b9e10bfa..1a9bc16f 100644 --- a/src/agent_manager/application/service.py +++ b/src/agent_manager/application/service.py @@ -100,7 +100,7 @@ async def list_conversations( 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, @@ -111,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, From 3fb7decca891c06acb3b37b94f467049d83bd38e Mon Sep 17 00:00:00 2001 From: Sameer Ahamad S Date: Tue, 28 Jul 2026 12:44:51 +0530 Subject: [PATCH 6/6] refactor: preserve engine statelessness by scoping message_id handling to agent_manager --- src/agent_engine/runtime/streaming.py | 1 - src/agent_manager/api/routes.py | 13 +++++++------ src/agent_manager/application/service.py | 7 ++++--- tests/agent_manager/test_api.py | 4 +++- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/agent_engine/runtime/streaming.py b/src/agent_engine/runtime/streaming.py index 254adbcd..c61434d8 100644 --- a/src/agent_engine/runtime/streaming.py +++ b/src/agent_engine/runtime/streaming.py @@ -59,4 +59,3 @@ class RunStreamEvent: approval_id: str | None = None agent_id: str | None = None description: str | None = None - message_id: str | None = None diff --git a/src/agent_manager/api/routes.py b/src/agent_manager/api/routes.py index cf5ed408..70e26c5e 100644 --- a/src/agent_manager/api/routes.py +++ b/src/agent_manager/api/routes.py @@ -130,7 +130,7 @@ async def record_feedback( return FeedbackResponse(message_id=msg.message_id, feedback=msg.feedback) -def _to_stream_event(event: RunStreamEvent) -> StreamEventOut: +def _to_stream_event(event: RunStreamEvent, message_id: str | None = None) -> StreamEventOut: return StreamEventOut( type=event.type, content=event.content, @@ -146,7 +146,7 @@ def _to_stream_event(event: RunStreamEvent) -> StreamEventOut: if event.used_tools else None ), - message_id=event.message_id, + message_id=message_id, ) @@ -168,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/application/service.py b/src/agent_manager/application/service.py index 1a9bc16f..0eb7c480 100644 --- a/src/agent_manager/application/service.py +++ b/src/agent_manager/application/service.py @@ -195,7 +195,7 @@ async def complete_turn( 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 @@ -211,7 +211,8 @@ async def stream( ): if event.type == "final": final = event - yield event + else: + yield event, None except Exception: if final is None: raise @@ -236,7 +237,7 @@ async def stream( msg, snapshot_ttl_seconds=self._snapshot_ttl_seconds, ) - yield dataclasses.replace(final, message_id=msg.message_id) + yield final, msg.message_id async def record_feedback( self, conversation_id: str, message_id: str, feedback: str | None diff --git a/tests/agent_manager/test_api.py b/tests/agent_manager/test_api.py index 832389a0..5cc02f27 100644 --- a/tests/agent_manager/test_api.py +++ b/tests/agent_manager/test_api.py @@ -224,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] == [