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
说明: 在把工作交给段哥前还没有实现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>
273 lines
9.0 KiB
TypeScript
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);
|
|
}
|