Files
Genarrative/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts
T
k88936 30c377d0f0
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 6m21s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m51s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m3s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 5m54s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m28s
Project CI / AI game creator shell Rust crates (push) Successful in 1m53s
Project CI / Repository checks (push) Successful in 5m33s
Project CI / Frontend tests (push) Successful in 7m20s
Project CI / Native shell tests (push) Successful in 8m29s
Project CI / Backend tests (push) Successful in 8m57s
Project CI / AI game creator shell web tests (push) Successful in 3m16s
保留唯一的thread manager作为direct project的状态来源 (#384)
说明: 在把工作交给段哥前还没有实现direct project聊天页面的迁移, 导致在 #375 里用很复杂的实现又做了一套事件流, 实测还有会话丢失的bug, 我在这里把数据获取的部分迁移到 #367 上

---------

Co-authored-by: 孔令弘 <ink29535@proton.me>
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/384
Reviewed-by: 孔令弘 <ink29535@proton.me>
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-09-18 02:13:50 +08:00

273 lines
9.0 KiB
TypeScript

/**
* DirectProject 聊天 reducer:把运行态事件与历史切片归并成同一份聊天条目。
*
* 事实源只有一个——项目对话历史;运行态事件只负责"当前回合"。顺序 = 历史文件顺序 +
* 运行态独有条目。这里不做可见性判断(那是投影的事),也不认任何回合身份:DirectProject
* 同一时刻只有一个回合在跑,`turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔。
*/
import type { GameCreatorDirectToolCall } from '../../app/types';
import type {
DirectThreadConsumeResult,
DirectThreadEvent,
DirectThreadHistorySlice,
DirectThreadItem,
DirectThreadSubscriptionBootstrap,
} from './directThreadEvents';
import { projectDirectThreadItem } from './directThreadItemProjection';
export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool';
/** 聊天卡片里的工具形状:持久化卡片去掉回合身份(Rust 侧已经不下发 turn id)。 */
export type DirectChatToolCard = Omit<GameCreatorDirectToolCall, 'turnId'>;
/** 聊天视图里的一条条目;运行态事件与历史切片共用的唯一形状。 */
export type DirectChatEntry = {
itemId: string;
kind: DirectChatEntryKind;
role?: 'user' | 'assistant' | null;
text?: string | null;
toolCall?: DirectChatToolCard | null;
at?: number;
};
export type DirectThreadChatState = {
/** 最新回合是否还在跑;只由生命周期事件的先后决定。 */
turnRunning: boolean;
/** 历史切片条目,保持文件顺序。 */
history: DirectChatEntry[];
/** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */
live: DirectChatEntry[];
};
export function emptyDirectThreadChatState(): DirectThreadChatState {
return {
turnRunning: false,
history: [],
live: [],
};
}
function longerText(
left: string | null | undefined,
right: string | null | undefined,
): string | null {
const a = typeof left === 'string' ? left : '';
const b = typeof right === 'string' ? right : '';
// 正文只增不减:增量往同一段落追加,完成快照可能比累计更长(漏过几条 delta)。
return b.length > a.length ? b : a;
}
function mergeToolStatus(
left: DirectChatToolCard['status'] | null | undefined,
right: DirectChatToolCard['status'] | null | undefined,
): DirectChatToolCard['status'] {
// 只有终态才算数:先到的 `running` 允许被后到的完成 / 失败覆盖,反过来不行。
if (left === 'running' || !left) return right ?? left ?? 'running';
return left;
}
function mergeToolCard(
left: DirectChatToolCard | null,
right: DirectChatToolCard | null,
): DirectChatToolCard | null {
if (!left) return right;
if (!right) return left;
return {
...left,
kind: left.kind && left.kind !== 'other' ? left.kind : right.kind,
title: left.title?.trim() ? left.title : right.title,
summary: left.summary?.trim() ? left.summary : right.summary,
status: mergeToolStatus(left.status, right.status),
detail: {
command: left.detail.command ?? right.detail.command,
output: left.detail.output ?? right.detail.output,
changes: left.detail.changes?.length
? left.detail.changes
: right.detail.changes,
},
startedAt: left.startedAt > 0 ? left.startedAt : right.startedAt,
updatedAt: Math.max(left.updatedAt, right.updatedAt),
};
}
/**
* 先到的快照赢,后到的只补空字段。
*
* 三个例外只有"后到信息一定更全"时才成立:正文取更长的一份、工具状态允许从 `running`
* 升级到终态、`updatedAt` 取较新的时间。其余字段一律先到先用,后到的空值不得抹掉它。
*/
export function mergeDirectChatEntry(
existing: DirectChatEntry,
incoming: DirectChatEntry,
): DirectChatEntry {
return {
itemId: existing.itemId || incoming.itemId,
kind:
existing.kind === 'tool' || incoming.kind === 'tool'
? 'tool'
: existing.kind,
role: existing.role ?? incoming.role ?? null,
text: longerText(existing.text, incoming.text),
toolCall: mergeToolCard(
existing.toolCall ?? null,
incoming.toolCall ?? null,
),
at: existing.at || incoming.at,
};
}
function upsertLiveEntry(
state: DirectThreadChatState,
entry: DirectChatEntry,
): DirectThreadChatState {
const index = state.live.findIndex(
(existing) => existing.itemId === entry.itemId,
);
if (index < 0) {
return { ...state, live: [...state.live, entry] };
}
const existing = state.live[index];
if (!existing) {
return { ...state, live: [...state.live, entry] };
}
const live = [...state.live];
live[index] = mergeDirectChatEntry(existing, entry);
return { ...state, live };
}
function appendLiveText(
state: DirectThreadChatState,
event: Extract<DirectThreadEvent, { type: 'item.delta' }>,
): DirectThreadChatState {
const itemId = event.itemId.trim();
if (!itemId || !event.delta) return state;
const reasoning = event.kind === 'reasoning';
const existing = state.live.find((entry) => entry.itemId === itemId);
return upsertLiveEntry(state, {
itemId,
kind: reasoning ? 'reasoning' : 'message',
role: reasoning ? null : 'assistant',
text: `${existing?.text ?? ''}${event.delta}`,
});
}
export function reduceDirectThreadEvent(
state: DirectThreadChatState,
event: DirectThreadEvent,
): DirectThreadChatState {
switch (event.type) {
case 'turn.started':
return { ...state, turnRunning: true };
case 'turn.completed':
// 回合结束:条目已经落盘,运行态并入历史后清空,避免同一条目渲染两次。
return {
...state,
turnRunning: false,
history: mergeHistoryEntries(state.history, state.live),
live: [],
};
case 'item.delta':
return appendLiveText(state, event);
case 'item.started':
case 'item.completed': {
const entry = projectDirectThreadItem(event.item);
return entry ? upsertLiveEntry(state, entry) : state;
}
case 'request':
// 审批 / 提问只影响面板交互,不并入聊天条目。
return state;
default:
return state;
}
}
export function reduceDirectThreadEvents(
state: DirectThreadChatState,
events: readonly DirectThreadEvent[],
): DirectThreadChatState {
return events.reduce(reduceDirectThreadEvent, state);
}
/**
* bootstrap 是运行态的唯一权威:游标已在队尾,返回的事件就是此刻要处理的事件。
*
* 订阅身份与首屏历史锚点(`subscriptionId` / `lastCompletedItemId`)是订阅循环自己的局部
* 事实,不进聊天状态:这里只把 bootstrap 事件 reduce 进现有状态。
*/
export function resolveDirectThreadBootstrap(
state: DirectThreadChatState,
bootstrap: DirectThreadSubscriptionBootstrap,
): DirectThreadChatState {
return reduceDirectThreadEvents(state, bootstrap.events);
}
/** 事件顺序 = 游标顺序;调用方只需要把 `consume` 的结果喂进来。 */
export function applyDirectThreadConsumeResult(
state: DirectThreadChatState,
result: DirectThreadConsumeResult,
): DirectThreadChatState {
return reduceDirectThreadEvents(state, result.events);
}
/** 同一身份的条目合并,先到者在前:历史在前、运行态在后,运行态只补空。 */
export function mergeHistoryEntries(
leading: readonly DirectChatEntry[],
trailing: readonly DirectChatEntry[],
): DirectChatEntry[] {
const byId = new Map<string, number>();
const entries: DirectChatEntry[] = [];
for (const entry of [...leading, ...trailing]) {
const index = byId.get(entry.itemId);
if (index === undefined) {
byId.set(entry.itemId, entries.length);
entries.push(entry);
continue;
}
const existing = entries[index];
if (existing) entries[index] = mergeDirectChatEntry(existing, entry);
}
return entries;
}
/** 历史切片条目 → 聊天条目:可见性判定的唯一入口,分页判据也读这一份。 */
export function projectDirectHistoryItems(
items: readonly DirectThreadItem[],
): DirectChatEntry[] {
return items
.map((item) => projectDirectThreadItem(item))
.filter((entry): entry is DirectChatEntry => Boolean(entry));
}
/**
* 历史切片并入:切片是脱敏原始条目,投影规则与运行态完全同一份。
*
* 同一调用的调用与输出在这里按身份合并成一张卡片,而不是在 Rust 侧合并。
*/
export function mergeDirectHistoryItems(
state: DirectThreadChatState,
items: readonly DirectThreadItem[],
): DirectThreadChatState {
return {
...state,
history: mergeHistoryEntries(
projectDirectHistoryItems(items),
state.history,
),
};
}
export function mergeDirectThreadHistorySlice(
state: DirectThreadChatState,
slice: DirectThreadHistorySlice,
): DirectThreadChatState {
return mergeDirectHistoryItems(state, slice.items);
}
/** 聊天投影输入:历史顺序 + 运行态覆盖;运行态独有条目排在最后。 */
export function selectDirectChatEntries(
state: DirectThreadChatState,
): DirectChatEntry[] {
return mergeHistoryEntries(state.history, state.live);
}