Commit 2ef1a8af by luoqi

feat(助手): P4 草稿状态机 —— 出新版即作废旧版,作废态显式标注且不可确认

产品定(2026-08-12):作废发生在**新草稿生成时**,不是在确认时。
否则主管面前会同时摆着两张都能点确认的卡,点错就是分错一批人。

- state: 'pending' → 'active' | 'superseded' | 'confirmed' | 'cancelled'
  ️ 顺带解掉一个命名撞车:原 state:'pending' 与「待分配」的 group:'pending'
  同名不同义,读代码时极易混。
- 新草稿到达时**跨消息**扫描,把此前所有 active 作废;confirmed/cancelled
  是终态, 不许被回退。
- edit 指令改为落到**唯一那张 active**, 不再是"最后一张" —— 重出一版后
  最后一张可能正是刚作废的,改上去主管一点反应都看不到。
- 作废态:顶部横幅 + 底部说明 + 确认按钮禁用 + 降到 60% 不透明度。
   不隐藏、 不折叠 —— 他要能看见自己出过几版、哪一版才是当前的。
  ️ 顶部横幅不能省:确认单可以很长,视线停在上半部分时看不到底部那句。

代码组织(按 dev-plan §三):
- 数据模型 + 纯函数操作抽到 chat-blocks.ts(use-assistant-chat 534 → 479 行)。
  草稿状态机的失败形态全是静默的,端到端点一遍代价高且不稳 —— 抽成纯函数锁住。
- pac-web 补 vitest(此前无任何测试基建),8 条回归覆盖跨消息作废、终态不回退、
  同 id 重推清空 edits、findActiveSheet 不退化成"最后一张"。

