feat(agent): support Responses steering and project-aware tools

Use the dedicated TJWATER_AGENT_URL and Responses stream protocol. Keep running
turns active while users steer or queue follow-ups and edit pending delivery.
Refresh the model catalog before generation and preserve approval mode selection.

Integrate scoped investigation messaging/presentation, geographic place selection,
current Agent composer styles and supporting workbench contracts.

Validation: 77 focused Vitest checks, 21 browser cases, lint, typecheck and
production build. Preserve direct Server/Agent routing and project authentication.
This commit is contained in:
2026-10-19 18:46:58 +08:00
parent 2c5bf15cce
commit ec23d44b02
131 changed files with 6663 additions and 1204 deletions
@@ -0,0 +1,33 @@
import { afterEach, expect, it, vi } from "vitest";
import { createAgentApiClient } from "./client";
afterEach(() => vi.unstubAllGlobals());
it("reads mailbox pages without sending or consuming messages and validates the contract", async () => {
const message = { message_id: "m1", task_id: "t1", run_id: "r1", source_session_id: "task-session", target_session_id: "chat-session", direction: "to_chat", text: "现场记录已核对", status: "pending", in_reply_to: null, created_at: "2026-10-13T00:00:00Z", source_owner_id: "private-owner" };
const fetchMock = vi.fn().mockResolvedValue(Response.json({ items: [message], next_cursor: "m1" }));
vi.stubGlobal("fetch", fetchMock);
const client = createAgentApiClient("http://agent.local", { getAccessToken: async () => "token", getProjectId: async () => "p1" });
const page = await client.listAgentMessages("chat-session", { cursor: "previous" });
expect(page.items[0]).not.toHaveProperty("source_owner_id");
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("http://agent.local/api/v1/session/chat-session/agent-messages?limit=50&cursor=previous");
expect(init.method).toBeUndefined();
expect(init.body).toBeUndefined();
expect(new Headers(init.headers).get("X-Project-Id")).toBe("p1");
fetchMock.mockResolvedValue(Response.json({ items: [{ ...message, target_session_id: null, direction: "to_task" }], next_cursor: null }));
expect((await client.listAgentMessages("chat-session")).items[0].target_session_id).toBeNull();
fetchMock.mockResolvedValue(Response.json({ items: [{ ...message, status: "invented" }], next_cursor: null }));
await expect(client.listAgentMessages("chat-session")).rejects.toThrow();
});
it("accepts inbox notifications only for the subscribed session", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response([
'event: agent_message\ndata: {"session_id":"other"}\n\n',
'event: agent_message\ndata: {"session_id":"main"}\n\n',
': heartbeat\n\n'
].join(""))));
const onMessage = vi.fn();
await createAgentApiClient("http://agent.local").streamAgentMessages("main", { onMessage });
expect(onMessage).toHaveBeenCalledOnce();
});
+21
View File
@@ -0,0 +1,21 @@
import { z } from "zod";
export const agentMessageSchema = z.object({
message_id: z.string().min(1),
task_id: z.string().min(1),
run_id: z.string().min(1),
source_session_id: z.string().min(1),
target_session_id: z.string().min(1).nullable(),
direction: z.enum(["to_task", "to_chat"]),
text: z.string(),
status: z.enum(["pending", "enqueued", "delivered", "failed"]),
in_reply_to: z.string().nullable(),
created_at: z.string(),
failure_detail: z.string().nullable().optional()
});
export const agentMessagePageSchema = z.object({
items: z.array(agentMessageSchema),
next_cursor: z.string().nullable()
});
export type AgentMailboxMessage = z.infer<typeof agentMessageSchema>;
export type AgentMessagePage = z.infer<typeof agentMessagePageSchema>;
+50 -3
View File
@@ -1,7 +1,54 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createAgentApiClient } from "./client";
describe("Agent prompt suggestions", () => {
it("submits and edits pending input through the scoped Responses and session endpoints", async () => {
const input = { id: `msg_${"1".repeat(32)}`, text: "补充压力测点", delivery: "steer", status: "pending" };
const fetchMock = vi.fn().mockResolvedValueOnce(Response.json(input)).mockResolvedValueOnce(Response.json({ items: [input] }))
.mockResolvedValueOnce(Response.json({ ...input, delivery: "queue" }));
vi.stubGlobal("fetch", fetchMock);
const client = createAgentApiClient("http://agent.local", { getAccessToken: async () => "token", getProjectId: async () => "project" });
await expect(client.submitResponseInput("session-1", input.text, "steer", input.id)).resolves.toEqual(input);
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ input: input.text, conversation: "session-1", stream: false, tjwater: { delivery: "steer", input_id: input.id } });
await expect(client.listResponseInputs("session-1")).resolves.toEqual([input]);
await expect(client.updateResponseInput("session-1", input.id, "queue")).resolves.toEqual({ ...input, delivery: "queue" });
expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://agent.local/api/v1/responses");
expect(String(fetchMock.mock.calls[2]?.[0])).toContain(`/api/v1/session/session-1/inputs/${input.id}`);
for (const [, request] of fetchMock.mock.calls) {
expect(new Headers(request?.headers).get("Authorization")).toBe("Bearer token");
expect(new Headers(request?.headers).get("X-Project-Id")).toBe("project");
}
});
it("uses the scoped Agent request and rejects invented activity references", async () => {
const fetchMock = vi.fn().mockResolvedValue(Response.json({ suggestions: [{ question: "核对城南压力告警的证据", activity_id: "alert:pressure" }] }));
vi.stubGlobal("fetch", fetchMock);
const client = createAgentApiClient("http://agent.local", { getAccessToken: async () => "token", getProjectId: async () => "project" });
const activities = [{ id: "alert:pressure", kind: "alert" as const, title: "城南压力告警", occurred_at: "2026-10-09T07:00:00Z", origin: "backend" as const }];
const controller = new AbortController();
expect(await client.generatePromptSuggestions(activities, "test/model", controller.signal)).toEqual([{ question: "核对城南压力告警的证据", activityId: "alert:pressure" }]);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("http://agent.local/api/v1/suggestions");
expect(new Headers(init.headers).get("Authorization")).toBe("Bearer token");
expect(new Headers(init.headers).get("X-Project-Id")).toBe("project");
expect(init.signal).toBe(controller.signal);
fetchMock.mockResolvedValue(Response.json({ suggestions: [{ question: "引用其他项目", activity_id: "unknown" }] }));
await expect(client.generatePromptSuggestions(activities, "test/model")).rejects.toThrow("Invalid Agent suggestion");
vi.unstubAllGlobals();
});
});
describe("Agent API client sessions", () => {
it("forces renewal and sends only the refresh request id with verified context headers", async () => {
const getAccessToken = vi.fn(async () => "TEST_NEW_TOKEN");
const fetchMock = vi.fn(async () => Response.json({ accepted: true }, { status: 202 }));
vi.stubGlobal("fetch", fetchMock);
await createAgentApiClient("http://agent.local", { getAccessToken, getProjectId: async () => "p" }).refreshCredential("s", "request-1");
expect(getAccessToken).toHaveBeenCalledWith(true);
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(new Headers(init.headers).get("Authorization")).toBe("Bearer TEST_NEW_TOKEN");
expect(JSON.parse(String(init.body))).toEqual({ request_id: "request-1" });
});
afterEach(() => {
vi.unstubAllGlobals();
});
@@ -85,10 +132,10 @@ describe("Agent API client sessions", () => {
});
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"http://not-agent.local/api/v1/agent/chat/ui-registry",
"http://not-agent.local/api/v1/ui-registry",
undefined
);
expect(fetchMock).toHaveBeenNthCalledWith(2, "http://agent.local/api/v1/agent/chat/ui-registry", undefined);
expect(fetchMock).toHaveBeenNthCalledWith(2, "http://agent.local/api/v1/ui-registry", undefined);
});
it("sends title update payloads using backend field names", async () => {
@@ -100,7 +147,7 @@ describe("Agent API client sessions", () => {
});
expect(fetchMock).toHaveBeenCalledWith(
"http://agent.local/api/v1/agent/chat/session/session-1/title",
"http://agent.local/api/v1/session/session-1/title",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({
+103 -6
View File
@@ -1,5 +1,6 @@
import { env } from "@/shared/config/env";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { agentMessagePageSchema, type AgentMessagePage } from "./agent-messages";
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
export type AgentSessionPurpose = "ordinary_chat" | "task_execution" | "task_collaboration";
@@ -37,6 +38,9 @@ export type AgentModelsResponse = {
models: AgentModelOption[];
};
export type AgentInputDelivery = "steer" | "queue";
export type AgentResponseInput = { id: string; text: string; delivery: AgentInputDelivery; status: "pending" | "delivered" };
export type AgentChatSession = {
id?: string;
session_id: string;
@@ -70,6 +74,21 @@ export type AgentChatSessionSummary = {
taskId?: string;
};
export type AgentRecentActivity = {
id: string;
kind: "alert" | "task" | "analysis" | "session";
title: string;
occurred_at: string;
summary?: string;
status?: string;
origin: "backend" | "agent" | "demo";
};
export type AgentPromptSuggestion = {
question: string;
activityId: string | null;
};
export type AgentLoadedChatSession = AgentChatSessionSummary & {
sessionId: string;
isTitleManuallyEdited: boolean;
@@ -94,7 +113,12 @@ export type AgentSessionStreamDataEventType =
| "session_title"
| "done"
| "error"
| "auth_required";
| "auth_required"
| "credential_refresh_required"
| "credential_refreshed"
| "credential_refresh_failed"
| "agent_message"
| "user_input";
export type AgentSessionStreamEvent =
| {
@@ -112,6 +136,12 @@ export type AgentSessionStreamEvent =
};
export type AgentApiClient = {
submitResponseInput: (sessionId: string, text: string, delivery: AgentInputDelivery, id: string) => Promise<AgentResponseInput>;
listResponseInputs: (sessionId: string) => Promise<AgentResponseInput[]>;
updateResponseInput: (sessionId: string, id: string, delivery: AgentInputDelivery) => Promise<AgentResponseInput>;
listAgentMessages: (sessionId: string, options?: { cursor?: string; signal?: AbortSignal }) => Promise<AgentMessagePage>;
streamAgentMessages: (sessionId: string, options: { signal?: AbortSignal; onMessage: () => void }) => Promise<void>;
generatePromptSuggestions: (activities: AgentRecentActivity[], model: string, signal?: AbortSignal) => Promise<AgentPromptSuggestion[]>;
createSession: (options?: { triggerContext?: AgentEventTriggerContext }) => Promise<AgentChatSession>;
listSessions: (options?: { purpose?: AgentSessionPurpose; taskId?: string }) => Promise<AgentChatSessionSummary[]>;
createTaskCollaborationSession: (taskId: string) => Promise<AgentChatSession>;
@@ -143,17 +173,18 @@ export type AgentApiClient = {
options: { sessionId: string; answers: string[][] }
) => Promise<void>;
rejectQuestion: (requestId: string, options: { sessionId: string }) => Promise<void>;
refreshCredential: (sessionId: string, requestId: string) => Promise<void>;
abort: (sessionId: string) => Promise<void>;
};
export type AgentApiClientOptions = {
getAccessToken?: AccessTokenProvider;
getProjectId?: () => Promise<string>;
getProjectId?: () => Promise<string | undefined>;
};
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")];
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_URL.replace(/\/$/, "")];
const CHAT_PATH = "/api/v1/agent/chat";
const API_PATH = "/api/v1";
export function createAgentApiClient(
baseUrls: string | string[] = AGENT_API_BASE_URLS,
@@ -187,6 +218,44 @@ export function createAgentApiClient(
};
return {
async submitResponseInput(sessionId, text, delivery, id) {
return requestJson<AgentResponseInput>("/responses", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: text, conversation: sessionId, stream: false, tjwater: { delivery, input_id: id } })
});
},
async listResponseInputs(sessionId) {
const result = await requestJson<{ items: AgentResponseInput[] }>(`/session/${encodeURIComponent(sessionId)}/inputs`);
return result.items;
},
async updateResponseInput(sessionId, id, delivery) {
return requestJson<AgentResponseInput>(`/session/${encodeURIComponent(sessionId)}/inputs/${encodeURIComponent(id)}`, {
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delivery })
});
},
async generatePromptSuggestions(activities, model, signal) {
const result = await requestJson<unknown>("/suggestions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ activities, model }),
signal,
});
if (!isRecord(result) || !Array.isArray(result.suggestions) || result.suggestions.length < 1 || result.suggestions.length > 3) {
throw new Error("Invalid Agent suggestions");
}
const ids = new Set(activities.map((item) => item.id));
const questions = new Set<string>();
return result.suggestions.map((item): AgentPromptSuggestion => {
if (!isRecord(item) || typeof item.question !== "string" || !item.question.trim() || item.question.length > 180 ||
(item.activity_id !== null && (typeof item.activity_id !== "string" || !ids.has(item.activity_id)))) {
throw new Error("Invalid Agent suggestion");
}
const question = item.question.trim();
if (questions.has(question)) throw new Error("Duplicate Agent suggestion");
questions.add(question);
return { question, activityId: item.activity_id as string | null };
});
},
async createSession(options) {
return requestJson<AgentChatSession>("/session", {
method: "POST",
@@ -254,6 +323,22 @@ export function createAgentApiClient(
await readSessionSseStream(response, options.onEvent);
},
async listAgentMessages(sessionId, options) {
const query = new URLSearchParams({ limit: "50" });
if (options?.cursor) query.set("cursor", options.cursor);
return agentMessagePageSchema.parse(await requestJson<unknown>(
`/session/${encodeURIComponent(sessionId)}/agent-messages?${query}`, { signal: options?.signal }
));
},
async streamAgentMessages(sessionId, options) {
const response = await request(`/session/${encodeURIComponent(sessionId)}/agent-messages/stream`, { signal: options.signal });
if (!response.ok) throw new Error(getResponseErrorMessage(await response.json(), response.status));
await readSessionSseStream(response, (event) => {
if (event.type === "agent_message" && event.sessionId === sessionId) options.onMessage();
});
},
async updateSessionTitle(sessionId, title, options) {
const normalizedTitle = title.trim();
if (!normalizedTitle) {
@@ -329,6 +414,13 @@ export function createAgentApiClient(
});
},
async refreshCredential(sessionId, requestId) {
await options.getAccessToken?.(true);
await requestJson(`/session/${encodeURIComponent(sessionId)}/credentials`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_id: requestId })
});
},
async abort(sessionId) {
await requestJson<unknown>("/abort", {
method: "POST",
@@ -346,7 +438,7 @@ async function fetchWithFallback(
path: string,
init?: RequestInit,
getAccessToken?: AccessTokenProvider,
getProjectId?: () => Promise<string>
getProjectId?: () => Promise<string | undefined>
) {
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
let lastError: unknown;
@@ -361,7 +453,7 @@ async function fetchWithFallback(
for (const baseUrl of orderedBaseUrls) {
try {
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, requestInit);
const response = await fetch(`${baseUrl}${API_PATH}${path}`, requestInit);
if (response.ok) {
setActiveBaseUrl(baseUrl);
return response;
@@ -603,7 +695,12 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea
value === "session_title" ||
value === "done" ||
value === "error" ||
value === "credential_refresh_required" ||
value === "credential_refreshed" ||
value === "credential_refresh_failed" ||
value === "auth_required"
|| value === "agent_message"
|| value === "user_input"
);
}
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import type { UIMessage, UIMessageChunk } from "ai";
import { createResponseRequest, responsesToUiStream } from "./responses-transport";
const bytes = (events: Record<string, unknown>[], partial = false) => {
const text = events.map((event) => `event: ${String(event.type)}\r\ndata: ${JSON.stringify(event)}\r\n\r\n`).join("");
const encoded = new TextEncoder().encode(text);
return new ReadableStream<Uint8Array>({ start(controller) {
if (partial) for (let i = 0; i < encoded.length; i += 7) controller.enqueue(encoded.slice(i, i + 7));
else controller.enqueue(encoded);
controller.close();
} });
};
const collect = async (stream: ReadableStream<UIMessageChunk>) => {
const parts: UIMessageChunk[] = [];
const reader = stream.getReader();
while (true) { const next = await reader.read(); if (next.done) break; parts.push(next.value); }
return parts;
};
const events = (): Record<string, unknown>[] => [
{ type: "response.created", sequence_number: 0, response: { id: "resp_1" } },
{ type: "response.output_item.added", sequence_number: 1, item: { type: "message", id: "msg_1", role: "assistant", content: [] } },
{ type: "response.content_part.added", sequence_number: 2, item_id: "msg_1", content_index: 0, part: { type: "output_text", text: "" } },
{ type: "response.output_text.delta", sequence_number: 3, item_id: "msg_1", content_index: 0, delta: "压力待核验" },
{ type: "tjwater.permission_request", sequence_number: 4, response_id: "resp_1", data: { request_id: "permission_1" } },
{ type: "response.output_text.done", sequence_number: 5, item_id: "msg_1", content_index: 0, text: "压力待核验" },
{ type: "response.completed", sequence_number: 6, response: { id: "resp_1", status: "completed" } },
];
describe("Responses transport", () => {
it("sends only the latest user input and keeps business options explicit", () => {
const messages = [{ id: "old", role: "assistant", parts: [{ type: "text", text: "旧回答" }] }, { id: "new", role: "user", parts: [{ type: "text", text: "继续核查" }] }] as UIMessage[];
expect(createResponseRequest(messages, { conversation: "owned", approvalMode: "always" })).toEqual({
input: [{ role: "user", content: [{ type: "input_text", text: "继续核查" }] }], stream: true, conversation: "owned",
tjwater: { approval_mode: "always", capabilities: [] },
});
});
it("decodes split UTF-8 and CRLF while retaining approval events", async () => {
expect(await collect(responsesToUiStream(bytes(events(), true)))).toEqual([
{ type: "start", messageId: "msg_1" }, { type: "text-start", id: "msg_1:0" },
{ type: "text-delta", id: "msg_1:0", delta: "压力待核验" },
{ type: "data-permission_request", data: { request_id: "permission_1" } },
{ type: "text-end", id: "msg_1:0" }, { type: "finish", finishReason: "stop" },
]);
});
it("reports premature disconnects, mismatched owners and invalid event order", async () => {
await expect(collect(responsesToUiStream(bytes(events().slice(0, 5))))).rejects.toThrow("断开");
const wrong = events(); wrong[4] = { ...wrong[4], response_id: "other" };
await expect(collect(responsesToUiStream(bytes(wrong)))).rejects.toThrow("归属");
await expect(collect(responsesToUiStream(bytes([events()[0]!, events()[3]!])))).rejects.toThrow();
});
it("turns failed and cancelled Responses into visible errors", async () => {
const failed = events();
failed[6] = { type: "response.failed", sequence_number: 6, response: { id: "resp_1", error: { message: "数据源不可用" } } };
expect((await collect(responsesToUiStream(bytes(failed)))).at(-2)).toEqual({ type: "error", errorText: "数据源不可用" });
});
});
@@ -0,0 +1,108 @@
import { HttpChatTransport, type UIMessage, type UIMessageChunk } from "ai";
const record = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
export function createResponseRequest(messages: UIMessage[], options: {
conversation?: string; model?: string; approvalMode?: string; capabilities?: string[];
triggerContext?: unknown; taskContext?: unknown;
}) {
const latest = [...messages].reverse().find((message) => message.role === "user");
const text = latest?.parts.filter((part) => part.type === "text").map((part) => part.text).join("\n").trim();
if (!text) throw new Error("Responses 请求需要用户文本");
return {
input: [{ role: "user", content: [{ type: "input_text", text }] }], stream: true,
...(options.model ? { model: options.model } : {}),
...(options.conversation ? { conversation: options.conversation } : {}),
tjwater: { approval_mode: options.approvalMode ?? "request", capabilities: options.capabilities ?? [],
...(options.triggerContext ? { trigger_context: options.triggerContext } : {}),
...(options.taskContext ? { task_context: options.taskContext } : {}),
},
};
}
/** Keep UI rendering independent of the public Responses wire protocol. */
export function responsesToUiStream(stream: ReadableStream<Uint8Array>): ReadableStream<UIMessageChunk> {
const decoder = new TextDecoder();
let buffer = "";
let responseId: string | undefined;
let messageStarted = false;
let messageId: string | undefined;
let terminal = false;
let sequence = -1;
const texts = new Set<string>();
const parse = (block: string, controller: TransformStreamDefaultController<UIMessageChunk>) => {
if (block.length > 1024 * 1024) throw new Error("Responses 单个流事件过大");
const lines = block.split(/\r?\n/);
const data = lines.filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n");
if (!data) return;
if (terminal) throw new Error("Responses 流在结束后仍返回事件");
const value: unknown = JSON.parse(data);
if (!record(value) || typeof value.type !== "string" || !Number.isInteger(value.sequence_number) || (value.sequence_number as number) <= sequence) throw new Error("Responses 流事件格式不正确");
sequence = value.sequence_number as number;
const named = lines.find((line) => line.startsWith("event:"))?.slice(6).trim();
if (named && named !== value.type) throw new Error("Responses 流事件类型不一致");
if (value.type === "response.created") {
if (responseId || !record(value.response) || typeof value.response.id !== "string") throw new Error("Responses 创建事件不正确");
responseId = value.response.id;
return;
}
if (!responseId) throw new Error("Responses 流缺少创建事件");
const textId = () => {
if (value.item_id !== messageId || !Number.isInteger(value.content_index) || (value.content_index as number) < 0) throw new Error("Responses 文本归属不正确");
return `${messageId}:${String(value.content_index)}`;
};
if (value.type === "response.output_item.added" && record(value.item) && value.item.type === "message") {
if (messageStarted || typeof value.item.id !== "string") throw new Error("Responses 回答消息不正确");
messageStarted = true;
messageId = value.item.id;
controller.enqueue({ type: "start", messageId: value.item.id });
} else if (value.type === "response.content_part.added" && record(value.part) && value.part.type === "output_text") {
if (!messageStarted) throw new Error("Responses 流缺少回答消息");
texts.add(textId());
controller.enqueue({ type: "text-start", id: textId() });
} else if (value.type === "response.output_text.delta") {
if (!texts.has(textId()) || typeof value.delta !== "string") throw new Error("Responses 文本事件顺序不正确");
controller.enqueue({ type: "text-delta", id: textId(), delta: value.delta });
} else if (value.type === "response.output_text.done") {
if (!texts.delete(textId())) throw new Error("Responses 文本结束事件不正确");
controller.enqueue({ type: "text-end", id: textId() });
} else if (value.type.startsWith("tjwater.")) {
if (value.response_id !== responseId || !/^tjwater\.[a-z][a-z0-9_]*$/.test(value.type)) throw new Error("业务流事件归属不正确");
controller.enqueue({ type: `data-${value.type.slice(8)}`, data: value.data,
...(typeof value.id === "string" ? { id: value.id } : {}), ...(value.transient === true ? { transient: true } : {}) } as UIMessageChunk);
} else if (["response.completed", "response.failed", "response.incomplete"].includes(value.type)) {
if (!record(value.response) || value.response.id !== responseId || texts.size) throw new Error("Responses 结束事件不正确");
terminal = true;
if (!messageStarted) controller.enqueue({ type: "start", messageId: responseId });
if (value.type !== "response.completed") {
const failure = value.response.error;
controller.enqueue({ type: "error", errorText: record(failure) && typeof failure.message === "string" ? failure.message : "请求未完成或已中止" });
}
controller.enqueue({ type: "finish", finishReason: value.type === "response.completed" ? "stop" : "error" });
}
};
const flushBlocks = (controller: TransformStreamDefaultController<UIMessageChunk>) => {
let match: RegExpExecArray | null;
while ((match = /\r?\n\r?\n/.exec(buffer))) {
const block = buffer.slice(0, match.index);
buffer = buffer.slice(match.index + match[0].length);
parse(block, controller);
}
if (buffer.length > 1024 * 1024) throw new Error("Responses 单个流事件过大");
};
return stream.pipeThrough(new TransformStream<Uint8Array, UIMessageChunk>({
transform(chunk, controller) { buffer += decoder.decode(chunk, { stream: true }); flushBlocks(controller); },
flush(controller) {
buffer += decoder.decode();
flushBlocks(controller);
if (buffer.trim()) parse(buffer, controller);
if (!terminal) throw new Error("Responses 流在完成前断开,请重新打开会话查看状态");
},
}));
}
export class ResponsesChatTransport<Message extends UIMessage> extends HttpChatTransport<Message> {
protected processResponseStream(stream: ReadableStream<Uint8Array>) {
return responsesToUiStream(stream);
}
}
+14 -1
View File
@@ -1,8 +1,21 @@
import { describe, expect, it, vi } from "vitest";
import type { AgentApiClient } from "./client";
import { fetchAgentBootstrap, fetchAgentSessions } from "./swr";
import { fetchAgentBootstrap, fetchAgentSessions, resolveAgentModelSelection } from "./swr";
describe("Agent API SWR helpers", () => {
it("preserves valid model choices and resolves removed choices against the current catalog", () => {
const models = [
{ id: "primary/fast", label: "快速", description: "Primary", icon: "bolt" as const },
{ id: "secondary/expert", label: "专家", description: "Secondary", icon: "sparkle" as const }
];
const catalog = { defaultModel: "secondary/expert", models };
expect(resolveAgentModelSelection(catalog, "primary/fast")).toBe("primary/fast");
expect(resolveAgentModelSelection(catalog, "retired/fast")).toBe("secondary/expert");
expect(resolveAgentModelSelection(catalog)).toBe("secondary/expert");
expect(resolveAgentModelSelection({ ...catalog, defaultModel: "missing/model" }, "retired/fast")).toBe("primary/fast");
expect(resolveAgentModelSelection({ defaultModel: "primary/fast", models: [] }, "primary/fast")).toBe("");
});
it("loads bootstrap data with a normalized UI registry", async () => {
const client = {
getUiRegistry: vi.fn(async () => ({
+8 -1
View File
@@ -1,7 +1,8 @@
import type {
AgentApiClient,
AgentChatSessionSummary,
AgentModelOption
AgentModelOption,
AgentModelsResponse
} from "./client";
import { parseUiRegistry, type UIRegistry } from "../ui-envelope";
import { parseFrontendActionRegistry, type FrontendActionRegistry } from "../frontend-action";
@@ -16,6 +17,12 @@ export type AgentBootstrapData = {
defaultModel: string;
};
export function resolveAgentModelSelection(catalog: AgentModelsResponse, current = ""): string {
if (catalog.models.some((model) => model.id === current)) return current;
if (catalog.models.some((model) => model.id === catalog.defaultModel)) return catalog.defaultModel;
return catalog.models[0]?.id ?? "";
}
export async function fetchAgentBootstrap(client: AgentApiClient): Promise<AgentBootstrapData> {
const [rawRegistry, rawFrontendActionRegistry, modelsResponse] = await Promise.all([
client.getUiRegistry(),
@@ -4,21 +4,25 @@ import { getAnswerPresentation } from "../answer-ui/answer-presentation";
import { Disclosure } from "@/shared/ui/disclosure";
import {
Activity,
ArrowUpRight,
CheckCircle2,
ChevronDown,
ChevronsUpDown,
ChevronLeft,
DatabaseZap,
Folder,
FolderPlus,
History,
LoaderCircle,
MapPinned,
Mic,
Pause,
Play,
Plus,
Quote,
SendHorizontal,
Shield,
ShieldCheck,
Square,
UserRound,
Volume2,
X
} from "lucide-react";
@@ -27,6 +31,7 @@ import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -58,12 +63,15 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import { StatusBadge, StatusDot, type StatusTone } from "@/shared/ui/status";
import type { AgentChatSessionSummary } from "../api/client";
import type { AgentChatSessionSummary, AgentInputDelivery, AgentResponseInput } from "../api/client";
import { AgentPersona } from "./agent-persona";
import {
agentPanelItemVariants,
@@ -71,6 +79,8 @@ import {
agentPanelSectionVariants
} from "./agent-motion";
import { AgentHistoryPanel } from "./agent-history-panel";
import { AgentMessageInbox } from "./agent-message-inbox";
import type { ComponentProps } from "react";
import { AgentMessageDetails, AgentMessageProgress } from "./agent-message-details";
import { AgentUiEnvelopeRenderer } from "./agent-ui-envelope-renderer";
import { useAgentSpeechRecognition, useAgentSpeechSynthesis } from "../hooks/use-agent-voice";
@@ -101,6 +111,11 @@ export type AgentWorkspaceContextItem = {
tone?: "neutral" | "info" | "warning";
};
export type AgentConversationProjectOption = {
id: string;
name: string;
};
type AgentCommandPanelProps = {
presentation?: AgentCommandPanelPresentation;
collapsing?: boolean;
@@ -108,20 +123,37 @@ type AgentCommandPanelProps = {
sessionTitle?: string;
sessionHistory?: AgentChatSessionSummary[];
sessionHistoryLoading?: boolean;
promptSuggestions?: Array<{ question: string; sourceLabel?: string }>;
promptSuggestionsLoading?: boolean;
promptSuggestionsUnavailable?: boolean;
activeSessionId?: string | null;
qaOnly?: boolean;
statusLabel?: string;
connectionStatus?: AgentConnectionStatus;
streaming?: boolean;
inputDelivery?: AgentInputDelivery;
pendingInputs?: AgentResponseInput[];
onInputDeliveryChange?: (delivery: AgentInputDelivery) => void;
onPendingInputDeliveryChange?: (id: string, delivery: AgentInputDelivery) => Promise<void> | void;
messages?: AgentChatMessage[];
streamRenderState?: AgentStreamRenderState;
modelOptions?: AgentModelOption[];
selectedModel?: string;
conversationScope?: "personal" | "project";
onConversationScopeChange?: (scope: "personal" | "project") => Promise<void> | void;
conversationProjects?: AgentConversationProjectOption[];
selectedConversationProjectId?: string | null;
onConversationProjectChange?: (projectId: string) => Promise<void> | void;
currentProjectName?: string | null;
onOpenProjectCreator?: () => void;
approvalMode?: AgentApprovalMode;
uiResults?: AgentUiResult[];
reviewPanel?: ReactNode;
messageInbox?: ComponentProps<typeof AgentMessageInbox>;
workspaceContext?: AgentWorkspaceContextItem[];
prompt: string;
promptFocusRequest?: number;
promptReference?: { title: string; detail?: string; placeholder?: string };
onCollapse: () => void;
onPromptChange: Dispatch<SetStateAction<string>>;
onRefreshHistory?: () => Promise<void> | void;
@@ -142,22 +174,6 @@ type AgentCommandPanelProps = {
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
};
const AGENT_PROMPT_SUGGESTIONS = [
"分析当前管网中的水力瓶颈管道,并给出改造建议。",
"供水服务分区分析。",
"帮我分析当前管网压力异常点,并按风险等级排序。",
"帮我生成一份今日运行简报,包含问题、原因和建议。",
"查询关键 SCADA 点位最近 24 小时的异常波动。",
"排查当前管网爆管风险,并说明优先处置建议。"
] as const;
const AGENT_CAPABILITIES = [
{ icon: DatabaseZap, label: "水力瓶颈诊断" },
{ icon: Activity, label: "压力异常研判" },
{ icon: ShieldCheck, label: "爆管风险排查" },
{ icon: MapPinned, label: "GIS 地图联动" }
] as const;
export function AgentCommandPanel({
presentation = "desktop-dock",
collapsing = false,
@@ -165,18 +181,35 @@ export function AgentCommandPanel({
sessionTitle = "新对话",
sessionHistory = [],
sessionHistoryLoading = false,
promptSuggestions = [],
promptSuggestionsLoading = false,
promptSuggestionsUnavailable = false,
activeSessionId,
qaOnly = false,
connectionStatus = "connecting",
streaming = false,
inputDelivery = "steer",
pendingInputs = [],
onInputDeliveryChange,
onPendingInputDeliveryChange,
messages = [],
streamRenderState = {},
modelOptions = [],
selectedModel = "",
conversationScope = "project",
onConversationScopeChange,
conversationProjects = [],
selectedConversationProjectId,
onConversationProjectChange,
currentProjectName,
onOpenProjectCreator,
approvalMode = "request",
uiResults = [],
reviewPanel,
messageInbox,
prompt,
promptFocusRequest = 0,
promptReference,
onCollapse,
onPromptChange,
onRefreshHistory,
@@ -200,11 +233,31 @@ export function AgentCommandPanel({
const historyTriggerRef = useRef<HTMLButtonElement | null>(null);
const historyPanelRef = useRef<HTMLDivElement | null>(null);
const promptRestoreGenerationRef = useRef(0);
const composerRef = useRef<HTMLDivElement | null>(null);
const promptTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const composerStartRectRef = useRef<DOMRect | null>(null);
const composerTransitionRef = useRef<Animation | null>(null);
const mountedRef = useRef(true);
const desktopFloating = presentation === "desktop-floating";
const floatingPresentation = desktopFloating || presentation === "mobile-sheet";
const hasConversation = messages.length > 0 || streaming;
const hasConversation = messages.length > 0 || streaming || Boolean(messageInbox?.messages.length);
const centeredComposer = !hasConversation && (presentation === "desktop-dock" || Boolean(promptReference));
const showStartSuggestions = centeredComposer && !qaOnly && !promptReference;
const prefersReducedMotion = useReducedMotion();
const trimmedPrompt = prompt.trim();
useEffect(() => {
if (!promptFocusRequest) return;
const frame = requestAnimationFrame(() => {
const textarea = promptTextareaRef.current;
textarea?.focus();
textarea?.setSelectionRange(textarea.value.length, textarea.value.length);
});
return () => cancelAnimationFrame(frame);
}, [promptFocusRequest]);
const selectedConversationProject = conversationProjects.find(
(project) => project.id === selectedConversationProjectId
);
const selectedProjectName = selectedConversationProject?.name ?? currentProjectName ?? "当前项目";
const {
speechState,
speakingMessageId,
@@ -296,12 +349,31 @@ export function AgentCommandPanel({
if (streaming) stopListening();
}, [stopListening, streaming]);
useLayoutEffect(() => {
if (centeredComposer || !composerStartRectRef.current || !composerRef.current) return;
const start = composerStartRectRef.current;
composerStartRectRef.current = null;
const end = composerRef.current.getBoundingClientRect();
if (prefersReducedMotion || (start.x === end.x && start.y === end.y)) return;
composerTransitionRef.current?.cancel();
composerTransitionRef.current = composerRef.current.animate(
[
{ transform: `translate(${start.x - end.x}px, ${start.y - end.y}px)` },
{ transform: "translate(0, 0)" }
],
{ duration: 280, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }
);
}, [centeredComposer, prefersReducedMotion]);
const submitPrompt = (nextPrompt: string) => {
if (!nextPrompt || streaming) {
if (!nextPrompt) {
return;
}
const restoreGeneration = promptRestoreGenerationRef.current + 1;
promptRestoreGenerationRef.current = restoreGeneration;
if (centeredComposer && !prefersReducedMotion && composerRef.current) {
composerStartRectRef.current = composerRef.current.getBoundingClientRect();
}
onPromptChange("");
stopListening();
setScrollRequestId((requestId) => requestId + 1);
@@ -335,6 +407,7 @@ export function AgentCommandPanel({
<aside
aria-label="Agent 命令面板"
data-agent-presentation={presentation}
data-agent-starting={centeredComposer}
className={cn(
"pointer-events-auto flex h-full min-h-0 w-full flex-col",
"agent-panel-shell",
@@ -361,12 +434,12 @@ export function AgentCommandPanel({
historyOpen && "agent-panel-header-expanded"
)}
>
<div className={cn("flex items-center justify-between gap-2 px-3", presentation === "desktop-dock" ? "min-h-14 py-1.5" : "min-h-16 py-2")}>
<div className={cn("flex items-center justify-between gap-2 px-3", presentation === "desktop-dock" ? "h-14 py-1" : "min-h-16 py-2")}>
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
<AgentPersona className={presentation === "desktop-dock" ? "h-10 w-10" : "h-12 w-12"} state={personaState} />
<div className="min-w-0 flex-1">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<div className="min-w-0 space-y-0.5">
<div className="flex min-w-0 items-center gap-3">
<p className="shrink-0 text-xs font-semibold uppercase text-[var(--ink-muted)]">
对话主题
</p>
@@ -382,12 +455,90 @@ export function AgentCommandPanel({
{agentConnectionMetadata[connectionStatus].label}
</StatusBadge>
</div>
<p
className="truncate text-sm font-semibold text-slate-950"
title={sessionTitle}
>
{sessionTitle}
</p>
<div className="flex min-w-0 items-center gap-2">
<p
className="min-w-0 shrink truncate text-sm font-semibold text-slate-950"
title={sessionTitle}
>
{sessionTitle}
</p>
{onConversationScopeChange && !qaOnly ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="sm" className="agent-project-trigger h-7 max-w-36 shrink-0 gap-1.5 rounded-xl px-2.5 text-xs font-medium text-slate-600 focus-visible:ring-1 focus-visible:ring-offset-0"
aria-label="选择对话归属" disabled={streaming}>
{conversationScope === "personal"
? <UserRound size={13} aria-hidden="true" />
: <Folder size={13} aria-hidden="true" />}
<span className="truncate">
{conversationScope === "personal" ? "个人会话" : selectedProjectName}
</span>
<ChevronDown size={12} className="opacity-60" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent data-agent-presentation={presentation} align="start" sideOffset={8} className="agent-panel-overlay w-60 p-2">
<DropdownMenuLabel className="px-3 pb-1.5 pt-1 text-xs font-medium text-[var(--ink-muted)]">
对话归属
</DropdownMenuLabel>
<DropdownMenuItem
className={cn(
"min-h-10 rounded-lg px-3 text-sm",
conversationScope === "personal" && "bg-[var(--action-selection-soft)] text-slate-900"
)}
onSelect={() => void onConversationScopeChange("personal")}
>
<UserRound size={14} className="text-[var(--ink-muted)]" aria-hidden="true" />
<span className="flex-1">个人会话</span>
{conversationScope === "personal" ? <CheckCircle2 size={14} className="text-[var(--action-blue-hover)]" aria-hidden="true" /> : null}
</DropdownMenuItem>
{conversationProjects.length > 0 ? conversationProjects.map((project) => {
const selected = conversationScope === "project"
&& project.id === selectedConversationProjectId;
return (
<DropdownMenuItem
key={project.id}
className={cn(
"min-h-10 rounded-lg px-3 text-sm",
selected && "bg-[var(--action-selection-soft)] text-slate-900"
)}
onSelect={() => {
if (onConversationProjectChange) {
void onConversationProjectChange(project.id);
return;
}
void onConversationScopeChange("project");
}}
>
<Folder size={14} className="text-[var(--ink-muted)]" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">{project.name}</span>
{selected ? <CheckCircle2 size={14} className="text-[var(--action-blue-hover)]" aria-hidden="true" /> : null}
</DropdownMenuItem>
);
}) : (
<DropdownMenuItem
className={cn(
"min-h-10 rounded-lg px-3 text-sm",
conversationScope === "project" && "bg-[var(--action-selection-soft)] text-slate-900"
)}
onSelect={() => void onConversationScopeChange("project")}
>
<Folder size={14} className="text-[var(--ink-muted)]" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">{selectedProjectName}</span>
{conversationScope === "project" ? <CheckCircle2 size={14} className="text-[var(--action-blue-hover)]" aria-hidden="true" /> : null}
</DropdownMenuItem>
)}
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem
className="min-h-10 rounded-lg px-3 text-sm font-medium text-[var(--action-blue-hover)] focus:bg-[var(--action-soft)] focus:text-[var(--action-blue-hover)]"
onSelect={onOpenProjectCreator}
>
<FolderPlus size={14} aria-hidden="true" />
新建项目
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
</div>
</div>
@@ -481,7 +632,7 @@ export function AgentCommandPanel({
)}
>
<AnimatePresence mode="popLayout" initial={false}>
{messages.length > 0 ? (
{messages.length > 0 || Boolean(messageInbox?.messages.length) ? (
<BackendMessageList
key="messages"
presentation={presentation}
@@ -502,27 +653,12 @@ export function AgentCommandPanel({
onLocateAnswerFeatures={onLocateAnswerFeatures}
onSubmitAnswerForm={onSubmitPrompt}
/>
) : (
<motion.div
key="empty"
className={cn(
"flex flex-1 px-4",
presentation === "mobile-sheet"
? "items-start justify-center pb-0 pt-5"
: "items-center justify-center py-8"
)}
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
>
<AgentEmptyState compact={presentation === "mobile-sheet"} />
</motion.div>
)}
) : null}
</AnimatePresence>
{!qaOnly ? <AgentUiEnvelopeRenderer results={uiResults} /> : null}
{reviewPanel}
{messageInbox ? <AgentMessageInbox {...messageInbox} /> : null}
</ConversationContent>
<AgentConversationScrollManager
activeSessionId={activeSessionId}
@@ -543,16 +679,20 @@ export function AgentCommandPanel({
<div
className={cn(
"agent-panel-band relative",
centeredComposer && "agent-panel-start-composer",
floatingPresentation
? "form-field agent-panel-floating-acrylic agent-panel-composer absolute bottom-3 z-20 rounded-2xl p-1"
: "border-t border-[var(--surface-divider)] p-3"
)}
>
{!hasConversation ? (
{showStartSuggestions ? (
<h2 className="agent-panel-start-heading">我能帮你处理什么?</h2>
) : null}
{!hasConversation && !centeredComposer && !qaOnly ? (
<div
className={cn(
"agent-panel-conversation pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2",
floatingPresentation && "bg-transparent"
"agent-panel-suggestions pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2",
floatingPresentation && "agent-panel-conversation bg-transparent"
)}
>
<Suggestions
@@ -560,78 +700,142 @@ export function AgentCommandPanel({
role="group"
className="pointer-events-auto px-0.5"
>
{AGENT_PROMPT_SUGGESTIONS.map((suggestion) => (
{promptSuggestions.map(({ question }) => (
<Suggestion
key={suggestion}
suggestion={suggestion}
key={question}
suggestion={question}
onClick={submitPrompt}
className="surface-reading max-w-full border border-[var(--surface-divider)] text-slate-600 hover:bg-[var(--action-soft)] hover:text-[var(--action-blue-hover)]"
>
<span className="truncate">{suggestion}</span>
<span className="truncate">{question}</span>
</Suggestion>
))}
{promptSuggestionsLoading ? <span className="px-2 text-xs text-[var(--ink-muted)]">正在整理推荐问题…</span> : null}
</Suggestions>
</div>
) : null}
<PromptInput
className="form-field agent-panel-control overflow-hidden rounded-2xl shadow-xs [&>[data-slot=input-group]]:rounded-[inherit] [&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:shadow-none"
onSubmit={(message) => {
const nextPrompt = message.text.trim();
if (nextPrompt) {
submitPrompt(nextPrompt);
}
}}
<div
ref={composerRef}
className="agent-panel-composer-motion"
>
<PromptInputBody>
<PromptInputTextarea
value={prompt}
onChange={(event) => {
promptRestoreGenerationRef.current += 1;
onPromptChange(event.currentTarget.value);
}}
className={cn("text-sm", floatingPresentation ? "min-h-20" : "min-h-14")}
placeholder="输入调度问题,Agent 将通过后端会话流式响应"
/>
</PromptInputBody>
<PromptInputFooter className="justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
{!qaOnly ? <ApprovalModeSelect presentation={presentation} value={approvalMode} disabled={streaming} onValueChange={onApprovalModeChange} /> : <span className="px-1 text-[11px] text-[var(--ink-muted)]">任务协作问答</span>}
{streaming ? (
<div className="mb-2 flex items-center justify-between gap-2 px-1 text-xs text-[var(--ink-muted)]">
<span>追加消息</span>
<InputDeliverySelect presentation={presentation} label="运行中消息发送方式"
value={inputDelivery} onValueChange={onInputDeliveryChange} />
</div>
<div className="flex shrink-0 items-center gap-2">
<AgentModelSelect
presentation={presentation}
models={modelOptions}
value={selectedModel}
onValueChange={onSelectModel}
/>
{isRecognitionSupported ? (
<VoiceInputButton
isListening={isListening}
disabled={streaming}
onClick={
isListening ? stopListening : () => startListening(showRecognitionError)
}
/>
) : null}
{pendingInputs.length ? (
<ul aria-label="待处理消息" className="mb-2 max-h-32 space-y-1 overflow-y-auto text-xs text-[var(--ink-muted)]">
{pendingInputs.map((input) => (
<li key={input.id} className="flex min-w-0 items-center gap-2 rounded-md bg-[var(--surface-subtle)] px-2 py-1.5">
<span className="min-w-0 flex-1 truncate" title={input.text}>{input.text}</span>
<InputDeliverySelect presentation={presentation} label={`消息发送方式:${input.text}`}
value={input.delivery} onValueChange={onPendingInputDeliveryChange
? (delivery) => { void onPendingInputDeliveryChange(input.id, delivery); }
: undefined} />
</li>
))}
</ul>
) : null}
<PromptInput
className={cn(
"agent-panel-control overflow-hidden rounded-2xl [&>[data-slot=input-group]]:rounded-[inherit] [&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:shadow-none",
presentation === "desktop-dock" ? "agent-panel-input-form" : "form-field shadow-xs"
)}
onSubmit={(message) => {
const nextPrompt = message.text.trim();
if (nextPrompt) {
submitPrompt(nextPrompt);
}
}}
>
<PromptInputBody className={presentation === "desktop-dock" ? "agent-panel-input-body form-field" : undefined}>
{promptReference ? (
<div role="group" aria-label="对话引用" className="mx-3 mt-3 flex min-w-0 items-start gap-2 rounded-lg bg-[var(--surface-subtle)] px-3 py-2 text-[var(--ink-muted)]">
<Quote aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0 text-xs leading-relaxed">
<p className="break-words font-medium text-[var(--ink)]">{promptReference.title}</p>
{promptReference.detail ? <p className="break-words">{promptReference.detail}</p> : null}
</div>
</div>
) : null}
<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}
style={{ transform: "rotate(-45deg)" }}
aria-hidden="true"
<PromptInputTextarea
ref={promptTextareaRef}
value={prompt}
onChange={(event) => {
promptRestoreGenerationRef.current += 1;
onPromptChange(event.currentTarget.value);
}}
className={cn("agent-panel-prompt-textarea text-sm", floatingPresentation ? "min-h-20" : "min-h-14")}
placeholder={promptReference?.placeholder ?? "询问供水问题,或描述需要处理的任务"}
/>
</PromptInputBody>
<PromptInputFooter className="agent-panel-input-tools justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
{!qaOnly ? <ApprovalModeSelect presentation={presentation} value={approvalMode} disabled={streaming} onValueChange={onApprovalModeChange} /> : <span className="px-1 text-[11px] text-[var(--ink-muted)]">任务协作问答</span>}
</div>
<div className="flex shrink-0 items-center gap-2">
<AgentModelSelect
presentation={presentation}
models={modelOptions}
value={selectedModel}
onValueChange={streaming ? undefined : onSelectModel}
/>
{isRecognitionSupported ? (
<VoiceInputButton
isListening={isListening}
disabled={streaming}
onClick={
isListening ? stopListening : () => startListening(showRecognitionError)
}
/>
)}
</PromptInputSubmit>
</div>
</PromptInputFooter>
</PromptInput>
) : null}
{streaming ? <Button type="button" variant="ghost" size="icon" aria-label="停止 Agent 指令" title="停止 Agent 指令"
className="agent-panel-icon-button shrink-0" onClick={onStopPrompt}><Square size={14} aria-hidden="true" /></Button> : null}
<PromptInputSubmit
aria-label={streaming ? inputDelivery === "steer" ? "引导当前 Agent 任务" : "将消息加入 Agent 队列" : "发送 Agent 指令"}
title={streaming ? inputDelivery === "steer" ? "引导当前 Agent 任务" : "将消息加入 Agent 队列" : "发送 Agent 指令"}
className="agent-panel-primary-icon"
disabled={!trimmedPrompt}
status="ready"
>
{(
<SendHorizontal
size={15}
style={{ transform: "rotate(-45deg)" }}
aria-hidden="true"
/>
)}
</PromptInputSubmit>
</div>
</PromptInputFooter>
</PromptInput>
</div>
{showStartSuggestions ? (
<div className="agent-panel-start-suggestions" role="group" aria-label="推荐问题" aria-live="polite" aria-busy={promptSuggestionsLoading}>
{promptSuggestionsLoading ? (
<p className="agent-panel-suggestion-status">正在根据最近活动整理问题…</p>
) : promptSuggestions.length ? promptSuggestions.map((suggestion) => (
<button
key={suggestion.question}
type="button"
className="agent-panel-start-suggestion group"
title={suggestion.question}
onClick={() => submitPrompt(suggestion.question)}
>
<Activity size={17} className="shrink-0 text-[var(--action-blue-hover)]" aria-hidden="true" />
<span className="min-w-0 flex-1">
<span className="line-clamp-2 block text-sm leading-5">{suggestion.question}</span>
{suggestion.sourceLabel ? <span className="mt-1 block text-[11px] leading-4 text-[var(--ink-muted)]">{suggestion.sourceLabel}</span> : null}
</span>
<ArrowUpRight size={14} className="shrink-0 text-[var(--ink-muted)]" aria-hidden="true" />
</button>
)) : (
<p className="agent-panel-suggestion-status">{promptSuggestionsUnavailable ? "推荐问题暂不可用,可直接输入任务。" : "描述调查目标,即可开始分析。"}</p>
)}
</div>
) : null}
</div>
</Agent>
</aside>
@@ -666,14 +870,14 @@ function VoiceInputButton({
aria-pressed={isListening}
title={label}
className={cn(
"group relative grid h-8 w-8 shrink-0 place-items-center overflow-visible rounded-lg border after:absolute after:-inset-1 after:content-['']",
"border-transparent bg-transparent shadow-none",
"group relative grid h-8 w-8 shrink-0 place-items-center overflow-visible rounded-lg border-0 after:absolute after:-inset-1 after:content-['']",
"bg-transparent shadow-none",
"transition-[color,background-color,border-color,box-shadow,transform] duration-200",
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-1",
"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-0",
"disabled:cursor-not-allowed disabled:opacity-45",
isListening
? "status-tone-danger bg-[var(--status-soft)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:brightness-95"
: "text-slate-600 [@media(hover:hover)]:hover:border-[var(--action-border-hover)] [@media(hover:hover)]:hover:bg-[var(--action-soft)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]"
: "text-slate-600 [@media(hover:hover)]:hover:bg-[var(--action-soft)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)] active:bg-[var(--action-soft-pressed)]"
)}
disabled={disabled}
onClick={onClick}
@@ -762,6 +966,38 @@ function VoiceInputButton({
const AGENT_SELECT_ITEM_CLASS_NAME =
"rounded-lg border border-transparent px-2.5 py-2 focus:border-[var(--action-border-hover)] focus:bg-[var(--action-selection-soft-hover)] data-[highlighted]:border-[var(--action-border-hover)] data-[highlighted]:bg-[var(--action-selection-soft-hover)] motion-reduce:transition-none";
function InputDeliverySelect({ presentation, label, value, onValueChange }: {
presentation: AgentCommandPanelPresentation;
label: string;
value: AgentInputDelivery;
onValueChange?: (delivery: AgentInputDelivery) => void;
}) {
return <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="sm" aria-label={label}
className="agent-panel-control h-10 shrink-0 gap-2 px-2.5 text-xs font-medium"
disabled={!onValueChange}>
{value === "queue" ? "排队处理" : "引导当前任务"}
<ChevronDown className="size-3 opacity-60" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent data-agent-presentation={presentation} side="top" align="end" sideOffset={6}
collisionPadding={12} className="agent-panel-overlay agent-panel-control w-44 rounded-xl p-1.5 shadow-lg">
<DropdownMenuRadioGroup aria-label={label} value={value} onValueChange={(delivery) => {
if (delivery === "steer" || delivery === "queue") onValueChange?.(delivery);
}}>
{([ ["steer", "引导当前任务"], ["queue", "排队处理"] ] as const).map(([delivery, text]) => (
<DropdownMenuRadioItem key={delivery} value={delivery}
className={cn(AGENT_SELECT_ITEM_CLASS_NAME, "min-h-10 pl-8 pr-2.5 text-xs",
value === delivery && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]")}>
{text}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>;
}
function ApprovalModeSelect({
presentation,
value,
@@ -780,9 +1016,9 @@ function ApprovalModeSelect({
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
variant="ghost"
size="sm"
className="shrink-0 gap-1.5 border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-[var(--surface-reading)]"
className="agent-panel-toolbar-trigger shrink-0 gap-1.5 border-0 bg-transparent px-1.5 text-xs text-slate-600 shadow-none data-[state=open]:bg-[var(--action-soft)] data-[state=open]:text-[var(--action-blue-hover)] focus-visible:ring-1 focus-visible:ring-offset-0"
disabled={disabled || !onValueChange}
aria-label="权限批准模式"
>
@@ -816,7 +1052,7 @@ function ApprovalModeSelect({
<Shield className="mt-0.5 text-[var(--action-blue-hover)]" aria-hidden="true" />
<span>
<span className="block text-sm font-semibold text-slate-800">请求批准</span>
<span className="block text-xs leading-5 text-[var(--ink-muted)]">工具权限逐次确认</span>
<span className="block text-xs leading-5 text-[var(--ink-muted)]">需要审批的请求逐次确认;只读白名单直接执行</span>
</span>
{requestApproval ? (
<CheckCircle2 className="ml-auto mt-0.5 text-[var(--action-blue-hover)]" aria-hidden="true" />
@@ -837,7 +1073,7 @@ function ApprovalModeSelect({
/>
<span>
<span className="block text-sm font-semibold text-slate-800">始终允许</span>
<span className="block text-xs leading-5 text-[var(--ink-muted)]">自动允许本轮权限请求</span>
<span className="block text-xs leading-5 text-[var(--ink-muted)]">自动批准本轮工具权限请求,不改变业务权限或安全限制</span>
</span>
{!requestApproval ? (
<CheckCircle2
@@ -951,7 +1187,7 @@ function AgentModelSelect({
value: string;
onValueChange?: (model: string) => void;
}) {
const selectedModel = models.find((model) => model.id === value) ?? models[0];
const selectedModel = models.find((model) => model.id === value);
if (!models.length) {
return (
@@ -964,9 +1200,9 @@ function AgentModelSelect({
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
variant="ghost"
size="sm"
className="w-12 shrink-0 justify-between gap-1 border-transparent bg-transparent px-1 text-xs text-slate-600 shadow-none hover:bg-[var(--surface-reading)] sm:w-24 sm:gap-1.5 sm:px-1.5"
className="agent-panel-toolbar-trigger w-12 shrink-0 justify-between gap-1 border-0 bg-transparent px-1 text-xs text-slate-600 shadow-none data-[state=open]:bg-[var(--action-soft)] data-[state=open]:text-[var(--action-blue-hover)] focus-visible:ring-1 focus-visible:ring-offset-0 sm:w-auto sm:min-w-[72px] sm:gap-1.5 sm:px-1.5"
disabled={!onValueChange}
aria-label="选择 Agent 模型"
aria-description={`当前模型:${selectedModel?.label ?? "模型"}`}
@@ -1041,7 +1277,7 @@ function FastModelIcon({ size }: { size: number }) {
height={size}
viewBox="0 0 24 24"
fill="none"
className="text-sky-600"
className="text-[var(--action-blue-hover)]"
aria-hidden="true"
>
<path
@@ -1070,111 +1306,6 @@ function ExpertModelIcon({ size }: { size: number }) {
);
}
function AgentReadyMark() {
return (
<svg viewBox="0 0 1024 1024" className="h-12 w-12 overflow-visible" aria-hidden="true">
<g className="agent-ready-orbit">
<path
d="M384.1536 952.1664a38.4 38.4 0 0 1-49.3568 22.528 498.3808 498.3808 0 0 1-284.928-273.92 38.4 38.4 0 0 1 70.8608-29.6448 421.5808 421.5808 0 0 0 240.896 231.6288 38.4 38.4 0 0 1 22.528 49.408zM952.1152 384.9728a38.4 38.4 0 0 1-49.4592-22.528 421.5296 421.5296 0 0 0-234.1376-241.5104 38.4 38.4 0 0 1 29.184-71.0656 498.3296 498.3296 0 0 1 276.8896 285.696 38.4 38.4 0 0 1-22.528 49.408z"
fill="var(--palette-brand)"
/>
<path
d="M981.248 768.0512a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.2992 0zM127.9488 256.0512a42.6496 42.6496 0 1 1-85.3504 0 42.6496 42.6496 0 0 1 85.3504 0z"
fill="var(--palette-champagne)"
/>
<path
d="M810.496 938.8544a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0zM298.496 85.504a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0z"
fill="var(--palette-sand)"
/>
</g>
<path
className="agent-ready-core"
d="M511.9488 276.736l-27.8528 114.7392A126.0544 126.0544 0 0 1 391.3216 484.352l-114.7904 27.8528 114.7904 27.8016a126.0544 126.0544 0 0 1 92.7744 92.8256L512 747.52l27.8016-114.7392a126.0544 126.0544 0 0 1 92.8256-92.8256l114.7392-27.8016-114.7392-27.8528a126.0544 126.0544 0 0 1-92.8256-92.8256L512 276.736z m55.6544-62.1568c-14.1312-58.368-97.1776-58.368-111.36 0L417.28 375.296a57.344 57.344 0 0 1-42.1888 42.1888l-160.6656 38.912c-58.4192 14.1824-58.4192 97.28 0 111.4112l160.6656 38.9632c20.8384 5.12 37.12 21.3504 42.1888 42.1888l38.9632 160.7168c14.1824 58.368 97.2288 58.368 111.36 0l38.9632-160.7168a57.344 57.344 0 0 1 42.1888-42.1888l160.7168-38.912c58.368-14.1824 58.368-97.28 0-111.4112l-160.7168-38.9632a57.344 57.344 0 0 1-42.1888-42.1888l-38.912-160.7168z"
fill="var(--action-selection-soft)"
/>
</svg>
);
}
function AgentEmptyState({ compact = false }: { compact?: boolean }) {
if (compact) {
return (
<section
aria-labelledby="agent-empty-state-title"
className="mx-auto flex w-full max-w-[360px] items-center gap-3 px-1 py-1"
lang="zh-CN"
>
<div className="surface-control grid size-9 shrink-0 place-items-center rounded-lg shadow-[inset_0_0_0_1px_oklch(0.84_0.025_260)]">
<AgentReadyMark />
</div>
<div className="min-w-0 flex-1 text-left">
<h2
id="agent-empty-state-title"
className="text-sm font-semibold leading-5 text-slate-950"
>
我已就绪,请描述任务
</h2>
<p className="mt-0.5 text-xs leading-5 text-slate-600">
描述调查目标,我会整理证据并带回地图。
</p>
</div>
</section>
);
}
return (
<section
aria-labelledby="agent-empty-state-title"
className="agent-empty-state mx-auto w-full max-w-[360px]"
lang="zh-CN"
>
<div className="px-2 py-4">
<div className="flex items-center gap-4">
<div className="agent-empty-mark surface-control grid h-[72px] w-[72px] shrink-0 place-items-center rounded-xl shadow-[inset_0_0_0_1px_oklch(0.84_0.025_260)]">
<AgentReadyMark />
</div>
<div className="min-w-0 flex-1 text-left">
<h2
id="agent-empty-state-title"
className="text-balance text-lg font-semibold leading-7 text-slate-950"
>
我已就绪,请描述任务
</h2>
</div>
</div>
<p className="mt-5 text-pretty text-left text-sm leading-6 text-[#334155]">
直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。
</p>
</div>
<ul
className={cn(
"agent-capabilities surface-control grid grid-cols-2 overflow-hidden rounded-xl shadow-[inset_0_0_0_1px_rgba(148,163,184,0.32)]",
compact && "hidden"
)}
aria-label="Agent 分析能力"
>
{AGENT_CAPABILITIES.map(({ icon: Icon, label }, index) => (
<li
key={label}
className={cn(
"agent-capability flex min-h-20 items-center gap-3 px-3.5 py-3 text-left",
index % 2 === 0 && "border-r border-[var(--surface-border)]/80",
index < 2 && "border-b border-[var(--surface-border)]/80"
)}
>
<span className="agent-capability-icon grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-[#f9fbfe] text-[#334155] shadow-[inset_0_0_0_1px_rgba(148,163,184,0.28)]">
<Icon size={16} strokeWidth={1.8} aria-hidden="true" />
</span>
<span className="text-sm font-semibold leading-5 text-[#1e293b]">{label}</span>
</li>
))}
</ul>
</section>
);
}
function BackendMessageList({
presentation,
messages,
@@ -0,0 +1,37 @@
import { Mail } from "lucide-react";
import { Disclosure } from "@/shared/ui/disclosure";
import { Button } from "@/shared/ui/button";
import type { AgentMailboxMessage } from "../api/agent-messages";
const messageStatusLabels = { pending: "待接收", enqueued: "已排队", delivered: "已投递", failed: "投递失败" };
export function AgentMessageInbox({ messages, loading, error, hasMore, loadingMore, onLoadMore, onRefresh }: {
messages: AgentMailboxMessage[];
loading?: boolean;
error?: string | null;
hasMore?: boolean;
loadingMore?: boolean;
onLoadMore?: () => Promise<void>;
onRefresh?: () => void;
}) {
if (!messages.length && !loading && !error) return null;
const received = messages.filter((message) => message.direction === "to_chat").length;
return <Disclosure label={<span className="flex items-center gap-2 px-3"><Mail size={14} />任务消息{received ? ` · 收到 ${received} 条` : ""}</span>} initiallyOpen className="surface-reading rounded-xl">
<div className="space-y-3 p-3 text-xs" aria-live="polite">
<p className="text-[var(--ink-muted)]">新消息会在此显示。由你决定是否继续发送指令。</p>
{loading ? <p className="text-[var(--ink-muted)]">正在加载任务消息…</p> : null}
{error ? <div className="flex items-center justify-between gap-2"><p className="text-[var(--status-warning-foreground)]">{error}</p><Button variant="ghost" size="sm" onClick={onRefresh}>重试</Button></div> : null}
{messages.map((message) => <article key={message.message_id} className="space-y-1.5 border-t border-[var(--surface-divider)] pt-3">
<div className="flex flex-wrap items-center justify-between gap-2 text-[var(--ink-muted)]">
<span>{message.direction === "to_chat" ? "调查任务来信" : "发给调查任务"}</span>
<span>{messageStatusLabels[message.status]}</span>
</div>
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-[var(--ink)]">{message.text}</p>
<p className="break-all text-[var(--ink-muted)]">task_id: <span className="select-text font-mono">{message.task_id}</span></p>
<p className="break-all text-[var(--ink-muted)]">run_id: <span className="select-text font-mono">{message.run_id}</span></p>
{message.failure_detail ? <p className="break-words text-[var(--status-danger-foreground)]">{message.failure_detail}</p> : null}
</article>)}
{hasMore ? <Button variant="ghost" size="sm" disabled={loadingMore} onClick={() => void onLoadMore?.().catch(() => undefined)}>{loadingMore ? "正在加载…" : "加载更多消息"}</Button> : null}
</div>
</Disclosure>;
}
@@ -1,135 +0,0 @@
import { ClipboardCheck, Crosshair, MapPinned, Radio, Route } from "lucide-react";
import type { ReactNode } from "react";
import { Button } from "@/shared/ui/button";
import { StatusDot, type StatusTone } from "@/shared/ui/status";
import { countPendingAgentInputs } from "../message-state";
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-operational-brief agent-panel-control rounded-2xl p-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<StatusDot tone={brief.statusTone} activity={streaming ? "live" : "static"} size="md" />
<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-600" title={brief.task}>
{brief.task}
</p>
</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="w-full text-xs"
disabled={streaming}
onClick={() => onSubmitCommand(brief.primaryCommand)}
>
<MapPinned size={14} aria-hidden="true" />
预览影响
</Button>
<Button
type="button"
variant="outline"
className="w-full text-xs"
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="agent-brief-metric min-w-0 rounded-xl border px-2 py-2">
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-600">
<span className="text-[var(--action-blue-hover)]">{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 latestProgress = progressItems.at(-1);
const activeTodo =
[...todoItems].reverse().find((todo) => todo.status === "in_progress") ??
[...todoItems].reverse().find((todo) => todo.status === "pending");
const pendingConfirmations = countPendingAgentInputs(messages);
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 statusTone: StatusTone =
pendingConfirmations > 0 ? "warning" : streaming ? "info" : "success";
const promptContext = lastUserMessage?.content.trim() || "当前供水管网运行态势";
return {
task,
evidence,
plan,
confirmation,
stateLabel,
statusTone,
primaryCommand: `请基于“${promptContext}”预览空间影响范围,并列出需要确认的操作。`,
secondaryCommand: `请检查“${promptContext}”的证据链,按数据来源、风险和不确定性汇总。`
};
}
@@ -48,7 +48,7 @@ function AgentUiResultCard({ result }: { result: AgentUiResult }) {
const { envelope } = result;
return (
<div className="agent-panel-message w-full rounded-2xl p-3 text-slate-700 shadow-xs">
<div data-agent-ui-result={result.id} className="agent-panel-message w-full rounded-2xl p-3 text-slate-700 shadow-xs">
<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-[var(--action-selection-soft)] text-[var(--action-blue-hover)]">
@@ -260,7 +260,8 @@ function RegisteredComponentEnvelope({
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: "时间范围" }} />
<PropRows props={envelope.props} labels={{ feature_id: "资产 ID", featureId: "资产 ID", metric: "指标", range: "时间范围", start_time: "开始时间", end_time: "结束时间" }} />
{envelope.data ? <SafeChartEnvelope envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: "监测历史", chart_type: "line" }, data: envelope.data }} /> : null}
</TrustedPanelShell>
);
}
@@ -268,7 +269,7 @@ function RegisteredComponentEnvelope({
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: "时间范围" }} />
<PropRows props={envelope.props} labels={{ device_id: "设备 ID", deviceId: "设备 ID", metric: "指标", range: "时间范围", start_time: "开始时间", end_time: "结束时间" }} />
</TrustedPanelShell>
);
}
@@ -13,7 +13,7 @@ beforeEach(() => {
});
describe("SCADA frontend actions", () => {
it("accepts only the two registered SCADA action names", () => {
it("accepts registered view actions and rejects unknown action names", () => {
expect(parseFrontendActionRegistry({
schema_version: "frontend-action-registry@1",
actions: [
@@ -23,9 +23,10 @@ describe("SCADA frontend actions", () => {
]
})?.actions.map((action) => action.id)).toEqual([
"render_scada_analysis",
"clear_scada_analysis"
"clear_scada_analysis",
"zoom_to_map"
]);
expect(parseFrontendActionRequest(createRequest("zoom_to_map"))).toBeNull();
expect(parseFrontendActionRequest(createRequest("execute_script"))).toBeNull();
expect(parseFrontendActionRequest(createRequest("render_scada_analysis"))).not.toBeNull();
});
+2 -1
View File
@@ -1,4 +1,5 @@
export const FRONTEND_ACTION_NAMES = ["render_scada_analysis", "clear_scada_analysis"] as const;
export const FRONTEND_ACTION_NAMES = ["render_scada_analysis", "clear_scada_analysis", "locate_features", "zoom_to_map", "show_chart", "view_history"] as const;
export { parseFrontendChart } from "./chart";
export type FrontendActionName = (typeof FRONTEND_ACTION_NAMES)[number];
export type FrontendActionRegistry = { schema_version: "frontend-action-registry@1"; actions: Array<{ id: FrontendActionName; version: "frontend-action@1" }> };
export type FrontendActionRequest = { version: "frontend-action@1"; actionId: string; toolCallId: string; sessionId: string; name: FrontendActionName; params: Record<string, unknown>; issuedAt: number; expiresAt: number };
@@ -0,0 +1,44 @@
import { useCallback, useEffect, useRef, useState } from "react";
import useSWR from "swr";
import type { AgentApiClient } from "../api/client";
export function useAgentMessageInbox(client: AgentApiClient, sessionId: string | null) {
const inbox = useSWR(sessionId ? ["agent-message-inbox", sessionId] : null,
([, id]) => client.listAgentMessages(id), { revalidateOnFocus: true, shouldRetryOnError: false });
const [loadingMore, setLoadingMore] = useState(false);
const mutate = inbox.mutate;
const refresh = useCallback(() => { void mutate().catch(() => undefined); }, [mutate]);
const refreshRef = useRef(refresh);
useEffect(() => { refreshRef.current = refresh; }, [refresh]);
useEffect(() => {
if (!sessionId) return;
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
let retryDelay = 1500;
function subscribe() {
void client.streamAgentMessages(sessionId!, { signal: controller.signal, onMessage: () => { retryDelay = 1500; refreshRef.current(); } })
.catch(() => { retryDelay = Math.min(retryDelay * 2, 30_000); })
.finally(() => {
if (!controller.signal.aborted) timer = setTimeout(subscribe, retryDelay);
});
}
subscribe();
return () => { controller.abort(); clearTimeout(timer); };
}, [client, sessionId]);
return {
messages: inbox.data?.items ?? [],
hasMore: Boolean(inbox.data?.next_cursor),
loading: inbox.isLoading,
error: inbox.error ? "任务消息暂时无法加载" : null,
refresh,
loadingMore,
loadMore: async () => {
if (!sessionId || !inbox.data?.next_cursor || loadingMore) return;
setLoadingMore(true);
try {
const page = await client.listAgentMessages(sessionId, { cursor: inbox.data.next_cursor });
await inbox.mutate((current) => current ? { items: [...current.items, ...page.items.filter((item) => !current.items.some((existing) => existing.message_id === item.message_id))], next_cursor: page.next_cursor } : page, { revalidate: false });
} finally { setLoadingMore(false); }
}
};
}
+13 -2
View File
@@ -3,26 +3,35 @@ export { agentTaskDiscussionContextSchema, agentTaskContextReferenceSchema, type
export { readExecutionReport, updateExecutionReportProgress, appendExecutionReportText, type AgentExecutionReport } from "./execution-report";
export { parseExecutionPlan, readExecutionPlanHistory, executionPlanStages, executionStatusLabels, type AgentExecutionPlan } from "./execution-plan";
export { AgentCommandPanel } from "./components/agent-command-panel";
export { AgentChartView } from "./components/agent-ui-envelope-renderer";
export { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
export type {
AgentCommandPanelPresentation,
AgentConversationProjectOption,
AgentWorkspaceContextItem
} from "./components/agent-command-panel";
export { AgentPersona } from "./components/agent-persona";
export { createAgentApiClient } from "./api/client";
export { useAgentMessageInbox } from "./hooks/use-agent-message-inbox";
export type { AgentMailboxMessage } from "./api/agent-messages";
export type {
AgentApiClient,
AgentApiClientOptions,
AgentInputDelivery,
AgentResponseInput,
AgentChatSessionSummary,
AgentSessionPurpose,
AgentEventTriggerContext,
AgentLoadedChatSession,
AgentSessionStreamEvent
AgentSessionStreamEvent,
AgentRecentActivity,
AgentPromptSuggestion
} from "./api/client";
export {
agentBootstrapKey,
agentSessionsKey,
fetchAgentBootstrap,
resolveAgentModelSelection,
fetchAgentSessions,
type AgentBootstrapData
} from "./api/swr";
@@ -83,8 +92,10 @@ export type {
} from "./types";
export type { SafeChartData, SafeChartSeries, SafeChartSpec } from "./chart-data";
export { AgentChartView } from "./components/agent-ui-envelope-renderer";
export { parseFrontendChart } from "./frontend-action/chart";
export { parseAnswerUiBlock } from "./answer-ui/contract";
export type { AnswerUiBlock, AnswerUiPart } from "./answer-ui/contract";
export type { AnswerUiMapTarget } from "./answer-ui/answer-ui-view";
export { ResponsesChatTransport, createResponseRequest } from "./api/responses-transport";