diff --git a/src/agent_manager/api/static/widget-demo.html b/src/agent_manager/api/static/widget-demo.html index 965a451b..5565c6f8 100644 --- a/src/agent_manager/api/static/widget-demo.html +++ b/src/agent_manager/api/static/widget-demo.html @@ -45,8 +45,8 @@

Floating widget

<script type="module" src="/widget.js"></script>
 <agent-chat
   title="Support"
-  color="#2563eb"
-  greeting="Hi! How can I help?"
+  color="#18181b"
+  greeting="How can I help you today?"
   mode="floating"
   position="bottom-right">
 </agent-chat>
@@ -54,8 +54,8 @@

Floating widget

diff --git a/src/agent_manager/api/static/widget.js b/src/agent_manager/api/static/widget.js index 8aaca327..a69e7455 100644 --- a/src/agent_manager/api/static/widget.js +++ b/src/agent_manager/api/static/widget.js @@ -40239,7 +40239,7 @@ var init_remark_gfm = __esm({ }); // node_modules/remend/dist/index.js -var cn, un, fn, $2, dn, gn, O, I, k, c, hn, L, f, mn, E, M, N, y, R, U, W, K, d, b, H, D, w, G, F, T, g, X, C, h2, In, m, z, p, kn, Y, bn, Tn, Cn, A, v, An, j, Bn, Q, Sn, Z, q, _n, Pn, J, $n, On, V, Ln, x, En, Mn, nn, en, Nn, yn, Rn, rn, sn, Un, on, Wn, B, Kn, Hn, Dn, wn, ln, Gn, tn, an, S, Fn, u, Xn, zn, vn, $e; +var cn, un, fn, $2, dn, gn, O, I, k, c, hn, L, f, mn, E, M, N, y, R, U, W, K, d, b, H, D, w, G, F, T, g, X2, C, h2, In, m, z, p, kn, Y, bn, Tn, Cn, A, v, An, j, Bn, Q, Sn, Z, q, _n, Pn, J, $n, On, V, Ln, x, En, Mn, nn, en, Nn, yn, Rn, rn, sn, Un, on, Wn, B, Kn, Hn, Dn, wn, ln, Gn, tn, an, S, Fn, u, Xn, zn, vn, $e; var init_dist3 = __esm({ "node_modules/remend/dist/index.js"() { cn = Object.defineProperty; @@ -40325,7 +40325,7 @@ var init_dist3 = __esm({ let r2 = n.charCodeAt(0); return r2 >= 48 && r2 <= 57 || r2 >= 65 && r2 <= 90 || r2 >= 97 && r2 <= 122 || r2 === 95 ? true : H.test(n); }; - X = (n, r2) => { + X2 = (n, r2) => { let e = 1; for (let i = r2 - 1; i >= 0; i -= 1) if (n[i] === "]") e += 1; else if (n[i] === "[" && (e -= 1, e === 0)) return i; @@ -40626,7 +40626,7 @@ $$` : `${n}$$`; sn = (n) => yn(n) % 2 === 1 ? `${n}$` : n; Un = (n, r2, e) => { if (n.substring(r2 + 2).includes(")")) return null; - let s2 = X(n, r2); + let s2 = X2(n, r2); if (s2 === -1 || c(n, s2)) return null; let o = s2 > 0 && n[s2 - 1] === "!", t = o ? s2 - 1 : s2, a = n.substring(0, t); if (o) return a; @@ -49689,7 +49689,7 @@ function nt(u4, e, t) { function g2(u4, e) { return L2.parse(u4, e); } -var O2, _, be, m2, Re, Oe, Te, C2, we, Q2, se, ie, ye, j2, Pe, F2, Se, $e2, v2, U2, _e, oe, Le, K2, ne, Me, ze, Ee, Ie, ae, Ae, z2, H2, W2, Ce, le, Be, De, qe, ue, ve, He, pe, Ze, Ge, Ne, Qe, je, Fe, Ue, Ke, We, Xe, q2, Je, ce, he, Ve, re2, X2, Ye, N2, et, B2, E2, tt, ke, w2, x2, y2, $3, b2, _a2, P, D2, L2, Qt, jt, Ft, Ut, Kt, Xt, Jt; +var O2, _, be, m2, Re, Oe, Te, C2, we, Q2, se, ie, ye, j2, Pe, F2, Se, $e2, v2, U2, _e, oe, Le, K2, ne, Me, ze, Ee, Ie, ae, Ae, z2, H2, W2, Ce, le, Be, De, qe, ue, ve, He, pe, Ze, Ge, Ne, Qe, je, Fe, Ue, Ke, We, Xe, q2, Je, ce, he, Ve, re2, X3, Ye, N2, et, B2, E2, tt, ke, w2, x2, y2, $3, b2, _a2, P, D2, L2, Qt, jt, Ft, Ut, Kt, Xt, Jt; var init_marked_esm = __esm({ "node_modules/marked/lib/marked.esm.js"() { O2 = M2(); @@ -49758,12 +49758,12 @@ var init_marked_esm = __esm({ he = k2(/^!?\[(ref)\](?:\[\])?/).replace("ref", F2).getRegex(); Ve = k2("reflink|nolink(?!\\()", "g").replace("reflink", ce).replace("nolink", he).getRegex(); re2 = /[hH][tT][tT][pP][sS]?|[fF][tT][pP]/; - X2 = { _backpedal: _, anyPunctuation: Ue, autolink: Ke, blockSkip: qe, br: ae, code: Ie, del: _, delLDelim: _, delRDelim: _, emStrongLDelim: ve, emStrongRDelimAst: Ze, emStrongRDelimUnd: Ne, escape: Ee, link: Je, nolink: he, punctuation: Ce, reflink: ce, reflinkSearch: Ve, tag: Xe, text: Ae, url: _ }; - Ye = { ...X2, link: k2(/^!?\[(label)\]\((.*?)\)/).replace("label", q2).getRegex(), reflink: k2(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label", q2).getRegex() }; - N2 = { ...X2, emStrongRDelimAst: Ge, emStrongLDelim: He, delLDelim: Qe, delRDelim: Fe, url: k2(/^((?:protocol):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/).replace("protocol", re2).replace("email", /[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(), _backpedal: /(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/, del: /^(~~?)(?=[^\s~])((?:\\[\s\S]|[^\\])*?(?:\\[\s\S]|[^\s~\\]))\1(?=[^~]|$)/, text: k2(/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\": ">", '"': """, "'": "'" }; ke = (u4) => tt[u4]; w2 = class { @@ -51925,16 +51925,16 @@ var init_chunk_BO2N2NFS = __esm({ }); Tn2.displayName = "Block"; Qs = (0, import_react5.memo)(({ children: e, mode: t = "streaming", dir: o, parseIncompleteMarkdown: n = true, normalizeHtmlIndentation: r2 = false, components: s2, rehypePlugins: a = gn2, remarkPlugins: l = Us, className: i, shikiTheme: d2 = vn2, mermaid: c2, controls: p2 = true, isAnimating: m3 = false, animated: u4, BlockComponent: f2 = Tn2, parseMarkdownIntoBlocksFn: h3 = kt, caret: b3, plugins: g3, remend: T3, linkSafety: v3 = xn, lineNumbers: w3 = true, allowedTags: P2, literalTagContent: M3, translations: H3, icons: S2, prefix: F3, onAnimationStart: j3, onAnimationEnd: z3, ...B3 }) => { - let _2 = (0, import_react5.useId)(), [Q3, U3] = (0, import_react5.useTransition)(), x3 = (0, import_react5.useMemo)(() => Dt(F3), [F3]), q3 = (0, import_react5.useRef)(null), X3 = (0, import_react5.useRef)(j3), Re2 = (0, import_react5.useRef)(z3); - X3.current = j3, Re2.current = z3, (0, import_react5.useEffect)(() => { + let _2 = (0, import_react5.useId)(), [Q3, U3] = (0, import_react5.useTransition)(), x3 = (0, import_react5.useMemo)(() => Dt(F3), [F3]), q3 = (0, import_react5.useRef)(null), X4 = (0, import_react5.useRef)(j3), Re2 = (0, import_react5.useRef)(z3); + X4.current = j3, Re2.current = z3, (0, import_react5.useEffect)(() => { var A2, K3, ee; if (t === "static") return; let k3 = q3.current; if (q3.current = m3, k3 === null) { - m3 && ((A2 = X3.current) == null || A2.call(X3)); + m3 && ((A2 = X4.current) == null || A2.call(X4)); return; } - m3 && !k3 ? (K3 = X3.current) == null || K3.call(X3) : !m3 && k3 && ((ee = Re2.current) == null || ee.call(Re2)); + m3 && !k3 ? (K3 = X4.current) == null || K3.call(X4) : !m3 && k3 && ((ee = Re2.current) == null || ee.call(Re2)); }, [m3, t]); let Je2 = (0, import_react5.useMemo)(() => P2 ? Object.keys(P2) : [], [P2]), Se2 = (0, import_react5.useMemo)(() => { if (typeof e != "string") return ""; @@ -52000,8 +52000,8 @@ var init_chunk_BO2N2NFS = __esm({ }, [b3]), Q3 = (0, import_react5.useCallback)((x3) => { if (!T3) return; x3.preventDefault(); - let q3 = x3.clientX - w3.x, X3 = x3.clientY - w3.y; - g3({ x: M3.x + q3, y: M3.y + X3 }); + let q3 = x3.clientX - w3.x, X4 = x3.clientY - w3.y; + g3({ x: M3.x + q3, y: M3.y + X4 }); }, [T3, w3, M3]), U3 = (0, import_react5.useCallback)((x3) => { v3(false); let q3 = x3.currentTarget; @@ -52056,7 +52056,7 @@ var init_chunk_BO2N2NFS = __esm({ // src/agent_manager/api/static/widget/config/parseConfig.ts var DEFAULT_CONFIG = { title: "Assistant", - color: "#2563eb", + color: "#18181b", greeting: "", position: "bottom-right", avatar: "", @@ -52184,23 +52184,6 @@ function parseSseFrame(frame) { return JSON.parse(data); } -// src/agent_manager/api/static/widget/react/AgentChatApp.tsx -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)); -} - // node_modules/lucide-react/dist/esm/createLucideIcon.mjs var import_react3 = __toESM(require_react(), 1); @@ -52305,27 +52288,53 @@ var createLucideIcon = (iconName, iconNode) => { return Component; }; -// node_modules/lucide-react/dist/esm/icons/circle-check-big.mjs +// node_modules/lucide-react/dist/esm/icons/bot.mjs var __iconNode = [ + ["path", { d: "M12 8V4H8", key: "hb8ula" }], + ["rect", { width: "16", height: "12", x: "4", y: "8", rx: "2", key: "enze0r" }], + ["path", { d: "M2 14h2", key: "vft8re" }], + ["path", { d: "M20 14h2", key: "4cs60a" }], + ["path", { d: "M15 13v2", key: "1xurst" }], + ["path", { d: "M9 13v2", key: "rq6x2g" }] +]; +var Bot = createLucideIcon("bot", __iconNode); + +// node_modules/lucide-react/dist/esm/icons/check.mjs +var __iconNode2 = [["path", { d: "M20 6 9 17l-5-5", key: "1gmf2c" }]]; +var Check = createLucideIcon("check", __iconNode2); + +// node_modules/lucide-react/dist/esm/icons/chevron-down.mjs +var __iconNode3 = [["path", { d: "m6 9 6 6 6-6", key: "qrunsl" }]]; +var ChevronDown = createLucideIcon("chevron-down", __iconNode3); + +// node_modules/lucide-react/dist/esm/icons/circle-check-big.mjs +var __iconNode4 = [ ["path", { d: "M21.801 10A10 10 0 1 1 17 3.335", key: "yps3ct" }], ["path", { d: "m9 11 3 3L22 4", key: "1pflzl" }] ]; -var CircleCheckBig = createLucideIcon("circle-check-big", __iconNode); +var CircleCheckBig = createLucideIcon("circle-check-big", __iconNode4); // node_modules/lucide-react/dist/esm/icons/circle-x.mjs -var __iconNode2 = [ +var __iconNode5 = [ ["circle", { cx: "12", cy: "12", r: "10", key: "1mglay" }], ["path", { d: "m15 9-6 6", key: "1uzhvr" }], ["path", { d: "m9 9 6 6", key: "z0biqf" }] ]; -var CircleX = createLucideIcon("circle-x", __iconNode2); +var CircleX = createLucideIcon("circle-x", __iconNode5); // node_modules/lucide-react/dist/esm/icons/circle.mjs -var __iconNode3 = [["circle", { cx: "12", cy: "12", r: "10", key: "1mglay" }]]; -var Circle = createLucideIcon("circle", __iconNode3); +var __iconNode6 = [["circle", { cx: "12", cy: "12", r: "10", key: "1mglay" }]]; +var Circle = createLucideIcon("circle", __iconNode6); + +// node_modules/lucide-react/dist/esm/icons/copy.mjs +var __iconNode7 = [ + ["rect", { width: "14", height: "14", x: "8", y: "8", rx: "2", ry: "2", key: "17jyea" }], + ["path", { d: "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2", key: "zix9uf" }] +]; +var Copy = createLucideIcon("copy", __iconNode7); // node_modules/lucide-react/dist/esm/icons/send.mjs -var __iconNode4 = [ +var __iconNode8 = [ [ "path", { @@ -52335,10 +52344,10 @@ var __iconNode4 = [ ], ["path", { d: "m21.854 2.147-10.94 10.939", key: "12cjpa" }] ]; -var Send = createLucideIcon("send", __iconNode4); +var Send = createLucideIcon("send", __iconNode8); // node_modules/lucide-react/dist/esm/icons/wrench.mjs -var __iconNode5 = [ +var __iconNode9 = [ [ "path", { @@ -52347,7 +52356,17 @@ var __iconNode5 = [ } ] ]; -var Wrench = createLucideIcon("wrench", __iconNode5); +var Wrench = createLucideIcon("wrench", __iconNode9); + +// node_modules/lucide-react/dist/esm/icons/x.mjs +var __iconNode10 = [ + ["path", { d: "M18 6 6 18", key: "1bl5f8" }], + ["path", { d: "m6 6 12 12", key: "d8bk6v" }] +]; +var X = createLucideIcon("x", __iconNode10); + +// src/agent_manager/api/static/widget/react/AgentChatApp.tsx +var import_react10 = __toESM(require_react(), 1); // src/agent_manager/api/static/widget/react/shadcnAiElements.tsx var import_react8 = __toESM(require_react(), 1); @@ -52845,6 +52864,7 @@ function PromptInput({ if (!text10) return; input.value = ""; input.style.height = "auto"; + input.style.overflowY = "hidden"; onSubmit({ text: text10 }); } function onFormSubmit(event) { @@ -52866,7 +52886,7 @@ function PromptInputTextarea({ onSubmit(); } } - return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)( + return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "input-wrap", children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)( "textarea", { ...props, @@ -52876,13 +52896,14 @@ function PromptInputTextarea({ props.onInput?.(event); const input = event.currentTarget; input.style.height = "auto"; - input.style.height = `${Math.min(input.scrollHeight, 120)}px`; + input.style.height = `${Math.min(input.scrollHeight, 140)}px`; + input.style.overflowY = input.scrollHeight > 140 ? "auto" : "hidden"; }, onKeyDown, ref: inputRef, rows: props.rows ?? 1 } - ); + ) }); } function PromptInputFooter({ children: children2 }) { return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "prompt-footer", children: children2 }); @@ -52920,143 +52941,204 @@ function statusLabel(state) { return "Running"; } +// src/agent_manager/api/static/widget/react/streamReducer.ts +var TOOL_STATUS = { + tool_started: "started", + tool_succeeded: "succeeded", + tool_failed: "failed" +}; +function reduceStreamEvent(entry, event) { + switch (event.type) { + case "answer_delta": + return { ...entry, text: entry.text + (event.content ?? ""), typing: false }; + case "route": + return { ...entry, route: event.route ?? entry.route, typing: false }; + case "tool_started": + case "tool_succeeded": + case "tool_failed": + return { ...entry, tools: upsertTool(entry.tools ?? [], toToolRecord(event)), typing: false }; + 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"); + default: + return entry; + } +} +function toToolRecord(event) { + return { + name: event.tool_name ?? "tool", + provider: event.provider ?? "runtime", + status: TOOL_STATUS[event.type] ?? "started", + server_id: event.server_id, + error: event.error + }; +} +function upsertTool(tools, next2) { + const index2 = tools.findIndex((tool) => tool.name === next2.name && tool.provider === next2.provider); + if (index2 === -1) return [...tools, next2]; + return tools.map((tool, position4) => position4 === index2 ? { ...tool, ...next2 } : tool); +} + +// 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) { + const startConversation = (0, import_react9.useCallback)(async () => { + const created = await client.createConversation(); + setStoredConversationId(endpoint, created); + return created; + }, [client, endpoint]); + const ensureId = (0, import_react9.useCallback)( + async () => getStoredConversationId(endpoint) ?? startConversation(), + [endpoint, startConversation] + ); + const restartId = (0, import_react9.useCallback)(async () => { + removeStoredConversationId(endpoint); + return startConversation(); + }, [endpoint, startConversation]); + const send = (0, import_react9.useCallback)( + async (text10) => { + try { + return await client.sendMessage(await ensureId(), text10); + } catch (error) { + if (!isMissingConversation(error)) throw error; + return client.sendMessage(await restartId(), text10); + } + }, + [client, ensureId, restartId] + ); + const stream = (0, import_react9.useCallback)( + async function* (text10) { + try { + yield* client.streamMessage(await ensureId(), text10); + } catch (error) { + if (!isMissingConversation(error)) throw error; + yield* client.streamMessage(await restartId(), text10); + } + }, + [client, ensureId, restartId] + ); + const loadHistory = (0, import_react9.useCallback)(async () => { + const stored = getStoredConversationId(endpoint); + if (!stored) return []; + try { + return await client.getMessages(stored); + } catch (error) { + if (isMissingConversation(error)) removeStoredConversationId(endpoint); + return []; + } + }, [client, endpoint]); + return (0, import_react9.useMemo)(() => ({ send, stream, loadHistory }), [send, stream, loadHistory]); +} + // src/agent_manager/api/static/widget/react/AgentChatApp.tsx var import_jsx_runtime4 = __toESM(require_jsx_runtime(), 1); -var nextMessageCounter = 0; -function nextMessageId(role) { - nextMessageCounter += 1; - return `${role}-${Date.now()}-${nextMessageCounter}`; -} +var DEFAULT_GREETING = "How can I help you today?"; +var GENERIC_ERROR = "Something went wrong. Please try again."; +var COPIED_RESET_MS = 2e3; +var newId = () => crypto.randomUUID(); +var toEntry = (message) => ({ + id: newId(), + role: message.role === "user" ? "user" : "ai", + text: message.content +}); function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { const inline = config.mode === "inline"; - const [open, setOpen] = (0, import_react9.useState)(inline); - const [loaded, setLoaded] = (0, import_react9.useState)(false); - const [sending, setSending] = (0, import_react9.useState)(false); - const [entries, setEntries] = (0, import_react9.useState)([]); - const launcherRef = (0, import_react9.useRef)(null); - const inputRef = (0, import_react9.useRef)(null); - const loadHistory = (0, import_react9.useCallback)(async () => { + const conversation = useConversation(client, config.endpoint); + 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 launcherRef = (0, import_react10.useRef)(null); + const inputRef = (0, import_react10.useRef)(null); + const loadHistory = (0, import_react10.useCallback)(async () => { if (loaded) return; setLoaded(true); - const existing = localStorage.getItem(conversationStorageKey(config.endpoint)); - if (!existing) { - if (config.greeting) setEntries((prev) => [...prev, { id: nextMessageId("ai"), role: "ai", text: config.greeting }]); - return; - } - try { - const history = await client.getMessages(existing); - setEntries( - history.map((message) => ({ - id: nextMessageId(message.role === "user" ? "user" : "ai"), - role: message.role === "user" ? "user" : "ai", - text: message.content - })) - ); - } catch (error) { - if (error instanceof AgentChatHttpError && error.status === 404) { - removeStoredConversationId(config.endpoint); - } - } - }, [client, config.endpoint, config.greeting, loaded]); - (0, import_react9.useEffect)(() => { - if (inline) { - void loadHistory(); - } + const history = await conversation.loadHistory(); + if (history.length) setEntries(history.map(toEntry)); + }, [conversation, loaded]); + (0, import_react10.useEffect)(() => { + if (inline) void loadHistory(); }, [inline, loadHistory]); - (0, import_react9.useEffect)(() => { + (0, import_react10.useEffect)(() => { if (open) inputRef.current?.focus({ preventScroll: true }); }, [open, loaded, sending]); - const openChat = (0, import_react9.useCallback)(async () => { + const openChat = (0, import_react10.useCallback)(async () => { if (inline) return; setOpen(true); await loadHistory(); }, [inline, loadHistory]); - const closeChat = (0, import_react9.useCallback)(() => { + const closeChat = (0, import_react10.useCallback)(() => { if (inline) return; setOpen(false); launcherRef.current?.focus({ preventScroll: true }); }, [inline]); - const conversationId = (0, import_react9.useCallback)(async () => { - let id = getStoredConversationId(config.endpoint); - if (!id) { - id = await client.createConversation(); - setStoredConversationId(config.endpoint, id); - } - return id; - }, [client, config.endpoint]); - const sendToAgent = (0, import_react9.useCallback)( - async (text10) => { - const id = await conversationId(); - try { - return await client.sendMessage(id, text10); - } catch (error) { - if (!(error instanceof AgentChatHttpError) || error.status !== 404) throw error; - removeStoredConversationId(config.endpoint); - const freshId = await client.createConversation(); - setStoredConversationId(config.endpoint, freshId); - return await client.sendMessage(freshId, text10); - } - }, - [client, config.endpoint, conversationId] - ); - const streamFromAgent = (0, import_react9.useCallback)( - async function* (text10) { - const id = await conversationId(); + const replaceEntry = (0, import_react10.useCallback)((id, entry) => { + setEntries((prev) => prev.map((current) => current.id === id ? entry : current)); + }, []); + const sendWithoutStreaming = (0, import_react10.useCallback)( + async (text10, entryId) => { try { - yield* client.streamMessage(id, text10); - } catch (error) { - if (!(error instanceof AgentChatHttpError) || error.status !== 404) throw error; - removeStoredConversationId(config.endpoint); - const freshId = await client.createConversation(); - setStoredConversationId(config.endpoint, freshId); - yield* client.streamMessage(freshId, text10); + const answer = await conversation.send(text10); + replaceEntry(entryId, { + id: entryId, + role: "ai", + text: answer.answer, + route: answer.visited, + tools: answer.used_tools + }); + onAnswer({ visited: answer.visited ?? [], used_tools: answer.used_tools ?? [] }); + } catch { + replaceEntry(entryId, { id: entryId, role: "ai", text: GENERIC_ERROR, error: true }); } }, - [client, config.endpoint, conversationId] + [conversation, onAnswer, replaceEntry] ); - const patchEntry = (0, import_react9.useCallback)((id, update) => { - setEntries((prev) => prev.map((entry) => entry.id === id ? update(entry) : entry)); - }, []); - const submit = (0, import_react9.useCallback)( + const submit = (0, import_react10.useCallback)( async (text10) => { - const assistantId = nextMessageId("ai"); - setEntries((prev) => [ - ...prev, - { id: nextMessageId("user"), role: "user", text: text10 }, - { id: assistantId, role: "ai", text: "", typing: true } - ]); + const pending = { id: newId(), role: "ai", text: "", typing: true }; + setEntries((prev) => [...prev, { id: newId(), role: "user", text: text10 }, pending]); setSending(true); try { - let finalDetail = { visited: [], used_tools: [] }; - for await (const event of streamFromAgent(text10)) { - finalDetail = applyStreamEvent(assistantId, event, patchEntry, finalDetail); - } - patchEntry(assistantId, (entry) => ({ ...entry, typing: false })); - onAnswer(finalDetail); - } catch (error) { - try { - const data = await sendToAgent(text10); - patchEntry(assistantId, () => ({ - id: assistantId, - role: "ai", - text: data.answer, - route: data.visited, - tools: data.used_tools - })); - onAnswer({ visited: data.visited ?? [], used_tools: data.used_tools ?? [] }); - } catch { - patchEntry(assistantId, () => ({ - id: assistantId, - role: "ai", - text: error instanceof Error && error.message ? `Something went wrong. Please try again.` : "Something went wrong. Please try again." - })); + let entry = pending; + for await (const event of conversation.stream(text10)) { + entry = reduceStreamEvent(entry, event); + replaceEntry(pending.id, entry); } + replaceEntry(pending.id, { ...entry, typing: false }); + onAnswer({ visited: entry.route ?? [], used_tools: entry.tools ?? [] }); + } catch { + await sendWithoutStreaming(text10, pending.id); } finally { setSending(false); } }, - [onAnswer, patchEntry, sendToAgent, streamFromAgent] + [conversation, onAnswer, replaceEntry, sendWithoutStreaming] ); + const toggle = () => void (open ? closeChat() : openChat()); return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)( "div", { @@ -53071,25 +53153,7 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { onKeyPress: (event) => event.stopPropagation(), onKeyUp: (event) => event.stopPropagation(), children: [ - !inline ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)( - "button", - { - "aria-controls": panelId, - "aria-expanded": open, - "aria-label": "Open chat", - className: "launcher", - onClick: () => void (open ? closeChat() : openChat()), - onKeyDown: (event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - void openChat(); - } - }, - ref: launcherRef, - type: "button", - children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ChatIcon, {}) - } - ) : null, + !inline ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Launcher, { open, panelId, buttonRef: launcherRef, onToggle: toggle }) : null, /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)( "section", { @@ -53099,29 +53163,15 @@ 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)( - "span", - { - className: "dot", - style: config.avatar ? { backgroundImage: `url("${config.avatar.replace(/"/g, "%22")}")` } : void 0 - } - ), + /* @__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 }), - !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)(CloseIcon, {}) }) : null + !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)(Conversation, { children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ConversationContent, { children: entries.map((entry, index2) => /* @__PURE__ */ (0, import_jsx_runtime4.jsx)( - Message, - { - from: entry.role === "user" ? "user" : "assistant", - typing: entry.typing, - children: entry.typing ? "..." : /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_jsx_runtime4.Fragment, { children: [ - entry.role === "ai" ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ToolMessage, { route: entry.route, tools: entry.tools }) : null, - /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(MessageContent, { children: entry.role === "ai" ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(MessageResponse, { children: entry.text }) : entry.text }) - ] }) - }, - entry.id - )) }) }), + /* @__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)) + ] }) }), /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(PromptInput, { onSubmit: (message) => void submit(message.text), children: [ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)( PromptInputTextarea, @@ -53129,9 +53179,7 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { "aria-label": "Message", disabled: false, inputRef, - onSubmit: () => { - inputRef.current?.form?.requestSubmit(); - }, + onSubmit: () => inputRef.current?.form?.requestSubmit(), placeholder: "Message..." } ), @@ -53139,7 +53187,8 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { /* @__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 }) ] }) - ] }) + ] }), + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "powered", children: "Powered by Extra" }) ] }) ] } @@ -53148,94 +53197,98 @@ function AgentChatApp({ client, config, onAnswer, panelId, titleId }) { } ); } -function applyStreamEvent(assistantId, event, patchEntry, currentDetail) { - if (event.type === "answer_delta") { - patchEntry(assistantId, (entry) => ({ - ...entry, - text: entry.text + (event.content ?? ""), - typing: false - })); - return currentDetail; - } - if (event.type === "route") { - const route = event.route ?? currentDetail.visited; - patchEntry(assistantId, (entry) => ({ ...entry, route, typing: false })); - return { ...currentDetail, visited: route }; - } - if (event.type === "tool_started" || event.type === "tool_succeeded" || event.type === "tool_failed") { - const tool = streamToolRecord(event); - patchEntry(assistantId, (entry) => ({ - ...entry, - tools: upsertTool(entry.tools ?? [], tool), - typing: false - })); - return { ...currentDetail, used_tools: upsertTool(currentDetail.used_tools, tool) }; - } - if (event.type === "final") { - const visited = event.route ?? currentDetail.visited; - const usedTools = event.used_tools ?? currentDetail.used_tools; - patchEntry(assistantId, (entry) => ({ - ...entry, - text: event.content ?? entry.text, - route: visited, - tools: usedTools, - typing: false - })); - return { visited, used_tools: usedTools }; +function Launcher({ + open, + panelId, + buttonRef, + onToggle +}) { + return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)( + "button", + { + "aria-controls": panelId, + "aria-expanded": open, + "aria-label": open ? "Close Assistant" : "Open Assistant", + className: `launcher${open ? " open" : ""}`, + onClick: onToggle, + onKeyDown: (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onToggle(); + } + }, + ref: buttonRef, + type: "button", + children: [ + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Bot, { className: "icon-bot", "aria-hidden": true }), + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ChevronDown, { className: "icon-chevron", "aria-hidden": true }) + ] + } + ); +} +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 (event.type === "error") { - throw new Error(event.error || "stream failed"); + 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 currentDetail; + return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(Message, { from: "assistant", 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 + ] }); } -function streamToolRecord(event) { - return { - name: event.tool_name ?? "tool", - provider: event.provider ?? "runtime", - status: event.type === "tool_started" ? "started" : event.type === "tool_succeeded" ? "succeeded" : "failed", - server_id: event.server_id, - error: event.error - }; +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 upsertTool(tools, next2) { - const index2 = tools.findIndex((tool) => tool.name === next2.name && tool.provider === next2.provider); - if (index2 === -1) return [...tools, next2]; - const copy = tools.slice(); - copy[index2] = { ...copy[index2], ...next2 }; - return copy; +function CopyButton({ text: text10 }) { + const [copied, setCopied] = (0, import_react10.useState)(false); + const copy = (0, import_react10.useCallback)(() => { + void navigator.clipboard?.writeText(text10).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), COPIED_RESET_MS); + }).catch(() => { + }); + }, [text10]); + const Icon2 = copied ? Check : Copy; + return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { "aria-label": copied ? "Copied" : "Copy", className: "msg-action", onClick: copy, type: "button", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Icon2, { "aria-hidden": true }) }); } -function ToolMessage({ route, tools = [] }) { +function AgentActivity({ route, tools = [] }) { if (!route?.length && tools.length === 0) return null; return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "tool-list", children: [ route?.length ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "route", "aria-label": "Agent route", children: route.join(" -> ") }) : null, tools.map((tool, index2) => /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(Tool, { defaultOpen: tool.status === "failed", children: [ - /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ToolHeader, { state: toolState(tool.status), title: tool.provider ? `${tool.name} \xB7 ${tool.provider}` : tool.name }), + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)( + ToolHeader, + { + state: toToolState(tool.status), + title: tool.provider ? `${tool.name} \xB7 ${tool.provider}` : tool.name + } + ), tool.error ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ToolContent, { children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ToolOutput, { errorText: tool.error }) }) : null ] }, `${tool.name}-${index2}`)) ] }); } -function toolState(status) { +function Welcome({ title }) { + return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "welcome", children: [ + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "welcome-avatar", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Bot, { "aria-hidden": true }) }), + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: "welcome-title", children: title }) + ] }); +} +function avatarStyle(avatar) { + if (!avatar) return void 0; + return { backgroundImage: `url("${avatar.replace(/"/g, "%22")}")` }; +} +function toToolState(status) { if (status === "failed") return "output-error"; if (status === "succeeded") return "output-available"; return "input-available"; } -function ChatIcon() { - return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("svg", { "aria-hidden": "true", fill: "currentColor", viewBox: "0 0 24 24", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("path", { d: "M12 3C6.5 3 2 6.8 2 11.5c0 2.3 1.1 4.4 2.9 5.9L4 21l4.3-1.5c1.1.3 2.4.5 3.7.5 5.5 0 10-3.8 10-8.5S17.5 3 12 3z" }) }); -} -function CloseIcon() { - return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)( - "svg", - { - "aria-hidden": "true", - fill: "none", - stroke: "currentColor", - strokeLinecap: "round", - strokeWidth: "2.2", - viewBox: "0 0 24 24", - children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("path", { d: "M6 6l12 12M18 6L6 18" }) - } - ); -} // src/agent_manager/api/static/widget/styles/styles.ts function styles(config) { @@ -53246,22 +53299,37 @@ function styles(config) { .react-mount, .agent-chat-react { display: contents; } .launcher { - position: fixed; bottom: 20px; ${side}: 20px; width: 56px; height: 56px; + position: fixed; bottom: 16px; ${side}: 16px; width: 44px; height: 44px; border: 0; border-radius: 50%; background: ${config.color}; color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; - box-shadow: 0 10px 28px rgba(0,0,0,.2), 0 0 0 1px rgba(0,0,0,.04); - z-index: 2147483000; transition: transform .15s; } - .launcher:hover { transform: scale(1.06); } - .launcher svg { width: 26px; height: 26px; } + box-shadow: 0 10px 15px -3px rgba(0,0,0,.1), 0 4px 6px -4px rgba(0,0,0,.1); + z-index: 2147483000; transition: transform .15s ease-out; } + .launcher:hover { transform: scale(1.05); } + .launcher:active { transform: scale(.96); } + .launcher svg { position: absolute; width: 24px; height: 24px; + transition: scale .2s cubic-bezier(.2,0,0,1), opacity .2s cubic-bezier(.2,0,0,1), + filter .2s cubic-bezier(.2,0,0,1); } + .launcher .icon-bot { scale: 1; opacity: 1; filter: blur(0); } + .launcher .icon-chevron { scale: .25; opacity: 0; filter: blur(4px); } + .launcher.open .icon-bot { scale: .25; opacity: 0; filter: blur(4px); } + .launcher.open .icon-chevron { scale: 1; opacity: 1; filter: blur(0); } .panel { - position: fixed; bottom: 88px; ${side}: 20px; width: 380px; height: 560px; - max-height: calc(100vh - 120px); background: #fff; border-radius: 20px; overflow: hidden; - border: 1px solid #e4e4e7; + position: fixed; bottom: 76px; ${side}: 16px; width: 440px; height: 680px; + max-width: calc(100vw - 2rem); max-height: calc(100vh - 92px); + background: #fff; border-radius: 40px; overflow: hidden; + border: 1px solid rgba(228,228,231,.6); display: flex; flex-direction: column; - box-shadow: 0 20px 60px rgba(0,0,0,.14), 0 2px 8px rgba(0,0,0,.06); - opacity: 0; transform: translateY(12px) scale(.98); pointer-events: none; - transition: opacity .18s ease, transform .18s ease; z-index: 2147483000; } - .panel.open { opacity: 1; transform: none; pointer-events: auto; } + box-shadow: 0 20px 25px -5px rgba(0,0,0,.1), 0 8px 10px -6px rgba(0,0,0,.1); + transform-origin: bottom ${side}; + opacity: 0; transform: translateY(8px) scale(.95); pointer-events: none; + transition: opacity .2s cubic-bezier(.32,.72,0,1), transform .2s cubic-bezier(.32,.72,0,1); + z-index: 2147483000; } + .panel.open { opacity: 1; transform: none; pointer-events: auto; + transition-duration: .3s; } + .panel.open .composer { + animation: aui-footer-in .3s .1s cubic-bezier(.32,.72,0,1) backwards; } + @keyframes aui-footer-in { + 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; @@ -53276,9 +53344,23 @@ function styles(config) { .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; } - .messages { flex: 1; min-height: 0; overflow-y: auto; } + .messages { flex: 1; min-height: 0; overflow-y: auto; + scrollbar-width: thin; scrollbar-color: #d4d4d8 transparent; } + .messages::-webkit-scrollbar { width: 10px; } + .messages::-webkit-scrollbar-track { background: transparent; } + .messages::-webkit-scrollbar-thumb { background: #d4d4d8; border-radius: 999px; + border: 3px solid transparent; background-clip: content-box; } + .messages::-webkit-scrollbar-thumb:hover { background: #a1a1aa; background-clip: content-box; } .conversation-content { min-height: 100%; padding: 16px 18px; display: flex; flex-direction: column; gap: 14px; } + .welcome { flex: 1; display: flex; flex-direction: column; align-items: center; + justify-content: center; gap: 16px; text-align: center; padding: 32px 24px; + animation: aui-footer-in .3s .05s cubic-bezier(.32,.72,0,1) backwards; } + .welcome-avatar { width: 48px; height: 48px; border-radius: 50%; flex: 0 0 auto; + background: ${config.color}; color: #fff; + display: flex; align-items: center; justify-content: center; } + .welcome-avatar svg { width: 26px; height: 26px; } + .welcome-title { margin: 0; font-size: 17px; font-weight: 600; color: #18181b; line-height: 1.4; } .msg { font-size: 14.5px; line-height: 1.55; word-wrap: break-word; white-space: pre-wrap; } .msg.ai { color: #18181b; max-width: 100%; } .msg.ai.typing { color: #a1a1aa; letter-spacing: 1px; } @@ -53290,6 +53372,15 @@ 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; + 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; + width: 26px; height: 26px; border: 0; border-radius: 7px; background: transparent; + 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; } + @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; font-size: 12px; line-height: 1.3; } @@ -53308,12 +53399,25 @@ function styles(config) { .tool-badge.output-error { color: #991b1b; background: #fee2e2; } .tool-content { border-top: 1px solid #e4e4e7; padding: 8px 10px; } .tool-error { color: #991b1b; font-size: 12px; white-space: pre-wrap; } + .msg-error { margin-top: 2px; border: 1px solid #fecaca; background: #fef2f2; + 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; } .composer { display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 8px; padding: 12px 14px; border-top: 1px solid #f0f0f1; } - .input { flex: 1; resize: none; max-height: 120px; border: 1px solid #e4e4e7; - border-radius: 20px; padding: 10px 16px; font-size: 14.5px; color: #18181b; - font-family: inherit; background: #fff; outline: none; } - .input:focus { border-color: ${config.color}; } + .input-wrap { min-width: 0; display: flex; border-radius: 20px; background: #fff; + overflow: hidden; box-shadow: inset 0 0 0 1px #e4e4e7; transition: box-shadow .15s ease; } + .input-wrap:focus-within { box-shadow: inset 0 0 0 1px ${config.color}, + 0 0 0 3px color-mix(in srgb, ${config.color} 14%, transparent); } + .input { flex: 1; min-width: 0; resize: none; max-height: 140px; overflow-y: hidden; + border: 0; padding: 12px 16px; font-size: 15px; color: #18181b; + font-family: inherit; background: transparent; outline: none; + scrollbar-width: thin; scrollbar-color: #d4d4d8 transparent; } + .input::-webkit-scrollbar { width: 10px; } + .input::-webkit-scrollbar-track { background: transparent; margin: 8px 0; } + .input::-webkit-scrollbar-thumb { background: #d4d4d8; border-radius: 999px; + border: 3px solid transparent; background-clip: content-box; } + .input::-webkit-scrollbar-thumb:hover { background: #a1a1aa; background-clip: content-box; } .input::placeholder { color: #a1a1aa; } .send { flex: 0 0 auto; width: 34px; height: 34px; border-radius: 50%; border: 0; background: ${config.color}; color: #fff; cursor: pointer; @@ -53324,14 +53428,20 @@ 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; } + .powered { text-align: center; padding: 0 14px 10px; color: #a1a1aa; + font-size: 11px; letter-spacing: .01em; } @media (prefers-reduced-motion: reduce) { .launcher, + .launcher svg, .close, .send, .panel { transition: none; } .launcher:hover { transform: none; } + .panel.open .composer { animation: none; } + .welcome { animation: none; } + .msg-action svg { animation: none; } } @media (max-width: 480px) { .panel:not(.inline) { width: 100vw; height: 100dvh; max-height: 100dvh; @@ -53619,6 +53729,30 @@ lucide-react/dist/esm/createLucideIcon.mjs: * See the LICENSE file in the root directory of this source tree. *) +lucide-react/dist/esm/icons/bot.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/check.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/chevron-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/circle-check-big.mjs: (** * @license lucide-react v1.22.0 - ISC @@ -53643,6 +53777,14 @@ lucide-react/dist/esm/icons/circle.mjs: * See the LICENSE file in the root directory of this source tree. *) +lucide-react/dist/esm/icons/copy.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 @@ -53659,6 +53801,14 @@ lucide-react/dist/esm/icons/wrench.mjs: * See the LICENSE file in the root directory of this source tree. *) +lucide-react/dist/esm/icons/x.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/lucide-react.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 e590c5d3..c84172c3 100644 --- a/src/agent_manager/api/static/widget.test.mjs +++ b/src/agent_manager/api/static/widget.test.mjs @@ -255,7 +255,7 @@ assert.equal(customElements.defineCount, definesBefore, "defineAgentChat is idem assert.deepEqual(cfg, { endpoint: "https://widget.example", title: "Assistant", - color: "#2563eb", + color: "#18181b", greeting: "", position: "bottom-right", avatar: "", @@ -288,7 +288,7 @@ assert.equal(customElements.defineCount, definesBefore, "defineAgentChat is idem element.setAttribute("position", "top-left"); element.setAttribute("mode", "sideways"); const cfg = parseConfig(element, "https://widget.example"); - assert.equal(cfg.color, "#2563eb"); + assert.equal(cfg.color, "#18181b"); assert.equal(cfg.position, "bottom-right"); assert.equal(cfg.mode, "floating"); } diff --git a/src/agent_manager/api/static/widget/config/parseConfig.ts b/src/agent_manager/api/static/widget/config/parseConfig.ts index 689ae098..24425c22 100644 --- a/src/agent_manager/api/static/widget/config/parseConfig.ts +++ b/src/agent_manager/api/static/widget/config/parseConfig.ts @@ -2,7 +2,7 @@ import type { AgentChatConfig, AgentChatConfigInput, AgentChatMode, AgentChatPos export const DEFAULT_CONFIG: Omit = { title: "Assistant", - color: "#2563eb", + color: "#18181b", greeting: "", position: "bottom-right", avatar: "", diff --git a/src/agent_manager/api/static/widget/react/AgentChatApp.tsx b/src/agent_manager/api/static/widget/react/AgentChatApp.tsx index 4bd932c1..0428b3eb 100644 --- a/src/agent_manager/api/static/widget/react/AgentChatApp.tsx +++ b/src/agent_manager/api/static/widget/react/AgentChatApp.tsx @@ -1,13 +1,14 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { BotIcon, CheckIcon, ChevronDownIcon, CopyIcon, XIcon } from "lucide-react"; +import { type Ref, useCallback, useEffect, useRef, useState } from "react"; -import { AgentChatHttpError, type AgentChatClient } from "../api/AgentChatClient"; -import { - conversationStorageKey, - getStoredConversationId, - removeStoredConversationId, - setStoredConversationId, -} from "../storage/conversationStorage"; -import type { AgentChatAnswerDetail, AgentChatConfig, StreamEvent, ToolRecord } from "../types"; +import type { AgentChatClient } from "../api/AgentChatClient"; +import type { + AgentChatAnswerDetail, + AgentChatConfig, + ChatMessage, + MessageEntry, + ToolRecord, +} from "../types"; import { Conversation, ConversationContent, @@ -24,22 +25,20 @@ import { ToolOutput, type ToolState, } from "./shadcnAiElements"; +import { reduceStreamEvent } from "./streamReducer"; +import { useConversation } from "./useConversation"; -type MessageEntry = { - id: string; - role: "user" | "ai"; - text: string; - typing?: boolean; - route?: string[]; - tools?: ToolRecord[]; -}; +const DEFAULT_GREETING = "How can I help you today?"; +const GENERIC_ERROR = "Something went wrong. Please try again."; +const COPIED_RESET_MS = 2000; -let nextMessageCounter = 0; +const newId = () => crypto.randomUUID(); -function nextMessageId(role: MessageEntry["role"]): string { - nextMessageCounter += 1; - return `${role}-${Date.now()}-${nextMessageCounter}`; -} +const toEntry = (message: ChatMessage): MessageEntry => ({ + id: newId(), + role: message.role === "user" ? "user" : "ai", + text: message.content, +}); export interface AgentChatAppProps { client: AgentChatClient; @@ -51,6 +50,7 @@ export interface AgentChatAppProps { export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: AgentChatAppProps) { const inline = config.mode === "inline"; + const conversation = useConversation(client, config.endpoint); const [open, setOpen] = useState(inline); const [loaded, setLoaded] = useState(false); const [sending, setSending] = useState(false); @@ -61,32 +61,12 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age const loadHistory = useCallback(async () => { if (loaded) return; setLoaded(true); - const existing = localStorage.getItem(conversationStorageKey(config.endpoint)); - if (!existing) { - if (config.greeting) setEntries((prev) => [...prev, { id: nextMessageId("ai"), role: "ai", text: config.greeting }]); - return; - } - try { - const history = await client.getMessages(existing); - setEntries( - history.map((message) => ({ - id: nextMessageId(message.role === "user" ? "user" : "ai"), - role: message.role === "user" ? "user" : "ai", - text: message.content, - })), - ); - } catch (error) { - if (error instanceof AgentChatHttpError && error.status === 404) { - removeStoredConversationId(config.endpoint); - } - // Offline/stale history on load is non-fatal. - } - }, [client, config.endpoint, config.greeting, loaded]); + const history = await conversation.loadHistory(); + if (history.length) setEntries(history.map(toEntry)); + }, [conversation, loaded]); useEffect(() => { - if (inline) { - void loadHistory(); - } + if (inline) void loadHistory(); }, [inline, loadHistory]); useEffect(() => { @@ -105,95 +85,53 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age launcherRef.current?.focus({ preventScroll: true }); }, [inline]); - const conversationId = useCallback(async () => { - let id = getStoredConversationId(config.endpoint); - if (!id) { - id = await client.createConversation(); - setStoredConversationId(config.endpoint, id); - } - return id; - }, [client, config.endpoint]); - - const sendToAgent = useCallback( - async (text: string) => { - const id = await conversationId(); - try { - return await client.sendMessage(id, text); - } catch (error) { - if (!(error instanceof AgentChatHttpError) || error.status !== 404) throw error; - removeStoredConversationId(config.endpoint); - const freshId = await client.createConversation(); - setStoredConversationId(config.endpoint, freshId); - return await client.sendMessage(freshId, text); - } - }, - [client, config.endpoint, conversationId], - ); + const replaceEntry = useCallback((id: string, entry: MessageEntry) => { + setEntries((prev) => prev.map((current) => (current.id === id ? entry : current))); + }, []); - const streamFromAgent = useCallback( - async function* (text: string): AsyncGenerator { - const id = await conversationId(); + const sendWithoutStreaming = useCallback( + async (text: string, entryId: string) => { try { - yield* client.streamMessage(id, text); - } catch (error) { - if (!(error instanceof AgentChatHttpError) || error.status !== 404) throw error; - removeStoredConversationId(config.endpoint); - const freshId = await client.createConversation(); - setStoredConversationId(config.endpoint, freshId); - yield* client.streamMessage(freshId, text); + const answer = await conversation.send(text); + replaceEntry(entryId, { + id: entryId, + role: "ai", + text: answer.answer, + route: answer.visited, + tools: answer.used_tools, + }); + onAnswer({ visited: answer.visited ?? [], used_tools: answer.used_tools ?? [] }); + } catch { + replaceEntry(entryId, { id: entryId, role: "ai", text: GENERIC_ERROR, error: true }); } }, - [client, config.endpoint, conversationId], + [conversation, onAnswer, replaceEntry], ); - const patchEntry = useCallback((id: string, update: (entry: MessageEntry) => MessageEntry) => { - setEntries((prev) => prev.map((entry) => (entry.id === id ? update(entry) : entry))); - }, []); - const submit = useCallback( async (text: string) => { - const assistantId = nextMessageId("ai"); - setEntries((prev) => [ - ...prev, - { id: nextMessageId("user"), role: "user", text }, - { id: assistantId, role: "ai", text: "", typing: true }, - ]); + const pending: MessageEntry = { id: newId(), role: "ai", text: "", typing: true }; + setEntries((prev) => [...prev, { id: newId(), role: "user", text }, pending]); setSending(true); try { - let finalDetail: AgentChatAnswerDetail = { visited: [], used_tools: [] }; - for await (const event of streamFromAgent(text)) { - finalDetail = applyStreamEvent(assistantId, event, patchEntry, finalDetail); - } - patchEntry(assistantId, (entry) => ({ ...entry, typing: false })); - onAnswer(finalDetail); - } catch (error) { - try { - const data = await sendToAgent(text); - patchEntry(assistantId, () => ({ - id: assistantId, - role: "ai", - text: data.answer, - route: data.visited, - tools: data.used_tools, - })); - onAnswer({ visited: data.visited ?? [], used_tools: data.used_tools ?? [] }); - } catch { - patchEntry(assistantId, () => ({ - id: assistantId, - role: "ai", - text: - error instanceof Error && error.message - ? `Something went wrong. Please try again.` - : "Something went wrong. Please try again.", - })); + let entry = pending; + for await (const event of conversation.stream(text)) { + entry = reduceStreamEvent(entry, event); + replaceEntry(pending.id, entry); } + replaceEntry(pending.id, { ...entry, typing: false }); + onAnswer({ visited: entry.route ?? [], used_tools: entry.tools ?? [] }); + } catch { + await sendWithoutStreaming(text, pending.id); } finally { setSending(false); } }, - [onAnswer, patchEntry, sendToAgent, streamFromAgent], + [conversation, onAnswer, replaceEntry, sendWithoutStreaming], ); + const toggle = () => void (open ? closeChat() : openChat()); + return (
event.stopPropagation()} > {!inline ? ( - + ) : null}
- + {config.title} {!inline ? ( ) : null}
@@ -251,23 +170,11 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age
- {entries.map((entry, index) => ( - - {entry.typing ? ( - "..." - ) : ( - <> - {entry.role === "ai" ? : null} - - {entry.role === "ai" ? {entry.text} : entry.text} - - - )} - + {entries.length === 0 ? ( + + ) : null} + {entries.map((entry) => ( + ))} @@ -276,9 +183,7 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age aria-label="Message" disabled={false} inputRef={inputRef} - onSubmit={() => { - inputRef.current?.form?.requestSubmit(); - }} + onSubmit={() => inputRef.current?.form?.requestSubmit()} placeholder="Message..." /> @@ -286,82 +191,116 @@ export function AgentChatApp({ client, config, onAnswer, panelId, titleId }: Age +
Powered by Extra
); } -function applyStreamEvent( - assistantId: string, - event: StreamEvent, - patchEntry: (id: string, update: (entry: MessageEntry) => MessageEntry) => void, - currentDetail: AgentChatAnswerDetail, -): AgentChatAnswerDetail { - if (event.type === "answer_delta") { - patchEntry(assistantId, (entry) => ({ - ...entry, - text: entry.text + (event.content ?? ""), - typing: false, - })); - return currentDetail; - } - if (event.type === "route") { - const route = event.route ?? currentDetail.visited; - patchEntry(assistantId, (entry) => ({ ...entry, route, typing: false })); - return { ...currentDetail, visited: route }; - } - if (event.type === "tool_started" || event.type === "tool_succeeded" || event.type === "tool_failed") { - const tool = streamToolRecord(event); - patchEntry(assistantId, (entry) => ({ - ...entry, - tools: upsertTool(entry.tools ?? [], tool), - typing: false, - })); - return { ...currentDetail, used_tools: upsertTool(currentDetail.used_tools, tool) }; +function Launcher({ + open, + panelId, + buttonRef, + onToggle, +}: { + open: boolean; + panelId: string; + buttonRef: Ref; + onToggle: () => void; +}) { + return ( + + ); +} + +function ChatMessage({ entry }: { entry: MessageEntry }) { + const from = entry.role === "user" ? "user" : "assistant"; + + if (entry.typing) { + return ( + + ... + + ); } - if (event.type === "final") { - const visited = event.route ?? currentDetail.visited; - const usedTools = event.used_tools ?? currentDetail.used_tools; - patchEntry(assistantId, (entry) => ({ - ...entry, - text: event.content ?? entry.text, - route: visited, - tools: usedTools, - typing: false, - })); - return { visited, used_tools: usedTools }; + + if (entry.error) { + return ( + +
+ {entry.text} +
+
+ ); } - if (event.type === "error") { - throw new Error(event.error || "stream failed"); + + if (entry.role === "user") { + return ( + + {entry.text} + + ); } - return currentDetail; + + return ( + + + + {entry.text} + + {entry.text.trim() ? : null} + + ); } -function streamToolRecord(event: StreamEvent): ToolRecord { - return { - name: event.tool_name ?? "tool", - provider: event.provider ?? "runtime", - status: - event.type === "tool_started" - ? "started" - : event.type === "tool_succeeded" - ? "succeeded" - : "failed", - server_id: event.server_id, - error: event.error, - }; +function MessageActions({ text }: { text: string }) { + return ( +
+ +
+ ); } -function upsertTool(tools: ToolRecord[], next: ToolRecord): ToolRecord[] { - const index = tools.findIndex((tool) => tool.name === next.name && tool.provider === next.provider); - if (index === -1) return [...tools, next]; - const copy = tools.slice(); - copy[index] = { ...copy[index], ...next }; - return copy; +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + + const copy = useCallback(() => { + void navigator.clipboard + ?.writeText(text) + .then(() => { + setCopied(true); + setTimeout(() => setCopied(false), COPIED_RESET_MS); + }) + .catch(() => {}); + }, [text]); + + const Icon = copied ? CheckIcon : CopyIcon; + return ( + + ); } -function ToolMessage({ route, tools = [] }: { route?: string[]; tools?: ToolRecord[] }) { +function AgentActivity({ route, tools = [] }: { route?: string[]; tools?: ToolRecord[] }) { if (!route?.length && tools.length === 0) return null; return (
@@ -372,7 +311,10 @@ function ToolMessage({ route, tools = [] }: { route?: string[]; tools?: ToolReco ) : null} {tools.map((tool, index) => ( - + {tool.error ? ( @@ -384,31 +326,24 @@ function ToolMessage({ route, tools = [] }: { route?: string[]; tools?: ToolReco ); } -function toolState(status: string): ToolState { - if (status === "failed") return "output-error"; - if (status === "succeeded") return "output-available"; - return "input-available"; -} - -function ChatIcon() { +function Welcome({ title }: { title: string }) { return ( - +
+ + + +

{title}

+
); } -function CloseIcon() { - return ( - - ); +function avatarStyle(avatar: string) { + if (!avatar) return undefined; + return { backgroundImage: `url("${avatar.replace(/"/g, "%22")}")` }; +} + +function toToolState(status: string): ToolState { + if (status === "failed") return "output-error"; + if (status === "succeeded") return "output-available"; + return "input-available"; } diff --git a/src/agent_manager/api/static/widget/react/shadcnAiElements.tsx b/src/agent_manager/api/static/widget/react/shadcnAiElements.tsx index 44a0f963..bb62192e 100644 --- a/src/agent_manager/api/static/widget/react/shadcnAiElements.tsx +++ b/src/agent_manager/api/static/widget/react/shadcnAiElements.tsx @@ -89,6 +89,7 @@ export function PromptInput({ if (!text) return; input!.value = ""; input!.style.height = "auto"; + input!.style.overflowY = "hidden"; onSubmit({ text }); } @@ -122,20 +123,24 @@ export function PromptInputTextarea({ } return ( -