恢复合并中被回退的 Direct 前端架构
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
恢复主分支的线程事件消费、聊天投影、历史锚点与分页模块。 恢复过程折叠、工具卡片计时以及对应测试和测试桩。 保留策划 V1/V2 退役清理及 Design Agent 总控面板修复。 修正 Direct 验证文档中的测试入口。 验证通过:60 个 Direct 单元测试、69 个相关界面测试、4 个 Design Agent 测试及静态检查。 完整界面测试仍有 8 个资源 UI 测试失败,涉及旧卡片断言和提示词输入方式,本次未修改。
This commit is contained in:
File diff suppressed because it is too large
Load Diff
+230
-337
File diff suppressed because it is too large
Load Diff
@@ -6,26 +6,32 @@ import {
|
||||
Terminal,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useId, useState } from 'react';
|
||||
import { useId, useState } from 'react';
|
||||
|
||||
import { AgentMessageContent } from '../../../../../packages/shared/src/components/AgentMessageContent';
|
||||
import type { GameCreatorDirectToolCall } from '../../app/types';
|
||||
import { AgentProcessSummary } from '../../../../../packages/shared/src/components/AgentProcessSummary';
|
||||
import type { DirectChatToolCard } from './directThreadChat';
|
||||
import {
|
||||
formatToolCallDuration,
|
||||
formatTurnDuration,
|
||||
resolveToolGroupTiming,
|
||||
toolCallDurationMs,
|
||||
toolCallGroupSummary,
|
||||
toolCallInputText,
|
||||
toolCallRowText,
|
||||
turnToolCallDurationMs,
|
||||
turnToolCallTimeLabel,
|
||||
} from './toolCallGroupPresentation';
|
||||
import { useLiveNow } from './useLiveNow';
|
||||
|
||||
/**
|
||||
* 一回合的工具调用折叠块(Codex 风格):
|
||||
* 块头一行汇总 + 该回合总用时 + 结束时间,展开态每行一条工具(行可二级展开看命令 / 文件明细 / 输出)。
|
||||
* 块头一行汇总 + 本组用时,展开态每行一条工具(行可二级展开看命令 / 文件明细 / 输出)。
|
||||
* 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`。
|
||||
*
|
||||
* 计时口径(组用时与单条耗时各一套,都是 100ms 粒度、始终一位小数):
|
||||
* - 本组用时属于**这一组工具**:`min(工具开始)` → `max(工具完成)`。同轮另一组还在跑不会让它
|
||||
* 继续增长;本组工具全部拿到终态就立即冻结。缺开始 / 缺终态边界时隐藏,不借别组或整轮边界。
|
||||
* 整轮总耗时(用户发送 → 回合终态)只在对话底部渲染一处,不在这里重复。
|
||||
* - 单条耗时只认该工具的事件级边界(`item.started` → `item.completed`),不看条目展示时间。
|
||||
*
|
||||
* 无障碍:块头与每一行都是 `<button aria-expanded>` + `hidden` 控制正文,
|
||||
* 所以 Tab 可达、Enter/Space 可切换、读屏能读到展开状态与 `aria-label`。
|
||||
* 文案与耗时规则(纯函数)在 `toolCallGroupPresentation.ts`。
|
||||
@@ -34,75 +40,53 @@ import {
|
||||
/** 一回合的工具调用折叠块;空集合不渲染。 */
|
||||
export function ToolCallGroup({
|
||||
calls,
|
||||
userSentAt = 0,
|
||||
active = false,
|
||||
className,
|
||||
}: {
|
||||
calls: GameCreatorDirectToolCall[];
|
||||
/** 同一回合用户消息的 `updatedAt`;拿不到就传 0,只显示结束时间。 */
|
||||
userSentAt?: number | null;
|
||||
calls: DirectChatToolCard[];
|
||||
/**
|
||||
* 这个块是否属于**正在跑的回合**。只有正在跑的回合才允许出现"执行中 / 进行中":
|
||||
* 已结束的回合里若还留着 `running` 快照(进程被杀、completed 事件没到),
|
||||
* 那是数据残留,不该让用户一直看到"执行中"。
|
||||
* 这个块是否属于**正在跑的回合**:决定本组是否还能用当前时钟增长,以及残留的
|
||||
* `running` 快照要不要显示"执行中 / 进行中"。已结束的回合里残留的 `running` 快照
|
||||
* (进程被杀、completed 事件没到)是数据残留,不该让用户一直看到"执行中"。
|
||||
*/
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [tick, setTick] = useState(0);
|
||||
const bodyId = useId();
|
||||
const hasRunningSnapshot = calls.some((call) => call.status === 'running');
|
||||
const running = active && hasRunningSnapshot;
|
||||
// 正在跑时每秒重算一次"已用时",否则这个数字只在收到新事件时才变。
|
||||
useEffect(() => {
|
||||
if (!running) {
|
||||
return undefined;
|
||||
}
|
||||
const timer = setInterval(() => setTick((current) => current + 1), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [running]);
|
||||
if (calls.length === 0) {
|
||||
return null;
|
||||
}
|
||||
void tick;
|
||||
const orderedCalls = [...calls].sort(
|
||||
(left, right) => left.startedAt - right.startedAt,
|
||||
);
|
||||
const summary = toolCallGroupSummary(orderedCalls, running);
|
||||
const startedAt =
|
||||
orderedCalls.find((call) => call.startedAt > 0)?.startedAt ?? 0;
|
||||
// 进行中:用"现在 - 最早开始时间"(会随 tick 每秒变);已结束:用快照里的
|
||||
// min(startedAt) → max(updatedAt),不掺入本地时钟。
|
||||
const totalDurationMs = running
|
||||
? startedAt > 0
|
||||
? (() => {
|
||||
const latestUpdatedAt = Math.max(
|
||||
...orderedCalls.map((call) => call.updatedAt || 0),
|
||||
);
|
||||
// A running snapshot can still carry a monotonic update time. Use
|
||||
// that delta when available; fall back to wall-clock elapsed time
|
||||
// for live events that have no update timestamp yet.
|
||||
return latestUpdatedAt > startedAt
|
||||
? latestUpdatedAt - startedAt
|
||||
: Math.max(0, Date.now() - startedAt);
|
||||
})()
|
||||
: null
|
||||
: turnToolCallDurationMs(orderedCalls);
|
||||
const durationText = formatTurnDuration(totalDurationMs);
|
||||
const timeLabel = turnToolCallTimeLabel(orderedCalls, userSentAt);
|
||||
const status = running
|
||||
? 'running'
|
||||
: orderedCalls.some((call) => call.status === 'failed')
|
||||
? 'failed'
|
||||
: 'completed';
|
||||
const headLabel = [
|
||||
summary,
|
||||
running ? '进行中' : '',
|
||||
durationText ? `用时 ${durationText}` : '',
|
||||
const hasRunningSnapshot = calls.some((call) => call.status === 'running');
|
||||
// 行级"执行中"与 `data-status` 看**这一组**是否还有工具在跑:同轮另一组在跑、或回合还在
|
||||
// 推理,都不会让已经跑完的组继续标"进行中"。
|
||||
const toolRunning = active && hasRunningSnapshot;
|
||||
// 是否需要跟着墙上时钟走:只取决于本组里"还有工具在跑(且回合还在跑)"。
|
||||
const ticking =
|
||||
active &&
|
||||
orderedCalls.some(
|
||||
(call) => call.status === 'running' && call.startedAt > 0,
|
||||
);
|
||||
const now = useLiveNow(ticking);
|
||||
if (calls.length === 0) {
|
||||
return null;
|
||||
}
|
||||
// 本组用时:只看这一组自己的工具边界;本组全完成即冻结。
|
||||
const timing = resolveToolGroupTiming(orderedCalls, { running: active, now });
|
||||
const summary = toolCallGroupSummary(orderedCalls);
|
||||
const hasFailure = orderedCalls.some((call) => call.status === 'failed');
|
||||
const status = toolRunning ? 'running' : hasFailure ? 'failed' : 'completed';
|
||||
// 用户口径:`执行了 X 个操作,耗时 XXX`。耗时属于**这一组**(不是整轮),可见文本里用
|
||||
// 中文逗号连起来,所以分隔符跟着 meta 一起进 DOM。
|
||||
// 顺序按用户口径:`执行了 X 个操作,耗时 XXX`,运行状态挂到最后,不插在中间。
|
||||
const headMeta = [
|
||||
timing.durationText ? `耗时 ${timing.durationText}` : '',
|
||||
hasFailure ? '有操作失败' : '',
|
||||
toolRunning ? '进行中' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
const headLabel = [summary, headMeta].filter(Boolean).join(',');
|
||||
return (
|
||||
<AgentMessageContent
|
||||
as="section"
|
||||
@@ -115,7 +99,8 @@ export function ToolCallGroup({
|
||||
data-testid="agent-tool-call-group"
|
||||
aria-label="陶泥儿执行过程"
|
||||
data-status={status}
|
||||
data-duration-ms={totalDurationMs ?? ''}
|
||||
data-has-failure={hasFailure ? 'true' : undefined}
|
||||
data-duration-ms={timing.durationMs ?? ''}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -127,26 +112,17 @@ export function ToolCallGroup({
|
||||
title={headLabel}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
>
|
||||
<span className="agent-tool-call-group-icon" aria-hidden="true">
|
||||
<ToolCallKindIcon kind={orderedCalls[0]?.kind ?? 'other'} />
|
||||
</span>
|
||||
<span className="agent-tool-call-group-summary">{summary}</span>
|
||||
<span className="agent-tool-call-group-meta">
|
||||
{running ? (
|
||||
<span className="agent-tool-call-group-running">进行中</span>
|
||||
) : timeLabel ? (
|
||||
<span className="agent-tool-call-group-time">{timeLabel}</span>
|
||||
) : null}
|
||||
{durationText ? (
|
||||
<span className="agent-tool-call-group-duration">
|
||||
{`用时 ${durationText}`}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className="agent-tool-call-group-chevron"
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
<AgentProcessSummary
|
||||
icon={<ToolCallKindIcon kind={orderedCalls[0]?.kind ?? 'other'} />}
|
||||
preview={summary}
|
||||
meta={headMeta ? `,${headMeta}` : ''}
|
||||
chevron={
|
||||
<ChevronDown
|
||||
className="agent-tool-call-group-chevron"
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
@@ -156,7 +132,7 @@ export function ToolCallGroup({
|
||||
>
|
||||
<ul className="agent-tool-call-group-rows">
|
||||
{orderedCalls.map((call) => (
|
||||
<ToolCallRow key={call.id} call={call} active={active} />
|
||||
<ToolCallRow key={call.id} call={call} active={active} now={now} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -168,14 +144,18 @@ export function ToolCallGroup({
|
||||
function ToolCallRow({
|
||||
call,
|
||||
active,
|
||||
now,
|
||||
}: {
|
||||
call: GameCreatorDirectToolCall;
|
||||
call: DirectChatToolCard;
|
||||
active: boolean;
|
||||
/** 所属回合的运行时钟;只在"该工具仍在跑"时用得上。 */
|
||||
now: number;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const detailId = useId();
|
||||
const text = toolCallRowText(call);
|
||||
const durationMs = toolCallDurationMs(call);
|
||||
const liveRunning = active && call.status === 'running';
|
||||
const durationMs = toolCallDurationMs(call, { now, running: liveRunning });
|
||||
const durationText = formatToolCallDuration(durationMs);
|
||||
// "执行中"只在**正在跑的回合**里显示;已结束的回合里残留的 running 快照按已完成处理,
|
||||
// 否则用户会看到一条永远停在"执行中"的记录。
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 首屏历史锚点闸门:把「订阅回执里的 `lastCompletedItemId`」交给「首屏切片读取」。
|
||||
*
|
||||
* 这个 id 是订阅那一刻最后一条已完成条目,也是历史切片与运行态事件的唯一分界:首屏切片只
|
||||
* 允许取到它为止(含该条),比它更新的条目只能来自运行态事件。回执到达之前首屏必须等它,
|
||||
* 不能退化成"取文件尾"——那会把回执之后才完成的条目也拉进历史,与运行态事件重叠。
|
||||
*
|
||||
* 生命周期:打开项目时首屏读取先开一道闸门(订阅 effect 还没跑),订阅侧复用同一道闸门并在
|
||||
* 回执到达后 `settle` 它;订阅不可用 / 失败 / 切项目时 settle 成 `null`,首屏退化成取文件尾。
|
||||
* 一道闸门只服务这次订阅的第一次首屏读取,用过之后置 `consumed`。
|
||||
*/
|
||||
|
||||
export type DirectHistoryAnchorGate = {
|
||||
projectPath: string;
|
||||
anchor: Promise<string | null>;
|
||||
settle: (anchor: string | null) => void;
|
||||
/** 是否已经被一次首屏读取用掉:同一道闸门只锚定"这次订阅的第一次首屏"。 */
|
||||
consumed: boolean;
|
||||
};
|
||||
|
||||
/** 为某个项目开一道闸门:`settle` 由订阅侧调用,`anchor` 由首屏读取 `await`。 */
|
||||
export function openDirectHistoryAnchorGate(
|
||||
projectPath: string,
|
||||
): DirectHistoryAnchorGate {
|
||||
let settle: (anchor: string | null) => void = () => {};
|
||||
const anchor = new Promise<string | null>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
return { projectPath, anchor, settle, consumed: false };
|
||||
}
|
||||
|
||||
/** 订阅侧用:已有同项目闸门就复用(首屏可能已经开好),换项目才新开一道。 */
|
||||
export function reuseOrOpenDirectHistoryAnchorGate(
|
||||
current: DirectHistoryAnchorGate | null,
|
||||
projectPath: string,
|
||||
): DirectHistoryAnchorGate {
|
||||
if (current && current.projectPath === projectPath) {
|
||||
return current;
|
||||
}
|
||||
return openDirectHistoryAnchorGate(projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 首屏读取侧用:取这次要等的闸门,`null` 表示这次首屏不该等锚点。
|
||||
*
|
||||
* 没有这个项目的闸门(打开项目时订阅 effect 还没跑)就新开一道;同一个订阅下已经用过
|
||||
* (重开同一个项目)没有新回执可等,返回 `null` 让调用方按当前文件尾取尾屏。
|
||||
*/
|
||||
export function directHistoryAnchorGateToWaitFor(
|
||||
current: DirectHistoryAnchorGate | null,
|
||||
projectPath: string,
|
||||
): DirectHistoryAnchorGate | null {
|
||||
const sameProjectGate =
|
||||
current && current.projectPath === projectPath ? current : null;
|
||||
if (!sameProjectGate) {
|
||||
return openDirectHistoryAnchorGate(projectPath);
|
||||
}
|
||||
return sameProjectGate.consumed ? null : sameProjectGate;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* DirectProject 历史分页连拉:一次翻页操作连续取页,直到出现用户看得见的变化。
|
||||
*
|
||||
* 可见性判断留在前端聊天投影(后端切片只按原始条目切片):工具卡片与思考文本同样占满一屏,
|
||||
* 所以一整页完全可能都落进已经渲染的回合里,对用户就是"点了没变化"。判据因此取**合并后
|
||||
* 聊天投影的回合数增加**(出现新的用户气泡),而不是"这一页里有没有能渲染的条目"。
|
||||
*
|
||||
* 这里是该判据的唯一出处:首屏与「显示更早」共用同一条循环,别在调用点各写一份。
|
||||
*/
|
||||
|
||||
import { DIRECT_HISTORY_MAX_PAGES_PER_ACTION } from '../../app/constants';
|
||||
import {
|
||||
type DirectChatEntry,
|
||||
mergeHistoryEntries,
|
||||
projectDirectHistoryItems,
|
||||
} from './directThreadChat';
|
||||
import type {
|
||||
DirectThreadHistorySlice,
|
||||
DirectThreadItem,
|
||||
} from './directThreadEvents';
|
||||
import { buildDirectChatTurns } from './directTurnPresentation';
|
||||
|
||||
export type DirectHistoryPages = {
|
||||
/** 累积到的条目,保持文件顺序(旧 → 新)。 */
|
||||
items: DirectThreadItem[];
|
||||
/** 后端声明的"还有更早的历史";取不动时收口为 false。 */
|
||||
hasMore: boolean;
|
||||
/** 下一屏的锚点;取不动或失败时停在最后一个可用的锚点上。 */
|
||||
firstItemId: string | null;
|
||||
/** 中断原因;`null` 表示正常停止。 */
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
function visibleTurnCount(entries: readonly DirectChatEntry[]): number {
|
||||
return buildDirectChatTurns({ entries }).length;
|
||||
}
|
||||
|
||||
/** 取页是"从新往旧"的,拼回文件顺序要整个翻过来。 */
|
||||
function inFileOrder(pages: readonly (readonly DirectThreadItem[])[]) {
|
||||
return [...pages].reverse().flat();
|
||||
}
|
||||
|
||||
export async function readDirectHistoryPages({
|
||||
existingEntries,
|
||||
beforeItemId,
|
||||
readSlice,
|
||||
}: {
|
||||
/** 当前聊天视图里的条目:判据必须和用户看到的是同一份。 */
|
||||
existingEntries: readonly DirectChatEntry[];
|
||||
beforeItemId: string | null;
|
||||
readSlice: (beforeItemId: string | null) => Promise<DirectThreadHistorySlice>;
|
||||
}): Promise<DirectHistoryPages> {
|
||||
const baseTurnCount = visibleTurnCount(existingEntries);
|
||||
const pages: DirectThreadItem[][] = [];
|
||||
let anchor = beforeItemId;
|
||||
let hasMore = false;
|
||||
let error: unknown = null;
|
||||
for (let page = 0; page < DIRECT_HISTORY_MAX_PAGES_PER_ACTION; page += 1) {
|
||||
let slice: DirectThreadHistorySlice;
|
||||
try {
|
||||
slice = await readSlice(anchor);
|
||||
} catch (thrown) {
|
||||
// 已经取到的页照常交给调用方:第 N 页失败不该丢掉前 N-1 页。
|
||||
error = thrown;
|
||||
break;
|
||||
}
|
||||
pages.push([...slice.items]);
|
||||
hasMore = slice.hasMore;
|
||||
const nextAnchor = slice.firstItemId;
|
||||
if (slice.items.length === 0 || !nextAnchor || nextAnchor === anchor) {
|
||||
// 锚点不前进就再也取不到更早的页:继续只会重复拿回同一个窗口。按"取不动了"收口。
|
||||
hasMore = false;
|
||||
break;
|
||||
}
|
||||
anchor = nextAnchor;
|
||||
if (!hasMore) break;
|
||||
const merged = mergeHistoryEntries(
|
||||
projectDirectHistoryItems(inFileOrder(pages)),
|
||||
existingEntries,
|
||||
);
|
||||
if (visibleTurnCount(merged) > baseTurnCount) break;
|
||||
}
|
||||
return { items: inFileOrder(pages), hasMore, firstItemId: anchor, error };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,123 +1,17 @@
|
||||
import type {
|
||||
ChatMessage,
|
||||
LocalConversationMessageRecord,
|
||||
} from '../../app/types';
|
||||
/**
|
||||
* DirectProject 运行态事件的线上类型。
|
||||
*
|
||||
* 类型由 Rust 侧 ts-rs 导出(改完 Rust 模型后跑 `cargo test export_bindings`),这里只做
|
||||
* 入口转发:前端不再自己抄一份形状,字段增删必须改 Rust。
|
||||
*/
|
||||
|
||||
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: unknown[];
|
||||
hasMore: boolean;
|
||||
itemTimestamps?: Record<string, number>;
|
||||
oldestItemId?: string | null;
|
||||
};
|
||||
|
||||
/** 游标取原始响应,而非过滤后的聊天消息;拒绝不能前进的页,避免静默反复回读。 */
|
||||
export function directThreadHistoryPage(
|
||||
slice: DirectThreadHistorySlice,
|
||||
previousCursor: string | null = null,
|
||||
) {
|
||||
const first = slice.items[0];
|
||||
const firstId =
|
||||
first && typeof first === 'object' && 'id' in first
|
||||
? (first as { id?: unknown }).id
|
||||
: null;
|
||||
const cursor =
|
||||
slice.oldestItemId ??
|
||||
(typeof firstId === 'string' && firstId ? firstId : null);
|
||||
if (slice.hasMore && (!cursor || cursor === previousCursor)) {
|
||||
throw new Error('对话历史分页游标未前进,请重新读取项目历史');
|
||||
}
|
||||
return {
|
||||
messages: directThreadHistoryItemsToMessages(
|
||||
slice.items,
|
||||
slice.itemTimestamps,
|
||||
),
|
||||
hasMore: slice.hasMore,
|
||||
cursor,
|
||||
};
|
||||
}
|
||||
|
||||
/** 保留当前实时/已显示版本;原始身份相同的回读消息不能插入第二次。 */
|
||||
export function prependDirectHistoryMessages(
|
||||
current: readonly ChatMessage[],
|
||||
older: readonly ChatMessage[],
|
||||
): ChatMessage[] {
|
||||
const ids = new Set(
|
||||
current.flatMap((message) =>
|
||||
message.messageId ? [message.messageId] : [],
|
||||
),
|
||||
);
|
||||
const additions = older.filter((message) => {
|
||||
if (!message.messageId) return true;
|
||||
if (ids.has(message.messageId)) return false;
|
||||
ids.add(message.messageId);
|
||||
return true;
|
||||
});
|
||||
return [...additions, ...current];
|
||||
}
|
||||
|
||||
export function directThreadHistoryItemsToMessages(
|
||||
items: unknown[],
|
||||
itemTimestamps: Readonly<Record<string, number>> = {},
|
||||
): LocalConversationMessageRecord[] {
|
||||
return items.flatMap((raw) => {
|
||||
if (!raw || typeof raw !== 'object') return [];
|
||||
const item = raw as Record<string, unknown>;
|
||||
const role = item.role;
|
||||
if (role !== 'user' && role !== 'assistant') return [];
|
||||
const messageRole = role as 'user' | 'assistant';
|
||||
const content = Array.isArray(item.content)
|
||||
? item.content
|
||||
.map((part) =>
|
||||
part && typeof part === 'object' && 'text' in part
|
||||
? (part as { text?: unknown }).text
|
||||
: null,
|
||||
)
|
||||
.filter((text): text is string => typeof text === 'string')
|
||||
.join('')
|
||||
: '';
|
||||
if (!content) return [];
|
||||
const messageId = typeof item.id === 'string' ? item.id : undefined;
|
||||
return [
|
||||
{
|
||||
schemaVersion: 'agc-direct-project-context.v1',
|
||||
role: messageRole,
|
||||
content,
|
||||
agentId: null,
|
||||
messageId,
|
||||
updatedAt: messageId ? (itemTimestamps[messageId] ?? 0) : 0,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** 只有这些状态表示仍持有活动回合;Provider 回放的终态不是活动快照。 */
|
||||
export function isDirectTurnInProgress(
|
||||
status: string | null | undefined,
|
||||
): status is 'accepted' | 'running' | 'streaming' | 'finalizing' {
|
||||
return (
|
||||
status === 'accepted' ||
|
||||
status === 'running' ||
|
||||
status === 'streaming' ||
|
||||
status === 'finalizing'
|
||||
);
|
||||
}
|
||||
|
||||
export type { DirectThreadEvent, DirectThreadItem } from './generated';
|
||||
export type {
|
||||
DirectThreadConsumeResult,
|
||||
DirectThreadDeltaKind,
|
||||
DirectThreadEvent,
|
||||
DirectThreadFileChange,
|
||||
DirectThreadHistorySlice,
|
||||
DirectThreadItem,
|
||||
DirectThreadRequestKind,
|
||||
DirectThreadSubscriptionBootstrap,
|
||||
} from './generated';
|
||||
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* DirectProject「原始条目 → 聊天条目」投影。
|
||||
*
|
||||
* 输入是 Rust 侧 ts-rs 导出的 `DirectThreadItem`(脱敏后的 Codex 原始条目),工具卡片的
|
||||
* `kind`、标题、折叠摘要、状态判定和可见性全部在这里完成。运行态事件与历史切片走同一个
|
||||
* 函数,因此实时与回读不可能出现两套口径。
|
||||
*/
|
||||
|
||||
import type {
|
||||
GameCreatorDirectToolCallChange,
|
||||
GameCreatorDirectToolCallDetail,
|
||||
GameCreatorDirectToolCallKind,
|
||||
GameCreatorDirectToolCallStatus,
|
||||
} from '../../app/types';
|
||||
import type { DirectChatEntry, DirectChatToolCard } from './directThreadChat';
|
||||
import type { DirectThreadItem } from './directThreadEvents';
|
||||
|
||||
/** 折叠态摘要上限,与卡片契约一致。 */
|
||||
const TOOL_SUMMARY_MAX_CHARS = 120;
|
||||
|
||||
const FAILED_ITEM_STATUS = new Set([
|
||||
'failed',
|
||||
'declined',
|
||||
'cancelled',
|
||||
'canceled',
|
||||
'aborted',
|
||||
]);
|
||||
|
||||
function firstLine(value: string): string {
|
||||
const [line = ''] = value.split('\n');
|
||||
const trimmed = line.trim();
|
||||
return trimmed.length > TOOL_SUMMARY_MAX_CHARS
|
||||
? `${trimmed.slice(0, TOOL_SUMMARY_MAX_CHARS)}…`
|
||||
: trimmed;
|
||||
}
|
||||
|
||||
function toolKindFromFunctionName(name: string): GameCreatorDirectToolCallKind {
|
||||
switch (name) {
|
||||
case 'exec_command':
|
||||
case 'shell':
|
||||
case 'exec':
|
||||
return 'command';
|
||||
case 'apply_patch':
|
||||
case 'write_file':
|
||||
case 'edit_file':
|
||||
case 'create_file':
|
||||
return 'file_change';
|
||||
case 'web_search':
|
||||
case 'web_search_preview':
|
||||
return 'web_search';
|
||||
default:
|
||||
return 'mcp_tool';
|
||||
}
|
||||
}
|
||||
|
||||
function toolStatus(
|
||||
status: string | null,
|
||||
exitCode: number | null,
|
||||
): GameCreatorDirectToolCallStatus {
|
||||
// 显式失败 / 取消终态最优先:上游已经说这次调用失败,就不能再被别的字段洗成成功。
|
||||
if (status && FAILED_ITEM_STATUS.has(status)) return 'failed';
|
||||
// 非零退出码优先于 `completed`:命令跑完了不等于成功(退出码 1 是失败,只认退出码,
|
||||
// 不从 output 的自然语言里推断)。
|
||||
if (typeof exitCode === 'number' && exitCode !== 0) return 'failed';
|
||||
if (status === 'completed') return 'completed';
|
||||
// 退出码 0:正常完成。
|
||||
if (typeof exitCode === 'number') return 'completed';
|
||||
// 还有状态但没有终态信息:仍在跑;完全没有状态字段的调用按「已完成」处理。
|
||||
return status ? 'running' : 'completed';
|
||||
}
|
||||
|
||||
function toolTitle(
|
||||
kind: GameCreatorDirectToolCallKind,
|
||||
changes: readonly GameCreatorDirectToolCallChange[],
|
||||
): string {
|
||||
switch (kind) {
|
||||
case 'command':
|
||||
return '执行命令';
|
||||
case 'file_change': {
|
||||
const paths = new Set(changes.map((change) => change.path));
|
||||
return paths.size > 0 ? `编辑 ${paths.size} 个文件` : '编辑文件';
|
||||
}
|
||||
case 'web_search':
|
||||
return '联网检索';
|
||||
case 'context_compaction':
|
||||
return '整理上下文';
|
||||
default:
|
||||
return '调用工具';
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
kind: GameCreatorDirectToolCallKind;
|
||||
/** 输出条目只带输出:标题与摘要留空,交给先到的调用快照。 */
|
||||
outputOnly?: boolean;
|
||||
tool?: string;
|
||||
command?: string;
|
||||
output?: string;
|
||||
status: GameCreatorDirectToolCallStatus;
|
||||
changes?: GameCreatorDirectToolCallChange[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 条目的事件级阶段时间:`item.started` / `item.completed` 事件上的 `at`。
|
||||
*
|
||||
* 工具卡片的时间边界**只认这里**:条目自身的 `item.at` 是展示时间,不能当起止。
|
||||
* 缺失(0)就留空:这个边界不知道,由展示层隐藏不能证明的耗时,不猜时间。
|
||||
*/
|
||||
export type DirectItemPhaseTime = {
|
||||
/** 收到 `item.started` 时该事件的时间;其它事件与历史切片不传。 */
|
||||
startedAt?: number;
|
||||
/** 收到 `item.completed` 时该事件的时间;其它事件与历史切片不传。 */
|
||||
completedAt?: number;
|
||||
};
|
||||
|
||||
/** 阶段时间合法性:缺失 / 0 / 非有限一律算"没有这个边界"。 */
|
||||
function phaseTime(value: number | undefined) {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||
? value
|
||||
: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 阶段优先的状态:只有 `item.started` 阶段、而且条目本身没有终态信息时,工具就是"运行中"。
|
||||
*
|
||||
* 有些条目(文件变更 / 联网检索 / 上下文整理 / 没有状态字段的 MCP 调用)type 上不带状态,
|
||||
* 收到开始事件时不能一律当"已完成",否则它们在块里永远不会动态增长;真正结束由同身份的
|
||||
* `item.completed` 事件给出。条目自带的失败 / 完成状态优先,不被阶段改写。
|
||||
*/
|
||||
function toolStatusForPhase(
|
||||
status: GameCreatorDirectToolCallStatus,
|
||||
phase: DirectItemPhaseTime,
|
||||
): GameCreatorDirectToolCallStatus {
|
||||
if (
|
||||
phaseTime(phase.startedAt) > 0 &&
|
||||
phaseTime(phase.completedAt) === 0 &&
|
||||
status === 'completed'
|
||||
) {
|
||||
return 'running';
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* `function_call` 的时间只表示"这次调用什么时候发起":即使它是随 `item.completed`
|
||||
* 到达的(调用条目本身完成了),那也不是工具结束——结束由 `function_call_output` 给出。
|
||||
* 因此这里只认起点,终点留空,不会伪造一个 0 秒的完成。
|
||||
*/
|
||||
function callStartPhase(phase: DirectItemPhaseTime): DirectItemPhaseTime {
|
||||
return {
|
||||
startedAt: phaseTime(phase.startedAt) || phaseTime(phase.completedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function buildToolCard(
|
||||
input: ToolCardInput,
|
||||
phase: DirectItemPhaseTime,
|
||||
): 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 =
|
||||
(input.kind === 'mcp_tool' ? (input.tool ?? '') : '') ||
|
||||
detail.command ||
|
||||
changes[0]?.path ||
|
||||
input.tool ||
|
||||
'';
|
||||
return {
|
||||
schemaVersion: 'agc-tool-call.v1',
|
||||
id: input.itemId,
|
||||
kind: input.kind,
|
||||
title: input.outputOnly ? '' : toolTitle(input.kind, changes),
|
||||
summary: input.outputOnly ? '' : firstLine(summarySource),
|
||||
// 输出条目本身就是"结果快照",不因阶段被改成运行中。
|
||||
status: input.outputOnly
|
||||
? input.status
|
||||
: toolStatusForPhase(input.status, phase),
|
||||
detail,
|
||||
startedAt: phaseTime(phase.startedAt),
|
||||
// 终点只有一种来源:明确的完成事件时间。运行中的卡片拿不到终点(0),
|
||||
// 由展示层用宿主当前时钟算增长,绝不用快照更新时间冒充"现在"。
|
||||
updatedAt: phaseTime(phase.completedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function toolCardFromItem(
|
||||
item: DirectThreadItem,
|
||||
phase: DirectItemPhaseTime,
|
||||
): DirectChatToolCard | null {
|
||||
switch (item.itemType) {
|
||||
case 'function_call':
|
||||
return buildToolCard(
|
||||
{
|
||||
itemId: item.itemId,
|
||||
kind: toolKindFromFunctionName(item.name),
|
||||
tool: item.name,
|
||||
command: item.arguments,
|
||||
status: 'running',
|
||||
},
|
||||
callStartPhase(phase),
|
||||
);
|
||||
case 'function_call_output':
|
||||
return buildToolCard(
|
||||
{
|
||||
itemId: item.itemId,
|
||||
kind: 'other',
|
||||
outputOnly: true,
|
||||
output: item.output,
|
||||
status: 'completed',
|
||||
},
|
||||
phase,
|
||||
);
|
||||
case 'commandExecution':
|
||||
return buildToolCard(
|
||||
{
|
||||
itemId: item.itemId,
|
||||
kind: 'command',
|
||||
command: item.command,
|
||||
output: item.output ?? '',
|
||||
status: toolStatus(item.status, item.exitCode),
|
||||
},
|
||||
phase,
|
||||
);
|
||||
case 'fileChange':
|
||||
return buildToolCard(
|
||||
{
|
||||
itemId: item.itemId,
|
||||
kind: 'file_change',
|
||||
changes: fileChanges(item),
|
||||
status: 'completed',
|
||||
},
|
||||
phase,
|
||||
);
|
||||
case 'mcpToolCall':
|
||||
return buildToolCard(
|
||||
{
|
||||
itemId: item.itemId,
|
||||
kind: 'mcp_tool',
|
||||
tool: item.tool,
|
||||
command: item.arguments,
|
||||
output: item.output ?? '',
|
||||
status: toolStatus(item.status, null),
|
||||
},
|
||||
phase,
|
||||
);
|
||||
case 'webSearch':
|
||||
return buildToolCard(
|
||||
{
|
||||
itemId: item.itemId,
|
||||
kind: 'web_search',
|
||||
command: item.query ?? '',
|
||||
output: item.output ?? '',
|
||||
status: 'completed',
|
||||
},
|
||||
phase,
|
||||
);
|
||||
case 'contextCompaction':
|
||||
return buildToolCard(
|
||||
{
|
||||
itemId: item.itemId,
|
||||
kind: 'context_compaction',
|
||||
command: '整理上下文',
|
||||
status: 'completed',
|
||||
},
|
||||
phase,
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 原始条目投影成聊天条目;不属于聊天内容的条目返回 `null`。
|
||||
*
|
||||
* 可见性判定只在这里:系统 / 开发者 message、无正文的空条目、未识别的 item 类型都不进
|
||||
* 聊天视图。`other` 是 Rust 原样透传的未知类型,要不要显示属于前端可见性决策,当前不显示。
|
||||
*/
|
||||
export function projectDirectThreadItem(
|
||||
item: DirectThreadItem | null | undefined,
|
||||
phase: DirectItemPhaseTime = {},
|
||||
): DirectChatEntry | null {
|
||||
if (!item) return null;
|
||||
// 身份是条目唯一的主键:拿不到身份的载荷既不能渲染也不能合并,只丢弃这一条。
|
||||
const itemId = typeof item.itemId === 'string' ? item.itemId.trim() : '';
|
||||
if (!itemId) return null;
|
||||
|
||||
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, phase);
|
||||
if (!toolCall) return null;
|
||||
return {
|
||||
itemId,
|
||||
kind: 'tool',
|
||||
role: null,
|
||||
text: null,
|
||||
toolCall,
|
||||
at: item.at,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+330
-247
File diff suppressed because it is too large
Load Diff
+213
-143
@@ -1,7 +1,6 @@
|
||||
import type {
|
||||
GameCreatorDirectToolCall,
|
||||
GameCreatorDirectToolCallKind,
|
||||
} from '../../app/types';
|
||||
import { formatElapsedDuration } from '../../../../../packages/shared/src/lib/formatElapsedDuration';
|
||||
import type { GameCreatorDirectToolCallKind } from '../../app/types';
|
||||
import type { DirectChatToolCard } from './directThreadChat';
|
||||
|
||||
/**
|
||||
* 工具调用折叠块的纯文案计算:汇总 / 行文案。
|
||||
@@ -11,25 +10,6 @@ import type {
|
||||
* 组件文件不导出非组件值,也便于单测直接断言文案规则)。
|
||||
*/
|
||||
|
||||
/** 汇总文案里 kind 的固定顺序:command → file_change → mcp_tool → web_search → context_compaction → other。 */
|
||||
const TOOL_CALL_KIND_ORDER: GameCreatorDirectToolCallKind[] = [
|
||||
'command',
|
||||
'file_change',
|
||||
'mcp_tool',
|
||||
'web_search',
|
||||
'context_compaction',
|
||||
'other',
|
||||
];
|
||||
|
||||
const TOOL_CALL_KIND_LABELS: Record<GameCreatorDirectToolCallKind, string> = {
|
||||
command: '命令',
|
||||
file_change: '文件变更',
|
||||
mcp_tool: '工具调用',
|
||||
web_search: '联网搜索',
|
||||
context_compaction: '上下文整理',
|
||||
other: '其他操作',
|
||||
};
|
||||
|
||||
/** 行文案动词:`已运行 {summary}` / `已编辑 {summary}` / …,`context_compaction` 不带 summary。 */
|
||||
export const TOOL_CALL_ROW_VERBS: Partial<
|
||||
Record<GameCreatorDirectToolCallKind, string>
|
||||
@@ -41,43 +21,21 @@ export const TOOL_CALL_ROW_VERBS: Partial<
|
||||
other: '已执行',
|
||||
};
|
||||
|
||||
/** 汇总文案:按 kind 计数、固定顺序拼成 `已执行 5 个命令、2 个文件变更`;空集合返回空串。 */
|
||||
/**
|
||||
* 汇总文案:`执行了 N 个操作`(N = 这一组自己的调用数,不按段落数、不拆 kind)。
|
||||
*
|
||||
* 与耗时一起构成用户口径的 `执行了 X 个操作,耗时 XXX`:耗时由调用方作为 meta 追加,
|
||||
* 这里只出前半句。
|
||||
*/
|
||||
export function toolCallGroupSummary(
|
||||
calls: GameCreatorDirectToolCall[],
|
||||
running = false,
|
||||
) {
|
||||
const counts = new Map<string, number>();
|
||||
for (const call of calls) {
|
||||
counts.set(call.kind, (counts.get(call.kind) ?? 0) + 1);
|
||||
}
|
||||
const parts: string[] = [];
|
||||
const append = (kind: string) => {
|
||||
const count = counts.get(kind) ?? 0;
|
||||
if (count <= 0) {
|
||||
return;
|
||||
}
|
||||
counts.delete(kind);
|
||||
const label =
|
||||
TOOL_CALL_KIND_LABELS[kind as GameCreatorDirectToolCallKind] ??
|
||||
'其他操作';
|
||||
parts.push(`${count} 个${label}`);
|
||||
};
|
||||
for (const kind of TOOL_CALL_KIND_ORDER) {
|
||||
append(kind);
|
||||
}
|
||||
// 契约外的 kind:不丢计数,落到末尾的「其他操作」。
|
||||
for (const kind of [...counts.keys()]) {
|
||||
append(kind);
|
||||
}
|
||||
return parts.length > 0
|
||||
? running
|
||||
? `已执行 ${parts.join('、')}进行中`
|
||||
: `已执行 ${parts.join('、')}`
|
||||
: '';
|
||||
calls: readonly DirectChatToolCard[],
|
||||
): string {
|
||||
if (calls.length === 0) return '';
|
||||
return `执行了 ${calls.length} 个操作`;
|
||||
}
|
||||
|
||||
/** 一行工具的文案:只用工具本身的摘要(不带"已运行"这类动词前缀),状态由行尾状态列表达。 */
|
||||
export function toolCallRowText(call: GameCreatorDirectToolCall) {
|
||||
export function toolCallRowText(call: DirectChatToolCard) {
|
||||
// 行首不再写"已运行/已编辑"这类动词前缀:命令状态由行尾的状态列表达
|
||||
// (执行中 / 已执行 / 失败),前缀会和它重复。
|
||||
if (call.kind === 'context_compaction') {
|
||||
@@ -86,7 +44,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 +72,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)
|
||||
@@ -175,97 +133,65 @@ function unwrapDisplayArgument(argument: string) {
|
||||
|
||||
/**
|
||||
* 单条工具的耗时(毫秒)。
|
||||
* `startedAt` 为 0(缺失)或 `updatedAt < startedAt`(时间倒序)时返回 `null`:
|
||||
* 这两种情况不显示耗时,不显示 `0s` / 负数。
|
||||
*
|
||||
* 边界只来自事件级时间:`startedAt` 是 `item.started` 事件的时间,完成态用
|
||||
* `item.completed` 事件的时间。两者任一缺失(0 / 非有限)或时间倒序时返回 `null`:
|
||||
* 没有开始事件的完成快照不猜开始时间,缺失与倒序都不显示耗时。
|
||||
*
|
||||
* `running` 为真(该工具仍在跑、且所属回合仍在跑)时用 `now` 当终点:以时间戳差值
|
||||
* 计算,不按 tick 累加,后台节流后回到前台不会累计漂移。合法 0 是有效耗时,照常返回 0。
|
||||
*/
|
||||
export function toolCallDurationMs(
|
||||
call: Pick<GameCreatorDirectToolCall, 'startedAt' | 'updatedAt'>,
|
||||
call: Pick<DirectChatToolCard, 'startedAt' | 'updatedAt'>,
|
||||
options?: { now?: number; running?: boolean },
|
||||
): number | null {
|
||||
const startedAt = Number.isFinite(call.startedAt) ? call.startedAt : 0;
|
||||
const updatedAt = Number.isFinite(call.updatedAt) ? call.updatedAt : 0;
|
||||
if (startedAt <= 0 || updatedAt < startedAt) {
|
||||
if (startedAt <= 0) {
|
||||
return null;
|
||||
}
|
||||
return updatedAt - startedAt;
|
||||
const endAt = options?.running
|
||||
? Number(options.now)
|
||||
: Number.isFinite(call.updatedAt)
|
||||
? call.updatedAt
|
||||
: 0;
|
||||
if (!Number.isFinite(endAt) || endAt <= 0 || endAt < startedAt) {
|
||||
return null;
|
||||
}
|
||||
return endAt - startedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条工具的耗时文案:`0.4s`(<1s)/ `12.3s`(<60s,整秒省略小数)/ `2m 5s`(≥60s)。
|
||||
* 无法计算的耗时(`null` / 0 / 负数)返回 `null`。
|
||||
* 单条工具与整轮、生成任务共用中文时分秒格式;未知耗时隐藏,合法 0 显示 0.0秒。
|
||||
*/
|
||||
export function formatToolCallDuration(ms: number | null | undefined) {
|
||||
if (ms === null || ms === undefined || !Number.isFinite(ms) || ms <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (ms < 60000) {
|
||||
const tenths = Math.max(1, Math.round(ms / 100));
|
||||
if (tenths < 600) {
|
||||
const value = tenths / 10;
|
||||
return Number.isInteger(value) ? `${value}s` : `${value.toFixed(1)}s`;
|
||||
}
|
||||
}
|
||||
const totalSeconds = Math.max(60, Math.round(ms / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const restSeconds = totalSeconds % 60;
|
||||
return restSeconds === 0 ? `${minutes}m` : `${minutes}m ${restSeconds}s`;
|
||||
return formatElapsedDuration(ms);
|
||||
}
|
||||
|
||||
/** 一回合总用时:该回合所有工具的 `min(startedAt)` → `max(updatedAt)`;取不到返回 `null`。 */
|
||||
export function turnToolCallDurationMs(
|
||||
calls: Array<Pick<GameCreatorDirectToolCall, 'startedAt' | 'updatedAt'>>,
|
||||
): number | null {
|
||||
let minStartedAt = Number.POSITIVE_INFINITY;
|
||||
let maxUpdatedAt = Number.NEGATIVE_INFINITY;
|
||||
for (const call of calls) {
|
||||
const startedAt = Number.isFinite(call.startedAt) ? call.startedAt : 0;
|
||||
const updatedAt = Number.isFinite(call.updatedAt) ? call.updatedAt : 0;
|
||||
if (startedAt > 0) {
|
||||
minStartedAt = Math.min(minStartedAt, startedAt);
|
||||
}
|
||||
if (updatedAt > 0) {
|
||||
maxUpdatedAt = Math.max(maxUpdatedAt, updatedAt);
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(minStartedAt) || !Number.isFinite(maxUpdatedAt)) {
|
||||
return null;
|
||||
}
|
||||
if (maxUpdatedAt < minStartedAt) {
|
||||
return null;
|
||||
}
|
||||
return maxUpdatedAt - minStartedAt;
|
||||
}
|
||||
|
||||
/** 块头总用时文案:`42秒` / `4分钟` / `5分钟 45秒`;无法计算的耗时返回 `null`。 */
|
||||
/**
|
||||
* 整轮总耗时文案:0.0秒 / 1分02.3秒 / 1时02分05.2秒。
|
||||
* 无法计算的耗时(`null` / `undefined` / 非有限 / 负数)返回 `null`;合法 0 显示 `0.0秒`。
|
||||
*/
|
||||
export function formatTurnDuration(ms: number | null | undefined) {
|
||||
if (ms === null || ms === undefined || !Number.isFinite(ms) || ms <= 0) {
|
||||
return null;
|
||||
}
|
||||
const seconds = Math.max(1, Math.round(ms / 1000));
|
||||
if (seconds < 60) {
|
||||
return `${seconds}秒`;
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const restSeconds = seconds % 60;
|
||||
return restSeconds === 0
|
||||
? `${minutes}分钟`
|
||||
: `${minutes}分钟 ${restSeconds}秒`;
|
||||
return formatElapsedDuration(ms);
|
||||
}
|
||||
|
||||
/** 该回合的结束时间:`max(updatedAt)`;取不到返回 0。 */
|
||||
export function turnToolCallEndedAt(
|
||||
calls: Array<Pick<GameCreatorDirectToolCall, 'updatedAt'>>,
|
||||
/**
|
||||
* 时间戳取整到展示用的 100ms 网格(非法 / 缺失一律回 0)。
|
||||
*
|
||||
* 时间范围与总耗时必须来自同一组取整边界:分别对原始毫秒做四舍五入会让
|
||||
* `14:20:05.9 → 14:20:06.1` 与 `0.1秒` 这类组合互相矛盾。
|
||||
*/
|
||||
export function quantizeDisplayMs(value: number | null | undefined) {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||
? Math.round(value / 100) * 100
|
||||
: 0;
|
||||
}
|
||||
|
||||
/** 本地 `HH:mm:ss`;`tenths` 为真时补一位小数(`HH:mm:ss.S`)。缺失(0 / 非法)返回 `null`。 */
|
||||
export function formatClockTime(
|
||||
timestamp: number | null | undefined,
|
||||
options?: { tenths?: boolean },
|
||||
) {
|
||||
let maxUpdatedAt = 0;
|
||||
for (const call of calls) {
|
||||
if (Number.isFinite(call.updatedAt) && call.updatedAt > maxUpdatedAt) {
|
||||
maxUpdatedAt = call.updatedAt;
|
||||
}
|
||||
}
|
||||
return maxUpdatedAt;
|
||||
}
|
||||
|
||||
/** 本地 `HH:mm:ss`;时间戳缺失(0 / 非法)返回 `null`,不编造时间。 */
|
||||
export function formatClockTime(timestamp: number | null | undefined) {
|
||||
if (
|
||||
timestamp === null ||
|
||||
timestamp === undefined ||
|
||||
@@ -279,21 +205,165 @@ export function formatClockTime(timestamp: number | null | undefined) {
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${hours}:${minutes}:${seconds}`;
|
||||
if (!options?.tenths) {
|
||||
return `${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
return `${hours}:${minutes}:${seconds}.${Math.floor(date.getMilliseconds() / 100)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 块头时间文案:该回合结束时间(`max(updatedAt)` 的本地 `HH:mm:ss`);
|
||||
* 能拿到同回合用户消息时间(`updatedAt > 0`)时显示「发送 → 结束」,取不到就只显示结束时间。
|
||||
* 整轮耗时(毫秒)= 本轮起点 → 本轮终态。
|
||||
*
|
||||
* 起点是该轮实际用户消息的发送时间,缺失时用原生 `turn.started.at`;终点是明确的
|
||||
* `turn.completed.at`,运行中则是当前时刻(回合还在跑就持续增长,即使组内工具都结束了)。
|
||||
* 两端任一缺失、非有限或倒序都返回 `null`:不伪造 `0.0秒`。
|
||||
*/
|
||||
export function turnToolCallTimeLabel(
|
||||
calls: Array<Pick<GameCreatorDirectToolCall, 'updatedAt'>>,
|
||||
userSentAt: number | null | undefined,
|
||||
) {
|
||||
const endLabel = formatClockTime(turnToolCallEndedAt(calls));
|
||||
if (!endLabel) {
|
||||
export function turnTotalDurationMs({
|
||||
startedAt,
|
||||
endedAt,
|
||||
running = false,
|
||||
now = 0,
|
||||
}: {
|
||||
/** 本轮起点:用户实际发送时间优先,缺失时原生 `turn.started.at`;0 = 未知。 */
|
||||
startedAt: number | null | undefined;
|
||||
/** 本轮明确终态时间(`turn.completed.at`);运行中忽略。 */
|
||||
endedAt?: number | null;
|
||||
/** 本轮是否仍在跑:为真时用 `now` 当终点。 */
|
||||
running?: boolean;
|
||||
now?: number;
|
||||
}): number | null {
|
||||
const rawEnd = running ? now : endedAt;
|
||||
if (
|
||||
typeof startedAt !== 'number' ||
|
||||
!Number.isFinite(startedAt) ||
|
||||
startedAt <= 0 ||
|
||||
typeof rawEnd !== 'number' ||
|
||||
!Number.isFinite(rawEnd) ||
|
||||
rawEnd <= 0 ||
|
||||
rawEnd < startedAt
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const sentLabel = formatClockTime(userSentAt ?? 0);
|
||||
return sentLabel ? `${sentLabel} → ${endLabel}` : endLabel;
|
||||
const start = quantizeDisplayMs(startedAt);
|
||||
const end = quantizeDisplayMs(rawEnd);
|
||||
if (start <= 0 || end <= 0 || end < start) {
|
||||
return null;
|
||||
}
|
||||
return end - start;
|
||||
}
|
||||
|
||||
export type DirectTurnTiming = {
|
||||
/** 展示用总耗时(毫秒,已取整到 100ms 网格);null = 边界不完整,隐藏。 */
|
||||
durationMs: number | null;
|
||||
/** `总耗时` 后面的文案;null = 隐藏。 */
|
||||
durationText: string | null;
|
||||
/** `HH:mm:ss.S → HH:mm:ss.S`;起点未知时只给终点,两端都未知为 null。 */
|
||||
timeLabel: string | null;
|
||||
};
|
||||
|
||||
export type DirectToolGroupTiming = {
|
||||
/** 本组用时(毫秒,已取整到 100ms 网格);null = 边界不完整,这一组的用时隐藏。 */
|
||||
durationMs: number | null;
|
||||
/** `本组用时` 后面的文案;null = 隐藏。 */
|
||||
durationText: string | null;
|
||||
/** 本组此刻是否仍在跑(组内有运行中的工具且回合还在跑):只有它为真才跟时钟增长。 */
|
||||
running: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 本组用时:只读**这一组自己**的工具边界 —— `min(工具开始)` → `max(工具完成)`。
|
||||
*
|
||||
* 它与整轮总耗时是两件事:整轮是用户发送 → 回合终态(含无工具的 LLM 等待),只在对话里
|
||||
* 渲染一处;组头只回答"这一组工具跑了多久"。因此这个函数**不接收任何回合边界**,也不会
|
||||
* 因为同轮另一组还在跑而跟着增长(那是上一版把两组显示成同一用时的原因)。
|
||||
*
|
||||
* - 缺任何开始时间(`startedAt <= 0`)、或某个**已结束**的工具缺完成时间(`updatedAt <= 0`)
|
||||
* → 整组用时未知:隐藏,不拿别组或整轮边界补(已知的单行耗时照常显示)。
|
||||
* - 组内还有运行中的工具且回合在跑时,终点取调用方给的宿主时钟 `now`,组头每 100ms 增长;
|
||||
* 组内工具全部拿到终态那一刻立即冻结,不等回合收口。
|
||||
* - 每条工具**自己的**原始边界先成立(终点 >= 起点,运行中的工具用 `now` 当终点),
|
||||
* 再聚合成 `min(开始)` → `max(完成)`:任何一条倒序 / 非有限都让整组隐藏,不靠别的正常
|
||||
* 工具体把坏边界掩盖成合法区间;亚 100ms 的倒序也不会被展示量化抹成 `0.0秒`。
|
||||
* - 合法的同一时刻显示 `0.0秒`。
|
||||
*/
|
||||
export function resolveToolGroupTiming(
|
||||
calls: readonly Pick<
|
||||
DirectChatToolCard,
|
||||
'status' | 'startedAt' | 'updatedAt'
|
||||
>[],
|
||||
options?: { running?: boolean; now?: number },
|
||||
): DirectToolGroupTiming {
|
||||
const hidden: DirectToolGroupTiming = {
|
||||
durationMs: null,
|
||||
durationText: null,
|
||||
running: false,
|
||||
};
|
||||
if (calls.length === 0) return hidden;
|
||||
const turnRunning = Boolean(options?.running);
|
||||
const now = Number(options?.now);
|
||||
let startedAt = 0;
|
||||
let endedAt = 0;
|
||||
let running = false;
|
||||
for (const call of calls) {
|
||||
const start = Number.isFinite(call.startedAt) ? call.startedAt : 0;
|
||||
if (start <= 0) return hidden;
|
||||
let end = 0;
|
||||
if (call.status === 'running') {
|
||||
// 运行中的工具没有终态时间:只有"组内还在跑且回合在跑"时才用当前时钟当终点。
|
||||
if (!turnRunning || !Number.isFinite(now) || now <= 0) return hidden;
|
||||
running = true;
|
||||
end = now;
|
||||
} else {
|
||||
end = Number.isFinite(call.updatedAt) ? call.updatedAt : 0;
|
||||
if (end <= 0) return hidden;
|
||||
}
|
||||
// 单条边界先判:倒序(含量化后会被抹成 0.0 秒的亚 100ms 倒序)整组隐藏,
|
||||
// 不允许被同组另一条正常工具的区间掩盖。
|
||||
if (end < start) return hidden;
|
||||
startedAt = startedAt === 0 ? start : Math.min(startedAt, start);
|
||||
endedAt = Math.max(endedAt, end);
|
||||
}
|
||||
const start = quantizeDisplayMs(startedAt);
|
||||
const end = quantizeDisplayMs(endedAt);
|
||||
// 聚合后量化:每条都已成立,聚合区间不会倒序;量化取整按单调函数做,也不会互相矛盾。
|
||||
if (start <= 0 || end <= 0 || end < start) return hidden;
|
||||
const durationMs = end - start;
|
||||
return {
|
||||
durationMs,
|
||||
durationText: formatTurnDuration(durationMs),
|
||||
running,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 整轮计时的唯一口径:总耗时与时间范围共用同一组边界。
|
||||
*
|
||||
* 只认明确边界:缺字段、非有限、时间戳为 0、结束早于开始一律隐藏耗时(也不会退化成
|
||||
* `0.0秒`);合法的同一时刻(耗时 0)正常显示 `0.0秒`。
|
||||
*/
|
||||
export function resolveTurnTiming({
|
||||
startedAt,
|
||||
endedAt,
|
||||
running = false,
|
||||
now = 0,
|
||||
}: {
|
||||
startedAt: number | null | undefined;
|
||||
endedAt?: number | null;
|
||||
running?: boolean;
|
||||
now?: number;
|
||||
}): DirectTurnTiming {
|
||||
const start = quantizeDisplayMs(startedAt);
|
||||
const end = quantizeDisplayMs(running ? now : endedAt);
|
||||
const durationMs = turnTotalDurationMs({ startedAt, endedAt, running, now });
|
||||
const startLabel =
|
||||
start > 0 ? formatClockTime(start, { tenths: true }) : null;
|
||||
const endLabel = end > 0 ? formatClockTime(end, { tenths: true }) : null;
|
||||
return {
|
||||
durationMs,
|
||||
durationText: formatTurnDuration(durationMs),
|
||||
timeLabel:
|
||||
startLabel && endLabel
|
||||
? `${startLabel} → ${endLabel}`
|
||||
: (endLabel ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -482,6 +482,23 @@ function agentRuntimeUserInputRequest({
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行态事件里的条目身份:只有 `item.started` / `item.completed` 带条目。
|
||||
*
|
||||
* `item.delta` 只带 itemId 不带条目,属于瞬时事件,不参与 bootstrap 回放。
|
||||
*/
|
||||
function directThreadEventItemId(
|
||||
event: Record<string, unknown>,
|
||||
): string | null {
|
||||
if (event.type !== 'item.started' && event.type !== 'item.completed') {
|
||||
return null;
|
||||
}
|
||||
const item = event.item;
|
||||
if (!item || typeof item !== 'object') return null;
|
||||
const itemId = (item as Record<string, unknown>).itemId;
|
||||
return typeof itemId === 'string' && itemId ? itemId : null;
|
||||
}
|
||||
|
||||
function createProjectSupervisorRuntimeHarness({
|
||||
projectPath = '/tmp/authorized-game',
|
||||
sessionId = 'supervisor-session-active',
|
||||
@@ -588,8 +605,31 @@ function createProjectSupervisorRuntimeHarness({
|
||||
}) => void)
|
||||
| null = null;
|
||||
let designAgentUpdateHandler:
|
||||
| ((event: { payload: Record<string, unknown> }) => void)
|
||||
| null = null;
|
||||
((event: { payload: Record<string, unknown> }) => void) | null = null;
|
||||
let directThreadNotifyHandler:
|
||||
((event: { payload: { subscriptionId: string } }) => void) | null = null;
|
||||
let directThreadSubscriptionId: string | null = null;
|
||||
let directThreadSubscriptionSequence = 0;
|
||||
// 未消费的运行态事件队列:`subscribe` 的 bootstrap 与 `consume` 都从这里取,
|
||||
// 与 Rust 侧"游标在队尾、事件按序下发"的语义一致。
|
||||
let pendingDirectThreadEvents: Array<Record<string, unknown>> = [];
|
||||
let directThreadHistoryItems: Array<Record<string, unknown>> = [];
|
||||
let directThreadLastCompletedItemId: string | null = null;
|
||||
// 运行态事件里只有一个"最新回合是否在跑"的布尔:与 Thread Manager 只保留一条生命周期
|
||||
// 锚点一致,重复 `turn.started` 在生产里不会出现。
|
||||
let directThreadTurnRunning = false;
|
||||
// bootstrap 的回放规则与 Rust 侧 `is_bootstrap_event` 对齐:只补"这一刻还没结束的条目"
|
||||
// 与最新一条生命周期锚点;已完成条目、增量正文这类瞬时事件都不回放。
|
||||
const directThreadActiveItemIds = new Set<string>();
|
||||
const directThreadPendingEventSeqs = new Map<
|
||||
Record<string, unknown>,
|
||||
number
|
||||
>();
|
||||
let directThreadEventSequence = 0;
|
||||
let directThreadLifecycleAnchor: {
|
||||
seq: number;
|
||||
event: Record<string, unknown>;
|
||||
} | null = null;
|
||||
|
||||
const conversationRecord = (
|
||||
role: 'user' | 'assistant',
|
||||
@@ -784,6 +824,93 @@ function createProjectSupervisorRuntimeHarness({
|
||||
const states = runtimeMapLoader ? await runtimeMapLoader() : [];
|
||||
return states.map((state) => runtimeResult(state));
|
||||
}
|
||||
if (command === 'subscribe_direct_project_thread') {
|
||||
directThreadSubscriptionSequence += 1;
|
||||
directThreadSubscriptionId = `direct-thread-${directThreadSubscriptionSequence}`;
|
||||
const pending = pendingDirectThreadEvents;
|
||||
pendingDirectThreadEvents = [];
|
||||
// 游标落在队尾:只有"还没结束的条目"与最新生命周期锚点作为 bootstrap 回放,
|
||||
// 已完成条目与增量正文不再补发(Rust 侧 `is_bootstrap_event` 的同一口径)。
|
||||
const replay = pending
|
||||
.filter((event) => {
|
||||
const itemId = directThreadEventItemId(event);
|
||||
return Boolean(itemId && directThreadActiveItemIds.has(itemId));
|
||||
})
|
||||
.map((event) => ({
|
||||
seq: directThreadPendingEventSeqs.get(event) ?? 0,
|
||||
event,
|
||||
}));
|
||||
for (const event of pending) {
|
||||
directThreadPendingEventSeqs.delete(event);
|
||||
}
|
||||
if (
|
||||
directThreadLifecycleAnchor &&
|
||||
!replay.some(
|
||||
(entry) => entry.event === directThreadLifecycleAnchor?.event,
|
||||
)
|
||||
) {
|
||||
replay.push(directThreadLifecycleAnchor);
|
||||
}
|
||||
replay.sort((left, right) => left.seq - right.seq);
|
||||
return {
|
||||
subscriptionId: directThreadSubscriptionId,
|
||||
lastCompletedItemId: directThreadLastCompletedItemId,
|
||||
events: replay.map((entry) => entry.event),
|
||||
};
|
||||
}
|
||||
if (command === 'consume_direct_project_thread') {
|
||||
if (String(args?.subscriptionId ?? '') !== directThreadSubscriptionId) {
|
||||
throw new Error('SUBSCRIPTION_EXPIRED');
|
||||
}
|
||||
const events = pendingDirectThreadEvents;
|
||||
pendingDirectThreadEvents = [];
|
||||
return { events };
|
||||
}
|
||||
if (command === 'read_direct_project_history_slice') {
|
||||
// 生产口径:从文件尾反向取一屏可显示条目——`throughItemId` 是窗口新端边界(含该条,
|
||||
// 首屏用),`beforeItemId` 是旧端边界(不含该条,翻页用);收满一屏之后再见一条才算
|
||||
// `hasMore`,`firstItemId` 是本屏最老一条的 itemId。
|
||||
const requestedLimit = Number(args?.limit ?? 20);
|
||||
const limit = Math.min(
|
||||
Math.max(Number.isFinite(requestedLimit) ? requestedLimit : 20, 1),
|
||||
200,
|
||||
);
|
||||
const throughItemId =
|
||||
typeof args?.throughItemId === 'string' && args.throughItemId
|
||||
? args.throughItemId
|
||||
: null;
|
||||
const beforeItemId =
|
||||
typeof args?.beforeItemId === 'string' && args.beforeItemId
|
||||
? args.beforeItemId
|
||||
: null;
|
||||
let end = directThreadHistoryItems.length;
|
||||
if (throughItemId) {
|
||||
const anchorIndex = directThreadHistoryItems.findIndex(
|
||||
(item) => String(item.itemId ?? '') === throughItemId,
|
||||
);
|
||||
if (anchorIndex < 0) {
|
||||
throw new Error(
|
||||
`DirectProject 历史中不存在 item:${throughItemId}`,
|
||||
);
|
||||
}
|
||||
end = anchorIndex + 1;
|
||||
} else if (beforeItemId) {
|
||||
const anchorIndex = directThreadHistoryItems.findIndex(
|
||||
(item) => String(item.itemId ?? '') === beforeItemId,
|
||||
);
|
||||
end =
|
||||
anchorIndex >= 0 ? anchorIndex : directThreadHistoryItems.length;
|
||||
}
|
||||
const start = Math.max(0, end - limit);
|
||||
const window = directThreadHistoryItems.slice(start, end);
|
||||
const oldest = window[0];
|
||||
return {
|
||||
items: [...window],
|
||||
hasMore: start > 0,
|
||||
firstItemId:
|
||||
oldest && typeof oldest.itemId === 'string' ? oldest.itemId : null,
|
||||
};
|
||||
}
|
||||
if (command === 'start_game_creator_supervisor_runtime_task') {
|
||||
if (args?.runProfile !== expectedRunProfile) {
|
||||
throw new Error('unexpected Project Supervisor run profile');
|
||||
@@ -937,6 +1064,10 @@ function createProjectSupervisorRuntimeHarness({
|
||||
designAgentUpdateHandler =
|
||||
handler as unknown as typeof designAgentUpdateHandler;
|
||||
}
|
||||
if (eventName === 'game-creator-direct-thread-notify') {
|
||||
directThreadNotifyHandler =
|
||||
handler as unknown as typeof directThreadNotifyHandler;
|
||||
}
|
||||
return () => {
|
||||
if (runtimeUpdateHandler === handler) {
|
||||
runtimeUpdateHandler = null;
|
||||
@@ -950,10 +1081,45 @@ function createProjectSupervisorRuntimeHarness({
|
||||
if (designAgentUpdateHandler === handler) {
|
||||
designAgentUpdateHandler = null;
|
||||
}
|
||||
if (directThreadNotifyHandler === handler) {
|
||||
directThreadNotifyHandler = null;
|
||||
}
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
/** 追加运行态事件并唤醒订阅者:bootstrap / consume 共用同一份队列。 */
|
||||
const emitDirectThreadEvents = (
|
||||
...events: Array<Record<string, unknown>>
|
||||
) => {
|
||||
for (const event of events) {
|
||||
directThreadEventSequence += 1;
|
||||
directThreadPendingEventSeqs.set(event, directThreadEventSequence);
|
||||
if (event.type === 'turn.started') directThreadTurnRunning = true;
|
||||
if (event.type === 'turn.completed') directThreadTurnRunning = false;
|
||||
if (event.type === 'turn.started' || event.type === 'turn.completed') {
|
||||
// 生命周期锚点只留最新一条,与 Thread Manager 的 `lifecycle_anchor` 一致。
|
||||
directThreadLifecycleAnchor = {
|
||||
seq: directThreadEventSequence,
|
||||
event,
|
||||
};
|
||||
}
|
||||
const itemId = directThreadEventItemId(event);
|
||||
if (itemId && event.type === 'item.started') {
|
||||
directThreadActiveItemIds.add(itemId);
|
||||
}
|
||||
if (itemId && event.type === 'item.completed') {
|
||||
directThreadActiveItemIds.delete(itemId);
|
||||
}
|
||||
}
|
||||
pendingDirectThreadEvents.push(...events);
|
||||
if (directThreadSubscriptionId) {
|
||||
directThreadNotifyHandler?.({
|
||||
payload: { subscriptionId: directThreadSubscriptionId },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
invoke,
|
||||
listen,
|
||||
@@ -1036,6 +1202,71 @@ function createProjectSupervisorRuntimeHarness({
|
||||
},
|
||||
});
|
||||
},
|
||||
emitDirectThreadEvents,
|
||||
/**
|
||||
* 一轮 Direct 回合的标准事件序列:生命周期 → 落盘用户条目 → 助手正文 → 终态。
|
||||
*
|
||||
* 与 Rust 侧一致:消息身份是 `direct-codex:{turnId}:{role}`,工具条目另配 itemId。
|
||||
* 事件级 `at` 与条目 `item.at` 同源:原生在两个阶段事件上都给时间,计时只读事件级那一份。
|
||||
*/
|
||||
completeDirectThreadTurn({
|
||||
turnId,
|
||||
reply,
|
||||
prompt = '',
|
||||
at = 9_000,
|
||||
status = 'completed',
|
||||
}: {
|
||||
turnId: string;
|
||||
reply: string;
|
||||
prompt?: string;
|
||||
at?: number;
|
||||
status?: string;
|
||||
}) {
|
||||
// 生命周期锚点只有一份:回合已经在跑时不再补 `turn.started`。
|
||||
const events: Array<Record<string, unknown>> = directThreadTurnRunning
|
||||
? []
|
||||
: [{ type: 'turn.started', at }];
|
||||
if (prompt.trim()) {
|
||||
events.push({
|
||||
type: 'item.completed',
|
||||
at,
|
||||
item: {
|
||||
itemType: 'message',
|
||||
itemId: `direct-codex:${turnId}:user`,
|
||||
role: 'user',
|
||||
text: prompt,
|
||||
at,
|
||||
},
|
||||
});
|
||||
}
|
||||
events.push({
|
||||
type: 'item.completed',
|
||||
at: at + 1,
|
||||
item: {
|
||||
itemType: 'message',
|
||||
itemId: `direct-codex:${turnId}:assistant`,
|
||||
role: 'assistant',
|
||||
text: reply,
|
||||
at: at + 1,
|
||||
},
|
||||
});
|
||||
directThreadLastCompletedItemId = `direct-codex:${turnId}:assistant`;
|
||||
// 终态时间就是调用方给的这一刻:整轮总耗时读它,不读正文条目的展示时间。
|
||||
events.push({ type: 'turn.completed', status, at });
|
||||
emitDirectThreadEvents(...events);
|
||||
},
|
||||
setDirectThreadHistory(items: Array<Record<string, unknown>>) {
|
||||
directThreadHistoryItems = [...items];
|
||||
// 生产口径:`subscribe` 回执里的 `lastCompletedItemId` 是订阅那一刻文件里最后一条可显示
|
||||
// 条目(Rust 侧由 `read_direct_project_last_item_id_at` 从磁盘回填)。
|
||||
const newest = directThreadHistoryItems.at(-1);
|
||||
directThreadLastCompletedItemId =
|
||||
typeof newest?.itemId === 'string' ? newest.itemId : null;
|
||||
},
|
||||
/** 模拟"订阅回执给的就是这一刻的最后一条已完成条目":之后落盘的条目只应从运行态事件来。 */
|
||||
setDirectThreadLastCompletedItemId(itemId: string | null) {
|
||||
directThreadLastCompletedItemId = itemId;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
directHistoryAnchorGateToWaitFor,
|
||||
openDirectHistoryAnchorGate,
|
||||
reuseOrOpenDirectHistoryAnchorGate,
|
||||
} from '../src/features/project-workspace/directHistoryAnchorGate';
|
||||
|
||||
const projectA = '/tmp/项目A';
|
||||
const projectB = '/tmp/项目B';
|
||||
|
||||
/** 订阅侧的回执:一次订阅只 settle 一次。 */
|
||||
function subscribeReceipt(gate: { settle: (anchor: string | null) => void }) {
|
||||
gate.settle('turn-2-user');
|
||||
}
|
||||
|
||||
describe('openDirectHistoryAnchorGate', () => {
|
||||
it('默认不解析:回执到达前首屏必须等它', async () => {
|
||||
const gate = openDirectHistoryAnchorGate(projectA);
|
||||
const settled = { value: 'pending' as string | null | 'pending' };
|
||||
void gate.anchor.then((anchor) => {
|
||||
settled.value = anchor;
|
||||
});
|
||||
|
||||
// 让微任务跑一轮:没有回执就不该有结果。
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(settled.value).toBe('pending');
|
||||
expect(gate.projectPath).toBe(projectA);
|
||||
expect(gate.consumed).toBe(false);
|
||||
});
|
||||
|
||||
it('settle 之后 anchor 解析成回执里的 lastCompletedItemId', async () => {
|
||||
const gate = openDirectHistoryAnchorGate(projectA);
|
||||
subscribeReceipt(gate);
|
||||
|
||||
await expect(gate.anchor).resolves.toBe('turn-2-user');
|
||||
});
|
||||
|
||||
it('订阅不可用时 settle(null):首屏退化成取文件尾', async () => {
|
||||
const gate = openDirectHistoryAnchorGate(projectA);
|
||||
gate.settle(null);
|
||||
|
||||
await expect(gate.anchor).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reuseOrOpenDirectHistoryAnchorGate', () => {
|
||||
it('同一个项目复用同一道闸门:订阅侧要 settle 首屏已经开好的那道', () => {
|
||||
const opened = openDirectHistoryAnchorGate(projectA);
|
||||
|
||||
expect(reuseOrOpenDirectHistoryAnchorGate(opened, projectA)).toBe(opened);
|
||||
});
|
||||
|
||||
it('换项目开一道新闸门,不复用旧项目的回执', () => {
|
||||
const opened = openDirectHistoryAnchorGate(projectA);
|
||||
const next = reuseOrOpenDirectHistoryAnchorGate(opened, projectB);
|
||||
|
||||
expect(next).not.toBe(opened);
|
||||
expect(next.projectPath).toBe(projectB);
|
||||
expect(next.consumed).toBe(false);
|
||||
});
|
||||
|
||||
it('还没有闸门时新开一道', () => {
|
||||
expect(reuseOrOpenDirectHistoryAnchorGate(null, projectA).projectPath).toBe(
|
||||
projectA,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('directHistoryAnchorGateToWaitFor', () => {
|
||||
it('没有闸门(首屏比订阅 effect 先跑)时开一道给调用方登记', async () => {
|
||||
const gate = directHistoryAnchorGateToWaitFor(null, projectA);
|
||||
|
||||
expect(gate).not.toBeNull();
|
||||
expect(gate?.projectPath).toBe(projectA);
|
||||
subscribeReceipt(gate!);
|
||||
await expect(gate!.anchor).resolves.toBe('turn-2-user');
|
||||
});
|
||||
|
||||
it('同项目未消费的闸门直接复用:首屏等到订阅回执', async () => {
|
||||
const opened = openDirectHistoryAnchorGate(projectA);
|
||||
const toWaitFor = directHistoryAnchorGateToWaitFor(opened, projectA);
|
||||
|
||||
expect(toWaitFor).toBe(opened);
|
||||
subscribeReceipt(opened);
|
||||
await expect(toWaitFor!.anchor).resolves.toBe('turn-2-user');
|
||||
});
|
||||
|
||||
it('同项目已消费返回 null:重开项目没有新回执可等,退回取文件尾', () => {
|
||||
const opened = openDirectHistoryAnchorGate(projectA);
|
||||
opened.consumed = true;
|
||||
|
||||
expect(directHistoryAnchorGateToWaitFor(opened, projectA)).toBeNull();
|
||||
});
|
||||
|
||||
it('换项目不复用旧闸门:新项目的首屏要等新订阅的回执', () => {
|
||||
const opened = openDirectHistoryAnchorGate(projectA);
|
||||
subscribeReceipt(opened);
|
||||
opened.consumed = true;
|
||||
|
||||
const toWaitFor = directHistoryAnchorGateToWaitFor(opened, projectB);
|
||||
|
||||
expect(toWaitFor).not.toBeNull();
|
||||
expect(toWaitFor).not.toBe(opened);
|
||||
expect(toWaitFor?.projectPath).toBe(projectB);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DIRECT_HISTORY_MAX_PAGES_PER_ACTION } from '../src/app/constants';
|
||||
import { readDirectHistoryPages } from '../src/features/project-workspace/directHistoryPaging';
|
||||
import type { DirectChatEntry } from '../src/features/project-workspace/directThreadChat';
|
||||
import {
|
||||
emptyDirectThreadChatState,
|
||||
mergeDirectHistoryItems,
|
||||
selectDirectChatEntries,
|
||||
} from '../src/features/project-workspace/directThreadChat';
|
||||
import type {
|
||||
DirectThreadHistorySlice,
|
||||
DirectThreadItem,
|
||||
} from '../src/features/project-workspace/directThreadEvents';
|
||||
|
||||
function userItem(itemId: string): DirectThreadItem {
|
||||
return { itemType: 'message', itemId, role: 'user', text: itemId, at: 1000 };
|
||||
}
|
||||
|
||||
function assistantItem(itemId: string): DirectThreadItem {
|
||||
return {
|
||||
itemType: 'message',
|
||||
itemId,
|
||||
role: 'assistant',
|
||||
text: `答复 ${itemId}`,
|
||||
at: 2000,
|
||||
};
|
||||
}
|
||||
|
||||
function toolItem(itemId: string): DirectThreadItem {
|
||||
return {
|
||||
itemType: 'function_call',
|
||||
itemId,
|
||||
name: 'exec_command',
|
||||
arguments: '{"cmd": "ls"}',
|
||||
at: 1500,
|
||||
};
|
||||
}
|
||||
|
||||
function reasoningItem(itemId: string): DirectThreadItem {
|
||||
return { itemType: 'reasoning', itemId, text: '想想', at: 1400 };
|
||||
}
|
||||
|
||||
function slice(
|
||||
items: DirectThreadItem[],
|
||||
hasMore: boolean,
|
||||
firstItemId: string | null,
|
||||
): DirectThreadHistorySlice {
|
||||
return { items, hasMore, firstItemId };
|
||||
}
|
||||
|
||||
/** 用生产投影把条目拉成聊天条目:判据必须和视图看到的是同一份。 */
|
||||
function entriesOf(items: DirectThreadItem[]): DirectChatEntry[] {
|
||||
return selectDirectChatEntries(
|
||||
mergeDirectHistoryItems(emptyDirectThreadChatState(), items),
|
||||
);
|
||||
}
|
||||
|
||||
function readerOf(pages: DirectThreadHistorySlice[]) {
|
||||
const remaining = [...pages];
|
||||
return vi.fn(async (_beforeItemId: string | null) => {
|
||||
const next = remaining.shift();
|
||||
if (!next) throw new Error('测试桩:不该再取页');
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
describe('DirectProject 历史分页连拉', () => {
|
||||
it('整页工具与思考条目落进已渲染回合时继续取页,直到出现新的用户气泡', async () => {
|
||||
// 已渲染的回合是「turn-2-user」,后面跟着它的折叠执行过程。
|
||||
const existing = entriesOf([userItem('turn-2-user'), toolItem('call-2')]);
|
||||
const readSlice = readerOf([
|
||||
slice([toolItem('call-3'), reasoningItem('reason-3')], true, 'reason-3'),
|
||||
slice(
|
||||
[userItem('turn-1-user'), assistantItem('turn-1-assistant')],
|
||||
false,
|
||||
'turn-1-user',
|
||||
),
|
||||
]);
|
||||
|
||||
const result = await readDirectHistoryPages({
|
||||
existingEntries: existing,
|
||||
beforeItemId: 'turn-2-user',
|
||||
readSlice,
|
||||
});
|
||||
|
||||
// 第二页才带来新回合:锚点按上一页最老一条推进,条目按文件顺序拼回去。
|
||||
expect(readSlice.mock.calls).toEqual([['turn-2-user'], ['reason-3']]);
|
||||
expect(result.items.map((item) => item.itemId)).toEqual([
|
||||
'turn-1-user',
|
||||
'turn-1-assistant',
|
||||
'call-3',
|
||||
'reason-3',
|
||||
]);
|
||||
expect(result.hasMore).toBe(false);
|
||||
expect(result.firstItemId).toBe('turn-1-user');
|
||||
expect(result.error).toBeNull();
|
||||
const merged = selectDirectChatEntries(
|
||||
mergeDirectHistoryItems(
|
||||
{ turnRunning: false, history: existing, live: [] },
|
||||
result.items,
|
||||
),
|
||||
);
|
||||
expect(merged.some((entry) => entry.itemId === 'turn-1-user')).toBe(true);
|
||||
});
|
||||
|
||||
it('第一页就出现新的用户气泡时只取一页', async () => {
|
||||
const existing = entriesOf([userItem('turn-2-user')]);
|
||||
const readSlice = readerOf([
|
||||
slice([userItem('turn-1-user')], true, 'turn-1-user'),
|
||||
]);
|
||||
|
||||
const result = await readDirectHistoryPages({
|
||||
existingEntries: existing,
|
||||
beforeItemId: 'turn-2-user',
|
||||
readSlice,
|
||||
});
|
||||
|
||||
expect(readSlice).toHaveBeenCalledTimes(1);
|
||||
// 后端说还有更早的:按钮继续留在界面上,用户可以接着往前翻。
|
||||
expect(result.hasMore).toBe(true);
|
||||
expect(result.firstItemId).toBe('turn-1-user');
|
||||
});
|
||||
|
||||
it('首屏没有已渲染回合时,第一页本身就构成可见反馈', async () => {
|
||||
const readSlice = readerOf([slice([toolItem('call-1')], true, 'call-1')]);
|
||||
|
||||
const result = await readDirectHistoryPages({
|
||||
existingEntries: [],
|
||||
beforeItemId: null,
|
||||
readSlice,
|
||||
});
|
||||
|
||||
expect(readSlice.mock.calls).toEqual([[null]]);
|
||||
expect(result.items.map((item) => item.itemId)).toEqual(['call-1']);
|
||||
});
|
||||
|
||||
it('hasMore=false 时即使没有新回合也立即停止', async () => {
|
||||
const existing = entriesOf([userItem('turn-2-user')]);
|
||||
const readSlice = readerOf([slice([toolItem('call-3')], false, 'call-3')]);
|
||||
|
||||
const result = await readDirectHistoryPages({
|
||||
existingEntries: existing,
|
||||
beforeItemId: 'turn-2-user',
|
||||
readSlice,
|
||||
});
|
||||
|
||||
expect(readSlice).toHaveBeenCalledTimes(1);
|
||||
expect(result.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('一直没有新回合时最多连拉上限页数就停手', async () => {
|
||||
const existing = entriesOf([userItem('turn-2-user')]);
|
||||
const readSlice = readerOf(
|
||||
Array.from(
|
||||
{ length: DIRECT_HISTORY_MAX_PAGES_PER_ACTION + 2 },
|
||||
(_, index) =>
|
||||
slice([toolItem(`call-${index + 3}`)], true, `call-${index + 3}`),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await readDirectHistoryPages({
|
||||
existingEntries: existing,
|
||||
beforeItemId: 'turn-2-user',
|
||||
readSlice,
|
||||
});
|
||||
|
||||
expect(readSlice).toHaveBeenCalledTimes(
|
||||
DIRECT_HISTORY_MAX_PAGES_PER_ACTION,
|
||||
);
|
||||
// 用满上限但文件里还有更早的历史:按钮留在界面上,用户可再点一次。
|
||||
expect(result.hasMore).toBe(true);
|
||||
expect(result.items).toHaveLength(DIRECT_HISTORY_MAX_PAGES_PER_ACTION);
|
||||
expect(result.firstItemId).toBe(
|
||||
`call-${DIRECT_HISTORY_MAX_PAGES_PER_ACTION + 2}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('锚点不前进时立即停止,不空转也不留按钮', async () => {
|
||||
const existing = entriesOf([userItem('turn-2-user')]);
|
||||
const readSlice = readerOf([slice([], true, null)]);
|
||||
|
||||
const result = await readDirectHistoryPages({
|
||||
existingEntries: existing,
|
||||
beforeItemId: 'turn-2-user',
|
||||
readSlice,
|
||||
});
|
||||
|
||||
expect(readSlice).toHaveBeenCalledTimes(1);
|
||||
expect(result.items).toEqual([]);
|
||||
expect(result.hasMore).toBe(false);
|
||||
expect(result.firstItemId).toBe('turn-2-user');
|
||||
});
|
||||
|
||||
it('锚点原地打转时立即停止,不重复取同一页', async () => {
|
||||
const existing = entriesOf([userItem('turn-2-user')]);
|
||||
const readSlice = readerOf([
|
||||
slice([toolItem('call-3')], true, 'turn-2-user'),
|
||||
]);
|
||||
|
||||
const result = await readDirectHistoryPages({
|
||||
existingEntries: existing,
|
||||
beforeItemId: 'turn-2-user',
|
||||
readSlice,
|
||||
});
|
||||
|
||||
expect(readSlice).toHaveBeenCalledTimes(1);
|
||||
expect(result.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('某一页读取失败时保留已经取到的页', async () => {
|
||||
const existing = entriesOf([userItem('turn-2-user')]);
|
||||
const failure = new Error('读取失败');
|
||||
const readSlice = vi.fn(async (beforeItemId: string | null) => {
|
||||
if (beforeItemId === 'turn-2-user') {
|
||||
return slice([toolItem('call-3')], true, 'call-3');
|
||||
}
|
||||
throw failure;
|
||||
});
|
||||
|
||||
const result = await readDirectHistoryPages({
|
||||
existingEntries: existing,
|
||||
beforeItemId: 'turn-2-user',
|
||||
readSlice,
|
||||
});
|
||||
|
||||
expect(readSlice).toHaveBeenCalledTimes(2);
|
||||
expect(result.items.map((item) => item.itemId)).toEqual(['call-3']);
|
||||
// 失败的页没有推进锚点:下一次点击会重取这一页。
|
||||
expect(result.firstItemId).toBe('call-3');
|
||||
expect(result.hasMore).toBe(true);
|
||||
expect(result.error).toBe(failure);
|
||||
});
|
||||
|
||||
it('首页读取失败时保留首屏锚点并返回错误', async () => {
|
||||
const failure = new Error('首页读取失败');
|
||||
const readSlice = vi.fn(async (_beforeItemId: string | null) => {
|
||||
throw failure;
|
||||
});
|
||||
|
||||
const result = await readDirectHistoryPages({
|
||||
existingEntries: [],
|
||||
beforeItemId: null,
|
||||
readSlice,
|
||||
});
|
||||
|
||||
expect(readSlice).toHaveBeenCalledTimes(1);
|
||||
expect(result.items).toEqual([]);
|
||||
expect(result.firstItemId).toBeNull();
|
||||
expect(result.hasMore).toBe(false);
|
||||
expect(result.error).toBe(failure);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,8 +28,8 @@
|
||||
|
||||
1. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_thread -- --nocapture`
|
||||
2. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings`(生成 `src/features/project-workspace/generated/`),随后用 `prettier --write` 格式化生成目录,避免未格式化的 ts-rs 输出混进提交
|
||||
3. `npx vitest run apps/ai-game-creator-shell/tests/directThreadChat.test.ts apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts`
|
||||
4. `npx vitest run apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`
|
||||
3. `npm run test -- apps/ai-game-creator-shell/tests/directThreadChat.test.ts apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts apps/ai-game-creator-shell/tests/directHistoryAnchorGate.test.ts`
|
||||
4. `npm run test -- apps/ai-game-creator-shell/tests/appSurface.test.ts`(`project-development.suite.ts` 由该入口注册,不能作为独立测试入口;同时验证现役 Design Agent 的会话恢复与审批界面。)
|
||||
5. TypeScript 类型检查与 ESLint(范围同前次 DirectProject 迁移)。
|
||||
6. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user