e5460280ce
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (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
- 后端:GET /api/runtime/frontend-config 新增 gameDistributionPublishEnabled,复用 `game-distribution:publish` 判据(未配置或 enabled=false 时对已登录作者默认开放,显式收紧后只放行白名单/灰度命中用户,匿名恒为 false),前端入口与写入口共用同一事实源。 - 后端用例:新增 frontend_runtime_config_game_distribution_publish_is_scoped_to_authenticated_gate,覆盖默认开放、enabled=true 无白名单、白名单命中、deny 名单、enabled=false 回退与 rolloutPercent=100。 - 网页:平台壳按灰度隐藏「发布游戏 / 发布新版本」入口;/games/publish 直接访问时渲染「发布功能正在灰度中」并提供重新检查;读取失败按放行处理,由后端写入口把关并返回可读文案。 - AGC:新增 readGamePublishAvailability(同一运行时配置字段),只有命中才把发布回调交给 DirectProject 聊天头;字段缺失或读取失败按不开放处理。 - 文档:玩法链路、后端数据契约与实施计划记录灰度口径、入口行为与验证命令。
2497 lines
84 KiB
TypeScript
2497 lines
84 KiB
TypeScript
/* 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 <WorkspaceLauncherShell {...props} ProjectChat={App} />;
|
|
}
|
|
|
|
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<DesignView | null>(
|
|
null,
|
|
);
|
|
const designAgentLaneRef = useRef(planningStartMode);
|
|
const designAgentTurnRef = useRef<{
|
|
projectPath: string;
|
|
clientTurnId: string;
|
|
} | null>(null);
|
|
const designAgentEventSubscriptionReadyRef = useRef<Promise<void> | 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<InitLocalProjectResult | null>(() =>
|
|
eagerProject
|
|
? {
|
|
projectPath: initialProjectPath,
|
|
manifestPath: `${initialProjectPath.replace(/[\\/]+$/, '')}/.agent/manifest.json`,
|
|
manifest: initialProjectManifest ?? seedManifest,
|
|
}
|
|
: null,
|
|
);
|
|
const localProjectPathRef = useRef<string | null>(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<void> | null;
|
|
}
|
|
>(),
|
|
);
|
|
const [manifest, setManifest] = useState<GameCreationAppManifest>(
|
|
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<string | null>(null);
|
|
|
|
function updateClientPreview(
|
|
nextPreview: LocalPreviewResult | null,
|
|
status: GameCreationAppPreviewStatus = nextPreview ? 'running' : 'stopped',
|
|
) {
|
|
onPreviewChange?.(
|
|
nextPreview
|
|
? {
|
|
status,
|
|
url: nextPreview.url,
|
|
port: nextPreview.port,
|
|
}
|
|
: { status },
|
|
);
|
|
}
|
|
|
|
const chatComposerRef = useRef<ResourceReferenceInputHandle | null>(null);
|
|
const [chatAgentBusy, setChatAgentBusy] = useState(false);
|
|
// 发布到游戏广场:试玩包导出结果与面板开关由工作台壳持有,聊天容器只负责触发。
|
|
const [publishPackageResult, setPublishPackageResult] =
|
|
useState<LocalProjectExportPackageResult | null>(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<void>(
|
|
(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<DesignEvent>('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<string, string[]>();
|
|
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<DesignView | null>(
|
|
'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<DesignView>('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<HTMLDivElement | null>(null);
|
|
const planningChatShouldFollowLatestRef = useRef(true);
|
|
const directProjectChatRef = useRef<DirectProjectChatHandle | null>(null);
|
|
const [agentRuntimeById, setAgentRuntimeById] = useState<
|
|
Record<string, AgentRuntimeState | undefined>
|
|
>({});
|
|
const agentRuntimeByIdRef = useRef(agentRuntimeById);
|
|
agentRuntimeByIdRef.current = agentRuntimeById;
|
|
const [professionalAgentResultsById, setProfessionalAgentResultsById] =
|
|
useState<Record<string, ProjectAgentResultSummary>>({});
|
|
const [workspaceStatus, setWorkspaceStatus] = useState(
|
|
eagerProject ? `已打开:${initialProjectPath}` : '请选择工作区',
|
|
);
|
|
const [messages, setMessages] = useState<ChatMessage[]>(
|
|
createDefaultChatMessages,
|
|
);
|
|
const [conversationVisibleCount, setConversationVisibleCount] = useState(
|
|
CONVERSATION_INITIAL_VISIBLE_COUNT,
|
|
);
|
|
const [pendingUiConfirmation, setPendingUiConfirmation] =
|
|
useState<PendingUiConfirmation | null>(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<string | null>(
|
|
eagerProject ? initialProjectPath : null,
|
|
);
|
|
const projectConversationWriteConfirmedRef = useRef<string | null>(null);
|
|
const projectConversationWriteCancelledRef = useRef<{
|
|
projectPath: string;
|
|
messageCount: number;
|
|
} | null>(null);
|
|
const latestMessagesRef = useRef<ChatMessage[]>([]);
|
|
const conversationWriteInFlightRef = useRef(false);
|
|
const projectScopeVersionRef = useRef(0);
|
|
|
|
const refreshManifest = useCallback(
|
|
(nextProjectPath = localProjectPathRef.current ?? ''): Promise<void> => {
|
|
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<GameCreationAppManifest>(
|
|
'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<void>
|
|
>(async () => undefined);
|
|
const agentRuntimeResumeProjectPathRef = useRef<string | null>(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<ProjectPermissionPolicyView>(
|
|
'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<LocalConversationResult>(
|
|
'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<ProjectPermissionPolicyView>(
|
|
'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<ProjectPermissionPolicyView>(
|
|
'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<LocalProjectExportPackageResult>(
|
|
'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<ProjectPermissionPolicyView>(
|
|
'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<GameCreationAppManifest>(
|
|
'get_local_game_manifest',
|
|
{ projectPath: trimmedProjectPath },
|
|
),
|
|
}
|
|
: await invoke<InitLocalProjectResult>('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<ResourceReferenceInsertEventDetail>)
|
|
.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<ProjectPermissionPolicyView>(
|
|
'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<LocalPreviewResult | null> {
|
|
try {
|
|
const status = await invoke<LocalPreviewStatus>(
|
|
'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<LocalPreviewResult>(
|
|
'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<LocalProjectFileResult>(
|
|
'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<AgentRuntimeResult[]>(
|
|
'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<AgentRuntimeResult[]>(
|
|
'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<AgentRuntimeResult[]>(
|
|
'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<LocalGameProjectRevisionStatus>(
|
|
'get_local_game_project_revision',
|
|
{ projectPath: nextProjectPath },
|
|
);
|
|
const currentManifest = await invoke<GameCreationAppManifest>(
|
|
'get_local_game_manifest',
|
|
{ projectPath: nextProjectPath },
|
|
);
|
|
const after = await invoke<LocalGameProjectRevisionStatus>(
|
|
'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<LocalConversationResult>(
|
|
'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<HTMLElement>) {
|
|
if (!hasEarlierConversationMessages) {
|
|
return;
|
|
}
|
|
if (event.currentTarget.scrollTop <= 24) {
|
|
showEarlierConversationMessages();
|
|
}
|
|
}
|
|
|
|
function handlePlanningChatScroll(event: UIEvent<HTMLDivElement>) {
|
|
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<HTMLFormElement>) {
|
|
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 (
|
|
<>
|
|
<DirectProjectChatView
|
|
ensureConversationReadAllowed={ensureDirectHistoryReadAllowed}
|
|
ensureConversationWriteAllowed={ensureDirectTurnWriteAllowed}
|
|
initialTurn={initialDirectTurn}
|
|
onRequestGamePublish={
|
|
gamePublishAllowed ? requestGamePublish : undefined
|
|
}
|
|
projectPath={localProject?.projectPath ?? projectPath ?? null}
|
|
ref={directProjectChatRef}
|
|
/>
|
|
<GameDistributionPublishPanel
|
|
open={publishPanelOpen}
|
|
projectPath={localProject?.projectPath ?? projectPath ?? ''}
|
|
manifest={manifest}
|
|
packageResult={publishPackageResult}
|
|
onClose={() => setPublishPanelOpen(false)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<PlanningChatView
|
|
initialPlanningPrompt={initialPlanningPrompt}
|
|
activeVersionId={chatActiveVersionId}
|
|
composerRef={chatComposerRef}
|
|
chatProjectAssets={chatProjectAssets}
|
|
hiddenConversationCount={hiddenConversationCount}
|
|
hasEarlierConversationMessages={hasEarlierConversationMessages}
|
|
messagesRef={planningChatMessagesRef}
|
|
needsUserInput={false}
|
|
onCancelConfirmation={cancelUiCommandConfirmation}
|
|
onConfirmConfirmation={confirmUiCommand}
|
|
onScroll={handlePlanningChatScroll}
|
|
onShowEarlierMessages={showEarlierConversationMessages}
|
|
onSubmit={handlePlanningChatSubmit}
|
|
pendingConfirmation={pendingUiConfirmation}
|
|
projectPath={localProject?.projectPath ?? projectPath}
|
|
conversationMessages={messages}
|
|
transientReply={designAgentTransientReply}
|
|
showDesignReasoning={designAgentActive}
|
|
designReasoning={designAgentReasoning}
|
|
designReasoningEntries={
|
|
useDesignAgentSurface ? (designAgentView?.reasoningEntries ?? []) : []
|
|
}
|
|
visibleMessages={visibleMessages}
|
|
workspaceStatus={workspaceStatus}
|
|
designView={useDesignAgentSurface ? designAgentView : null}
|
|
onDesignApprove={
|
|
useDesignAgentSurface
|
|
? (requestId, approved) => {
|
|
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<DesignView>('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 ? (
|
|
<RuntimeConfigDialog
|
|
projectPath={localProject?.projectPath}
|
|
onClose={() => setRuntimeConfigOpen(false)}
|
|
/>
|
|
) : null}
|
|
<GameDistributionPublishPanel
|
|
open={publishPanelOpen}
|
|
projectPath={localProject?.projectPath ?? projectPath ?? ''}
|
|
manifest={manifest}
|
|
packageResult={publishPackageResult}
|
|
onClose={() => setPublishPanelOpen(false)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|