feat(agent): add pressure trend demo
This commit is contained in:
@@ -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=="],
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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