diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 6fd45862..868f30bf 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -5403,4 +5403,38 @@ - 复发自:BUG-354(按领域交错思考/分析,步骤树截断) - 修复版本:`995b752e` +## BUG-357 | 生时纠正思考与结论同一样式,过程自述进了正文 + +- 状态:resolved +- 首次发现:2026-08-23 +- 最近更新:2026-08-23 +- 影响面:生时纠正对话、`runV9AgentTurn`、`ChatMessageRow`、灰色思考折叠 +- 用户现象:生时纠正里思考和模型结论是同一样式;用户可见气泡里先出现长段内部权衡(日期精度、batch 写入、skill 规则),最后才是确认口误和下一个追问,分不清哪段是思考、哪段是给用户的话。 +- 触发条件:生时纠正已读取 Case 且工具成功后,模型把中文过程自述写进 `text-delta`。 +- 根因:BUG-354 为保住正文预算关闭了纠正 provider thinking。`reasoning-delta` 不再出现,中文自述改走 `text-delta`。契约门只在 Case 未加载或工具失败时把正文改送到 `thinking.delta`;工具成功后全部当作回答。界面上思考折叠和正文也没有咨询页那种「思考 / 回复」分层。 +- 修复:保持 `thinking: disabled` 与 16384 正文预算。对已加载 Case 的 `text-delta` 按段落拆成过程自述与口语结论:自述进 `thinking.delta`,结论进 `answer.delta` 并作为落盘正文。界面与咨询对齐:折叠「思考」(13px 次要色,有正文后默认收起)和完整「回复」。 +- 验证:`frontend/tests/rectification-spoken-answer.test.ts`、`frontend/tests/rectification-v9-stream.test.ts`、`frontend/tests/rectification-agentic-entry.test.ts`、`frontend/tests/chat-stream-layout.test.ts` +- 防复发:纠正组答不得在不拆开正文预算的情况下重开 provider thinking 来充当思考 UI。`answer.delta` 不得承载 skill/schema/batch 过程自述。思考与回复必须分层渲染,不得共用同一段正文样式。 +- 相关记录:BUG-345、BUG-354、BUG-356 +- 复发自:BUG-345(关 thinking 后自述改走正文)、BUG-354(纠正与咨询一并关闭 provider thinking) +- 修复版本: + +## BUG-358 | 生时纠正离开页面再回来,当轮回答被另一段开场覆盖 + +- 状态:resolved +- 首次发现:2026-08-23 +- 最近更新:2026-08-23 +- 影响面:生时纠正会话恢复、`shouldStartOpening`、Turn 水合、聊天组件 remount key +- 用户现象:当轮已经看到 Agent 追问,跳到别的页面再回来后,原来的回答不见了;会话里多出一段新的助手正文(例如从教育追问变成感情追问)。 +- 触发条件:生时纠正 Case 已有用户+助手同一物理 Turn;离开校正表面后再进入。常见路径是侧栏切走再切回,客户端仍留着创建时的 `shouldStartOpening=true`。 +- 根因:聊天组件用最后一条 Turn id 做 React key,新回合会整棵重挂,`openingStarted` 被清掉。创建 Case 时的 `shouldStartOpening` 在开场成功后仍保持 true;切走再回来若不再走 open RPC,就会对已有账本再发一轮 `opening`。模型读到已有证据后写出下一条追问,盖过用户刚看过的当轮正文。已落盘的过程自述也会原样回到气泡。 +- 修复:有持久化 Turn 时禁止自动 opening;开场一旦发出就把 `shouldStartOpening` 收成 false。水合 key 只在空历史/`ready` 之间切换,不跟最后一条 id。回合成功后刷新 Case turns。恢复时把已落盘过程自述拆进折叠「思考」,正文只留口语结论。 +- 验证:`frontend/tests/rectification-agentic-entry.test.ts`、`frontend/tests/rectification-spoken-answer.test.ts` +- 防复发:已有 Turn 的校正表面不得因 remount 再发 opening。`shouldStartOpening` 只能表示「这个 Case 还没有第一轮」;开场发出后必须立刻关掉。水合 key 不得使用会随新回合变化的 Turn id。 +- 相关记录:BUG-176、BUG-345、BUG-357 +- 复发自:BUG-176(同一物理 Turn 的助手正文在恢复时丢失;后来又用最后一条 id 做 key,把 opening 状态一起冲掉) +- 修复版本: + + + diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index a29277c4..5040896f 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -873,6 +873,11 @@ button:disabled { cursor: default; opacity: .45; } .consultation-report-analysis .message-markdown { color: var(--color-ink-strong); } +.consultation-thinking-report .message-thinking { + margin-bottom: 0; + padding-bottom: var(--space-3); + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent); +} .message-markdown ul.markdown-list, .message-markdown ol.markdown-list { display: grid; diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 5de50be7..44c0ca03 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -4034,7 +4034,7 @@ export default function Home() { {rectificationSurfaceOpen && rectificationCaseId && ( 0 ? "ready" : "loading"}`} caseId={rectificationCaseId} sessionId={rectificationSessionId ?? ""} readonly={rectificationReadonly} @@ -4044,7 +4044,13 @@ export default function Home() { selectedModelId={activeSession?.modelId ?? ""} onSelectModel={(modelId) => void selectSessionModel(modelId)} onMessagesChange={handleRectificationMessagesChange} - onCompleted={() => void refreshAccount()} + onOpeningConsumed={() => setRectificationShouldStartOpening(false)} + onCompleted={() => { + void refreshAccount(); + if (rectificationCaseId && rectificationSessionId) { + void refreshRectificationCase(rectificationCaseId, rectificationSessionId); + } + }} onPendingChange={setRectificationMutationPending} onProfileIncomplete={handleRectificationProfileIncomplete} onSaved={() => void refreshAccount()} diff --git a/frontend/src/components/agent-activity-status.tsx b/frontend/src/components/agent-activity-status.tsx index cc86cbfa..0f56bb3a 100644 --- a/frontend/src/components/agent-activity-status.tsx +++ b/frontend/src/components/agent-activity-status.tsx @@ -59,7 +59,7 @@ function MessageThinkingTrace({ setUserOpen((event.currentTarget as HTMLDetailsElement).open); }} > - 思考过程 + 思考
{text}
); diff --git a/frontend/src/components/chat-message-row.tsx b/frontend/src/components/chat-message-row.tsx index 6f7df95b..17667de5 100644 --- a/frontend/src/components/chat-message-row.tsx +++ b/frontend/src/components/chat-message-row.tsx @@ -64,6 +64,8 @@ export function ChatMessageRow({ const thinkingSections = message.thinkingSections ?? []; const showReport = thinkingSections.length > 0; const showThinkingPanel = !showReport && (showActivity || Boolean(message.thinkingText?.trim())); + const showSpokenAnswer = !showReport && Boolean(message.text); + const stackedThinkingAndAnswer = showThinkingPanel && showSpokenAnswer; useEntryEffect(() => { const row = messageRow.current; @@ -89,6 +91,29 @@ export function ChatMessageRow({ return () => motion.revert(); }, [message.role]); + const thinkingPanel = showThinkingPanel + ? ( + + ) + : null; + const spokenAnswer = showSpokenAnswer + ? ( + + ) + : null; + return (
)} - {showThinkingPanel && ( - - )} - {!showReport && message.text && ( - + {stackedThinkingAndAnswer ? ( +
+ {thinkingPanel} +
+ {spokenAnswer} +
+
+ ) : ( + <> + {thinkingPanel} + {spokenAnswer} + )} ) :

{message.text}

} diff --git a/frontend/src/components/conversational-birth-time-rectification.tsx b/frontend/src/components/conversational-birth-time-rectification.tsx index e13ccb5e..e4273bf7 100644 --- a/frontend/src/components/conversational-birth-time-rectification.tsx +++ b/frontend/src/components/conversational-birth-time-rectification.tsx @@ -34,6 +34,7 @@ export type ConversationalBirthTimeRectificationProps = Readonly<{ onProfileIncomplete?: () => void; onSaved?: (time: string, status: "accepted" | "confirmed") => void; onStartConsultation?: () => void; + onOpeningConsumed?: () => void; pendingConsultationQuestion?: string | null; onRestart?: () => void; headerSlot: HTMLElement | null; diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 8bda6fad..157c511a 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -33,6 +33,7 @@ import { isPublicRectificationMethod, isPublicRectificationTool, } from "@/lib/rectification-agentic/v9/public-receipt"; +import { finalizeRectificationSpokenAndThinking } from "@/lib/rectification-agentic/v9/spoken-answer"; import { CHOICE_STOP_MESSAGE, choiceCardUserMessage, @@ -136,6 +137,7 @@ type RectificationAgenticChatProps = Readonly<{ onProfileIncomplete?: () => void; onSaved?: (time: string, status: "accepted" | "confirmed") => void; onStartConsultation?: () => void; + onOpeningConsumed?: () => void; pendingConsultationQuestion?: string | null; onRestart?: () => void; headerSlot: HTMLElement | null; @@ -200,9 +202,13 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag const key = `persisted-${turn.id}-${index}`; if (turn.role === "assistant") { const failed = persistedTurnFailed(turn); + const raw = failed ? "" : turn.text ?? ""; + const split = raw ? finalizeRectificationSpokenAndThinking(raw) : { thinking: "", spoken: raw }; + const thinkingText = split.thinking.trim() || undefined; return [{ role: "assistant", - text: failed ? "" : turn.text ?? "", + text: split.spoken || raw, + ...(thinkingText ? { thinkingText } : {}), renderKey: key, state: turn.status === "completed" || failed ? "settled" : "thinking", completedReceipt: completedReceiptFromPersisted(turn.receipt), @@ -235,6 +241,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { onProfileIncomplete, onSaved, onStartConsultation, + onOpeningConsumed, pendingConsultationQuestion, onRestart, headerSlot, @@ -617,10 +624,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { }, [busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onProfileIncomplete, readonly, selectedModelId, sessionId, setPending]); useEffect(() => { + if (initialTurns.length > 0) { + if (shouldStartOpening) onOpeningConsumed?.(); + return; + } if (readonly || openingStarted.current || !shouldStartOpening) return; openingStarted.current = true; + onOpeningConsumed?.(); void send("opening", ""); - }, [readonly, send, shouldStartOpening]); + }, [initialTurns.length, onOpeningConsumed, readonly, send, shouldStartOpening]); const acceptCandidate = useCallback(async (candidateId: string) => { if (!candidateResult || acceptingCandidateId || readonly) return; diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run.ts b/frontend/src/lib/rectification-agentic/v9/agent-run.ts index 690aa0a5..044d841c 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-run.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-run.ts @@ -35,6 +35,11 @@ import { isPublicRectificationToolName, type PublicStreamEvent, } from "./stream-mapping"; +import { + finalizeRectificationSpokenAndThinking, + nextStableChannelDelta, + splitRectificationSpokenAndThinking, +} from "./spoken-answer"; export type V9RunBilling = Readonly<{ reserve(): Promise<{ success: boolean; reason?: string; status: number }>; @@ -468,6 +473,9 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise(); const events: PublicStreamEvent[] = []; @@ -609,11 +617,20 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise Promise, + emit: (event: PublicStreamEvent) => Promise, + finalize = false, +): Promise<{ thinking: string; spoken: string; spokenDelta: string }> { + const split = finalize + ? finalizeRectificationSpokenAndThinking(buffer) + : splitRectificationSpokenAndThinking(buffer); + const thinkingDelta = nextStableChannelDelta(publishedThinking, split.thinking); + const thinking = split.thinking.startsWith(publishedThinking) ? split.thinking : publishedThinking; + if (thinkingDelta) { + const thinkingEvent = toPublicThinkingDelta(thinkingDelta); + if (thinkingEvent) await publish(thinkingEvent); + } + const spokenDelta = nextStableChannelDelta(publishedSpoken, split.spoken); + const spoken = split.spoken.startsWith(publishedSpoken) ? split.spoken : publishedSpoken; + const emitSpoken = Boolean(spokenDelta) && Boolean(answerText.trim() || spokenDelta.trim()); + if (emitSpoken) await emit({ type: "answer.delta", text: spokenDelta }); + return { + thinking, + spoken, + spokenDelta: emitSpoken ? spokenDelta : "", + }; +} + export { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION }; diff --git a/frontend/src/lib/rectification-agentic/v9/spoken-answer.ts b/frontend/src/lib/rectification-agentic/v9/spoken-answer.ts new file mode 100644 index 00000000..dfdf7c01 --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/spoken-answer.ts @@ -0,0 +1,77 @@ +/** + * Separate process narration from the user-facing rectification reply. + * + * Provider thinking stays disabled so hidden CoT cannot pinch the spoken + * budget. The model still dumps Chinese self-talk onto text-delta; this + * splitter routes that talk onto `thinking.delta` and keeps only the spoken + * conclusion on `answer.delta`. + */ + +const CJK_RE = /[\u4e00-\u9fff]/; +const INTERNAL_TOKEN_RE = /\b(?:datePrecision|occurredFrom|occurredTo|proposedKind|education_start|missing_evidence|SKILL\.md|rectification-[a-z0-9-]+|focusId|evidenceId|display_date_label)\b/; +const PROCESS_ZH_RE = /skill\s*规则|不得猜补|让我(?:调用|记录|batch|提交)|我(?:决定|倾向|batch)|权衡:|内部矛盾|思维链|调用 batch|datePrecision|occurredFrom|occurredTo/; +const THIRD_PERSON_USER_RE = /用户(?:提到|先(?:说|提到)|说|自己|的核心|想表达|原话|的最终|对年份)/; + +export type SplitSpokenAndThinking = Readonly<{ + thinking: string; + spoken: string; +}>; + +export function isRectificationProcessNarration(text: string): boolean { + const trimmed = text.trim(); + if (!trimmed) return false; + if (/[A-Za-z]{4,}/.test(trimmed) && !CJK_RE.test(trimmed)) return true; + if (INTERNAL_TOKEN_RE.test(trimmed)) return true; + if (PROCESS_ZH_RE.test(trimmed)) return true; + if (THIRD_PERSON_USER_RE.test(trimmed)) return true; + return false; +} + +export function nextStableChannelDelta(published: string, next: string): string { + if (!next.startsWith(published)) return ""; + return next.slice(published.length); +} + +function splitParagraphs(text: string): string[] { + return text + .split(/\n{2,}/) + .flatMap((block) => { + const trimmed = block.trim(); + if (!trimmed) return []; + const lines = trimmed.split(/\n/).map((line) => line.trim()).filter(Boolean); + if (lines.length <= 1) return [trimmed]; + const hasProcess = lines.some(isRectificationProcessNarration); + const hasSpoken = lines.some((line) => !isRectificationProcessNarration(line)); + return hasProcess && hasSpoken ? lines : [trimmed]; + }); +} + +export function splitRectificationSpokenAndThinking(text: string): SplitSpokenAndThinking { + const paragraphs = splitParagraphs(text); + if (paragraphs.length === 0) return { thinking: "", spoken: text }; + + const thinking: string[] = []; + const spoken: string[] = []; + for (const paragraph of paragraphs) { + if (isRectificationProcessNarration(paragraph)) thinking.push(paragraph); + else spoken.push(paragraph); + } + + if (thinking.length === 0) return { thinking: "", spoken: text }; + return { + thinking: thinking.join("\n\n"), + spoken: spoken.join("\n\n"), + }; +} + +export function finalizeRectificationSpokenAndThinking(text: string): SplitSpokenAndThinking { + const split = splitRectificationSpokenAndThinking(text); + if (split.spoken.trim()) return split; + if (!split.thinking.trim()) return { thinking: "", spoken: text }; + const paragraphs = splitParagraphs(split.thinking); + if (paragraphs.length < 2) return { thinking: "", spoken: text }; + return { + thinking: paragraphs.slice(0, -1).join("\n\n"), + spoken: paragraphs.at(-1) ?? text, + }; +} diff --git a/frontend/tests/chat-stream-layout.test.ts b/frontend/tests/chat-stream-layout.test.ts index 4f8b7dfd..282dcf89 100644 --- a/frontend/tests/chat-stream-layout.test.ts +++ b/frontend/tests/chat-stream-layout.test.ts @@ -68,8 +68,10 @@ test("shows honest agent activity states before and during streamed text", () => assert.match(messageRowSource, /正在处理…/); assert.match(messageRowSource, /showActivity = message\.state !== "settled"/); assert.match(messageRowSource, /showThinkingPanel &&/); - assert.match(messageRowSource, /message\.text && \(/); + assert.match(messageRowSource, /showSpokenAnswer/); assert.match(messageRowSource, /