feat: initialize drainage network frontend
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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 || "工具权限请求";
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user