From 59559d4b24cee3654491c1debddb94c60c02a2c1 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sat, 22 Aug 2026 09:10:47 +0800 Subject: [PATCH] fix(web): persist thinking, title sessions distinctly, and send follow-ups from the answer Thinking disappeared on failure and never reached session storage. Keep the sanitized chain on disk and on errors, and regroup the sidebar around reports, charts, favorites, and dated history titles. Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 18 +- frontend/DESIGN.md | 9 +- frontend/src/app/api/consult/route.ts | 12 +- frontend/src/app/globals.css | 123 +++++++++++-- frontend/src/app/page.tsx | 143 +++++++++++++--- .../src/components/agent-activity-status.tsx | 73 ++++++-- frontend/src/components/app-sidebar.tsx | 161 +++++++++++++----- frontend/src/components/chat-message-row.tsx | 40 +---- .../components/conversation-follow-ups.tsx | 28 +++ .../src/components/sidebar-session-row.tsx | 12 +- frontend/src/lib/agent-reply.ts | 72 +++++++- frontend/src/lib/chat-message-view.ts | 7 + .../src/lib/chat-session-write-contract.ts | 9 +- frontend/src/lib/consultation-follow-ups.ts | 69 ++++++++ frontend/src/lib/stream-agent-response.ts | 10 +- .../tests/agent-activity-progress.test.ts | 5 + frontend/tests/agent-reply.test.ts | 27 +++ frontend/tests/chat-session-write.test.ts | 11 +- frontend/tests/chat-stream-layout.test.ts | 14 +- .../consultation-agentic-runtime.test.ts | 3 + .../tests/consultation-entrypoint.test.ts | 6 +- .../tests/consultation-follow-ups.test.ts | 26 +++ frontend/tests/consultation-recovery.test.ts | 10 +- .../consultation-stream-recovery.test.ts | 2 +- .../tests/rectification-agentic-entry.test.ts | 10 +- frontend/tests/sidebar-contract.test.ts | 8 +- tests/test_session_management_entrypoints.py | 12 +- 27 files changed, 741 insertions(+), 179 deletions(-) create mode 100644 frontend/src/components/conversation-follow-ups.tsx create mode 100644 frontend/src/lib/consultation-follow-ups.ts create mode 100644 frontend/tests/consultation-follow-ups.test.ts diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index e0b0a8aa..e23aeb43 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -5236,10 +5236,26 @@ - 用户现象:生时纠正已有灰色「思考过程」、正文出来后折叠;普通咨询仍无思维链。 - 触发条件:网页个人咨询 / 无分钟百科 / 声明窗口咨询。 - 根因:BUG-305 / BUG-340 为保住可见 token 预算,把咨询 `consultationGenerationSettings` 固定为 thinking disabled,并丢弃 `reasoning-*`。BUG-345 只给纠正打开独立 `thinking.delta` 与折叠 UI。 -- 修复:咨询同样启用 provider thinking,8192 可见预算不变。`reasoning-delta` 经中文清洗后发 `thinking.delta`;契约未绿的 `text-delta` 仍按 BUG-277 丢弃,不升格为思维链。咨询页把 `thinkingText` 接到共用 `ChatMessageRow`,有正文后默认折叠。会话落盘不保存思维链。 +- 修复:咨询同样启用 provider thinking,8192 可见预算不变。`reasoning-delta` 经中文清洗后发 `thinking.delta`;契约未绿的 `text-delta` 仍按 BUG-277 丢弃,不升格为思维链。咨询页把 `thinkingText` 接到共用 `ChatMessageRow`,有正文后默认折叠。后续 BUG-347 把思维链写入会话落盘,并在失败时保留已展示的思考过程。 - 验证:`frontend/tests/consultation-agentic-runtime.test.ts` 中文 reasoning 进 thinking 通道、英文过程自述不进正文;`consultation-workflow-contract.test.ts` 锁定 `thinking: "enabled"`;`chat-stream-layout.test.ts` 锁定咨询页与折叠 UI。 - 防复发:咨询与纠正的思维链必须是 `thinking.delta`,不得混进 `answer.delta`。BUG-277 的契约前正文仍须丢弃。打开 thinking 不得降低 8192 可见预算,`finish_reason=length` 仍不得当完成。 - 相关记录:BUG-277、BUG-305、BUG-340、BUG-345 - 复发自:BUG-305(咨询关 thinking 保预算)、BUG-345(只修了纠正) - 修复版本:4e247c11 +## BUG-347 | 咨询失败或刷新后思维链被清掉 + +- 状态:resolved +- 首次发现:2026-08-22 +- 最近更新:2026-08-22 +- 影响面:咨询流式 UI、`complete_consultation_response` 落盘、`chatSessionWriteSchema`、会话读取 +- 用户现象:开始出回答时思维链消失;若随后报「Agent 回答未完成」或刷新,已展示的思考过程一起没了。 +- 触发条件:普通咨询流式生成中出现 `run.failed`、空正文,或成功后刷新页面。 +- 根因:思维链只活在 `streamingReply.thinkingText`。失败路径回滚到提问前的会话并清掉 streaming;成功路径虽然把 thinking 写进内存消息,但结算 RPC 与会话 PATCH 都不保存该字段,读取时也会丢掉额外消息字段。 +- 修复:失败时保留用户问题、已有思考过程和失败说明,不再把思维链卸掉。成功结算把清洗后的 `thinkingText` 写入 `p_response_message`;客户端会话读写契约同步接收该字段。界面改成时间线思考块,完成步骤一行一项。 +- 验证:`frontend/tests/chat-session-write.test.ts` 接受 thinkingText;`consultation-stream-recovery.test.ts` 锁定结算消息含思维链;`chat-stream-layout.test.ts` 锁定时间线 UI;失败路径保留 `thinkingText` 的源码合同。 +- 防复发:咨询结算消息与会话 PATCH 必须能保存 `thinkingText`。失败不得用回滚抹掉已展示的思考过程。读取会话时不得只保留 `role/text`。 +- 相关记录:BUG-277、BUG-346 +- 复发自:BUG-346(打开思维链通道但明确不落盘) +- 修复版本:pending + diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index edb0fede..95d1c06f 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -152,7 +152,8 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3: - **Desktop:** 288px expanded by default at 1024px and above; 64px collapsed icon rail. - **Tablet:** 64px collapsed by default from 768px through 1023px; 240px when expanded. - **Mobile:** no icon rail; an off-canvas drawer uses `min(86vw, 320px)` and closes through its scrim or Escape. -- **Collapsed content:** logo, new-chat action, one history expansion action, and account avatar. Individual sessions do not become indistinguishable repeated icons. +- **Collapsed content:** logo, new-chat action, reports action, one history expansion action, and account avatar. Individual sessions do not become indistinguishable repeated icons. +- **Expanded content:** new chat, my reports, chart list, favorites, then history. Empty untitled sessions stay off the history list. Pin is favorite; archive stays in the session menu. - **Scroll ownership:** header and footer remain fixed; `SidebarContent` is the sole sidebar scroll owner. - **Scrollbar:** `SidebarContent`, ordinary session `.conversation`, and the rectification house board use a quiet overlay scrollbar: transparent track, no `scrollbar-gutter`, and a 4px warm thumb mixed from `--color-ink`. The thumb stays transparent until hover or keyboard focus inside the scroller, then uses `color-mix(in srgb, var(--color-ink) 26%, transparent)`; thumb hover uses 40%. Increased contrast keeps the thumb visible; forced colors restore the system scrollbar. - **Motion:** Sidebar state changes are immediate on desktop, tablet, and mobile. The 44px trigger keeps one stable 18px sidebar glyph and never enters an intermediate scale or opacity state. @@ -161,10 +162,10 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3: ### Message -- **Variants:** assistant editorial text on canvas; user text on warm card surface; streaming; error. +- **Variants:** assistant editorial text on canvas; user text on warm card surface; streaming; error. Streaming uses a timeline of completed steps plus the current step; the thinking body expands while streaming, collapses when answer text appears, and is stored with the assistant message. - **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. -- **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. +- **Follow-up:** the latest settled consultation answer may offer two or three grounded next questions under that answer. Clicking one sends it in the current session. The composer never hosts suggestion chips. If the answer does not support a grounded continuation, nothing is shown. - **Motion:** new messages enter with a short opacity/translate transition only. ### Suggestion card @@ -177,7 +178,7 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3: ### Product entrypoint card - **Structure:** the homepage daily-reading and birth-time cards are single native-button targets stretched across their article surface. Content remains semantic card copy; a compact action label and arrow sit at the lower right. -- **Copy:** chat history shows only the public labels “深入看今日”, “生时校正”, or “再次校正”. Clicking the daily card starts a consultation session with that public label and waits for the model; it does not place the question in the composer. Private model instructions are selected by a closed entrypoint identifier and expanded only on the server. +- **Copy:** chat history titles daily readings as “8月22日 · 今日节奏”, topic cards as “事业 · …”, and rectification as “生时校正 · 8月21日”. Clicking the daily card starts a consultation session immediately; it does not place the question in the composer. Private model instructions are selected by a closed entrypoint identifier and expanded only on the server. - **States:** default, whole-card hover, pressed, focus-visible, and disabled. The card surface—not an inner promotional button—carries the interaction feedback. - **Responsive:** cards stack below 768px without introducing a large nested button; the footer keeps supporting copy flexible and the action label on one line. - **Accessibility:** each card exposes exactly one native button with a descriptive accessible name, preserves a visible focus ring, and meets the full-card touch target. diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index 4673ee22..8a043994 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -500,6 +500,7 @@ export async function POST(request: Request) { techniqueTruth: string, workflowReceipt: WorkflowReceipt, agentExecutionReceipt?: AgentExecutionReceipt, + thinkingText?: string, ): Promise { try { const reply = parseAgentReply( @@ -507,9 +508,11 @@ export async function POST(request: Request) { createConsultationReplyMetadata({ question: visibleQuestion }), ); if (!reply.text) throw new Error("empty_agent_reply"); + const persistedThinking = thinkingText?.trim().slice(0, 4_000); const responseMessage = { role: "assistant" as const, text: reply.text, + ...(persistedThinking ? { thinkingText: persistedThinking } : {}), techniqueTruth, workflowReceipt, ...(agentExecutionReceipt ? { agentExecutionReceipt } : {}), @@ -755,12 +758,13 @@ export async function POST(request: Request) { headers: { "x-jyotish-birth-time-mode": consultationMode }, onFirstActivity: markFirstActivity, onFirstOutput: markFirstText, - onComplete: (output, agentExecutionReceipt) => settleRun(() => completeResponse( + onComplete: (output, agentExecutionReceipt, thinkingText) => settleRun(() => completeResponse( output, mergeUsage(usages), generalDailyContext ? "public-panchanga-only" : "not-applicable", workflowReceipt, agentExecutionReceipt, + thinkingText, ), undefined), onError: (error) => settleRun( cancel, @@ -840,12 +844,13 @@ export async function POST(request: Request) { headers: { "x-jyotish-birth-time-mode": consultationMode }, onFirstActivity: markFirstActivity, onFirstOutput: markFirstText, - onComplete: (output, agentExecutionReceipt) => settleRun(() => completeResponse( + onComplete: (output, agentExecutionReceipt, thinkingText) => settleRun(() => completeResponse( output, mergeUsage(usages), state.techniqueTruth ?? "declared-window", state.workflowReceipt ?? workflowReceipt, agentExecutionReceipt, + thinkingText, ), undefined), onError: (error) => settleRun( cancel, @@ -930,12 +935,13 @@ export async function POST(request: Request) { headers: { "x-jyotish-birth-time-mode": consultationMode }, onFirstActivity: markFirstActivity, onFirstOutput: markFirstText, - onComplete: (output, agentExecutionReceipt) => settleRun(() => completeResponse( + onComplete: (output, agentExecutionReceipt, thinkingText) => settleRun(() => completeResponse( output, mergeUsage(usages), state.techniqueTruth ?? "unknown", state.workflowReceipt ?? workflowReceipt, agentExecutionReceipt, + thinkingText, ), undefined), onError: (error) => settleRun( cancel, diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 08583df7..c849f6cf 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -241,20 +241,50 @@ button:disabled { cursor: default; opacity: .45; } } .markdown-table tr:last-child th, .markdown-table tr:last-child td { border-bottom: 0; } -.agent-activity-status { +.agent-activity-status, +.agent-thinking-panel { min-height: 24px; - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 2px; - color: var(--color-ink-tertiary); + display: grid; + gap: var(--space-2); + margin: 0 0 var(--space-3); + color: var(--color-ink-secondary); font-size: var(--type-body-sm); line-height: 1.5; } +.agent-thinking-timeline { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: var(--space-2); +} +.agent-thinking-step { + display: grid; + grid-template-columns: 20px minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-2); + min-height: 24px; +} +.agent-thinking-marker { + width: 20px; + height: 20px; + display: grid; + place-items: center; + border-radius: 999px; + background: color-mix(in srgb, var(--color-success) 14%, var(--color-canvas)); + color: var(--color-success); +} +.agent-thinking-marker svg { width: 12px; height: 12px; } +.agent-thinking-marker.is-live-marker { + background: transparent; + color: inherit; +} .agent-activity-status__row { min-height: 24px; display: flex; align-items: center; gap: var(--space-2); min-width: 0; width: 100%; } .agent-activity-status__live { min-height: 24px; display: flex; align-items: center; gap: var(--space-2); min-width: 0; } -.agent-activity-status canvas { flex: 0 0 auto; } -.agent-activity-status__elapsed { +.agent-activity-status canvas, +.agent-thinking-marker canvas { flex: 0 0 auto; } +.agent-activity-status__elapsed, +.agent-thinking-elapsed { flex: 0 0 auto; color: var(--color-ink-tertiary); font-variant-numeric: tabular-nums; @@ -288,7 +318,8 @@ button:disabled { cursor: default; opacity: .45; } from { background-position: 120% 0; } to { background-position: -120% 0; } } -.agent-activity-status + .message-answer { margin-top: var(--space-2); } +.agent-activity-status + .message-answer, +.agent-thinking-panel + .message-answer { margin-top: var(--space-2); } .composer textarea::placeholder { color: var(--color-ink-tertiary); } .composer button svg { width: 19px; height: 19px; } .dialog-close svg { width: 19px; height: 19px; } @@ -465,18 +496,55 @@ button:disabled { cursor: default; opacity: .45; } .brand-row strong { font-weight: 400; } .brand-mark, .auth-brand span { width: 32px; height: 32px; border-radius: 50%; background: var(--color-canvas) url("/jyotish-logo.png") center / contain no-repeat; box-shadow: 0 0 0 1px oklch(0 0 0 / .1); } .auth-story-brand img { width: 32px; height: 32px; border-radius: 50%; object-fit: contain; box-shadow: 0 0 0 1px oklch(0 0 0 / .1); } -.new-chat { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: center; gap: var(--space-2); padding: 0 var(--space-3); border: 0; background: var(--sidebar-primary); color: var(--sidebar-primary-foreground); cursor: pointer; font-size: var(--type-body-sm); transition: background-color 120ms ease-out, transform 120ms ease-out; margin: var(--space-4) 0 var(--space-2); border-radius: var(--radius-md); font-weight: 500; } -.report-nav-button { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: center; gap: var(--space-2); padding: 0 var(--space-3); margin-bottom: var(--space-4); border: 1px solid var(--sidebar-border); border-radius: var(--radius-md); color: var(--sidebar-foreground); } -.report-nav-button:hover { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); } +.new-chat { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: center; gap: var(--space-2); padding: 0 var(--space-3); border: 0; background: transparent; color: var(--sidebar-foreground); cursor: pointer; font-size: var(--type-body-sm); transition: background-color 120ms ease-out, transform 120ms ease-out; margin: 0; border-radius: var(--radius-md); font-weight: 500; } +.report-nav-button { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: center; gap: var(--space-2); padding: 0 var(--space-3); margin-bottom: var(--space-2); border: 0; border-radius: var(--radius-md); color: var(--sidebar-foreground); } +.report-nav-button:hover, +.new-chat:hover { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); } +[data-state="expanded"] .new-chat, +[data-state="expanded"] .report-nav-button { justify-content: flex-start; } .session-nav-header { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--space-2); padding: 0 var(--space-3) var(--space-2); } -.sidebar-label { min-height: 44px; display: flex; align-items: center; color: var(--sidebar-muted-foreground); font-size: var(--type-overline); font-weight: 500; letter-spacing: 1.5px; } +.session-nav-header-inline { padding-top: 0; } +.sidebar-section { margin: 0 0 var(--space-3); } +.sidebar-section-summary { + display: flex; + min-height: 44px; + align-items: center; + gap: var(--space-2); + padding: 0 var(--space-3); + list-style: none; + color: var(--sidebar-muted-foreground); + cursor: pointer; + font-size: var(--type-overline); + font-weight: 500; + letter-spacing: 1.5px; +} +.sidebar-section-summary::-webkit-details-marker { display: none; } +.sidebar-section-summary > svg { width: 16px; height: 16px; flex: 0 0 auto; } +.sidebar-section-chevron { margin-left: auto; width: 16px; height: 16px; transition: transform 120ms ease-out; } +.sidebar-section[open] .sidebar-section-chevron { transform: rotate(180deg); } +.sidebar-empty { margin: 0; padding: 0 var(--space-3) var(--space-2); color: var(--color-ink-tertiary); font-size: var(--type-caption); } +.chart-nav-list { display: flex; flex-wrap: wrap; gap: var(--space-2); padding: 0 var(--space-3) var(--space-2); } +.chart-nav-chip { + min-height: 32px; + max-width: 100%; + padding: 0 12px; + border: 1px solid var(--sidebar-border); + border-radius: 999px; + background: var(--color-canvas); + color: var(--sidebar-foreground); + cursor: pointer; + font-size: var(--type-caption); +} +.chart-nav-chip:hover { background: var(--sidebar-accent); } +.sidebar-label { min-height: 44px; display: flex; align-items: center; gap: var(--space-2); color: var(--sidebar-muted-foreground); font-size: var(--type-overline); font-weight: 500; letter-spacing: 1.5px; } .sidebar-label:focus-visible { outline: 3px solid color-mix(in srgb, var(--sidebar-ring) 56%, transparent); outline-offset: 2px; border-radius: var(--radius-sm); } -.session-nav-toggle { min-height: 44px; padding: 0 var(--space-2); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--sidebar-muted-foreground); cursor: pointer; font-size: var(--type-overline); transition: background-color 120ms ease-out, color 120ms ease-out; } +.session-nav-toggle { min-height: 44px; min-width: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0 var(--space-2); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--sidebar-muted-foreground); cursor: pointer; font-size: var(--type-overline); transition: background-color 120ms ease-out, color 120ms ease-out; } +.session-nav-toggle svg { width: 16px; height: 16px; } .session-list { min-height: 0; display: flex; flex-direction: column; gap: var(--space-1); } .session-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: center; border-radius: var(--radius-lg); color: var(--sidebar-muted-foreground); transition: background-color 120ms ease-out, box-shadow 120ms ease-out, color 120ms ease-out; } .session-row:hover, .session-row:focus-within { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); } .session-row:has(.session-main[data-active="true"]) { background: var(--sidebar-accent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--sidebar-ring) 22%, transparent); color: var(--sidebar-accent-foreground); } -.session-main { position: relative; width: 100%; min-height: 52px; display: grid; gap: 2px; padding: var(--space-2) var(--space-3) var(--space-2) var(--space-4); border: 0; border-radius: var(--radius-md); background: transparent; color: inherit; cursor: pointer; text-align: left; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; } +.session-main { position: relative; width: 100%; min-height: 44px; display: grid; gap: 2px; padding: var(--space-2) var(--space-3) var(--space-2) var(--space-4); border: 0; border-radius: var(--radius-md); background: transparent; color: inherit; cursor: pointer; text-align: left; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; } .session-main[data-active="true"] { color: var(--sidebar-accent-foreground); background: transparent; } .session-main[data-active="true"]::before { position: absolute; border-radius: 3px; content: ""; top: var(--space-3); bottom: var(--space-3); left: var(--space-1); width: 2px; background: var(--sidebar-ring); } .session-title { min-width: 0; display: flex; align-items: center; gap: var(--space-1); overflow: hidden; line-height: 1.35; font-size: var(--type-body-sm); font-weight: 500; } @@ -518,7 +586,7 @@ button:disabled { cursor: default; opacity: .45; } [data-state="collapsed"] .brand-row { justify-content: center; gap: 0; padding: 0; } [data-state="collapsed"] [data-sidebar="menu"] { justify-items: center; } [data-state="collapsed"] [data-sidebar="menu-button"] { width: 44px; height: 44px; min-height: 44px; padding: 0; place-items: center; } - [data-state="collapsed"] .new-chat { margin-block: var(--space-4) var(--space-6); } + [data-state="collapsed"] .new-chat { margin-block: var(--space-2); } [data-state="collapsed"] .profile-trigger { width: 44px; height: 44px; min-height: 44px; margin-inline: auto; padding: 6px; display: grid; grid-template-columns: 32px; place-items: center; } [data-state="collapsed"] .sidebar-footer { padding-top: var(--space-3); } } @@ -764,6 +832,27 @@ button:disabled { cursor: default; opacity: .45; } margin: -4px 0 8px 42px; color: var(--color-ink-tertiary); } +.conversation-follow-ups { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 12px var(--assistant-content-inset); +} +.conversation-follow-up { + max-width: 100%; + min-height: 44px; + padding: 0 14px; + border: 1px solid var(--color-border); + border-radius: 999px; + background: var(--color-canvas); + color: var(--color-ink); + cursor: pointer; + font-size: 13px; + line-height: 1.4; + text-align: left; +} +.conversation-follow-up:hover { background: var(--color-canvas-muted); } +.conversation-follow-up:disabled { cursor: not-allowed; opacity: .56; } .message-actions button { display: inline-flex; width: 26px; @@ -1108,7 +1197,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .status-已过期, .status-已兑换, .empty-cell { color: var(--color-ink-tertiary); } @media (hover: hover) { - .new-chat:not(:disabled):hover { background: var(--color-surface-dark-raised); } + .new-chat:not(:disabled):hover { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); } .composer button:not(:disabled):hover, .button-primary:not(:disabled):hover { background: var(--color-action-hover); } .model-selector-trigger:not(:disabled):hover { background: var(--color-canvas-muted); color: var(--color-ink); } .model-selector-option:hover { background: var(--color-canvas-soft); color: var(--color-ink); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 6ccf3fb0..4054467a 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -39,7 +39,9 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/s import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft"; import { clearStaleClientReload } from "@/lib/stale-client-recovery"; import { chinaLocations, type ProvinceNode } from "@/data/china-locations"; -import { parseAgentReply, resolveSessionTitle, type ReplyTheme } from "@/lib/agent-reply"; +import { ConversationFollowUps } from "@/components/conversation-follow-ups"; +import { parseAgentReply, isGenericSessionTitle, resolveSessionTitle, type ReplyTheme } from "@/lib/agent-reply"; +import { deriveConsultationFollowUps } from "@/lib/consultation-follow-ups"; import { beamAvatarPalettes, beamAvatarSchema, @@ -755,16 +757,24 @@ function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null updated_at?: unknown; }; const messages: Message[] = Array.isArray(session.messages) - ? session.messages.flatMap((message) => ( - message && typeof message === "object" - && ((message as Message).role === "user" || (message as Message).role === "assistant") - && typeof (message as Message).text === "string" - ? [{ - role: (message as Message).role, - text: (message as Message).text.slice(0, 12000), - }] - : [] - )) + ? session.messages.flatMap((message) => { + if (!message || typeof message !== "object") return []; + const stored = message as Message; + if ((stored.role !== "user" && stored.role !== "assistant") || typeof stored.text !== "string") { + return []; + } + const thinkingText = typeof stored.thinkingText === "string" && stored.thinkingText.trim() + ? stored.thinkingText.slice(0, 4000) + : undefined; + return [{ + role: stored.role, + text: stored.text.slice(0, 12000), + ...(thinkingText ? { thinkingText } : {}), + ...(typeof stored.techniqueTruth === "string" ? { techniqueTruth: stored.techniqueTruth } : {}), + ...(stored.agentExecutionReceipt ? { agentExecutionReceipt: stored.agentExecutionReceipt } : {}), + ...(stored.workflowReceipt ? { workflowReceipt: stored.workflowReceipt } : {}), + }]; + }) : []; if (typeof session.id !== "string") return []; @@ -775,7 +785,7 @@ function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null if (catalog && selection.fellBack) fallbackSessionIds.push(session.id); return [{ id: session.id, - title: typeof session.title === "string" ? session.title.slice(0, 36) : "新对话", + title: typeof session.title === "string" ? session.title.slice(0, 48) : "新对话", theme: normalizeConsultationDomain(session.theme) ?? "general", modelId: selection.modelId, messages, @@ -1155,6 +1165,7 @@ export default function Home() { && activeSession.id === rectificationSessionId; const visibleSessions = sessions .filter((session) => showArchivedSessions ? archivedSessionIds.includes(session.id) : !archivedSessionIds.includes(session.id)) + .filter((session) => session.sessionType === "birth_time_rectification" || session.messages.length > 0) .sort((left, right) => Number(pinnedSessionIds.includes(right.id)) - Number(pinnedSessionIds.includes(left.id))); const activeError = requestError && requestError.sessionId === activeSession?.id ? requestError.message : ""; const isLoading = pendingSessionId === activeSession?.id; @@ -1375,8 +1386,12 @@ export default function Home() { ? session : { ...session, - title: session.messages.length === 0 && session.title === "新对话" - ? resolveSessionTitle(question) + title: session.messages.length === 0 && isGenericSessionTitle(session.title) + ? resolveSessionTitle(question, undefined, { + entrypoint: stored?.entrypoint, + theme: stored?.theme ?? session.theme, + existingTitles: sessions.filter((item) => item.id !== session.id).map((item) => item.title), + }) : session.title, theme: stored?.theme ?? session.theme, messages: [...session.messages, { role: "user" as const, text: question }], @@ -1983,6 +1998,7 @@ export default function Home() { messages: session.messages.map((message) => ({ role: message.role, text: message.text, + thinkingText: message.thinkingText, techniqueTruth: message.techniqueTruth, agentExecutionReceipt: message.agentExecutionReceipt, workflowReceipt: message.workflowReceipt, @@ -2631,7 +2647,10 @@ export default function Home() { // never generates a Case id; it only mirrors the returned binding. const merged: ChatSession = { id: opened.sessionId, - title: "生时校正", + title: resolveSessionTitle("生时校正", undefined, { + entrypoint: "birth_time_rectification", + existingTitles: sessions.map((session) => session.title), + }), theme: "general", modelId: modelCatalog.defaultModelId ?? "", messages: [], @@ -2640,6 +2659,7 @@ export default function Home() { rectificationCaseId: opened.caseId, }; setSessions((current) => [merged, ...current.filter((session) => session.id !== merged.id)]); + void persistSession(merged).catch(() => {}); setRectificationPendingQuestion(pendingConsultationQuestion); setDraft(""); @@ -3075,8 +3095,12 @@ export default function Home() { : currentSession.messages); const userSession: ChatSession = { ...currentSession, - title: currentSession.messages.length === 0 && currentSession.title === "新对话" - ? resolveSessionTitle(question) + title: currentSession.messages.length === 0 && isGenericSessionTitle(currentSession.title) + ? resolveSessionTitle(question, undefined, { + entrypoint: consultEntrypoint, + theme, + existingTitles: sessions.filter((item) => item.id !== currentSession.id).map((item) => item.title), + }) : currentSession.title, theme, messages: questionAlreadyPresent ? preservedMessages : [...preservedMessages, { role: "user", text: question }], @@ -3209,6 +3233,7 @@ export default function Home() { } setStreamingReply({ sessionId, text: "" }); let latestPartialReply = ""; + let thinking = ""; try { const response = await fetch("/api/consult", { method: "POST", @@ -3270,7 +3295,6 @@ export default function Home() { const reader = response.body.getReader(); const decoder = new TextDecoder(); let answer = ""; - let thinking = ""; const updateStreamingAnswer = (activity?: AgentActivityView) => { const partialReply = parseAgentReply(answer).text; latestPartialReply = partialReply; @@ -3320,7 +3344,7 @@ export default function Home() { const parser = createNdjsonParser((event) => { if (event.type === "answer.delta") answer += event.text; if (event.type === "thinking.delta") { - thinking += event.text; + thinking = `${thinking}${event.text}`.slice(0, 4_000); setStreamingReply((current) => ({ sessionId, text: current?.sessionId === sessionId ? current.text : parseAgentReply(answer).text, @@ -3386,7 +3410,14 @@ export default function Home() { void refreshAccount(); return true; } - if (!runCompleted && !truncatedFailure) throw new ConsultationResponseError(502, "Agent 回答未完成,本次不会保存为成功咨询。"); + if (!runCompleted && !truncatedFailure) { + throw new ConsultationResponseError( + 502, + thinking.trim() + ? "这次还没有生成可显示的回答。思考过程已保留,可以直接继续问。" + : "Agent 回答未完成,本次不会保存为成功咨询。", + ); + } } else { while (true) { const { done, value } = await reader.read(); @@ -3397,13 +3428,23 @@ export default function Home() { answer += decoder.decode(); } if (controller.signal.aborted) return Boolean(latestPartialReply); - if (!answer.trim()) throw new Error("Agent 没有返回内容,请重试。"); const reply = parseAgentReply(answer); - if (!reply.text) throw new Error("Agent 没有返回可显示的回答,请重试。"); + if (!reply.text) { + throw thinking.trim() + ? new ConsultationResponseError(502, "这次还没有生成可显示的回答。思考过程已保留,可以直接继续问。") + : new Error("Agent 没有返回可显示的回答,请重试。"); + } + const completedTitle = reply.title && !isGenericSessionTitle(reply.title) + ? resolveSessionTitle(question, reply.title, { + entrypoint: consultEntrypoint, + theme, + existingTitles: sessions.filter((item) => item.id !== sessionId).map((item) => item.title), + }) + : userSession.title; const completedSession: ChatSession = { ...userSession, - title: userSession.title, + title: completedTitle, messages: [...userSession.messages, { role: "assistant", text: reply.text, @@ -3415,6 +3456,11 @@ export default function Home() { updatedAt: timestamp(), }; updateSession(sessionId, () => completedSession); + try { + await persistSession(completedSession); + } catch (error) { + setComposerNotice(error instanceof Error ? error.message : "回答已生成,但云端同步暂时失败。"); + } setReplyOutcome({ sessionId, phase: "completed", @@ -3446,6 +3492,18 @@ export default function Home() { if (restore) { updateSession(sessionId, () => restore); void persistSession(restore).catch(() => {}); + } else if (thinking.trim() || latestPartialReply) { + const failedSession: ChatSession = { + ...userSession, + messages: [...userSession.messages, { + role: "assistant", + text: latestPartialReply, + thinkingText: thinking.trim() || undefined, + }], + updatedAt: timestamp(), + }; + updateSession(sessionId, () => failedSession); + void persistSession(failedSession).catch(() => {}); } completeConsultationInterface(requestId); return false; @@ -3599,10 +3657,19 @@ export default function Home() { const sidebarSessions = visibleSessions.map((session) => ({ id: session.id, title: session.title, - messageCount: session.messages.length, pinned: pinnedSessionIds.includes(session.id), archived: archivedSessionIds.includes(session.id), })); + const sidebarCharts = (chartLibrary.length > 0 + ? chartLibrary + : profile.name.trim() + ? [{ id: "self", role: "self" as const, profile, updatedAt: timestamp() }] + : [] + ).map((record) => ({ + id: record.id, + name: record.profile.name.trim() || (record.role === "self" ? "我" : "对方"), + role: record.role, + })); const modalOpen = activeAccountDialog !== null || onboardingPaywallOpen; return ( @@ -3611,6 +3678,7 @@ export default function Home() { {replyAnnouncement} void startNewChat()} onOpenReports={() => router.push("/reports")} onSelectSession={selectSession} + onSelectChart={() => { + setChartLibraryOpen(true); + openAccountDialog("profile"); + }} + onAddChart={() => { + setChartLibraryOpen(true); + openAccountDialog("profile"); + }} onOpenProfile={() => openAccountDialog("profile")} onOpenRedeem={() => router.push(membershipHref("account-menu"))} onOpenLogout={() => openAccountDialog("logout")} @@ -3856,7 +3932,7 @@ export default function Home() { ) : (
- {chatMessageViews(activeSession.messages, isLoading, activeStreamingText, activeStreamingActivity, activeStreamingThinking).map((message, _, views) => { + {chatMessageViews(activeSession.messages, isLoading, activeStreamingText, activeStreamingActivity, activeStreamingThinking).map((message, index, views) => { const showActions = message.role === "assistant" && message.state === "settled" && Boolean(message.text); @@ -3866,6 +3942,18 @@ export default function Home() { item.role === "assistant" && item.state === "settled" && Boolean(item.text) ))?.renderKey : undefined; + const previousQuestion = views[index - 1]?.role === "user" ? views[index - 1]?.text : ""; + const followUps = showActions + && message.renderKey === latestRegeneratableKey + && activeSession.sessionType === "consultation" + && previousQuestion + ? deriveConsultationFollowUps({ + question: previousQuestion, + answer: message.text, + theme: activeSession.theme, + entrypoint: isGeneralDailyFortuneQuestion(previousQuestion) ? "daily_starlanguage" : null, + }) + : []; return (
@@ -3885,6 +3973,11 @@ export default function Home() { onRegenerate={() => regenerateLatestAnswer(message.renderKey)} /> )} + void send(question, activeSession.theme)} + />
); })} diff --git a/frontend/src/components/agent-activity-status.tsx b/frontend/src/components/agent-activity-status.tsx index 3fcfa7d3..cc86cbfa 100644 --- a/frontend/src/components/agent-activity-status.tsx +++ b/frontend/src/components/agent-activity-status.tsx @@ -2,10 +2,11 @@ import { useEffect, useState } from "react"; import dynamic from "next/dynamic"; +import { Check } from "lucide-react"; import type { OrbState } from "thinking-orbs"; import { prefetchOnIdle } from "@/components/chat-chunk-prefetch"; -import { activityElapsedLabel } from "@/lib/chat-message-view"; +import { activityCompletedSteps, activityElapsedLabel } from "@/lib/chat-message-view"; const labels = { working: "正在处理任务…", @@ -40,29 +41,77 @@ function ActivityElapsed({ startedAt }: Readonly<{ startedAt: number }>) { return ; } +function MessageThinkingTrace({ + text, + hasAnswer, +}: Readonly<{ + text: string; + hasAnswer: boolean; +}>) { + const [userOpen, setUserOpen] = useState(null); + const open = userOpen ?? !hasAnswer; + if (!text.trim()) return null; + return ( +
{ + setUserOpen((event.currentTarget as HTMLDetailsElement).open); + }} + > + 思考过程 +
{text}
+
+ ); +} + export function AgentActivityStatus({ state, label = labels[state], startedAt, completedTrail, + thinkingText, + hasAnswer = false, + showLive = true, }: Readonly<{ state: AgentActivityState; label?: string; startedAt?: number; completedTrail?: string; + thinkingText?: string; + hasAnswer?: boolean; + showLive?: boolean; }>) { + const completedSteps = activityCompletedSteps(completedTrail); + const live = showLive && !hasAnswer; + if (!live && completedSteps.length === 0 && !thinkingText?.trim()) return null; + return ( -
-
-
-
- {startedAt ? : null} -
- {completedTrail ? ( - - ) : null} +
+ {(live || completedSteps.length > 0) && ( +
    + {completedSteps.map((step) => ( +
  1. + + {step} +
  2. + ))} + {live ? ( +
  3. + + + + {label} + {startedAt ? : null} + +
  4. + ) : null} +
+ )} + {thinkingText ? : null}
); } diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index 90babe06..7de544e2 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -2,13 +2,18 @@ import { Menu } from "@base-ui/react/menu"; import { + ChevronDown, ChevronRight, + Clock3, FileText, Gift, LogOut, MessageSquareText, - Plus, + SquarePen, + Star, + UserPlus, UserRound, + Users, } from "lucide-react"; import { useEffect, useRef } from "react"; import type { Ref } from "react"; @@ -42,8 +47,15 @@ export type SidebarAccount = { avatar: BeamAvatar | null; }; +export type SidebarChart = { + readonly id: string; + readonly name: string; + readonly role: "self" | "other"; +}; + export type AppSidebarProps = { sessions: readonly SidebarSession[]; + charts: readonly SidebarChart[]; activeSessionId: string | null; account: SidebarAccount; accountMenuOpen: boolean; @@ -55,6 +67,8 @@ export type AppSidebarProps = { onNewChat: () => void; onOpenReports: () => void; onSelectSession: (sessionId: string) => void; + onSelectChart: (chartId: string) => void; + onAddChart: () => void; onOpenProfile: () => void; onOpenRedeem: () => void; onOpenLogout: () => void; @@ -62,6 +76,7 @@ export type AppSidebarProps = { export function AppSidebar({ sessions, + charts, activeSessionId, account, accountMenuOpen, @@ -73,17 +88,21 @@ export function AppSidebar({ onNewChat, onOpenReports, onSelectSession, + onSelectChart, + onAddChart, onOpenProfile, onOpenRedeem, onOpenLogout, }: AppSidebarProps) { const { isMobile, setOpen, setOpenMobile, state, viewport } = useSidebar(); const firstSessionRef = useRef(null); - const historyHeadingRef = useRef(null); + const historyHeadingRef = useRef(null); const isCollapsedDesktop = state === "collapsed" && !isMobile; const showExpandedContent = !isCollapsedDesktop; const menuPlacement = `${viewport}:${state}`; const previousMenuPlacement = useRef(menuPlacement); + const favoriteSessions = sessions.filter((session) => session.pinned); + const historySessions = sessions.filter((session) => !session.pinned); useEffect(() => { if (previousMenuPlacement.current !== menuPlacement && accountMenuOpen) { @@ -109,6 +128,30 @@ export function AppSidebar({ }); } + function renderSession(session: SidebarSession, index: number) { + return ( + + sessionControls.onMenuSessionChange(open ? session.id : null)} + onSelect={() => { + onSelectSession(session.id); + if (isMobile) setOpenMobile(false); + }} + onTogglePinned={() => sessionControls.onTogglePinned(session.id)} + onRename={() => sessionControls.onRename(session.id)} + onShare={() => sessionControls.onShare(session.id)} + onToggleArchived={() => sessionControls.onToggleArchived(session.id)} + onDelete={() => sessionControls.onDelete(session.id)} + /> + + ); + } + return ( @@ -125,8 +168,8 @@ export function AppSidebar({ disabled={newChatDisabled} onClick={handleNewChat} > -