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:
@@ -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();
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user