From 52f3c04b3bf1b32b002411c9504dac269ea57aee Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 18 Aug 2026 15:41:25 +0800 Subject: [PATCH] refactor(chat): remove the post-answer suggestion chips and the table that fed them Measured use of the three chips above the composer was negligible. They were also not what they appeared to be: the server looked up a fixed triplet by session theme and passed it as metadata that overrode anything the model produced, so the same ten hardcoded sets served every user regardless of question or chart. That is a plausible reason nobody pressed them. Both copies of the per-theme table are gone, reply metadata narrows to the session title, and the two parse entry points collapse into one now that they return the same shape. The write schema still tolerates a suggestions field so a client on the previous bundle does not lose its message mid-deploy, and stored answers containing the legacy hidden block are still stripped rather than shown raw. Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 18 +++++- frontend/DESIGN.md | 4 +- frontend/src/app/api/consult/route.ts | 4 +- frontend/src/app/globals.css | 22 +------ frontend/src/app/page.tsx | 45 ++------------ .../components/rectification-agentic-chat.tsx | 6 +- frontend/src/lib/agent-reply.ts | 60 +++++-------------- frontend/src/lib/chat-message-view.ts | 1 - .../src/lib/chat-session-write-contract.ts | 3 + .../src/lib/consultation-reply-metadata.ts | 26 +------- frontend/src/mastra/index.ts | 4 +- frontend/tests/agent-reply.test.ts | 17 +++--- frontend/tests/chat-stream-layout.test.ts | 18 +++--- .../tests/composer-isolation-contract.test.ts | 2 +- .../tests/consultation-entrypoint.test.ts | 20 ++++--- .../tests/consultation-reply-metadata.test.ts | 46 +++++++++----- .../consultation-stream-recovery.test.ts | 4 +- .../tests/rectification-agentic-entry.test.ts | 3 +- .../tests/session-conversation-layout.test.ts | 2 +- frontend/tests/starter-questions.test.ts | 12 ---- frontend/tests/timing-output-guard.test.ts | 10 ++-- 21 files changed, 123 insertions(+), 204 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index e92c36cd..d872efc4 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -4012,7 +4012,23 @@ 2. 契约收敛(用户评审后决定,同一分支内完成):`onboarding.greeting` 从 Agent 契约里彻底移除——schema、fallback、prompt、客户端响应校验与 `OnboardingContent` 类型全部不再有这个字段,不再为无渲染点的内容付费。`suggestions` 从 `career`/`marriage`/`timing` 的固定三元组改成按 `consultationDomainIds` 顺序覆盖全部十个域,校验用 refine 钉住「长度与顺序都必须与 registry 一致」,于是首页十张卡全部是 Agent 写的,静态 `prompt` 退回纯兜底角色。fallback 直接由 `generalGuidedJyotishTopics` 派生,避免手写十条又与 registry 漂移。生成十条比三条显著更慢,预算随之上调:路由 `maxDuration` 30→60 秒、服务端生成超时 18→45 秒、客户端单次请求超时 25→50 秒。缓存版本 `ayanam-onboarding-v4`→`v5`,让所有存量 v4 payload 重新生成一次。 - 验证:`onboarding-presentation.test.ts` 的 hero 断言改为同时检查 `page.tsx` 与 `globals.css` 中不再出现 `starter-hero-note`,避免只删元素留下死样式;`starter-questions.test.ts` 的边界声明断言从「文件里存在这句话」收紧为「这句话出现在主题区小标题且受 `personalChartAvailable` 分支控制」,防止下一次挪动文案时悄悄丢掉。契约部分新增 5 条回归:只覆盖三个域的旧形态响应必须整体拒绝并落到覆盖全域的兜底、主题顺序被交换必须拒绝(否则问题会挂到错误的主题标签下)、fallback 自身必须能通过 payload schema(registry 里的 prompt 一旦不再第一人称就会被这条抓住)、prompt 与 payload/client 源码中不得再出现 greeting 字段、路由必须把 registry 主题列表发给 Agent。客户端请求超时断言从 25 秒下限改到 45 秒下限,与服务端生成预算对齐。全量套件 1720/1729 通过,9 个失败全部是本机 Docker/PostgreSQL fixture(与 BUG-265、BUG-269 同一类环境失败);`tsc --noEmit` 清洁,ESLint 仅存量 4 条 warning,`next build` 成功。未做的验证:**没有在浏览器里看过改动后的首页**,也没有真实调用过新契约的生成——本机没有模型密钥,十条问题的实际生成耗时是否落在 45 秒预算内、十张卡的文案是否彼此重复、以及 hero 删掉第三行后的留白与边界声明在窄屏的折行,都要等 staging 发布后确认。若线上出现大量 `fallback`,第一嫌疑就是 45 秒预算仍然不够。 - 防复发:删除一个 UI 元素前必须先确认它有没有在承载与自身样式无关的合规或真实性文案——`starter-hero-note` 表面是装饰性说明行,实际是 BUG-200 边界声明的唯一落点。删元素时同步删样式,并用测试同时钉住两个文件,否则死 CSS 会在下一次改版时被误当作现有设计复用。声明数量的文案(「三个起点」)不要写死在与数据源无关的地方,数量必须跟着 registry 走。Agent 契约里的字段数量变化必须同步三件事:缓存版本、生成超时预算、客户端请求超时;只改 schema 不改预算的结果是全量用户静默落到 fallback,而 fallback 看起来是「正常内容」,不会报错。契约收紧时要用 refine 校验「集合与顺序」而不是只校验单项,否则模型少写几项或错位映射都能通过。 -- 相关记录:BUG-269(本次推翻其 hero 说明行处置)、BUG-200(首页真实性边界声明的原始约束,本次迁移未削弱) +- 相关记录:BUG-269(本次推翻其 hero 说明行处置)、BUG-200(首页真实性边界声明的原始约束,本次迁移未削弱)、BUG-271(同一轮评审的下一项删除) +- 复发自:无 +- 修复版本:本地未提交候选 + +## BUG-271 | 回答后输入框上方的三条推荐问题实际使用率极低且从未由 Agent 生成,整条链路删除 + +- 状态:resolved(本地修复,待提交与发布) +- 首次发现:2026-08-18 +- 最近更新:2026-08-18 +- 影响面:会话内 `composer-suggestions` 追问按钮、`/api/consult` 写入的 assistant 消息字段、`consultation-reply-metadata` 与 `agent-reply` 的解析契约、会话写入 schema、`composer-wrap` 相关样式与 DESIGN.md 的对应条目。 +- 用户现象:用户实际测试后反馈「Agent 回答后用户输入框上方的三个推荐问题」使用频率很少,要求删除,并要求 Agent 也不再生成这三个问题。 +- 触发条件:任何咨询会话收到 assistant 回复之后,输入框上方固定出现三条按钮。 +- 根因:这里有一个与用户预期不同的事实——**这三条问题从来不是 Agent 生成的**。`createConsultationReplyMetadata` 按会话主题从写死的十主题三元组里取一组,服务端总是把它塞进 `parseAgentReply` 的 metadata 参数,而 metadata 分支优先级高于模型输出(`metadata?.suggestions ?? …`),因此模型即便真的输出了 `AYANAM_SUGGESTIONS` 也会被覆盖;Prompt 本身还明确禁止模型产出隐藏元数据块。同一份三元组表被完整复制在 `agent-reply.ts` 与 `consultation-reply-metadata.ts` 两处。也就是说,界面上看起来「个性化」的追问入口,实际是按主题查表的十组固定文案,与用户的具体问题、星盘证据都无关——这正是使用率低的合理解释。附带发现:`chooseConversationSuggestion` 里针对「先完成生时校正」这一条的生时校正跳转分支在当前链路下不可达,因为服务端 metadata 恒定覆盖,三元组里从不含这条文案。 +- 修复:删除渲染点(`composer-suggestions` 块)、派生状态 `activeSuggestions`、点击处理 `chooseConversationSuggestion` 与常量 `rectifyBeforeConsultationSuggestion`、客户端 `readSuggestions` 与三处 `suggestions` 写入(send、本地预览、预览会话种子)。服务端 `consultationReplyMetadataSchema` 收缩为只有 `title` 且 `.strict()`,`createConsultationReplyMetadata` 不再需要 `theme` 入参,两份 `fallbackSuggestions` 表全部删除。`parseAgentReply` 与 `parseAgentReplyBody` 在失去 suggestions 后返回形状完全相同,合并为单一 `parseAgentReply(value, metadata?)`,生时校正改调这一个入口。`ChatMessage` 去掉 `suggestions` 字段;但会话写入 schema 的 `suggestions` **有意保留为可选**——该 schema 是 `.strict()` 的,发布瞬间仍在运行旧 bundle 的客户端会继续带上这个字段,拒绝整个写入等于丢掉用户那条消息,代价远大于留一个被忽略的字段。`stripAgentReplyMetadata` 同样保留剥离 `AYANAM_SUGGESTIONS` 注释的能力,因为删除前写入的历史回答里仍带着这个块,不剥离会把原始 HTML 注释显示给用户。CSS 删除 8 处 `.composer-suggestions` 规则(含混合选择器中的片段与两个媒体查询内的规则),Prompt 里「follow-up suggestions 由服务端生成」改为只提标题,DESIGN.md 对应条目改写为「回答不提供追问建议」并记录原因。 +- 验证:新增/改写回归共 6 条:会话内不得再出现 `composer-suggestions`/`activeSuggestions`/`chooseConversationSuggestion` 且 CSS 同步无残留、metadata schema 必须拒绝 `suggestions` 字段(防止 chips 从元数据侧回流)、两个库文件与 consult 路由中不得再出现主题三元组或 suggestions 写入、历史遗留的 `AYANAM_SUGGESTIONS` 块必须被剥离且不得复活成字段、输入框与正文之间不得再有会改变高度的兄弟节点(原 chip 行会在流式过程中撑高 composer)、生时校正入口在失去 chip 跳转后仍可从首页卡片进入。全量 1716/1721 通过,5 个失败全部是本机 Docker/PostgreSQL migration fixture;`tsc --noEmit` 清洁,ESLint 仅存量 4 条 warning,`next build` 成功。未做的验证:**没有在浏览器里确认删掉 chip 行后会话底部的留白与滚动锚点表现**,尤其是 BUG-263 里「跳到最新」按钮的定位曾依赖 chip 行带来的高度变化,需要发布后目视确认按钮位置仍然合理。 +- 防复发:不要把查表得到的固定文案摆在会让用户以为是个性化生成的位置——这类「假个性化」入口既消耗界面空间又必然低使用率,而且因为看起来正常,不会有人报 bug。同一份兜底数据不允许在两个模块各存一份副本,否则删除时必然漏掉一处。删除一个可选字段时要区分「输出契约」与「输入契约」:输出侧应当立刻停止产出,输入侧在旧客户端与历史数据仍可能带上它时必须继续宽容接收,否则发布窗口内会丢用户数据。当两个函数因为字段删除而返回形状相同时应当合并,但要意识到其中一个可能是某个历史 Bug(此处 BUG-179)刻意建立的隔离措施,合并前必须确认该措施防的问题已经在结构上不可能发生。 +- 相关记录:BUG-179(曾因通用解析器的三条建议兜底污染生时校正,本次删除使其隔离措施不再必要)、BUG-263(「跳到最新」按钮定位曾受 chip 行高度影响)、BUG-249(草稿隔离验证里包含推荐问题填入路径)、BUG-270(同一轮评审的上一项删除) - 复发自:无 - 修复版本:`a12f5797`(staging) diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index bb87d7a3..281cd950 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -163,7 +163,7 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3: - **Variants:** assistant editorial text on canvas; user text on warm card surface; streaming; error. - **Identity:** every assistant message carries the 32px Jyotisha logo avatar; user messages stay visually lighter and avatar-free. - **Typography:** assistant body 16px with serif subheadings; user body 14px. -- **Suggestions:** keep follow-up actions compact, with 8px internal horizontal padding and a narrower 680px group width. The current set remains visible while the user types or selects a suggestion and leaves only when that question is submitted. +- **Follow-up:** answers offer no suggested next questions. The composer is the only way to continue, because measured use of the suggestion chips was negligible against the vertical space and reading interruption they cost. - **Motion:** new messages enter with a short opacity/translate transition only. ### Suggestion card @@ -171,7 +171,7 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3: - **Structure:** topic label, question, directional icon. Categories are not numbered because they have no required order. At tablet widths, the cards stack into one column so Chinese questions keep natural phrase boundaries beside the persistent sidebar. - **Surface:** warm light cards; the lead card uses the pale brown emphasis surface and border instead of a dark block. - **States:** default, hover, active, focus, disabled, loading, fallback notice. -- **Visibility:** the three initial cards remain visible while the user types or chooses a question. They leave only after the question is submitted and the session receives its first user message. +- **Visibility:** the initial cards, one per consultation domain, remain visible while the user types or chooses a question. They leave only after the question is submitted and the session receives its first user message. ### Product entrypoint card diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index 6f4faa54..cd0d4595 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -469,14 +469,12 @@ export async function POST(request: Request) { try { const reply = parseAgentReply( rawTransformedText, - consultationTheme, - createConsultationReplyMetadata({ theme: consultationTheme, question: visibleQuestion }), + createConsultationReplyMetadata({ question: visibleQuestion }), ); if (!reply.text) throw new Error("empty_agent_reply"); const responseMessage = { role: "assistant" as const, text: reply.text, - suggestions: reply.suggestions, techniqueTruth, workflowReceipt, ...(agentExecutionReceipt ? { agentExecutionReceipt } : {}), diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 51477311..37b089ef 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -224,7 +224,6 @@ button:disabled { cursor: default; opacity: .45; } to { background-position: -120% 0; } } .agent-activity-status + .message-markdown { margin-top: var(--space-2); } -.composer-suggestions::-webkit-scrollbar { display: none; } .composer textarea::placeholder { color: var(--color-ink-tertiary); } .composer button svg { width: 19px; height: 19px; } .dialog-close svg { width: 19px; height: 19px; } @@ -270,7 +269,7 @@ button:disabled { cursor: default; opacity: .45; } .status-已过期, .status-已兑换 { color: var(--color-ink-secondary); } .empty-cell { color: var(--color-ink-secondary); text-align: center !important; } -.new-chat:not(:disabled):active, .session-main:not(:disabled):active, .session-menu-trigger:not(:disabled):active, .session-action-item:not([data-disabled]):active, .profile-trigger:not(:disabled):active, .credit-button:not(:disabled):active, .account-menu-item:not([data-disabled]):active, .starter-list > button:not(:disabled):active, .composer-suggestions button:not(:disabled):active, .composer button:not(:disabled):active, .button-primary:not(:disabled):active, .button-secondary:not(:disabled):active, .dialog-close:not(:disabled):active, .generated-list button:not(:disabled):active, .inline-actions button:not(:disabled):active { transform: scale(.96); } +.new-chat:not(:disabled):active, .session-main:not(:disabled):active, .session-menu-trigger:not(:disabled):active, .session-action-item:not([data-disabled]):active, .profile-trigger:not(:disabled):active, .credit-button:not(:disabled):active, .account-menu-item:not([data-disabled]):active, .starter-list > button:not(:disabled):active, .composer button:not(:disabled):active, .button-primary:not(:disabled):active, .button-secondary:not(:disabled):active, .dialog-close:not(:disabled):active, .generated-list button:not(:disabled):active, .inline-actions button:not(:disabled):active { transform: scale(.96); } @keyframes app-loading-orbit { to { transform: rotate(360deg); } } @keyframes pulse { from { opacity: .28; transform: translateY(1px); } to { opacity: 1; transform: translateY(-1px); } } @@ -724,8 +723,6 @@ button:disabled { cursor: default; opacity: .45; } .error-message { margin: 12px 0 0; padding: 12px 14px; border-left: 3px solid var(--color-danger); background: var(--color-danger-muted); color: var(--color-danger); font-size: 13px; line-height: 1.6; border-color: var(--color-danger); border-radius: 0 var(--radius-md) var(--radius-md) 0; } .composer-wrap { position: sticky; bottom: 0; z-index: 2; min-width: 0; border-top: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); padding: var(--space-3) var(--space-6) var(--space-4); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); } -.composer-suggestions { width: min(680px, 100%); margin: 0 auto 9px; overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x proximity; scrollbar-width: none; touch-action: pan-x; -webkit-overflow-scrolling: touch; display: grid; grid-template-columns: repeat(auto-fit, minmax(96px, 1fr)); gap: var(--space-2); margin-bottom: var(--space-3); overflow: visible; } -.composer-suggestions button { flex: 0 0 clamp(176px, 56vw, 230px); overflow: hidden; border: 1px solid var(--color-border); color: var(--color-ink-secondary); cursor: pointer; scroll-snap-align: start; text-overflow: ellipsis; white-space: nowrap; transition: border-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; width: 100%; min-width: 0; min-height: 44px; padding: 0 var(--space-2); border-color: var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-soft); font-size: var(--type-caption); } .composer { width: min(760px, 100%); display: flex; align-items: flex-end; gap: 10px; margin: 0 auto; border: 1px solid var(--color-border); transition: border-color 140ms ease-out; min-height: 60px; padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4); border-color: var(--color-border-strong); border-radius: var(--radius-lg); background: var(--color-canvas); box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-border) 42%, transparent); } .composer:focus-within { border-color: var(--color-action); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-action) 15%, transparent); } .composer textarea { min-width: 0; min-height: 44px; max-height: 128px; flex: 1; resize: none; padding: 11px 0 8px; border: 0; outline: 0; background: transparent; color: var(--color-ink); line-height: 1.5; font-size: 16px; } @@ -907,7 +904,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .model-selector-option:hover { background: var(--color-canvas-soft); color: var(--color-ink); } .starter-list > button:not(:disabled):hover { background: var(--color-canvas-strong); } .starter-list > button:first-of-type:not(:disabled):hover { background: color-mix(in srgb, var(--color-action-soft) 72%, var(--color-canvas)); } - .composer-suggestions button:not(:disabled):hover { border-color: var(--color-action); background: var(--color-canvas); color: var(--color-action-hover); } .button-secondary:not(:disabled):hover { background: var(--color-canvas-muted); } .danger-primary:not(:disabled):hover { background: color-mix(in srgb, var(--color-danger) 88%, var(--color-ink)); } .birth-time-source-option:hover, .birth-time-answer-list button:not(:disabled):hover { border-color: var(--color-action); } @@ -1333,8 +1329,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class } .composer-wrap-starter .composer, -.composer-wrap-starter .composer-footer, -.composer-wrap-starter .composer-suggestions { +.composer-wrap-starter .composer-footer { width: min(1040px, 100%); } @@ -1513,7 +1508,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class border-top-color: color-mix(in srgb, var(--color-border) 54%, transparent); } -.conversation:not(.is-empty):not(.is-rectification) + .composer-wrap .composer-suggestions, .conversation:not(.is-empty):not(.is-rectification) + .composer-wrap .composer, .conversation:not(.is-empty):not(.is-rectification) + .composer-wrap .composer-footer { width: min(var(--session-column-width), 100%); @@ -1525,18 +1519,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class box-shadow: 0 1px 2px rgba(29, 29, 31, .05), 0 12px 32px -24px rgba(29, 29, 31, .34); } -.conversation:not(.is-empty):not(.is-rectification) + .composer-wrap .composer-suggestions button { - background: color-mix(in srgb, var(--color-canvas-soft) 72%, var(--color-canvas)); -} - -@media (hover: hover) { - .conversation:not(.is-empty):not(.is-rectification) + .composer-wrap .composer-suggestions button:not(:disabled):hover { - border-color: color-mix(in srgb, var(--color-action) 36%, var(--color-border)); - background: var(--color-action-soft); - color: var(--color-action-hover); - } -} - @media (max-width: 767px) { :root { --session-column-gutter: var(--space-4); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 10cf98c7..15491707 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -266,8 +266,6 @@ const china = chinaLocations.country; const themes = defaultGuidedJyotishTopics; -const rectifyBeforeConsultationSuggestion = "先完成生时校正"; - const accountDialogTitles = { profile: "个人资料", logout: "退出登录?", @@ -564,14 +562,6 @@ function completedOnboardingTranscript(profile: Profile, greeting: string): Mess ]; } -function readSuggestions(value: unknown) { - if (!Array.isArray(value)) return []; - return [...new Set(value - .filter((item): item is string => typeof item === "string") - .map((item) => item.replace(/\s+/g, " ").trim().slice(0, 80)) - .filter(Boolean))].slice(0, 3); -} - function readProfile(value: unknown): Profile { if (!value || typeof value !== "object") return emptyProfile; const profile = value as Partial & { @@ -686,7 +676,6 @@ function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null ? [{ role: (message as Message).role, text: (message as Message).text.slice(0, 12000), - suggestions: readSuggestions((message as Message).suggestions), }] : [] )) @@ -1165,10 +1154,6 @@ export default function Home() { localStorage.setItem(`${prefix}archived`, JSON.stringify(archivedSessionIds)); }, [accountId, archivedSessionIds, hydrated, pinnedSessionIds]); - const activeSuggestions = activeSession?.messages.reduce( - (latest, message) => message.role === "assistant" && message.suggestions?.length ? message.suggestions : latest, - [], - ) ?? []; useEffect(() => { if (!accountId) { setChartLibrary([]); @@ -1372,7 +1357,7 @@ export default function Home() { const previewMessages: Message[] = previewMode === "conversation" || previewMode === "streaming" || previewMode === "partial" ? [ { role: "user", text: "未来半年是否适合换工作?" }, - { role: "assistant", text: "可以先看职业方向、关键时间。\n同时评估现实风险。\n此处只展示本地预览,\n不调用真实星盘。", suggestions: ["先看事业方向", "再看关键时间", "评估现实风险"] }, + { role: "assistant", text: "可以先看职业方向、关键时间。\n同时评估现实风险。\n此处只展示本地预览,\n不调用真实星盘。" }, ] : []; const previewSession: ChatSession = { @@ -2359,18 +2344,6 @@ export default function Home() { window.requestAnimationFrame(() => composerInput.current?.focus()); } - function chooseConversationSuggestion(suggestion: string) { - if (suggestion !== rectifyBeforeConsultationSuggestion - || activeSession?.sessionType !== "consultation") { - chooseSuggestedQuestion(suggestion); - return; - } - const originalQuestion = [...activeSession.messages] - .reverse() - .find((message) => message.role === "user")?.text.trim(); - void openRectificationFromHomepage(originalQuestion ?? null); - } - function draftDailyStarlanguageQuestion() { chooseSuggestedQuestion( personalChartAvailable @@ -2937,16 +2910,14 @@ export default function Home() { } const previewReply = parseAgentReply([ "这是本地交互预览。正式对话会结合你的星盘证据继续分析。", - '', "", - ].join("\n"), theme); + ].join("\n")); const previewSession: ChatSession = { ...userSession, title: userSession.title, messages: [...userSession.messages, { role: "assistant", text: previewReply.text, - suggestions: previewReply.suggestions, }], updatedAt: timestamp(), }; @@ -3047,7 +3018,7 @@ export default function Home() { const decoder = new TextDecoder(); let answer = ""; const updateStreamingAnswer = (activity?: AgentActivityView) => { - const partialReply = parseAgentReply(answer, theme).text; + const partialReply = parseAgentReply(answer).text; latestPartialReply = partialReply; setStreamingReply({ sessionId, text: partialReply, activity }); if (partialReply && pendingConsultation.current?.requestId === requestId) { @@ -3100,7 +3071,7 @@ export default function Home() { } if (controller.signal.aborted) return Boolean(latestPartialReply); if (!answer.trim()) throw new Error("Agent 没有返回内容,请重试。"); - const reply = parseAgentReply(answer, theme); + const reply = parseAgentReply(answer); if (!reply.text) throw new Error("Agent 没有返回可显示的回答,请重试。"); const completedSession: ChatSession = { @@ -3109,7 +3080,6 @@ export default function Home() { messages: [...userSession.messages, { role: "assistant", text: reply.text, - suggestions: reply.suggestions, techniqueTruth, workflowReceipt, agentExecutionReceipt, @@ -3519,13 +3489,6 @@ export default function Home() { )} - {activeSuggestions.length > 0 && ( -
- {activeSuggestions.map((question) => ( - - ))} -
- )} current.map((message) => message.renderKey === assistantRenderKey ? { ...message, text: parsed.text, state: "streaming" } : message)); @@ -376,7 +376,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { } } - const parsed = parseAgentReplyBody(raw); + const parsed = parseAgentReply(raw); const succeeded = completed && !streamFailed && Boolean(parsed.text); setMessages((current) => current.flatMap((message): RenderMessage[] => { if (message.renderKey !== assistantRenderKey) return [message]; diff --git a/frontend/src/lib/agent-reply.ts b/frontend/src/lib/agent-reply.ts index f2155f02..c7088091 100644 --- a/frontend/src/lib/agent-reply.ts +++ b/frontend/src/lib/agent-reply.ts @@ -4,27 +4,6 @@ export type ReplyTheme = ConsultationDomain; import type { ConsultationReplyMetadata } from "./consultation-reply-metadata.ts"; -const fallbackSuggestions: Record = { - career: ["我更适合怎样的职业路径?", "未来一年事业上要避开什么?", "我该如何发挥自己的优势?"], - marriage: ["我在关系里容易重复什么模式?", "怎样的伴侣更适合我?", "未来一年关系上要注意什么?"], - wealth: ["我的财富增长方式是什么?", "接下来财务上要避开什么?", "我该如何稳定提升收入?"], - health: ["近期压力主要来自哪里?", "怎样安排休息与恢复?", "哪些信号值得持续观察?"], - education: ["我更适合怎样的学习路径?", "现在该补哪项能力?", "什么阶段适合考试或进修?"], - migration: ["迁居方向该优先考虑什么?", "置业与流动之间如何取舍?", "海外发展要注意哪些条件?"], - family: ["家庭关系中该建立什么边界?", "我承担的责任是否失衡?", "接下来适合如何沟通?"], - annual: ["未来一年最重要的主题是什么?", "哪些阶段适合主动推进?", "哪些阶段更适合调整?"], - timing: ["接下来最值得把握的阶段是什么?", "哪些时期更适合主动行动?", "我现在应该优先准备什么?"], - general: ["未来一年,事业和收入该关注什么?", "我的关系模式是什么?", "未来哪些阶段值得把握?"], -}; - -function readSuggestions(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return [...new Set(value - .filter((item): item is string => typeof item === "string") - .map((item) => item.replace(/\s+/g, " ").trim().slice(0, 80)) - .filter(Boolean))].slice(0, 3); -} - function readTitle(value: string): string | undefined { const title = value.replace(/\s+/g, " ").trim(); if (!title || /[\d\p{P}\p{S}]/u.test(title)) return undefined; @@ -36,37 +15,26 @@ function readTitle(value: string): string | undefined { return words.length >= 3 && words.length <= 7 && title.length <= 64 ? title : undefined; } +// AYANAM_SUGGESTIONS blocks are no longer produced anywhere, but stored answers from +// before the follow-up chips were removed still carry them, so they stay strippable +// rather than leaking a raw HTML comment into a replayed transcript. function stripAgentReplyMetadata(value: string) { - let suggestions: string[] = []; let title: string | undefined; - const withoutSuggestions = value.replace(//g, (_, json: string) => { - try { - suggestions = readSuggestions(JSON.parse(json)); - } catch { - suggestions = []; - } - return ""; - }); - const text = withoutSuggestions.replace(//g, (_, rawTitle: string) => { - title = readTitle(rawTitle); - return ""; - }).replace(//g, "") + .replace(//g, (_, rawTitle: string) => { + title = readTitle(rawTitle); + return ""; + }) + .replace(/"; // When - const reply = parseAgentReply(response, "general"); + const reply = parseAgentReply(response); // Then assert.equal(reply.text, "回答正文"); @@ -35,7 +35,7 @@ test("accepts a concise English model-generated session title", () => { const response = "Your next step is to test the market first.\n"; // When - const reply = parseAgentReply(response, "career"); + const reply = parseAgentReply(response); // Then assert.equal(reply.title, "Career Change Timing"); @@ -46,7 +46,7 @@ test("hides an incomplete metadata block while a reply is streaming", () => { const response = "回答正文\n', ].join("\n")); assert.deepEqual(reply, { text: "可以先采用 05:07 作为当前排盘时间。", title: undefined }); + assert.deepEqual(Object.keys(reply), ["text", "title"]); }); diff --git a/frontend/tests/chat-stream-layout.test.ts b/frontend/tests/chat-stream-layout.test.ts index 87b06667..7b0b7621 100644 --- a/frontend/tests/chat-stream-layout.test.ts +++ b/frontend/tests/chat-stream-layout.test.ts @@ -73,16 +73,16 @@ test("shows honest agent activity states before and during streamed text", () => assert.match(pageSource, /agentExecutionReceipt = event\.receipt/); }); -test("keeps the suggestion row height stable while an answer streams", () => { - // Given: a completed answer already supplies follow-up suggestions. - const suggestionBlock = pageSource.match(/\{activeSuggestions\.length > 0[\s\S]*?
\n\s*\)\}/); +test("nothing sits between the transcript and the composer to shift height while streaming", () => { + // The follow-up chip row used to appear and disappear here, changing composer height + // mid-stream; the jump-to-latest overlay is the only remaining sibling and it is + // absolutely positioned, so it cannot reflow the composer. + const composerWrap = pageSource.slice( + pageSource.indexOf('composer-wrap ${starterHomeVisible'), + pageSource.indexOf(" { diff --git a/frontend/tests/composer-isolation-contract.test.ts b/frontend/tests/composer-isolation-contract.test.ts index 3c7afb42..53ee2335 100644 --- a/frontend/tests/composer-isolation-contract.test.ts +++ b/frontend/tests/composer-isolation-contract.test.ts @@ -81,7 +81,7 @@ test("every external draft writer keeps working through the page-owned setters", assert.match(pageSource, /onChange=\{\(event\) => \{\n\s*setDraft\(event\.target\.value\);\n\s*setDraftTheme\(null\);\n\s*setDraftEntrypoint\(null\);\n\s*setComposerNotice\(""\);\n\s*\}\}/); // When: each existing write path is inspected. - const chooseSuggested = sourceBetween(pageSource, "function chooseSuggestedQuestion(", "function chooseConversationSuggestion"); + const chooseSuggested = sourceBetween(pageSource, "function chooseSuggestedQuestion(", "function draftDailyStarlanguageQuestion"); const startNewChat = sourceBetween(pageSource, "async function startNewChat()", "function selectSession("); const selectSession = sourceBetween(pageSource, "function selectSession(sessionId: string)", "async function selectSessionModel"); const saveOnboardingName = sourceBetween(pageSource, "async function saveOnboardingName()", "async function saveOnboardingBirth"); diff --git a/frontend/tests/consultation-entrypoint.test.ts b/frontend/tests/consultation-entrypoint.test.ts index 7aeba921..b35747fd 100644 --- a/frontend/tests/consultation-entrypoint.test.ts +++ b/frontend/tests/consultation-entrypoint.test.ts @@ -183,16 +183,20 @@ test("homepage creation and sidebar selection resolve through distinct server in assert.match(component, / { +test("an answered conversation offers no suggested follow-up questions", () => { const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); - const start = source.indexOf("function chooseConversationSuggestion"); - const end = source.indexOf("function draftDailyStarlanguageQuestion", start); - const handler = source.slice(start, end); + const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); - assert.match(handler, /suggestion !== rectifyBeforeConsultationSuggestion/); - assert.match(handler, /find\(\(message\) => message\.role === "user"\)/); - assert.match(handler, /openRectificationFromHomepage\(originalQuestion \?\? null\)/); - assert.match(source, /onClick=\{\(\) => chooseConversationSuggestion\(question\)\}/); + // Given: the chips above the composer were removed after measured use stayed negligible. + assert.doesNotMatch(source, /composer-suggestions|activeSuggestions|chooseConversationSuggestion/); + assert.doesNotMatch(styles, /composer-suggestions/); + + // Then: nothing in the conversation carries or stores a suggestion list any more. + assert.doesNotMatch(source, /suggestions: (?:reply|previewReply|parsed)\.suggestions/); + assert.doesNotMatch(source, /readSuggestions/); + + // And: rectification is still reachable without the chip that used to hand off to it. + assert.match(source, /onClick=\{\(\) => void openRectificationFromHomepage\(\)\}/); }); test("rectify-first handoffs stay as Agent context", () => { diff --git a/frontend/tests/consultation-reply-metadata.test.ts b/frontend/tests/consultation-reply-metadata.test.ts index 09e46913..6badba35 100644 --- a/frontend/tests/consultation-reply-metadata.test.ts +++ b/frontend/tests/consultation-reply-metadata.test.ts @@ -1,32 +1,48 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import test from "node:test"; import { createConsultationReplyMetadata, consultationReplyMetadataSchema } from "../src/lib/consultation-reply-metadata.ts"; import { parseAgentReply } from "../src/lib/agent-reply.ts"; -import { consultationDomainIds } from "../src/lib/consultation-domain-registry.ts"; -for (const theme of consultationDomainIds) { - test(`server-owned metadata is bounded for ${theme}`, () => { - const metadata = createConsultationReplyMetadata({ theme, question: "未来的重点是什么?" }); - const parsed = consultationReplyMetadataSchema.parse(metadata); - assert.equal(parsed.suggestions.length, 3); - assert.ok(parsed.suggestions.every((item) => item.length > 0 && item.length <= 80)); - assert.ok(parsed.title.length >= 6 && parsed.title.length <= 14); - assert.doesNotMatch(parsed.title, /[\d\p{P}\p{S}]/u); - }); -} +test("server-owned metadata is a bounded title and nothing else", () => { + const metadata = createConsultationReplyMetadata({ question: "未来的重点是什么?" }); + const parsed = consultationReplyMetadataSchema.parse(metadata); -test("server metadata wins without blocking legacy hidden-block parsing", () => { + assert.deepEqual(Object.keys(parsed), ["title"]); + assert.ok(parsed.title.length >= 6 && parsed.title.length <= 14); + assert.doesNotMatch(parsed.title, /[\d\p{P}\p{S}]/u); +}); + +test("the schema rejects a suggestions field so the chips cannot come back through metadata", () => { + assert.equal( + consultationReplyMetadataSchema.safeParse({ title: "工作变化的重点是什么", suggestions: ["一", "二", "三"] }).success, + false, + ); +}); + +test("server metadata wins over a model title and produces no suggestions", () => { const reply = parseAgentReply( [ "回答正文", '', "", ].join("\n"), - "career", - createConsultationReplyMetadata({ theme: "career", question: "工作变化的重点是什么?" }), + createConsultationReplyMetadata({ question: "工作变化的重点是什么?" }), ); assert.equal(reply.text, "回答正文"); - assert.deepEqual(reply.suggestions, ["我更适合怎样的职业路径?", "未来一年事业上要避开什么?", "我该如何发挥自己的优势?"]); assert.equal(reply.title, "工作变化的重点是什么"); + assert.deepEqual(Object.keys(reply), ["text", "title"]); +}); + +test("no per-theme suggestion table survives anywhere in the reply pipeline", () => { + // Two copies of the same ten-theme triplet used to exist; both are gone, and the + // consult route must no longer attach a suggestions field to the stored message. + const metadata = readFileSync(new URL("../src/lib/consultation-reply-metadata.ts", import.meta.url), "utf8"); + const parsing = readFileSync(new URL("../src/lib/agent-reply.ts", import.meta.url), "utf8"); + const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8"); + + assert.doesNotMatch(metadata, /fallbackSuggestions|suggestions:/); + assert.doesNotMatch(parsing, /fallbackSuggestions/); + assert.doesNotMatch(route, /suggestions/); }); diff --git a/frontend/tests/consultation-stream-recovery.test.ts b/frontend/tests/consultation-stream-recovery.test.ts index b72bb98f..429e12e4 100644 --- a/frontend/tests/consultation-stream-recovery.test.ts +++ b/frontend/tests/consultation-stream-recovery.test.ts @@ -22,9 +22,9 @@ test("persists transformed assistant metadata before atomically settling usage", assert.equal(consultRoute.match(/onComplete: \(rawTransformedText\) => settle\(\(\) => completeResponse\(/g)?.length, 2); assert.match( consultRoute, - /parseAgentReply\([\s\S]*?rawTransformedText,[\s\S]*?consultationTheme,[\s\S]*?createConsultationReplyMetadata\(\{ theme: consultationTheme, question: visibleQuestion \}\),[\s\S]*?\)/, + /parseAgentReply\([\s\S]*?rawTransformedText,[\s\S]*?createConsultationReplyMetadata\(\{ question: visibleQuestion \}\),[\s\S]*?\)/, ); - assert.match(consultRoute, /role: "assistant" as const,[\s\S]*suggestions: reply\.suggestions,[\s\S]*techniqueTruth,[\s\S]*workflowReceipt/); + assert.match(consultRoute, /role: "assistant" as const,[\s\S]*techniqueTruth,[\s\S]*workflowReceipt/); const append = migration.indexOf("set messages = session.messages || jsonb_build_array(p_response_message)"); const store = migration.indexOf("set response_message = p_response_message"); diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index bebabe63..ff8ea0c5 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -321,8 +321,7 @@ test("rectification keeps the composer but never renders generated suggestion ch assert.match(chat, /
{ diff --git a/frontend/tests/session-conversation-layout.test.ts b/frontend/tests/session-conversation-layout.test.ts index 8f2ea0c3..b4cb57b6 100644 --- a/frontend/tests/session-conversation-layout.test.ts +++ b/frontend/tests/session-conversation-layout.test.ts @@ -10,7 +10,7 @@ test("aligns the session transcript and composer to one readable column", () => assert.match(globalStyles, /--session-column-width:\s*760px/); assert.match(globalStyles, /\.conversation:not\(\.is-empty\):not\(\.is-rectification\) \.message-list\s*\{[^}]*width:\s*min\(calc\(var\(--session-column-width\)/); assert.match(globalStyles, /\.conversation:not\(\.is-empty\):not\(\.is-rectification\) \+ \.composer-wrap \.composer[^\{]*\{[^}]*width:\s*min\(var\(--session-column-width\),\s*100%\)/); - assert.match(globalStyles, /\.conversation:not\(\.is-empty\):not\(\.is-rectification\) \+ \.composer-wrap \.composer-suggestions[^\{]*[\s\S]*?width:\s*min\(var\(--session-column-width\),\s*100%\)/); + assert.match(globalStyles, /\.conversation:not\(\.is-empty\):not\(\.is-rectification\) \+ \.composer-wrap \.composer-footer[^\{]*\{[^}]*width:\s*min\(var\(--session-column-width\),\s*100%\)/); }); test("keeps message motion restrained and honors reduced-motion preferences", () => { diff --git a/frontend/tests/starter-questions.test.ts b/frontend/tests/starter-questions.test.ts index 57dfef39..7f6a88ae 100644 --- a/frontend/tests/starter-questions.test.ts +++ b/frontend/tests/starter-questions.test.ts @@ -109,18 +109,6 @@ test("profiles without a usable birth minute receive user-centered starter promp assert.match(pageSource, /personalChartAvailable \? "daily_starlanguage" : null/); }); -test("keeps follow-up suggestions visible while the user edits a draft", () => { - // Given: the follow-up suggestion block and its render guard. - const suggestionGuard = sourceBetween( - pageSource, - "{activeSuggestions.length > 0", - "(\n
{ // Given: the page-owned selection callback and the app sidebar session action. diff --git a/frontend/tests/timing-output-guard.test.ts b/frontend/tests/timing-output-guard.test.ts index 71e361d9..a73cb3c4 100644 --- a/frontend/tests/timing-output-guard.test.ts +++ b/frontend/tests/timing-output-guard.test.ts @@ -65,7 +65,7 @@ test("guards only visible prose and preserves AYANAM blocks across arbitrary chu transformText: createBirthTimeModeOutputGuard("general_no_birth_time", false), }); const text = await response.text(); - const parsed = parseAgentReply(text, "general"); + const parsed = parseAgentReply(text); assert.doesNotMatch(text, /2027年3月15日|正文说你将在.*一定会升职/); assert.match(text, /^一般知识不依赖个人星盘/); @@ -73,7 +73,9 @@ test("guards only visible prose and preserves AYANAM blocks across arbitrary chu assert.equal(text.includes(suggestions), true); assert.equal(text.includes(title), true); assert.doesNotMatch(text, /