From 42c6de142b006ab75d9a8cb03d7bc1a3fc0f48a9 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 2 Sep 2026 16:30:26 +0800 Subject: [PATCH] fix(rectification): put the collect stem in the assistant bubble The live question slot still rendered the collect prompt below the action icons when the streamed body was only a greeting. Join the GET prompt into that bubble and stop drawing a sibling collect slot. Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 16 ++++ .../components/rectification-agentic-chat.tsx | 79 ++++++++++--------- .../rectification-spoken-collect.test.ts | 19 ++--- 3 files changed, 67 insertions(+), 47 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 92f9cb88..e5d5a45f 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -7504,3 +7504,19 @@ - 相关记录:BUG-449、BUG-461、BUG-469、BUG-471、BUG-485 - 复发自:BUG-485(槽位可见但不是聊天历史) - 修复版本:待发布 + +## BUG-488 | 口述采集题干仍画在动作图标下方,没有进气泡正文 + +- 状态:resolved +- 首次发现:2026-09-02 +- 最近更新:2026-09-02 +- 影响面:`rectification-agentic-chat` 最新助手气泡、`.rectification-question-slot__prompt` +- 用户现象:开场气泡只有打招呼,采集题干仍出现在点赞/复制/重生成图标下面。staging 已是 BUG-487 的 SHA。 +- 触发条件:新 Case opening,`current_question.kind=collect_spoken`,GET 已有 prompt。 +- 根因:BUG-487 把题干接到 `assistant_message` 并允许槽位兜底。直播正文仍可能只有打招呼(replace 未进客户端 raw、或 GET 后才有 current_question),于是 `showCollectSpokenPrompt` 把同一句画在图标下。用户要的是气泡正文,不是槽。 +- 修复:不再渲染采集题槽。最新助手气泡按题干精确身份 `composeCollectSpokenAssistantText`;定稿与 snapshot 把组合文本写进该条消息状态,复制走组合文本。选择卡仍走问题槽。模型仍不自己提问,避免气泡里两句相近问法。不 bump Skill,不放宽确认门。 +- 验证:`rectification-spoken-collect` 锁气泡拼接、禁止 `rectification-question-slot__prompt`;既有 collect-prompt / v9-agent / regenerate / voice 锁保持。 +- 防复发:口述采集题干不得作为动作图标下的兄弟节点。不得用正文字符串判断「有没有问过」。不得同时让模型提问又拼接同一句服务端题干。 +- 相关记录:BUG-471、BUG-485、BUG-487 +- 复发自:BUG-487(服务端拼接后槽位兜底仍可见) +- 修复版本:待发布 diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 1c28dde9..f53ff519 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -323,12 +323,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const previousBoardResult = useRef(null); const runAbort = useRef(null); const choiceActionIds = useRef(new Map()); + const currentQuestionRef = useRef(currentQuestion); + currentQuestionRef.current = currentQuestion; const [compactBoard, setCompactBoard] = useState(false); const [boardOpen, setBoardOpen] = useState(false); const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null)); const boardId = useId(); const boardTitleId = useId(); - const collectSpokenPromptId = useId(); const composerRemainingId = useId(); // The same anchor-and-follow the consultation surface uses: streamed tokens and // new cards land the viewport on the bottom only while the reader is there. @@ -427,6 +428,25 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { return () => controller.abort(); }, [applyCaseSnapshot, caseId, sessionId]); + useEffect(() => { + const prompt = currentQuestion?.kind === "collect_spoken" ? currentQuestion.prompt : null; + if (!prompt || busy || regeneratingMessageKey !== null) return; + setMessages((current) => { + const last = [...current].reverse().find((message) => ( + message.role === "assistant" + && message.state === "settled" + && !message.failed + && Boolean(message.text) + )); + if (!last) return current; + const combined = composeCollectSpokenAssistantText(last.text, prompt); + if (combined === last.text) return current; + return current.map((item) => ( + item.renderKey === last.renderKey ? { ...item, text: combined } : item + )); + }); + }, [busy, currentQuestion, regeneratingMessageKey]); + const send = useCallback(async (action: "opening" | "message" | "read_only", messageText: string) => { const trimmed = action === "message" ? messageText.trim() : ""; if ((action === "message" && !trimmed) || busy || readonly) return; @@ -647,12 +667,18 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const parsed = completed && !streamFailed ? parseAgentReply(raw) : { text: "", title: undefined }; const succeeded = completed && !streamFailed && Boolean(parsed.text); + const collectPrompt = currentQuestionRef.current?.kind === "collect_spoken" + ? currentQuestionRef.current.prompt + : null; + const settledText = succeeded && collectPrompt + ? composeCollectSpokenAssistantText(parsed.text, collectPrompt) + : parsed.text; setMessages((current) => current.flatMap((message): RenderMessage[] => { if (message.renderKey !== assistantRenderKey) return [message]; if (succeeded) { return [{ ...message, - text: parsed.text, + text: settledText, activityTrace: completeActivityTrace(activityTrace), state: "settled", completedReceipt, @@ -684,7 +710,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { if (succeeded) { onMessagesChange?.([ ...(action === "message" ? [{ role: "user" as const, text: trimmed }] : []), - { role: "assistant", text: parsed.text }, + { role: "assistant", text: settledText }, ]); onCompleted?.(); await loadCaseSnapshot(); @@ -1048,20 +1074,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const collectSpokenPrompt = currentQuestion?.kind === "collect_spoken" ? currentQuestion.prompt : null; - const latestCollectBody = latestSettledAssistant?.text ?? ""; - const collectStemAlreadyInLatestBody = Boolean( - collectSpokenPrompt - && latestSettledAssistant - && composeCollectSpokenAssistantText(latestCollectBody, collectSpokenPrompt) === latestCollectBody.trim(), - ); - const showCollectSpokenPrompt = Boolean( - collectSpokenPrompt - && !collectStemAlreadyInLatestBody - && !showLiveChoiceCard - && !readonly - && !busy - && regeneratingMessageKey === null, - ); const resumableCase = caseStatus !== null && isResumableStatus(caseStatus); const showMissingQuestion = Boolean( caseSnapshotLoaded @@ -1081,7 +1093,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { ); const showQuestionSlot = Boolean( showLiveChoiceCard - || showCollectSpokenPrompt || showMissingQuestion || showUnavailableQuestion, ); @@ -1164,12 +1175,21 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { ? vargaSentenceFromMethods(message.completedReceipt?.methods) : null; const isLatestMessage = message.renderKey === messages[messages.length - 1]?.renderKey; + const bubbleText = !regenerating + && isLatestMessage + && message.role === "assistant" + && collectSpokenPrompt + ? composeCollectSpokenAssistantText(displayedMessage.text, collectSpokenPrompt) + : displayedMessage.text; + const bubbleMessage = bubbleText === displayedMessage.text + ? displayedMessage + : { ...displayedMessage, text: bubbleText }; return (
- {(!message.failed || Boolean(displayedMessage.text) || regenerating) && ( + {(!message.failed || Boolean(bubbleMessage.text) || regenerating) && ( )} @@ -1182,7 +1202,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { ...current, [message.renderKey]: toggleRectificationFeedback(current[message.renderKey], requested), }))} - onCopy={() => void copyMessage(message)} + onCopy={() => void copyMessage({ ...message, text: bubbleText })} onRegenerate={() => void regenerateMessage(message)} /> )} @@ -1220,11 +1240,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { onStop={submitStop} /> )} - {showCollectSpokenPrompt && ( -

- {collectSpokenPrompt} -

- )} {showMissingQuestion && (

当前没有可回答的问题,正在等待服务端更新。 @@ -1253,11 +1268,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { onStop={submitStop} /> )} - {showCollectSpokenPrompt && ( -

- {collectSpokenPrompt} -

- )} {showMissingQuestion && (

当前没有可回答的问题,正在等待服务端更新。 @@ -1298,9 +1308,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { )} { assert.equal(parseRectificationChoiceCard(COLLECT_GET_QUESTION), null); }); -test("collect_spoken stem stays in the same assistant body; the slot is fallback only", () => { +test("collect_spoken stem is joined into the latest assistant bubble, never a slot sibling", () => { const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8"); assert.match(chat, /currentQuestion\?\.kind === "collect_spoken"/); assert.match(chat, /const collectSpokenPrompt =/); - assert.match(chat, /composeCollectSpokenAssistantText\(latestCollectBody, collectSpokenPrompt\)/); - assert.match(chat, /const collectStemAlreadyInLatestBody = Boolean\(/); - assert.match(chat, /const showCollectSpokenPrompt = Boolean\(/); - assert.match(chat, /!collectStemAlreadyInLatestBody/); - assert.match(chat, /!busy/); + assert.match(chat, /composeCollectSpokenAssistantText\(displayedMessage\.text, collectSpokenPrompt\)/); + assert.match(chat, /composeCollectSpokenAssistantText\(last\.text, prompt\)/); + assert.match(chat, /composeCollectSpokenAssistantText\(parsed\.text, collectPrompt\)/); + assert.doesNotMatch(chat, /showCollectSpokenPrompt/); + assert.doesNotMatch(chat, /rectification-question-slot__prompt/); + assert.doesNotMatch(chat, /collectSpokenPromptId/); assert.match(chat, /showQuestionSlot/); assert.match(chat, /isLatestMessage && showQuestionSlot/); - assert.match(chat, /messages\.length === 0 && showQuestionSlot/); - assert.match(chat, /showCollectSpokenPrompt \|\| collectStemAlreadyInLatestBody/); - assert.match(chat, /rectification-question-slot__prompt/); - assert.match(chat, /describedBy=\{showCollectSpokenPrompt/); + assert.match(chat, /collectSpokenPrompt\n\s+\? "请回答上面的问题…"/); assert.match( readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8"), /\[describedBy, showRemaining \? remainingId : undefined\]\.filter\(Boolean\)\.join\(" "\)/, @@ -137,7 +135,6 @@ test("collect_spoken stem stays in the same assistant body; the slot is fallback assert.doesNotMatch(agentRun, /answerText\.(?:includes|match|search)\(/); assert.match(styles, /\.rectification-question-slot \{[\s\S]*width: calc\(100% - var\(--assistant-content-inset\)\)/); assert.match(styles, /\.rectification-question-slot \{[\s\S]*margin-inline-start: var\(--assistant-content-inset\)/); - assert.match(styles, /\.rectification-question-slot__prompt \{/); assert.doesNotMatch(styles, /\.rectification-question-slot \.rectification-choice-card \{/); });