tsc(service/web) 干净;pac-web build 通过;vitest 8 passed;jest 1212 passed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 6658baca
......@@ -8,7 +8,8 @@
"start": "next start -p 3100",
"lint": "eslint .",
"type-check": "tsc --noEmit",
"clean": "rm -rf .next .turbo"
"clean": "rm -rf .next .turbo",
"test": "vitest run"
},
"dependencies": {
"@pac/types": "workspace:*",
......@@ -46,6 +47,7 @@
"eslint": "^9.17.0",
"eslint-config-next": "^16.2.4",
"tailwindcss": "^4.2.4",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^3.2.7"
}
}
......@@ -242,7 +242,8 @@ export function AssignmentConfirmSheet({
}: {
requestId: string;
sheet: AssignmentProposal;
state: 'pending' | 'confirmed' | 'cancelled';
/** 草稿状态机,见 use-assistant-chat 的 Block 注释。`superseded` = 已被新版顶掉 */
state: 'active' | 'superseded' | 'confirmed' | 'cancelled';
assignmentId?: string;
/** 落库成功 → 交给外层把卡片切终态 + 往消息流注入一条文本(补模型记忆) */
/** `modelSummary` = 同一句话的**喂模型版**(带完整批次 id,界面不显示) */
......@@ -370,7 +371,14 @@ export function AssignmentConfirmSheet({
const done = state === 'confirmed';
/// 助手撤的整批(state 由 tool_result 切过来);本地撤的走 revoked
const cancelled = state === 'cancelled';
const readOnly = done || cancelled || submitting;
/**
* ⭐ 已被新版顶掉 —— 主管让助手重排了一版,这张当场作废。
* 🔴 **必须显式标注 + 禁用确认**,⛔ 不许只是变灰、更不许消失:
* 两张都能点确认的卡摆在一起,点错就是分错一批人;
* 而直接消失又会让他以为自己看错了、或者系统吞了东西。
*/
const superseded = state === 'superseded';
const readOnly = done || cancelled || superseded || submitting;
/**
* 生效条目 = 提案条目 − 删掉的 + 拖拽改派 + 逐条时效。
......@@ -957,9 +965,25 @@ export function AssignmentConfirmSheet({
onDragOver={onDragOverAnywhere}
onDragEnd={stopAutoScroll}
onDrop={stopAutoScroll}
className="overflow-hidden border-brand-100 text-[12px] shadow-sm"
className={
superseded
? // ⚠️ 作废态**只压低存在感,不隐藏**:主色边框换成中性、内容压到 60% 不透明度。
// ⛔ 不用 `hidden`、⛔ 不折叠 —— 他要能看见自己出过几版。
'overflow-hidden border-slate-200 text-[12px] opacity-60 shadow-none'
: 'overflow-hidden border-brand-100 text-[12px] shadow-sm'
}
>
{/*
⭐ 作废横幅 —— 钉在**卡片顶部**。
🔴 光靠底部那句不够:确认单可以很长,主管的视线停在卡片上半部分时
根本看不到底下写着"已作废",而顶部这条第一眼就在。
*/}
{superseded && (
<div className="border-b border-amber-200 bg-amber-50 px-3 py-1.5 text-[11px] font-medium text-amber-800">
这一版已作废,不能确认 —— 当前有效的是下面最新的那一张
</div>
)}
{/*
① 汇总
🔴 **必须 `flex-wrap`**(2026-08-11 产品指出):窗口窄的时候这一行装不下
「共 N 人 · 拟分 X · 待分配 Y」+ 角标 + 时效下拉,不换行就只能挤 ——
......@@ -1431,7 +1455,15 @@ export function AssignmentConfirmSheet({
{/* ④ 确认 */}
<div className="border-t px-3 py-2">
{error && <p className="mb-1.5 text-[11px] text-rose-600">{error}</p>}
{cancelled && !revoked ? (
{superseded ? (
/**
* ⭐ 已被新版顶掉。**说清三件事**:作废了、为什么、去哪找当前那张。
* ⛔ 不要只写"已失效" —— 主管会以为是系统出错,而不是他自己刚才要求重排的结果。
*/
<span className="text-[11.5px] font-medium text-amber-700">
这一版已作废 —— 你让我重排了一版,请看下面最新的那张
</span>
) : cancelled && !revoked ? (
// 助手撤的:本地没有三个数,只说状态 —— 具体数字助手在消息里已经原话转述过
<span className="text-[11.5px] font-medium text-slate-500">这批已撤销</span>
) : done ? (
......
import { describe, expect, it } from 'vitest';
import type { AssignmentProposal } from '@pac/types';
import {
applyIncomingSheet,
findActiveSheet,
type Block,
type ChatMessage,
type DraftState,
} from './chat-blocks';
/**
* 草稿状态机的回归 —— 这段逻辑的失败形态**全都是静默的**:
* 改错了卡片主管看不到反应、两张 active 同时能点确认就是分错一批人。
* 端到端点一遍代价高且不稳定,所以抽成纯函数在这里锁住。
*/
const sheet = (n: number) => ({ placed: n } as unknown as AssignmentProposal);
function msg(id: string, ...sheets: Array<[string, DraftState]>): ChatMessage {
return {
id,
role: 'assistant',
blocks: sheets.map(([requestId, state]): Block => ({
kind: 'assignment_sheet',
requestId,
sheet: sheet(1),
state,
})),
};
}
/** 摊平成 [requestId, state] 便于断言 */
function states(ms: ChatMessage[]): Array<[string, DraftState]> {
return ms.flatMap((m) =>
m.blocks
.filter((b): b is Extract<Block, { kind: 'assignment_sheet' }> => b.kind === 'assignment_sheet')
.map((b) => [b.requestId, b.state] as [string, DraftState]),
);
}
describe('applyIncomingSheet', () => {
it('第一张草稿挂到宿主消息下,状态是 active', () => {
const out = applyIncomingSheet([msg('m1')], {
requestId: 'r1',
sheet: sheet(10),
hostMessageId: 'm1',
});
expect(states(out)).toEqual([['r1', 'active']]);
});
it('🔴 新草稿到达时,把**上一轮消息里**的 active 作废(跨消息扫描)', () => {
const prev = [msg('m1', ['r1', 'active']), msg('m2')];
const out = applyIncomingSheet(prev, {
requestId: 'r2',
sheet: sheet(20),
hostMessageId: 'm2',
});
expect(states(out)).toEqual([
['r1', 'superseded'],
['r2', 'active'],
]);
});
it('🔴 终态不许被回退:confirmed / cancelled 不受作废扫描影响', () => {
const prev = [msg('m1', ['r1', 'confirmed'], ['r2', 'cancelled']), msg('m2')];
const out = applyIncomingSheet(prev, {
requestId: 'r3',
sheet: sheet(30),
hostMessageId: 'm2',
});
expect(states(out)).toEqual([
['r1', 'confirmed'],
['r2', 'cancelled'],
['r3', 'active'],
]);
});
it('同一 requestId 重推:原地换内容、回到 active、⛔ 不堆第二张卡', () => {
const withEdits: ChatMessage = {
id: 'm1',
role: 'assistant',
blocks: [
{
kind: 'assignment_sheet',
requestId: 'r1',
sheet: sheet(1),
state: 'active',
edits: [{ seq: 1, ops: [] }],
},
],
};
const out = applyIncomingSheet([withEdits], {
requestId: 'r1',
sheet: sheet(99),
hostMessageId: 'm1',
});
expect(states(out)).toEqual([['r1', 'active']]);
const b = out[0]!.blocks[0] as Extract<Block, { kind: 'assignment_sheet' }>;
expect(b.sheet).toEqual(sheet(99));
// ⚠️ 旧编辑指令必须清空 —— 对新名单没有意义
expect(b.edits).toEqual([]);
});
it('任一时刻最多一张 active(连出三版)', () => {
let ms: ChatMessage[] = [msg('m1')];
for (const r of ['r1', 'r2', 'r3']) {
ms = applyIncomingSheet(ms, { requestId: r, sheet: sheet(1), hostMessageId: 'm1' });
}
expect(states(ms).filter(([, s]) => s === 'active')).toHaveLength(1);
expect(states(ms)).toEqual([
['r1', 'superseded'],
['r2', 'superseded'],
['r3', 'active'],
]);
});
});
describe('findActiveSheet', () => {
it('🔴 找的是 active,不是「最后一张」—— 这正是 2026-08-12 改掉的坑', () => {
// 顺序上 r2 在后,但它已作废;助手的修改必须落到 r1 上
const ms = [msg('m1', ['r1', 'active']), msg('m2', ['r2', 'superseded'])];
const at = findActiveSheet(ms);
expect(at).toEqual({ messageIndex: 0, blockIndex: 0 });
});
it('一张 active 都没有时返回 null(⛔ 不许退化成改最后一张)', () => {
const ms = [msg('m1', ['r1', 'confirmed']), msg('m2', ['r2', 'superseded'])];
expect(findActiveSheet(ms)).toBeNull();
});
it('没有确认单时返回 null', () => {
expect(findActiveSheet([msg('m1')])).toBeNull();
});
});
import type { AssignmentProposal, SheetEditOp } from '@pac/types';
/**
* chat-blocks —— 助手消息流的**数据模型 + 纯函数操作**。
*
* 从 `use-assistant-chat` 抽出来的原因:草稿状态机(active / superseded / …)
* 是这条链路上最容易出错、也最值得回归的一段逻辑,而它原来内联在 React hook 里,
* 只能靠端到端点一遍来验。抽成纯函数之后可以直接测。
*
* ⛔ 这里只放**不碰 React、不碰网络**的东西。带副作用的仍留在 hook 里。
*/
/** 一步工具调用(Claude 式透明步骤:看到调了哪个工具、入参、返回数据)。 */
export interface ToolStep {
id: string;
/** 后端 toolCallId,用于把 tool_result/tool_error 精确匹配回对应步骤(支持并行/多调用)。 */
callId?: string;
tool: string;
args: unknown;
result?: unknown;
status: 'running' | 'done' | 'error';
error?: string;
}
/** 模型产出的 HTML 卡片(在输出区沙箱 iframe 渲染)。 */
export interface Artifact {
id: string;
title?: string;
html: string;
/** true = 还在流式生成中(只注入内容,图表脚本不执行);false/缺省 = 最终(执行图表脚本)。 */
streaming?: boolean;
}
/**
* 草稿状态机 —— **任一时刻最多只有一张 `active`**。
*
* ⭐ `superseded` = 主管让助手重出了一版,这张当场作废。
* 🔴 作废发生在**新版生成时**,⛔ 不是在确认时(产品定,2026-08-12):
* 否则主管面前会同时摆着两张都能点确认的卡,点错就是分错一批人。
* ⚠️ 作废的卡**继续留在对话里并显式标注**,⛔ 不许静默变灰、⛔ 不许消失 ——
* 他要能看见自己出过几版、以及哪一版才是当前的。
* ⚠️ `confirmed` / `cancelled` 是**终态**,⛔ 不许被后续的作废扫描回退。
*/
export type DraftState = 'active' | 'superseded' | 'confirmed' | 'cancelled';
export type Block =
/**
* 文本块。`modelText` = **只给模型看**的版本(缺省时模型看 `text`)。
*
* 🔴 为什么需要两份:确认后注入的那句话里要带**完整批次 uuid**,否则模型撤销时
* 只能拿界面上的 8 位短号去调 `revoke_assignment` —— 实测必然报「批次不存在」。
* 但 uuid 对主管毫无意义,摆在对话里纯属噪音。⇒ 显示一份、喂模型一份。
* ⚠️ 用它承载的必须是**同一件事的两种措辞**,⛔ 不许塞模型专属的隐藏指令:
* 主管看不见的话就无法纠正,那是"界面替主管说话"。
*/
| { kind: 'text'; text: string; modelText?: string }
| { kind: 'tool'; step: ToolStep }
| { kind: 'artifact'; artifact: Artifact }
/**
* 全景确认单 —— **原生 React 组件**,不是 artifact。
*
* ⚠️ 为什么不能用 artifact:artifact 跑在 `sandbox="allow-scripts"` 的 iframe 里,
* CSP 是 `connect-src 'none'` —— **卡片内不可能发出写请求**。确认单要能点确认落库,
* 只能是原生组件。
* 分岔记牢:**只读展示用 artifact,可交互用原生组件。**
*
* ⚠️ sheet 由**服务端经侧信道推来**,不是模型生成的 —— 它含几百个 planId,
* 让模型吐等于让它编。
*/
| {
kind: 'assignment_sheet';
requestId: string;
sheet: AssignmentProposal;
state: DraftState;
/** 确认后的批次 id(终态展示用) */
assignmentId?: string;
/**
* ⭐ 助手下发的**语义编辑指令队列**(edit_assignment_sheet 走侧信道推来)。
* 卡片组件按 `seq` 只执行新增的那一批,执行完把结果回一句进对话。
* ⚠️ 用队列而不是"最新一条":主管连着说两句时,两批指令都要生效。
*/
edits?: { seq: number; ops: SheetEditOp[] }[];
};
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
blocks: Block[];
}
export type ChatStatus = 'idle' | 'streaming' | 'error';
// ─────────────────────────────────────────────────────────
// 纯函数操作
// ─────────────────────────────────────────────────────────
/** 把 tool_result/tool_error 匹配回对应工具步骤:优先按 callId,退化到"最后一个 running"。 */
export function findToolIdx(blocks: Block[], callId: string | undefined): number {
if (callId) {
const i = blocks.findIndex((b) => b.kind === 'tool' && b.step.callId === callId);
if (i !== -1) return i;
}
for (let i = blocks.length - 1; i >= 0; i--) {
const b = blocks[i];
if (b && b.kind === 'tool' && b.step.status === 'running') return i;
}
return -1;
}
/** 按 callId upsert artifact 块:流式 html 增量更新,最终 tool_call 覆盖(title+完整 html)。 */
export function upsertArtifact(
blocks: Block[],
id: string,
patch: { title?: string; html?: string; streaming?: boolean },
): Block[] {
const i = blocks.findIndex((b) => b.kind === 'artifact' && b.artifact.id === id);
if (i === -1) {
return [
...blocks,
{
kind: 'artifact',
artifact: { id, title: patch.title, html: patch.html ?? '', streaming: patch.streaming },
},
];
}
return blocks.map((b, j) => {
if (j !== i || b.kind !== 'artifact') return b;
return {
kind: 'artifact',
artifact: {
id,
title: patch.title ?? b.artifact.title,
html: patch.html ?? b.artifact.html,
streaming: patch.streaming ?? b.artifact.streaming,
},
};
});
}
/**
* 新草稿到达 —— upsert 它,并把**此前所有 `active` 的当场作废**。
*
* 🔴 必须**跨消息**扫:上一版几乎总在**上一轮**的消息里
* (主管看着它才说「重排一版」的)。只扫当前消息永远扫不到,而且不会报错。
* ⚠️ 只作废 `active`:`confirmed` / `cancelled` 是终态,⛔ 不许回退。
* ⚠️ 同一 `requestId` 重推时清空 `edits` —— 旧的编辑指令对新名单没有意义。
*
* @param hostMessageId 新卡挂到哪条消息下(通常是当前这条助手消息)
*/
export function applyIncomingSheet(
messages: ChatMessage[],
input: { requestId: string; sheet: AssignmentProposal; hostMessageId: string },
): ChatMessage[] {
const { requestId, sheet, hostMessageId } = input;
let placed = false;
const swept = messages.map((m) => ({
...m,
blocks: m.blocks.map((b): Block => {
if (b.kind !== 'assignment_sheet') return b;
if (b.requestId === requestId) {
placed = true;
return { ...b, sheet, state: 'active', edits: [] };
}
return b.state === 'active' ? { ...b, state: 'superseded' } : b;
}),
}));
if (placed) return swept;
return swept.map((m) =>
m.id === hostMessageId
? { ...m, blocks: [...m.blocks, { kind: 'assignment_sheet', requestId, sheet, state: 'active' }] }
: m,
);
}
/**
* 找**当前有效**的那张确认单(唯一的 `active`)。
*
* ⭐ 2026-08-12 由「最后一张」改成「唯一 active」:重出一版之后,最后一张可能
* 正是刚作废的那张 —— 助手的修改指令打上去,主管一点反应都看不到。
* ⚠️ 模型手里没有 requestId(服务端铸的,没进它上下文),它说「这批」指的就是当前有效那张。
*/
export function findActiveSheet(
messages: ChatMessage[],
): { messageIndex: number; blockIndex: number } | null {
for (let mi = messages.length - 1; mi >= 0; mi--) {
const blocks = messages[mi]?.blocks ?? [];
const bi = blocks.findIndex((b) => b.kind === 'assignment_sheet' && b.state === 'active');
if (bi >= 0) return { messageIndex: mi, blockIndex: bi };
}
return null;
}
......@@ -6,65 +6,25 @@ import { env } from '@/lib/env';
import { emitPetEvent } from '@/lib/pet-events';
import { useAuthStore } from '@/stores/auth-store';
/** 一步工具调用(Claude 式透明步骤:看到调了哪个工具、入参、返回数据)。 */
export interface ToolStep {
id: string;
/** 后端 toolCallId,用于把 tool_result/tool_error 精确匹配回对应步骤(支持并行/多调用)。 */
callId?: string;
tool: string;
args: unknown;
result?: unknown;
status: 'running' | 'done' | 'error';
error?: string;
}
// ⭐ 数据模型 + 纯函数操作已抽到 chat-blocks(草稿状态机是最该有回归的一段逻辑,
// 内联在 hook 里只能端到端点一遍才能验)。这里 re-export 保持下游 import 不变。
import {
applyIncomingSheet,
findActiveSheet,
findToolIdx,
upsertArtifact,
type Block,
type ChatMessage,
type ChatStatus,
} from './chat-blocks';
/** 模型产出的 HTML 卡片(在输出区沙箱 iframe 渲染)。 */
export interface Artifact {
id: string;
title?: string;
html: string;
/** true = 还在流式生成中(只注入内容,图表脚本不执行);false/缺省 = 最终(执行图表脚本)。 */
streaming?: boolean;
}
export type { Artifact, Block, ChatMessage, ChatStatus, DraftState, ToolStep } from './chat-blocks';
export type Block =
/**
* 文本块。`modelText` = **只给模型看**的版本(缺省时模型看 `text`)。
*
* 🔴 为什么需要两份:确认后注入的那句话里要带**完整批次 uuid**,否则模型撤销时
* 只能拿界面上的 8 位短号去调 `revoke_assignment` —— 实测必然报「批次不存在」。
* 但 uuid 对主管毫无意义,摆在对话里纯属噪音。⇒ 显示一份、喂模型一份。
* ⚠️ 用它承载的必须是**同一件事的两种措辞**,⛔ 不许塞模型专属的隐藏指令:
* 主管看不见的话就无法纠正,那是"界面替主管说话"。
*/
| { kind: 'text'; text: string; modelText?: string }
| { kind: 'tool'; step: ToolStep }
| { kind: 'artifact'; artifact: Artifact }
/**
* 全景确认单 —— **原生 React 组件**,不是 artifact。
*
* ⚠️ 为什么不能用 artifact:artifact 跑在 `sandbox="allow-scripts"` 的 iframe 里,
* CSP 是 `connect-src 'none'` —— **卡片内不可能发出写请求**。确认单要能点确认落库,
* 只能是原生组件。
* 分岔记牢:**只读展示用 artifact,可交互用原生组件。**
*
* ⚠️ sheet 由**服务端经侧信道推来**,不是模型生成的 —— 它含几百个 planId,
* 让模型吐等于让它编。
*/
| {
kind: 'assignment_sheet';
requestId: string;
sheet: AssignmentProposal;
state: 'pending' | 'confirmed' | 'cancelled';
/** 确认后的批次 id(终态展示用) */
assignmentId?: string;
/**
* ⭐ 助手下发的**语义编辑指令队列**(edit_assignment_sheet 走侧信道推来)。
* 卡片组件按 `seq` 只执行新增的那一批,执行完把结果回一句进对话。
* ⚠️ 用队列而不是"最新一条":主管连着说两句时,两批指令都要生效。
*/
edits?: { seq: number; ops: SheetEditOp[] }[];
};
let _id = 0;
const nextId = () => `m${Date.now()}_${_id++}`;
/** render_artifact 是本地"渲染"工具,不当普通工具步骤展示,而是渲成卡片。 */
const ARTIFACT_TOOL = 'render_artifact';
/**
* 从 revoke_assignment 的返回里抠出 assignmentId。
......@@ -74,10 +34,7 @@ export type Block =
*/
function extractRevokedId(result: unknown): string | null {
try {
const raw =
typeof result === 'string'
? result
: JSON.stringify(result ?? '');
const raw = typeof result === 'string' ? result : JSON.stringify(result ?? '');
const m = raw.match(/"assignmentId"\s*:\s*"([0-9a-fA-F-]{36})"/);
return m?.[1] ?? null;
} catch {
......@@ -85,59 +42,6 @@ function extractRevokedId(result: unknown): string | null {
}
}
/** render_artifact 是本地"渲染"工具,不当普通工具步骤展示,而是渲成卡片。 */
const ARTIFACT_TOOL = 'render_artifact';
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
blocks: Block[];
}
export type ChatStatus = 'idle' | 'streaming' | 'error';
let _id = 0;
const nextId = () => `m${Date.now()}_${_id++}`;
/** 把 tool_result/tool_error 匹配回对应工具步骤:优先按 callId,退化到"最后一个 running"。 */
function findToolIdx(blocks: Block[], callId: string | undefined): number {
if (callId) {
const i = blocks.findIndex((b) => b.kind === 'tool' && b.step.callId === callId);
if (i !== -1) return i;
}
for (let i = blocks.length - 1; i >= 0; i--) {
const b = blocks[i];
if (b && b.kind === 'tool' && b.step.status === 'running') return i;
}
return -1;
}
/** 按 callId upsert artifact 块:流式 html 增量更新,最终 tool_call 覆盖(title+完整 html)。 */
function upsertArtifact(
blocks: Block[],
id: string,
patch: { title?: string; html?: string; streaming?: boolean },
): Block[] {
const i = blocks.findIndex((b) => b.kind === 'artifact' && b.artifact.id === id);
if (i === -1) {
return [
...blocks,
{ kind: 'artifact', artifact: { id, title: patch.title, html: patch.html ?? '', streaming: patch.streaming } },
];
}
return blocks.map((b, j) => {
if (j !== i || b.kind !== 'artifact') return b;
return {
kind: 'artifact',
artifact: {
id,
title: patch.title ?? b.artifact.title,
html: patch.html ?? b.artifact.html,
streaming: patch.streaming ?? b.artifact.streaming,
},
};
});
}
/** 把一条消息压平成后端要的 {role, content} 文本(工具块不回传,模型自行重新决策)。 */
function toApiMessage(m: ChatMessage): { role: 'user' | 'assistant'; content: string } | null {
......@@ -223,20 +127,15 @@ export function useAssistantChat() {
appendText((evt.text as string) ?? '');
break;
case 'assignment_sheet':
// ⭐ 侧信道推来的确认单。upsert 按 requestId —— 模型若因某种原因重复调用,
// 不该在对话里堆出两张卡片。
patch((blocks) => {
const rid = String(evt.requestId ?? '');
const i = blocks.findIndex((b) => b.kind === 'assignment_sheet' && b.requestId === rid);
const next: Block = {
kind: 'assignment_sheet',
requestId: rid,
// ⭐ 侧信道推来的确认单:upsert 它 + 把此前所有 active 的作废。
// 逻辑在 chat-blocks.applyIncomingSheet(纯函数,有回归测试)。
setMessages((prev) =>
applyIncomingSheet(prev, {
requestId: String(evt.requestId ?? ''),
sheet: evt.sheet as AssignmentProposal,
state: 'pending',
};
if (i >= 0) return [...blocks.slice(0, i), next, ...blocks.slice(i + 1)];
return [...blocks, next];
});
hostMessageId: assistantId,
}),
);
break;
case 'assignment_sheet_edit':
// ⭐ 助手要改**已经呈现**的那张卡:指令挂到最后一张确认单上,由它自己去匹配执行。
......@@ -244,12 +143,13 @@ export function useAssistantChat() {
// 🔴 必须**跨消息**找,⛔ 不能用 patch(那只改当前这条助手消息)——
// 确认单几乎总是在**上一轮**的消息里(主管看着它才提的要求),
// 只在当前消息里找永远找不到,而且不会报错、界面毫无反应。
// ⚠️ 挂"最后一张"而不是按 requestId 找:主管说"把某某移出这批"指的就是眼前这张,
// 而模型手里根本没有 requestId(服务端铸的,没进它上下文)。
// ⭐ 找的是**唯一那张 `active`**,⛔ 不再是"最后一张"(2026-08-12)——
// 重出一版之后,最后一张可能正是刚作废的那张,改上去主管一点反应都看不到。
// 模型手里没有 requestId(服务端铸的,没进它上下文),它说"这批"指的就是当前有效那张。
setMessages((prev) => {
for (let mi = prev.length - 1; mi >= 0; mi--) {
const bi = prev[mi]!.blocks.map((b) => b.kind).lastIndexOf('assignment_sheet');
if (bi < 0) continue;
const at = findActiveSheet(prev);
if (!at) return prev;
const { messageIndex: mi, blockIndex: bi } = at;
const b = prev[mi]!.blocks[bi] as Extract<Block, { kind: 'assignment_sheet' }>;
const next: Block = {
...b,
......@@ -261,8 +161,6 @@ export function useAssistantChat() {
return prev.map((m, k) =>
k === mi ? { ...m, blocks: m.blocks.map((x, j) => (j === bi ? next : x)) } : m,
);
}
return prev;
});
break;
case 'artifact_html':
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment