From 33d55b3a54ba17d6da6a89c0c7d20ac0ef0abfbe Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 2 Sep 2026 17:05:29 +0800 Subject: [PATCH] fix(rectification): satisfy React compiler lint for collect stem attach Move the current-question ref off render and persist the collect stem from snapshot/send callbacks so ESLint no longer fails the staging gate. Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 4 +- .../components/rectification-agentic-chat.tsx | 84 ++++++++++++------- .../rectification-spoken-collect.test.ts | 3 + 3 files changed, 58 insertions(+), 33 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 14922bc3..4426a279 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -7514,8 +7514,8 @@ - 用户现象:开场气泡只有打招呼,采集题干仍出现在点赞/复制/重生成图标下面。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 锁保持。staging 门禁 run 2304 因 `showActivity={bubbleMessage.state…}` 对不上 `rectification-agentic-entry` 源码锁失败;`showActivity` 改回 `displayedMessage.state`(气泡仍走 `bubbleMessage`),该文件与 spoken-collect 本地 52/52。 +- 修复:不再渲染采集题槽。最新助手气泡按题干精确身份 `composeCollectSpokenAssistantText`;定稿后的 Case snapshot、发送下一条、以及挂载后的 snapshot 回调把组合文本写进该条消息状态,复制走组合文本。选择卡仍走问题槽。模型仍不自己提问,避免气泡里两句相近问法。不 bump Skill,不放宽确认门。 +- 验证:`rectification-spoken-collect` 锁气泡拼接、禁止 `rectification-question-slot__prompt`;既有 collect-prompt / v9-agent / regenerate / voice 锁保持。staging 门禁 run 2304 因 `showActivity={bubbleMessage.state…}` 对不上 `rectification-agentic-entry` 源码锁失败;`showActivity` 改回 `displayedMessage.state`(气泡仍走 `bubbleMessage`)。run 2305 前端 2509/2509 后 ESLint 在 `currentQuestionRef.current = currentQuestion`(render)和 snapshot `setMessages` effect 上报 `react-hooks/refs` / `react-hooks/set-state-in-effect`;ref 改到 `useLayoutEffect`,题干写入改到 snapshot/send 回调。 - 防复发:口述采集题干不得作为动作图标下的兄弟节点。不得用正文字符串判断「有没有问过」。不得同时让模型提问又拼接同一句服务端题干。 - 相关记录: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 2e190dce..ca95a5e3 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -120,6 +120,25 @@ function currentQuestionFromSnapshot(value: unknown): CurrentQuestionModel | nul }; } +function collectSpokenPromptFromQuestion(question: CurrentQuestionModel | null): string | null { + return question?.kind === "collect_spoken" ? question.prompt : null; +} + +function attachCollectSpokenStem(current: RenderMessage[], prompt: string): RenderMessage[] { + 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 + )); +} + function RectificationCandidateCards({ result, @@ -324,7 +343,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { 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)); @@ -335,6 +353,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { // new cards land the viewport on the bottom only while the reader is there. const conversationAnchor = useConversationScrollAnchor(conversation, true, caseId); + useLayoutEffect(() => { + currentQuestionRef.current = currentQuestion; + }, [currentQuestion]); + useLayoutEffect(() => { const query = window.matchMedia(`(max-width: ${RECTIFICATION_BOARD_SPLIT_MIN_PX - 1}px)`); const update = () => { @@ -399,16 +421,19 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { } }, []); - const loadCaseSnapshot = useCallback(async () => { + const loadCaseSnapshot = useCallback(async (): Promise => { try { const response = await fetch( `/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`, { cache: "no-store" }, ); - if (!response.ok) return; - applyCaseSnapshot(await response.json().catch(() => null)); + if (!response.ok) return undefined; + const payload = await response.json().catch(() => null); + applyCaseSnapshot(payload); + return currentQuestionFromSnapshot(payload?.current_question); } catch { // Snapshot refresh is best-effort; the durable Case remains on the server. + return undefined; } }, [applyCaseSnapshot, caseId, sessionId]); @@ -420,7 +445,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { ) .then((response) => (response.ok ? response.json() : null)) .then((payload) => { - if (!controller.signal.aborted) applyCaseSnapshot(payload); + if (controller.signal.aborted) return; + applyCaseSnapshot(payload); + const prompt = collectSpokenPromptFromQuestion(currentQuestionFromSnapshot(payload?.current_question)); + if (prompt) setMessages((current) => attachCollectSpokenStem(current, prompt)); }) .catch(() => { // Snapshot refresh is best-effort; the durable Case remains on the server. @@ -428,25 +456,6 @@ 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; @@ -459,8 +468,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const userRenderKey = `v9-user-${turnKey}`; const assistantRenderKey = `v9-assistant-${turnKey}`; + const pendingCollectPrompt = collectSpokenPromptFromQuestion(currentQuestionRef.current); setMessages((current) => [ - ...current, + ...(action === "message" && pendingCollectPrompt + ? attachCollectSpokenStem(current, pendingCollectPrompt) + : current), ...(action === "message" ? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage] : []), @@ -667,9 +679,7 @@ 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 collectPrompt = collectSpokenPromptFromQuestion(currentQuestionRef.current); const settledText = succeeded && collectPrompt ? composeCollectSpokenAssistantText(parsed.text, collectPrompt) : parsed.text; @@ -708,12 +718,20 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { setError((current) => current || userFacingRunFailure("run_failed")); } if (succeeded) { + const snapshotPrompt = collectSpokenPromptFromQuestion(await loadCaseSnapshot() ?? null); + const attachedText = snapshotPrompt + ? composeCollectSpokenAssistantText(parsed.text, snapshotPrompt) + : settledText; + if (attachedText !== settledText) { + setMessages((current) => current.map((message) => ( + message.renderKey === assistantRenderKey ? { ...message, text: attachedText } : message + ))); + } onMessagesChange?.([ ...(action === "message" ? [{ role: "user" as const, text: trimmed }] : []), - { role: "assistant", text: settledText }, + { role: "assistant", text: attachedText }, ]); onCompleted?.(); - await loadCaseSnapshot(); } } catch (caught) { frames.settle(); @@ -1010,8 +1028,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { if (!response.ok || payload?.ok !== true || typeof payload.assistantMessage !== "string") { throw new Error(payload?.message || payload?.error || "暂时无法重新生成回答"); } + const collectPrompt = collectSpokenPromptFromQuestion(currentQuestion); + const nextText = collectPrompt + ? composeCollectSpokenAssistantText(payload.assistantMessage, collectPrompt) + : payload.assistantMessage; setMessages((current) => current.map((item) => item.renderKey === message.renderKey - ? { ...item, text: payload.assistantMessage, state: "settled" } + ? { ...item, text: nextText, state: "settled" } : item)); } catch (caught) { setMessages((current) => current.map((item) => item.renderKey === message.renderKey diff --git a/frontend/tests/rectification-spoken-collect.test.ts b/frontend/tests/rectification-spoken-collect.test.ts index 288a243a..4de68489 100644 --- a/frontend/tests/rectification-spoken-collect.test.ts +++ b/frontend/tests/rectification-spoken-collect.test.ts @@ -114,6 +114,9 @@ test("collect_spoken stem is joined into the latest assistant bubble, never a sl assert.match(chat, /composeCollectSpokenAssistantText\(displayedMessage\.text, collectSpokenPrompt\)/); assert.match(chat, /composeCollectSpokenAssistantText\(last\.text, prompt\)/); assert.match(chat, /composeCollectSpokenAssistantText\(parsed\.text, collectPrompt\)/); + assert.match(chat, /function attachCollectSpokenStem/); + assert.match(chat, /useLayoutEffect\(\(\) => \{\s*currentQuestionRef\.current = currentQuestion;/); + assert.doesNotMatch(chat, /\[busy, currentQuestion, regeneratingMessageKey\]/); assert.doesNotMatch(chat, /showCollectSpokenPrompt/); assert.doesNotMatch(chat, /rectification-question-slot__prompt/); assert.doesNotMatch(chat, /collectSpokenPromptId/);