fix: unify chat process and reply presentation
This commit is contained in:
@@ -23,7 +23,7 @@ export function AnswerPartsView({
|
||||
onAsk?: (selection: AnswerUiSelection) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4" data-answer-parts>
|
||||
{parts.map((part, index) =>
|
||||
part.type === "answer-ui" ? (
|
||||
<AnswerUiView
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentChatMessage } from "../types";
|
||||
import { getAnswerPresentation } from "./answer-presentation";
|
||||
import { canExpandAnswer, getAnswerPresentation, getFinalAnswerTranscript } from "./answer-presentation";
|
||||
import type { AnswerUiPart } from "./contract";
|
||||
|
||||
const ui = (id: string): AnswerUiPart => ({
|
||||
@@ -28,6 +28,17 @@ const message: AgentChatMessage = {
|
||||
answerParts: parts
|
||||
};
|
||||
describe("final report and collapsible process", () => {
|
||||
it("keeps short warnings and location confirmations inline while retaining report readers", () => {
|
||||
const note = ui("note");
|
||||
if (note.type !== "answer-ui") throw new Error("invalid fixture");
|
||||
note.block.spec.elements.text.props = { text: "当前工具不能确认高亮。", tone: "warning" };
|
||||
expect(canExpandAnswer([{ type: "text", text: "已定位管道。" }, note])).toBe(false);
|
||||
expect(canExpandAnswer([{ type: "text", text: "完整报告".repeat(300) }])).toBe(true);
|
||||
for (const type of ["Table", "Chart", "FollowUpForm"]) {
|
||||
note.block.spec.elements.text.type = type;
|
||||
expect(canExpandAnswer([note])).toBe(true);
|
||||
}
|
||||
});
|
||||
it("shows all incremental content while waiting for final_answer", () => {
|
||||
expect(getAnswerPresentation(message)).toEqual({ report: parts, process: [], final: false });
|
||||
});
|
||||
@@ -52,3 +63,68 @@ describe("final report and collapsible process", () => {
|
||||
expect(view.process).toEqual(parts);
|
||||
});
|
||||
});
|
||||
|
||||
function phasedUi(id: string, phase: "process" | "final"): AnswerUiPart {
|
||||
const part = ui(id);
|
||||
if (part.type === "answer-ui") {
|
||||
part.block.phase = phase;
|
||||
part.block.fallback_text = `摘要 ${id}`;
|
||||
}
|
||||
return part;
|
||||
}
|
||||
|
||||
describe("final UI anchors", () => {
|
||||
const chart = phasedUi("chart", "final");
|
||||
const progress = phasedUi("progress", "process");
|
||||
it("separates explicit process blocks from the report before a final answer arrives", () => {
|
||||
const view = getAnswerPresentation({ ...message, answerParts: [parts[0], progress, chart, parts[3]] });
|
||||
expect(view.report).toEqual([parts[0], chart, parts[3]]);
|
||||
expect(view.process).toEqual([progress]);
|
||||
expect(view.final).toBe(false);
|
||||
});
|
||||
const anchoredMessage = (content: string, uiBlockIds = ["chart", "progress"]): AgentChatMessage => ({
|
||||
...message,
|
||||
answerParts: [parts[0], progress, chart, parts[3]],
|
||||
finalAnswer: { content, uiBlockIds }
|
||||
});
|
||||
|
||||
it("places final UI between the original Markdown sections and mounts duplicates once", () => {
|
||||
const content = "# 分析\n\n- 第一项\n\n[[ui:chart]]\n\n## 建议\n保留换行\n[[ui:chart]]\n";
|
||||
const view = getAnswerPresentation(anchoredMessage(content));
|
||||
expect(view.report).toEqual([
|
||||
{ type: "text", text: "# 分析\n\n- 第一项\n\n" },
|
||||
chart,
|
||||
{ type: "text", text: "\n## 建议\n保留换行\n" }
|
||||
]);
|
||||
expect(view.process).toEqual([parts[0], progress, parts[3]]);
|
||||
expect(getFinalAnswerTranscript(anchoredMessage(content))).toBe("# 分析\n\n- 第一项\n\n摘要 chart\n## 建议\n保留换行\n");
|
||||
});
|
||||
|
||||
it("never includes process UI even when referenced and anchored", () => {
|
||||
const value = anchoredMessage("结论\n[[ui:progress]]\n[[ui:missing]]\n[[ui:chart]]", ["progress", "missing"]);
|
||||
const view = getAnswerPresentation(value);
|
||||
expect(view.report.every((part) => part.type === "text")).toBe(true);
|
||||
expect(view.process).toEqual([parts[0], progress, chart, parts[3]]);
|
||||
const transcript = getFinalAnswerTranscript(value);
|
||||
expect(transcript).not.toContain("progress");
|
||||
expect(transcript).not.toContain("missing");
|
||||
expect(transcript).not.toContain("chart");
|
||||
expect(transcript).toContain("暂不可用");
|
||||
});
|
||||
|
||||
it("preserves fenced, indented, and quoted markers as literal Markdown", () => {
|
||||
const literal = "```md\n[[ui:chart]]\n```\n\n~~~\n[[ui:chart]]\n~~~\n\n [[ui:chart]]\n\n> [[ui:chart]]\n";
|
||||
const view = getAnswerPresentation(anchoredMessage(literal, []));
|
||||
expect(view.report).toEqual([{ type: "text", text: literal }]);
|
||||
});
|
||||
|
||||
it("retains legacy references and appends final references without anchors", () => {
|
||||
const view = getAnswerPresentation(anchoredMessage("报告"));
|
||||
expect(view.report).toEqual([{ type: "text", text: "报告" }, chart]);
|
||||
expect(getFinalAnswerTranscript(anchoredMessage("报告"))).toBe("报告\n\n摘要 chart");
|
||||
});
|
||||
|
||||
it("does not expand a short answer because of marker identifiers", () => {
|
||||
expect(canExpandAnswer([{ type: "text", text: `结论\n[[ui:${"x".repeat(1500)}]]` }])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,103 @@
|
||||
import type { AgentChatMessage } from "../types";
|
||||
import type { AnswerUiPart } from "./contract";
|
||||
|
||||
/** Short notes and map confirmations stay inline; dense data and reports can expand. */
|
||||
export function canExpandAnswer(parts: AnswerUiPart[]): boolean {
|
||||
let textLength = 0;
|
||||
for (const part of parts) {
|
||||
if (part.type === "text") {
|
||||
textLength += part.text.replace(/\[\[ui:[^\]\r\n]*\]\]/g, "").length;
|
||||
} else {
|
||||
for (const node of Object.values(part.block.spec.elements)) {
|
||||
if (["Table", "Chart", "FollowUpForm"].includes(node.type)) return true;
|
||||
if (node.type === "Text" && typeof node.props.text === "string") textLength += node.props.text.length;
|
||||
if (node.type === "Evidence" && typeof node.props.summary === "string") textLength += node.props.summary.length;
|
||||
if (node.type === "Result" && typeof node.props.summary === "string") textLength += node.props.summary.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return textLength >= 1200;
|
||||
}
|
||||
|
||||
/** Readable copy/TTS text for the same ordered parts shown in the answer. */
|
||||
export function getAnswerPartsTranscript(parts: AnswerUiPart[]): string {
|
||||
return parts.reduce((text, part) => {
|
||||
const next = part.type === "text" ? part.text : part.block.fallback_text;
|
||||
if (!text || !next) return text + next;
|
||||
return text + (text.endsWith("\n") || next.startsWith("\n") ? "" : "\n\n") + next;
|
||||
}, "");
|
||||
}
|
||||
|
||||
/** Final report transcript excludes process blocks and resolves authorized anchors. */
|
||||
export function getFinalAnswerTranscript(message: AgentChatMessage): string {
|
||||
return getAnswerPartsTranscript(getAnswerPresentation(message).report);
|
||||
}
|
||||
|
||||
function splitAnchors(content: string): Array<{ text: string } | { id: string }> {
|
||||
const result: Array<{ text: string } | { id: string }> = [];
|
||||
let buffer = "";
|
||||
let fence: { character: string; length: number } | undefined;
|
||||
for (const line of content.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
|
||||
const bare = line.replace(/\r?\n$/, "");
|
||||
const delimiter = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(bare);
|
||||
if (fence) {
|
||||
buffer += line;
|
||||
if (delimiter && delimiter[1][0] === fence.character && delimiter[1].length >= fence.length && !delimiter[2].trim()) fence = undefined;
|
||||
continue;
|
||||
}
|
||||
if (delimiter) {
|
||||
fence = { character: delimiter[1][0], length: delimiter[1].length };
|
||||
buffer += line;
|
||||
continue;
|
||||
}
|
||||
const anchor = /^ {0,3}\[\[ui:([^\]\r\n]*)\]\][ \t]*$/.exec(bare);
|
||||
if (!anchor) { buffer += line; continue; }
|
||||
if (buffer.trim()) result.push({ text: buffer });
|
||||
buffer = "";
|
||||
result.push({ id: anchor[1] });
|
||||
}
|
||||
if (buffer.trim()) result.push({ text: buffer });
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getAnswerPresentation(message: AgentChatMessage): {
|
||||
report: AnswerUiPart[];
|
||||
process: AnswerUiPart[];
|
||||
final: boolean;
|
||||
} {
|
||||
const parts =
|
||||
message.answerParts ??
|
||||
const parts = message.answerParts ??
|
||||
(message.content ? [{ type: "text" as const, text: message.content }] : []);
|
||||
if (!message.finalAnswer) return { report: parts, process: [], final: false };
|
||||
const selected = new Set(message.finalAnswer.uiBlockIds);
|
||||
const report: AnswerUiPart[] = [{ type: "text", text: message.finalAnswer.content }];
|
||||
for (const id of selected) {
|
||||
const part = parts.find((item) => item.type === "answer-ui" && item.block.block_id === id);
|
||||
if (part) report.push(part);
|
||||
if (!message.finalAnswer) {
|
||||
const isProcess = (part: AnswerUiPart) => part.type === "answer-ui" && part.block.phase === "process";
|
||||
return { report: parts.filter((part) => !isProcess(part)), process: parts.filter(isProcess), final: false };
|
||||
}
|
||||
const process = parts.filter((part) =>
|
||||
part.type === "answer-ui"
|
||||
? !selected.has(part.block.block_id)
|
||||
: part.text.trim() !== message.finalAnswer!.content.trim()
|
||||
);
|
||||
const selected = new Set(message.finalAnswer.uiBlockIds);
|
||||
const available = new Map(parts.flatMap((part) =>
|
||||
part.type === "answer-ui" ? [[part.block.block_id, part] as const] : []));
|
||||
const mounted = new Set<string>();
|
||||
const anchored = new Set<string>();
|
||||
const report: AnswerUiPart[] = [];
|
||||
for (const segment of splitAnchors(message.finalAnswer.content)) {
|
||||
if ("text" in segment) { report.push({ type: "text", text: segment.text }); continue; }
|
||||
anchored.add(segment.id);
|
||||
const part = available.get(segment.id);
|
||||
if (!selected.has(segment.id) || !part || part.block.phase === "process") {
|
||||
report.push({ type: "text", text: "交互内容暂不可用。\n" });
|
||||
} else if (!mounted.has(segment.id)) {
|
||||
mounted.add(segment.id);
|
||||
report.push(part);
|
||||
}
|
||||
}
|
||||
// Old reports carried references without positional anchors.
|
||||
for (const id of selected) {
|
||||
const part = available.get(id);
|
||||
if (part && part.block.phase !== "process" && !mounted.has(id) && !anchored.has(id)) {
|
||||
mounted.add(id);
|
||||
report.push(part);
|
||||
}
|
||||
}
|
||||
const process = parts.filter((part) => part.type === "answer-ui"
|
||||
? !mounted.has(part.block.block_id)
|
||||
: part.text.trim() !== message.finalAnswer!.content.trim());
|
||||
return { report, process, final: true };
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const payloadSchema = z
|
||||
.object({
|
||||
version: z.literal(ANSWER_UI_VERSION),
|
||||
block_id: key,
|
||||
phase: z.enum(["process", "final"]).optional(),
|
||||
fallback_text: z.string().min(1).max(4000),
|
||||
spec: specSchema
|
||||
})
|
||||
@@ -20,6 +21,7 @@ const payloadSchema = z
|
||||
export const storedAnswerUiBlockSchema = z.object({
|
||||
version: z.string(),
|
||||
block_id: key,
|
||||
phase: z.enum(["process", "final"]).optional(),
|
||||
fallback_text: z.string().min(1).max(4000),
|
||||
spec: z.unknown()
|
||||
});
|
||||
@@ -32,6 +34,7 @@ export type StoredReportUiBlock = z.infer<typeof reportUiBlocksSchema>[number];
|
||||
export type AnswerUiBlock = {
|
||||
version: typeof ANSWER_UI_VERSION;
|
||||
block_id: string;
|
||||
phase?: "process" | "final";
|
||||
fallback_text: string;
|
||||
spec: Spec;
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createInteractionRequestApi, type InteractionRequestApi } from "./interaction-requests";
|
||||
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 AgentRunStatus = "running" | "completed" | "error" | "aborted" | "waiting_input";
|
||||
export type AgentSessionPurpose = "ordinary_chat" | "task_execution" | "task_collaboration";
|
||||
|
||||
export type AgentEventTriggerContext = {
|
||||
@@ -40,6 +41,10 @@ export type AgentModelsResponse = {
|
||||
|
||||
export type AgentInputDelivery = "steer" | "queue";
|
||||
export type AgentResponseInput = { id: string; text: string; delivery: AgentInputDelivery; status: "pending" | "delivered" | "cancelled" };
|
||||
export class AgentResponseFinishedError extends Error {}
|
||||
export class AgentSessionStreamError extends Error {
|
||||
constructor(message: string, readonly status: number) { super(message); }
|
||||
}
|
||||
|
||||
export type AgentTopic = { id: string; name: string; createdAt: string; updatedAt: string };
|
||||
|
||||
@@ -115,6 +120,7 @@ export type AgentSessionStreamDataEventType =
|
||||
| "todo_update"
|
||||
| "permission_request"
|
||||
| "permission_response"
|
||||
| "interaction_request"
|
||||
| "question_request"
|
||||
| "question_response"
|
||||
| "tool_call"
|
||||
@@ -148,7 +154,8 @@ export type AgentSessionStreamEvent =
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AgentApiClient = {
|
||||
export type AgentApiClient = InteractionRequestApi & {
|
||||
getHealth: (signal: AbortSignal) => Promise<void>;
|
||||
downloadFile: (path: string) => Promise<void>;
|
||||
submitResponseInput: (sessionId: string, text: string, delivery: AgentInputDelivery, id: string) => Promise<AgentResponseInput>;
|
||||
listResponseInputs: (sessionId: string) => Promise<AgentResponseInput[]>;
|
||||
@@ -191,6 +198,7 @@ export type AgentApiClient = {
|
||||
requestId: string,
|
||||
options: { sessionId: string; reply: AgentPermissionReply; message?: string }
|
||||
) => Promise<void>;
|
||||
getQuestionStatus: (sessionId: string, requestId: string) => Promise<{ status: "pending" | "answered" | "rejected" | "invalidated" }>;
|
||||
replyQuestion: (
|
||||
requestId: string,
|
||||
options: { sessionId: string; answers: string[][] }
|
||||
@@ -239,8 +247,14 @@ export function createAgentApiClient(
|
||||
|
||||
return data as T;
|
||||
};
|
||||
|
||||
return {
|
||||
...createInteractionRequestApi(request),
|
||||
async getHealth(signal) {
|
||||
const response = await fetchWithFallback(candidates, activeBaseUrl, setActiveBaseUrl, "/health", { signal, cache: "no-store" }, undefined, undefined, "");
|
||||
if (!response.ok) throw new Error("Agent 服务暂不可用");
|
||||
const data: unknown = await response.json();
|
||||
if (!isRecord(data) || data.ok !== true || data.ready !== true) throw new Error("Agent 服务尚未就绪");
|
||||
},
|
||||
async downloadFile(path) {
|
||||
const match = /^\/api\/v1(\/session\/[^/?#]+\/files\/mcp-[a-f0-9-]{36}\.(?:zip|xlsx))$/.exec(path);
|
||||
if (!match) throw new Error("无效的会话文件地址");
|
||||
@@ -255,10 +269,14 @@ export function createAgentApiClient(
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
},
|
||||
async submitResponseInput(sessionId, text, delivery, id) {
|
||||
return requestJson<AgentResponseInput>("/responses", {
|
||||
const response = await request("/responses", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ input: text, conversation: sessionId, stream: false, tjwater: { delivery, input_id: id } })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (response.status === 409 && data?.code === "RESPONSE_FINISHED") throw new AgentResponseFinishedError("当前回复已结束");
|
||||
if (!response.ok) throw new Error(getResponseErrorMessage(data, response.status));
|
||||
return data as AgentResponseInput;
|
||||
},
|
||||
async listResponseInputs(sessionId) {
|
||||
const result = await requestJson<{ items: AgentResponseInput[] }>(`/session/${encodeURIComponent(sessionId)}/inputs`);
|
||||
@@ -384,7 +402,7 @@ export function createAgentApiClient(
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
throw new Error(getResponseErrorMessage(data, response.status));
|
||||
throw new AgentSessionStreamError(getResponseErrorMessage(data, response.status), response.status);
|
||||
}
|
||||
|
||||
await readSessionSseStream(response, options.onEvent);
|
||||
@@ -460,6 +478,11 @@ export function createAgentApiClient(
|
||||
});
|
||||
},
|
||||
|
||||
async getQuestionStatus(sessionId, requestId) {
|
||||
const value = await requestJson<{ status: "pending" | "answered" | "rejected" | "invalidated" }>(`/session/${encodeURIComponent(sessionId)}/questions/${encodeURIComponent(requestId)}`, { cache: "no-store" });
|
||||
if (!["pending", "answered", "rejected", "invalidated"].includes(value.status)) throw new Error("问题状态核对失败");
|
||||
return value;
|
||||
},
|
||||
async replyQuestion(requestId, options) {
|
||||
await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reply`, {
|
||||
method: "POST",
|
||||
@@ -505,7 +528,8 @@ async function fetchWithFallback(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
getAccessToken?: AccessTokenProvider,
|
||||
getProjectId?: () => Promise<string | undefined>
|
||||
getProjectId?: () => Promise<string | undefined>,
|
||||
prefix = API_PATH
|
||||
) {
|
||||
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
|
||||
let lastError: unknown;
|
||||
@@ -520,7 +544,7 @@ async function fetchWithFallback(
|
||||
|
||||
for (const baseUrl of orderedBaseUrls) {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${API_PATH}${path}`, requestInit);
|
||||
const response = await fetch(`${baseUrl}${prefix}${path}`, requestInit);
|
||||
if (response.ok) {
|
||||
setActiveBaseUrl(baseUrl);
|
||||
return response;
|
||||
@@ -609,7 +633,7 @@ function normalizeTitle(value: unknown) {
|
||||
}
|
||||
|
||||
function readRunStatus(value: unknown): AgentRunStatus | undefined {
|
||||
return value === "running" || value === "completed" || value === "error" || value === "aborted"
|
||||
return value === "waiting_input" || value === "running" || value === "completed" || value === "error" || value === "aborted"
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
@@ -757,6 +781,7 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea
|
||||
value === "execution_plan" ||
|
||||
value === "permission_request" ||
|
||||
value === "permission_response" ||
|
||||
value === "interaction_request" ||
|
||||
value === "question_request" ||
|
||||
value === "question_response" ||
|
||||
value === "tool_call" ||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { createInteractionRequestApi, interactionRequestSchema } from "./interaction-requests";
|
||||
it("rejects malformed host requests and scopes escaped URLs with no-store reads", async () => {
|
||||
expect(interactionRequestSchema.safeParse({ version: "agent-interaction-request@1", questions: [] }).success).toBe(false);
|
||||
const request = vi.fn(async () => new Response(JSON.stringify({ items: [] }), { status: 200 }));
|
||||
const api = createInteractionRequestApi(request);
|
||||
await expect(api.listInteractionRequests("session/other")).resolves.toEqual({ items: [] });
|
||||
expect(request).toHaveBeenCalledWith("/session/session%2Fother/interactions", { cache: "no-store" });
|
||||
});
|
||||
it("keeps denied status available to purge the scope even with an empty response", async () => {
|
||||
const api = createInteractionRequestApi(async () => new Response(null, { status: 403 }));
|
||||
await expect(api.listInteractionRequests("session")).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const id = z.string().min(1).max(128);
|
||||
const revision = z.number().int().nonnegative();
|
||||
const option = z.object({ id, label: z.string().max(200) }).strict();
|
||||
export const interactionAnswerSchema = z.object({ question_id: id, selected_option_ids: z.array(id).max(12), custom_text: z.string().max(2000) }).strict();
|
||||
export const interactionRequestSchema = z.object({
|
||||
version: z.literal("agent-interaction-request@1"), interaction_id: id, message_id: id, turn_id: id, call_id: id,
|
||||
questions: z.array(z.object({ question_id: id, title: z.string().max(200), body: z.string().max(2000), options: z.array(option).max(12), mode: z.enum(["single", "multiple"]), allow_custom: z.boolean(), required: z.boolean() }).strict()).min(1).max(3),
|
||||
status: z.enum(["pending", "answered", "skipped", "cancelled", "invalidated"]), revision, draft_revision: revision,
|
||||
draft: z.array(interactionAnswerSchema).max(3), answers: z.array(interactionAnswerSchema.extend({ selected_options: z.array(option) }).strict()).optional(),
|
||||
created_at: z.string(), resolved_at: z.string().optional(),
|
||||
delivery: z.object({ native_message_id: id, revision, status: z.enum(["queued", "dispatching", "delivered", "blocked", "cancelled"]), error_code: z.string().optional(), response_id: z.string().optional() }).strict().optional(),
|
||||
}).strict();
|
||||
export type InteractionRequest = z.infer<typeof interactionRequestSchema>;
|
||||
export const canReplaceInteractionRequest = (next: InteractionRequest, current: InteractionRequest) =>
|
||||
(next.delivery?.revision ?? -1) >= (current.delivery?.revision ?? -1)
|
||||
&& (next.revision > current.revision || (next.revision === current.revision && next.draft_revision >= current.draft_revision));
|
||||
export function readInteractionReply(data: unknown, messageId: string): InteractionRequest | undefined {
|
||||
const parsed = interactionRequestSchema.safeParse(data);
|
||||
return parsed.success && ["answered", "skipped"].includes(parsed.data.status)
|
||||
&& parsed.data.delivery?.native_message_id === messageId ? parsed.data : undefined;
|
||||
}
|
||||
export type InteractionAnswer = z.infer<typeof interactionAnswerSchema>;
|
||||
export type InteractionWrite = { version: "agent-interaction-request@1"; request_id: string; expected_revision: number; answers?: InteractionAnswer[] };
|
||||
export type InteractionDraftWrite = { version: "agent-interaction-request@1"; request_id: string; expected_draft_revision: number; answers: InteractionAnswer[] };
|
||||
export class InteractionRequestApiError extends Error {
|
||||
constructor(message: string, readonly status: number, readonly current?: InteractionRequest) { super(message); }
|
||||
}
|
||||
export function createInteractionRequestApi(request: (path: string, init?: RequestInit) => Promise<Response>) {
|
||||
const path = (sessionId: string, interactionId?: string) => `/session/${encodeURIComponent(sessionId)}/interactions${interactionId ? `/${encodeURIComponent(interactionId)}` : ""}`;
|
||||
const read = async (response: Response) => {
|
||||
const value: unknown = await response.json().catch(() => {
|
||||
throw new InteractionRequestApiError("提问状态暂不可用", response.status);
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = z.object({ detail: z.string().optional(), current: z.unknown().optional() }).passthrough().safeParse(value);
|
||||
const current = interactionRequestSchema.safeParse(error.success ? error.data.current : null);
|
||||
throw new InteractionRequestApiError(error.success ? error.data.detail ?? "提问状态暂不可用" : "提问状态暂不可用", response.status, current.success ? current.data : undefined);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
return {
|
||||
async listInteractionRequests(sessionId: string) { return z.object({ items: z.array(interactionRequestSchema), next_cursor: z.string().optional() }).strict().parse(await read(await request(path(sessionId), { cache: "no-store" }))); },
|
||||
async getInteractionRequest(sessionId: string, interactionId: string) { return interactionRequestSchema.parse(await read(await request(path(sessionId, interactionId), { cache: "no-store" }))); },
|
||||
async writeInteractionRequest(sessionId: string, interactionId: string, action: "draft" | "reply" | "skip" | "cancel" | "resume", body: InteractionWrite | InteractionDraftWrite) {
|
||||
return interactionRequestSchema.parse(await read(await request(`${path(sessionId, interactionId)}/${action}`, { method: action === "draft" ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })));
|
||||
},
|
||||
};
|
||||
}
|
||||
export type InteractionRequestApi = ReturnType<typeof createInteractionRequestApi>;
|
||||
@@ -1,10 +1,11 @@
|
||||
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 class AgentStreamDisconnectedError extends Error {}
|
||||
|
||||
export function createResponseRequest(messages: UIMessage[], options: {
|
||||
conversation?: string; model?: string; approvalMode?: string; capabilities?: string[];
|
||||
triggerContext?: unknown; taskContext?: unknown; topicId?: string;
|
||||
interactionId?: string; triggerContext?: unknown; taskContext?: unknown; topicId?: string;
|
||||
}) {
|
||||
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();
|
||||
@@ -13,7 +14,7 @@ export function createResponseRequest(messages: UIMessage[], options: {
|
||||
input: [{ role: "user", content: [{ type: "input_text", text }] }], stream: true,
|
||||
...(options.model ? { model: options.model } : {}),
|
||||
...(options.conversation ? { conversation: options.conversation } : {}),
|
||||
tjwater: { ...(options.topicId ? { topic_id: options.topicId } : {}), ...(latest?.id ? { client_message_id: latest.id } : {}), approval_mode: options.approvalMode ?? "request", capabilities: options.capabilities ?? [],
|
||||
tjwater: { ...(options.interactionId ? { interaction_id: options.interactionId } : {}), ...(options.topicId ? { topic_id: options.topicId } : {}), ...(latest?.id ? { client_message_id: latest.id } : {}), approval_mode: options.approvalMode ?? "request", capabilities: options.capabilities ?? [],
|
||||
...(options.triggerContext ? { trigger_context: options.triggerContext } : {}),
|
||||
...(options.taskContext ? { task_context: options.taskContext } : {}),
|
||||
},
|
||||
@@ -99,7 +100,7 @@ export function responsesToUiStream(stream: ReadableStream<Uint8Array>): Readabl
|
||||
buffer += decoder.decode();
|
||||
flushBlocks(controller);
|
||||
if (buffer.trim()) parse(buffer, controller);
|
||||
if (!terminal) throw new Error("Responses 流在完成前断开,请重新打开会话查看状态");
|
||||
if (!terminal) throw new AgentStreamDisconnectedError("Responses 流在完成前断开,正在恢复会话连接");
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,36 +1,49 @@
|
||||
import { useState } from "react";
|
||||
import { Check, Copy, LoaderCircle } from "lucide-react";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Check, Copy, LoaderCircle, CircleAlert } from "lucide-react";
|
||||
import { MessageAction, MessageActions } from "@/shared/ai-elements/message";
|
||||
import { getAnswerPartsTranscript } from "../answer-ui/answer-presentation";
|
||||
import type { AnswerUiPart } from "../answer-ui/contract";
|
||||
|
||||
export function AgentChatMessageHeader({
|
||||
running,
|
||||
interactive
|
||||
}: {
|
||||
running: boolean;
|
||||
interactive: boolean;
|
||||
}) {
|
||||
if (!running && !interactive) return null;
|
||||
|
||||
export function AgentChatMessageHeader({ running }: { running: boolean }) {
|
||||
if (!running) return null;
|
||||
return (
|
||||
<div className="agent-chat-message-header">
|
||||
{running ? (
|
||||
<span
|
||||
role="status"
|
||||
className="ml-auto inline-flex items-center gap-1.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
<LoaderCircle size={12} className="motion-safe:animate-spin" aria-hidden="true" />
|
||||
正在回复
|
||||
</span>
|
||||
) : interactive ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-auto border-0 px-2 py-0.5 text-[10px] font-medium shadow-none"
|
||||
>
|
||||
交互回答
|
||||
</Badge>
|
||||
) : null}
|
||||
<div className="agent-chat-message-header justify-start text-left">
|
||||
<span
|
||||
role="status"
|
||||
className="inline-flex items-center gap-1.5 text-sm leading-5 text-muted-foreground"
|
||||
>
|
||||
<LoaderCircle size={14} className="motion-safe:animate-spin" aria-hidden="true" />
|
||||
正在回复
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const AGENT_MESSAGE_ACTION_CLASS_NAME =
|
||||
"h-8 min-w-8 gap-1.5 px-2 text-sm font-normal text-muted-foreground shadow-none after:inset-0";
|
||||
export const AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME = "hidden @[320px]/agent-message-actions:inline";
|
||||
|
||||
export function AgentChatMessageToolbar({
|
||||
speech,
|
||||
copy,
|
||||
children
|
||||
}: {
|
||||
speech?: ReactNode;
|
||||
copy?: ReactNode;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-agent-message-footer
|
||||
className="@container/agent-message-actions mt-2 border-t border-border/70 pt-1"
|
||||
>
|
||||
<div data-agent-message-toolbar className="flex min-h-8 min-w-0 items-center gap-1">
|
||||
{children}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{speech}
|
||||
{copy}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,18 +51,16 @@ export function AgentChatMessageHeader({
|
||||
export function AgentChatMessageActions({ parts }: { parts: AnswerUiPart[] }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const text = parts
|
||||
.map((part) => (part.type === "text" ? part.text : part.block.fallback_text))
|
||||
.join("\n\n");
|
||||
const text = getAnswerPartsTranscript(parts);
|
||||
if (!text.trim()) return null;
|
||||
|
||||
return (
|
||||
<MessageActions className="agent-chat-message-actions">
|
||||
<MessageAction
|
||||
tooltip={copied ? "回复已复制" : "复制回复"}
|
||||
tooltip={error ? "复制失败,点击重试" : copied ? "回复已复制" : "复制回复"}
|
||||
label={copied ? "回复已复制" : "复制回复"}
|
||||
size="sm"
|
||||
className="h-10 gap-1.5 px-2 text-[11px] font-normal shadow-none"
|
||||
className={AGENT_MESSAGE_ACTION_CLASS_NAME}
|
||||
aria-label={copied ? "回复已复制" : "复制回复"}
|
||||
onClick={async () => {
|
||||
try {
|
||||
@@ -62,10 +73,18 @@ export function AgentChatMessageActions({ parts }: { parts: AnswerUiPart[] }) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{copied ? <Check size={13} aria-hidden="true" /> : <Copy size={13} aria-hidden="true" />}
|
||||
{copied ? "已复制" : "复制回复"}
|
||||
{error ? (
|
||||
<CircleAlert size={16} className="text-destructive" aria-hidden="true" />
|
||||
) : copied ? (
|
||||
<Check size={16} aria-hidden="true" />
|
||||
) : (
|
||||
<Copy size={16} aria-hidden="true" />
|
||||
)}
|
||||
<span className={AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME}>
|
||||
{error ? "重试复制" : copied ? "已复制" : "复制"}
|
||||
</span>
|
||||
</MessageAction>
|
||||
<span role="status" className="text-[11px] text-muted-foreground">
|
||||
<span role="status" className="sr-only">
|
||||
{error ? "复制失败,请选择文字复制" : copied ? "已复制回复文字与交互内容摘要" : ""}
|
||||
</span>
|
||||
</MessageActions>
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
import { AgentInteractionReply, AgentInteractionRequests } from "./agent-interaction-request-card";
|
||||
import { canReplaceInteractionRequest } from "../api/interaction-requests";
|
||||
import type { InteractionRequestsController } from "../hooks/use-interaction-requests";
|
||||
import type { AnswerUiMapTarget } from "../answer-ui/answer-ui-view";
|
||||
import { AnswerPartsView } from "../answer-ui/answer-parts-view";
|
||||
import type { AnswerUiSelection } from "../answer-ui/answer-ui-interaction-context";
|
||||
import { ExpandableReading } from "@/shared/ui/reading-workspace";
|
||||
import { getAnswerPresentation } from "../answer-ui/answer-presentation";
|
||||
import { Disclosure } from "@/shared/ui/disclosure";
|
||||
import { canExpandAnswer, getAnswerPartsTranscript, getAnswerPresentation } from "../answer-ui/answer-presentation";
|
||||
import { AgentProcessDisclosure } from "./agent-process-disclosure";
|
||||
import { AgentMessageProcessContent } from "./agent-message-process-content";
|
||||
import { groupMessageProcesses, hasPendingMessageInput } from "../presentation/message-process-groups";
|
||||
import { AgentUserMessageText } from "./agent-user-message-text";
|
||||
import { useAgentAnswerTransition } from "../hooks/use-agent-answer-transition";
|
||||
import { copySelectedText } from "@/shared/ai-elements/paragraph-copy";
|
||||
import {
|
||||
ArrowUp,
|
||||
AudioLines,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
ChevronLeft,
|
||||
History,
|
||||
LoaderCircle,
|
||||
Mic,
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
Quote,
|
||||
SendHorizontal,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Square,
|
||||
@@ -34,6 +41,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type Ref,
|
||||
type ReactNode,
|
||||
type SetStateAction
|
||||
} from "react";
|
||||
@@ -73,7 +81,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shar
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { StatusDot, type StatusTone } from "@/shared/ui/status";
|
||||
import type { AgentChatSessionSummary, AgentInputDelivery, AgentResponseInput } from "../api/client";
|
||||
import { AgentChatMessageActions, AgentChatMessageHeader } from "./agent-chat-message-chrome";
|
||||
import { AGENT_MESSAGE_ACTION_CLASS_NAME, AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME, AgentChatMessageActions, AgentChatMessageHeader, AgentChatMessageToolbar } from "./agent-chat-message-chrome";
|
||||
import {
|
||||
agentLayoutTransition,
|
||||
agentPanelItemVariants,
|
||||
@@ -86,7 +94,7 @@ import { AgentHistoryPanel } from "./agent-history-panel";
|
||||
import { AgentMessageInbox } from "./agent-message-inbox";
|
||||
import { AgentPendingInputs } from "./agent-pending-inputs";
|
||||
import type { ComponentProps } from "react";
|
||||
import { AgentMessageDetails, AgentMessageProgress } from "./agent-message-details";
|
||||
import { AgentMessageDetails } from "./agent-message-details";
|
||||
import { AgentUiEnvelopeRenderer } from "./agent-ui-envelope-renderer";
|
||||
import { useAgentSpeechRecognition, useAgentSpeechSynthesis } from "../hooks/use-agent-voice";
|
||||
import {
|
||||
@@ -142,6 +150,7 @@ type AgentCommandPanelProps = {
|
||||
promptSuggestions?: Array<{ question: string; sourceLabel?: string }>;
|
||||
promptSuggestionsLoading?: boolean;
|
||||
promptSuggestionsUnavailable?: boolean;
|
||||
onRetryPromptSuggestions?: () => void;
|
||||
activeSessionId?: string | null;
|
||||
qaOnly?: boolean;
|
||||
statusLabel?: string;
|
||||
@@ -165,6 +174,7 @@ type AgentCommandPanelProps = {
|
||||
onMoveSessionToTopic?: (sessionId: string, topicId: string | null) => Promise<void>;
|
||||
approvalMode?: AgentApprovalMode;
|
||||
uiResults?: AgentUiResult[];
|
||||
interactionRequests?: InteractionRequestsController;
|
||||
reviewPanel?: ReactNode;
|
||||
messageInbox?: ComponentProps<typeof AgentMessageInbox>;
|
||||
workspaceContext?: AgentWorkspaceContextItem[];
|
||||
@@ -207,6 +217,7 @@ export function AgentCommandPanel({
|
||||
promptSuggestions = [],
|
||||
promptSuggestionsLoading = false,
|
||||
promptSuggestionsUnavailable = false,
|
||||
onRetryPromptSuggestions,
|
||||
activeSessionId,
|
||||
qaOnly = false,
|
||||
connectionStatus = "connecting",
|
||||
@@ -226,6 +237,7 @@ export function AgentCommandPanel({
|
||||
topicControls, historyTopicId, onHistoryTopicChange, onMoveSessionToTopic,
|
||||
approvalMode = "request",
|
||||
uiResults = [],
|
||||
interactionRequests,
|
||||
reviewPanel,
|
||||
messageInbox,
|
||||
prompt,
|
||||
@@ -253,6 +265,7 @@ export function AgentCommandPanel({
|
||||
const [historySelectionError, setHistorySelectionError] = useState<string | undefined>();
|
||||
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||
const [scrollRequestId, setScrollRequestId] = useState(0);
|
||||
const [submissionError, setSubmissionError] = useState<string | null>(null);
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const historyTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const promptRestoreGenerationRef = useRef(0);
|
||||
@@ -261,11 +274,13 @@ export function AgentCommandPanel({
|
||||
const desktopFloating = presentation === "desktop-floating";
|
||||
const floatingPresentation = desktopFloating;
|
||||
const hasConversation = messages.length > 0 || streaming || Boolean(messageInbox?.messages.length);
|
||||
// Existing task state and loading/errors stay visible even before a first message.
|
||||
const centeredComposer = !hasConversation && !loadingSessionId && !reviewPanel
|
||||
// Task discussions keep their reference and composer layout before the first message.
|
||||
const centeredComposer = !hasConversation && !loadingSessionId && !reviewPanel && !promptReference
|
||||
&& uiResults.length === 0 && !messageInbox?.loading && !messageInbox?.error;
|
||||
const showStartSuggestions = centeredComposer && !qaOnly && !promptReference;
|
||||
const trimmedPrompt = prompt.trim();
|
||||
const isStopAction = streaming && !trimmedPrompt;
|
||||
const acceptsAdditionalInput = streaming || Boolean(interactionRequests?.awaitingInput);
|
||||
useEffect(() => {
|
||||
if (!promptFocusRequest) return;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
@@ -315,6 +330,7 @@ export function AgentCommandPanel({
|
||||
|
||||
useEffect(() => {
|
||||
stopSpeech();
|
||||
setSubmissionError(null);
|
||||
}, [activeSessionId, stopSpeech]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -325,17 +341,22 @@ export function AgentCommandPanel({
|
||||
if (!nextPrompt) {
|
||||
return;
|
||||
}
|
||||
const submittedReference = contentReference;
|
||||
const message = submittedReference
|
||||
? `${nextPrompt}\n\n引用内容:${submittedReference.title}\n${submittedReference.text}`
|
||||
: nextPrompt;
|
||||
if (message.length > 10000) {
|
||||
setSubmissionError(`消息与引用合计 ${message.length.toLocaleString()} 字,超过 10,000 字。请缩短问题或减少引用内容后再发送。`);
|
||||
return;
|
||||
}
|
||||
setSubmissionError(null);
|
||||
const restoreGeneration = promptRestoreGenerationRef.current + 1;
|
||||
promptRestoreGenerationRef.current = restoreGeneration;
|
||||
onPromptChange("");
|
||||
stopListening();
|
||||
setScrollRequestId((requestId) => requestId + 1);
|
||||
const submittedReference = contentReference;
|
||||
const message = submittedReference
|
||||
? `${nextPrompt}\n\n引用内容:${submittedReference.title}\n${submittedReference.text}`
|
||||
: nextPrompt;
|
||||
onContentReferenceChange?.(undefined);
|
||||
void Promise.resolve(onSubmitPrompt(message)).catch(() => {
|
||||
void Promise.resolve().then(() => onSubmitPrompt(message)).catch(() => {
|
||||
if (!mountedRef.current || promptRestoreGenerationRef.current !== restoreGeneration) {
|
||||
return;
|
||||
}
|
||||
@@ -375,6 +396,7 @@ export function AgentCommandPanel({
|
||||
<MotionConfig reducedMotion="user">
|
||||
{historyPanel}
|
||||
<motion.aside
|
||||
onCopy={copySelectedText}
|
||||
aria-label="Agent 命令面板"
|
||||
data-agent-presentation={presentation}
|
||||
data-agent-starting={!hasConversation}
|
||||
@@ -423,7 +445,7 @@ export function AgentCommandPanel({
|
||||
<div className="flex min-w-0 max-w-full items-center gap-1 sm:gap-2">
|
||||
<p className="shrink-0 whitespace-nowrap text-sm font-semibold leading-5 text-foreground">供水助手</p>
|
||||
<p role="status" title={agentConnectionMetadata[connectionStatus].label}
|
||||
className="flex shrink-0 items-center gap-0.5 whitespace-nowrap text-xs leading-4 text-muted-foreground sm:gap-1.5">
|
||||
className="flex shrink-0 items-center gap-1 whitespace-nowrap text-xs leading-4 text-muted-foreground sm:gap-1.5">
|
||||
<StatusDot tone={agentConnectionMetadata[connectionStatus].tone} activity={connectionStatus === "connecting" ? "live" : "static"} />
|
||||
<span>{agentConnectionMetadata[connectionStatus].label}</span>
|
||||
</p>
|
||||
@@ -527,6 +549,7 @@ export function AgentCommandPanel({
|
||||
messages={messages}
|
||||
streaming={streaming}
|
||||
streamRenderState={streamRenderState}
|
||||
interactionRequests={interactionRequests}
|
||||
onReplyPermission={onReplyPermission}
|
||||
onReplyQuestion={onReplyQuestion}
|
||||
onRejectQuestion={onRejectQuestion}
|
||||
@@ -550,6 +573,8 @@ export function AgentCommandPanel({
|
||||
|
||||
{!qaOnly ? <AgentUiEnvelopeRenderer results={uiResults} /> : null}
|
||||
{reviewPanel}
|
||||
{interactionRequests ? <AgentInteractionRequests controller={interactionRequests}
|
||||
representedMessageIds={new Set(messages.filter((message) => message.role === "user").map((message) => message.id))} /> : null}
|
||||
{messageInbox ? <AgentMessageInbox {...messageInbox} /> : null}
|
||||
</ConversationContent>
|
||||
<AgentConversationScrollManager
|
||||
@@ -574,7 +599,7 @@ export function AgentCommandPanel({
|
||||
className={cn(
|
||||
"agent-panel-band relative",
|
||||
floatingPresentation
|
||||
? "form-field agent-panel-floating-acrylic agent-panel-composer absolute bottom-3 z-20 rounded-2xl p-1"
|
||||
? "agent-panel-floating-acrylic agent-panel-composer absolute bottom-3 z-20 rounded-2xl p-1"
|
||||
: "border-t border-[var(--surface-divider)] p-3"
|
||||
)}
|
||||
>
|
||||
@@ -590,11 +615,11 @@ export function AgentCommandPanel({
|
||||
layoutDependency={centeredComposer}
|
||||
transition={{ layout: agentLayoutTransition }}
|
||||
>
|
||||
<AgentPendingInputs presentation={presentation} inputs={pendingInputs}
|
||||
onDeliveryChange={onPendingInputDeliveryChange}
|
||||
onEdit={onPendingInputEdit} onCancel={onPendingInputCancel} />
|
||||
<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",
|
||||
!desktopFloating ? "agent-panel-input-form" : "form-field shadow-xs"
|
||||
)}
|
||||
className="@container/agent-composer agent-panel-control agent-panel-input-form overflow-hidden rounded-2xl [&>[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) {
|
||||
@@ -602,29 +627,34 @@ export function AgentCommandPanel({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AgentPendingInputs presentation={presentation} inputs={pendingInputs}
|
||||
onDeliveryChange={onPendingInputDeliveryChange}
|
||||
onEdit={onPendingInputEdit} onCancel={onPendingInputCancel} />
|
||||
<PromptInputBody className={!desktopFloating ? "agent-panel-input-body form-field" : undefined}>
|
||||
{contentReference ? <div role="group" aria-label="已选内容引用" className="m-3 mb-0 rounded-lg border bg-muted/30 p-2.5">
|
||||
<div className="flex items-start gap-2">
|
||||
<Quote size={14} className="mt-1 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<div className="min-w-0 flex-1 text-xs leading-5">
|
||||
<p className="font-medium">{contentReference.title}</p>
|
||||
<details><summary className="cursor-pointer text-muted-foreground">查看引用内容</summary>
|
||||
<p className="max-h-32 overflow-y-auto whitespace-pre-wrap break-words text-muted-foreground">{contentReference.text}</p>
|
||||
</details>
|
||||
<PromptInputBody className="agent-panel-input-body">
|
||||
{contentReference ? <div role="group" aria-label="已选内容引用" className="agent-panel-input-reference mx-3 mt-2 flex min-w-0 items-start gap-1.5 text-sm leading-5">
|
||||
<Quote size={14} className="mt-2 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<details className="min-w-0 flex-1 py-1.5">
|
||||
<summary className="flex cursor-pointer list-none flex-wrap items-baseline gap-x-2 gap-y-1 rounded-sm focus-visible:outline-2 focus-visible:outline-ring">
|
||||
<span className="min-w-0 truncate font-medium">{contentReference.title}</span>
|
||||
<span className="shrink-0 text-muted-foreground">查看引用内容</span>
|
||||
</summary>
|
||||
<p className="mt-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words text-muted-foreground">{contentReference.text}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1" aria-label="引用内容的分析方向">
|
||||
{["解释这些内容", "比较差异与关联", "核查依据与缺口"].map((intent) => <Button key={intent} type="button" variant="outline" size="sm" className="h-8 text-sm"
|
||||
onClick={() => {
|
||||
promptRestoreGenerationRef.current += 1;
|
||||
onPromptChange((current) => current.trim() ? `${current}\n${intent}` : intent);
|
||||
setSubmissionError(null);
|
||||
promptTextareaRef.current?.focus();
|
||||
}}>{intent}</Button>)}
|
||||
</div>
|
||||
<Button type="button" size="sm" variant="ghost" className="h-8 text-xs" aria-label="移除内容引用"
|
||||
onClick={() => onContentReferenceChange?.(undefined)}>移除</Button>
|
||||
</div>
|
||||
</details>
|
||||
<Button type="button" size="icon" variant="ghost" className="size-8 shrink-0" aria-label="移除内容引用" title="移除内容引用"
|
||||
onClick={() => { onContentReferenceChange?.(undefined); setSubmissionError(null); }}><X size={14} aria-hidden /></Button>
|
||||
</div> : null}
|
||||
{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 role="group" aria-label="对话引用" className="agent-panel-input-reference mx-3 mt-2 flex min-w-0 items-start gap-1.5 py-1 text-sm leading-5 text-muted-foreground">
|
||||
<Quote aria-hidden="true" className="mt-0.5 size-3.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1 break-words">
|
||||
<span data-agent-reference-title className="font-medium text-foreground">{promptReference.title}</span>
|
||||
{promptReference.detail ? <>{" "}<span data-agent-reference-detail className="ml-1">{promptReference.detail.split(" · ").map((detail, index) => <span key={detail} className="inline-block whitespace-nowrap">{index ? " · " : ""}{detail}</span>)}</span></> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -632,20 +662,22 @@ export function AgentCommandPanel({
|
||||
ref={promptTextareaRef}
|
||||
value={prompt}
|
||||
onChange={(event) => {
|
||||
setSubmissionError(null);
|
||||
promptRestoreGenerationRef.current += 1;
|
||||
onPromptChange(event.currentTarget.value);
|
||||
}}
|
||||
className={cn("agent-panel-prompt-textarea text-sm", floatingPresentation ? "min-h-20" : "min-h-14")}
|
||||
className={cn("agent-panel-prompt-textarea text-base", floatingPresentation ? "min-h-20" : "min-h-14")}
|
||||
aria-label={promptReference?.placeholder ?? "询问供水问题,或描述需要处理的任务"}
|
||||
placeholder={promptReference?.placeholder ?? (streaming ? "继续补充,或安排下一条消息…" : "询问供水问题,或描述需要处理的任务")}
|
||||
/>
|
||||
</PromptInputBody>
|
||||
{submissionError ? <p role="alert" className="mx-3 mb-2 text-sm leading-5 text-destructive">{submissionError}</p> : null}
|
||||
<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 className="flex min-w-0 items-center gap-1">
|
||||
<ApprovalModeSelect presentation={presentation} value={approvalMode} disabled={streaming} onValueChange={onApprovalModeChange} />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{streaming ? <InputDeliverySelect presentation={presentation} label="运行中消息发送方式"
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{acceptsAdditionalInput ? <InputDeliverySelect presentation={presentation} label="消息发送方式"
|
||||
value={inputDelivery} onValueChange={onInputDeliveryChange} /> : <AgentModelSelect
|
||||
presentation={presentation}
|
||||
models={modelOptions}
|
||||
@@ -662,20 +694,18 @@ export function AgentCommandPanel({
|
||||
/>
|
||||
) : null}
|
||||
<PromptInputSubmit
|
||||
aria-label={streaming && !trimmedPrompt ? "停止 Agent 指令" : streaming ? inputDelivery === "steer" ? "引导当前 Agent 任务" : "将消息加入 Agent 队列" : "发送 Agent 指令"}
|
||||
title={streaming && !trimmedPrompt ? "停止 Agent 指令" : streaming ? inputDelivery === "steer" ? "引导当前 Agent 任务" : "将消息加入 Agent 队列" : "发送 Agent 指令"}
|
||||
className="agent-panel-primary-icon"
|
||||
aria-label={isStopAction ? "停止 Agent 指令" : acceptsAdditionalInput ? inputDelivery === "steer" ? "引导当前 Agent 任务" : "将消息加入 Agent 队列" : "发送 Agent 指令"}
|
||||
title={isStopAction ? "停止 Agent 指令" : acceptsAdditionalInput ? inputDelivery === "steer" ? "引导当前 Agent 任务" : "将消息加入 Agent 队列" : "发送 Agent 指令"}
|
||||
className="agent-panel-primary-icon size-10 shrink-0"
|
||||
data-agent-action={isStopAction ? "stop" : "send"}
|
||||
disabled={!streaming && !trimmedPrompt}
|
||||
status={streaming && !trimmedPrompt ? "streaming" : "ready"}
|
||||
status={isStopAction ? "streaming" : "ready"}
|
||||
onStop={onStopPrompt}
|
||||
>
|
||||
{streaming && !trimmedPrompt ? <Square size={15} aria-hidden="true" /> : (
|
||||
<SendHorizontal
|
||||
size={15}
|
||||
style={{ transform: "rotate(-45deg)" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{isStopAction ? <Square className="size-4" aria-hidden /> : <ArrowUp
|
||||
className="agent-panel-send-icon size-5"
|
||||
aria-hidden="true"
|
||||
/>}
|
||||
</PromptInputSubmit>
|
||||
</div>
|
||||
</PromptInputFooter>
|
||||
@@ -704,7 +734,12 @@ export function AgentCommandPanel({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)) : (
|
||||
<p className="agent-panel-suggestion-status">{promptSuggestionsUnavailable ? "推荐问题暂不可用,可直接输入任务。" : "描述调查目标,即可开始分析。"}</p>
|
||||
<div className="agent-panel-suggestion-status">
|
||||
<p>{promptSuggestionsUnavailable ? "推荐问题暂不可用,可直接输入任务。" : "描述调查目标,即可开始分析。"}</p>
|
||||
{promptSuggestionsUnavailable && onRetryPromptSuggestions && (
|
||||
<Button variant="ghost" size="sm" onClick={onRetryPromptSuggestions}>重试推荐</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Suggestions>
|
||||
</TooltipProvider>
|
||||
@@ -744,7 +779,7 @@ 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-0 after:absolute after:-inset-1 after:content-['']",
|
||||
"agent-panel-voice-input group relative grid size-10 shrink-0 place-items-center overflow-visible rounded-full border-0",
|
||||
"bg-transparent shadow-none",
|
||||
"transition-[color,background-color,border-color,box-shadow,transform] duration-200",
|
||||
"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-0",
|
||||
@@ -759,7 +794,7 @@ function VoiceInputButton({
|
||||
{isListening ? (
|
||||
<motion.span
|
||||
data-slot="voice-input-pulse"
|
||||
className="pointer-events-none absolute inset-0 rounded-lg bg-[var(--status-mark)] opacity-20"
|
||||
className="pointer-events-none absolute inset-0 rounded-full bg-[var(--status-mark)] opacity-20"
|
||||
aria-hidden="true"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={
|
||||
@@ -816,7 +851,7 @@ function VoiceInputButton({
|
||||
</motion.span>
|
||||
) : (
|
||||
<motion.span
|
||||
key="voice-microphone"
|
||||
key="voice-idle"
|
||||
className="grid place-items-center"
|
||||
aria-hidden="true"
|
||||
initial={{ opacity: 0, scale: 0.72, y: 2 }}
|
||||
@@ -824,10 +859,9 @@ function VoiceInputButton({
|
||||
exit={{ opacity: 0, scale: 0.72, y: -2 }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
>
|
||||
<Mic
|
||||
size={15}
|
||||
strokeWidth={2.15}
|
||||
className="transition-transform duration-200 group-hover:scale-105"
|
||||
<AudioLines
|
||||
strokeWidth={2}
|
||||
className="size-4 transition-transform duration-200 group-hover:scale-105"
|
||||
/>
|
||||
</motion.span>
|
||||
)}
|
||||
@@ -840,6 +874,10 @@ 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 AgentToolbarCaret() {
|
||||
return <ChevronDown className="agent-panel-toolbar-caret size-3 shrink-0 opacity-50" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
function InputDeliverySelect({ presentation, label, value, onValueChange }: {
|
||||
presentation: AgentCommandPanelPresentation;
|
||||
label: string;
|
||||
@@ -850,10 +888,10 @@ function InputDeliverySelect({ presentation, label, value, onValueChange }: {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="sm" aria-label={label}
|
||||
title={value === "queue" ? "当前任务结束后处理" : "在下一步补充当前任务"}
|
||||
className="agent-panel-toolbar-trigger h-10 shrink-0 gap-1.5 px-2 text-xs font-medium active:scale-95 motion-reduce:transform-none"
|
||||
className="agent-panel-toolbar-trigger h-10 shrink-0 gap-1.5 px-2 text-sm font-normal active:scale-95 motion-reduce:transform-none"
|
||||
disabled={!onValueChange}>
|
||||
{value === "queue" ? "排队" : "引导"}
|
||||
<ChevronDown className="size-3 opacity-60" aria-hidden="true" />
|
||||
<AgentToolbarCaret />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent data-agent-presentation={presentation} side="top" align="end" sideOffset={6}
|
||||
@@ -863,7 +901,7 @@ function InputDeliverySelect({ presentation, label, value, onValueChange }: {
|
||||
}}>
|
||||
{([ ["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",
|
||||
className={cn(AGENT_SELECT_ITEM_CLASS_NAME, "min-h-10 pl-8 pr-2.5 text-sm",
|
||||
value === delivery && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]")}>
|
||||
{text}
|
||||
</DropdownMenuRadioItem>
|
||||
@@ -893,17 +931,18 @@ function ApprovalModeSelect({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
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"
|
||||
className="agent-panel-toolbar-trigger h-10 shrink-0 gap-1.5 border-0 bg-transparent px-1.5 text-sm 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="权限批准模式"
|
||||
title={requestApproval ? "请求批准" : "始终允许"}
|
||||
>
|
||||
{requestApproval ? (
|
||||
<Shield size={14} aria-hidden="true" />
|
||||
<Shield className="size-4" aria-hidden="true" />
|
||||
) : (
|
||||
<ShieldCheck size={14} aria-hidden="true" />
|
||||
<ShieldCheck className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
<span>{requestApproval ? "请求批准" : "始终允许"}</span>
|
||||
<ChevronsUpDown size={12} className="opacity-50" aria-hidden="true" />
|
||||
<AgentToolbarCaret />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
@@ -1130,7 +1169,7 @@ function AgentModelSelect({
|
||||
|
||||
if (!models.length) {
|
||||
return (
|
||||
<span className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-[var(--ink-muted)]">模型</span>
|
||||
<span className="shrink-0 rounded-md px-2 py-1 text-sm text-[var(--ink-muted)]">模型</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1141,7 +1180,7 @@ function AgentModelSelect({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
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"
|
||||
className="agent-panel-toolbar-trigger h-10 w-11 shrink-0 justify-between gap-1 border-0 bg-transparent px-1 text-sm 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 @[400px]/agent-composer:w-auto @[400px]/agent-composer:max-w-40 @[400px]/agent-composer:gap-1.5 @[400px]/agent-composer:px-1.5"
|
||||
disabled={!onValueChange}
|
||||
aria-label="选择 Agent 模型"
|
||||
aria-description={`当前模型:${selectedModel?.label ?? "模型"}`}
|
||||
@@ -1149,9 +1188,9 @@ function AgentModelSelect({
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<ModelIcon model={selectedModel} />
|
||||
<span className="hidden shrink-0 sm:inline">{selectedModel?.label ?? "模型"}</span>
|
||||
<span className="hidden truncate @[400px]/agent-composer:inline">{selectedModel?.label ?? "模型"}</span>
|
||||
</span>
|
||||
<ChevronsUpDown size={12} className="shrink-0 opacity-50" aria-hidden="true" />
|
||||
<AgentToolbarCaret />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
@@ -1207,13 +1246,77 @@ const agentConnectionMetadata = {
|
||||
|
||||
function ModelIcon({ model }: { model?: AgentModelOption }) {
|
||||
const Icon = model?.icon === "bolt" ? Zap : Sparkles;
|
||||
return <Icon className="size-4 text-primary" aria-hidden="true" />;
|
||||
return <Icon className="size-4" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
function BackendMessageList({
|
||||
type BackendMessageListProps = {
|
||||
presentation: AgentCommandPanelPresentation;
|
||||
messages: AgentChatMessage[];
|
||||
streaming: boolean;
|
||||
streamRenderState: AgentStreamRenderState;
|
||||
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
|
||||
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
|
||||
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
|
||||
isSpeechSupported: boolean;
|
||||
speakingMessageId: string | null;
|
||||
speechState: AgentSpeechState;
|
||||
onSpeak: (messageId: string, text: string, options?: AgentSpeakOptions) => Promise<void> | void;
|
||||
onPauseSpeech: () => void;
|
||||
onResumeSpeech: () => void;
|
||||
onStopSpeech: () => void;
|
||||
qaOnly: boolean;
|
||||
onLocateAnswerFeatures?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
|
||||
onSubmitAnswerForm: (text: string) => Promise<void> | void;
|
||||
onAsk?: (selection: AnswerUiSelection) => void;
|
||||
interactionRequests?: InteractionRequestsController;
|
||||
};
|
||||
|
||||
function BackendMessageList(props: BackendMessageListProps) {
|
||||
const { messages, streaming, interactionRequests } = props;
|
||||
const replies = new Map<string, InteractionRequestsController["entries"][number]>();
|
||||
for (const message of messages) {
|
||||
if (message.role === "user" && message.interactionReply) replies.set(message.id, {
|
||||
record: message.interactionReply, draft: message.interactionReply.draft, dirty: false, busy: false,
|
||||
});
|
||||
}
|
||||
for (const entry of interactionRequests?.entries ?? []) {
|
||||
if (!entry.record.delivery || !["answered", "skipped"].includes(entry.record.status)) continue;
|
||||
const id = entry.record.delivery.native_message_id;
|
||||
const previous = replies.get(id);
|
||||
if (!previous || canReplaceInteractionRequest(entry.record, previous.record)) replies.set(id, entry);
|
||||
}
|
||||
const processGroups = groupMessageProcesses(messages, [...replies.values()].map(({ record }) => record));
|
||||
return (
|
||||
<motion.div
|
||||
className="agent-chat-message-list flex w-full flex-col gap-6"
|
||||
variants={agentPanelListVariants}
|
||||
animate="animate"
|
||||
>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{messages.map((message, index) => {
|
||||
return (
|
||||
<BackendMessage
|
||||
key={message.id}
|
||||
{...props}
|
||||
message={message}
|
||||
processMessages={processGroups.get(message.id) ?? []}
|
||||
running={streaming && index === messages.length - 1}
|
||||
reply={message.role === "user" ? replies.get(message.id) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackendMessage({
|
||||
ref,
|
||||
message: incomingMessage,
|
||||
processMessages: incomingProcessMessages,
|
||||
running,
|
||||
reply,
|
||||
presentation,
|
||||
messages,
|
||||
streaming,
|
||||
streamRenderState,
|
||||
onReplyPermission,
|
||||
onReplyQuestion,
|
||||
@@ -1228,147 +1331,170 @@ function BackendMessageList({
|
||||
qaOnly,
|
||||
onLocateAnswerFeatures,
|
||||
onSubmitAnswerForm,
|
||||
onAsk
|
||||
}: {
|
||||
presentation: AgentCommandPanelPresentation;
|
||||
messages: AgentChatMessage[];
|
||||
streaming: boolean;
|
||||
streamRenderState: AgentStreamRenderState;
|
||||
onReplyPermission?: (
|
||||
permission: AgentPermissionRequest,
|
||||
reply: AgentPermissionReply
|
||||
) => Promise<void> | void;
|
||||
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
|
||||
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
|
||||
isSpeechSupported: boolean;
|
||||
speakingMessageId: string | null;
|
||||
speechState: AgentSpeechState;
|
||||
onSpeak: (messageId: string, text: string, options?: AgentSpeakOptions) => Promise<void> | void;
|
||||
onPauseSpeech: () => void;
|
||||
onResumeSpeech: () => void;
|
||||
onStopSpeech: () => void;
|
||||
qaOnly: boolean;
|
||||
onLocateAnswerFeatures?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
|
||||
onSubmitAnswerForm: (text: string) => Promise<void> | void;
|
||||
onAsk?: (selection: AnswerUiSelection) => void;
|
||||
onAsk,
|
||||
interactionRequests,
|
||||
}: BackendMessageListProps & {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
message: AgentChatMessage;
|
||||
processMessages: AgentChatMessage[];
|
||||
running: boolean;
|
||||
reply?: InteractionRequestsController["entries"][number];
|
||||
}) {
|
||||
const transition = useAgentAnswerTransition(incomingMessage, running && incomingMessage.role === "assistant");
|
||||
const { message, completed } = transition;
|
||||
const processMessages = incomingProcessMessages.map((source) => (source.id === message.id ? message : source));
|
||||
const answer = getAnswerPresentation(message);
|
||||
const needsReply = hasPendingMessageInput(message);
|
||||
if (message.role === "assistant" && !processMessages.length && !answer.report.length && !needsReply) return null;
|
||||
|
||||
const reportText = getAnswerPartsTranscript(answer.report);
|
||||
const hasOutput = answer.report.length > 0;
|
||||
const speech =
|
||||
completed && hasOutput && isSpeechSupported ? (
|
||||
<AgentMessageSpeechControls
|
||||
speechState={speakingMessageId === message.id ? speechState : "idle"}
|
||||
onSpeak={() => void Promise.resolve(onSpeak(message.id, stripSpeechMarkdown(reportText)))}
|
||||
onPause={onPauseSpeech}
|
||||
onResume={onResumeSpeech}
|
||||
onStop={onStopSpeech}
|
||||
/>
|
||||
) : null;
|
||||
const copy = completed ? <AgentChatMessageActions parts={answer.report} /> : null;
|
||||
const processComplete = completed;
|
||||
const hasProcess =
|
||||
message.role === "assistant" &&
|
||||
processMessages.some(
|
||||
(source) =>
|
||||
getAnswerPresentation(source).process.length > 0 ||
|
||||
(!qaOnly &&
|
||||
(source.progress?.length ||
|
||||
(!hasPendingMessageInput(source) &&
|
||||
(source.todos?.todos.length || source.permissions?.length || source.questions?.length)))),
|
||||
);
|
||||
const hasProcessError = processMessages.some((source) => source.progress?.some((item) => item.status === "error"));
|
||||
return (
|
||||
<motion.div
|
||||
className="agent-chat-message-list flex w-full flex-col gap-6"
|
||||
variants={agentPanelListVariants}
|
||||
ref={ref}
|
||||
className={cn("w-full", message.role === "user" && "flex justify-end")}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{messages.map((message, index) => {
|
||||
const answer = getAnswerPresentation(message);
|
||||
const needsReply = Boolean(
|
||||
message.permissions?.some((item) => ["pending", "submitting", "error"].includes(item.status)) ||
|
||||
message.questions?.some((item) => ["pending", "submitting", "error"].includes(item.status))
|
||||
);
|
||||
return (
|
||||
<Message
|
||||
data-agent-message-id={message.id}
|
||||
from={message.role}
|
||||
className={message.role === "user" ? "max-w-[92%]" : "max-w-full"}
|
||||
>
|
||||
<MessageContent
|
||||
className={cn(
|
||||
message.role === "user" &&
|
||||
"agent-panel-user-message rounded-2xl px-3 py-3 text-base leading-7 text-slate-800",
|
||||
message.role === "assistant" &&
|
||||
"agent-panel-message w-full rounded-2xl p-3 text-base leading-7 text-slate-700",
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
key={message.id}
|
||||
className={cn("w-full", message.role === "user" && "flex justify-end")}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
ref={transition.root}
|
||||
initial={false}
|
||||
animate={transition.controls}
|
||||
data-answer-phase={transition.phase}
|
||||
className="space-y-3"
|
||||
>
|
||||
<Message
|
||||
data-agent-message-id={message.id}
|
||||
from={message.role}
|
||||
className={message.role === "user" ? "max-w-[92%]" : "max-w-full"}
|
||||
>
|
||||
<MessageContent
|
||||
className={cn(
|
||||
message.role === "user" &&
|
||||
"agent-panel-user-message rounded-2xl px-3 py-3 text-slate-800",
|
||||
message.role === "assistant" &&
|
||||
"agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-700"
|
||||
{message.role === "assistant" ? <AgentChatMessageHeader running={!completed} /> : null}
|
||||
{message.role === "assistant" && message.taskContext ? (
|
||||
<p className="text-sm leading-5 text-[var(--ink-muted)]">
|
||||
依据:
|
||||
{message.taskContext.execution_generation == null
|
||||
? "历史资料 · 轮次未记录"
|
||||
: `第 ${message.taskContext.execution_generation} 轮`}
|
||||
{message.taskContext.report_revision == null
|
||||
? " · 尚无报告"
|
||||
: ` · 报告第 ${message.taskContext.report_revision} 版`}
|
||||
</p>
|
||||
) : null}
|
||||
{hasOutput ? (
|
||||
message.role === "assistant" ? (
|
||||
<AgentSpeechMessage
|
||||
presentation={presentation}
|
||||
messageId={message.id}
|
||||
content={reportText}
|
||||
disabled={!completed}
|
||||
isSupported={isSpeechSupported}
|
||||
onSpeak={onSpeak}
|
||||
>
|
||||
<ExpandableReading
|
||||
title="对话中的分析报告"
|
||||
enabled={canExpandAnswer(answer.report)}
|
||||
summary={
|
||||
<p className="line-clamp-3">
|
||||
{answer.report.find((part) => part.type === "text")?.text ?? "交互内容已在工作区展开。"}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<AnswerPartsView
|
||||
parts={answer.report}
|
||||
messageId={message.id}
|
||||
disabled={!completed}
|
||||
streaming={!answer.final && !completed}
|
||||
streamDone={answer.final || (streamRenderState[message.id]?.done ?? completed)}
|
||||
onLocate={onLocateAnswerFeatures}
|
||||
onSubmit={onSubmitAnswerForm}
|
||||
onAsk={onAsk}
|
||||
/>
|
||||
</ExpandableReading>
|
||||
</AgentSpeechMessage>
|
||||
) : reply ? (
|
||||
<AgentInteractionReply
|
||||
entry={reply}
|
||||
checking={interactionRequests?.checking ?? false}
|
||||
onResume={() => void interactionRequests?.act(reply.record.interaction_id, "resume")}
|
||||
/>
|
||||
) : (
|
||||
<AgentUserMessageText content={message.content} />
|
||||
)
|
||||
) : null}
|
||||
{message.role === "assistant" && processComplete && hasProcessError ? (
|
||||
<p role="status" className="text-xs text-[var(--status-warning-foreground)]">
|
||||
部分查询或工具未完成,可展开处理过程查看原因。
|
||||
</p>
|
||||
) : null}
|
||||
{message.role === "assistant" &&
|
||||
!qaOnly &&
|
||||
(needsReply ||
|
||||
(!completed && processMessages.length > 0) ||
|
||||
(processMessages.length > 0 && !hasProcess)) ? (
|
||||
<AgentMessageDetails
|
||||
message={message}
|
||||
onReplyPermission={onReplyPermission}
|
||||
onReplyQuestion={onReplyQuestion}
|
||||
onRejectQuestion={onRejectQuestion}
|
||||
/>
|
||||
) : null}
|
||||
{hasProcess ? (
|
||||
<AgentProcessDisclosure
|
||||
key="message-process"
|
||||
label="查看处理过程"
|
||||
live={!completed}
|
||||
toolbar={(trigger) => (
|
||||
<AgentChatMessageToolbar speech={speech} copy={copy}>
|
||||
{trigger}
|
||||
</AgentChatMessageToolbar>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{message.role === "assistant" ? (
|
||||
<AgentChatMessageHeader
|
||||
running={streaming && index === messages.length - 1}
|
||||
interactive={answer.report.some((part) => part.type === "answer-ui")}
|
||||
/>
|
||||
) : null}
|
||||
{message.role === "assistant" && message.taskContext ? <p className="text-xs leading-5 text-[var(--ink-muted)]">
|
||||
依据:{message.taskContext.execution_generation == null ? "历史资料 · 轮次未记录" : `第 ${message.taskContext.execution_generation} 轮`}
|
||||
{message.taskContext.report_revision == null ? " · 尚无报告" : ` · 报告第 ${message.taskContext.report_revision} 版`}
|
||||
</p> : null}
|
||||
{message.role === "assistant" && !qaOnly && !answer.final ? (
|
||||
<AgentMessageProgress
|
||||
progress={message.progress}
|
||||
running={streaming && index === messages.length - 1}
|
||||
/>
|
||||
) : null}
|
||||
{message.content || answer.report.length > 0 ? (
|
||||
message.role === "assistant" ? (
|
||||
<AgentSpeechMessage
|
||||
presentation={presentation}
|
||||
messageId={message.id}
|
||||
content={message.content}
|
||||
disabled={streaming && index === messages.length - 1}
|
||||
isSupported={isSpeechSupported}
|
||||
speechState={speakingMessageId === message.id ? speechState : "idle"}
|
||||
onSpeak={onSpeak}
|
||||
onPause={onPauseSpeech}
|
||||
onResume={onResumeSpeech}
|
||||
onStop={onStopSpeech}
|
||||
>
|
||||
<ExpandableReading title="对话中的分析报告"
|
||||
enabled={answer.report.some((part) => part.type === "answer-ui")}
|
||||
summary={<p className="line-clamp-3">{answer.report.find((part) => part.type === "text")?.text ?? "交互内容已在工作区展开。"}</p>}>
|
||||
<AnswerPartsView
|
||||
parts={answer.report}
|
||||
messageId={message.id}
|
||||
disabled={streaming}
|
||||
streaming={!answer.final && streaming && index === messages.length - 1}
|
||||
streamDone={answer.final || (streamRenderState[message.id]?.done ?? !streaming)}
|
||||
onLocate={onLocateAnswerFeatures}
|
||||
onSubmit={onSubmitAnswerForm}
|
||||
onAsk={onAsk}
|
||||
/>
|
||||
</ExpandableReading>
|
||||
</AgentSpeechMessage>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||
)
|
||||
) : streaming && index === messages.length - 1 ? (
|
||||
<span className="text-[var(--ink-muted)]">正在生成...</span>
|
||||
) : null}
|
||||
{message.role === "assistant" && answer.final && (answer.process.length > 0 || (!qaOnly && message.progress?.length)) ? (
|
||||
<Disclosure key="completed-process" label="查看处理过程" className="border-t border-[var(--surface-divider)]">
|
||||
<div className="space-y-3 py-2">
|
||||
{!qaOnly ? <AgentMessageProgress progress={message.progress} running={false} /> : null}
|
||||
<AnswerPartsView parts={answer.process} messageId={`${message.id}-process`} disabled streaming={false} streamDone />
|
||||
{!qaOnly && !needsReply ? <AgentMessageDetails message={message} /> : null}
|
||||
</div>
|
||||
</Disclosure>
|
||||
) : null}
|
||||
{message.role === "assistant" && !qaOnly && (!answer.final || needsReply) ? (
|
||||
<AgentMessageDetails
|
||||
message={message}
|
||||
onReplyPermission={onReplyPermission}
|
||||
onReplyQuestion={onReplyQuestion}
|
||||
onRejectQuestion={onRejectQuestion}
|
||||
/>
|
||||
) : null}
|
||||
{message.role === "assistant" && answer.final && message.progress?.some((item) => item.status === "error") ? (
|
||||
<p role="status" className="text-xs text-[var(--status-warning-foreground)]">部分查询或工具未完成,可展开处理过程查看原因。</p>
|
||||
) : null}
|
||||
{message.role === "assistant" && !(streaming && index === messages.length - 1) ? (
|
||||
<AgentChatMessageActions parts={answer.report} />
|
||||
) : null}
|
||||
</div>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
<AgentMessageProcessContent
|
||||
messages={processMessages}
|
||||
qaOnly={qaOnly}
|
||||
runningMessageId={!completed ? message.id : undefined}
|
||||
/>
|
||||
</AgentProcessDisclosure>
|
||||
) : null}
|
||||
{message.role === "assistant" && completed && hasOutput && !hasProcess ? (
|
||||
<AgentChatMessageToolbar speech={speech} copy={copy} />
|
||||
) : null}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1385,11 +1511,7 @@ function AgentSpeechMessage({
|
||||
content,
|
||||
disabled,
|
||||
isSupported,
|
||||
speechState,
|
||||
onSpeak,
|
||||
onPause,
|
||||
onResume,
|
||||
onStop,
|
||||
children
|
||||
}: {
|
||||
presentation: AgentCommandPanelPresentation;
|
||||
@@ -1397,11 +1519,7 @@ function AgentSpeechMessage({
|
||||
content: string;
|
||||
disabled: boolean;
|
||||
isSupported: boolean;
|
||||
speechState: AgentSpeechState;
|
||||
onSpeak: (messageId: string, text: string, options?: AgentSpeakOptions) => Promise<void> | void;
|
||||
onPause: () => void;
|
||||
onResume: () => void;
|
||||
onStop: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -1534,57 +1652,62 @@ function AgentSpeechMessage({
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{isSupported && !disabled ? (
|
||||
<div className="mt-2 flex min-h-8 items-center gap-1 border-t border-[var(--surface-divider)] pt-1.5">
|
||||
{speechState === "idle" ? (
|
||||
<SpeechIconButton
|
||||
label="语音播放"
|
||||
onClick={() => void Promise.resolve(onSpeak(messageId, speechText))}
|
||||
>
|
||||
<Volume2 size={15} aria-hidden="true" />
|
||||
</SpeechIconButton>
|
||||
) : null}
|
||||
{speechState === "loading" ? (
|
||||
<>
|
||||
<SpeechIconButton label="正在生成语音" disabled>
|
||||
<LoaderCircle size={15} className="animate-spin text-[var(--action-blue-hover)]" aria-hidden="true" />
|
||||
</SpeechIconButton>
|
||||
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
|
||||
<Square size={14} fill="currentColor" aria-hidden="true" />
|
||||
</SpeechIconButton>
|
||||
</>
|
||||
) : null}
|
||||
{speechState === "playing" ? (
|
||||
<>
|
||||
<SpeechIconButton label="暂停语音播放" tone="primary" onClick={onPause}>
|
||||
<Pause size={15} fill="currentColor" aria-hidden="true" />
|
||||
</SpeechIconButton>
|
||||
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
|
||||
<Square size={14} fill="currentColor" aria-hidden="true" />
|
||||
</SpeechIconButton>
|
||||
</>
|
||||
) : null}
|
||||
{speechState === "paused" ? (
|
||||
<>
|
||||
<SpeechIconButton label="继续语音播放" tone="primary" onClick={onResume}>
|
||||
<Play size={15} fill="currentColor" aria-hidden="true" />
|
||||
</SpeechIconButton>
|
||||
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
|
||||
<Square size={14} fill="currentColor" aria-hidden="true" />
|
||||
</SpeechIconButton>
|
||||
</>
|
||||
) : null}
|
||||
<span className="text-[11px] text-[var(--ink-muted)]">
|
||||
{speechState === "idle"
|
||||
? "语音播放"
|
||||
: speechState === "loading"
|
||||
? "正在生成"
|
||||
: speechState === "playing"
|
||||
? "播放中"
|
||||
: "已暂停"}
|
||||
</span>
|
||||
</div>
|
||||
function AgentMessageSpeechControls({
|
||||
speechState,
|
||||
onSpeak,
|
||||
onPause,
|
||||
onResume,
|
||||
onStop
|
||||
}: {
|
||||
speechState: AgentSpeechState;
|
||||
onSpeak: () => void;
|
||||
onPause: () => void;
|
||||
onResume: () => void;
|
||||
onStop: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{speechState === "idle" ? (
|
||||
<SpeechIconButton label="语音播放" onClick={onSpeak}>
|
||||
<Volume2 size={16} aria-hidden="true" />
|
||||
<span className={AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME}>语音播放</span>
|
||||
</SpeechIconButton>
|
||||
) : null}
|
||||
{speechState === "loading" ? (
|
||||
<>
|
||||
<SpeechIconButton label="正在生成语音" disabled>
|
||||
<LoaderCircle size={16} className="motion-safe:animate-spin" aria-hidden="true" />
|
||||
</SpeechIconButton>
|
||||
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
|
||||
<Square size={16} aria-hidden="true" />
|
||||
</SpeechIconButton>
|
||||
</>
|
||||
) : null}
|
||||
{speechState === "playing" ? (
|
||||
<>
|
||||
<SpeechIconButton label="暂停语音播放" tone="primary" onClick={onPause}>
|
||||
<Pause size={16} aria-hidden="true" />
|
||||
<span className={AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME}>暂停</span>
|
||||
</SpeechIconButton>
|
||||
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
|
||||
<Square size={16} aria-hidden="true" />
|
||||
</SpeechIconButton>
|
||||
</>
|
||||
) : null}
|
||||
{speechState === "paused" ? (
|
||||
<>
|
||||
<SpeechIconButton label="继续语音播放" tone="primary" onClick={onResume}>
|
||||
<Play size={16} aria-hidden="true" />
|
||||
<span className={AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME}>继续</span>
|
||||
</SpeechIconButton>
|
||||
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
|
||||
<Square size={16} aria-hidden="true" />
|
||||
</SpeechIconButton>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
@@ -1610,10 +1733,11 @@ function SpeechIconButton({
|
||||
title={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size="icon-sm"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"shrink-0 text-[var(--ink-muted)] hover:bg-[var(--surface-control)] disabled:cursor-wait",
|
||||
AGENT_MESSAGE_ACTION_CLASS_NAME,
|
||||
"shrink-0 disabled:cursor-wait",
|
||||
tone === "primary" && "text-[var(--action-blue-hover)]",
|
||||
tone === "danger" && "status-tone-danger text-[var(--status-foreground)]"
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { CircleCheck, CircleHelp, LoaderCircle, RotateCw, X } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Checkbox } from "@/shared/ui/checkbox";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { Radio } from "@/shared/ui/radio";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import type { InteractionAnswer } from "../api/interaction-requests";
|
||||
import type { InteractionRequestEntry, InteractionRequestsController } from "../hooks/use-interaction-requests";
|
||||
import { AgentUserMessageText } from "./agent-user-message-text";
|
||||
|
||||
const statuses = { pending: "等待回答", answered: "答案已保存", skipped: "已跳过", cancelled: "已取消", invalidated: "已失效" };
|
||||
const deliveries = { queued: "等待继续", dispatching: "正在继续处理", delivered: "已继续处理", blocked: "继续失败,可以重试", cancelled: "已停止继续" };
|
||||
|
||||
export function AgentInteractionRequests({ controller, representedMessageIds }: {
|
||||
controller: InteractionRequestsController;
|
||||
representedMessageIds?: ReadonlySet<string>;
|
||||
}) {
|
||||
return <section aria-label="Agent 提问" className="space-y-3">
|
||||
{controller.checking ? <p role="status" className="text-xs text-muted-foreground">正在核对提问状态,核对后可回答。</p> : null}
|
||||
{controller.error ? <div role="alert" className="text-sm">{controller.error}<Button type="button" variant="ghost" size="sm" onClick={() => void controller.refresh()} aria-label="重新核对提问"><RotateCw size={14} aria-hidden />重新核对</Button></div> : null}
|
||||
{controller.entries.filter(({ record }) => !(record.delivery && ["answered", "skipped"].includes(record.status)
|
||||
&& representedMessageIds?.has(record.delivery.native_message_id))).map((entry) =>
|
||||
<AgentInteractionRequestCard key={entry.record.interaction_id} entry={entry} checking={controller.checking}
|
||||
onChange={(draft) => controller.update(entry.record.interaction_id, draft)} onAction={(action) => void controller.act(entry.record.interaction_id, action)} />)}
|
||||
</section>;
|
||||
}
|
||||
|
||||
/** Render the stored human answer, independent of the model-facing transcript. */
|
||||
export function AgentInteractionReply({ entry, checking, onResume }: {
|
||||
entry: InteractionRequestEntry;
|
||||
checking: boolean;
|
||||
onResume: () => void;
|
||||
}) {
|
||||
const { record } = entry;
|
||||
const retryable = record.delivery && ["queued", "dispatching", "blocked"].includes(record.delivery.status);
|
||||
return <div role="group" aria-label="已提交的回答" className="min-w-0 space-y-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<CircleCheck size={14} className="shrink-0" aria-hidden />
|
||||
<span>{record.status === "skipped" ? "已跳过" : "已回答"}</span>
|
||||
</div>
|
||||
{record.status === "skipped" ? <p className="text-sm leading-6">已跳过本组可选问题。</p> : <dl className="space-y-3">
|
||||
{record.questions.map((question) => {
|
||||
const answer = record.answers?.find((item) => item.question_id === question.question_id);
|
||||
return <div key={question.question_id} className="min-w-0">
|
||||
<dt className="mb-1 break-words text-xs leading-5 text-muted-foreground">{question.title}</dt>
|
||||
<dd className="space-y-1.5 whitespace-pre-wrap break-words text-sm leading-6 text-foreground">
|
||||
{answer?.selected_options.map((option) => <div key={option.id} className="font-medium"><AgentUserMessageText content={option.label} /></div>)}
|
||||
{answer?.custom_text ? <div>{answer.selected_options.length ? <span className="block text-xs text-muted-foreground">补充说明</span> : null}<AgentUserMessageText content={answer.custom_text} /></div> : null}
|
||||
{!answer?.selected_options.length && !answer?.custom_text ? <p className="text-muted-foreground">未填写</p> : null}
|
||||
</dd>
|
||||
</div>;
|
||||
})}
|
||||
</dl>}
|
||||
{entry.error ? <p role="alert" className="text-xs leading-5 text-destructive">{entry.error}</p> : null}
|
||||
{entry.conflict ? <details className="text-xs"><summary className="cursor-pointer">查看本窗口未提交的输入,供复制</summary><pre className="mt-2 whitespace-pre-wrap break-words">{JSON.stringify(entry.conflict, null, 2)}</pre></details> : null}
|
||||
{record.delivery ? <div className="flex flex-wrap items-center justify-between gap-2 border-t border-border pt-2">
|
||||
<span role="status" className="text-xs leading-5 text-muted-foreground">{checking ? "正在核对提问" : deliveries[record.delivery.status]}</span>
|
||||
{retryable ? <Button type="button" variant="ghost" size="sm" disabled={checking || entry.busy} onClick={onResume}>
|
||||
<RotateCw size={13} aria-hidden />重试继续
|
||||
</Button> : null}
|
||||
</div> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function AgentInteractionRequestCard({ entry, checking, onChange, onAction }: {
|
||||
entry: InteractionRequestEntry; checking: boolean; onChange: (draft: InteractionAnswer[]) => void;
|
||||
onAction: (action: "reply" | "skip" | "cancel" | "resume") => void;
|
||||
}) {
|
||||
const { record } = entry;
|
||||
if (["answered", "skipped"].includes(record.status)) return <div className="flex min-w-0 justify-end">
|
||||
<div className="agent-panel-user-message min-w-0 max-w-[92%] rounded-2xl px-3 py-3">
|
||||
<AgentInteractionReply entry={entry} checking={checking} onResume={() => onAction("resume")} />
|
||||
</div>
|
||||
</div>;
|
||||
const pending = record.status === "pending";
|
||||
const disabled = checking || entry.busy || !pending;
|
||||
const values = entry.draft;
|
||||
const answerFor = (id: string) => values.find((answer) => answer.question_id === id) ?? { question_id: id, selected_option_ids: [], custom_text: "" };
|
||||
const change = (answer: InteractionAnswer) => onChange(record.questions.map((question) => question.question_id === answer.question_id ? answer : answerFor(question.question_id)));
|
||||
const valid = record.questions.every((question) => !question.required || answerFor(question.question_id).selected_option_ids.length > 0 || (question.allow_custom && answerFor(question.question_id).custom_text.trim().length > 0));
|
||||
const draftStatus = checking ? "正在核对提问" : entry.saving ? "正在保存草稿" : entry.dirty ? "草稿尚未保存"
|
||||
: entry.draft.some((answer) => answer.selected_option_ids.length || answer.custom_text.trim()) ? "草稿已保存" : "提交后继续处理";
|
||||
|
||||
return <article aria-label="需要补充的信息" aria-busy={entry.busy || checking}
|
||||
className="min-w-0 overflow-hidden rounded-xl border border-border bg-card text-card-foreground">
|
||||
<header className="flex min-h-10 items-center justify-between gap-3 px-4 pt-3 sm:px-5">
|
||||
<div role="status" className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<CircleHelp size={15} className="shrink-0" aria-hidden />
|
||||
<span>{checking ? "核对中" : statuses[record.status]}</span>
|
||||
</div>
|
||||
{pending ? <Button type="button" size="icon-sm" variant="ghost" aria-label="取消提问" title="取消提问"
|
||||
disabled={disabled} onClick={() => onAction("cancel")}><X size={15} aria-hidden /></Button> : null}
|
||||
</header>
|
||||
|
||||
<div className="space-y-5 px-4 pb-4 pt-2 sm:px-5 sm:pb-5">
|
||||
{record.questions.map((question) => {
|
||||
const answer = answerFor(question.question_id);
|
||||
const selected = new Set(answer.selected_option_ids);
|
||||
return <fieldset key={question.question_id} disabled={disabled} className="min-w-0">
|
||||
<legend className="mb-2 w-full text-sm font-semibold leading-6 text-foreground">
|
||||
{question.title}
|
||||
{pending ? <span className="ml-2 text-[11px] font-normal text-muted-foreground">
|
||||
{question.required ? "必填" : "可选"}{question.options.length ? question.mode === "multiple" ? " · 可多选" : " · 单选" : ""}
|
||||
</span> : null}
|
||||
</legend>
|
||||
{pending ? <>
|
||||
<p className="mb-3 whitespace-pre-wrap break-words text-sm leading-6 text-muted-foreground">{question.body}</p>
|
||||
<div className="space-y-2">
|
||||
{question.options.map((option) => <label key={option.id}
|
||||
className={cn("flex min-h-11 min-w-0 cursor-pointer items-start gap-2.5 rounded-lg border px-3 py-2.5 text-sm leading-6 has-[:disabled]:cursor-default",
|
||||
selected.has(option.id) ? "border-primary/50 bg-primary/5" : "border-border bg-background has-[:enabled]:hover:bg-muted/40")}>
|
||||
<span className="mt-0.5 inline-flex shrink-0">
|
||||
{question.mode === "single" ? <Radio name={`${record.interaction_id}-${question.question_id}`}
|
||||
checked={selected.has(option.id)} onChange={() => change({ ...answer, selected_option_ids: [option.id] })} />
|
||||
: <Checkbox checked={selected.has(option.id)} onChange={(event) => change({ ...answer,
|
||||
selected_option_ids: event.target.checked ? [...answer.selected_option_ids, option.id] : answer.selected_option_ids.filter((id) => id !== option.id) })} />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 break-words">{option.label}</span>
|
||||
</label>)}
|
||||
</div>
|
||||
{question.allow_custom ? <label className="mt-3 block text-xs font-medium leading-5 text-foreground">
|
||||
{question.options.length ? "自定义回答 / 补充说明" : "你的回答"}
|
||||
<Textarea aria-label={`${question.title}:补充说明`} placeholder={question.options.length ? "可以直接填写回答,也可以补充已选内容" : "请输入你的回答"}
|
||||
maxLength={2000} value={answer.custom_text} onChange={(event) => change({ ...answer, custom_text: event.target.value })}
|
||||
className="mt-1.5 min-h-20 resize-y bg-background text-sm font-normal leading-6" />
|
||||
</label> : null}
|
||||
</> : <p className="text-sm leading-6 text-muted-foreground">未提交回答。</p>}
|
||||
</fieldset>;
|
||||
})}
|
||||
{entry.error ? <p role="alert" className="text-sm leading-5 text-destructive">{entry.error}</p> : null}
|
||||
{entry.conflict ? <details className="text-xs"><summary className="cursor-pointer">查看本窗口未提交的输入,供复制</summary><pre className="mt-2 whitespace-pre-wrap break-words">{JSON.stringify(entry.conflict, null, 2)}</pre></details> : null}
|
||||
</div>
|
||||
|
||||
<footer className="flex flex-wrap items-center justify-between gap-3 border-t border-border px-4 py-3 sm:px-5">
|
||||
<p role="status" className="text-xs leading-5 text-muted-foreground">{pending ? draftStatus : "本次提问已结束"}</p>
|
||||
<div className="ml-auto flex flex-wrap gap-2">
|
||||
{pending ? <>
|
||||
{record.questions.every((question) => !question.required) ? <Button type="button" size="sm" variant="outline" disabled={disabled} onClick={() => onAction("skip")}>跳过</Button> : null}
|
||||
<Button type="button" size="sm" disabled={disabled || !valid} onClick={() => onAction("reply")}>
|
||||
{entry.busy ? <LoaderCircle size={14} className="animate-spin motion-reduce:animate-none" aria-hidden /> : null}提交回答
|
||||
</Button>
|
||||
</> : null}
|
||||
</div>
|
||||
</footer>
|
||||
</article>;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { AgentProgressStatusIcon } from "./agent-progress-status-icon";
|
||||
import { AgentToolProgress } from "./agent-tool-progress";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
@@ -40,7 +42,6 @@ import type {
|
||||
AgentTodoUpdate
|
||||
} from "../types";
|
||||
|
||||
type ProgressStatus = AgentChatProgress["status"];
|
||||
type TodoStatus = AgentTodoUpdate["todos"][number]["status"];
|
||||
type PermissionStatus = AgentPermissionRequest["status"];
|
||||
type QuestionStatus = AgentQuestionRequest["status"];
|
||||
@@ -53,22 +54,6 @@ type AgentStatusMeta = {
|
||||
activity?: StatusActivity;
|
||||
};
|
||||
|
||||
const progressStatusMeta: Record<ProgressStatus, AgentStatusMeta> = {
|
||||
running: {
|
||||
label: "进行中",
|
||||
tone: "info",
|
||||
activity: "live"
|
||||
},
|
||||
completed: {
|
||||
label: "已完成",
|
||||
tone: "success"
|
||||
},
|
||||
error: {
|
||||
label: "失败",
|
||||
tone: "danger"
|
||||
}
|
||||
};
|
||||
|
||||
const todoStatusMeta: Record<TodoStatus, AgentStatusMeta> = {
|
||||
completed: {
|
||||
label: "完成",
|
||||
@@ -100,6 +85,8 @@ const permissionStatusMeta: Record<PermissionStatus, AgentStatusMeta> = {
|
||||
};
|
||||
|
||||
const questionStatusMeta: Record<QuestionStatus, AgentStatusMeta> = {
|
||||
checking: { label: "核对中", tone: "info" },
|
||||
invalidated: { label: "已失效", tone: "neutral" },
|
||||
answered: { label: "已回答", tone: "success" },
|
||||
rejected: { label: "已跳过", tone: "neutral" },
|
||||
error: { label: "失败", tone: "danger" },
|
||||
@@ -208,28 +195,21 @@ function AnimatedDetailItem({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
function ProgressList({ progress, running }: { progress: AgentChatProgress[]; running: boolean }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [userExpanded, setExpanded] = useState(false);
|
||||
const expanded = !running || userExpanded;
|
||||
const listId = useId();
|
||||
const compact = running && !expanded;
|
||||
const visibleProgress = expanded
|
||||
? progress
|
||||
: running
|
||||
? progress.slice(-STREAMING_PROGRESS_LIMIT)
|
||||
: [];
|
||||
const listMode = expanded ? "expanded" : running ? "streaming" : "collapsed";
|
||||
const visibleProgress = expanded ? progress : progress.slice(-STREAMING_PROGRESS_LIMIT);
|
||||
|
||||
return (
|
||||
<div className="agent-panel-nested rounded-xl p-2.5" data-agent-progress>
|
||||
<div className="flex min-h-8 items-center gap-2 px-0.5">
|
||||
<div className="min-w-0" data-agent-progress>
|
||||
{running ? <div className="flex min-h-8 items-center gap-2 px-0.5">
|
||||
<ListTree size={15} className="shrink-0 text-[var(--action-blue-hover)]" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-slate-700">执行进度</span>
|
||||
<span className="min-w-0 flex-1 text-sm font-medium text-foreground">执行进度</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-[var(--ink-muted)]">
|
||||
{running ? (
|
||||
<StatusDot tone="info" activity="live" aria-hidden="true" />
|
||||
) : null}
|
||||
<span>{running ? (expanded ? "全部" : "最近") : "步骤"}</span>
|
||||
<StatusDot tone="info" activity="live" aria-hidden="true" />
|
||||
<span>{expanded ? "全部" : "最近"}</span>
|
||||
<span className="font-mono tabular-nums text-slate-600">
|
||||
{expanded || !running
|
||||
{expanded
|
||||
? progress.length
|
||||
: Math.min(progress.length, STREAMING_PROGRESS_LIMIT)}
|
||||
<span className="px-0.5 text-slate-300">/</span>
|
||||
@@ -254,14 +234,14 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div> : null}
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{visibleProgress.length ? (
|
||||
<motion.ol
|
||||
key={listMode}
|
||||
key="progress"
|
||||
id={listId}
|
||||
aria-label={expanded ? "全部执行进度" : "最近执行进度"}
|
||||
className="mt-2 flex flex-col gap-1.5 overflow-hidden"
|
||||
className={cn("flex flex-col gap-3 overflow-hidden", running && "mt-2")}
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
@@ -272,63 +252,33 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
|
||||
<motion.li
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"grid grid-cols-[16px_minmax(0,1fr)_auto] items-start gap-2 text-xs",
|
||||
compact && "h-12 overflow-hidden"
|
||||
"items-start gap-2 text-sm",
|
||||
item.phase === "tool" ? "block min-w-0" : "grid grid-cols-[24px_minmax(0,1fr)]"
|
||||
)}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -2 }}
|
||||
transition={{ duration: 0.16, ease: agentEase }}
|
||||
>
|
||||
<StatusDot
|
||||
tone={progressStatusMeta[item.status].tone}
|
||||
activity={progressStatusMeta[item.status].activity}
|
||||
size="md"
|
||||
className="mt-1"
|
||||
/>
|
||||
{item.phase === "tool" ? <AgentToolProgress item={item} /> : <>
|
||||
<AgentProgressStatusIcon status={item.status} />
|
||||
<span className="min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
"block font-semibold text-slate-800",
|
||||
compact ? "truncate" : "break-words"
|
||||
)}
|
||||
className="block break-words font-medium leading-6 text-foreground"
|
||||
title={item.title}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
{item.detail ? (
|
||||
<span
|
||||
className={cn(
|
||||
"block leading-5 text-[var(--ink-muted)]",
|
||||
compact ? "truncate" : "break-words"
|
||||
)}
|
||||
className="block break-words leading-5 text-muted-foreground"
|
||||
title={item.detail}
|
||||
>
|
||||
{item.detail}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.span
|
||||
key={item.status}
|
||||
data-progress-status={item.status}
|
||||
className={cn(
|
||||
"min-w-12 pt-0.5 text-right text-xs font-semibold",
|
||||
item.status === "running"
|
||||
? "text-[var(--action-blue-hover)]"
|
||||
: [
|
||||
`status-tone-${progressStatusMeta[item.status].tone}`,
|
||||
"text-[var(--status-foreground)]"
|
||||
]
|
||||
)}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.14 }}
|
||||
>
|
||||
{progressStatusMeta[item.status].label}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</> }
|
||||
</motion.li>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { AnswerPartsView } from "../answer-ui/answer-parts-view";
|
||||
import { getAnswerPresentation } from "../answer-ui/answer-presentation";
|
||||
import { hasPendingMessageInput } from "../presentation/message-process-groups";
|
||||
import type { AgentChatMessage } from "../types";
|
||||
import { AgentMessageDetails, AgentMessageProgress } from "./agent-message-details";
|
||||
|
||||
export function AgentMessageProcessContent({
|
||||
messages,
|
||||
qaOnly,
|
||||
runningMessageId
|
||||
}: {
|
||||
messages: AgentChatMessage[];
|
||||
qaOnly: boolean;
|
||||
runningMessageId?: string;
|
||||
}) {
|
||||
const progress = messages.flatMap((message) => (message.progress ?? []).map((item) => ({
|
||||
...item, id: JSON.stringify([message.id, item.id])
|
||||
})));
|
||||
return (
|
||||
<>
|
||||
{!qaOnly ? <AgentMessageProgress progress={progress} running={Boolean(runningMessageId)} /> : null}
|
||||
{messages.map((message) => {
|
||||
const { process } = getAnswerPresentation(message);
|
||||
const running = message.id === runningMessageId;
|
||||
const showDetails = !qaOnly && !running && !hasPendingMessageInput(message)
|
||||
&& Boolean(message.todos?.todos.length || message.permissions?.length || message.questions?.length);
|
||||
if (!process.length && !showDetails) return null;
|
||||
return <div key={message.id} data-agent-process-message-id={message.id} className="space-y-3">
|
||||
{process.length > 0 ? (
|
||||
<AnswerPartsView parts={process} messageId={`${message.id}-process`} disabled streaming={running} streamDone={!running} />
|
||||
) : null}
|
||||
{showDetails ? <AgentMessageDetails message={message} /> : null}
|
||||
</div>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useEffect, useId, useRef, useState, type MouseEvent, type ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { motion, useAnimationControls, useReducedMotion } from "motion/react";
|
||||
import { useStickToBottomContext } from "use-stick-to-bottom";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { AGENT_MESSAGE_ACTION_CLASS_NAME } from "./agent-chat-message-chrome";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { agentEase } from "./agent-motion";
|
||||
|
||||
type Anchor = {
|
||||
scroll: HTMLElement;
|
||||
element: HTMLElement;
|
||||
top: number;
|
||||
release: () => void;
|
||||
};
|
||||
|
||||
/** A reader action stops bottom-following before it changes the message's layout. */
|
||||
export function AgentProcessDisclosure({
|
||||
label,
|
||||
children,
|
||||
kind = "process",
|
||||
toolbar,
|
||||
live = false
|
||||
}: {
|
||||
label: ReactNode;
|
||||
children: ReactNode;
|
||||
kind?: "process" | "tool";
|
||||
toolbar?: (trigger: ReactNode) => ReactNode;
|
||||
live?: boolean;
|
||||
}) {
|
||||
const { scrollRef, stopScroll } = useStickToBottomContext();
|
||||
const [userOpen, setOpen] = useState<boolean>();
|
||||
const open = live || (userOpen ?? false);
|
||||
const [animated, setAnimated] = useState(false);
|
||||
const [phase, setPhase] = useState<"idle" | "opening" | "closing">("idle");
|
||||
const requestedOpen = useRef(false);
|
||||
const sequence = useRef(0);
|
||||
const root = useRef<HTMLDetailsElement>(null);
|
||||
const anchor = useRef<Anchor | null>(null);
|
||||
const frame = useRef<number | undefined>(undefined);
|
||||
const controls = useAnimationControls();
|
||||
const reducedMotion = useReducedMotion();
|
||||
const contentId = useId();
|
||||
|
||||
function releaseAnchor() {
|
||||
if (frame.current !== undefined) cancelAnimationFrame(frame.current);
|
||||
frame.current = undefined;
|
||||
anchor.current?.release();
|
||||
anchor.current = null;
|
||||
}
|
||||
|
||||
function captureAnchor() {
|
||||
releaseAnchor();
|
||||
const scroll = scrollRef.current;
|
||||
const details = root.current;
|
||||
if (!scroll || !details) return;
|
||||
stopScroll();
|
||||
const element = details.closest<HTMLElement>("[data-agent-message-id]") ?? details;
|
||||
const previous = scroll.style.overflowAnchor;
|
||||
scroll.style.overflowAnchor = "none";
|
||||
const cancel = () => releaseAnchor();
|
||||
for (const event of ["wheel", "pointerdown", "touchmove", "keydown"])
|
||||
scroll.addEventListener(event, cancel, { passive: true });
|
||||
anchor.current = {
|
||||
scroll,
|
||||
element,
|
||||
top: element.getBoundingClientRect().top,
|
||||
release: () => {
|
||||
scroll.style.overflowAnchor = previous;
|
||||
for (const event of ["wheel", "pointerdown", "touchmove", "keydown"])
|
||||
scroll.removeEventListener(event, cancel);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function restoreAnchor() {
|
||||
const value = anchor.current;
|
||||
if (!value || !value.element.isConnected) return;
|
||||
stopScroll();
|
||||
const delta = value.element.getBoundingClientRect().top - value.top;
|
||||
if (Math.abs(delta) > 0.5) value.scroll.scrollTop += delta;
|
||||
}
|
||||
|
||||
async function toggle(event: MouseEvent<HTMLElement>) {
|
||||
event.preventDefault();
|
||||
const current = ++sequence.current;
|
||||
const next = !(phase === "idle" ? open : requestedOpen.current);
|
||||
requestedOpen.current = next;
|
||||
controls.stop();
|
||||
captureAnchor();
|
||||
const immediate = Boolean(reducedMotion) || event.detail === 0;
|
||||
setAnimated(!immediate);
|
||||
if (next) {
|
||||
controls.set(immediate ? { opacity: 1, y: 0 } : { opacity: 0, y: -4 });
|
||||
flushSync(() => {
|
||||
setPhase(immediate ? "idle" : "opening");
|
||||
setOpen(true);
|
||||
});
|
||||
restoreAnchor();
|
||||
} else {
|
||||
setPhase(immediate ? "idle" : "closing");
|
||||
}
|
||||
if (!immediate)
|
||||
await controls.start(next ? { opacity: 1, y: 0 } : { opacity: 0, y: -4 }, {
|
||||
duration: 0.16,
|
||||
ease: agentEase
|
||||
});
|
||||
if (sequence.current !== current) return;
|
||||
if (!next) {
|
||||
flushSync(() => setOpen(false));
|
||||
controls.set({ opacity: 1, y: 0 });
|
||||
}
|
||||
restoreAnchor();
|
||||
setPhase("idle");
|
||||
frame.current = requestAnimationFrame(() => {
|
||||
restoreAnchor();
|
||||
releaseAnchor();
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
sequence.current += 1;
|
||||
controls.stop();
|
||||
if (frame.current !== undefined) cancelAnimationFrame(frame.current);
|
||||
anchor.current?.release();
|
||||
anchor.current = null;
|
||||
},
|
||||
[controls]
|
||||
);
|
||||
|
||||
const processIcon = (
|
||||
<span data-process-toggle-icon className="inline-grid size-6 shrink-0 place-items-center">
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"size-4",
|
||||
animated && "transition-transform duration-150 motion-reduce:transition-none",
|
||||
open && phase !== "closing" && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{!live && toolbar?.(
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(AGENT_MESSAGE_ACTION_CLASS_NAME, "gap-2 rounded-lg px-3")}
|
||||
aria-expanded={open}
|
||||
aria-controls={contentId}
|
||||
data-process-toggle
|
||||
onClick={(event) => {
|
||||
void toggle(event);
|
||||
}}
|
||||
>
|
||||
{processIcon}
|
||||
<span data-process-toggle-label>{label}</span>
|
||||
</Button>
|
||||
)}
|
||||
<details
|
||||
ref={root}
|
||||
open={open}
|
||||
hidden={!live && toolbar && !open ? true : undefined}
|
||||
data-agent-process={kind === "process" && !live ? "" : undefined}
|
||||
data-agent-live-process={live ? "" : undefined}
|
||||
data-agent-tool={kind === "tool" ? "" : undefined}
|
||||
data-disclosure-motion={phase}
|
||||
className={cn(
|
||||
"min-w-0 overflow-hidden",
|
||||
kind === "process" &&
|
||||
(toolbar
|
||||
? "mt-2 rounded-xl border border-border bg-card"
|
||||
: "rounded-xl border border-border bg-card")
|
||||
)}
|
||||
>
|
||||
<summary
|
||||
onClick={(event) => {
|
||||
void toggle(event);
|
||||
}}
|
||||
aria-controls={contentId}
|
||||
aria-hidden={toolbar ? true : undefined}
|
||||
tabIndex={toolbar ? -1 : undefined}
|
||||
className={cn(
|
||||
toolbar
|
||||
? "hidden"
|
||||
: "flex min-h-10 cursor-pointer list-none gap-2 text-xs font-medium leading-5 text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring [&::-webkit-details-marker]:hidden",
|
||||
!toolbar &&
|
||||
(kind === "process" ? "items-center px-3 py-2" : "items-start rounded-md py-1"),
|
||||
kind === "process" && open && "border-b border-border"
|
||||
)}
|
||||
>
|
||||
{!toolbar ? (
|
||||
<>
|
||||
{kind === "process" && !toolbar ? processIcon : null}
|
||||
<span className="min-w-0 flex-1">{label}</span>
|
||||
{kind === "tool" ? (
|
||||
<span data-tool-chevron className="inline-grid size-6 shrink-0 place-items-center">
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"size-4 text-muted-foreground",
|
||||
animated && "transition-transform duration-150 motion-reduce:transition-none",
|
||||
open && phase !== "closing" ? "rotate-0" : "-rotate-90"
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</summary>
|
||||
<motion.div
|
||||
id={contentId}
|
||||
initial={false}
|
||||
animate={controls}
|
||||
data-agent-disclosure-content
|
||||
className={kind === "process" ? "space-y-3 p-3" : "pb-2 pl-8"}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</details>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { CheckCircle2, LoaderCircle, XCircle } from "lucide-react";
|
||||
import { StatusIcon, type StatusTone } from "@/shared/ui/status";
|
||||
import type { AgentChatProgress } from "../types";
|
||||
|
||||
const states = {
|
||||
completed: { label: "已完成", tone: "success", Icon: CheckCircle2 },
|
||||
error: { label: "失败", tone: "danger", Icon: XCircle },
|
||||
running: { label: "进行中", tone: "info", Icon: LoaderCircle }
|
||||
} satisfies Record<
|
||||
AgentChatProgress["status"],
|
||||
{ label: string; tone: StatusTone; Icon: typeof CheckCircle2 }
|
||||
>;
|
||||
|
||||
/** Ordinary steps and tool calls share the same status glyph, size and tone. */
|
||||
export function AgentProgressStatusIcon({ status }: { status: AgentChatProgress["status"] }) {
|
||||
const { label, tone, Icon } = states[status];
|
||||
return (
|
||||
<StatusIcon
|
||||
data-progress-status={status}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
tone={tone}
|
||||
activity={status === "running" ? "loading" : "static"}
|
||||
shape="circle"
|
||||
size="sm"
|
||||
className="border-0"
|
||||
>
|
||||
<Icon className="size-3.5" aria-hidden="true" />
|
||||
</StatusIcon>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { AgentToolProgress } from "./agent-tool-progress";
|
||||
|
||||
vi.mock("use-stick-to-bottom", () => ({ useStickToBottomContext: () => ({ scrollRef: { current: null }, stopScroll: vi.fn() }) }));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("keeps full structured parameters collapsed until the tool row opens", () => {
|
||||
const address = "完整地址".repeat(70);
|
||||
const { container } = render(<AgentToolProgress item={{ id: "tool", phase: "tool", status: "completed", title: "已完成", tool: { name: "mcp_tjwater_geocode", input: { address, radius: 500 } }, detail: "legacy secret summary" }} />);
|
||||
const details = container.querySelector("details")!;
|
||||
expect(details.open).toBe(false);
|
||||
expect(screen.getByText("mcp_tjwater_geocode")).toBeTruthy();
|
||||
expect(container.querySelector("pre")?.textContent).toContain(address);
|
||||
expect(screen.queryByText("legacy secret summary")).toBeNull();
|
||||
fireEvent.click(container.querySelector("summary")!);
|
||||
expect(details.open).toBe(true);
|
||||
});
|
||||
|
||||
it("retains historical details and removes the old status text from the tool label", () => {
|
||||
const { container } = render(<AgentToolProgress item={{ id: "old", phase: "tool", status: "running", title: "正在调用 mcp_tjwater_query_nearby_elements", detail: "历史参数:radius=500" }} />);
|
||||
expect(screen.getByText("mcp_tjwater_query_nearby_elements")).toBeTruthy();
|
||||
expect(screen.getByText("历史参数:radius=500")).toBeTruthy();
|
||||
expect(container.querySelector("details")?.open).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Wrench } from "lucide-react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { AgentProgressStatusIcon } from "./agent-progress-status-icon";
|
||||
import type { AgentChatProgress } from "../types";
|
||||
import { AgentProcessDisclosure } from "./agent-process-disclosure";
|
||||
|
||||
export function AgentToolProgress({
|
||||
item,
|
||||
compact = false
|
||||
}: {
|
||||
item: AgentChatProgress;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const name =
|
||||
item.tool?.name ??
|
||||
item.title
|
||||
.replace(/^(?:正在调用|准备调用)\s*/, "")
|
||||
.replace(
|
||||
/\s*(?:调用失败|已执行完成|已完成|正在执行|正在调用|调用中|已进入待执行状态|等待执行|执行失败)\s*$/,
|
||||
""
|
||||
);
|
||||
const input = item.tool?.input;
|
||||
return (
|
||||
<AgentProcessDisclosure
|
||||
kind="tool"
|
||||
label={
|
||||
<span className={cn("flex min-w-0 items-start gap-2", compact && "text-xs")}>
|
||||
<AgentProgressStatusIcon status={item.status} />
|
||||
<span
|
||||
data-tool-symbol
|
||||
className="inline-flex h-6 w-4 shrink-0 items-center justify-center"
|
||||
>
|
||||
<Wrench aria-hidden="true" className="size-4 text-muted-foreground" />
|
||||
</span>
|
||||
<span data-tool-name className="min-w-0 break-all font-medium leading-6">
|
||||
{name || "工具调用"}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{input ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">调用参数</p>
|
||||
<pre className="max-h-72 overflow-auto whitespace-pre-wrap break-all rounded-md border border-border bg-muted/30 p-3 text-xs leading-relaxed">
|
||||
{JSON.stringify(input, null, 2)}
|
||||
</pre>
|
||||
{item.status === "error" && item.detail ? (
|
||||
<p className="whitespace-pre-wrap break-words text-xs leading-relaxed text-destructive">
|
||||
{item.detail}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{!input && item.detail ? (
|
||||
<p className="whitespace-pre-wrap break-words text-xs leading-relaxed text-muted-foreground">
|
||||
{item.detail}
|
||||
</p>
|
||||
) : !input ? (
|
||||
<p className="text-xs text-muted-foreground">无附加参数</p>
|
||||
) : null}
|
||||
</AgentProcessDisclosure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { copySelectedParagraph } from "@/shared/ai-elements/paragraph-copy";
|
||||
|
||||
export function AgentUserMessageText({ content }: { content: string }) {
|
||||
return <p className="whitespace-pre-wrap" onCopy={(event) => copySelectedParagraph(event, event.currentTarget)}>{content}</p>;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { useAnimationControls, useReducedMotion } from "motion/react";
|
||||
import { useStickToBottomContext } from "use-stick-to-bottom";
|
||||
import type { AgentChatMessage } from "../types";
|
||||
import { agentEase } from "../components/agent-motion";
|
||||
|
||||
type ReadingAnchor = { element: HTMLElement; text: string; top: number };
|
||||
const readable = "p, h1, h2, h3, li, summary";
|
||||
|
||||
/** A final report is readable before transport cleanup finishes. Handoff once. */
|
||||
export function useAgentAnswerTransition(message: AgentChatMessage, running: boolean) {
|
||||
const ready = Boolean(message.finalAnswer) || !running;
|
||||
const [committed, setCommitted] = useState(ready);
|
||||
const [phase, setPhase] = useState(ready ? "settled" : "streaming");
|
||||
const previous = useRef(message);
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const anchor = useRef<{ scroll: HTMLElement; reading?: ReadingAnchor; top: number; release: () => void } | null>(null);
|
||||
const controls = useAnimationControls();
|
||||
const reducedMotion = useReducedMotion();
|
||||
const { scrollRef, stopScroll, state } = useStickToBottomContext();
|
||||
|
||||
useLayoutEffect(() => { if (!ready) previous.current = message; }, [message, ready]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!ready && committed) {
|
||||
setCommitted(false);
|
||||
setPhase("streaming");
|
||||
controls.set({ opacity: 1 });
|
||||
return;
|
||||
}
|
||||
if (!ready || committed) return;
|
||||
let disposed = false;
|
||||
const scroll = scrollRef.current;
|
||||
const element = root.current;
|
||||
if (scroll && element) {
|
||||
anchor.current?.release();
|
||||
anchor.current = null;
|
||||
const viewport = scroll.getBoundingClientRect();
|
||||
// Use the same near-bottom state as the conversation's follow button.
|
||||
const following = state.isAtBottom || state.isNearBottom;
|
||||
const reading = !following ? [...scroll.querySelectorAll<HTMLElement>(readable)].find((node) => {
|
||||
const bounds = node.getBoundingClientRect();
|
||||
return bounds.height > 0 && bounds.bottom > viewport.top + 12 && bounds.top < viewport.bottom;
|
||||
}) : undefined;
|
||||
stopScroll();
|
||||
const overflowAnchor = scroll.style.overflowAnchor;
|
||||
scroll.style.overflowAnchor = "none";
|
||||
const cancel = () => { anchor.current?.release(); anchor.current = null; };
|
||||
const events = ["wheel", "pointerdown", "touchmove", "keydown"];
|
||||
for (const event of events) scroll.addEventListener(event, cancel, { passive: true });
|
||||
anchor.current = { scroll,
|
||||
reading: reading ? { element: reading, text: reading.textContent ?? "", top: reading.getBoundingClientRect().top } : undefined,
|
||||
top: Math.max(viewport.top + 12, element.getBoundingClientRect().top),
|
||||
release: () => {
|
||||
scroll.style.overflowAnchor = overflowAnchor;
|
||||
for (const event of events) scroll.removeEventListener(event, cancel);
|
||||
},
|
||||
};
|
||||
}
|
||||
const commit = () => { if (!disposed) { setCommitted(true); setPhase(reducedMotion ? "settled" : "entering"); } };
|
||||
if (reducedMotion) commit();
|
||||
else {
|
||||
setPhase("leaving");
|
||||
void controls.start({ opacity: 0 }, { duration: 0.08, ease: agentEase }).then(commit);
|
||||
}
|
||||
return () => { disposed = true; controls.stop(); };
|
||||
}, [committed, controls, ready, reducedMotion, scrollRef, state, stopScroll]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!committed) return;
|
||||
let disposed = false;
|
||||
let frame: number | undefined;
|
||||
const restore = () => {
|
||||
const current = anchor.current;
|
||||
if (!current || !root.current) return;
|
||||
let target: HTMLElement | null = root.current.querySelector("[data-answer-parts]") ?? root.current;
|
||||
const reading = current.reading;
|
||||
if (reading) {
|
||||
target = reading.element.isConnected && reading.element.textContent === reading.text && reading.element.getBoundingClientRect().height > 0 ? reading.element
|
||||
: [...current.scroll.querySelectorAll<HTMLElement>(readable)].find((node) => node.textContent === reading.text && node.getBoundingClientRect().height > 0) ?? target;
|
||||
}
|
||||
if (target) current.scroll.scrollTop += target.getBoundingClientRect().top - (reading?.top ?? current.top);
|
||||
};
|
||||
restore();
|
||||
frame = requestAnimationFrame(restore);
|
||||
const finish = () => {
|
||||
if (disposed) return;
|
||||
restore();
|
||||
anchor.current?.release();
|
||||
anchor.current = null;
|
||||
setPhase("settled");
|
||||
};
|
||||
if (reducedMotion) {
|
||||
controls.set({ opacity: 1 });
|
||||
frame = requestAnimationFrame(() => { restore(); frame = requestAnimationFrame(finish); });
|
||||
}
|
||||
else void controls.start({ opacity: 1 }, { duration: 0.12, ease: agentEase }).then(finish);
|
||||
return () => { disposed = true; if (frame !== undefined) cancelAnimationFrame(frame); controls.stop(); };
|
||||
}, [committed, controls, reducedMotion]);
|
||||
|
||||
useLayoutEffect(() => () => { anchor.current?.release(); anchor.current = null; }, []);
|
||||
return { message: ready && !committed ? previous.current : message, completed: committed, phase, root, controls };
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { useAgentConnection } from "./use-agent-connection";
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("rechecks an offline service, probes on focus and clears work on unmount", async () => {
|
||||
vi.useFakeTimers();
|
||||
const getHealth = vi.fn().mockRejectedValueOnce(new Error("offline")).mockResolvedValue(undefined);
|
||||
const client = { getHealth };
|
||||
const { result, unmount } = renderHook(() => useAgentConnection(client, "project-a"));
|
||||
await act(async () => {});
|
||||
expect(result.current.connectionStatus).toBe("offline");
|
||||
await act(async () => vi.advanceTimersByTimeAsync(1_000));
|
||||
expect(result.current.connectionStatus).toBe("online");
|
||||
expect(result.current.recoveryVersion).toBe(1);
|
||||
await act(async () => window.dispatchEvent(new Event("focus")));
|
||||
expect(getHealth).toHaveBeenCalledTimes(3);
|
||||
unmount();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("discards a late result from a previous client and scope", async () => {
|
||||
vi.useFakeTimers();
|
||||
let finish!: () => void;
|
||||
let oldSignal!: AbortSignal;
|
||||
const oldClient = { getHealth: vi.fn((signal: AbortSignal) => { oldSignal = signal; return new Promise<void>((resolve) => { finish = resolve; }); }) };
|
||||
const nextClient = { getHealth: vi.fn(async () => { throw new Error("next service offline"); }) };
|
||||
const { result, rerender, unmount } = renderHook(({ client, scope }) => useAgentConnection(client, scope), { initialProps: { client: oldClient, scope: "old" } });
|
||||
await act(async () => {});
|
||||
rerender({ client: nextClient, scope: "next" });
|
||||
await act(async () => {});
|
||||
expect(oldSignal.aborted).toBe(true);
|
||||
await act(async () => finish());
|
||||
expect(result.current.connectionStatus).toBe("offline");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("aborts a stalled probe and recovers on the next retry", async () => {
|
||||
vi.useFakeTimers();
|
||||
const getHealth = vi.fn().mockImplementationOnce((signal: AbortSignal) => new Promise<void>((_resolve, reject) => {
|
||||
signal.addEventListener("abort", () => reject(new DOMException("timeout", "AbortError")), { once: true });
|
||||
})).mockResolvedValue(undefined);
|
||||
const client = { getHealth };
|
||||
const { result, unmount } = renderHook(() => useAgentConnection(client, "project"));
|
||||
await act(async () => vi.advanceTimersByTimeAsync(5_000));
|
||||
expect(result.current.connectionStatus).toBe("offline");
|
||||
await act(async () => vi.advanceTimersByTimeAsync(1_000));
|
||||
expect(result.current.connectionStatus).toBe("online");
|
||||
unmount();
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { AgentApiClient } from "../api/client";
|
||||
import type { AgentConnectionStatus } from "../types";
|
||||
|
||||
/** Service readiness is independent of a conversation's business result. */
|
||||
export function useAgentConnection(client: Pick<AgentApiClient, "getHealth">, scope: string) {
|
||||
const [snapshot, setSnapshot] = useState({ client, scope, status: "connecting" as AgentConnectionStatus, recoveryVersion: 0 });
|
||||
const check = useRef<() => void>(() => {});
|
||||
const recheck = useCallback(() => check.current(), []);
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let controller: AbortController | undefined;
|
||||
let failures = 0;
|
||||
let status: AgentConnectionStatus = "connecting";
|
||||
let recoveryVersion = 0;
|
||||
const publish = (next: AgentConnectionStatus) => {
|
||||
if (disposed) return;
|
||||
if (next === "online" && status === "offline") recoveryVersion++;
|
||||
status = next;
|
||||
setSnapshot((previous) => previous.client === client && previous.scope === scope && previous.status === status && previous.recoveryVersion === recoveryVersion
|
||||
? previous : { client, scope, status, recoveryVersion });
|
||||
};
|
||||
const probe = async () => {
|
||||
if (disposed || controller) return;
|
||||
clearTimeout(timer);
|
||||
if (!navigator.onLine) { publish("offline"); return; }
|
||||
const pending = new AbortController();
|
||||
controller = pending;
|
||||
timeout = setTimeout(() => pending.abort(), 5_000);
|
||||
try {
|
||||
await client.getHealth(pending.signal);
|
||||
if (disposed || controller !== pending) return;
|
||||
failures = 0;
|
||||
publish("online");
|
||||
} catch {
|
||||
if (disposed || controller !== pending) return;
|
||||
failures++;
|
||||
publish("offline");
|
||||
} finally {
|
||||
if (controller === pending) {
|
||||
clearTimeout(timeout);
|
||||
controller = undefined;
|
||||
if (!disposed) timer = setTimeout(() => void probe(), failures ? Math.min(1_000 * 2 ** Math.min(failures - 1, 4), 15_000) : 15_000);
|
||||
}
|
||||
}
|
||||
};
|
||||
const reconnect = () => { void probe(); };
|
||||
const visible = () => { if (document.visibilityState === "visible") reconnect(); };
|
||||
const offline = () => {
|
||||
clearTimeout(timer);
|
||||
clearTimeout(timeout);
|
||||
const pending = controller;
|
||||
controller = undefined;
|
||||
pending?.abort();
|
||||
publish("offline");
|
||||
};
|
||||
check.current = reconnect;
|
||||
publish("connecting");
|
||||
reconnect();
|
||||
window.addEventListener("focus", reconnect);
|
||||
window.addEventListener("online", reconnect);
|
||||
window.addEventListener("offline", offline);
|
||||
document.addEventListener("visibilitychange", visible);
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearTimeout(timer);
|
||||
clearTimeout(timeout);
|
||||
controller?.abort();
|
||||
check.current = () => {};
|
||||
window.removeEventListener("focus", reconnect);
|
||||
window.removeEventListener("online", reconnect);
|
||||
window.removeEventListener("offline", offline);
|
||||
document.removeEventListener("visibilitychange", visible);
|
||||
};
|
||||
}, [client, scope]);
|
||||
return { connectionStatus: snapshot.client === client && snapshot.scope === scope ? snapshot.status : "connecting" as const,
|
||||
recoveryVersion: snapshot.client === client && snapshot.scope === scope ? snapshot.recoveryVersion : 0, recheck };
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import type { InteractionRequest, InteractionRequestApi } from "../api/interaction-requests";
|
||||
import { useInteractionRequests } from "./use-interaction-requests";
|
||||
const record = (status: InteractionRequest["status"] = "pending", revision = 0): InteractionRequest => ({ version: "agent-interaction-request@1", interaction_id: "i", message_id: "m", turn_id: "t", call_id: "c", questions: [{ question_id: "q", title: "确认范围", body: "选择范围", mode: "single", options: [{ id: "all", label: "全部" }], required: true, allow_custom: true }], status, revision, draft_revision: 0, draft: [], created_at: "now" });
|
||||
it("debounces draft CAS and never continues on open; late pending reads cannot revive accepted answers", async () => {
|
||||
vi.useFakeTimers();
|
||||
const root = createRoot(document.createElement("div"));
|
||||
let readResolve!: (value: { items: InteractionRequest[] }) => void;
|
||||
const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [record()] })), getInteractionRequest: vi.fn(async () => record("answered", 1)), writeInteractionRequest: vi.fn(async (_session, _id, action) => action === "draft" ? { ...record(), draft_revision: 1 } : { ...record("answered", 1), delivery: { native_message_id: "native", revision: 0, status: "queued" as const } }) };
|
||||
const resume = vi.fn(async () => {});
|
||||
let current!: ReturnType<typeof useInteractionRequests>;
|
||||
function Probe({ session = "first" }: { session?: string }) { current = useInteractionRequests(api, "actor:project", session, resume); return null; }
|
||||
try {
|
||||
await act(async () => root.render(<Probe />));
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
await act(async () => current.update("i", [{ question_id: "q", selected_option_ids: ["all"], custom_text: "" }]));
|
||||
await act(async () => vi.advanceTimersByTimeAsync(499));
|
||||
expect(api.writeInteractionRequest).not.toHaveBeenCalled();
|
||||
await act(async () => vi.advanceTimersByTimeAsync(1));
|
||||
expect(api.writeInteractionRequest).toHaveBeenCalledWith("first", "i", "draft", expect.objectContaining({ expected_draft_revision: 0 }));
|
||||
vi.mocked(api.listInteractionRequests).mockImplementationOnce(() => new Promise((resolve) => { readResolve = resolve; }));
|
||||
let refresh!: Promise<void>;
|
||||
await act(async () => { refresh = current.refresh(); });
|
||||
// Finish checking then submit. A stale read dispatched before submit must not win afterward.
|
||||
await act(async () => { readResolve({ items: [record()] }); await refresh; });
|
||||
await act(async () => current.act("i", "reply"));
|
||||
expect(current.entries[0].record.status).toBe("answered");
|
||||
expect(resume).toHaveBeenCalledWith("first", "i");
|
||||
await act(async () => current.refresh());
|
||||
expect(current.entries[0].record.status).toBe("answered");
|
||||
await act(async () => root.render(<Probe session="second" />));
|
||||
expect(current.entries[0].record.status).toBe("pending");
|
||||
await act(async () => root.render(<Probe />));
|
||||
expect(current.entries[0].record.status).toBe("answered");
|
||||
} finally { await act(async () => root.unmount()); vi.useRealTimers(); }
|
||||
});
|
||||
it("binds a delayed submission to its original session and keeps the next session unchanged", async () => {
|
||||
const root = createRoot(document.createElement("div"));
|
||||
let resolveReply!: (value: InteractionRequest) => void;
|
||||
const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [record()] })), getInteractionRequest: vi.fn(async () => record()), writeInteractionRequest: vi.fn(() => new Promise<InteractionRequest>((resolve) => { resolveReply = resolve; })) };
|
||||
const resume = vi.fn(async () => {});
|
||||
let current!: ReturnType<typeof useInteractionRequests>;
|
||||
function Probe({ session }: { session: string }) { current = useInteractionRequests(api, "actor", session, resume); return null; }
|
||||
try {
|
||||
await act(async () => root.render(<Probe session="first" />));
|
||||
await act(async () => current.update("i", [{ question_id: "q", selected_option_ids: ["all"], custom_text: "first draft" }]));
|
||||
let submission!: Promise<void>;
|
||||
await act(async () => { submission = current.act("i", "reply"); });
|
||||
await act(async () => root.render(<Probe session="second" />));
|
||||
await act(async () => { resolveReply({ ...record("answered", 1), delivery: { native_message_id: "native", revision: 0, status: "queued" } }); await submission; });
|
||||
expect(resume).toHaveBeenCalledWith("first", "i");
|
||||
expect(current.entries[0].record.status).toBe("pending");
|
||||
expect(current.entries[0].draft).toEqual([]);
|
||||
await act(async () => root.render(<Probe session="first" />));
|
||||
expect(current.entries[0].record.status).toBe("answered");
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
it("keeps delivery revisions monotonic and ignores superseded reads", async () => {
|
||||
const root = createRoot(document.createElement("div"));
|
||||
const delivered = { ...record("answered", 1), delivery: { native_message_id: "native", revision: 2, status: "delivered" as const } };
|
||||
const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [delivered] })), getInteractionRequest: vi.fn(async () => delivered), writeInteractionRequest: vi.fn(async () => delivered) };
|
||||
const resume = vi.fn(async () => {});
|
||||
let current!: ReturnType<typeof useInteractionRequests>;
|
||||
function Probe() { current = useInteractionRequests(api, "actor", "session", resume); return null; }
|
||||
try {
|
||||
await act(async () => root.render(<Probe />));
|
||||
vi.mocked(api.listInteractionRequests).mockResolvedValueOnce({ items: [{ ...delivered, delivery: { ...delivered.delivery, revision: 1, status: "queued" } }] });
|
||||
await act(async () => current.refresh());
|
||||
expect(current.entries[0].record.delivery?.status).toBe("delivered");
|
||||
let first!: (value: { items: InteractionRequest[] }) => void;
|
||||
let second!: (value: { items: InteractionRequest[] }) => void;
|
||||
vi.mocked(api.listInteractionRequests).mockImplementationOnce(() => new Promise((resolve) => { first = resolve; })).mockImplementationOnce(() => new Promise((resolve) => { second = resolve; }));
|
||||
let a!: Promise<void>, b!: Promise<void>;
|
||||
await act(async () => { a = current.refresh(); b = current.refresh(); });
|
||||
await act(async () => { first({ items: [delivered] }); await a; });
|
||||
expect(current.checking).toBe(true);
|
||||
await act(async () => { second({ items: [delivered] }); await b; });
|
||||
expect(current.checking).toBe(false);
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
it("purges revoked snapshots and prevents pending reads and old callbacks from restoring them", async () => {
|
||||
const { InteractionRequestApiError } = await import("../api/interaction-requests");
|
||||
const root = createRoot(document.createElement("div"));
|
||||
const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [record()] })), getInteractionRequest: vi.fn(async () => record()), writeInteractionRequest: vi.fn(async () => record()) };
|
||||
const resume = vi.fn(async () => {});
|
||||
let current!: ReturnType<typeof useInteractionRequests>;
|
||||
function Probe({ client = api }: { client?: InteractionRequestApi }) { current = useInteractionRequests(client, "actor", "session", resume); return null; }
|
||||
try {
|
||||
await act(async () => root.render(<Probe />));
|
||||
const staleAction = current.act;
|
||||
vi.mocked(api.listInteractionRequests).mockRejectedValueOnce(new InteractionRequestApiError("无权访问", 403));
|
||||
await act(async () => current.refresh());
|
||||
expect(current.entries).toEqual([]);
|
||||
await act(async () => staleAction("i", "reply"));
|
||||
expect(api.writeInteractionRequest).not.toHaveBeenCalled();
|
||||
const next = { ...api, listInteractionRequests: vi.fn(async () => ({ items: [] })) };
|
||||
await act(async () => root.render(<Probe client={next} />));
|
||||
expect(current.entries).toEqual([]);
|
||||
await act(async () => staleAction("i", "reply"));
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
it("flushes the old session draft on switch without waiting for the debounce", async () => {
|
||||
vi.useFakeTimers();
|
||||
const root = createRoot(document.createElement("div"));
|
||||
const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [record()] })), getInteractionRequest: vi.fn(async () => record()), writeInteractionRequest: vi.fn(async () => ({ ...record(), draft_revision: 1 })) };
|
||||
const resume = vi.fn(async () => {});
|
||||
let current!: ReturnType<typeof useInteractionRequests>;
|
||||
function Probe({ session }: { session: string }) { current = useInteractionRequests(api, "actor", session, resume); return null; }
|
||||
try {
|
||||
await act(async () => root.render(<Probe session="first" />));
|
||||
await act(async () => current.update("i", [{ question_id: "q", selected_option_ids: [], custom_text: "保留的输入" }]));
|
||||
expect(api.writeInteractionRequest).not.toHaveBeenCalled();
|
||||
await act(async () => root.render(<Probe session="second" />));
|
||||
expect(api.writeInteractionRequest).toHaveBeenCalledWith("first", "i", "draft", expect.objectContaining({ answers: [{ question_id: "q", selected_option_ids: [], custom_text: "保留的输入" }] }));
|
||||
expect(current.entries[0].draft).toEqual([]);
|
||||
} finally { await act(async () => root.unmount()); vi.useRealTimers(); }
|
||||
});
|
||||
it("drops an in-flight accepted reply after the API identity changes", async () => {
|
||||
const root = createRoot(document.createElement("div"));
|
||||
let resolveReply!: (value: InteractionRequest) => void;
|
||||
const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [record()] })), getInteractionRequest: vi.fn(async () => record()), writeInteractionRequest: vi.fn(() => new Promise<InteractionRequest>((resolve) => { resolveReply = resolve; })) };
|
||||
const other = { ...api, listInteractionRequests: vi.fn(async () => ({ items: [] })) };
|
||||
const resume = vi.fn(async () => {});
|
||||
let current!: ReturnType<typeof useInteractionRequests>;
|
||||
function Probe({ client }: { client: InteractionRequestApi }) { current = useInteractionRequests(client, "project", "session", resume); return null; }
|
||||
try {
|
||||
await act(async () => root.render(<Probe client={api} />));
|
||||
let submission!: Promise<void>;
|
||||
await act(async () => { submission = current.act("i", "reply"); });
|
||||
await act(async () => root.render(<Probe client={other} />));
|
||||
await act(async () => { resolveReply({ ...record("answered", 1), delivery: { native_message_id: "native", revision: 0, status: "queued" } }); await submission; });
|
||||
expect(current.entries).toEqual([]);
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { InteractionRequestApiError, canReplaceInteractionRequest, type InteractionAnswer, type InteractionRequest, type InteractionRequestApi } from "../api/interaction-requests";
|
||||
|
||||
const version = "agent-interaction-request@1" as const;
|
||||
const requestId = () => crypto.randomUUID();
|
||||
type Entry = { record: InteractionRequest; draft: InteractionAnswer[]; dirty: boolean; busy: boolean; saving?: boolean; error?: string; conflict?: InteractionAnswer[]; submission?: { id: string; answers: InteractionAnswer[] }; draftWrite?: { id: string; answers: InteractionAnswer[]; revision: number } };
|
||||
export type InteractionRequestEntry = Entry;
|
||||
const denied = (error: unknown) => error instanceof Error && "status" in error && typeof error.status === "number" && [401, 403, 404].includes(error.status);
|
||||
export function useInteractionRequests(api: InteractionRequestApi, scope: string, sessionId: string | null, onContinue: (sessionId: string, interactionId: string) => Promise<void>) {
|
||||
const identity = useRef({ api, scope, generation: 0, revoked: false });
|
||||
const cache = useRef(new Map<string, Map<string, Entry>>());
|
||||
const checked = useRef(new Set<string>());
|
||||
const sequences = useRef(new Map<string, number>());
|
||||
if (identity.current.api !== api || identity.current.scope !== scope) {
|
||||
identity.current = { api, scope, generation: identity.current.generation + 1, revoked: false };
|
||||
cache.current.clear(); checked.current.clear(); sequences.current.clear();
|
||||
}
|
||||
const generation = identity.current.generation;
|
||||
const valid = useCallback(() => identity.current.generation === generation && !identity.current.revoked, [generation]);
|
||||
const key = `${scope}:${generation}:${sessionId ?? ""}`;
|
||||
const active = useRef(key); active.current = key;
|
||||
const [tick, render] = useState(0);
|
||||
const [checkingKey, setCheckingKey] = useState<string>();
|
||||
const [loadError, setLoadError] = useState<{ key: string; message: string }>();
|
||||
const notify = useCallback(() => render((value) => value + 1), []);
|
||||
const entries = useMemo(() => {
|
||||
const existing = cache.current.get(key) ?? new Map<string, Entry>();
|
||||
cache.current.set(key, existing);
|
||||
return existing;
|
||||
}, [key]);
|
||||
const purge = useCallback((error: unknown) => {
|
||||
if (!valid() || !denied(error)) return false;
|
||||
cache.current.forEach((items) => items.clear());
|
||||
checked.current.clear(); sequences.current.clear();
|
||||
identity.current.generation += 1;
|
||||
identity.current.revoked = true;
|
||||
setLoadError({ key, message: error instanceof Error ? error.message : "提问访问权限已失效" });
|
||||
notify(); return true;
|
||||
}, [key, notify, valid]);
|
||||
const refresh = useCallback(async () => {
|
||||
if (!sessionId || !valid()) return;
|
||||
const target = entries;
|
||||
const sequence = (sequences.current.get(key) ?? 0) + 1;
|
||||
sequences.current.set(key, sequence);
|
||||
const latest = () => valid() && sequences.current.get(key) === sequence;
|
||||
setCheckingKey(key); checked.current.delete(key);
|
||||
try {
|
||||
const result = await api.listInteractionRequests(sessionId);
|
||||
if (!latest()) return;
|
||||
for (const record of result.items) {
|
||||
const previous = target.get(record.interaction_id);
|
||||
if (previous && !canReplaceInteractionRequest(record, previous.record)) continue;
|
||||
const next: Entry = { ...previous, record, draft: previous?.dirty ? previous.draft : record.draft, dirty: previous?.dirty ?? false, busy: previous?.busy ?? false,
|
||||
...(record.status !== "pending" && previous?.dirty ? { conflict: previous.draft } : {}) };
|
||||
target.set(record.interaction_id, previous ? Object.assign(previous, next) : next);
|
||||
}
|
||||
checked.current.add(key);
|
||||
if (active.current === key) setLoadError(undefined);
|
||||
} catch (error) {
|
||||
if (latest() && !purge(error) && active.current === key) setLoadError({ key, message: error instanceof Error ? error.message : "提问核对失败" });
|
||||
} finally { if (latest() && active.current === key) { setCheckingKey(undefined); notify(); } }
|
||||
}, [api, entries, key, sessionId, notify, valid, purge]);
|
||||
useEffect(() => { void refresh(); }, [refresh]);
|
||||
const update = (interactionId: string, draft: InteractionAnswer[]) => {
|
||||
const entry = entries.get(interactionId);
|
||||
if (!valid() || !entry || entry.record.status !== "pending" || entry.busy) return;
|
||||
entry.draft = draft; entry.dirty = true; entry.error = undefined; entry.submission = undefined; notify();
|
||||
};
|
||||
const saveDrafts = useCallback(() => {
|
||||
if (!sessionId || !valid() || !checked.current.has(key)) return;
|
||||
for (const entry of entries.values()) {
|
||||
if (!entry.dirty || entry.busy || entry.saving || entry.error || entry.record.status !== "pending") continue;
|
||||
entry.saving = true;
|
||||
const write = entry.draftWrite ?? { id: requestId(), answers: entry.draft, revision: entry.record.draft_revision };
|
||||
entry.draftWrite = write;
|
||||
void api.writeInteractionRequest(sessionId, entry.record.interaction_id, "draft", { version, request_id: write.id, expected_draft_revision: write.revision, answers: write.answers }).then((record) => {
|
||||
if (!valid() || !canReplaceInteractionRequest(record, entry.record)) return;
|
||||
entry.record = record; entry.draftWrite = undefined;
|
||||
if (entry.draft === write.answers) entry.dirty = false;
|
||||
}).catch((error: unknown) => {
|
||||
if (!valid() || purge(error)) return;
|
||||
if (error instanceof InteractionRequestApiError && error.current && canReplaceInteractionRequest(error.current, entry.record)) {
|
||||
entry.record = error.current; entry.draftWrite = undefined;
|
||||
if (error.current.status !== "pending") entry.conflict = write.answers;
|
||||
}
|
||||
entry.error = error instanceof Error ? error.message : "草稿未保存";
|
||||
}).finally(() => { entry.saving = false; if (valid() && active.current === key) notify(); });
|
||||
}
|
||||
}, [api, entries, key, notify, purge, sessionId, valid]);
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(saveDrafts, 500);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [saveDrafts, tick]);
|
||||
// Flush the owning session before replacing its subscription. Identity changes invalidate this closure.
|
||||
useEffect(() => () => saveDrafts(), [saveDrafts]);
|
||||
const act = async (interactionId: string, action: "reply" | "skip" | "cancel" | "resume") => {
|
||||
if (!sessionId || !valid() || !checked.current.has(key)) return;
|
||||
const entry = entries.get(interactionId);
|
||||
if (!entry || entry.busy || checkingKey === key) return;
|
||||
entry.busy = true; entry.error = undefined; notify();
|
||||
const submission = entry.submission ?? { id: requestId(), answers: structuredClone(entry.draft) };
|
||||
if (action === "reply") entry.submission = submission;
|
||||
try {
|
||||
const record = await api.writeInteractionRequest(sessionId, interactionId, action, { version, request_id: action === "reply" ? submission.id : requestId(), expected_revision: action === "resume" ? entry.record.delivery?.revision ?? 0 : entry.record.revision, ...(action === "reply" ? { answers: submission.answers } : {}) });
|
||||
if (!valid()) return;
|
||||
if (canReplaceInteractionRequest(record, entry.record)) entry.record = record;
|
||||
entry.dirty = false;
|
||||
if (action !== "cancel" && record.delivery && ["queued", "blocked", "dispatching"].includes(record.delivery.status)) await onContinue(sessionId, interactionId);
|
||||
} catch (error) {
|
||||
if (!valid() || purge(error)) return;
|
||||
if (error instanceof InteractionRequestApiError && error.current && canReplaceInteractionRequest(error.current, entry.record)) { entry.record = error.current; entry.conflict = submission.answers; }
|
||||
entry.error = error instanceof Error ? error.message : "操作失败,请重试";
|
||||
try {
|
||||
const record = await api.getInteractionRequest(sessionId, interactionId);
|
||||
if (valid() && canReplaceInteractionRequest(record, entry.record)) entry.record = record;
|
||||
} catch (readError) { purge(readError); }
|
||||
} finally { entry.busy = false; if (valid() && active.current === key) notify(); }
|
||||
};
|
||||
const list = [...entries.values()];
|
||||
const checking = Boolean(sessionId) && (!checked.current.has(key) || checkingKey === key);
|
||||
return { entries: list, checking, error: loadError && (loadError.key === key || identity.current.revoked) ? loadError.message : undefined, refresh, update, act,
|
||||
awaitingInput: list.some(({ record }) => record.status === "pending" || (record.delivery && ["queued", "dispatching", "blocked"].includes(record.delivery.status))) };
|
||||
}
|
||||
export type InteractionRequestsController = ReturnType<typeof useInteractionRequests>;
|
||||
@@ -10,7 +10,8 @@ export type {
|
||||
AgentWorkspaceContextItem
|
||||
} from "./components/agent-command-panel";
|
||||
export { AgentPersona } from "./components/agent-persona";
|
||||
export { createAgentApiClient } from "./api/client";
|
||||
export { createAgentApiClient, AgentResponseFinishedError, AgentSessionStreamError } from "./api/client";
|
||||
export { useAgentConnection } from "./hooks/use-agent-connection";
|
||||
export { useAgentMessageInbox } from "./hooks/use-agent-message-inbox";
|
||||
export type { AgentMailboxMessage } from "./api/agent-messages";
|
||||
export type {
|
||||
@@ -96,11 +97,17 @@ export { parseFrontendChart } from "./frontend-action/chart";
|
||||
|
||||
export type { AnswerUiMapTarget } from "./answer-ui/answer-ui-view";
|
||||
|
||||
export { ResponsesChatTransport, createResponseRequest } from "./api/responses-transport";
|
||||
export { ResponsesChatTransport, createResponseRequest, AgentStreamDisconnectedError } from "./api/responses-transport";
|
||||
export { AnswerUiView } from "./answer-ui/answer-ui-view";
|
||||
export { parseAnswerUiBlock, reportUiBlocksSchema } from "./answer-ui/contract";
|
||||
export { getFinalAnswerTranscript } from "./answer-ui/answer-presentation";
|
||||
export type { AnswerUiBlock, AnswerUiPart, StoredReportUiBlock } from "./answer-ui/contract";
|
||||
export type { AnswerUiSelection } from "./answer-ui/answer-ui-interaction-context";
|
||||
|
||||
export type { AgentSessionPage, AgentHistoryMessagePage, AgentMessagePagination } from "./api/client";
|
||||
export type { AgentRunStatus } from "./api/client";
|
||||
|
||||
export { useInteractionRequests } from "./hooks/use-interaction-requests";
|
||||
export { AgentInteractionRequests } from "./components/agent-interaction-request-card";
|
||||
export { readInteractionReply } from "./api/interaction-requests";
|
||||
export type { InteractionRequestsController } from "./hooks/use-interaction-requests";
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { InteractionRequest } from "../api/interaction-requests";
|
||||
import type { AgentChatMessage } from "../types";
|
||||
import { groupMessageProcesses } from "./message-process-groups";
|
||||
|
||||
const assistant = (id: string, content = ""): AgentChatMessage => ({
|
||||
id, role: "assistant", content,
|
||||
progress: [{ id: "query", phase: "tool", status: "completed", title: id }]
|
||||
});
|
||||
const user = (id: string): AgentChatMessage => ({ id, role: "user", content: id });
|
||||
const request = (source: string, reply: string, extra: Partial<InteractionRequest> = {}): InteractionRequest => ({
|
||||
version: "agent-interaction-request@1", interaction_id: `question-${source}`, message_id: source,
|
||||
turn_id: `turn-${source}`, call_id: `call-${source}`, status: "answered", revision: 1, draft_revision: 0,
|
||||
questions: [{ question_id: "scope", title: "范围", body: "核查范围?", mode: "single", required: true, allow_custom: true, options: [] }],
|
||||
draft: [], created_at: "2026-10-28T10:00:00Z", delivery: { native_message_id: reply, revision: 1, status: "delivered" },
|
||||
...extra
|
||||
});
|
||||
|
||||
describe("process groups across clarification replies", () => {
|
||||
const source = assistant("question");
|
||||
const reply = user("reply");
|
||||
const result = assistant("answer_reply", "结果正文");
|
||||
const messages = [user("original"), source, reply, result];
|
||||
|
||||
it("moves the source process to the related continuation without editing stored messages or progress ids", () => {
|
||||
const original = structuredClone(messages);
|
||||
const groups = groupMessageProcesses(messages, [request(source.id, reply.id)]);
|
||||
expect(groups.get(source.id)).toEqual([]);
|
||||
expect(groups.get(result.id)).toEqual([source, result]);
|
||||
expect(groups.get(result.id)?.map((message) => message.progress?.[0].id)).toEqual(["query", "query"]);
|
||||
expect(messages).toEqual(original);
|
||||
});
|
||||
|
||||
it("groups an existing result even when delivery is blocked and there is no final_answer", () => {
|
||||
const record = request(source.id, reply.id, { delivery: { native_message_id: reply.id, revision: 2, status: "blocked", error_code: "CONTINUATION_UNCONFIRMED" } });
|
||||
expect(groupMessageProcesses(messages, [record]).get(result.id)).toEqual([source, result]);
|
||||
expect(record.delivery?.status).toBe("blocked");
|
||||
});
|
||||
|
||||
it("keeps processes in place while waiting, cancelled, missing a source page or missing a continuation", () => {
|
||||
for (const extra of [{ status: "pending" }, { status: "cancelled" }, { delivery: undefined },
|
||||
{ delivery: { native_message_id: reply.id, revision: 2, status: "cancelled" } }] satisfies Partial<InteractionRequest>[]) {
|
||||
expect(groupMessageProcesses(messages, [request(source.id, reply.id, extra)]).get(source.id)).toEqual([source]);
|
||||
}
|
||||
expect(groupMessageProcesses(messages.slice(0, -1), [request(source.id, reply.id)]).get(source.id)).toEqual([source]);
|
||||
expect(groupMessageProcesses(messages.slice(2), [request(source.id, reply.id)]).get(result.id)).toEqual([result]);
|
||||
});
|
||||
|
||||
it("does not merge across an unrelated user request on either side of the saved reply", () => {
|
||||
for (const list of [
|
||||
[source, user("other-request"), reply, result],
|
||||
[source, reply, user("other-request"), result],
|
||||
[reply, source, result]
|
||||
]) {
|
||||
const groups = groupMessageProcesses(list, [request(source.id, reply.id)]);
|
||||
expect(groups.get(source.id)).toEqual([source]);
|
||||
expect(groups.get(result.id)).toEqual([result]);
|
||||
}
|
||||
});
|
||||
|
||||
it("combines successive clarifications in order and ignores duplicate records", () => {
|
||||
const reply2 = user("reply2");
|
||||
const final = assistant("answer_reply2", "最终结果");
|
||||
const first = request(source.id, reply.id);
|
||||
const second = request(result.id, reply2.id);
|
||||
const groups = groupMessageProcesses([...messages, reply2, final], [second, first, first]);
|
||||
expect(groups.get(source.id)).toEqual([]);
|
||||
expect(groups.get(result.id)).toEqual([]);
|
||||
expect(groups.get(final.id)).toEqual([source, result, final]);
|
||||
});
|
||||
|
||||
it("retains actionable questions and permissions at their original message", () => {
|
||||
const pendingQuestion: AgentChatMessage = { ...source, questions: [{
|
||||
requestId: "pending", sessionId: "s", questions: [], createdAt: 0, status: "checking"
|
||||
}] };
|
||||
const pendingPermission: AgentChatMessage = { ...source, permissions: [{
|
||||
requestId: "pending", sessionId: "s", permission: "simulate", patterns: [], always: [], createdAt: 0, status: "pending"
|
||||
}] };
|
||||
for (const pending of [pendingQuestion, pendingPermission]) {
|
||||
expect(groupMessageProcesses([pending, reply, result], [request(source.id, reply.id)]).get(source.id)).toEqual([pending]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { InteractionRequest } from "../api/interaction-requests";
|
||||
import type { AgentChatMessage } from "../types";
|
||||
|
||||
export function hasPendingMessageInput(message: AgentChatMessage): boolean {
|
||||
return Boolean(
|
||||
message.permissions?.some((item) => ["pending", "submitting", "error"].includes(item.status)) ||
|
||||
message.questions?.some((item) => ["checking", "pending", "submitting", "error"].includes(item.status))
|
||||
);
|
||||
}
|
||||
|
||||
/** Group presentation only; preserve persisted message identities and per-message step ids. */
|
||||
export function groupMessageProcesses(
|
||||
messages: readonly AgentChatMessage[],
|
||||
requests: readonly InteractionRequest[]
|
||||
): Map<string, AgentChatMessage[]> {
|
||||
const positions = new Map(messages.map((message, index) => [message.id, index]));
|
||||
const groups = new Map(messages.filter((message) => message.role === "assistant").map((message) => [message.id, [message]]));
|
||||
const ordered = [...requests].sort((a, b) => (positions.get(a.message_id) ?? -1) - (positions.get(b.message_id) ?? -1));
|
||||
for (const record of ordered) {
|
||||
if (!["answered", "skipped"].includes(record.status) || !record.delivery || record.delivery.status === "cancelled") continue;
|
||||
const sourceIndex = positions.get(record.message_id);
|
||||
const replyIndex = positions.get(record.delivery.native_message_id);
|
||||
if (sourceIndex === undefined || replyIndex === undefined || sourceIndex >= replyIndex) continue;
|
||||
const source = messages[sourceIndex];
|
||||
const reply = messages[replyIndex];
|
||||
const target = messages[replyIndex + 1];
|
||||
if (source.role !== "assistant" || reply.role !== "user" || target?.role !== "assistant" || hasPendingMessageInput(source)) continue;
|
||||
// A saved reply can arrive late; do not pull a process across another user request.
|
||||
if (messages.slice(sourceIndex + 1, replyIndex).some((message) => message.role === "user")) continue;
|
||||
const previous = groups.get(source.id);
|
||||
if (!previous?.length) continue;
|
||||
groups.set(target.id, [...previous, ...(groups.get(target.id) ?? [])]);
|
||||
groups.set(source.id, []);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
@@ -31,6 +31,15 @@ describe("agent session state", () => {
|
||||
expect(next[0]).toMatchObject({ id: "tool-1", status: "completed", title: "工具完成", durationMs: 40 });
|
||||
});
|
||||
|
||||
it("retains full tool inputs across stream updates and persisted history", () => {
|
||||
const input = { address: "完整地址".repeat(70), point: { longitude: 121.5, latitude: 31.2 }, radius: 500 };
|
||||
const first = upsertProgress(undefined, { id: "tool", phase: "tool", tool: { name: "mcp_tjwater_geocode", call_id: "native-call", input } });
|
||||
const next = upsertProgress(first, { id: "tool", phase: "tool", status: "completed" });
|
||||
expect(next[0].tool).toEqual({ name: "mcp_tjwater_geocode", callId: "native-call", input });
|
||||
expect(upsertProgress(undefined, next[0])[0].tool).toEqual(next[0].tool);
|
||||
expect(upsertProgress(undefined, { tool: { name: 3, input: [] } })[0].tool).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes todo updates", () => {
|
||||
expect(
|
||||
toTodoUpdate({
|
||||
|
||||
@@ -29,6 +29,7 @@ export function upsertProgress(progress: AgentChatProgress[] | undefined, data:
|
||||
status,
|
||||
title: readString(payload.title) ?? "正在处理",
|
||||
detail: readString(payload.detail),
|
||||
tool: readProgressTool(payload.tool) ?? existing?.tool,
|
||||
startedAt,
|
||||
endedAt,
|
||||
elapsedMs: status === "running" ? elapsedMs : undefined,
|
||||
@@ -373,3 +374,15 @@ function readNumber(value: unknown) {
|
||||
function readStringArray(value: unknown) {
|
||||
return Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
export function readProgressTool(value: unknown): AgentChatProgress["tool"] {
|
||||
const tool = asRecord(value);
|
||||
const name = readString(tool.name);
|
||||
if (!name) return undefined;
|
||||
return {
|
||||
name,
|
||||
callId: readString(tool.call_id) ?? readString(tool.callId),
|
||||
input: tool.input && typeof tool.input === "object" && !Array.isArray(tool.input)
|
||||
? tool.input as Record<string, unknown> : undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export type AgentChatMessage = {
|
||||
questions?: AgentQuestionRequest[];
|
||||
todos?: AgentTodoUpdate;
|
||||
taskContext?: import("./task-context").AgentTaskContextReference;
|
||||
interactionReply?: import("./api/interaction-requests").InteractionRequest;
|
||||
};
|
||||
|
||||
export type AgentStreamRenderMessageState = {
|
||||
@@ -20,6 +21,7 @@ export type AgentStreamRenderState = Record<string, AgentStreamRenderMessageStat
|
||||
export type AgentConnectionStatus = "connecting" | "online" | "offline";
|
||||
|
||||
export type AgentChatProgress = {
|
||||
tool?: { name: string; callId?: string; input?: Record<string, unknown> };
|
||||
id: string;
|
||||
phase: string;
|
||||
status: "running" | "completed" | "error";
|
||||
@@ -86,7 +88,7 @@ export type AgentQuestionRequest = {
|
||||
tool?: AgentInteractionToolRef;
|
||||
createdAt: number;
|
||||
repliedAt?: number;
|
||||
status: "pending" | "submitting" | "answered" | "rejected" | "error";
|
||||
status: "checking" | "invalidated" | "pending" | "submitting" | "answered" | "rejected" | "error";
|
||||
answers?: string[][];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user