合并 origin/master:保留 3D 契约与 provider checkpoint,旧玩法表随主线退役

- server-rs/crates/shared-contracts/src/lib.rs:采用主线的模块裁剪(删掉 cfg(any()) 遗留模块、补上 agc_analytics 与 game_distribution),同时保留本分支的 editor_canvas、model3d 模块与 EDITOR_GENERATION_OPERATION_KINDS 导出
- server-rs/crates/spacetime-module/src/migration.rs:主线删除的 410 行旧玩法表 normalize 段保持删除,保留本分支的 provider_kind / provider_task_id 兼容段与对应用例
- docs/【开发运维】本地开发验证与生产运维-2026-05-15.md:校验清单同时保留 Tripo 3D 生成任务与 editor_background_music_generation / model3d_text_to_model / model3d_image_to_model
- .gitignore:补回本分支新增的 3D 模型文件忽略规则(*.glb / *.gltf 等 12 行),压测数据段随主线一并删除
- 共享记忆:本分支的 3D 决策与踩坑条目保留在主线重排后的 decision-log.md 与 pitfalls.md 中
This commit is contained in:
2026-09-23 19:39:30 +08:00
997 changed files with 56264 additions and 80302 deletions
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,15 @@
import appPackage from '../../package.json';
const DEFAULT_APP_NAME = '陶泥儿开发版';
/**
* 产品名由构建期注入;本地 Vite 开发没有注入时沿用 dev 渠道产品名。
*
* 发布构建可通过 `VITE_AGC_PRODUCT_NAME` 注入渠道产品名,避免 UI 自己
* 根据渠道推导名称,保证标题栏、关于页等前端展示与安装身份保持一致。
*/
const injectedAppName = import.meta.env.VITE_AGC_PRODUCT_NAME?.trim();
/** Product metadata shared by the client UI and release bundle. */
export const APP_NAME = 'Genarrative AI Game Creator';
export const APP_NAME = injectedAppName || DEFAULT_APP_NAME;
export const APP_VERSION = appPackage.version;
+4 -1
View File
@@ -1,3 +1,6 @@
export function resolveTauriInvoke() {
import type { TauriInvoke } from './types';
/** 返回 undefined 表示不在 Tauri 环境(浏览器预览、测试壳),调用方必须先判空。 */
export function resolveTauriInvoke(): TauriInvoke | undefined {
return window.__TAURI__?.core?.invoke;
}
+14 -11
View File
@@ -512,17 +512,6 @@ export interface AgentGoalMutationResult {
providerInterrupted: boolean;
}
export interface AgentRuntimeSteerResult {
runtime: AgentRuntimeResult;
steerId: string;
sequence: number;
status: string;
providerInterrupted: boolean;
assistantReply?: string | null;
interruptDecision?: boolean | null;
decisionReason?: string | null;
}
export interface GameCreatorAgentRuntimeUpdateEvent {
projectPath: string;
agentId: string;
@@ -801,6 +790,20 @@ export interface LocalProjectExportPackageResult {
totalBytes: number;
}
export interface LocalProjectExportPackageFileDigest {
path: string;
sizeBytes: number;
sha256: string;
}
export interface LocalProjectExportPackagePayload {
packageRelativePath: string;
packageBytes: number[];
packageSha256: string;
packageSizeBytes: number;
files: LocalProjectExportPackageFileDigest[];
}
export interface LocalProjectExportPackageSummary {
packagePath: string;
packageRelativePath: string;
@@ -87,6 +87,7 @@ export function AppUpdateNotice() {
</div>
<button
type="button"
className="app-update-download"
onClick={() => void handleDownload()}
disabled={isDownloading}
>
@@ -6,6 +6,7 @@ import {
Component,
createContext,
isValidElement,
memo,
useContext,
} from 'react';
import ReactMarkdown, { type Components } from 'react-markdown';
@@ -302,7 +303,15 @@ const streamingMarkdownComponents: Components = {
p: StreamingMarkdownParagraph,
};
export function ChatMarkdownMessage({
/**
* 解析器的输入只有 `text` / `role` / `streaming` / `preserveBlankLines` 这几个标量,所以
* 内容没变的旧消息在父级重渲染时可以直接跳过:Markdown 解析与语法高亮是这个组件里最贵的
* 两件事(`react-markdown` 每次渲染都会重建 `unified()` 处理器并重跑全部插件),而旧消息
* 的文本永远不会再改。
*/
export const ChatMarkdownMessage = memo(ChatMarkdownMessageImpl);
function ChatMarkdownMessageImpl({
text,
role,
streaming = false,
@@ -317,8 +326,13 @@ export function ChatMarkdownMessage({
<ReactMarkdown
skipHtml
remarkPlugins={[remarkGfm]}
// 流式中不高亮:高亮要跑一遍完整 AST + highlight.js,而流式增量会让同一段代码
// 每来一个 chunk 就重新高亮一次(文本还在长,结果立刻作废)。文本定稿时
// `streaming` 变回 false,这一笔高亮自然补上。
rehypePlugins={
text.length <= MAX_HIGHLIGHT_CHARACTERS ? [rehypeHighlight] : []
streaming || text.length > MAX_HIGHLIGHT_CHARACTERS
? []
: [rehypeHighlight]
}
components={
streaming ? streamingMarkdownComponents : markdownComponents
@@ -9,6 +9,7 @@ import {
} from 'react';
import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png';
import { APP_NAME } from '../app/appMetadata';
import { appUpdateCheckEnabled } from '../app/featureFlags';
import { ActiveProjectRunsPanel } from '../features/app-shell/ActiveProjectRunsPanel';
import { subscribeTauriEvent } from '../services/tauriEventSubscription';
@@ -150,12 +151,15 @@ export function WindowChrome({ children }: WindowChromeProps) {
{appUpdateCheckEnabled ? <AppUpdateNotice /> : null}
<header className="window-chrome__bar" aria-label="窗口标题栏">
<div className="window-chrome__leading">
<div className="window-chrome__brand" aria-label="陶泥儿 GameAgent">
<div
className="window-chrome__brand"
aria-label={`${APP_NAME} GameAgent`}
>
<span className="window-chrome__brand-mark">
<img src={brandIcon} alt="" />
</span>
<span className="window-chrome__brand-copy">
<strong>陶泥儿</strong>
<strong>{APP_NAME}</strong>
<span>GameAgent</span>
</span>
</div>
@@ -0,0 +1,31 @@
import { CircleAlert } from 'lucide-react';
import { ThemedModal } from '../modal/ThemedModal';
export function GamePublishBlockedDialog({
message,
onClose,
}: {
message: string | null;
onClose: () => void;
}) {
return (
<ThemedModal
open={message !== null}
ariaLabel="发布提示"
onClose={onClose}
overlayClassName="game-publish-progress-overlay"
panelClassName="game-publish-progress-dialog game-publish-blocked-dialog"
panelStyle={{ background: '#fffaf7', color: '#4f362d' }}
>
<div className="game-publish-progress-icon is-failed" aria-hidden="true">
<CircleAlert size={30} />
</div>
<h2>暂不能发布</h2>
<p role="alert">{message}</p>
<button type="button" onClick={onClose}>
关闭
</button>
</ThemedModal>
);
}
@@ -0,0 +1,58 @@
import { CircleAlert, LoaderCircle } from 'lucide-react';
import { ThemedModal } from '../modal/ThemedModal';
export type GamePublishProgressState =
| {
phase: 'running';
message: string;
}
| {
phase: 'failed';
message: string;
detail: string;
};
export function GamePublishProgressDialog({
progress,
onClose,
}: {
progress: GamePublishProgressState | null;
onClose: () => void;
}) {
const failed = progress?.phase === 'failed';
return (
<ThemedModal
open={progress !== null}
ariaLabel={failed ? '发布失败' : '发布进度'}
onClose={onClose}
closeOnBackdrop={false}
closeOnEscape={failed}
overlayClassName="game-publish-progress-overlay"
panelClassName="game-publish-progress-dialog"
panelStyle={{ background: '#fffaf7', color: '#4f362d' }}
>
<div
className={`game-publish-progress-icon${failed ? ' is-failed' : ''}`}
aria-hidden="true"
>
{failed ? (
<CircleAlert size={30} />
) : (
<LoaderCircle className="is-spinning" size={30} />
)}
</div>
<h2>{failed ? '发布失败' : '正在发布'}</h2>
<p role={failed ? 'alert' : 'status'}>
{failed ? progress.detail : progress?.message}
</p>
{failed ? (
<button type="button" onClick={onClose}>
关闭
</button>
) : (
<small>发布完成前请保持客户端开启,页面暂时不可操作。</small>
)}
</ThemedModal>
);
}
@@ -0,0 +1,70 @@
import { PlatformRuntimeStatusToast } from '@genarrative/shared/components';
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import type { ProjectRunNotice } from './model';
/**
* 成功提示一闪而过就够;失败提示要留得够久,用户得看清是什么没跑起来。
*
* 「运行 / 预览失败」这类错误已经不再写对话区(那里只保留对话内容),所以这枚 toast 是它
* 唯一的出口——2.6 秒对错误太短。
*/
const RUN_NOTICE_MILLIS: Record<'success' | 'error', number> = {
success: 2600,
error: 6000,
};
export type RunNotice = ProjectRunNotice & {
/** 每次提示自增,保证重复触发同一个文案时也会重新弹一次。 */
id: number;
};
/**
* 运行 / 预览类动作的浮层提示。
*
* 这类过程反馈以前以 assistant 消息写进对话区,会一直堆在对话底部挡住运行画面;
* 现在统一走 toast,对话区只保留对话内容——运行页的预览地址改用顶栏的「在浏览器打开」。
*/
export function RunNoticeToast({
notice,
onDismiss,
}: {
notice: RunNotice | null;
onDismiss: () => void;
}) {
useEffect(() => {
if (!notice) {
return;
}
const timer = window.setTimeout(
onDismiss,
RUN_NOTICE_MILLIS[notice.tone ?? 'success'],
);
return () => {
window.clearTimeout(timer);
};
}, [notice, onDismiss]);
if (!notice) {
return null;
}
return createPortal(
<div
key={notice.id}
className="pointer-events-none fixed bottom-8 left-1/2 z-[1100] -translate-x-1/2"
data-project-run-notice-toast="true"
>
<PlatformRuntimeStatusToast
tone={notice.tone ?? 'success'}
surface="solid"
size="sm"
shape="pill"
className="shadow-lg"
>
{notice.message}
</PlatformRuntimeStatusToast>
</div>,
document.body,
);
}
@@ -42,8 +42,9 @@ import { projectPathsMatchForInvalidation } from '../project-summary/projectPath
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
import { useTemplateLibrary } from '../template-library/useTemplateLibrary';
import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet';
import type { WorkspaceLauncherShellProps } from './model';
import type { ProjectRunNotice, WorkspaceLauncherShellProps } from './model';
import { NonEmptyProjectDialog, ProjectsPage } from './ProjectCreation';
import { type RunNotice, RunNoticeToast } from './RunNoticeToast';
import { useAccountWallet } from './useAccountWallet';
import {
DESIGN_ARTIFACTS_BUILD_PROMPT,
@@ -79,6 +80,13 @@ export function WorkspaceLauncherShell({
title: string;
message: string;
} | null>(null);
/**
* 运行 / 预览类动作的浮层提示。
*
* 这类过程反馈不进对话区(见 `ProjectChatComponentProps.onRunNotice`),
* `id` 每次自增,保证同一句文案连续触发时也会重新弹一次。
*/
const [runNotice, setRunNotice] = useState<RunNotice | null>(null);
// 「做成游戏」切换记录按项目上下文(路径 + createdAt)定位。运行模式必须随
// currentProjectContext 同步派生,不能靠 effect 后置修正:首帧挂错 lane 会先
// 以游戏运行时挂载并消耗首轮 claim,重挂后的策划实例再也发不出首轮。
@@ -93,8 +101,12 @@ export function WorkspaceLauncherShell({
});
const templateLibrary = useTemplateLibrary({
userId: currentUser.id,
onProjectCreated: async (result, isCurrent) => {
await homeProject.enterCreatedTemplateProject(result, isCurrent);
onProjectCreated: async (result, isCurrent, analytics) => {
await homeProject.enterCreatedTemplateProject(
result,
isCurrent,
analytics,
);
},
});
useEffect(() => {
@@ -600,6 +612,21 @@ export function WorkspaceLauncherShell({
);
}, []);
function navigateLauncher(view: LauncherView) {
if (
launcherView === 'project-development' &&
view !== launcherView &&
currentProjectContext
) {
homeProject.leaveProjectAnalytics();
}
setLauncherView(view);
}
const handleRunNotice = useCallback((notice: ProjectRunNotice) => {
setRunNotice((current) => ({ id: (current?.id ?? 0) + 1, ...notice }));
}, []);
function showLauncherNotice(title: string) {
setLauncherNotice({
title,
@@ -668,6 +695,7 @@ export function WorkspaceLauncherShell({
templateLibraryEnabled={templateLibrary.enabled}
currentUser={currentUser}
onLogout={() => {
homeProject.leaveProjectAnalytics();
resetLauncherHomeDraft();
accountWallet.resetWalletBalance();
onLogout();
@@ -675,7 +703,7 @@ export function WorkspaceLauncherShell({
onNoticeRequest={showLauncherNotice}
onRechargeRequest={accountWallet.openRecharge}
onRuntimeConfigOpen={() => setRuntimeConfigOpen(true)}
onViewChange={setLauncherView}
onViewChange={navigateLauncher}
/>
<section
className={
@@ -705,7 +733,7 @@ export function WorkspaceLauncherShell({
onCreateDraftAutomatically={createHomeDraftAutomatically}
creationBusy={homeCreationBusy}
recoverableCreatedProjectPath={homeCreationRecoverableProjectPath}
onProjectsOpen={() => setLauncherView('projects')}
onProjectsOpen={() => navigateLauncher('projects')}
onProjectOpen={(path) => {
setProjectPath(path);
void openProject(path, 'open');
@@ -825,9 +853,10 @@ export function WorkspaceLauncherShell({
currentProjectContext.projectPath,
)
}
onNotice={handleRunNotice}
onManifestChange={syncActiveProjectManifest}
onHomeOpen={() => setLauncherView('home')}
onProjectsOpen={() => setLauncherView('projects')}
onHomeOpen={() => navigateLauncher('home')}
onProjectsOpen={() => navigateLauncher('projects')}
chat={
<ProjectChat
key={`${currentProjectContext.projectPath}:${agentRuntimeMode}`}
@@ -856,12 +885,12 @@ export function WorkspaceLauncherShell({
initialPlanningPromptClaimScope={
switchedToGameRuntime ? 'approved-design-build' : ''
}
activeVersionId={activeVersionId}
planningStartMode={planningStartMode}
playRequest={playRequest}
onPlayRequestHandled={handlePlayRequestHandled}
onManifestChange={syncActiveProjectManifest}
onPreviewChange={setActiveProjectPreview}
onRunNotice={handleRunNotice}
onAgentRuntimeSummariesChange={
setActiveProjectAgentRuntimeSummaries
}
@@ -905,6 +934,7 @@ export function WorkspaceLauncherShell({
) : isWindowChrome ? null : (
<AccountWalletBar controller={accountWallet} />
)}
<RunNoticeToast notice={runNotice} onDismiss={() => setRunNotice(null)} />
{launcherNotice ? (
<div
className="launcher-dialog-backdrop"
@@ -34,6 +34,17 @@ export type WorkspaceLauncherProps = {
initialView?: LauncherView;
};
/**
* 运行 / 预览类动作的一次性浮层提示。
*
* `tone` 只区分观感(成功绿 / 失败红),文案由发出方给出:运行成功、切到运行视图、
* 在浏览器打开失败都走这一条通道。
*/
export type ProjectRunNotice = {
message: string;
tone?: 'success' | 'error';
};
export type ProjectChatComponentProps = {
initialProjectPath?: string;
initialProjectManifest?: GameCreationAppManifest;
@@ -48,11 +59,6 @@ export type ProjectChatComponentProps = {
importing: boolean,
) => void;
planningStartMode?: boolean;
/**
* C7 当前游戏版本:由工作台壳持有,策划聊天里的 `@` 面板按它切「当前版本素材」。
* `null` 表示回退到 manifest 中最新的版本。
*/
activeVersionId?: string | null;
playRequest?: {
projectPath: string;
requestId: number;
@@ -64,6 +70,13 @@ export type ProjectChatComponentProps = {
metadata?: ProjectManifestSnapshotMetadata,
) => void;
onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void;
/**
* 运行 / 预览类动作的一次性浮层提示。
*
* 「跑起来了」「已切到运行视图」「在浏览器打开失败」属于过程反馈,不进对话区——
* 对话区只保留对话内容。工作台壳收到后弹 toast,`tone` 决定成功还是失败观感。
*/
onRunNotice?: (notice: ProjectRunNotice) => void;
onAgentRuntimeSummariesChange?: (
summaries: ProjectAgentRuntimeSummary[],
) => void;
@@ -2,6 +2,7 @@ import {
type Dispatch,
type SetStateAction,
useCallback,
useLayoutEffect,
useRef,
useState,
} from 'react';
@@ -25,6 +26,11 @@ import type {
TauriInvoke,
UploadLocalAssetResult,
} from '../../app/types';
import {
beginProjectOpenAnalytics,
type ProjectOpenAnalytics,
recordAnalyticsProjectLeave,
} from '../../services/clientAnalytics';
import {
type ClientOperation,
createClientOperation,
@@ -169,6 +175,33 @@ export function useHomeProjectCreation({
setLauncherView,
rememberRecentWorkspace,
}: UseHomeProjectCreationOptions) {
const lifecycleRef = useRef({ mounted: false, generation: 0 });
const analyticsProjectPathRef = useRef<string | null>(null);
const leaveProjectAnalytics = useCallback(() => {
const path = analyticsProjectPathRef.current;
analyticsProjectPathRef.current = null;
const invoke = resolveTauriInvoke();
if (path && invoke) recordAnalyticsProjectLeave(invoke, path);
}, []);
useLayoutEffect(() => {
const lifecycle = lifecycleRef.current;
lifecycle.mounted = true;
const onPageHide = () => {
lifecycle.mounted = false;
lifecycle.generation += 1;
leaveProjectAnalytics();
};
window.addEventListener('pagehide', onPageHide);
return () => {
lifecycle.mounted = false;
lifecycle.generation += 1;
window.removeEventListener('pagehide', onPageHide);
// StrictMode 的同步 setup 重放不是用户离开;真实卸载才清理宿主登记。
queueMicrotask(() => {
if (!lifecycle.mounted) leaveProjectAnalytics();
});
};
}, [leaveProjectAnalytics]);
const [projectPath, setProjectPathState] = useState('');
const [projectAction, setProjectAction] = useState<
'opening' | 'creating' | null
@@ -241,7 +274,10 @@ export function useHomeProjectCreation({
async function enterProjectDevelopment(
context: LauncherProjectContext,
isCurrent: () => boolean = () => true,
analytics: ProjectOpenAnalytics = null,
) {
if (!lifecycleRef.current.mounted) return;
const generation = lifecycleRef.current.generation;
const entryToken = (projectEntryTokenRef.current += 1);
/**
* 会话预览只认"内存 registry 里真的还在跑"的那一个(见
@@ -254,7 +290,12 @@ export function useHomeProjectCreation({
projectPath: context.projectPath,
recordedPreview: context.manifest.preview ?? null,
});
if (entryToken !== projectEntryTokenRef.current || !isCurrent()) {
if (
!lifecycleRef.current.mounted ||
generation !== lifecycleRef.current.generation ||
entryToken !== projectEntryTokenRef.current ||
!isCurrent()
) {
// 更晚的一次进项目已经接管工作区:这一次的结果(预览与项目上下文)全部丢弃,
// 否则慢请求后到会把新项目覆盖回旧项目。
return;
@@ -276,6 +317,8 @@ export function useHomeProjectCreation({
setProjectPath(context.projectPath);
setLauncherView('project-development');
rememberRecentWorkspace(context.projectPath);
analyticsProjectPathRef.current = context.projectPath;
analytics?.record(context.projectPath);
}
async function readCurrentProjectRevision(
@@ -314,6 +357,7 @@ export function useHomeProjectCreation({
prompt: string,
attachments: HomeAttachmentDraft[],
startMode: ProjectStartMode,
analytics: ProjectOpenAnalytics,
) {
if (startMode === 'planning') {
await invoke('set_design_agent_runtime_mode', {
@@ -333,29 +377,33 @@ export function useHomeProjectCreation({
startMode === 'planning'
? []
: await importHomeAttachments(invoke, result.projectPath, attachments);
await enterProjectDevelopment({
projectPath: result.projectPath,
projectName:
result.manifest.name || projectNameFromPath(result.projectPath),
projectKind: 'web',
manifest: result.manifest,
projectRevision: await readCurrentProjectRevision(
invoke,
result.projectPath,
),
creationType,
startMode,
initialPrompt:
prompt.trim() ||
(attachments.length > 0 && startMode !== 'planning'
? '用户上传了参考附件,等待后续补充需求。'
: ''),
attachments: importedAttachments,
fileImportNotice,
recentRunStatus: null,
recentRunStopReason: null,
createdAt: Date.now(),
});
await enterProjectDevelopment(
{
projectPath: result.projectPath,
projectName:
result.manifest.name || projectNameFromPath(result.projectPath),
projectKind: 'web',
manifest: result.manifest,
projectRevision: await readCurrentProjectRevision(
invoke,
result.projectPath,
),
creationType,
startMode,
initialPrompt:
prompt.trim() ||
(attachments.length > 0 && startMode !== 'planning'
? '用户上传了参考附件,等待后续补充需求。'
: ''),
attachments: importedAttachments,
fileImportNotice,
recentRunStatus: null,
recentRunStopReason: null,
createdAt: Date.now(),
},
undefined,
analytics,
);
resetLauncherHomeDraft();
}
@@ -366,6 +414,7 @@ export function useHomeProjectCreation({
attachments: HomeAttachmentDraft[],
startMode: ProjectStartMode,
skipNonEmptyCheck = false,
analytics?: ProjectOpenAnalytics,
) {
const trimmedProjectPath = validateProjectPath(nextProjectPath);
if (!trimmedProjectPath) {
@@ -376,6 +425,10 @@ export function useHomeProjectCreation({
setStatus('需要在 Tauri App 内运行');
return '需要在 Tauri App 内运行';
}
analytics =
analytics === undefined
? beginProjectOpenAnalytics(invoke, 'create')
: analytics;
setStatus('正在创建项目');
try {
if (!skipNonEmptyCheck) {
@@ -416,6 +469,7 @@ export function useHomeProjectCreation({
prompt,
attachments,
startMode,
analytics,
);
setStatus('已创建项目,正在开始智能创作');
} catch (error) {
@@ -439,6 +493,7 @@ export function useHomeProjectCreation({
// 首页输入框里已经写好的要求:打开已有项目时不能再丢掉(此前写死空串,
// 用户写的内容既不发首轮也不进对话历史)。
initialPrompt = '',
analytics?: ProjectOpenAnalytics,
) {
if (projectActionRef.current) {
return;
@@ -454,6 +509,10 @@ export function useHomeProjectCreation({
}
projectActionRef.current = 'creating';
setProjectAction('creating');
analytics =
analytics === undefined
? beginProjectOpenAnalytics(invoke, 'create')
: analytics;
setStatus('正在创建项目');
try {
if (!skipNonEmptyCheck) {
@@ -479,24 +538,28 @@ export function useHomeProjectCreation({
},
);
setStatus('已创建项目');
await enterProjectDevelopment({
projectPath: result.projectPath,
projectName:
result.manifest.name || projectNameFromPath(result.projectPath),
projectKind: 'web',
manifest: result.manifest,
projectRevision: await readCurrentProjectRevision(
invoke,
result.projectPath,
),
creationType: null,
startMode: null,
initialPrompt,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
createdAt: Date.now(),
});
await enterProjectDevelopment(
{
projectPath: result.projectPath,
projectName:
result.manifest.name || projectNameFromPath(result.projectPath),
projectKind: 'web',
manifest: result.manifest,
projectRevision: await readCurrentProjectRevision(
invoke,
result.projectPath,
),
creationType: null,
startMode: null,
initialPrompt,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
createdAt: Date.now(),
},
undefined,
analytics,
);
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error));
} finally {
@@ -514,6 +577,7 @@ export function useHomeProjectCreation({
async function enterCreatedTemplateProject(
result: InitLocalProjectResult,
isCurrent: () => boolean = () => true,
analytics: ProjectOpenAnalytics = null,
) {
const invoke = resolveTauriInvoke();
if (!invoke) {
@@ -541,10 +605,15 @@ export function useHomeProjectCreation({
createdAt: Date.now(),
},
isCurrent,
analytics,
);
}
async function openProject(nextProjectPath: string, mode: 'open' | 'create') {
async function openProject(
nextProjectPath: string,
mode: 'open' | 'create',
analytics?: ProjectOpenAnalytics,
) {
if (mode === 'create') {
await createProjectFromProjectPage(nextProjectPath);
return;
@@ -563,6 +632,10 @@ export function useHomeProjectCreation({
}
projectActionRef.current = 'opening';
setProjectAction('opening');
analytics =
analytics === undefined
? beginProjectOpenAnalytics(invoke, 'recent')
: analytics;
setStatus('正在打开');
try {
const directoryStatus = await invoke<LocalProjectDirectoryStatus>(
@@ -626,32 +699,37 @@ export function useHomeProjectCreation({
projectPath: trimmedProjectPath,
});
setStatus('已打开项目');
await enterProjectDevelopment({
projectPath: trimmedProjectPath,
projectName:
directoryStatus.projectName ||
projectNameFromPath(trimmedProjectPath),
projectKind: directoryStatus.isUnityProject
? 'unity'
: directoryStatus.isCocosProject
? 'cocos'
: directoryStatus.godotProjectRoot !== null &&
directoryStatus.godotProjectRoot !== undefined
? 'godot'
: 'web',
manifest: projectManifest,
projectRevision: await readCurrentProjectRevision(
invoke,
trimmedProjectPath,
),
creationType: null,
startMode: runtimeMode?.activeRuntime === 'design' ? 'planning' : null,
initialPrompt: homeDraftPromptText(),
attachments: [],
recentRunStatus: directoryStatus.recentRunStatus,
recentRunStopReason: directoryStatus.recentRunStopReason,
createdAt: Date.now(),
});
await enterProjectDevelopment(
{
projectPath: trimmedProjectPath,
projectName:
directoryStatus.projectName ||
projectNameFromPath(trimmedProjectPath),
projectKind: directoryStatus.isUnityProject
? 'unity'
: directoryStatus.isCocosProject
? 'cocos'
: directoryStatus.godotProjectRoot !== null &&
directoryStatus.godotProjectRoot !== undefined
? 'godot'
: 'web',
manifest: projectManifest,
projectRevision: await readCurrentProjectRevision(
invoke,
trimmedProjectPath,
),
creationType: null,
startMode:
runtimeMode?.activeRuntime === 'design' ? 'planning' : null,
initialPrompt: homeDraftPromptText(),
attachments: [],
recentRunStatus: directoryStatus.recentRunStatus,
recentRunStopReason: directoryStatus.recentRunStopReason,
createdAt: Date.now(),
},
undefined,
analytics,
);
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error));
} finally {
@@ -704,6 +782,7 @@ export function useHomeProjectCreation({
if (!invoke) {
throw new Error('需要在 Tauri App 内运行');
}
const analytics = beginProjectOpenAnalytics(invoke, 'create');
const selectedPath = await invoke<string | null>(
'pick_local_project_directory',
projectPath.trim() ? { initialPath: projectPath.trim() } : undefined,
@@ -717,6 +796,8 @@ export function useHomeProjectCreation({
draft.prompt,
draft.attachments,
startMode,
false,
analytics,
);
}
@@ -748,6 +829,7 @@ export function useHomeProjectCreation({
if (!invoke) {
throw new Error('需要在陶泥儿客户端内运行');
}
const analytics = beginProjectOpenAnalytics(invoke, 'create');
const attempt = (homeCreationAttemptRef.current += 1);
const isCurrentAttempt = () => homeCreationAttemptRef.current === attempt;
const operation = createClientOperation(
@@ -849,6 +931,7 @@ export function useHomeProjectCreation({
draft.prompt,
draft.attachments,
startMode,
analytics,
);
setHomeCreationOperation(
transitionClientOperation(operation, 'success', {
@@ -910,6 +993,7 @@ export function useHomeProjectCreation({
}
projectActionRef.current = 'opening';
setProjectAction('opening');
const analytics = beginProjectOpenAnalytics(invoke, 'picker');
setStatus('正在选择项目');
try {
const selectedPath = await invoke<string | null>(
@@ -923,7 +1007,7 @@ export function useHomeProjectCreation({
setProjectPath(selectedPath);
projectActionRef.current = null;
setProjectAction(null);
await openProject(selectedPath, 'open');
await openProject(selectedPath, 'open', analytics);
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error));
} finally {
@@ -945,6 +1029,7 @@ export function useHomeProjectCreation({
}
projectActionRef.current = 'creating';
setProjectAction('creating');
const analytics = beginProjectOpenAnalytics(invoke, 'create');
setStatus('正在选择新项目文件夹');
try {
const selectedPath = await invoke<string | null>(
@@ -962,6 +1047,7 @@ export function useHomeProjectCreation({
selectedPath,
false,
homeDraftPromptText(),
analytics,
);
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error));
@@ -999,6 +1085,7 @@ export function useHomeProjectCreation({
}
return {
leaveProjectAnalytics,
projectPath,
setProjectPath,
currentProjectContext,
@@ -8,7 +8,7 @@ import {
} from 'react';
import { resolveTauriInvoke } from '../../app/tauri';
import type { LocalProjectDirectoryStatus } from '../../app/types';
import type { LocalProjectDirectoryStatus, TauriInvoke } from '../../app/types';
import {
isAbsoluteProjectPath,
projectPathHasControlCharacter,
@@ -21,6 +21,90 @@ import {
} from './model';
const RECENT_WORKSPACE_CHECK_TIMEOUT_MS = 5_000;
/** 单次检查失败后的就地重试退避,数组长度即重试次数。 */
const RECENT_WORKSPACE_CHECK_RETRY_DELAYS_MS = [300];
/** 一轮结束仍有可重试失败时重跑整张列表的退避,数组长度即重跑次数上限。 */
const RECENT_WORKSPACE_FAILURE_RECHECK_DELAYS_MS = [15_000, 45_000, 120_000];
/**
* 提权/权限类失败不重试:Rust 侧会重新走 `Start-Process -Verb RunAs -Wait`,
* 而提权闸门只存在于单次 invoke 内,重试等于在用户刚点「否」后再弹一次 UAC。
* 判据与 config.rs 的 `windows_acl_error_may_need_elevation` 同口径。
*/
const RECENT_WORKSPACE_ELEVATION_ERROR_MARKERS = [
'DACL',
'权限',
'error 5',
'安全对象不属于当前用户',
'启用 Windows',
'特权',
'1300',
'AGC ACL 提权修复未成功',
];
type RecentWorkspaceInspection = {
path: string;
status: LocalProjectDirectoryStatus | null;
/** false 表示提权/权限类失败:既不重试,也不驱动整表重查。 */
retryable: boolean;
};
function recentWorkspaceFailureIsRetryable(message: string): boolean {
return !RECENT_WORKSPACE_ELEVATION_ERROR_MARKERS.some((marker) =>
message.includes(marker),
);
}
async function inspectRecentWorkspace(
invoke: TauriInvoke,
workspace: string,
): Promise<RecentWorkspaceInspection> {
let timeoutHandle: number | undefined;
try {
const status = await Promise.race([
invoke<LocalProjectDirectoryStatus>('inspect_local_project_directory', {
projectPath: workspace,
}),
new Promise<never>((_, reject) => {
timeoutHandle = window.setTimeout(
() => reject(new Error('项目目录检查超时')),
RECENT_WORKSPACE_CHECK_TIMEOUT_MS,
);
}),
]);
return { path: workspace, status, retryable: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
path: workspace,
status: null,
retryable: recentWorkspaceFailureIsRetryable(message),
};
} finally {
if (timeoutHandle !== undefined) {
window.clearTimeout(timeoutHandle);
}
}
}
async function inspectRecentWorkspaceWithRetry(
invoke: TauriInvoke,
workspace: string,
): Promise<RecentWorkspaceInspection> {
for (let attempt = 0; ; attempt += 1) {
const inspection = await inspectRecentWorkspace(invoke, workspace);
const retryDelayMs = RECENT_WORKSPACE_CHECK_RETRY_DELAYS_MS[attempt];
if (
inspection.status ||
!inspection.retryable ||
retryDelayMs === undefined
) {
return inspection;
}
await new Promise<void>((resolve) => {
window.setTimeout(resolve, retryDelayMs);
});
}
}
export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
const [recentWorkspaces, setRecentWorkspaces] =
@@ -35,32 +119,28 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
const recentWorkspacesRef = useRef(recentWorkspaces);
recentWorkspacesRef.current = recentWorkspaces;
const inspectionGenerationRef = useRef(0);
const failureRecheckTimerRef = useRef<number | undefined>(undefined);
const failureStreakRef = useRef(0);
const lastRetryableFailureKeyRef = useRef('');
// 提权类失败在用户再次主动操作前不再自动重试。
const nonRetryablePathsRef = useRef<Set<string>>(new Set());
async function inspectRecentWorkspace(
invoke: NonNullable<ReturnType<typeof resolveTauriInvoke>>,
workspace: string,
): Promise<[string, LocalProjectDirectoryStatus | null]> {
let timeoutHandle: number | undefined;
try {
const result = await Promise.race([
invoke<LocalProjectDirectoryStatus>('inspect_local_project_directory', {
projectPath: workspace,
}),
new Promise<never>((_, reject) => {
timeoutHandle = window.setTimeout(
() => reject(new Error('项目目录检查超时')),
RECENT_WORKSPACE_CHECK_TIMEOUT_MS,
);
}),
]);
return [workspace, result];
} catch {
return [workspace, null];
} finally {
if (timeoutHandle !== undefined) {
window.clearTimeout(timeoutHandle);
}
function scheduleFailureRecheck(retryableFailureCount: number) {
window.clearTimeout(failureRecheckTimerRef.current);
if (retryableFailureCount === 0) {
failureStreakRef.current = 0;
return;
}
const recheckDelayMs =
RECENT_WORKSPACE_FAILURE_RECHECK_DELAYS_MS[failureStreakRef.current];
if (recheckDelayMs === undefined) {
return;
}
failureStreakRef.current += 1;
failureRecheckTimerRef.current = window.setTimeout(() => {
failureRecheckTimerRef.current = undefined;
setRecentWorkspaceRefreshKey((current) => current + 1);
}, recheckDelayMs);
}
useEffect(() => {
@@ -73,24 +153,43 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
}
const inspectionGeneration = ++inspectionGenerationRef.current;
let disposed = false;
let pendingCount = recentWorkspaces.length;
// 保留已完成项目的最后一个独立结果。刷新是增量投影:只有新项目或
// 尚未完成检查的项目显示“检查中”,不能因为另一个坏目录而把整张列表
// 清空成同一个异常状态。
const pendingWorkspaces = recentWorkspaces.filter(
(workspace) => !nonRetryablePathsRef.current.has(workspace),
);
let pendingCount = pendingWorkspaces.length;
const retryableFailures: string[] = [];
const finishRound = () => {
setRecentWorkspaceRefreshing(false);
// 失败集合变化即重置退避预算,避免长期坏目录吃满新瞬时失败的额度。
const failureKey = [...retryableFailures].sort().join('|');
if (failureKey !== lastRetryableFailureKeyRef.current) {
lastRetryableFailureKeyRef.current = failureKey;
failureStreakRef.current = 0;
}
scheduleFailureRecheck(retryableFailures.length);
};
// 刷新是增量投影:成功结果保留,但上一轮的失败结果不进新投影,
// 失败项回到「检查中」并在本轮重新检查。
setRecentWorkspaceStatuses((current) => {
const next: Record<string, LocalProjectDirectoryStatus | null> = {};
for (const workspace of recentWorkspaces) {
if (Object.prototype.hasOwnProperty.call(current, workspace)) {
next[workspace] = current[workspace] ?? null;
const status = current[workspace];
if (status) {
next[workspace] = status;
}
}
return next;
});
setRecentWorkspaceRefreshing(true);
for (const workspace of recentWorkspaces) {
void inspectRecentWorkspace(invoke, workspace).then(
([projectPath, status]) => {
if (pendingCount === 0) {
finishRound();
return;
}
for (const workspace of pendingWorkspaces) {
void inspectRecentWorkspaceWithRetry(invoke, workspace).then(
({ path: projectPath, status, retryable }) => {
if (
disposed ||
inspectionGeneration !== inspectionGenerationRef.current ||
@@ -98,23 +197,34 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
) {
return;
}
if (status) {
nonRetryablePathsRef.current.delete(projectPath);
} else if (retryable) {
retryableFailures.push(projectPath);
} else {
nonRetryablePathsRef.current.add(projectPath);
}
setRecentWorkspaceStatuses((current) => ({
...current,
[projectPath]: status,
}));
pendingCount -= 1;
if (pendingCount === 0) {
setRecentWorkspaceRefreshing(false);
finishRound();
}
},
);
}
return () => {
disposed = true;
window.clearTimeout(failureRecheckTimerRef.current);
failureRecheckTimerRef.current = undefined;
};
}, [recentWorkspaces, recentWorkspaceRefreshKey]);
function rememberRecentWorkspace(projectPath: string) {
// 用户主动打开或新建项目:解除提权类失败的跳过标记。
nonRetryablePathsRef.current.clear();
setRecentWorkspaces(writeRecentWorkspace(projectPath));
setRecentWorkspaceRefreshKey((current) => current + 1);
}
@@ -124,17 +234,29 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
if (!invoke) {
return;
}
const [, status] = await inspectRecentWorkspace(invoke, projectPath);
nonRetryablePathsRef.current.delete(projectPath);
const inspection = await inspectRecentWorkspaceWithRetry(
invoke,
projectPath,
);
if (!recentWorkspacesRef.current.includes(projectPath)) {
return;
}
if (inspection.status) {
nonRetryablePathsRef.current.delete(projectPath);
} else if (inspection.retryable) {
scheduleFailureRecheck(1);
} else {
nonRetryablePathsRef.current.add(projectPath);
}
setRecentWorkspaceStatuses((current) => ({
...current,
[projectPath]: status,
[projectPath]: inspection.status,
}));
}
function handleRecentWorkspaceRemove(projectPath: string) {
nonRetryablePathsRef.current.delete(projectPath);
setRecentWorkspaces(removeRecentWorkspace(projectPath));
setRecentWorkspaceStatuses((current) => {
const { [projectPath]: _removed, ...rest } = current;
File diff suppressed because it is too large Load Diff
@@ -1,311 +0,0 @@
import {
type GameCreationAgentRunTrace,
type GameCreationAppManifest,
type GameCreationAppTaskState,
selectGameCreationAppReadyTasks,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
type AgentRunHistoryItem,
type AgentStatusCard,
} from '../../app/types';
import { taskRowsFromManifest } from '../agent-runtime';
import {
formatAgentRunStatus,
formatTraceRepairRoutes,
isAgentReviewStep,
isAgentRunTracePassed,
} from './agentTrace';
import { isSafeProjectRelativePath } from './projectPath';
import { previewStatusLabels } from './projectSummaryConstants';
export function summarizeAgentRunBudget(
trace: GameCreationAgentRunTrace | null,
) {
if (!trace) {
return {
text: '运行预算:\n- 最近 Run:暂无\n- 建议:/next',
draftCommand: '/next',
draftCommandLabel: '查看下一步',
};
}
const remainingPasses = Math.max(trace.maxPasses - trace.passes, 0);
const remainingToolCalls = Math.max(
trace.maxToolCalls - trace.toolCallCount,
0,
);
const blocked =
trace.lifecycleStatus === 'killed' ||
trace.status === 'failed' ||
trace.status === 'needs-revision' ||
trace.stopReason === 'max-passes-exhausted' ||
remainingPasses === 0 ||
remainingToolCalls === 0;
const draftCommand = blocked
? '/review'
: isAgentRunTracePassed(trace)
? '/publish'
: '/trace';
return {
text: [
'运行预算:',
`- Run:${trace.runId} · ${formatAgentRunStatus(trace)}`,
`- 轮次:已用 ${trace.passes}/${trace.maxPasses} · 剩余 ${remainingPasses}`,
`- 工具调用:已用 ${trace.toolCallCount}/${trace.maxToolCalls} · 剩余 ${remainingToolCalls}`,
`- 下一步:${trace.nextStep}`,
`- 建议:${draftCommand}`,
].join('\n'),
draftCommand,
draftCommandLabel:
draftCommand === '/review'
? '查看评审'
: draftCommand === '/publish'
? '查看发布准备'
: '查看 trace',
};
}
export function summarizeAgentReviewState(
trace: GameCreationAgentRunTrace | null,
) {
if (!trace) {
return {
text: '评审状态:暂无最近 trace',
draftCommand: '/next',
draftCommandLabel: '查看下一步',
};
}
const reviewSteps = trace.steps.filter(isAgentReviewStep);
const visibleReviewSteps = reviewSteps.slice(-3);
const reviewStepLines = visibleReviewSteps.map((step) => {
const outputPaths = step.outputPaths.filter(isSafeProjectRelativePath);
return [
`- ${step.agent} #${step.pass} · ${step.status} · ${step.summary}`,
outputPaths.length > 0 ? `输出 ${outputPaths.join(', ')}` : null,
]
.filter(Boolean)
.join(' · ');
});
if (reviewSteps.length > visibleReviewSteps.length) {
reviewStepLines.push(
`- 还有 ${reviewSteps.length - visibleReviewSteps.length} 个较早评审步骤`,
);
}
const repair = formatTraceRepairRoutes(
trace.taskGraph.repairRoutes,
trace.taskGraph.tasks,
);
const needsResume =
trace.lifecycleStatus === 'killed' ||
trace.status === 'failed' ||
trace.status === 'needs-revision' ||
trace.stopReason === 'max-passes-exhausted';
const evaluatorState = isAgentRunTracePassed(trace)
? '通过'
: needsResume
? '需返工'
: '未通过';
return {
text: [
'评审状态:',
`- Run:${trace.runId} · ${formatAgentRunStatus(trace)}`,
`- Evaluator:${evaluatorState}`,
`- 返工焦点:${
trace.taskGraph.repairFocus.length > 0
? trace.taskGraph.repairFocus.join(';')
: '暂无'
}`,
`- 返工路线:${repair || '暂无'}`,
`- 下一步:${trace.nextStep}`,
'- 评审记录:/read .agent/findings.md',
reviewStepLines.length > 0
? `- 最近评审步骤:\n${reviewStepLines.join('\n')}`
: '- 最近评审步骤:暂无',
].join('\n'),
draftCommand: needsResume ? '/agent-resume ' : '/read .agent/findings.md',
draftCommandLabel: needsResume ? '继续修复' : '读取评审记录',
};
}
export function summarizeProjectContextSources(
nextManifest: GameCreationAppManifest,
trace: GameCreationAgentRunTrace | null,
) {
const tasks = taskRowsFromManifest(nextManifest);
const llmInputPaths = trace
? Array.from(
new Set(
trace.steps
.filter((step) =>
step.toolCalls.some((toolCall) =>
toolCall.toolId.startsWith('llm.'),
),
)
.flatMap((step) => step.inputPaths)
.filter(isSafeProjectRelativePath),
),
).slice(0, 8)
: [];
const inputPathLines = llmInputPaths.map(
(path) => `- ${path}:/read ${path}`,
);
const draftCommand =
llmInputPaths.length > 0
? `/read ${llmInputPaths[0]}`
: '/memory blackboard';
return {
text: [
'上下文来源:',
'- 项目对话:/history',
'- 短期记忆:/memory short',
'- 长期记忆:/memory long',
'- 项目黑板:/memory blackboard',
`- Agent 对话:${tasks.length} 个 · /agent-conversations`,
`- Agent 私有记忆:${tasks.length} 个 · /agent-memories`,
'- 项目 manifest:/read .agent/manifest.json',
trace ? `- 最近 Run:${trace.runId} · /trace` : '- 最近 Run:暂无',
inputPathLines.length > 0
? `最近 LLM 输入:\n${inputPathLines.join('\n')}`
: '最近 LLM 输入:暂无',
].join('\n'),
draftCommand,
draftCommandLabel: llmInputPaths.length > 0 ? '读取首个上下文' : '查看黑板',
};
}
export function summarizeProjectTimeline(
nextManifest: GameCreationAppManifest,
trace: GameCreationAgentRunTrace | null,
) {
const commandRuns = (nextManifest.commandRuns ?? []).slice(-5);
const commandLines = commandRuns.map((commandRun) => {
const logSuffix = isSafeProjectRelativePath(commandRun.logPath)
? ` · 日志 /read ${commandRun.logPath}`
: '';
return `- 命令 ${commandRun.commandId} · ${
commandRun.status === 'completed' ? '完成' : '失败'
}${logSuffix}`;
});
const visibleSteps = trace?.steps.slice(-6) ?? [];
const stepLines = visibleSteps.map((step) => {
const outputPaths = step.outputPaths
.filter(isSafeProjectRelativePath)
.slice(0, 3);
return [
`- ${step.agent} #${step.pass} / ${step.phase} · ${step.status} · ${step.summary}`,
outputPaths.length > 0 ? `输出 ${outputPaths.join(', ')}` : null,
]
.filter(Boolean)
.join(' · ');
});
const latestSafeLogPath = [...commandRuns]
.reverse()
.find((commandRun) =>
isSafeProjectRelativePath(commandRun.logPath),
)?.logPath;
const draftCommand = latestSafeLogPath
? `/read ${latestSafeLogPath}`
: trace
? '/trace'
: '/history';
return {
text: [
'项目时间线:',
`- Run:${trace ? `${trace.runId} · ${formatAgentRunStatus(trace)}` : '暂无最近 run'}`,
commandLines.length > 0
? `- 最近命令:\n${commandLines.join('\n')}`
: '- 最近命令:暂无',
stepLines.length > 0
? `- 最近步骤:\n${stepLines.join('\n')}`
: '- 最近步骤:暂无',
].join('\n'),
draftCommand,
draftCommandLabel: latestSafeLogPath
? '读取最近日志'
: trace
? '查看 trace'
: '查看历史',
};
}
export function summarizeProjectHandoff(
nextManifest: GameCreationAppManifest,
nextProjectPath: string,
trace: GameCreationAgentRunTrace | null,
history: AgentRunHistoryItem[],
agents: AgentStatusCard[],
) {
const tasks = taskRowsFromManifest(nextManifest);
const completedCount = tasks.filter(
(task) => task.status === 'completed',
).length;
const manifestTasksById = new Map(tasks.map((task) => [task.id, task]));
const traceTasksById = new Map(
trace?.taskGraph.tasks.map((task) => [task.id, task]) ?? [],
);
const traceReadyTasks =
trace?.taskGraph.readyTaskIds
.map(
(taskId) => traceTasksById.get(taskId) ?? manifestTasksById.get(taskId),
)
.filter((task): task is GameCreationAppTaskState => Boolean(task)) ?? [];
const readyTasks =
traceReadyTasks.length > 0
? traceReadyTasks
: selectGameCreationAppReadyTasks({ tasks });
const failedTasks = tasks.filter((task) => task.status === 'failed');
const sourceCounts = nextManifest.assets.reduce(
(counts, asset) => {
counts[asset.source.kind] += 1;
return counts;
},
{ uploaded: 0, generated: 0, canvas: 0 },
);
const preview = nextManifest.preview;
const previewSummary =
preview?.status === 'running' && preview.url
? `运行中 ${preview.url}`
: preview
? previewStatusLabels[preview.status]
: '未启动';
const commandRuns = nextManifest.commandRuns ?? [];
const latestCommandRun = commandRuns[commandRuns.length - 1];
const evidenceAgentCount = agents.filter(
(agent) => agent.hasRecentEvidence,
).length;
const activeAgentCount = agents.filter(
(agent) => agent.taskGraphState === 'active',
).length;
const runSummary = trace
? `${trace.runId} · ${formatAgentRunStatus(trace)} · next ${trace.nextStep}`
: history[0]
? `无 latest,最近历史 ${history[0].trace.runId} · ${formatAgentRunStatus(history[0].trace)}`
: '暂无 run';
const readySummary =
readyTasks.length > 0
? readyTasks
.slice(0, 3)
.map((task) => `${task.group}/${task.role} ${task.title}`)
.join(';')
: '暂无';
const failedSummary =
failedTasks.length > 0
? failedTasks
.slice(0, 3)
.map((task) => `${task.group}/${task.role} ${task.title}`)
.join(';')
: '暂无';
return `项目交接:\n- 项目:${nextManifest.name}\n- 目录:${nextProjectPath}\n- Run:${runSummary}\n- 任务:完成 ${completedCount}/${tasks.length} · ready ${readyTasks.length} · 失败 ${failedTasks.length}\n- Ready:${readySummary}\n- 失败项:${failedSummary}\n- 资产:${nextManifest.assets.length} 个 · 上传 ${sourceCounts.uploaded} / 生成 ${sourceCounts.generated} / 画板 ${sourceCounts.canvas}\n- 预览:${previewSummary}\n- Agent:${evidenceAgentCount}/${agents.length} 有运行证据 · active ${activeAgentCount}\n- 历史:已加载 ${history.length} 个 run\n- 最近命令:${
latestCommandRun
? `${latestCommandRun.commandId} · ${
latestCommandRun.status === 'completed' ? '完成' : '失败'
}`
: '暂无'
}`;
}
@@ -1,96 +0,0 @@
import {
type GameCreationAgentRepairRouteTrace,
type GameCreationAgentRunStep,
type GameCreationAgentRunTrace,
type GameCreationAppTaskState,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { isSafeProjectRelativePath } from './projectPath';
import { taskGroupLabels } from './projectSummaryConstants';
export function isAgentRunTracePassed(trace: GameCreationAgentRunTrace | null) {
return (
trace?.status === 'passed' ||
trace?.status === 'artifacts-written' ||
trace?.stopReason === 'evaluator-passed'
);
}
export function isAgentReviewStep(step: GameCreationAgentRunStep) {
return (
step.agent.toLowerCase().includes('evaluator') ||
step.phase === 'evaluation' ||
step.phase === 'evaluate' ||
step.taskId === 'quality-review'
);
}
export function isPlaytestTraceStep(step: GameCreationAgentRunStep) {
return (
step.agent.toLowerCase().includes('playtest') ||
step.phase === 'playtest' ||
step.taskId === 'preview-playtest' ||
step.toolCalls.some(
(toolCall) =>
toolCall.toolId === 'game.static_smoke' ||
toolCall.toolId.startsWith('preview.'),
)
);
}
export function formatAgentRunStatus(trace: GameCreationAgentRunTrace) {
const status = trace.lifecycleStatus
? `${trace.status} / ${trace.lifecycleStatus}`
: trace.status;
return `${status} · ${trace.passes}/${trace.maxPasses} 轮 · ${trace.stopReason}`;
}
export function formatTraceTaskId(
taskId: string,
tasks: GameCreationAppTaskState[],
) {
const task = tasks.find((candidate) => candidate.id === taskId);
if (!task) {
return taskId;
}
return `${taskGroupLabels[task.group]} / ${task.role} ${task.title}(${task.id})`;
}
export function formatTraceTaskIds(
taskIds: string[],
tasks: GameCreationAppTaskState[],
) {
return taskIds.length > 0
? taskIds.map((taskId) => formatTraceTaskId(taskId, tasks)).join(', ')
: 'none';
}
export function formatTraceRepairRoutes(
routes: GameCreationAgentRepairRouteTrace[],
tasks: GameCreationAppTaskState[],
) {
const visibleRoutes = routes
.slice(0, 3)
.map(
(route) => `${route.reason}: ${formatTraceTaskIds(route.taskIds, tasks)}`,
);
if (routes.length > visibleRoutes.length) {
visibleRoutes.push(`还有 ${routes.length - visibleRoutes.length} 条路线`);
}
return visibleRoutes.join(';');
}
export function readableArtifactPathFromAgentRunTrace(
trace: GameCreationAgentRunTrace,
) {
return trace.artifacts.find((artifact) =>
isSafeProjectRelativePath(artifact.path),
)?.path;
}
export function readableArtifactsFromAgentRunTrace(
trace: GameCreationAgentRunTrace,
) {
return trace.artifacts.filter((artifact) =>
isSafeProjectRelativePath(artifact.path),
);
}
@@ -1,48 +0,0 @@
export function missingChatCommandArgumentMessage(prompt: string) {
switch (prompt) {
case '/project':
return '格式:/project /绝对路径';
case '/generate':
case '/draft':
return '格式:/generate 创作想法';
case '/diff':
return '格式:/diff checkpoint-id';
case '/restore':
return '格式:/restore checkpoint-id';
case '/policy-deny':
return '格式:/policy-deny file.write';
case '/policy-allow':
return '格式:/policy-allow file.write';
case '/policy-confirm':
return '格式:/policy-confirm project.index';
case '/policy-auto':
return '格式:/policy-auto project.index';
case '/agent-policy-deny':
return '格式:/agent-policy-deny design-director file.read';
case '/agent-policy-allow':
return '格式:/agent-policy-allow design-director file.read';
case '/agent-policy-confirm':
return '格式:/agent-policy-confirm design-director memory.write';
case '/agent-policy-auto':
return '格式:/agent-policy-auto design-director memory.write';
case '/read':
return '格式:/read game/index.html';
case '/asset-register':
return '格式:/asset-register assets/hero.png [kind] [mediaType]';
case '/remember':
return '请提供要追加的记忆内容。';
case '/memory-set':
return '请提供要保存的记忆内容。';
case '/canvas':
case '/sync-canvas-project':
return '请提供画板项目 ID。';
case '/generate-art':
return '请提供美术生成提示词。';
case '/import-canvas-asset':
return '格式:/import-canvas-asset assets/hero.png 画板项目ID 资源ID|object:资产对象ID';
case '/import-canvas-export':
return '格式:/import-canvas-export /绝对/画板素材.zip 画板项目ID';
default:
return null;
}
}
@@ -1,368 +0,0 @@
import {
type LocalProjectCheckpointResult,
type LocalProjectCheckpointSummary,
type LocalProjectDiffResult,
type LocalProjectExportPackageResult,
type LocalProjectExportPackagesResult,
type LocalProjectFileEntry,
type LocalProjectFileResult,
type LocalProjectIndexResult,
type ProjectAssetDraft,
type ProjectFileActionDraft,
type ProjectPermissionPolicyView,
} from '../../app/types';
import {
commonAgentRunSupportReadDrafts,
commonProjectArtifactReadDrafts,
commonProjectInternalReadDrafts,
commonProjectLogReadDrafts,
} from './projectSummaryConstants';
export function summarizeProjectFiles(files: LocalProjectFileEntry[]) {
if (files.length === 0) {
return '本地项目还没有文件。';
}
const visibleFiles = files.slice(0, 40);
const lines = visibleFiles.map((file) =>
file.kind === 'directory' ? `- ${file.path}/` : `- ${file.path}`,
);
if (files.length > visibleFiles.length) {
lines.push(`- 还有 ${files.length - visibleFiles.length} 项`);
}
return `本地项目文件:\n${lines.join('\n')}`;
}
export function summarizeProjectIndex(result: LocalProjectIndexResult) {
const visibleFiles = result.files.slice(0, 12);
const lines = visibleFiles.map((file) => `- ${file.path} · ${file.size}B`);
if (result.files.length > visibleFiles.length) {
lines.push(`- 还有 ${result.files.length - visibleFiles.length} 项`);
}
return [
`索引:${result.fileCount} 个文件,${result.totalBytes}B`,
`路径:${result.indexPath}`,
lines.join('\n'),
]
.filter(Boolean)
.join('\n');
}
export function summarizeProjectCheckpoint(
result: LocalProjectCheckpointResult,
) {
return [
`已保存 checkpoint:${result.checkpointId}`,
`文件:${result.fileCount} 个,${result.totalBytes}B`,
`路径:${result.checkpointPath}`,
].join('\n');
}
export function summarizeProjectExportPackage(
result: LocalProjectExportPackageResult,
) {
return [
`已导出本地试玩包:${result.packageRelativePath}`,
`文件:${result.fileCount} 个,${result.totalBytes}B`,
`路径:${result.packagePath}`,
].join('\n');
}
export function summarizeProjectExportPackages(
result: LocalProjectExportPackagesResult,
) {
if (result.packages.length === 0) {
return '本地试玩包:暂无。输入 /export 导出当前可试玩原型。';
}
const visiblePackages = result.packages.slice(0, 8);
const lines = visiblePackages.map(
(item) => `- ${item.packageRelativePath} · ${item.totalBytes}B`,
);
if (result.packages.length > visiblePackages.length) {
lines.push(
`- 还有 ${result.packages.length - visiblePackages.length} 个更早试玩包`,
);
}
return `本地试玩包:\n${lines.join('\n')}`;
}
export function checkpointIdFromManifestPath(path: string) {
const match = path.match(/^\.agent\/checkpoints\/([^/]+)\/manifest\.json$/);
return match?.[1] ?? null;
}
export function isCheckpointManifestFile(file: LocalProjectFileEntry) {
return file.kind === 'file' && checkpointIdFromManifestPath(file.path);
}
export function sortCheckpointManifestFiles(files: LocalProjectFileEntry[]) {
return files.filter(isCheckpointManifestFile).sort((left, right) => {
const modifiedDelta = (right.modifiedAt ?? 0) - (left.modifiedAt ?? 0);
return modifiedDelta || right.path.localeCompare(left.path);
});
}
export function summarizeProjectCheckpoints(
checkpoints: LocalProjectCheckpointSummary[],
hiddenCount: number,
) {
if (checkpoints.length === 0) {
return '还没有 checkpoint。输入 /checkpoint 保存当前项目快照。';
}
const lines = checkpoints.map((checkpoint) =>
[
`- ${checkpoint.checkpointId}`,
`${checkpoint.fileCount} 个文件`,
`${checkpoint.totalBytes}B`,
checkpoint.createdAt ? `createdAt ${checkpoint.createdAt}` : null,
`/diff ${checkpoint.checkpointId}`,
`/restore ${checkpoint.checkpointId}`,
]
.filter(Boolean)
.join(' · '),
);
if (hiddenCount > 0) {
lines.push(`- 还有 ${hiddenCount} 个更早 checkpoint`);
}
return `最近 checkpoint:\n${lines.join('\n')}`;
}
export function checkpointSummaryFromManifest(
file: LocalProjectFileEntry,
content: string,
): LocalProjectCheckpointSummary {
const fallbackId = checkpointIdFromManifestPath(file.path) ?? file.path;
try {
const parsed: unknown = JSON.parse(content);
const data =
parsed && typeof parsed === 'object'
? (parsed as {
checkpointId?: unknown;
createdAt?: unknown;
files?: unknown;
})
: {};
const files = Array.isArray(data.files) ? data.files : [];
const manifestFileCount = (data as { fileCount?: unknown }).fileCount;
const fileCount =
files.length > 0
? files.length
: typeof manifestFileCount === 'number' && manifestFileCount >= 0
? manifestFileCount
: 0;
const totalBytes = files.reduce((sum, item) => {
if (!item || typeof item !== 'object') {
return sum;
}
const size = (item as { size?: unknown }).size;
return sum + (typeof size === 'number' && size > 0 ? size : 0);
}, 0);
const manifestTotalBytes = (data as { totalBytes?: unknown }).totalBytes;
const resolvedTotalBytes =
totalBytes > 0
? totalBytes
: typeof manifestTotalBytes === 'number' && manifestTotalBytes >= 0
? manifestTotalBytes
: 0;
return {
checkpointId:
typeof data.checkpointId === 'string' && data.checkpointId.trim()
? data.checkpointId
: fallbackId,
path: file.path,
fileCount,
totalBytes: resolvedTotalBytes,
createdAt:
typeof data.createdAt === 'number' || typeof data.createdAt === 'string'
? String(data.createdAt)
: '',
modifiedAt: file.modifiedAt,
};
} catch {
return {
checkpointId: fallbackId,
path: file.path,
fileCount: 0,
totalBytes: 0,
createdAt: '',
modifiedAt: file.modifiedAt,
};
}
}
export function summarizeProjectDiff(result: LocalProjectDiffResult) {
const section = (label: string, files: Array<{ path: string }>) => {
if (files.length === 0) {
return null;
}
const visibleFiles = files.slice(0, 20);
const lines = visibleFiles.map((file) => `- ${file.path}`);
if (files.length > visibleFiles.length) {
lines.push(`- 还有 ${files.length - visibleFiles.length} 项`);
}
return `${label}:\n${lines.join('\n')}`;
};
return (
[
`checkpoint:${result.checkpointId}`,
section('新增', result.added),
section('变更', result.changed),
section('删除', result.deleted),
]
.filter(Boolean)
.join('\n') || '无差异。'
);
}
export function summarizeProjectPolicy(view: ProjectPermissionPolicyView) {
const agentPolicies = view.policy.agentPolicies ?? {};
const agentPolicyLines = Object.entries(agentPolicies)
.slice(0, 8)
.map(
([agentId, policy]) =>
`Agent ${agentId}:拒绝 ${formatProjectPolicyCommandList(
policy.deniedCommands,
)};确认 ${formatProjectPolicyCommandList(policy.confirmCommands)}`,
);
if (Object.keys(agentPolicies).length > agentPolicyLines.length) {
agentPolicyLines.push(
`Agent 策略还有 ${Object.keys(agentPolicies).length - agentPolicyLines.length} 项`,
);
}
return [
`策略:${view.path}`,
`拒绝:${formatProjectPolicyCommandList(view.policy.deniedCommands)}`,
`确认:${formatProjectPolicyCommandList(view.policy.confirmCommands)}`,
...agentPolicyLines,
].join('\n');
}
export function formatProjectPolicyCommandList(values: string[]) {
if (values.length === 0) {
return '无';
}
const visibleValues = values.slice(0, 12);
return [
visibleValues.join('、'),
values.length > visibleValues.length
? `还有 ${values.length - visibleValues.length} 项`
: null,
]
.filter(Boolean)
.join('、');
}
export function formatCanvasAssetSource(source: {
canvasProjectId: string;
canvasAssetId: string;
canvasAssetObjectId?: string;
}) {
const assetReference = source.canvasAssetObjectId
? `object:${source.canvasAssetObjectId}`
: source.canvasAssetId;
return `${source.canvasProjectId} / ${assetReference || '未提供资产 ID'}`;
}
export function summarizeProjectFileContent(result: LocalProjectFileResult) {
const limit = 4000;
const content =
result.content.length > limit
? `${result.content.slice(0, limit)}\n...已截断 ${
result.content.length - limit
} 字符`
: result.content;
const visibleContent = content || '空文件';
let longestBacktickRun = 0;
for (const match of visibleContent.matchAll(/`+/gu)) {
longestBacktickRun = Math.max(longestBacktickRun, match[0].length);
}
const fence = '`'.repeat(Math.max(3, longestBacktickRun + 1));
return `文件:${result.path}\n\n${fence}text\n${visibleContent}\n${fence}`;
}
export function inferProjectFileAssetDraft(
localPath: string,
): ProjectAssetDraft {
const extension = localPath.split('.').pop()?.toLowerCase() ?? '';
if (
['png', 'jpg', 'jpeg', 'webp', 'gif', 'svg', 'avif'].includes(extension)
) {
const normalizedExtension = extension === 'jpg' ? 'jpeg' : extension;
return {
localPath,
kind: 'image',
mediaType:
extension === 'svg' ? 'image/svg+xml' : `image/${normalizedExtension}`,
};
}
if (['mp3', 'wav', 'ogg', 'm4a', 'flac'].includes(extension)) {
return {
localPath,
kind: 'audio',
mediaType: extension === 'm4a' ? 'audio/mp4' : `audio/${extension}`,
};
}
if (['mp4', 'webm', 'mov'].includes(extension)) {
return {
localPath,
kind: 'video',
mediaType: extension === 'mov' ? 'video/quicktime' : `video/${extension}`,
};
}
if (extension === 'json') {
return { localPath, kind: 'document', mediaType: 'application/json' };
}
if (extension === 'html') {
return { localPath, kind: 'document', mediaType: 'text/html' };
}
if (['txt', 'md', 'csv'].includes(extension)) {
return { localPath, kind: 'document', mediaType: 'text/plain' };
}
// 判不出内容类型就写 `unknown`(→「待归类」),与 Rust 侧 `uploaded_asset_kind` 同口径:
// `/asset-register` 的 kind 会原样进 `register_local_asset` 的严格解析,写 `data` / `asset`
// 这类非 canonical 值只会被判成"认不出"并留痕,不能靠它表达"这是数据 / 这是资产"。
return { localPath, kind: 'unknown', mediaType: 'application/octet-stream' };
}
export function projectAssetDraftCommand(draft: ProjectAssetDraft) {
return `/asset-register ${draft.localPath} ${draft.kind} ${draft.mediaType}`;
}
export function projectFileActionDrafts(
localPath: string,
): ProjectFileActionDraft {
return {
readCommand: `/read ${localPath}`,
assetCommand: projectAssetDraftCommand(
inferProjectFileAssetDraft(localPath),
),
};
}
export function summarizeCommonProjectArtifactReadDrafts() {
return `常用生成产物:\n${commonProjectArtifactReadDrafts
.map(
(artifact) =>
`- ${artifact.label} · ${artifact.path} · /read ${artifact.path}`,
)
.join('\n')}`;
}
export function summarizeCommonProjectLogReadDrafts() {
return `常用日志读取命令:\n${commonProjectLogReadDrafts
.map((log) => `- ${log.label} · ${log.path} · /read ${log.path}`)
.join('\n')}`;
}
export function summarizeAgentRunSupportFileReadDrafts() {
return `Agent 运行辅助文件读取命令:\n${commonAgentRunSupportReadDrafts
.map((file) => `- ${file.label} · ${file.path} · /read ${file.path}`)
.join('\n')}`;
}
export function summarizeProjectInternalReadDrafts() {
return `项目内部真相源读取命令:\n${commonProjectInternalReadDrafts
.map((file) => `- ${file.label} · ${file.path} · /read ${file.path}`)
.join('\n')}`;
}
@@ -1,282 +0,0 @@
import {
type GameCreationAppAssetSourceKind,
type GameCreationAppManifest,
isGameCreationAppAssetAudioKind,
isGameCreationAppAssetVisualKind,
selectGameCreationAppReadyTasks,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { taskRowsFromManifest } from '../agent-runtime';
import { isSafeProjectRelativePath } from './projectPath';
import {
assetSourceKindLabels,
taskGroupLabels,
taskStatusLabels,
} from './projectSummaryConstants';
export function summarizeProjectAssets(nextManifest: GameCreationAppManifest) {
if (nextManifest.assets.length === 0) {
return '本地项目还没有登记资产。';
}
const visibleAssets = nextManifest.assets.slice(0, 20);
const lines = visibleAssets.map(
(asset) =>
`- ${asset.kind} · ${asset.localPath} · ${asset.source.kind}${
asset.source.canvasProjectId
? ` · 画板 ${asset.source.canvasProjectId}`
: ''
}`,
);
if (nextManifest.assets.length > visibleAssets.length) {
lines.push(
`- 还有 ${nextManifest.assets.length - visibleAssets.length} 个资产`,
);
}
return `本地项目资产:\n${lines.join('\n')}`;
}
export function summarizeProjectAssetCredits(
nextManifest: GameCreationAppManifest,
) {
if (nextManifest.assets.length === 0) {
return {
text: [
'素材署名:',
'- 当前资产:暂无登记资产',
'- 来源清单:暂无',
'- 需要确认:上传素材授权;生成素材模型;画板资源来源',
'- 建议:/assets',
].join('\n'),
draftCommand: '/assets',
draftCommandLabel: '查看资产',
};
}
const sourceCounts = nextManifest.assets.reduce(
(counts, asset) => {
counts[asset.source.kind] += 1;
return counts;
},
{ uploaded: 0, generated: 0, canvas: 0 } satisfies Record<
GameCreationAppAssetSourceKind,
number
>,
);
const visibleAssets = nextManifest.assets.slice(0, 10);
const lines = visibleAssets.map((asset) => {
const sourceLabel = assetSourceKindLabels[asset.source.kind];
const sourceDetail =
asset.source.kind === 'canvas'
? `画板 ${asset.source.canvasProjectId ?? '未记录'}`
: asset.source.kind === 'generated'
? `生成${asset.source.model ? ` ${asset.source.model}` : ''}`
: '用户上传';
return `- ${asset.localPath} · ${asset.mediaType} · ${sourceLabel} · ${sourceDetail}`;
});
if (nextManifest.assets.length > visibleAssets.length) {
lines.push(
`- 还有 ${nextManifest.assets.length - visibleAssets.length} 个资产`,
);
}
const sourceSummary = (
Object.keys(sourceCounts) as GameCreationAppAssetSourceKind[]
)
.filter((source) => sourceCounts[source] > 0)
.map((source) => `${assetSourceKindLabels[source]} ${sourceCounts[source]}`)
.join(' / ');
return {
text: [
'素材署名:',
`- 当前资产:${nextManifest.assets.length} 个`,
`- 来源分布:${sourceSummary || '暂无'}`,
`- 来源清单:\n${lines.join('\n')}`,
'- 需要确认:上传素材授权;生成素材模型;画板资源来源;本地试玩包保留来源口径',
'- 参考:/assets;/art;/audio;/listing',
'- 建议:/assets',
].join('\n'),
draftCommand: '/assets',
draftCommandLabel: '查看资产',
};
}
export function isProjectAudioAsset(
asset: GameCreationAppManifest['assets'][number],
) {
const mediaType = asset.mediaType.toLowerCase();
return (
mediaType.startsWith('audio/') ||
isGameCreationAppAssetAudioKind(asset.kind)
);
}
export function isProjectVisualAsset(
asset: GameCreationAppManifest['assets'][number],
) {
const mediaType = asset.mediaType.toLowerCase();
return (
mediaType.startsWith('image/') ||
mediaType.startsWith('video/') ||
mediaType === 'application/vnd.genarrative.image-sequence' ||
isGameCreationAppAssetVisualKind(asset.kind)
);
}
export function summarizeProjectVisualAssets(
nextManifest: GameCreationAppManifest,
) {
const visualAssets = nextManifest.assets.filter(isProjectVisualAsset);
if (visualAssets.length === 0) {
return {
text: [
'美术素材:暂无登记图片、视频或序列帧。',
'可先生成首版美术,或同步已有画板项目资源。',
].join('\n'),
draftCommand: '/generate-art 首版核心美术素材',
draftCommandLabel: '生成美术',
};
}
const sourceCounts = visualAssets.reduce(
(counts, asset) => {
counts[asset.source.kind] += 1;
return counts;
},
{ uploaded: 0, generated: 0, canvas: 0 } satisfies Record<
GameCreationAppAssetSourceKind,
number
>,
);
const visibleAssets = visualAssets.slice(0, 8);
const lines = visibleAssets.map(
(asset) =>
`- ${asset.localPath} · ${asset.mediaType} · ${
assetSourceKindLabels[asset.source.kind]
}${
asset.source.canvasProjectId
? ` · 画板 ${asset.source.canvasProjectId}`
: ''
}`,
);
if (visualAssets.length > visibleAssets.length) {
lines.push(
`- 还有 ${visualAssets.length - visibleAssets.length} 个美术素材`,
);
}
const hasCanvasVisualAsset = visualAssets.some(
(asset) => asset.source.kind === 'canvas',
);
return {
text: [
`美术素材:${visualAssets.length} 个`,
`来源:${(Object.keys(sourceCounts) as GameCreationAppAssetSourceKind[])
.filter((source) => sourceCounts[source] > 0)
.map(
(source) =>
`${assetSourceKindLabels[source]} ${sourceCounts[source]}`,
)
.join('、')}`,
hasCanvasVisualAsset
? '画板来源:已接入'
: '画板来源:暂无 · 建议 /generate-art 首版核心美术素材',
lines.join('\n'),
].join('\n'),
draftCommand: hasCanvasVisualAsset
? '/read assets/manifest.art.json'
: '/generate-art 首版核心美术素材',
draftCommandLabel: hasCanvasVisualAsset ? '读美术清单' : '生成美术',
};
}
export function summarizeProjectAudioAssets(
nextManifest: GameCreationAppManifest,
) {
const audioAssets = nextManifest.assets.filter(isProjectAudioAsset);
if (audioAssets.length === 0) {
return {
text: [
'音频素材:暂无登记音频。',
'可先登记项目内音效,或把已有画板音频作为素材导入。',
].join('\n'),
draftCommand: '/asset-register assets/audio/sfx.wav audio audio/wav',
draftCommandLabel: '登记音效',
};
}
const sourceCounts = audioAssets.reduce(
(counts, asset) => {
counts[asset.source.kind] += 1;
return counts;
},
{ uploaded: 0, generated: 0, canvas: 0 } satisfies Record<
GameCreationAppAssetSourceKind,
number
>,
);
const visibleAssets = audioAssets.slice(0, 8);
const lines = visibleAssets.map(
(asset) =>
`- ${asset.localPath} · ${asset.mediaType} · ${
assetSourceKindLabels[asset.source.kind]
}${
asset.source.canvasProjectId
? ` · 画板 ${asset.source.canvasProjectId}`
: ''
}`,
);
if (audioAssets.length > visibleAssets.length) {
lines.push(
`- 还有 ${audioAssets.length - visibleAssets.length} 个音频素材`,
);
}
return {
text: [
`音频素材:${audioAssets.length} 个`,
`来源:${(Object.keys(sourceCounts) as GameCreationAppAssetSourceKind[])
.filter((source) => sourceCounts[source] > 0)
.map(
(source) =>
`${assetSourceKindLabels[source]} ${sourceCounts[source]}`,
)
.join('、')}`,
lines.join('\n'),
].join('\n'),
draftCommand: '/read assets/manifest.audio.json',
draftCommandLabel: '读音频清单',
};
}
export function firstReadableProjectAssetPath(
nextManifest: GameCreationAppManifest,
) {
return nextManifest.assets.find((asset) =>
isSafeProjectRelativePath(asset.localPath),
)?.localPath;
}
export function summarizeProjectTasks(nextManifest: GameCreationAppManifest) {
const tasks = taskRowsFromManifest(nextManifest);
if (tasks.length === 0) {
return '还没有任务拆分。';
}
const readyTasks = selectGameCreationAppReadyTasks({
tasks,
});
const readySummary =
readyTasks.length > 0
? `\n下一步:${readyTasks
.map((task) => `${taskGroupLabels[task.group]} / ${task.role}`)
.join(';')}`
: '\n下一步:等待确认或暂无可执行任务';
return `任务拆分:\n${tasks
.map(
(task) =>
`- ${taskGroupLabels[task.group]} / ${task.role}:${task.title} · ${
taskStatusLabels[task.status]
} -> ${task.artifacts.join(', ')}`,
)
.join('\n')}${readySummary}`;
}
@@ -1,271 +0,0 @@
import {
type GameCreationAgentRunTrace,
type GameCreationAppAssetSourceKind,
type GameCreationAppManifest,
selectGameCreationAppReadyTasks,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { type AgentStatusCard } from '../../app/types';
import { taskRowsFromManifest } from '../agent-runtime';
import { isAgentRunTracePassed } from './agentTrace';
import {
isProjectAudioAsset,
isProjectVisualAsset,
} from './projectAssetSummaries';
import { assetSourceKindLabels } from './projectSummaryConstants';
export function summarizeNextProjectActions(
nextManifest: GameCreationAppManifest,
trace: GameCreationAgentRunTrace | null,
) {
const suggestions: Array<{ label: string; command?: string }> = [];
const addSuggestion = (label: string, command?: string) => {
if (command && suggestions.some((item) => item.command === command)) {
return;
}
suggestions.push({ label, command });
};
const preview = nextManifest.preview;
if (!trace) {
addSuggestion('直接输入一句游戏需求,确认后生成首版原型');
} else if (
trace.lifecycleStatus === 'killed' ||
trace.status === 'failed' ||
trace.stopReason === 'max-passes-exhausted'
) {
addSuggestion('补充说明并继续最近 run', '/agent-resume ');
} else if (
trace.status === 'passed' ||
trace.status === 'artifacts-written' ||
trace.stopReason === 'evaluator-passed'
) {
addSuggestion(
preview?.status === 'running' && preview.url
? '打开当前本地预览'
: '运行自检并启动本地预览',
preview?.status === 'running' && preview.url ? '/open-preview' : '/run',
);
addSuggestion('导出本地试玩包', '/export');
addSuggestion('查看本地试玩包', '/exports');
} else {
addSuggestion('查看最近 loop 进展', '/trace');
}
addSuggestion('查看创作目标', '/goal');
addSuggestion('查看普通用户操作导引', '/guide');
addSuggestion('查看项目进度', '/progress');
addSuggestion('查看创作规格包', '/spec');
addSuggestion('查看本轮 MVP 范围', '/mvp');
addSuggestion('查看试玩定位与卖点', '/pitch');
addSuggestion('准备 30 秒试玩讲解稿', '/demo');
addSuggestion('查看玩法操作与规则', '/rules');
addSuggestion('查看新手引导检查', '/tutorial');
addSuggestion('查看移动试玩检查', '/mobile');
addSuggestion('准备兼容性说明', '/compatibility');
addSuggestion('查看可读性与无障碍检查', '/accessibility');
addSuggestion('查看本地化与文案检查', '/localization');
addSuggestion('查看性能与加载检查', '/performance');
addSuggestion('查看试玩前打磨清单', '/polish');
addSuggestion('查看数值与难度口径', '/balance');
addSuggestion('查看当前阻塞项', '/blockers');
addSuggestion('查看试玩就绪度', '/ready');
addSuggestion('查看验证证据台账', '/evidence');
addSuggestion('查看 Agent 智能服务状态', '/llm-routes');
addSuggestion('查看任务依赖链', '/deps');
addSuggestion('准备下一轮改版说明', '/revise');
addSuggestion('查看隐私与导出边界', '/privacy');
addSuggestion('查看首批试玩对象', '/audience');
addSuggestion('准备试玩邀请文案', '/invite');
addSuggestion('准备试玩问卷问题', '/survey');
addSuggestion('准备封面与缩略图检查', '/cover');
addSuggestion('准备宣传截图清单', '/screenshots');
addSuggestion('准备试玩短视频脚本', '/trailer');
addSuggestion('准备试玩常见问答', '/faq');
addSuggestion('准备社区发布文案', '/post');
addSuggestion('准备上架资料清单', '/store');
addSuggestion('准备媒体资料包清单', '/media-kit');
addSuggestion('准备试玩更新说明', '/release-notes');
addSuggestion('准备已知问题清单', '/known-issues');
const readyTasks = selectGameCreationAppReadyTasks({
tasks: taskRowsFromManifest(nextManifest),
});
if (readyTasks.length > 0) {
addSuggestion(`查看 ${readyTasks.length} 个 ready 任务`, '/tasks');
addSuggestion('查看当前任务验收标准', '/criteria');
addSuggestion('查看专业组进度', '/groups');
} else {
addSuggestion('查看任务拆分和等待项', '/tasks');
addSuggestion('查看当前任务验收标准', '/criteria');
addSuggestion('查看专业组进度', '/groups');
}
addSuggestion('查看质量检查清单', '/qa');
addSuggestion('查看最近生成变更', '/changes');
addSuggestion('查看下一轮分工计划', '/plan');
addSuggestion('查看下一轮小步清单', '/todo');
if (nextManifest.assets.length > 0) {
addSuggestion(`查看 ${nextManifest.assets.length} 个本地资产`, '/assets');
addSuggestion('查看素材署名与来源', '/credits');
addSuggestion(
nextManifest.assets.some(isProjectVisualAsset)
? '查看美术素材'
: '生成或同步美术素材',
'/art',
);
addSuggestion(
nextManifest.assets.some(isProjectAudioAsset)
? '查看音频素材'
: '登记或导入音频素材',
'/audio',
);
} else {
addSuggestion(
'登记本地素材或同步画板资源',
'/asset-register assets/hero.png image image/png',
);
addSuggestion('同步已有画板项目资源', '/sync-canvas-project ');
addSuggestion('查看素材署名与来源', '/credits');
addSuggestion('生成或同步美术素材', '/art');
addSuggestion('登记或导入音频素材', '/audio');
}
if (trace) {
addSuggestion('查看发布准备清单', '/publish');
addSuggestion('准备作品页文案清单', '/listing');
addSuggestion('查看试玩状态', '/playtest');
addSuggestion('准备手动测试计划', '/test-plan');
addSuggestion('准备试玩反馈', '/feedback');
addSuggestion('准备复玩观察清单', '/retention');
addSuggestion('准备试玩交付清单', '/share');
addSuggestion('查看最近 run 预算', '/budget');
addSuggestion('查看评审和返工焦点', '/review');
addSuggestion('查看生成上下文来源', '/context');
addSuggestion('查看项目活动时间线', '/timeline');
addSuggestion('查看最近 trace 摘要', '/trace');
addSuggestion('列出最近 Run 产物', '/run-artifacts');
addSuggestion('列出 Agent 轮次产物', '/passes');
addSuggestion('列出 Agent 运行辅助文件', '/run-files');
}
addSuggestion('打开产物命令列表', '/artifacts');
addSuggestion('列出内部真相源读取命令', '/internals');
addSuggestion('打开日志命令列表', '/logs');
const firstCommand = suggestions.find((item) => item.command);
return {
text: `下一步建议:\n${suggestions
.map(
(item) => `- ${item.label}${item.command ? `:${item.command}` : ''}`,
)
.join('\n')}`,
draftCommand: firstCommand?.command,
draftCommandLabel: firstCommand?.label,
};
}
export function summarizeProjectUserGuide(
nextManifest: GameCreationAppManifest,
trace: GameCreationAgentRunTrace | null,
) {
const preview = nextManifest.preview;
const previewRunning = preview?.status === 'running' && preview.url;
const tracePassed = isAgentRunTracePassed(trace);
const blockedTrace =
trace?.lifecycleStatus === 'killed' ||
trace?.status === 'failed' ||
trace?.status === 'needs-revision' ||
trace?.stopReason === 'max-passes-exhausted';
const exported = (nextManifest.commandRuns ?? []).some(
(commandRun) =>
commandRun.commandId === 'project.export_package' &&
commandRun.status === 'completed',
);
let stage = '未开始';
let nextAction = '先确认一句游戏目标,再生成首版原型。';
let recommendedCommands = ['/brief', '/mvp', '/next'];
if (blockedTrace) {
stage = '需修复';
nextAction = '先看评审和阻塞,再把修复说明交回 agent。';
recommendedCommands = ['/review', '/todo', '/plan'];
} else if (exported) {
stage = '已导出';
nextAction = '先检查试玩包和交付材料,再发给测试者。';
recommendedCommands = ['/exports', '/share', '/listing'];
} else if (previewRunning) {
stage = '可预览';
nextAction = '先打开本地预览试玩一轮,再记录反馈。';
recommendedCommands = ['/open-preview', '/test-plan', '/feedback'];
} else if (tracePassed) {
stage = '可导出';
nextAction = '先运行自检并启动本地预览,通过后再导出试玩包。';
recommendedCommands = ['/run', '/test-plan', '/share'];
} else if (trace) {
stage = '生成中/待验收';
nextAction = '先看最近 loop 和下一轮小步,再决定是否继续。';
recommendedCommands = ['/trace', '/todo', '/plan'];
}
const draftCommand = recommendedCommands[0];
return {
text: [
'使用导引:',
`- 项目:${nextManifest.name}`,
`- 当前阶段:${stage}`,
`- 现在先做:${nextAction}`,
`- 推荐命令:${recommendedCommands.join(' / ')}`,
'- 边界:只给操作导引;不读取文件;不启动 run;不启动预览;不写项目',
].join('\n'),
draftCommand,
draftCommandLabel: '执行导引建议',
};
}
export function summarizeMainProjectHeader(
nextManifest: GameCreationAppManifest,
agents: AgentStatusCard[],
) {
const tasks = taskRowsFromManifest(nextManifest);
const completedCount = tasks.filter(
(task) => task.status === 'completed',
).length;
const readyTaskIds = new Set(
selectGameCreationAppReadyTasks({ tasks }).map((task) => task.id),
);
for (const agent of agents) {
if (agent.taskGraphState === 'ready') {
readyTaskIds.add(agent.taskId);
}
}
const sourceCounts = nextManifest.assets.reduce(
(counts, asset) => {
counts[asset.source.kind] += 1;
return counts;
},
{
uploaded: 0,
generated: 0,
canvas: 0,
} satisfies Record<GameCreationAppAssetSourceKind, number>,
);
const sourceSummary = (
Object.keys(sourceCounts) as GameCreationAppAssetSourceKind[]
)
.filter((source) => sourceCounts[source] > 0)
.map((source) => `${assetSourceKindLabels[source]} ${sourceCounts[source]}`)
.join(' / ');
const commandRuns = nextManifest.commandRuns ?? [];
const latestCommandRun = commandRuns[commandRuns.length - 1];
return [
`任务:已完成 ${completedCount}/${tasks.length} · ready ${readyTaskIds.size}`,
`资产:${nextManifest.assets.length} 个${
sourceSummary ? ` · ${sourceSummary}` : ''
}`,
latestCommandRun
? `最近命令:${latestCommandRun.commandId} ${
latestCommandRun.status === 'completed' ? '完成' : '失败'
}`
: '最近命令:暂无',
].join(' · ');
}
@@ -37,15 +37,3 @@ export function projectPathsMatchForInvalidation(
}
return normalize(eventPath) === normalize(activePath);
}
export function isSafeProjectRelativePath(value: string) {
const path = value.trim();
return (
!!path &&
!isAbsoluteProjectPath(path) &&
!projectPathHasControlCharacter(path) &&
!path.includes('\\') &&
!path.includes(':') &&
path.split('/').every((part) => part && part !== '.' && part !== '..')
);
}

Some files were not shown because too many files have changed in this diff Show More