diff --git a/docs/agent-chat-ui-design.md b/docs/agent-chat-ui-design.md index 9b48575..eeae1b2 100644 --- a/docs/agent-chat-ui-design.md +++ b/docs/agent-chat-ui-design.md @@ -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 补齐相同表示,不通过文本前缀猜测对应关系,也不改写历史文件。给模型的“用户提交的提问回复”协议前缀只用于推理输入。 diff --git a/docs/style-design-guide.md b/docs/style-design-guide.md index 659cbf5..9908bba 100644 --- a/docs/style-design-guide.md +++ b/docs/style-design-guide.md @@ -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)。 + +聊天的生成、续接与刷新恢复共用一个过程容器和一份步骤列表。过程入口在回复底部靠左,语音和复制靠右。发送保持蓝色圆形按钮,停止使用柔和红底与深红图标,悬停时红底略深。 diff --git a/src/features/agent/answer-ui/answer-parts-view.tsx b/src/features/agent/answer-ui/answer-parts-view.tsx index 77b8499..25c4059 100644 --- a/src/features/agent/answer-ui/answer-parts-view.tsx +++ b/src/features/agent/answer-ui/answer-parts-view.tsx @@ -23,7 +23,7 @@ export function AnswerPartsView({ onAsk?: (selection: AnswerUiSelection) => void; }) { return ( -
+
{parts.map((part, index) => part.type === "answer-ui" ? ( ({ @@ -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); + }); +}); diff --git a/src/features/agent/answer-ui/answer-presentation.ts b/src/features/agent/answer-ui/answer-presentation.ts index 8a6d23f..c5e8d06 100644 --- a/src/features/agent/answer-ui/answer-presentation.ts +++ b/src/features/agent/answer-ui/answer-presentation.ts @@ -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(); + const anchored = new Set(); + 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 }; } diff --git a/src/features/agent/answer-ui/contract.ts b/src/features/agent/answer-ui/contract.ts index a56b485..f857c49 100644 --- a/src/features/agent/answer-ui/contract.ts +++ b/src/features/agent/answer-ui/contract.ts @@ -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[number]; export type AnswerUiBlock = { version: typeof ANSWER_UI_VERSION; block_id: string; + phase?: "process" | "final"; fallback_text: string; spec: Spec; }; diff --git a/src/features/agent/api/client.ts b/src/features/agent/api/client.ts index 14737ce..7fc5456 100644 --- a/src/features/agent/api/client.ts +++ b/src/features/agent/api/client.ts @@ -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; }; -export type AgentApiClient = { +export type AgentApiClient = InteractionRequestApi & { + getHealth: (signal: AbortSignal) => Promise; downloadFile: (path: string) => Promise; submitResponseInput: (sessionId: string, text: string, delivery: AgentInputDelivery, id: string) => Promise; listResponseInputs: (sessionId: string) => Promise; @@ -191,6 +198,7 @@ export type AgentApiClient = { requestId: string, options: { sessionId: string; reply: AgentPermissionReply; message?: string } ) => Promise; + 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("/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(`/question/${encodeURIComponent(requestId)}/reply`, { method: "POST", @@ -505,7 +528,8 @@ async function fetchWithFallback( path: string, init?: RequestInit, getAccessToken?: AccessTokenProvider, - getProjectId?: () => Promise + getProjectId?: () => Promise, + 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" || diff --git a/src/features/agent/api/interaction-requests.test.ts b/src/features/agent/api/interaction-requests.test.ts new file mode 100644 index 0000000..a66fbd8 --- /dev/null +++ b/src/features/agent/api/interaction-requests.test.ts @@ -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 }); +}); diff --git a/src/features/agent/api/interaction-requests.ts b/src/features/agent/api/interaction-requests.ts new file mode 100644 index 0000000..56d6525 --- /dev/null +++ b/src/features/agent/api/interaction-requests.ts @@ -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; +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; +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) { + 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; diff --git a/src/features/agent/api/responses-transport.ts b/src/features/agent/api/responses-transport.ts index ce17610..637b18f 100644 --- a/src/features/agent/api/responses-transport.ts +++ b/src/features/agent/api/responses-transport.ts @@ -1,10 +1,11 @@ import { HttpChatTransport, type UIMessage, type UIMessageChunk } from "ai"; const record = (value: unknown): value is Record => 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): Readabl buffer += decoder.decode(); flushBlocks(controller); if (buffer.trim()) parse(buffer, controller); - if (!terminal) throw new Error("Responses 流在完成前断开,请重新打开会话查看状态"); + if (!terminal) throw new AgentStreamDisconnectedError("Responses 流在完成前断开,正在恢复会话连接"); }, })); } diff --git a/src/features/agent/components/agent-chat-message-chrome.tsx b/src/features/agent/components/agent-chat-message-chrome.tsx index d2b8c88..694e98e 100644 --- a/src/features/agent/components/agent-chat-message-chrome.tsx +++ b/src/features/agent/components/agent-chat-message-chrome.tsx @@ -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 ( -
- {running ? ( - - - ) : interactive ? ( - - 交互回答 - - ) : null} +
+ + +
+ ); +} + +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 ( +
+
+ {children} +
+ {speech} + {copy} +
+
); } @@ -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 ( { try { @@ -62,10 +73,18 @@ export function AgentChatMessageActions({ parts }: { parts: AnswerUiPart[] }) { } }} > - {copied ? - + {error ? "复制失败,请选择文字复制" : copied ? "已复制回复文字与交互内容摘要" : ""} diff --git a/src/features/agent/components/agent-command-panel.tsx b/src/features/agent/components/agent-command-panel.tsx index d658d93..0369037 100644 --- a/src/features/agent/components/agent-command-panel.tsx +++ b/src/features/agent/components/agent-command-panel.tsx @@ -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; approvalMode?: AgentApprovalMode; uiResults?: AgentUiResult[]; + interactionRequests?: InteractionRequestsController; reviewPanel?: ReactNode; messageInbox?: ComponentProps; 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(); const [loadingSessionId, setLoadingSessionId] = useState(null); const [scrollRequestId, setScrollRequestId] = useState(0); + const [submissionError, setSubmissionError] = useState(null); const prefersReducedMotion = useReducedMotion(); const historyTriggerRef = useRef(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({ {historyPanel}

供水助手

+ className="flex shrink-0 items-center gap-1 whitespace-nowrap text-xs leading-4 text-muted-foreground sm:gap-1.5"> {agentConnectionMetadata[connectionStatus].label}

@@ -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 ? : null} {reviewPanel} + {interactionRequests ? message.role === "user").map((message) => message.id))} /> : null} {messageInbox ? : null} @@ -590,11 +615,11 @@ export function AgentCommandPanel({ layoutDependency={centeredComposer} transition={{ layout: agentLayoutTransition }} > + [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({ } }} > - - - {contentReference ?
-
- -
-

{contentReference.title}

-
查看引用内容 -

{contentReference.text}

-
+ + {contentReference ?
+ +
+ + {contentReference.title} + 查看引用内容 + +

{contentReference.text}

+
+ {["解释这些内容", "比较差异与关联", "核查依据与缺口"].map((intent) => )}
- -
+ +
: null} {promptReference ? ( -
-