feat: initialize drainage network frontend

This commit is contained in:
2026-07-10 16:16:17 +08:00
commit 66de96d9e4
133 changed files with 33057 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createAgentApiClient } from "./client";
describe("Agent API client sessions", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("lists normalized sessions in newest-first order", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
new Response(
JSON.stringify({
sessions: [
{ id: "old", title: "", created_at: 100, updated_at: 120 },
{ id: "new", title: " 新会话 ", created_at: 200, updated_at: 210, run_status: "running" }
]
}),
{ status: 200 }
)
)
);
const sessions = await createAgentApiClient("http://agent.local").listSessions();
expect(sessions).toEqual([
{
id: "new",
title: "新会话",
createdAt: 200,
updatedAt: 210,
status: undefined,
parentSessionId: undefined,
isStreaming: undefined,
runStatus: "running"
},
{
id: "old",
title: "新对话",
createdAt: 100,
updatedAt: 120,
status: undefined,
parentSessionId: undefined,
isStreaming: undefined,
runStatus: undefined
}
]);
});
it("returns null when loading a missing session", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ message: "missing" }), { status: 404 })));
await expect(createAgentApiClient("http://agent.local").loadSession("missing")).resolves.toBeNull();
});
it("falls back when a candidate is not the agent service", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ message: "not found" }), { status: 404 }))
.mockResolvedValueOnce(
new Response(
JSON.stringify({
schema_version: "agent-ui-registry@1",
chart_grammars: [],
components: [],
actions: []
}),
{ status: 200 }
)
);
vi.stubGlobal("fetch", fetchMock);
await expect(createAgentApiClient(["http://not-agent.local", "http://agent.local"]).getUiRegistry()).resolves.toEqual({
schema_version: "agent-ui-registry@1",
chart_grammars: [],
components: [],
actions: []
});
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"http://not-agent.local/api/v1/agent/chat/ui-registry",
undefined
);
expect(fetchMock).toHaveBeenNthCalledWith(2, "http://agent.local/api/v1/agent/chat/ui-registry", undefined);
});
it("sends title update payloads using backend field names", async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
await createAgentApiClient("http://agent.local").updateSessionTitle("session-1", " 标题 ", {
isTitleManuallyEdited: true
});
expect(fetchMock).toHaveBeenCalledWith(
"http://agent.local/api/v1/agent/chat/session/session-1/title",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({
title: "标题",
is_title_manually_edited: true
})
})
);
});
it("streams session events from the backend SSE endpoint", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
new Response(
[
'event: state\ndata: {"session_id":"session-1","messages":[{"id":"u1","role":"user","parts":[{"type":"text","text":"检查压力"}]}],"is_streaming":true,"run_status":"running"}\n\n',
'event: token\ndata: {"session_id":"session-1","content":"处理中"}\n\n',
'event: done\ndata: {"session_id":"session-1","duration_ms":20}\n\n'
].join(""),
{ status: 200 }
)
)
);
const events: unknown[] = [];
await createAgentApiClient("http://agent.local").streamSession("session-1", {
onEvent: (event) => events.push(event)
});
expect(events).toEqual([
{
type: "state",
sessionId: "session-1",
messages: [{ id: "u1", role: "user", parts: [{ type: "text", text: "检查压力" }] }],
isStreaming: true,
runStatus: "running"
},
{
type: "token",
sessionId: "session-1",
data: { session_id: "session-1", content: "处理中" }
},
{
type: "done",
sessionId: "session-1",
data: { session_id: "session-1", duration_ms: 20 }
}
]);
});
});
+563
View File
@@ -0,0 +1,563 @@
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
export type AgentPermissionReply = "once" | "always" | "reject";
export type AgentModelOption = {
id: string;
label: string;
description: string;
icon: "bolt" | "sparkle";
};
export type AgentModelsResponse = {
defaultModel: string;
models: AgentModelOption[];
};
export type AgentChatSession = {
id?: string;
session_id: string;
title?: string;
status?: string;
run_status?: AgentRunStatus;
is_streaming?: boolean;
messages?: unknown[];
created_at?: number | string;
updated_at?: number | string;
is_title_manually_edited?: boolean;
parent_session_id?: string;
};
export type AgentChatSessionSummary = {
id: string;
title: string;
createdAt: number;
updatedAt: number;
status?: string;
parentSessionId?: string;
isStreaming?: boolean;
runStatus?: AgentRunStatus;
};
export type AgentLoadedChatSession = AgentChatSessionSummary & {
sessionId: string;
isTitleManuallyEdited: boolean;
messages: unknown[];
};
export type AgentSessionStreamDataEventType =
| "token"
| "progress"
| "todo_update"
| "permission_request"
| "permission_response"
| "question_request"
| "question_response"
| "tool_call"
| "ui_envelope"
| "session_title"
| "done"
| "error"
| "auth_required";
export type AgentSessionStreamEvent =
| {
type: "state";
sessionId: string;
messages: unknown[];
isStreaming: boolean;
runStatus?: AgentRunStatus;
}
| {
type: AgentSessionStreamDataEventType;
sessionId?: string;
data: Record<string, unknown>;
};
export type AgentApiClient = {
createSession: () => Promise<AgentChatSession>;
listSessions: () => Promise<AgentChatSessionSummary[]>;
loadSession: (sessionId: string) => Promise<AgentLoadedChatSession | null>;
streamSession: (
sessionId: string,
options: {
signal?: AbortSignal;
onEvent: (event: AgentSessionStreamEvent) => void;
}
) => Promise<void>;
updateSessionTitle: (
sessionId: string,
title: string,
options?: { isTitleManuallyEdited?: boolean }
) => Promise<void>;
deleteSession: (sessionId: string) => Promise<void>;
getModels: () => Promise<AgentModelsResponse>;
getUiRegistry: () => Promise<unknown>;
resolveRenderRef: (renderRef: string, sessionId: string) => Promise<unknown>;
replyPermission: (
requestId: string,
options: { sessionId: string; reply: AgentPermissionReply; message?: string }
) => Promise<void>;
replyQuestion: (
requestId: string,
options: { sessionId: string; answers: string[][] }
) => Promise<void>;
rejectQuestion: (requestId: string, options: { sessionId: string }) => Promise<void>;
abort: (sessionId: string) => Promise<void>;
};
const AGENT_API_BASE_URLS = process.env.NEXT_PUBLIC_AGENT_API_BASE_URL
? [process.env.NEXT_PUBLIC_AGENT_API_BASE_URL.replace(/\/$/, "")]
: ["", "http://127.0.0.1:8787"];
const CHAT_PATH = "/api/v1/agent/chat";
export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BASE_URLS): AgentApiClient {
const candidates = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).map((item) => item.replace(/\/$/, ""));
let activeBaseUrl = candidates[0] ?? "";
return {
async createSession() {
return requestJsonWithFallback<AgentChatSession>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
},
async listSessions() {
const payload = await requestJsonWithFallback<{ sessions?: unknown[] }>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
"/sessions"
);
return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries);
},
async loadSession(sessionId) {
const response = await fetchWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/session/${encodeURIComponent(sessionId)}`);
const text = await response.text();
const data = text ? JSON.parse(text) : null;
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(getResponseErrorMessage(data, response.status));
}
return toLoadedSession(data);
},
async streamSession(sessionId, options) {
const response = await fetchWithFallback(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
`/session/${encodeURIComponent(sessionId)}/stream`,
{ signal: options.signal }
);
if (!response.ok) {
const text = await response.text();
const data = text ? JSON.parse(text) : null;
throw new Error(getResponseErrorMessage(data, response.status));
}
await readSessionSseStream(response, options.onEvent);
},
async updateSessionTitle(sessionId, title, options) {
const normalizedTitle = title.trim();
if (!normalizedTitle) {
return;
}
await requestJsonWithFallback<unknown>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
`/session/${encodeURIComponent(sessionId)}/title`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: normalizedTitle,
is_title_manually_edited: options?.isTitleManuallyEdited
})
}
);
},
async deleteSession(sessionId) {
await requestJsonWithFallback<unknown>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
`/session/${encodeURIComponent(sessionId)}`,
{ method: "DELETE" }
);
},
async getModels() {
const payload = await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/models");
return toModelsResponse(payload);
},
async getUiRegistry() {
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/ui-registry");
},
async resolveRenderRef(renderRef, sessionId) {
const params = new URLSearchParams({ session_id: sessionId });
return requestJsonWithFallback<unknown>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
`/render-ref/${encodeURIComponent(renderRef)}?${params.toString()}`
);
},
async replyPermission(requestId, options) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/permission/${encodeURIComponent(requestId)}/reply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: options.sessionId,
reply: options.reply,
message: options.message
})
});
},
async replyQuestion(requestId, options) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/question/${encodeURIComponent(requestId)}/reply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: options.sessionId,
answers: options.answers
})
});
},
async rejectQuestion(requestId, options) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/question/${encodeURIComponent(requestId)}/reject`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: options.sessionId
})
});
},
async abort(sessionId) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/abort", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId })
});
}
};
}
async function requestJsonWithFallback<T>(
baseUrls: string[],
activeBaseUrl: string,
setActiveBaseUrl: (baseUrl: string) => void,
path: string,
init?: RequestInit
) {
const response = await fetchWithFallback(baseUrls, activeBaseUrl, setActiveBaseUrl, path, init);
const text = await response.text();
const data = text ? JSON.parse(text) : null;
if (!response.ok) {
throw new Error(getResponseErrorMessage(data, response.status));
}
return data as T;
}
async function fetchWithFallback(
baseUrls: string[],
activeBaseUrl: string,
setActiveBaseUrl: (baseUrl: string) => void,
path: string,
init?: RequestInit
) {
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
let lastError: unknown;
let lastResponse: Response | null = null;
for (const baseUrl of orderedBaseUrls) {
try {
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, init);
if (response.ok) {
setActiveBaseUrl(baseUrl);
return response;
}
lastResponse = response;
if (shouldFallbackOnHttpStatus(response.status) && baseUrl !== orderedBaseUrls[orderedBaseUrls.length - 1]) {
await response.body?.cancel();
continue;
}
return response;
} catch (error) {
lastError = error;
}
}
if (lastResponse) {
return lastResponse;
}
throw lastError instanceof Error ? lastError : new Error("Agent API unavailable");
}
function shouldFallbackOnHttpStatus(status: number) {
return status === 404 || status === 405 || status === 502 || status === 503 || status === 504;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isPresent<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined;
}
function toModelsResponse(value: unknown): AgentModelsResponse {
if (!isRecord(value)) {
return { defaultModel: "", models: [] };
}
const models = Array.isArray(value.models) ? value.models.map(toModelOption).filter(isPresent) : [];
const defaultModel = typeof value.default_model === "string" ? value.default_model : models[0]?.id ?? "";
return {
defaultModel,
models
};
}
function toModelOption(value: unknown): AgentModelOption | null {
if (!isRecord(value) || typeof value.id !== "string") {
return null;
}
const label = typeof value.label === "string" && value.label.trim() ? value.label : value.id;
const description =
typeof value.description === "string" && value.description.trim() ? value.description : label;
const icon = value.icon === "bolt" || value.icon === "sparkle" ? value.icon : "sparkle";
return {
id: value.id,
label,
description,
icon
};
}
function getResponseErrorMessage(data: unknown, status: number) {
return isRecord(data) && typeof data.message === "string"
? data.message
: `Agent API failed with HTTP ${status}`;
}
function toMillis(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim()) {
const parsed = new Date(value).getTime();
return Number.isFinite(parsed) ? parsed : Date.now();
}
return Date.now();
}
function normalizeTitle(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : "新对话";
}
function readRunStatus(value: unknown): AgentRunStatus | undefined {
return value === "running" || value === "completed" || value === "error" || value === "aborted"
? value
: undefined;
}
function toSessionSummary(value: unknown): AgentChatSessionSummary | null {
if (!isRecord(value)) {
return null;
}
const id = typeof value.id === "string" ? value.id : typeof value.session_id === "string" ? value.session_id : "";
if (!id) {
return null;
}
return {
id,
title: normalizeTitle(value.title),
createdAt: toMillis(value.created_at),
updatedAt: toMillis(value.updated_at),
status: typeof value.status === "string" ? value.status : undefined,
parentSessionId: typeof value.parent_session_id === "string" ? value.parent_session_id : undefined,
isStreaming: typeof value.is_streaming === "boolean" ? value.is_streaming : undefined,
runStatus: readRunStatus(value.run_status)
};
}
function toLoadedSession(value: unknown): AgentLoadedChatSession {
const summary = toSessionSummary(value);
if (!summary || !isRecord(value)) {
throw new Error("Invalid agent session payload");
}
return {
...summary,
sessionId: typeof value.session_id === "string" ? value.session_id : summary.id,
isTitleManuallyEdited:
typeof value.is_title_manually_edited === "boolean" ? value.is_title_manually_edited : false,
messages: Array.isArray(value.messages) ? value.messages : []
};
}
async function readSessionSseStream(
response: Response,
onEvent: (event: AgentSessionStreamEvent) => void
) {
if (!response.body) {
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
let delimiterIndex = buffer.indexOf("\n\n");
while (delimiterIndex !== -1) {
const rawEvent = buffer.slice(0, delimiterIndex);
buffer = buffer.slice(delimiterIndex + 2);
emitSessionSseEvent(rawEvent, onEvent);
delimiterIndex = buffer.indexOf("\n\n");
}
if (done) {
break;
}
}
if (buffer.trim()) {
emitSessionSseEvent(buffer, onEvent);
}
}
function emitSessionSseEvent(
rawEvent: string,
onEvent: (event: AgentSessionStreamEvent) => void
) {
const lines = rawEvent.split(/\r?\n/);
const eventType = lines
.find((line) => line.startsWith("event:"))
?.slice("event:".length)
.trim();
const dataText = lines
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice("data:".length).trimStart())
.join("\n");
if (!eventType || !dataText) {
return;
}
const data = JSON.parse(dataText) as unknown;
if (!isRecord(data)) {
return;
}
if (eventType === "state") {
const sessionId = typeof data.session_id === "string" ? data.session_id : "";
onEvent({
type: "state",
sessionId,
messages: Array.isArray(data.messages) ? data.messages : [],
isStreaming: data.is_streaming === true,
runStatus: readRunStatus(data.run_status)
});
return;
}
if (!isSessionStreamDataEventType(eventType)) {
return;
}
onEvent({
type: eventType,
sessionId: typeof data.session_id === "string" ? data.session_id : undefined,
data
});
}
function isSessionStreamDataEventType(value: string): value is AgentSessionStreamDataEventType {
return (
value === "token" ||
value === "progress" ||
value === "todo_update" ||
value === "permission_request" ||
value === "permission_response" ||
value === "question_request" ||
value === "question_response" ||
value === "tool_call" ||
value === "ui_envelope" ||
value === "session_title" ||
value === "done" ||
value === "error" ||
value === "auth_required"
);
}
function compareSessionSummaries(left: AgentChatSessionSummary, right: AgentChatSessionSummary) {
const createdAtDiff = right.createdAt - left.createdAt;
if (createdAtDiff !== 0) {
return createdAtDiff;
}
const updatedAtDiff = right.updatedAt - left.updatedAt;
if (updatedAtDiff !== 0) {
return updatedAtDiff;
}
return right.id.localeCompare(left.id);
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from "vitest";
import type { AgentApiClient } from "./client";
import { fetchAgentBootstrap, fetchAgentSessions } from "./swr";
describe("Agent API SWR helpers", () => {
it("loads bootstrap data with a normalized UI registry", async () => {
const client = {
getUiRegistry: vi.fn(async () => ({
schema_version: "agent-ui-registry@1",
chart_grammars: ["echarts-safe-subset"],
components: [{ id: "HistoryPanel", supportedSurfaces: ["side_panel", "unknown"] }],
actions: [{ id: "zoom_to_map", supportedSurfaces: ["map_overlay"] }]
})),
getModels: vi.fn(async () => ({
defaultModel: "model-a",
models: [{ id: "model-a", label: "Model A", description: "Default model", icon: "sparkle" }]
}))
} as Pick<AgentApiClient, "getUiRegistry" | "getModels"> as AgentApiClient;
await expect(fetchAgentBootstrap(client)).resolves.toEqual({
defaultModel: "model-a",
models: [{ id: "model-a", label: "Model A", description: "Default model", icon: "sparkle" }],
registry: {
schema_version: "agent-ui-registry@1",
chart_grammars: ["echarts-safe-subset"],
components: [{ id: "HistoryPanel", supportedSurfaces: ["side_panel"] }],
actions: [{ id: "zoom_to_map", supportedSurfaces: ["map_overlay"] }]
}
});
});
it("loads session summaries through the shared client", async () => {
const sessions = [
{
id: "session-1",
title: "会话",
createdAt: 1,
updatedAt: 2
}
];
const client = {
listSessions: vi.fn(async () => sessions)
} as Pick<AgentApiClient, "listSessions"> as AgentApiClient;
await expect(fetchAgentSessions(client)).resolves.toBe(sessions);
});
});
+32
View File
@@ -0,0 +1,32 @@
import type {
AgentApiClient,
AgentChatSessionSummary,
AgentModelOption
} from "./client";
import { parseUiRegistry, type UIRegistry } from "../ui-envelope";
export const agentBootstrapKey = ["agent", "bootstrap"] as const;
export const agentSessionsKey = ["agent", "sessions"] as const;
export type AgentBootstrapData = {
registry: UIRegistry | null;
models: AgentModelOption[];
defaultModel: string;
};
export async function fetchAgentBootstrap(client: AgentApiClient): Promise<AgentBootstrapData> {
const [rawRegistry, modelsResponse] = await Promise.all([
client.getUiRegistry(),
client.getModels()
]);
return {
registry: parseUiRegistry(rawRegistry),
models: modelsResponse.models,
defaultModel: modelsResponse.defaultModel
};
}
export async function fetchAgentSessions(client: AgentApiClient): Promise<AgentChatSessionSummary[]> {
return client.listSessions();
}
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { normalizeSafeChartData, parseChartSpec, pointToLabelValue } from "./chart-data";
describe("chart data normalization", () => {
it("parses chart spec aliases", () => {
expect(parseChartSpec({ title: "压力", chart_type: "bar", x_axis_name: "时间" })).toEqual({
title: "压力",
chartType: "bar",
xAxisName: "时间",
yAxisName: undefined
});
});
it("normalizes series with x_data and numeric strings", () => {
expect(
normalizeSafeChartData({
x_data: ["00:00", "01:00"],
series: [{ name: "压力", data: ["1.2", 1.4] }]
})
).toEqual({
xData: ["00:00", "01:00"],
series: [{ name: "压力", data: [1.2, 1.4], type: undefined }]
});
});
it("normalizes raw point arrays into a default series", () => {
expect(
normalizeSafeChartData({
series: [
["A", 12],
["B", "13"]
]
})
).toEqual({
xData: ["A", "B"],
series: [{ name: "数据", data: [12, 13], type: undefined }]
});
});
it("normalizes object points", () => {
expect(pointToLabelValue({ time: "10:00", value: "2.4" }, "fallback")).toEqual({
label: "10:00",
value: 2.4
});
});
it("clips chart points to the safe limit", () => {
const data = normalizeSafeChartData(
{
x_data: Array.from({ length: 30 }, (_, index) => String(index)),
series: [{ name: "流量", data: Array.from({ length: 30 }, (_, index) => index) }]
},
5
);
expect(data?.xData).toHaveLength(5);
expect(data?.series[0].data).toEqual([0, 1, 2, 3, 4]);
});
});
+183
View File
@@ -0,0 +1,183 @@
export type SafeChartSpec = {
title?: string;
chartType: "line" | "bar";
xAxisName?: string;
yAxisName?: string;
};
export type SafeChartSeries = {
name: string;
data: number[];
type?: "line" | "bar";
};
export type SafeChartData = {
xData: string[];
series: SafeChartSeries[];
};
type RawChartPoint = number | string | [unknown, unknown] | RawChartPointObject;
type RawChartPointObject = {
x?: unknown;
y?: unknown;
time?: unknown;
timestamp?: unknown;
label?: unknown;
name?: unknown;
value?: unknown;
};
type RawChartSeries = {
name?: unknown;
data?: unknown;
points?: unknown;
values?: unknown;
type?: unknown;
};
export function parseChartSpec(value: unknown): SafeChartSpec {
if (!isRecord(value)) {
return { chartType: "line" };
}
return {
title: stringValue(value.title),
chartType: value.chart_type === "bar" || value.chartType === "bar" ? "bar" : "line",
xAxisName: stringValue(value.x_axis_name ?? value.xAxisName),
yAxisName: stringValue(value.y_axis_name ?? value.yAxisName)
};
}
export function normalizeSafeChartData(value: unknown, maxPoints = 24): SafeChartData | null {
if (!isRecord(value)) {
return null;
}
const xData = normalizeXData(value.x_data ?? value.xData);
const rawSeriesItems = normalizeRawSeriesItems(value.series);
if (!rawSeriesItems.length) {
return null;
}
const normalizedSeries = rawSeriesItems
.map((rawItem, seriesIndex): SafeChartSeries | null => {
const item =
isRecord(rawItem) && !isRawChartPoint(rawItem) ? rawItem : ({ data: rawItem } satisfies RawChartSeries);
const rawData = item.data ?? item.points ?? item.values;
if (!Array.isArray(rawData)) {
return null;
}
const labelsFromPoints: string[] = [];
const data = rawData
.slice(0, maxPoints)
.map((point, index) => {
const parsed = pointToLabelValue(point as RawChartPoint, xData[index] ?? `${index + 1}`);
if (!parsed) {
return null;
}
labelsFromPoints[index] = parsed.label;
return parsed.value;
})
.filter((item): item is number => item !== null);
if (!data.length) {
return null;
}
if (!xData.length && labelsFromPoints.length) {
xData.push(...labelsFromPoints);
}
return {
name: stringValue(item.name) ?? `系列 ${seriesIndex + 1}`,
data,
type: item.type === "line" || item.type === "bar" ? item.type : undefined
};
})
.filter((item): item is SafeChartSeries => Boolean(item));
const clippedXData = xData.slice(0, maxPoints);
const clippedSeries = normalizedSeries.map((series) => ({
...series,
data: series.data.slice(0, clippedXData.length)
}));
return clippedXData.length > 0 && clippedSeries.length > 0
? {
xData: clippedXData,
series: clippedSeries
}
: null;
}
export function pointToLabelValue(point: RawChartPoint, fallbackLabel: string): { label: string; value: number } | null {
const directValue = toFiniteNumber(point);
if (directValue !== null) {
return { label: fallbackLabel, value: directValue };
}
if (Array.isArray(point)) {
const value = toFiniteNumber(point[1]);
if (value === null) {
return null;
}
return { label: String(point[0] ?? fallbackLabel), value };
}
if (isRecord(point)) {
const value = toFiniteNumber(point.value ?? point.y);
if (value === null) {
return null;
}
const label = point.x ?? point.time ?? point.timestamp ?? point.label ?? point.name ?? fallbackLabel;
return { label: String(label), value };
}
return null;
}
function normalizeXData(rawXData: unknown): string[] {
return Array.isArray(rawXData) ? rawXData.map((item) => String(item ?? "")).filter(Boolean) : [];
}
function normalizeRawSeriesItems(rawSeries: unknown): unknown[] {
if (!Array.isArray(rawSeries)) {
return isRecord(rawSeries) ? [rawSeries] : [];
}
return rawSeries.length > 0 && rawSeries.every(isRawChartPoint) ? [{ name: "数据", data: rawSeries }] : rawSeries;
}
function isRawChartPoint(item: unknown): boolean {
if (toFiniteNumber(item) !== null) {
return true;
}
if (Array.isArray(item)) {
return item.length >= 2 && toFiniteNumber(item[1]) !== null;
}
if (isRecord(item)) {
return item.data === undefined && item.points === undefined && item.values === undefined && toFiniteNumber(item.value ?? item.y) !== null;
}
return false;
}
function toFiniteNumber(value: unknown): number | null {
if (typeof value === "number") {
return Number.isFinite(value) ? value : null;
}
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function stringValue(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,79 @@
"use client";
import { ChevronRight } from "lucide-react";
import type { PersonaState } from "@/shared/ai-elements/persona";
import { cn } from "@/lib/utils";
import {
MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
} from "@/features/map/core/components/map-control-styles";
import { AgentPersona } from "./agent-persona";
type AgentCollapsedRailProps = {
personaState?: PersonaState;
statusLabel?: string;
onExpand: () => void;
};
export function AgentCollapsedRail({ personaState, statusLabel = "Agent", onExpand }: AgentCollapsedRailProps) {
const state = getCollapsedAgentState(statusLabel, personaState);
return (
<aside className={cn("agent-rail-enter pointer-events-auto w-[188px] p-2", MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, MAP_CONTROL_SURFACE_CLASS_NAME)}>
<div className="agent-panel-control agent-rail-item rounded-2xl p-2">
<div className="flex items-center gap-2">
<AgentPersona
className="h-10 w-10"
fallbackClassName="h-10 w-10"
state={personaState}
statusLabel={statusLabel}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-semibold text-slate-950">HydroAgent</p>
<p className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs font-semibold text-slate-500">
<span className={cn("h-1.5 w-1.5 shrink-0 rounded-full", state.dotClassName)} />
<span className="truncate" title={statusLabel}>{state.label}</span>
</p>
</div>
<button
type="button"
aria-label="展开 Agent 面板"
title="展开 Agent 面板"
onClick={onExpand}
className="agent-panel-primary-icon grid h-9 w-9 shrink-0 place-items-center rounded-xl transition"
>
<ChevronRight size={17} aria-hidden="true" />
</button>
</div>
</div>
</aside>
);
}
function getCollapsedAgentState(statusLabel: string, personaState?: PersonaState) {
if (personaState === "asleep" || /失败|不可用|错误|降级/.test(statusLabel)) {
return {
label: "离线",
dotClassName: "bg-red-500"
};
}
if (personaState === "listening") {
return {
label: "待确认",
dotClassName: "bg-orange-500"
};
}
if (personaState === "thinking") {
return {
label: "处理中",
dotClassName: "bg-blue-500"
};
}
return {
label: "在线",
dotClassName: "bg-emerald-500"
};
}
@@ -0,0 +1,629 @@
"use client";
import {
Bolt,
CheckCircle2,
ChevronsUpDown,
ChevronLeft,
Gauge,
History,
Plus,
SendHorizontal,
Sparkles
} from "lucide-react";
import { AnimatePresence, MotionConfig, motion } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useStickToBottomContext } from "use-stick-to-bottom";
import { Agent, AgentContent } from "@/shared/ai-elements/agent";
import {
Conversation,
ConversationContent,
ConversationScrollButton
} from "@/shared/ai-elements/conversation";
import type { PersonaState } from "@/shared/ai-elements/persona";
import { Message, MessageContent } from "@/shared/ai-elements/message";
import {
PromptInput,
PromptInputBody,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea
} from "@/shared/ai-elements/prompt-input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import { Button } from "@/shared/ui/button";
import { cn } from "@/lib/utils";
import {
MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
} from "@/features/map/core/components/map-control-styles";
import type { AgentChatSessionSummary } from "../api/client";
import { AgentPersona } from "./agent-persona";
import {
agentLayoutTransition,
agentPanelItemVariants,
agentPanelListVariants,
agentPanelSectionVariants
} from "./agent-motion";
import { AgentHistoryPanel } from "./agent-history-panel";
import { AgentMessageDetails } from "./agent-message-details";
import { AgentOperationalBrief } from "./agent-operational-brief";
import { AgentUiEnvelopeRenderer } from "./agent-ui-envelope-renderer";
import { StreamingTokenResponse } from "./streaming-token-response";
import type {
AgentChatMessage,
AgentModelOption,
AgentPermissionReply,
AgentPermissionRequest,
AgentQuestionRequest,
AgentStreamRenderState,
AgentUiResult
} from "../types";
type AgentCommandPanelProps = {
collapsing?: boolean;
personaState?: PersonaState;
sessionTitle?: string;
sessionHistory?: AgentChatSessionSummary[];
sessionHistoryLoading?: boolean;
activeSessionId?: string | null;
statusLabel?: string;
streaming?: boolean;
messages?: AgentChatMessage[];
streamRenderState?: AgentStreamRenderState;
modelOptions?: AgentModelOption[];
selectedModel?: string;
uiResults?: AgentUiResult[];
onCollapse: () => void;
onRefreshHistory?: () => Promise<void> | void;
onStartNewSession?: () => Promise<void> | void;
onLoadHistorySession?: (sessionId: string) => Promise<void> | void;
onRenameHistorySession?: (sessionId: string, title: string) => Promise<void> | void;
onDeleteHistorySession?: (sessionId: string) => Promise<void> | void;
onSelectModel?: (model: string) => void;
onSubmitPrompt: (prompt: string) => Promise<void> | void;
onStopPrompt?: () => void;
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
};
export function AgentCommandPanel({
collapsing = false,
personaState,
sessionTitle = "新对话",
sessionHistory = [],
sessionHistoryLoading = false,
activeSessionId,
statusLabel = "Agent 后端连接中",
streaming = false,
messages = [],
streamRenderState = {},
modelOptions = [],
selectedModel = "",
uiResults = [],
onCollapse,
onRefreshHistory,
onStartNewSession,
onLoadHistorySession,
onRenameHistorySession,
onDeleteHistorySession,
onSelectModel,
onSubmitPrompt,
onStopPrompt,
onReplyPermission,
onReplyQuestion,
onRejectQuestion
}: AgentCommandPanelProps) {
const [prompt, setPrompt] = useState("");
const [historyOpen, setHistoryOpen] = useState(false);
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
const [scrollRequestId, setScrollRequestId] = useState(0);
const trimmedPrompt = prompt.trim();
useEffect(() => {
if (historyOpen) {
void Promise.resolve(onRefreshHistory?.());
}
}, [historyOpen, onRefreshHistory]);
const submitPrompt = (nextPrompt: string) => {
if (!nextPrompt || streaming) {
return;
}
setPrompt("");
setScrollRequestId((requestId) => requestId + 1);
void Promise.resolve(onSubmitPrompt(nextPrompt)).catch(() => {
setPrompt(nextPrompt);
});
};
return (
<MotionConfig reducedMotion="user">
<aside
className={cn(
"pointer-events-auto flex h-full min-h-0 w-full flex-col overflow-hidden",
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME,
collapsing ? "agent-panel-collapse" : "agent-panel-enter"
)}
>
<Agent className="flex h-full min-h-0 flex-col border-0 bg-transparent">
<div className="agent-panel-band border-b border-white/70">
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex min-w-0 flex-1 items-center gap-3 text-slate-900">
<AgentPersona
className="h-[72px] w-[72px]"
fallbackClassName="h-[72px] w-[72px] rounded-2xl"
state={personaState}
statusLabel={statusLabel}
streaming={streaming}
/>
<div className="min-w-0 flex-1 space-y-2">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500"></p>
<span className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-lg border border-slate-200/70 bg-white/75 px-2 text-xs font-semibold text-slate-600">
<span className={cn("h-1.5 w-1.5 rounded-full", getAgentConnectionClassName(statusLabel))} />
{getAgentConnectionLabel(statusLabel)}
</span>
</div>
<p className="truncate text-sm font-semibold text-slate-950" title={sessionTitle}>
{sessionTitle}
</p>
</div>
</div>
</div>
<button
type="button"
aria-label="新建 Agent 对话"
title="新建 Agent 对话"
onClick={() => {
setHistoryOpen(false);
setPrompt("");
void Promise.resolve(onStartNewSession?.());
}}
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
>
<Plus size={17} aria-hidden="true" />
</button>
<button
type="button"
aria-label="打开 Agent 历史记录"
title="打开 Agent 历史记录"
onClick={() => setHistoryOpen((open) => !open)}
className={cn(
"agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition",
historyOpen && "border-blue-200 bg-blue-50 text-blue-700"
)}
>
<History size={17} aria-hidden="true" />
</button>
<button
type="button"
aria-label="折叠 Agent 面板"
title="折叠 Agent 面板"
onClick={onCollapse}
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
>
<ChevronLeft size={17} aria-hidden="true" />
</button>
</div>
<AnimatePresence initial={false}>
{historyOpen ? (
<motion.div
key="history"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
style={{ overflow: "hidden" }}
>
<AgentHistoryPanel
activeSessionId={activeSessionId}
loadingSessionId={loadingSessionId}
loading={sessionHistoryLoading}
sessions={sessionHistory}
onRefresh={onRefreshHistory}
onSelectSession={(nextSessionId) => {
setLoadingSessionId(nextSessionId);
void Promise.resolve(onLoadHistorySession?.(nextSessionId))
.then(() => setHistoryOpen(false))
.finally(() => setLoadingSessionId(null));
}}
onRenameSession={onRenameHistorySession}
onDeleteSession={onDeleteHistorySession}
/>
</motion.div>
) : null}
</AnimatePresence>
<AnimatePresence initial={false}>
{messages.length > 0 || streaming ? (
<motion.div
key="operational-brief"
className="space-y-2 px-3 pb-3"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
style={{ overflow: "hidden" }}
>
<AgentOperationalBrief
messages={messages}
statusLabel={statusLabel}
streaming={streaming}
onSubmitCommand={submitPrompt}
/>
</motion.div>
) : null}
</AnimatePresence>
</div>
<AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0">
<Conversation className="agent-panel-conversation min-h-0">
<ConversationContent className="gap-4 p-3">
<AnimatePresence mode="popLayout" initial={false}>
{messages.length > 0 ? (
<BackendMessageList
key="messages"
messages={messages}
streaming={streaming}
streamRenderState={streamRenderState}
onReplyPermission={onReplyPermission}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
) : (
<motion.div
key="empty"
className="w-full"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
>
<AgentEmptyState statusLabel={statusLabel} />
</motion.div>
)}
</AnimatePresence>
<AgentUiEnvelopeRenderer results={uiResults} />
</ConversationContent>
<AgentConversationScrollManager
activeSessionId={activeSessionId}
messages={messages}
scrollRequestId={scrollRequestId}
streaming={streaming}
uiResults={uiResults}
/>
<ConversationScrollButton className="bottom-3" />
</Conversation>
</AgentContent>
<div className="agent-panel-band border-t border-white/70 p-3">
<PromptInput
className="agent-panel-control rounded-2xl shadow-sm"
onSubmit={(message) => {
const nextPrompt = message.text.trim();
if (nextPrompt) {
submitPrompt(nextPrompt);
}
}}
>
<PromptInputBody>
<PromptInputTextarea
value={prompt}
onChange={(event) => setPrompt(event.currentTarget.value)}
className="min-h-14 text-sm"
placeholder="输入调度问题,Agent 将通过后端会话流式响应"
/>
</PromptInputBody>
<PromptInputFooter>
<span className="min-w-0 flex-1 truncate text-xs text-slate-500" title={statusLabel}>
{statusLabel}
</span>
<div className="flex shrink-0 items-center gap-1.5">
<AgentModelSelect
models={modelOptions}
value={selectedModel}
onValueChange={onSelectModel}
/>
<PromptInputSubmit
aria-label={streaming ? "停止 Agent 指令" : "发送 Agent 指令"}
title={streaming ? "停止 Agent 指令" : "发送 Agent 指令"}
className="agent-panel-primary-icon"
disabled={!streaming && !trimmedPrompt}
status={streaming ? "streaming" : "ready"}
onStop={onStopPrompt}
>
{streaming ? undefined : <SendHorizontal size={15} aria-hidden="true" />}
</PromptInputSubmit>
</div>
</PromptInputFooter>
</PromptInput>
</div>
</Agent>
</aside>
</MotionConfig>
);
}
type AgentConversationScrollSnapshot = {
activeSessionId?: string | null;
lastUserMessageId: string | null;
scrollRequestId: number;
signature: string;
};
function AgentConversationScrollManager({
activeSessionId,
messages,
scrollRequestId,
streaming,
uiResults
}: {
activeSessionId?: string | null;
messages: AgentChatMessage[];
scrollRequestId: number;
streaming: boolean;
uiResults: AgentUiResult[];
}) {
const { escapedFromLock, isAtBottom, scrollToBottom, state } = useStickToBottomContext();
const previousSnapshotRef = useRef<AgentConversationScrollSnapshot | null>(null);
const wasPinnedToBottom = !escapedFromLock && (isAtBottom || state.isAtBottom || state.isNearBottom);
const snapshot = useMemo<AgentConversationScrollSnapshot>(
() => ({
activeSessionId,
lastUserMessageId: getLastUserMessageId(messages),
scrollRequestId,
signature: createAgentConversationScrollSignature(messages, streaming, uiResults)
}),
[activeSessionId, messages, scrollRequestId, streaming, uiResults]
);
useEffect(() => {
const previousSnapshot = previousSnapshotRef.current;
previousSnapshotRef.current = snapshot;
const forceScroll =
!previousSnapshot ||
previousSnapshot.activeSessionId !== snapshot.activeSessionId ||
previousSnapshot.scrollRequestId !== snapshot.scrollRequestId ||
(Boolean(snapshot.lastUserMessageId) && previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId);
const contentChanged = !previousSnapshot || previousSnapshot.signature !== snapshot.signature;
if (!forceScroll && (!contentChanged || !wasPinnedToBottom)) {
return;
}
const animationFrameId = window.requestAnimationFrame(() => {
void scrollToBottom({
animation: "instant",
ignoreEscapes: forceScroll,
wait: !forceScroll
});
});
return () => window.cancelAnimationFrame(animationFrameId);
}, [scrollToBottom, snapshot, wasPinnedToBottom]);
return null;
}
function getLastUserMessageId(messages: AgentChatMessage[]) {
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index].role === "user") {
return messages[index].id;
}
}
return null;
}
function createAgentConversationScrollSignature(
messages: AgentChatMessage[],
streaming: boolean,
uiResults: AgentUiResult[]
) {
return [
streaming ? "streaming" : "idle",
messages.map(createAgentMessageScrollSignature).join("|"),
uiResults.map((result) => result.id).join("|")
].join("#");
}
function createAgentMessageScrollSignature(message: AgentChatMessage) {
return [
message.id,
message.role,
message.content.length,
message.progress?.map((item) => `${item.id}:${item.status}:${item.title.length}:${item.detail?.length ?? 0}`).join(",") ?? "",
message.todos?.todos.map((todo) => `${todo.id}:${todo.status}:${todo.content.length}`).join(",") ?? "",
message.permissions?.map((permission) => `${permission.requestId}:${permission.status}:${permission.error?.length ?? 0}`).join(",") ?? "",
message.questions?.map((question) => `${question.requestId}:${question.status}:${question.error?.length ?? 0}`).join(",") ?? ""
].join(":");
}
function AgentModelSelect({
models,
value,
onValueChange
}: {
models: AgentModelOption[];
value: string;
onValueChange?: (model: string) => void;
}) {
const selectedModel = models.find((model) => model.id === value) ?? models[0];
if (!models.length) {
return (
<span className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-slate-500">
</span>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
className="h-8 w-[126px] shrink-0 justify-between rounded-md border-transparent bg-transparent px-2 text-xs text-slate-600 shadow-none hover:bg-white/80"
disabled={!onValueChange}
aria-label="选择 Agent 模型"
>
<span className="flex min-w-0 items-center gap-1.5">
<ModelIcon model={selectedModel} size={13} />
<span className="truncate">{selectedModel?.label ?? "模型"}</span>
</span>
<ChevronsUpDown size={13} className="shrink-0 opacity-50" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="end"
sideOffset={8}
className="agent-panel-control w-72 rounded-xl p-2 shadow-lg"
>
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-slate-500">
Agent
</DropdownMenuLabel>
<div className="space-y-1">
{models.map((model) => {
const selected = model.id === selectedModel?.id;
return (
<DropdownMenuItem
key={model.id}
className={cn(
"rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50",
selected && "border-violet-200 bg-violet-50/80 focus:bg-violet-50"
)}
onSelect={() => onValueChange?.(model.id)}
>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-white text-slate-600 shadow-sm">
<ModelIcon model={model} size={15} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-slate-800">{model.label}</span>
<span className="block truncate text-xs leading-4 text-slate-500">{model.description}</span>
</span>
{selected ? <CheckCircle2 size={15} className="text-violet-600" aria-hidden="true" /> : null}
</DropdownMenuItem>
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
function getAgentConnectionLabel(statusLabel: string) {
return /失败|不可用|错误|降级/.test(statusLabel) ? "离线" : "在线";
}
function getAgentConnectionClassName(statusLabel: string) {
return /失败|不可用|错误|降级/.test(statusLabel) ? "bg-red-500" : "bg-emerald-500";
}
function ModelIcon({ model, size }: { model?: AgentModelOption; size: number }) {
return model?.icon === "bolt" ? (
<Bolt size={size} className="text-amber-500" aria-hidden="true" />
) : (
<Sparkles size={size} className="text-violet-500" aria-hidden="true" />
);
}
function AgentEmptyState({ statusLabel }: { statusLabel: string }) {
return (
<Message from="assistant" className="max-w-full">
<MessageContent className="agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-600 shadow-sm">
<div className="flex items-center gap-2">
<Gauge size={15} className="text-blue-600" aria-hidden="true" />
<p className="font-semibold text-slate-900"></p>
</div>
<p className="mt-2"></p>
<p className="mt-1 text-xs text-slate-500">{statusLabel}</p>
</MessageContent>
</Message>
);
}
function BackendMessageList({
messages,
streaming,
streamRenderState,
onReplyPermission,
onReplyQuestion,
onRejectQuestion
}: {
messages: AgentChatMessage[];
streaming: boolean;
streamRenderState: AgentStreamRenderState;
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
}) {
return (
<motion.div className="flex w-full flex-col gap-4" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{messages.map((message, index) => (
<motion.div
key={message.id}
layout
className={cn("w-full", message.role === "user" && "flex justify-end")}
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
transition={agentLayoutTransition}
>
<Message
from={message.role}
className={message.role === "user" ? "max-w-[92%]" : "max-w-full"}
>
<MessageContent
className={cn(
message.role === "user" && "bg-blue-600 px-3 py-2 text-white",
message.role === "assistant" && "agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-700 shadow-sm"
)}
>
<div className="space-y-3">
{message.content ? (
message.role === "assistant" ? (
<StreamingTokenResponse
className="leading-6"
content={message.content}
messageId={message.id}
streamDone={streamRenderState[message.id]?.done ?? !streaming}
streaming={streaming && index === messages.length - 1}
/>
) : (
<p className="whitespace-pre-wrap">{message.content}</p>
)
) : streaming && index === messages.length - 1 ? (
<span className="text-slate-500">...</span>
) : null}
{message.role === "assistant" ? (
<AgentMessageDetails
message={message}
onReplyPermission={onReplyPermission}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
) : null}
</div>
</MessageContent>
</Message>
</motion.div>
))}
</AnimatePresence>
</motion.div>
);
}
@@ -0,0 +1,296 @@
"use client";
import { Check, Loader2, Pencil, RefreshCw, Trash2, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { Input } from "@/shared/ui/input";
import { cn } from "@/lib/utils";
import type { AgentChatSessionSummary } from "../api/client";
import {
agentLayoutTransition,
agentPanelItemVariants,
agentPanelListVariants
} from "./agent-motion";
type AgentHistoryPanelProps = {
activeSessionId?: string | null;
loading: boolean;
loadingSessionId: string | null;
sessions: AgentChatSessionSummary[];
onRefresh?: () => Promise<void> | void;
onSelectSession: (sessionId: string) => void;
onRenameSession?: (sessionId: string, title: string) => Promise<void> | void;
onDeleteSession?: (sessionId: string) => Promise<void> | void;
};
export function AgentHistoryPanel({
activeSessionId,
loading,
loadingSessionId,
sessions,
onRefresh,
onSelectSession,
onRenameSession,
onDeleteSession
}: AgentHistoryPanelProps) {
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const [draftTitle, setDraftTitle] = useState("");
const [savingSessionId, setSavingSessionId] = useState<string | null>(null);
const [deletingSessionId, setDeletingSessionId] = useState<string | null>(null);
const [confirmingDeleteSessionId, setConfirmingDeleteSessionId] = useState<string | null>(null);
const beginRename = (session: AgentChatSessionSummary) => {
setEditingSessionId(session.id);
setDraftTitle(session.title);
};
const cancelRename = () => {
setEditingSessionId(null);
setDraftTitle("");
};
const saveRename = (session: AgentChatSessionSummary) => {
const normalizedTitle = draftTitle.trim();
if (!normalizedTitle || normalizedTitle === session.title) {
cancelRename();
return;
}
setSavingSessionId(session.id);
void Promise.resolve(onRenameSession?.(session.id, normalizedTitle))
.then(cancelRename)
.finally(() => setSavingSessionId(null));
};
const confirmDelete = (sessionId: string) => {
setDeletingSessionId(sessionId);
void Promise.resolve(onDeleteSession?.(sessionId))
.then(() => setConfirmingDeleteSessionId(null))
.finally(() => setDeletingSessionId(null));
};
return (
<div className="px-3 pb-3">
<div className="agent-panel-control rounded-2xl p-2.5 shadow-sm">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-semibold text-slate-950"></p>
<p className="mt-0.5 text-xs text-slate-500">{sessions.length ? `${sessions.length} 个会话` : "暂无历史会话"}</p>
</div>
<button
type="button"
aria-label="刷新 Agent 历史记录"
title="刷新 Agent 历史记录"
className="grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500 transition hover:bg-white hover:text-slate-950 disabled:cursor-not-allowed disabled:opacity-50"
disabled={loading}
onClick={() => void Promise.resolve(onRefresh?.())}
>
{loading ? (
<Loader2 size={15} className="animate-spin" aria-hidden="true" />
) : (
<RefreshCw size={15} aria-hidden="true" />
)}
</button>
</div>
<motion.div className="max-h-56 space-y-1 overflow-y-auto pr-1" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{loading && sessions.length === 0 ? (
<motion.div
key="history-loading"
className="flex items-center gap-2 rounded-xl bg-slate-50 px-3 py-3 text-xs font-semibold text-slate-500"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
>
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
</motion.div>
) : sessions.length > 0 ? (
sessions.map((session) => {
const active = session.id === activeSessionId;
const itemLoading = loadingSessionId === session.id;
const editing = editingSessionId === session.id;
const saving = savingSessionId === session.id;
const deleting = deletingSessionId === session.id;
const confirmingDelete = confirmingDeleteSessionId === session.id;
return (
<motion.div
key={session.id}
layout
className={cn(
"grid w-full grid-cols-[1fr_auto] gap-2 rounded-xl px-3 py-2 text-left transition hover:bg-white",
active ? "border border-blue-200 bg-blue-50/80" : "border border-transparent bg-slate-50/80"
)}
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
transition={agentLayoutTransition}
>
<AnimatePresence initial={false} mode="popLayout">
{confirmingDelete ? (
<motion.div key="confirm-delete" className="min-w-0" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<span className="block truncate text-xs font-semibold text-red-700" title={session.title}>
{session.title}
</span>
<span className="mt-0.5 block truncate text-xs text-slate-500">
</span>
</motion.div>
) : editing ? (
<motion.form
key="rename"
className="min-w-0"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
onSubmit={(event) => {
event.preventDefault();
saveRename(session);
}}
>
<Input
autoFocus
className="h-8 rounded-lg bg-white text-xs"
value={draftTitle}
disabled={saving}
onChange={(event) => setDraftTitle(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
cancelRename();
}
}}
/>
</motion.form>
) : (
<motion.button
key="session-summary"
type="button"
className="min-w-0 text-left"
disabled={itemLoading || deleting}
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
onClick={() => onSelectSession(session.id)}
>
<span className="block truncate text-xs font-semibold text-slate-900" title={session.title}>
{session.title}
</span>
<span className="mt-0.5 block truncate text-xs text-slate-500">
{formatSessionUpdatedAt(session.updatedAt)}
</span>
</motion.button>
)}
</AnimatePresence>
<span className="flex items-center gap-1">
{session.runStatus === "running" || session.isStreaming ? (
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
) : null}
{itemLoading || saving || deleting ? (
<Loader2 size={13} className="animate-spin text-blue-600" aria-hidden="true" />
) : null}
{active && !editing && !confirmingDelete ? (
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-semibold text-blue-700">
</span>
) : null}
{confirmingDelete ? (
<>
<button
type="button"
className="h-7 rounded-lg bg-red-600 px-2 text-xs font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
disabled={deleting}
onClick={() => confirmDelete(session.id)}
>
</button>
<button
type="button"
aria-label="取消删除"
title="取消删除"
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white disabled:opacity-50"
disabled={deleting}
onClick={() => setConfirmingDeleteSessionId(null)}
>
<X size={14} aria-hidden="true" />
</button>
</>
) : editing ? (
<>
<button
type="button"
aria-label="保存对话主题"
title="保存对话主题"
className="grid h-7 w-7 place-items-center rounded-lg text-blue-600 transition hover:bg-white disabled:opacity-50"
disabled={saving}
onClick={() => saveRename(session)}
>
<Check size={14} aria-hidden="true" />
</button>
<button
type="button"
aria-label="取消重命名"
title="取消重命名"
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white disabled:opacity-50"
disabled={saving}
onClick={cancelRename}
>
<X size={14} aria-hidden="true" />
</button>
</>
) : (
<>
<button
type="button"
aria-label="重命名对话"
title="重命名对话"
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white hover:text-slate-950 disabled:opacity-50"
disabled={itemLoading || deleting || !onRenameSession}
onClick={() => beginRename(session)}
>
<Pencil size={14} aria-hidden="true" />
</button>
<button
type="button"
aria-label="删除对话"
title="删除对话"
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
disabled={itemLoading || deleting || !onDeleteSession}
onClick={() => {
cancelRename();
setConfirmingDeleteSessionId(session.id);
}}
>
<Trash2 size={14} aria-hidden="true" />
</button>
</>
)}
</span>
</motion.div>
);
})
) : (
<motion.div key="history-empty" className="rounded-xl bg-slate-50 px-3 py-3 text-xs text-slate-500" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
</div>
);
}
function formatSessionUpdatedAt(value: number) {
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
}).format(value);
}
@@ -0,0 +1,612 @@
"use client";
import {
CheckCircle2,
HelpCircle,
ListChecks,
LockKeyhole,
Loader2,
ShieldCheck,
XCircle,
type LucideIcon
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/shared/ui/button";
import {
agentLayoutTransition,
agentPanelItemVariants,
agentPanelListVariants,
agentPanelSectionVariants
} from "./agent-motion";
import type {
AgentChatMessage,
AgentChatProgress,
AgentPermissionReply,
AgentPermissionRequest,
AgentQuestionInfo,
AgentQuestionRequest,
AgentTodoUpdate
} from "../types";
type ProgressStatus = AgentChatProgress["status"];
type TodoStatus = AgentTodoUpdate["todos"][number]["status"];
type PermissionStatus = AgentPermissionRequest["status"];
type QuestionStatus = AgentQuestionRequest["status"];
const progressStatusMeta: Record<ProgressStatus, { label: string; className: string; dotClassName: string }> = {
running: {
label: "进行中",
className: "bg-blue-100 text-blue-700",
dotClassName: "border-blue-500 bg-blue-100"
},
completed: {
label: "完成",
className: "bg-emerald-100 text-emerald-700",
dotClassName: "border-emerald-500 bg-emerald-100"
},
error: {
label: "失败",
className: "bg-red-100 text-red-700",
dotClassName: "border-red-500 bg-red-100"
}
};
const todoStatusMeta: Record<TodoStatus, { label: string; className: string; dotClassName: string }> = {
completed: {
label: "完成",
className: "bg-emerald-100 text-emerald-700",
dotClassName: "border-emerald-500 bg-emerald-500"
},
in_progress: {
label: "进行中",
className: "bg-blue-100 text-blue-700",
dotClassName: "border-blue-500 bg-blue-100"
},
pending: {
label: "待办",
className: "bg-slate-100 text-slate-600",
dotClassName: "border-slate-300 bg-white"
},
cancelled: {
label: "取消",
className: "bg-slate-200 text-slate-500",
dotClassName: "border-slate-300 bg-slate-200"
}
};
const permissionStatusMeta: Record<PermissionStatus, { label: string; className: string }> = {
approved_always: { label: "已始终允许", className: "bg-emerald-100 text-emerald-700" },
approved_once: { label: "已允许一次", className: "bg-emerald-100 text-emerald-700" },
rejected: { label: "已拒绝", className: "bg-red-100 text-red-700" },
aborted: { label: "已中止", className: "bg-slate-200 text-slate-500" },
error: { label: "失败", className: "bg-red-100 text-red-700" },
submitting: { label: "提交中", className: "bg-blue-100 text-blue-700" },
pending: { label: "待确认", className: "bg-orange-100 text-orange-700" }
};
const questionStatusMeta: Record<QuestionStatus, { label: string; className: string }> = {
answered: { label: "已回答", className: "bg-emerald-100 text-emerald-700" },
rejected: { label: "已跳过", className: "bg-slate-200 text-slate-500" },
error: { label: "失败", className: "bg-red-100 text-red-700" },
submitting: { label: "提交中", className: "bg-blue-100 text-blue-700" },
pending: { label: "待回答", className: "bg-blue-100 text-blue-700" }
};
export function AgentMessageDetails({
message,
onReplyPermission,
onReplyQuestion,
onRejectQuestion
}: {
message: AgentChatMessage;
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
}) {
const hasDetails = Boolean(
message.progress?.length ||
message.todos?.todos.length ||
message.permissions?.length ||
message.questions?.length
);
if (!hasDetails) {
return null;
}
return (
<motion.div className="space-y-2" layout variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{message.progress?.length ? (
<motion.div key="progress" layout initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<ProgressList progress={message.progress} />
</motion.div>
) : null}
{message.todos ? (
<motion.div key="todos" layout initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<TodoCard todoUpdate={message.todos} />
</motion.div>
) : null}
{message.permissions?.length ? (
<motion.div key="permissions" layout initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<PermissionList permissions={message.permissions} onReplyPermission={onReplyPermission} />
</motion.div>
) : null}
{message.questions?.length ? (
<motion.div key="questions" layout initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<QuestionList
questions={message.questions}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
</motion.div>
) : null}
</AnimatePresence>
</motion.div>
);
}
function AgentNestedBlock({
icon: Icon,
iconClassName,
title,
children
}: {
icon: LucideIcon;
iconClassName: string;
title: string;
children: ReactNode;
}) {
return (
<div className="agent-panel-nested rounded-xl p-2.5">
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-600">
<Icon size={14} className={iconClassName} aria-hidden="true" />
{title}
</div>
{children}
</div>
);
}
function AnimatedDetailItem({ children }: { children: ReactNode }) {
return (
<motion.div
layout
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
transition={agentLayoutTransition}
>
{children}
</motion.div>
);
}
function ProgressList({ progress }: { progress: AgentChatProgress[] }) {
return (
<AgentNestedBlock icon={Loader2} iconClassName="text-blue-600" title="执行进度">
<motion.ol className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{progress.slice(-6).map((item) => (
<motion.li
key={item.id}
layout
className="grid grid-cols-[16px_1fr_auto] items-start gap-2 text-xs"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
transition={agentLayoutTransition}
>
<span
className={cn(
"mt-0.5 h-3.5 w-3.5 rounded-full border",
progressStatusMeta[item.status].dotClassName
)}
/>
<span className="min-w-0">
<span className="block truncate font-semibold text-slate-800" title={item.title}>
{item.title}
</span>
{item.detail ? (
<span className="block line-clamp-2 whitespace-pre-wrap leading-5 text-slate-500">{item.detail}</span>
) : null}
</span>
<span className={cn("rounded-full px-2 py-0.5 font-semibold", progressStatusMeta[item.status].className)}>
{progressStatusMeta[item.status].label}
</span>
</motion.li>
))}
</AnimatePresence>
</motion.ol>
</AgentNestedBlock>
);
}
function TodoCard({ todoUpdate }: { todoUpdate: AgentTodoUpdate }) {
return (
<AgentNestedBlock icon={ListChecks} iconClassName="text-emerald-600" title="计划">
<motion.ol className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{todoUpdate.todos.slice(0, 6).map((todo) => (
<motion.li
key={todo.id}
layout
className="grid grid-cols-[16px_1fr_auto] items-start gap-2 text-xs"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
transition={agentLayoutTransition}
>
<span
className={cn(
"mt-0.5 grid h-3.5 w-3.5 place-items-center rounded-full border",
todoStatusMeta[todo.status].dotClassName
)}
>
{todo.status === "completed" ? <CheckCircle2 size={10} className="text-white" aria-hidden="true" /> : null}
</span>
<span className="min-w-0 truncate text-slate-700" title={todo.content}>
{todo.content}
</span>
<span className={cn("rounded-full px-2 py-0.5 font-semibold", todoStatusMeta[todo.status].className)}>
{todoStatusMeta[todo.status].label}
</span>
</motion.li>
))}
</AnimatePresence>
</motion.ol>
</AgentNestedBlock>
);
}
function PermissionList({
permissions,
onReplyPermission
}: {
permissions: AgentPermissionRequest[];
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
}) {
return (
<AgentNestedBlock icon={LockKeyhole} iconClassName="text-orange-600" title="权限请求">
<motion.div className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{permissions.slice(-4).map((permission) => (
<AnimatedDetailItem key={permission.requestId}>
<PermissionRequestCard
permission={permission}
onReplyPermission={onReplyPermission}
/>
</AnimatedDetailItem>
))}
</AnimatePresence>
</motion.div>
</AgentNestedBlock>
);
}
function PermissionRequestCard({
permission,
onReplyPermission
}: {
permission: AgentPermissionRequest;
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
}) {
const actionable = permission.status === "pending" || permission.status === "error";
const submitting = permission.status === "submitting";
const canApproveAlways = permission.always.length > 0;
const disabled = !onReplyPermission || submitting;
const target = permission.target ?? (permission.patterns.join(" ") || permission.permission);
const reply = (nextReply: AgentPermissionReply) => {
if (!onReplyPermission || submitting) {
return;
}
void Promise.resolve(onReplyPermission(permission, nextReply));
};
return (
<div className="rounded-lg bg-white/90 px-2.5 py-2 text-xs">
<div className="grid grid-cols-[1fr_auto] gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-slate-800" title={permission.target ?? permission.permission}>
{getPermissionTitle(permission)}
</p>
<p className="mt-0.5 truncate font-mono text-xs text-slate-500" title={target}>
{target}
</p>
{permission.error ? (
<p className="mt-1 line-clamp-2 text-xs leading-4 text-red-600">{permission.error}</p>
) : null}
</div>
<span className={cn("self-start rounded-full px-2 py-0.5 font-semibold", permissionStatusMeta[permission.status].className)}>
{permissionStatusMeta[permission.status].label}
</span>
</div>
<AnimatePresence initial={false}>
{actionable ? (
<motion.div
key="permission-actions"
className="mt-2 flex flex-wrap items-center gap-1.5"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
style={{ overflow: "hidden" }}
>
<Button
type="button"
size="sm"
className="agent-panel-primary-action h-7 rounded-md px-2 text-xs"
disabled={disabled}
onClick={() => reply("once")}
>
<CheckCircle2 size={13} aria-hidden="true" />
</Button>
{canApproveAlways ? (
<Button
type="button"
size="sm"
variant="outline"
className="h-7 rounded-md border-blue-200 bg-blue-50 px-2 text-xs text-blue-700 hover:bg-blue-100"
disabled={disabled}
onClick={() => reply("always")}
>
<ShieldCheck size={13} aria-hidden="true" />
</Button>
) : null}
<Button
type="button"
size="sm"
variant="outline"
className="h-7 rounded-md border-red-200 bg-red-50 px-2 text-xs text-red-700 hover:bg-red-100"
disabled={disabled}
onClick={() => reply("reject")}
>
<XCircle size={13} aria-hidden="true" />
</Button>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
function QuestionList({
questions,
onReplyQuestion,
onRejectQuestion
}: {
questions: AgentQuestionRequest[];
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
}) {
return (
<AgentNestedBlock icon={HelpCircle} iconClassName="text-blue-600" title="补充信息">
<motion.div className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{questions.slice(-3).map((request) => (
<AnimatedDetailItem key={request.requestId}>
<QuestionRequestCard
request={request}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
</AnimatedDetailItem>
))}
</AnimatePresence>
</motion.div>
</AgentNestedBlock>
);
}
function QuestionRequestCard({
request,
onReplyQuestion,
onRejectQuestion
}: {
request: AgentQuestionRequest;
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
}) {
const [draftAnswers, setDraftAnswers] = useState(() => createInitialQuestionAnswers(request));
const actionable = request.status === "pending" || request.status === "error";
const submitting = request.status === "submitting";
const disabled = submitting || !onReplyQuestion;
const answers = normalizeDraftAnswers(draftAnswers);
const canSubmit = actionable && answers.length === request.questions.length && answers.every((answer) => answer.length > 0);
const submit = () => {
if (!onReplyQuestion || !canSubmit) {
return;
}
void Promise.resolve(onReplyQuestion(request, answers));
};
const reject = () => {
if (!onRejectQuestion || submitting) {
return;
}
void Promise.resolve(onRejectQuestion(request));
};
return (
<div className="rounded-lg bg-white/90 px-2.5 py-2 text-xs">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="min-w-0 truncate font-semibold text-slate-800">
{request.questions[0]?.header || "问题"}
</span>
<span className={cn("rounded-full px-2 py-0.5 font-semibold", questionStatusMeta[request.status].className)}>
{questionStatusMeta[request.status].label}
</span>
</div>
<div className="space-y-3">
{request.questions.map((question, questionIndex) => (
<QuestionInput
key={`${request.requestId}-${questionIndex}`}
question={question}
questionIndex={questionIndex}
value={draftAnswers[questionIndex] ?? { selected: [], custom: "" }}
disabled={!actionable || submitting}
onChange={(nextValue) =>
setDraftAnswers((current) => current.map((item, index) => (index === questionIndex ? nextValue : item)))
}
/>
))}
</div>
{request.error ? (
<p className="mt-2 line-clamp-2 text-xs leading-4 text-red-600">{request.error}</p>
) : null}
{request.answers?.length ? (
<p className="mt-2 truncate text-slate-500" title={request.answers.map((answer) => answer.join("、")).join("")}>
{request.answers.map((answer) => answer.join("、")).join("")}
</p>
) : null}
<AnimatePresence initial={false}>
{actionable ? (
<motion.div
key="question-actions"
className="mt-2 flex flex-wrap items-center gap-1.5"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
style={{ overflow: "hidden" }}
>
<Button
type="button"
size="sm"
className="agent-panel-primary-action h-7 rounded-md px-2 text-xs"
disabled={disabled || !canSubmit}
onClick={submit}
>
<CheckCircle2 size={13} aria-hidden="true" />
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 rounded-md border-slate-200 bg-white px-2 text-xs text-slate-600 hover:bg-slate-50"
disabled={submitting || !onRejectQuestion}
onClick={reject}
>
<XCircle size={13} aria-hidden="true" />
</Button>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
type QuestionDraftAnswer = {
selected: string[];
custom: string;
};
function QuestionInput({
question,
questionIndex,
value,
disabled,
onChange
}: {
question: AgentQuestionInfo;
questionIndex: number;
value: QuestionDraftAnswer;
disabled: boolean;
onChange: (value: QuestionDraftAnswer) => void;
}) {
const name = `agent-question-${questionIndex}-${question.question}`;
const showCustomInput = question.custom || question.options.length === 0;
const toggleOption = (label: string, checked: boolean) => {
if (question.multiple) {
onChange({
...value,
selected: checked ? [...value.selected, label] : value.selected.filter((item) => item !== label)
});
return;
}
onChange({
...value,
selected: checked ? [label] : []
});
};
return (
<div className="space-y-1.5">
<p className="leading-5 text-slate-700">{question.question}</p>
{question.options.length ? (
<div className="space-y-1">
{question.options.map((option) => {
const checked = value.selected.includes(option.label);
return (
<label
key={option.label}
className="flex cursor-pointer items-start gap-2 rounded-md border border-slate-200 bg-white/90 px-2 py-1.5 text-slate-600"
>
<input
type={question.multiple ? "checkbox" : "radio"}
name={name}
className="mt-0.5 h-3.5 w-3.5 accent-blue-600"
checked={checked}
disabled={disabled}
onChange={(event) => toggleOption(option.label, event.currentTarget.checked)}
/>
<span className="min-w-0">
<span className="block font-semibold text-slate-700">{option.label}</span>
{option.description ? (
<span className="block leading-4 text-slate-500">{option.description}</span>
) : null}
</span>
</label>
);
})}
</div>
) : null}
{showCustomInput ? (
<textarea
className="min-h-16 w-full resize-none rounded-md border border-slate-200 bg-white/90 px-2 py-1.5 text-xs leading-5 text-slate-700 outline-none transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
placeholder="输入回答"
value={value.custom}
disabled={disabled}
onChange={(event) => onChange({ ...value, custom: event.currentTarget.value })}
/>
) : null}
</div>
);
}
function createInitialQuestionAnswers(request: AgentQuestionRequest): QuestionDraftAnswer[] {
return request.questions.map((question, index) => ({
selected: request.answers?.[index] ?? [],
custom: ""
}));
}
function normalizeDraftAnswers(draftAnswers: QuestionDraftAnswer[]) {
return draftAnswers.map((answer) => {
const custom = answer.custom.trim();
return custom ? [...answer.selected, custom] : answer.selected;
});
}
function getPermissionTitle(permission: AgentPermissionRequest) {
if (permission.permission === "bash") return "执行终端命令";
if (permission.permission === "edit") return "修改文件内容";
if (permission.permission === "external_directory") return "访问工作区外目录";
return permission.permission || "工具权限请求";
}
+72
View File
@@ -0,0 +1,72 @@
"use client";
import type { Variants } from "motion/react";
export const AGENT_MOTION_DURATION_MS = 180;
export const agentEase = [0.22, 1, 0.36, 1] as const;
export const agentPanelItemVariants: Variants = {
initial: {
opacity: 0,
y: 6,
scale: 0.985
},
animate: {
opacity: 1,
y: 0,
scale: 1,
transition: {
duration: 0.18,
ease: agentEase
}
},
exit: {
opacity: 0,
y: -4,
scale: 0.99,
transition: {
duration: 0.14,
ease: [0.5, 0, 0.2, 1]
}
}
};
export const agentPanelSectionVariants: Variants = {
initial: {
opacity: 0,
height: 0,
y: -4
},
animate: {
opacity: 1,
height: "auto",
y: 0,
transition: {
duration: 0.18,
ease: agentEase
}
},
exit: {
opacity: 0,
height: 0,
y: -4,
transition: {
duration: 0.14,
ease: [0.5, 0, 0.2, 1]
}
}
};
export const agentPanelListVariants: Variants = {
animate: {
transition: {
staggerChildren: 0.025
}
}
};
export const agentLayoutTransition = {
duration: 0.18,
ease: agentEase
};
@@ -0,0 +1,122 @@
"use client";
import {
ClipboardCheck,
Crosshair,
MapPinned,
Radio,
Route
} from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import type {
AgentChatMessage
} from "../types";
export function AgentOperationalBrief({
messages,
statusLabel,
streaming,
onSubmitCommand
}: {
messages: AgentChatMessage[];
statusLabel: string;
streaming: boolean;
onSubmitCommand: (prompt: string) => void;
}) {
const brief = createOperationalBrief(messages, statusLabel, streaming);
return (
<div className="agent-panel-control rounded-2xl p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={cn("h-2 w-2 rounded-full", brief.statusDotClassName)} />
<p className="truncate text-sm font-semibold text-slate-950">{brief.stateLabel}</p>
</div>
<p className="mt-1 truncate text-xs leading-5 text-slate-500" title={brief.task}>
{brief.task}
</p>
</div>
<span className="shrink-0 rounded-full bg-violet-100 px-2 py-1 text-xs font-semibold text-violet-700">
Agent
</span>
</div>
<dl className="mt-3 grid grid-cols-3 gap-2">
<AgentBriefMetric icon={<Radio size={13} aria-hidden="true" />} label="证据" value={brief.evidence} />
<AgentBriefMetric icon={<Route size={13} aria-hidden="true" />} label="计划" value={brief.plan} />
<AgentBriefMetric icon={<ClipboardCheck size={13} aria-hidden="true" />} label="确认" value={brief.confirmation} />
</dl>
<div className="mt-3 grid grid-cols-2 gap-2">
<button
type="button"
className="agent-panel-primary-action flex h-9 items-center justify-center gap-1.5 rounded-xl px-3 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-50"
disabled={streaming}
onClick={() => onSubmitCommand(brief.primaryCommand)}
>
<MapPinned size={14} aria-hidden="true" />
</button>
<button
type="button"
className="flex h-9 items-center justify-center gap-1.5 rounded-xl border border-slate-200 bg-white/90 px-3 text-xs font-semibold text-slate-700 transition hover:border-blue-200 hover:bg-blue-50 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
disabled={streaming}
onClick={() => onSubmitCommand(brief.secondaryCommand)}
>
<Crosshair size={14} aria-hidden="true" />
</button>
</div>
</div>
);
}
function AgentBriefMetric({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
return (
<div className="min-w-0 rounded-xl border border-slate-200/70 bg-slate-50/90 px-2 py-2">
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-500">
<span className="text-blue-600">{icon}</span>
{label}
</dt>
<dd className="mt-1 truncate text-xs font-semibold text-slate-900" title={value}>
{value}
</dd>
</div>
);
}
function createOperationalBrief(messages: AgentChatMessage[], statusLabel: string, streaming: boolean) {
const lastUserMessage = [...messages].reverse().find((message) => message.role === "user" && message.content.trim());
const progressItems = messages.flatMap((message) => message.progress ?? []);
const todoItems = messages.flatMap((message) => message.todos?.todos ?? []);
const permissionItems = messages.flatMap((message) => message.permissions ?? []);
const questionItems = messages.flatMap((message) => message.questions ?? []);
const latestProgress = progressItems.at(-1);
const activeTodo =
[...todoItems].reverse().find((todo) => todo.status === "in_progress") ??
[...todoItems].reverse().find((todo) => todo.status === "pending");
const pendingConfirmations =
permissionItems.filter((permission) => permission.status === "pending" || permission.status === "error").length +
questionItems.filter((question) => question.status === "pending" || question.status === "error").length;
const completedSteps = progressItems.filter((item) => item.status === "completed").length;
const task = lastUserMessage?.content.trim() || "等待调度指令";
const evidence = latestProgress?.detail || latestProgress?.title || statusLabel;
const plan = activeTodo?.content || (completedSteps > 0 ? `${completedSteps} 项已完成` : "待生成");
const confirmation = pendingConfirmations > 0 ? `${pendingConfirmations} 项待确认` : "无需确认";
const stateLabel = pendingConfirmations > 0 ? "等待人工确认" : streaming ? "Agent 分析中" : "分析完成";
const statusDotClassName = pendingConfirmations > 0 ? "bg-orange-500" : streaming ? "bg-blue-500" : "bg-emerald-500";
const promptContext = lastUserMessage?.content.trim() || "当前排水管网运行态势";
return {
task,
evidence,
plan,
confirmation,
stateLabel,
statusDotClassName,
primaryCommand: `请基于“${promptContext}”预览空间影响范围,并列出需要确认的操作。`,
secondaryCommand: `请检查“${promptContext}”的证据链,按数据来源、风险和不确定性汇总。`
};
}
@@ -0,0 +1,62 @@
"use client";
import { Bot } from "lucide-react";
import { useState } from "react";
import { Persona, type PersonaState, type PersonaVariant } from "@/shared/ai-elements/persona";
import { cn } from "@/lib/utils";
type AgentPersonaProps = {
className?: string;
fallbackClassName?: string;
state?: PersonaState;
statusLabel?: string;
streaming?: boolean;
variant?: PersonaVariant;
};
export function AgentPersona({
className,
fallbackClassName,
state,
statusLabel,
streaming = false,
variant = "obsidian"
}: AgentPersonaProps) {
const [failed, setFailed] = useState(false);
const personaState = state ?? getAgentPersonaState(statusLabel, streaming);
if (failed) {
return (
<span
className={cn(
"grid shrink-0 place-items-center rounded-xl bg-violet-100 text-violet-700",
className,
fallbackClassName
)}
>
<Bot size={19} aria-hidden="true" />
</span>
);
}
return (
<Persona
className={cn("shrink-0", className)}
onLoadError={() => setFailed(true)}
state={personaState}
variant={variant}
/>
);
}
export function getAgentPersonaState(statusLabel = "", streaming = false): PersonaState {
if (/失败|不可用|错误|降级/.test(statusLabel)) {
return "asleep";
}
if (streaming || /请求|分析|生成|连接中|加载/.test(statusLabel)) {
return "thinking";
}
return "idle";
}
@@ -0,0 +1,407 @@
"use client";
import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "lucide-react";
import { AnimatePresence, MotionConfig, motion } from "motion/react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { normalizeSafeChartData, parseChartSpec, type SafeChartData, type SafeChartSeries, type SafeChartSpec } from "../chart-data";
import type { AgentUiResult } from "../types";
import type { UIEnvelope } from "../ui-envelope";
import {
agentLayoutTransition,
agentPanelItemVariants,
agentPanelListVariants
} from "./agent-motion";
type AgentUiEnvelopeRendererProps = {
results: AgentUiResult[];
};
export function AgentUiEnvelopeRenderer({ results }: AgentUiEnvelopeRendererProps) {
if (results.length === 0) {
return null;
}
return (
<MotionConfig reducedMotion="user">
<motion.div className="space-y-3" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{results.map((result) => (
<motion.div
key={result.id}
layout
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
transition={agentLayoutTransition}
>
<AgentUiResultCard result={result} />
</motion.div>
))}
</AnimatePresence>
</motion.div>
</MotionConfig>
);
}
function AgentUiResultCard({ result }: { result: AgentUiResult }) {
const { envelope } = result;
return (
<div className="agent-panel-message w-full rounded-2xl p-3 text-slate-700 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-blue-100 text-blue-700">
{getEnvelopeIcon(envelope)}
</span>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-slate-950">{getEnvelopeTitle(envelope)}</p>
<p className="text-xs text-slate-500">{getSurfaceLabel(envelope.surface)}</p>
</div>
</div>
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-600">
agent-ui@1
</span>
</div>
{envelope.kind === "chart" ? <SafeChartEnvelope envelope={envelope} /> : null}
{envelope.kind === "registered_component" ? <RegisteredComponentEnvelope envelope={envelope} /> : null}
{envelope.kind === "map_action" ? null : null}
</div>
);
}
function SafeChartEnvelope({ envelope }: { envelope: Extract<UIEnvelope, { kind: "chart" }> }) {
const spec = parseChartSpec(envelope.spec);
const data = normalizeSafeChartData(envelope.data);
if (!data || data.series.length === 0 || data.xData.length === 0) {
return <FallbackText text={envelope.fallbackText ?? "图表数据未通过前端安全子集校验。"} />;
}
return (
<div className="agent-panel-nested rounded-xl p-3">
<div className="mb-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-slate-950" title={spec.title}>
{spec.title ?? "Agent 图表"}
</p>
<p className="mt-1 text-xs text-slate-500">
{spec.chartType === "bar" ? "柱状图" : "折线图"}
{spec.yAxisName ? ` · ${spec.yAxisName}` : ""}
</p>
</div>
<div className="flex shrink-0 flex-wrap justify-end gap-1.5">
{data.series.slice(0, 3).map((series, index) => (
<span key={series.name} className="flex items-center gap-1 text-xs text-slate-600">
<span className={cn("h-2 w-2 rounded-full", chartColorClass(index))} />
{series.name}
</span>
))}
</div>
</div>
<SafeChartSvg spec={spec} data={data} />
{spec.xAxisName ? <p className="mt-2 text-center text-xs text-slate-500">{spec.xAxisName}</p> : null}
</div>
);
}
function SafeChartSvg({ spec, data }: { spec: SafeChartSpec; data: SafeChartData }) {
const width = 360;
const height = 190;
const padding = { top: 16, right: 18, bottom: 32, left: 38 };
const plotWidth = width - padding.left - padding.right;
const plotHeight = height - padding.top - padding.bottom;
const values = data.series.flatMap((series) => series.data);
const min = Math.min(0, ...values);
const max = Math.max(...values);
const range = max - min || 1;
const xFor = (index: number) =>
padding.left + (data.xData.length === 1 ? plotWidth / 2 : (index / (data.xData.length - 1)) * plotWidth);
const yFor = (value: number) => padding.top + plotHeight - ((value - min) / range) * plotHeight;
const ticks = createTicks(min, max);
return (
<svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label={spec.title ?? "Agent 图表"} className="h-48 w-full">
<rect x="0" y="0" width={width} height={height} rx="12" className="fill-white/70" />
{ticks.map((tick) => {
const y = yFor(tick);
return (
<g key={tick}>
<line x1={padding.left} x2={width - padding.right} y1={y} y2={y} className="stroke-slate-200" />
<text x={padding.left - 8} y={y + 4} textAnchor="end" className="fill-slate-400 text-xs">
{formatChartNumber(tick)}
</text>
</g>
);
})}
<line x1={padding.left} x2={padding.left} y1={padding.top} y2={height - padding.bottom} className="stroke-slate-300" />
<line x1={padding.left} x2={width - padding.right} y1={height - padding.bottom} y2={height - padding.bottom} className="stroke-slate-300" />
{data.xData.map((label, index) => (
<text
key={`${label}-${index}`}
x={xFor(index)}
y={height - 12}
textAnchor="middle"
className="fill-slate-500 text-xs"
>
{shortLabel(label)}
</text>
))}
{data.series.slice(0, 4).map((series, seriesIndex) =>
(series.type ?? spec.chartType) === "bar" ? (
<BarSeries
key={series.name}
series={series}
seriesIndex={seriesIndex}
seriesCount={Math.min(data.series.length, 4)}
xFor={xFor}
yFor={yFor}
baselineY={yFor(0)}
/>
) : (
<LineSeries key={series.name} series={series} seriesIndex={seriesIndex} xFor={xFor} yFor={yFor} />
)
)}
</svg>
);
}
function LineSeries({
series,
seriesIndex,
xFor,
yFor
}: {
series: SafeChartSeries;
seriesIndex: number;
xFor: (index: number) => number;
yFor: (value: number) => number;
}) {
const points = series.data.map((value, index) => `${xFor(index)},${yFor(value)}`).join(" ");
const stroke = chartStroke(seriesIndex);
return (
<g>
<polyline
points={points}
fill="none"
stroke={stroke}
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="agent-chart-line"
/>
{series.data.map((value, index) => (
<circle
key={`${series.name}-${index}`}
cx={xFor(index)}
cy={yFor(value)}
r="2.8"
fill={stroke}
className="agent-chart-point"
style={{ animationDelay: `${80 + index * 18}ms` }}
/>
))}
</g>
);
}
function BarSeries({
series,
seriesIndex,
seriesCount,
xFor,
yFor,
baselineY
}: {
series: SafeChartSeries;
seriesIndex: number;
seriesCount: number;
xFor: (index: number) => number;
yFor: (value: number) => number;
baselineY: number;
}) {
const fill = chartStroke(seriesIndex);
const barWidth = Math.max(5, 22 / seriesCount);
const offset = (seriesIndex - (seriesCount - 1) / 2) * (barWidth + 2);
return (
<g>
{series.data.map((value, index) => {
const y = yFor(value);
return (
<rect
key={`${series.name}-${index}`}
x={xFor(index) - barWidth / 2 + offset}
y={Math.min(y, baselineY)}
width={barWidth}
height={Math.max(1, Math.abs(baselineY - y))}
rx="2"
fill={fill}
className="agent-chart-bar"
style={{ animationDelay: `${seriesIndex * 28 + index * 16}ms` }}
/>
);
})}
</g>
);
}
function RegisteredComponentEnvelope({
envelope
}: {
envelope: Extract<UIEnvelope, { kind: "registered_component" }>;
}) {
if (envelope.component === "HistoryPanel") {
return (
<TrustedPanelShell icon={<History size={15} aria-hidden="true" />} title="历史数据面板">
<PropRows props={envelope.props} labels={{ feature_id: "资产 ID", featureId: "资产 ID", metric: "指标", range: "时间范围" }} />
</TrustedPanelShell>
);
}
if (envelope.component === "ScadaPanel") {
return (
<TrustedPanelShell icon={<MonitorCog size={15} aria-hidden="true" />} title="SCADA 面板">
<PropRows props={envelope.props} labels={{ device_id: "设备 ID", deviceId: "设备 ID", metric: "指标", range: "时间范围" }} />
</TrustedPanelShell>
);
}
return <FallbackText text={envelope.fallbackText ?? "当前注册组件未接入渲染器。"} />;
}
function TrustedPanelShell({
icon,
title,
children
}: {
icon: ReactNode;
title: string;
children: ReactNode;
}) {
return (
<div className="agent-panel-nested rounded-xl p-3">
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-950">
<span className="grid h-7 w-7 place-items-center rounded-lg bg-emerald-100 text-emerald-700">{icon}</span>
{title}
</div>
{children}
</div>
);
}
function PropRows({ props, labels }: { props: unknown; labels: Record<string, string> }) {
const rows = safePropRows(props, labels);
if (rows.length === 0) {
return <p className="text-sm text-slate-600"></p>;
}
return (
<dl className="grid grid-cols-2 gap-2">
{rows.map((row) => (
<div key={row.label} className="min-w-0 rounded-lg bg-white/90 px-2.5 py-2">
<dt className="truncate text-xs text-slate-500">{row.label}</dt>
<dd className="mt-1 truncate text-sm font-semibold text-slate-900" title={row.value}>
{row.value}
</dd>
</div>
))}
</dl>
);
}
function FallbackText({ text }: { text: string }) {
return (
<div className="agent-panel-nested rounded-xl p-3 text-sm leading-6 text-slate-600">
{text}
</div>
);
}
function safePropRows(props: unknown, labels: Record<string, string>) {
if (!isRecord(props)) {
return [];
}
return Object.entries(labels).flatMap(([key, label]) => {
const value = props[key];
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
return [];
}
return [{ label, value: String(value) }];
});
}
function getEnvelopeIcon(envelope: UIEnvelope) {
if (envelope.kind === "chart") {
return envelope.spec && isRecord(envelope.spec) && envelope.spec.chart_type === "bar" ? (
<BarChart3 size={16} aria-hidden="true" />
) : (
<LineChart size={16} aria-hidden="true" />
);
}
if (envelope.kind === "registered_component") {
return <Database size={16} aria-hidden="true" />;
}
return <Activity size={16} aria-hidden="true" />;
}
function getEnvelopeTitle(envelope: UIEnvelope) {
if (envelope.kind === "chart") {
const spec = parseChartSpec(envelope.spec);
return spec.title ?? "Agent 图表";
}
if (envelope.kind === "registered_component") {
return envelope.component;
}
return envelope.action;
}
function getSurfaceLabel(surface: UIEnvelope["surface"]) {
if (surface === "chat_inline") return "聊天内联展示";
if (surface === "side_panel") return "侧栏展示";
if (surface === "canvas") return "画布展示";
return "地图叠加";
}
function createTicks(min: number, max: number) {
const range = max - min || 1;
return [max, min + range * 0.5, min];
}
function chartStroke(index: number) {
return ["#2563eb", "#0aa6a6", "#ff7a45", "#0477bf"][index % 4] ?? "#2563eb";
}
function chartColorClass(index: number) {
return ["bg-blue-600", "bg-teal-600", "bg-orange-500", "bg-sky-700"][index % 4] ?? "bg-blue-600";
}
function formatChartNumber(value: number) {
if (Math.abs(value) >= 100) {
return value.toFixed(0);
}
if (Math.abs(value) >= 10) {
return value.toFixed(1);
}
return value.toFixed(2);
}
function shortLabel(value: string) {
return value.length > 8 ? `${value.slice(0, 7)}...` : value;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -0,0 +1,301 @@
export const DEFAULT_STREAMING_TOKEN_LOCALE = "zh";
export const STREAMING_TOKEN_BLOCK_SIZE = 4;
const FALLBACK_CHUNK_SIZE = 2;
export function splitStreamingText(text: string, locale = DEFAULT_STREAMING_TOKEN_LOCALE) {
if (!text) {
return [];
}
const segmenter =
typeof Intl !== "undefined" && "Segmenter" in Intl
? new Intl.Segmenter(locale, { granularity: "word" })
: null;
if (segmenter) {
return Array.from(segmenter.segment(text), (item) => item.segment).filter(Boolean);
}
return splitStreamingTextFallback(text);
}
export function splitStreamingTextFallback(text: string) {
const codePoints = Array.from(text);
const chunks: string[] = [];
for (let index = 0; index < codePoints.length; index += FALLBACK_CHUNK_SIZE) {
chunks.push(codePoints.slice(index, index + FALLBACK_CHUNK_SIZE).join(""));
}
return chunks;
}
function takeStreamingTokenBlock(pending: string[], blockSize = STREAMING_TOKEN_BLOCK_SIZE) {
return pending.splice(0, blockSize).join("");
}
export type StreamingMarkdownBufferState = {
content: string;
displayContent: string;
locale: string;
};
type FenceMarker = {
char: "`" | "~";
length: number;
};
const FENCE_START_PATTERN = /^ {0,3}(`{3,}|~{3,})/;
const HEADING_OPENER_PATTERN = /^ {0,3}#{1,6}[ \t]+\S/;
const PARTIAL_HEADING_OPENER_PATTERN = /^ {0,3}#{1,6}[ \t]*$/;
const LIST_OPENER_PATTERN = /^ {0,3}(?:[-+*]|\d{1,9}[.)])[ \t]+\S/;
const PARTIAL_LIST_OPENER_PATTERN = /^ {0,3}(?:[-+*]|\d{1,9}[.)])[ \t]*$/;
const BLOCKQUOTE_OPENER_PATTERN = /^ {0,3}>[ \t]?\S/;
const PARTIAL_BLOCKQUOTE_OPENER_PATTERN = /^ {0,3}>[ \t]*$/;
const HORIZONTAL_RULE_PATTERN = /^ {0,3}(?:-{3,}|\*{3,}|_{3,})[ \t]*(?:\r?\n|$)/;
const TABLE_SEPARATOR_PATTERN = /^ {0,3}\|?(?:\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/;
const POSSIBLE_TABLE_HEADER_PATTERN = /^ {0,3}\|.*\||^ {0,3}\S.*\|.*\|/;
const LINE_START_PATTERN = /(?:^|\n)(?= {0,3}(?:#{1,6}|[-+*]|\d{1,9}[.)]|>|`{3,}|~{3,}|\|))/g;
function readFenceStart(line: string): FenceMarker | null {
const match = FENCE_START_PATTERN.exec(line);
if (!match) {
return null;
}
const marker = match[1];
return {
char: marker[0] as FenceMarker["char"],
length: marker.length,
};
}
function readLineEnd(content: string) {
const newlineIndex = content.indexOf("\n");
if (newlineIndex === -1) {
return content.length;
}
return newlineIndex + 1;
}
function isTableLine(line: string) {
return line.includes("|") && line.trim().length > 0;
}
function isTableSeparatorLine(line: string) {
return TABLE_SEPARATOR_PATTERN.test(line);
}
type MarkdownOpenerMatch = {
chunk: string;
};
export function createStreamingMarkdownBufferState(
content = "",
displayContent = "",
locale = DEFAULT_STREAMING_TOKEN_LOCALE
): StreamingMarkdownBufferState {
return { content, displayContent, locale };
}
export function resolveStreamingMarkdownDisplayContent(
content: string,
displayContent: string,
streamDone: boolean
) {
if (streamDone) {
return content;
}
return content.startsWith(displayContent) ? displayContent : "";
}
function isLineStart(content: string, offset: number) {
return offset === 0 || content[offset - 1] === "\n";
}
function takeMarkdownTableOpener(content: string): MarkdownOpenerMatch | null {
const headerEnd = readLineEnd(content);
const headerLine = content.slice(0, headerEnd).replace(/\r?\n$/, "");
if (!POSSIBLE_TABLE_HEADER_PATTERN.test(headerLine)) {
return null;
}
if (headerEnd >= content.length && !content.endsWith("\n")) {
return { chunk: "" };
}
const afterHeader = content.slice(headerEnd);
const separatorEnd = readLineEnd(afterHeader);
const separatorLine = afterHeader.slice(0, separatorEnd).replace(/\r?\n$/, "");
if (!separatorLine) {
return { chunk: "" };
}
if (!isTableSeparatorLine(separatorLine)) {
return null;
}
return {
chunk: content.slice(0, headerEnd + separatorEnd),
};
}
function isStreamingTableOpen(displayContent: string) {
const lines = displayContent.replace(/\r\n/g, "\n").split("\n");
while (lines.at(-1) === "") {
lines.pop();
}
const blockLines: string[] = [];
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index];
if (!line.trim()) {
break;
}
if (!isTableLine(line)) {
break;
}
blockLines.unshift(line);
}
return (
blockLines.length >= 2 &&
blockLines.every(isTableLine) &&
blockLines.some(isTableSeparatorLine)
);
}
function takeMarkdownOpener(
content: string,
{ allowTable = true }: { allowTable?: boolean } = {}
): MarkdownOpenerMatch | null {
if (!content) {
return null;
}
const lineEnd = readLineEnd(content);
const firstLine = content.slice(0, lineEnd);
const lineWithoutEnding = firstLine.replace(/\r?\n$/, "");
const fence = readFenceStart(lineWithoutEnding);
if (fence) {
return firstLine.endsWith("\n") ? { chunk: firstLine } : { chunk: "" };
}
const tableOpener = allowTable ? takeMarkdownTableOpener(content) : null;
if (tableOpener) {
return tableOpener;
}
const firstNonSyntaxIndex = (pattern: RegExp) => {
const match = pattern.exec(content);
return match ? match[0].length : 0;
};
const headingLength = firstNonSyntaxIndex(HEADING_OPENER_PATTERN);
if (headingLength > 0) {
return { chunk: content.slice(0, headingLength) };
}
if (PARTIAL_HEADING_OPENER_PATTERN.test(lineWithoutEnding)) {
return { chunk: "" };
}
const listLength = firstNonSyntaxIndex(LIST_OPENER_PATTERN);
if (listLength > 0) {
return { chunk: content.slice(0, listLength) };
}
if (PARTIAL_LIST_OPENER_PATTERN.test(lineWithoutEnding)) {
return { chunk: "" };
}
const blockquoteLength = firstNonSyntaxIndex(BLOCKQUOTE_OPENER_PATTERN);
if (blockquoteLength > 0) {
return { chunk: content.slice(0, blockquoteLength) };
}
if (PARTIAL_BLOCKQUOTE_OPENER_PATTERN.test(lineWithoutEnding)) {
return { chunk: "" };
}
const ruleMatch = HORIZONTAL_RULE_PATTERN.exec(content);
if (ruleMatch) {
return { chunk: ruleMatch[0] };
}
return null;
}
export function isMarkdownOpenerBoundary(content: string, offset = 0) {
if (!isLineStart(content, offset)) {
return false;
}
const opener = takeMarkdownOpener(content.slice(offset));
return Boolean(opener?.chunk);
}
function findNextPotentialMarkdownOpenerIndex(content: string) {
LINE_START_PATTERN.lastIndex = 0;
for (const match of content.matchAll(LINE_START_PATTERN)) {
const index = match.index + (match[0] === "\n" ? 1 : 0);
if (index > 0) {
return index;
}
}
return -1;
}
function takeSegmentedMarkdownChunk(
content: string,
locale: string,
blockSize = STREAMING_TOKEN_BLOCK_SIZE
) {
const pendingSegments = splitStreamingText(content, locale);
const candidate = takeStreamingTokenBlock(pendingSegments, blockSize);
if (!candidate) {
return "";
}
const nextOpenerIndex = findNextPotentialMarkdownOpenerIndex(candidate);
return nextOpenerIndex > 0 ? candidate.slice(0, nextOpenerIndex) : candidate;
}
export function takeNextStreamingMarkdownChunk(
state: StreamingMarkdownBufferState,
blockSize = STREAMING_TOKEN_BLOCK_SIZE
) {
const displayContent = state.content.startsWith(state.displayContent)
? state.displayContent
: "";
const pendingContent = state.content.slice(displayContent.length);
if (!pendingContent) {
return {
chunk: "",
state: { ...state, displayContent },
};
}
const opener = isLineStart(state.content, displayContent.length)
? takeMarkdownOpener(pendingContent, {
allowTable: !isStreamingTableOpen(displayContent),
})
: null;
const chunk = opener
? opener.chunk
: takeSegmentedMarkdownChunk(pendingContent, state.locale, blockSize);
const nextDisplayContent = `${displayContent}${chunk}`;
return {
chunk,
state: {
...state,
displayContent: nextDisplayContent,
},
};
}
@@ -0,0 +1,83 @@
"use client";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { MessageResponse } from "@/shared/ai-elements/message";
import {
createStreamingMarkdownBufferState,
resolveStreamingMarkdownDisplayContent,
takeNextStreamingMarkdownChunk,
} from "./streaming-token-response-state";
type StreamingTokenResponseProps = {
content: string;
streaming: boolean;
streamDone?: boolean;
className?: string;
messageId?: string;
};
const STREAMING_MARKDOWN_FRAME_MS = 45;
export function StreamingTokenResponse({
content,
streaming,
streamDone = !streaming,
className,
messageId,
}: StreamingTokenResponseProps) {
const [displayContent, setDisplayContent] = useState(() =>
streamDone ? content : ""
);
useEffect(() => {
setDisplayContent((currentDisplayContent) =>
resolveStreamingMarkdownDisplayContent(
content,
currentDisplayContent,
streamDone
)
);
}, [content, messageId, streamDone]);
useEffect(() => {
if (streamDone || !streaming) {
return;
}
const showNextChunk = () => {
setDisplayContent((currentDisplayContent) => {
const { chunk, state } = takeNextStreamingMarkdownChunk(
createStreamingMarkdownBufferState(content, currentDisplayContent)
);
if (chunk || state.displayContent !== currentDisplayContent) {
return state.displayContent;
}
return currentDisplayContent;
});
};
showNextChunk();
const timer = window.setInterval(showNextChunk, STREAMING_MARKDOWN_FRAME_MS);
return () => window.clearInterval(timer);
}, [content, streamDone, streaming]);
if (!displayContent) {
return null;
}
return (
<MessageResponse
className={cn("agent-streaming-response", className)}
isAnimating={streaming}
mode="streaming"
parseIncompleteMarkdown={true}
>
{displayContent}
</MessageResponse>
);
}
+46
View File
@@ -0,0 +1,46 @@
export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
export { AgentCommandPanel } from "./components/agent-command-panel";
export { AgentPersona } from "./components/agent-persona";
export { normalizeSafeChartData, parseChartSpec, pointToLabelValue } from "./chart-data";
export { parseAssistantMessageSections, parseContentWithToolCalls } from "./message-sections";
export {
applyPermissionResponse,
applyQuestionResponse,
cancelRunningTodos,
completeRunningProgress,
finalizeAssistantMessageAfterAbort,
toTodoUpdate,
updateMessageById,
upsertPermission,
upsertProgress,
upsertQuestion
} from "./session-state";
export type {
AgentChatMessage,
AgentChatProgress,
AgentInteractionToolRef,
AgentModelOption,
AgentPermissionReply,
AgentPermissionRequest,
AgentPermissionStatus,
AgentQuestionInfo,
AgentQuestionOption,
AgentQuestionRequest,
AgentStreamRenderChunk,
AgentStreamRenderMessageState,
AgentStreamRenderState,
AgentTodoItem,
AgentTodoUpdate,
AgentUiResult
} from "./types";
export type {
SafeChartData,
SafeChartSeries,
SafeChartSpec
} from "./chart-data";
export type {
AssistantMessageSections,
ContentSegment,
ParsedToolContent,
ToolCall
} from "./message-sections";
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import { parseAssistantMessageSections, parseContentWithToolCalls } from "./message-sections";
describe("parseAssistantMessageSections", () => {
it("returns plain assistant content when there is no thought block", () => {
expect(parseAssistantMessageSections("直接回答")).toEqual({
answer: "直接回答",
thought: null,
thoughtComplete: false
});
});
it("extracts a completed thought block and keeps the final answer visible", () => {
expect(parseAssistantMessageSections("<think>先分析需求</think>\n\n最终回答")).toEqual({
answer: "最终回答",
thought: "先分析需求",
thoughtComplete: true
});
});
it("supports streaming thought content before the closing tag arrives", () => {
expect(parseAssistantMessageSections("准备中...\n<think>继续推理中")).toEqual({
answer: "准备中...",
thought: "继续推理中",
thoughtComplete: false
});
});
it("merges multiple thought blocks into a single collapsed section", () => {
expect(
parseAssistantMessageSections(
"<think>第一段思考</think>\n答案开头\n<think>第二段思考</think>\n答案结尾"
)
).toEqual({
answer: "答案开头\n\n答案结尾",
thought: "第一段思考\n\n第二段思考",
thoughtComplete: true
});
});
});
describe("parseContentWithToolCalls", () => {
it("returns a single text segment when there are no tool calls", () => {
const result = parseContentWithToolCalls("普通文本回答");
expect(result.segments).toEqual([{ type: "text", content: "普通文本回答" }]);
expect(result.toolCalls).toHaveLength(0);
});
it("parses a complete tool_call block", () => {
const content =
'分析完成。\n<tool_call>{"tool":"locate_junctions","params":{"ids":["J1","J2"]}}</tool_call>\n以上是结果。';
const result = parseContentWithToolCalls(content);
expect(result.toolCalls).toHaveLength(1);
expect(result.toolCalls[0].tool).toBe("locate_junctions");
expect(result.toolCalls[0].params).toEqual({ ids: ["J1", "J2"] });
expect(result.segments).toHaveLength(3);
expect(result.segments[0]).toEqual({ type: "text", content: "分析完成。" });
expect(result.segments[1]).toMatchObject({
type: "tool_call",
toolCall: { tool: "locate_junctions" }
});
expect(result.segments[2]).toEqual({ type: "text", content: "以上是结果。" });
});
it("parses multiple tool_call blocks", () => {
const content =
'文本1\n<tool_call>{"tool":"locate_pipes","params":{"ids":["P1"]}}</tool_call>\n文本2\n<tool_call>{"tool":"chart","params":{"title":"图"}}</tool_call>';
const result = parseContentWithToolCalls(content);
expect(result.toolCalls).toHaveLength(2);
expect(result.toolCalls[0].tool).toBe("locate_pipes");
expect(result.toolCalls[1].tool).toBe("chart");
expect(result.segments).toHaveLength(4);
});
it("detects an unclosed tool_call tag as pending while streaming", () => {
const result = parseContentWithToolCalls('正在分析...\n<tool_call>{"tool":"locate_no');
expect(result.segments).toEqual([
{ type: "text", content: "正在分析..." },
{ type: "tool_call_pending" }
]);
expect(result.toolCalls).toHaveLength(0);
});
it("strips partial opening tags during streaming", () => {
const result = parseContentWithToolCalls("正在分析...\n<tool_c");
expect(result.segments).toEqual([{ type: "text", content: "正在分析..." }]);
});
it("handles malformed JSON gracefully", () => {
const result = parseContentWithToolCalls("前文\n<tool_call>{invalid json}</tool_call>\n后文");
expect(result.toolCalls).toHaveLength(0);
expect(result.segments.length).toBeGreaterThanOrEqual(2);
});
it("returns empty segments for empty content", () => {
const result = parseContentWithToolCalls("");
expect(result.segments).toHaveLength(0);
expect(result.toolCalls).toHaveLength(0);
});
});
+129
View File
@@ -0,0 +1,129 @@
export type AssistantMessageSections = {
answer: string;
thought: string | null;
thoughtComplete: boolean;
};
export type ToolCall = {
id: string;
tool: string;
params: Record<string, unknown>;
};
export type ContentSegment =
| { type: "text"; content: string }
| { type: "tool_call"; toolCall: ToolCall }
| { type: "tool_call_pending" };
export type ParsedToolContent = {
segments: ContentSegment[];
toolCalls: ToolCall[];
};
const THINK_BLOCK_PATTERN = /<think>([\s\S]*?)<\/think>/gi;
const THINK_OPEN_TAG = "<think>";
const THINK_CLOSE_TAG = "</think>";
export function parseAssistantMessageSections(content: string): AssistantMessageSections {
if (!content) {
return { answer: "", thought: null, thoughtComplete: false };
}
const thoughtParts: string[] = [];
let answer = content;
answer = answer.replace(THINK_BLOCK_PATTERN, (_, thoughtContent: string) => {
const trimmedThought = thoughtContent.trim();
if (trimmedThought) {
thoughtParts.push(trimmedThought);
}
return "\n";
});
const lastOpenIndex = answer.lastIndexOf(THINK_OPEN_TAG);
const lastCloseIndex = answer.lastIndexOf(THINK_CLOSE_TAG);
const hasUnclosedThought = lastOpenIndex !== -1 && lastOpenIndex > lastCloseIndex;
if (hasUnclosedThought) {
const streamingThought = answer.slice(lastOpenIndex + THINK_OPEN_TAG.length).trim();
if (streamingThought) {
thoughtParts.push(streamingThought);
}
answer = answer.slice(0, lastOpenIndex);
}
const normalizedAnswer = answer.replace(/\n{3,}/g, "\n\n").trim();
const normalizedThought = thoughtParts.join("\n\n").trim();
return {
answer: normalizedAnswer,
thought: normalizedThought || null,
thoughtComplete: Boolean(normalizedThought) && !hasUnclosedThought
};
}
const TOOL_CALL_OPEN_TAG = "<tool_call>";
const TOOL_CALL_PATTERN = /<tool_call>([\s\S]*?)<\/tool_call>/gi;
const PARTIAL_TOOL_TAG_TAIL = /<(?:t(?:o(?:o(?:l(?:_(?:c(?:a(?:l(?:l)?)?)?)?)?)?)?)?)?$/;
export function parseContentWithToolCalls(content: string): ParsedToolContent {
if (!content) {
return { segments: [], toolCalls: [] };
}
const segments: ContentSegment[] = [];
const toolCalls: ToolCall[] = [];
let lastIndex = 0;
let toolCallIndex = 0;
let match: RegExpExecArray | null;
while ((match = TOOL_CALL_PATTERN.exec(content)) !== null) {
const textBefore = content.slice(lastIndex, match.index);
if (textBefore.trim()) {
segments.push({ type: "text", content: textBefore.trim() });
}
try {
const parsed = JSON.parse(match[1].trim()) as {
tool?: string;
params?: Record<string, unknown>;
};
const toolCall: ToolCall = {
id: `tc-${toolCallIndex++}`,
tool: parsed.tool ?? "unknown",
params: parsed.params ?? {}
};
segments.push({ type: "tool_call", toolCall });
toolCalls.push(toolCall);
} catch {
segments.push({ type: "text", content: match[0] });
}
lastIndex = match.index + match[0].length;
}
const remaining = content.slice(lastIndex);
const unclosedIndex = remaining.lastIndexOf(TOOL_CALL_OPEN_TAG);
if (unclosedIndex !== -1) {
const textBefore = remaining.slice(0, unclosedIndex);
if (textBefore.trim()) {
segments.push({ type: "text", content: textBefore.trim() });
}
segments.push({ type: "tool_call_pending" });
} else {
const cleaned = remaining.replace(PARTIAL_TOOL_TAG_TAIL, "").trim();
if (cleaned) {
segments.push({ type: "text", content: cleaned });
}
}
if (segments.length === 0) {
segments.push({ type: "text", content });
}
return { segments, toolCalls };
}
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import {
applyPermissionResponse,
applyQuestionResponse,
finalizeAssistantMessageAfterAbort,
toTodoUpdate,
upsertPermission,
upsertProgress,
upsertQuestion
} from "./session-state";
import type { AgentChatMessage } from "./types";
describe("agent session state", () => {
it("upserts progress events by id", () => {
const first = upsertProgress(undefined, {
id: "tool-1",
phase: "tool",
status: "running",
title: "调用工具",
elapsed_ms: 20
});
const next = upsertProgress(first, {
id: "tool-1",
phase: "tool",
status: "completed",
title: "工具完成",
duration_ms: 40
});
expect(next).toHaveLength(1);
expect(next[0]).toMatchObject({ id: "tool-1", status: "completed", title: "工具完成", durationMs: 40 });
});
it("normalizes todo updates", () => {
expect(
toTodoUpdate({
session_id: "session-1",
todos: [{ id: "todo-1", content: "分析压力", status: "in_progress", priority: "high" }],
created_at: 123
})
).toEqual({
sessionId: "session-1",
messageId: undefined,
todos: [{ id: "todo-1", content: "分析压力", status: "in_progress", priority: "high" }],
createdAt: 123
});
});
it("tracks permission request and response", () => {
const permissions = upsertPermission(undefined, {
session_id: "session-1",
request_id: "permission-1",
permission: "bash",
patterns: ["pnpm test"],
always: [],
created_at: 100
});
const next = applyPermissionResponse(permissions, {
request_id: "permission-1",
reply: "once"
});
expect(next?.[0]).toMatchObject({
requestId: "permission-1",
status: "approved_once"
});
});
it("dedupes question requests by tool call id", () => {
const questions = upsertQuestion(undefined, {
session_id: "session-1",
request_id: "call-1",
tool: { messageID: "message-1", callID: "call-1" },
questions: [{ header: "范围", question: "选择范围", options: [] }],
created_at: 100
});
const next = upsertQuestion(questions, {
session_id: "session-1",
request_id: "question-1",
tool: { messageID: "message-1", callID: "call-1" },
questions: [{ header: "范围", question: "选择范围", options: [] }],
created_at: 120
});
const answered = applyQuestionResponse(next, {
request_id: "question-1",
answers: [["东部"]]
});
expect(next).toHaveLength(1);
expect(answered?.[0]).toMatchObject({
requestId: "question-1",
status: "answered",
answers: [["东部"]]
});
});
it("finalizes open assistant state after abort", () => {
const message: AgentChatMessage = {
id: "assistant-1",
role: "assistant",
content: "",
progress: [{ id: "run", phase: "run", status: "running", title: "处理中", startedAt: Date.now() }],
permissions: [
{
requestId: "permission-1",
sessionId: "session-1",
permission: "bash",
patterns: [],
always: [],
createdAt: 100,
status: "pending"
}
]
};
const next = finalizeAssistantMessageAfterAbort(message);
expect(next.content).toBe("请求已中止。");
expect(next.progress?.[0].status).toBe("completed");
expect(next.permissions?.[0].status).toBe("aborted");
});
});
+370
View File
@@ -0,0 +1,370 @@
import type {
AgentChatMessage,
AgentChatProgress,
AgentInteractionToolRef,
AgentPermissionRequest,
AgentPermissionStatus,
AgentQuestionInfo,
AgentQuestionRequest,
AgentTodoItem,
AgentTodoUpdate
} from "./types";
type RecordValue = Record<string, unknown>;
export function updateMessageById(
messages: AgentChatMessage[],
messageId: string,
updater: (message: AgentChatMessage) => AgentChatMessage
) {
return messages.map((message) => (message.id === messageId ? updater(message) : message));
}
export function upsertProgress(progress: AgentChatProgress[] | undefined, data: unknown) {
const payload = asRecord(data);
const id = readString(payload.id) ?? `progress-${Date.now().toString(36)}`;
const now = Date.now();
const index = progress?.findIndex((item) => item.id === id) ?? -1;
const existing = index >= 0 ? progress?.[index] : undefined;
const status = readProgressStatus(payload.status);
const startedAt = readNumber(payload.started_at) ?? readNumber(payload.startedAt) ?? existing?.startedAt;
const endedAt = readNumber(payload.ended_at) ?? readNumber(payload.endedAt);
const elapsedMs = readNumber(payload.elapsed_ms) ?? readNumber(payload.elapsedMs);
const durationMs = readNumber(payload.duration_ms) ?? readNumber(payload.durationMs);
const nextItem: AgentChatProgress = {
id,
phase: readString(payload.phase) ?? "progress",
status,
title: readString(payload.title) ?? "正在处理",
detail: readString(payload.detail),
startedAt,
endedAt,
elapsedMs: status === "running" ? elapsedMs : undefined,
elapsedSnapshotAt: status === "running" && elapsedMs !== undefined ? now : undefined,
durationMs: status === "running" ? undefined : durationMs
};
const next = [...(progress ?? [])];
if (index >= 0) {
next[index] = nextItem;
} else {
next.push(nextItem);
}
return next;
}
export function completeRunningProgress(progress: AgentChatProgress[] | undefined) {
return progress?.map((item) => {
if (item.status !== "running") {
return item;
}
const endedAt = Date.now();
return {
...item,
status: "completed" as const,
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs:
item.durationMs ??
(item.startedAt !== undefined ? Math.max(0, endedAt - item.startedAt) : item.elapsedMs)
};
});
}
export function toTodoUpdate(data: unknown): AgentTodoUpdate | null {
const payload = asRecord(data);
const sessionId = readString(payload.session_id) ?? readString(payload.sessionId);
const rawTodos = Array.isArray(payload.todos) ? payload.todos : [];
if (!sessionId || rawTodos.length === 0) {
return null;
}
return {
sessionId,
messageId: readString(payload.message_id) ?? readString(payload.messageId),
todos: rawTodos.map(toTodoItem).filter((item): item is AgentTodoItem => Boolean(item)),
createdAt: readNumber(payload.created_at) ?? readNumber(payload.createdAt) ?? Date.now()
};
}
export function cancelRunningTodos(todoUpdate: AgentTodoUpdate | undefined) {
return todoUpdate
? {
...todoUpdate,
todos: todoUpdate.todos.map((todo) =>
todo.status === "pending" || todo.status === "in_progress"
? {
...todo,
status: "cancelled" as const,
updatedAt: Date.now()
}
: todo
)
}
: undefined;
}
export function upsertPermission(
permissions: AgentPermissionRequest[] | undefined,
data: unknown
) {
const payload = asRecord(data);
const requestId = readString(payload.request_id) ?? readString(payload.requestId);
const sessionId = readString(payload.session_id) ?? readString(payload.sessionId);
if (!requestId || !sessionId) {
return permissions;
}
const nextItem: AgentPermissionRequest = {
requestId,
sessionId,
permission: readString(payload.permission) ?? "unknown",
patterns: readStringArray(payload.patterns),
target: readString(payload.target),
always: readStringArray(payload.always),
tool: toToolRef(payload.tool),
createdAt: readNumber(payload.created_at) ?? readNumber(payload.createdAt) ?? Date.now(),
status: "pending"
};
const next = [...(permissions ?? [])];
const index = next.findIndex((permission) => permission.requestId === requestId);
if (index >= 0) {
next[index] = {
...next[index],
...nextItem,
status: next[index].status === "submitting" ? "submitting" : nextItem.status
};
} else {
next.push(nextItem);
}
return next;
}
export function applyPermissionResponse(
permissions: AgentPermissionRequest[] | undefined,
data: unknown
) {
const payload = asRecord(data);
const requestId = readString(payload.request_id) ?? readString(payload.requestId);
if (!requestId) {
return permissions;
}
return permissions?.map((permission) =>
permission.requestId === requestId
? {
...permission,
status: toPermissionStatus(readString(payload.reply)),
repliedAt: Date.now(),
error: undefined
}
: permission
);
}
export function upsertQuestion(questions: AgentQuestionRequest[] | undefined, data: unknown) {
const payload = asRecord(data);
const requestId = readString(payload.request_id) ?? readString(payload.requestId);
const sessionId = readString(payload.session_id) ?? readString(payload.sessionId);
const rawQuestions = Array.isArray(payload.questions) ? payload.questions : [];
if (!requestId || !sessionId || rawQuestions.length === 0) {
return questions;
}
const nextItem: AgentQuestionRequest = {
requestId,
sessionId,
questions: rawQuestions.map(toQuestionInfo).filter((item): item is AgentQuestionInfo => Boolean(item)),
tool: toToolRef(payload.tool),
createdAt: readNumber(payload.created_at) ?? readNumber(payload.createdAt) ?? Date.now(),
status: "pending"
};
const next = [...(questions ?? [])];
const index = next.findIndex((question) => isSameQuestion(question, nextItem));
if (index >= 0) {
next[index] = {
...next[index],
...nextItem,
status: next[index].status === "submitting" ? "submitting" : nextItem.status
};
} else {
next.push(nextItem);
}
return next;
}
export function applyQuestionResponse(questions: AgentQuestionRequest[] | undefined, data: unknown) {
const payload = asRecord(data);
const requestId = readString(payload.request_id) ?? readString(payload.requestId);
if (!requestId) {
return questions;
}
const answers = Array.isArray(payload.answers)
? payload.answers.map((answer) => (Array.isArray(answer) ? answer.map(String) : []))
: undefined;
const rejected = payload.rejected === true;
return questions?.map((question) =>
question.requestId === requestId
? {
...question,
status: rejected ? ("rejected" as const) : ("answered" as const),
answers: answers ?? question.answers,
repliedAt: Date.now(),
error: undefined
}
: question
);
}
export function finalizeAssistantMessageAfterAbort(message: AgentChatMessage): AgentChatMessage {
const progress = completeRunningProgress(message.progress);
const todos = cancelRunningTodos(message.todos);
return {
...message,
content: message.content || "请求已中止。",
progress,
todos,
permissions: message.permissions?.map((permission) =>
permission.status === "pending" || permission.status === "submitting" || permission.status === "error"
? {
...permission,
status: "aborted" as const,
repliedAt: Date.now(),
error: undefined
}
: permission
),
questions: message.questions?.map((question) =>
question.status === "pending" || question.status === "submitting" || question.status === "error"
? {
...question,
status: "rejected" as const,
repliedAt: Date.now(),
error: undefined
}
: question
)
};
}
function toPermissionStatus(reply: string | undefined): AgentPermissionStatus {
if (reply === "always") {
return "approved_always";
}
if (reply === "once") {
return "approved_once";
}
return "rejected";
}
function isSameQuestion(left: AgentQuestionRequest, right: AgentQuestionRequest) {
if (left.requestId === right.requestId) {
return true;
}
return Boolean(left.tool?.callID && right.tool?.callID && left.tool.callID === right.tool.callID);
}
function toQuestionInfo(value: unknown): AgentQuestionInfo | null {
const payload = asRecord(value);
const question = readString(payload.question);
if (!question) {
return null;
}
return {
header: readString(payload.header) ?? "",
question,
options: Array.isArray(payload.options)
? payload.options
.map((option) => {
const item = asRecord(option);
const label = readString(item.label);
return label
? {
label,
description: readString(item.description) ?? ""
}
: null;
})
.filter((item): item is { label: string; description: string } => Boolean(item))
: [],
multiple: typeof payload.multiple === "boolean" ? payload.multiple : undefined,
custom: typeof payload.custom === "boolean" ? payload.custom : undefined
};
}
function toTodoItem(value: unknown): AgentTodoItem | null {
const payload = asRecord(value);
const id = readString(payload.id);
const content = readString(payload.content);
const status = readTodoStatus(payload.status);
if (!id || !content || !status) {
return null;
}
return {
id,
content,
status,
priority: readTodoPriority(payload.priority),
createdAt: readNumber(payload.created_at) ?? readNumber(payload.createdAt),
updatedAt: readNumber(payload.updated_at) ?? readNumber(payload.updatedAt)
};
}
function toToolRef(value: unknown): AgentInteractionToolRef | undefined {
const payload = asRecord(value);
const messageID = readString(payload.messageID);
const callID = readString(payload.callID);
return messageID && callID ? { messageID, callID } : undefined;
}
function readProgressStatus(value: unknown): AgentChatProgress["status"] {
return value === "completed" || value === "error" ? value : "running";
}
function readTodoStatus(value: unknown): AgentTodoItem["status"] | undefined {
if (value === "pending" || value === "in_progress" || value === "completed" || value === "cancelled") {
return value;
}
return undefined;
}
function readTodoPriority(value: unknown): AgentTodoItem["priority"] | undefined {
if (value === "low" || value === "medium" || value === "high") {
return value;
}
return undefined;
}
function asRecord(value: unknown): RecordValue {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as RecordValue)
: {};
}
function readString(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readNumber(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function readStringArray(value: unknown) {
return Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : [];
}
@@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
createStreamingMarkdownBufferState,
isMarkdownOpenerBoundary,
resolveStreamingMarkdownDisplayContent,
splitStreamingText,
splitStreamingTextFallback,
takeNextStreamingMarkdownChunk,
} from "./components/streaming-token-response-state";
describe("streaming token response state", () => {
it("splits streaming text without dropping content", () => {
const content = "管网压力异常需要继续分析";
const chunks = splitStreamingText(content, "zh");
expect(chunks.length).toBeGreaterThan(0);
expect(chunks.join("")).toBe(content);
});
it("uses bounded code point chunks for fallback segmentation", () => {
expect(splitStreamingTextFallback("管网压力")).toEqual(["管网", "压力"]);
});
it("emits continuous Chinese text without waiting for markdown block closure", () => {
let state = createStreamingMarkdownBufferState("正在分析管网压力异常需要继续分析");
const chunks: string[] = [];
for (let index = 0; index < 20; index += 1) {
const next = takeNextStreamingMarkdownChunk(state);
state = next.state;
if (!next.chunk) {
break;
}
chunks.push(next.chunk);
}
expect(chunks.length).toBeGreaterThan(0);
expect(chunks.join("")).toBe("正在分析管网压力异常需要继续分析");
});
it("flushes a heading opener atomically", () => {
const next = takeNextStreamingMarkdownChunk(
createStreamingMarkdownBufferState("### 压力分析")
);
expect(next.chunk).toBe("### 压");
expect(isMarkdownOpenerBoundary("### 压力分析")).toBe(true);
});
it("waits for a heading opener to include text", () => {
const next = takeNextStreamingMarkdownChunk(
createStreamingMarkdownBufferState("### ")
);
expect(next.chunk).toBe("");
expect(next.state.displayContent).toBe("");
});
it("flushes list and quote openers atomically", () => {
expect(
takeNextStreamingMarkdownChunk(
createStreamingMarkdownBufferState("- 节点 A")
).chunk
).toBe("- 节");
expect(
takeNextStreamingMarkdownChunk(
createStreamingMarkdownBufferState("> 注意压力")
).chunk
).toBe("> 注");
});
it("flushes code and mermaid fence openers as complete lines", () => {
expect(
takeNextStreamingMarkdownChunk(
createStreamingMarkdownBufferState("```ts\nconst pressure = 1;")
).chunk
).toBe("```ts\n");
expect(
takeNextStreamingMarkdownChunk(
createStreamingMarkdownBufferState("```mermaid\ngraph TD")
).chunk
).toBe("```mermaid\n");
});
it("waits for fenced code openers to reach the line ending", () => {
const next = takeNextStreamingMarkdownChunk(
createStreamingMarkdownBufferState("```ts")
);
expect(next.chunk).toBe("");
expect(next.state.displayContent).toBe("");
});
it("flushes table header and separator rows atomically", () => {
const content = "| 节点 | 压力 |\n| --- | --- |\n| J1 | 0.32 |\n\n后续";
expect(
takeNextStreamingMarkdownChunk(
createStreamingMarkdownBufferState(content)
).chunk
).toBe("| 节点 | 压力 |\n| --- | --- |\n");
});
it("grows table rows after the table opener is visible", () => {
const tableOpener = "| 节点 | 压力 |\n| --- | --- |\n";
const state = createStreamingMarkdownBufferState(
`${tableOpener}| J1 | 0.32 |`,
tableOpener
);
expect(takeNextStreamingMarkdownChunk(state).chunk).not.toBe("");
});
it("flushes all display content after streaming is done", () => {
const content = "前文\n\n```ts\nconst pressure = 1;";
expect(resolveStreamingMarkdownDisplayContent(content, "前文", true)).toBe(content);
});
it("StreamingTokenResponse does not use closed-block markdown gating", () => {
const source = readFileSync(
join(process.cwd(), "features/agent/components/streaming-token-response.tsx"),
"utf8"
);
expect(source).not.toContain("splitStreamingMarkdownContent");
});
});
+119
View File
@@ -0,0 +1,119 @@
export type AgentChatMessage = {
id: string;
role: "user" | "assistant";
content: string;
progress?: AgentChatProgress[];
permissions?: AgentPermissionRequest[];
questions?: AgentQuestionRequest[];
todos?: AgentTodoUpdate;
};
export type AgentStreamRenderChunk = {
id: number;
text: string;
};
export type AgentStreamRenderMessageState = {
chunks: AgentStreamRenderChunk[];
done: boolean;
};
export type AgentStreamRenderState = Record<string, AgentStreamRenderMessageState>;
export type AgentChatProgress = {
id: string;
phase: string;
status: "running" | "completed" | "error";
title: string;
detail?: string;
startedAt?: number;
endedAt?: number;
elapsedMs?: number;
elapsedSnapshotAt?: number;
durationMs?: number;
};
export type AgentPermissionStatus =
| "pending"
| "submitting"
| "approved_once"
| "approved_always"
| "rejected"
| "aborted"
| "error";
export type AgentPermissionReply = "once" | "always" | "reject";
export type AgentModelOption = {
id: string;
label: string;
description: string;
icon: "bolt" | "sparkle";
};
export type AgentPermissionRequest = {
requestId: string;
sessionId: string;
permission: string;
patterns: string[];
target?: string;
always: string[];
tool?: AgentInteractionToolRef;
createdAt: number;
repliedAt?: number;
status: AgentPermissionStatus;
error?: string;
};
export type AgentQuestionOption = {
label: string;
description: string;
};
export type AgentQuestionInfo = {
header: string;
question: string;
options: AgentQuestionOption[];
multiple?: boolean;
custom?: boolean;
};
export type AgentQuestionRequest = {
requestId: string;
sessionId: string;
questions: AgentQuestionInfo[];
tool?: AgentInteractionToolRef;
createdAt: number;
repliedAt?: number;
status: "pending" | "submitting" | "answered" | "rejected" | "error";
answers?: string[][];
error?: string;
};
export type AgentTodoItem = {
id: string;
content: string;
status: "pending" | "in_progress" | "completed" | "cancelled";
priority?: "low" | "medium" | "high";
createdAt?: number;
updatedAt?: number;
};
export type AgentTodoUpdate = {
sessionId: string;
messageId?: string;
todos: AgentTodoItem[];
createdAt: number;
};
export type AgentInteractionToolRef = {
messageID: string;
callID: string;
};
export type AgentUiResult = {
id: string;
sessionId: string;
createdAt: number;
envelope: import("./ui-envelope").UIEnvelope;
};
+8
View File
@@ -0,0 +1,8 @@
export type { UIEnvelope, UIEnvelopePayload, UIRegistry, UISurface } from "./types";
export { toTrustedMapAction, type TrustedMapAction } from "./map-actions";
export {
isUiEnvelopeAllowed,
parseUiEnvelope,
parseUiEnvelopePayload,
parseUiRegistry
} from "./validator";
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { toTrustedMapAction } from "./map-actions";
describe("toTrustedMapAction", () => {
it("rejects zoom_to_map actions without a valid center", () => {
expect(toTrustedMapAction("zoom_to_map", { zoom: 14 })).toBeNull();
expect(toTrustedMapAction("zoom_to_map", { center: ["east", 39.1] })).toBeNull();
});
it("rejects unsupported map actions", () => {
expect(toTrustedMapAction("run_javascript", { code: "alert(1)" })).toBeNull();
});
it("downgrades malformed apply_layer_style params to safe undefined fields", () => {
expect(
toTrustedMapAction("apply_layer_style", {
layer_group_id: 123,
layer_id: [],
visible: "true"
})
).toEqual({
type: "apply_layer_style",
layerGroupId: undefined,
layerId: undefined,
visible: undefined,
fallbackText: undefined
});
});
it("accepts valid zoom_to_map coordinates", () => {
expect(toTrustedMapAction("zoom_to_map", { lng: 117.2, lat: 39.1, zoom: 13 })).toEqual({
type: "zoom_to_map",
center: [117.2, 39.1],
zoom: 13,
fallbackText: undefined
});
});
it("accepts legacy zoom coordinate aliases and numeric strings", () => {
expect(toTrustedMapAction("zoom_to_map", { coordinate: ["117.2", "39.1"], zoom: "13" })).toEqual({
type: "zoom_to_map",
center: [117.2, 39.1],
zoom: 13,
fallbackText: undefined
});
expect(toTrustedMapAction("zoom_to_map", { x: "117.2", y: "39.1" })).toMatchObject({
type: "zoom_to_map",
center: [117.2, 39.1]
});
});
it("normalizes legacy locate tool actions", () => {
expect(toTrustedMapAction("locate_pipes", { pipe_ids: "P1,P2" })).toEqual({
type: "locate_features",
featureIds: ["P1", "P2"],
layer: "geo_pipes_mat",
fallbackText: undefined
});
});
it("normalizes numeric locate ids", () => {
expect(toTrustedMapAction("locate_junctions", { junction_id: 42 })).toMatchObject({
type: "locate_features",
featureIds: ["42"],
layer: "geo_junctions_mat"
});
});
});
+185
View File
@@ -0,0 +1,185 @@
export type TrustedMapAction =
| {
type: "locate_features";
featureIds: string[];
layer?: string;
fallbackText?: string;
}
| {
type: "zoom_to_map";
center: [number, number];
zoom?: number;
fallbackText?: string;
}
| {
type: "apply_layer_style";
layerGroupId?: string;
layerId?: string;
visible?: boolean;
fallbackText?: string;
}
| {
type: "render_junctions";
renderRef: string;
fallbackText?: string;
};
export function toTrustedMapAction(action: string, params: unknown, fallbackText?: string): TrustedMapAction | null {
if (!isRecord(params)) {
return null;
}
if (action === "locate_features" || action in LEGACY_LOCATE_ACTION_LAYERS) {
const featureIds = readLocateIds(params);
return {
type: "locate_features",
featureIds,
layer:
stringValue(params.layer ?? params.target_layer ?? params.targetLayer) ??
LEGACY_LOCATE_ACTION_LAYERS[action],
fallbackText
};
}
if (action === "zoom_to_map") {
const center = parseCenter(params);
if (!center) {
return null;
}
return {
type: "zoom_to_map",
center,
zoom: numberValue(params.zoom),
fallbackText
};
}
if (action === "apply_layer_style") {
return {
type: "apply_layer_style",
layerGroupId: stringValue(params.layer_group_id ?? params.layerGroupId),
layerId: stringValue(params.layer_id ?? params.layerId),
visible: booleanValue(params.visible),
fallbackText
};
}
if (action === "render_junctions") {
const renderRef = stringValue(params.render_ref ?? params.renderRef);
if (!renderRef) {
return null;
}
return {
type: "render_junctions",
renderRef,
fallbackText
};
}
return null;
}
function parseCenter(params: Record<string, unknown>): [number, number] | null {
if (Array.isArray(params.center) && params.center.length >= 2) {
const lng = numberValue(params.center[0]);
const lat = numberValue(params.center[1]);
return lng === undefined || lat === undefined ? null : [lng, lat];
}
const rawCoordinate = params.coordinate ?? params.coordinates;
if (Array.isArray(rawCoordinate) && rawCoordinate.length >= 2) {
const lng = numberValue(rawCoordinate[0]);
const lat = numberValue(rawCoordinate[1]);
return lng === undefined || lat === undefined ? null : [lng, lat];
}
const lng = numberValue(params.lng ?? params.lon ?? params.longitude ?? params.x);
const lat = numberValue(params.lat ?? params.latitude ?? params.y);
return lng === undefined || lat === undefined ? null : [lng, lat];
}
function stringValue(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function numberValue(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
function booleanValue(value: unknown) {
return typeof value === "boolean" ? value : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
const LEGACY_LOCATE_ACTION_LAYERS: Record<string, string> = {
locate_junctions: "geo_junctions_mat",
locate_pipes: "geo_pipes_mat",
locate_valves: "geo_valves",
locate_reservoirs: "geo_reservoirs",
locate_pumps: "geo_pumps",
locate_tanks: "geo_tanks"
};
const LOCATE_ID_PARAM_KEYS = [
"feature_ids",
"feature_id",
"featureIds",
"featureId",
"ids",
"id",
"node_ids",
"node_id",
"junction_ids",
"junction_id",
"pipe_ids",
"pipe_id",
"valve_ids",
"valve_id",
"reservoir_ids",
"reservoir_id",
"pump_ids",
"pump_id",
"tank_ids",
"tank_id"
] as const;
function readLocateIds(params: Record<string, unknown>) {
for (const key of LOCATE_ID_PARAM_KEYS) {
const value = params[key];
const ids = normalizeIds(value);
if (ids.length > 0) {
return ids;
}
}
return [];
}
function normalizeIds(value: unknown) {
if (Array.isArray(value)) {
return value.map((item) => String(item).trim()).filter(Boolean);
}
if (typeof value === "string" || typeof value === "number") {
return String(value)
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
return [];
}
+43
View File
@@ -0,0 +1,43 @@
export type UISurface = "chat_inline" | "side_panel" | "canvas" | "map_overlay";
export type UIEnvelope =
| {
kind: "registered_component";
schemaVersion: "agent-ui@1";
component: string;
surface: UISurface;
props: unknown;
data?: unknown;
fallbackText?: string;
}
| {
kind: "chart";
schemaVersion: "agent-ui@1";
grammar: "echarts-safe-subset";
surface: "chat_inline" | "side_panel" | "canvas";
spec: unknown;
data: unknown;
fallbackText?: string;
}
| {
kind: "map_action";
schemaVersion: "agent-ui@1";
action: string;
surface: "map_overlay";
params: unknown;
fallbackText?: string;
};
export type UIEnvelopePayload = {
session_id: string;
envelope_id: string;
created_at: number;
envelope: UIEnvelope;
};
export type UIRegistry = {
schema_version: "agent-ui-registry@1";
chart_grammars: string[];
components: Array<{ id: string; supportedSurfaces: UISurface[] }>;
actions: Array<{ id: string; supportedSurfaces: UISurface[] }>;
};
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { isUiEnvelopeAllowed, parseUiEnvelope, parseUiRegistry } from "./validator";
import type { UIRegistry } from "./types";
const registry: UIRegistry = {
schema_version: "agent-ui-registry@1",
chart_grammars: ["echarts-safe-subset"],
components: [
{ id: "HistoryPanel", supportedSurfaces: ["side_panel", "canvas"] },
{ id: "ScadaPanel", supportedSurfaces: ["side_panel", "canvas"] }
],
actions: [{ id: "zoom_to_map", supportedSurfaces: ["map_overlay"] }]
};
describe("UIEnvelope validation", () => {
it("rejects registered components outside the frontend allowlist", () => {
const envelope = parseUiEnvelope({
kind: "registered_component",
schemaVersion: "agent-ui@1",
component: "UnsafePanel",
surface: "side_panel",
props: {}
});
expect(envelope).not.toBeNull();
expect(envelope ? isUiEnvelopeAllowed(envelope, registry) : false).toBe(false);
});
it("rejects registered components on unsupported surfaces", () => {
const envelope = parseUiEnvelope({
kind: "registered_component",
schemaVersion: "agent-ui@1",
component: "HistoryPanel",
surface: "chat_inline",
props: {}
});
expect(envelope).not.toBeNull();
expect(envelope ? isUiEnvelopeAllowed(envelope, registry) : false).toBe(false);
});
it("accepts only the echarts-safe-subset chart grammar", () => {
expect(
parseUiEnvelope({
kind: "chart",
schemaVersion: "agent-ui@1",
grammar: "vega-lite",
surface: "chat_inline",
spec: {},
data: {}
})
).toBeNull();
const envelope = parseUiEnvelope({
kind: "chart",
schemaVersion: "agent-ui@1",
grammar: "echarts-safe-subset",
surface: "chat_inline",
spec: {},
data: {}
});
expect(envelope).not.toBeNull();
expect(envelope ? isUiEnvelopeAllowed(envelope, registry) : false).toBe(true);
});
it("filters invalid registry surfaces before allowlist checks", () => {
const parsed = parseUiRegistry({
schema_version: "agent-ui-registry@1",
chart_grammars: ["echarts-safe-subset"],
components: [{ id: "HistoryPanel", supportedSurfaces: ["side_panel", "unsafe_surface"] }],
actions: []
});
expect(parsed?.components[0]?.supportedSurfaces).toEqual(["side_panel"]);
});
});
+141
View File
@@ -0,0 +1,141 @@
import type { UIEnvelope, UIEnvelopePayload, UIRegistry, UISurface } from "./types";
const surfaces = new Set<UISurface>(["chat_inline", "side_panel", "canvas", "map_overlay"]);
export function parseUiEnvelopePayload(value: unknown): UIEnvelopePayload | null {
if (!isRecord(value)) {
return null;
}
const envelope = parseUiEnvelope(value.envelope);
if (!envelope) {
return null;
}
return {
session_id: typeof value.session_id === "string" ? value.session_id : "",
envelope_id: typeof value.envelope_id === "string" ? value.envelope_id : "",
created_at: typeof value.created_at === "number" ? value.created_at : Date.now(),
envelope
};
}
export function parseUiEnvelope(value: unknown): UIEnvelope | null {
if (!isRecord(value) || value.schemaVersion !== "agent-ui@1") {
return null;
}
if (value.kind === "registered_component") {
if (typeof value.component !== "string" || !isSurface(value.surface)) {
return null;
}
return {
kind: "registered_component",
schemaVersion: "agent-ui@1",
component: value.component,
surface: value.surface,
props: value.props,
data: value.data,
fallbackText: optionalString(value.fallbackText)
};
}
if (value.kind === "chart") {
if (value.grammar !== "echarts-safe-subset" || !isChartSurface(value.surface)) {
return null;
}
return {
kind: "chart",
schemaVersion: "agent-ui@1",
grammar: "echarts-safe-subset",
surface: value.surface,
spec: value.spec,
data: value.data,
fallbackText: optionalString(value.fallbackText)
};
}
if (value.kind === "map_action") {
if (typeof value.action !== "string" || value.surface !== "map_overlay") {
return null;
}
return {
kind: "map_action",
schemaVersion: "agent-ui@1",
action: value.action,
surface: "map_overlay",
params: value.params,
fallbackText: optionalString(value.fallbackText)
};
}
return null;
}
export function isUiEnvelopeAllowed(envelope: UIEnvelope, registry: UIRegistry | null) {
if (!registry) {
return true;
}
if (envelope.kind === "registered_component") {
const manifest = registry.components.find((item) => item.id === envelope.component);
return Boolean(manifest?.supportedSurfaces.includes(envelope.surface));
}
if (envelope.kind === "chart") {
return registry.chart_grammars.includes(envelope.grammar);
}
const manifest = registry.actions.find((item) => item.id === envelope.action);
return Boolean(manifest?.supportedSurfaces.includes(envelope.surface));
}
export function parseUiRegistry(value: unknown): UIRegistry | null {
if (!isRecord(value) || value.schema_version !== "agent-ui-registry@1") {
return null;
}
return {
schema_version: "agent-ui-registry@1",
chart_grammars: stringArray(value.chart_grammars),
components: manifestArray(value.components),
actions: manifestArray(value.actions)
};
}
function manifestArray(value: unknown) {
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((item) => {
if (!isRecord(item) || typeof item.id !== "string") {
return [];
}
return [{ id: item.id, supportedSurfaces: stringArray(item.supportedSurfaces).filter(isSurface) }];
});
}
function stringArray(value: unknown) {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
}
function isChartSurface(value: unknown): value is "chat_inline" | "side_panel" | "canvas" {
return value === "chat_inline" || value === "side_panel" || value === "canvas";
}
function isSurface(value: unknown): value is UISurface {
return typeof value === "string" && surfaces.has(value as UISurface);
}
function optionalString(value: unknown) {
return typeof value === "string" ? value : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}