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:
@@ -1,5 +1,9 @@
|
|||||||
# 印度占星 Skill 更新日志
|
# 印度占星 Skill 更新日志
|
||||||
|
|
||||||
|
## 2026-09-06 — 生成中仍可打字,停止不再像报错
|
||||||
|
|
||||||
|
回答生成时输入框不再变灰,回车会排成一条待发送,当前回答正常结束后自动发出,停止或失败则放回输入框。生时校正里点停止会留下已生成的内容,并用灰字说明已停止、不扣点,不再出现红色告警。Skill 版本未变。
|
||||||
|
|
||||||
## 2026-09-06 — 短暂连不上数据库时不再说「服务尚未配置」
|
## 2026-09-06 — 短暂连不上数据库时不再说「服务尚未配置」
|
||||||
|
|
||||||
部署刚切换、数据库暂时连不上时,页面会提示服务暂时不可用、请稍后重试,而不会再说成环境没配好。真的缺少数据库环境变量时,原来的「服务尚未配置」还在。Skill 版本未变。
|
部署刚切换、数据库暂时连不上时,页面会提示服务暂时不可用、请稍后重试,而不会再说成环境没配好。真的缺少数据库环境变量时,原来的「服务尚未配置」还在。Skill 版本未变。
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
|||||||
| 界面上继续有下一问。 | 接下来我们继续。 | 正文不得断言界面当前有没有题;题干在同一条消息里正文之后出现,过渡用中性句。 |
|
| 界面上继续有下一问。 | 接下来我们继续。 | 正文不得断言界面当前有没有题;题干在同一条消息里正文之后出现,过渡用中性句。 |
|
||||||
| 选一个最贴近你实际情况的就行,答不上来也可以选「一时说不好」。 | 2024 年那件事记下了。接下来我们继续。 | 正文先承接用户刚说的年份和事件;不要预告选项。 |
|
| 选一个最贴近你实际情况的就行,答不上来也可以选「一时说不好」。 | 2024 年那件事记下了。接下来我们继续。 | 正文先承接用户刚说的年份和事件;不要预告选项。 |
|
||||||
| ## 统一参数与原始结构(用户刚问「我婚姻怎么样」) | 先用几句口语答婚姻方向,再进入统一参数与原始结构。 | 骨架仍在,开场先回人。 |
|
| ## 统一参数与原始结构(用户刚问「我婚姻怎么样」) | 先用几句口语答婚姻方向,再进入统一参数与原始结构。 | 骨架仍在,开场先回人。 |
|
||||||
|
| 生成中输入框变灰,回车没反应 | 生成中仍可打字;回车后显示「已排队,回答结束后发出」,可撤回 | 对标主流对话产品,焦点不丢。 |
|
||||||
|
| 停止后出现红色告警条 | 已停止,已生成的内容保留;本次不会扣点。 | 停止是用户动作,不是出错。 |
|
||||||
|
|
||||||
## 服务端探针 → Agent 题干
|
## 服务端探针 → Agent 题干
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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_NOTICE,error-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\);/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user