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