diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index e261129a..0891ef51 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -7536,3 +7536,36 @@ - 相关记录:BUG-486、BUG-467、BUG-352 - 复发自:BUG-486(键名别名与 demote 治标,值形状与缺失层未修) - 修复版本:`7faf8555`(staging;未提升 main) + +## BUG-490 | 校正题目必须在同一条助手消息里,删除问题槽 + +- 状态:resolved +- 首次发现:2026-09-02 +- 最近更新:2026-09-02 +- 影响面:`agentic_rectification_conversation_focuses.asked_turn_id` / `answer_option`、GET `/api/rectification/cases/[caseId]` `turns[].question`、`rectification-set-focus.spokenPrompt`、`rectification-agentic-chat`、`.rectification-question-slot` +- 用户现象:采集题、区分题、前事核对出现在助手气泡下面的独立问题槽,或刷新后只剩正文。产品要求每一问都是同一条助手消息:正文 → 题干 → 选项;已答原地变灰;刷新从 turn 重建。 +- 触发条件:任意校正 opening / message / answer_choice / accept 后仍有 active focus。 +- 根因:focus 没有挂到 asked turn,也没有记录所选选项。前端只能从 GET 直播槽或正文拼接恢复题目,所以必须另画 `.rectification-question-slot`。Agent 被禁止调用 set-focus,题干只能用服务端模板。 +- 修复:focus 增加 `asked_turn_id` 与 `answer_option`。GET 按 `asked_turn_id` 把 `question` 接到 assistant turn。Agent 用 `rectification-set-focus.spokenPrompt` 写题干(校验失败两次落模板)。题干和选项画在同一条 `
` 里。删除问题槽与把题干拼进 `assistant_message` 的路径。旧 turn 仅在精确后缀 `\n\n${prompt}` 且 `asked_turn_id` 命中时 detach。不 bump Skill 10.0.14,确认门仍 fail-closed。 +- 验证:`rectification-question-in-message`、`rectification-spoken-prompt`、`rectification-spoken-collect` / `rectification-collect-prompt` / `rectification-walkthrough-polish` / `rectification-answer-choice` / `rectification-decision-authority` / `rectification-varga-style-copy` 迁到新合同;`rectification-v10-tool-contract` 锁 Agent 可调用 set-focus,两次 `invalid_spoken_prompt` 后 `retryable=false` 落模板。 +- 防复发:DOM 中不得再出现 `.rectification-question-slot`。运行时不得用正文字符串判断「有没有问过」。任意 active focus 必须能在某条 assistant turn 的 `question.focus_id` 上找到。缺题状态只出现在输入框上方。本题废止 BUG-441/461/471/485/487/488 中与「槽」或「正文不得含题干」绑定的条款;其余条款保留(有选项时题干可见、共用 `--assistant-content-inset`、不得用正文判断状态、开场不得并行提问、不得追加第二条 opening 题干消息)。 +- 相关记录:BUG-441、BUG-461、BUG-471、BUG-480、BUG-485、BUG-487、BUG-488、BUG-491 +- 复发自:BUG-488(拼接进正文后选择卡仍走槽,刷新合同仍不完整) +- 修复版本:待发布 + +## BUG-491 | message 路径预建 choice focus 不挂 asked_turn_id,刷新丢题干 + +- 状态:resolved +- 首次发现:2026-09-02 +- 最近更新:2026-09-02 +- 影响面:`persistNextInterviewIfIdle`、`finalizeSuccessfulTurnExit`、agent-run message 路径预建 choice focus +- 用户现象:用户回答采集题后进入区分题时,在线会话也许能看到下一问;刷新后对应助手消息没有题干和选项。 +- 触发条件:message 路径上 agent-run 已为下一问建好 choice focus,随后 `finalizeSuccessfulTurnExit` 因已有 focus 早退,不写题干 turn。引入提交约为 `39c30b55`。 +- 根因:题干曾依赖第二条 assistant turn 或正文拼接。已有 active focus 时 idle persist 直接返回,不把 focus 链到当前 turn。 +- 修复:题干改为 turn 上的 `question`。idle persist 若已有 active focus,仍用当前 `turnId` 回写 `asked_turn_id`。GET 按该列重建。不再需要第二条题干 turn,早退不再丢题。 +- 验证:`rectification-question-in-message` 锁「预建 choice focus + askedTurnId → set_focus.p_asked_turn_id」;GET `turns[].question` 按 `asked_turn_id` 连接,禁止按时间戳推断。 +- 防复发:opening / message / answer_choice / accept 建立的每个 focus 都必须有 `asked_turn_id`,且 GET `turns[]` 恰有一条 assistant turn 带该 `question`。不得按 `asked_at` 与 `created_at` 对题。 +- 相关记录:BUG-480、BUG-490 +- 复发自:无 +- 修复版本:待发布 + diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 4f4e818a..977d11f3 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -1,6 +1,6 @@ # Voice -Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、问题槽归属、代表分钟边界)不靠冷冰冰的句子来守。 +Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、题干归属、代表分钟边界)不靠冷冰冰的句子来守。 ## 人设 @@ -23,6 +23,9 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | 坏 | 好 | 为什么 | |---|---|---| | 请先说一件你记得大概时间的人生经历,比如升学、入职、搬家、结婚或生病;只记得年份也可以。 | 从你最容易想起来的开始就好——比如哪年上的大学、哪年换的工作,记得大概年份就行。 | 公文枚举改成一件容易开口的事。 | +| (服务端探针 2023 感情)2023 年前后,你有没有一段认真开始或结束的关系?逐字复读模板。 | 你刚才说到工作这块已经比较清楚了。2023 年前后,有没有一段认真开始或结束的关系? | 接着用户上一句,同年份同家族,不复读模板。 | +| (服务端探针 2016 升学)2018 年你是哪年上的大学? | 2016 年前后,你是哪年上的大学? | 不得改年份。 | +| (服务端探针 2019 入职)2019 年前后,你有没有换过工作?A. 明确发生且时间吻合 | 2019 年前后,你有没有换过工作? | 题干不写选项字面;选项由服务端画在同一条消息里。 | | 有没有记得住时间的收入变化、大笔支出或欠债? | 钱的方面,还记得哪年收入明显变过、有过大笔支出,或欠过债吗? | 去掉审讯口吻,先给领域引子。 | | 请点选下面的选项作答。点选和一句话回答会按同一规则计入当前区分题。 | 直接点下面的选项就行,打字回答也一样算数。 | 内部计分规则不进聊天。 | | 我没能确定这句话是否在回答当前问题。请点选下面的选项,或换一种说法。 | 我不太确定这句是不是在回答上面的问题——点个选项,或者换个说法都行。 | 承认不确定,不训人。 | @@ -30,13 +33,23 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | 可以从下面的时间里选一个采用。 | 可以从下面选一个先用着。 | 采用仍是采用,不说成已确认。 | | 当前问题已更新,请刷新后重新作答。 | 这一问刚换成新的,刷新后再答就行。 | 机器状态句改成下一步。 | | 好的,记下了。 | 2016 年 9 月上大学,记下了——这类有明确月份的节点对校正特别有用。 | 确认收到什么,并说清为什么有用。 | -| 界面上继续有下一问。 | 接下来我们继续。 | 正文不得断言界面当前有没有题;题干在问题槽,过渡用中性句。 | +| 界面上继续有下一问。 | 接下来我们继续。 | 正文不得断言界面当前有没有题;题干在同一条消息里正文之后出现,过渡用中性句。 | | ## 统一参数与原始结构(用户刚问「我婚姻怎么样」) | 先用几句口语答婚姻方向,再进入统一参数与原始结构。 | 骨架仍在,开场先回人。 | -## 红线(语气改不了这些) +## 服务端探针 → Agent 题干 + +题干由 Agent 通过 `rectification-set-focus.spokenPrompt` 写;年份、事件家族、选项语义必须与服务端探针一致。 + +| 服务端探针 | 坏 | 好 | 为什么 | +|---|---|---|---| +| 2023 感情区分 | 2023 年前后,你有没有一段认真开始或结束的关系?(逐字复读模板) | 工作这块记下了。2023 年前后,有没有一段认真开始或结束的关系? | 接着用户上一句,同年份同家族,不复读整句模板。 | +| 2016 升学 | 2018 年你是哪年上的大学? | 2016 年前后,你是哪年上的大学? | 不得改年份。 | +| 2019 入职 | 2019 年前后,你有没有换过工作?A. 明确发生且时间吻合 | 2019 年前后,你有没有换过工作? | 不得把选项字面写进题干。 | + + - 不得虚构星盘事实或唯一出生分钟。 -- 问题槽仍由服务端 focus 唯一所有;正文不得出题、复述或改写题干。 +- 问哪道题、年份、选项语义仍由服务端 focus 唯一所有;Agent 用 `spokenPrompt` 写题干,不得改年份、不得改选项含义。正文不得出题、复述或改写题干——题干作为同一条消息里正文之后的独立段落出现。 - 正文不得断言界面当前状态(「界面上有/出现了下一问」);过渡用「接下来我们继续」这类中性句。 - 交付/采用轮必须出现「不是已确认的唯一出生分钟」这一语义。 - 技法审计表仍在本命回答文末。 diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index 9c23ea65..fcb5cbd4 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -737,6 +737,7 @@ export async function POST(request: Request) { caseId, action, assistantBodyPresent: Boolean(result.answerText?.trim()), + askedTurnId: result.turnId, }); } catch (error) { const code = error instanceof RectificationToolServiceError ? error.code : "run_failed"; diff --git a/frontend/src/app/api/rectification/cases/[caseId]/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/route.ts index 834bc6e9..6e2546e3 100644 --- a/frontend/src/app/api/rectification/cases/[caseId]/route.ts +++ b/frontend/src/app/api/rectification/cases/[caseId]/route.ts @@ -9,10 +9,13 @@ import { loadV9TurnReceipt, receiptStatusFromTurn, RectificationToolServiceError, + listV10ConversationFocuses, + type ConversationFocus, type V9CaseDossier, } from "@/lib/rectification-agentic/v9/tool-service"; import { choiceCardFromCaseDossier, decideFromDossier, overlayPublicDecision } from "@/lib/rectification-agentic/v9/interview-state"; import { projectCurrentQuestion } from "@/lib/rectification-agentic/v9/turn-decision"; +import { attachQuestionsToTurns } from "@/lib/rectification-agentic/v9/turn-question"; import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inference-adapter"; import { publicDecisionFields } from "@/lib/rectification-agentic/core/rectification-decision"; import { slimDecisionReceipt } from "@/lib/rectification-agentic/v9/case-receipt-projection"; @@ -69,7 +72,8 @@ export async function GET(request: Request, context: RouteContext) { } }), ); - return NextResponse.json(dossierResponse(dossier, receipts, skillIdentity, { fullDetail })); + const focuses = await listV10ConversationFocuses(accounting, user.id, caseId); + return NextResponse.json(dossierResponse(dossier, receipts, skillIdentity, { fullDetail, focuses })); } catch (error) { if (error instanceof RectificationToolServiceError) { const message = error.message; @@ -88,11 +92,12 @@ function dossierResponse( dossier: V9CaseDossier, receipts: Array>>, skillIdentity: Awaited>, - options: { fullDetail?: boolean } = {}, + options: { fullDetail?: boolean; focuses?: readonly ConversationFocus[] } = {}, ) { const decision = decideFromDossier(dossier, { currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence), }); + const turns = attachQuestionsToTurns(dossier.turns, options.focuses ?? []); return { case: { case_id: dossier.case.caseId, @@ -110,13 +115,14 @@ function dossierResponse( last_activity_at: dossier.case.lastActivityAt, case_revision: previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null)?.revision ?? 0, }, - turns: dossier.turns.map((turn) => ({ + turns: turns.map((turn) => ({ id: turn.id, role: turn.role, text: turn.text, status: turn.status, created_at: turn.createdAt, receipt: turnReceipt(turn.id, receipts), + question: turn.question, })), evidence: dossier.evidence, latest_result: dossier.latestResult diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index d498258c..c279a957 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -2898,30 +2898,21 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class font-size: var(--type-caption); } -.rectification-question-slot { +.rectification-message-question { display: grid; - gap: var(--space-2); - width: calc(100% - var(--assistant-content-inset)); - margin-block: var(--space-3); - margin-inline-start: var(--assistant-content-inset); -} -.rectification-message-wrap .rectification-question-slot { - width: calc(100% - var(--assistant-content-inset)); - margin-inline-start: var(--assistant-content-inset); -} -.rectification-message-wrap .rectification-question-slot > .rectification-choice-card { + gap: var(--space-3); + margin-block-start: var(--space-3); width: 100%; - margin-inline-start: 0; } -.rectification-question-slot__prompt { +.rectification-message-question__prompt { margin: 0; color: var(--color-ink); font-size: var(--type-body-sm); font-weight: 600; line-height: 1.55; } -.rectification-question-slot__status { - margin: 0; +.rectification-composer-status { + margin: 0 0 var(--space-2); color: var(--color-ink-secondary); font-size: var(--type-caption); } @@ -2976,6 +2967,19 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class margin: var(--space-3) 0 var(--space-4); margin-inline-start: var(--assistant-content-inset); } +.rectification-message-wrap .rectification-choice-card.is-embedded, +.message-bubble .rectification-choice-card.is-embedded { + width: 100%; + margin: 0; + margin-inline-start: 0; + padding: 0; + border: none; + border-radius: 0; + background: transparent; +} +.rectification-choice-card.is-embedded .birth-time-choice-question legend { + display: unset; +} .rectification-choice-why { margin: 0 0 var(--space-3); color: var(--color-ink-secondary); diff --git a/frontend/src/components/chat-message-row.tsx b/frontend/src/components/chat-message-row.tsx index 41b95032..66ff77bc 100644 --- a/frontend/src/components/chat-message-row.tsx +++ b/frontend/src/components/chat-message-row.tsx @@ -5,7 +5,7 @@ import { prefetchOnIdle } from "@/components/chat-chunk-prefetch"; import { ChatMessageContent } from "@/components/chat-message-content"; import { ConsultationRunTimeline } from "@/components/consultation-run-timeline"; import type { ChatMessageView } from "@/lib/chat-message-view"; -import { useEffect, useLayoutEffect, useRef } from "react"; +import { useEffect, useLayoutEffect, useRef, type ReactNode } from "react"; type GsapCore = typeof import("gsap")["gsap"]; @@ -39,10 +39,12 @@ export function ChatMessageRow({ message, showActivity = message.state !== "settled", vargaSentence, + afterAnswer, }: Readonly<{ message: ChatMessageView; showActivity?: boolean; vargaSentence?: string | null; + afterAnswer?: ReactNode; }>) { const messageRow = useRef(null); const assistantLabel = message.state === "thinking" @@ -137,8 +139,9 @@ export function ChatMessageRow({ {hasAnswer ? (
{spokenAnswer} + {afterAnswer}
- ) : null} + ) : afterAnswer} ) : ( <> @@ -147,17 +150,19 @@ export function ChatMessageRow({ {thinkingPanel}
{spokenAnswer} + {afterAnswer}
) : ( <> {thinkingPanel} {spokenAnswer} + {afterAnswer} )} ) - ) :

{message.text}

} + ) : <>

{message.text}

{afterAnswer}}
diff --git a/frontend/src/components/conversational-birth-time-rectification.tsx b/frontend/src/components/conversational-birth-time-rectification.tsx index e4273bf7..a53b36d4 100644 --- a/frontend/src/components/conversational-birth-time-rectification.tsx +++ b/frontend/src/components/conversational-birth-time-rectification.tsx @@ -9,6 +9,7 @@ export type PersistedRectificationTurn = Readonly<{ role: "user" | "assistant"; text: string | null; status: string; + question?: unknown; receipt?: Readonly<{ status: string; phases: readonly string[]; diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index ca95a5e3..bf4c2db0 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -57,13 +57,7 @@ import { type ChoiceOptionId, } from "@/lib/rectification-agentic/v9/choice-action"; import { isRectificationCaseStatus, isResumableStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status"; -import { composeCollectSpokenAssistantText } from "@/lib/rectification-agentic/v9/collect-prompt"; -import { - isPersistedFocusId, - parseRectificationChoiceCard, - type ChoiceKey, - type RectificationChoiceCard as ChoiceCardModel, -} from "@/lib/rectification-agentic/v9/choice-card"; +import { CHOICE_MODE, CHOICE_STOP_LABEL, isPersistedFocusId, parseRectificationChoiceCard, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card"; import type { PublicLanguageModel } from "@/lib/public-models"; import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anchor"; import { CharacterRemaining } from "./character-remaining"; @@ -77,13 +71,15 @@ import { import { ModelSelector } from "./model-selector"; import { RectificationBoard, RectificationBoardPeek } from "./rectification-board"; import { RectificationChoiceCard } from "./rectification-choice-card"; -import { Button } from "./ui/button"; +import { copyTextForMessage, parseTurnQuestion, questionIsAnswered, type TurnQuestion } from "@/lib/rectification-agentic/v9/turn-question"; +import { Button } from "@/components/ui/button"; type PersistedTurn = Readonly<{ id: string; role: "user" | "assistant"; text: string | null; status: string; + question?: unknown; receipt?: Readonly<{ status: string; phases: readonly string[]; @@ -106,40 +102,24 @@ type CandidateResult = RectificationCandidateResult | null; type CurrentQuestionModel = Readonly<{ kind: "choice" | "collect_spoken"; prompt: string | null; + focus_id: string | null; + question_id: string | null; }>; function currentQuestionFromSnapshot(value: unknown): CurrentQuestionModel | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; - const question = value as { kind?: unknown; prompt?: unknown }; + const question = value as { kind?: unknown; prompt?: unknown; focus_id?: unknown; question_id?: unknown }; if (question.kind !== "choice" && question.kind !== "collect_spoken") return null; return { kind: question.kind, prompt: typeof question.prompt === "string" && question.prompt.trim() ? question.prompt.trim() : null, + focus_id: typeof question.focus_id === "string" ? question.focus_id : null, + question_id: typeof question.question_id === "string" ? question.question_id : null, }; } -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, acceptingCandidateId, @@ -207,20 +187,93 @@ type RectificationAgenticChatProps = Readonly<{ headerSlot: HTMLElement | null; }>; -type SettledChoiceAttachment = Readonly<{ - card: ChoiceCardModel; - selectedKey: ChoiceKey | "stop"; -}>; - type RenderMessage = ChatMessageView & { renderKey: string; completedReceipt?: CompletedActivityReceiptView; failed?: boolean; turnId?: string; activityTrace?: readonly AgentActivityTraceItem[]; - choiceAttachment?: SettledChoiceAttachment; + question?: TurnQuestion; }; +function mergeTurnQuestions(current: RenderMessage[], turns: readonly unknown[]): RenderMessage[] { + const byId = new Map(); + for (const item of turns) { + if (!item || typeof item !== "object") continue; + const turn = item as { id?: unknown; question?: unknown }; + if (typeof turn.id !== "string") continue; + byId.set(turn.id, parseTurnQuestion(turn.question)); + } + return current.map((message) => { + if (!message.turnId || !byId.has(message.turnId)) return message; + const question = byId.get(message.turnId) ?? undefined; + return { ...message, question: question ?? undefined }; + }); +} + +function markQuestionAnswered( + current: RenderMessage[], + focusId: string, + selected: ChoiceKey | "stop" | "typed", +): RenderMessage[] { + return current.map((message) => { + const question = message.question; + if (!question || question.focus_id !== focusId || questionIsAnswered(question)) return message; + return { + ...message, + question: { + ...question, + status: "resolved", + answer_option: selected === "typed" ? null : selected, + }, + }; + }); +} + +function snapshotTurns(payload: { turns?: unknown } | null | undefined): readonly unknown[] { + return Array.isArray(payload?.turns) ? payload.turns : []; +} + +function appendUnseenAssistantTurns(current: RenderMessage[], turns: readonly unknown[]): RenderMessage[] { + const known = new Set(current.flatMap((message) => message.turnId ? [message.turnId] : [])); + const extras = messagesFromTurns(turns as readonly PersistedTurn[]).filter((message) => ( + message.role === "assistant" + && message.turnId + && !known.has(message.turnId) + )); + return extras.length ? [...current, ...extras] : current; +} + +function choiceCardFromQuestion( + question: TurnQuestion, + live: ChoiceCardModel | null, +): ChoiceCardModel | null { + if (live && live.focus_id === question.focus_id && live.options.length === 4) { + return { ...live, prompt: question.prompt }; + } + if (!question.options || question.options.length !== 4) return null; + return { + question_id: question.question_id, + method_id: "", + prompt: question.prompt, + why: "", + varga: null, + choice_mode: CHOICE_MODE, + options: question.options.map((option) => ({ + key: option.key, + label: option.label, + answer_class: "unsure", + role: "primary", + })), + stop_label: CHOICE_STOP_LABEL, + stop_message: "先这样", + scoring: question.kind !== "reverse_verify", + probe_id: question.probe_id, + case_revision: null, + focus_id: question.focus_id, + }; +} + function completedReceiptFromPersisted(receipt: PersistedTurn["receipt"]): CompletedActivityReceiptView { if (!receipt) return { steps: [], methods: [] }; if (Array.isArray(receipt.tool_activities)) { @@ -282,6 +335,7 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag activityTrace: activityTraceFromReceipt(completedReceipt), failed, turnId: turn.id, + question: parseTurnQuestion(turn.question) ?? undefined, }]; } if (isIncompleteRunBanner(turn.text ?? "")) return []; @@ -392,6 +446,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { latest_result?: unknown; current_question?: unknown; choice_card?: unknown; + turns?: unknown; case?: { status?: unknown; accepted_time?: unknown; @@ -421,7 +476,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { } }, []); - const loadCaseSnapshot = useCallback(async (): Promise => { + const loadCaseSnapshot = useCallback(async (): Promise<{ + question: CurrentQuestionModel | null; + turns: readonly unknown[]; + } | null | undefined> => { try { const response = await fetch( `/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`, @@ -430,7 +488,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { if (!response.ok) return undefined; const payload = await response.json().catch(() => null); applyCaseSnapshot(payload); - return currentQuestionFromSnapshot(payload?.current_question); + return { + question: currentQuestionFromSnapshot(payload?.current_question), + turns: snapshotTurns(payload), + }; } catch { // Snapshot refresh is best-effort; the durable Case remains on the server. return undefined; @@ -447,8 +508,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { .then((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. @@ -468,10 +527,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const userRenderKey = `v9-user-${turnKey}`; const assistantRenderKey = `v9-assistant-${turnKey}`; - const pendingCollectPrompt = collectSpokenPromptFromQuestion(currentQuestionRef.current); + const pendingFocusId = currentQuestionRef.current?.focus_id; setMessages((current) => [ - ...(action === "message" && pendingCollectPrompt - ? attachCollectSpokenStem(current, pendingCollectPrompt) + ...(action === "message" && pendingFocusId + ? markQuestionAnswered(current, pendingFocusId, "typed") : current), ...(action === "message" ? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage] @@ -679,16 +738,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const parsed = completed && !streamFailed ? parseAgentReply(raw) : { text: "", title: undefined }; const succeeded = completed && !streamFailed && Boolean(parsed.text); - const collectPrompt = collectSpokenPromptFromQuestion(currentQuestionRef.current); - 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: settledText, + text: parsed.text, activityTrace: completeActivityTrace(activityTrace), state: "settled", completedReceipt, @@ -718,18 +773,13 @@ 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 - ))); + const snapshot = await loadCaseSnapshot(); + if (snapshot?.turns.length) { + setMessages((current) => mergeTurnQuestions(current, snapshot.turns)); } onMessagesChange?.([ ...(action === "message" ? [{ role: "user" as const, text: trimmed }] : []), - { role: "assistant", text: attachedText }, + { role: "assistant", text: parsed.text }, ]); onCompleted?.(); } @@ -797,26 +847,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { } const questionId = choiceCard.question_id; const actionId = actionIdForChoice(focusId, optionId); - const answeredCard = choiceCard; - const offeringRenderKey = [...messages].reverse().find((message) => ( - message.role === "assistant" - && message.state === "settled" - && Boolean(message.text) - && !message.choiceAttachment - ))?.renderKey ?? null; setError(""); keyCounter.current += 1; const turnKey = keyCounter.current; const assistantRenderKey = `v9-choice-assistant-${turnKey}`; setMessages((current) => [ - ...current.map((message) => ( - offeringRenderKey && message.renderKey === offeringRenderKey - ? { - ...message, - choiceAttachment: { card: answeredCard, selectedKey: optionId }, - } - : message - )), + ...markQuestionAnswered(current, focusId, optionId), { role: "assistant", text: "", @@ -852,13 +888,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { }); const payload = await response.json().catch(() => null); if (!response.ok) { - setMessages((current) => current.flatMap((message) => { - if (message.renderKey === assistantRenderKey) return []; - if (offeringRenderKey && message.renderKey === offeringRenderKey) { - return [{ ...message, choiceAttachment: undefined }]; - } - return [message]; - })); + setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey)); setChoiceNonce((current) => current + 1); if (payload?.code === "profile_incomplete") { onProfileIncomplete?.(); @@ -869,34 +899,42 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { } const willContinue = shouldContinueAfterStructuredChoice(payload?.nextAction, payload); onCompleted?.(); - await loadCaseSnapshot(); + const snapshot = await loadCaseSnapshot(); + const turns = snapshot?.turns ?? []; if (willContinue) { - setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey)); + setMessages((current) => mergeTurnQuestions( + current.filter((message) => message.renderKey !== assistantRenderKey), + turns, + )); choiceContinuationPending.current = true; } else { const narration = typeof payload?.narration === "string" && payload.narration.trim() ? payload.narration.trim() : "已记录你的选择。"; - setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey - ? { - ...message, - text: narration, - state: "settled" as const, - activity: undefined, - } - : message)); + setMessages((current) => { + const withoutPlaceholder = current.filter((message) => message.renderKey !== assistantRenderKey); + const withHistory = appendUnseenAssistantTurns( + mergeTurnQuestions(withoutPlaceholder, turns), + turns, + ); + if (withHistory.length > withoutPlaceholder.length) return withHistory; + return [ + ...withHistory, + { + role: "assistant" as const, + text: narration, + renderKey: assistantRenderKey, + state: "settled" as const, + activity: undefined, + }, + ]; + }); onMessagesChange?.([ { role: "assistant", text: narration }, ]); } } catch { - setMessages((current) => current.flatMap((message) => { - if (message.renderKey === assistantRenderKey) return []; - if (offeringRenderKey && message.renderKey === offeringRenderKey) { - return [{ ...message, choiceAttachment: undefined }]; - } - return [message]; - })); + setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey)); setChoiceNonce((current) => current + 1); setError("选择题处理失败,请稍后重试。"); } finally { @@ -908,7 +946,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { caseId, choiceCard, loadCaseSnapshot, - messages, onCompleted, onMessagesChange, onProfileIncomplete, @@ -997,7 +1034,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { async function copyMessage(message: RenderMessage) { try { - await navigator.clipboard.writeText(message.text); + await navigator.clipboard.writeText(copyTextForMessage(message.text, message.question)); setCopiedMessageKey(message.renderKey); window.setTimeout(() => setCopiedMessageKey((current) => ( current === message.renderKey ? null : current @@ -1028,10 +1065,7 @@ 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; + const nextText = payload.assistantMessage; setMessages((current) => current.map((item) => item.renderKey === message.renderKey ? { ...item, text: nextText, state: "settled" } : item)); @@ -1067,15 +1101,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { && !message.failed && Boolean(message.text) )); - const answeredQuestionIds = new Set( - messages.flatMap((message) => message.choiceAttachment - ? [message.choiceAttachment.card.question_id] - : []), - ); + const latestLiveQuestion = [...messages].reverse().find((message) => ( + message.role === "assistant" + && message.question + && !questionIsAnswered(message.question) + ))?.question ?? null; const showLiveChoiceCard = Boolean( - currentQuestion?.kind === "choice" - && choiceCard - && !answeredQuestionIds.has(choiceCard.question_id) + latestLiveQuestion + && latestLiveQuestion.options?.length === 4 + && currentQuestion?.focus_id === latestLiveQuestion.focus_id && !busy && !readonly && regeneratingMessageKey === null, @@ -1097,6 +1131,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { ? currentQuestion.prompt : null; const resumableCase = caseStatus !== null && isResumableStatus(caseStatus); + const liveQuestionOnMessages = messages.some((message) => ( + message.role === "assistant" + && message.state === "settled" + && message.question + && currentQuestion + && message.question.focus_id === currentQuestion.focus_id + && !questionIsAnswered(message.question) + )); const showMissingQuestion = Boolean( caseSnapshotLoaded && resumableCase @@ -1110,13 +1152,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { && !readonly && !busy && currentQuestion !== null - && ((currentQuestion.kind === "choice" && !choiceCard) - || (currentQuestion.kind === "collect_spoken" && !collectSpokenPrompt)), - ); - const showQuestionSlot = Boolean( - showLiveChoiceCard - || showMissingQuestion - || showUnavailableQuestion, + && !liveQuestionOnMessages, ); const canSend = !busy && !readonly && !regeneratingMessageKey; @@ -1191,28 +1227,48 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { settled: message.state === "settled", }), }; - const settledChoice = message.choiceAttachment; - const visibleChoiceCard = settledChoice?.card; const vargaSentence = !message.failed ? 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 }; + const question = regenerating ? undefined : message.question; + const liveQuestion = Boolean( + question + && currentQuestion?.focus_id === question.focus_id + && !questionIsAnswered(question) + && choiceCard + && choiceCard.focus_id === question.focus_id + && !busy + && !readonly + && regeneratingMessageKey === null, + ); + const embeddedCard = question ? choiceCardFromQuestion(question, liveQuestion ? choiceCard : null) : null; + const afterAnswer = question && displayedMessage.state === "settled" + ? ( +
+

{question.prompt}

+ {embeddedCard && ( + + )} +
+ ) + : undefined; return (
- {(!message.failed || Boolean(bubbleMessage.text) || regenerating) && ( + {(!message.failed || Boolean(displayedMessage.text) || regenerating) && ( )} {showActions && !regenerating && ( @@ -1224,7 +1280,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { ...current, [message.renderKey]: toggleRectificationFeedback(current[message.renderKey], requested), }))} - onCopy={() => void copyMessage({ ...message, text: bubbleText })} + onCopy={() => void copyMessage(message)} onRegenerate={() => void regenerateMessage(message)} /> )} @@ -1236,72 +1292,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { onAccept={(candidateId) => void acceptCandidate(candidateId)} /> )} - {visibleChoiceCard && ( - - )} - {isLatestMessage && showQuestionSlot && ( -
- {showLiveChoiceCard && choiceCard && ( - - )} - {showMissingQuestion && ( -

- 当前没有可回答的问题,正在等待服务端更新。 -

- )} - {showUnavailableQuestion && ( -

- 当前问题暂时无法显示,请等待服务端更新。 -

- )} -
- )}
); })} - {messages.length === 0 && showQuestionSlot && ( -
- {showLiveChoiceCard && choiceCard && ( - - )} - {showMissingQuestion && ( -

- 当前没有可回答的问题,正在等待服务端更新。 -

- )} - {showUnavailableQuestion && ( -

- 当前问题暂时无法显示,请等待服务端更新。 -

- )} -
- )} {savedTime && savedStatus === "confirmed" && (

已确认校正时间:{savedTime} @@ -1328,6 +1321,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { {!conversationAnchor.anchored && ( )} + {(showMissingQuestion || showUnavailableQuestion) && ( +

+ {showMissingQuestion + ? "当前没有可回答的问题,正在等待服务端更新。" + : "当前问题暂时无法显示,请等待服务端更新。"} +

+ )} void; onStop: () => void; + variant?: "card" | "embedded"; + hidePrompt?: boolean; }>; export function RectificationChoiceCard(props: RectificationChoiceCardProps) { @@ -38,9 +40,12 @@ export function RectificationChoiceCard(props: RectificationChoiceCardProps) { props.onStop(); } + const embedded = props.variant === "embedded"; + const hidePrompt = embedded || props.hidePrompt === true; + return (
- {props.card.prompt} - {props.card.why ?

{props.card.why}

: null} + {hidePrompt + ? {props.card.prompt} + : {props.card.prompt}} + {!hidePrompt && props.card.why ?

{props.card.why}

: null}
{props.card.options.map((option, index) => (