fix: unify chat process and reply presentation

This commit is contained in:
2026-10-29 17:54:34 +08:00
parent 7621bf0f86
commit 9474788db6
46 changed files with 3160 additions and 500 deletions
+12
View File
@@ -204,3 +204,15 @@ Agent 形象基于 [Obsidian 原版动画资源](https://ejiidnob33g9ap1r.public
固定报告字段不变:阶段、结果、结论、边界、数据缺口、证据和建议仍独立校验。新字段通过原有报告 JSON 保存,跟随 revision 与 execution_generation 读取;请求幂等沿用原有机制。旧报告缺省为空,不因缺省字段而产生虚假的报告更新通知。无需数据库表或环境变量迁移。 固定报告字段不变:阶段、结果、结论、边界、数据缺口、证据和建议仍独立校验。新字段通过原有报告 JSON 保存,跟随 revision 与 execution_generation 读取;请求幂等沿用原有机制。旧报告缺省为空,不因缺省字段而产生虚假的报告更新通知。无需数据库表或环境变量迁移。
组件参数唯一来源为前端 `catalog.ts`,导出到 Agent 与 Server;跨字段约束在三端分别验证。Agent `publish_task_report` 使用正式报告通道,聊天 `present_answer` 使用聊天通道。实现与生成命令见 [本地交互式回答](agent-answer-ui.md)。 组件参数唯一来源为前端 `catalog.ts`,导出到 Agent 与 Server;跨字段约束在三端分别验证。Agent `publish_task_report` 使用正式报告通道,聊天 `present_answer` 使用聊天通道。实现与生成命令见 [本地交互式回答](agent-answer-ui.md)。
## 聊天过程与回答恢复
处理过程统一位于回复正文下方。生成中,文字与回答内容块在前,公开执行摘要、标记为过程的内容块和待办在后;最终答复到达或回复流结束后,过程收起为底部“查看处理过程”,入口位于语音播放左侧,没有 `final_answer` 标记的历史回复也遵循同一布局。最终答复明确引用的回答内容块留在主阅读流,其他记录进入处理过程。待回答问题、权限审批和失败状态在正文下方保持可见,不能因回复结束而被隐藏。这里借鉴 Reasoning 的折叠行为,呈现的数据是服务端已发布的过程信息,不额外生成思考文本,也不根据耗时推算完成比例。
最终答复到达后,正文、回复状态与语音/复制操作在同一次交接中切换,网络流的后续收尾不再改变这条回复的展示阶段。输入框仍根据真实执行状态接受引导或排队消息。过程复用生成时的容器和步骤实例,保留工具详情的展开状态;无论用户是否向上滚动或查看过工具详情,交接后默认收起过程并优先让结果正文可读;用户可通过“查看处理过程”重新展开。历史回放直接显示保存的结果,不重新播放交接。
补充问题打断后继续执行时,依据提问记录的 `message_id` 和补充回答的 `delivery.native_message_id` 将前后过程归到续接答复下方;连续补充时沿同一关联链顺序归组。没有正文的旧过程消息不再单独占位,原有正文和用户补充回答仍保留。尚未出现续接答复、历史分页缺少关联消息、仍有待回答问题或审批、关联之间出现新用户请求时,保留原位置。该归组仅影响展示,不改原始记录或消息标识。
生成中、补充后续接和历史恢复共用一个处理过程容器,边框、背景和内边距保持一致;各段执行记录合并为一份列表,不再嵌套另一张“执行进度”卡片。步骤显示身份包含来源消息,避免前后轮次的同名步骤互相覆盖;生成时统一控制整个过程的最近三项与展开全部,完成后由底部入口统一折叠。
已提交的回答以原生用户消息 ID 保存纯净文本和 `data-interaction_reply` 快照,首屏即可还原同一张“已回答”卡片并归组前后过程,不等待独立提问接口。该接口返回较新修订后更新投递状态和重试操作。旧历史由 Agent 按所属会话中已保存的投递 ID 补齐相同表示,不通过文本前缀猜测对应关系,也不改写历史文件。给模型的“用户提交的提问回复”协议前缀只用于推理输入。
+2
View File
@@ -179,3 +179,5 @@ Agent 界面另检查所有弹窗、面板及其子菜单是否沿用本地 shad
- [shadcn/ui 组件原则](https://ui.shadcn.com/docs)、[主题变量](https://ui.shadcn.com/docs/theming)、[Vite 接入](https://ui.shadcn.com/docs/installation/vite)、[CLI 配置](https://ui.shadcn.com/docs/components-json)。 - [shadcn/ui 组件原则](https://ui.shadcn.com/docs)、[主题变量](https://ui.shadcn.com/docs/theming)、[Vite 接入](https://ui.shadcn.com/docs/installation/vite)、[CLI 配置](https://ui.shadcn.com/docs/components-json)。
- [Tailwind CSS 主题变量](https://tailwindcss.com/docs/theme)、[自定义样式与层叠](https://tailwindcss.com/docs/adding-custom-styles)。 - [Tailwind CSS 主题变量](https://tailwindcss.com/docs/theme)、[自定义样式与层叠](https://tailwindcss.com/docs/adding-custom-styles)。
- [Fluent Acrylic](https://learn.microsoft.com/en-us/windows/apps/design/style/acrylic)、[Mica](https://learn.microsoft.com/en-us/windows/apps/design/style/mica)。 - [Fluent Acrylic](https://learn.microsoft.com/en-us/windows/apps/design/style/acrylic)、[Mica](https://learn.microsoft.com/en-us/windows/apps/design/style/mica)。
聊天的生成、续接与刷新恢复共用一个过程容器和一份步骤列表。过程入口在回复底部靠左,语音和复制靠右。发送保持蓝色圆形按钮,停止使用柔和红底与深红图标,悬停时红底略深。
@@ -23,7 +23,7 @@ export function AnswerPartsView({
onAsk?: (selection: AnswerUiSelection) => void; onAsk?: (selection: AnswerUiSelection) => void;
}) { }) {
return ( return (
<div className="space-y-4"> <div className="space-y-4" data-answer-parts>
{parts.map((part, index) => {parts.map((part, index) =>
part.type === "answer-ui" ? ( part.type === "answer-ui" ? (
<AnswerUiView <AnswerUiView
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { AgentChatMessage } from "../types"; import type { AgentChatMessage } from "../types";
import { getAnswerPresentation } from "./answer-presentation"; import { canExpandAnswer, getAnswerPresentation, getFinalAnswerTranscript } from "./answer-presentation";
import type { AnswerUiPart } from "./contract"; import type { AnswerUiPart } from "./contract";
const ui = (id: string): AnswerUiPart => ({ const ui = (id: string): AnswerUiPart => ({
@@ -28,6 +28,17 @@ const message: AgentChatMessage = {
answerParts: parts answerParts: parts
}; };
describe("final report and collapsible process", () => { 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", () => { it("shows all incremental content while waiting for final_answer", () => {
expect(getAnswerPresentation(message)).toEqual({ report: parts, process: [], final: false }); expect(getAnswerPresentation(message)).toEqual({ report: parts, process: [], final: false });
}); });
@@ -52,3 +63,68 @@ describe("final report and collapsible process", () => {
expect(view.process).toEqual(parts); 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 { AgentChatMessage } from "../types";
import type { AnswerUiPart } from "./contract"; 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): { export function getAnswerPresentation(message: AgentChatMessage): {
report: AnswerUiPart[]; report: AnswerUiPart[];
process: AnswerUiPart[]; process: AnswerUiPart[];
final: boolean; final: boolean;
} { } {
const parts = const parts = message.answerParts ??
message.answerParts ??
(message.content ? [{ type: "text" as const, text: message.content }] : []); (message.content ? [{ type: "text" as const, text: message.content }] : []);
if (!message.finalAnswer) return { report: parts, process: [], final: false }; if (!message.finalAnswer) {
const selected = new Set(message.finalAnswer.uiBlockIds); const isProcess = (part: AnswerUiPart) => part.type === "answer-ui" && part.block.phase === "process";
const report: AnswerUiPart[] = [{ type: "text", text: message.finalAnswer.content }]; return { report: parts.filter((part) => !isProcess(part)), process: parts.filter(isProcess), final: false };
for (const id of selected) {
const part = parts.find((item) => item.type === "answer-ui" && item.block.block_id === id);
if (part) report.push(part);
} }
const process = parts.filter((part) => const selected = new Set(message.finalAnswer.uiBlockIds);
part.type === "answer-ui" const available = new Map(parts.flatMap((part) =>
? !selected.has(part.block.block_id) part.type === "answer-ui" ? [[part.block.block_id, part] as const] : []));
: part.text.trim() !== message.finalAnswer!.content.trim() 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 }; return { report, process, final: true };
} }
+3
View File
@@ -11,6 +11,7 @@ const payloadSchema = z
.object({ .object({
version: z.literal(ANSWER_UI_VERSION), version: z.literal(ANSWER_UI_VERSION),
block_id: key, block_id: key,
phase: z.enum(["process", "final"]).optional(),
fallback_text: z.string().min(1).max(4000), fallback_text: z.string().min(1).max(4000),
spec: specSchema spec: specSchema
}) })
@@ -20,6 +21,7 @@ const payloadSchema = z
export const storedAnswerUiBlockSchema = z.object({ export const storedAnswerUiBlockSchema = z.object({
version: z.string(), version: z.string(),
block_id: key, block_id: key,
phase: z.enum(["process", "final"]).optional(),
fallback_text: z.string().min(1).max(4000), fallback_text: z.string().min(1).max(4000),
spec: z.unknown() spec: z.unknown()
}); });
@@ -32,6 +34,7 @@ export type StoredReportUiBlock = z.infer<typeof reportUiBlocksSchema>[number];
export type AnswerUiBlock = { export type AnswerUiBlock = {
version: typeof ANSWER_UI_VERSION; version: typeof ANSWER_UI_VERSION;
block_id: string; block_id: string;
phase?: "process" | "final";
fallback_text: string; fallback_text: string;
spec: Spec; spec: Spec;
}; };
+33 -8
View File
@@ -1,8 +1,9 @@
import { createInteractionRequestApi, type InteractionRequestApi } from "./interaction-requests";
import { env } from "@/shared/config/env"; import { env } from "@/shared/config/env";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth"; import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { agentMessagePageSchema, type AgentMessagePage } from "./agent-messages"; 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 AgentSessionPurpose = "ordinary_chat" | "task_execution" | "task_collaboration";
export type AgentEventTriggerContext = { export type AgentEventTriggerContext = {
@@ -40,6 +41,10 @@ export type AgentModelsResponse = {
export type AgentInputDelivery = "steer" | "queue"; export type AgentInputDelivery = "steer" | "queue";
export type AgentResponseInput = { id: string; text: string; delivery: AgentInputDelivery; status: "pending" | "delivered" | "cancelled" }; 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 }; export type AgentTopic = { id: string; name: string; createdAt: string; updatedAt: string };
@@ -115,6 +120,7 @@ export type AgentSessionStreamDataEventType =
| "todo_update" | "todo_update"
| "permission_request" | "permission_request"
| "permission_response" | "permission_response"
| "interaction_request"
| "question_request" | "question_request"
| "question_response" | "question_response"
| "tool_call" | "tool_call"
@@ -148,7 +154,8 @@ export type AgentSessionStreamEvent =
data: Record<string, unknown>; data: Record<string, unknown>;
}; };
export type AgentApiClient = { export type AgentApiClient = InteractionRequestApi & {
getHealth: (signal: AbortSignal) => Promise<void>;
downloadFile: (path: string) => Promise<void>; downloadFile: (path: string) => Promise<void>;
submitResponseInput: (sessionId: string, text: string, delivery: AgentInputDelivery, id: string) => Promise<AgentResponseInput>; submitResponseInput: (sessionId: string, text: string, delivery: AgentInputDelivery, id: string) => Promise<AgentResponseInput>;
listResponseInputs: (sessionId: string) => Promise<AgentResponseInput[]>; listResponseInputs: (sessionId: string) => Promise<AgentResponseInput[]>;
@@ -191,6 +198,7 @@ export type AgentApiClient = {
requestId: string, requestId: string,
options: { sessionId: string; reply: AgentPermissionReply; message?: string } options: { sessionId: string; reply: AgentPermissionReply; message?: string }
) => Promise<void>; ) => Promise<void>;
getQuestionStatus: (sessionId: string, requestId: string) => Promise<{ status: "pending" | "answered" | "rejected" | "invalidated" }>;
replyQuestion: ( replyQuestion: (
requestId: string, requestId: string,
options: { sessionId: string; answers: string[][] } options: { sessionId: string; answers: string[][] }
@@ -239,8 +247,14 @@ export function createAgentApiClient(
return data as T; return data as T;
}; };
return { 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) { async downloadFile(path) {
const match = /^\/api\/v1(\/session\/[^/?#]+\/files\/mcp-[a-f0-9-]{36}\.(?:zip|xlsx))$/.exec(path); const match = /^\/api\/v1(\/session\/[^/?#]+\/files\/mcp-[a-f0-9-]{36}\.(?:zip|xlsx))$/.exec(path);
if (!match) throw new Error("无效的会话文件地址"); if (!match) throw new Error("无效的会话文件地址");
@@ -255,10 +269,14 @@ export function createAgentApiClient(
setTimeout(() => URL.revokeObjectURL(url), 1000); setTimeout(() => URL.revokeObjectURL(url), 1000);
}, },
async submitResponseInput(sessionId, text, delivery, id) { async submitResponseInput(sessionId, text, delivery, id) {
return requestJson<AgentResponseInput>("/responses", { const response = await request("/responses", {
method: "POST", headers: { "Content-Type": "application/json" }, method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: text, conversation: sessionId, stream: false, tjwater: { delivery, input_id: id } }) 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) { async listResponseInputs(sessionId) {
const result = await requestJson<{ items: AgentResponseInput[] }>(`/session/${encodeURIComponent(sessionId)}/inputs`); const result = await requestJson<{ items: AgentResponseInput[] }>(`/session/${encodeURIComponent(sessionId)}/inputs`);
@@ -384,7 +402,7 @@ export function createAgentApiClient(
if (!response.ok) { if (!response.ok) {
const text = await response.text(); const text = await response.text();
const data = text ? JSON.parse(text) : null; 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); 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) { async replyQuestion(requestId, options) {
await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reply`, { await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reply`, {
method: "POST", method: "POST",
@@ -505,7 +528,8 @@ async function fetchWithFallback(
path: string, path: string,
init?: RequestInit, init?: RequestInit,
getAccessToken?: AccessTokenProvider, getAccessToken?: AccessTokenProvider,
getProjectId?: () => Promise<string | undefined> getProjectId?: () => Promise<string | undefined>,
prefix = API_PATH
) { ) {
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)]; const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
let lastError: unknown; let lastError: unknown;
@@ -520,7 +544,7 @@ async function fetchWithFallback(
for (const baseUrl of orderedBaseUrls) { for (const baseUrl of orderedBaseUrls) {
try { try {
const response = await fetch(`${baseUrl}${API_PATH}${path}`, requestInit); const response = await fetch(`${baseUrl}${prefix}${path}`, requestInit);
if (response.ok) { if (response.ok) {
setActiveBaseUrl(baseUrl); setActiveBaseUrl(baseUrl);
return response; return response;
@@ -609,7 +633,7 @@ function normalizeTitle(value: unknown) {
} }
function readRunStatus(value: unknown): AgentRunStatus | undefined { 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 ? value
: undefined; : undefined;
} }
@@ -757,6 +781,7 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea
value === "execution_plan" || value === "execution_plan" ||
value === "permission_request" || value === "permission_request" ||
value === "permission_response" || value === "permission_response" ||
value === "interaction_request" ||
value === "question_request" || value === "question_request" ||
value === "question_response" || value === "question_response" ||
value === "tool_call" || 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"; 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); 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: { export function createResponseRequest(messages: UIMessage[], options: {
conversation?: string; model?: string; approvalMode?: string; capabilities?: string[]; 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 latest = [...messages].reverse().find((message) => message.role === "user");
const text = latest?.parts.filter((part) => part.type === "text").map((part) => part.text).join("\n").trim(); 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, input: [{ role: "user", content: [{ type: "input_text", text }] }], stream: true,
...(options.model ? { model: options.model } : {}), ...(options.model ? { model: options.model } : {}),
...(options.conversation ? { conversation: options.conversation } : {}), ...(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.triggerContext ? { trigger_context: options.triggerContext } : {}),
...(options.taskContext ? { task_context: options.taskContext } : {}), ...(options.taskContext ? { task_context: options.taskContext } : {}),
}, },
@@ -99,7 +100,7 @@ export function responsesToUiStream(stream: ReadableStream<Uint8Array>): Readabl
buffer += decoder.decode(); buffer += decoder.decode();
flushBlocks(controller); flushBlocks(controller);
if (buffer.trim()) parse(buffer, 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 { useState, type ReactNode } from "react";
import { Check, Copy, LoaderCircle } from "lucide-react"; import { Check, Copy, LoaderCircle, CircleAlert } from "lucide-react";
import { Badge } from "@/shared/ui/badge";
import { MessageAction, MessageActions } from "@/shared/ai-elements/message"; import { MessageAction, MessageActions } from "@/shared/ai-elements/message";
import { getAnswerPartsTranscript } from "../answer-ui/answer-presentation";
import type { AnswerUiPart } from "../answer-ui/contract"; import type { AnswerUiPart } from "../answer-ui/contract";
export function AgentChatMessageHeader({ export function AgentChatMessageHeader({ running }: { running: boolean }) {
running, if (!running) return null;
interactive
}: {
running: boolean;
interactive: boolean;
}) {
if (!running && !interactive) return null;
return ( return (
<div className="agent-chat-message-header"> <div className="agent-chat-message-header justify-start text-left">
{running ? ( <span
<span role="status"
role="status" className="inline-flex items-center gap-1.5 text-sm leading-5 text-muted-foreground"
className="ml-auto inline-flex items-center gap-1.5 text-[11px] text-muted-foreground" >
> <LoaderCircle size={14} className="motion-safe:animate-spin" aria-hidden="true" />
<LoaderCircle size={12} className="motion-safe:animate-spin" aria-hidden="true" /> 正在回复
正在回复 </span>
</span> </div>
) : interactive ? ( );
<Badge }
variant="secondary"
className="ml-auto border-0 px-2 py-0.5 text-[10px] font-medium shadow-none" 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";
</Badge>
) : null} 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> </div>
); );
} }
@@ -38,18 +51,16 @@ export function AgentChatMessageHeader({
export function AgentChatMessageActions({ parts }: { parts: AnswerUiPart[] }) { export function AgentChatMessageActions({ parts }: { parts: AnswerUiPart[] }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const text = parts const text = getAnswerPartsTranscript(parts);
.map((part) => (part.type === "text" ? part.text : part.block.fallback_text))
.join("\n\n");
if (!text.trim()) return null; if (!text.trim()) return null;
return ( return (
<MessageActions className="agent-chat-message-actions"> <MessageActions className="agent-chat-message-actions">
<MessageAction <MessageAction
tooltip={copied ? "回复已复制" : "复制回复"} tooltip={error ? "复制失败,点击重试" : copied ? "回复已复制" : "复制回复"}
label={copied ? "回复已复制" : "复制回复"} label={copied ? "回复已复制" : "复制回复"}
size="sm" 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 ? "回复已复制" : "复制回复"} aria-label={copied ? "回复已复制" : "复制回复"}
onClick={async () => { onClick={async () => {
try { try {
@@ -62,10 +73,18 @@ export function AgentChatMessageActions({ parts }: { parts: AnswerUiPart[] }) {
} }
}} }}
> >
{copied ? <Check size={13} aria-hidden="true" /> : <Copy size={13} aria-hidden="true" />} {error ? (
{copied ? "已复制" : "复制回复"} <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> </MessageAction>
<span role="status" className="text-[11px] text-muted-foreground"> <span role="status" className="sr-only">
{error ? "复制失败,请选择文字复制" : copied ? "已复制回复文字与交互内容摘要" : ""} {error ? "复制失败,请选择文字复制" : copied ? "已复制回复文字与交互内容摘要" : ""}
</span> </span>
</MessageActions> </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 type { AnswerUiMapTarget } from "../answer-ui/answer-ui-view";
import { AnswerPartsView } from "../answer-ui/answer-parts-view"; import { AnswerPartsView } from "../answer-ui/answer-parts-view";
import type { AnswerUiSelection } from "../answer-ui/answer-ui-interaction-context"; import type { AnswerUiSelection } from "../answer-ui/answer-ui-interaction-context";
import { ExpandableReading } from "@/shared/ui/reading-workspace"; import { ExpandableReading } from "@/shared/ui/reading-workspace";
import { getAnswerPresentation } from "../answer-ui/answer-presentation"; import { canExpandAnswer, getAnswerPartsTranscript, getAnswerPresentation } from "../answer-ui/answer-presentation";
import { Disclosure } from "@/shared/ui/disclosure"; 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 { import {
ArrowUp,
AudioLines,
CheckCircle2, CheckCircle2,
ChevronDown, ChevronDown,
ChevronsUpDown,
ChevronLeft, ChevronLeft,
History, History,
LoaderCircle, LoaderCircle,
Mic,
Pause, Pause,
Play, Play,
Plus, Plus,
Quote, Quote,
SendHorizontal,
Shield, Shield,
ShieldCheck, ShieldCheck,
Square, Square,
@@ -34,6 +41,7 @@ import {
useRef, useRef,
useState, useState,
type Dispatch, type Dispatch,
type Ref,
type ReactNode, type ReactNode,
type SetStateAction type SetStateAction
} from "react"; } from "react";
@@ -73,7 +81,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shar
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { StatusDot, type StatusTone } from "@/shared/ui/status"; import { StatusDot, type StatusTone } from "@/shared/ui/status";
import type { AgentChatSessionSummary, AgentInputDelivery, AgentResponseInput } from "../api/client"; 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 { import {
agentLayoutTransition, agentLayoutTransition,
agentPanelItemVariants, agentPanelItemVariants,
@@ -86,7 +94,7 @@ import { AgentHistoryPanel } from "./agent-history-panel";
import { AgentMessageInbox } from "./agent-message-inbox"; import { AgentMessageInbox } from "./agent-message-inbox";
import { AgentPendingInputs } from "./agent-pending-inputs"; import { AgentPendingInputs } from "./agent-pending-inputs";
import type { ComponentProps } from "react"; 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 { AgentUiEnvelopeRenderer } from "./agent-ui-envelope-renderer";
import { useAgentSpeechRecognition, useAgentSpeechSynthesis } from "../hooks/use-agent-voice"; import { useAgentSpeechRecognition, useAgentSpeechSynthesis } from "../hooks/use-agent-voice";
import { import {
@@ -142,6 +150,7 @@ type AgentCommandPanelProps = {
promptSuggestions?: Array<{ question: string; sourceLabel?: string }>; promptSuggestions?: Array<{ question: string; sourceLabel?: string }>;
promptSuggestionsLoading?: boolean; promptSuggestionsLoading?: boolean;
promptSuggestionsUnavailable?: boolean; promptSuggestionsUnavailable?: boolean;
onRetryPromptSuggestions?: () => void;
activeSessionId?: string | null; activeSessionId?: string | null;
qaOnly?: boolean; qaOnly?: boolean;
statusLabel?: string; statusLabel?: string;
@@ -165,6 +174,7 @@ type AgentCommandPanelProps = {
onMoveSessionToTopic?: (sessionId: string, topicId: string | null) => Promise<void>; onMoveSessionToTopic?: (sessionId: string, topicId: string | null) => Promise<void>;
approvalMode?: AgentApprovalMode; approvalMode?: AgentApprovalMode;
uiResults?: AgentUiResult[]; uiResults?: AgentUiResult[];
interactionRequests?: InteractionRequestsController;
reviewPanel?: ReactNode; reviewPanel?: ReactNode;
messageInbox?: ComponentProps<typeof AgentMessageInbox>; messageInbox?: ComponentProps<typeof AgentMessageInbox>;
workspaceContext?: AgentWorkspaceContextItem[]; workspaceContext?: AgentWorkspaceContextItem[];
@@ -207,6 +217,7 @@ export function AgentCommandPanel({
promptSuggestions = [], promptSuggestions = [],
promptSuggestionsLoading = false, promptSuggestionsLoading = false,
promptSuggestionsUnavailable = false, promptSuggestionsUnavailable = false,
onRetryPromptSuggestions,
activeSessionId, activeSessionId,
qaOnly = false, qaOnly = false,
connectionStatus = "connecting", connectionStatus = "connecting",
@@ -226,6 +237,7 @@ export function AgentCommandPanel({
topicControls, historyTopicId, onHistoryTopicChange, onMoveSessionToTopic, topicControls, historyTopicId, onHistoryTopicChange, onMoveSessionToTopic,
approvalMode = "request", approvalMode = "request",
uiResults = [], uiResults = [],
interactionRequests,
reviewPanel, reviewPanel,
messageInbox, messageInbox,
prompt, prompt,
@@ -253,6 +265,7 @@ export function AgentCommandPanel({
const [historySelectionError, setHistorySelectionError] = useState<string | undefined>(); const [historySelectionError, setHistorySelectionError] = useState<string | undefined>();
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null); const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
const [scrollRequestId, setScrollRequestId] = useState(0); const [scrollRequestId, setScrollRequestId] = useState(0);
const [submissionError, setSubmissionError] = useState<string | null>(null);
const prefersReducedMotion = useReducedMotion(); const prefersReducedMotion = useReducedMotion();
const historyTriggerRef = useRef<HTMLButtonElement | null>(null); const historyTriggerRef = useRef<HTMLButtonElement | null>(null);
const promptRestoreGenerationRef = useRef(0); const promptRestoreGenerationRef = useRef(0);
@@ -261,11 +274,13 @@ export function AgentCommandPanel({
const desktopFloating = presentation === "desktop-floating"; const desktopFloating = presentation === "desktop-floating";
const floatingPresentation = desktopFloating; const floatingPresentation = desktopFloating;
const hasConversation = messages.length > 0 || streaming || Boolean(messageInbox?.messages.length); const hasConversation = messages.length > 0 || streaming || Boolean(messageInbox?.messages.length);
// Existing task state and loading/errors stay visible even before a first message. // Task discussions keep their reference and composer layout before the first message.
const centeredComposer = !hasConversation && !loadingSessionId && !reviewPanel const centeredComposer = !hasConversation && !loadingSessionId && !reviewPanel && !promptReference
&& uiResults.length === 0 && !messageInbox?.loading && !messageInbox?.error; && uiResults.length === 0 && !messageInbox?.loading && !messageInbox?.error;
const showStartSuggestions = centeredComposer && !qaOnly && !promptReference; const showStartSuggestions = centeredComposer && !qaOnly && !promptReference;
const trimmedPrompt = prompt.trim(); const trimmedPrompt = prompt.trim();
const isStopAction = streaming && !trimmedPrompt;
const acceptsAdditionalInput = streaming || Boolean(interactionRequests?.awaitingInput);
useEffect(() => { useEffect(() => {
if (!promptFocusRequest) return; if (!promptFocusRequest) return;
const frame = requestAnimationFrame(() => { const frame = requestAnimationFrame(() => {
@@ -315,6 +330,7 @@ export function AgentCommandPanel({
useEffect(() => { useEffect(() => {
stopSpeech(); stopSpeech();
setSubmissionError(null);
}, [activeSessionId, stopSpeech]); }, [activeSessionId, stopSpeech]);
useEffect(() => { useEffect(() => {
@@ -325,17 +341,22 @@ export function AgentCommandPanel({
if (!nextPrompt) { if (!nextPrompt) {
return; 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; const restoreGeneration = promptRestoreGenerationRef.current + 1;
promptRestoreGenerationRef.current = restoreGeneration; promptRestoreGenerationRef.current = restoreGeneration;
onPromptChange(""); onPromptChange("");
stopListening(); stopListening();
setScrollRequestId((requestId) => requestId + 1); setScrollRequestId((requestId) => requestId + 1);
const submittedReference = contentReference;
const message = submittedReference
? `${nextPrompt}\n\n引用内容:${submittedReference.title}\n${submittedReference.text}`
: nextPrompt;
onContentReferenceChange?.(undefined); onContentReferenceChange?.(undefined);
void Promise.resolve(onSubmitPrompt(message)).catch(() => { void Promise.resolve().then(() => onSubmitPrompt(message)).catch(() => {
if (!mountedRef.current || promptRestoreGenerationRef.current !== restoreGeneration) { if (!mountedRef.current || promptRestoreGenerationRef.current !== restoreGeneration) {
return; return;
} }
@@ -375,6 +396,7 @@ export function AgentCommandPanel({
<MotionConfig reducedMotion="user"> <MotionConfig reducedMotion="user">
{historyPanel} {historyPanel}
<motion.aside <motion.aside
onCopy={copySelectedText}
aria-label="Agent 命令面板" aria-label="Agent 命令面板"
data-agent-presentation={presentation} data-agent-presentation={presentation}
data-agent-starting={!hasConversation} 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"> <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 className="shrink-0 whitespace-nowrap text-sm font-semibold leading-5 text-foreground">供水助手</p>
<p role="status" title={agentConnectionMetadata[connectionStatus].label} <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"} /> <StatusDot tone={agentConnectionMetadata[connectionStatus].tone} activity={connectionStatus === "connecting" ? "live" : "static"} />
<span>{agentConnectionMetadata[connectionStatus].label}</span> <span>{agentConnectionMetadata[connectionStatus].label}</span>
</p> </p>
@@ -527,6 +549,7 @@ export function AgentCommandPanel({
messages={messages} messages={messages}
streaming={streaming} streaming={streaming}
streamRenderState={streamRenderState} streamRenderState={streamRenderState}
interactionRequests={interactionRequests}
onReplyPermission={onReplyPermission} onReplyPermission={onReplyPermission}
onReplyQuestion={onReplyQuestion} onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion} onRejectQuestion={onRejectQuestion}
@@ -550,6 +573,8 @@ export function AgentCommandPanel({
{!qaOnly ? <AgentUiEnvelopeRenderer results={uiResults} /> : null} {!qaOnly ? <AgentUiEnvelopeRenderer results={uiResults} /> : null}
{reviewPanel} {reviewPanel}
{interactionRequests ? <AgentInteractionRequests controller={interactionRequests}
representedMessageIds={new Set(messages.filter((message) => message.role === "user").map((message) => message.id))} /> : null}
{messageInbox ? <AgentMessageInbox {...messageInbox} /> : null} {messageInbox ? <AgentMessageInbox {...messageInbox} /> : null}
</ConversationContent> </ConversationContent>
<AgentConversationScrollManager <AgentConversationScrollManager
@@ -574,7 +599,7 @@ export function AgentCommandPanel({
className={cn( className={cn(
"agent-panel-band relative", "agent-panel-band relative",
floatingPresentation 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" : "border-t border-[var(--surface-divider)] p-3"
)} )}
> >
@@ -590,11 +615,11 @@ export function AgentCommandPanel({
layoutDependency={centeredComposer} layoutDependency={centeredComposer}
transition={{ layout: agentLayoutTransition }} transition={{ layout: agentLayoutTransition }}
> >
<AgentPendingInputs presentation={presentation} inputs={pendingInputs}
onDeliveryChange={onPendingInputDeliveryChange}
onEdit={onPendingInputEdit} onCancel={onPendingInputCancel} />
<PromptInput <PromptInput
className={cn( 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"
"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"
)}
onSubmit={(message) => { onSubmit={(message) => {
const nextPrompt = message.text.trim(); const nextPrompt = message.text.trim();
if (nextPrompt) { if (nextPrompt) {
@@ -602,29 +627,34 @@ export function AgentCommandPanel({
} }
}} }}
> >
<AgentPendingInputs presentation={presentation} inputs={pendingInputs} <PromptInputBody className="agent-panel-input-body">
onDeliveryChange={onPendingInputDeliveryChange} {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">
onEdit={onPendingInputEdit} onCancel={onPendingInputCancel} /> <Quote size={14} className="mt-2 shrink-0 text-muted-foreground" aria-hidden />
<PromptInputBody className={!desktopFloating ? "agent-panel-input-body form-field" : undefined}> <details className="min-w-0 flex-1 py-1.5">
{contentReference ? <div role="group" aria-label="已选内容引用" className="m-3 mb-0 rounded-lg border bg-muted/30 p-2.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">
<div className="flex items-start gap-2"> <span className="min-w-0 truncate font-medium">{contentReference.title}</span>
<Quote size={14} className="mt-1 shrink-0 text-muted-foreground" aria-hidden /> <span className="shrink-0 text-muted-foreground">查看引用内容</span>
<div className="min-w-0 flex-1 text-xs leading-5"> </summary>
<p className="font-medium">{contentReference.title}</p> <p className="mt-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words text-muted-foreground">{contentReference.text}</p>
<details><summary className="cursor-pointer text-muted-foreground">查看引用内容</summary> <div className="mt-2 flex flex-wrap gap-1" aria-label="引用内容的分析方向">
<p className="max-h-32 overflow-y-auto whitespace-pre-wrap break-words text-muted-foreground">{contentReference.text}</p> {["解释这些内容", "比较差异与关联", "核查依据与缺口"].map((intent) => <Button key={intent} type="button" variant="outline" size="sm" className="h-8 text-sm"
</details> onClick={() => {
promptRestoreGenerationRef.current += 1;
onPromptChange((current) => current.trim() ? `${current}\n${intent}` : intent);
setSubmissionError(null);
promptTextareaRef.current?.focus();
}}>{intent}</Button>)}
</div> </div>
<Button type="button" size="sm" variant="ghost" className="h-8 text-xs" aria-label="移除内容引用" </details>
onClick={() => onContentReferenceChange?.(undefined)}>移除</Button> <Button type="button" size="icon" variant="ghost" className="size-8 shrink-0" aria-label="移除内容引用" title="移除内容引用"
</div> onClick={() => { onContentReferenceChange?.(undefined); setSubmissionError(null); }}><X size={14} aria-hidden /></Button>
</div> : null} </div> : null}
{promptReference ? ( {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)]"> <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-4 shrink-0" /> <Quote aria-hidden="true" className="mt-0.5 size-3.5 shrink-0" />
<div className="min-w-0 text-xs leading-relaxed"> <div className="min-w-0 flex-1 break-words">
<p className="break-words font-medium text-[var(--ink)]">{promptReference.title}</p> <span data-agent-reference-title className="font-medium text-foreground">{promptReference.title}</span>
{promptReference.detail ? <p className="break-words">{promptReference.detail}</p> : null} {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>
</div> </div>
) : null} ) : null}
@@ -632,20 +662,22 @@ export function AgentCommandPanel({
ref={promptTextareaRef} ref={promptTextareaRef}
value={prompt} value={prompt}
onChange={(event) => { onChange={(event) => {
setSubmissionError(null);
promptRestoreGenerationRef.current += 1; promptRestoreGenerationRef.current += 1;
onPromptChange(event.currentTarget.value); 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 ?? "询问供水问题,或描述需要处理的任务"} aria-label={promptReference?.placeholder ?? "询问供水问题,或描述需要处理的任务"}
placeholder={promptReference?.placeholder ?? (streaming ? "继续补充,或安排下一条消息…" : "询问供水问题,或描述需要处理的任务")} placeholder={promptReference?.placeholder ?? (streaming ? "继续补充,或安排下一条消息…" : "询问供水问题,或描述需要处理的任务")}
/> />
</PromptInputBody> </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"> <PromptInputFooter className="agent-panel-input-tools justify-between gap-2">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-1">
{!qaOnly ? <ApprovalModeSelect presentation={presentation} value={approvalMode} disabled={streaming} onValueChange={onApprovalModeChange} /> : <span className="px-1 text-[11px] text-[var(--ink-muted)]">任务协作问答</span>} <ApprovalModeSelect presentation={presentation} value={approvalMode} disabled={streaming} onValueChange={onApprovalModeChange} />
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-1">
{streaming ? <InputDeliverySelect presentation={presentation} label="运行中消息发送方式" {acceptsAdditionalInput ? <InputDeliverySelect presentation={presentation} label="消息发送方式"
value={inputDelivery} onValueChange={onInputDeliveryChange} /> : <AgentModelSelect value={inputDelivery} onValueChange={onInputDeliveryChange} /> : <AgentModelSelect
presentation={presentation} presentation={presentation}
models={modelOptions} models={modelOptions}
@@ -662,20 +694,18 @@ export function AgentCommandPanel({
/> />
) : null} ) : null}
<PromptInputSubmit <PromptInputSubmit
aria-label={streaming && !trimmedPrompt ? "停止 Agent 指令" : streaming ? inputDelivery === "steer" ? "引导当前 Agent 任务" : "将消息加入 Agent 队列" : "发送 Agent 指令"} aria-label={isStopAction ? "停止 Agent 指令" : acceptsAdditionalInput ? inputDelivery === "steer" ? "引导当前 Agent 任务" : "将消息加入 Agent 队列" : "发送 Agent 指令"}
title={streaming && !trimmedPrompt ? "停止 Agent 指令" : streaming ? inputDelivery === "steer" ? "引导当前 Agent 任务" : "将消息加入 Agent 队列" : "发送 Agent 指令"} title={isStopAction ? "停止 Agent 指令" : acceptsAdditionalInput ? inputDelivery === "steer" ? "引导当前 Agent 任务" : "将消息加入 Agent 队列" : "发送 Agent 指令"}
className="agent-panel-primary-icon" className="agent-panel-primary-icon size-10 shrink-0"
data-agent-action={isStopAction ? "stop" : "send"}
disabled={!streaming && !trimmedPrompt} disabled={!streaming && !trimmedPrompt}
status={streaming && !trimmedPrompt ? "streaming" : "ready"} status={isStopAction ? "streaming" : "ready"}
onStop={onStopPrompt} onStop={onStopPrompt}
> >
{streaming && !trimmedPrompt ? <Square size={15} aria-hidden="true" /> : ( {isStopAction ? <Square className="size-4" aria-hidden /> : <ArrowUp
<SendHorizontal className="agent-panel-send-icon size-5"
size={15} aria-hidden="true"
style={{ transform: "rotate(-45deg)" }} />}
aria-hidden="true"
/>
)}
</PromptInputSubmit> </PromptInputSubmit>
</div> </div>
</PromptInputFooter> </PromptInputFooter>
@@ -704,7 +734,12 @@ export function AgentCommandPanel({
</TooltipContent> </TooltipContent>
</Tooltip> </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> </Suggestions>
</TooltipProvider> </TooltipProvider>
@@ -744,7 +779,7 @@ function VoiceInputButton({
aria-pressed={isListening} aria-pressed={isListening}
title={label} title={label}
className={cn( 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", "bg-transparent shadow-none",
"transition-[color,background-color,border-color,box-shadow,transform] duration-200", "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", "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 ? ( {isListening ? (
<motion.span <motion.span
data-slot="voice-input-pulse" 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" aria-hidden="true"
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, scale: 0.9 }}
animate={ animate={
@@ -816,7 +851,7 @@ function VoiceInputButton({
</motion.span> </motion.span>
) : ( ) : (
<motion.span <motion.span
key="voice-microphone" key="voice-idle"
className="grid place-items-center" className="grid place-items-center"
aria-hidden="true" aria-hidden="true"
initial={{ opacity: 0, scale: 0.72, y: 2 }} initial={{ opacity: 0, scale: 0.72, y: 2 }}
@@ -824,10 +859,9 @@ function VoiceInputButton({
exit={{ opacity: 0, scale: 0.72, y: -2 }} exit={{ opacity: 0, scale: 0.72, y: -2 }}
transition={{ duration: 0.16, ease: "easeOut" }} transition={{ duration: 0.16, ease: "easeOut" }}
> >
<Mic <AudioLines
size={15} strokeWidth={2}
strokeWidth={2.15} className="size-4 transition-transform duration-200 group-hover:scale-105"
className="transition-transform duration-200 group-hover:scale-105"
/> />
</motion.span> </motion.span>
)} )}
@@ -840,6 +874,10 @@ function VoiceInputButton({
const AGENT_SELECT_ITEM_CLASS_NAME = 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"; "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 }: { function InputDeliverySelect({ presentation, label, value, onValueChange }: {
presentation: AgentCommandPanelPresentation; presentation: AgentCommandPanelPresentation;
label: string; label: string;
@@ -850,10 +888,10 @@ function InputDeliverySelect({ presentation, label, value, onValueChange }: {
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="sm" aria-label={label} <Button type="button" variant="ghost" size="sm" aria-label={label}
title={value === "queue" ? "当前任务结束后处理" : "在下一步补充当前任务"} 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}> disabled={!onValueChange}>
{value === "queue" ? "排队" : "引导"} {value === "queue" ? "排队" : "引导"}
<ChevronDown className="size-3 opacity-60" aria-hidden="true" /> <AgentToolbarCaret />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent data-agent-presentation={presentation} side="top" align="end" sideOffset={6} <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]) => ( {([ ["steer", "引导当前任务"], ["queue", "排队处理"] ] as const).map(([delivery, text]) => (
<DropdownMenuRadioItem key={delivery} value={delivery} <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)]")}> value === delivery && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]")}>
{text} {text}
</DropdownMenuRadioItem> </DropdownMenuRadioItem>
@@ -893,17 +931,18 @@ function ApprovalModeSelect({
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" 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} disabled={disabled || !onValueChange}
aria-label="权限批准模式" aria-label="权限批准模式"
title={requestApproval ? "请求批准" : "始终允许"}
> >
{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> <span>{requestApproval ? "请求批准" : "始终允许"}</span>
<ChevronsUpDown size={12} className="opacity-50" aria-hidden="true" /> <AgentToolbarCaret />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
@@ -1130,7 +1169,7 @@ function AgentModelSelect({
if (!models.length) { if (!models.length) {
return ( 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" type="button"
variant="ghost" variant="ghost"
size="sm" 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} disabled={!onValueChange}
aria-label="选择 Agent 模型" aria-label="选择 Agent 模型"
aria-description={`当前模型:${selectedModel?.label ?? "模型"}`} aria-description={`当前模型:${selectedModel?.label ?? "模型"}`}
@@ -1149,9 +1188,9 @@ function AgentModelSelect({
> >
<span className="flex min-w-0 flex-1 items-center gap-1"> <span className="flex min-w-0 flex-1 items-center gap-1">
<ModelIcon model={selectedModel} /> <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> </span>
<ChevronsUpDown size={12} className="shrink-0 opacity-50" aria-hidden="true" /> <AgentToolbarCaret />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
@@ -1207,13 +1246,77 @@ const agentConnectionMetadata = {
function ModelIcon({ model }: { model?: AgentModelOption }) { function ModelIcon({ model }: { model?: AgentModelOption }) {
const Icon = model?.icon === "bolt" ? Zap : Sparkles; 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, presentation,
messages,
streaming,
streamRenderState, streamRenderState,
onReplyPermission, onReplyPermission,
onReplyQuestion, onReplyQuestion,
@@ -1228,147 +1331,170 @@ function BackendMessageList({
qaOnly, qaOnly,
onLocateAnswerFeatures, onLocateAnswerFeatures,
onSubmitAnswerForm, onSubmitAnswerForm,
onAsk onAsk,
}: { interactionRequests,
presentation: AgentCommandPanelPresentation; }: BackendMessageListProps & {
messages: AgentChatMessage[]; ref?: Ref<HTMLDivElement>;
streaming: boolean; message: AgentChatMessage;
streamRenderState: AgentStreamRenderState; processMessages: AgentChatMessage[];
onReplyPermission?: ( running: boolean;
permission: AgentPermissionRequest, reply?: InteractionRequestsController["entries"][number];
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;
}) { }) {
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 ( return (
<motion.div <motion.div
className="agent-chat-message-list flex w-full flex-col gap-6" ref={ref}
variants={agentPanelListVariants} className={cn("w-full", message.role === "user" && "flex justify-end")}
initial="initial"
animate="animate" animate="animate"
exit="exit"
variants={agentPanelItemVariants}
> >
<AnimatePresence initial={false} mode="popLayout"> <Message
{messages.map((message, index) => { data-agent-message-id={message.id}
const answer = getAnswerPresentation(message); from={message.role}
const needsReply = Boolean( className={message.role === "user" ? "max-w-[92%]" : "max-w-full"}
message.permissions?.some((item) => ["pending", "submitting", "error"].includes(item.status)) || >
message.questions?.some((item) => ["pending", "submitting", "error"].includes(item.status)) <MessageContent
); className={cn(
return ( 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 <motion.div
key={message.id} ref={transition.root}
className={cn("w-full", message.role === "user" && "flex justify-end")} initial={false}
initial="initial" animate={transition.controls}
animate="animate" data-answer-phase={transition.phase}
exit="exit" className="space-y-3"
variants={agentPanelItemVariants}
> >
<Message {message.role === "assistant" ? <AgentChatMessageHeader running={!completed} /> : null}
data-agent-message-id={message.id} {message.role === "assistant" && message.taskContext ? (
from={message.role} <p className="text-sm leading-5 text-[var(--ink-muted)]">
className={message.role === "user" ? "max-w-[92%]" : "max-w-full"} 依据:
> {message.taskContext.execution_generation == null
<MessageContent ? "历史资料 · 轮次未记录"
className={cn( : `第 ${message.taskContext.execution_generation} 轮`}
message.role === "user" && {message.taskContext.report_revision == null
"agent-panel-user-message rounded-2xl px-3 py-3 text-slate-800", ? " · 尚无报告"
message.role === "assistant" && : ` · 报告第 ${message.taskContext.report_revision} 版`}
"agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-700" </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"> <AgentMessageProcessContent
{message.role === "assistant" ? ( messages={processMessages}
<AgentChatMessageHeader qaOnly={qaOnly}
running={streaming && index === messages.length - 1} runningMessageId={!completed ? message.id : undefined}
interactive={answer.report.some((part) => part.type === "answer-ui")} />
/> </AgentProcessDisclosure>
) : null} ) : null}
{message.role === "assistant" && message.taskContext ? <p className="text-xs leading-5 text-[var(--ink-muted)]"> {message.role === "assistant" && completed && hasOutput && !hasProcess ? (
依据:{message.taskContext.execution_generation == null ? "历史资料 · 轮次未记录" : `第 ${message.taskContext.execution_generation} 轮`} <AgentChatMessageToolbar speech={speech} copy={copy} />
{message.taskContext.report_revision == null ? " · 尚无报告" : ` · 报告第 ${message.taskContext.report_revision} 版`} ) : null}
</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>
</motion.div> </motion.div>
); </MessageContent>
})} </Message>
</AnimatePresence>
</motion.div> </motion.div>
); );
} }
@@ -1385,11 +1511,7 @@ function AgentSpeechMessage({
content, content,
disabled, disabled,
isSupported, isSupported,
speechState,
onSpeak, onSpeak,
onPause,
onResume,
onStop,
children children
}: { }: {
presentation: AgentCommandPanelPresentation; presentation: AgentCommandPanelPresentation;
@@ -1397,11 +1519,7 @@ function AgentSpeechMessage({
content: string; content: string;
disabled: boolean; disabled: boolean;
isSupported: boolean; isSupported: boolean;
speechState: AgentSpeechState;
onSpeak: (messageId: string, text: string, options?: AgentSpeakOptions) => Promise<void> | void; onSpeak: (messageId: string, text: string, options?: AgentSpeakOptions) => Promise<void> | void;
onPause: () => void;
onResume: () => void;
onStop: () => void;
children: ReactNode; children: ReactNode;
}) { }) {
const contentRef = useRef<HTMLDivElement | null>(null); const contentRef = useRef<HTMLDivElement | null>(null);
@@ -1534,57 +1652,62 @@ function AgentSpeechMessage({
document.body document.body
) )
: null} : null}
</div>
);
}
{isSupported && !disabled ? ( function AgentMessageSpeechControls({
<div className="mt-2 flex min-h-8 items-center gap-1 border-t border-[var(--surface-divider)] pt-1.5"> speechState,
{speechState === "idle" ? ( onSpeak,
<SpeechIconButton onPause,
label="语音播放" onResume,
onClick={() => void Promise.resolve(onSpeak(messageId, speechText))} onStop
> }: {
<Volume2 size={15} aria-hidden="true" /> speechState: AgentSpeechState;
</SpeechIconButton> onSpeak: () => void;
) : null} onPause: () => void;
{speechState === "loading" ? ( onResume: () => void;
<> onStop: () => void;
<SpeechIconButton label="正在生成语音" disabled> }) {
<LoaderCircle size={15} className="animate-spin text-[var(--action-blue-hover)]" aria-hidden="true" /> return (
</SpeechIconButton> <div className="flex shrink-0 items-center gap-0.5">
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}> {speechState === "idle" ? (
<Square size={14} fill="currentColor" aria-hidden="true" /> <SpeechIconButton label="语音播放" onClick={onSpeak}>
</SpeechIconButton> <Volume2 size={16} aria-hidden="true" />
</> <span className={AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME}>语音播放</span>
) : null} </SpeechIconButton>
{speechState === "playing" ? ( ) : null}
<> {speechState === "loading" ? (
<SpeechIconButton label="暂停语音播放" tone="primary" onClick={onPause}> <>
<Pause size={15} fill="currentColor" aria-hidden="true" /> <SpeechIconButton label="正在生成语音" disabled>
</SpeechIconButton> <LoaderCircle size={16} className="motion-safe:animate-spin" aria-hidden="true" />
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}> </SpeechIconButton>
<Square size={14} fill="currentColor" aria-hidden="true" /> <SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
</SpeechIconButton> <Square size={16} aria-hidden="true" />
</> </SpeechIconButton>
) : null} </>
{speechState === "paused" ? ( ) : null}
<> {speechState === "playing" ? (
<SpeechIconButton label="继续语音播放" tone="primary" onClick={onResume}> <>
<Play size={15} fill="currentColor" aria-hidden="true" /> <SpeechIconButton label="暂停语音播放" tone="primary" onClick={onPause}>
</SpeechIconButton> <Pause size={16} aria-hidden="true" />
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}> <span className={AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME}>暂停</span>
<Square size={14} fill="currentColor" aria-hidden="true" /> </SpeechIconButton>
</SpeechIconButton> <SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
</> <Square size={16} aria-hidden="true" />
) : null} </SpeechIconButton>
<span className="text-[11px] text-[var(--ink-muted)]"> </>
{speechState === "idle" ) : null}
? "语音播放" {speechState === "paused" ? (
: speechState === "loading" <>
? "正在生成" <SpeechIconButton label="继续语音播放" tone="primary" onClick={onResume}>
: speechState === "playing" <Play size={16} aria-hidden="true" />
? "播放中" <span className={AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME}>继续</span>
: "已暂停"} </SpeechIconButton>
</span> <SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
</div> <Square size={16} aria-hidden="true" />
</SpeechIconButton>
</>
) : null} ) : null}
</div> </div>
); );
@@ -1610,10 +1733,11 @@ function SpeechIconButton({
title={label} title={label}
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
size="icon-sm" size="sm"
variant="ghost" variant="ghost"
className={cn( 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 === "primary" && "text-[var(--action-blue-hover)]",
tone === "danger" && "status-tone-danger text-[var(--status-foreground)]" 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 { import {
CheckCircle2, CheckCircle2,
ChevronDown, ChevronDown,
@@ -40,7 +42,6 @@ import type {
AgentTodoUpdate AgentTodoUpdate
} from "../types"; } from "../types";
type ProgressStatus = AgentChatProgress["status"];
type TodoStatus = AgentTodoUpdate["todos"][number]["status"]; type TodoStatus = AgentTodoUpdate["todos"][number]["status"];
type PermissionStatus = AgentPermissionRequest["status"]; type PermissionStatus = AgentPermissionRequest["status"];
type QuestionStatus = AgentQuestionRequest["status"]; type QuestionStatus = AgentQuestionRequest["status"];
@@ -53,22 +54,6 @@ type AgentStatusMeta = {
activity?: StatusActivity; 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> = { const todoStatusMeta: Record<TodoStatus, AgentStatusMeta> = {
completed: { completed: {
label: "完成", label: "完成",
@@ -100,6 +85,8 @@ const permissionStatusMeta: Record<PermissionStatus, AgentStatusMeta> = {
}; };
const questionStatusMeta: Record<QuestionStatus, AgentStatusMeta> = { const questionStatusMeta: Record<QuestionStatus, AgentStatusMeta> = {
checking: { label: "核对中", tone: "info" },
invalidated: { label: "已失效", tone: "neutral" },
answered: { label: "已回答", tone: "success" }, answered: { label: "已回答", tone: "success" },
rejected: { label: "已跳过", tone: "neutral" }, rejected: { label: "已跳过", tone: "neutral" },
error: { label: "失败", tone: "danger" }, error: { label: "失败", tone: "danger" },
@@ -208,28 +195,21 @@ function AnimatedDetailItem({ children }: { children: ReactNode }) {
} }
function ProgressList({ progress, running }: { progress: AgentChatProgress[]; running: boolean }) { 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 listId = useId();
const compact = running && !expanded; const visibleProgress = expanded ? progress : progress.slice(-STREAMING_PROGRESS_LIMIT);
const visibleProgress = expanded
? progress
: running
? progress.slice(-STREAMING_PROGRESS_LIMIT)
: [];
const listMode = expanded ? "expanded" : running ? "streaming" : "collapsed";
return ( return (
<div className="agent-panel-nested rounded-xl p-2.5" data-agent-progress> <div className="min-w-0" data-agent-progress>
<div className="flex min-h-8 items-center gap-2 px-0.5"> {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" /> <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)]"> <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" />
<StatusDot tone="info" activity="live" aria-hidden="true" /> <span>{expanded ? "全部" : "最近"}</span>
) : null}
<span>{running ? (expanded ? "全部" : "最近") : "步骤"}</span>
<span className="font-mono tabular-nums text-slate-600"> <span className="font-mono tabular-nums text-slate-600">
{expanded || !running {expanded
? progress.length ? progress.length
: Math.min(progress.length, STREAMING_PROGRESS_LIMIT)} : Math.min(progress.length, STREAMING_PROGRESS_LIMIT)}
<span className="px-0.5 text-slate-300">/</span> <span className="px-0.5 text-slate-300">/</span>
@@ -254,14 +234,14 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
aria-hidden="true" aria-hidden="true"
/> />
</button> </button>
</div> </div> : null}
<AnimatePresence initial={false} mode="wait"> <AnimatePresence initial={false} mode="wait">
{visibleProgress.length ? ( {visibleProgress.length ? (
<motion.ol <motion.ol
key={listMode} key="progress"
id={listId} id={listId}
aria-label={expanded ? "全部执行进度" : "最近执行进度"} 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 }} initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }} animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }} exit={{ height: 0, opacity: 0 }}
@@ -272,63 +252,33 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
<motion.li <motion.li
key={item.id} key={item.id}
className={cn( className={cn(
"grid grid-cols-[16px_minmax(0,1fr)_auto] items-start gap-2 text-xs", "items-start gap-2 text-sm",
compact && "h-12 overflow-hidden" item.phase === "tool" ? "block min-w-0" : "grid grid-cols-[24px_minmax(0,1fr)]"
)} )}
initial={{ opacity: 0, y: 4 }} initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -2 }} exit={{ opacity: 0, y: -2 }}
transition={{ duration: 0.16, ease: agentEase }} transition={{ duration: 0.16, ease: agentEase }}
> >
<StatusDot {item.phase === "tool" ? <AgentToolProgress item={item} /> : <>
tone={progressStatusMeta[item.status].tone} <AgentProgressStatusIcon status={item.status} />
activity={progressStatusMeta[item.status].activity}
size="md"
className="mt-1"
/>
<span className="min-w-0"> <span className="min-w-0">
<span <span
className={cn( className="block break-words font-medium leading-6 text-foreground"
"block font-semibold text-slate-800",
compact ? "truncate" : "break-words"
)}
title={item.title} title={item.title}
> >
{item.title} {item.title}
</span> </span>
{item.detail ? ( {item.detail ? (
<span <span
className={cn( className="block break-words leading-5 text-muted-foreground"
"block leading-5 text-[var(--ink-muted)]",
compact ? "truncate" : "break-words"
)}
title={item.detail} title={item.detail}
> >
{item.detail} {item.detail}
</span> </span>
) : null} ) : null}
</span> </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> </motion.li>
))} ))}
</AnimatePresence> </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>;
+9 -2
View File
@@ -10,7 +10,8 @@ export type {
AgentWorkspaceContextItem AgentWorkspaceContextItem
} from "./components/agent-command-panel"; } from "./components/agent-command-panel";
export { AgentPersona } from "./components/agent-persona"; 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 { useAgentMessageInbox } from "./hooks/use-agent-message-inbox";
export type { AgentMailboxMessage } from "./api/agent-messages"; export type { AgentMailboxMessage } from "./api/agent-messages";
export type { export type {
@@ -96,11 +97,17 @@ export { parseFrontendChart } from "./frontend-action/chart";
export type { AnswerUiMapTarget } from "./answer-ui/answer-ui-view"; 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 { AnswerUiView } from "./answer-ui/answer-ui-view";
export { parseAnswerUiBlock, reportUiBlocksSchema } from "./answer-ui/contract"; 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 { AnswerUiBlock, AnswerUiPart, StoredReportUiBlock } from "./answer-ui/contract";
export type { AnswerUiSelection } from "./answer-ui/answer-ui-interaction-context"; export type { AnswerUiSelection } from "./answer-ui/answer-ui-interaction-context";
export type { AgentSessionPage, AgentHistoryMessagePage, AgentMessagePagination } from "./api/client"; export type { AgentSessionPage, AgentHistoryMessagePage, AgentMessagePagination } from "./api/client";
export type { AgentRunStatus } 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;
}
+9
View File
@@ -31,6 +31,15 @@ describe("agent session state", () => {
expect(next[0]).toMatchObject({ id: "tool-1", status: "completed", title: "工具完成", durationMs: 40 }); 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", () => { it("normalizes todo updates", () => {
expect( expect(
toTodoUpdate({ toTodoUpdate({
+13
View File
@@ -29,6 +29,7 @@ export function upsertProgress(progress: AgentChatProgress[] | undefined, data:
status, status,
title: readString(payload.title) ?? "正在处理", title: readString(payload.title) ?? "正在处理",
detail: readString(payload.detail), detail: readString(payload.detail),
tool: readProgressTool(payload.tool) ?? existing?.tool,
startedAt, startedAt,
endedAt, endedAt,
elapsedMs: status === "running" ? elapsedMs : undefined, elapsedMs: status === "running" ? elapsedMs : undefined,
@@ -373,3 +374,15 @@ function readNumber(value: unknown) {
function readStringArray(value: unknown) { function readStringArray(value: unknown) {
return Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : []; 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
};
}
+3 -1
View File
@@ -9,6 +9,7 @@ export type AgentChatMessage = {
questions?: AgentQuestionRequest[]; questions?: AgentQuestionRequest[];
todos?: AgentTodoUpdate; todos?: AgentTodoUpdate;
taskContext?: import("./task-context").AgentTaskContextReference; taskContext?: import("./task-context").AgentTaskContextReference;
interactionReply?: import("./api/interaction-requests").InteractionRequest;
}; };
export type AgentStreamRenderMessageState = { export type AgentStreamRenderMessageState = {
@@ -20,6 +21,7 @@ export type AgentStreamRenderState = Record<string, AgentStreamRenderMessageStat
export type AgentConnectionStatus = "connecting" | "online" | "offline"; export type AgentConnectionStatus = "connecting" | "online" | "offline";
export type AgentChatProgress = { export type AgentChatProgress = {
tool?: { name: string; callId?: string; input?: Record<string, unknown> };
id: string; id: string;
phase: string; phase: string;
status: "running" | "completed" | "error"; status: "running" | "completed" | "error";
@@ -86,7 +88,7 @@ export type AgentQuestionRequest = {
tool?: AgentInteractionToolRef; tool?: AgentInteractionToolRef;
createdAt: number; createdAt: number;
repliedAt?: number; repliedAt?: number;
status: "pending" | "submitting" | "answered" | "rejected" | "error"; status: "checking" | "invalidated" | "pending" | "submitting" | "answered" | "rejected" | "error";
answers?: string[][]; answers?: string[][];
error?: string; error?: string;
}; };
@@ -2,6 +2,21 @@ import { describe, expect, it } from "vitest";
import { applySessionStreamEvent, toAgentChatMessages, toAgentUiMessages } from "./agent-session-message-state"; import { applySessionStreamEvent, toAgentChatMessages, toAgentUiMessages } from "./agent-session-message-state";
describe("task context in persisted answers", () => { describe("task context in persisted answers", () => {
it("restores submitted answer cards only from snapshots bound to their user message", () => {
const reply = { version: "agent-interaction-request@1", interaction_id: "question", message_id: "source", turn_id: "turn", call_id: "call",
status: "answered", revision: 1, draft_revision: 0, draft: [], created_at: "2026-10-28T10:00:00Z",
questions: [{ question_id: "q", title: "核查时段", body: "请选择", mode: "single", required: true, allow_custom: false, options: [{ id: "a", label: "前一天" }] }],
answers: [{ question_id: "q", selected_option_ids: ["a"], selected_options: [{ id: "a", label: "前一天" }], custom_text: "" }],
delivery: { native_message_id: "reply", revision: 2, status: "delivered" } };
const parts = [{ type: "text", text: "核查时段: 前一天" }, { type: "data-interaction_reply", data: reply }];
const messages = toAgentChatMessages(toAgentUiMessages([
{ id: "reply", role: "user", parts }, { id: "other", role: "user", parts },
{ id: "reply", role: "assistant", parts },
{ id: "pending", role: "user", parts: [{ type: "data-interaction_reply", data: { ...reply, status: "pending", delivery: { ...reply.delivery, native_message_id: "pending" } } }] }
]), {}, {});
expect(messages[0].interactionReply).toEqual(reply);
expect(messages.slice(1).every((message) => message.interactionReply === undefined)).toBe(true);
});
it("inserts consumed user input once before the active assistant in live and resumed streams", () => { it("inserts consumed user input once before the active assistant in live and resumed streams", () => {
const messages = toAgentUiMessages([ const messages = toAgentUiMessages([
{ id: "original", role: "user", parts: [{ type: "text", text: "原问题" }] }, { id: "original", role: "user", parts: [{ type: "text", text: "原问题" }] },
@@ -60,6 +75,20 @@ describe("task context in persisted answers", () => {
describe("rich answer replay", () => { describe("rich answer replay", () => {
const block = { version: "answer-ui@1", block_id: "reading", fallback_text: "演练读数", spec: { root: "metric", elements: { metric: { type: "Metric", props: { label: "压力", value: "0.3", unit: "MPa", source: "演练数据" }, children: [] } } } }; const block = { version: "answer-ui@1", block_id: "reading", fallback_text: "演练读数", spec: { root: "metric", elements: { metric: { type: "Metric", props: { label: "压力", value: "0.3", unit: "MPa", source: "演练数据" }, children: [] } } } };
it("restores anchored final content without process UI, duplicate blocks or internal reference markers", () => {
const messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [
{ type: "data-answer_ui", data: { ...block, block_id: "query", phase: "process", fallback_text: "查询过程资料" } },
{ type: "data-answer_ui", data: { ...block, phase: "final" } },
{ type: "data-answer_ui", data: { ...block, phase: "final", fallback_text: "更新后的最终读数" } },
{ type: "data-final_answer", data: { content: "最终说明\n\n[[ui:reading]]\n\n后续解读\n\n[[ui:query]]", ui_block_ids: ["reading", "query"] } },
] }]);
const restored = toAgentChatMessages(messages, {}, {})[0];
expect(restored.answerParts?.filter((part) => part.type === "answer-ui")).toHaveLength(2);
expect(restored.content).toContain("更新后的最终读数");
expect(restored.content).not.toContain("查询过程资料");
expect(restored.content).not.toContain("[[ui:");
expect(restored.content.indexOf("更新后的最终读数")).toBeLessThan(restored.content.indexOf("后续解读"));
});
it("keeps text, component and summary in their original order across history restoration", () => { it("keeps text, component and summary in their original order across history restoration", () => {
const messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [ const messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [
{ type: "text", text: "下面是演练数据。" }, { type: "text", text: "下面是演练数据。" },
@@ -2,6 +2,8 @@ import type { UIMessage } from "ai";
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
import { import {
parseAnswerUiBlock, parseAnswerUiBlock,
readInteractionReply,
getFinalAnswerTranscript,
type AnswerUiPart, type AnswerUiPart,
applyPermissionResponse, applyPermissionResponse,
agentTaskContextReferenceSchema, agentTaskContextReferenceSchema,
@@ -31,6 +33,8 @@ type AgentUiDataParts = {
todo_update: Record<string, unknown>; todo_update: Record<string, unknown>;
permission_request: Record<string, unknown>; permission_request: Record<string, unknown>;
permission_response: Record<string, unknown>; permission_response: Record<string, unknown>;
interaction_request: Record<string, unknown>;
interaction_reply: Record<string, unknown>;
question_request: Record<string, unknown>; question_request: Record<string, unknown>;
question_response: Record<string, unknown>; question_response: Record<string, unknown>;
stream_token: Record<string, unknown>; stream_token: Record<string, unknown>;
@@ -137,7 +141,10 @@ export function toAgentChatMessages(
if (!isAgentDataPart(part)) { if (!isAgentDataPart(part)) {
continue; continue;
} }
if (part.type === "data-task_context") { if (part.type === "data-interaction_reply" && message.role === "user") {
const reply = readInteractionReply(part.data, message.id);
if (reply) next = { ...next, interactionReply: reply };
} else if (part.type === "data-task_context") {
const parsed = agentTaskContextReferenceSchema.safeParse(part.data); const parsed = agentTaskContextReferenceSchema.safeParse(part.data);
if (parsed.success) next = { ...next, taskContext: parsed.data }; if (parsed.success) next = { ...next, taskContext: parsed.data };
} else if (part.type === "data-final_answer") { } else if (part.type === "data-final_answer") {
@@ -184,8 +191,7 @@ export function toAgentChatMessages(
} }
if (next.finalAnswer) { if (next.finalAnswer) {
const selected = new Set(next.finalAnswer.uiBlockIds); next.content = getFinalAnswerTranscript(next);
next.content = [next.finalAnswer.content, ...(next.answerParts ?? []).flatMap((part) => part.type === "answer-ui" && selected.has(part.block.block_id) ? [part.block.fallback_text] : [])].join("\n\n");
} }
return [applyQuestionOverrides(applyPermissionOverrides(next, permissionOverrides), questionOverrides)]; return [applyQuestionOverrides(applyPermissionOverrides(next, permissionOverrides), questionOverrides)];
}); });
@@ -526,9 +532,15 @@ function collectAnswerParts(message: AgentUiMessage): AnswerUiPart[] | undefined
if (part.type === "text" && part.text) result.push({ type: "text", text: part.text }); if (part.type === "text" && part.text) result.push({ type: "text", text: part.text });
if (part.type === "data-answer_ui") { if (part.type === "data-answer_ui") {
const data = typeof part.data === "object" && part.data !== null ? part.data : {}; const data = typeof part.data === "object" && part.data !== null ? part.data : {};
const { version, block_id, fallback_text, spec } = data; const { version, block_id, phase, fallback_text, spec } = data;
const block = parseAnswerUiBlock({ version, block_id, fallback_text, spec }); const block = parseAnswerUiBlock({ version, block_id, ...(phase === undefined ? {} : { phase }), fallback_text, spec });
if (block) result.push({ type: "answer-ui", block }); if (block) {
const existing = result.findIndex((item) => item.type === "answer-ui" && item.block.block_id === block.block_id);
if (existing >= 0) {
const previous = result[existing];
if (previous.type === "answer-ui" && previous.block.phase === block.phase) result[existing] = { type: "answer-ui", block };
} else result.push({ type: "answer-ui", block });
}
else result.push({ type: "text", text: typeof fallback_text === "string" ? fallback_text.slice(0, 4000) : "这段结构化内容无法显示。" }); else result.push({ type: "text", text: typeof fallback_text === "string" ? fallback_text.slice(0, 4000) : "这段结构化内容无法显示。" });
} }
@@ -1,6 +1,6 @@
import { Chat } from "@ai-sdk/react"; import { Chat } from "@ai-sdk/react";
import type { ChatTransport } from "ai"; import type { ChatTransport } from "ai";
import { parseFrontendActionRequest, type AgentApiClient, type AgentRunStatus, type AgentLoadedChatSession, import { AgentSessionStreamError, AgentStreamDisconnectedError, parseFrontendActionRequest, type AgentApiClient, type AgentRunStatus, type AgentLoadedChatSession,
type AgentMessagePagination, type AgentSessionStreamEvent, type AgentSessionPurpose, type AgentTaskDiscussionContext, type AnswerUiSelection } from "@/features/agent"; type AgentMessagePagination, type AgentSessionStreamEvent, type AgentSessionPurpose, type AgentTaskDiscussionContext, type AnswerUiSelection } from "@/features/agent";
import { showMapNotice } from "@/features/map/core"; import { showMapNotice } from "@/features/map/core";
import { applySessionStreamEvent, getDataString, toAgentChatMessages, toAgentUiMessages, import { applySessionStreamEvent, getDataString, toAgentChatMessages, toAgentUiMessages,
@@ -8,6 +8,15 @@ import { applySessionStreamEvent, getDataString, toAgentChatMessages, toAgentUiM
import type { WorkbenchSessionResources } from "./workbench-session-resources"; import type { WorkbenchSessionResources } from "./workbench-session-resources";
export type AgentReadingPosition = { messageId?: string; offset: number; atBottom: boolean }; export type AgentReadingPosition = { messageId?: string; offset: number; atBottom: boolean };
export function isPendingInteractionError(error: unknown) {
const message = error instanceof Error ? error.message : error;
if (typeof message !== "string") return false;
try {
const value: unknown = JSON.parse(message);
return typeof value === "object" && value !== null && "code" in value && value.code === "INTERACTION_PENDING";
} catch { return false; }
}
export type AgentSessionRuntime = { export type AgentSessionRuntime = {
key: string; key: string;
scope: string; scope: string;
@@ -29,6 +38,9 @@ export type AgentSessionRuntime = {
updatedAt: number; updatedAt: number;
resumed: boolean; resumed: boolean;
controller?: AbortController; controller?: AbortController;
recoveryTimer?: ReturnType<typeof setTimeout>;
transportInterrupted?: boolean;
connectionError?: string;
error?: string; error?: string;
runStatus?: AgentRunStatus; runStatus?: AgentRunStatus;
handlers?: { handlers?: {
@@ -36,6 +48,7 @@ export type AgentSessionRuntime = {
onEvent: (event: AgentSessionStreamEvent) => void; onEvent: (event: AgentSessionStreamEvent) => void;
onError: (error: Error) => void; onError: (error: Error) => void;
onFinish: (status: AgentRunStatus) => void; onFinish: (status: AgentRunStatus) => void;
onReconnect?: () => void;
}; };
}; };
@@ -84,6 +97,7 @@ export function createAgentRuntime(resources: WorkbenchSessionResources, scope:
const sessionId = getDataString(data.data, "session_id"); const sessionId = getDataString(data.data, "session_id");
if (sessionId && runtime.sessionId && sessionId !== runtime.sessionId) return; if (sessionId && runtime.sessionId && sessionId !== runtime.sessionId) return;
if (sessionId) runtime.sessionId = sessionId; if (sessionId) runtime.sessionId = sessionId;
if (data.type === "data-interaction_request") runtime.runStatus = "waiting_input";
if (data.type === "data-session_title") runtime.title = getDataString(data.data, "title") ?? runtime.title; if (data.type === "data-session_title") runtime.title = getDataString(data.data, "title") ?? runtime.title;
if (data.type === "data-credential_refresh_required") { if (data.type === "data-credential_refresh_required") {
void refreshRuntimeCredential(runtime, data.data); void refreshRuntimeCredential(runtime, data.data);
@@ -92,19 +106,35 @@ export function createAgentRuntime(resources: WorkbenchSessionResources, scope:
} else if (active()) runtime.handlers?.onData(data); } else if (active()) runtime.handlers?.onData(data);
notify(); notify();
}, },
onError: (error) => { if (resources.disposed) return; runtime.error = error.message; runtime.runStatus = "error"; if (active()) runtime.handlers?.onError(error); notify(); }, onError: (error) => { if (resources.disposed) return; runtime.transportInterrupted = error instanceof AgentStreamDisconnectedError || error instanceof TypeError;
runtime.error = error.message; runtime.runStatus = isPendingInteractionError(error) ? "waiting_input" : "error";
if (active()) {
if (runtime.transportInterrupted && runtime.sessionId && runtime.handlers?.onReconnect) runtime.handlers.onReconnect();
else runtime.handlers?.onError(error);
}
notify(); },
onFinish: ({ isAbort, isError }) => { onFinish: ({ isAbort, isError }) => {
if (resources.disposed) return; if (resources.disposed) return;
runtime.loadedAt = Date.now(); runtime.loadedAt = Date.now();
runtime.updatedAt = Date.now(); runtime.updatedAt = Date.now();
runtime.runStatus = isAbort ? "aborted" : isError ? "error" : "completed"; runtime.runStatus = isAbort ? "aborted" : isError
if (active()) runtime.handlers?.onFinish(runtime.runStatus); ? isPendingInteractionError(runtime.error) ? "waiting_input" : "error"
else notifyBackgroundCompletion(runtime); : runtime.runStatus === "waiting_input" ? "waiting_input" : "completed";
const recovering = isError && !isAbort && runtime.transportInterrupted && runtime.sessionId;
if (active()) { if (!recovering) runtime.handlers?.onFinish(runtime.runStatus); }
else if (!recovering) notifyBackgroundCompletion(runtime);
if (recovering) {
runtime.recoveryTimer = setTimeout(() => {
runtime.recoveryTimer = undefined;
if (!resources.disposed && resources.agents.get(runtime.key) === runtime) resumeAgentRuntime(resources, runtime);
}, 0);
}
runtime.transportInterrupted = false;
notify(); notify();
}, },
}); });
resources.agents.set(runtime.key, runtime); resources.agents.set(runtime.key, runtime);
resources.disposers.add(() => { runtime.handlers = undefined; runtime.controller?.abort(); void runtime.chat.stop(); }); resources.disposers.add(() => { runtime.handlers = undefined; clearTimeout(runtime.recoveryTimer); runtime.controller?.abort(); void runtime.chat.stop(); });
return runtime; return runtime;
} }
@@ -132,15 +162,13 @@ async function rejectBackgroundAction(resources: WorkbenchSessionResources, runt
} }
export function resumeAgentRuntime(resources: WorkbenchSessionResources, runtime: AgentSessionRuntime) { export function resumeAgentRuntime(resources: WorkbenchSessionResources, runtime: AgentSessionRuntime) {
if (!runtime.sessionId || runtime.controller || runtime.chat.status === "submitted" || runtime.chat.status === "streaming") return; if (resources.disposed || !runtime.sessionId || runtime.controller || runtime.chat.status === "submitted" || runtime.chat.status === "streaming") return;
const controller = new AbortController(); const controller = new AbortController();
runtime.controller = controller; runtime.controller = controller;
runtime.resumed = true; runtime.resumed = true;
runtime.runStatus = "running"; runtime.runStatus = "running";
runtime.error = undefined; const onEvent = (event: AgentSessionStreamEvent) => {
void runtime.client.streamSession(runtime.sessionId, { signal: controller.signal, messageLimit: 40, if (resources.disposed || controller.signal.aborted || runtime.controller !== controller) return;
onEvent: (event) => {
if (resources.disposed) return;
const sessionId = event.type === "state" ? event.sessionId : event.sessionId ?? getDataString(event.data, "session_id"); const sessionId = event.type === "state" ? event.sessionId : event.sessionId ?? getDataString(event.data, "session_id");
if (sessionId && sessionId !== runtime.sessionId) return; if (sessionId && sessionId !== runtime.sessionId) return;
if (event.type === "state") { if (event.type === "state") {
@@ -155,27 +183,47 @@ export function resumeAgentRuntime(resources: WorkbenchSessionResources, runtime
if (!runtime.messagePage) runtime.messagePage = event.messagePage; if (!runtime.messagePage) runtime.messagePage = event.messagePage;
runtime.resumed = event.isStreaming; runtime.resumed = event.isStreaming;
runtime.runStatus = event.runStatus ?? (event.isStreaming ? "running" : "completed"); runtime.runStatus = event.runStatus ?? (event.isStreaming ? "running" : "completed");
if (runtime.runStatus !== "error") runtime.error = undefined;
terminal = !event.isStreaming;
runtime.connectionError = undefined;
} else { } else {
if (event.type === "credential_refresh_required") void refreshRuntimeCredential(runtime, event.data); if (event.type === "credential_refresh_required") void refreshRuntimeCredential(runtime, event.data);
else if (resources.activeAgent !== runtime && event.type === "frontend_action") void rejectBackgroundAction(resources, runtime, event.data); else if (resources.activeAgent !== runtime && event.type === "frontend_action") void rejectBackgroundAction(resources, runtime, event.data);
runtime.chat.messages = applySessionStreamEvent(runtime.chat.messages, event); runtime.chat.messages = applySessionStreamEvent(runtime.chat.messages, event);
if (event.type === "session_title") runtime.title = getDataString(event.data, "title") ?? runtime.title; if (event.type === "session_title") runtime.title = getDataString(event.data, "title") ?? runtime.title;
if (event.type === "done" || event.type === "error") { if (event.type === "done" || event.type === "error") {
terminal = true;
runtime.resumed = false; runtime.resumed = false;
runtime.runStatus = event.type === "error" ? "error" : "completed"; runtime.runStatus = event.type === "error" ? "error" : event.data.reason === "waiting_input" ? "waiting_input" : "completed";
runtime.updatedAt = Date.now(); runtime.updatedAt = Date.now();
} }
} }
if (resources.activeAgent === runtime && event.type !== "credential_refresh_required") runtime.handlers?.onEvent(event); if (resources.activeAgent === runtime && event.type !== "credential_refresh_required") runtime.handlers?.onEvent(event);
resources.notify(); resources.notify();
}, };
}).catch((error: unknown) => { let terminal = false;
if (!controller.signal.aborted) { void (async () => {
runtime.error = error instanceof Error ? error.message : "会话连接中断"; let failures = 0;
if (resources.activeAgent === runtime) runtime.handlers?.onError(new Error(runtime.error)); while (!resources.disposed && !controller.signal.aborted) {
terminal = false;
try {
await runtime.client.streamSession(runtime.sessionId!, { signal: controller.signal, messageLimit: 40, onEvent });
if (terminal || controller.signal.aborted || resources.disposed) break;
throw new AgentStreamDisconnectedError("会话连接已断开,正在自动恢复");
} catch (error) {
if (controller.signal.aborted || resources.disposed || terminal) break;
runtime.connectionError = error instanceof Error ? error.message : "会话连接中断";
if (error instanceof AgentSessionStreamError && [400, 401, 403, 404, 410].includes(error.status)) {
runtime.runStatus = "error";
if (resources.activeAgent === runtime) runtime.handlers?.onError(error);
break;
}
if (resources.activeAgent === runtime) runtime.handlers?.onReconnect?.();
resources.notify();
await waitForReconnect(Math.min(1_000 * 2 ** Math.min(failures++, 4), 15_000), controller.signal);
}
} }
}).finally(() => { })().finally(() => {
if (runtime.resumed && !controller.signal.aborted && !runtime.error) runtime.error = "会话连接已断开,返回会话后重新连接";
if (runtime.controller === controller) { runtime.controller = undefined; runtime.resumed = false; } if (runtime.controller === controller) { runtime.controller = undefined; runtime.resumed = false; }
runtime.loadedAt = Date.now(); runtime.loadedAt = Date.now();
if (!resources.disposed && !controller.signal.aborted && resources.activeAgent !== runtime) notifyBackgroundCompletion(runtime); if (!resources.disposed && !controller.signal.aborted && resources.activeAgent !== runtime) notifyBackgroundCompletion(runtime);
@@ -183,6 +231,23 @@ export function resumeAgentRuntime(resources: WorkbenchSessionResources, runtime
}); });
} }
function waitForReconnect(delay: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (signal.aborted) { resolve(); return; }
const finish = () => {
clearTimeout(timer);
signal.removeEventListener("abort", finish);
window.removeEventListener("online", finish);
window.removeEventListener("focus", finish);
resolve();
};
const timer = navigator.onLine ? setTimeout(finish, delay) : undefined;
signal.addEventListener("abort", finish, { once: true });
window.addEventListener("online", finish, { once: true });
window.addEventListener("focus", finish, { once: true });
});
}
function notifyBackgroundCompletion(runtime: AgentSessionRuntime) { function notifyBackgroundCompletion(runtime: AgentSessionRuntime) {
showMapNotice({ id: `agent-background-${runtime.key}`, tone: runtime.error || runtime.runStatus === "error" ? "error" : "info", showMapNotice({ id: `agent-background-${runtime.key}`, tone: runtime.error || runtime.runStatus === "error" ? "error" : "info",
title: runtime.error || runtime.runStatus === "error" ? "后台对话处理失败" : runtime.runStatus === "aborted" ? "后台对话已停止" : "后台对话已完成", title: runtime.error || runtime.runStatus === "error" ? "后台对话处理失败" : runtime.runStatus === "aborted" ? "后台对话已停止" : "后台对话已完成",
@@ -10,11 +10,14 @@ import { showMapNotice } from "@/features/map/core";
import { findOwnedWorkbenchProject } from "../api/project-client"; import { findOwnedWorkbenchProject } from "../api/project-client";
import type { InvestigationDiscussion } from "../api/investigation-report"; import type { InvestigationDiscussion } from "../api/investigation-report";
import { import {
useInteractionRequests,
useAgentConnection,
FrontendActionExecutor, FrontendActionExecutor,
agentTaskDiscussionContextSchema, agentTaskDiscussionContextSchema,
type AgentTaskDiscussionContext, type AgentTaskDiscussionContext,
agentBootstrapKey, agentBootstrapKey,
createAgentApiClient, createAgentApiClient,
AgentResponseFinishedError,
useAgentMessageInbox, useAgentMessageInbox,
fetchAgentBootstrap, fetchAgentBootstrap,
resolveAgentModelSelection, resolveAgentModelSelection,
@@ -59,6 +62,7 @@ import { useWorkbenchSessionResources } from "./workbench-session-resources";
import { useAgentTopics } from "./use-agent-topics"; import { useAgentTopics } from "./use-agent-topics";
import { useAgentHistory } from "./use-agent-history"; import { useAgentHistory } from "./use-agent-history";
import { agentRuntimes, createAgentRuntime, mergeHistoryMessages, resumeAgentRuntime, runtimeNeedsInput, runtimeRunning, import { agentRuntimes, createAgentRuntime, mergeHistoryMessages, resumeAgentRuntime, runtimeNeedsInput, runtimeRunning,
isPendingInteractionError,
type AgentReadingPosition, type AgentSessionRuntime } from "./agent-session-runtime"; type AgentReadingPosition, type AgentSessionRuntime } from "./agent-session-runtime";
const AGENT_PANEL_COLLAPSE_MS = 180; const AGENT_PANEL_COLLAPSE_MS = 180;
@@ -98,6 +102,7 @@ export function useWorkbenchAgent({
clientRef.current = scopeClient; clientRef.current = scopeClient;
const projectScopeKey = `project:${getWorkbenchProject(getAccessToken)?.project_id ?? "default"}`; const projectScopeKey = `project:${getWorkbenchProject(getAccessToken)?.project_id ?? "default"}`;
const scopeKey = projectScopeKey; const scopeKey = projectScopeKey;
const { connectionStatus, recheck: checkAgentConnection, recoveryVersion } = useAgentConnection(scopeClient, scopeKey);
const history = useAgentHistory(scopeClient, scopeKey); const history = useAgentHistory(scopeClient, scopeKey);
const topics = useAgentTopics(scopeClient, scopeKey); const topics = useAgentTopics(scopeClient, scopeKey);
const runtimeSummaries: AgentChatSessionSummary[] = agentRuntimes(resources).filter((entry) => entry.scope === scopeKey && entry.sessionId && entry.purpose !== "task_execution") const runtimeSummaries: AgentChatSessionSummary[] = agentRuntimes(resources).filter((entry) => entry.scope === scopeKey && entry.sessionId && entry.purpose !== "task_execution")
@@ -142,8 +147,6 @@ export function useWorkbenchAgent({
const [registry, setRegistry] = useState<UIRegistry | null>(null); const [registry, setRegistry] = useState<UIRegistry | null>(null);
const [sessionTitle, setSessionTitle] = useState("新对话"); const [sessionTitle, setSessionTitle] = useState("新对话");
const [statusLabel, setStatusLabel] = useState("Agent 后端连接中"); const [statusLabel, setStatusLabel] = useState("Agent 后端连接中");
const [connectionStatus, setConnectionStatus] =
useState<AgentConnectionStatus>("connecting");
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]); const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
const [selectedModel, setSelectedModel] = useState(""); const [selectedModel, setSelectedModel] = useState("");
const [inputDelivery, setInputDelivery] = useState<AgentInputDelivery>("steer"); const [inputDelivery, setInputDelivery] = useState<AgentInputDelivery>("steer");
@@ -162,6 +165,44 @@ export function useWorkbenchAgent({
const [questionOverrides, setQuestionOverrides] = useState<Record<string, QuestionOverride>>({}); const [questionOverrides, setQuestionOverrides] = useState<Record<string, QuestionOverride>>({});
const agentMessageInbox = useAgentMessageInbox(clientRef.current, sessionPurpose === "ordinary_chat" ? sessionId : null); const agentMessageInbox = useAgentMessageInbox(clientRef.current, sessionPurpose === "ordinary_chat" ? sessionId : null);
const refreshAgentMessageInbox = agentMessageInbox.refresh; const refreshAgentMessageInbox = agentMessageInbox.refresh;
const continueInteraction = useCallback(async (owningSessionId: string, interactionId: string) => {
const target = agentRuntimes(resources).find((entry) => entry.scope === scopeKey && entry.sessionId === owningSessionId);
if (!target || target.client !== scopeClient || resources.disposed) throw new Error("原会话已关闭,请重新打开后继续。");
const record = await target.client.getInteractionRequest(owningSessionId, interactionId);
if (target.client !== scopeClient || resources.disposed || !agentRuntimes(resources).includes(target)) return;
if (!record.delivery || ["delivered", "cancelled"].includes(record.delivery.status)) return;
const text = record.status === "skipped" ? "已跳过本组提问。" : record.questions.map((question) => {
const answer = record.answers?.find((item) => item.question_id === question.question_id);
return `${question.title}:${[...(answer?.selected_options.map((option) => option.label) ?? []), answer?.custom_text].filter(Boolean).join(";")}`;
}).join("\n");
const alreadyPresent = target.chat.messages.some((message) => message.id === record.delivery!.native_message_id);
const replyMessage: AgentUiMessage = { id: record.delivery.native_message_id, role: "user", parts: [
{ type: "text", text }, { type: "data-interaction_reply", data: record },
] };
if (alreadyPresent) target.chat.messages = target.chat.messages.map((message) => message.id === replyMessage.id ? replyMessage : message);
target.runStatus = "running";
try {
await target.chat.sendMessage(alreadyPresent ? undefined : replyMessage, { body: {
session_id: owningSessionId, interaction_id: interactionId, model: selectedModel || undefined,
capabilities: frontendActionEnabledRef.current ? ["host-question@1", "frontend-action@1"] : ["host-question@1"],
approval_mode: approvalMode, task_context: target.taskDiscussion?.context,
} });
} catch (error) { target.runStatus = "error"; throw error; }
finally {
if (!resources.disposed && target.client === scopeClient && !runtimeRunning(target)) {
try {
const loaded = await target.client.loadSession(owningSessionId, { messageLimit: 40 });
if (loaded && !resources.disposed && target.client === scopeClient) {
target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages));
resources.notify();
}
} catch { /* A failed reconciliation leaves the accepted answer intact. */ }
}
}
}, [resources, scopeKey, scopeClient, selectedModel, approvalMode]);
const interactionRequests = useInteractionRequests(scopeClient, scopeKey, sessionId, continueInteraction);
const refreshInteractionRequests = interactionRequests.refresh;
useEffect(() => { useEffect(() => {
sessionIdRef.current = sessionId; sessionIdRef.current = sessionId;
@@ -232,7 +273,6 @@ export function useWorkbenchAgent({
const handleDataPart = useCallback( const handleDataPart = useCallback(
(part: AgentDataPart) => { (part: AgentDataPart) => {
setConnectionStatus("online");
const eventSessionId = getDataString(part.data, "session_id"); const eventSessionId = getDataString(part.data, "session_id");
if (eventSessionId && sessionIdRef.current && eventSessionId !== sessionIdRef.current) { if (eventSessionId && sessionIdRef.current && eventSessionId !== sessionIdRef.current) {
return; return;
@@ -247,6 +287,7 @@ export function useWorkbenchAgent({
); );
return; return;
} }
if (part.type === "data-interaction_request") { void refreshInteractionRequests(); return; }
if (part.type === "data-agent_message") { refreshAgentMessageInbox(); return; } if (part.type === "data-agent_message") { refreshAgentMessageInbox(); return; }
if (part.type === "data-user_input" && part.data.status === "delivered") { if (part.type === "data-user_input" && part.data.status === "delivered") {
chatRef.current.setMessages((current) => applySessionStreamEvent(current, { type: "user_input", data: part.data })); chatRef.current.setMessages((current) => applySessionStreamEvent(current, { type: "user_input", data: part.data }));
@@ -295,7 +336,7 @@ export function useWorkbenchAgent({
}); });
}); });
}, },
[refreshAgentMessageInbox, applySessionId, claimEnvelope, handleCredentialRefresh, handleFrontendAction, onUiEnvelope] [refreshInteractionRequests, refreshAgentMessageInbox, applySessionId, claimEnvelope, handleCredentialRefresh, handleFrontendAction, onUiEnvelope]
); );
const transport = useMemo(() => createWorkbenchAgentTransport(scopeClient, getAccessToken, const transport = useMemo(() => createWorkbenchAgentTransport(scopeClient, getAccessToken,
@@ -355,9 +396,14 @@ export function useWorkbenchAgent({
const { const {
data: backendConnection, data: backendConnection,
error: backendConnectionError error: backendConnectionError,
mutate: refreshBootstrap
} = useSWR([...agentBootstrapKey, getAccessToken, scopeKey], () => fetchAgentBootstrap(scopeClient), { } = useSWR([...agentBootstrapKey, getAccessToken, scopeKey], () => fetchAgentBootstrap(scopeClient), {
shouldRetryOnError: false shouldRetryOnError: true,
errorRetryInterval: 5_000,
errorRetryCount: 3,
revalidateOnFocus: true,
revalidateOnReconnect: true
}); });
const clearCollapseTimer = useCallback(() => { const clearCollapseTimer = useCallback(() => {
if (collapseTimerRef.current !== null) { if (collapseTimerRef.current !== null) {
@@ -374,6 +420,7 @@ export function useWorkbenchAgent({
}, []); }, []);
const stopSessionStreamSubscription = useCallback(() => { const stopSessionStreamSubscription = useCallback(() => {
clearTimeout(runtimeRef.current?.recoveryTimer);
runtimeRef.current?.controller?.abort(); runtimeRef.current?.controller?.abort();
if (runtimeRef.current) { runtimeRef.current.controller = undefined; runtimeRef.current.resumed = false; } if (runtimeRef.current) { runtimeRef.current.controller = undefined; runtimeRef.current.resumed = false; }
}, []); }, []);
@@ -402,8 +449,7 @@ export function useWorkbenchAgent({
setRegistry(backendConnection.registry); setRegistry(backendConnection.registry);
setModelOptions(backendConnection.models); setModelOptions(backendConnection.models);
setSelectedModel((current) => resolveAgentModelSelection(backendConnection, current)); setSelectedModel((current) => resolveAgentModelSelection(backendConnection, current));
if (chatRef.current.status === "ready") { if (chatRef.current.status === "ready" && runtimeRef.current && !runtimeRunning(runtimeRef.current) && !runtimeRef.current.error) {
setConnectionStatus("online");
setStatusLabel("准备就绪"); setStatusLabel("准备就绪");
} }
}, [backendConnection]); }, [backendConnection]);
@@ -413,8 +459,7 @@ export function useWorkbenchAgent({
return; return;
} }
setConnectionStatus("offline"); if (runtimeRef.current && !runtimeRunning(runtimeRef.current) && !runtimeRef.current.error) setStatusLabel("Agent 初始化信息暂不可用");
setStatusLabel("Agent 后端不可用");
}, [backendConnectionError]); }, [backendConnectionError]);
const collapsePanel = useCallback(() => { const collapsePanel = useCallback(() => {
@@ -469,6 +514,12 @@ export function useWorkbenchAgent({
() => clientRef.current.listResponseInputs(sessionId!), () => clientRef.current.listResponseInputs(sessionId!),
{ refreshInterval: streaming ? 1000 : 0, shouldRetryOnError: false } { refreshInterval: streaming ? 1000 : 0, shouldRetryOnError: false }
); );
useEffect(() => {
if (!recoveryVersion) return;
void refreshBootstrap();
void refreshInteractionRequests();
void refreshPendingInputs();
}, [recoveryVersion, refreshBootstrap, refreshInteractionRequests, refreshPendingInputs]);
const updateInputDelivery = useCallback(async (id: string, delivery: AgentInputDelivery) => { const updateInputDelivery = useCallback(async (id: string, delivery: AgentInputDelivery) => {
if (!sessionIdRef.current) return; if (!sessionIdRef.current) return;
try { try {
@@ -504,10 +555,38 @@ export function useWorkbenchAgent({
if (sessionIdRef.current === activeSessionId) void refreshPendingInputs().catch(() => {}); if (sessionIdRef.current === activeSessionId) void refreshPendingInputs().catch(() => {});
} }
}, [refreshPendingInputs, resources, setPromptDraft]); }, [refreshPendingInputs, resources, setPromptDraft]);
const checkedLegacyQuestions = useRef(new Set<string>());
const checkingLegacyQuestions = useRef(new Set<string>());
const legacyQuestionScope = useRef(`${scopeKey}:${sessionId}`);
if (legacyQuestionScope.current !== `${scopeKey}:${sessionId}`) {
legacyQuestionScope.current = `${scopeKey}:${sessionId}`;
checkedLegacyQuestions.current.clear();
checkingLegacyQuestions.current.clear();
}
const messages = useMemo( const messages = useMemo(
() => toAgentChatMessages(chat.messages, permissionOverrides, questionOverrides), () => toAgentChatMessages(chat.messages, permissionOverrides, questionOverrides).map((message) => ({ ...message,
[chat.messages, permissionOverrides, questionOverrides] questions: message.questions?.map((question) => question.status === "pending" && !checkedLegacyQuestions.current.has(`${scopeKey}:${question.sessionId}:${question.requestId}`)
? { ...question, status: "checking" as const } : question),
})),
[chat.messages, permissionOverrides, questionOverrides, scopeKey]
); );
useEffect(() => {
const target = runtime;
for (const message of messages) for (const question of message.questions ?? []) {
const identity = `${scopeKey}:${question.sessionId}:${question.requestId}`;
if (question.status !== "checking" || checkingLegacyQuestions.current.has(identity)) continue;
checkingLegacyQuestions.current.add(identity);
void target.client.getQuestionStatus(question.sessionId, question.requestId).then(({ status }) => {
checkedLegacyQuestions.current.add(identity);
checkingLegacyQuestions.current.delete(identity);
target.questionOverrides = { ...target.questionOverrides, [question.requestId]: { status } };
if (resources.activeAgent === target) setQuestionOverrides(target.questionOverrides);
}).catch(() => {
// Uncertainty keeps historical controls disabled until the session is reopened.
checkingLegacyQuestions.current.delete(identity);
});
}
}, [messages, resources, runtime, scopeKey]);
const personaState = useMemo( const personaState = useMemo(
() => deriveAgentPersonaState(messages, { () => deriveAgentPersonaState(messages, {
chatStatus: chat.status, chatStatus: chat.status,
@@ -521,13 +600,15 @@ export function useWorkbenchAgent({
const handleSessionStreamEvent = useCallback( const handleSessionStreamEvent = useCallback(
(event: AgentSessionStreamEvent, expectedSessionId: string) => { (event: AgentSessionStreamEvent, expectedSessionId: string) => {
if (event.type === "interaction_request") { void refreshInteractionRequests(); return; }
if (event.type === "state") { if (event.type === "state") {
void refreshInteractionRequests();
if (sessionIdRef.current !== expectedSessionId && sessionIdRef.current !== event.sessionId) { if (sessionIdRef.current !== expectedSessionId && sessionIdRef.current !== event.sessionId) {
return; return;
} }
const nextMessages = runtimeRef.current?.chat.messages ?? toAgentUiMessages(event.messages); const nextMessages = runtimeRef.current?.chat.messages ?? toAgentUiMessages(event.messages);
setStreamRenderState(createCompletedStreamRenderState(nextMessages)); setStreamRenderState(createCompletedStreamRenderState(nextMessages));
setStatusLabel(event.isStreaming ? "Agent 会话流已恢复" : "Agent 历史会话已打开"); setStatusLabel(event.runStatus === "waiting_input" ? "等待补充信息" : event.isStreaming ? "Agent 会话流已恢复" : "Agent 历史会话已打开");
return; return;
} }
@@ -576,8 +657,9 @@ export function useWorkbenchAgent({
setStatusLabel(title); setStatusLabel(title);
} }
} else if (event.type === "done") { } else if (event.type === "done") {
void refreshInteractionRequests();
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState); markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
setStatusLabel("Agent 分析完成"); setStatusLabel(event.data.reason === "waiting_input" ? "等待补充信息" : "Agent 分析完成");
runtimeRef.current!.loadedAt = Date.now(); runtimeRef.current!.loadedAt = Date.now();
} else if (event.type === "error") { } else if (event.type === "error") {
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState); markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
@@ -587,7 +669,7 @@ export function useWorkbenchAgent({
// Message changes are applied once by the owning runtime, including background streams. // Message changes are applied once by the owning runtime, including background streams.
}, },
[refreshAgentMessageInbox, claimEnvelope, handleCredentialRefresh, handleFrontendAction, onUiEnvelope] [refreshInteractionRequests, refreshAgentMessageInbox, claimEnvelope, handleCredentialRefresh, handleFrontendAction, onUiEnvelope]
); );
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -595,23 +677,35 @@ export function useWorkbenchAgent({
onData: handleDataPart, onData: handleDataPart,
onEvent: (event) => handleSessionStreamEvent(event, runtime.sessionId ?? ""), onEvent: (event) => handleSessionStreamEvent(event, runtime.sessionId ?? ""),
onError: (error) => { onError: (error) => {
void refreshInteractionRequests();
markLastAssistantStreamDone(runtime.chat.messages, setStreamRenderState); markLastAssistantStreamDone(runtime.chat.messages, setStreamRenderState);
setConnectionStatus("offline"); setStatusLabel(error.message); if (isPendingInteractionError(error)) {
setStatusLabel("等待补充信息");
showMapNotice({ id: "agent-stream-status", tone: "warning", title: "有待回答的问题", message: "请在对话中的提问卡片中选择或填写后提交,也可以取消提问。" });
return;
}
checkAgentConnection();
setStatusLabel(error.message);
showMapNotice({ id: "agent-stream-status", tone: "error", title: "Agent 请求失败", message: error.message }); showMapNotice({ id: "agent-stream-status", tone: "error", title: "Agent 请求失败", message: error.message });
}, },
onFinish: (status) => { onFinish: (status) => {
void refreshInteractionRequests();
markLastAssistantStreamDone(runtime.chat.messages, setStreamRenderState); markLastAssistantStreamDone(runtime.chat.messages, setStreamRenderState);
setConnectionStatus(status === "error" ? "offline" : "online"); if (status === "error") checkAgentConnection();
setStatusLabel(status === "error" ? runtime.error ?? "Agent 处理失败" : status === "aborted" ? "Agent 已停止" : "Agent 分析完成"); setStatusLabel(status === "error" ? runtime.error ?? "Agent 处理失败" : status === "aborted" ? "Agent 已停止" : status === "waiting_input" ? "等待补充信息" : "Agent 分析完成");
},
onReconnect: () => {
checkAgentConnection();
setStatusLabel("连接已中断,正在恢复会话");
}, },
}; };
return () => { runtime.handlers = undefined; }; return () => { runtime.handlers = undefined; };
}, [handleDataPart, handleSessionStreamEvent, runtime]); }, [checkAgentConnection, handleDataPart, handleSessionStreamEvent, refreshInteractionRequests, runtime]);
const loadHistorySession = useCallback(async (nextSessionId: string) => { const loadHistorySession = useCallback(async (nextSessionId: string) => {
const generation = ++loadingGenerationRef.current; const generation = ++loadingGenerationRef.current;
let target = agentRuntimes(resources).find((entry) => entry.scope === scopeKey && entry.sessionId === nextSessionId); let target = agentRuntimes(resources).find((entry) => entry.scope === scopeKey && entry.sessionId === nextSessionId);
try { try {
if (!target || target.error || (!runtimeRunning(target) && Date.now() - target.loadedAt >= 60_000)) { if (!target || target.error || target.connectionError || (!runtimeRunning(target) && Date.now() - target.loadedAt >= 60_000)) {
const loaded = await resources.cache.load(JSON.stringify(["session", scopeKey, nextSessionId]), const loaded = await resources.cache.load(JSON.stringify(["session", scopeKey, nextSessionId]),
() => scopeClient.loadSession(nextSessionId, { messageLimit: 40, signal: AbortSignal.timeout(15_000) }), 60_000, { force: Boolean(target?.error) }); () => scopeClient.loadSession(nextSessionId, { messageLimit: 40, signal: AbortSignal.timeout(15_000) }), 60_000, { force: Boolean(target?.error) });
if (!loaded) throw new Error("这个历史会话不存在或已被删除。"); if (!loaded) throw new Error("这个历史会话不存在或已被删除。");
@@ -732,7 +826,6 @@ export function useWorkbenchAgent({
activateRuntime(next); activateRuntime(next);
setTaskDiscussion(null); setTaskDiscussion(null);
setStatusLabel(nextStatusLabel); setStatusLabel(nextStatusLabel);
setConnectionStatus("online");
}, [activateRuntime, resources, scopeClient, scopeKey, transport]); }, [activateRuntime, resources, scopeClient, scopeKey, transport]);
const selectTopic = (topicId: string | null) => { const selectTopic = (topicId: string | null) => {
@@ -798,7 +891,9 @@ export function useWorkbenchAgent({
return; return;
} }
if (streaming) { if (streaming || interactionRequests.awaitingInput) {
const owningRuntime = runtimeRef.current;
const owningSessionId = sessionIdRef.current;
try { try {
if (!sessionIdRef.current) throw new Error("Agent 正在建立会话,请稍后追加消息。"); if (!sessionIdRef.current) throw new Error("Agent 正在建立会话,请稍后追加消息。");
const receipt = await clientRef.current.submitResponseInput(sessionIdRef.current, normalizedPrompt, inputDelivery, `msg_${crypto.randomUUID().replaceAll("-", "")}`); const receipt = await clientRef.current.submitResponseInput(sessionIdRef.current, normalizedPrompt, inputDelivery, `msg_${crypto.randomUUID().replaceAll("-", "")}`);
@@ -808,15 +903,23 @@ export function useWorkbenchAgent({
? [...current.filter((input) => input.id !== receipt.id), receipt] ? [...current.filter((input) => input.id !== receipt.id), receipt]
: current.filter((input) => input.id !== receipt.id), { revalidate: false }) : current.filter((input) => input.id !== receipt.id), { revalidate: false })
.then(() => refreshPendingInputs()).catch(() => {}); .then(() => refreshPendingInputs()).catch(() => {});
return;
} catch (error) { } catch (error) {
showMapNotice({ tone: "error", title: "追加消息失败", message: error instanceof Error ? error.message : "请稍后重试。" }); if (error instanceof AgentResponseFinishedError) {
throw error; await refreshInteractionRequests();
if (runtimeRef.current !== owningRuntime || sessionIdRef.current !== owningSessionId) {
throw new Error("原会话已切换,请回到原会话后重新发送。");
}
} else {
showMapNotice({ tone: "error", title: "追加消息失败", message: error instanceof Error ? error.message : "请稍后重试。" });
throw error;
}
} }
return;
} }
setStatusLabel("Agent 正在分析"); setStatusLabel("Agent 正在分析");
runtimeRef.current!.error = undefined; runtimeRef.current!.error = undefined;
runtimeRef.current!.runStatus = "running";
await chat.sendMessage( await chat.sendMessage(
{ text: normalizedPrompt }, { text: normalizedPrompt },
@@ -826,13 +929,13 @@ export function useWorkbenchAgent({
topic_id: !sessionIdRef.current ? runtimeRef.current?.topicId ?? undefined : undefined, topic_id: !sessionIdRef.current ? runtimeRef.current?.topicId ?? undefined : undefined,
model: selectedModel || undefined, model: selectedModel || undefined,
approval_mode: approvalMode, approval_mode: approvalMode,
capabilities: frontendActionEnabledRef.current ? ["frontend-action@1"] : [], capabilities: frontendActionEnabledRef.current ? ["frontend-action@1", "host-question@1"] : ["host-question@1"],
task_context: taskDiscussion?.sessionId === sessionIdRef.current ? taskDiscussion.context : undefined task_context: taskDiscussion?.sessionId === sessionIdRef.current ? taskDiscussion.context : undefined
} }
} }
); );
}, },
[approvalMode, chat, inputDelivery, refreshPendingInputs, selectedModel, streaming, taskDiscussion] [approvalMode, chat, inputDelivery, interactionRequests.awaitingInput, refreshInteractionRequests, refreshPendingInputs, selectedModel, streaming, taskDiscussion]
); );
const triggerEvent = useCallback(async (trigger: AgentEventTriggerContext, prompt: string) => { const triggerEvent = useCallback(async (trigger: AgentEventTriggerContext, prompt: string) => {
@@ -846,17 +949,19 @@ export function useWorkbenchAgent({
if (loaded.runStatus === "running" || loaded.isStreaming) resumeAgentRuntime(resources, target); if (loaded.runStatus === "running" || loaded.isStreaming) resumeAgentRuntime(resources, target);
} }
activateRuntime(target); activateRuntime(target);
setConnectionStatus("online");
if (runtimeRunning(target) || target.chat.messages.length) return { sessionId: id, reused: true }; if (runtimeRunning(target) || target.chat.messages.length) return { sessionId: id, reused: true };
setStatusLabel("Agent 正在调查水质投诉事件"); setStatusLabel("Agent 正在调查水质投诉事件");
void target.chat.sendMessage({ text: prompt }, { body: { void target.chat.sendMessage({ text: prompt }, { body: {
session_id: id, model: selectedModel || undefined, approval_mode: approvalMode, trigger_context: trigger, session_id: id, model: selectedModel || undefined, approval_mode: approvalMode, trigger_context: trigger,
capabilities: frontendActionEnabledRef.current ? ["frontend-action@1"] : [], capabilities: frontendActionEnabledRef.current ? ["frontend-action@1", "host-question@1"] : ["host-question@1"],
} }).catch(() => {}); } }).catch(() => {});
return { sessionId: id, reused: reserved.event_duplicate === true }; return { sessionId: id, reused: reserved.event_duplicate === true };
}, [activateRuntime, approvalMode, resources, scopeClient, scopeKey, selectedModel, transport]); }, [activateRuntime, approvalMode, resources, scopeClient, scopeKey, selectedModel, transport]);
const stopPrompt = useCallback(() => { const stopPrompt = useCallback(() => {
for (const entry of interactionRequests.entries) {
if (entry.record.status === "pending") void interactionRequests.act(entry.record.interaction_id, "cancel");
}
chat.stop(); chat.stop();
stopSessionStreamSubscription(); stopSessionStreamSubscription();
const activeSessionId = sessionIdRef.current; const activeSessionId = sessionIdRef.current;
@@ -864,7 +969,7 @@ export function useWorkbenchAgent({
void clientRef.current.abort(activeSessionId).then(() => refreshPendingInputs()).catch(() => undefined); void clientRef.current.abort(activeSessionId).then(() => refreshPendingInputs()).catch(() => undefined);
} }
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState); markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
}, [chat, refreshPendingInputs, stopSessionStreamSubscription]); }, [chat, interactionRequests, refreshPendingInputs, stopSessionStreamSubscription]);
const replyPermission = useCallback(async (permission: AgentPermissionRequest, reply: AgentPermissionReply) => { const replyPermission = useCallback(async (permission: AgentPermissionRequest, reply: AgentPermissionReply) => {
const target = resources.activeAgent; const target = resources.activeAgent;
@@ -933,6 +1038,7 @@ export function useWorkbenchAgent({
inputDelivery, inputDelivery,
setInputDelivery, setInputDelivery,
pendingInputs, pendingInputs,
interactionRequests,
updateInputDelivery, updateInputDelivery,
withdrawInput, withdrawInput,
agentMessageInbox, agentMessageInbox,
@@ -946,6 +1052,7 @@ export function useWorkbenchAgent({
openMobilePanel, openMobilePanel,
closeMobilePanel, closeMobilePanel,
connectionStatus, connectionStatus,
connectionRecoveryVersion: recoveryVersion,
panelCollapsing, panelCollapsing,
panelOpen, panelOpen,
personaState, personaState,
@@ -1063,9 +1170,9 @@ function createWorkbenchAgentTransport(client: AgentApiClient, access?: Workbenc
const model = resolveAgentModelSelection(catalog, readBodyString(body, "model") ?? ""); const model = resolveAgentModelSelection(catalog, readBodyString(body, "model") ?? "");
if (!model) throw new Error("Agent 后端当前没有可用模型,请检查模型配置。"); if (!model) throw new Error("Agent 后端当前没有可用模型,请检查模型配置。");
return { body: createResponseRequest(messages, { return { body: createResponseRequest(messages, {
conversation: readBodyString(body, "session_id"), model, topicId: readBodyString(body, "topic_id"), conversation: readBodyString(body, "session_id"), interactionId: readBodyString(body, "interaction_id"), model, topicId: readBodyString(body, "topic_id"),
approvalMode: readBodyString(body, "approval_mode") ?? "request", approvalMode: readBodyString(body, "approval_mode") ?? "request",
capabilities: Array.isArray(body?.capabilities) ? body.capabilities.filter((value): value is string => value === "frontend-action@1") : [], triggerContext: body?.trigger_context, taskContext: body?.task_context, capabilities: Array.isArray(body?.capabilities) ? body.capabilities.filter((value): value is string => value === "frontend-action@1" || value === "host-question@1") : [], triggerContext: body?.trigger_context, taskContext: body?.task_context,
}) }; }) };
}, },
}); });
@@ -1247,6 +1247,7 @@ function MapWorkbenchContent({
inputDelivery: agent.inputDelivery, inputDelivery: agent.inputDelivery,
onInputDeliveryChange: agent.setInputDelivery, onInputDeliveryChange: agent.setInputDelivery,
pendingInputs: agent.pendingInputs, pendingInputs: agent.pendingInputs,
interactionRequests: agent.interactionRequests,
onPendingInputDeliveryChange: agent.updateInputDelivery, onPendingInputDeliveryChange: agent.updateInputDelivery,
onPendingInputEdit: (id) => agent.withdrawInput(id, true), onPendingInputEdit: (id) => agent.withdrawInput(id, true),
onPendingInputCancel: (id) => agent.withdrawInput(id, false), onPendingInputCancel: (id) => agent.withdrawInput(id, false),
+51
View File
@@ -0,0 +1,51 @@
import type { ClipboardEvent } from "react";
export function copySelectedText(event: ClipboardEvent<HTMLElement>) {
if (event.defaultPrevented) return;
const target = event.target instanceof Element ? event.target : null;
if (target?.closest("input, textarea, [contenteditable]:not([contenteditable=false])")) return;
const selection = event.currentTarget.ownerDocument.defaultView?.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount !== 1) return;
const range = selection.getRangeAt(0);
const text = selection.toString();
const suffix = text.match(/\n+$/)?.[0].length ?? 0;
const sourceSuffix = range.toString().match(/\n+$/)?.[0].length ?? 0;
// Chromium includes the block boundary when selecting an entire paragraph.
// Keep native internal paragraphs, table tabs and code indentation, removing
// only the trailing boundary that is absent from the selected source.
if (suffix < 2 || suffix <= sourceSuffix) return;
event.clipboardData.setData("text/plain", text.slice(0, text.length - suffix + sourceSuffix));
event.preventDefault();
}
export function copySelectedParagraph(event: ClipboardEvent<HTMLElement>, paragraph: HTMLParagraphElement, hasNonSelectableControls = false) {
const selection = paragraph.ownerDocument.defaultView?.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount !== 1) return;
const selected = selection.getRangeAt(0);
if (!selected.intersectsNode(paragraph)) return;
const contents = paragraph.ownerDocument.createRange();
contents.selectNodeContents(paragraph);
const bounded = selected.cloneRange();
if (bounded.compareBoundaryPoints(Range.START_TO_START, contents) < 0) {
bounded.setStart(contents.startContainer, contents.startOffset);
}
if (bounded.compareBoundaryPoints(Range.END_TO_END, contents) > 0) {
bounded.setEnd(contents.endContainer, contents.endOffset);
}
// Native paragraph selection can extend to the next message and serialize
// the paragraph boundary as extra newlines. Range text preserves the actual
// selected source, including intentional whitespace inside the message.
const text = bounded.toString();
if (!text) return;
if (hasNonSelectableControls) {
// DOM ranges can include adjacent user-select:none toolbar labels, while
// the rendered selection contains only this paragraph and its boundary.
if (selection.toString() !== `${text}\n\n`) return;
} else if (selected.toString() !== text || selection.toString() === text) return;
event.clipboardData.setData("text/plain", text);
event.preventDefault();
}
+136 -29
View File
@@ -157,6 +157,7 @@
--shadow-panel: 0 12px 32px rgba(15, 33, 55, 0.14); --shadow-panel: 0 12px 32px rgba(15, 33, 55, 0.14);
--shadow-agent-history-bottom: 0 12px 12px -12px rgba(15, 33, 55, 0.32); --shadow-agent-history-bottom: 0 12px 12px -12px rgba(15, 33, 55, 0.32);
--shadow-control: 0 6px 18px rgba(15, 33, 55, 0.1); --shadow-control: 0 6px 18px rgba(15, 33, 55, 0.1);
--shadow-agent-composer: 0 2px 4px rgb(15 23 42 / 0.04), 0 12px 32px -10px rgb(15 23 42 / 0.12);
--shadow-transient: 0 14px 34px rgba(15, 33, 55, 0.18); --shadow-transient: 0 14px 34px rgba(15, 33, 55, 0.18);
--shadow-agent-floating: --shadow-agent-floating:
0 18px 42px rgba(15, 33, 55, 0.18), 0 4px 12px rgba(15, 33, 55, 0.1), 0 18px 42px rgba(15, 33, 55, 0.18), 0 4px 12px rgba(15, 33, 55, 0.1),
@@ -824,11 +825,6 @@ button:disabled {
border: 0; border: 0;
} }
.agent-panel-composer.form-field:focus-within {
outline: 1px solid var(--field-focus-border);
outline-offset: -1px;
}
.agent-panel-history-extension .agent-history-item, .agent-panel-history-extension .agent-history-item,
.agent-panel-history-extension .agent-history-state { .agent-panel-history-extension .agent-history-state {
background-color: var(--agent-floating-reading-item); background-color: var(--agent-floating-reading-item);
@@ -932,11 +928,53 @@ button.agent-history-session-trigger:not(:disabled):active {
.agent-streaming-response { .agent-streaming-response {
display: block; display: block;
min-width: 0;
font-size: 16px;
line-height: 1.75;
--sd-animation: agent-stream-token-in; --sd-animation: agent-stream-token-in;
--sd-duration: 260ms; --sd-duration: 260ms;
--sd-easing: cubic-bezier(0.16, 1, 0.3, 1); --sd-easing: cubic-bezier(0.16, 1, 0.3, 1);
} }
.agent-streaming-response :is(p, li) { font-size: inherit; line-height: inherit; }
.agent-streaming-response :is(h1, h2, h3, h4) { margin-block: 1.3em 0.6em; line-height: 1.5; text-wrap: pretty; }
.agent-streaming-response h1 { font-size: 24px; }
.agent-streaming-response h2 { font-size: 21px; }
.agent-streaming-response h3 { font-size: 18px; }
.agent-streaming-response h4 { font-size: 16px; }
.agent-streaming-response :is(ul, ol) { padding-inline-start: 1.5em; }
.agent-streaming-response blockquote { margin-block: 1em; padding: 12px 16px; border: 1px solid var(--surface-border); border-radius: var(--radius); background: var(--surface-control); }
.agent-streaming-response pre { max-width: 100%; overflow-x: auto; font-size: 13px; line-height: 1.7; }
.markdown-reading-block { min-width: 0; margin-block: 20px; overflow: hidden; border: 1px solid var(--surface-border); border-radius: var(--radius); background: var(--surface-reading); }
.markdown-table-scroll { max-width: 100%; max-height: 384px; overflow: auto; overscroll-behavior: contain; }
.markdown-table-scroll:focus-visible { outline: 2px solid var(--action-blue); outline-offset: -2px; }
.markdown-table-scroll table { width: 100%; border-collapse: collapse; font-size: 15px; line-height: 1.7; }
.markdown-table-scroll :is(th, td) { min-width: 112px; max-width: 400px; padding: 12px 16px; border: 0; border-bottom: 1px solid var(--surface-divider); vertical-align: top; overflow-wrap: anywhere; }
.markdown-table-scroll th { position: sticky; top: 0; z-index: 1; background: var(--surface-control); color: var(--ink); text-align: start; font-weight: 600; }
.markdown-table-scroll td { font-variant-numeric: tabular-nums; }
.markdown-table-scroll tr:last-child td { border-bottom: 0; }
.markdown-reading-window { width: calc(100vw - 32px); max-width: 1120px; height: min(84dvh, 900px); max-height: calc(100dvh - 32px); display: flex; flex-direction: column; gap: 0; overflow: hidden; padding: 0; border-radius: 12px; background: var(--surface-reading); color: var(--ink); }
.markdown-reading-window [data-sd-animate] { animation: none; filter: none; opacity: 1; transform: none; }
.markdown-reading-window .markdown-table-scroll { max-height: none; overflow: visible; }
.markdown-reading-window .markdown-table-scroll table { font-size: 16px; }
.markdown-reading-window[data-reading-kind="table"] { height: auto; max-height: min(84dvh, 900px); }
.markdown-diagram-renderer { min-width: 0; padding: 8px; }
.markdown-diagram-renderer [data-streamdown="mermaid-block"] { margin: 0; border: 0; border-radius: 0; background: var(--surface-reading); padding: 0; }
.markdown-diagram-renderer [data-streamdown="mermaid-block"] > div:first-child { visibility: hidden; }
.markdown-diagram-renderer [data-streamdown="mermaid-block-actions"] { background: var(--surface-control); backdrop-filter: none; }
.markdown-diagram-renderer [data-streamdown="mermaid"] { height: clamp(300px, 52dvh, 480px); }
.markdown-diagram-renderer [data-streamdown="mermaid"] > div { height: 100%; }
.markdown-diagram-renderer [data-streamdown="mermaid"] [role="application"] { justify-content: flex-start; }
.markdown-diagram-renderer [data-streamdown="mermaid"] [role="img"] { width: max-content; min-width: 100%; flex: none; padding: 20px 48px 20px 12px; }
.markdown-diagram-renderer [data-streamdown="mermaid"] [role="img"] > svg { width: auto; max-width: none; height: auto; flex-shrink: 0; }
.markdown-reading-window .markdown-diagram-renderer [data-streamdown="mermaid"] { height: min(62dvh, 640px); }
@media (max-width: 639px) {
.markdown-reading-window { width: calc(100vw - 16px); max-height: calc(100dvh - 16px); height: min(88dvh, 900px); }
.markdown-reading-window[data-reading-kind="table"] { height: auto; max-height: min(88dvh, 900px); }
.markdown-table-scroll :is(th, td) { padding: 10px 12px; }
}
[data-streamdown="table-fullscreen"] [data-sd-animate] { [data-streamdown="table-fullscreen"] [data-sd-animate] {
animation: none; animation: none;
filter: none; filter: none;
@@ -1159,10 +1197,6 @@ button.agent-history-session-trigger:not(:disabled):active {
transition: background-color 180ms ease; transition: background-color 180ms ease;
} }
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-input-body:focus-within {
border-color: var(--field-focus-border);
}
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-input-tools { .agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-input-tools {
min-height: 40px; min-height: 40px;
padding: 0 4px; padding: 0 4px;
@@ -1196,7 +1230,7 @@ button.agent-history-session-trigger:not(:disabled):active {
.agent-panel-suggestion-status { .agent-panel-suggestion-status {
padding: 12px; padding: 12px;
font-size: 12px; font-size: 14px;
line-height: 20px; line-height: 20px;
color: var(--ink-muted); color: var(--ink-muted);
} }
@@ -1241,7 +1275,7 @@ button.agent-history-session-trigger:not(:disabled):active {
background: transparent; background: transparent;
} }
.agent-panel-shell[data-agent-presentation="desktop-dock"] [data-agent-progress] { .agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-nested[data-agent-progress] {
border-color: var(--surface-divider); border-color: var(--surface-divider);
background: var(--agent-dock-surface); background: var(--agent-dock-surface);
box-shadow: none; box-shadow: none;
@@ -2020,7 +2054,7 @@ details[open] > summary > .disclosure-accordion-indicator {
} }
.agent-chat-avatar { width: 28px; height: 28px; border-radius: 8px; } .agent-chat-avatar { width: 28px; height: 28px; border-radius: 8px; }
.agent-chat-message-header { display: flex; align-items: center; gap: 8px; min-height: 28px; } .agent-chat-message-header { display: flex; align-items: center; gap: 8px; min-height: 28px; }
.agent-chat-message-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 12px; } .agent-chat-message-actions { display: flex; flex-wrap: nowrap; align-items: center; gap: 0; }
.agent-chat-context-bar { display: flex; flex-direction: column; flex-shrink: 0; min-width: 0; padding: 12px; background: var(--surface-navigation); } .agent-chat-context-bar { display: flex; flex-direction: column; flex-shrink: 0; min-width: 0; padding: 12px; background: var(--surface-navigation); }
.agent-panel-shell[data-agent-presentation] .agent-panel-conversation { .agent-panel-shell[data-agent-presentation] .agent-panel-conversation {
@@ -2044,7 +2078,8 @@ details[open] > summary > .disclosure-accordion-indicator {
background: #f1f5f9; background: #f1f5f9;
box-shadow: none; box-shadow: none;
} }
.agent-chat-message-list { padding: 8px 4px; } .agent-panel-shell[data-agent-presentation] { --agent-message-list-inset: 4px; }
.agent-chat-message-list { padding: 8px var(--agent-message-list-inset, 4px); }
.agent-panel-shell[data-agent-presentation] .agent-panel-band { .agent-panel-shell[data-agent-presentation] .agent-panel-band {
background: #ffffff; background: #ffffff;
padding: 8px 12px; padding: 8px 12px;
@@ -2056,38 +2091,73 @@ details[open] > summary > .disclosure-accordion-indicator {
} }
.agent-panel-shell[data-agent-presentation] .agent-panel-input-form { .agent-panel-shell[data-agent-presentation] .agent-panel-input-form {
border: 1px solid #cbd5e1; border: 1px solid #e4e4e7;
border-radius: 14px; border-radius: 22px;
background: #ffffff; background: #ffffff;
background-image: none; background-image: none;
backdrop-filter: none; backdrop-filter: none;
-webkit-backdrop-filter: none; -webkit-backdrop-filter: none;
box-shadow: 0 2px 8px rgb(15 23 42 / 0.04);
}
.agent-panel-shell[data-agent-presentation] .agent-panel-input-form:focus-within {
border-color: #2563eb;
box-shadow: 0 0 0 2px rgb(37 99 235 / 0.08);
} }
.agent-panel-shell[data-agent-presentation] .agent-panel-input-body { .agent-panel-shell[data-agent-presentation] .agent-panel-input-body {
display: block;
width: 100%;
border: 0; border: 0;
border-radius: 14px 14px 0 0; border-radius: 22px 22px 0 0;
background: #ffffff; background: #ffffff;
} }
.agent-panel-shell[data-agent-presentation] .agent-panel-input-tools { .agent-panel-shell[data-agent-presentation] .agent-panel-input-tools {
width: auto;
min-width: 0; min-width: 0;
align-self: stretch;
flex-wrap: nowrap;
margin: 0 6px 6px; margin: 0 6px 6px;
padding: 4px; padding: 4px;
border-top: 0; border-top: 0;
background: #ffffff; background: #ffffff;
} }
.agent-panel-shell[data-agent-presentation="mobile-sheet"] .agent-panel-band { flex-shrink: 0; } .agent-panel-shell[data-agent-presentation="mobile-sheet"] .agent-panel-band { flex-shrink: 0; }
.agent-panel-shell[data-agent-presentation="mobile-sheet"] .agent-panel-input-tools { flex-wrap: wrap; } .agent-panel-shell[data-agent-presentation] .agent-panel-toolbar-trigger { border-radius: 10px; }
.agent-panel-shell[data-agent-presentation] .agent-panel-voice-input { border-radius: 50%; }
.agent-panel-shell[data-agent-presentation] .agent-panel-input-tools svg {
width: 16px;
height: 16px;
stroke-width: 2;
}
.agent-panel-shell[data-agent-presentation] .agent-panel-input-tools .agent-panel-toolbar-caret {
width: 12px;
height: 12px;
color: var(--ink-muted);
}
.agent-panel-shell[data-agent-presentation] .agent-panel-input-tools .agent-panel-send-icon {
width: 20px;
height: 20px;
stroke-width: 2.25;
}
.agent-panel-shell[data-agent-presentation] .agent-panel-primary-icon { .agent-panel-shell[data-agent-presentation] .agent-panel-primary-icon {
border-radius: 9px; display: grid;
place-items: center;
padding: 0;
border-radius: 999px;
background: #2563eb; background: #2563eb;
box-shadow: none; box-shadow: none;
} }
.agent-panel-shell[data-agent-presentation] .agent-panel-primary-icon:disabled { background: #94a3b8; } .agent-panel-shell[data-agent-presentation] .agent-panel-primary-icon:disabled {
background: var(--surface-control);
color: var(--ink-muted);
opacity: 1;
}
.agent-panel-shell[data-agent-presentation] .agent-panel-primary-icon[data-agent-action="stop"] {
background: var(--status-danger-soft);
color: var(--status-danger-foreground);
}
@media (hover: hover) {
.agent-panel-shell[data-agent-presentation] .agent-panel-primary-icon:hover:not(:disabled) {
background: var(--action-blue-hover);
}
.agent-panel-shell[data-agent-presentation] .agent-panel-primary-icon[data-agent-action="stop"]:hover:not(:disabled) {
background: var(--status-danger-border);
}
}
.agent-panel-shell[data-agent-presentation] .agent-panel-icon-button, .agent-panel-shell[data-agent-presentation] .agent-panel-icon-button,
.agent-panel-shell[data-agent-presentation] .agent-project-trigger { .agent-panel-shell[data-agent-presentation] .agent-project-trigger {
border-color: transparent; border-color: transparent;
@@ -2111,7 +2181,39 @@ details[open] > summary > .disclosure-accordion-indicator {
@media (max-width: 639px) { @media (max-width: 639px) {
.agent-panel-shell[data-agent-centered="true"] .agent-panel-band { padding: 24px 16px; } .agent-panel-shell[data-agent-centered="true"] .agent-panel-band { padding: 24px 16px; }
} }
.agent-panel-shell[data-agent-presentation] .agent-chat-context-bar { background: #ffffff; } .agent-panel-shell[data-agent-presentation] .agent-chat-context-bar {
position: relative;
z-index: 2;
padding-inline: calc(12px + var(--agent-message-list-inset));
background: var(--surface-navigation);
text-align: left;
}
.agent-panel-shell[data-agent-presentation] .agent-chat-context-bar > p {
scrollbar-gutter: stable both-edges;
}
.agent-panel-shell[data-agent-presentation] .agent-chat-context-bar::after,
.agent-panel-shell[data-agent-presentation][data-agent-starting="false"] .agent-panel-band::before {
position: absolute;
inset-inline: 0;
height: 32px;
pointer-events: none;
content: "";
}
.agent-panel-shell[data-agent-presentation] .agent-chat-context-bar::after {
top: 100%;
background: linear-gradient(to bottom, var(--surface-navigation), transparent);
}
.agent-panel-shell[data-agent-presentation][data-agent-starting="false"] .agent-panel-band::before {
z-index: 2;
bottom: 100%;
background: linear-gradient(to top, var(--surface-navigation), transparent);
}
@media (forced-colors: active), (prefers-reduced-transparency: reduce) {
.agent-panel-shell[data-agent-presentation] .agent-chat-context-bar::after,
.agent-panel-shell[data-agent-presentation][data-agent-starting="false"] .agent-panel-band::before {
content: none;
}
}
/* Keep one compact form mounted while the starting group becomes a bottom composer. */ /* Keep one compact form mounted while the starting group becomes a bottom composer. */
.agent-panel-shell[data-agent-presentation][data-agent-starting] .agent-panel-input-form > [data-slot="input-group"] { gap: 4px; } .agent-panel-shell[data-agent-presentation][data-agent-starting] .agent-panel-input-form > [data-slot="input-group"] { gap: 4px; }
.agent-panel-shell[data-agent-presentation][data-agent-starting] .agent-panel-input-tools { padding-block: 4px; } .agent-panel-shell[data-agent-presentation][data-agent-starting] .agent-panel-input-tools { padding-block: 4px; }
@@ -2119,8 +2221,11 @@ details[open] > summary > .disclosure-accordion-indicator {
min-height: 64px; min-height: 64px;
max-height: clamp(64px, 24dvh, 160px); max-height: clamp(64px, 24dvh, 160px);
padding: 14px 14px 8px; padding: 14px 14px 8px;
font-size: 14px; font-size: 16px;
line-height: 22px; line-height: 28px;
}
.agent-panel-shell[data-agent-presentation][data-agent-starting] .agent-panel-input-body:has(.agent-panel-input-reference) textarea.agent-panel-prompt-textarea {
padding-top: 6px;
} }
.agent-panel-shell[data-agent-presentation] .agent-chat-suggestions { .agent-panel-shell[data-agent-presentation] .agent-chat-suggestions {
flex-direction: column; flex-direction: column;
@@ -2142,7 +2247,7 @@ details[open] > summary > .disclosure-accordion-indicator {
background: #ffffff; background: #ffffff;
color: #52525b; color: #52525b;
box-shadow: none; box-shadow: none;
font-size: 12px; font-size: 14px;
line-height: 20px; line-height: 20px;
font-weight: 400; font-weight: 400;
} }
@@ -2153,15 +2258,17 @@ details[open] > summary > .disclosure-accordion-indicator {
color: #18181b; color: #18181b;
} }
} }
.agent-panel-shell[data-agent-presentation] .agent-panel-input-form { border-color: #e4e4e7; box-shadow: 0 1px 2px rgb(0 0 0 / 0.03); } .agent-panel-shell[data-agent-presentation] form.agent-panel-control { box-shadow: var(--shadow-agent-composer); }
.resizable-handle { color: var(--ink-muted); touch-action: none; } .resizable-handle { color: var(--ink-muted); touch-action: none; }
.resizable-grip { opacity: 0; }
.resizable-handle::after { content: ""; position: absolute; inset: 0 -6px; } .resizable-handle::after { content: ""; position: absolute; inset: 0 -6px; }
.resizable-handle[aria-orientation="horizontal"]::after { inset: -6px 0; } .resizable-handle[aria-orientation="horizontal"]::after { inset: -6px 0; }
.resizable-handle[aria-orientation="horizontal"] .resizable-grip { transform: rotate(90deg); } .resizable-handle[aria-orientation="horizontal"] .resizable-grip { transform: rotate(90deg); }
.resizable-handle:is([data-separator="hover"], [data-separator="active"], :focus-visible) { background: var(--action-blue); } .resizable-handle:is([data-separator="hover"], [data-separator="active"], :focus-visible) { background: var(--action-blue); }
.resizable-handle:is([data-separator="hover"], [data-separator="active"], :focus-visible) .resizable-grip { .resizable-handle:is([data-separator="hover"], [data-separator="active"], :focus-visible) .resizable-grip {
opacity: 1;
color: var(--action-blue); color: var(--action-blue);
border-color: var(--action-blue); border-color: var(--action-blue);
} }
@@ -0,0 +1,113 @@
import { expect, test, type Page } from "@playwright/test";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { responseFixtureWriter } from "./support/responses-fixture";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
const finalText = "已完成三根管道的压力核查。结果属于模型计算,仍需现场核验。";
async function openStream(page: Page, width: number) {
await page.setViewportSize({ width, height: 900 });
await mockWorkbenchContext(page);
await mockAgentApi(page);
let publishFinal!: () => void;
let finish!: () => void;
const server = createServer((_request, response) => {
response.writeHead(200, { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" });
const encode = responseFixtureWriter();
const send = (chunk: Record<string, unknown>) => response.write(encode(chunk));
send({ type: "start", messageId: "answer-transition" });
send({ type: "text-start", id: "process-text" });
send({ type: "text-delta", id: "process-text", delta: Array.from({ length: 8 }, (_, i) => `第${i + 1}阶段:正在核对管道压力与模拟时段,本阶段数据仅用于分析过程。`).join("\n\n") });
for (let i = 0; i < 6; i++) send({ type: "data-progress", data: { id: `step-${i}`, phase: "tool", title: `核对资料步骤 ${i + 1}`, detail: "查询监测记录并检查时段与单位。", status: i === 5 ? "running" : "completed" } });
send({ type: "text-end", id: "process-text" });
publishFinal = () => send({ type: "data-final_answer", data: { content: finalText, ui_block_ids: [] } });
finish = () => { send({ type: "finish", finishReason: "stop" }); response.end(); };
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
await page.route("**/api/v1/responses", (route) => route.continue({ url: `http://127.0.0.1:${(server.address() as AddressInfo).port}/stream` }));
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.locator("textarea").fill("核对压力");
await panel.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
const message = panel.locator('[data-agent-message-id="answer-transition"]');
await expect(message.getByText("核对资料步骤 6", { exact: true })).toBeVisible();
return { panel, message, publishFinal: () => publishFinal(), finish: () => finish(), close: async () => {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
} };
}
for (const width of [1440, 320]) {
for (const reducedMotion of ["reduce", "no-preference"] as const) {
test(`final report settles once before transport close at ${width}px with ${reducedMotion}`, async ({ page }, testInfo) => {
await page.emulateMedia({ reducedMotion });
const stream = await openStream(page, width);
try {
const { message } = stream;
await message.locator("[data-agent-progress]").evaluate((node) => { node.setAttribute("data-original-progress", "true"); });
stream.publishFinal();
await expect(message.getByText(finalText, { exact: true })).toBeVisible();
await expect(message.getByRole("button", { name: "复制回复", exact: true })).toBeVisible();
await expect(message.getByText("正在回复", { exact: true })).toHaveCount(0);
await expect(message.locator("details[data-agent-process]")).not.toHaveAttribute("open");
const settled = await message.boundingBox();
await message.screenshot({ path: testInfo.outputPath(`report-ready-${width}-${reducedMotion}.png`) });
stream.finish();
await expect(stream.panel.getByRole("button", { name: "发送 Agent 指令", exact: true })).toBeVisible();
const after = await message.boundingBox();
expect(Math.abs(after!.height - settled!.height)).toBeLessThanOrEqual(1);
expect(Math.abs(after!.y - settled!.y)).toBeLessThanOrEqual(1);
await message.getByRole("button", { name: "查看处理过程", exact: true }).click();
const process = message.locator("details[data-agent-process]");
await expect(process.getByText(/第1阶段:/)).toBeVisible();
await expect(process.locator('[data-original-progress="true"]')).toBeVisible();
} finally { await stream.close(); }
});
}
}
for (const reducedMotion of ["reduce", "no-preference"] as const) test(`reading a live process still folds on completion and retains tool details with ${reducedMotion}`, async ({ page }) => {
await page.emulateMedia({ reducedMotion });
const stream = await openStream(page, 1440);
try {
const { message, panel } = stream;
const tool = message.locator("details[data-agent-tool]").last();
await tool.locator("summary").click();
const detail = tool.getByText("查询监测记录并检查时段与单位。", { exact: true });
await expect(detail).toBeVisible();
await expect(tool).toHaveAttribute("data-disclosure-motion", "idle");
const scroll = panel.locator(".agent-conversation-scroll");
await scroll.hover();
await page.mouse.wheel(0, -300);
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
const reading = message.getByText(/第1阶段:/).first();
await expect(reading).toBeInViewport();
// Finish the reader's wheel movement and any preceding disclosure layout
// before measuring the separate final-report handoff.
let lastTop: number | undefined;
await expect.poll(async () => {
const top = (await reading.boundingBox())!.y;
const stable = top === lastTop;
lastTop = top;
return stable;
}).toBe(true);
stream.publishFinal();
await expect(message.getByRole("button", { name: "复制回复", exact: true })).toBeVisible();
const process = message.locator("details[data-agent-process]");
await expect(process).not.toHaveAttribute("open");
await expect(detail).toBeHidden();
await expect(message.locator("[data-answer-phase]")).toHaveAttribute("data-answer-phase", "settled");
await expect(message.getByText(finalText, { exact: true })).toBeInViewport();
stream.finish();
await expect(panel.getByRole("button", { name: "发送 Agent 指令", exact: true })).toBeVisible();
await expect(process).not.toHaveAttribute("open");
await message.getByRole("button", { name: "查看处理过程", exact: true }).click();
await expect(process).toHaveAttribute("open", "");
await expect(process.locator("details[data-agent-tool]").last()).toHaveAttribute("open", "");
await expect(detail).toBeVisible();
await message.getByRole("button", { name: "查看处理过程", exact: true }).click();
await expect(process).not.toHaveAttribute("open");
} finally { await stream.close(); }
});
@@ -0,0 +1,78 @@
import { expect, test } from "@playwright/test";
import type { InteractionAnswer, InteractionRequest } from "../../src/features/agent/api/interaction-requests";
import { mockAgentApi } from "./support/mock-agent-api";
for (const width of [1440, 375, 320]) {
test(`question card aligns wrapped choices and accepts a text-only answer at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
await mockAgentApi(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.setContent('<html lang="zh-CN"><head><link rel="stylesheet" href="/src/styles.css"></head><body><main id="question-reader" data-agent-theme="chat" style="max-width:700px;margin:16px auto;padding:12px"></main></body></html>');
await page.evaluate(async () => {
const entry = await (await fetch("/src/main.tsx")).text();
const reactUrl = entry.match(/from "([^"\n]*\/react\.js[^"\n]*)"/)?.[1];
const clientUrl = entry.match(/from "([^"\n]*\/react-dom_client\.js[^"\n]*)"/)?.[1];
if (!reactUrl || !clientUrl) throw new Error("Vite React module paths unavailable");
const reactModule = await import(reactUrl);
const React = reactModule.default ?? reactModule;
const clientModule = await import(clientUrl);
const { createRoot } = clientModule.default ?? clientModule;
const { AgentInteractionRequestCard } = await import("/src/features/agent/components/agent-interaction-request-card.tsx");
const root = createRoot(document.getElementById("question-reader"));
function Fixture({ custom }: { custom: boolean }) {
const [draft, setDraft] = React.useState([] as InteractionAnswer[]);
const [submitted, setSubmitted] = React.useState(false);
const record: InteractionRequest = { version: "agent-interaction-request@1", interaction_id: custom ? "free-text" : "closed-choices", message_id: "m", turn_id: "t", call_id: "c",
questions: [{ question_id: "target", title: "确认要定位的对象", body: custom ? "可直接填写需要定位的对象和地址线索。" : "当前记录包含公司上报地址和三组模拟管段位置。不同目标会在地图上定位到不同位置,请选择要定位的对象。",
options: custom ? [] : [
{ id: "company", label: "公司上报地址:安徽省滁州市凤阳县西泉镇的公司点位" },
{ id: "pipe1", label: "模拟管段 Pipe19026(用户给出的约315米位置)" },
{ id: "pipe2", label: "模拟管段 Pipe18751(用户给出的约516米位置)" },
{ id: "pipe3", label: "模拟管段 Pipe18750(用户给出的约523米位置)" }
], mode: "single", required: true, allow_custom: custom }],
status: submitted ? "answered" : "pending", revision: submitted ? 1 : 0, draft_revision: 0, draft: [], created_at: "2026-10-28T09:19:24Z",
...(submitted ? { answers: draft.map((answer: InteractionAnswer) => ({ ...answer, selected_options: [] })) } : {}) };
return React.createElement(AgentInteractionRequestCard, { entry: { record, draft, dirty: draft.length > 0, busy: false }, checking: false,
onChange: (next: InteractionAnswer[]) => setDraft(next), onAction: (action: string) => { if (action === "reply") setSubmitted(true); } });
}
const render = (custom: boolean) => root.render(React.createElement(Fixture, { key: String(custom), custom }));
Object.assign(window, { showTextOnlyQuestion: () => render(true) });
render(false);
});
const card = page.getByRole("article", { name: "需要补充的信息" });
await expect(card.getByRole("radio")).toHaveCount(4);
await expect(card.getByRole("textbox")).toHaveCount(0);
await expect(card.getByRole("button", { name: "提交回答", exact: true })).toBeDisabled();
const choice = card.getByRole("radio").last();
const backgroundBefore = await choice.locator("xpath=ancestor::label").evaluate((element) => getComputedStyle(element).backgroundColor);
await choice.focus();
await page.keyboard.press("Space");
await expect(choice).toBeChecked();
const backgroundAfter = await choice.locator("xpath=ancestor::label").evaluate((element) => getComputedStyle(element).backgroundColor);
expect(backgroundAfter).not.toBe(backgroundBefore);
const offsets = await card.getByRole("radio").evaluateAll((inputs) => inputs.map((input) => {
const label = input.closest("label")!;
const text = label.lastElementChild!;
const radioBounds = input.getBoundingClientRect();
const textBounds = text.getBoundingClientRect();
return Math.abs(radioBounds.top + radioBounds.height / 2 - textBounds.top - parseFloat(getComputedStyle(text).lineHeight) / 2);
}));
expect(Math.max(...offsets)).toBeLessThanOrEqual(1);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await card.screenshot({ path: testInfo.outputPath(`question-choices-${width}.png`) });
await page.evaluate(() => (window as unknown as { showTextOnlyQuestion: () => void }).showTextOnlyQuestion());
await expect(card.getByRole("radio")).toHaveCount(0);
const custom = card.getByRole("textbox", { name: "确认要定位的对象:补充说明" });
await custom.fill("请定位公司上报地址,并保留现有管段标记。");
await expect(card.getByRole("button", { name: "提交回答", exact: true })).toBeEnabled();
await card.screenshot({ path: testInfo.outputPath(`question-custom-${width}.png`) });
await card.getByRole("button", { name: "提交回答", exact: true }).click();
const reply = page.getByRole("group", { name: "已提交的回答" });
await expect(reply.getByText("已回答", { exact: true })).toBeVisible();
await expect(card).toHaveCount(0);
await expect(page.getByRole("textbox")).toHaveCount(0);
await expect(reply.getByText("请定位公司上报地址,并保留现有管段标记。", { exact: true })).toBeVisible();
});
}
@@ -0,0 +1,173 @@
import { expect, test } from "@playwright/test";
import { responseFixtureWriter } from "./support/responses-fixture";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import type { InteractionRequest } from "../../src/features/agent/api/interaction-requests";
for (const width of [1440, 320]) {
test(`host question restores draft and requires explicit submit at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page, undefined, undefined, {}, [], { runtimeMode: "backend" });
await mockAgentApi(page, { sessions: [{ session_id: "host-question", title: "待补充范围", created_at: Date.now(), updated_at: Date.now() }] });
const headers = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, PUT, POST, OPTIONS", "Access-Control-Allow-Headers": "Authorization, Content-Type, X-Project-Id" };
let record: InteractionRequest = { version: "agent-interaction-request@1", interaction_id: "i", message_id: "m", turn_id: "t", call_id: "c", questions: [{ question_id: "q", title: "核查范围", body: "选择本次核查范围", options: [{ id: "north", label: "北侧管网" }, { id: "all", label: "全部管网" }], mode: "single", required: true, allow_custom: true }], revision: 0, draft_revision: 0, draft: [], status: "pending", created_at: new Date().toISOString() };
const writes: string[] = [];
const inputs: Array<{ id: string; text: string; delivery: string; status: string }> = [];
await page.route("**/api/v1/responses", (route) => {
const body = route.request().postDataJSON();
expect(body.stream).toBe(false);
const input = { id: body.tjwater.input_id, text: body.input, delivery: body.tjwater.delivery, status: "pending" };
inputs.push(input);
return route.fulfill({ headers, status: 202, json: input });
});
await page.route("**/api/v1/session/host-question/inputs", (route) => route.fulfill({ headers, json: { items: inputs } }));
await page.route("**/api/v1/session/host-question?**", (route) => route.fulfill({ headers, json: { session_id: "host-question", title: "待补充范围", purpose: "ordinary_chat", status: "active", is_streaming: false, messages: [{ id: "m", role: "assistant", parts: [{ type: "text", text: "请补充核查范围。" }] }] } }));
await page.route("**/api/v1/session/host-question/interactions**", async (route) => {
const request = route.request();
if (request.method() === "OPTIONS") return route.fulfill({ status: 204, headers });
const action = new URL(request.url()).pathname.split("/").at(-1)!;
if (request.method() === "GET") return route.fulfill({ headers, json: action === "interactions" ? { items: [record] } : record });
writes.push(action);
const body = request.postDataJSON();
if (action === "draft") record = { ...record, draft: body.answers, draft_revision: record.draft_revision + 1 };
if (action === "cancel") record = { ...record, status: "cancelled", revision: 1 };
return route.fulfill({ headers, json: record });
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.getByRole("button", { name: /历史/ }).click();
await page.locator("button.agent-history-session-trigger").filter({ hasText: "待补充范围" }).click();
const card = panel.getByRole("article", { name: "需要补充的信息" });
await expect(card).toBeVisible();
await expect(card.getByRole("button", { name: "提交回答" })).toBeDisabled();
await expect(card.getByRole("button", { name: "跳过", exact: true })).toHaveCount(0);
await card.getByRole("radio", { name: "北侧管网" }).check();
await card.getByRole("textbox", { name: "核查范围:补充说明" }).fill("优先检查压力下降区域");
await expect.poll(() => record.draft[0]?.custom_text).toBe("优先检查压力下降区域");
expect(writes.length).toBeGreaterThan(0);
expect(writes.every((action) => action === "draft")).toBe(true);
await expect(card.getByRole("button", { name: "提交回答" })).toBeEnabled();
await card.screenshot({ path: `/tmp/host-question-${width}.png` });
const bounds = await card.boundingBox();
expect(bounds!.x).toBeGreaterThanOrEqual(0);
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(width);
await panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill("先核对压力测点");
await panel.getByRole("button", { name: "引导当前 Agent 任务", exact: true }).click();
await expect.poll(() => inputs.map((input) => input.delivery)).toEqual(["steer"]);
await expect(card.getByText("等待回答", { exact: true })).toBeVisible();
expect(writes.every((action) => action === "draft")).toBe(true);
await card.getByRole("button", { name: "取消提问" }).click();
await expect(card.getByText("已取消", { exact: true })).toBeVisible();
await expect(card.getByRole("button", { name: "提交回答" })).toHaveCount(0);
});
}
for (const responseMode of ["completed_without_event", "pending_conflict"] as const) {
test(`collaboration restores a persisted question after ${responseMode} and submits its answer`, async ({ page }, testInfo) => {
const width = responseMode === "pending_conflict" ? 320 : 1440;
await page.setViewportSize({ width, height: 900 });
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page, undefined, undefined, {}, [], { runtimeMode: "backend" });
const sessionId = "collaboration-question";
await mockAgentApi(page, { sessions: [{ session_id: sessionId, title: "公司位置协作", purpose: "task_collaboration", created_at: Date.now(), updated_at: Date.now() }] });
const headers = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, PUT, POST, OPTIONS", "Access-Control-Allow-Headers": "Authorization, Content-Type, X-Project-Id" };
let pending = false;
let listReads = 0;
let responses = 0;
let record: InteractionRequest = { version: "agent-interaction-request@1", interaction_id: "locate-target", message_id: "question-message", turn_id: "question-turn", call_id: "ask-call", questions: [{ question_id: "target", title: "选择定位对象", body: "选择公司上报地址或模拟管段。", options: [{ id: "company", label: "公司上报地址" }, { id: "pipes", label: "模拟管段" }], mode: "single", required: true, allow_custom: true }], revision: 0, draft_revision: 0, draft: [], status: "pending", created_at: new Date().toISOString() };
await page.route(`**/api/v1/session/${sessionId}?**`, (route) => route.fulfill({ headers, json: { session_id: sessionId, title: "公司位置协作", purpose: "task_collaboration", status: "active", is_streaming: false, messages: [{ id: "context-message", role: "assistant", parts: [{ type: "text", text: "调查报告已就绪。" }] },
...(record.delivery ? [{ id: record.delivery.native_message_id, role: "user", parts: [{ type: "text", text: "用户提交的提问回复:\n选择定位对象:公司上报地址;使用报告中的公司位置" }] }] : [])] } }));
await page.route(`**/api/v1/session/${sessionId}/interactions**`, async (route) => {
const request = route.request();
if (request.method() === "OPTIONS") return route.fulfill({ status: 204, headers });
const action = new URL(request.url()).pathname.split("/").at(-1)!;
if (request.method() === "GET") {
if (action === "interactions") listReads++;
return route.fulfill({ headers, json: action === "interactions" ? { items: pending ? [record] : [] } : record });
}
const body = request.postDataJSON();
if (action === "draft") record = { ...record, draft: body.answers, draft_revision: record.draft_revision + 1 };
if (action === "reply") record = { ...record, status: "answered", revision: 1,
answers: body.answers.map((answer: { question_id: string; selected_option_ids: string[]; custom_text: string }) => ({ ...answer, selected_options: record.questions[0].options.filter((option) => answer.selected_option_ids.includes(option.id)) })),
delivery: { native_message_id: `msg_${"a".repeat(32)}`, status: "queued", revision: 0 } };
if (action === "resume") record = { ...record, delivery: { ...record.delivery!, status: "queued", revision: record.delivery!.revision + 1 } };
return route.fulfill({ headers, json: record });
});
await page.route("**/api/v1/responses", (route) => {
responses++;
const body = route.request().postDataJSON();
if (responses === 1) {
pending = true;
if (responseMode === "pending_conflict") return route.fulfill({ headers, status: 409, json: { code: "INTERACTION_PENDING", message: "请先回答或取消当前提问。" } });
} else {
expect(body.tjwater.interaction_id).toBe(record.interaction_id);
expect(record.answers?.[0]).toMatchObject({ selected_option_ids: responseMode === "pending_conflict" ? ["company"] : [], custom_text: "使用报告中的公司位置" });
if (responseMode === "pending_conflict" && responses === 2) {
record = { ...record, delivery: { ...record.delivery!, status: "blocked", revision: 1 } };
return route.fulfill({ headers, status: 503, body: "暂时无法继续,请重试。" });
}
record = { ...record, delivery: { ...record.delivery!, status: "delivered", revision: record.delivery!.revision + 1 } };
}
const write = responseFixtureWriter(sessionId);
return route.fulfill({ headers: { ...headers, "Content-Type": "text/event-stream" }, body: [
{ type: "start", messageId: responses === 1 ? "question-message" : "located-message" },
{ type: "text-start", id: "text" },
{ type: "text-delta", id: "text", delta: responses === 1 ? "请选择要定位的对象。" : "已定位公司上报地址。" },
{ type: "text-end", id: "text" },
{ type: "finish", finishReason: "stop" }
].map(write).join("") });
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.getByRole("button", { name: /历史/ }).click();
await page.locator("button.agent-history-session-trigger").filter({ hasText: "公司位置协作" }).click();
await expect.poll(() => listReads).toBeGreaterThan(0);
await expect(panel.getByRole("article", { name: "需要补充的信息" })).toHaveCount(0);
const readsBeforeSend = listReads;
const input = panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" });
await input.fill("定位到公司上报地址");
await panel.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
const card = panel.getByRole("article", { name: "需要补充的信息" });
await expect(card).toBeVisible();
expect(listReads).toBeGreaterThan(readsBeforeSend);
await expect(panel.getByText("离线", { exact: true })).toHaveCount(0);
await expect(panel.getByRole("button", { name: "引导当前 Agent 任务", exact: true })).toBeDisabled();
if (responseMode === "pending_conflict") await card.getByRole("radio", { name: "公司上报地址", exact: true }).check();
await card.getByRole("textbox", { name: "选择定位对象:补充说明" }).fill("使用报告中的公司位置");
await card.screenshot({ path: testInfo.outputPath(`recovered-${responseMode}.png`) });
await card.getByRole("button", { name: "提交回答", exact: true }).click();
const reply = panel.getByRole("group", { name: "已提交的回答" });
await expect(reply).toHaveCount(1);
await expect(reply.getByText("已回答", { exact: true })).toBeVisible();
await expect(reply.getByText("使用报告中的公司位置", { exact: false })).toBeVisible();
if (responseMode === "pending_conflict") {
await expect(reply.getByText("继续失败,可以重试", { exact: true })).toBeVisible();
await reply.getByRole("button", { name: "重试继续", exact: true }).click();
}
await expect(reply.getByText("已继续处理", { exact: true })).toBeVisible();
await expect(card).toHaveCount(0);
await expect(panel.getByText("用户提交的提问回复", { exact: false })).toHaveCount(0);
expect(await reply.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
await reply.screenshot({ path: testInfo.outputPath(`submitted-${responseMode}.png`) });
await reply.getByText("使用报告中的公司位置", { exact: true }).click({ clickCount: 3 });
await page.keyboard.press("Control+c");
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("使用报告中的公司位置");
await expect(panel.getByText("已定位公司上报地址。", { exact: true })).toBeVisible();
expect(responses).toBe(responseMode === "pending_conflict" ? 3 : 2);
await expect(reply).toHaveCount(1);
await input.fill("继续核查");
await expect(panel.getByRole("button", { name: "发送 Agent 指令", exact: true })).toBeEnabled();
await page.reload({ waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
await panel.getByRole("button", { name: /历史/ }).click();
await page.locator("button.agent-history-session-trigger").filter({ hasText: "公司位置协作" }).click();
await expect(reply).toHaveCount(1);
await expect(reply.getByText("使用报告中的公司位置", { exact: false })).toBeVisible();
await expect(panel.getByText("用户提交的提问回复", { exact: false })).toHaveCount(0);
await expect(card).toHaveCount(0);
});
}
@@ -0,0 +1,131 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { responsesFixture } from "./support/responses-fixture";
for (const width of [1440, 320]) {
test(`restored clarification process belongs to its continuation at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
const sessionId = "clarification-process";
const nativeReplyId = `msg_${"b".repeat(32)}`;
const continuationId = `answer_${nativeReplyId}`;
const progress = (id: string, title: string) => ({ type: "data-progress", data: {
id, title, phase: "tool", status: "completed", detail: "合成演练记录。"
} });
const messages: Array<{ id: string; role: string; parts: Array<{ type: string; [key: string]: unknown }> }> = [
{ id: "original-user", role: "user", parts: [{ type: "text", text: "分别模拟三根管道。" }] },
{ id: "clarifying-message", role: "assistant", parts: [progress("query", "核对管道与时段")] },
{ id: nativeReplyId, role: "user", parts: [{ type: "text", text: "用户提交的提问回复:按顺序对应时段" }] },
{ id: continuationId, role: "assistant", parts: [
progress("query", "完成对应时段模拟"),
{ type: "text", text: "三根管道的模拟结果已整理,仅供演练。" }
] }
];
const interaction = {
version: "agent-interaction-request@1", interaction_id: "clarification", message_id: "clarifying-message",
turn_id: "clarification-turn", call_id: "ask", status: "answered", revision: 1, draft_revision: 0,
draft: [], created_at: "2026-10-28T10:00:00Z",
questions: [{ question_id: "mapping", title: "管道与时段对应", body: "请确认对应顺序。", mode: "single", required: true,
allow_custom: false, options: [{ id: "ordered", label: "按顺序对应时段" }] }],
answers: [{ question_id: "mapping", selected_option_ids: ["ordered"], custom_text: "", selected_options: [{ id: "ordered", label: "按顺序对应时段" }] }],
delivery: { native_message_id: nativeReplyId, revision: 2, status: "blocked", error_code: "CONTINUATION_UNCONFIRMED" }
};
messages[2].parts.push({ type: "data-interaction_reply", data: interaction });
let releaseInteractions!: () => void;
let interactionGate = new Promise<void>((resolve) => { releaseInteractions = resolve; });
await mockAgentApi(page, { sessions: [{ session_id: sessionId, title: "补充问题后继续模拟", created_at: Date.now(), updated_at: Date.now() }] });
const headers = { "Access-Control-Allow-Origin": "*" };
let writes = 0;
let finishBeforeNextInput = false;
const queued: Array<{ id: string; text: string; delivery: string; status: string }> = [];
await page.route(`**/api/v1/session/${sessionId}?**`, (route) => route.fulfill({ headers, json: {
session_id: sessionId, title: "补充问题后继续模拟", purpose: "ordinary_chat", status: "active", is_streaming: false, messages
} }));
await page.route(`**/api/v1/session/${sessionId}/interactions**`, async (route) => {
if (route.request().method() !== "GET") writes++;
await interactionGate;
return route.fulfill({ headers, json: { items: [interaction] } });
});
await page.route(`**/api/v1/session/${sessionId}/inputs`, (route) => route.fulfill({ headers, json: { items: queued } }));
await page.route("**/api/v1/responses", (route) => {
writes++;
const body = route.request().postDataJSON();
if (finishBeforeNextInput) {
expect(typeof body.input === "string" ? body.input : body.input[0].content[0].text).toBe("继续检查模拟结果");
if (body.stream === false) {
interaction.delivery.status = "delivered";
return route.fulfill({ headers, status: 409, json: { code: "RESPONSE_FINISHED", message: "Finished before admission" } });
}
return route.fulfill({ headers: { ...headers, "Content-Type": "text/event-stream" }, body: responsesFixture([
{ type: "start", messageId: "next-response" }, { type: "text-start", id: "text" },
{ type: "text-delta", id: "text", delta: "已开始新一轮核查。" }, { type: "text-end", id: "text" }, { type: "finish", finishReason: "stop" }
]) });
}
expect(body.stream).toBe(false);
expect(body.conversation).toBe(sessionId);
const receipt = { id: body.tjwater.input_id, text: body.input, delivery: body.tjwater.delivery, status: "pending" };
queued.push(receipt);
return route.fulfill({ headers, status: 202, json: receipt });
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.getByRole("button", { name: /历史/ }).click();
await page.locator("button.agent-history-session-trigger").filter({ hasText: "补充问题后继续模拟" }).click();
const continuation = panel.locator(`[data-agent-message-id="${continuationId}"]`);
await expect(continuation.getByText("三根管道的模拟结果已整理,仅供演练。", { exact: true })).toBeVisible();
const reply = panel.getByRole("group", { name: "已提交的回答" });
await expect(reply).toHaveCount(1);
await expect(panel.getByText(/用户提交的提问回复/)).toHaveCount(0);
await expect(reply.getByText("按顺序对应时段", { exact: true })).toBeVisible();
releaseInteractions();
await expect(panel.locator('[data-agent-message-id="clarifying-message"]')).toHaveCount(0);
await expect(panel.getByRole("button", { name: "查看处理过程", exact: true })).toHaveCount(1);
const toggle = continuation.getByRole("button", { name: "查看处理过程", exact: true });
await toggle.click();
const process = continuation.locator("details[data-agent-process]");
await expect(process.getByText("核对管道与时段", { exact: true })).toBeVisible();
await expect(process.getByText("完成对应时段模拟", { exact: true })).toBeVisible();
await expect(process.locator("[data-agent-progress]")).toHaveCount(1);
await expect(process.getByRole("list", { name: "全部执行进度" }).locator("li")).toHaveCount(2);
await expect(process.getByRole("list", { name: "全部执行进度" }).locator("[data-tool-name]")).toHaveText(["核对管道与时段", "完成对应时段模拟"]);
await expect(reply.getByText("继续失败,可以重试", { exact: true })).toBeVisible();
await expect(reply.getByRole("button", { name: "重试继续", exact: true })).toBeEnabled();
const reportBox = (await continuation.locator("[data-answer-parts]").first().boundingBox())!;
const processBox = (await process.boundingBox())!;
expect(processBox.y).toBeGreaterThanOrEqual(reportBox.y + reportBox.height);
await panel.screenshot({ path: testInfo.outputPath(`continued-process-${width}.png`) });
// A hard reload must keep the same human card while metadata is delayed again.
interactionGate = new Promise<void>((resolve) => { releaseInteractions = resolve; });
await page.reload({ waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
await panel.getByRole("button", { name: /历史/ }).click();
await page.locator("button.agent-history-session-trigger").filter({ hasText: "补充问题后继续模拟" }).click();
await expect(reply).toHaveCount(1);
await expect(panel.getByText(/用户提交的提问回复/)).toHaveCount(0);
await expect(panel.locator('[data-agent-message-id="clarifying-message"]')).toHaveCount(0);
await expect(toggle).toHaveCount(1);
releaseInteractions();
await expect(reply.getByRole("button", { name: "重试继续", exact: true })).toBeEnabled();
expect(writes).toBe(0);
const prompt = panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" });
await prompt.fill("先核查压力数据");
await panel.getByRole("button", { name: "消息发送方式", exact: true }).click();
await page.getByRole("menuitemradio", { name: "排队处理", exact: true }).click();
await panel.getByRole("button", { name: "将消息加入 Agent 队列", exact: true }).click();
await expect(panel.getByRole("region", { name: "待处理消息队列" }).getByText("先核查压力数据", { exact: true })).toBeVisible();
await prompt.fill("优先检查第二根管道");
await panel.getByRole("button", { name: "消息发送方式", exact: true }).click();
await page.getByRole("menuitemradio", { name: "引导当前任务", exact: true }).click();
await panel.getByRole("button", { name: "引导当前 Agent 任务", exact: true }).click();
await expect.poll(() => queued.map((item) => item.delivery)).toEqual(["queue", "steer"]);
expect(writes).toBe(2);
finishBeforeNextInput = true;
await prompt.fill("继续检查模拟结果");
await panel.getByRole("button", { name: "引导当前 Agent 任务", exact: true }).click();
await expect(panel.getByText("已开始新一轮核查。", { exact: true })).toBeVisible();
expect(writes).toBe(4);
});
}
+112
View File
@@ -0,0 +1,112 @@
import { expect, test, type Locator } from "@playwright/test";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { responseFixtureWriter } from "./support/responses-fixture";
async function readRowStyle(row: Locator) {
return row.evaluate((element) => {
const style = getComputedStyle(element);
const text = element.querySelector("span.min-w-0 > span")!;
const icon = element.querySelector('[role="img"]')!;
return {
columns: style.gridTemplateColumns.split(" ")[0],
gap: style.columnGap,
fontSize: style.fontSize,
titleWeight: getComputedStyle(text).fontWeight,
titleLineHeight: getComputedStyle(text).lineHeight,
iconWidth: icon.getBoundingClientRect().width,
iconHeight: icon.getBoundingClientRect().height
};
});
}
for (const width of [1440, 320]) {
test(`live progress matches archived rows and retains folding at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
await mockAgentApi(page);
const steps = [
{ id: "start", title: "已收到请求", status: "completed", detail: "正在核对当前项目。" },
{ id: "context", title: "Agent 正在处理请求", status: "running", detail: "Agent 正在执行推理、工具调用或结果整理。" },
{ id: "data", title: "获取业务数据", status: "running", detail: "正在核对附近阀门、关联管道与隔离范围。" },
{ id: "isolation", phase: "tool", title: "阀门隔离分析", status: "running", tool: {
name: "mcp_tjwater_analyze_valve_isolation", call_id: "isolation-call", input: { link_ids: [533793, 486121, 523616] }
} }
];
let finish!: () => void;
const server = createServer((_request, response) => {
response.writeHead(200, { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" });
const write = responseFixtureWriter();
const send = (chunk: Record<string, unknown>) => response.write(write(chunk));
send({ type: "start", messageId: "consistent-progress" });
for (const step of steps) send({ type: "data-progress", data: step });
finish = () => {
for (const step of steps) send({ type: "data-progress", data: { ...step, status: "completed" } });
send({ type: "data-final_answer", data: { content: "已核对附近可用于隔离的阀门。" } });
send({ type: "finish", finishReason: "stop" });
response.end();
};
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
await page.route("**/api/v1/responses", (route) => route.continue({ url: `http://127.0.0.1:${(server.address() as AddressInfo).port}` }));
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill("找下附近可用的阀门,用于隔离");
await panel.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
const message = panel.locator('[data-agent-message-id="consistent-progress"]');
const progress = message.locator("[data-agent-progress]");
const recent = progress.getByRole("list", { name: "最近执行进度" });
await expect(recent.locator("li")).toHaveCount(3);
const container = message.locator("details[data-agent-live-process]");
await expect(container).toHaveCSS("background-color", "rgb(255, 255, 255)");
await expect(container).toHaveCSS("border-radius", "12px");
await expect(container).toHaveCSS("border-top-width", "1px");
await expect(progress).toHaveCSS("border-top-width", "0px");
const containerStyle = await container.evaluate((element) => {
const style = getComputedStyle(element);
return [style.borderColor, style.borderRadius, style.backgroundColor];
});
const liveRow = recent.locator("li").filter({ hasText: "Agent 正在处理请求" });
const liveStyle = await readRowStyle(liveRow);
expect(liveStyle).toMatchObject({ columns: "24px", gap: "8px", fontSize: "14px", titleWeight: "500", iconWidth: 24, iconHeight: 24 });
await expect(liveRow.getByRole("img", { name: "进行中", exact: true })).toBeVisible();
await progress.getByRole("button", { name: "展开执行进度", exact: true }).click();
await expect(progress.getByRole("list", { name: "全部执行进度" }).locator("li")).toHaveCount(4);
await progress.getByRole("button", { name: "收起执行进度", exact: true }).click();
await expect(recent.locator("li")).toHaveCount(3);
const tool = progress.locator("details[data-agent-tool]");
await expect(tool).not.toHaveAttribute("open");
await tool.locator("summary").click();
await expect(tool.locator("pre")).toContainText("533793");
await message.screenshot({ path: testInfo.outputPath(`live-progress-${width}.png`) });
finish();
const archived = message.locator("details[data-agent-process]");
await expect(archived).not.toHaveAttribute("open");
await message.getByRole("button", { name: "查看处理过程", exact: true }).click();
await expect(archived).toHaveAttribute("open", "");
await expect(archived).toHaveCSS("background-color", "rgb(255, 255, 255)");
expect(await archived.evaluate((element) => {
const style = getComputedStyle(element);
return [style.borderColor, style.borderRadius, style.backgroundColor];
})).toEqual(containerStyle);
const archivedRow = archived.getByRole("list", { name: "全部执行进度" }).locator("li").filter({ hasText: "Agent 正在处理请求" });
expect(await readRowStyle(archivedRow)).toEqual(liveStyle);
await expect(archivedRow.getByRole("img", { name: "已完成", exact: true })).toBeVisible();
await expect(tool).toHaveAttribute("open", "");
await message.getByRole("button", { name: "查看处理过程", exact: true }).click();
await expect(progress).toBeHidden();
await message.getByRole("button", { name: "查看处理过程", exact: true }).click();
await expect(progress).toBeVisible();
await message.screenshot({ path: testInfo.outputPath(`archived-progress-${width}.png`) });
expect(await progress.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
}
+18 -5
View File
@@ -79,7 +79,7 @@ for (const width of [1440, 375, 320]) {
const composer = page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }); const composer = page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" });
await composer.fill("核对压力变化"); await composer.fill("核对压力变化");
await page.getByRole("button", { name: "发送 Agent 指令" }).click(); await page.getByRole("button", { name: "发送 Agent 指令" }).click();
const deliveryMode = page.getByRole("button", { name: "运行中消息发送方式" }); const deliveryMode = page.getByRole("button", { name: "消息发送方式", exact: true });
await expect(deliveryMode).toHaveText("引导"); await expect(deliveryMode).toHaveText("引导");
await deliveryMode.focus(); await deliveryMode.focus();
await page.keyboard.press("ArrowDown"); await page.keyboard.press("ArrowDown");
@@ -93,12 +93,21 @@ for (const width of [1440, 375, 320]) {
await expect(deliveryMode).toBeFocused(); await expect(deliveryMode).toBeFocused();
await expect(deliveryMode).toHaveText("引导"); await expect(deliveryMode).toHaveText("引导");
await expect(page.getByRole("button", { name: "停止 Agent 指令" })).toBeVisible(); await expect(page.getByRole("button", { name: "停止 Agent 指令" })).toBeVisible();
const stopButton = page.getByRole("button", { name: "停止 Agent 指令" });
await expectThemeColor(stopButton, "background-color", "--status-danger-soft");
await expectThemeColor(stopButton, "color", "--status-danger-foreground");
await stopButton.hover();
await expectThemeColor(stopButton, "background-color", "--status-danger-border");
await page.locator("form.agent-panel-input-form").screenshot({ path: testInfo.outputPath("stop-action-hover.png"), animations: "disabled" });
const actionSlot = await page.getByRole("button", { name: "停止 Agent 指令" }).boundingBox(); const actionSlot = await page.getByRole("button", { name: "停止 Agent 指令" }).boundingBox();
await composer.fill("补充当前任务"); await composer.fill("补充当前任务");
await expect(page.getByRole("button", { name: "停止 Agent 指令" })).toHaveCount(0); await expect(page.getByRole("button", { name: "停止 Agent 指令" })).toHaveCount(0);
const sendSlot = await page.getByRole("button", { name: "引导当前 Agent 任务" }).boundingBox(); const sendButton = page.getByRole("button", { name: "引导当前 Agent 任务" });
expect(sendSlot).toEqual(actionSlot); await expect(sendButton).toHaveAttribute("data-agent-action", "send");
await page.getByRole("button", { name: "引导当前 Agent 任务" }).click(); expect(await sendButton.boundingBox()).toEqual(actionSlot);
await expect(page.getByRole("button", { name: "权限批准模式" })).toHaveText("请求批准");
await page.locator("form.agent-panel-input-form").screenshot({ path: testInfo.outputPath("streaming-composer-with-draft.png"), animations: "disabled" });
await sendButton.click();
const queue = page.getByRole("list", { name: "待处理消息" }); const queue = page.getByRole("list", { name: "待处理消息" });
const pendingRow = queue.getByRole("listitem").filter({ hasText: "补充当前任务" }); const pendingRow = queue.getByRole("listitem").filter({ hasText: "补充当前任务" });
const pendingMode = pendingRow.getByRole("button", { name: "消息操作:补充当前任务" }); const pendingMode = pendingRow.getByRole("button", { name: "消息操作:补充当前任务" });
@@ -131,7 +140,11 @@ for (const width of [1440, 375, 320]) {
const expandMessage = longRow.getByRole("button", { name: "展开全文" }); const expandMessage = longRow.getByRole("button", { name: "展开全文" });
await expect(expandMessage).toBeVisible(); await expect(expandMessage).toBeVisible();
await expect(expandMessage).toHaveAttribute("aria-expanded", "false"); await expect(expandMessage).toHaveAttribute("aria-expanded", "false");
await expect(page.locator("form").getByRole("list", { name: "待处理消息" })).toBeVisible(); await expect(queue).toBeVisible();
await expect(page.locator("form").getByRole("list", { name: "待处理消息" })).toHaveCount(0);
const queueBox = (await page.getByRole("region", { name: "待处理消息队列" }).boundingBox())!;
const editorBox = (await page.locator("form").boundingBox())!;
expect(queueBox.y + queueBox.height).toBeLessThanOrEqual(editorBox.y);
await page.screenshot({ path: testInfo.outputPath("pending-messages-compact.png"), animations: "disabled" }); await page.screenshot({ path: testInfo.outputPath("pending-messages-compact.png"), animations: "disabled" });
await expandMessage.click(); await expandMessage.click();
await expect(longRow.getByRole("button", { name: "收起全文" })).toHaveAttribute("aria-expanded", "true"); await expect(longRow.getByRole("button", { name: "收起全文" })).toHaveAttribute("aria-expanded", "true");
@@ -0,0 +1,132 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { responsesFixture } from "./support/responses-fixture";
for (const width of [1440, 320]) {
test(`native user message copy preserves source text at ${width}px`, async ({ page, context }) => {
await page.setViewportSize({ width, height: 900 });
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await mockWorkbenchContext(page);
await mockAgentApi(page);
let responseIndex = 0;
await page.route("**/api/v1/responses", (route) => route.fulfill({
headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" },
body: responsesFixture([
{ type: "start", messageId: `copy-answer-${++responseIndex}` },
{ type: "data-final_answer", data: { content: "已核对管道。" } },
{ type: "finish", finishReason: "stop" }
])
}));
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
const prompt = "能直接高亮三根管道吗";
await panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill(prompt);
await panel.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
await expect(panel.getByText("已核对管道。", { exact: true })).toBeVisible();
const answerParagraph = panel.locator(".agent-streaming-response p").first();
await answerParagraph.click({ clickCount: 3 });
await page.keyboard.press("Control+c");
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("已核对管道。");
const text = panel.locator(".agent-panel-user-message p");
await text.click({ clickCount: 3 });
await page.keyboard.press("Control+c");
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(prompt);
// Exact substrings retain native copy behavior.
await text.evaluate((paragraph) => {
const range = document.createRange();
range.setStart(paragraph.firstChild!, 2);
range.setEnd(paragraph.firstChild!, 6);
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
});
await page.keyboard.press("Control+c");
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(prompt.slice(2, 6));
// A selection containing another message must retain its native separators.
const expectedCrossMessage = await panel.evaluate((element) => {
const user = element.querySelector(".agent-panel-user-message p")!;
const answer = element.querySelector(".agent-streaming-response p")!;
const range = document.createRange();
range.setStart(user.firstChild!, 0);
range.setEnd(answer.firstChild!, answer.textContent!.length);
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
return window.getSelection()!.toString();
});
expect(expectedCrossMessage).toContain("已核对管道。");
await page.keyboard.press("Control+c");
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(expectedCrossMessage);
// Multiline source text keeps blank lines, indentation and interior spaces.
const multiline = "第一行\n\n 第二行 内容";
await panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill(multiline);
await panel.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
const lastText = panel.locator(".agent-panel-user-message p").last();
await expect(lastText).toHaveText(multiline, { useInnerText: false });
await expect(panel.locator(".agent-streaming-response p")).toHaveCount(2);
await expect(panel.locator(".agent-streaming-response p").last()).toHaveText("已核对管道。");
await panel.evaluate((element) => {
const user = element.querySelectorAll(".agent-panel-user-message p");
const answer = element.querySelectorAll(".agent-streaming-response p");
const range = document.createRange();
range.setStart(user[user.length - 1].firstChild!, 0);
range.setEnd(answer[answer.length - 1].firstChild!, 0);
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
});
await page.keyboard.press("Control+c");
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(multiline);
});
}
for (const width of [1440, 320]) {
test(`assistant rich text keeps meaningful native copy at ${width}px`, async ({ page, context }) => {
await page.setViewportSize({ width, height: 900 });
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await mockWorkbenchContext(page);
await mockAgentApi(page);
const markdown = "首段包含**加粗**文字。\n\n第二段保留段落。\n\n```text\n first\n second\n```\n\n| 名称 | 数值 |\n| --- | --- |\n| 流量 | 12 |";
await page.route("**/api/v1/responses", (route) => route.fulfill({
headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" },
body: responsesFixture([
{ type: "start", messageId: "rich-copy-answer" },
{ type: "data-final_answer", data: { content: markdown } },
{ type: "finish", finishReason: "stop" }
])
}));
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill("核对格式");
await panel.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
const response = panel.locator(".agent-streaming-response");
await expect(response.locator("table")).toBeVisible();
await response.locator("p").first().click({ clickCount: 3 });
await page.keyboard.press("Control+c");
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("首段包含加粗文字。");
for (const selector of ["p", "pre", "table"]) {
const nativeText = await response.evaluate((element, selector) => {
const nodes = element.querySelectorAll(selector);
const range = document.createRange();
if (selector === "p") {
range.setStart(nodes[0].firstChild!, 0);
range.setEnd(nodes[1].firstChild!, nodes[1].firstChild!.textContent!.length);
} else {
range.selectNodeContents(nodes[0]);
}
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
return window.getSelection()!.toString();
}, selector);
if (selector === "p") expect(nativeText).toContain("\n\n");
if (selector === "pre") expect(nativeText).toContain(" first\n second");
if (selector === "table") expect(nativeText).toContain("\t");
await page.keyboard.press("Control+c");
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(nativeText);
}
await expect(panel.getByRole("button", { name: /复制/ }).first()).toBeAttached();
});
}
+1
View File
@@ -13,6 +13,7 @@ type MockAgentApiOptions = {
export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}) { export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}) {
const corsHeaders = { "Access-Control-Allow-Origin": "*" }; const corsHeaders = { "Access-Control-Allow-Origin": "*" };
await page.route("http://127.0.0.1:8787/health", (route) => route.fulfill({ headers: corsHeaders, json: { ok: true, ready: true } }));
const model = options.model ?? { const model = options.model ?? {
id: "test/model", id: "test/model",
label: "测试模型", label: "测试模型",