接入DirectProject前端线程订阅

新增独立raw event reducer并按item与turn事件重建运行态\n页面进入时注册notify订阅并通过consume推进Rust内部游标\n队列过期后保留旧状态并重新bootstrap
This commit is contained in:
2026-09-15 17:56:37 +08:00
committed by kdletters
parent f138cd7d55
commit a18aecd0e0
2 changed files with 240 additions and 0 deletions
+120
View File
@@ -222,6 +222,12 @@ import {
} from './features/project-workspace/agentRunTrace';
import { DeveloperProjectPanels } from './features/project-workspace/DeveloperProjectPanels';
import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels';
import {
type DirectThreadConsumeResult,
type DirectThreadSubscriptionBootstrap,
emptyDirectThreadReducerState,
reduceDirectThreadEvents,
} from './features/project-workspace/directThreadEvents';
import type { DirectCodexUserContentPart } from './features/project-workspace/generated';
import {
appendMemoryContent,
@@ -771,6 +777,8 @@ export function App({
lastSequence: number;
receivedDirectUpdate: boolean;
} | null>(null);
const directThreadSubscriptionIdRef = useRef<string | null>(null);
const directThreadReducerStateRef = useRef(emptyDirectThreadReducerState());
const lastDirectCodexActivityRef = useRef<string | null>(null);
const directCodexConversationTurnSequenceRef = useRef(0);
const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState<
@@ -1905,6 +1913,118 @@ export function App({
return () => window.clearInterval(timer);
}, [chatAgentBusy, directCodexProductRuntime]);
useEffect(() => {
const projectPath = localProject?.projectPath ?? null;
const directInvoke = resolveTauriInvoke();
if (!directCodexProductRuntime || !projectPath || !directInvoke) {
directThreadSubscriptionIdRef.current = null;
directThreadReducerStateRef.current = emptyDirectThreadReducerState();
return;
}
let disposed = false;
let cleanup: (() => void) | null = null;
const applyReducerState = (
state: ReturnType<typeof emptyDirectThreadReducerState>,
) => {
if (disposed) return;
directThreadReducerStateRef.current = state;
const running =
state.status === 'accepted' ||
state.status === 'running' ||
state.status === 'streaming' ||
state.status === 'finalizing';
setChatAgentBusy(running);
setDirectCodexStatus(state.status);
setDirectCodexProgress(state.progress);
setDirectCodexProgressUpdatedAt(Date.now());
if (state.accumulatedText) {
setDirectCodexTransientReply(state.accumulatedText);
directCodexTransientReplyRef.current = state.accumulatedText;
setDirectCodexTransientReplyUpdatedAt(Date.now());
}
};
const bootstrap = async () => {
const result = await directInvoke<DirectThreadSubscriptionBootstrap>(
'subscribe_direct_project_thread',
{ projectPath },
);
if (disposed) return;
directThreadSubscriptionIdRef.current = result.subscriptionId;
const state = reduceDirectThreadEvents(
result.events,
emptyDirectThreadReducerState(),
);
applyReducerState(state);
if (state.turnId && !activeDirectCodexTurnRef.current) {
activeDirectCodexTurnRef.current = {
projectPath,
turnId: state.turnId,
lastSequence: state.lastSeq,
receivedDirectUpdate: true,
};
setDirectCodexProcessKey(`${projectPath}\u0000${state.turnId}`);
}
};
const consume = async () => {
const subscriptionId = directThreadSubscriptionIdRef.current;
if (!subscriptionId || disposed) return;
try {
const result = await directInvoke<DirectThreadConsumeResult>(
'consume_direct_project_thread',
{ subscriptionId },
);
if (disposed) return;
const state = reduceDirectThreadEvents(
result.events,
directThreadReducerStateRef.current,
);
applyReducerState(state);
} catch (error) {
if (String(error).includes('SUBSCRIPTION_EXPIRED')) {
directThreadSubscriptionIdRef.current = null;
try {
await bootstrap();
} catch {
// A later project activation or notification will retry bootstrap.
}
}
}
};
const setup = async () => {
try {
const unlisten = await subscribeTauriEvent<{ subscriptionId: string }>(
'game-creator-direct-thread-notify',
(event) => {
if (
event.payload.subscriptionId ===
directThreadSubscriptionIdRef.current
) {
void consume();
}
},
);
if (disposed) {
unlisten();
return;
}
cleanup = unlisten;
await bootstrap();
} catch {
// The history view remains usable when the runtime subscription is unavailable.
}
};
void setup();
return () => {
disposed = true;
cleanup?.();
directThreadSubscriptionIdRef.current = null;
};
}, [directCodexProductRuntime, localProject?.projectPath]);
useEffect(() => {
if (projectSupervisorOnly && !directCodexProductRuntime) {
return;
@@ -0,0 +1,120 @@
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 DirectThreadReducerState = {
lastSeq: number;
turnId: string | null;
status:
| 'accepted'
| 'running'
| 'streaming'
| 'finalizing'
| 'completed'
| 'failed'
| null;
activeItemIds: Set<string>;
accumulatedText: string;
progress: string;
};
export const emptyDirectThreadReducerState = (): DirectThreadReducerState => ({
lastSeq: 0,
turnId: null,
status: null,
activeItemIds: new Set(),
accumulatedText: '',
progress: '',
});
function itemType(event: DirectThreadRawEvent) {
const value = event.payload.itemType;
return typeof value === 'string' ? value : '';
}
function activityDetail(type: string) {
switch (type) {
case 'mcpToolCall':
return '正在调用工具';
case 'commandExecution':
return '正在执行命令';
case 'fileChange':
return '正在写入文件';
case 'webSearch':
return '正在搜索资料';
case 'contextCompaction':
return '正在整理上下文';
default:
return '正在处理';
}
}
export function reduceDirectThreadEvent(
state: DirectThreadReducerState,
event: DirectThreadRawEvent,
): DirectThreadReducerState {
if (!Number.isSafeInteger(event.seq) || event.seq <= state.lastSeq) {
return state;
}
const next: DirectThreadReducerState = {
...state,
lastSeq: event.seq,
turnId: event.turnId || state.turnId,
activeItemIds: new Set(state.activeItemIds),
};
switch (event.type) {
case 'turn.started':
next.status = 'running';
next.progress = '正在处理';
break;
case 'item.started':
if (event.itemId) next.activeItemIds.add(event.itemId);
next.status = 'running';
next.progress = activityDetail(itemType(event));
break;
case 'item.delta': {
const delta = event.payload.delta;
if (typeof delta === 'string' && delta) {
next.accumulatedText += delta;
next.status = 'streaming';
next.progress = '正在生成回复';
}
break;
}
case 'item.completed':
if (event.itemId) next.activeItemIds.delete(event.itemId);
if (next.status === null) next.status = 'running';
break;
case 'turn.completed': {
const status = event.payload.status;
next.status = status === 'completed' ? 'completed' : 'failed';
next.progress =
status === 'completed' ? '正在提交回复' : '正在记录失败原因';
break;
}
default:
break;
}
return next;
}
export function reduceDirectThreadEvents(
events: DirectThreadRawEvent[],
initial = emptyDirectThreadReducerState(),
) {
return events.reduce(reduceDirectThreadEvent, initial);
}