合并 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:
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;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
export function resolveTauriInvoke() {
|
||||
import type { TauriInvoke } from './types';
|
||||
|
||||
/** 返回 undefined 表示不在 Tauri 环境(浏览器预览、测试壳),调用方必须先判空。 */
|
||||
export function resolveTauriInvoke(): TauriInvoke | undefined {
|
||||
return window.__TAURI__?.core?.invoke;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+880
File diff suppressed because it is too large
Load Diff
+31
@@ -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>
|
||||
);
|
||||
}
|
||||
+58
@@ -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}`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(' · ');
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
Reference in New Issue
Block a user