diff --git a/bun.lock b/bun.lock index 9d21e2e..fa9a891 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "tjwater-agent", "dependencies": { "@opencode-ai/sdk": "^1.16.2", + "ai": "7.0.8", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^4.21.2", @@ -23,10 +24,18 @@ }, }, "packages": { + "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.6", "", { "dependencies": { "@ai-sdk/provider": "4.0.1", "@ai-sdk/provider-utils": "5.0.2", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8GjssUxXeTd8tst2fYXNOFbtNNZFv3sG7aq7wKnQYrU2eB3JFR7np6o4F3Snp/fy/rJZSeH28LvjSWq5wkdL8Q=="], + + "@ai-sdk/provider": ["@ai-sdk/provider@4.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6p3C/vGqVIjcptBu1DnVd/BZJ2wWmV9TUv9192vT6ZvT9KNED8EwRTqyqFpoQZKgSbMDSvBSq3dqR524Nt/Crw=="], + + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.2", "", { "dependencies": { "@ai-sdk/provider": "4.0.1", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EcmdjJb7yggsZPCbS3MFBpvAUnKaPW+QvanU5GzF00XCq0bqqAmvJ3MN19ejlmOETbW8sJNiq6qam48wTcbUNw=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.16.2", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Z/xZ7q79dYeE0afqIk/yFEcRNGEQFcE+H8ssYivUiy+xGZ1mGwT72jpaQZKBwPn3JH4sRCu4KA2lcktBQfcOjg=="], "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], @@ -49,8 +58,14 @@ "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], + "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], + + "@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="], + "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], + "ai": ["ai@7.0.8", "", { "dependencies": { "@ai-sdk/gateway": "4.0.6", "@ai-sdk/provider": "4.0.1", "@ai-sdk/provider-utils": "5.0.2" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-vTEKl6fDBZ2IxBXTRaZOajf9W2Ev57Ju8iKtUvqlmDk8Z9BrEP4c22SWJsg1RcWHSFmJMSBa/s5dlUBHUq3YwA=="], + "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], @@ -107,6 +122,8 @@ "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + "express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="], "fast-copy": ["fast-copy@4.0.3", "", {}, "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw=="], @@ -145,6 +162,8 @@ "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], diff --git a/docs/agent-ui-protocol.md b/docs/agent-ui-protocol.md index 72064c8..21d8d0d 100644 --- a/docs/agent-ui-protocol.md +++ b/docs/agent-ui-protocol.md @@ -21,6 +21,16 @@ data: { ## Envelope Types +Current `agent-ui@1` support is intentionally limited to three trusted shapes: + +- `registered_component` for frontend-registered business panels. +- `chart` for the `echarts-safe-subset` chart grammar. +- `map_action` for the WebGIS map adapter. + +The Agent must not return executable JS, JSX, HTML, CSS, or arbitrary component +source. The frontend renders only allowlisted components, chart specs, and map +actions. + ```ts type UISurface = "chat_inline" | "side_panel" | "canvas" | "map_overlay"; @@ -62,6 +72,11 @@ GET /api/v1/agent/chat/ui-registry The registry returns the allowed chart grammar, registered components, and map actions. Unknown components/actions should be rejected by the frontend. +`map_action` is a WebGIS-specific extension, not a general command channel. It +is valid only on `map_overlay`; every action and parameter set must be validated +by the frontend before it can affect MapLibre state. Invalid map actions should +fall back to `fallbackText`. + Initial mappings: - `show_chart` -> `chart` diff --git a/package.json b/package.json index 3fea22d..fe21ffa 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@opencode-ai/sdk": "^1.16.2", + "ai": "7.0.8", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^4.21.2", diff --git a/src/routes/chat.ts b/src/routes/chat.ts index 24573a9..9ea4c63 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -1,4 +1,9 @@ import { Router } from "express"; +import { + UI_MESSAGE_STREAM_HEADERS, + type UIMessage, + type UIMessageChunk, +} from "ai"; import { z } from "zod"; import { getLocalAgentContext } from "../context/localContext.js"; @@ -24,6 +29,11 @@ import { type SessionRecord } from "../sessions/metadataStore.js"; import { uiRegistryResponse } from "../uiEnvelope/registry.js"; import type { UIEnvelopePayload } from "../uiEnvelope/types.js"; import { toActorKey, toProjectKey } from "../utils/fileStore.js"; +import { + createPressureTrendDemoEnvelopePayload, + createPressureTrendDemoToolCall, + isPressureTrendDemoPrompt, +} from "./pressureTrendDemo.js"; import { buildPromptWithLearningContext, extractLatestFrontendTurn, @@ -61,7 +71,9 @@ import { } from "./chatUiState.js"; const payloadSchema = z.object({ - message: z.string().min(1).max(10000), + id: z.string().max(128).optional(), + messages: z.array(z.unknown()).max(200).optional(), + message: z.string().min(1).max(10000).optional(), session_id: z.string().max(128).optional(), model: z.string().refine(isSupportedModel, { message: "unsupported model", @@ -114,6 +126,73 @@ const runtimeHasConversation = async ( ); }; +const isUiMessage = (value: unknown): value is UIMessage => + isObjectRecord(value) && + typeof value.id === "string" && + (value.role === "system" || value.role === "user" || value.role === "assistant") && + Array.isArray(value.parts); + +const readUiMessageText = (message: UIMessage | undefined) => + message?.parts + .flatMap((part) => + isObjectRecord(part) && part.type === "text" && typeof part.text === "string" + ? [part.text] + : [], + ) + .join("") + .trim() ?? ""; + +const readLatestUserText = (messages: UIMessage[]) => { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message.role !== "user") { + continue; + } + const text = readUiMessageText(message); + if (text) { + return text; + } + } + return ""; +}; + +const toUiMessages = (messages: unknown[] | undefined) => + Array.isArray(messages) ? messages.filter(isUiMessage) : []; + +const toLegacyMessages = (messages: UIMessage[]) => + messages.flatMap((message) => { + const content = readUiMessageText(message); + if (!content || (message.role !== "user" && message.role !== "assistant")) { + return []; + } + return [ + { + id: message.id, + role: message.role, + content, + }, + ]; + }); + +const createFallbackUserUiMessage = (content: string): UIMessage => ({ + id: `user-${Date.now().toString(36)}`, + role: "user", + parts: [{ type: "text", text: content }], +}); + +const pipeUiChunk = ( + res: { + write: (chunk: string) => void; + writableEnded?: boolean; + destroyed?: boolean; + }, + chunk: UIMessageChunk, +) => { + if (!res.writableEnded && !res.destroyed) { + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + } +}; + export const buildChatRouter = ( sessionBridge: ChatSessionBridge, runtime: OpencodeRuntimeAdapter, @@ -153,7 +232,18 @@ export const buildChatRouter = ( const actorKey = toActorKey(userId); const projectKey = toProjectKey(projectId); const requestedSessionId = parsed.data.session_id?.trim(); - const sessionId = requestedSessionId || (await runtime.createSession()).id; + let sessionId = requestedSessionId; + if (!sessionId) { + try { + sessionId = (await runtime.createSession()).id; + } catch (error) { + sessionId = sessionBridge.createClientSessionId(); + logger.warn( + { err: error, sessionId }, + "runtime unavailable while creating chat session; using local degraded session", + ); + } + } const { record, created } = await sessionMetadataStore.ensure({ actorKey, @@ -518,7 +608,21 @@ export const buildChatRouter = ( const userId = authContext.userId; const actorKey = toActorKey(userId); const projectKey = toProjectKey(projectId); - const requestedSessionId = parsed.data.session_id?.trim(); + const requestUiMessages = toUiMessages(parsed.data.messages); + const promptText = + parsed.data.message?.trim() || readLatestUserText(requestUiMessages); + if (!promptText) { + res.status(400).json({ + message: "message or messages with latest user text is required", + }); + return; + } + const requestedSessionId = + parsed.data.session_id?.trim() || + parsed.data.id?.trim() || + (isPressureTrendDemoPrompt(promptText) + ? sessionBridge.createClientSessionId() + : undefined); const existingSessionRecord = requestedSessionId ? await sessionMetadataStore.get( { actorKey, projectId, projectKey, userId }, @@ -559,8 +663,16 @@ export const buildChatRouter = ( const initialSessionState = await sessionUiStateStore.read( toSessionUiStateContext(activeSessionRecord), ); - const persistedMessages = initialSessionState?.messages ?? []; - const baseMessages = persistedMessages; + const persistedUiMessages = toUiMessages(initialSessionState?.messages); + const requestMessages = + requestUiMessages.length > 0 + ? requestUiMessages + : [ + ...persistedUiMessages, + createFallbackUserUiMessage(promptText), + ]; + let latestUiMessages = requestMessages; + const baseMessages = toLegacyMessages(requestMessages); if (activeRuns.get(activeSessionRecord.sessionId)?.status === "running") { res.status(409).json({ message: "session is already streaming", @@ -584,9 +696,9 @@ export const buildChatRouter = ( ); res.status(200); - res.setHeader("Content-Type", "text/event-stream; charset=utf-8"); - res.setHeader("Cache-Control", "no-cache"); - res.setHeader("Connection", "keep-alive"); + for (const [header, value] of Object.entries(UI_MESSAGE_STREAM_HEADERS)) { + res.setHeader(header, value); + } res.setHeader("X-Accel-Buffering", "no"); res.flushHeaders?.(); @@ -596,7 +708,7 @@ export const buildChatRouter = ( sessionBridge.registerAbortController(clientSessionId, abortController); const initialMessages = createInitialStreamingMessages( baseMessages, - parsed.data.message, + promptText, ); const activeRun: ActiveRun = { clientSessionId, @@ -610,16 +722,110 @@ export const buildChatRouter = ( activeRuns.set(clientSessionId, activeRun); lastRunStatuses.set(clientSessionId, "running"); const sessionUiStateContext = toSessionUiStateContext(activeSessionRecord); + const assistantUiMessageId = `assistant-${Date.now().toString(36)}`; + const textPartId = "answer"; + const assistantParts: Record[] = []; + let responseText = ""; + let textStarted = false; + let textEnded = false; + let uiFinished = false; + const syncLatestUiMessages = () => { + latestUiMessages = [ + ...requestMessages, + { + id: assistantUiMessageId, + role: "assistant", + parts: assistantParts.map((part) => ({ ...part })), + } as UIMessage, + ]; + }; + const writeUiChunk = (chunk: UIMessageChunk) => { + pipeUiChunk(res, chunk); + }; + const writeTextDelta = (delta: string) => { + if (!delta || uiFinished) { + return; + } + if (!textStarted) { + textStarted = true; + assistantParts.push({ + type: "text", + text: "", + state: "streaming", + }); + writeUiChunk({ type: "text-start", id: textPartId }); + } + responseText += delta; + const textPart = assistantParts.find( + (part) => part.type === "text", + ); + if (textPart) { + textPart.text = responseText; + textPart.state = "streaming"; + } + writeUiChunk({ type: "text-delta", id: textPartId, delta }); + syncLatestUiMessages(); + }; + const writeDataPart = (event: string, data: Record) => { + if (uiFinished) { + return; + } + const type = `data-${event}`; + const id = + typeof data.id === "string" + ? data.id + : typeof data.request_id === "string" + ? data.request_id + : typeof data.envelope_id === "string" + ? data.envelope_id + : undefined; + const existingIndex = + id === undefined + ? -1 + : assistantParts.findIndex( + (part) => part.type === type && part.id === id, + ); + const part = id ? { type, id, data } : { type, data }; + if (existingIndex >= 0) { + assistantParts[existingIndex] = part; + } else { + assistantParts.push(part); + } + writeUiChunk(part as UIMessageChunk); + syncLatestUiMessages(); + }; + const finishUiMessage = (finishReason: "stop" | "error" = "stop") => { + if (uiFinished) { + return; + } + if (!textStarted && assistantParts.length === 0) { + writeTextDelta("Agent 已完成处理。"); + } + if (textStarted && !textEnded) { + textEnded = true; + const textPart = assistantParts.find( + (part) => part.type === "text", + ); + if (textPart) { + textPart.state = "done"; + } + writeUiChunk({ type: "text-end", id: textPartId }); + } + syncLatestUiMessages(); + writeUiChunk({ type: "finish", finishReason }); + uiFinished = true; + }; + writeUiChunk({ type: "start", messageId: assistantUiMessageId }); let persistQueue = sessionUiStateStore.write(sessionUiStateContext, { sessionId: activeSessionRecord.sessionId, isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false, - messages: initialMessages, + messages: latestUiMessages, }); const queueSessionUiStatePersist = () => { const snapshot = { sessionId: activeSessionRecord.sessionId, isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false, - messages: activeRun.messages, + messages: latestUiMessages, }; persistQueue = persistQueue .catch((error) => { @@ -633,9 +839,29 @@ export const buildChatRouter = ( }; const primarySubscriber: StreamSubscriber = { write: (event, data) => { - if (!streamClosed && !res.writableEnded && !res.destroyed) { - res.write(toSse(event, data)); + if (streamClosed || res.writableEnded || res.destroyed) { + return; } + if (event === "token") { + writeTextDelta(typeof data.content === "string" ? data.content : ""); + return; + } + if (event === "done") { + finishUiMessage("stop"); + return; + } + if (event === "error") { + writeUiChunk({ + type: "error", + errorText: + typeof data.message === "string" + ? data.message + : "Agent stream failed", + }); + finishUiMessage("error"); + return; + } + writeDataPart(event, data); }, close: () => { if (!res.writableEnded && !res.destroyed) { @@ -793,6 +1019,39 @@ export const buildChatRouter = ( }; try { + if (isPressureTrendDemoPrompt(promptText)) { + const toolCall = createPressureTrendDemoToolCall(); + publish("progress", { + session_id: clientSessionId, + id: "pressure-trend-demo", + phase: "tool", + status: "running", + title: "生成压力趋势图", + detail: "已命中受控演示用例,正在通过 show_chart 生成可信 UIEnvelope。", + started_at: Date.now(), + elapsed_ms: 0, + }); + publish("tool_call", { + session_id: clientSessionId, + tool: toolCall.tool, + params: toolCall.params, + reason: toolCall.reason, + }); + publish( + "ui_envelope", + createPressureTrendDemoEnvelopePayload(clientSessionId, toolCall), + ); + publish("token", { + session_id: clientSessionId, + content: "已生成最近压力趋势图,并通过可信 UIEnvelope 发送到前端。", + }); + publish("done", { + session_id: clientSessionId, + duration_ms: 0, + }); + return; + } + const preparedMessage = await buildPromptWithLearningContext( memoryStore, requestContext.actorKey, @@ -800,7 +1059,7 @@ export const buildChatRouter = ( { recentTurns, persistedMessages: baseMessages, - message: parsed.data.message, + message: promptText, restoreConversation: shouldRestoreConversation, }, ); @@ -847,7 +1106,7 @@ export const buildChatRouter = ( sessionTitle = await generateSessionTitle(runtime, { sessionId: binding.sessionId, latestAssistantMessage: assistantText, - latestUserMessage: parsed.data.message, + latestUserMessage: promptText, fallbackTitle: existingSessionTitle, }); } @@ -895,10 +1154,20 @@ export const buildChatRouter = ( progress: completeBackendProgress(message.progress), todos: cancelBackendTodos(message.todos), })); + if (!uiFinished && !streamClosed && !res.writableEnded && !res.destroyed) { + writeUiChunk({ + type: "error", + errorText: "请求已中断", + }); + finishUiMessage("error"); + } void queueSessionUiStatePersist().catch((error) => { logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist aborted chat stream state"); }); } + if (!uiFinished && !streamClosed && !res.writableEnded && !res.destroyed) { + finishUiMessage(activeRun.status === "error" ? "error" : "stop"); + } await persistQueue.catch((error) => { logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state"); }); @@ -934,10 +1203,14 @@ export const buildChatRouter = ( logger.error({ err: error }, "chat stream failed"); if (res.headersSent) { if (!res.writableEnded && !res.destroyed) { - res.write(toSse("error", { - message: "chat stream failed", - detail, - })); + pipeUiChunk(res, { + type: "error", + errorText: detail, + }); + pipeUiChunk(res, { + type: "finish", + finishReason: "error", + }); res.end(); } return; diff --git a/src/routes/pressureTrendDemo.ts b/src/routes/pressureTrendDemo.ts new file mode 100644 index 0000000..346b820 --- /dev/null +++ b/src/routes/pressureTrendDemo.ts @@ -0,0 +1,66 @@ +import { createEnvelopeId } from "../uiEnvelope/ids.js"; +import { toUiEnvelopeFromToolCall } from "../uiEnvelope/fromToolCall.js"; +import type { UIEnvelopePayload } from "../uiEnvelope/types.js"; + +export type PressureTrendDemoToolCall = { + tool: "show_chart"; + params: { + reason: string; + title: string; + chart_type: "line"; + x_axis_name: string; + y_axis_name: string; + x_data: string[]; + series: Array<{ + name: string; + type: "line"; + data: number[]; + }>; + }; + reason: string; +}; + +export const isPressureTrendDemoPrompt = (message: string) => + message.replace(/\s+/g, "").includes("展示最近压力趋势"); + +export const createPressureTrendDemoToolCall = (): PressureTrendDemoToolCall => ({ + tool: "show_chart", + reason: "展示最近 6 小时压力趋势。", + params: { + reason: "展示最近 6 小时压力趋势。", + title: "最近压力趋势", + chart_type: "line", + x_axis_name: "时间", + y_axis_name: "压力 MPa", + x_data: ["10:00", "11:00", "12:00", "13:00", "14:00", "15:00"], + series: [ + { + name: "东部主干压力", + type: "line", + data: [0.42, 0.41, 0.39, 0.36, 0.35, 0.37], + }, + { + name: "正常基线", + type: "line", + data: [0.43, 0.43, 0.42, 0.42, 0.41, 0.41], + }, + ], + }, +}); + +export const createPressureTrendDemoEnvelopePayload = ( + sessionId: string, + toolCall: PressureTrendDemoToolCall = createPressureTrendDemoToolCall(), +): UIEnvelopePayload => { + const envelope = toUiEnvelopeFromToolCall(toolCall); + if (!envelope) { + throw new Error("pressure trend demo tool call did not produce a UIEnvelope"); + } + + return { + session_id: sessionId, + envelope_id: createEnvelopeId(toolCall.tool), + created_at: Date.now(), + envelope, + }; +}; diff --git a/tests/routes/pressureTrendDemo.test.ts b/tests/routes/pressureTrendDemo.test.ts new file mode 100644 index 0000000..3c6a751 --- /dev/null +++ b/tests/routes/pressureTrendDemo.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "bun:test"; + +import { + createPressureTrendDemoEnvelopePayload, + createPressureTrendDemoToolCall, + isPressureTrendDemoPrompt, +} from "../../src/routes/pressureTrendDemo.js"; + +describe("pressure trend demo", () => { + it("matches the controlled demo prompt", () => { + expect(isPressureTrendDemoPrompt("展示最近压力趋势")).toBe(true); + expect(isPressureTrendDemoPrompt("请 展示 最近 压力 趋势")).toBe(true); + expect(isPressureTrendDemoPrompt("展示最近流量趋势")).toBe(false); + }); + + it("creates a show_chart tool call that maps to a chart UIEnvelope", () => { + const toolCall = createPressureTrendDemoToolCall(); + const payload = createPressureTrendDemoEnvelopePayload("client-session-1", toolCall); + + expect(toolCall.tool).toBe("show_chart"); + expect(payload).toMatchObject({ + session_id: "client-session-1", + envelope: { + kind: "chart", + schemaVersion: "agent-ui@1", + grammar: "echarts-safe-subset", + surface: "chat_inline", + spec: { + title: "最近压力趋势", + chart_type: "line", + }, + data: { + x_data: ["10:00", "11:00", "12:00", "13:00", "14:00", "15:00"], + }, + }, + }); + }); +});