前端:失败说明按回合身份归位,不再落进上一轮、气泡也不再自成假回合
- 回合归属改成按身份(开口用户条目的 canonical itemId):失败说明条目带 turnUserItemId(reducer 写,缺身份时保持原顺序语义),buildDirectChatTurns 按身份分组,同一身份的条目永远同一轮 - 本轮开口条目还没到(回合在宿主下发条目之前就失败、或历史切片还没读回)时,本地乐观气泡按身份挂回自己那一轮,不再另开一轮:界面不再出现「错误显示在用户消息上面」+「气泡底下 0.0 秒」+「上一轮借走本轮终点(15.6 秒)」这一组现象 - 收口早退不再吞掉还没写进界面的失败说明(订阅重建后的 bootstrap 只回放生命周期锚点):只补说明、终点时间与「回合完成」计数,不重开回合、不动本轮起点 / 终点 / 身份 - 用例:directTurnPresentation 复现现场(两个回合、说明与气泡同段、耗时不再借上一轮的终点);directThreadChat 补身份字段与早退不吞说明两条
This commit is contained in:
+46
-11
@@ -50,6 +50,18 @@ export type DirectChatEntry = {
|
||||
* 缺失时不写,不能拿最后一条工具 / 正文的时间顶替。
|
||||
*/
|
||||
turnEndedAt?: number;
|
||||
/**
|
||||
* 这条条目**属于哪一轮**(本轮开口用户条目的 canonical 身份)。
|
||||
*
|
||||
* 只有失败说明带它:它不是原生条目,而是宿主终态载荷派生出来的说明(见
|
||||
* `directTurnFailureItemId`)。一旦本轮的开口用户条目晚到或压根没到(回合在宿主下发用户条目
|
||||
* 之前就失败、历史切片还没读回),说明条目在数组里的位置就会落在**上一轮**里,界面会渲染成
|
||||
* "错误显示在用户消息上面",上一轮还会把它那一轮的耗时显示成本轮的。
|
||||
*
|
||||
* 有了身份,投影层就能按身份归位(`buildDirectChatTurns` 的回合判据),不再靠位置猜。
|
||||
* 缺失表示归属不可证明(旧事件 / 旧历史切片),那时保持原有的顺序语义。
|
||||
*/
|
||||
turnUserItemId?: string;
|
||||
};
|
||||
|
||||
export type DirectThreadChatState = {
|
||||
@@ -223,6 +235,7 @@ export function mergeDirectChatEntry(
|
||||
// 展示元数据先到先用:后到的重放 / 历史切片不得覆盖已经确定的边界。
|
||||
turnStartedAt: existing.turnStartedAt || incoming.turnStartedAt,
|
||||
turnEndedAt: existing.turnEndedAt || incoming.turnEndedAt,
|
||||
turnUserItemId: existing.turnUserItemId || incoming.turnUserItemId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -337,10 +350,6 @@ export function reduceDirectThreadEvent(
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
// 已经收口、而且没有新的运行态条目:重复 / 迟到的终态事件不改动时间,也不复活运行态。
|
||||
if (!state.turnRunning && state.live.length === 0) {
|
||||
return state;
|
||||
}
|
||||
// 失败终态带 `failure` 载荷:先把它落成本轮最后一条说明条目,再和正常终态走同一个收口
|
||||
// 函数。载荷在、原因非空才算一条说明;空原因不补一条空气泡(终态照样收口)。
|
||||
const failure = event.failure;
|
||||
@@ -351,14 +360,40 @@ export function reduceDirectThreadEvent(
|
||||
failure && typeof failure.message === 'string'
|
||||
? failure.message.trim()
|
||||
: '';
|
||||
const noticeItemId = directTurnFailureItemId(eventUserItemId, eventAt);
|
||||
const noticeOf = (): DirectChatEntry => ({
|
||||
itemId: noticeItemId,
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
text: directTurnFailureNoticeText(failureText),
|
||||
at: eventAt,
|
||||
// 归属带上身份:本轮的开口用户条目可能还没到过界面(回合在宿主下发用户条目之前就失败、
|
||||
// 或历史切片还没读回),那时只有身份能把这条说明归回自己那一轮,而不是按位置留给上一轮。
|
||||
...(eventUserItemId ? { turnUserItemId: eventUserItemId } : {}),
|
||||
});
|
||||
// 已经收口、而且没有新的运行态条目:重复 / 迟到的终态事件不改动时间,也不复活运行态。
|
||||
//
|
||||
// 唯一例外:这条终态带着**还没写进界面的失败说明**。订阅重建后的 bootstrap 只回放一条
|
||||
// 生命周期锚点(`direct_thread_manager.rs` 的 `lifecycle_anchor`),那一条可能正是某个已经
|
||||
// 收口的回合的失败——照原样早退就会把这一轮唯一的解释静默吞掉。这里只补说明与它的终点时间
|
||||
// 和"回合完成"这个计数(忙态放行靠它),不重开回合、不动本轮的起点 / 终点 / 身份。
|
||||
if (!state.turnRunning && state.live.length === 0) {
|
||||
const alreadyRecorded = state.history.some(
|
||||
(entry) => entry.itemId === noticeItemId,
|
||||
);
|
||||
if (!failureText || alreadyRecorded) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
completedTurnCount: state.completedTurnCount + 1,
|
||||
history: mergeHistoryEntries(state.history, [
|
||||
withTurnBoundary(noticeOf(), { turnEndedAt: eventAt }),
|
||||
]),
|
||||
};
|
||||
}
|
||||
const withNotice = failureText
|
||||
? upsertLiveEntry(state, {
|
||||
itemId: directTurnFailureItemId(eventUserItemId, eventAt),
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
text: directTurnFailureNoticeText(failureText),
|
||||
at: eventAt,
|
||||
})
|
||||
? upsertLiveEntry(state, noticeOf())
|
||||
: state;
|
||||
return finishDirectThreadTurn(withNotice, eventAt);
|
||||
}
|
||||
|
||||
+53
-9
@@ -238,14 +238,31 @@ function newTurn(key: string): DirectChatTurnEntries {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 条目自带的**回合身份**:用户条目就是自己的身份;失败说明带的是它所属回合的开口条目身份。
|
||||
*
|
||||
* 只有这两种条目能开 / 认领一个回合:其余条目(工具、思考、正文)没有身份,只能跟着当前回合走。
|
||||
* 返回空串 = 归属不可证明,按原有顺序语义处理。
|
||||
*/
|
||||
function directEntryTurnKey(entry: DirectChatEntry): string {
|
||||
if (entry.kind === 'message' && entry.role === 'user') return entry.itemId;
|
||||
return entry.turnUserItemId ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 条目 + 运行期本地消息 → 回合列表。
|
||||
*
|
||||
* 每个用户条目开一个新回合;本地用户气泡(乐观发送)也算开新回合;本地 assistant 消息
|
||||
* (终止说明、壳层 `announce`)挂到当前回合末尾。同身份的本地消息不重复渲染:条目赢。
|
||||
* **回合身份是开口用户条目的 canonical `itemId`**(`direct-codex:{clientTurnId}:user`),不是
|
||||
* 数组位置:用户条目、本地乐观气泡、以及这一轮的失败说明都按同一个身份归进同一轮。三种来源谁先到
|
||||
* 都行——回合在宿主下发用户条目之前就失败时,只有失败说明会到,那时也必须靠身份归位,否则说明会
|
||||
* 按位置落进上一轮(界面表现:错误显示在用户消息上面、上一轮顶替本轮显示耗时),用户气泡再自成
|
||||
* 一轮(多出一个 0.0 秒的假回合)。
|
||||
*
|
||||
* 失败说明不在这条本地通道里:它是宿主 `turn.completed.failure` 载荷落成的普通条目,来源与
|
||||
* 顺序都归 reducer。
|
||||
* 其余条目(工具、思考、正文)不带身份,跟着当前回合走;本地 assistant 消息(终止说明、壳层
|
||||
* `announce`)挂到当前回合末尾。同身份的本地消息不重复渲染:条目赢。
|
||||
*
|
||||
* 失败说明不在这条本地通道里:它是宿主 `turn.completed.failure` 载荷落成的普通条目,来源与顺序
|
||||
* 都归 reducer(身份字段 `turnUserItemId` 也由 reducer 写)。
|
||||
*/
|
||||
export function buildDirectChatTurns({
|
||||
entries,
|
||||
@@ -272,19 +289,36 @@ export function buildDirectChatTurns({
|
||||
pendingUserItemId?: string;
|
||||
}): DirectChatTurn[] {
|
||||
const turns: DirectChatTurnEntries[] = [];
|
||||
const turnsByIdentity = new Map<string, DirectChatTurnEntries>();
|
||||
let current: DirectChatTurnEntries | null = null;
|
||||
const localSentAt = localSentTimes(localMessages);
|
||||
// 分页切片的开头可能落在半截回合里(那一条用户条目还在更早的一屏):这些前导条目先攒着,
|
||||
// 交给后面第一个用户条目开的回合,避免渲染出一个没有用户气泡的孤儿回合。
|
||||
const leadingEntries: DirectChatEntry[] = [];
|
||||
const openTurn = (key: string) => {
|
||||
const turn = newTurn(key);
|
||||
turns.push(turn);
|
||||
turnsByIdentity.set(key, turn);
|
||||
return turn;
|
||||
};
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === 'message' && entry.role === 'user') {
|
||||
current = newTurn(entry.itemId);
|
||||
turns.push(current);
|
||||
const identity = directEntryTurnKey(entry);
|
||||
if (identity) {
|
||||
// 同一身份的条目永远属于同一轮。用户条目与这一轮的失败说明可能分头到达(订阅重放、历史
|
||||
// 切片晚到、或本轮压根没有下发用户条目),靠位置分组会把说明留给上一轮 —— 界面就成了
|
||||
// "错误显示在用户消息上面",上一轮还会顶替本轮显示耗时。
|
||||
const existing = turnsByIdentity.get(identity);
|
||||
if (existing) {
|
||||
existing.entries.push(entry);
|
||||
continue;
|
||||
}
|
||||
current = openTurn(identity);
|
||||
if (leadingEntries.length > 0) {
|
||||
current.entries.push(...leadingEntries);
|
||||
leadingEntries.length = 0;
|
||||
}
|
||||
current.entries.push(entry);
|
||||
continue;
|
||||
}
|
||||
if (!current) {
|
||||
leadingEntries.push(entry);
|
||||
@@ -304,8 +338,18 @@ export function buildDirectChatTurns({
|
||||
if (message.messageId && entryIds.has(message.messageId)) return;
|
||||
if (message.role === 'user') {
|
||||
const block = blockFromLocalMessage(message, index);
|
||||
current = newTurn(message.messageId ?? `local:${index}`);
|
||||
turns.push(current);
|
||||
// 身份已经开过回合(本轮的开口用户条目还没到,但它的失败说明到了):挂回自己那一轮。
|
||||
// 另开一轮就会多出一个"用户气泡 + 0.0 秒终态"的假回合,而这一轮的说明还留在上面那一段。
|
||||
const claimed = message.messageId
|
||||
? turnsByIdentity.get(message.messageId)
|
||||
: undefined;
|
||||
if (claimed) {
|
||||
if (block && claimed.localUsers.length === 0) {
|
||||
claimed.localUsers.push(block);
|
||||
}
|
||||
return;
|
||||
}
|
||||
current = openTurn(message.messageId ?? `local:${index}`);
|
||||
if (block) current.localUsers.push(block);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -442,6 +442,90 @@ describe('DirectProject 聊天 reducer', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('失败说明带上它所属回合的身份,开口用户条目没到时投影层也能归位', () => {
|
||||
const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||||
withUserItemId(
|
||||
event({ type: 'turn.started', at: 1_000 }),
|
||||
'direct-codex:turn-1:user',
|
||||
),
|
||||
withUserItemId(
|
||||
event({
|
||||
type: 'turn.completed',
|
||||
status: 'failed',
|
||||
failure: { kind: 'transport-failed', message: '连接失败' },
|
||||
at: 2_000,
|
||||
}),
|
||||
'direct-codex:turn-1:user',
|
||||
),
|
||||
]);
|
||||
// 身份是投影层"这条说明属于哪一轮"的唯一判据:本轮的开口用户条目可能还没到过界面
|
||||
// (回合在宿主下发用户条目之前就失败、或历史切片还没读回),那时只有它能把说明挂回自己的回合。
|
||||
expect(failed.history.at(-1)?.itemId).toBe(
|
||||
'direct-codex:turn-1:user:failure',
|
||||
);
|
||||
expect(failed.history.at(-1)?.turnUserItemId).toBe(
|
||||
'direct-codex:turn-1:user',
|
||||
);
|
||||
// 没有身份(旧事件)时不写这个字段,保持原有的顺序语义。
|
||||
const anonymous = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||||
event({ type: 'turn.started', at: 1_000 }),
|
||||
event({
|
||||
type: 'turn.completed',
|
||||
status: 'failed',
|
||||
failure: { kind: 'model-failed', message: '第一轮失败' },
|
||||
at: 2_000,
|
||||
}),
|
||||
]);
|
||||
expect(anonymous.history.at(-1)?.turnUserItemId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('收口早退不吞掉还没写进界面的失败说明(订阅重建只回放生命周期锚点)', () => {
|
||||
const finished = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||||
withUserItemId(
|
||||
event({ type: 'turn.started', at: 1_000 }),
|
||||
'direct-codex:turn-1:user',
|
||||
),
|
||||
withUserItemId(
|
||||
event({ type: 'turn.completed', status: 'completed', at: 2_000 }),
|
||||
'direct-codex:turn-1:user',
|
||||
),
|
||||
]);
|
||||
expect(finished.history).toHaveLength(0);
|
||||
|
||||
// 订阅重建后的 bootstrap 只回放最新一条生命周期事件:它就是某个已收口回合的失败,界面上
|
||||
// 没有任何东西能解释这一轮,必须补上这条说明(而不是按"重复终态"早退)。
|
||||
const replayed = reduceDirectThreadEvents(finished, [
|
||||
withUserItemId(
|
||||
event({
|
||||
type: 'turn.completed',
|
||||
status: 'failed',
|
||||
failure: { kind: 'transport-failed', message: '连接失败' },
|
||||
at: 3_000,
|
||||
}),
|
||||
'direct-codex:turn-1:user',
|
||||
),
|
||||
]);
|
||||
expect(replayed.history.map((entry) => entry.itemId)).toEqual([
|
||||
'direct-codex:turn-1:user:failure',
|
||||
]);
|
||||
expect(replayed.history[0]?.turnEndedAt).toBe(3_000);
|
||||
expect(replayed.completedTurnCount).toBe(finished.completedTurnCount + 1);
|
||||
// 重复回放同一条锚点:说明已经写进去了,计数不再涨,也不追加第二条。
|
||||
const replayAgain = reduceDirectThreadEvents(replayed, [
|
||||
withUserItemId(
|
||||
event({
|
||||
type: 'turn.completed',
|
||||
status: 'failed',
|
||||
failure: { kind: 'transport-failed', message: '连接失败' },
|
||||
at: 3_000,
|
||||
}),
|
||||
'direct-codex:turn-1:user',
|
||||
),
|
||||
]);
|
||||
expect(replayAgain.history).toHaveLength(1);
|
||||
expect(replayAgain.completedTurnCount).toBe(replayed.completedTurnCount);
|
||||
});
|
||||
|
||||
it('没有身份时用事件时间派生说明身份,两轮失败不会合并成一条', () => {
|
||||
const first = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||||
event({ type: 'turn.started', at: 1_000 }),
|
||||
|
||||
@@ -285,6 +285,54 @@ describe('DirectProject 聊天分区', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('本轮开口条目没到时,失败说明按身份挂回自己那一轮,气泡不再自成假回合', () => {
|
||||
// 现场:回合在宿主下发开口用户条目之前就失败,于是界面上既没有正式用户条目、也没有历史
|
||||
// 切片,只有这一轮的失败说明和本地乐观气泡(这条消息的开口条目还没到)。
|
||||
const turns = buildDirectChatTurns({
|
||||
entries: [
|
||||
userEntry('direct-codex:turn-1:user'),
|
||||
{
|
||||
...assistantEntry(
|
||||
'direct-codex:turn-1:user:failure',
|
||||
'陶泥儿智能创作 连接失败,请重试',
|
||||
),
|
||||
turnUserItemId: 'direct-codex:turn-1:user',
|
||||
turnStartedAt: 1_800_000_010_000,
|
||||
turnEndedAt: 1_800_000_010_400,
|
||||
},
|
||||
{
|
||||
...assistantEntry(
|
||||
'direct-codex:turn-2:user:failure',
|
||||
'陶泥儿智能创作 连接失败,请重试',
|
||||
),
|
||||
turnUserItemId: 'direct-codex:turn-2:user',
|
||||
turnStartedAt: 1_800_000_020_000,
|
||||
turnEndedAt: 1_800_000_035_600,
|
||||
},
|
||||
],
|
||||
localMessages: [localUser('hello', 'direct-codex:turn-2:user')],
|
||||
turnRunning: false,
|
||||
});
|
||||
|
||||
// 两个回合:第二个回合的用户气泡与它自己的失败说明同段 —— 说明不再落进上一轮(否则界面
|
||||
// 表现就是"错误显示在用户消息上面"),气泡也不再自成"0.0 秒"的假回合。
|
||||
expect(turns.map((turn) => turn.key)).toEqual([
|
||||
'direct-codex:turn-1:user',
|
||||
'direct-codex:turn-2:user',
|
||||
]);
|
||||
expect(turns[0]?.finals.map((block) => block.key)).toEqual([
|
||||
'direct-codex:turn-1:user:direct-codex:turn-1:user:failure',
|
||||
]);
|
||||
expect(turns[1]?.users.map((block) => block.text)).toEqual(['hello']);
|
||||
expect(turns[1]?.finals.map((block) => block.key)).toEqual([
|
||||
'direct-codex:turn-2:user:direct-codex:turn-2:user:failure',
|
||||
]);
|
||||
// 耗时按用户气泡自己的发送时间起算,不再把上一轮的终点借过来。
|
||||
expect(turns[0]?.endedAt).toBe(1_800_000_010_400);
|
||||
expect(turns[1]?.startedAt).toBe(1_800_000_002_000);
|
||||
expect(turns[1]?.endedAt).toBe(1_800_000_035_600);
|
||||
});
|
||||
|
||||
it('乐观用户气泡自成回合,已落盘的同一身份不重复渲染', () => {
|
||||
const turns = buildDirectChatTurns({
|
||||
entries: [userEntry('u1'), assistantEntry('a1', '答复')],
|
||||
|
||||
Reference in New Issue
Block a user