feat(agent): add pressure trend demo
This commit is contained in:
@@ -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=="],
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
|
||||
+292
-19
@@ -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<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, {
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user