diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 857203d8..811f598d 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -4660,9 +4660,41 @@ - 用户现象:Agent 正文和赞踩操作栏缩在头像右侧,本命宫位标题和表格贴着对话左缘,和头像齐平。 - 触发条件:校正对话出现本命宫位表。 - 根因:宫位表是 `.message-list` 里消息行的兄弟节点,不走 `.message-assistant` 的头像 + `gap` 列,也没有补上同一条起始线。 -- 修复:`.message-list` 定义 `--assistant-content-inset: calc(32px + var(--space-3))`,宫位表用 `padding-inline-start`,候选卡和已采用说明用同一 inset 的 `margin-inline-start`。 -- 验证:`frontend/tests/rectification-agentic-entry.test.ts` 锁定 inset 等于头像宽加 assistant gap。 -- 防复发:宫位表和后续结果块不得再与头像左缘齐平;起始线必须来自头像尺寸加 `.message-assistant` 的 gap,不能另写一个魔法像素。 -- 相关记录:BUG-307 +- 修复:当时用 `--assistant-content-inset` 把头像宽加 gap 补到表上,让它先和正文齐。后续在 BUG-311 改成快照卡片,不再用消息缩进冒充气泡。 +- 验证:`frontend/tests/rectification-agentic-entry.test.ts` 曾锁定 inset;现由 BUG-311 的快照卡片合同接替。 +- 防复发:宫位表不是聊天气泡。不要再把它缩进成 Agent 正文;结果块要用独立卡片表面。 +- 相关记录:BUG-307、BUG-311 - 复发自:BUG-307(表作为消息兄弟落地,未接入 assistant 列) +- 修复版本:`053e63ef` + +## BUG-311 | 生时纠正宫位表被当成聊天正文,而不是随线索更新的当前盘面 + +- 状态:resolved +- 首次发现:2026-08-19 +- 最近更新:2026-08-19 +- 影响面:生时纠正对话的宫位表 +- 用户现象:本命宫位出现在最后一条 Agent 回答下面,看起来像那条回复的一部分;补充经历后表格其实会整表替换,界面没有把这一点说清楚。 +- 触发条件:校正已经有 Candidate Snapshot,对话里同时出现 Agent 正文和宫位表。 +- 根因:表来自 Case 快照、挂在消息列表末尾,却按聊天气泡的起始线排版,标题也只说“本命宫位”。 +- 修复:宫位表单独放在消息列表后的 `rectification-snapshot`,表面与结果卡同类。标题改为“当前本命宫位”,并写明补充经历后会按新线索重算。时间选择卡不再进这个快照,见 BUG-312。 +- 验证:`frontend/tests/rectification-agentic-entry.test.ts` 锁定快照容器在 `ChatMessageRow` 之外、不含候选卡、`aria-live`、重算文案,以及成功回合后 `loadCandidate`。 +- 防复发:宫位表必须在消息气泡外;文案必须说明随新线索重算。不得再用 `--assistant-content-inset` 把它扮成 Agent 正文。 +- 相关记录:BUG-307、BUG-310、BUG-312 +- 复发自:BUG-310(对齐 Agent 正文后更像一条聊天) +- 修复版本:待提交 + +## BUG-312 | 生时纠正时间选择卡在线索未齐时直接出现,且不在 Agent 气泡下方 + +- 状态:resolved +- 首次发现:2026-08-19 +- 最近更新:2026-08-19 +- 影响面:生时纠正对话的候选时间卡、`selectionAllowed`、Case 快照 +- 用户现象:当前可能的出生时间作为独立盘面模块直接出现;没有等线索齐到可以给出代表性时间,也不挂在刚说完的 Agent 气泡下面。 +- 触发条件:Case 已有快照或宫位表;引擎尚未 `selection_allowed`,或 Agent 回合仍在生成。 +- 根因:候选卡和宫位表被捆在同一块始终可见的 `rectification-snapshot` 里。宫位表应当随线索更新;时间选择是“可以给出代表性时间”之后的采用动作,应对齐当轮 Agent 回复。 +- 修复:候选卡只在 `selectionAllowed`、存在最近一条已落地且未失败的 Agent 正文、且当前不 busy/不重跑时,画在该条气泡和操作栏下方。生成中隐藏,避免挂在上一条气泡下。`--assistant-content-inset` 只用于这块卡片,不用于宫位表。 +- 验证:`frontend/tests/rectification-agentic-entry.test.ts` 锁定候选卡在消息循环内、操作栏之后、快照容器之外,以及 `selectionAllowed` / `latestOfferMessageKey` / `!busy` 门。 +- 防复发:时间选择卡不得进 `rectification-snapshot`;不得在 `selectionAllowed` 之前或回合生成中显示。宫位表仍是消息外的 live 快照。 +- 相关记录:BUG-120、BUG-179、BUG-304、BUG-311 +- 复发自:BUG-120(过早出示采用卡);BUG-311(与宫位表捆成始终可见模块) - 修复版本:待提交 diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 6ab215d6..5bb08d18 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1847,17 +1847,25 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .birth-time-clock-menu.select-content { width: 108px; min-width: 108px; } .birth-time-clock-menu .select-item { justify-content: flex-start; } +.rectification-snapshot { + display: grid; + gap: var(--space-3); + margin: 8px 0 16px; +} .rectification-candidates { display: grid; gap: 14px; - width: calc(100% - var(--assistant-content-inset)); - margin: 8px 0 16px; - margin-inline-start: var(--assistant-content-inset); + margin: 0; padding: 18px; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas-soft); } +.rectification-message-wrap .rectification-candidates { + width: calc(100% - var(--assistant-content-inset)); + margin: var(--space-3) 0 var(--space-4); + margin-inline-start: var(--assistant-content-inset); +} .rectification-candidates-heading { display: grid; gap: 5px; } .rectification-candidates-heading strong { font-size: var(--type-title-sm); font-family: var(--font-display); font-weight: 600; letter-spacing: -.2px; } .rectification-candidates-heading span, @@ -1905,18 +1913,15 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class font-weight: 650; } .rectification-candidate-action { align-self: end; color: var(--color-action); font-size: 13px; font-weight: 650; } -.rectification-saved { - width: calc(100% - var(--assistant-content-inset)); - margin: 8px 0 16px; - margin-inline-start: var(--assistant-content-inset); - color: var(--color-ink); - font-size: var(--type-body-sm); -} +.rectification-saved { margin: 8px 0 16px; color: var(--color-ink); font-size: var(--type-body-sm); } .rectification-house-table { display: grid; gap: var(--space-3); - margin: 8px 0 16px; - padding-inline-start: var(--assistant-content-inset); + margin: 0; + padding: 18px; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-canvas-soft); } .rectification-house-table__heading { display: grid; gap: 5px; } .rectification-house-table__heading strong { @@ -1932,6 +1937,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class } .rectification-house-table .markdown-table { margin: 0; } @media (max-width: 640px) { + .rectification-house-table, .rectification-candidates { padding: 14px; } .rectification-candidate-list { grid-template-columns: none; diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 4138db6d..224b5532 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -54,6 +54,53 @@ type PersistedTurn = Readonly<{ type CandidateResult = RectificationCandidateResult | null; +function RectificationCandidateCards({ + result, + acceptingCandidateId, + readonly, + onAccept, +}: Readonly<{ + result: RectificationCandidateResult; + acceptingCandidateId: string | null; + readonly: boolean; + onAccept: (candidateId: string) => void; +}>) { + return ( +
+
+ 当前可能的出生时间 + 这次校正的结果是采用一个代表性时间作当前排盘。相邻分钟目前分不开,还不能确认唯一分钟。可以先采用,也可以继续补充事件或改选;新增证据后,候选和相对支持度会重新计算。 + 相对支持度不是统计概率;采用不等于确认出生时间,也不会覆盖原始填报时间。 +
+
+ {result.candidates.map((candidate) => { + const selected = result.selectedTime === candidate.time; + const recommended = isRecommendedRectificationCandidate(result, candidate); + return ( + + ); + })} +
+
+ ); +} + type RectificationAgenticChatProps = Readonly<{ caseId: string; sessionId: string; @@ -194,7 +241,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { top: container.scrollHeight, behavior: busy || reduceMotion ? "auto" : "smooth", }); - }, [busy, error, messages, savedTime]); + }, [busy, candidateResult, error, messages, savedTime]); // Candidate snapshot comes from the persisted Candidate Snapshot API, never // from parsing agent text or hidden sentinels. @@ -498,6 +545,20 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { && Boolean(message.turnId) && Boolean(message.text) ))?.renderKey; + const latestOfferMessageKey = [...messages] + .reverse() + .find((message) => ( + message.role === "assistant" + && message.state === "settled" + && !message.failed + && Boolean(message.text) + ))?.renderKey; + const showSelectionCards = Boolean( + candidateResult?.selectionAllowed + && latestOfferMessageKey + && !busy + && regeneratingMessageKey === null, + ); const canSend = !busy && !readonly && !regeneratingMessageKey; return ( @@ -552,45 +613,21 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { onRegenerate={() => void regenerateMessage(message)} /> )} + {showSelectionCards && message.renderKey === latestOfferMessageKey && candidateResult && ( + void acceptCandidate(candidateId)} + /> + )} ); })} {candidateResult?.houseTable && ( - - )} - {candidateResult?.selectionAllowed && ( -
-
- 当前可能的出生时间 - 这次校正的结果是采用一个代表性时间作当前排盘。相邻分钟目前分不开,还不能确认唯一分钟。可以先采用,也可以继续补充事件或改选;新增证据后,候选和相对支持度会重新计算。 - 相对支持度不是统计概率;采用不等于确认出生时间,也不会覆盖原始填报时间。 -
-
- {candidateResult.candidates.map((candidate) => { - const selected = candidateResult.selectedTime === candidate.time; - const recommended = isRecommendedRectificationCandidate(candidateResult, candidate); - return ( - - ); - })} -
-
+
+ +
)} {savedTime && (

diff --git a/frontend/src/components/rectification-house-table.tsx b/frontend/src/components/rectification-house-table.tsx index 08f613a7..aa860f13 100644 --- a/frontend/src/components/rectification-house-table.tsx +++ b/frontend/src/components/rectification-house-table.tsx @@ -6,12 +6,13 @@ export function RectificationHouseTableView({ table, }: Readonly<{ table: RectificationHouseTable }>) { return ( -

+
- 本命宫位 + 当前本命宫位 - 按 {table.time} 排出,上升{table.lagna}。这是当前代表性时间下的盘面,不是已确认出生盘。 + 按 {table.time} 排出,上升{table.lagna}。补充经历后会按新线索重算。 + 这是当前代表性时间下的盘面,不是已确认出生盘。
diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index 1ed80bb2..b8210add 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -227,7 +227,7 @@ test("Agentic rectification scrolls the conversation container as streamed messa assert.match(chat, /
{"), - chat.indexOf("{candidateResult?.selectionAllowed"), + chat.indexOf("{savedTime &&"), ); const replyIndex = messageRender.indexOf(" {"), - chat.indexOf("{candidateResult?.selectionAllowed"), + chat.indexOf("{savedTime &&"), ); const replyIndex = messageRender.indexOf(" { + const houseTable = readFileSync( + new URL("../src/components/rectification-house-table.tsx", import.meta.url), + "utf8", + ); + const messageLoop = chat.slice( + chat.indexOf("{messages.map((message) => {"), + chat.indexOf("{candidateResult?.houseTable &&"), + ); + const snapshot = chat.slice( + chat.indexOf("{candidateResult?.houseTable &&"), + chat.indexOf("{savedTime &&"), + ); + const rowIndex = messageLoop.indexOf("= 0); + assert.ok(snapshotIndex >= 0 && houseIndex > snapshotIndex); + assert.doesNotMatch(snapshot, / \{/); + assert.match(houseTable, /aria-live="polite"/); + assert.match(houseTable, /当前本命宫位/); + assert.match(houseTable, /补充经历后会按新线索重算/); + assert.doesNotMatch(houseTable, / { + const messageLoop = chat.slice( + chat.indexOf("{messages.map((message) => {"), + chat.indexOf("{candidateResult?.houseTable &&"), + ); + const actionsIndex = messageLoop.indexOf("= 0 && cardsIndex > actionsIndex); + assert.match(chat, /candidateResult\?\.selectionAllowed/); + assert.match(chat, /latestOfferMessageKey/); + assert.match(chat, /showSelectionCards = Boolean\(\s*candidateResult\?\.selectionAllowed[\s\S]*!busy[\s\S]*regeneratingMessageKey === null/); + assert.match(messageLoop, /showSelectionCards && message\.renderKey === latestOfferMessageKey/); + assert.doesNotMatch(messageLoop, /className="rectification-snapshot"/); +}); + test("rectification keeps the composer but never renders generated suggestion chips", () => { assert.match(chat, /