前端聊天投影按 ts-rs 生成类型收口,删掉 callId 与回合身份
- directThreadItemProjection.ts 直接消费生成的 DirectThreadItem,工具卡片 kind / 标题 / 摘要 / 状态与可见性判定都在这里 - directThreadChat.ts 聊天条目只留一个 itemId:去掉 callId 概念,去掉 deltaText 缓冲,增量直接追加到运行态条目正文 - 合并规则收成先到者赢、后到只补空:正文只增不减、工具状态允许从 running 升级到终态 - 回合结束把运行态条目并入历史并清空 live,条目既不消失也不重复 - directThreadEvents.ts 改为转发 ts-rs 绑定,只保留历史条目转消息的过渡函数 - ToolCallGroup 与 toolCallGroupPresentation 改用去掉回合身份的 DirectChatToolCard - generated/index.ts 补上 DirectThread* 绑定导出 - 回退本仓库其它模块仍在改的 DirectCodexUser* 绑定重新生成(避免误改他人进行中的契约) - 两个 vitest 用例文件按新 API 重写,11 条用例通过
This commit is contained in:
@@ -9,7 +9,7 @@ import {
|
||||
import { useEffect, useId, useState } from 'react';
|
||||
|
||||
import { AgentMessageContent } from '../../../../../packages/shared/src/components/AgentMessageContent';
|
||||
import type { GameCreatorDirectToolCall } from '../../app/types';
|
||||
import type { DirectChatToolCard } from './directThreadChat';
|
||||
import {
|
||||
formatToolCallDuration,
|
||||
formatTurnDuration,
|
||||
@@ -38,7 +38,7 @@ export function ToolCallGroup({
|
||||
active = false,
|
||||
className,
|
||||
}: {
|
||||
calls: GameCreatorDirectToolCall[];
|
||||
calls: DirectChatToolCard[];
|
||||
/** 同一回合用户消息的 `updatedAt`;拿不到就传 0,只显示结束时间。 */
|
||||
userSentAt?: number | null;
|
||||
/**
|
||||
@@ -169,7 +169,7 @@ function ToolCallRow({
|
||||
call,
|
||||
active,
|
||||
}: {
|
||||
call: GameCreatorDirectToolCall;
|
||||
call: DirectChatToolCard;
|
||||
active: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
@@ -1,120 +1,133 @@
|
||||
/**
|
||||
* DirectProject 聊天 reducer:把运行态 raw event 与历史切片归并成同一份聊天条目。
|
||||
* DirectProject 聊天 reducer:把运行态事件与历史切片归并成同一份聊天条目。
|
||||
*
|
||||
* 事实源只有一个——项目对话历史;运行态事件只负责"当前回合"。这里不判断哪些条目
|
||||
* 要显示,也不决定顺序策略之外的任何东西:顺序 = 历史文件顺序 + 运行态独有条目。
|
||||
* 事实源只有一个——项目对话历史;运行态事件只负责"当前回合"。顺序 = 历史文件顺序 +
|
||||
* 运行态独有条目。这里不做可见性判断(那是投影的事),也不认任何回合身份:DirectProject
|
||||
* 同一时刻只有一个回合在跑,`turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔。
|
||||
*/
|
||||
|
||||
import type { GameCreatorDirectToolCall } from '../../app/types';
|
||||
import type {
|
||||
DirectThreadRawEvent,
|
||||
DirectThreadConsumeResult,
|
||||
DirectThreadEvent,
|
||||
DirectThreadHistorySlice,
|
||||
DirectThreadItem,
|
||||
DirectThreadSubscriptionBootstrap,
|
||||
} from './directThreadEvents';
|
||||
import {
|
||||
type DirectThreadRawItem,
|
||||
isDirectThreadRawItem,
|
||||
projectDirectThreadRawItem,
|
||||
} from './directThreadItemProjection';
|
||||
import { projectDirectThreadItem } from './directThreadItemProjection';
|
||||
|
||||
export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool';
|
||||
|
||||
/** 与 Rust `DirectChatEntry` 同形:运行态事件与历史切片共用的唯一形状。 */
|
||||
/** 聊天卡片里的工具形状:持久化卡片去掉回合身份(Rust 侧已经不下发 turn id)。 */
|
||||
export type DirectChatToolCard = Omit<GameCreatorDirectToolCall, 'turnId'>;
|
||||
|
||||
/** 聊天视图里的一条条目;运行态事件与历史切片共用的唯一形状。 */
|
||||
export type DirectChatEntry = {
|
||||
itemId: string;
|
||||
callId?: string | null;
|
||||
turnId?: string | null;
|
||||
kind: DirectChatEntryKind;
|
||||
role?: 'user' | 'assistant' | null;
|
||||
text?: string | null;
|
||||
toolCall?: GameCreatorDirectToolCall | null;
|
||||
toolCall?: DirectChatToolCard | null;
|
||||
at?: number;
|
||||
};
|
||||
|
||||
export type DirectThreadChatState = {
|
||||
subscriptionId: string | null;
|
||||
/** 首屏历史锚点:`subscribe` 给出的最后一个完成条目 id。 */
|
||||
/** 首屏历史锚点:`subscribe` 给出的最后一条完成条目 id。 */
|
||||
lastCompletedItemId: string | null;
|
||||
/** 活动回合:只有出现 `turn.started` 且没有对应 `turn.completed` 才有值。 */
|
||||
activeTurnId: string | null;
|
||||
/** 最新回合是否还在跑;只由生命周期事件的先后决定。 */
|
||||
turnRunning: boolean;
|
||||
/** 历史切片条目,保持文件顺序。 */
|
||||
history: DirectChatEntry[];
|
||||
/** 运行态条目,保持到达顺序。 */
|
||||
/** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */
|
||||
live: DirectChatEntry[];
|
||||
/** 流式正文累计(itemId → text)。 */
|
||||
deltaText: Record<string, string>;
|
||||
};
|
||||
|
||||
export function emptyDirectThreadChatState(): DirectThreadChatState {
|
||||
return {
|
||||
subscriptionId: null,
|
||||
lastCompletedItemId: null,
|
||||
activeTurnId: null,
|
||||
turnRunning: false,
|
||||
history: [],
|
||||
live: [],
|
||||
deltaText: {},
|
||||
};
|
||||
}
|
||||
|
||||
/** 合并身份:工具条目的 app-server itemId 与原始 item 的 callId 是同一个值。 */
|
||||
export function directChatEntryIdentity(entry: DirectChatEntry): string {
|
||||
return (entry.callId ?? '').trim() || entry.itemId;
|
||||
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 mergeToolCall(
|
||||
left: GameCreatorDirectToolCall,
|
||||
right: GameCreatorDirectToolCall,
|
||||
): GameCreatorDirectToolCall {
|
||||
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,
|
||||
...right,
|
||||
title: right.title?.trim() ? right.title : left.title,
|
||||
summary: right.summary?.trim() ? right.summary : left.summary,
|
||||
kind: right.kind && right.kind !== 'other' ? right.kind : left.kind,
|
||||
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: right.detail?.command ?? left.detail?.command,
|
||||
output: right.detail?.output ?? left.detail?.output,
|
||||
changes: right.detail?.changes?.length
|
||||
? right.detail.changes
|
||||
: left.detail?.changes,
|
||||
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: Math.min(
|
||||
left.startedAt > 0 ? left.startedAt : right.startedAt,
|
||||
right.startedAt > 0 ? right.startedAt : left.startedAt,
|
||||
),
|
||||
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 {
|
||||
const merged: DirectChatEntry = {
|
||||
...existing,
|
||||
...incoming,
|
||||
return {
|
||||
itemId: existing.itemId || incoming.itemId,
|
||||
kind:
|
||||
incoming.kind === 'tool' || existing.kind === 'tool'
|
||||
existing.kind === 'tool' || incoming.kind === 'tool'
|
||||
? 'tool'
|
||||
: incoming.kind,
|
||||
text: incoming.text ?? existing.text,
|
||||
role: incoming.role ?? existing.role,
|
||||
turnId: incoming.turnId ?? existing.turnId,
|
||||
: 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,
|
||||
};
|
||||
const left = existing.toolCall ?? null;
|
||||
const right = incoming.toolCall ?? null;
|
||||
merged.toolCall =
|
||||
left && right ? mergeToolCall(left, right) : (right ?? left);
|
||||
return merged;
|
||||
}
|
||||
|
||||
function upsertLiveEntry(
|
||||
state: DirectThreadChatState,
|
||||
entry: DirectChatEntry,
|
||||
): DirectThreadChatState {
|
||||
const identity = directChatEntryIdentity(entry);
|
||||
const index = state.live.findIndex(
|
||||
(existing) => directChatEntryIdentity(existing) === identity,
|
||||
(existing) => existing.itemId === entry.itemId,
|
||||
);
|
||||
if (index < 0) {
|
||||
return { ...state, live: [...state.live, entry] };
|
||||
@@ -128,49 +141,47 @@ function upsertLiveEntry(
|
||||
return { ...state, live };
|
||||
}
|
||||
|
||||
function reduceDirectThreadEvent(
|
||||
function appendLiveText(
|
||||
state: DirectThreadChatState,
|
||||
event: DirectThreadRawEvent,
|
||||
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, activeTurnId: event.turnId };
|
||||
return { ...state, turnRunning: true };
|
||||
case 'turn.completed':
|
||||
// 回合结束:条目已经落盘,运行态并入历史后清空,避免同一条目渲染两次。
|
||||
return {
|
||||
...state,
|
||||
activeTurnId:
|
||||
state.activeTurnId === event.turnId ? null : state.activeTurnId,
|
||||
turnRunning: false,
|
||||
history: mergeHistoryEntries(state.history, state.live),
|
||||
live: [],
|
||||
};
|
||||
case 'item.delta': {
|
||||
const itemId = event.itemId?.trim();
|
||||
if (!itemId) return state;
|
||||
const payload = event.payload as { delta?: unknown; kind?: unknown };
|
||||
const delta = typeof payload?.delta === 'string' ? payload.delta : '';
|
||||
if (!delta) return state;
|
||||
const text = `${state.deltaText[itemId] ?? ''}${delta}`;
|
||||
const reasoning = payload?.kind === 'reasoning';
|
||||
return upsertLiveEntry(
|
||||
{ ...state, deltaText: { ...state.deltaText, [itemId]: text } },
|
||||
{
|
||||
itemId,
|
||||
kind: reasoning ? 'reasoning' : 'message',
|
||||
role: reasoning ? null : 'assistant',
|
||||
turnId: event.turnId,
|
||||
text,
|
||||
},
|
||||
);
|
||||
}
|
||||
case 'item.delta':
|
||||
return appendLiveText(state, event);
|
||||
case 'item.started':
|
||||
case 'item.completed': {
|
||||
const entry = isDirectThreadRawItem(event.payload)
|
||||
? projectDirectThreadRawItem(event.payload)
|
||||
: null;
|
||||
if (!entry) return state;
|
||||
return upsertLiveEntry(state, {
|
||||
...entry,
|
||||
turnId: entry.turnId ?? event.turnId,
|
||||
});
|
||||
const entry = projectDirectThreadItem(event.item);
|
||||
return entry ? upsertLiveEntry(state, entry) : state;
|
||||
}
|
||||
case 'request':
|
||||
// 审批 / 提问只影响面板交互,不并入聊天条目。
|
||||
return state;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -178,20 +189,23 @@ function reduceDirectThreadEvent(
|
||||
|
||||
export function reduceDirectThreadEvents(
|
||||
state: DirectThreadChatState,
|
||||
events: readonly DirectThreadRawEvent[],
|
||||
events: readonly DirectThreadEvent[],
|
||||
): DirectThreadChatState {
|
||||
return events.reduce(reduceDirectThreadEvent, state);
|
||||
}
|
||||
|
||||
/** bootstrap 是运行态的唯一权威:原子替换运行态,历史窗口保留。 */
|
||||
/**
|
||||
* bootstrap 是运行态的唯一权威:游标已在队尾,返回的事件就是此刻要处理的事件。
|
||||
*
|
||||
* 历史窗口保留:bootstrap 不重新回读历史切片,那是 `lastCompletedItemId` 的职责。
|
||||
*/
|
||||
export function resolveDirectThreadBootstrap(
|
||||
state: DirectThreadChatState,
|
||||
bootstrap: DirectThreadSubscriptionBootstrap,
|
||||
): DirectThreadChatState {
|
||||
return reduceDirectThreadEvents(
|
||||
{
|
||||
...emptyDirectThreadChatState(),
|
||||
history: state.history,
|
||||
...state,
|
||||
subscriptionId: bootstrap.subscriptionId,
|
||||
lastCompletedItemId: bootstrap.lastCompletedItemId ?? null,
|
||||
},
|
||||
@@ -199,69 +213,59 @@ export function resolveDirectThreadBootstrap(
|
||||
);
|
||||
}
|
||||
|
||||
/** 历史切片并入:重叠条目按身份去重,新切片在前、已有历史在后。 */
|
||||
export function mergeDirectHistoryEntries(
|
||||
/** 事件顺序 = 游标顺序;调用方只需要把 `consume` 的结果喂进来。 */
|
||||
export function applyDirectThreadConsumeResult(
|
||||
state: DirectThreadChatState,
|
||||
entries: readonly DirectChatEntry[],
|
||||
result: DirectThreadConsumeResult,
|
||||
): DirectThreadChatState {
|
||||
const byIdentity = new Map<string, DirectChatEntry>();
|
||||
const order: string[] = [];
|
||||
for (const entry of [...entries, ...state.history]) {
|
||||
const identity = directChatEntryIdentity(entry);
|
||||
const existing = byIdentity.get(identity);
|
||||
if (existing) {
|
||||
byIdentity.set(identity, mergeDirectChatEntry(existing, entry));
|
||||
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;
|
||||
}
|
||||
byIdentity.set(identity, entry);
|
||||
order.push(identity);
|
||||
const existing = entries[index];
|
||||
if (existing) entries[index] = mergeDirectChatEntry(existing, entry);
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
history: order
|
||||
.map((identity) => byIdentity.get(identity))
|
||||
.filter((entry): entry is DirectChatEntry => Boolean(entry)),
|
||||
};
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* 历史切片并入:切片是脱敏原始条目,投影规则与运行态完全同一份。
|
||||
*
|
||||
* `items` 必须按文件顺序给出;同一调用的 `function_call` 与 `function_call_output`
|
||||
* 在这里按身份合并成一张卡片,而不是在 Rust 侧合并。
|
||||
* 同一调用的调用与输出在这里按身份合并成一张卡片,而不是在 Rust 侧合并。
|
||||
*/
|
||||
export function mergeDirectHistoryItems(
|
||||
state: DirectThreadChatState,
|
||||
items: readonly DirectThreadRawItem[],
|
||||
items: readonly DirectThreadItem[],
|
||||
): DirectThreadChatState {
|
||||
return mergeDirectHistoryEntries(
|
||||
state,
|
||||
items
|
||||
.map((item) => projectDirectThreadRawItem(item))
|
||||
.filter((entry): entry is DirectChatEntry => Boolean(entry)),
|
||||
);
|
||||
const entries = items
|
||||
.map((item) => projectDirectThreadItem(item))
|
||||
.filter((entry): entry is DirectChatEntry => Boolean(entry));
|
||||
return { ...state, history: mergeHistoryEntries(entries, state.history) };
|
||||
}
|
||||
|
||||
export function mergeDirectThreadHistorySlice(
|
||||
state: DirectThreadChatState,
|
||||
slice: DirectThreadHistorySlice,
|
||||
): DirectThreadChatState {
|
||||
return mergeDirectHistoryItems(state, slice.items);
|
||||
}
|
||||
|
||||
/** 聊天投影输入:历史顺序 + 运行态覆盖;运行态独有条目排在最后。 */
|
||||
export function selectDirectChatEntries(
|
||||
state: DirectThreadChatState,
|
||||
): DirectChatEntry[] {
|
||||
const liveByIdentity = new Map(
|
||||
state.live.map((entry) => [directChatEntryIdentity(entry), entry]),
|
||||
);
|
||||
const seen = new Set<string>();
|
||||
const entries: DirectChatEntry[] = [];
|
||||
for (const entry of state.history) {
|
||||
const identity = directChatEntryIdentity(entry);
|
||||
seen.add(identity);
|
||||
const live = liveByIdentity.get(identity);
|
||||
entries.push(live ? mergeDirectChatEntry(entry, live) : entry);
|
||||
}
|
||||
for (const entry of state.live) {
|
||||
const identity = directChatEntryIdentity(entry);
|
||||
if (seen.has(identity)) continue;
|
||||
seen.add(identity);
|
||||
entries.push(entry);
|
||||
}
|
||||
return entries;
|
||||
return mergeHistoryEntries(state.history, state.live);
|
||||
}
|
||||
|
||||
@@ -1,62 +1,44 @@
|
||||
/**
|
||||
* DirectProject 运行态事件的线上类型。
|
||||
*
|
||||
* 类型由 Rust 侧 ts-rs 导出(改完 Rust 模型后跑 `cargo test export_bindings`),这里只做
|
||||
* 入口转发:前端不再自己抄一份形状,字段增删必须改 Rust。
|
||||
*/
|
||||
|
||||
import type { LocalConversationMessageRecord } from '../../app/types';
|
||||
import type { DirectThreadRawItem } from './directThreadItemProjection';
|
||||
import type { DirectThreadItem } from './generated';
|
||||
|
||||
export type DirectThreadRawEvent = {
|
||||
seq: number;
|
||||
type: string;
|
||||
turnId: string;
|
||||
itemId?: string;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type DirectThreadSubscriptionBootstrap = {
|
||||
subscriptionId: string;
|
||||
lastCompletedItemId: string | null;
|
||||
events: DirectThreadRawEvent[];
|
||||
};
|
||||
|
||||
export type DirectThreadConsumeResult = {
|
||||
events: DirectThreadRawEvent[];
|
||||
};
|
||||
|
||||
export type DirectThreadHistorySlice = {
|
||||
/** 脱敏原始条目,顺序即文件顺序;与运行态事件载荷同形。 */
|
||||
items: DirectThreadRawItem[];
|
||||
hasMore: boolean;
|
||||
/** 本次切片的原始条目锚点:分页继续向前靠它,而不是靠"有没有可显示条目"。 */
|
||||
firstItemId: string | null;
|
||||
};
|
||||
export type {
|
||||
DirectThreadConsumeResult,
|
||||
DirectThreadDeltaKind,
|
||||
DirectThreadEvent,
|
||||
DirectThreadFileChange,
|
||||
DirectThreadHistorySlice,
|
||||
DirectThreadItem,
|
||||
DirectThreadRequestKind,
|
||||
DirectThreadSubscriptionBootstrap,
|
||||
} from './generated';
|
||||
|
||||
/**
|
||||
* 历史条目转聊天消息。
|
||||
*
|
||||
* 迁移期的过渡函数:只保留 `role ∈ {user, assistant}` 的文本条目,工具卡片与交替顺序
|
||||
* 由 `directThreadChat` 的 reducer 投影。App.tsx 切换到 reducer 后本函数删除。
|
||||
* 过渡函数:`App.tsx` 仍按 `LocalConversationMessageRecord` 渲染,切换成 reducer 之后删除。
|
||||
* 只保留 `role ∈ {user, assistant}` 且有正文的条目;工具卡片与交替顺序由 reducer 投影。
|
||||
*/
|
||||
export function directThreadHistoryItemsToMessages(
|
||||
items: readonly DirectThreadRawItem[],
|
||||
items: readonly DirectThreadItem[],
|
||||
): LocalConversationMessageRecord[] {
|
||||
return items.flatMap((item) => {
|
||||
if (
|
||||
item.itemType !== 'message' &&
|
||||
item.itemType !== 'agentMessage' &&
|
||||
item.itemType !== 'userMessage'
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const role = item.role;
|
||||
if (role !== 'user' && role !== 'assistant') return [];
|
||||
const messageRole = role as 'user' | 'assistant';
|
||||
const content = typeof item.text === 'string' ? item.text : '';
|
||||
if (!content) return [];
|
||||
const messageId = item.itemId;
|
||||
if (item.itemType !== 'message') return [];
|
||||
if (item.role !== 'user' && item.role !== 'assistant') return [];
|
||||
if (!item.text) return [];
|
||||
return [
|
||||
{
|
||||
schemaVersion: 'agc-direct-project-context.v1',
|
||||
role: messageRole,
|
||||
content,
|
||||
role: item.role,
|
||||
content: item.text,
|
||||
agentId: null,
|
||||
messageId,
|
||||
messageId: item.itemId,
|
||||
updatedAt: item.at ?? 0,
|
||||
},
|
||||
];
|
||||
|
||||
+176
-189
@@ -1,57 +1,22 @@
|
||||
/**
|
||||
* DirectProject「原始条目 → 聊天条目」投影。
|
||||
*
|
||||
* Rust 只搬运脱敏后的 Codex 原始条目(消息 / 思考 / 工具调用),工具卡片的
|
||||
* `kind`、标题、折叠摘要、状态判定和可见性全部在这里完成。运行态事件与历史切片
|
||||
* 走同一个函数,因此实时与回读不可能出现两套口径。
|
||||
* 输入是 Rust 侧 ts-rs 导出的 `DirectThreadItem`(脱敏后的 Codex 原始条目),工具卡片的
|
||||
* `kind`、标题、折叠摘要、状态判定和可见性全部在这里完成。运行态事件与历史切片走同一个
|
||||
* 函数,因此实时与回读不可能出现两套口径。
|
||||
*/
|
||||
|
||||
import type {
|
||||
GameCreatorDirectToolCall,
|
||||
GameCreatorDirectToolCallChange,
|
||||
GameCreatorDirectToolCallDetail,
|
||||
GameCreatorDirectToolCallKind,
|
||||
GameCreatorDirectToolCallStatus,
|
||||
} from '../../app/types';
|
||||
import type { DirectChatEntry } from './directThreadChat';
|
||||
import type { DirectChatEntry, DirectChatToolCard } from './directThreadChat';
|
||||
import type { DirectThreadItem } from './directThreadEvents';
|
||||
|
||||
/** 一个片段里的文件变更(与 Rust `DirectThreadRawChange` 同形)。 */
|
||||
export type DirectThreadRawChange = {
|
||||
path: string;
|
||||
kind: string;
|
||||
};
|
||||
|
||||
/** Rust 搬运的脱敏原始条目:没有卡片语义,只有 Codex 原始字段。 */
|
||||
export type DirectThreadRawItem = {
|
||||
itemId: string;
|
||||
callId?: string | null;
|
||||
turnId?: string | null;
|
||||
itemType: string;
|
||||
role?: string | null;
|
||||
text?: string | null;
|
||||
name?: string | null;
|
||||
arguments?: string | null;
|
||||
output?: string | null;
|
||||
command?: string | null;
|
||||
tool?: string | null;
|
||||
changes?: DirectThreadRawChange[] | null;
|
||||
itemStatus?: string | null;
|
||||
exitCode?: number | null;
|
||||
success?: boolean | null;
|
||||
completed: boolean;
|
||||
at?: number | null;
|
||||
};
|
||||
|
||||
const MESSAGE_ITEM_TYPES = new Set(['message', 'agentMessage', 'userMessage']);
|
||||
const REASONING_ITEM_TYPES = new Set(['reasoning']);
|
||||
const TOOL_ITEM_TYPES = new Set([
|
||||
'function_call',
|
||||
'function_call_output',
|
||||
'commandExecution',
|
||||
'fileChange',
|
||||
'mcpToolCall',
|
||||
'webSearch',
|
||||
'contextCompaction',
|
||||
]);
|
||||
/** 折叠态摘要上限,与卡片契约一致。 */
|
||||
const TOOL_SUMMARY_MAX_CHARS = 120;
|
||||
|
||||
const FAILED_ITEM_STATUS = new Set([
|
||||
'failed',
|
||||
@@ -61,13 +26,6 @@ const FAILED_ITEM_STATUS = new Set([
|
||||
'aborted',
|
||||
]);
|
||||
|
||||
/** 折叠态摘要上限,与卡片契约一致。 */
|
||||
const TOOL_SUMMARY_MAX_CHARS = 120;
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
function firstLine(value: string): string {
|
||||
const [line = ''] = value.split('\n');
|
||||
const trimmed = line.trim();
|
||||
@@ -95,51 +53,17 @@ function toolKindFromFunctionName(name: string): GameCreatorDirectToolCallKind {
|
||||
}
|
||||
}
|
||||
|
||||
function toolKind(raw: DirectThreadRawItem): GameCreatorDirectToolCallKind {
|
||||
switch (raw.itemType) {
|
||||
case 'function_call':
|
||||
return toolKindFromFunctionName(text(raw.name));
|
||||
// 输出条目本身不携带工具语义:它只负责补上 `output`。
|
||||
case 'function_call_output':
|
||||
return 'other';
|
||||
case 'commandExecution':
|
||||
return 'command';
|
||||
case 'fileChange':
|
||||
return 'file_change';
|
||||
case 'mcpToolCall':
|
||||
return 'mcp_tool';
|
||||
case 'webSearch':
|
||||
return 'web_search';
|
||||
case 'contextCompaction':
|
||||
return 'context_compaction';
|
||||
default:
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
|
||||
function toolStatus(raw: DirectThreadRawItem): GameCreatorDirectToolCallStatus {
|
||||
const itemStatus = text(raw.itemStatus);
|
||||
if (itemStatus === 'completed') return 'completed';
|
||||
if (FAILED_ITEM_STATUS.has(itemStatus)) return 'failed';
|
||||
function toolStatus(
|
||||
status: string | null,
|
||||
exitCode: number | null,
|
||||
): GameCreatorDirectToolCallStatus {
|
||||
if (status === 'completed') return 'completed';
|
||||
if (status && FAILED_ITEM_STATUS.has(status)) return 'failed';
|
||||
// Codex 的退出码约定:非 0 即失败;缺席时按「已完成」处理。
|
||||
if (typeof raw.exitCode === 'number') {
|
||||
return raw.exitCode === 0 ? 'completed' : 'failed';
|
||||
if (typeof exitCode === 'number') {
|
||||
return exitCode === 0 ? 'completed' : 'failed';
|
||||
}
|
||||
if (typeof raw.success === 'boolean') {
|
||||
return raw.success ? 'completed' : 'failed';
|
||||
}
|
||||
return raw.completed ? 'completed' : 'running';
|
||||
}
|
||||
|
||||
function toolChanges(
|
||||
raw: DirectThreadRawItem,
|
||||
): GameCreatorDirectToolCallChange[] {
|
||||
return (raw.changes ?? [])
|
||||
.filter((change) => text(change?.path).trim().length > 0)
|
||||
.map((change) => ({
|
||||
path: text(change.path),
|
||||
kind: text(change.kind) || 'update',
|
||||
}));
|
||||
return status ? 'running' : 'completed';
|
||||
}
|
||||
|
||||
function toolTitle(
|
||||
@@ -162,120 +86,183 @@ function toolTitle(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具卡片投影。
|
||||
*
|
||||
* `function_call_output` 只带输出,因此它的标题与摘要留空:后到的输出快照不允许
|
||||
* 抹掉先到的命令与标题(合并规则见 `mergeDirectChatEntry`)。
|
||||
*/
|
||||
function projectToolCard(
|
||||
raw: DirectThreadRawItem,
|
||||
): GameCreatorDirectToolCall | null {
|
||||
const kind = toolKind(raw);
|
||||
const changes = toolChanges(raw);
|
||||
const command = text(raw.command) || text(raw.arguments);
|
||||
const tool = text(raw.tool);
|
||||
const output = text(raw.output);
|
||||
const isOutputOnly = raw.itemType === 'function_call_output';
|
||||
const detail: GameCreatorDirectToolCall['detail'] = {};
|
||||
if (command && !isOutputOnly) detail.command = command;
|
||||
if (output) detail.output = output;
|
||||
function fileChanges(
|
||||
item: Extract<DirectThreadItem, { itemType: 'fileChange' }>,
|
||||
): GameCreatorDirectToolCallChange[] {
|
||||
return item.changes
|
||||
.filter((change) => change.path.trim().length > 0)
|
||||
.map((change) => ({
|
||||
path: change.path,
|
||||
kind: change.kind || 'update',
|
||||
}));
|
||||
}
|
||||
|
||||
type ToolCardInput = {
|
||||
itemId: string;
|
||||
at: number;
|
||||
kind: GameCreatorDirectToolCallKind;
|
||||
/** 输出条目只带输出:标题与摘要留空,交给先到的调用快照。 */
|
||||
outputOnly?: boolean;
|
||||
tool?: string;
|
||||
command?: string;
|
||||
output?: string;
|
||||
status: GameCreatorDirectToolCallStatus;
|
||||
changes?: GameCreatorDirectToolCallChange[];
|
||||
};
|
||||
|
||||
function buildToolCard(input: ToolCardInput): DirectChatToolCard | null {
|
||||
const changes = input.changes ?? [];
|
||||
const detail: GameCreatorDirectToolCallDetail = {};
|
||||
if (input.command && !input.outputOnly) detail.command = input.command;
|
||||
if (input.output) detail.output = input.output;
|
||||
if (changes.length > 0) detail.changes = changes;
|
||||
// 没有命令 / 输出 / 文件明细的条目不渲染成卡片:一张空卡片对用户没有信息量。
|
||||
if (!detail.command && !detail.output && !detail.changes?.length) return null;
|
||||
|
||||
const summarySource =
|
||||
(kind === 'mcp_tool' ? tool : '') ||
|
||||
(input.kind === 'mcp_tool' ? (input.tool ?? '') : '') ||
|
||||
detail.command ||
|
||||
changes[0]?.path ||
|
||||
tool ||
|
||||
input.tool ||
|
||||
'';
|
||||
const at = typeof raw.at === 'number' && raw.at > 0 ? raw.at : 0;
|
||||
return {
|
||||
schemaVersion: 'agc-tool-call.v1',
|
||||
id: text(raw.callId) || raw.itemId,
|
||||
turnId: text(raw.turnId),
|
||||
kind,
|
||||
title: isOutputOnly ? '' : toolTitle(kind, changes),
|
||||
summary: isOutputOnly ? '' : firstLine(summarySource),
|
||||
status: toolStatus(raw),
|
||||
id: input.itemId,
|
||||
kind: input.kind,
|
||||
title: input.outputOnly ? '' : toolTitle(input.kind, changes),
|
||||
summary: input.outputOnly ? '' : firstLine(summarySource),
|
||||
status: input.status,
|
||||
detail,
|
||||
startedAt: at,
|
||||
updatedAt: at,
|
||||
startedAt: input.at,
|
||||
updatedAt: input.at,
|
||||
};
|
||||
}
|
||||
|
||||
function toolCardFromItem(item: DirectThreadItem): DirectChatToolCard | null {
|
||||
switch (item.itemType) {
|
||||
case 'function_call':
|
||||
return buildToolCard({
|
||||
itemId: item.itemId,
|
||||
at: item.at,
|
||||
kind: toolKindFromFunctionName(item.name),
|
||||
tool: item.name,
|
||||
command: item.arguments,
|
||||
status: 'running',
|
||||
});
|
||||
case 'function_call_output':
|
||||
return buildToolCard({
|
||||
itemId: item.itemId,
|
||||
at: item.at,
|
||||
kind: 'other',
|
||||
outputOnly: true,
|
||||
output: item.output,
|
||||
status: 'completed',
|
||||
});
|
||||
case 'commandExecution':
|
||||
return buildToolCard({
|
||||
itemId: item.itemId,
|
||||
at: item.at,
|
||||
kind: 'command',
|
||||
command: item.command,
|
||||
output: item.output ?? '',
|
||||
status: toolStatus(item.status, item.exitCode),
|
||||
});
|
||||
case 'fileChange':
|
||||
return buildToolCard({
|
||||
itemId: item.itemId,
|
||||
at: item.at,
|
||||
kind: 'file_change',
|
||||
changes: fileChanges(item),
|
||||
status: 'completed',
|
||||
});
|
||||
case 'mcpToolCall':
|
||||
return buildToolCard({
|
||||
itemId: item.itemId,
|
||||
at: item.at,
|
||||
kind: 'mcp_tool',
|
||||
tool: item.tool,
|
||||
command: item.arguments,
|
||||
output: item.output ?? '',
|
||||
status: toolStatus(item.status, null),
|
||||
});
|
||||
case 'webSearch':
|
||||
return buildToolCard({
|
||||
itemId: item.itemId,
|
||||
at: item.at,
|
||||
kind: 'web_search',
|
||||
command: item.query ?? '',
|
||||
output: item.output ?? '',
|
||||
status: 'completed',
|
||||
});
|
||||
case 'contextCompaction':
|
||||
return buildToolCard({
|
||||
itemId: item.itemId,
|
||||
at: item.at,
|
||||
kind: 'context_compaction',
|
||||
command: '整理上下文',
|
||||
status: 'completed',
|
||||
});
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 原始条目投影成聊天条目;不属于聊天内容的条目返回 `null`。
|
||||
*
|
||||
* 可见性判定只在这里:系统 / 开发者 message、无正文的空条目、未识别的 item 类型
|
||||
* 都不进聊天视图。
|
||||
* 可见性判定只在这里:系统 / 开发者 message、无正文的空条目、未识别的 item 类型都不进
|
||||
* 聊天视图。`other` 是 Rust 原样透传的未知类型,要不要显示属于前端可见性决策,当前不显示。
|
||||
*/
|
||||
export function projectDirectThreadRawItem(
|
||||
raw: DirectThreadRawItem | null | undefined,
|
||||
export function projectDirectThreadItem(
|
||||
item: DirectThreadItem | null | undefined,
|
||||
): DirectChatEntry | null {
|
||||
if (!raw) return null;
|
||||
const itemId = text(raw.itemId).trim();
|
||||
const itemType = text(raw.itemType).trim();
|
||||
if (!itemId || !itemType) return null;
|
||||
if (!item) return null;
|
||||
const itemId = item.itemId.trim();
|
||||
if (!itemId) return null;
|
||||
|
||||
if (REASONING_ITEM_TYPES.has(itemType)) {
|
||||
const body = text(raw.text);
|
||||
if (!body.trim()) return null;
|
||||
return {
|
||||
itemId,
|
||||
callId: raw.callId ?? null,
|
||||
turnId: raw.turnId ?? null,
|
||||
kind: 'reasoning',
|
||||
role: null,
|
||||
text: body,
|
||||
toolCall: null,
|
||||
at: raw.at ?? 0,
|
||||
};
|
||||
switch (item.itemType) {
|
||||
case 'message': {
|
||||
const role =
|
||||
item.role === 'user'
|
||||
? 'user'
|
||||
: item.role === 'assistant'
|
||||
? 'assistant'
|
||||
: null;
|
||||
if (!role || !item.text.trim()) return null;
|
||||
return {
|
||||
itemId,
|
||||
kind: 'message',
|
||||
role,
|
||||
text: item.text,
|
||||
toolCall: null,
|
||||
at: item.at,
|
||||
};
|
||||
}
|
||||
case 'reasoning': {
|
||||
if (!item.text.trim()) return null;
|
||||
return {
|
||||
itemId,
|
||||
kind: 'reasoning',
|
||||
role: null,
|
||||
text: item.text,
|
||||
toolCall: null,
|
||||
at: item.at,
|
||||
};
|
||||
}
|
||||
case 'other':
|
||||
// TODO(direct-thread): 未识别类型目前不显示;要让它们出现只改这里,别回 Rust 加白名单。
|
||||
return null;
|
||||
default: {
|
||||
const toolCall = toolCardFromItem(item);
|
||||
if (!toolCall) return null;
|
||||
return {
|
||||
itemId,
|
||||
kind: 'tool',
|
||||
role: null,
|
||||
text: null,
|
||||
toolCall,
|
||||
at: item.at,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (MESSAGE_ITEM_TYPES.has(itemType)) {
|
||||
const role = text(raw.role);
|
||||
if (role !== 'user' && role !== 'assistant') return null;
|
||||
const body = text(raw.text);
|
||||
if (!body) return null;
|
||||
return {
|
||||
itemId,
|
||||
callId: raw.callId ?? null,
|
||||
turnId: raw.turnId ?? null,
|
||||
kind: 'message',
|
||||
role,
|
||||
text: body,
|
||||
toolCall: null,
|
||||
at: raw.at ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (TOOL_ITEM_TYPES.has(itemType)) {
|
||||
const toolCall = projectToolCard(raw);
|
||||
if (!toolCall) return null;
|
||||
return {
|
||||
itemId,
|
||||
callId: raw.callId ?? null,
|
||||
turnId: raw.turnId ?? null,
|
||||
kind: 'tool',
|
||||
role: null,
|
||||
text: null,
|
||||
toolCall,
|
||||
at: raw.at ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 事件 / 历史切片载荷是否像一条原始条目。 */
|
||||
export function isDirectThreadRawItem(
|
||||
payload: unknown,
|
||||
): payload is DirectThreadRawItem {
|
||||
if (!payload || typeof payload !== 'object') return false;
|
||||
const candidate = payload as Partial<DirectThreadRawItem>;
|
||||
return (
|
||||
typeof candidate.itemId === 'string' &&
|
||||
typeof candidate.itemType === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart';
|
||||
|
||||
export type DirectCodexUserContentPart =
|
||||
|
||||
+3
-7
@@ -1,9 +1,5 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
import type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem';
|
||||
|
||||
/**
|
||||
* DirectProject 本轮 user input 的唯一结构化入口。
|
||||
*/
|
||||
export type DirectCodexUserItem = {
|
||||
type: 'message';
|
||||
} & DirectCodexUserMessageItem;
|
||||
export type DirectCodexUserItem = DirectCodexUserMessageItem;
|
||||
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DirectCodexUserItem } from './DirectCodexUserItem';
|
||||
|
||||
export type DirectCodexUserMessageEnvelope = { item: DirectCodexUserItem };
|
||||
+4
-2
@@ -1,9 +1,11 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
import type { DirectCodexUserContentPart } from './DirectCodexUserContentPart';
|
||||
import type { DirectCodexUserRole } from './DirectCodexUserRole';
|
||||
|
||||
export type DirectCodexUserMessageItem = {
|
||||
type: 'message';
|
||||
role: DirectCodexUserRole;
|
||||
content: Array<DirectCodexUserContentPart>;
|
||||
content: DirectCodexUserContentPart[];
|
||||
id: string;
|
||||
};
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type DirectCodexUserRole = 'user';
|
||||
|
||||
+9
-9
@@ -1,13 +1,13 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type DirectCodexUserRuntimeRegionPart = {
|
||||
label: string;
|
||||
runId: string | null;
|
||||
versionId: string | null;
|
||||
elementTag: string | null;
|
||||
elementRole: string | null;
|
||||
text: string | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
resourceIds: Array<string>;
|
||||
runId?: string;
|
||||
versionId?: string;
|
||||
elementTag?: string;
|
||||
elementRole?: string;
|
||||
text?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
resourceIds: string[];
|
||||
};
|
||||
|
||||
@@ -3,3 +3,11 @@ export type { DirectCodexUserItem } from './DirectCodexUserItem';
|
||||
export type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem';
|
||||
export type { DirectCodexUserRole } from './DirectCodexUserRole';
|
||||
export type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart';
|
||||
export type { DirectThreadConsumeResult } from './DirectThreadConsumeResult';
|
||||
export type { DirectThreadDeltaKind } from './DirectThreadDeltaKind';
|
||||
export type { DirectThreadEvent } from './DirectThreadEvent';
|
||||
export type { DirectThreadFileChange } from './DirectThreadFileChange';
|
||||
export type { DirectThreadHistorySlice } from './DirectThreadHistorySlice';
|
||||
export type { DirectThreadItem } from './DirectThreadItem';
|
||||
export type { DirectThreadRequestKind } from './DirectThreadRequestKind';
|
||||
export type { DirectThreadSubscriptionBootstrap } from './DirectThreadSubscriptionBootstrap';
|
||||
|
||||
+10
-12
@@ -1,7 +1,5 @@
|
||||
import type {
|
||||
GameCreatorDirectToolCall,
|
||||
GameCreatorDirectToolCallKind,
|
||||
} from '../../app/types';
|
||||
import type { GameCreatorDirectToolCallKind } from '../../app/types';
|
||||
import type { DirectChatToolCard } from './directThreadChat';
|
||||
|
||||
/**
|
||||
* 工具调用折叠块的纯文案计算:汇总 / 行文案。
|
||||
@@ -43,7 +41,7 @@ export const TOOL_CALL_ROW_VERBS: Partial<
|
||||
|
||||
/** 汇总文案:按 kind 计数、固定顺序拼成 `已执行 5 个命令、2 个文件变更`;空集合返回空串。 */
|
||||
export function toolCallGroupSummary(
|
||||
calls: GameCreatorDirectToolCall[],
|
||||
calls: DirectChatToolCard[],
|
||||
running = false,
|
||||
) {
|
||||
const counts = new Map<string, number>();
|
||||
@@ -77,7 +75,7 @@ export function toolCallGroupSummary(
|
||||
}
|
||||
|
||||
/** 一行工具的文案:只用工具本身的摘要(不带"已运行"这类动词前缀),状态由行尾状态列表达。 */
|
||||
export function toolCallRowText(call: GameCreatorDirectToolCall) {
|
||||
export function toolCallRowText(call: DirectChatToolCard) {
|
||||
// 行首不再写"已运行/已编辑"这类动词前缀:命令状态由行尾的状态列表达
|
||||
// (执行中 / 已执行 / 失败),前缀会和它重复。
|
||||
if (call.kind === 'context_compaction') {
|
||||
@@ -86,7 +84,7 @@ export function toolCallRowText(call: GameCreatorDirectToolCall) {
|
||||
return toolCallRowSummary(call);
|
||||
}
|
||||
|
||||
function toolCallRowSummary(call: GameCreatorDirectToolCall) {
|
||||
function toolCallRowSummary(call: DirectChatToolCard) {
|
||||
if (call.kind === 'command') {
|
||||
// 历史摘要可能已被可执行文件路径占满;优先从完整、已脱敏的输入提取正文。
|
||||
const script = windowsPowerShellCommandBody(
|
||||
@@ -114,7 +112,7 @@ function toolCallRowSummary(call: GameCreatorDirectToolCall) {
|
||||
}
|
||||
|
||||
/** 仅格式化卡片输入,不修改执行参数、历史记录或工具输出。 */
|
||||
export function toolCallInputText(call: GameCreatorDirectToolCall) {
|
||||
export function toolCallInputText(call: DirectChatToolCard) {
|
||||
const input = call.detail.command?.trim() ?? '';
|
||||
return call.kind === 'command'
|
||||
? (windowsPowerShellCommandBody(input) ?? input)
|
||||
@@ -179,7 +177,7 @@ function unwrapDisplayArgument(argument: string) {
|
||||
* 这两种情况不显示耗时,不显示 `0s` / 负数。
|
||||
*/
|
||||
export function toolCallDurationMs(
|
||||
call: Pick<GameCreatorDirectToolCall, 'startedAt' | 'updatedAt'>,
|
||||
call: Pick<DirectChatToolCard, 'startedAt' | 'updatedAt'>,
|
||||
): number | null {
|
||||
const startedAt = Number.isFinite(call.startedAt) ? call.startedAt : 0;
|
||||
const updatedAt = Number.isFinite(call.updatedAt) ? call.updatedAt : 0;
|
||||
@@ -212,7 +210,7 @@ export function formatToolCallDuration(ms: number | null | undefined) {
|
||||
|
||||
/** 一回合总用时:该回合所有工具的 `min(startedAt)` → `max(updatedAt)`;取不到返回 `null`。 */
|
||||
export function turnToolCallDurationMs(
|
||||
calls: Array<Pick<GameCreatorDirectToolCall, 'startedAt' | 'updatedAt'>>,
|
||||
calls: Array<Pick<DirectChatToolCard, 'startedAt' | 'updatedAt'>>,
|
||||
): number | null {
|
||||
let minStartedAt = Number.POSITIVE_INFINITY;
|
||||
let maxUpdatedAt = Number.NEGATIVE_INFINITY;
|
||||
@@ -253,7 +251,7 @@ export function formatTurnDuration(ms: number | null | undefined) {
|
||||
|
||||
/** 该回合的结束时间:`max(updatedAt)`;取不到返回 0。 */
|
||||
export function turnToolCallEndedAt(
|
||||
calls: Array<Pick<GameCreatorDirectToolCall, 'updatedAt'>>,
|
||||
calls: Array<Pick<DirectChatToolCard, 'updatedAt'>>,
|
||||
) {
|
||||
let maxUpdatedAt = 0;
|
||||
for (const call of calls) {
|
||||
@@ -287,7 +285,7 @@ export function formatClockTime(timestamp: number | null | undefined) {
|
||||
* 能拿到同回合用户消息时间(`updatedAt > 0`)时显示「发送 → 结束」,取不到就只显示结束时间。
|
||||
*/
|
||||
export function turnToolCallTimeLabel(
|
||||
calls: Array<Pick<GameCreatorDirectToolCall, 'updatedAt'>>,
|
||||
calls: Array<Pick<DirectChatToolCard, 'updatedAt'>>,
|
||||
userSentAt: number | null | undefined,
|
||||
) {
|
||||
const endLabel = formatClockTime(turnToolCallEndedAt(calls));
|
||||
|
||||
@@ -1,95 +1,104 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
directChatEntryIdentity,
|
||||
emptyDirectThreadChatState,
|
||||
mergeDirectHistoryItems,
|
||||
reduceDirectThreadEvents,
|
||||
resolveDirectThreadBootstrap,
|
||||
selectDirectChatEntries,
|
||||
} from '../src/features/project-workspace/directThreadChat';
|
||||
import type { DirectThreadRawEvent } from '../src/features/project-workspace/directThreadEvents';
|
||||
import type { DirectThreadRawItem } from '../src/features/project-workspace/directThreadItemProjection';
|
||||
import type { DirectThreadEvent } from '../src/features/project-workspace/directThreadEvents';
|
||||
import type { DirectThreadItem } from '../src/features/project-workspace/directThreadItemProjection';
|
||||
|
||||
function event(
|
||||
partial: Partial<DirectThreadRawEvent> &
|
||||
Pick<DirectThreadRawEvent, 'type' | 'turnId'>,
|
||||
): DirectThreadRawEvent {
|
||||
return { seq: 1, payload: {}, ...partial };
|
||||
partial: Pick<DirectThreadEvent, 'type'> & Partial<DirectThreadEvent>,
|
||||
): DirectThreadEvent {
|
||||
return partial as DirectThreadEvent;
|
||||
}
|
||||
|
||||
/** app-server `item/started`:工具真正开始执行,itemId 就是 call_id。 */
|
||||
function rawToolStarted(
|
||||
overrides: Partial<DirectThreadRawItem> = {},
|
||||
): DirectThreadRawItem {
|
||||
/** app-server `item/started`:工具真正开始执行,itemId 就是归一后的唯一身份。 */
|
||||
function toolStarted(
|
||||
overrides: Partial<
|
||||
Extract<DirectThreadItem, { itemType: 'function_call' }>
|
||||
> = {},
|
||||
): DirectThreadItem {
|
||||
return {
|
||||
itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473',
|
||||
turnId: 'turn-1',
|
||||
itemType: 'function_call',
|
||||
itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473',
|
||||
name: 'exec_command',
|
||||
arguments: '{"cmd": "ls"}',
|
||||
completed: false,
|
||||
at: 1000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** 原始 response item 的 `function_call_output`:itemId 是另一个 id 空间,靠 callId 对齐。 */
|
||||
function rawToolOutput(
|
||||
overrides: Partial<DirectThreadRawItem> = {},
|
||||
): DirectThreadRawItem {
|
||||
/** 原始 response item 的输出条目:Rust 已经把它归一成同一个 itemId。 */
|
||||
function toolOutput(): DirectThreadItem {
|
||||
return {
|
||||
itemId: 'fco_01a06fa5-d636-7452-b337-a641c2e6bc76',
|
||||
callId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473',
|
||||
turnId: 'turn-1',
|
||||
itemType: 'function_call_output',
|
||||
itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473',
|
||||
output: 'assets\ngame',
|
||||
completed: true,
|
||||
at: 2000,
|
||||
};
|
||||
}
|
||||
|
||||
function messageItem(
|
||||
overrides: Partial<Extract<DirectThreadItem, { itemType: 'message' }>> = {},
|
||||
): DirectThreadItem {
|
||||
return {
|
||||
itemType: 'message',
|
||||
itemId: 'msg-1',
|
||||
role: 'assistant',
|
||||
text: '你好',
|
||||
at: 3000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DirectProject 聊天 reducer', () => {
|
||||
it('只有 turn.started 且未完成的回合才是活动回合', () => {
|
||||
it('生命周期事件只切换"是否还在跑",不带回合身份', () => {
|
||||
const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||||
event({ type: 'turn.started', turnId: 'turn-1' }),
|
||||
event({ type: 'turn.started' }),
|
||||
]);
|
||||
expect(started.activeTurnId).toBe('turn-1');
|
||||
expect(started.turnRunning).toBe(true);
|
||||
|
||||
const completed = reduceDirectThreadEvents(started, [
|
||||
event({ type: 'turn.completed', turnId: 'turn-1' }),
|
||||
event({ type: 'turn.completed', status: 'completed' }),
|
||||
]);
|
||||
expect(completed.activeTurnId).toBeNull();
|
||||
expect(completed.turnRunning).toBe(false);
|
||||
});
|
||||
|
||||
it('bootstrap 原子替换运行态,没有生命周期事件就没有活动回合', () => {
|
||||
const stale = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||||
event({ type: 'turn.started', turnId: 'turn-old' }),
|
||||
]);
|
||||
const bootstrapped = resolveDirectThreadBootstrap(stale, {
|
||||
subscriptionId: 'sub-1',
|
||||
lastCompletedItemId: 'msg-9',
|
||||
events: [],
|
||||
});
|
||||
expect(bootstrapped.activeTurnId).toBeNull();
|
||||
it('bootstrap 事件就是要处理的事件:未完成条目直接进运行态', () => {
|
||||
const bootstrapped = resolveDirectThreadBootstrap(
|
||||
emptyDirectThreadChatState(),
|
||||
{
|
||||
subscriptionId: 'sub-1',
|
||||
lastCompletedItemId: 'msg-9',
|
||||
events: [
|
||||
event({ type: 'turn.started' }),
|
||||
event({ type: 'item.started', item: toolStarted() }),
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(bootstrapped.subscriptionId).toBe('sub-1');
|
||||
expect(bootstrapped.lastCompletedItemId).toBe('msg-9');
|
||||
expect(bootstrapped.turnRunning).toBe(true);
|
||||
expect(selectDirectChatEntries(bootstrapped)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('增量正文按条目累计,完成快照覆盖同一段', () => {
|
||||
it('增量正文按条目累计,完成快照更长时覆盖同一段', () => {
|
||||
const streamed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||||
event({
|
||||
type: 'item.delta',
|
||||
turnId: 'turn-1',
|
||||
itemId: 'msg-1',
|
||||
payload: { delta: '你', kind: 'message' },
|
||||
kind: 'message',
|
||||
delta: '你',
|
||||
}),
|
||||
event({
|
||||
type: 'item.delta',
|
||||
turnId: 'turn-1',
|
||||
itemId: 'msg-1',
|
||||
payload: { delta: '好', kind: 'message' },
|
||||
kind: 'message',
|
||||
delta: '好',
|
||||
}),
|
||||
]);
|
||||
expect(selectDirectChatEntries(streamed)).toHaveLength(1);
|
||||
@@ -98,17 +107,7 @@ describe('DirectProject 聊天 reducer', () => {
|
||||
const done = reduceDirectThreadEvents(streamed, [
|
||||
event({
|
||||
type: 'item.completed',
|
||||
turnId: 'turn-1',
|
||||
itemId: 'msg-1',
|
||||
payload: {
|
||||
itemId: 'msg-1',
|
||||
turnId: 'turn-1',
|
||||
itemType: 'message',
|
||||
role: 'assistant',
|
||||
text: '你好,我是陶泥儿。',
|
||||
completed: true,
|
||||
at: 3000,
|
||||
} satisfies DirectThreadRawItem,
|
||||
item: messageItem({ text: '你好,我是陶泥儿。' }),
|
||||
}),
|
||||
]);
|
||||
const entries = selectDirectChatEntries(done);
|
||||
@@ -120,9 +119,9 @@ describe('DirectProject 聊天 reducer', () => {
|
||||
const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||||
event({
|
||||
type: 'item.delta',
|
||||
turnId: 'turn-1',
|
||||
itemId: 'reason-1',
|
||||
payload: { delta: '先看目录', kind: 'reasoning' },
|
||||
kind: 'reasoning',
|
||||
delta: '先看目录',
|
||||
}),
|
||||
]);
|
||||
const entries = selectDirectChatEntries(state);
|
||||
@@ -132,26 +131,14 @@ describe('DirectProject 聊天 reducer', () => {
|
||||
expect(entries[0]?.text).toBe('先看目录');
|
||||
});
|
||||
|
||||
it('工具 started / output 跨 id 空间按 callId 归并成一张卡片', () => {
|
||||
it('工具调用与输出共用唯一 itemId,归并成一张卡片', () => {
|
||||
const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||||
event({
|
||||
type: 'item.started',
|
||||
turnId: 'turn-1',
|
||||
itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473',
|
||||
payload: rawToolStarted(),
|
||||
}),
|
||||
event({
|
||||
type: 'item.completed',
|
||||
turnId: 'turn-1',
|
||||
itemId: 'fco_01a06fa5-d636-7452-b337-a641c2e6bc76',
|
||||
payload: rawToolOutput(),
|
||||
}),
|
||||
event({ type: 'item.started', item: toolStarted() }),
|
||||
event({ type: 'item.completed', item: toolOutput() }),
|
||||
]);
|
||||
const entries = selectDirectChatEntries(state);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(directChatEntryIdentity(entries[0]!)).toBe(
|
||||
'call_00_Gpd0s0Ytm9YgIbwbEXva1473',
|
||||
);
|
||||
expect(entries[0]?.itemId).toBe('call_00_Gpd0s0Ytm9YgIbwbEXva1473');
|
||||
expect(entries[0]?.toolCall?.kind).toBe('command');
|
||||
expect(entries[0]?.toolCall?.title).toBe('执行命令');
|
||||
expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}');
|
||||
@@ -159,18 +146,27 @@ describe('DirectProject 聊天 reducer', () => {
|
||||
expect(entries[0]?.toolCall?.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('回合结束把运行态并入历史并清空,条目不会消失也不会重复', () => {
|
||||
const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||||
event({ type: 'turn.started' }),
|
||||
event({ type: 'item.completed', item: messageItem() }),
|
||||
event({ type: 'item.started', item: toolStarted() }),
|
||||
]);
|
||||
expect(running.live).toHaveLength(2);
|
||||
|
||||
const done = reduceDirectThreadEvents(running, [
|
||||
event({ type: 'turn.completed', status: 'completed' }),
|
||||
]);
|
||||
expect(done.live).toHaveLength(0);
|
||||
expect(done.history).toHaveLength(2);
|
||||
expect(selectDirectChatEntries(done)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('历史切片搬运层不合并,合并发生在前端投影', () => {
|
||||
const state = mergeDirectHistoryItems(emptyDirectThreadChatState(), [
|
||||
rawToolStarted(),
|
||||
rawToolOutput(),
|
||||
{
|
||||
itemId: 'msg-user',
|
||||
itemType: 'message',
|
||||
role: 'user',
|
||||
text: '做一个拼图游戏',
|
||||
completed: true,
|
||||
at: 0,
|
||||
},
|
||||
toolStarted(),
|
||||
toolOutput(),
|
||||
messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图游戏' }),
|
||||
]);
|
||||
const entries = selectDirectChatEntries(state);
|
||||
expect(entries).toHaveLength(2);
|
||||
@@ -182,58 +178,36 @@ describe('DirectProject 聊天 reducer', () => {
|
||||
const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||||
event({
|
||||
type: 'item.completed',
|
||||
turnId: 'turn-1',
|
||||
itemId: 'sys-1',
|
||||
payload: {
|
||||
item: messageItem({
|
||||
itemId: 'sys-1',
|
||||
itemType: 'message',
|
||||
role: 'system',
|
||||
text: '内部指令',
|
||||
completed: true,
|
||||
at: 0,
|
||||
} satisfies DirectThreadRawItem,
|
||||
}),
|
||||
}),
|
||||
event({
|
||||
type: 'item.completed',
|
||||
turnId: 'turn-1',
|
||||
itemId: 'plan-1',
|
||||
payload: {
|
||||
item: {
|
||||
itemType: 'other',
|
||||
itemId: 'plan-1',
|
||||
itemType: 'plan',
|
||||
completed: true,
|
||||
rawType: 'plan',
|
||||
at: 0,
|
||||
} satisfies DirectThreadRawItem,
|
||||
} satisfies DirectThreadItem,
|
||||
}),
|
||||
]);
|
||||
expect(selectDirectChatEntries(state)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('历史条目与运行态按身份合并,重复条目只出现一次', () => {
|
||||
it('历史条目与运行态按唯一 id 合并,重复条目只出现一次', () => {
|
||||
const historical = mergeDirectHistoryItems(emptyDirectThreadChatState(), [
|
||||
rawToolStarted(),
|
||||
{
|
||||
itemId: 'msg-user',
|
||||
itemType: 'message',
|
||||
role: 'user',
|
||||
text: '做一个拼图游戏',
|
||||
completed: true,
|
||||
at: 0,
|
||||
},
|
||||
toolStarted(),
|
||||
messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图游戏' }),
|
||||
]);
|
||||
const live = reduceDirectThreadEvents(historical, [
|
||||
event({
|
||||
type: 'item.completed',
|
||||
turnId: 'turn-1',
|
||||
itemId: 'fco_01a06fa5-d636-7452-b337-a641c2e6bc76',
|
||||
payload: rawToolOutput(),
|
||||
}),
|
||||
event({ type: 'item.completed', item: toolOutput() }),
|
||||
]);
|
||||
const entries = selectDirectChatEntries(live);
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0]?.toolCall?.status).toBe('completed');
|
||||
expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}');
|
||||
expect(directChatEntryIdentity(entries[0]!)).toBe(
|
||||
'call_00_Gpd0s0Ytm9YgIbwbEXva1473',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
directThreadHistoryItemsToMessages,
|
||||
isDirectTurnInProgress,
|
||||
} from '../src/features/project-workspace/directThreadEvents';
|
||||
import type { DirectThreadItem } from '../src/features/project-workspace/directThreadItemProjection';
|
||||
|
||||
describe('Direct 回合状态与历史时间', () => {
|
||||
it('终态和空状态不恢复为活动回合', () => {
|
||||
@@ -27,7 +28,6 @@ describe('Direct 回合状态与历史时间', () => {
|
||||
itemType: 'message',
|
||||
role: 'user',
|
||||
text: '帮我修改游戏',
|
||||
completed: true,
|
||||
at: 1_800_000_000_001,
|
||||
},
|
||||
{
|
||||
@@ -35,9 +35,9 @@ describe('Direct 回合状态与历史时间', () => {
|
||||
itemType: 'message',
|
||||
role: 'assistant',
|
||||
text: '好的',
|
||||
completed: true,
|
||||
at: 0,
|
||||
},
|
||||
];
|
||||
] satisfies DirectThreadItem[];
|
||||
expect(directThreadHistoryItemsToMessages(items)[0]?.updatedAt).toBe(
|
||||
1_800_000_000_001,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user