diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 3947d8c5..3b1b7964 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -4582,8 +4582,8 @@ - 根因:两层。其一,`finish_reason=length` 与超时中断后的半截正文仍走 `run.completed` → `complete_consultation_response`,公开回执故意不含 `modelFinishReason`,界面无法区分正常结束与夹断。其二,咨询 `agent.stream` 未设可见输出预算,也未关闭 Flash 默认 thinking;隐藏推理与可见正文共用 `max_tokens`,更容易在标题中途 `length` 停住。`AGENT_TIMEOUT_MS = 110_000` 与路由 `maxDuration = 120` 仍可能在组答阶段掐流,旧逻辑同样把已发出的半截当成功。 - 修复:可见正文在 `length` 结束,或超时/中止时已有输出,改为 `run.failed` / `answer_truncated`,保留已流出文本,账务走 `cancel`。咨询流设置 `maxOutputTokens = 8192`,并对当前供应商与 `openai` 兼容键关闭 thinking。客户端保存半截助手消息并提示未完成、不会扣点,不再要求 `run.completed` 才落盘。 - 验证:`frontend/tests/consultation-agentic-runtime.test.ts` 覆盖 `length` 与超时半截不得 `run.completed`、不得调用 `onComplete`;`consultation-workflow-contract.test.ts` 锁定输出预算与 thinking disabled;`consultation-recovery.test.ts` 与 `chat-stream-layout.test.ts` 锁定半截落盘与提示;`agent-observability.test.ts` 将 `answer_truncated` 纳入已知错误码。 -- 防复发:有可见正文不等于咨询完成。`finish_reason=length`、超时半截不得再映射为 `run.completed`。咨询生成必须显式保留可见 token 预算;不得依赖 Flash 默认 thinking 与提供方默认 `max_tokens`。公开回执仍不得带 `modelFinishReason`,失败码必须能单独说明夹断。 -- 相关记录:BUG-280、BUG-277、BUG-340 +- 防复发:有可见正文不等于咨询完成。`finish_reason=length`、超时半截不得再映射为 `run.completed`。咨询生成必须显式保留 8192 可见 token 预算。若打开 provider thinking,必须走独立 `thinking.delta`,不得把推理写进 `answer.delta`。公开回执仍不得带 `modelFinishReason`,失败码必须能单独说明夹断。 +- 相关记录:BUG-280、BUG-277、BUG-340、BUG-346 - 复发自:无 - 修复版本:待提交 @@ -5126,8 +5126,8 @@ - 根因:BUG-305 只修了咨询路径。纠正 `agent.stream` 没有 `maxOutputTokens`、没有关闭 thinking,并把任意 `finish` 当成成功。进度事件 `tool.activity started` 被客户端丢掉,所以长计算期间用户只能干等。 - 修复:咨询与纠正共用 `agentGenerationSettings`(8192 可见 token,thinking disabled)。纠正在 `finish_reason=length` 时走 `answer_truncated`、不扣点、不把半截写入成功 Turn;客户端保留已流出正文并提示未完成。等待态改为公开工具进度(正在比较候选时间等)和 8 秒后的已用时,不展示模型思维链。 - 验证:`frontend/tests/rectification-v9-stream.test.ts` 的 length 夹断不得 `run.completed`;`frontend/tests/rectification-v9-agent.test.ts` 锁定纠正流的输出预算与 thinking disabled;`frontend/tests/agent-activity-progress.test.ts`、`frontend/tests/rectification-agentic-entry.test.ts`、`frontend/tests/consultation-workflow-contract.test.ts`。 -- 防复发:纠正与咨询必须走同一套 generation settings。不得为了等待体验打开 provider thinking。`tool.activity started` 必须驱动 Orb 文案。`finish_reason=length` 不得映射为 `run.completed`。 -- 相关记录:BUG-305、BUG-282、BUG-329 +- 防复发:纠正与咨询必须走同一套 generation settings 与 8192 可见预算。不得为了等待体验打开 provider thinking。若打开 thinking,必须走独立 `thinking.delta`,不得把推理写进 `answer.delta`。`tool.activity started` 必须驱动 Orb 文案。`finish_reason=length` 不得映射为 `run.completed`。 +- 相关记录:BUG-305、BUG-282、BUG-329、BUG-345 - 复发自:BUG-305(咨询已修,纠正仍用默认 thinking 与任意 finish) - 修复版本:`6a44c778` @@ -5211,3 +5211,35 @@ - 复发自:BUG-341(第三条咨询路径扩大了 mode 联合类型,未改类型谓词) - 修复版本:eeee0e49 +## BUG-345 | 生时纠正把工具重试自述当成回答,且 kind 在 schema 里是自由字符串 + +- 状态:resolved +- 首次发现:2026-08-21 +- 最近更新:2026-08-21 +- 影响面:`POST /api/rectification/agent`、`runV9AgentTurn`、证据工具 `proposedKind`/`domain` schema、生时纠正对话 +- 用户现象:用户补了一条升学经历后,结算气泡里是英文工具重试自述(大意:proposedKind 被拒、education 不是合法 kind、改用 education_start),没有中文跟进问句。棋盘已推进,说明计分跑过,失败的是用户可见正文。 +- 触发条件:用户给出升学类经历,模型把 `missing_evidence_categories` 里的领域名 `education` 当成 `proposedKind` 调用证据工具。 +- 根因:与 BUG-278 / BUG-277 同类。`proposedKind` 与 `domain` 在 Zod schema 里是自由字符串,白名单只在 `execute` 的 `isEvidenceKind()` 里;模型在 JSON schema 里看不到 `education_start`。被拒后把重试过程写进 `text-delta`。纠正 runner 立刻把每个 `text-delta` 发成 `answer.delta`,没有咨询路径那种契约门。BUG-340 关闭 provider thinking 后,过程自述更容易落到正文通道。 +- 修复:证据 kind/domain 改为枚举,模型在调用前能看到 `education_start`。工具失败未恢复成功前的正文、以及纯英文过程自述,不得进入 `answer.delta`。中文思维链走独立的 `thinking.delta`(来自 `reasoning-delta` 或契约未绿的中文片段);英文过程自述丢弃。纠正打开 provider thinking。界面灰色「思考过程」,有正文后默认折叠。 +- 验证:`frontend/tests/rectification-v10-tool-contract.test.ts` 拒绝 `proposedKind: "education"`;`frontend/tests/rectification-v9-stream.test.ts` 英文重试自述不得出现在公共流,用户只拿到中文回答;思维链映射与折叠 UI 的源码合同。 +- 防复发:模型必须遵守的词汇写在 schema 枚举里,不要只写在 execute。`answer.delta` 不得承载工具重试自述。思维链必须是独立事件类型。`thinking.delta` 不得写入 run_phase 表。 +- 相关记录:BUG-277、BUG-278、BUG-340、BUG-346 +- 复发自:BUG-277(纠正未做契约门)、BUG-278(kind 未枚举)、BUG-340(关 thinking 后自述改走正文) +- 修复版本:待提交 + +## BUG-346 | 普通咨询未打开思维链通道,灰色折叠 UI 只在生时纠正生效 + +- 状态:resolved +- 首次发现:2026-08-21 +- 最近更新:2026-08-21 +- 影响面:`POST /api/consult`、`streamAgentResponse`、咨询页 `ChatMessageRow` +- 用户现象:生时纠正已有灰色「思考过程」、正文出来后折叠;普通咨询仍无思维链。 +- 触发条件:网页个人咨询 / 无分钟百科 / 声明窗口咨询。 +- 根因:BUG-305 / BUG-340 为保住可见 token 预算,把咨询 `consultationGenerationSettings` 固定为 thinking disabled,并丢弃 `reasoning-*`。BUG-345 只给纠正打开独立 `thinking.delta` 与折叠 UI。 +- 修复:咨询同样启用 provider thinking,8192 可见预算不变。`reasoning-delta` 经中文清洗后发 `thinking.delta`;契约未绿的 `text-delta` 仍按 BUG-277 丢弃,不升格为思维链。咨询页把 `thinkingText` 接到共用 `ChatMessageRow`,有正文后默认折叠。会话落盘不保存思维链。 +- 验证:`frontend/tests/consultation-agentic-runtime.test.ts` 中文 reasoning 进 thinking 通道、英文过程自述不进正文;`consultation-workflow-contract.test.ts` 锁定 `thinking: "enabled"`;`chat-stream-layout.test.ts` 锁定咨询页与折叠 UI。 +- 防复发:咨询与纠正的思维链必须是 `thinking.delta`,不得混进 `answer.delta`。BUG-277 的契约前正文仍须丢弃。打开 thinking 不得降低 8192 可见预算,`finish_reason=length` 仍不得当完成。 +- 相关记录:BUG-277、BUG-305、BUG-340、BUG-345 +- 复发自:BUG-305(咨询关 thinking 保预算)、BUG-345(只修了纠正) +- 修复版本:待提交 + diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 48387826..08583df7 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -740,6 +740,22 @@ button:disabled { cursor: default; opacity: .45; } .message-content { min-width: 0; max-width: min(80%, 680px); } .message-bubble { overflow: hidden; border: 0; padding: var(--space-3) var(--space-4); border-radius: var(--radius-lg); background: var(--color-canvas-muted); } .message-assistant .message-bubble { border-radius: 0; background: transparent; padding: var(--space-3) 0; } +.message-thinking { + margin: 0 0 var(--space-3); +} +.message-thinking > summary { + cursor: pointer; + color: var(--color-ink-tertiary); + font-size: 13px; + line-height: 1.5; +} +.message-thinking-body { + margin-top: var(--space-2); + color: var(--color-ink-tertiary); + font-size: 13px; + line-height: 1.55; + white-space: pre-wrap; +} .rectification-message-entry { min-width: 0; } .message-actions { display: flex; diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 3b402d96..581f4655 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -217,7 +217,7 @@ type ReplyOutcome = { readonly phase: Extract; readonly replyOrdinal: number; }; -type StreamingReply = { sessionId: string; text: string; activity?: AgentActivityView }; +type StreamingReply = { sessionId: string; text: string; activity?: AgentActivityView; thinkingText?: string }; type BirthPlace = { label: string; lat: number; @@ -1169,6 +1169,9 @@ export default function Home() { const activeStreamingActivity = streamingReply && streamingReply.sessionId === activeSession?.id ? streamingReply.activity : undefined; + const activeStreamingThinking = streamingReply && streamingReply.sessionId === activeSession?.id + ? streamingReply.thinkingText + : undefined; const activeReplyOutcome = replyOutcome && replyOutcome.sessionId === activeSession?.id ? replyOutcome : null; const replyPhase: ChatReplyPhase = isLoading ? consultationPhase === "recovering" ? "recovering" : "generating" @@ -1977,7 +1980,13 @@ export default function Home() { title: session.title, theme: session.theme, model_id: session.modelId, - messages: session.messages, + messages: session.messages.map((message) => ({ + role: message.role, + text: message.text, + techniqueTruth: message.techniqueTruth, + agentExecutionReceipt: message.agentExecutionReceipt, + workflowReceipt: message.workflowReceipt, + })), session_type: session.sessionType, rectification_case_id: session.rectificationCaseId, updated_at: new Date(session.updatedAt).toISOString(), @@ -3230,12 +3239,14 @@ export default function Home() { const reader = response.body.getReader(); const decoder = new TextDecoder(); let answer = ""; + let thinking = ""; const updateStreamingAnswer = (activity?: AgentActivityView) => { const partialReply = parseAgentReply(answer).text; latestPartialReply = partialReply; setStreamingReply((current) => ({ sessionId, text: partialReply, + thinkingText: current?.sessionId === sessionId ? current.thinkingText : undefined, activity: activity ? nextActivityView(current?.sessionId === sessionId ? current.activity : undefined, activity) : current?.sessionId === sessionId ? current.activity : undefined, @@ -3277,6 +3288,15 @@ export default function Home() { if ((response.headers.get("content-type") ?? "").includes("application/x-ndjson")) { const parser = createNdjsonParser((event) => { if (event.type === "answer.delta") answer += event.text; + if (event.type === "thinking.delta") { + thinking += event.text; + setStreamingReply((current) => ({ + sessionId, + text: current?.sessionId === sessionId ? current.text : parseAgentReply(answer).text, + thinkingText: thinking, + activity: current?.sessionId === sessionId ? current.activity : undefined, + })); + } if (event.type === "run.completed") { runCompleted = true; agentExecutionReceipt = event.receipt; @@ -3312,6 +3332,7 @@ export default function Home() { messages: [...userSession.messages, { role: "assistant", text: reply.text, + thinkingText: thinking || undefined, techniqueTruth, workflowReceipt, agentExecutionReceipt, @@ -3355,6 +3376,7 @@ export default function Home() { messages: [...userSession.messages, { role: "assistant", text: reply.text, + thinkingText: thinking || undefined, techniqueTruth, workflowReceipt, agentExecutionReceipt, @@ -3803,7 +3825,7 @@ export default function Home() { ) : (
- {chatMessageViews(activeSession.messages, isLoading, activeStreamingText, activeStreamingActivity).map((message, _, views) => { + {chatMessageViews(activeSession.messages, isLoading, activeStreamingText, activeStreamingActivity, activeStreamingThinking).map((message, _, views) => { const showActions = message.role === "assistant" && message.state === "settled" && Boolean(message.text); diff --git a/frontend/src/components/chat-message-row.tsx b/frontend/src/components/chat-message-row.tsx index c6e46100..cfe964eb 100644 --- a/frontend/src/components/chat-message-row.tsx +++ b/frontend/src/components/chat-message-row.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useLayoutEffect, useRef } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { AgentActivityStatus } from "@/components/agent-activity-status"; import { prefetchOnIdle } from "@/components/chat-chunk-prefetch"; @@ -31,6 +31,30 @@ function motionPreferred() { if (motionPreferred()) prefetchOnIdle(loadGsap); +function MessageThinkingTrace({ + text, + hasAnswer, +}: Readonly<{ + text: string; + hasAnswer: boolean; +}>) { + const [userOpen, setUserOpen] = useState(null); + const open = userOpen ?? !hasAnswer; + if (!text.trim()) return null; + return ( +
{ + setUserOpen((event.currentTarget as HTMLDetailsElement).open); + }} + > + 思考过程 +
{text}
+
+ ); +} + export function AgentAvatar() { return