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 <cursoragent@cursor.com>
This commit is contained in:
+17
-1
@@ -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
|
||||
|
||||
|
||||
+5
-4
@@ -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.
|
||||
|
||||
@@ -500,6 +500,7 @@ export async function POST(request: Request) {
|
||||
techniqueTruth: string,
|
||||
workflowReceipt: WorkflowReceipt,
|
||||
agentExecutionReceipt?: AgentExecutionReceipt,
|
||||
thinkingText?: string,
|
||||
): Promise<AgentSettlementResult> {
|
||||
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,
|
||||
|
||||
+106
-17
@@ -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); }
|
||||
|
||||
+118
-25
@@ -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() {
|
||||
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{replyAnnouncement}</span>
|
||||
<AppSidebar
|
||||
sessions={sidebarSessions}
|
||||
charts={sidebarCharts}
|
||||
activeSessionId={activeSession?.id ?? null}
|
||||
account={sidebarAccount}
|
||||
accountMenuOpen={accountMenuOpen}
|
||||
@@ -3646,6 +3714,14 @@ export default function Home() {
|
||||
onNewChat={() => 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() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="message-list" aria-busy={isLoading}>
|
||||
{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 (
|
||||
<div className="message-entry" key={message.renderKey}>
|
||||
<ChatMessageRow message={message} />
|
||||
@@ -3885,6 +3973,11 @@ export default function Home() {
|
||||
onRegenerate={() => regenerateLatestAnswer(message.renderKey)}
|
||||
/>
|
||||
)}
|
||||
<ConversationFollowUps
|
||||
questions={followUps}
|
||||
disabled={productEntrypointsDisabled}
|
||||
onSelect={(question) => void send(question, activeSession.theme)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 <span className="agent-activity-status__elapsed" aria-hidden="true">{label}</span>;
|
||||
}
|
||||
|
||||
function MessageThinkingTrace({
|
||||
text,
|
||||
hasAnswer,
|
||||
}: Readonly<{
|
||||
text: string;
|
||||
hasAnswer: boolean;
|
||||
}>) {
|
||||
const [userOpen, setUserOpen] = useState<boolean | null>(null);
|
||||
const open = userOpen ?? !hasAnswer;
|
||||
if (!text.trim()) return null;
|
||||
return (
|
||||
<details
|
||||
className="message-thinking"
|
||||
open={open}
|
||||
onToggle={(event) => {
|
||||
setUserOpen((event.currentTarget as HTMLDetailsElement).open);
|
||||
}}
|
||||
>
|
||||
<summary>思考过程</summary>
|
||||
<div className="message-thinking-body">{text}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="agent-activity-status">
|
||||
<div className="agent-activity-status__row">
|
||||
<div className="agent-activity-status__live" role="status">
|
||||
<ThinkingOrb aria-hidden="true" state={state} size={20} />
|
||||
<span key={label} className="agent-activity-status__text">{label}</span>
|
||||
</div>
|
||||
{startedAt ? <ActivityElapsed key={startedAt} startedAt={startedAt} /> : null}
|
||||
</div>
|
||||
{completedTrail ? (
|
||||
<p className="agent-activity-status__trail" aria-hidden="true">{completedTrail}</p>
|
||||
) : null}
|
||||
<div className="agent-thinking-panel agent-activity-status">
|
||||
{(live || completedSteps.length > 0) && (
|
||||
<ol className="agent-thinking-timeline">
|
||||
{completedSteps.map((step) => (
|
||||
<li className="agent-thinking-step is-done" key={step}>
|
||||
<span className="agent-thinking-marker" aria-hidden="true">
|
||||
<Check />
|
||||
</span>
|
||||
<span>{step}</span>
|
||||
</li>
|
||||
))}
|
||||
{live ? (
|
||||
<li className="agent-thinking-step is-live">
|
||||
<span className="agent-thinking-marker is-live-marker">
|
||||
<ThinkingOrb aria-hidden="true" state={state} size={20} />
|
||||
</span>
|
||||
<span className="agent-activity-status__live" role="status">
|
||||
<span key={label} className="agent-activity-status__text">{label}</span>
|
||||
{startedAt ? <ActivityElapsed key={startedAt} startedAt={startedAt} /> : null}
|
||||
</span>
|
||||
</li>
|
||||
) : null}
|
||||
</ol>
|
||||
)}
|
||||
{thinkingText ? <MessageThinkingTrace text={thinkingText} hasAnswer={hasAnswer} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HTMLButtonElement>(null);
|
||||
const historyHeadingRef = useRef<HTMLHeadingElement>(null);
|
||||
const historyHeadingRef = useRef<HTMLElement>(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 (
|
||||
<SidebarMenuItem key={session.id}>
|
||||
<SidebarSessionRow
|
||||
ref={index === 0 ? firstSessionRef : undefined}
|
||||
session={session}
|
||||
active={session.id === activeSessionId}
|
||||
disabled={sessionControls.disabled}
|
||||
menuOpen={sessionControls.menuSessionId === session.id}
|
||||
onMenuOpenChange={(open) => 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)}
|
||||
/>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar className="sidebar" aria-label="对话导航">
|
||||
<SidebarHeader className="sidebar-header">
|
||||
@@ -125,8 +168,8 @@ export function AppSidebar({
|
||||
disabled={newChatDisabled}
|
||||
onClick={handleNewChat}
|
||||
>
|
||||
<Plus aria-hidden="true" />
|
||||
{showExpandedContent ? <span>{creatingSession ? "正在创建" : "新对话"}</span> : null}
|
||||
<SquarePen aria-hidden="true" />
|
||||
{showExpandedContent ? <span>{creatingSession ? "正在创建" : "新建对话"}</span> : null}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
@@ -145,43 +188,79 @@ export function AppSidebar({
|
||||
|
||||
<SidebarContent>
|
||||
{showExpandedContent ? (
|
||||
<SidebarGroup className="session-nav" aria-label="聊天记录">
|
||||
<div className="session-nav-header">
|
||||
<SidebarGroupLabel className="sidebar-label" ref={historyHeadingRef} tabIndex={-1}>
|
||||
{sessionControls.showingArchived ? "归档记录" : "聊天记录"}
|
||||
</SidebarGroupLabel>
|
||||
<button className="session-nav-toggle" type="button" onClick={sessionControls.onToggleArchivedView}>
|
||||
{sessionControls.showingArchived ? "返回" : `归档 ${sessionControls.archivedCount}`}
|
||||
</button>
|
||||
</div>
|
||||
<SidebarGroupContent>
|
||||
{sessions.length === 0 ? <p className="sidebar-empty">暂无对话</p> : (
|
||||
<SidebarMenu className="session-list">
|
||||
{sessions.map((session, index) => (
|
||||
<SidebarMenuItem key={session.id}>
|
||||
<SidebarSessionRow
|
||||
ref={index === 0 ? firstSessionRef : undefined}
|
||||
session={session}
|
||||
active={session.id === activeSessionId}
|
||||
disabled={sessionControls.disabled}
|
||||
menuOpen={sessionControls.menuSessionId === session.id}
|
||||
onMenuOpenChange={(open) => 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)}
|
||||
/>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
)}
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<>
|
||||
<SidebarGroup className="chart-nav" aria-label="星盘列表">
|
||||
<div className="session-nav-header">
|
||||
<SidebarGroupLabel className="sidebar-label">
|
||||
<Users aria-hidden="true" />
|
||||
星盘列表
|
||||
</SidebarGroupLabel>
|
||||
<button
|
||||
className="session-nav-toggle"
|
||||
type="button"
|
||||
aria-label="添加星盘"
|
||||
onClick={onAddChart}
|
||||
>
|
||||
<UserPlus aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<SidebarGroupContent>
|
||||
{charts.length === 0 ? <p className="sidebar-empty">还没有星盘</p> : (
|
||||
<div className="chart-nav-list">
|
||||
{charts.map((chart) => (
|
||||
<button
|
||||
className="chart-nav-chip"
|
||||
key={chart.id}
|
||||
type="button"
|
||||
onClick={() => onSelectChart(chart.id)}
|
||||
>
|
||||
{chart.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarGroup className="session-nav" aria-label="收藏对话">
|
||||
<details className="sidebar-section" open>
|
||||
<summary className="sidebar-section-summary">
|
||||
<Star aria-hidden="true" />
|
||||
<span>收藏对话</span>
|
||||
<ChevronDown className="sidebar-section-chevron" aria-hidden="true" />
|
||||
</summary>
|
||||
<SidebarGroupContent>
|
||||
{favoriteSessions.length === 0 ? <p className="sidebar-empty">还没有收藏</p> : (
|
||||
<SidebarMenu className="session-list">
|
||||
{favoriteSessions.map((session, index) => renderSession(session, index))}
|
||||
</SidebarMenu>
|
||||
)}
|
||||
</SidebarGroupContent>
|
||||
</details>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarGroup className="session-nav" aria-label={sessionControls.showingArchived ? "归档记录" : "历史对话"}>
|
||||
<details className="sidebar-section" open>
|
||||
<summary className="sidebar-section-summary" ref={historyHeadingRef} tabIndex={-1}>
|
||||
<Clock3 aria-hidden="true" />
|
||||
<span>{sessionControls.showingArchived ? "归档记录" : "历史对话"}</span>
|
||||
<ChevronDown className="sidebar-section-chevron" aria-hidden="true" />
|
||||
</summary>
|
||||
<div className="session-nav-header session-nav-header-inline">
|
||||
<button className="session-nav-toggle" type="button" onClick={sessionControls.onToggleArchivedView}>
|
||||
{sessionControls.showingArchived ? "返回" : `归档 ${sessionControls.archivedCount}`}
|
||||
</button>
|
||||
</div>
|
||||
<SidebarGroupContent>
|
||||
{historySessions.length === 0 ? <p className="sidebar-empty">暂无对话</p> : (
|
||||
<SidebarMenu className="session-list">
|
||||
{historySessions.map((session, index) => renderSession(session, favoriteSessions.length + index))}
|
||||
</SidebarMenu>
|
||||
)}
|
||||
</SidebarGroupContent>
|
||||
</details>
|
||||
</SidebarGroup>
|
||||
</>
|
||||
) : (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
import { AgentActivityStatus } from "@/components/agent-activity-status";
|
||||
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
|
||||
import { ChatMessageContent } from "@/components/chat-message-content";
|
||||
import type { ChatMessageView } from "@/lib/chat-message-view";
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
|
||||
type GsapCore = typeof import("gsap")["gsap"];
|
||||
|
||||
@@ -31,30 +30,6 @@ function motionPreferred() {
|
||||
|
||||
if (motionPreferred()) prefetchOnIdle(loadGsap);
|
||||
|
||||
function MessageThinkingTrace({
|
||||
text,
|
||||
hasAnswer,
|
||||
}: Readonly<{
|
||||
text: string;
|
||||
hasAnswer: boolean;
|
||||
}>) {
|
||||
const [userOpen, setUserOpen] = useState<boolean | null>(null);
|
||||
const open = userOpen ?? !hasAnswer;
|
||||
if (!text.trim()) return null;
|
||||
return (
|
||||
<details
|
||||
className="message-thinking"
|
||||
open={open}
|
||||
onToggle={(event) => {
|
||||
setUserOpen((event.currentTarget as HTMLDetailsElement).open);
|
||||
}}
|
||||
>
|
||||
<summary>思考过程</summary>
|
||||
<div className="message-thinking-body">{text}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentAvatar() {
|
||||
return <span className="agent-avatar" aria-hidden="true" />;
|
||||
}
|
||||
@@ -84,6 +59,8 @@ export function ChatMessageRow({
|
||||
: message.state === "thinking" ? "working" : "composing";
|
||||
const activityLabel = message.activity?.label
|
||||
?? (message.state === "thinking" ? "正在处理…" : undefined);
|
||||
const hasAnswer = Boolean(message.text.trim());
|
||||
const showThinkingPanel = showActivity || Boolean(message.thinkingText?.trim());
|
||||
|
||||
useEntryEffect(() => {
|
||||
const row = messageRow.current;
|
||||
@@ -120,18 +97,15 @@ export function ChatMessageRow({
|
||||
<div className="message-bubble">
|
||||
{message.role === "assistant" ? (
|
||||
<>
|
||||
{showActivity && (
|
||||
{showThinkingPanel && (
|
||||
<AgentActivityStatus
|
||||
state={activityState}
|
||||
label={activityLabel}
|
||||
startedAt={message.activity?.startedAt}
|
||||
completedTrail={message.activity?.completedTrail}
|
||||
/>
|
||||
)}
|
||||
{message.thinkingText && (
|
||||
<MessageThinkingTrace
|
||||
text={message.thinkingText}
|
||||
hasAnswer={Boolean(message.text.trim())}
|
||||
thinkingText={message.thinkingText}
|
||||
hasAnswer={hasAnswer}
|
||||
showLive={showActivity}
|
||||
/>
|
||||
)}
|
||||
{message.text && (
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
export function ConversationFollowUps({
|
||||
questions,
|
||||
disabled = false,
|
||||
onSelect,
|
||||
}: Readonly<{
|
||||
questions: readonly string[];
|
||||
disabled?: boolean;
|
||||
onSelect: (question: string) => void;
|
||||
}>) {
|
||||
if (questions.length === 0) return null;
|
||||
return (
|
||||
<div className="conversation-follow-ups" aria-label="可以继续问">
|
||||
{questions.map((question) => (
|
||||
<button
|
||||
key={question}
|
||||
className="conversation-follow-up"
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onSelect(question)}
|
||||
>
|
||||
{question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
ArchiveRestore,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Pin,
|
||||
PinOff,
|
||||
Share2,
|
||||
Star,
|
||||
StarOff,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { forwardRef } from "react";
|
||||
@@ -18,7 +18,6 @@ import { sessionMutationMenuVisible } from "@/lib/chat-session-persistence";
|
||||
export type SidebarSession = {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly messageCount: number;
|
||||
readonly pinned: boolean;
|
||||
readonly archived: boolean;
|
||||
};
|
||||
@@ -88,10 +87,9 @@ export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRow
|
||||
onClick={onSelect}
|
||||
>
|
||||
<span className="session-title">
|
||||
{session.pinned ? <Pin aria-label="已置顶" /> : null}
|
||||
{session.pinned ? <Star aria-label="已收藏" /> : null}
|
||||
<span className="truncate">{session.title}</span>
|
||||
</span>
|
||||
{session.messageCount > 0 ? <small>{session.messageCount} 条消息</small> : null}
|
||||
</SidebarMenuButton>
|
||||
<Menu.Trigger
|
||||
className="session-menu-trigger"
|
||||
@@ -106,8 +104,8 @@ export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRow
|
||||
<Menu.Positioner className="session-actions-positioner" side="bottom" align="end" sideOffset={4} collisionPadding={12}>
|
||||
<Menu.Popup className="session-actions" aria-label={`${session.title} 操作`}>
|
||||
<Menu.Item className="session-action-item" onClick={onTogglePinned}>
|
||||
{session.pinned ? <PinOff aria-hidden="true" /> : <Pin aria-hidden="true" />}
|
||||
<span>{session.pinned ? "取消置顶" : "置顶"}</span>
|
||||
{session.pinned ? <StarOff aria-hidden="true" /> : <Star aria-hidden="true" />}
|
||||
<span>{session.pinned ? "取消收藏" : "收藏"}</span>
|
||||
</Menu.Item>
|
||||
<Menu.Item className="session-action-item" onClick={onRename}><Pencil aria-hidden="true" /><span>重命名</span></Menu.Item>
|
||||
<Menu.Item className="session-action-item" onClick={onShare}><Share2 aria-hidden="true" /><span>转发</span></Menu.Item>
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import type { ConsultationDomain } from "./consultation-domain-registry.ts";
|
||||
import { consultationDomainDefinition } from "./consultation-domain-registry.ts";
|
||||
import type { ConsultationEntrypoint } from "./consultation-entrypoint.ts";
|
||||
import {
|
||||
isGeneralDailyFortuneQuestion,
|
||||
isRectificationHandoffQuestion,
|
||||
} from "./consultation-entrypoint.ts";
|
||||
|
||||
export type ReplyTheme = ConsultationDomain;
|
||||
|
||||
import type { ConsultationReplyMetadata } from "./consultation-reply-metadata.ts";
|
||||
|
||||
const GENERIC_SESSION_TITLES = new Set([
|
||||
"新对话",
|
||||
"深入看今日",
|
||||
"从今日问起",
|
||||
"查看今日运势",
|
||||
"生时校正",
|
||||
"再次校正",
|
||||
"一般占星咨询",
|
||||
"深入看今日主题咨询",
|
||||
]);
|
||||
|
||||
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;
|
||||
@@ -37,10 +54,57 @@ export function parseAgentReply(value: string, metadata?: ConsultationReplyMetad
|
||||
return { text: parsed.text, title: metadata?.title ?? parsed.title };
|
||||
}
|
||||
|
||||
export function resolveSessionTitle(question: string, modelTitle?: string): string {
|
||||
if (modelTitle && modelTitle !== "一般占星咨询") return modelTitle;
|
||||
export type SessionTitleOptions = {
|
||||
readonly entrypoint?: ConsultationEntrypoint | null;
|
||||
readonly theme?: ConsultationDomain;
|
||||
readonly at?: Date;
|
||||
readonly existingTitles?: readonly string[];
|
||||
};
|
||||
|
||||
export function isGenericSessionTitle(title: string): boolean {
|
||||
const text = title.replace(/\s+/g, " ").trim();
|
||||
if (!text) return true;
|
||||
if (GENERIC_SESSION_TITLES.has(text)) return true;
|
||||
return /^(?:深入看今日|从今日问起|查看今日运势|生时校正|再次校正)/.test(text);
|
||||
}
|
||||
|
||||
function clipTitle(value: string, maxChars = 14): string {
|
||||
const characters = Array.from(value);
|
||||
return characters.length > maxChars ? `${characters.slice(0, maxChars).join("")}…` : value;
|
||||
}
|
||||
|
||||
function datedSessionTitle(at: Date, suffix: string): string {
|
||||
return `${at.getMonth() + 1}月${at.getDate()}日 · ${suffix}`;
|
||||
}
|
||||
|
||||
function uniquifySessionTitle(title: string, existingTitles: readonly string[], at: Date): string {
|
||||
if (!existingTitles.includes(title)) return title;
|
||||
const hours = String(at.getHours()).padStart(2, "0");
|
||||
const minutes = String(at.getMinutes()).padStart(2, "0");
|
||||
return `${title} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
export function resolveSessionTitle(
|
||||
question: string,
|
||||
modelTitle?: string,
|
||||
options: SessionTitleOptions = {},
|
||||
): string {
|
||||
const at = options.at ?? new Date();
|
||||
const existingTitles = options.existingTitles ?? [];
|
||||
if (modelTitle && !isGenericSessionTitle(modelTitle)) {
|
||||
return uniquifySessionTitle(clipTitle(modelTitle), existingTitles, at);
|
||||
}
|
||||
if (options.entrypoint === "daily_starlanguage" || isGeneralDailyFortuneQuestion(question)) {
|
||||
return uniquifySessionTitle(datedSessionTitle(at, "今日节奏"), existingTitles, at);
|
||||
}
|
||||
if (options.entrypoint === "birth_time_rectification" || isRectificationHandoffQuestion(question)) {
|
||||
return uniquifySessionTitle(datedSessionTitle(at, "生时校正"), existingTitles, at);
|
||||
}
|
||||
const normalized = question.replace(/\s+/g, " ").trim().replace(/[??!!。.,,;;::]+$/u, "");
|
||||
if (!normalized) return "新对话";
|
||||
const characters = Array.from(normalized);
|
||||
return characters.length > 14 ? `${characters.slice(0, 14).join("")}…` : normalized;
|
||||
if (options.theme && options.theme !== "general") {
|
||||
const label = consultationDomainDefinition(options.theme).label;
|
||||
return uniquifySessionTitle(`${label} · ${clipTitle(normalized, 10)}`, existingTitles, at);
|
||||
}
|
||||
return uniquifySessionTitle(clipTitle(normalized), existingTitles, at);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,13 @@ export function activityCompletedTrail(steps: readonly string[]): string | undef
|
||||
return `已完成:${steps.slice(-ACTIVITY_COMPLETED_TRAIL_LIMIT).join(" · ")}`;
|
||||
}
|
||||
|
||||
export function activityCompletedSteps(trail: string | undefined): string[] {
|
||||
if (!trail) return [];
|
||||
const body = trail.replace(/^已完成:/, "").trim();
|
||||
if (!body) return [];
|
||||
return body.split(" · ").map((step) => step.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function nextActivityView(
|
||||
previous: AgentActivityView | undefined,
|
||||
next: Omit<AgentActivityView, "startedAt">,
|
||||
|
||||
@@ -19,6 +19,7 @@ const chatMessageSchema = z.object({
|
||||
// is strict and a client running the previous bundle still sends them; rejecting the
|
||||
// whole write would lose that user's message rather than a dead field.
|
||||
suggestions: z.array(z.string().max(200)).max(3).optional(),
|
||||
thinkingText: z.string().max(4_000).optional(),
|
||||
techniqueTruth: z.string().max(120).optional(),
|
||||
agentExecutionReceipt: agentExecutionReceiptSchema.optional(),
|
||||
workflowReceipt: workflowReceiptSchema.optional(),
|
||||
@@ -34,11 +35,14 @@ const chatSessionWriteObjectSchema = z.object({
|
||||
updated_at: z.string().datetime(),
|
||||
}).strict();
|
||||
|
||||
function limitTranscriptSize<Output extends { messages: Array<{ text: string }> }>(
|
||||
function limitTranscriptSize<Output extends { messages: Array<{ text: string; thinkingText?: string }> }>(
|
||||
schema: z.ZodType<Output>,
|
||||
): z.ZodType<Output> {
|
||||
return schema.superRefine((value, context) => {
|
||||
const totalChars = value.messages.reduce((sum, message) => sum + message.text.length, 0);
|
||||
const totalChars = value.messages.reduce(
|
||||
(sum, message) => sum + message.text.length + (message.thinkingText?.length ?? 0),
|
||||
0,
|
||||
);
|
||||
if (totalChars > CHAT_SESSION_MAX_TOTAL_MESSAGE_CHARS) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
@@ -86,6 +90,7 @@ export type ChatSessionWrite = Readonly<{
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
suggestions?: readonly string[];
|
||||
thinkingText?: string;
|
||||
techniqueTruth?: string;
|
||||
agentExecutionReceipt?: AgentExecutionReceipt;
|
||||
workflowReceipt?: WorkflowReceipt;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ConsultationDomain } from "./consultation-domain-registry.ts";
|
||||
import type { ConsultationEntrypoint } from "./consultation-entrypoint.ts";
|
||||
import { isGeneralDailyFortuneQuestion } from "./consultation-entrypoint.ts";
|
||||
|
||||
export const CONVERSATION_FOLLOW_UP_LIMIT = 3;
|
||||
export const CONVERSATION_FOLLOW_UP_MAX_CHARS = 18;
|
||||
|
||||
function compact(value: string) {
|
||||
return value.normalize("NFKC").replace(/\s+/gu, " ").trim();
|
||||
}
|
||||
|
||||
function clipFollowUp(value: string) {
|
||||
const characters = Array.from(compact(value).replace(/[??!!。.]+$/u, ""));
|
||||
if (characters.length === 0) return "";
|
||||
return characters.length > CONVERSATION_FOLLOW_UP_MAX_CHARS
|
||||
? `${characters.slice(0, CONVERSATION_FOLLOW_UP_MAX_CHARS).join("")}…`
|
||||
: characters.join("");
|
||||
}
|
||||
|
||||
function pushFollowUp(target: string[], candidate: string, question: string) {
|
||||
const next = clipFollowUp(candidate);
|
||||
if (!next) return;
|
||||
if (next === compact(question).replace(/[??!!。.]+$/u, "")) return;
|
||||
if (target.includes(next)) return;
|
||||
if (target.length >= CONVERSATION_FOLLOW_UP_LIMIT) return;
|
||||
target.push(next);
|
||||
}
|
||||
|
||||
export function deriveConsultationFollowUps(input: Readonly<{
|
||||
question: string;
|
||||
answer: string;
|
||||
theme?: ConsultationDomain;
|
||||
entrypoint?: ConsultationEntrypoint | null;
|
||||
}>): string[] {
|
||||
const question = compact(input.question);
|
||||
const answer = compact(input.answer);
|
||||
if (answer.length < 40) return [];
|
||||
|
||||
const followUps: string[] = [];
|
||||
const daily = input.entrypoint === "daily_starlanguage" || isGeneralDailyFortuneQuestion(question);
|
||||
|
||||
if (daily) {
|
||||
if (/适合推进|推进/.test(answer)) pushFollowUp(followUps, "今天最该先推进哪一件", question);
|
||||
if (/避开|留意|注意|先放/.test(answer)) pushFollowUp(followUps, "这周哪些事最好先放一放", question);
|
||||
if (/节奏|趋势|阶段/.test(answer)) pushFollowUp(followUps, "这股节奏大概还会持续多久", question);
|
||||
}
|
||||
|
||||
if (/事业|工作|职业|岗位/.test(answer)) {
|
||||
pushFollowUp(followUps, "接下来工作上更适合怎么安排", question);
|
||||
}
|
||||
if (/关系|感情|伴侣|婚姻/.test(answer)) {
|
||||
pushFollowUp(followUps, "这段关系里最该先看清什么", question);
|
||||
}
|
||||
if (/财富|钱|收入|财务|花钱/.test(answer)) {
|
||||
pushFollowUp(followUps, "钱的安排上现在更该注意什么", question);
|
||||
}
|
||||
if (/压力|身体|睡眠|情绪/.test(answer)) {
|
||||
pushFollowUp(followUps, "近期压力可以从哪件事先卸", question);
|
||||
}
|
||||
if (/迁居|搬家|海外|置业/.test(answer)) {
|
||||
pushFollowUp(followUps, "搬家或置业现在适合推进吗", question);
|
||||
}
|
||||
|
||||
if (followUps.length === 0 && input.theme === "career") {
|
||||
pushFollowUp(followUps, "如果只做一件,工作上该先做什么", question);
|
||||
}
|
||||
|
||||
return followUps.slice(0, CONVERSATION_FOLLOW_UP_LIMIT);
|
||||
}
|
||||
@@ -250,7 +250,7 @@ type StreamAgentResponseOptions = EventOptions & {
|
||||
headers?: HeadersInit;
|
||||
onFirstActivity?: () => void | Promise<void>;
|
||||
onFirstOutput?: () => void | Promise<void>;
|
||||
onComplete?: (output: string, receipt: AgentExecutionReceipt) => void | Promise<void>;
|
||||
onComplete?: (output: string, receipt: AgentExecutionReceipt, thinkingText?: string) => void | Promise<void>;
|
||||
onError?: (error: unknown, emitted: boolean, output: string) => void | Promise<void>;
|
||||
onCancel?: (emitted: boolean) => void | Promise<void>;
|
||||
};
|
||||
@@ -273,6 +273,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
let firstActivity = false;
|
||||
let firstOutput = false;
|
||||
let fullOutput = "";
|
||||
let fullThinking = "";
|
||||
const startedAt = new Map<string, number>();
|
||||
// A retry reuses these counters so a failure in either attempt is recorded once.
|
||||
const toolErrors = { seen: 0 };
|
||||
@@ -327,7 +328,10 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
}
|
||||
if (chunk.type === "reasoning-delta" && typeof chunk.payload?.text === "string") {
|
||||
const thinking = sanitizePublicThinkingText(chunk.payload.text);
|
||||
if (thinking) send(controller, { type: "thinking.delta", text: thinking });
|
||||
if (thinking) {
|
||||
fullThinking = `${fullThinking}${thinking}`.slice(0, 4_000);
|
||||
send(controller, { type: "thinking.delta", text: thinking });
|
||||
}
|
||||
}
|
||||
}
|
||||
await outputText(visible.finish(""));
|
||||
@@ -373,7 +377,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
if (options.state.modelFinishReason === "length") throw new Error("answer_truncated");
|
||||
settling = true;
|
||||
const receipt = agentExecutionReceiptSchema.parse(options.receipt());
|
||||
await options.onComplete?.(fullOutput, receipt);
|
||||
await options.onComplete?.(fullOutput, receipt, fullThinking || undefined);
|
||||
settled = true;
|
||||
settling = false;
|
||||
send(controller, { type: "run.completed", receipt });
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from "node:test";
|
||||
|
||||
import { agentGenerationSettings, AGENT_MAX_OUTPUT_TOKENS } from "../src/lib/agent-generation-settings.ts";
|
||||
import {
|
||||
activityCompletedSteps,
|
||||
activityCompletedTrail,
|
||||
activityElapsedLabel,
|
||||
nextActivityView,
|
||||
@@ -77,6 +78,10 @@ test("completed-step trail stays short and drops while composing", () => {
|
||||
label: "正在组织回答…",
|
||||
}, 20);
|
||||
assert.equal(composing.completedTrail, undefined);
|
||||
assert.deepEqual(
|
||||
activityCompletedSteps("已完成:读取分析方法 · 计算本命盘"),
|
||||
["读取分析方法", "计算本命盘"],
|
||||
);
|
||||
});
|
||||
|
||||
test("live rectification labels name the actual public tool", () => {
|
||||
|
||||
@@ -58,6 +58,33 @@ test("general no-birth-time replies keep a question-specific session title", ()
|
||||
assert.equal(resolveSessionTitle("工作变化的重点是什么?", "事业方向与工作变化"), "事业方向与工作变化");
|
||||
});
|
||||
|
||||
test("daily and topic consultations get distinct dated or domain titles", () => {
|
||||
const at = new Date(2026, 7, 22, 8, 36);
|
||||
assert.equal(
|
||||
resolveSessionTitle("深入看今日", undefined, { entrypoint: "daily_starlanguage", at }),
|
||||
"8月22日 · 今日节奏",
|
||||
);
|
||||
assert.equal(
|
||||
resolveSessionTitle("深入看今日", undefined, {
|
||||
entrypoint: "daily_starlanguage",
|
||||
at,
|
||||
existingTitles: ["8月22日 · 今日节奏"],
|
||||
}),
|
||||
"8月22日 · 今日节奏 08:36",
|
||||
);
|
||||
assert.equal(
|
||||
resolveSessionTitle("请帮我梳理目前的事业方向和下一步重点。", undefined, { theme: "career", at }),
|
||||
"事业 · 请帮我梳理目前的事业…",
|
||||
);
|
||||
assert.equal(
|
||||
resolveSessionTitle("请用刚才采用的代表性出生时间看盘。", undefined, {
|
||||
entrypoint: "birth_time_rectification",
|
||||
at,
|
||||
}),
|
||||
"8月22日 · 生时校正",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
test("a stored legacy suggestion block is stripped and never becomes suggested questions", () => {
|
||||
// Answers written before the follow-up chips were removed still carry this block, and
|
||||
|
||||
@@ -50,6 +50,14 @@ test("chat session schema preserves the safe agent execution receipt", () => {
|
||||
assert.deepEqual(parsed.messages[0]?.agentExecutionReceipt, receipt);
|
||||
});
|
||||
|
||||
test("chat session schema keeps sanitized thinking text on assistant messages", () => {
|
||||
const parsed = chatSessionWriteSchema.parse({
|
||||
...values,
|
||||
messages: [{ role: "assistant", text: "回答", thinkingText: "先看今日节奏。" }],
|
||||
});
|
||||
assert.equal(parsed.messages[0]?.thinkingText, "先看今日节奏。");
|
||||
});
|
||||
|
||||
test("chat session writes use same-origin API instead of browser-to-Supabase requests", async () => {
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||
await writeChatSession(sessionId, values, "update", async (url, init) => {
|
||||
@@ -112,6 +120,7 @@ test("session API owns create and update while answer UI keeps sync failures out
|
||||
const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8");
|
||||
const contract = readFileSync(new URL("../src/lib/chat-session-write-contract.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(page, /thinkingText: message\.thinkingText/);
|
||||
assert.match(page, /writeChatSession\(session\.id, values, mode\)/);
|
||||
assert.doesNotMatch(page, /云端同步失败.*回答仍保留在当前页面/);
|
||||
assert.match(collectionRoute, /export async function POST/);
|
||||
@@ -122,7 +131,7 @@ test("session API owns create and update while answer UI keeps sync failures out
|
||||
assert.match(collectionRoute, /ChatSessionBodyTooLargeError/);
|
||||
assert.match(itemRoute, /ChatSessionBodyTooLargeError/);
|
||||
assert.match(collectionRoute, /const \{ id, \.\.\.values \} = parsed\.data/);
|
||||
assert.match(contract, /function limitTranscriptSize<Output extends \{ messages: Array<\{ text: string \}> \}>/);
|
||||
assert.match(contract, /function limitTranscriptSize<Output extends \{ messages: Array<\{ text: string; thinkingText\?: string \}> \}>/);
|
||||
assert.match(contract, /\): z\.ZodType<Output> \{/);
|
||||
});
|
||||
|
||||
|
||||
@@ -59,19 +59,19 @@ test("shows honest agent activity states before and during streamed text", () =>
|
||||
assert.doesNotMatch(messageRowSource, /正在核对星盘信息/);
|
||||
assert.match(messageRowSource, /正在处理…/);
|
||||
assert.match(messageRowSource, /showActivity = message\.state !== "settled"/);
|
||||
assert.match(messageRowSource, /\{showActivity && \(/);
|
||||
assert.match(messageRowSource, /showThinkingPanel &&/);
|
||||
assert.match(messageRowSource, /message\.text && \(/);
|
||||
assert.match(messageRowSource, /<ChatMessageContent[\s\S]*text=\{message\.text\}[\s\S]*auditRows=\{message\.agentExecutionReceipt\?\.techniqueAuditTable\}/);
|
||||
assert.match(globalStyles, /\.agent-activity-status \+ \.message-answer/);
|
||||
assert.match(activitySource, /<ThinkingOrb aria-hidden="true" state=\{state\} size=\{20\}/);
|
||||
assert.match(activitySource, /className="agent-activity-status__text"/);
|
||||
assert.match(activitySource, /className="agent-activity-status__elapsed" aria-hidden="true"/);
|
||||
assert.match(activitySource, /className="agent-activity-status__trail" aria-hidden="true"/);
|
||||
assert.match(activitySource, /className="agent-thinking-timeline"/);
|
||||
assert.match(activitySource, /role="status"/);
|
||||
assert.match(globalStyles, /@keyframes agent-activity-shimmer/);
|
||||
assert.match(globalStyles, /agent-activity-status-in 160ms ease-out/);
|
||||
assert.match(globalStyles, /\.agent-activity-status__row/);
|
||||
assert.match(globalStyles, /\.agent-activity-status__trail/);
|
||||
assert.match(globalStyles, /\.agent-thinking-step/);
|
||||
assert.match(globalStyles, /\.agent-thinking-timeline/);
|
||||
assert.match(globalStyles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.agent-activity-status__text[\s\S]*animation: none/);
|
||||
assert.match(pageSource, /nextActivityView/);
|
||||
assert.match(pageSource, /chartCalculationProgressLabel|CONSULTATION_CHART_CALCULATION_LABEL/);
|
||||
@@ -84,12 +84,14 @@ test("shows honest agent activity states before and during streamed text", () =>
|
||||
assert.match(pageSource, /createNdjsonParser/);
|
||||
assert.match(pageSource, /event.type === "thinking.delta"/);
|
||||
assert.match(pageSource, /activeStreamingThinking/);
|
||||
assert.match(messageRowSource, /思考过程/);
|
||||
assert.match(activitySource, /思考过程/);
|
||||
assert.match(pageSource, /event\.type === "run\.failed"/);
|
||||
assert.match(pageSource, /event\.code === "answer_truncated"/);
|
||||
assert.match(pageSource, /throw new ConsultationResponseError/);
|
||||
assert.match(pageSource, /if \(!runCompleted && !truncatedFailure\) throw new ConsultationResponseError/);
|
||||
assert.match(pageSource, /if \(!runCompleted && !truncatedFailure\) \{\s*throw new ConsultationResponseError/);
|
||||
assert.match(pageSource, /agentExecutionReceipt = event\.receipt/);
|
||||
assert.match(pageSource, /const failedSession: ChatSession/);
|
||||
assert.match(pageSource, /thinkingText: thinking.trim\(\) \|\| undefined/);
|
||||
});
|
||||
|
||||
test("nothing sits between the transcript and the composer to shift height while streaming", () => {
|
||||
|
||||
@@ -1294,9 +1294,11 @@ test("Chinese thinking stays off the spoken answer and does not bill a thought-o
|
||||
yield { type: "reasoning-delta", payload: { text: "先看事业宫的结构。" } };
|
||||
yield { type: "text-delta", payload: { text: "事业方向的判断如下。" } };
|
||||
}
|
||||
let completedThinking: string | undefined;
|
||||
const response = streamAgentResponse({
|
||||
runId: "run", requestId: "req", state, stream: chunks(), requireTool: true,
|
||||
toolStatus: () => "ready", receipt: () => receipt(state),
|
||||
onComplete: (_output, _receipt, thinkingText) => { completedThinking = thinkingText; },
|
||||
});
|
||||
const events: unknown[] = [];
|
||||
const parser = createNdjsonParser((event) => events.push(event));
|
||||
@@ -1310,6 +1312,7 @@ test("Chinese thinking stays off the spoken answer and does not bill a thought-o
|
||||
.map((event) => event.text)
|
||||
.join("");
|
||||
assert.equal(answer, "事业方向的判断如下。");
|
||||
assert.equal(completedThinking, "先看事业宫的结构。");
|
||||
assert.doesNotMatch(JSON.stringify(events), /proposedKind/);
|
||||
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1);
|
||||
});
|
||||
|
||||
@@ -235,9 +235,13 @@ test("an answered conversation offers no suggested follow-up questions", () => {
|
||||
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
|
||||
// Given: the chips above the composer were removed after measured use stayed negligible.
|
||||
// Given: the chips above the composer stay gone. Grounded continuations sit under
|
||||
// the latest answer and send immediately instead of filling the composer.
|
||||
assert.doesNotMatch(source, /composer-suggestions|activeSuggestions|chooseConversationSuggestion/);
|
||||
assert.doesNotMatch(styles, /composer-suggestions/);
|
||||
assert.match(source, /ConversationFollowUps/);
|
||||
assert.match(source, /deriveConsultationFollowUps/);
|
||||
assert.match(styles, /\.conversation-follow-ups/);
|
||||
|
||||
// Then: nothing in the conversation carries or stores a suggestion list any more.
|
||||
assert.doesNotMatch(source, /suggestions: (?:reply|previewReply|parsed)\.suggestions/);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { deriveConsultationFollowUps } from "../src/lib/consultation-follow-ups.ts";
|
||||
|
||||
test("daily answers surface grounded next questions instead of theme lookup chips", () => {
|
||||
const followUps = deriveConsultationFollowUps({
|
||||
question: "深入看今日",
|
||||
answer: "今天更适合推进已经开过口的事,也要避开临时加塞的承诺。这股节奏会把注意力放在一件主线上。",
|
||||
theme: "timing",
|
||||
entrypoint: "daily_starlanguage",
|
||||
});
|
||||
assert.deepEqual(followUps, [
|
||||
"今天最该先推进哪一件",
|
||||
"这周哪些事最好先放一放",
|
||||
"这股节奏大概还会持续多久",
|
||||
]);
|
||||
});
|
||||
|
||||
test("short or empty answers do not invent follow-ups", () => {
|
||||
assert.deepEqual(deriveConsultationFollowUps({
|
||||
question: "事业下一步怎么走?",
|
||||
answer: "可以。",
|
||||
theme: "career",
|
||||
}), []);
|
||||
});
|
||||
@@ -127,11 +127,11 @@ test("the first default consultation title is persisted with the user question",
|
||||
sendSource.indexOf("const userSession: ChatSession"),
|
||||
sendSource.indexOf("const requestId = resumeRequestId ?? globalThis.crypto.randomUUID()"),
|
||||
);
|
||||
assert.match(userSessionBlock, /currentSession\.messages\.length === 0 && currentSession\.title === "新对话"[\s\S]*resolveSessionTitle\(question\)/);
|
||||
assert.match(userSessionBlock, /currentSession\.messages\.length === 0 && isGenericSessionTitle\(currentSession\.title\)[\s\S]*resolveSessionTitle\(question/);
|
||||
assert.ok(sendSource.indexOf("await persistSession(userSession)") < sendSource.indexOf('fetch("/api/consult"'));
|
||||
assert.doesNotMatch(sendSource, /persistSession\(completedSession\)/);
|
||||
assert.match(sendSource, /const completedSession: ChatSession = \{[\s\S]*title: userSession\.title/);
|
||||
assert.doesNotMatch(sendSource, /resolveSessionTitle\(question, reply\.title\)/);
|
||||
assert.match(sendSource, /await persistSession\(completedSession\)/);
|
||||
assert.match(sendSource, /const completedTitle = reply\.title && !isGenericSessionTitle\(reply\.title\)/);
|
||||
assert.match(sendSource, /resolveSessionTitle\(question, reply\.title/);
|
||||
});
|
||||
|
||||
test("a truncated generation keeps the partial answer and does not wait for a successful run", () => {
|
||||
@@ -141,6 +141,6 @@ test("a truncated generation keeps the partial answer and does not wait for a su
|
||||
assert.match(stream, /const truncatedSession: ChatSession = \{[\s\S]*role: "assistant"[\s\S]*text: reply\.text/);
|
||||
assert.match(stream, /await persistSession\(truncatedSession\)/);
|
||||
assert.match(stream, /setComposerNotice\(truncatedFailure\.message\)/);
|
||||
assert.match(stream, /if \(!runCompleted && !truncatedFailure\) throw new ConsultationResponseError/);
|
||||
assert.match(stream, /if \(!runCompleted && !truncatedFailure\) \{\s*throw new ConsultationResponseError/);
|
||||
assert.doesNotMatch(stream.slice(stream.indexOf("if (truncatedFailure)")), /runCompleted = true/);
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ test("persists transformed assistant metadata before atomically settling usage",
|
||||
consultRoute,
|
||||
/parseAgentReply\([\s\S]*?rawTransformedText,[\s\S]*?createConsultationReplyMetadata\(\{ question: visibleQuestion \}\),[\s\S]*?\)/,
|
||||
);
|
||||
assert.match(consultRoute, /role: "assistant" as const,[\s\S]*techniqueTruth,[\s\S]*workflowReceipt/);
|
||||
assert.match(consultRoute, /role: "assistant" as const,[\s\S]*thinkingText: persistedThinking[\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");
|
||||
|
||||
@@ -10,8 +10,8 @@ const chat = readFileSync(
|
||||
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const messageRow = readFileSync(
|
||||
new URL("../src/components/chat-message-row.tsx", import.meta.url),
|
||||
const activityStatus = readFileSync(
|
||||
new URL("../src/components/agent-activity-status.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const messageActions = readFileSync(
|
||||
@@ -300,9 +300,9 @@ test("rectification keeps receipts for the varga sentence and shows live tool pr
|
||||
assert.match(chat, /回答未完成,已保留现有内容;本次不会扣点/);
|
||||
assert.doesNotMatch(chat, /reasoning-delta|chain-of-thought/);
|
||||
assert.match(chat, /event.type === "thinking.delta"/);
|
||||
assert.match(messageRow, /className="message-thinking"/);
|
||||
assert.match(messageRow, /思考过程/);
|
||||
assert.match(messageRow, /userOpen \?\? !hasAnswer/);
|
||||
assert.match(activityStatus, /className="message-thinking"/);
|
||||
assert.match(activityStatus, /思考过程/);
|
||||
assert.match(activityStatus, /userOpen \?\? !hasAnswer/);
|
||||
assert.match(styles, /\.message-thinking-body/);
|
||||
});
|
||||
|
||||
|
||||
@@ -116,7 +116,11 @@ test("uses one collapsed history action instead of icon-only session rows", () =
|
||||
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
|
||||
assert.match(appSidebar, /MessageSquareText/);
|
||||
assert.match(appSidebar, /state === "collapsed" && !isMobile/);
|
||||
assert.match(appSidebar, /sessions\.map/);
|
||||
assert.match(appSidebar, /favoriteSessions\.map/);
|
||||
assert.match(appSidebar, /historySessions\.map/);
|
||||
assert.match(appSidebar, /星盘列表/);
|
||||
assert.match(appSidebar, /收藏对话/);
|
||||
assert.match(appSidebar, /历史对话/);
|
||||
assert.doesNotMatch(appSidebar, /sessions\.map\([^)]*\)\s*=>\s*<[^>]+aria-label=/);
|
||||
});
|
||||
|
||||
@@ -151,6 +155,8 @@ test("keeps app sidebar props as product data and callbacks", () => {
|
||||
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
|
||||
assert.match(appSidebar, /export type AppSidebarProps/);
|
||||
assert.match(appSidebar, /onSelectSession: \(sessionId: string\) => void/);
|
||||
assert.match(appSidebar, /onSelectChart: \(chartId: string\) => void/);
|
||||
assert.match(appSidebar, /charts: readonly SidebarChart\[\]/);
|
||||
assert.doesNotMatch(appSidebar, /supabase|fetch\(|\/api\//i);
|
||||
});
|
||||
|
||||
|
||||
@@ -23,13 +23,11 @@ def test_chat_history_management_actions_are_exposed() -> None:
|
||||
"shareSession",
|
||||
"share_payload_version",
|
||||
"messages.map",
|
||||
"onContextMenu",
|
||||
"session-menu-trigger",
|
||||
'role="menu"',
|
||||
"closeSessionMenu",
|
||||
"Escape",
|
||||
"focus-within",
|
||||
"置顶",
|
||||
"onContextMenu",
|
||||
"session-menu-trigger",
|
||||
"Escape",
|
||||
"focus-within",
|
||||
"收藏",
|
||||
"重命名",
|
||||
"归档",
|
||||
"恢复",
|
||||
|
||||
Reference in New Issue
Block a user