feat(agent): add pressure trend demo

This commit is contained in:
2026-06-30 20:01:34 +08:00
parent 123f79ce4d
commit ca075d718e
6 changed files with 431 additions and 19 deletions
+19
View File
@@ -6,6 +6,7 @@
"name": "tjwater-agent", "name": "tjwater-agent",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "^1.16.2", "@opencode-ai/sdk": "^1.16.2",
"ai": "7.0.8",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.21.2", "express": "^4.21.2",
@@ -23,10 +24,18 @@
}, },
}, },
"packages": { "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=="], "@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=="], "@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/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=="], "@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=="], "@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=="], "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=="], "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], "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=="], "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=="], "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=="], "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=="], "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=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
+15
View File
@@ -21,6 +21,16 @@ data: {
## Envelope Types ## 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 ```ts
type UISurface = "chat_inline" | "side_panel" | "canvas" | "map_overlay"; 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 The registry returns the allowed chart grammar, registered components, and map
actions. Unknown components/actions should be rejected by the frontend. 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: Initial mappings:
- `show_chart` -> `chart` - `show_chart` -> `chart`
+1
View File
@@ -16,6 +16,7 @@
}, },
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "^1.16.2", "@opencode-ai/sdk": "^1.16.2",
"ai": "7.0.8",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.21.2", "express": "^4.21.2",
+292 -19
View File
@@ -1,4 +1,9 @@
import { Router } from "express"; import { Router } from "express";
import {
UI_MESSAGE_STREAM_HEADERS,
type UIMessage,
type UIMessageChunk,
} from "ai";
import { z } from "zod"; import { z } from "zod";
import { getLocalAgentContext } from "../context/localContext.js"; import { getLocalAgentContext } from "../context/localContext.js";
@@ -24,6 +29,11 @@ import { type SessionRecord } from "../sessions/metadataStore.js";
import { uiRegistryResponse } from "../uiEnvelope/registry.js"; import { uiRegistryResponse } from "../uiEnvelope/registry.js";
import type { UIEnvelopePayload } from "../uiEnvelope/types.js"; import type { UIEnvelopePayload } from "../uiEnvelope/types.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js"; import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import {
createPressureTrendDemoEnvelopePayload,
createPressureTrendDemoToolCall,
isPressureTrendDemoPrompt,
} from "./pressureTrendDemo.js";
import { import {
buildPromptWithLearningContext, buildPromptWithLearningContext,
extractLatestFrontendTurn, extractLatestFrontendTurn,
@@ -61,7 +71,9 @@ import {
} from "./chatUiState.js"; } from "./chatUiState.js";
const payloadSchema = z.object({ 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(), session_id: z.string().max(128).optional(),
model: z.string().refine(isSupportedModel, { model: z.string().refine(isSupportedModel, {
message: "unsupported model", 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 = ( export const buildChatRouter = (
sessionBridge: ChatSessionBridge, sessionBridge: ChatSessionBridge,
runtime: OpencodeRuntimeAdapter, runtime: OpencodeRuntimeAdapter,
@@ -153,7 +232,18 @@ export const buildChatRouter = (
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); const projectKey = toProjectKey(projectId);
const requestedSessionId = parsed.data.session_id?.trim(); 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({ const { record, created } = await sessionMetadataStore.ensure({
actorKey, actorKey,
@@ -518,7 +608,21 @@ export const buildChatRouter = (
const userId = authContext.userId; const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); 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 const existingSessionRecord = requestedSessionId
? await sessionMetadataStore.get( ? await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId }, { actorKey, projectId, projectKey, userId },
@@ -559,8 +663,16 @@ export const buildChatRouter = (
const initialSessionState = await sessionUiStateStore.read( const initialSessionState = await sessionUiStateStore.read(
toSessionUiStateContext(activeSessionRecord), toSessionUiStateContext(activeSessionRecord),
); );
const persistedMessages = initialSessionState?.messages ?? []; const persistedUiMessages = toUiMessages(initialSessionState?.messages);
const baseMessages = persistedMessages; const requestMessages =
requestUiMessages.length > 0
? requestUiMessages
: [
...persistedUiMessages,
createFallbackUserUiMessage(promptText),
];
let latestUiMessages = requestMessages;
const baseMessages = toLegacyMessages(requestMessages);
if (activeRuns.get(activeSessionRecord.sessionId)?.status === "running") { if (activeRuns.get(activeSessionRecord.sessionId)?.status === "running") {
res.status(409).json({ res.status(409).json({
message: "session is already streaming", message: "session is already streaming",
@@ -584,9 +696,9 @@ export const buildChatRouter = (
); );
res.status(200); res.status(200);
res.setHeader("Content-Type", "text/event-stream; charset=utf-8"); for (const [header, value] of Object.entries(UI_MESSAGE_STREAM_HEADERS)) {
res.setHeader("Cache-Control", "no-cache"); res.setHeader(header, value);
res.setHeader("Connection", "keep-alive"); }
res.setHeader("X-Accel-Buffering", "no"); res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders?.(); res.flushHeaders?.();
@@ -596,7 +708,7 @@ export const buildChatRouter = (
sessionBridge.registerAbortController(clientSessionId, abortController); sessionBridge.registerAbortController(clientSessionId, abortController);
const initialMessages = createInitialStreamingMessages( const initialMessages = createInitialStreamingMessages(
baseMessages, baseMessages,
parsed.data.message, promptText,
); );
const activeRun: ActiveRun = { const activeRun: ActiveRun = {
clientSessionId, clientSessionId,
@@ -610,16 +722,110 @@ export const buildChatRouter = (
activeRuns.set(clientSessionId, activeRun); activeRuns.set(clientSessionId, activeRun);
lastRunStatuses.set(clientSessionId, "running"); lastRunStatuses.set(clientSessionId, "running");
const sessionUiStateContext = toSessionUiStateContext(activeSessionRecord); const sessionUiStateContext = toSessionUiStateContext(activeSessionRecord);
const assistantUiMessageId = `assistant-${Date.now().toString(36)}`;
const textPartId = "answer";
const assistantParts: Record<string, unknown>[] = [];
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<string, unknown>) => {
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, { let persistQueue = sessionUiStateStore.write(sessionUiStateContext, {
sessionId: activeSessionRecord.sessionId, sessionId: activeSessionRecord.sessionId,
isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false, isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false,
messages: initialMessages, messages: latestUiMessages,
}); });
const queueSessionUiStatePersist = () => { const queueSessionUiStatePersist = () => {
const snapshot = { const snapshot = {
sessionId: activeSessionRecord.sessionId, sessionId: activeSessionRecord.sessionId,
isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false, isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false,
messages: activeRun.messages, messages: latestUiMessages,
}; };
persistQueue = persistQueue persistQueue = persistQueue
.catch((error) => { .catch((error) => {
@@ -633,9 +839,29 @@ export const buildChatRouter = (
}; };
const primarySubscriber: StreamSubscriber = { const primarySubscriber: StreamSubscriber = {
write: (event, data) => { write: (event, data) => {
if (!streamClosed && !res.writableEnded && !res.destroyed) { if (streamClosed || res.writableEnded || res.destroyed) {
res.write(toSse(event, data)); 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: () => { close: () => {
if (!res.writableEnded && !res.destroyed) { if (!res.writableEnded && !res.destroyed) {
@@ -793,6 +1019,39 @@ export const buildChatRouter = (
}; };
try { 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( const preparedMessage = await buildPromptWithLearningContext(
memoryStore, memoryStore,
requestContext.actorKey, requestContext.actorKey,
@@ -800,7 +1059,7 @@ export const buildChatRouter = (
{ {
recentTurns, recentTurns,
persistedMessages: baseMessages, persistedMessages: baseMessages,
message: parsed.data.message, message: promptText,
restoreConversation: shouldRestoreConversation, restoreConversation: shouldRestoreConversation,
}, },
); );
@@ -847,7 +1106,7 @@ export const buildChatRouter = (
sessionTitle = await generateSessionTitle(runtime, { sessionTitle = await generateSessionTitle(runtime, {
sessionId: binding.sessionId, sessionId: binding.sessionId,
latestAssistantMessage: assistantText, latestAssistantMessage: assistantText,
latestUserMessage: parsed.data.message, latestUserMessage: promptText,
fallbackTitle: existingSessionTitle, fallbackTitle: existingSessionTitle,
}); });
} }
@@ -895,10 +1154,20 @@ export const buildChatRouter = (
progress: completeBackendProgress(message.progress), progress: completeBackendProgress(message.progress),
todos: cancelBackendTodos(message.todos), todos: cancelBackendTodos(message.todos),
})); }));
if (!uiFinished && !streamClosed && !res.writableEnded && !res.destroyed) {
writeUiChunk({
type: "error",
errorText: "请求已中断",
});
finishUiMessage("error");
}
void queueSessionUiStatePersist().catch((error) => { void queueSessionUiStatePersist().catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist aborted chat stream state"); 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) => { await persistQueue.catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state"); 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"); logger.error({ err: error }, "chat stream failed");
if (res.headersSent) { if (res.headersSent) {
if (!res.writableEnded && !res.destroyed) { if (!res.writableEnded && !res.destroyed) {
res.write(toSse("error", { pipeUiChunk(res, {
message: "chat stream failed", type: "error",
detail, errorText: detail,
})); });
pipeUiChunk(res, {
type: "finish",
finishReason: "error",
});
res.end(); res.end();
} }
return; return;
+66
View File
@@ -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,
};
};
+38
View File
@@ -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"],
},
},
});
});
});