fix(web): keep composer enabled while generating and treat stop as neutral (BUG-551, BUG-552)

Enter now queues one follow-up instead of dropping it, and a rectification stop leaves the streamed reply with a grey notice instead of an error alert.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-06 13:08:43 +08:00
parent f5566c06ef
commit 055b7adca9
18 changed files with 534 additions and 74 deletions
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志 # 印度占星 Skill 更新日志
## 2026-09-06 — 生成中仍可打字,停止不再像报错
回答生成时输入框不再变灰,回车会排成一条待发送,当前回答正常结束后自动发出,停止或失败则放回输入框。生时校正里点停止会留下已生成的内容,并用灰字说明已停止、不扣点,不再出现红色告警。Skill 版本未变。
## 2026-09-06 — 短暂连不上数据库时不再说「服务尚未配置」 ## 2026-09-06 — 短暂连不上数据库时不再说「服务尚未配置」
部署刚切换、数据库暂时连不上时,页面会提示服务暂时不可用、请稍后重试,而不会再说成环境没配好。真的缺少数据库环境变量时,原来的「服务尚未配置」还在。Skill 版本未变。 部署刚切换、数据库暂时连不上时,页面会提示服务暂时不可用、请稍后重试,而不会再说成环境没配好。真的缺少数据库环境变量时,原来的「服务尚未配置」还在。Skill 版本未变。
+33
View File
@@ -8517,3 +8517,36 @@
- 复发自:BUG-546(计划层继续采集,决策层已经可采用) - 复发自:BUG-546(计划层继续采集,决策层已经可采用)
- 修复版本:`ca4e2408` - 修复版本:`ca4e2408`
## BUG-551 | 生成中禁用输入框,焦点丢失,回车消息被丢弃
- 状态:resolved
- 首次发现:2026-09-06
- 最近更新:2026-09-06
- 影响面:`ChatComposer`、主咨询 `page.tsx``use-consultation-run.ts`、生时校正 `rectification-agentic-chat.tsx`
- 用户现象:发出消息后输入框变灰,不能继续打字;回答结束后必须再点一次输入框。生成中按回车,消息被丢掉。
- 触发条件:任一对话面正在生成回答时打字或按回车。
- 根因:两个对话面把 `isLoading` / `busy` 写进 `<textarea disabled>`。浏览器会立刻移走焦点且解禁后不归还。`send()` 在已有请求时直接 `return false`,没有任何排队。
- 修复:输入框只在只读、会话加载、onboarding 或另一面打开时禁用。生成中回车把文字排成一条待发送,正常结算后自动发出;停止、失败或断线恢复时放回输入框。发送键在有排队时禁用。
- 验证:`frontend/tests/chat-composer-queue.test.ts``composer-ime-contract``composer-isolation-contract``rectification-surface-contract`
- 防复发:不得再用生成中状态禁用 textarea;生成中的发送必须进入排队或明确放回草稿,不得静默丢弃。
- 相关记录:BUG-216、BUG-329
- 复发自:无
- 修复版本:待发布
## BUG-552 | 生时校正停止穿报错衣服,选择题与采用等待中停止无效
- 状态:resolved
- 首次发现:2026-09-06
- 最近更新:2026-09-06
- 影响面:`rectification-agentic-chat.tsx``ChatMessageRow`、选择题与采用 `fetch`
- 用户现象:生成中点停止后对话区出现红色告警,气泡按失败处理;还没有正文时思考行消失。回答选择题或点采用后的等待里,停止键在但点了没反应。
- 触发条件:生时校正流式回答中点停止;或选择题 / 采用请求尚未回到 `send()` 时点停止。
- 根因:`AbortError` 把气泡写成 `failed``setError(RECTIFICATION_STOPPED_NOTICE)`,唯一出口是 `role="alert"``.error-message`。空正文时 `flatMap` 丢掉该行。选择题与采用两条 `fetch` 没有挂 `AbortController``runAbort`,停止是空操作。BUG-329 的防复发只锁了 `send()` 那一路 fetch abort。
- 修复:停止后气泡 `settled` + `stopped`,灰字说明已停止且不扣点,不再 `setError`。无正文也保留气泡。选择题与采用的 fetch 挂上同一个 `runAbort`
- 验证:`frontend/tests/chat-composer-queue.test.ts``rectification-agentic-entry.test.ts``rectification-surface-contract.test.ts``chat-notice-and-scroll-contract.test.ts`
- 防复发:停止不得走 `role="alert"``busy` 时可见的停止键必须 abort 当前 fetch,包括选择题与采用,而不仅是 `send()`
- 相关记录:BUG-329、BUG-551
- 复发自:BUG-329(停止按钮只绑了 `send()` 的 abort,没覆盖选择题与采用)
- 修复版本:待发布
@@ -0,0 +1,39 @@
# PROGRESS · 生成中输入框可打字、排队发送、停止中性态(2026-09-06)
工作树:`.worktrees/composer-live-input-and-stop-20260906`
分支:`codex/composer-live-input-and-stop-20260906`
基线:任务书写 `origin/staging` @ `b8ea3112`;开工时 `origin/staging` 已到 `d2a209d3`(后续任务书文档),以当时远端为准。
`page.tsx` 收尾 2040 行(上限 2041)。未改服务端、未改 `.gitea/workflows/**`、未 bump Skill。
| 任务 | 状态 | BUG |
| --- | --- | --- |
| 5.1 输入框不再禁用 + 排队发送 | 完成 | BUG-551 |
| 5.2 停止 = 中性状态 | 完成 | BUG-552 |
| 5.3 BUG_HISTORY / CHANGELOG / DESIGN / VOICE | 完成 | — |
## 实现要点
- 共用 `queued-draft.ts` + `useQueuedMessage`。主咨询在 `useConsultationRun` 里结算排队;校正在 `busy` 下降后按 `lastRunOutcome` 发送或放回。
- `ChatComposer` 增加可选 `queued` 卡片;textarea 只接受结构性 `inputDisabled`
- 校正 `AbortError`:气泡 `stopped`,灰字 `RECTIFICATION_STOPPED_NOTICE`,不再 `setError`。选择题 / 采用 fetch 挂 `runAbort`
- 发送键在有排队时禁用。停止后不抢焦点;撤回才聚焦。
## 既有断言改动
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `rectification-surface-contract` | `if (raw.trim()) setError(RECTIFICATION_STOPPED_NOTICE)` | 气泡 `stopped` + `stoppedNotice`,无 setError | 停止不能穿报错衣服(BUG-552) |
| `rectification-agentic-entry` | `caught.name === "AbortError"` | `isAbortError(caught)` | 选择题与采用共用中止判定 |
| `chat-notice-and-scroll-contract` | 未改断言正文 | `JumpToLatestButton` 仍保持原多行 JSX | 曾误压成一行导致 `sourceBetween` 找不到 |
## 测试
| 命令 | 结果 |
| --- | --- |
| `npx tsx --test tests/chat-composer-queue.test.ts tests/composer-*.test.ts tests/chat-notice-and-scroll-contract.test.ts tests/rectification-surface-contract.test.ts tests/rectification-agentic-entry.test.ts tests/agent-voice-copy-contract.test.ts` | 81 项 pass 81 fail 0 |
| `./node_modules/.bin/tsc --noEmit` | 0 错 |
| 改动文件 eslint `--quiet` | 0 error |
| `tests/class-name-definition-contract.test.ts` | 3/3 |
| `page.tsx` | 2040 行 |
| `npm run build -- --webpack` | 编译通过;随后在既有 `dossierResponse` 路由导出类型上失败,与本次无关 |
@@ -0,0 +1,50 @@
# Staging 人肉复核 · 生成中可打字、排队发送、停止中性态(2026-09-06)
给产品负责人。不要把真实出生资料或对话正文写进任何记录。
对应 BUG-551、BUG-552。测之前先做第 0 条。
## 0. 确认测的是新版本
浏览器打开 `https://staging.jyotisha.chat/api/health`,看 `deployment.gitCommit` 前 8 位是否等于本单合入 staging 后的提交。不一致 = 先别测。
## 1. 生成中连续打字不丢焦点(P0)
1. 打开一次普通咨询,发出一条问题。
2. 回答还在生成时,立刻在输入框里打几个字,不要先点输入框。
- ✅ 预期:输入框没有变灰,字能打进去,光标还在。
- ❌ 失败:输入框是灰的,或必须再点一次才能打字。
## 2. 回车排队,回答结束后自动发出(P0)
1. 生成中输入一句新问题,按回车。
2. 看输入框上方是否出现「已排队,回答结束后发出」。
3. 等当前回答正常结束。
- ✅ 预期:排队卡片出现;当前回答结束后,那句话自动发出,卡片消失。
- ❌ 失败:回车没反应;或回答结束后排队文字还停着不发。
## 3. 撤回(P0
1. 生成中再排一条。
2. 点「撤回」。
- ✅ 预期:卡片消失,文字回到输入框,输入框重新聚焦。
- ❌ 失败:文字丢了,或焦点不在输入框。
## 4. 两个对话面停止后无红条(P0)
1. 普通咨询生成中点停止。
2. 生时校正生成中点停止,包括还没有正文的工具阶段。
3. 生时校正点一道选择题后、下一问还没出来时点停止。
- ✅ 预期:已生成内容还在;一句灰字说明已停止、不扣点;没有红色告警条。选择题等待中一点停止就停,`busy` 马上结束。
- ❌ 失败:红色告警、气泡被标成失败、思考行凭空消失,或停止键没反应。
## 5. 停止后排队不自动发(P1)
生成中先排队,再点停止。
- ✅ 预期:排队文字回到输入框,不会自动发出。
- ❌ 失败:停止后仍把排队文字发出去。
+8 -6
View File
@@ -192,7 +192,8 @@ Radii have two visual steps. Controls use 8px (`--radius-md`; `--radius-xs` and
### Input and composer ### Input and composer
- **Structure:** warm canvas field, hairline, typed value, optional icon action. - **Structure:** warm canvas field, hairline, typed value, optional icon action.
- **States:** default, hover, focus with deep-brown ring, disabled, invalid, loading. - **States:** default, hover, focus with deep-brown ring, disabled only for structural reasons (readonly, session loading, onboarding, the other surface open), invalid. Generating does not disable the textarea; the send control becomes stop.
- **Queue:** one pending card (`.composer-queue`) sits above the field while an answer is in flight. Copy: “已排队,回答结束后发出”, plus the text and a 44px “撤回”. A second Enter appends to the same card with a newline. Successful settlement sends it; stop, failure, or recovery puts the text back in the field. The send button is disabled while a card is waiting. No spinner.
- **Accessibility:** persistent label where practical; composer has an explicit accessible label. - **Accessibility:** persistent label where practical; composer has an explicit accessible label.
- **One composer:** the rectification surface renders the same `ChatComposer` as the main chat; there is no second composer. A surface that owns its own draft passes it as `value`; the main chat reads the draft store. Both count down from the same 500-character ceiling through `CharacterRemaining`. - **One composer:** the rectification surface renders the same `ChatComposer` as the main chat; there is no second composer. A surface that owns its own draft passes it as `value`; the main chat reads the draft store. Both count down from the same 500-character ceiling through `CharacterRemaining`.
@@ -223,18 +224,18 @@ The birth-time rectification session is the consultation transcript plus a house
| State | Trailing entry | Composer | | State | Trailing entry | Composer |
|---|---|---| |---|---|---|
| `opening` | live row “正在读取你的出生资料,准备第一个问题…”, then tool labels | disabled, stop visible | | `opening` | live row “正在读取你的出生资料,准备第一个问题…”, then tool labels | enabled (typing queues), stop visible |
| `empty` | “这段校正还没有开始。” and one primary action “开始提问” | enabled | | `empty` | “这段校正还没有开始。” and one primary action “开始提问” | enabled |
| `question-live` | the asking message carries the embedded card or the spoken stem | enabled, placeholder points at the card | | `question-live` | the asking message carries the embedded card or the spoken stem | enabled, placeholder points at the card |
| `question-gap`, retries left | one timeline live row “正在准备下一个问题…”, refetching on a 2s timer up to two retries | enabled | | `question-gap`, retries left | one timeline live row “正在准备下一个问题…”, refetching on a 2s timer up to two retries | enabled |
| `question-gap`, retries spent | “没有拿到下一个问题。” and a 44px “重新加载” | enabled | | `question-gap`, retries spent | “没有拿到下一个问题。” and a 44px “重新加载” | enabled |
| `verified_idle` | one closing line `postAdoptVerifyDone` after adopted reverse-verify is exhausted; no spinner, no reload | enabled | | `verified_idle` | one closing line `postAdoptVerifyDone` after adopted reverse-verify is exhausted; no spinner, no reload | enabled |
| `choice-pending` | the answered card (`data-selected` fill, a top row “正在记录…”) and the same live row from “正在记录本次选择…” through the follow-up turn | disabled, stop visible | | `choice-pending` | the answered card (`data-selected` fill, a top row “正在记录…”) and the same live row from “正在记录本次选择…” through the follow-up turn | enabled (typing queues), stop visible |
| `candidates` | the offer cards under the message that offered them | enabled | | `candidates` | the offer cards under the message that offered them | enabled |
| `adopting` | “正在采用 HH:MM…” through the follow-up turn | disabled during the chain | | `adopting` | “正在采用 HH:MM…” through the follow-up turn | enabled (typing queues), stop visible |
| `confirmed` | “已确认校正时间:HH:MM” | enabled | | `confirmed` | “已确认校正时间:HH:MM” | enabled |
| `readonly` | “该校正已结束,只能查看历史。” and “再次校正” | disabled | | `readonly` | “该校正已结束,只能查看历史。” and “再次校正” | disabled |
| stopped | the row settles with what streamed; notice “已停止,已生成的内容保留;本次不会扣点。” | enabled | | stopped | the row settles with what streamed; a grey caption “已停止,已生成的内容保留;本次不会扣点。” under the body, never `role="alert"` | enabled |
- **Rules:** a follow-up turn continues on the live row already in place; `busy` never drops in the middle of a chain. No copy may ask the reader to wait for the server; a gap is a live row with retries, then a reload button. A hydration that timed out is the same gap. A 402 shows “校正点数不足,正在前往兑换…” for 600ms before the page leaves. - **Rules:** a follow-up turn continues on the live row already in place; `busy` never drops in the middle of a chain. No copy may ask the reader to wait for the server; a gap is a live row with retries, then a reload button. A hydration that timed out is the same gap. A 402 shows “校正点数不足,正在前往兑换…” for 600ms before the page leaves.
- **Board:** before any candidate exists the header clock shows the declared birth minute and the body reads “填报出生时间 HH:MM” / “回答几个问题后,这里会显示宫位随时间的变化。”; the column narrows to `minmax(16rem, 18rem)` (`is-board-empty`) and widens once a result arrives. No house table is invented for the declared time; the snapshot API does not provide one. - **Board:** before any candidate exists the header clock shows the declared birth minute and the body reads “填报出生时间 HH:MM” / “回答几个问题后,这里会显示宫位随时间的变化。”; the column narrows to `minmax(16rem, 18rem)` (`is-board-empty`) and widens once a result arrives. No house table is invented for the declared time; the snapshot API does not provide one.
@@ -329,7 +330,8 @@ Every assistant reply moves through the same states on both chat surfaces, and e
| `thinking` | `thinking.section` / `thinking.delta` | live think row; thinking text inside the row, paced per frame | label swap, 120ms fade | | `thinking` | `thinking.section` / `thinking.delta` | live think row; thinking text inside the row, paced per frame | label swap, 120ms fade |
| `composing` | first `answer.delta` | live write row; answer text below the timeline, released per frame | answer paragraphs appear as text, no per-token animation | | `composing` | first `answer.delta` | live write row; answer text below the timeline, released per frame | answer paragraphs appear as text, no per-token animation |
| `settled` | `run.completed` | summary becomes “已完成 N 步”, timeline collapses in place unless the reader opened or closed it themselves; actions and follow-ups appear | 180ms height transition, 120ms label fade | | `settled` | `run.completed` | summary becomes “已完成 N 步”, timeline collapses in place unless the reader opened or closed it themselves; actions and follow-ups appear | 180ms height transition, 120ms label fade |
| `failed` | `run.failed`, abort, network loss | whatever was received stays in place; the message carries no inline banner the notice goes to the composer notice / error line | none | | `stopped` | the reader pressed stop | whatever streamed stays; a grey caption under the body says the run stopped and was not billed | none |
| `failed` | `run.failed`, network loss | whatever was received stays in place; the message carries no inline banner — the notice goes to the composer notice / error line | none |
Text release is paced, not animated: the frame buffer commits at most once per animation frame and reveals `max(2, ⌈backlog ÷ 12⌉)` characters per frame, so a burst catches up in about twelve frames and a slow model never reads as stalled. The reader's own toggle on the timeline always wins over the state default. Under `prefers-reduced-motion: reduce` the collapse and the label fade are instant; the pacing stays, because it is content arrival rather than decoration. Text release is paced, not animated: the frame buffer commits at most once per animation frame and reveals `max(2, ⌈backlog ÷ 12⌉)` characters per frame, so a burst catches up in about twelve frames and a slow model never reads as stalled. The reader's own toggle on the timeline always wins over the state default. Under `prefers-reduced-motion: reduce` the collapse and the label fade are instant; the pacing stays, because it is content arrival rather than decoration.
+2
View File
@@ -38,6 +38,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 界面上继续有下一问。 | 接下来我们继续。 | 正文不得断言界面当前有没有题;题干在同一条消息里正文之后出现,过渡用中性句。 | | 界面上继续有下一问。 | 接下来我们继续。 | 正文不得断言界面当前有没有题;题干在同一条消息里正文之后出现,过渡用中性句。 |
| 选一个最贴近你实际情况的就行,答不上来也可以选「一时说不好」。 | 2024 年那件事记下了。接下来我们继续。 | 正文先承接用户刚说的年份和事件;不要预告选项。 | | 选一个最贴近你实际情况的就行,答不上来也可以选「一时说不好」。 | 2024 年那件事记下了。接下来我们继续。 | 正文先承接用户刚说的年份和事件;不要预告选项。 |
| ## 统一参数与原始结构(用户刚问「我婚姻怎么样」) | 先用几句口语答婚姻方向,再进入统一参数与原始结构。 | 骨架仍在,开场先回人。 | | ## 统一参数与原始结构(用户刚问「我婚姻怎么样」) | 先用几句口语答婚姻方向,再进入统一参数与原始结构。 | 骨架仍在,开场先回人。 |
| 生成中输入框变灰,回车没反应 | 生成中仍可打字;回车后显示「已排队,回答结束后发出」,可撤回 | 对标主流对话产品,焦点不丢。 |
| 停止后出现红色告警条 | 已停止,已生成的内容保留;本次不会扣点。 | 停止是用户动作,不是出错。 |
## 服务端探针 → Agent 题干 ## 服务端探针 → Agent 题干
+8
View File
@@ -1501,6 +1501,14 @@ button:disabled { cursor: default; opacity: .45; }
} }
} }
.error-message { margin: var(--space-3) 0 0; padding: var(--space-3) var(--space-4); border-left: 3px solid var(--color-danger); background: var(--color-danger-muted); color: var(--color-danger); font-size: var(--type-caption); line-height: 1.6; border-color: var(--color-danger); border-radius: 0 var(--radius-md) var(--radius-md) 0; } .error-message { margin: var(--space-3) 0 0; padding: var(--space-3) var(--space-4); border-left: 3px solid var(--color-danger); background: var(--color-danger-muted); color: var(--color-danger); font-size: var(--type-caption); line-height: 1.6; border-color: var(--color-danger); border-radius: 0 var(--radius-md) var(--radius-md) 0; }
.message-stopped-notice { margin: var(--space-2) 0 0; color: var(--color-ink-tertiary); font-size: var(--type-caption); line-height: 1.5; }
.composer-queue { width: min(760px, 100%); display: flex; align-items: flex-start; gap: var(--space-3); margin: 0 auto var(--space-2); padding: var(--space-3) var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-muted); }
.composer-queue__body { min-width: 0; flex: 1; }
.composer-queue__label { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.4; }
.composer-queue__text { margin: var(--space-1) 0 0; color: var(--color-ink); font-size: var(--type-body-sm); line-height: 1.5; overflow-wrap: anywhere; }
.composer-queue__recall { flex: 0 0 auto; min-height: 44px; min-width: 44px; padding: 0 var(--space-3); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-ink-secondary); cursor: pointer; font: inherit; font-size: var(--type-caption); }
.composer-queue__recall:hover { color: var(--color-ink); }
.composer-queue__recall:focus-visible { outline: 3px solid color-mix(in srgb, var(--color-focus) 56%, transparent); outline-offset: 2px; }
.composer-wrap { position: sticky; bottom: 0; z-index: 2; min-width: 0; border-top: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); padding: var(--space-3) var(--space-6) var(--space-4); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); } .composer-wrap { position: sticky; bottom: 0; z-index: 2; min-width: 0; border-top: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); padding: var(--space-3) var(--space-6) var(--space-4); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); }
/* Jump to latest: shared by both chat surfaces. Hangs off the composer wrap's top /* Jump to latest: shared by both chat surfaces. Hangs off the composer wrap's top
+7 -8
View File
@@ -731,12 +731,7 @@ export default function Home() {
!rectificationSurfaceOpen && !starterHomeVisible, !rectificationSurfaceOpen && !starterHomeVisible,
activeSessionId, activeSessionId,
); );
const { const { restoreConsultationRecovery, stopResponse, send, regenerateLatestAnswer, queuedDraft, enqueueQueuedDraft, recallQueuedDraft } = useConsultationRun({
restoreConsultationRecovery,
stopResponse,
send,
regenerateLatestAnswer,
} = useConsultationRun({
account, account,
activeSession, activeSession,
activeSessionIdRef, activeSessionIdRef,
@@ -752,6 +747,7 @@ export default function Home() {
consultationReplayStarted, consultationReplayStarted,
consultationStatusMissingCount, consultationStatusMissingCount,
conversationAnchor, conversationAnchor,
consultationPhase,
isLoading, isLoading,
modelCatalog, modelCatalog,
onboardingJustCompleted, onboardingJustCompleted,
@@ -772,6 +768,7 @@ export default function Home() {
setPendingRequestId, setPendingRequestId,
setPendingSessionId, setPendingSessionId,
setProfileNotice, setProfileNotice,
replyOutcome,
setReplyOutcome, setReplyOutcome,
setRequestError, setRequestError,
setSessionFullPrompt, setSessionFullPrompt,
@@ -1546,6 +1543,7 @@ export default function Home() {
if (onboardingStep === "name" && presetMessageFinished) void saveOnboardingName(); if (onboardingStep === "name" && presetMessageFinished) void saveOnboardingName();
return; return;
} }
if (isLoading || cancellationPending) return void enqueueQueuedDraft(composerDraftSnapshot());
void send(composerDraftSnapshot(), draftTheme.current ?? undefined, draftEntrypoint.current); void send(composerDraftSnapshot(), draftTheme.current ?? undefined, draftEntrypoint.current);
} }
@@ -1995,9 +1993,9 @@ export default function Home() {
? "余额不足,可前往会员页充值点数或开通会员" ? "余额不足,可前往会员页充值点数或开通会员"
: "例如:未来半年是否适合换工作?"} : "例如:未来半年是否适合换工作?"}
maxLength={!profileComplete && onboardingStep === "name" ? 80 : 500} maxLength={!profileComplete && onboardingStep === "name" ? 80 : 500}
inputDisabled={isLoading || sessionMessagesLoading || cancellationPending || rectificationSurfaceOpen || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))} inputDisabled={sessionMessagesLoading || rectificationSurfaceOpen || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
submitLabel={!profileComplete && onboardingStep === "name" ? "确认称呼" : "发送"} submitLabel={!profileComplete && onboardingStep === "name" ? "确认称呼" : "发送"}
submitBlocked={Boolean(pendingSessionId) || sessionMessagesLoading || cancellationPending || rectificationSurfaceOpen || !account || !modelCatalog || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))} submitBlocked={Boolean(pendingSessionId) || Boolean(queuedDraft) || sessionMessagesLoading || rectificationSurfaceOpen || !account || !modelCatalog || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
stopVisible={isLoading} stopVisible={isLoading}
stopLabel={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留已生成内容并退回本次点数" : "停止回答并申请退回本次点数"} stopLabel={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留已生成内容并退回本次点数" : "停止回答并申请退回本次点数"}
stopTitle={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留现有内容并申请退回本次点数" : "停止回答"} stopTitle={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留现有内容并申请退回本次点数" : "停止回答"}
@@ -2009,6 +2007,7 @@ export default function Home() {
setComposerNotice(""); setComposerNotice("");
}} }}
onKeyDown={handleComposerKeyDown} onKeyDown={handleComposerKeyDown}
queued={queuedDraft ? { text: queuedDraft, onRecall: recallQueuedDraft } : undefined}
onStop={() => void stopResponse()} onStop={() => void stopResponse()}
/> />
<div className="composer-footer"> <div className="composer-footer">
+46 -30
View File
@@ -11,6 +11,7 @@ import {
serverComposerDraftSnapshot, serverComposerDraftSnapshot,
subscribeComposerDraft, subscribeComposerDraft,
} from "@/lib/composer-draft"; } from "@/lib/composer-draft";
import { COMPOSER_QUEUE_LABEL, COMPOSER_QUEUE_RECALL_LABEL } from "@/lib/queued-draft";
export const composerRemainingId = "composer-character-remaining"; export const composerRemainingId = "composer-character-remaining";
@@ -41,6 +42,7 @@ type ChatComposerProps = {
readonly stopVisible: boolean; readonly stopVisible: boolean;
readonly stopLabel: string; readonly stopLabel: string;
readonly stopTitle: string; readonly stopTitle: string;
readonly queued?: { text: string; onRecall: () => void };
readonly onSubmit: (event: FormEvent<HTMLFormElement>) => void; readonly onSubmit: (event: FormEvent<HTMLFormElement>) => void;
readonly onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void; readonly onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void;
readonly onKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void; readonly onKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
@@ -61,6 +63,7 @@ export function ChatComposer({
stopVisible, stopVisible,
stopLabel, stopLabel,
stopTitle, stopTitle,
queued,
onSubmit, onSubmit,
onChange, onChange,
onKeyDown, onKeyDown,
@@ -72,35 +75,48 @@ export function ChatComposer({
const describedByIds = [describedBy, showRemaining ? remainingId : undefined].filter(Boolean).join(" ") || undefined; const describedByIds = [describedBy, showRemaining ? remainingId : undefined].filter(Boolean).join(" ") || undefined;
return ( return (
<form className="composer" onSubmit={onSubmit}> <>
<Textarea {queued ? (
ref={inputRef} <div className="composer-queue" role="status">
aria-label={inputLabel} <div className="composer-queue__body">
aria-describedby={describedByIds} <p className="composer-queue__label">{COMPOSER_QUEUE_LABEL}</p>
placeholder={placeholder} <p className="composer-queue__text">{queued.text}</p>
rows={1} </div>
maxLength={maxLength} <button className="composer-queue__recall" type="button" onClick={queued.onRecall}>
disabled={inputDisabled} {COMPOSER_QUEUE_RECALL_LABEL}
value={draft} </button>
onChange={onChange} </div>
onKeyDown={onKeyDown} ) : null}
/> <form className="composer" onSubmit={onSubmit}>
{stopVisible ? ( <Textarea
<Button ref={inputRef}
className="composer-stop" aria-label={inputLabel}
aria-label={stopLabel} aria-describedby={describedByIds}
title={stopTitle} placeholder={placeholder}
size="icon" rows={1}
type="button" maxLength={maxLength}
onClick={onStop} disabled={inputDisabled}
> value={draft}
<Square aria-hidden="true" /> onChange={onChange}
</Button> onKeyDown={onKeyDown}
) : ( />
<Button aria-label={submitLabel} disabled={!draft.trim() || submitBlocked} size="icon" type="submit"> {stopVisible ? (
<ArrowUp aria-hidden="true" /> <Button
</Button> className="composer-stop"
)} aria-label={stopLabel}
</form> title={stopTitle}
size="icon"
type="button"
onClick={onStop}
>
<Square aria-hidden="true" />
</Button>
) : (
<Button aria-label={submitLabel} disabled={!draft.trim() || submitBlocked} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
)}
</form>
</>
); );
} }
@@ -40,11 +40,13 @@ export function ChatMessageRow({
showActivity = message.state !== "settled", showActivity = message.state !== "settled",
vargaSentence, vargaSentence,
afterAnswer, afterAnswer,
stoppedNotice,
}: Readonly<{ }: Readonly<{
message: ChatMessageView; message: ChatMessageView;
showActivity?: boolean; showActivity?: boolean;
vargaSentence?: string | null; vargaSentence?: string | null;
afterAnswer?: ReactNode; afterAnswer?: ReactNode;
stoppedNotice?: string;
}>) { }>) {
const messageRow = useRef<HTMLElement>(null); const messageRow = useRef<HTMLElement>(null);
const assistantLabel = message.state === "thinking" const assistantLabel = message.state === "thinking"
@@ -163,6 +165,7 @@ export function ChatMessageRow({
</> </>
) )
) : <><p>{message.text}</p>{afterAnswer}</>} ) : <><p>{message.text}</p>{afterAnswer}</>}
{stoppedNotice ? <p className="message-stopped-notice">{stoppedNotice}</p> : null}
</div> </div>
</div> </div>
</article> </article>
@@ -1,6 +1,8 @@
"use client"; "use client";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { useQueuedMessage } from "@/hooks/use-queued-message";
import { appendQueuedText, queuedDraftSettleAction } from "@/lib/queued-draft";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { parseAgentReply } from "@/lib/agent-reply"; import { parseAgentReply } from "@/lib/agent-reply";
import { nextActivityView, type AgentActivityView, type ChatMessage, type ChatMessageView } from "@/lib/chat-message-view"; import { nextActivityView, type AgentActivityView, type ChatMessage, type ChatMessageView } from "@/lib/chat-message-view";
@@ -56,6 +58,7 @@ import {
RECTIFICATION_QUESTION_RETRY_LIMIT, RECTIFICATION_QUESTION_RETRY_LIMIT,
RECTIFICATION_QUESTION_UNAVAILABLE_COPY, RECTIFICATION_QUESTION_UNAVAILABLE_COPY,
RECTIFICATION_STOPPED_NOTICE, RECTIFICATION_STOPPED_NOTICE,
isAbortError,
rectificationConversationState, rectificationConversationState,
rectificationInitialLiveLabel, rectificationInitialLiveLabel,
rectificationQuestionGapState, rectificationQuestionGapState,
@@ -248,6 +251,7 @@ type RenderMessage = ChatMessageView & {
renderKey: string; renderKey: string;
completedReceipt?: CompletedActivityReceiptView; completedReceipt?: CompletedActivityReceiptView;
failed?: boolean; failed?: boolean;
stopped?: boolean;
turnId?: string; turnId?: string;
activityTrace?: readonly AgentActivityTraceItem[]; activityTrace?: readonly AgentActivityTraceItem[];
question?: TurnQuestion; question?: TurnQuestion;
@@ -481,6 +485,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [messages, setMessages] = useState<RenderMessage[]>(() => messagesFromTurns(initialTurns)); const [messages, setMessages] = useState<RenderMessage[]>(() => messagesFromTurns(initialTurns));
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const queued = useQueuedMessage();
const lastRunOutcome = useRef<"succeeded" | "stopped" | "failed" | "readonly" | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [savedTime, setSavedTime] = useState<string | null>(() => caseSnapshotState(initialSnapshot)?.savedTime ?? null); const [savedTime, setSavedTime] = useState<string | null>(() => caseSnapshotState(initialSnapshot)?.savedTime ?? null);
@@ -761,9 +767,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
: []), : []),
liveRow, liveRow,
])); ]));
setDraft("");
let raw = ""; let raw = "";
let runOutcome: "succeeded" | "stopped" | "failed" = "failed";
let activityTrace: readonly AgentActivityTraceItem[] = emptyActivityTrace(); let activityTrace: readonly AgentActivityTraceItem[] = emptyActivityTrace();
let activityReceiptState = createRectificationActivityReceiptState(); let activityReceiptState = createRectificationActivityReceiptState();
let completedReceipt = receiptFromRectificationActivityState(activityReceiptState); let completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
@@ -997,6 +1002,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
setError((current) => current || userFacingRunFailure("run_failed")); setError((current) => current || userFacingRunFailure("run_failed"));
} }
if (succeeded) { if (succeeded) {
runOutcome = "succeeded";
const snapshot = await loadCaseSnapshot(); const snapshot = await loadCaseSnapshot();
if (snapshot?.turns.length) { if (snapshot?.turns.length) {
setMessages((current) => mergeTurnQuestions(current, snapshot.turns)); setMessages((current) => mergeTurnQuestions(current, snapshot.turns));
@@ -1009,28 +1015,23 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
} }
} catch (caught) { } catch (caught) {
frames.settle(); frames.settle();
const aborted = caught instanceof DOMException const aborted = isAbortError(caught);
? caught.name === "AbortError"
: caught instanceof Error && caught.name === "AbortError";
if (aborted) { if (aborted) {
setMessages((current) => current.flatMap((message): RenderMessage[] => { runOutcome = "stopped";
if (message.renderKey !== assistantRenderKey) return [message]; setMessages((current) => current.map((message) => {
if (raw.trim() || hasActivityReceipt(completedReceipt)) { if (message.renderKey !== assistantRenderKey) return message;
return [{ return {
...message, ...message,
text: raw, text: raw,
activityTrace: completeActivityTrace(activityTrace), activityTrace: completeActivityTrace(activityTrace),
state: "settled", state: "settled" as const,
completedReceipt, completedReceipt,
failed: true, failed: false,
turnId: completedTurnId, stopped: true,
}]; turnId: completedTurnId,
} activity: undefined,
return []; };
})); }));
// The reader pressed stop: what streamed stays, and the notice says so
// rather than blaming the service.
if (raw.trim()) setError(RECTIFICATION_STOPPED_NOTICE);
return; return;
} }
setError("生时校正暂时不可用,请稍后再试。"); setError("生时校正暂时不可用,请稍后再试。");
@@ -1049,10 +1050,25 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
} finally { } finally {
frames.dispose(); frames.dispose();
if (runAbort.current === abortController) runAbort.current = null; if (runAbort.current === abortController) runAbort.current = null;
lastRunOutcome.current = readonly ? "readonly" : runOutcome;
setPending(false); setPending(false);
} }
}, [beginLiveRun, busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onProfileIncomplete, readonly, rememberLiveActivity, selectedModelId, sessionId, setPending]); }, [beginLiveRun, busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onProfileIncomplete, readonly, rememberLiveActivity, selectedModelId, sessionId, setPending]);
useEffect(() => {
if (busy) return;
const outcome = lastRunOutcome.current;
lastRunOutcome.current = null;
if (!outcome || !queued.text) return;
const next = queued.take();
if (!next) return;
if (queuedDraftSettleAction(outcome) === "send" && !readonly) {
void send("message", next);
return;
}
setDraft((current) => appendQueuedText(current, next));
}, [busy, readonly, send]);
const actionIdForChoice = useCallback((focusId: string, optionId: ChoiceOptionId) => { const actionIdForChoice = useCallback((focusId: string, optionId: ChoiceOptionId) => {
const key = stableChoiceActionKey(focusId, optionId); const key = stableChoiceActionKey(focusId, optionId);
const existing = choiceActionIds.current.get(key); const existing = choiceActionIds.current.get(key);
@@ -1105,10 +1121,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}, },
]); ]);
setPending(true); setPending(true);
const abortController = new AbortController();
runAbort.current = abortController;
try { try {
const response = await fetch("/api/rectification/agent", { const response = await fetch("/api/rectification/agent", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
signal: abortController.signal,
body: JSON.stringify({ body: JSON.stringify({
caseId, caseId,
sessionId, sessionId,
@@ -1175,11 +1194,27 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{ role: "assistant", text: narration }, { role: "assistant", text: narration },
]); ]);
} }
} catch { } catch (caught) {
if (isAbortError(caught)) {
lastRunOutcome.current = "stopped";
setMessages((current) => current.map((message) => (
message.renderKey !== assistantRenderKey ? message : {
...message,
state: "settled" as const,
failed: false,
stopped: true,
activity: undefined,
}
)));
return;
}
lastRunOutcome.current = "failed";
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey)); setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
setChoiceNonce((current) => current + 1); setChoiceNonce((current) => current + 1);
setError("选择题处理失败,请稍后重试。"); setError("选择题处理失败,请稍后重试。");
} finally { } finally {
if (runAbort.current === abortController) runAbort.current = null;
if (lastRunOutcome.current === null) lastRunOutcome.current = "succeeded";
setPending(false); setPending(false);
} }
}, [ }, [
@@ -1230,12 +1265,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
activity: { phase: "evidence-validation", label: adoptingLabel, startedAt: Date.now() }, activity: { phase: "evidence-validation", label: adoptingLabel, startedAt: Date.now() },
}, },
]); ]);
const abortController = new AbortController();
runAbort.current = abortController;
try { try {
const response = await fetch( const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}/candidates/accept`, `/api/rectification/cases/${encodeURIComponent(caseId)}/candidates/accept`,
{ {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
signal: abortController.signal,
body: JSON.stringify({ body: JSON.stringify({
sessionId, sessionId,
resultId: candidateResult.resultId, resultId: candidateResult.resultId,
@@ -1279,9 +1317,25 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
await loadCaseSnapshot(); await loadCaseSnapshot();
await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel }); await send("read_only", "", { reuseAssistantRenderKey: assistantRenderKey, label: adoptingLabel });
} catch (caught) { } catch (caught) {
if (isAbortError(caught)) {
lastRunOutcome.current = "stopped";
setMessages((current) => current.map((message) => (
message.renderKey !== assistantRenderKey ? message : {
...message,
state: "settled" as const,
failed: false,
stopped: true,
activity: undefined,
}
)));
return;
}
lastRunOutcome.current = "failed";
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey)); setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间"); setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间");
} finally { } finally {
if (runAbort.current === abortController) runAbort.current = null;
if (lastRunOutcome.current === null) lastRunOutcome.current = "succeeded";
setAcceptingCandidateId(null); setAcceptingCandidateId(null);
setPending(false); setPending(false);
} }
@@ -1336,7 +1390,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
async function submit(event: React.FormEvent) { async function submit(event: React.FormEvent) {
event.preventDefault(); event.preventDefault();
await send("message", draft); if (readonly) return;
if (busy || regeneratingMessageKey) {
if (queued.enqueue(draft)) setDraft("");
return;
}
const text = draft;
setDraft("");
await send("message", text);
} }
const latestRegeneratableKey = [...messages] const latestRegeneratableKey = [...messages]
@@ -1427,8 +1488,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
shouldStartOpening, shouldStartOpening,
openingStarted: openingRequested, openingStarted: openingRequested,
}); });
const canSend = !busy && !readonly && !regeneratingMessageKey;
// Question recovery: the turn already refetched once on completion; while // Question recovery: the turn already refetched once on completion; while
// the gap is `preparing`, refetch on a timer up to the retry limit, then // the gap is `preparing`, refetch on a timer up to the retry limit, then
// hand the reader a reload button. Attempts reset once a question arrives // hand the reader a reload button. Attempts reset once a question arrives
@@ -1604,6 +1663,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
showActivity={displayedMessage.state !== "settled"} showActivity={displayedMessage.state !== "settled"}
vargaSentence={vargaSentence} vargaSentence={vargaSentence}
afterAnswer={afterAnswer} afterAnswer={afterAnswer}
stoppedNotice={message.stopped ? RECTIFICATION_STOPPED_NOTICE : undefined}
/> />
)} )}
{showActions && !regenerating && ( {showActions && !regenerating && (
@@ -1701,12 +1761,18 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
? "请回答上面的问题…" ? "请回答上面的问题…"
: "继续说你记得的人生经历,或回答刚才的问题…"} : "继续说你记得的人生经历,或回答刚才的问题…"}
maxLength={RECTIFICATION_COMPOSER_MAX_LENGTH} maxLength={RECTIFICATION_COMPOSER_MAX_LENGTH}
inputDisabled={!canSend} inputDisabled={readonly}
submitLabel="发送" submitLabel="发送"
submitBlocked={!canSend} submitBlocked={readonly || Boolean(queued.text)}
stopVisible={busy} stopVisible={busy}
stopLabel="停止回答" stopLabel="停止回答"
stopTitle="停止当前推理,已生成内容会保留" stopTitle="停止当前推理,已生成内容会保留"
queued={queued.text ? { text: queued.text, onRecall: () => {
const recalled = queued.take();
if (!recalled) return;
setDraft(appendQueuedText(recalled, draft));
composer.current?.focus();
} } : undefined}
onSubmit={submit} onSubmit={submit}
onChange={(event) => setDraft(event.target.value)} onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
@@ -1,7 +1,12 @@
"use client"; "use client";
import { useEffect } from "react";
import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from "react"; import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from "react";
import { composerDraftSnapshot } from "@/lib/composer-draft";
import { appendQueuedText, queuedDraftSettleAction } from "@/lib/queued-draft";
import { useQueuedMessage } from "@/hooks/use-queued-message";
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice"; import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
import { parseAgentReply, isGenericSessionTitle, resolveSessionTitle } from "@/lib/agent-reply"; import { parseAgentReply, isGenericSessionTitle, resolveSessionTitle } from "@/lib/agent-reply";
import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mode"; import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mode";
@@ -95,6 +100,7 @@ export type ConsultationRunParams = {
consultationReplayStarted: MutableRefObject<string | null>; consultationReplayStarted: MutableRefObject<string | null>;
consultationStatusMissingCount: MutableRefObject<number>; consultationStatusMissingCount: MutableRefObject<number>;
conversationAnchor: ConversationAnchor; conversationAnchor: ConversationAnchor;
consultationPhase: "undo" | "streaming" | "recovering" | null;
isLoading: boolean; isLoading: boolean;
modelCatalog: PublicLanguageModelCatalog | null; modelCatalog: PublicLanguageModelCatalog | null;
onboardingJustCompleted: boolean; onboardingJustCompleted: boolean;
@@ -115,6 +121,7 @@ export type ConsultationRunParams = {
setPendingRequestId: Dispatch<SetStateAction<string | null>>; setPendingRequestId: Dispatch<SetStateAction<string | null>>;
setPendingSessionId: Dispatch<SetStateAction<string | null>>; setPendingSessionId: Dispatch<SetStateAction<string | null>>;
setProfileNotice: Dispatch<SetStateAction<string>>; setProfileNotice: Dispatch<SetStateAction<string>>;
replyOutcome: ReplyOutcome | null;
setReplyOutcome: Dispatch<SetStateAction<ReplyOutcome | null>>; setReplyOutcome: Dispatch<SetStateAction<ReplyOutcome | null>>;
setRequestError: Dispatch<SetStateAction<RequestError | null>>; setRequestError: Dispatch<SetStateAction<RequestError | null>>;
setSessionFullPrompt: Dispatch<SetStateAction<{ question: string; theme: Theme } | null>>; setSessionFullPrompt: Dispatch<SetStateAction<{ question: string; theme: Theme } | null>>;
@@ -151,6 +158,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
consultationReplayStarted, consultationReplayStarted,
consultationStatusMissingCount, consultationStatusMissingCount,
conversationAnchor, conversationAnchor,
consultationPhase,
isLoading, isLoading,
modelCatalog, modelCatalog,
onboardingJustCompleted, onboardingJustCompleted,
@@ -171,6 +179,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
setPendingRequestId, setPendingRequestId,
setPendingSessionId, setPendingSessionId,
setProfileNotice, setProfileNotice,
replyOutcome,
setReplyOutcome, setReplyOutcome,
setRequestError, setRequestError,
setSessionFullPrompt, setSessionFullPrompt,
@@ -189,6 +198,19 @@ export function useConsultationRun(params: ConsultationRunParams) {
openAccountDialog, openAccountDialog,
openRectificationFromHomepage, openRectificationFromHomepage,
} = params; } = params;
const queued = useQueuedMessage();
function enqueueQueuedDraft(incoming: string) {
if (queued.enqueue(incoming)) setDraft("");
}
function recallQueuedDraft() {
const recalled = queued.take();
if (!recalled) return;
setDraft(appendQueuedText(composerDraftSnapshot(), recalled));
composerInput.current?.focus();
}
function restoreConsultationRecovery( function restoreConsultationRecovery(
session: ChatSession, session: ChatSession,
@@ -1066,6 +1088,24 @@ export function useConsultationRun(params: ConsultationRunParams) {
setComposerNotice("后台未找到本次咨询请求,已停止恢复,请重新发送。"); setComposerNotice("后台未找到本次咨询请求,已停止恢复,请重新发送。");
}); });
}; };
useEffect(() => {
if (!queued.text) return;
const settlePhase = consultationPhase === "recovering"
? "recovering" as const
: replyOutcome?.phase === "completed" || replyOutcome?.phase === "stopped" || replyOutcome?.phase === "failed"
? replyOutcome.phase
: null;
if (!settlePhase) return;
if (settlePhase === "completed" && (isLoading || cancellationPending)) return;
const next = queued.take();
if (!next) return;
if (queuedDraftSettleAction(settlePhase) === "send") {
void send(next);
return;
}
setDraft(appendQueuedText(composerDraftSnapshot(), next));
}, [cancellationPending, consultationPhase, isLoading, queued.text, replyOutcome?.phase]);
return { return {
restoreConsultationRecovery, restoreConsultationRecovery,
requestCancellation, requestCancellation,
@@ -1074,5 +1114,8 @@ export function useConsultationRun(params: ConsultationRunParams) {
completeConsultationInterface, completeConsultationInterface,
send, send,
regenerateLatestAnswer, regenerateLatestAnswer,
queuedDraft: queued.text,
enqueueQueuedDraft,
recallQueuedDraft,
}; };
} }
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { appendQueuedText } from "@/lib/queued-draft";
export function useQueuedMessage() {
const [text, setText] = useState("");
const textRef = useRef("");
const enqueue = useCallback((incoming: string) => {
const nextIncoming = incoming.trim();
if (!nextIncoming) return false;
const next = appendQueuedText(textRef.current, nextIncoming);
textRef.current = next;
setText(next);
return true;
}, []);
const take = useCallback(() => {
const current = textRef.current;
if (!current) return "";
textRef.current = "";
setText("");
return current;
}, []);
return { text, enqueue, take };
}
+17
View File
@@ -0,0 +1,17 @@
export const COMPOSER_QUEUE_LABEL = "已排队,回答结束后发出";
export const COMPOSER_QUEUE_RECALL_LABEL = "撤回";
export type QueuedDraftSettle = "send" | "restore";
export function appendQueuedText(current: string, incoming: string): string {
const next = incoming.trim();
if (!next) return current.trim();
if (!current.trim()) return next;
return `${current.trimEnd()}\n${next}`;
}
export function queuedDraftSettleAction(
phase: "completed" | "succeeded" | "stopped" | "failed" | "recovering" | "readonly",
): QueuedDraftSettle {
return phase === "completed" || phase === "succeeded" ? "send" : "restore";
}
@@ -34,6 +34,10 @@ export const RECTIFICATION_EMPTY_COPY = "这段校正还没有开始。";
export const RECTIFICATION_EMPTY_ACTION_LABEL = "开始提问"; export const RECTIFICATION_EMPTY_ACTION_LABEL = "开始提问";
export const RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE = "校正记录没有完全加载,可以继续。"; export const RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE = "校正记录没有完全加载,可以继续。";
export const RECTIFICATION_STOPPED_NOTICE = "已停止,已生成的内容保留;本次不会扣点。"; export const RECTIFICATION_STOPPED_NOTICE = "已停止,已生成的内容保留;本次不会扣点。";
export function isAbortError(caught: unknown): boolean {
return (caught instanceof DOMException || caught instanceof Error) && caught.name === "AbortError";
}
export const RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE = "校正点数不足,正在前往兑换…"; export const RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE = "校正点数不足,正在前往兑换…";
export const RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS = 600; export const RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS = 600;
+125
View File
@@ -0,0 +1,125 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { createElement, createRef } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { ChatComposer } from "../src/components/chat-composer.tsx";
import {
appendQueuedText,
COMPOSER_QUEUE_LABEL,
COMPOSER_QUEUE_RECALL_LABEL,
queuedDraftSettleAction,
} from "../src/lib/queued-draft.ts";
import { chatReplyAnnouncer } from "../src/lib/chat-reply-announcement.ts";
import { RECTIFICATION_STOPPED_NOTICE } from "../src/lib/rectification-surface-state.ts";
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const composer = readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8");
const rectification = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
);
const consultationRun = readFileSync(
new URL("../src/hooks/use-consultation-run.ts", import.meta.url),
"utf8",
);
const messageRow = readFileSync(
new URL("../src/components/chat-message-row.tsx", import.meta.url),
"utf8",
);
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
test("queued drafts append onto one card and settle by outcome", () => {
assert.equal(appendQueuedText("", " 先问事业 "), "先问事业");
assert.equal(appendQueuedText("先问事业", "再问婚姻"), "先问事业\n再问婚姻");
assert.equal(appendQueuedText("先问事业", " "), "先问事业");
assert.equal(queuedDraftSettleAction("completed"), "send");
assert.equal(queuedDraftSettleAction("succeeded"), "send");
assert.equal(queuedDraftSettleAction("stopped"), "restore");
assert.equal(queuedDraftSettleAction("failed"), "restore");
assert.equal(queuedDraftSettleAction("recovering"), "restore");
assert.equal(queuedDraftSettleAction("readonly"), "restore");
});
test("generating does not disable the textarea; Enter queues instead of dropping", () => {
assert.match(composer, /disabled=\{inputDisabled\}/);
assert.match(composer, /queued\?: \{ text: string; onRecall: \(\) => void \}/);
assert.match(composer, /className="composer-queue"/);
assert.match(composer, /COMPOSER_QUEUE_LABEL/);
assert.match(composer, /COMPOSER_QUEUE_RECALL_LABEL/);
assert.doesNotMatch(styles, /composer-queue[\s\S]{0,400}spinner|composer-queue[\s\S]{0,400}skeleton/i);
assert.match(
page,
/inputDisabled=\{sessionMessagesLoading \|\| rectificationSurfaceOpen \|\| \(!profileComplete && \(onboardingStep !== "name" \|\| !presetMessageFinished \|\| profileSaving\)\)\}/,
);
assert.doesNotMatch(page, /inputDisabled=\{isLoading \|\| sessionMessagesLoading \|\| cancellationPending/);
assert.match(page, /if \(isLoading \|\| cancellationPending\) return void enqueueQueuedDraft\(composerDraftSnapshot\(\)\);/);
assert.match(consultationRun, /queuedDraftSettleAction\(settlePhase\) === "send"/);
assert.match(consultationRun, /function enqueueQueuedDraft/);
assert.match(consultationRun, /composerInput\.current\?\.focus\(\)/);
assert.match(rectification, /inputDisabled=\{readonly\}/);
assert.doesNotMatch(
rectification.slice(rectification.indexOf("<ChatComposer"), rectification.indexOf("onStop={stopRun}")),
/inputDisabled=\{!canSend\}/,
);
assert.match(rectification, /if \(busy \|\| regeneratingMessageKey\) \{/);
assert.match(rectification, /queued\.enqueue\(draft\)/);
});
test("a queued card renders above the live textarea", () => {
const html = renderToStaticMarkup(createElement(ChatComposer, {
inputRef: createRef<HTMLTextAreaElement>(),
value: "还想再问一句",
inputLabel: "输入你的问题",
placeholder: "例如:未来半年是否适合换工作?",
maxLength: 500,
inputDisabled: false,
submitLabel: "发送",
submitBlocked: true,
stopVisible: true,
stopLabel: "停止回答",
stopTitle: "停止回答",
queued: { text: "先问事业", onRecall() {} },
onSubmit() {},
onChange() {},
onKeyDown() {},
onStop() {},
}));
assert.match(html, /composer-queue/);
assert.match(html, new RegExp(COMPOSER_QUEUE_LABEL));
assert.match(html, /先问事业/);
assert.match(html, new RegExp(COMPOSER_QUEUE_RECALL_LABEL));
assert.doesNotMatch(html, /<textarea[^>]*\sdisabled=/);
assert.match(html, /还想再问一句/);
});
test("rectification abort settles as stopped, not a failed alert", () => {
assert.match(rectification, /stopped: true/);
assert.match(rectification, /failed: false,\s*stopped: true/);
assert.doesNotMatch(rectification, /if \(raw\.trim\(\)\) setError\(RECTIFICATION_STOPPED_NOTICE\)/);
assert.match(rectification, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/);
assert.match(messageRow, /stoppedNotice/);
assert.match(messageRow, /className="message-stopped-notice"/);
assert.equal(RECTIFICATION_STOPPED_NOTICE, "已停止,已生成的内容保留;本次不会扣点。");
assert.match(rectification, /signal: abortController\.signal/);
const choiceFetch = rectification.slice(
rectification.indexOf("const submitStructuredChoice"),
rectification.indexOf("const acceptCandidate"),
);
assert.match(choiceFetch, /runAbort\.current = abortController/);
assert.match(choiceFetch, /signal: abortController\.signal/);
const adoptFetch = rectification.slice(
rectification.indexOf("const acceptCandidate"),
rectification.indexOf("async function copyMessage"),
);
assert.match(adoptFetch, /runAbort\.current = abortController/);
assert.match(adoptFetch, /signal: abortController\.signal/);
assert.equal(chatReplyAnnouncer("stopped"), "chat_notice_toast");
assert.doesNotMatch(
rectification.slice(rectification.indexOf("{error &&"), rectification.indexOf("{readonly &&")),
/RECTIFICATION_STOPPED_NOTICE/,
);
});
@@ -542,10 +542,25 @@ test("rectification composer can stop a live agent run", () => {
assert.match(chat, /onStop=\{stopRun\}/); assert.match(chat, /onStop=\{stopRun\}/);
assert.match(readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8"), /className="composer-stop"/); assert.match(readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8"), /className="composer-stop"/);
assert.match(chat, /event\.type === "attempt\.reset"/); assert.match(chat, /event\.type === "attempt\.reset"/);
assert.match(chat, /caught\.name === "AbortError"/); // 原值:caught.name === "AbortError"
// 新值:isAbortError(caught)
// 原因:选择题与采用路径共用同一中止判定(BUG-552)
assert.match(chat, /isAbortError\(caught\)/);
assert.match(chat, /showActivity=\{displayedMessage\.state !== "settled"\}/); assert.match(chat, /showActivity=\{displayedMessage\.state !== "settled"\}/);
}); });
test("aborting a live run marks the bubble stopped without an alert", () => {
assert.match(chat, /failed: false,\s*stopped: true/);
assert.match(chat, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/);
assert.doesNotMatch(chat, /role="alert"[\s\S]{0,80}RECTIFICATION_STOPPED_NOTICE/);
const choiceFetch = chat.slice(chat.indexOf("const submitStructuredChoice"), chat.indexOf("const acceptCandidate"));
assert.match(choiceFetch, /runAbort\.current = abortController/);
assert.match(choiceFetch, /signal: abortController\.signal/);
const adoptFetch = chat.slice(chat.indexOf("const acceptCandidate"), chat.indexOf("async function copyMessage"));
assert.match(adoptFetch, /runAbort\.current = abortController/);
assert.match(adoptFetch, /signal: abortController\.signal/);
});
test("conversation and house board reuse the quiet overlay scrollbar", () => { test("conversation and house board reuse the quiet overlay scrollbar", () => {
assert.match(styles, /\[data-sidebar="content"\],\s*\n\.conversation,\s*\n\.rectification-board__body \{/); assert.match(styles, /\[data-sidebar="content"\],\s*\n\.conversation,\s*\n\.rectification-board__body \{/);
assert.match(styles, /\.conversation \{[^}]*overflow-y:\s*auto/); assert.match(styles, /\.conversation \{[^}]*overflow-y:\s*auto/);
@@ -117,7 +117,13 @@ test("an empty Case with no automatic opening offers a way to start", () => {
}); });
test("stopping keeps what streamed and says so; a 402 explains itself before leaving", () => { test("stopping keeps what streamed and says so; a 402 explains itself before leaving", () => {
assert.match(chat, /if \(raw\.trim\(\)\) setError\(RECTIFICATION_STOPPED_NOTICE\);/); // 原值:if (raw.trim()) setError(RECTIFICATION_STOPPED_NOTICE)
// 新值:气泡 stopped + 灰字 RECTIFICATION_STOPPED_NOTICEerror-message 只留给真正失败
// 原因:停止是用户动作,不能穿报错衣服(BUG-552)
assert.match(chat, /failed: false,\s*stopped: true/);
assert.match(chat, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/);
assert.doesNotMatch(chat, /if \(raw\.trim\(\)\) setError\(RECTIFICATION_STOPPED_NOTICE\)/);
assert.match(chat, /inputDisabled=\{readonly\}/);
assert.match(chat, /setError\(RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE\);[\s\S]*window\.setTimeout\(\(\) => \{\s*window\.location\.assign\(membershipHref\("rectification"\)\);\s*\}, RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS\);/); assert.match(chat, /setError\(RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE\);[\s\S]*window\.setTimeout\(\(\) => \{\s*window\.location\.assign\(membershipHref\("rectification"\)\);\s*\}, RECTIFICATION_INSUFFICIENT_CREDITS_REDIRECT_MS\);/);
}); });