/* eslint-disable react-refresh/only-export-components -- Testable pure helpers currently share this legacy app module. */
import {
type FormEvent,
type UIEvent,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import {
type GameCreationAppCommandDescriptor,
type GameCreationAppManifest,
type GameCreationAppPreviewState,
type GameCreationAppPreviewStatus,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import {
AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD,
CONVERSATION_INITIAL_VISIBLE_COUNT,
CONVERSATION_VISIBLE_STEP,
createLocalProjectId,
seedManifest,
} from './app/constants';
import { useEscapeToClose } from './app/dialogs';
import { claimInitialTurnForPage } from './app/initialTurnClaims';
import { resolveTauriInvoke } from './app/tauri';
import type {
AgentRuntimeResult,
AgentRuntimeState,
ChatMessage,
DesignAgentInput,
DesignClarificationRequest,
DesignEvent,
DesignView,
InitLocalProjectResult,
LauncherImportedAttachment,
LocalConversationResult,
LocalGameProjectRevisionStatus,
LocalPreviewResult,
LocalPreviewStatus,
LocalProjectExportPackageResult,
LocalProjectFileResult,
LocalProjectKind,
PendingUiConfirmation,
ProjectPermissionPolicyView,
TauriInvoke,
} from './app/types';
import { GameDistributionPublishPanel } from './components/game-distribution/GameDistributionPublishPanel';
import {
agentConversationId,
agentRuntimeStateFromResult,
createAgentChatRunId,
createDefaultChatMessages,
isAgentFinalizationMessageId,
isMissingAgentRuntimeResumeCommandError,
isRuntimeConfigMissingError,
mergeAgentRuntimeStateIntoMap,
projectProfessionalAgentLabel,
taskRowsFromManifest,
} from './features/agent-runtime';
import {
isTransientProjectOpenMessage,
latestVisibleItems,
type ProjectChatComponentProps,
readInitialProjectPath,
type WorkspaceLauncherProps,
writeRecentWorkspace,
} from './features/app-shell/model';
import { WorkspaceLauncherShell } from './features/app-shell/WorkspaceLauncher';
import {
projectAgentRuntimeSummaries,
summarizeAgentRunCompletionForChat,
} from './features/project-summary/agentPresentation';
import {
isAbsoluteProjectPath,
projectPathHasControlCharacter,
} from './features/project-summary/projectSummary';
import { parseAgentRunTrace } from './features/project-workspace/agentRunTrace';
import { importDesignFiles } from './features/project-workspace/importDesignFiles';
import { parseRememberInput } from './features/project-workspace/memoryCommands';
import {
isAgentTraceFilePath,
needsInitializedChatProject,
resolveChatProjectPath,
} from './features/project-workspace/projectCommandPolicy';
import type { ResourceReferenceInputHandle } from './features/project-workspace/ResourceReferenceInput';
import {
directCodexContentToLegacyContentDto,
hasMeaningfulDirectCodexContent,
RESOURCE_REFERENCE_INSERT_EVENT,
type ResourceReferenceInsertEventDetail,
} from './features/project-workspace/resourceReferences';
import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog';
import { readGamePublishAvailability } from './services/gameDistributionPublish';
import {
setAgcPluginProjectPath,
startAvailableAgcEditorPlugins,
} from './services/pluginHost';
import {
canSubscribeTauriEvents,
subscribeTauriEvent,
} from './services/tauriEventSubscription';
import type { HomeCreationType } from './view/home';
import {
type ProjectAgentResultSummary,
type ProjectAgentRuntimeSummary,
} from './view/project-development';
import { DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX } from './view/project-development/chat/conversation/directCodexConversation';
import {
directCodexAttachmentContentParts,
toDirectCodexTurnAttachments,
} from './view/project-development/chat/conversation/directCodexTurnAttachments';
import {
type DirectProjectChatHandle,
DirectProjectChatView,
type DirectProjectInitialTurn,
} from './view/project-development/chat/DirectProjectChatView';
import { PlanningChatView } from './view/project-development/planning/PlanningChatView';
import type { ProjectManifestSnapshotMetadata } from './view/project-development/projectResourceLiveUpdateModel';
function isPersistableDirectCodexConversationMessage(message: ChatMessage) {
if (!message.runtimeOwned) {
return false;
}
const messageId = message.messageId?.trim() ?? '';
const roleSuffix = `:${message.role}`;
if (
!messageId.startsWith(DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX) ||
!messageId.endsWith(roleSuffix)
) {
return false;
}
const turnId = messageId.slice(
DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX.length,
-roleSuffix.length,
);
return /^[a-z0-9][a-z0-9-]{5,159}$/iu.test(turnId);
}
/**
* 历史回读与"尚未落盘的运行时消息"合并。
*
* 初始需求是**乐观插入**到 messages 的(latch 命中后先插一条 user 消息,再发起回合),
* 而历史回读在 replace 分支里是无条件整体替换 —— 只要回读晚于乐观插入,那条用户消息
* 就会被冲掉(界面上看不到初始需求,但回合其实已经跑起来了)。
* 这里把当前 messages 里"运行时拥有、且回读结果里没有"的消息保留在末尾(它们是最新的)。
*/
export { AuthenticatedClient } from './app/AuthenticatedClient';
export {
deriveAgentStatusCards,
summarizeAgentAudit,
summarizeAgentRunTrace,
} from './features/project-summary/agentPresentation';
export { isAbsoluteProjectPath } from './features/project-summary/projectSummary';
export {
needsInitializedChatProject,
parseRememberInput,
resolveChatProjectPath,
};
export function WorkspaceLauncher(props: WorkspaceLauncherProps) {
return ;
}
type AppProps = {
initialProjectPath?: string;
initialProjectManifest?: GameCreationAppManifest;
initialProjectKind?: LocalProjectKind;
planningStartMode?: boolean;
activeVersionId?: ProjectChatComponentProps['activeVersionId'];
initialPlanningPrompt?: string;
initialPlanningPromptClaimScope?: string;
initialCreationType?: HomeCreationType | null;
initialAttachments?: LauncherImportedAttachment[];
onDesignFilesImportingChange?: ProjectChatComponentProps['onDesignFilesImportingChange'];
playRequest?: ProjectChatComponentProps['playRequest'];
onPlayRequestHandled?: ProjectChatComponentProps['onPlayRequestHandled'];
onManifestChange?: (
projectPath: string,
manifest: GameCreationAppManifest,
metadata?: ProjectManifestSnapshotMetadata,
) => void;
onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void;
onAgentRuntimeSummariesChange?: (
summaries: ProjectAgentRuntimeSummary[],
) => void;
onAgentResultsChange?: (results: ProjectAgentResultSummary[]) => void;
};
/**
* 工作台壳里由立项策划链路接管的提交入参。
*
* DirectProject 的回合入参(`@` 引用、附件、权限确认重跑)归聊天容器自己的
* `DirectProjectTurnInput`,不再从这里穿过。
*/
type ExecuteChatAgentReplyInput = {
prompt: string;
clientTurnId?: string;
};
export function App({
initialProjectPath: initialProjectPathOverride = '',
initialProjectManifest,
initialProjectKind = 'web',
planningStartMode = false,
activeVersionId = null,
initialPlanningPrompt = '',
initialPlanningPromptClaimScope = '',
initialCreationType = null,
initialAttachments = [],
onDesignFilesImportingChange,
playRequest = null,
onPlayRequestHandled,
onManifestChange,
onPreviewChange,
onAgentRuntimeSummariesChange,
onAgentResultsChange,
}: AppProps = {}) {
const [designAgentActive, setDesignAgentActive] = useState(planningStartMode);
const designAgentActiveRef = useRef(planningStartMode);
designAgentActiveRef.current = designAgentActive;
const [designAgentView, setDesignAgentView] = useState(
null,
);
const designAgentLaneRef = useRef(planningStartMode);
const designAgentTurnRef = useRef<{
projectPath: string;
clientTurnId: string;
} | null>(null);
const designAgentEventSubscriptionReadyRef = useRef | null>(
null,
);
const designAgentEventSubscriptionResolveRef = useRef<(() => void) | null>(
null,
);
/**
* 本轮策划回合的思考归属。
*
* 与 `designAgentTurnRef` 分开:那个记录回合本身,回合结束就清空;这个只用来判定
* 「这条 reasoning 事件是不是本轮的」,回合结束后仍保留,最后一条思考不会在视图
* 落盘前的空隙里被丢掉。切项目时随其它聊天状态一起清空。
*/
const designAgentReasoningTurnRef = useRef<{
projectPath: string;
clientTurnId: string;
} | null>(null);
// 项目开发工作台的普通项目固定使用 DirectProject,立项策划项目走策划聊天;两条
// 链路各自独立,不通过兼容分支互相承载。
const directProjectMode = !designAgentActive;
const [initialProjectPath] = useState(
() => initialProjectPathOverride || readInitialProjectPath(),
);
const eagerProject = Boolean(initialProjectPath);
const [projectPath, setProjectPath] = useState(initialProjectPath);
const [localProject, setLocalProject] =
useState(() =>
eagerProject
? {
projectPath: initialProjectPath,
manifestPath: `${initialProjectPath.replace(/[\\/]+$/, '')}/.agent/manifest.json`,
manifest: initialProjectManifest ?? seedManifest,
}
: null,
);
const localProjectPathRef = useRef(null);
useEffect(() => {
const nextProjectPath = localProject?.projectPath ?? null;
const previousProjectPath = localProjectPathRef.current;
localProjectPathRef.current = nextProjectPath;
// 未绑定项目时无需触发插件宿主;这也避免启动空首页时产生无意义的 Tauri 调用。
if (!nextProjectPath && !previousProjectPath) return;
// 工程类型只决定「有哪几个编辑器插件可用」,不影响要不要尝试拉起:可用性由插件宿主
// 自己判(装配好的编辑器插件各自启动),未就绪时给出统一文案。
let active = true;
void setAgcPluginProjectPath(nextProjectPath)
.then(async () => {
if (active && nextProjectPath) {
await startAvailableAgcEditorPlugins(() => active);
}
})
.catch((error) => {
if (!active || !nextProjectPath) {
return;
}
setWorkspaceStatus(
`编辑器插件未就绪:${
error instanceof Error ? error.message : String(error)
}`,
);
});
return () => {
active = false;
if (nextProjectPath || previousProjectPath) {
void setAgcPluginProjectPath(null).catch(() => undefined);
}
localProjectPathRef.current = null;
};
}, [localProject?.projectPath]);
const manifestRefreshMountedRef = useRef(true);
const manifestRefreshStatesRef = useRef(
new Map<
string,
{
pending: boolean;
inFlight: Promise | null;
}
>(),
);
const [manifest, setManifest] = useState(
initialProjectManifest ?? seedManifest,
);
const manifestRef = useRef(manifest);
manifestRef.current = manifest;
/**
* 跟随工作台壳的清单快照。
*
* 资源命令(改标签 / 改类型 / 重命名 / 删素材)由资源画布写入,写入后壳重读一次
* manifest、按 CAS 归并进 `currentProjectContext`,再把归并结果继续以这个 prop 传下来。
* 但它是 `useState` 的**初值**:壳里换了新清单不会再进来,于是聊天侧(`@` 选择器的
* 标签统计与候选、`@` 候选菜单)一直用挂载时那份旧清单 —— 用户改完标签,画布已经
* 更新,聊天侧却还是旧标签。
*
* 这里把壳那份快照补进聊天侧状态,判据只有两条:同一项目、内容确实变了
* (按内容的短路是必要的:聊天侧自己的写入会被壳原样回传,只比身份会让两边
* 无意义地互相推一轮)。壳那份快照始终由磁盘重读 + CAS 归并得到,因此不会把
* 聊天侧带到更旧的版本上。
*/
useEffect(() => {
const snapshot = initialProjectManifest;
const current = manifestRef.current;
if (!snapshot || snapshot === current) return;
if (snapshot.projectId !== current.projectId) return;
if (JSON.stringify(snapshot) === JSON.stringify(current)) return;
setManifest(snapshot);
}, [initialProjectManifest]);
const initialPlanningPromptLatchRef = useRef({
projectPath: initialProjectPath,
prompt: initialPlanningPrompt.trim(),
claimScope: initialPlanningPromptClaimScope,
creationType: initialCreationType,
attachments: toDirectCodexTurnAttachments(initialAttachments),
});
const handledPlayRequestRef = useRef(null);
function updateClientPreview(
nextPreview: LocalPreviewResult | null,
status: GameCreationAppPreviewStatus = nextPreview ? 'running' : 'stopped',
) {
onPreviewChange?.(
nextPreview
? {
status,
url: nextPreview.url,
port: nextPreview.port,
}
: { status },
);
}
const chatComposerRef = useRef(null);
const [chatAgentBusy, setChatAgentBusy] = useState(false);
// 发布到游戏广场:试玩包导出结果与面板开关由工作台壳持有,聊天容器只负责触发。
const [publishPackageResult, setPublishPackageResult] =
useState(null);
const [publishPanelOpen, setPublishPanelOpen] = useState(false);
// 发布灰度:只有命中的账号才把「发布到游戏广场」入口交给聊天头;读取失败按不开放处理。
const [gamePublishAllowed, setGamePublishAllowed] = useState(false);
const [projectChatError, setProjectChatError] = useState('');
const [designAgentTransientReply, setDesignAgentTransientReplyVisible] =
useState('');
const designAgentTransientReplyTargetRef = useRef('');
const designAgentVisibleReplyRef = useRef('');
const designAgentPendingViewRef = useRef<{
clientTurnId: string;
projectPath: string;
view: DesignView;
} | null>(null);
const [designAgentReasoning, setDesignAgentReasoning] = useState('');
function setDesignAgentTransientReplyTarget(next: string) {
designAgentTransientReplyTargetRef.current = next;
if (!next) {
designAgentVisibleReplyRef.current = '';
setDesignAgentTransientReplyVisible('');
}
}
function designAgentEventSubscriptionReady() {
// 业务动作只读取当前订阅代次;清理与下一次 effect 建立之间不能创建
// 一个没有订阅 effect 接管的悬挂 promise。
return designAgentEventSubscriptionReadyRef.current ?? Promise.resolve();
}
function createDesignAgentEventSubscriptionReady() {
if (!designAgentEventSubscriptionReadyRef.current) {
designAgentEventSubscriptionReadyRef.current = new Promise(
(resolve) => {
designAgentEventSubscriptionResolveRef.current = resolve;
},
);
}
return designAgentEventSubscriptionReadyRef.current;
}
function resolveDesignAgentEventSubscriptionReady() {
designAgentEventSubscriptionResolveRef.current?.();
designAgentEventSubscriptionResolveRef.current = null;
}
useEffect(() => {
const timer = window.setInterval(() => {
const target = designAgentTransientReplyTargetRef.current;
setDesignAgentTransientReplyVisible((current) => {
if (!target) {
designAgentVisibleReplyRef.current = '';
return '';
}
const prefix = target.startsWith(current) ? current : '';
if (prefix === target) {
designAgentVisibleReplyRef.current = target;
return target;
}
const remaining = target.length - prefix.length;
const step = remaining > 160 ? 4 : remaining > 48 ? 2 : 1;
const next = target.slice(0, prefix.length + step);
designAgentVisibleReplyRef.current = next;
return next;
});
}, 50);
return () => window.clearInterval(timer);
}, []);
/**
* 策划 Agent 的实时事件流:本轮流式正文、思考过程和回合中途的视图都靠它推给界面。
*
* 订阅建立是异步的,而回合由一个 invoke 发起;`designAgentEventSubscriptionReady()`
* 让回合等监听器挂好再开始,避免开头几个事件丢掉。事件只认当前项目;有在跑的回合时
* 还要认本轮 `clientTurnId`,迟到的上一轮事件不会画到这一轮上。
*/
useEffect(() => {
const ready = createDesignAgentEventSubscriptionReady();
if (!canSubscribeTauriEvents() || !designAgentActive) {
resolveDesignAgentEventSubscriptionReady();
return () => {
if (designAgentEventSubscriptionReadyRef.current === ready) {
designAgentEventSubscriptionReadyRef.current = null;
createDesignAgentEventSubscriptionReady();
}
};
}
let cleanup: (() => void) | null = null;
let disposed = false;
void subscribeTauriEvent('design-agent-update', (event) => {
const payload = event.payload;
const tracked = designAgentTurnRef.current;
if (
payload.projectPath !== localProjectPathRef.current ||
(tracked && payload.clientTurnId !== tracked.clientTurnId)
) {
return;
}
if (
(payload.kind === 'text' || payload.kind === 'tool') &&
payload.text
) {
setDesignAgentTransientReplyTarget(payload.text);
}
if (payload.reasoningText != null) {
const reasoningTurn = designAgentReasoningTurnRef.current;
if (
reasoningTurn?.projectPath === payload.projectPath &&
reasoningTurn.clientTurnId === payload.clientTurnId
) {
setDesignAgentReasoning(payload.reasoningText);
}
}
if (payload.view) {
applyDesignAgentViewAfterTransient(
payload.view,
payload.projectPath,
payload.clientTurnId,
);
}
})
.then((unlisten) => {
resolveDesignAgentEventSubscriptionReady();
if (disposed) {
unlisten();
return;
}
cleanup = unlisten;
})
.catch(() => {
resolveDesignAgentEventSubscriptionReady();
});
return () => {
disposed = true;
cleanup?.();
resolveDesignAgentEventSubscriptionReady();
if (designAgentEventSubscriptionReadyRef.current === ready) {
designAgentEventSubscriptionReadyRef.current = null;
createDesignAgentEventSubscriptionReady();
}
};
// 订阅只跟着「是否走策划 Agent」这条链路;回调里的项目/回合判据都走 refs,
// 把它们写进依赖会在每轮回复时重订事件。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [designAgentActive]);
function designMessagesToChat(view: DesignView): ChatMessage[] {
const reasoningByMessageId = new Map();
for (const entry of view.reasoningEntries ?? []) {
if (!entry.messageId) {
continue;
}
const texts = reasoningByMessageId.get(entry.messageId) ?? [];
texts.push(entry.text);
reasoningByMessageId.set(entry.messageId, texts);
}
// 持久策划消息没有发送时间,不能把读取时刻显示成历史发送时间。
const messages: ChatMessage[] = view.messages
.filter((message) => message.text.trim())
.map((message) => ({
role: message.role === 'user' ? 'user' : 'assistant',
text: message.text,
runtimeOwned: true,
messageId: message.id,
reasoningText: reasoningByMessageId.get(message.id)?.join('\n\n'),
}));
const initialPrompt = initialPlanningPromptLatchRef.current.prompt;
if (
initialPrompt &&
!messages.some(
(message) => message.role === 'user' && message.text === initialPrompt,
)
) {
messages.unshift({
role: 'user',
text: initialPrompt,
runtimeOwned: true,
});
}
return messages;
}
function applyDesignView(view: DesignView, projectPath: string) {
designAgentLaneRef.current = true;
setDesignAgentView(view);
designAgentActiveRef.current = true;
setDesignAgentActive(true);
setProjectChatError(view.session.lastError ?? '');
setChatAgentBusy(view.running);
const conversation = designMessagesToChat(view);
setMessages(conversation);
savedConversationProjectPathRef.current = projectPath;
savedConversationCountRef.current = conversation.length;
latestMessagesRef.current = conversation;
}
function commitDesignAgentView(view: DesignView, projectPath: string) {
const pendingTurnId = designAgentPendingViewRef.current?.clientTurnId;
designAgentPendingViewRef.current = null;
applyDesignView(view, projectPath);
setDesignAgentReasoning('');
setDesignAgentTransientReplyTarget('');
if (designAgentTurnRef.current?.clientTurnId === pendingTurnId) {
designAgentTurnRef.current = null;
}
}
function applyDesignAgentViewAfterTransient(
view: DesignView,
projectPath: string,
clientTurnId: string,
) {
let target = designAgentTransientReplyTargetRef.current;
const tracked = designAgentTurnRef.current;
if (!target.trim() && !view.running) {
const latestAssistantText = [...view.messages]
.reverse()
.find((message) => message.role !== 'user' && message.text.trim())
?.text.trim();
if (latestAssistantText) {
setDesignAgentTransientReplyTarget(latestAssistantText);
target = latestAssistantText;
}
}
if (
!view.running &&
tracked?.clientTurnId === clientTurnId &&
target.trim() &&
designAgentVisibleReplyRef.current !== target
) {
designAgentPendingViewRef.current = {
clientTurnId,
projectPath,
view,
};
return;
}
commitDesignAgentView(view, projectPath);
}
useEffect(() => {
const timer = window.setInterval(() => {
const pending = designAgentPendingViewRef.current;
if (!pending) {
return;
}
const target = designAgentTransientReplyTargetRef.current;
if (target && designAgentVisibleReplyRef.current !== target) {
return;
}
commitDesignAgentView(pending.view, pending.projectPath);
}, 50);
return () => window.clearInterval(timer);
// 收尾定时器只需注册一次;它读取 refs,避免随每次渲染重建。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function hydrateDesignAgentSession(nextProjectPath: string) {
const invoke = resolveTauriInvoke();
if (!invoke || !nextProjectPath.trim()) {
return null;
}
const view = await invoke(
'hydrate_design_agent_session',
{ projectPath: nextProjectPath },
);
if (localProjectPathRef.current !== nextProjectPath) {
return null;
}
if (!view) {
return null;
}
applyDesignView(view, nextProjectPath);
return view;
}
async function executeDesignAgentTurn(
nextProjectPath: string,
input: DesignAgentInput,
clientTurnId = createAgentChatRunId('design-agent-turn'),
) {
const invoke = resolveTauriInvoke();
if (!invoke) {
setProjectChatError('需要在 Tauri App 内运行。');
return;
}
designAgentTurnRef.current = {
projectPath: nextProjectPath,
clientTurnId,
};
designAgentReasoningTurnRef.current = {
projectPath: nextProjectPath,
clientTurnId,
};
designAgentPendingViewRef.current = null;
await designAgentEventSubscriptionReady();
setChatAgentBusy(true);
setProjectChatError('');
setDesignAgentTransientReplyTarget('');
setDesignAgentReasoning('');
try {
const view = await invoke('continue_design_agent_session', {
projectPath: nextProjectPath,
clientTurnId,
input,
});
if (localProjectPathRef.current !== nextProjectPath) {
return;
}
applyDesignAgentViewAfterTransient(view, nextProjectPath, clientTurnId);
} catch (error) {
if (localProjectPathRef.current !== nextProjectPath) {
return;
}
const message = error instanceof Error ? error.message : String(error);
if (isRuntimeConfigMissingError(message)) {
requestRuntimeConfigOpen();
}
setProjectChatError(message);
} finally {
if (!designAgentPendingViewRef.current) {
designAgentTurnRef.current = null;
setDesignAgentTransientReplyTarget('');
}
setChatAgentBusy(false);
}
}
const [chatFilesImporting, setChatFilesImporting] = useState(false);
const [chatFileImportNotice, setChatFileImportNotice] = useState('');
const planningChatMessagesRef = useRef(null);
const planningChatShouldFollowLatestRef = useRef(true);
const directProjectChatRef = useRef(null);
const [agentRuntimeById, setAgentRuntimeById] = useState<
Record
>({});
const agentRuntimeByIdRef = useRef(agentRuntimeById);
agentRuntimeByIdRef.current = agentRuntimeById;
const [professionalAgentResultsById, setProfessionalAgentResultsById] =
useState>({});
const [workspaceStatus, setWorkspaceStatus] = useState(
eagerProject ? `已打开:${initialProjectPath}` : '请选择工作区',
);
const [messages, setMessages] = useState(
createDefaultChatMessages,
);
const [conversationVisibleCount, setConversationVisibleCount] = useState(
CONVERSATION_INITIAL_VISIBLE_COUNT,
);
const [pendingUiConfirmation, setPendingUiConfirmation] =
useState(null);
const [pendingNonEmptyProjectCreate, setPendingNonEmptyProjectCreate] =
useState<{
projectPath: string;
announceToChat: boolean;
} | null>(null);
const [conversationWriteVersion, setConversationWriteVersion] = useState(0);
const savedConversationCountRef = useRef(
eagerProject ? createDefaultChatMessages().length : 0,
);
const savedConversationProjectPathRef = useRef(
eagerProject ? initialProjectPath : null,
);
const projectConversationWriteConfirmedRef = useRef(null);
const projectConversationWriteCancelledRef = useRef<{
projectPath: string;
messageCount: number;
} | null>(null);
const latestMessagesRef = useRef([]);
const conversationWriteInFlightRef = useRef(false);
const projectScopeVersionRef = useRef(0);
const refreshManifest = useCallback(
(nextProjectPath = localProjectPathRef.current ?? ''): Promise => {
const invoke = resolveTauriInvoke();
if (!invoke || !nextProjectPath) {
return Promise.resolve();
}
const refreshStates = manifestRefreshStatesRef.current;
let refreshState = refreshStates.get(nextProjectPath);
if (!refreshState) {
refreshState = { pending: false, inFlight: null };
refreshStates.set(nextProjectPath, refreshState);
}
refreshState.pending = true;
if (refreshState.inFlight) {
return refreshState.inFlight;
}
const activeRefreshState = refreshState;
const refreshPromise = (async () => {
try {
while (activeRefreshState.pending) {
activeRefreshState.pending = false;
const projectScopeVersion = projectScopeVersionRef.current;
try {
const nextManifest = await invoke(
'get_local_game_manifest',
{ projectPath: nextProjectPath },
);
if (
manifestRefreshMountedRef.current &&
localProjectPathRef.current === nextProjectPath &&
projectScopeVersionRef.current === projectScopeVersion
) {
setManifest(nextManifest);
}
} catch {
// Dev-only convenience; command errors are surfaced by the action that triggered them.
}
if (
!manifestRefreshMountedRef.current ||
localProjectPathRef.current !== nextProjectPath ||
projectScopeVersionRef.current !== projectScopeVersion
) {
activeRefreshState.pending = false;
}
}
} finally {
activeRefreshState.inFlight = null;
if (!activeRefreshState.pending) {
refreshStates.delete(nextProjectPath);
}
}
})();
activeRefreshState.inFlight = refreshPromise;
return refreshPromise;
},
[],
);
useEffect(() => {
const refreshStates = manifestRefreshStatesRef.current;
manifestRefreshMountedRef.current = true;
return () => {
manifestRefreshMountedRef.current = false;
refreshStates.clear();
};
}, []);
const executeChatAgentReplyRef = useRef<
(input: ExecuteChatAgentReplyInput) => Promise
>(async () => undefined);
const agentRuntimeResumeProjectPathRef = useRef(null);
const initialProjectOpenedRef = useRef(false);
const pendingUiConfirmationActionRef = useRef<(() => void) | null>(null);
const executeRunLocalRef = useRef<(announceToChat: boolean) => void>(
() => undefined,
);
const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false);
function requestRuntimeConfigOpen() {
setRuntimeConfigOpen(true);
}
useEscapeToClose(cancelUiCommandConfirmation, pendingUiConfirmation !== null);
useEscapeToClose(
cancelProjectCreateInNonEmptyFolder,
pendingNonEmptyProjectCreate !== null,
);
useEffect(() => {
if (
pendingUiConfirmation?.projectPath &&
pendingUiConfirmation.projectPath !==
resolveCurrentUiConfirmationProjectPath()
) {
cancelUiCommandConfirmation();
}
// The callbacks intentionally read the latest project refs.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localProject?.projectPath, projectPath]);
useEffect(() => {
if (!initialProjectPath || initialProjectOpenedRef.current) {
return;
}
initialProjectOpenedRef.current = true;
if (!isAbsoluteProjectPath(initialProjectPath)) {
setWorkspaceStatus('请提供工作区绝对路径');
return;
}
if (projectPathHasControlCharacter(initialProjectPath)) {
setWorkspaceStatus('工作区路径不能包含控制字符');
return;
}
if (directProjectMode) {
// This component is mounted inside the already-created project workbench.
// Hydrate localProject before the first direct turn so the direct runtime
// cannot silently create a second workspace.
void openWorkspace(initialProjectPath, false, 'open', initialProjectKind);
return;
}
localProjectPathRef.current = initialProjectPath;
void loadProjectConversation(initialProjectPath).finally(() => {
if (
localProjectPathRef.current === initialProjectPath &&
!planningStartMode
) {
void refreshAgentRunTrace(initialProjectPath);
}
});
// Initial project opening is guarded by initialProjectOpenedRef.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialProjectPath]);
useLayoutEffect(() => {
if (!planningChatShouldFollowLatestRef.current) {
return;
}
const messageList = planningChatMessagesRef.current;
if (messageList) {
messageList.scrollTop = messageList.scrollHeight;
}
}, [
messages,
projectChatError,
designAgentTransientReply,
designAgentReasoning,
designAgentView,
pendingUiConfirmation,
chatFileImportNotice,
]);
useEffect(() => {
latestMessagesRef.current = messages;
const invoke = resolveTauriInvoke();
const nextProjectPath = localProject?.projectPath;
if (!invoke || !nextProjectPath) {
return;
}
if (savedConversationProjectPathRef.current !== nextProjectPath) {
savedConversationProjectPathRef.current = nextProjectPath;
savedConversationCountRef.current = 0;
projectConversationWriteConfirmedRef.current = null;
projectConversationWriteCancelledRef.current = null;
}
// DirectProject history is written by Rust from raw app-server items.
// The browser only renders that projection and must not append chat rows.
if (directProjectMode) {
// `messages` is only an optimistic UI projection in Direct mode; it is
// intentionally not proof of durability. Rust owns the raw response
// history, so this effect must not route these rows through the generic
// browser conversation writer.
savedConversationCountRef.current = messages.length;
return;
}
const start = savedConversationCountRef.current;
const pendingMessages = messages.slice(start);
if (pendingMessages.length === 0) {
return;
}
if (
pendingMessages.every(
(message) =>
(message.runtimeOwned &&
!isPersistableDirectCodexConversationMessage(message)) ||
isTransientProjectOpenMessage(message, nextProjectPath),
)
) {
savedConversationCountRef.current = messages.length;
return;
}
if (conversationWriteInFlightRef.current) {
return;
}
if (pendingUiConfirmation) {
return;
}
if (
projectConversationWriteCancelledRef.current?.projectPath ===
nextProjectPath &&
projectConversationWriteCancelledRef.current.messageCount ===
messages.length
) {
return;
}
if (
projectConversationWriteCancelledRef.current?.projectPath ===
nextProjectPath &&
projectConversationWriteCancelledRef.current.messageCount <
messages.length
) {
projectConversationWriteCancelledRef.current = null;
}
if (projectConversationWriteConfirmedRef.current !== nextProjectPath) {
void invoke(
'read_project_permission_policy',
{ projectPath: nextProjectPath },
)
.then((policyView) => {
if (
!policyView.policy.confirmCommands.includes('conversation.write')
) {
projectConversationWriteConfirmedRef.current = nextProjectPath;
setConversationWriteVersion((current) => current + 1);
return;
}
requestProjectPolicyConfirmation(
'conversation.write',
nextProjectPath,
`写入 ${nextProjectPath} 的项目对话`,
() => {
projectConversationWriteConfirmedRef.current = nextProjectPath;
setConversationWriteVersion((current) => current + 1);
},
);
setWorkspaceStatus('等待确认保存项目对话');
})
.catch(() => {
projectConversationWriteConfirmedRef.current = nextProjectPath;
setConversationWriteVersion((current) => current + 1);
});
return;
}
conversationWriteInFlightRef.current = true;
void (async () => {
for (const [index, message] of pendingMessages.entries()) {
if (
message.runtimeOwned &&
!isPersistableDirectCodexConversationMessage(message)
) {
savedConversationCountRef.current = start + index + 1;
continue;
}
if (isTransientProjectOpenMessage(message, nextProjectPath)) {
savedConversationCountRef.current = start + index + 1;
continue;
}
await invoke(
'append_local_conversation_message',
{
projectPath: nextProjectPath,
agentId: null,
...(message.messageId ? { messageId: message.messageId } : {}),
message: {
role: message.role === 'user' ? 'user' : 'assistant',
content: message.text,
agentId: null,
...(typeof message.updatedAt === 'number'
? { updatedAt: message.updatedAt }
: {}),
},
},
);
savedConversationCountRef.current = start + index + 1;
}
setWorkspaceStatus((current) =>
current.startsWith('项目对话保存失败') ||
current === '等待确认保存项目对话'
? `已打开:${nextProjectPath}`
: current,
);
})()
.catch((error) => {
savedConversationCountRef.current = Math.min(
savedConversationCountRef.current,
start,
);
setWorkspaceStatus(
`项目对话保存失败:${
error instanceof Error ? error.message : String(error)
}`,
);
})
.finally(() => {
conversationWriteInFlightRef.current = false;
if (
savedConversationCountRef.current < latestMessagesRef.current.length
) {
setConversationWriteVersion((current) => current + 1);
}
});
// Writes are driven by message and confirmation state; the confirmation helper
// intentionally observes the latest policy refs.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
localProject?.projectPath,
messages,
conversationWriteVersion,
pendingUiConfirmation,
directProjectMode,
]);
function appendLocalPermissionLog(
projectPath: string | null,
event:
| 'permission.pending'
| 'permission.confirm'
| 'permission.cancel'
| 'command.auto',
commandId: GameCreationAppCommandDescriptor['id'],
) {
const invoke = resolveTauriInvoke();
if (!invoke || !projectPath) {
return;
}
try {
void Promise.resolve(
invoke('append_local_permission_log', {
projectPath,
event,
commandId,
}),
).catch(() => undefined);
} catch {
// 权限日志只是审计旁路,写失败不能阻塞对话。
}
}
function resolveUiPermissionLogProjectPath(
commandId: GameCreationAppCommandDescriptor['id'],
) {
if (commandId === 'project.create') {
return null;
}
if (needsInitializedChatProject(commandId)) {
return resolveChatProjectPath(localProject);
}
return projectPath;
}
function resolveCurrentUiConfirmationProjectPath() {
const openedProjectPath = localProjectPathRef.current?.trim();
if (openedProjectPath) {
return openedProjectPath;
}
const draftProjectPath = projectPath.trim();
if (
!draftProjectPath ||
!isAbsoluteProjectPath(draftProjectPath) ||
projectPathHasControlCharacter(draftProjectPath)
) {
return null;
}
return draftProjectPath;
}
function requestProjectPolicyConfirmation(
commandId: GameCreationAppCommandDescriptor['id'],
projectPath: string,
detail: string,
onConfirm: () => void,
) {
pendingUiConfirmationActionRef.current = onConfirm;
setPendingUiConfirmation({ commandId, detail, projectPath });
appendLocalPermissionLog(projectPath, 'permission.pending', commandId);
}
function markProjectPolicyDenied(
commandId: GameCreationAppCommandDescriptor['id'],
message: string,
) {
if (
commandId.startsWith('project.') ||
commandId === 'task.list' ||
commandId.startsWith('preview.')
) {
setWorkspaceStatus(message);
}
}
async function queueProjectPolicyConfirmationIfNeeded(
invoke: TauriInvoke,
commandId: GameCreationAppCommandDescriptor['id'],
projectPath: string,
detail: string,
readyMessage: string,
onConfirm: () => void,
) {
const policyView = await invoke(
'read_project_permission_policy',
{ projectPath },
);
if (policyView.policy.deniedCommands.includes(commandId)) {
const message = `项目权限策略拒绝执行:${commandId}`;
markProjectPolicyDenied(commandId, message);
setMessages((current) => [
...current,
{ role: 'assistant', text: message },
]);
return true;
}
if (!policyView.policy.confirmCommands.includes(commandId)) {
return false;
}
requestProjectPolicyConfirmation(commandId, projectPath, detail, onConfirm);
setMessages((current) => [
...current,
{ role: 'assistant', text: readyMessage },
]);
return true;
}
async function denyPendingCommandIfNeeded(
commandId: GameCreationAppCommandDescriptor['id'],
projectPath: string | null,
) {
const invoke = resolveTauriInvoke();
if (!invoke || !projectPath) {
return false;
}
try {
const policyView = await invoke(
'read_project_permission_policy',
{ projectPath },
);
if (!policyView.policy.deniedCommands.includes(commandId)) {
return false;
}
const message = `项目权限策略拒绝执行:${commandId}`;
markProjectPolicyDenied(commandId, message);
setMessages((current) => [
...current,
{ role: 'assistant', text: message },
]);
return true;
} catch {
return false;
}
}
useEffect(() => {
let cancelled = false;
void readGamePublishAvailability()
.then((allowed) => {
if (!cancelled) setGamePublishAllowed(allowed);
})
.catch(() => {
if (!cancelled) setGamePublishAllowed(false);
});
return () => {
cancelled = true;
};
}, [localProject?.projectPath]);
/**
* 导出试玩包并打开发布面板。
*
* 权限口径沿用本地命令:`project.export_package` 需要确认时先入队,确认后再导出;
* 导出结果只留在壳里,发布面板关闭即丢弃,不写入项目。
*/
async function requestGamePublish() {
const invoke = resolveTauriInvoke();
if (!invoke) {
setWorkspaceStatus('需要在 Tauri App 内发布');
return;
}
const nextProjectPath =
resolveChatProjectPath(localProject) ?? projectPath.trim();
if (!nextProjectPath) {
setWorkspaceStatus('先打开一个项目再发布');
return;
}
const runExport = async () => {
try {
const result = await invoke(
'export_local_project_package',
{ projectPath: nextProjectPath },
);
setWorkspaceStatus(`已导出本地试玩包:${result.packageRelativePath}`);
setPublishPackageResult(result);
setPublishPanelOpen(true);
appendLocalPermissionLog(
nextProjectPath,
'command.auto',
'project.export_package',
);
} catch (error) {
setWorkspaceStatus(
error instanceof Error ? error.message : String(error),
);
}
};
const queued = await queueProjectPolicyConfirmationIfNeeded(
invoke,
'project.export_package',
nextProjectPath,
'导出试玩包并打开「发布到游戏广场」面板。',
'导出试玩包需要确认,确认后继续。',
() => void runExport(),
);
if (!queued) {
await runExport();
}
}
async function confirmUiCommand() {
const pending = pendingUiConfirmation;
if (!pending) {
return;
}
if (
pending.projectPath &&
pending.projectPath !== resolveCurrentUiConfirmationProjectPath()
) {
cancelUiCommandConfirmation();
return;
}
const permissionProjectPath =
pending.projectPath ??
resolveUiPermissionLogProjectPath(pending.commandId);
if (
await denyPendingCommandIfNeeded(pending.commandId, permissionProjectPath)
) {
pendingUiConfirmationActionRef.current = null;
setPendingUiConfirmation(null);
return;
}
const action = pendingUiConfirmationActionRef.current;
pendingUiConfirmationActionRef.current = null;
setPendingUiConfirmation(null);
appendLocalPermissionLog(
permissionProjectPath,
'permission.confirm',
pending.commandId,
);
action?.();
}
function cancelUiCommandConfirmation() {
const pending = pendingUiConfirmation;
if (!pending) {
return;
}
if (pending.commandId === 'conversation.write') {
const nextProjectPath = resolveChatProjectPath(localProject);
if (nextProjectPath) {
projectConversationWriteCancelledRef.current = {
projectPath: nextProjectPath,
messageCount: latestMessagesRef.current.length,
};
setWorkspaceStatus((current) =>
current === '等待确认保存项目对话'
? `已打开:${nextProjectPath}`
: current,
);
}
}
if (pending.commandId === 'conversation.read') {
if (!pending.detail.includes('Agent 对话')) {
setWorkspaceStatus((current) =>
current === '等待确认' ? '已取消读取项目对话' : current,
);
}
}
if (pending.commandId === 'memory.read') {
if (!pending.detail.includes('Agent 私有记忆')) {
setMessages((current) => [
...current,
{ role: 'assistant', text: '已取消读取项目记忆。' },
]);
}
}
if (
pending.commandId === 'project.status' ||
pending.commandId === 'task.list'
) {
const status =
pending.commandId === 'task.list'
? '已取消读取任务拆分'
: '已取消读取项目状态';
setWorkspaceStatus(status);
}
if (pending.commandId === 'asset.list') {
setWorkspaceStatus('已取消读取项目资产');
}
if (
pending.commandId === 'preview.start' ||
pending.commandId === 'preview.open' ||
pending.commandId === 'preview.stop' ||
pending.commandId === 'preview.status'
) {
setWorkspaceStatus('已取消预览操作');
setMessages((current) => [
...current,
{ role: 'assistant', text: '已取消预览操作' },
]);
}
if (
pending.commandId === 'project.index' ||
pending.commandId === 'project.diff' ||
pending.commandId === 'project.export_package'
) {
const status =
pending.commandId === 'project.index'
? '已取消刷新项目索引'
: pending.commandId === 'project.diff'
? '已取消对比项目 checkpoint'
: '已取消导出本地试玩包';
setWorkspaceStatus(status);
}
if (pending.commandId === 'project.create') {
setWorkspaceStatus('已取消');
}
pendingUiConfirmationActionRef.current = null;
setPendingUiConfirmation(null);
appendLocalPermissionLog(
pending.projectPath ??
resolveUiPermissionLogProjectPath(pending.commandId),
'permission.cancel',
pending.commandId,
);
}
executeRunLocalRef.current = executeRunLocal;
useEffect(() => {
const nextProjectPath = localProject?.projectPath;
if (
!playRequest ||
!nextProjectPath ||
playRequest.projectPath !== nextProjectPath
) {
return;
}
const requestKey = `${playRequest.projectPath}\n${playRequest.requestId}`;
if (handledPlayRequestRef.current === requestKey) {
return;
}
handledPlayRequestRef.current = requestKey;
onPlayRequestHandled?.(playRequest.requestId);
void executeRunLocalRef.current(true);
}, [localProject?.projectPath, onPlayRequestHandled, playRequest]);
/**
* 切换项目作用域时把这条链路自己的聊天状态清干净。
*
* 策划会话与设计 Agent 视图都属于上一个项目;项目身份一变就不能留到下一个项目里。
*/
function resetChatState() {
setChatFilesImporting(false);
setChatFileImportNotice('');
setProjectChatError('');
setDesignAgentTransientReplyTarget('');
designAgentPendingViewRef.current = null;
setDesignAgentReasoning('');
setDesignAgentActive(planningStartMode);
designAgentActiveRef.current = planningStartMode;
designAgentLaneRef.current = planningStartMode;
designAgentTurnRef.current = null;
designAgentReasoningTurnRef.current = null;
setDesignAgentView(null);
}
async function loadProjectConversation(
nextProjectPath: string,
skipPolicyConfirm = false,
) {
const invoke = resolveTauriInvoke();
if (!invoke) {
return;
}
if (!skipPolicyConfirm) {
try {
const policyView = await invoke(
'read_project_permission_policy',
{ projectPath: nextProjectPath },
);
if (policyView.policy.confirmCommands.includes('conversation.read')) {
requestProjectPolicyConfirmation(
'conversation.read',
nextProjectPath,
`读取 ${nextProjectPath} 的项目对话历史`,
() => void loadProjectConversation(nextProjectPath, true),
);
setWorkspaceStatus('等待确认');
return;
}
} catch {
return;
}
}
try {
const design = await hydrateDesignAgentSession(nextProjectPath);
if (design) {
if (localProjectPathRef.current !== nextProjectPath) {
return;
}
setWorkspaceStatus((workspaceStatus) =>
workspaceStatus === '等待确认'
? `已打开:${nextProjectPath}`
: workspaceStatus,
);
return;
}
} catch (error) {
if (planningStartMode) {
setProjectChatError(
`策划会话无法恢复:${error instanceof Error ? error.message : String(error)}`,
);
}
}
if (planningStartMode) {
if (localProjectPathRef.current !== nextProjectPath) {
return;
}
designAgentActiveRef.current = true;
setDesignAgentActive(true);
designAgentLaneRef.current = true;
setDesignAgentView(null);
setMessages(createDefaultChatMessages());
savedConversationProjectPathRef.current = nextProjectPath;
savedConversationCountRef.current = 0;
latestMessagesRef.current = [];
setWorkspaceStatus((workspaceStatus) =>
workspaceStatus === '等待确认'
? `已打开:${nextProjectPath}`
: workspaceStatus,
);
return;
}
// DirectProject 的项目对话由聊天容器自己的订阅与切片读取拥有:工作台壳不读项目
// 对话文件,也不把历史混进壳自己的 `messages`。
setProjectChatError('');
setWorkspaceStatus((workspaceStatus) =>
workspaceStatus === '等待确认'
? `已打开:${nextProjectPath}`
: workspaceStatus,
);
}
async function openWorkspace(
nextProjectPath: string,
announceToChat: boolean,
mode: 'create' | 'open' = 'create',
// 工程类型已不再由壳层决定插件可用性(插件宿主自己判),保留入参只为调用契约。
_projectKind: LocalProjectKind = 'web',
) {
const trimmedProjectPath = nextProjectPath.trim();
if (!trimmedProjectPath || !isAbsoluteProjectPath(trimmedProjectPath)) {
setWorkspaceStatus('请提供工作区绝对路径');
return;
}
if (projectPathHasControlCharacter(trimmedProjectPath)) {
setWorkspaceStatus('工作区路径不能包含控制字符');
return;
}
const invoke = resolveTauriInvoke();
if (!invoke) {
setWorkspaceStatus('需要在 Tauri App 内运行');
if (announceToChat) {
setMessages((current) => [
...current,
{ role: 'assistant', text: '需要在 Tauri App 内运行。' },
]);
}
return;
}
const projectScopeVersion = projectScopeVersionRef.current + 1;
projectScopeVersionRef.current = projectScopeVersion;
resetChatState();
updateClientPreview(null);
setWorkspaceStatus('正在打开');
try {
const result =
mode === 'open'
? {
projectPath: trimmedProjectPath,
manifestPath: `${trimmedProjectPath.replace(/[\\/]+$/, '')}/.agent/manifest.json`,
manifest: await invoke(
'get_local_game_manifest',
{ projectPath: trimmedProjectPath },
),
}
: await invoke('init_local_game_project', {
projectPath: trimmedProjectPath,
projectId: createLocalProjectId(),
name: seedManifest.name,
});
if (projectScopeVersionRef.current !== projectScopeVersion) {
return;
}
const openedProjectPath = result.projectPath.trim();
if (
!openedProjectPath ||
!isAbsoluteProjectPath(openedProjectPath) ||
projectPathHasControlCharacter(openedProjectPath)
) {
const message = '本地项目路径无效';
setWorkspaceStatus(message);
if (announceToChat) {
setMessages((current) => [
...current,
{ role: 'assistant', text: message },
]);
}
return;
}
const openedProject = { ...result, projectPath: openedProjectPath };
const conversationMessages = createDefaultChatMessages();
if (
pendingUiConfirmation?.projectPath &&
pendingUiConfirmation.projectPath !== openedProject.projectPath
) {
cancelUiCommandConfirmation();
}
localProjectPathRef.current = openedProject.projectPath;
setProjectPath(openedProject.projectPath);
setLocalProject(openedProject);
setManifest(openedProject.manifest);
setAgentRuntimeById({});
setMessages(conversationMessages);
setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT);
savedConversationProjectPathRef.current = openedProject.projectPath;
savedConversationCountRef.current = conversationMessages.length;
writeRecentWorkspace(openedProject.projectPath);
setWorkspaceStatus(`已打开:${openedProject.projectPath}`);
appendLocalPermissionLog(
openedProject.projectPath,
'permission.confirm',
mode === 'open' ? 'project.status' : 'project.create',
);
if (announceToChat) {
setMessages((current) => [
...current,
{
role: 'assistant',
text: `已设置本地项目:${openedProject.projectPath}`,
},
]);
}
void loadProjectConversation(openedProject.projectPath);
if (!directProjectMode) {
void refreshAgentRunTrace(openedProject.projectPath);
}
} catch (error) {
if (projectScopeVersionRef.current !== projectScopeVersion) {
return;
}
const message = error instanceof Error ? error.message : String(error);
setWorkspaceStatus(message);
if (announceToChat) {
setMessages((current) => [
...current,
{ role: 'assistant', text: message },
]);
}
}
}
useEffect(() => {
const handleResourceReferenceInsert = (event: Event) => {
const detail = (event as CustomEvent)
.detail;
if (!detail?.reference) return;
chatComposerRef.current?.insertReferences([detail.reference]);
chatComposerRef.current?.focus();
};
window.addEventListener(
RESOURCE_REFERENCE_INSERT_EVENT,
handleResourceReferenceInsert,
);
return () =>
window.removeEventListener(
RESOURCE_REFERENCE_INSERT_EVENT,
handleResourceReferenceInsert,
);
}, []);
/**
* DirectProject 聊天的首轮需求:入口 latch 里那份原文与附件,项目匹配后由聊天认领。
*
* 认领记录与 Design / Planning 入口共用一份(按页面保存),因此同一条
* 首轮需求不会被两个入口各发一次。
*/
const initialDirectTurn: DirectProjectInitialTurn | null = (() => {
const latch = initialPlanningPromptLatchRef.current;
if (!latch.prompt || !latch.projectPath) return null;
return {
projectPath: latch.projectPath,
// 首轮需求按 Composer 的 canonical content 形状交给聊天:文本与附件引用内联在
// 同一份 content 里,`@` 引用与附件的处理不需要两套入参。
content: [
{ type: 'input_text', text: latch.prompt },
...directCodexAttachmentContentParts(latch.attachments),
],
creationType: latch.creationType,
claimScope: latch.claimScope,
};
})();
/**
* DirectProject 读自己项目对话的门:策略要求确认时入队确认,读取由回调继续。
*/
async function ensureDirectHistoryReadAllowed(input: {
projectPath: string;
onConfirmed: () => void;
}) {
const invoke = resolveTauriInvoke();
if (!invoke) return false;
try {
const policyView = await invoke(
'read_project_permission_policy',
{ projectPath: input.projectPath },
);
if (!policyView.policy.confirmCommands.includes('conversation.read')) {
return true;
}
requestProjectPolicyConfirmation(
'conversation.read',
input.projectPath,
`读取 ${input.projectPath} 的项目对话历史`,
input.onConfirmed,
);
setWorkspaceStatus('等待确认');
return false;
} catch {
return false;
}
}
/**
* DirectProject 写自己项目对话的门:策略要求确认时入队确认,确认后重跑同一轮。
*/
async function ensureDirectTurnWriteAllowed(input: {
projectPath: string;
onConfirmed: () => void;
}) {
const invoke = resolveTauriInvoke();
if (!invoke) return false;
if (projectConversationWriteConfirmedRef.current === input.projectPath) {
return true;
}
try {
const paused = await queueProjectPolicyConfirmationIfNeeded(
invoke,
'conversation.write',
input.projectPath,
'写入 DirectProject 对话历史',
'DirectProject 对话写入需要确认。',
() => {
projectConversationWriteConfirmedRef.current = input.projectPath;
input.onConfirmed();
},
);
if (paused) return false;
projectConversationWriteConfirmedRef.current = input.projectPath;
return true;
} catch (error) {
if (localProjectPathRef.current === input.projectPath) {
setProjectChatError(
`DirectProject 对话权限检查失败:${
error instanceof Error ? error.message : String(error)
}`,
);
}
return false;
}
}
/**
* 工作台壳要把一句结果说给用户在项目对话里听。
*
* DirectProject 的会话由聊天容器持有,壳只把这句话交给聊天的本地消息流;
* 立项策划路径仍写壳自己的 `messages`。
*/
function announceProjectChatMessage(text: string) {
if (directProjectMode) {
directProjectChatRef.current?.announce(text);
return;
}
setMessages((current) => [...current, { role: 'assistant', text }]);
}
async function executeChatAgentReply({
prompt,
clientTurnId: directConversationTurnId,
}: ExecuteChatAgentReplyInput) {
const nextProjectPath = resolveChatProjectPath(localProject);
if (!nextProjectPath) {
setProjectChatError('请先初始化本地项目');
return;
}
await executeDesignAgentTurn(
nextProjectPath,
{ type: 'message', text: prompt },
directConversationTurnId ?? createAgentChatRunId('design-agent-turn'),
);
}
executeChatAgentReplyRef.current = executeChatAgentReply;
useEffect(() => {
const latch = initialPlanningPromptLatchRef.current;
if (!planningStartMode || !latch.prompt || !localProject) {
return;
}
if (localProject.projectPath !== latch.projectPath) {
// 不能在这里先"占用"这条初始消息:项目路径可能因为分隔符/大小写/时序先落到别的
// 路径上,一旦占用,真正匹配的项目就再也不会收到这条消息,用户的输入被静默丢掉。
// 这里只等待,占用留给下面真正要发送的那一步。
return;
}
if (
chatAgentBusy ||
!claimInitialTurnForPage(latch.projectPath, latch.claimScope)
) {
return;
}
planningChatShouldFollowLatestRef.current = true;
setMessages((current) => [
...current,
{
role: 'user',
text: latch.prompt,
runtimeOwned: true,
updatedAt: Date.now(),
},
]);
// DirectProject 的首轮需求由聊天容器自己认领并发送(见 `DirectProjectChat`)。
void executeChatAgentReplyRef.current({ prompt: latch.prompt });
// 初始消息 latch 只由入口状态驱动;controller 内部函数保持稳定语义。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
chatAgentBusy,
initialCreationType,
initialPlanningPrompt,
localProject,
planningStartMode,
]);
function cancelProjectCreateInNonEmptyFolder() {
const pendingCreate = pendingNonEmptyProjectCreate;
setPendingNonEmptyProjectCreate(null);
setWorkspaceStatus('已取消');
if (pendingCreate?.announceToChat) {
setMessages((current) => [
...current,
{
role: 'assistant',
text: `已取消在非空文件夹中新建项目:${pendingCreate.projectPath}`,
},
]);
}
}
/**
* 预览已经在跑时先切视图,不重启预览服务。
*
* `activate_local_game_preview` 是 Rust 侧的「这条预览还活着、且属于这个项目」闸门:
* 它只核对内存 registry 里的状态并回传可用的 loopback 地址,前端据此进客户端运行视图。
* 同一个动作过去由工作台壳的 `/preview open` 聊天命令承担,那条命令链随 Supervisor
* 前端链路一起退役,行为改由「运行」入口承接,结果经 DirectProject 聊天的 `announce`
* 交给聊天自己的消息流。返回 `null` 表示没有可复用的活体预览,调用方照旧重启预览。
*/
async function activateRunningPreview(
invoke: TauriInvoke,
nextProjectPath: string,
): Promise {
try {
const status = await invoke(
'activate_local_game_preview',
{ projectPath: nextProjectPath },
);
if (
status.status !== 'running' ||
!status.url ||
!status.port ||
!status.root
) {
return null;
}
return { url: status.url, port: status.port, root: status.root };
} catch {
// 没在跑、不属于这个项目,或权限位要求确认:都不是错误,回落到重新启动预览。
return null;
}
}
async function executeRunLocal(announceToChat: boolean) {
const invoke = resolveTauriInvoke();
if (!invoke) {
if (announceToChat) {
announceProjectChatMessage('需要在 Tauri App 内运行。');
}
return;
}
const nextProjectPath = resolveChatProjectPath(localProject);
if (!nextProjectPath) {
if (announceToChat) {
announceProjectChatMessage('请先用 /project 设置本地项目。');
}
return;
}
try {
const activePreview = await activateRunningPreview(
invoke,
nextProjectPath,
);
if (activePreview) {
updateClientPreview(activePreview);
if (announceToChat) {
announceProjectChatMessage(
`已切换到客户端运行视图:${activePreview.url}`,
);
}
return;
}
const previewResult = await invoke(
'start_local_game_preview',
{ projectPath: nextProjectPath },
);
updateClientPreview(previewResult);
void refreshManifest(nextProjectPath);
if (!directProjectMode) {
void refreshAgentRunTrace(nextProjectPath);
}
if (announceToChat) {
announceProjectChatMessage(
`运行通过,已载入客户端运行视图:${previewResult.url}`,
);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (announceToChat) {
announceProjectChatMessage(message);
}
}
}
async function loadAgentRunTraceFile(
relativePath: string,
nextProjectPath = resolveChatProjectPath(localProject) ?? '',
) {
const invoke = resolveTauriInvoke();
if (!invoke) {
return null;
}
if (!nextProjectPath) {
return null;
}
try {
const result = await invoke(
'read_local_project_file',
{
projectPath: nextProjectPath,
relativePath,
commandId: isAgentTraceFilePath(relativePath)
? 'agent.trace_read'
: 'file.read',
},
);
const trace = parseAgentRunTrace(result.content);
return summarizeAgentRunCompletionForChat(trace);
} catch {
return null;
}
}
function rememberAgentRuntimeState(runtime: AgentRuntimeState | null) {
if (!runtime) {
return;
}
const next = mergeAgentRuntimeStateIntoMap(
agentRuntimeByIdRef.current,
runtime,
false,
);
agentRuntimeByIdRef.current = next;
setAgentRuntimeById(next);
}
async function refreshAgentRuntimes(
nextProjectPath = resolveChatProjectPath(localProject) ?? '',
) {
const invoke = resolveTauriInvoke();
if (!invoke || !nextProjectPath) {
setAgentRuntimeById({});
agentRuntimeResumeProjectPathRef.current = null;
return;
}
if (directProjectMode) {
agentRuntimeResumeProjectPathRef.current = nextProjectPath;
setAgentRuntimeById({});
return;
}
try {
if (agentRuntimeResumeProjectPathRef.current !== nextProjectPath) {
try {
const resumedRuntimes = await invoke(
'resume_game_creator_agent_runtime_tasks',
{ projectPath: nextProjectPath },
);
if (localProjectPathRef.current !== nextProjectPath) {
return;
}
agentRuntimeResumeProjectPathRef.current = nextProjectPath;
for (const runtimeResult of resumedRuntimes) {
rememberAgentRuntimeState(
agentRuntimeStateFromResult(runtimeResult),
);
}
} catch (error) {
if (localProjectPathRef.current !== nextProjectPath) {
return;
}
const message =
error instanceof Error ? error.message : String(error);
if (isMissingAgentRuntimeResumeCommandError(error)) {
agentRuntimeResumeProjectPathRef.current = nextProjectPath;
} else if (
message.includes('项目权限策略要求用户确认:agent.resume')
) {
agentRuntimeResumeProjectPathRef.current = nextProjectPath;
requestProjectPolicyConfirmation(
'agent.resume',
nextProjectPath,
`恢复 ${nextProjectPath} 中未完成的 Agent Runtime 任务`,
() => {
void invoke(
'confirm_resume_game_creator_agent_runtime_tasks',
{ projectPath: nextProjectPath },
)
.then((resumedRuntimes) => {
if (localProjectPathRef.current !== nextProjectPath) {
return;
}
agentRuntimeResumeProjectPathRef.current = nextProjectPath;
for (const runtimeResult of resumedRuntimes) {
rememberAgentRuntimeState(
agentRuntimeStateFromResult(runtimeResult),
);
}
})
.catch(() => {
if (localProjectPathRef.current !== nextProjectPath) {
return;
}
agentRuntimeResumeProjectPathRef.current = null;
});
},
);
} else if (message.includes('项目权限策略拒绝执行:agent.resume')) {
agentRuntimeResumeProjectPathRef.current = nextProjectPath;
markProjectPolicyDenied('agent.resume', message);
} else {
agentRuntimeResumeProjectPathRef.current = null;
}
}
}
const runtimes = await invoke(
'read_game_creator_agent_runtimes',
{ projectPath: nextProjectPath },
);
if (localProjectPathRef.current !== nextProjectPath) {
return;
}
const nextRuntimes: AgentRuntimeState[] = [];
for (const runtimeResult of runtimes) {
nextRuntimes.push(agentRuntimeStateFromResult(runtimeResult));
}
setAgentRuntimeById((current) =>
nextRuntimes.reduce(
(next, runtime) => mergeAgentRuntimeStateIntoMap(next, runtime, true),
current,
),
);
} catch {
if (localProjectPathRef.current === nextProjectPath) {
setAgentRuntimeById({});
}
}
}
async function refreshAgentRunTrace(
nextProjectPath = resolveChatProjectPath(localProject) ?? '',
) {
if (!nextProjectPath) {
await refreshAgentRuntimes(nextProjectPath);
return null;
}
const summary = await loadAgentRunTraceFile(
'.agent/run.latest.json',
nextProjectPath,
);
await refreshAgentRuntimes(nextProjectPath);
return summary;
}
const professionalResultCandidates = taskRowsFromManifest(manifest).map(
(task) => ({
agentId: agentConversationId(task),
label: projectProfessionalAgentLabel(agentConversationId(task)),
runtimeUpdatedAt:
agentRuntimeById[agentConversationId(task)]?.updatedAt ?? 0,
}),
);
const professionalResultCandidateKey = professionalResultCandidates
.map((candidate) => `${candidate.agentId}:${candidate.runtimeUpdatedAt}`)
.join('|');
useEffect(() => {
const nextProjectPath = localProject?.projectPath;
if (!nextProjectPath || !onManifestChange) {
return;
}
const invoke = resolveTauriInvoke();
if (!invoke) {
return;
}
let cancelled = false;
void (async () => {
for (let attempt = 0; attempt < 2; attempt += 1) {
const before = await invoke(
'get_local_game_project_revision',
{ projectPath: nextProjectPath },
);
const currentManifest = await invoke(
'get_local_game_manifest',
{ projectPath: nextProjectPath },
);
const after = await invoke(
'get_local_game_project_revision',
{ projectPath: nextProjectPath },
);
if (before.revision !== after.revision) {
continue;
}
if (!cancelled) {
onManifestChange(nextProjectPath, currentManifest, {
projectId: currentManifest.projectId,
revision: after.revision,
source: 'chat',
});
}
return;
}
})().catch(() => undefined);
return () => {
cancelled = true;
};
}, [localProject?.projectPath, manifest, onManifestChange]);
useEffect(() => {
const invoke = resolveTauriInvoke();
const nextProjectPath = localProject?.projectPath ?? null;
if (
directProjectMode ||
!invoke ||
!nextProjectPath ||
professionalResultCandidates.length === 0
) {
setProfessionalAgentResultsById({});
return;
}
let disposed = false;
void Promise.all(
professionalResultCandidates.map(async (candidate) => {
try {
const conversation = await invoke(
'read_local_conversation',
{
projectPath: nextProjectPath,
agentId: candidate.agentId,
},
);
const finalMessage = conversation.messages
.slice()
.reverse()
.find(
(message) =>
message.role === 'assistant' &&
message.content.trim() &&
isAgentFinalizationMessageId(message.messageId),
);
if (!finalMessage) {
return {
agentId: candidate.agentId,
status: 'missing' as const,
};
}
return {
agentId: candidate.agentId,
status: 'loaded' as const,
result: {
agentId: candidate.agentId,
runId: finalMessage.messageId!,
label: candidate.label,
title: `${candidate.label} 文本回执`,
content: finalMessage.content.trim(),
updatedAt: finalMessage.updatedAt,
} satisfies ProjectAgentResultSummary,
};
} catch {
return {
agentId: candidate.agentId,
status: 'failed' as const,
};
}
}),
).then((results) => {
if (disposed || localProjectPathRef.current !== nextProjectPath) {
return;
}
setProfessionalAgentResultsById((current) => {
const candidateAgentIds = new Set(
professionalResultCandidates.map((candidate) => candidate.agentId),
);
const next = Object.fromEntries(
Object.entries(current).filter(([agentId]) =>
candidateAgentIds.has(agentId),
),
);
for (const outcome of results) {
if (outcome.status === 'loaded') {
next[outcome.agentId] = outcome.result;
} else if (outcome.status === 'missing') {
delete next[outcome.agentId];
}
}
return next;
});
});
return () => {
disposed = true;
};
// The semantic candidate key replaces the freshly allocated candidates array.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
directProjectMode,
localProject?.projectPath,
professionalResultCandidateKey,
]);
useEffect(() => {
if (!onAgentRuntimeSummariesChange) {
return;
}
// 工作台壳不再持有任何根 Runtime:普通项目的运行态由 `DirectProjectChatView`
// 自己的订阅拥有,策划链路只投影设计 Agent 会话。这里保留回调契约,根 Runtime
// 传 null,运行态叠加交给各入口自己的事实源。
onAgentRuntimeSummariesChange(
projectAgentRuntimeSummaries(manifest, null, agentRuntimeById),
);
}, [agentRuntimeById, manifest, onAgentRuntimeSummariesChange]);
useEffect(() => {
if (!onAgentResultsChange) {
return;
}
onAgentResultsChange(Object.values(professionalAgentResultsById));
}, [onAgentResultsChange, professionalAgentResultsById]);
const chatProjectAssets = manifest.assets.filter(
(asset) => asset.localPath && !asset.localPath.startsWith('.agent/'),
);
// `@` 面板「当前版本素材」的版本来源:版本列表来自 manifest,
// 当前版本由工作台壳(`WorkspaceLauncherShell`)持有的那一份状态给出,
// 传 `null` 表示回退到 manifest 中最新的版本。
const chatProjectVersions = manifest.versions ?? [];
const chatActiveVersionId = activeVersionId;
const visibleMessages = latestVisibleItems(
messages,
conversationVisibleCount,
);
const hiddenConversationCount = Math.max(
0,
messages.length - visibleMessages.length,
);
const hasEarlierConversationMessages = hiddenConversationCount > 0;
async function showEarlierConversationMessages() {
setConversationVisibleCount((current) =>
Math.min(messages.length, current + CONVERSATION_VISIBLE_STEP),
);
}
function handleConversationScroll(event: UIEvent) {
if (!hasEarlierConversationMessages) {
return;
}
if (event.currentTarget.scrollTop <= 24) {
showEarlierConversationMessages();
}
}
function handlePlanningChatScroll(event: UIEvent) {
handleConversationScroll(event);
const messageList = event.currentTarget;
const distanceFromBottom =
messageList.scrollHeight -
messageList.scrollTop -
messageList.clientHeight;
planningChatShouldFollowLatestRef.current =
distanceFromBottom <= AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD;
}
/**
* 策划输入盒导入本地文件:文件直接写进策划工作区(`references/`),既不进游戏资源
* 清单,也不生成回合附件;导入期间通知工作台壳暂缓切换游戏运行态。
*/
async function handleDesignComposerUploadFiles(files: readonly File[]) {
const invoke = resolveTauriInvoke();
const nextProjectPath = resolveChatProjectPath(localProject);
if (!invoke || !nextProjectPath) {
setChatFileImportNotice('需要先打开本地项目,才能导入文件');
return;
}
if (chatFilesImporting || files.length === 0) {
return;
}
setChatFilesImporting(true);
onDesignFilesImportingChange?.(nextProjectPath, true);
setChatFileImportNotice('正在导入文件');
try {
const notice = await importDesignFiles(invoke, nextProjectPath, files);
if (localProjectPathRef.current === nextProjectPath) {
setChatFileImportNotice(notice);
}
} finally {
setChatFilesImporting(false);
onDesignFilesImportingChange?.(nextProjectPath, false);
}
}
function handlePlanningChatSubmit(event: FormEvent) {
event.preventDefault();
const content = chatComposerRef.current?.getDraft().content ?? [];
const legacyContent = directCodexContentToLegacyContentDto(
content,
manifest.assets,
);
const canonicalPrompt = legacyContent.text;
if (!hasMeaningfulDirectCodexContent(content)) {
return;
}
if (chatAgentBusy) {
return;
}
if (!canonicalPrompt) {
return;
}
planningChatShouldFollowLatestRef.current = true;
const clientTurnId = createAgentChatRunId('design-agent-turn');
chatComposerRef.current?.clear();
setMessages((current) => [
...current,
{
role: 'user',
text: canonicalPrompt,
runtimeOwned: true,
updatedAt: Date.now(),
},
]);
void executeChatAgentReply({ prompt: canonicalPrompt, clientTurnId });
}
const useDesignAgentSurface = Boolean(designAgentView) || designAgentActive;
if (directProjectMode) {
// 普通项目固定走 DirectProject 自己的聊天容器:订阅、历史、发送、队列和附件都由
// 容器持有,工作台壳只提供项目身份、入口首轮需求和两条权限门。
return (
<>
setPublishPanelOpen(false)}
/>
>
);
}
return (
<>
{
const nextProjectPath = resolveChatProjectPath(localProject);
const invoke = resolveTauriInvoke();
if (!nextProjectPath || !invoke) {
return;
}
const clientTurnId = createAgentChatRunId('design-agent-turn');
designAgentTurnRef.current = {
projectPath: nextProjectPath,
clientTurnId,
};
designAgentReasoningTurnRef.current = {
projectPath: nextProjectPath,
clientTurnId,
};
designAgentPendingViewRef.current = null;
setDesignAgentTransientReplyTarget('');
setDesignAgentReasoning('');
setChatAgentBusy(true);
void designAgentEventSubscriptionReady()
.then(() =>
invoke('decide_design_phase', {
projectPath: nextProjectPath,
clientTurnId,
requestId,
approved,
}),
)
.then((view) => {
if (
localProjectPathRef.current !== nextProjectPath ||
designAgentTurnRef.current?.projectPath !==
nextProjectPath ||
designAgentTurnRef.current?.clientTurnId !== clientTurnId
) {
return;
}
applyDesignAgentViewAfterTransient(
view,
nextProjectPath,
clientTurnId,
);
})
.catch((error) => {
if (
localProjectPathRef.current !== nextProjectPath ||
designAgentTurnRef.current?.projectPath !==
nextProjectPath ||
designAgentTurnRef.current?.clientTurnId !== clientTurnId
) {
return;
}
setProjectChatError(String(error));
})
.finally(() => {
if (
localProjectPathRef.current !== nextProjectPath ||
designAgentTurnRef.current?.projectPath !==
nextProjectPath ||
designAgentTurnRef.current?.clientTurnId !== clientTurnId
) {
return;
}
if (!designAgentPendingViewRef.current) {
designAgentTurnRef.current = null;
setDesignAgentTransientReplyTarget('');
}
setChatAgentBusy(false);
});
}
: undefined
}
onDesignClarify={
useDesignAgentSurface
? (question: DesignClarificationRequest, optionIndex, text) => {
const nextProjectPath = resolveChatProjectPath(localProject);
if (!nextProjectPath) {
return;
}
void executeDesignAgentTurn(nextProjectPath, {
type: 'clarification',
requestId: question.requestId,
optionIndex,
text: text.trim() ? text : null,
});
}
: undefined
}
onDesignRetry={
useDesignAgentSurface
? () => {
const nextProjectPath = resolveChatProjectPath(localProject);
if (!nextProjectPath) {
return;
}
void executeDesignAgentTurn(nextProjectPath, { type: 'retry' });
}
: undefined
}
error={projectChatError}
controlBusy={chatAgentBusy}
attachmentNotice={chatFileImportNotice}
importingFiles={chatFilesImporting}
onUploadFiles={(files) => void handleDesignComposerUploadFiles(files)}
versions={chatProjectVersions}
/>
{runtimeConfigOpen ? (
setRuntimeConfigOpen(false)}
/>
) : null}
setPublishPanelOpen(false)}
/>
>
);
}