onChangeType(resource.manifestAssetId!)
+ : undefined
+ }
+ onInfoClick={() => onShowInfo(resource)}
+ />
{lineage ? (
// 文字是给人看的关系,`data-resource-lineage` 是给端到端验收的稳定判据
// (稳定 id 见卡上的 `data-resource-replaced-by` / `data-resource-replacement-of`)。
@@ -1342,6 +1479,7 @@ function ResourceBookScene({
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
+ onLostPointerCapture={onPointerCancel}
>
(null);
/** 画布上的只读信息浮层(「信息」动作的落点),与运行页签的信息面板同源。 */
- const [resourceInfoPanelOpen, setResourceInfoPanelOpen] = useState(false);
+ const [resourceInfoResourceId, setResourceInfoResourceId] = useState<
+ string | null
+ >(null);
+ const resourceInfoPanelOpen =
+ resourceInfoResourceId !== null &&
+ selectedResourceIds[0] === resourceInfoResourceId;
const [resourceCanvasMarquee, setResourceCanvasMarquee] =
useState(null);
/** 资源卡组织操作历史:只回滚布局坐标,不回滚素材。 */
@@ -1587,6 +1731,14 @@ export default function ProjectDevelopmentView({
const [resourcePanelOpen, setResourcePanelOpen] = useState(false);
const [resourceDocumentPreviewIdentity, setResourceDocumentPreviewIdentity] =
useState(null);
+ /**
+ * 引擎模型的放大预览浮层身份(同时是当前选中资源的预览身份)。
+ *
+ * 与文档预览浮层一样只在 `resources` 视图、且身份仍等于当前选中资源时渲染;
+ * 它是**只读预览**,不参与编辑、不写回 manifest。
+ */
+ const [resourceModelPreviewIdentity, setResourceModelPreviewIdentity] =
+ useState(null);
/**
* 「生成素材」浮层的本次放行类型。
*
@@ -1771,6 +1923,7 @@ export default function ProjectDevelopmentView({
const resourceCanvasFitKeysRef = useRef>(new Set());
const resourceCanvasPanRef = useRef<{
pointerId: number;
+ captureTarget: HTMLElement;
category: ResourceBookTarget;
startClientX: number;
startClientY: number;
@@ -1881,7 +2034,7 @@ export default function ProjectDevelopmentView({
*/
const clearResourceCanvasFocus = useCallback(() => {
setSelectedResourceIds([]);
- setResourceInfoPanelOpen(false);
+ setResourceInfoResourceId(null);
if (!canDismissResourceCanvasQuickEdit(quickEditPanelRef.current)) {
return;
}
@@ -2305,7 +2458,9 @@ export default function ProjectDevelopmentView({
* 是因为选中本身有多个清空入口(清焦点、切视图、切项目)。
*/
useEffect(() => {
- setResourceInfoPanelOpen(false);
+ setResourceInfoResourceId((current) =>
+ current === selectedResourceId ? current : null,
+ );
}, [selectedResourceId]);
const projectVersions = useMemo(
() => manifest.versions ?? [],
@@ -3363,15 +3518,14 @@ export default function ProjectDevelopmentView({
);
const cancelResourceCanvasPan = useCallback(() => {
- const pan = resourceCanvasPanRef.current;
- if (!pan) {
- return;
- }
- const canvas = resourceCanvasRef.current;
- if (canvas?.hasPointerCapture?.(pan.pointerId)) {
- canvas.releasePointerCapture?.(pan.pointerId);
- }
+ const pans = [resourceCanvasPanRef.current, resourceBookMainPanRef.current];
resourceCanvasPanRef.current = null;
+ resourceBookMainPanRef.current = null;
+ for (const pan of pans) {
+ if (pan?.captureTarget.hasPointerCapture?.(pan.pointerId)) {
+ pan.captureTarget.releasePointerCapture(pan.pointerId);
+ }
+ }
}, []);
const cancelResourceCardDrag = useCallback(() => {
@@ -4745,6 +4899,8 @@ export default function ProjectDevelopmentView({
};
const handleBlur = () => {
resourceCanvasSpacePanRef.current = false;
+ cancelResourceCanvasPan();
+ setResourceCanvasMarquee(null);
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
@@ -4754,7 +4910,7 @@ export default function ProjectDevelopmentView({
window.removeEventListener('keyup', handleKeyUp);
window.removeEventListener('blur', handleBlur);
};
- }, []);
+ }, [cancelResourceCanvasPan]);
const handleResourceBookWheel = useCallback(
(event: ReactWheelEvent | WheelEvent) => {
@@ -4851,7 +5007,10 @@ export default function ProjectDevelopmentView({
const handleResourceBookMainPointerDown = useCallback(
(event: ReactPointerEvent) => {
- if (resourceBookView !== 'main' || event.button !== 0) {
+ if (
+ resourceBookView !== 'main' ||
+ (event.button !== 0 && event.button !== 2)
+ ) {
return;
}
const target = event.target as HTMLElement;
@@ -4867,6 +5026,7 @@ export default function ProjectDevelopmentView({
resourceBookTransitionControllerRef.current.settle();
resourceBookMainPanRef.current = {
pointerId: event.pointerId,
+ captureTarget: event.currentTarget,
startClientX: event.clientX,
startClientY: event.clientY,
startViewport: resourceBookMainViewportRef.current,
@@ -5092,11 +5252,19 @@ export default function ProjectDevelopmentView({
const canvasTarget = resourceBookOpensAllResources
? RESOURCE_BOOK_ALL_TARGET
: activePageCategory;
- if (!canvasTarget || event.button > 1) {
+ if (!canvasTarget || event.button > 2) {
return;
}
const target = event.target as HTMLElement;
- if (isResourceCanvasInteractionTarget(target)) {
+ const isPan =
+ event.button === 2 ||
+ event.button === 1 ||
+ resourceCanvasSpacePanRef.current;
+ if (
+ event.button === 2
+ ? !isResourceCanvasPanTarget(target)
+ : isResourceCanvasInteractionTarget(target)
+ ) {
return;
}
// 点选态下空白处的左键不起框选、也不清画布焦点/选中:点选只认资源卡,
@@ -5110,12 +5278,13 @@ export default function ProjectDevelopmentView({
return;
}
resourceBookTransitionControllerRef.current.settle();
- // 与美术画布一致:中键或按住空格是平移,左键在空白处是框选。
- if (event.button === 1 || resourceCanvasSpacePanRef.current) {
+ // 右键平移,保留中键/空格抓手;空白处左键继续框选。
+ if (isPan) {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
resourceCanvasPanRef.current = {
pointerId: event.pointerId,
+ captureTarget: event.currentTarget,
category: canvasTarget,
startClientX: event.clientX,
startClientY: event.clientY,
@@ -5288,12 +5457,22 @@ export default function ProjectDevelopmentView({
const openResourceUiEditor = useCallback(
(resource: ProjectResource) => {
- if (resource.subtype === 'ui-prototype') {
+ const identity = resourceCardPreviews.identityByResourceId.get(
+ resource.id,
+ );
+ const jsonPresentation = projectResourceJsonPresentation(
+ resource,
+ identity ? resourceCardPreviews.previews.get(identity) : null,
+ );
+ if (resource.subtype === 'ui-prototype' && jsonPresentation === null) {
void openUiDesignEditor(resource);
return;
}
- if (resource.manifestAssetId === null) {
- setResourceWorkbenchNotice('该 UI 资源缺少有效的正式资产身份');
+ if (
+ resource.manifestAssetId === null ||
+ jsonPresentation !== 'ui-design'
+ ) {
+ setResourceWorkbenchNotice('该资源尚未通过 UI 设计 JSON 校验');
return;
}
canvasOpenEpochRef.current += 1;
@@ -5314,7 +5493,13 @@ export default function ProjectDevelopmentView({
: {}),
});
},
- [advanceFocusGeneration, manifest.assets, openUiDesignEditor],
+ [
+ advanceFocusGeneration,
+ manifest.assets,
+ openUiDesignEditor,
+ resourceCardPreviews.identityByResourceId,
+ resourceCardPreviews.previews,
+ ],
);
/**
@@ -6198,8 +6383,17 @@ export default function ProjectDevelopmentView({
setMode('run');
}
- const showRunUnavailableHint =
- !runAvailable && selectedResourceIds.length === 0 && !uiEditorRoute;
+ const showRunUnavailableHint = !runAvailable && !uiEditorRoute;
+
+ const showResourceCardInfo = useCallback(
+ (resource: ProjectResource) => {
+ handleResourceSelect(resource.id);
+ setResourceInfoResourceId((current) =>
+ current === resource.id ? null : resource.id,
+ );
+ },
+ [handleResourceSelect],
+ );
const renderResourceBookCard = useCallback(
(
@@ -6258,6 +6452,9 @@ export default function ProjectDevelopmentView({
}
activeMediaIdentity={activeCardMediaIdentity}
onSelect={handleResourceSelect}
+ onShowInfo={showResourceCardInfo}
+ onChangeType={setResourceTypeAssetId}
+ infoPressed={resourceInfoResourceId === resource.id}
onPointerDown={(event) =>
handleResourceCardPointerDown(
event,
@@ -6289,6 +6486,8 @@ export default function ProjectDevelopmentView({
handleResourceCardPointerMove,
handleResourceCardPointerUp,
handleResourceSelect,
+ showResourceCardInfo,
+ resourceInfoResourceId,
resourceCardDragPreview,
resourceCardPreviews,
resourceReplacementLineageBadgeMap,
@@ -6362,10 +6561,18 @@ export default function ProjectDevelopmentView({
) {
setResourceDocumentPreviewIdentity(null);
}
+ if (
+ mode !== 'resources' ||
+ uiEditorRoute ||
+ resourceModelPreviewIdentity !== selectedResourcePreviewIdentity
+ ) {
+ setResourceModelPreviewIdentity(null);
+ }
}, [
mode,
uiEditorRoute,
resourceDocumentPreviewIdentity,
+ resourceModelPreviewIdentity,
selectedResourcePreviewIdentity,
]);
@@ -6375,6 +6582,12 @@ export default function ProjectDevelopmentView({
return () => protectResourceCardPreview(null);
}, [protectResourceCardPreview, resourceDocumentPreviewIdentity]);
+ useEffect(() => {
+ if (!resourceModelPreviewIdentity) return;
+ protectResourceCardPreview(resourceModelPreviewIdentity);
+ return () => protectResourceCardPreview(null);
+ }, [protectResourceCardPreview, resourceModelPreviewIdentity]);
+
/**
* 解析一次资源派生的源身份:取项目 revision,必要时把任务产物正规化成正式素材。
*
@@ -7453,9 +7666,16 @@ export default function ProjectDevelopmentView({
: null,
[manifest, selectedResource],
);
+ const selectedResourceJsonPresentation = selectedResource
+ ? projectResourceJsonPresentation(
+ selectedResource,
+ selectedResourceCardPreview,
+ )
+ : null;
const selectedResourceOpensUiEditor =
- selectedResource?.subtype === 'UI' ||
- selectedResource?.subtype === 'ui-prototype';
+ selectedResourceJsonPresentation === 'ui-design' ||
+ (selectedResourceJsonPresentation === null &&
+ selectedResource?.subtype === 'ui-prototype');
const selectedToolbarStyle = selectedResourceLayer
? resolveSelectedToolbarStyle({
@@ -7843,10 +8063,24 @@ export default function ProjectDevelopmentView({
className={`game-resource-manager game-resource-book-manager game-resource-book-manager--${resourceBookState.view} game-resource-book-manager--${resourceBookState.phase}`}
data-resource-book-view={resourceBookState.view}
data-resource-book-transition={resourceBookState.phase}
+ onContextMenu={(event) => {
+ const target = event.target as Element;
+ if (
+ event.button === 2 &&
+ event.currentTarget.contains(target) &&
+ target.closest(
+ '.game-resource-book-scene, .game-resource-canvas, .game-resource-book-main',
+ ) &&
+ isResourceCanvasPanTarget(target)
+ ) {
+ event.preventDefault();
+ }
+ }}
onPointerDownCapture={(event) => {
if (
event.currentTarget.dataset.resourceBookTransition !==
'idle' &&
+ event.button === 0 &&
(event.target as Element).closest('.game-resource-card')
) {
event.preventDefault();
@@ -7894,6 +8128,7 @@ export default function ProjectDevelopmentView({
) ||
selectedResourceOpensUiEditor) ? (
预览
) : null}
- {selectedResourceOpensUiEditor ? (
+ {/*
+ * 引擎模型:进「放大预览」浮层做交互式视角操作。
+ * 判据与卡面同源(`projectResourceCardPreviewKind`),
+ * 因此不会出现「卡片是模型卡、却没有 3D 入口」的分叉。
+ */}
+ {selectedResource &&
+ selectedResourcePreviewIdentity &&
+ projectResourceCardPreviewKind(
+ selectedResource,
+ ) === 'model' ? (
+ }
+ onClick={() => {
+ stopActiveCardMedia();
+ resourceCardPreviews.requestPreview(
+ selectedResource,
+ selectedResourcePreviewIdentity,
+ 'detail',
+ );
+ setResourceModelPreviewIdentity(
+ selectedResourcePreviewIdentity,
+ );
+ }}
+ >
+ 3D 预览
+
+ ) : null}
+ {selectedResource &&
+ selectedResourceOpensUiEditor ? (
引用
) : null}
- {selectedResource ? (
- }
- onClick={() =>
- setResourceInfoPanelOpen((open) => !open)
- }
- >
- 信息
-
- ) : null}
{selectedResource?.manifestAssetId ? (
编辑标签
) : null}
- {selectedResource?.manifestAssetId ? (
- }
- onClick={() =>
- setResourceTypeAssetId(
- selectedResource.manifestAssetId,
- )
- }
- >
- 素材类型
-
- ) : null}
{selectedResource?.manifestAssetId ? (
setResourceInfoPanelOpen(false)}
+ onClose={() => setResourceInfoResourceId(null)}
/>
) : null}
@@ -8501,6 +8738,7 @@ export default function ProjectDevelopmentView({
onPointerMove={handleResourceCanvasPointerMove}
onPointerUp={stopResourceCanvasPan}
onPointerCancel={stopResourceCanvasPan}
+ onLostPointerCapture={stopResourceCanvasPan}
>
{sortMode === 'dependency' &&
dependencyRelationshipDescriptions.length > 0 ? (
@@ -8820,6 +9058,23 @@ export default function ProjectDevelopmentView({
onClose={() => setResourceDocumentPreviewIdentity(null)}
/>
) : null}
+ {mode === 'resources' &&
+ !uiEditorRoute &&
+ selectedResource &&
+ resourceModelPreviewIdentity &&
+ resourceModelPreviewIdentity === selectedResourcePreviewIdentity ? (
+ setResourceModelPreviewIdentity(null)}
+ />
+ ) : null}
{resourcePanelOpen ? (
{pending.assetName}
- {pendingResourceEditKindLabel(pending.editKind)} ·{' '}
+ {pendingResourceEditKindLabel(pending)} ·{' '}
{pendingResourceEditCreatedAtLabel(pending.createdAt)}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts
index 071d125b3..f6c8a763e 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts
+++ b/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts
@@ -29,6 +29,12 @@ export type ProjectResourceCardPreviewKind =
| 'audio'
| 'code'
| 'document'
+ /** 引擎序列化文本资源(Cocos 的 `.prefab` / `.scene` / `.anim` / `.effect` …):只读结构预览。 */
+ | 'structured'
+ /** 引擎三维模型(`.glb` / `.gltf` / `.fbx`):整份字节交给模型渲染器出缩略图。 */
+ | 'model'
+ /** 客户端解不了的引擎容器(压缩纹理、Spine 二进制、PSD/EXR、裸 PCM…):只画类型卡,不读取。 */
+ | 'binary'
| 'version'
| 'placeholder';
@@ -58,6 +64,8 @@ export type ProjectResourceCardPreviewPayload = {
hasAlpha?: boolean;
sourceUrl?: string;
content?: string;
+ /** 原生侧完整校验过的 UI State 资产身份,不由前端解析 JSON 推断。 */
+ uiDesignAssetId?: string;
};
export type ProjectResourceCardPreviewTransportPayload = Omit<
@@ -205,12 +213,44 @@ export function projectResourceCardPreviewEvictionIdentities(
}
/** `read_local_project_media_preview` 只认这两个线上取值,与画布栏目轴无关。 */
-export type ProjectResourceMediaPreviewCategory = 'art' | 'audio';
+/**
+ * `read_local_project_media_preview` 的线上 `category` 入参:只按**文件读取分支**分三支,
+ * 与画布栏目轴无关 —— `model` 是引擎三维模型的读取分支(整份字节送进模型渲染器)。
+ */
+export type ProjectResourceMediaPreviewCategory = 'art' | 'audio' | 'model';
const rasterImageExtension = /\.(png|jpe?g|webp)$/iu;
const extendedImageExtension = /\.(gif|svg|avif|bmp)$/iu;
const videoExtension = /\.(mp4|webm|mov)$/iu;
+/**
+ * 引擎三维模型:卡面走模型渲染器(`.glb` / `.gltf` / `.fbx` 是三种能被通用三维库直接
+ * 加载的格式)。`.mesh` / `.skeleton` 是引擎自己的实例化数据,归入结构预览而不是这里。
+ */
+const modelExtension = /\.(glb|gltf|fbx)$/iu;
+
+/**
+ * 引擎序列化文本资源:Cocos 把场景、预制体、动画、材质、特效、图集配置都存成
+ * JSON / XML / 自定义 DSL 文本,卡面按「结构摘要」预览。
+ *
+ * `.plist` 同时用于图集与粒子,`.pac` 是自动图集配置,`.atlas` 是 Spine 图集文本描述;
+ * 它们可能是二进制变体,此时原生读取返回 `content: null`,卡面退化到类型卡。
+ */
+const structuredExtension =
+ /\.(scene|fire|prefab|anim|animation|animgraph|animgraphvari|animask|mtl|material|pmtl|effect|chunk|tmx|terrain|plist|labelatlas|atlas|fnt|pac|mesh|skeleton)$/iu;
+
+/**
+ * 客户端无法解码、也拿不到有意义结构的引擎容器:**不发起任何读取**,卡面直接画类型卡。
+ *
+ * - 压缩纹理 / 渲染纹理(`.texture` / `.cubemap` / `.rt`)只有引擎自己能解;
+ * - `.skel` / `.dbbin` 是 Spine、DragonBones 的二进制骨架;
+ * - `.psd` / `.exr` 当前没有可用解码器(`.exr` 的解码依赖在本仓库依赖源里取不到);
+ * - `.pcm` 是无头裸音频,浏览器拿到也没法解码播放;
+ * - `.bin` 是引擎的 BufferAsset,语义取决于使用方。
+ */
+const opaqueContainerExtension =
+ /\.(dbbin|bin|skel|texture|cubemap|rt|psd|znt|exr|pcm)$/iu;
+
/** Markdown 文档:卡面显示前几行,并做轻量标记清理。 */
const markdownExtension = /\.(md|markdown|mdx)$/iu;
/**
@@ -219,7 +259,8 @@ const markdownExtension = /\.(md|markdown|mdx)$/iu;
* 与 `resourceProjectionModel` 的 `gameCodeExtension` 是两份口径,刻意不复用:
* 那份用于**筛选与归属**,改动会波及画布栏目与计数;这份只决定**卡面怎么画**。
* 这里按用户口径把 `.yaml` / `.toml` / `.xml` / `.html` / `.css` / `.sql`
- * 一并算代码;JSON 规格属于文档预览。
+ * 一并算代码。JSON 留在文本读取通道,由原生内容识别区分普通 JSON 和 UI 设计,
+ * 不能像源码卡一样跳过预取;卡面不显示 JSON 正文。
*/
const cardCodeExtension =
/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|rs|py|go|java|kt|kts|cs|cpp|cc|cxx|c|h|hpp|swift|php|rb|lua|sh|bash|zsh|ps1|psm1|ya?ml|toml|xml|html?|css|scss|less|sql|graphql|gql|vue|svelte)$/iu;
@@ -236,6 +277,30 @@ export function projectResourcePathExtension(path: string): string | null {
return matched ? matched[1]!.toLowerCase() : null;
}
+export function isProjectResourceJson(
+ resource: Pick,
+): boolean {
+ return (
+ projectResourcePathExtension(resource.path) === 'json' ||
+ (projectResourcePathExtension(resource.path) === null &&
+ resource.mediaType.toLowerCase().includes('json'))
+ );
+}
+
+/** 读取结果必须仍属于当前卡片;元数据中的 UI 标签本身不能授予编辑入口。 */
+export function projectResourceJsonPresentation(
+ resource: ProjectResource,
+ preview: ProjectResourceCardPreviewState | null | undefined,
+): 'json' | 'ui-design' | null {
+ if (!isProjectResourceJson(resource)) return null;
+ return resource.manifestAssetId &&
+ preview?.status === 'loaded' &&
+ preview.preview.path === resource.path &&
+ preview.preview.uiDesignAssetId === resource.manifestAssetId
+ ? 'ui-design'
+ : 'json';
+}
+
/** 代码文件的类型标签(如 `.ts` → `TS`);不是代码文件时返回 `null`。 */
export function projectResourceCodeTypeLabel(path: string): string | null {
const trimmed = path.trim();
@@ -256,13 +321,28 @@ export function projectResourceCardPreviewKind(
return 'document';
}
// Markdown / 代码在扩展名这一层就分流,不再依赖上游登记类型:
- // 上游把 JSON 规格登记成「文档」,卡面按文档预览;代码文件按扩展名分流。
+ // JSON 使用文档读取通道完成原生语义识别;源码文件按扩展名分流。
if (markdownExtension.test(resource.path)) {
return 'document';
}
if (cardCodeExtension.test(resource.path)) {
return 'code';
}
+ /*
+ * 引擎资源的三个新分支必须排在 mediaType 兜底之前:
+ * `.pcm` 的 mediaType 是 `audio/*`、`.tga` 是 `image/*`、`.glb` 是 `model/*`,
+ * 一旦让通用分支先跑,就会出现「音频卡点播放却解不出来」「模型卡按图片解码失败」
+ * 这类看起来像坏掉的预览。
+ */
+ if (modelExtension.test(resource.path)) {
+ return 'model';
+ }
+ if (structuredExtension.test(resource.path)) {
+ return 'structured';
+ }
+ if (opaqueContainerExtension.test(resource.path)) {
+ return 'binary';
+ }
const kind = projectResourceDisplayKind(resource);
if (kind === 'code') {
return 'code';
@@ -308,7 +388,12 @@ export function projectResourceCardPreviewKind(
* - `plain-text`:显示前几行,不做标记清理。
*/
export type ProjectResourceCardPreviewVariant =
- 'markdown' | 'code' | 'plain-text' | null;
+ | 'markdown'
+ | 'code'
+ | 'plain-text'
+ /** 引擎序列化文本资源:卡面显示结构摘要,不显示原始正文。 */
+ | 'structured'
+ | null;
export function projectResourceCardPreviewVariant(
resource: ProjectResource,
@@ -322,6 +407,9 @@ export function projectResourceCardPreviewVariant(
if (cardCodeExtension.test(resource.path)) {
return 'code';
}
+ if (projectResourceCardPreviewKind(resource) === 'structured') {
+ return 'structured';
+ }
return projectResourceCardPreviewKind(resource) === 'document'
? 'plain-text'
: null;
@@ -335,7 +423,10 @@ export function projectResourceCardPreviewVariant(
export function projectResourceCardPreviewReadsContent(
resource: ProjectResource,
): boolean {
- return projectResourceCardPreviewVariant(resource) !== 'code';
+ return (
+ projectResourceCardPreviewVariant(resource) !== 'code' &&
+ projectResourceCardPreviewKind(resource) !== 'binary'
+ );
}
/**
@@ -351,7 +442,11 @@ export function projectResourceCardPreviewReadsContent(
export function projectResourceMediaPreviewCategory(
resource: ProjectResource,
): ProjectResourceMediaPreviewCategory {
- return projectResourceCardPreviewKind(resource) === 'audio' ? 'audio' : 'art';
+ const kind = projectResourceCardPreviewKind(resource);
+ if (kind === 'audio') {
+ return 'audio';
+ }
+ return kind === 'model' ? 'model' : 'art';
}
export function projectResourceCardPreviewIdentity(input: {
@@ -431,3 +526,89 @@ export function projectResourceDocumentPreviewText(
}
return lines.join('\n');
}
+
+/**
+ * 引擎(Cocos)序列化资源的**结构摘要**。
+ *
+ * Cocos 把场景、预制体、动画剪辑、材质都序列化成「`{ __type__: 'cc.Xxx', ... }` 对象的
+ * 数组」,直接显示原始 JSON 前几行对用户没有信息量(第一行永远是 `[` 和第一个组件的
+ * 大段属性)。这里只抽取三类稳定事实:根类型、节点数与类型数、以及动画时长/名称。
+ *
+ * 解析失败或不是这种形状时返回 `null`,调用方回退到「显示前几行文本」,不猜内容。
+ */
+export function projectResourceCocosStructureSummary(
+ content: string,
+): string | null {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(content);
+ } catch {
+ return null;
+ }
+ if (!Array.isArray(parsed)) {
+ return null;
+ }
+ const entries = parsed.filter(
+ (entry): entry is Record =>
+ typeof entry === 'object' && entry !== null && !Array.isArray(entry),
+ );
+ const typeCounts = new Map();
+ for (const entry of entries) {
+ const type = entry['__type__'];
+ if (typeof type === 'string' && type) {
+ typeCounts.set(type, (typeCounts.get(type) ?? 0) + 1);
+ }
+ }
+ const rootType = entries
+ .map((entry) => entry['__type__'])
+ .find(
+ (type): type is string => typeof type === 'string' && type.length > 0,
+ );
+ if (!rootType) {
+ return null;
+ }
+ const parts: string[] = [rootType];
+ const nodeCount = typeCounts.get('cc.Node') ?? 0;
+ if (nodeCount > 0) {
+ parts.push(`${nodeCount} 个节点`);
+ }
+ const duration = entries
+ .map((entry) => entry['_duration'])
+ .find((value): value is number => typeof value === 'number' && value > 0);
+ if (duration !== undefined) {
+ parts.push(`${duration.toFixed(2)} 秒`);
+ }
+ const name = entries
+ .map((entry) => entry['_name'])
+ .find(
+ (value): value is string => typeof value === 'string' && value.length > 0,
+ );
+ if (name) {
+ parts.push(name);
+ }
+ const assetReferences = entries.filter(
+ (entry) => entry['__uuid__'] !== undefined,
+ ).length;
+ if (assetReferences > 0) {
+ parts.push(`${assetReferences} 处资源引用`);
+ }
+ parts.push(`${typeCounts.size} 种类型`);
+ return parts.join(' · ');
+}
+
+/**
+ * 引擎序列化资源的卡面预览文本:优先结构摘要,其次前几行正文。
+ *
+ * `content` 为 `undefined`(原生读取判定的二进制变体)时返回空串,卡面画类型卡。
+ */
+export function projectResourceStructuredPreviewText(
+ content: string | undefined,
+): string {
+ if (content === undefined) {
+ return '';
+ }
+ return (
+ projectResourceCocosStructureSummary(content) ??
+ projectResourceDocumentPreviewText(content, false)
+ );
+}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts
index ae4bfe73e..4d739bec6 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts
+++ b/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts
@@ -224,6 +224,12 @@ export function defaultCharacterAnimationResourceName(
return `${resourceBaseName(resource) || '资源'}-角色动画`;
}
+/**
+ * 资源编辑提示词上限:与 Rust `resource_edit_prompt_max_chars`
+ * (`src-tauri/src/project/resource_editor.rs`)逐值同口径,UI、资源编辑提交、
+ * `agc_tools` MCP 工具层与客户端工具桥共用同一组数字。改这里必须同时改那里,
+ * 并按 kind 同步 `direct_tools_mcp.rs` 工具 schema 里的 `prompt.maxLength`。
+ */
export function resourceEditPromptMaxLength(
editKind: LocalProjectResourceEditKind,
) {
diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceModelScene.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceModelScene.ts
new file mode 100644
index 000000000..d81cfdd6a
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/project-development/resourceModelScene.ts
@@ -0,0 +1,115 @@
+import type * as ThreeTypes from 'three';
+
+/**
+ * 引擎三维模型的**共用场景能力**:加载、取景、释放。
+ *
+ * 卡片缩略图(`resourceModelThumbnail`)和交互式预览浮层(`ResourceModelViewer`)都走这里,
+ * 避免出现「缩略图能打开、放大后打不开」这种两套加载逻辑的分叉 —— 格式支持面、取景算法和
+ * 释放口径必须逐字一致。
+ */
+export type ResourceModelSource = {
+ /** 预览管线给出的 blob URL(模型整份字节)。 */
+ sourceUrl: string;
+ /** `model/gltf-binary`、`model/gltf+json` 或 `application/octet-stream`(FBX)。 */
+ mediaType: string;
+};
+
+export function isResourceModelFbx(source: ResourceModelSource) {
+ return (
+ source.mediaType === 'application/octet-stream' ||
+ source.sourceUrl.toLowerCase().includes('.fbx')
+ );
+}
+
+/**
+ * 按媒体类型加载模型。
+ *
+ * 只支持**自包含**的模型:`.glb`、单文件 `.gltf`(buffer 内嵌)、`.fbx`。多文件 glTF
+ * (`baseURI` 指向外部 `.bin` / 贴图)在 blob URL 下无法解析相对路径,加载失败由调用方
+ * 降级成类型卡,不做静默半渲染。
+ */
+export async function loadResourceModelObject(
+ source: ResourceModelSource,
+): Promise {
+ if (isResourceModelFbx(source)) {
+ const { FBXLoader } =
+ await import('three/examples/jsm/loaders/FBXLoader.js');
+ return new FBXLoader().loadAsync(source.sourceUrl);
+ }
+ const { GLTFLoader } =
+ await import('three/examples/jsm/loaders/GLTFLoader.js');
+ const gltf = await new GLTFLoader().loadAsync(source.sourceUrl);
+ if (!gltf.scene) {
+ throw new Error('模型内容为空');
+ }
+ return gltf.scene;
+}
+
+/**
+ * 把相机摆到能完整看见整个模型的位置,并返回模型中心与尺寸。
+ *
+ * 取景口径与 3D 软件一致:按包围盒最大边计算距离,留 35% 余量,从右上前方俯视。
+ * 缩略图与交互预览共用同一条公式,尺寸窗口不同也不会出现「一边看得见、一边看不见」。
+ */
+export function frameResourceModelInCamera(
+ THREE: typeof ThreeTypes,
+ object: ThreeTypes.Object3D,
+ camera: ThreeTypes.PerspectiveCamera,
+) {
+ const box = new THREE.Box3().setFromObject(object);
+ const size = box.getSize(new THREE.Vector3());
+ const center = box.getCenter(new THREE.Vector3());
+ const maxDimension = Math.max(size.x, size.y, size.z);
+ if (!Number.isFinite(maxDimension) || maxDimension <= 0) {
+ throw new Error('模型几何尺寸无效');
+ }
+ const distance =
+ (maxDimension / 2 / Math.tan((camera.fov * Math.PI) / 360)) * 1.35;
+ camera.position.set(
+ center.x + distance * 0.55,
+ center.y + distance * 0.42,
+ center.z + distance * 0.75,
+ );
+ camera.near = Math.max(distance / 500, 0.001);
+ camera.far = distance * 20;
+ camera.lookAt(center);
+ camera.updateProjectionMatrix();
+ return { center, size, maxDimension, distance };
+}
+
+/** 环境光照:半球光 + 一盏主光,保证没有材质贴图的模型也有体积感。 */
+export function addResourceModelLights(
+ THREE: typeof ThreeTypes,
+ scene: ThreeTypes.Scene,
+) {
+ scene.add(new THREE.HemisphereLight(0xffffff, 0x445566, 2.2));
+ const keyLight = new THREE.DirectionalLight(0xffffff, 2.0);
+ keyLight.position.set(2, 3, 4);
+ scene.add(keyLight);
+}
+
+/** 释放模型对象占用的几何、材质与贴图,避免反复开关预览时显存只涨不降。 */
+export function disposeResourceModelObject(object: ThreeTypes.Object3D) {
+ object.traverse((child) => {
+ const mesh = child as ThreeTypes.Mesh;
+ mesh.geometry?.dispose?.();
+ const material = mesh.material;
+ const materials = Array.isArray(material)
+ ? material
+ : material
+ ? [material]
+ : [];
+ for (const entry of materials) {
+ for (const value of Object.values(
+ entry as unknown as Record,
+ )) {
+ const texture = value as
+ { isTexture?: boolean; dispose?: () => void } | undefined;
+ if (texture?.isTexture && typeof texture.dispose === 'function') {
+ texture.dispose();
+ }
+ }
+ (entry as { dispose?: () => void }).dispose?.();
+ }
+ });
+}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceModelThumbnail.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceModelThumbnail.ts
new file mode 100644
index 000000000..4ab1e0ecd
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/project-development/resourceModelThumbnail.ts
@@ -0,0 +1,164 @@
+import type * as ThreeTypes from 'three';
+
+import {
+ addResourceModelLights,
+ disposeResourceModelObject,
+ frameResourceModelInCamera,
+ loadResourceModelObject,
+} from './resourceModelScene';
+
+/**
+ * 引擎三维模型缩略图渲染器(模块级单例)。
+ *
+ * 为什么是单例:一张资源画布上可能同时停着十几张模型卡,而浏览器能给一个页面的
+ * WebGL 上下文是**有上限**的(超出后最早的上下文会被丢弃,表现为"有些卡片莫名其妙变白")。
+ * 这里只保留**一个**离屏渲染器 + 一个串行队列:谁需要缩略图谁排队,渲染完把像素画进
+ * 卡片自己的 2D canvas。因此无论画布上有多少张模型卡,WebGL 上下文始终只有 1 个。
+ *
+ * 渲染是**一次性**的静态帧(不跑动画循环):卡片只是缩略图,让十几张卡各自跑一个
+ * requestAnimationFrame 循环会白烧 GPU 和电量。
+ */
+export type ResourceModelThumbnailRequest = {
+ /** 预览管线给出的 blob URL(模型整份字节)。 */
+ sourceUrl: string;
+ /** `model/gltf-binary`、`model/gltf+json` 或 `application/octet-stream`(FBX)。 */
+ mediaType: string;
+ width: number;
+ height: number;
+ /**
+ * 缩略图的**稳定身份**(资源身份 + 路径 + 字节数)。
+ *
+ * blob URL 每次读取都会变,拿它当缓存键会让「预览缓存淘汰后重读同一个模型」变成
+ * 一次重新解析 + 重新渲染;用稳定身份作键,重读只会重新取字节,缩略图直接命中缓存。
+ */
+ identity: string;
+};
+
+const THUMBNAIL_CACHE_LIMIT = 24;
+const thumbnailCache = new Map();
+
+type RendererBundle = {
+ renderer: ThreeTypes.WebGLRenderer;
+ scene: ThreeTypes.Scene;
+ camera: ThreeTypes.PerspectiveCamera;
+};
+
+let rendererPromise: Promise | null = null;
+let renderQueue: Promise = Promise.resolve();
+
+export function resourceModelThumbnailCacheSize() {
+ return thumbnailCache.size;
+}
+
+/**
+ * 缩略图缓存键:**不含 blob URL**。
+ *
+ * 预览缓存淘汰后同一份模型会被重新读取、拿到新的 blob URL;若把 URL 放进键里,
+ * 每一次重读都会变成一次重新解析 + 重新渲染。用稳定身份作键,重读只会重新取字节。
+ */
+export function resourceModelThumbnailCacheKey(
+ request: Pick<
+ ResourceModelThumbnailRequest,
+ 'mediaType' | 'identity' | 'width' | 'height'
+ >,
+) {
+ return `${request.mediaType}|${request.identity}|${request.width}x${request.height}`;
+}
+
+export function resourceModelThumbnailIdentity(input: {
+ resourceKey: string;
+ path: string;
+ byteLen?: number;
+}) {
+ return `${input.resourceKey}|${input.path}|${input.byteLen ?? 0}`;
+}
+
+function rememberThumbnail(cacheKey: string, dataUrl: string) {
+ thumbnailCache.delete(cacheKey);
+ thumbnailCache.set(cacheKey, dataUrl);
+ while (thumbnailCache.size > THUMBNAIL_CACHE_LIMIT) {
+ const oldest = thumbnailCache.keys().next().value;
+ if (oldest === undefined) {
+ break;
+ }
+ thumbnailCache.delete(oldest);
+ }
+}
+
+async function createRendererBundle(): Promise {
+ // 动态导入:三维渲染器只在真的出现模型卡时才加载,不进主包、不影响其它卡片的启动成本。
+ const THREE = await import('three');
+ const canvas = document.createElement('canvas');
+ const renderer = new THREE.WebGLRenderer({
+ canvas,
+ alpha: true,
+ antialias: true,
+ // 没有它,`toDataURL` 拿到的可能是被清空的缓冲(否则渲染后立刻被交换掉)。
+ preserveDrawingBuffer: true,
+ });
+ renderer.setClearColor(0x000000, 0);
+ const scene = new THREE.Scene();
+ const camera = new THREE.PerspectiveCamera(35, 1, 0.01, 10_000);
+ addResourceModelLights(THREE, scene);
+ return { renderer, scene, camera };
+}
+
+function rendererBundle() {
+ rendererPromise ??= createRendererBundle().catch((error: unknown) => {
+ // 失败不缓存:下一次卡片进入视口时还有机会(例如 WebGL 上下文被临时耗尽)。
+ rendererPromise = null;
+ throw error;
+ });
+ return rendererPromise;
+}
+
+async function renderOnce(
+ request: ResourceModelThumbnailRequest,
+): Promise {
+ const THREE = await import('three');
+ const { renderer, scene, camera } = await rendererBundle();
+ const width = Math.max(64, Math.round(request.width));
+ const height = Math.max(64, Math.round(request.height));
+ const object = await loadResourceModelObject(request);
+ try {
+ scene.add(object);
+ /**
+ * 相机宽高比必须跟着这次渲染的像素尺寸走。
+ *
+ * 这个相机是单例复用件(默认 `aspect = 1`),而卡片是宽扁的:不更新宽高比时,
+ * 方形投影会被塞进非方形的绘制缓冲,模型在卡面上就是**被拉伸**的 —— 与 3D 软件里
+ * 同一个模型的比例对不上。浮层里的交互相机已经在创建 / 改尺寸时设过,这里补齐缩略图。
+ */
+ camera.aspect = width / height;
+ frameResourceModelInCamera(THREE, object, camera);
+ renderer.setPixelRatio(1);
+ renderer.setSize(width, height, false);
+ renderer.render(scene, camera);
+ return renderer.domElement.toDataURL('image/png');
+ } finally {
+ scene.remove(object);
+ disposeResourceModelObject(object);
+ }
+}
+
+/**
+ * 取一张模型缩略图(同一份字节只渲染一次)。
+ *
+ * 队列是串行的:渲染器只有一个,两个模型同时渲染会互相覆盖同一个 canvas。
+ */
+export async function renderResourceModelThumbnail(
+ request: ResourceModelThumbnailRequest,
+): Promise {
+ const cacheKey = resourceModelThumbnailCacheKey(request);
+ const cached = thumbnailCache.get(cacheKey);
+ if (cached) {
+ rememberThumbnail(cacheKey, cached);
+ return cached;
+ }
+ const task = renderQueue.then(() => renderOnce(request));
+ // 队列本身不能因为某一张失败就断掉:失败向调用方抛,队列继续排下一个。
+ renderQueue = task.catch(() => undefined);
+ const dataUrl = await task;
+ rememberThumbnail(cacheKey, dataUrl);
+ return dataUrl;
+}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts
index 998c5b9f5..4e305c3b0 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts
+++ b/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts
@@ -74,6 +74,20 @@ export type ProjectResourceTypeLabel =
| 'Agent 回执'
| '项目版本'
| '游戏代码'
+ | '模型'
+ | '动画'
+ | '骨骼动画'
+ | '场景'
+ | '预制体'
+ | '瓦片地图'
+ | '材质'
+ | '特效'
+ | '图集'
+ | '位图字体'
+ | '纹理'
+ | '自动图集'
+ | '音频片段'
+ | '二进制'
| '未知';
const documentExtension =
@@ -82,6 +96,20 @@ const gameCodeExtension =
/\.(html?|css|scss|less|m?[jt]sx?|cjs|rs|py|go|java|kt|kts|c|cc|cpp|h|hpp|cs|swift|php|rb|lua|sh|bash|zsh|sql|graphql|gql|vue|svelte)$/iu;
const artExtension = /\.(png|jpe?g|webp|gif|svg|avif|bmp|mp4|webm|mov)$/iu;
const audioExtension = /\.(mp3|wav|ogg|m4a|aac|flac|opus)$/iu;
+/*
+ * 引擎(Cocos 3.8.8)资源扩展名。四组扩展名与原生侧的三份表
+ * (`bridge_project_file_class` / `agent_local_project_file_type` / `prompt_context_media_type`)
+ * 必须同步:少一个扩展名在这里,资源就会「登记得了、画布不显示」,而且没有任何报错。
+ */
+/** 三维模型与网格数据。 */
+const engineModelExtension = /\.(glb|gltf|fbx|mesh|skeleton)$/iu;
+/** 图像容器(浏览器解不了,原生侧转码成 PNG 后按图片显示)。 */
+const engineImageContainerExtension = /\.(tga|tif|tiff|hdr|exr|psd|znt)$/iu;
+/** 材质与特效:登记为 `code`,卡面另行按结构预览。 */
+const engineMaterialExtension = /\.(mtl|material|pmtl|effect|chunk)$/iu;
+/** 场景、动画、图集与容器:登记为 `document`,卡面按结构预览或类型卡。 */
+const engineStructuredExtension =
+ /\.(scene|fire|prefab|anim|animation|animgraph|animgraphvari|animask|tmx|terrain|plist|labelatlas|atlas|fnt|pac|dbbin|bin|skel|texture|cubemap|rt)$/iu;
const gameCodeKind =
/(?:^|[-_])(game-(?:entry|style|script)|code|source)(?:$|[-_])/iu;
const artKind =
@@ -132,10 +160,17 @@ export function projectedResourceKind(input: {
if (
normalizedMediaType.startsWith('image/') ||
normalizedMediaType.startsWith('video/') ||
- artExtension.test(normalizedPath)
+ artExtension.test(normalizedPath) ||
+ engineModelExtension.test(normalizedPath) ||
+ engineImageContainerExtension.test(normalizedPath)
) {
return 'art';
}
+ // 引擎材质 / 特效排在文档之前:它们的 mediaType 多为 `application/json`,
+ // 一旦先落到文档分支,画布分类就和登记时的 kind 对不上。
+ if (engineMaterialExtension.test(normalizedPath)) {
+ return 'code';
+ }
if (
normalizedMediaType === 'text/html' ||
normalizedMediaType === 'text/css' ||
@@ -149,7 +184,8 @@ export function projectedResourceKind(input: {
normalizedMediaType.includes('json') ||
normalizedMediaType.includes('yaml') ||
normalizedMediaType.startsWith('text/') ||
- documentExtension.test(normalizedPath)
+ documentExtension.test(normalizedPath) ||
+ engineStructuredExtension.test(normalizedPath)
) {
return 'document';
}
@@ -203,6 +239,55 @@ export function projectResourceTypeLabel(
if (subtype === 'agent-result') {
return 'Agent 回执';
}
+ /*
+ * 引擎资源的类型角标先按扩展名判定。
+ *
+ * 它们在 manifest 里复用的是既有 canonical kind(模型/场景 → `scene`,动画 →
+ * `character-animation`,材质/特效 → `code`,容器 → `document`),只用 kind 反推
+ * 会把这些资源一律说成「图片」「文档」或「游戏代码」,用户看不出这是什么。
+ */
+ if (/\.(glb|gltf|fbx|mesh)$/iu.test(path)) {
+ return '模型';
+ }
+ if (/\.(anim|animation|animgraph|animgraphvari|animask)$/iu.test(path)) {
+ return '动画';
+ }
+ if (/\.(skeleton|skel|dbbin)$/iu.test(path)) {
+ return '骨骼动画';
+ }
+ if (/\.(scene|fire|terrain)$/iu.test(path)) {
+ return '场景';
+ }
+ if (/\.prefab$/iu.test(path)) {
+ return '预制体';
+ }
+ if (/\.tmx$/iu.test(path)) {
+ return '瓦片地图';
+ }
+ if (/\.(mtl|material|pmtl)$/iu.test(path)) {
+ return '材质';
+ }
+ if (/\.(effect|chunk)$/iu.test(path)) {
+ return '特效';
+ }
+ if (/\.(plist|atlas|labelatlas)$/iu.test(path)) {
+ return '图集';
+ }
+ if (/\.fnt$/iu.test(path)) {
+ return '位图字体';
+ }
+ if (/\.(texture|cubemap|rt)$/iu.test(path)) {
+ return '纹理';
+ }
+ if (/\.pac$/iu.test(path)) {
+ return '自动图集';
+ }
+ if (/\.pcm$/iu.test(path)) {
+ return '音频片段';
+ }
+ if (/\.bin$/iu.test(path)) {
+ return '二进制';
+ }
if (kind === 'code') {
return '游戏代码';
}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts
index f175264ba..508a16e11 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts
+++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts
@@ -95,6 +95,12 @@ function resourceReadKindLabel(kind: ProjectResourceCardPreviewKind) {
if (kind === 'code') {
return '游戏代码';
}
+ if (kind === 'structured') {
+ return '引擎资源';
+ }
+ if (kind === 'model') {
+ return '模型';
+ }
return '资源';
}
@@ -167,6 +173,7 @@ function materializeProjectResourceCardPreview(
pixelHeight: transport.pixelHeight,
hasAlpha: transport.hasAlpha,
content: transport.content,
+ uiDesignAssetId: transport.uiDesignAssetId,
},
retainedBytes:
transport.content === undefined
@@ -531,6 +538,22 @@ export function useProjectResourceCardPreviews(input: {
},
);
}
+ /**
+ * 引擎序列化资源走独立读取:准入白名单与服务 UI 编辑器的那份**分开**,
+ * 且允许「不是 UTF-8」的文件正常返回 `content: null`(卡面降级成类型卡),
+ * 而不是把它报成一次预览失败。
+ */
+ if (kind === 'structured') {
+ return invoke(
+ 'read_local_project_structured_preview',
+ {
+ projectPath: input.projectPath,
+ relativePath: job.resource.path,
+ scopeId: job.scopeId,
+ requestId: createProjectResourcePreviewRequestId(),
+ },
+ );
+ }
return invoke(
'read_local_project_media_preview',
{
@@ -642,6 +665,9 @@ export function useProjectResourceCardPreviews(input: {
if (
kind === 'version' ||
kind === 'placeholder' ||
+ // 类型卡不读字节:客户端解不了的容器(压缩纹理、Spine 二进制、PSD/EXR、裸 PCM)
+ // 读进来也画不出东西,占读取槽只会挤掉真正能出图的卡片。
+ kind === 'binary' ||
(kind === 'audio' && reason !== 'play')
) {
return;
@@ -1089,7 +1115,12 @@ export function useProjectResourceCardPreviews(input: {
continue;
}
const kind = projectResourceCardPreviewKind(resource);
- if (kind === 'version' || kind === 'placeholder' || kind === 'audio') {
+ if (
+ kind === 'version' ||
+ kind === 'placeholder' ||
+ kind === 'audio' ||
+ kind === 'binary'
+ ) {
continue;
}
const element = elementsByIdentity.get(identity);
@@ -1121,7 +1152,8 @@ export function useProjectResourceCardPreviews(input: {
identity &&
kind !== 'version' &&
kind !== 'placeholder' &&
- kind !== 'audio'
+ kind !== 'audio' &&
+ kind !== 'binary'
) {
requestPreview(resource, identity, 'visible');
fallbackRequested += 1;
diff --git a/apps/ai-game-creator-shell/src/view/template-library/TemplateCard.tsx b/apps/ai-game-creator-shell/src/view/template-library/TemplateCard.tsx
new file mode 100644
index 000000000..c484074ba
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/template-library/TemplateCard.tsx
@@ -0,0 +1,139 @@
+import { BadgeCheck, Download, Loader2, Play } from 'lucide-react';
+import { memo } from 'react';
+
+import type { GameTemplateEntry } from '../../features/template-library/templateLibraryModel';
+import {
+ formatGameTemplateSize,
+ needsTemplateDownload,
+ templateRuntimeLabel,
+} from '../../features/template-library/templateLibraryModel';
+
+export type TemplateCardActions = {
+ busyKind: 'download' | 'create' | null;
+ busyTemplateId: string | null;
+ onDownload: (template: GameTemplateEntry) => void;
+ onUse: (template: GameTemplateEntry) => void;
+};
+
+/**
+ * 虚拟列表里的单个模板卡片:高度由行高契约固定(封面 16:9 + 固定文字区),
+ * 用 memo 包住,滚动时只重渲染可视区域内的少量卡片。
+ */
+function TemplateCardView({
+ template,
+ busyKind,
+ busyTemplateId,
+ onDownload,
+ onUse,
+}: { template: GameTemplateEntry } & TemplateCardActions) {
+ const busy = busyTemplateId === template.id;
+ const needsDownload = needsTemplateDownload(template);
+ const busyLabel =
+ busy && busyKind === 'download'
+ ? '正在下载模板'
+ : busy && busyKind === 'create'
+ ? '正在创建项目'
+ : '';
+ const meta = [
+ templateRuntimeLabel(template.runtime),
+ template.engine,
+ `v${template.templateVersion}`,
+ formatGameTemplateSize(template.zipSizeBytes),
+ ]
+ .filter((value) => value && value.trim())
+ .join(' · ');
+
+ return (
+
+
+

+ {template.installed ? (
+
+
+ 已下载
+
+ ) : null}
+
+
+
+ {template.title}
+
+
+ {meta}
+
+ {template.summary ? (
+
+ {template.summary}
+
+ ) : null}
+ {template.tags.length > 0 ? (
+
+ {template.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ ) : null}
+
+
+ {/* 已下载且版本一致时不再提供下载入口;只有缺包或版本落后才显示(落后时按「更新」)。 */}
+ {needsDownload ? (
+
+ ) : null}
+ {busyLabel ? (
+
+ {busyLabel}
+
+ ) : null}
+
+
+
+ );
+}
+
+export const TemplateCard = memo(TemplateCardView);
diff --git a/apps/ai-game-creator-shell/src/view/template-library/index.tsx b/apps/ai-game-creator-shell/src/view/template-library/index.tsx
new file mode 100644
index 000000000..37616da12
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/template-library/index.tsx
@@ -0,0 +1,430 @@
+import { PlatformRuntimeStatusToast } from '@genarrative/shared/components';
+import {
+ ArrowLeft,
+ Loader2,
+ RefreshCw,
+ Search,
+ SearchX,
+ SlidersHorizontal,
+} from 'lucide-react';
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
+import { FixedSizeGrid, type GridChildComponentProps } from 'react-window';
+
+import {
+ buildTemplateRows,
+ computeTemplateGridLayout,
+ TEMPLATE_CARD_GAP,
+ TEMPLATE_GRID_OVERSCAN_ROWS,
+ templateGridItemKey,
+} from '../../features/template-library/templateLibraryGrid';
+import type { GameTemplateEntry } from '../../features/template-library/templateLibraryModel';
+import { templateRuntimeLabel } from '../../features/template-library/templateLibraryModel';
+import type { TemplateLibraryController } from '../../features/template-library/useTemplateLibrary';
+import { TemplateCard, type TemplateCardActions } from './TemplateCard';
+
+type TemplateLibraryViewProps = {
+ controller: TemplateLibraryController;
+ onBack: () => void;
+};
+
+const TEMPLATE_LIBRARY_TOAST_MILLIS = 2600;
+
+/**
+ * 模板库提示统一走浮层 toast:下载完成、开始建项目这类过程提示不再占用页面内位置,
+ * 页面里只保留可操作的错误与空态。
+ */
+function TemplateLibraryToast({
+ message,
+ onDismiss,
+}: {
+ message: string;
+ onDismiss: () => void;
+}) {
+ useEffect(() => {
+ if (!message) {
+ return;
+ }
+ const timer = window.setTimeout(onDismiss, TEMPLATE_LIBRARY_TOAST_MILLIS);
+ return () => {
+ window.clearTimeout(timer);
+ };
+ }, [message, onDismiss]);
+
+ if (!message) {
+ return null;
+ }
+ return createPortal(
+ ,
+ document.body,
+ );
+}
+
+const chipClass =
+ 'cursor-pointer rounded-full border border-(--platform-subpanel-border) bg-transparent px-2.5 py-1 text-[11px] text-(--platform-text-soft) transition hover:border-(--platform-warm-text) hover:text-(--platform-warm-text)';
+const activeChipClass =
+ 'cursor-pointer rounded-full border border-(--platform-warm-text) bg-transparent px-2.5 py-1 text-[11px] text-(--platform-warm-text)';
+
+type TemplateGridCellData = TemplateCardActions & {
+ rows: Array>;
+};
+
+function TemplateGridCell({
+ columnIndex,
+ rowIndex,
+ style,
+ data,
+}: GridChildComponentProps) {
+ const template = data.rows[rowIndex]?.[columnIndex] ?? null;
+ if (!template) {
+ return ;
+ }
+ return (
+
+
+
+ );
+}
+
+export default function TemplateLibraryView({
+ controller,
+ onBack,
+}: TemplateLibraryViewProps) {
+ const {
+ status,
+ error,
+ notice,
+ templates,
+ visibleTemplates,
+ tagOptions,
+ runtimeOptions,
+ installedCount,
+ filters,
+ filtersActive,
+ setQuery,
+ selectRuntime,
+ toggleTag,
+ setInstalledOnly,
+ clearFilters,
+ busyTemplateId,
+ busyKind,
+ refresh,
+ downloadTemplate,
+ createProjectFromTemplate,
+ clearNotice,
+ } = controller;
+
+ const gridRef = useRef(null);
+ const viewportRef = useRef(null);
+ const sectionRef = useRef(null);
+ const [sectionHeight, setSectionHeight] = useState(null);
+ const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 });
+
+ const showEmptyLibrary = status === 'ready' && templates.length === 0;
+ const showNoMatch =
+ status === 'ready' && templates.length > 0 && visibleTemplates.length === 0;
+ const showGrid = status === 'ready' && visibleTemplates.length > 0;
+
+ /**
+ * 页面高度按**父级实测高度**定,不用百分比也不用 100vh。
+ *
+ * 外壳样式 `.launcher-main > .platform-theme { height: 100% }` 特异性高于 Tailwind
+ * 工具类,而它的百分比在 `.launcher-shell { min-height: 100vh }` 这条链上是不定高,
+ * 页面会退化成内容高度(虚拟网格视口高度 0、卡片区空白);`100vh` 又比真实舞台高
+ * 一个标题栏(窗口 100vh=800、舞台 750),底部会被裁掉。这里直接量父级。
+ */
+ useEffect(() => {
+ const section = sectionRef.current;
+ const parent = section?.parentElement;
+ if (!section || !parent) {
+ return;
+ }
+ const apply = () => {
+ const height = Math.round(parent.getBoundingClientRect().height);
+ if (height > 0) {
+ setSectionHeight((current) => (current === height ? current : height));
+ }
+ };
+ apply();
+ if (typeof ResizeObserver === 'undefined') {
+ window.addEventListener('resize', apply);
+ return () => window.removeEventListener('resize', apply);
+ }
+ const observer = new ResizeObserver(apply);
+ observer.observe(parent);
+ return () => observer.disconnect();
+ }, []);
+
+ useEffect(() => {
+ const element = viewportRef.current;
+ if (!element) {
+ return;
+ }
+ const measure = () => {
+ const rect = element.getBoundingClientRect();
+ const width = Math.round(rect.width);
+ const height = Math.round(rect.height);
+ setViewportSize((current) =>
+ current.width === width && current.height === height
+ ? current
+ : { width, height },
+ );
+ };
+ measure();
+ if (typeof ResizeObserver === 'undefined') {
+ window.addEventListener('resize', measure);
+ return () => window.removeEventListener('resize', measure);
+ }
+ const observer = new ResizeObserver(measure);
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, [showGrid]);
+
+ const layout = useMemo(
+ () =>
+ computeTemplateGridLayout({
+ containerWidth: viewportSize.width,
+ itemCount: visibleTemplates.length,
+ }),
+ [viewportSize.width, visibleTemplates.length],
+ );
+ const rows = useMemo(
+ () => buildTemplateRows(visibleTemplates, layout.columnCount),
+ [visibleTemplates, layout.columnCount],
+ );
+
+ // 换筛选条件回到列表顶部:否则筛选后条目变少会把视口留在空白处,看起来像“卡住”。
+ useEffect(() => {
+ gridRef.current?.scrollTo({ scrollTop: 0 });
+ }, [
+ filters.query,
+ filters.tags,
+ filters.runtime,
+ filters.installedOnly,
+ layout.columnCount,
+ ]);
+
+ const handleDownload = useMemo(
+ () => (template: GameTemplateEntry) => {
+ void downloadTemplate(template).catch(() => undefined);
+ },
+ [downloadTemplate],
+ );
+ const handleUse = useMemo(
+ () => (template: GameTemplateEntry) => {
+ void createProjectFromTemplate(template).catch(() => undefined);
+ },
+ [createProjectFromTemplate],
+ );
+ const cellData = useMemo(
+ () => ({
+ rows,
+ busyKind,
+ busyTemplateId,
+ onDownload: handleDownload,
+ onUse: handleUse,
+ }),
+ [rows, busyKind, busyTemplateId, handleDownload, handleUse],
+ );
+
+ return (
+
+
+
+
+
+ 模板库
+
+
+ 共 {templates.length} 个模板 · 已下载 {installedCount} 个
+
+
+
+
+
+ {/* 标签/运行时筛选区可独立滚动:标签数量随库量增长时不会把卡片区挤出窗口。 */}
+
+
+
+
+ {filtersActive ? (
+
+ ) : null}
+
+ {runtimeOptions.length > 0 ? (
+
+
+
+ 运行时
+
+ {runtimeOptions.map((runtime) => (
+
+ ))}
+
+ ) : null}
+ {tagOptions.length > 0 ? (
+
+
+ 标签
+
+ {tagOptions.map((tag) => (
+
+ ))}
+
+ ) : null}
+
+
+
+ {error ? (
+
+ {error}
+
+
+ ) : null}
+
+ {status === 'loading' && templates.length === 0 ? (
+
+ 正在读取模板库…
+
+ ) : null}
+ {showEmptyLibrary ? (
+
+ 模板库暂时还没有可用的模板。
+
+ ) : null}
+ {showNoMatch ? (
+
+
+ 没有符合当前筛选的模板
+
+
+ ) : null}
+
+ {showGrid ? (
+
+ {viewportSize.width > 0 && viewportSize.height > 0 ? (
+
+ templateGridItemKey(rowIndex, columnIndex)
+ }
+ >
+ {TemplateGridCell}
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/cover.svg b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/cover.svg
new file mode 100644
index 000000000..48439503b
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/cover.svg
@@ -0,0 +1,13 @@
+
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/meta.json b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/meta.json
new file mode 100644
index 000000000..5c3bdbb8e
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/meta.json
@@ -0,0 +1,18 @@
+{
+ "id": "blank-2d-canvas",
+ "title": "空白二维画布工程",
+ "summary": "原生 Canvas 二维空白工程:自适应画布、按设备像素比缩放与 requestAnimationFrame 主循环已就绪。",
+ "tags": [
+ "空白",
+ "起步工程",
+ "2d",
+ "canvas"
+ ],
+ "runtime": "html",
+ "engine": "canvas",
+ "engineVersion": "",
+ "templateVersion": "0.1.0",
+ "entry": "game/index.html",
+ "coverWidth": 960,
+ "coverHeight": 540
+}
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/index.html b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/index.html
new file mode 100644
index 000000000..0aa1ef189
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/index.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+ Genarrative Game Draft
+
+
+
+
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/main.js b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/main.js
new file mode 100644
index 000000000..bfdce3f80
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/main.js
@@ -0,0 +1,30 @@
+const canvas = document.querySelector('#stage');
+const context = canvas.getContext('2d');
+let viewport = { width: 0, height: 0 };
+
+function resize() {
+ const ratio = window.devicePixelRatio || 1;
+ viewport = { width: window.innerWidth, height: window.innerHeight };
+ canvas.width = Math.floor(viewport.width * ratio);
+ canvas.height = Math.floor(viewport.height * ratio);
+ context.setTransform(ratio, 0, 0, ratio, 0, 0);
+}
+
+function update(_deltaSeconds) {}
+
+function render() {
+ context.clearRect(0, 0, viewport.width, viewport.height);
+}
+
+let previous = performance.now();
+function frame(now) {
+ const deltaSeconds = Math.min((now - previous) / 1000, 0.1);
+ previous = now;
+ update(deltaSeconds);
+ render();
+ requestAnimationFrame(frame);
+}
+
+window.addEventListener('resize', resize);
+resize();
+requestAnimationFrame(frame);
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/package.json b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/package.json
new file mode 100644
index 000000000..6352b75bc
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "agc-game",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "vite build",
+ "dev": "vite"
+ },
+ "devDependencies": {
+ "vite": "^6.2.0"
+ }
+}
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/style.css b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/style.css
new file mode 100644
index 000000000..f1256b925
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/style.css
@@ -0,0 +1,3 @@
+body { margin: 0; overflow: hidden; background: #0b1512; }
+main { display: block; width: 100vw; height: 100vh; }
+canvas { display: block; width: 100%; height: 100%; }
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/vite.config.js b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/vite.config.js
new file mode 100644
index 000000000..d8e631e07
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ root: '.',
+ base: './',
+ build: { outDir: 'dist', emptyOutDir: true },
+});
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/cover.svg b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/cover.svg
new file mode 100644
index 000000000..8da38262d
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/cover.svg
@@ -0,0 +1,13 @@
+
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/meta.json b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/meta.json
new file mode 100644
index 000000000..f099612e4
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/meta.json
@@ -0,0 +1,18 @@
+{
+ "id": "blank-3d-scene",
+ "title": "空白三维场景工程",
+ "summary": "Three.js 空白场景:空场景、透视相机、网格地面与自适应视口已就绪,适合从零搭三维玩法。",
+ "tags": [
+ "空白",
+ "起步工程",
+ "3d",
+ "three.js"
+ ],
+ "runtime": "html",
+ "engine": "three.js",
+ "engineVersion": "0.180.0",
+ "templateVersion": "0.1.0",
+ "entry": "game/index.html",
+ "coverWidth": 960,
+ "coverHeight": 540
+}
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/index.html b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/index.html
new file mode 100644
index 000000000..d01aa860a
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/index.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+ Genarrative Game Draft
+
+
+
+
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/main.js b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/main.js
new file mode 100644
index 000000000..73c37324b
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/main.js
@@ -0,0 +1,38 @@
+import * as THREE from 'three';
+
+const container = document.querySelector('#game');
+const scene = new THREE.Scene();
+scene.background = new THREE.Color(0x070b14);
+
+const camera = new THREE.PerspectiveCamera(
+ 60,
+ window.innerWidth / window.innerHeight,
+ 0.1,
+ 200,
+);
+camera.position.set(0, 3, 6);
+camera.lookAt(0, 0, 0);
+
+const renderer = new THREE.WebGLRenderer({ antialias: true });
+renderer.setPixelRatio(window.devicePixelRatio);
+renderer.setSize(window.innerWidth, window.innerHeight);
+container.append(renderer.domElement);
+
+const light = new THREE.DirectionalLight(0xffffff, 1.2);
+light.position.set(4, 8, 6);
+scene.add(light, new THREE.AmbientLight(0x8899ff, 0.5));
+
+const grid = new THREE.GridHelper(20, 20, 0x3b4a6b, 0x1d2739);
+scene.add(grid);
+
+window.addEventListener('resize', () => {
+ camera.aspect = window.innerWidth / window.innerHeight;
+ camera.updateProjectionMatrix();
+ renderer.setSize(window.innerWidth, window.innerHeight);
+});
+
+function render() {
+ renderer.render(scene, camera);
+ requestAnimationFrame(render);
+}
+render();
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/package.json b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/package.json
new file mode 100644
index 000000000..496deca74
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "agc-game",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "vite build",
+ "dev": "vite"
+ },
+ "dependencies": {
+ "three": "^0.180.0"
+ },
+ "devDependencies": {
+ "vite": "^6.2.0"
+ }
+}
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/style.css b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/style.css
new file mode 100644
index 000000000..a3c5a9c8c
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/style.css
@@ -0,0 +1,3 @@
+body { margin: 0; overflow: hidden; background: #05070d; }
+main { display: block; width: 100vw; height: 100vh; }
+canvas { display: block; }
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/vite.config.js b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/vite.config.js
new file mode 100644
index 000000000..d8e631e07
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ root: '.',
+ base: './',
+ build: { outDir: 'dist', emptyOutDir: true },
+});
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-web/cover.svg b/apps/ai-game-creator-shell/template-library/v1/blank-web/cover.svg
new file mode 100644
index 000000000..ac7c02d1b
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-web/cover.svg
@@ -0,0 +1,13 @@
+
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-web/meta.json b/apps/ai-game-creator-shell/template-library/v1/blank-web/meta.json
new file mode 100644
index 000000000..a92652137
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-web/meta.json
@@ -0,0 +1,18 @@
+{
+ "id": "blank-web",
+ "title": "空白网页工程",
+ "summary": "最小网页工程(HTML + CSS + 原生 JS + Vite),没有任何引擎依赖,适合从零写玩法。",
+ "tags": [
+ "空白",
+ "起步工程",
+ "网页",
+ "原生"
+ ],
+ "runtime": "html",
+ "engine": "none",
+ "engineVersion": "",
+ "templateVersion": "0.1.0",
+ "entry": "game/index.html",
+ "coverWidth": 960,
+ "coverHeight": 540
+}
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/index.html b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/index.html
new file mode 100644
index 000000000..d01aa860a
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/index.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+ Genarrative Game Draft
+
+
+
+
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/main.js b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/main.js
new file mode 100644
index 000000000..f2307680d
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/main.js
@@ -0,0 +1,2 @@
+const root = document.querySelector('#game');
+root.textContent = '';
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/package.json b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/package.json
new file mode 100644
index 000000000..6352b75bc
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "agc-game",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "vite build",
+ "dev": "vite"
+ },
+ "devDependencies": {
+ "vite": "^6.2.0"
+ }
+}
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/style.css b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/style.css
new file mode 100644
index 000000000..e40ed7036
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/style.css
@@ -0,0 +1,2 @@
+body { margin: 0; background: #0f1218; color: #e8eefc; font: 16px system-ui, sans-serif; }
+main { display: grid; min-height: 100vh; place-items: center; }
diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/vite.config.js b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/vite.config.js
new file mode 100644
index 000000000..d8e631e07
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ root: '.',
+ base: './',
+ build: { outDir: 'dist', emptyOutDir: true },
+});
diff --git a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/cover.svg b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/cover.svg
new file mode 100644
index 000000000..0d00d4b79
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/cover.svg
@@ -0,0 +1,13 @@
+
diff --git a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/meta.json b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/meta.json
new file mode 100644
index 000000000..71f52ba90
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/meta.json
@@ -0,0 +1,18 @@
+{
+ "id": "phaser-2d-starter",
+ "title": "Phaser 2D 起步工程",
+ "summary": "AGC 新建项目使用的默认二维起步工程(Phaser 4 + Vite),解压后即为可运行项目根。",
+ "tags": [
+ "起步工程",
+ "2d",
+ "phaser",
+ "像素"
+ ],
+ "runtime": "html",
+ "engine": "phaser",
+ "engineVersion": "4.2.1",
+ "templateVersion": "0.1.0",
+ "entry": "game/index.html",
+ "coverWidth": 960,
+ "coverHeight": 540
+}
diff --git a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/game.js b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/game.js
new file mode 100644
index 000000000..a77ba6be9
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/game.js
@@ -0,0 +1,21 @@
+import './style.css';
+
+import Phaser from 'phaser';
+
+class PlaceholderScene extends Phaser.Scene {
+ create() {
+ this.add.text(
+ 24,
+ 24,
+ '还没有生成游戏。回到聊天输入创意并确认生成后,这里会写入可试玩原型。',
+ );
+ }
+}
+
+new Phaser.Game({
+ type: Phaser.AUTO,
+ width: 720,
+ height: 420,
+ parent: 'game',
+ scene: PlaceholderScene,
+});
diff --git a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/index.html b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/index.html
new file mode 100644
index 000000000..3447d5909
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/index.html
@@ -0,0 +1,9 @@
+
+
+
+
+
+ Genarrative Game Draft
+
+
+
diff --git a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/package.json b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/package.json
new file mode 100644
index 000000000..6504e9aee
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "agc-game",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "vite build",
+ "dev": "vite"
+ },
+ "dependencies": {
+ "phaser": "4.2.1"
+ },
+ "devDependencies": {
+ "vite": "^6.2.0"
+ }
+}
diff --git a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/style.css b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/style.css
new file mode 100644
index 000000000..8b3c907f4
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/style.css
@@ -0,0 +1,2 @@
+body { margin: 0; display: grid; min-height: 100vh; place-items: center; background: #101827; color: #d9e7ff; font: 16px system-ui, sans-serif; }
+main { width: min(720px, calc(100vw - 32px)); }
diff --git a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/vite.config.js b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/vite.config.js
new file mode 100644
index 000000000..d8e631e07
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ root: '.',
+ base: './',
+ build: { outDir: 'dist', emptyOutDir: true },
+});
diff --git a/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/cover.svg b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/cover.svg
new file mode 100644
index 000000000..f08503a3f
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/cover.svg
@@ -0,0 +1,13 @@
+
diff --git a/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/meta.json b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/meta.json
new file mode 100644
index 000000000..2b492a71d
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/meta.json
@@ -0,0 +1,18 @@
+{
+ "id": "threejs-3d-starter",
+ "title": "Three.js 3D 起步工程",
+ "summary": "网页三维起步工程(Three.js + Vite),自带可旋转立方体场景、方向光与自适应视口。",
+ "tags": [
+ "起步工程",
+ "3d",
+ "three.js",
+ "网页"
+ ],
+ "runtime": "html",
+ "engine": "three.js",
+ "engineVersion": "0.180.0",
+ "templateVersion": "0.1.0",
+ "entry": "game/index.html",
+ "coverWidth": 960,
+ "coverHeight": 540
+}
diff --git a/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/index.html b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/index.html
new file mode 100644
index 000000000..d01aa860a
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/index.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+ Genarrative Game Draft
+
+
+
+
diff --git a/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/main.js b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/main.js
new file mode 100644
index 000000000..211f285ef
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/main.js
@@ -0,0 +1,43 @@
+import * as THREE from 'three';
+
+const container = document.querySelector('#game');
+const scene = new THREE.Scene();
+scene.background = new THREE.Color(0x0b1120);
+
+const camera = new THREE.PerspectiveCamera(
+ 60,
+ window.innerWidth / window.innerHeight,
+ 0.1,
+ 100,
+);
+camera.position.set(2.4, 1.8, 3.2);
+camera.lookAt(0, 0, 0);
+
+const renderer = new THREE.WebGLRenderer({ antialias: true });
+renderer.setPixelRatio(window.devicePixelRatio);
+renderer.setSize(window.innerWidth, window.innerHeight);
+container.append(renderer.domElement);
+
+const light = new THREE.DirectionalLight(0xffffff, 1.4);
+light.position.set(3, 5, 4);
+scene.add(light, new THREE.AmbientLight(0x8899ff, 0.6));
+
+const cube = new THREE.Mesh(
+ new THREE.BoxGeometry(1, 1, 1),
+ new THREE.MeshStandardMaterial({ color: 0xe0a060 }),
+);
+scene.add(cube);
+
+window.addEventListener('resize', () => {
+ camera.aspect = window.innerWidth / window.innerHeight;
+ camera.updateProjectionMatrix();
+ renderer.setSize(window.innerWidth, window.innerHeight);
+});
+
+function tick() {
+ cube.rotation.y += 0.01;
+ cube.rotation.x += 0.004;
+ renderer.render(scene, camera);
+ requestAnimationFrame(tick);
+}
+tick();
diff --git a/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/package.json b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/package.json
new file mode 100644
index 000000000..496deca74
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "agc-game",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "vite build",
+ "dev": "vite"
+ },
+ "dependencies": {
+ "three": "^0.180.0"
+ },
+ "devDependencies": {
+ "vite": "^6.2.0"
+ }
+}
diff --git a/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/style.css b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/style.css
new file mode 100644
index 000000000..b25e3e5ae
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/style.css
@@ -0,0 +1,3 @@
+body { margin: 0; overflow: hidden; background: #06080f; }
+main { display: block; width: 100vw; height: 100vh; }
+canvas { display: block; }
diff --git a/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/vite.config.js b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/vite.config.js
new file mode 100644
index 000000000..d8e631e07
--- /dev/null
+++ b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ root: '.',
+ base: './',
+ build: { outDir: 'dist', emptyOutDir: true },
+});
diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
index 83cf3dd1f..03954056b 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
@@ -148,24 +148,98 @@ export function registerClientHomeTests() {
);
});
- it('shows the built-in inspiration masonry gallery and opens a dismissible preview without requesting the retired feed', async () => {
+ it('shows the home template recommendations and opens the library without creating a project', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ const templateLibrarySnapshot = {
+ schemaVersion: 'game-template-library.v1',
+ library: 'genarrative-official',
+ libraryVersion: 3,
+ updatedAt: '2026-09-17T00:00:00Z',
+ fetchedAtMillis: 1,
+ source: 'remote',
+ templates: [
+ {
+ id: 'lane-defense',
+ title: '星际防线',
+ summary: '塔防原型',
+ tags: ['塔防'],
+ runtime: 'phaser',
+ engine: 'Phaser',
+ engineVersion: '4.2.1',
+ templateVersion: '1.0.0',
+ updatedAt: '2026-09-17T00:00:00Z',
+ entry: 'index.html',
+ zipUrl:
+ 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/templates/lane-defense.zip',
+ zipSizeBytes: 2048,
+ zipSha256: 'a'.repeat(64),
+ coverUrl:
+ 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/templates/lane-defense.png',
+ coverWidth: 320,
+ coverHeight: 180,
+ installed: true,
+ installedVersion: '1.0.0',
+ installedAtMillis: 2,
+ },
+ {
+ id: 'cozy-farm',
+ title: '悠然农场',
+ summary: '经营原型',
+ tags: ['经营'],
+ runtime: 'phaser',
+ engine: 'Phaser',
+ engineVersion: '4.2.1',
+ templateVersion: '1.0.0',
+ updatedAt: '2026-09-17T00:00:00Z',
+ entry: 'index.html',
+ zipUrl:
+ 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/templates/cozy-farm.zip',
+ zipSizeBytes: 4096,
+ zipSha256: 'b'.repeat(64),
+ coverUrl:
+ 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/templates/cozy-farm.png',
+ coverWidth: 320,
+ coverHeight: 180,
+ installed: false,
+ installedVersion: null,
+ installedAtMillis: null,
+ },
+ ],
+ };
+ const invoke = vi.fn(async (command: string) => {
+ if (command === 'read_game_creator_app_config') {
+ return { config: { selectedModelId: 'quality' } };
+ }
+ if (command === 'fetch_game_template_library') {
+ return templateLibrarySnapshot;
+ }
+ throw new Error(`unexpected invoke ${command}`);
+ });
+ window.__TAURI__ = { core: { invoke } };
renderLauncherAt('/?launcher');
- const inspiration = screen.getByLabelText('灵感推荐');
- const inspirationImages = within(inspiration).getAllByRole('button', {
- name: /查看灵感图片/,
+ // 首页推荐位只展示封面、标题、运行时与已下载徽标,本机灵感图库已随模板库上线删除。
+ const recommendations = await screen.findByLabelText('模板库推荐');
+ const recommendationCards = within(recommendations).getAllByRole('button', {
+ name: /^查看模板 /u,
});
- expect(inspirationImages.length).toBeGreaterThan(0);
- expect(inspiration.closest('.overflow-y-auto')).not.toBeNull();
- expect(screen.queryByText('暂无灵感')).toBeNull();
+ expect(recommendationCards.length).toBe(2);
+ expect(within(recommendationCards[0]!).getByText('已下载')).not.toBeNull();
- fireEvent.click(inspirationImages[0]!);
- const preview = screen.getByRole('dialog', { name: '查看灵感图片' });
- fireEvent.click(screen.getByAltText('放大的灵感图片'));
- expect(screen.getByRole('dialog', { name: '查看灵感图片' })).not.toBeNull();
- fireEvent.click(preview);
- expect(screen.queryByRole('dialog', { name: '查看灵感图片' })).toBeNull();
+ fireEvent.click(recommendationCards[0]!);
+
+ // 点击推荐位只进入模板库页面,不在首页直接下载或创建项目。
+ const librarySummary = await screen.findByText('共 2 个模板 · 已下载 1 个');
+ expect(librarySummary).not.toBeNull();
+ expect(screen.getByRole('button', { name: '返回' })).not.toBeNull();
+ expect(screen.queryByLabelText('模板库推荐')).toBeNull();
+ expect(
+ invoke.mock.calls.some(
+ ([command]) =>
+ command === 'download_game_template' ||
+ command === 'create_automatic_local_game_project_from_template',
+ ),
+ ).toBe(false);
await act(async () => {
await Promise.resolve();
@@ -303,9 +377,7 @@ export function registerClientHomeTests() {
await openResourceBookCategory('UI 交互');
expect(await findResourceSelectButton('live-hero.png')).not.toBeNull();
await openResourceBookCategory('项目版本');
- expect(
- await screen.findByRole('button', { name: /版本 1/ }),
- ).not.toBeNull();
+ expect(await findResourceSelectButton('版本 1')).not.toBeNull();
expect(runButton.getAttribute('data-unavailable')).toBeNull();
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
@@ -474,9 +546,7 @@ export function registerClientHomeTests() {
}),
).not.toBeNull();
await openResourceBookCategory('项目版本');
- expect(
- await screen.findByRole('button', { name: /版本 1/ }),
- ).not.toBeNull();
+ expect(await findResourceSelectButton('版本 1')).not.toBeNull();
expect(runButton.getAttribute('data-unavailable')).toBeNull();
await waitFor(() => {
expect(
@@ -2331,7 +2401,7 @@ export function registerHomeProjectCreationTests() {
};
}
if (command === 'read_direct_project_history_slice') {
- expect(args).toEqual({ projectPath, limit: 20 });
+ expect(args).toEqual({ projectPath, limit: 20, messagesOnly: true });
return { items: [...persistedMessages], hasMore: false };
}
if (command === 'append_local_conversation_message') {
@@ -2416,7 +2486,7 @@ export function registerHomeProjectCreationTests() {
return manifest;
}
if (command === 'read_direct_project_history_slice') {
- expect(args).toEqual({ projectPath, limit: 20 });
+ expect(args).toEqual({ projectPath, limit: 20, messagesOnly: true });
return { items: [...persistedMessages], hasMore: false };
}
if (command === 'append_local_permission_log') {
diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts
index 08168861f..96197e563 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts
@@ -3459,8 +3459,27 @@ export function registerProjectWorkbenchFoundationTests() {
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
- // 卡片本体不再自带描边;只有被当前版本绑定的素材才有边框。
- expect(styles).toMatch(/\.game-resource-card\s*\{[^}]*border:\s*0;/s);
+ /*
+ * 卡片本体只保留**透明**边框,可见描边仍然只属于「当前版本绑定」等状态。
+ *
+ * 透明而不是 `border: 0`:卡片是 `box-sizing: border-box`,卡面与角标又都是以 padding
+ * box 为包含块的绝对定位元素 —— 底态 0 宽、状态态 1px 宽时,一次悬停就会把内容盒四边
+ * 各吃掉 1px,卡片里的东西跟着位移。常驻 1px 透明边框让所有状态的 padding box 一致,
+ * 视觉上仍"本体无描边"。
+ */
+ expect(styles).toMatch(
+ /\.game-resource-card\s*\{[^}]*border:\s*1px solid transparent;/s,
+ );
+ // 状态态只允许点亮颜色,不允许改动宽度:宽度一变就又回到"内容跟着动"。
+ expect(styles).toMatch(
+ /\.game-resource-card:hover,[^{]*\{[^}]*border:\s*1px solid/s,
+ );
+ expect(styles).not.toMatch(
+ /\.game-resource-card[^{,]*\{[^}]*border-width:/s,
+ );
+ expect(styles).not.toMatch(
+ /\.game-resource-card[^{,]*\{[^}]*border:\s*[2-9]px/s,
+ );
expect(styles).toMatch(
/\.game-resource-card\.is-current-version\s*\{[^}]*border:\s*1px solid/s,
);
@@ -3816,21 +3835,12 @@ export function registerProjectWorkbenchFoundationTests() {
];
await openResourceBookCategory('角色与对象');
- fireEvent.click(await findResourceSelectButton('hero.png'));
- const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
- const toolbarLabels = within(toolbar)
- .getAllByRole('button')
- .map((button) => button.getAttribute('aria-label') ?? '');
- const infoButton = within(toolbar).getByRole('button', { name: '信息' });
- // 位置固定在「引用」之后、「编辑标签」之前:工具条上的顺序即功能顺序。
- expect(toolbarLabels.indexOf('信息')).toBeGreaterThan(
- toolbarLabels.indexOf('引用资源 hero.png'),
- );
- expect(toolbarLabels.indexOf('信息')).toBeLessThan(
- toolbarLabels.indexOf('编辑标签'),
- );
+ const infoButton = await screen.findByRole('button', {
+ name: '查看hero.png资源信息',
+ });
expect(infoButton.getAttribute('aria-pressed')).toBe('false');
+ // 未选中的卡片直接打开信息,不被选中变化 effect 立即关闭。
fireEvent.click(infoButton);
const canvasPanel = await screen.findByRole('dialog', {
name: '资源信息',
@@ -3862,11 +3872,11 @@ export function registerProjectWorkbenchFoundationTests() {
// Esc 与快速编辑浮层同一口径:既收浮层也清选中,整个工具条一起收起。
fireEvent.click(await findResourceSelectButton('hero.png'));
- const reopenedToolbar = await screen.findByRole('toolbar', {
+ await screen.findByRole('toolbar', {
name: '图片工具栏',
});
fireEvent.click(
- within(reopenedToolbar).getByRole('button', { name: '信息' }),
+ screen.getByRole('button', { name: '查看hero.png资源信息' }),
);
expect(
await screen.findByRole('dialog', { name: '资源信息' }),
@@ -4074,10 +4084,7 @@ export function registerProjectWorkbenchFoundationTests() {
),
).toBe(false);
// 音频资源的选中工具条复用美术画布的音频分支(aria-label「素材工具栏」),
- // 并且只渲染宿主编排层真实接通的动作:「引用」(从卡片挪进工具条的引用入口,
- // 资源卡上的圆钮已删除)「信息」(只读信息浮层)「编辑标签」(面板只编辑 manifest
- // `assets[].tags`)「素材类型」(功能分类的独立入口,与标签面板分家)「重命名」
- // 已接面板「删除素材」(破坏性动作放末位,前置共享分隔线,复用素材删除流程)
+ // 并且只渲染宿主编排层真实接通的五个动作;信息与类型由卡片角标承接。
// 「导出」复用资源面板同一条落盘链路,「改造」在宿主编排层仍是空回调,
// 不能再渲染成点了没反应的按钮。
const audioToolbar = screen.getByRole('toolbar', {
@@ -4090,15 +4097,7 @@ export function registerProjectWorkbenchFoundationTests() {
within(audioToolbar)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
- ).toEqual([
- '引用资源 bgm.mp3',
- '信息',
- '编辑标签',
- '素材类型',
- '重命名',
- '导出',
- '删除素材',
- ]);
+ ).toEqual(['引用资源 bgm.mp3', '编辑标签', '重命名', '导出', '删除素材']);
// 工具条的「导出」必须真的走通落盘链路:原生保存对话框 + Rust 分块复制,
// 而不是只渲染一个按钮。原生对话框由入口文件 mock 成"用户选了
@@ -5852,6 +5851,20 @@ export function registerProjectWorkbenchFoundationTests() {
expect(styles).toMatch(
/\.game-workbench-chat \.project-supervisor-conversation\s*\{[^}]*position:\s*relative[^}]*display:\s*block[^}]*height:\s*100%[^}]*min-height:\s*0[^}]*overflow:\s*hidden/s,
);
+ // 输入盒里的弹层不能被上面这条(连同 surface、聊天列共三层)裁掉:控制排最左侧是
+ // 「推理档」,它的菜单贴着触发钮右缘向左展开,窄布局(视口 ≤1000px 时面板只有
+ // 280px 宽)下会伸到面板左侧之外,档位文字正好落在被裁掉的那半边,点开只剩一个空
+ // 盒子。所以 direct-codex 这三层的裁切必须放开;菜单位置和尺寸不变,真机几何
+ // (整块可见、位置不动)由浏览器实测确认,这里只钉声明。
+ expect(styles).toMatch(
+ /\.game-workbench-chat:has\(\s*\.project-supervisor-composer\.is-direct-codex\s*\)\s*\{[^}]*overflow:\s*visible/s,
+ );
+ expect(styles).toMatch(
+ /\.game-workbench-chat \.project-supervisor-surface\.is-direct-codex\s*\{[^}]*overflow:\s*visible/s,
+ );
+ expect(styles).toMatch(
+ /\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-conversation\s*\{[^}]*overflow:\s*visible/s,
+ );
expect(styles).toMatch(
/\.game-workbench-chat \.project-supervisor-message-list\s*\{[^}]*height:\s*100%[^}]*min-height:\s*96px[^}]*overflow-y:\s*auto[^}]*padding-bottom:\s*12px[^}]*scroll-padding-bottom:\s*12px/s,
);
@@ -5984,7 +5997,7 @@ export function registerProjectWorkbenchFoundationTests() {
expect(chatWalletSlots).toHaveLength(1);
expect(projectDevelopmentSource).toMatch(/walletEntry=\{walletEntry\}/);
expect(projectDevelopmentSource).toMatch(
- /const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*selectedResourceIds\.length === 0\s*&&\s*!uiEditorRoute/s,
+ /const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*!uiEditorRoute\s*;/s,
);
expect(projectDevelopmentSource).toMatch(
/aria-describedby=\{\s*showRunUnavailableHint\s*\?\s*'run-unavailable-hint'\s*:\s*undefined\s*\}/s,
@@ -6048,6 +6061,15 @@ export function registerProjectWorkbenchFoundationTests() {
},
};
}
+ if (command === 'read_local_project_text_preview') {
+ return {
+ path: 'assets/ui-design.json',
+ mediaType: 'application/json',
+ byteLen: 2,
+ content: '{}',
+ uiDesignAssetId: 'ui-design-resource',
+ };
+ }
throw new Error(`unexpected invoke ${command}`);
},
);
@@ -9120,6 +9142,7 @@ export function registerProjectSupervisorSurfaceTests() {
expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', {
projectPath,
limit: 20,
+ messagesOnly: true,
}),
);
// 默认任务占位行也不能触发专业 Agent 历史的批量读取。
diff --git a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts
index 54036c26c..201d2ad15 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts
@@ -1033,7 +1033,7 @@ export function registerPublishedRuntimeSettingsTests() {
autoCompactTokenLimit: 64000,
toolOutputTokenLimit: 12000,
requestTimeoutMs: 180000,
- maxRetries: 2,
+ maxRetries: 10,
retryBackoffMs: 500,
}),
agentLlm: {},
diff --git a/apps/ai-game-creator-shell/tests/appUpdate.test.ts b/apps/ai-game-creator-shell/tests/appUpdate.test.ts
index 8bbe1e596..026dcdc52 100644
--- a/apps/ai-game-creator-shell/tests/appUpdate.test.ts
+++ b/apps/ai-game-creator-shell/tests/appUpdate.test.ts
@@ -1,47 +1,133 @@
-import { afterEach, describe, expect, it } from 'vitest';
+import { check } from '@tauri-apps/plugin-updater';
+import { afterEach, describe, expect, it, vi } from 'vitest';
-import {
- isNewerVersion,
- parseAppUpdateManifest,
- resetAppUpdateCheckForTests,
-} from '../src/services/appUpdate';
+vi.mock('@tauri-apps/plugin-updater', () => ({ check: vi.fn() }));
-afterEach(() => resetAppUpdateCheckForTests());
+const checkMock = vi.mocked(check);
-describe('AGC update manifest', () => {
- it('compares semantic versions and accepts v prefixes', () => {
- expect(isNewerVersion('v0.1.13', '0.1.12')).toBe(true);
- expect(isNewerVersion('0.1.12', '0.1.12')).toBe(false);
- expect(isNewerVersion('0.1.11', '0.1.12')).toBe(false);
+type FakeDownloadEvent =
+ | { event: 'Started'; data: { contentLength?: number } }
+ | { event: 'Progress'; data: { chunkLength: number } }
+ | { event: 'Finished' };
+
+function fakeUpdate() {
+ return {
+ version: '99.0.0',
+ currentVersion: '0.1.47',
+ body: '修复与改进',
+ downloadAndInstall: vi.fn(
+ async (onEvent: (event: FakeDownloadEvent) => void) => {
+ onEvent({ event: 'Started', data: { contentLength: 100 } });
+ onEvent({ event: 'Progress', data: { chunkLength: 40 } });
+ onEvent({ event: 'Progress', data: { chunkLength: 60 } });
+ onEvent({ event: 'Finished' });
+ },
+ ),
+ };
+}
+
+function stubTauriWindow() {
+ const invoke = vi.fn(async () => undefined);
+ vi.stubGlobal('window', { __TAURI__: { core: { invoke } } });
+ return invoke;
+}
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.unstubAllGlobals();
+ vi.resetModules();
+ checkMock.mockReset();
+});
+
+describe('AGC 客户端更新', () => {
+ it('开发态开关关闭时不请求清单', async () => {
+ vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '0');
+ vi.resetModules();
+ const { checkForAppUpdate, resetAppUpdateCheckForTests } =
+ await import('../src/services/appUpdate');
+
+ await expect(checkForAppUpdate()).resolves.toBeNull();
+ expect(checkMock).not.toHaveBeenCalled();
+ resetAppUpdateCheckForTests();
});
- it('validates an OSS manifest and rejects non-HTTPS downloads', () => {
- expect(
- parseAppUpdateManifest({
- version: '0.1.13',
- downloadUrl: 'https://oss.example/agc.exe',
- }),
- ).toMatchObject({
- version: '0.1.13',
- downloadUrl: 'https://oss.example/agc.exe',
+ it('开关打开时把插件返回的更新映射给界面,且同一生命周期只查一次', async () => {
+ vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1');
+ vi.resetModules();
+ checkMock.mockResolvedValue(fakeUpdate() as never);
+ const { checkForAppUpdate, resetAppUpdateCheckForTests } =
+ await import('../src/services/appUpdate');
+
+ await expect(checkForAppUpdate()).resolves.toEqual({
+ version: '99.0.0',
+ currentVersion: '0.1.47',
+ releaseNotes: '修复与改进',
});
- expect(
- parseAppUpdateManifest({
- version: '0.1.13',
- downloadUrl: 'http://oss.example/agc.exe',
- }),
- ).toBeNull();
+ await checkForAppUpdate();
+ expect(checkMock).toHaveBeenCalledTimes(1);
+ resetAppUpdateCheckForTests();
});
- it('preserves multiline release notes', () => {
- expect(
- parseAppUpdateManifest({
- version: '0.1.13',
- downloadUrl: 'https://oss.example/agc.exe',
- releaseNotes: '第一行\n第二行\r\n第三行',
- }),
- ).toMatchObject({
- releaseNotes: '第一行\n第二行\r\n第三行',
- });
+ it('清单缺失或网络失败时静默按无更新收口', async () => {
+ vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1');
+ vi.resetModules();
+ checkMock.mockRejectedValue(new Error('updater: manifest 404'));
+ const { checkForAppUpdate, resetAppUpdateCheckForTests } =
+ await import('../src/services/appUpdate');
+
+ await expect(checkForAppUpdate()).resolves.toBeNull();
+ resetAppUpdateCheckForTests();
+ });
+
+ it('安装时按下载事件上报进度并在完成后重启进程', async () => {
+ vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1');
+ vi.resetModules();
+ const invoke = stubTauriWindow();
+ const update = fakeUpdate();
+ checkMock.mockResolvedValue(update as never);
+ const { checkForAppUpdate, installAppUpdate, resetAppUpdateCheckForTests } =
+ await import('../src/services/appUpdate');
+
+ await checkForAppUpdate();
+ const progress: Array<{ downloadedBytes: number; totalBytes?: number }> =
+ [];
+ await installAppUpdate((value) => progress.push(value));
+
+ expect(update.downloadAndInstall).toHaveBeenCalledTimes(1);
+ expect(progress).toEqual([
+ { downloadedBytes: 0, totalBytes: 100 },
+ { downloadedBytes: 40, totalBytes: 100 },
+ { downloadedBytes: 100, totalBytes: 100 },
+ { downloadedBytes: 100, totalBytes: 100 },
+ ]);
+ expect(invoke).toHaveBeenCalledWith('restart_agc_app');
+ resetAppUpdateCheckForTests();
+ });
+
+ it('没有待安装更新时安装请求失败关闭', async () => {
+ vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1');
+ vi.resetModules();
+ const { installAppUpdate, resetAppUpdateCheckForTests } =
+ await import('../src/services/appUpdate');
+
+ await expect(installAppUpdate()).rejects.toThrow('没有可安装的更新');
+ resetAppUpdateCheckForTests();
+ });
+
+ it('下载失败后仍可重试安装', async () => {
+ vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1');
+ vi.resetModules();
+ const update = fakeUpdate();
+ update.downloadAndInstall.mockRejectedValue(new Error('下载更新失败'));
+ checkMock.mockResolvedValue(update as never);
+ const { checkForAppUpdate, installAppUpdate, resetAppUpdateCheckForTests } =
+ await import('../src/services/appUpdate');
+
+ await checkForAppUpdate();
+ await expect(installAppUpdate()).rejects.toThrow('下载更新失败');
+ // 重试仍能拿到待装更新,而不是报“没有可安装的更新”。
+ await expect(installAppUpdate()).rejects.toThrow('下载更新失败');
+ expect(update.downloadAndInstall).toHaveBeenCalledTimes(2);
+ resetAppUpdateCheckForTests();
});
});
diff --git a/apps/ai-game-creator-shell/tests/dev-feature-flags.test.ts b/apps/ai-game-creator-shell/tests/dev-feature-flags.test.ts
new file mode 100644
index 000000000..0f93e5095
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/dev-feature-flags.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, test } from 'vitest';
+
+import {
+ agcAppUpdateCheckEnvKey,
+ withAgcDevFeatureFlags,
+} from '../scripts/dev-feature-flags.mjs';
+
+describe('AGC dev 特性开关环境', () => {
+ test('未显式配置时下发关闭检测更新的默认值', () => {
+ expect(withAgcDevFeatureFlags({ KEEP_ME: 'yes' })).toMatchObject({
+ KEEP_ME: 'yes',
+ [agcAppUpdateCheckEnvKey]: '0',
+ });
+ });
+
+ test('保留显式配置的开关取值,忽略空白取值', () => {
+ expect(
+ withAgcDevFeatureFlags({ [agcAppUpdateCheckEnvKey]: '1' }),
+ ).toMatchObject({ [agcAppUpdateCheckEnvKey]: '1' });
+ expect(
+ withAgcDevFeatureFlags({ [agcAppUpdateCheckEnvKey]: ' ' }),
+ ).toMatchObject({ [agcAppUpdateCheckEnvKey]: '0' });
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx
index 71fc665ef..043918975 100644
--- a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx
+++ b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx
@@ -1,5 +1,4 @@
// @vitest-environment jsdom
-
import {
act,
cleanup,
@@ -7,120 +6,236 @@ import {
render,
renderHook,
screen,
+ waitFor,
} from '@testing-library/react';
-import { afterEach, expect, it, vi } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
-import { useDirectActiveTurns } from '../src/features/agent-runtime/directActiveTurns';
+import type { GameCreatorDirectActiveTurn } from '../src/app/types';
+import {
+ DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS,
+ useDirectActiveTurns,
+} from '../src/features/agent-runtime/directActiveTurns';
import { ActiveProjectRunsPanel } from '../src/features/app-shell/ActiveProjectRunsPanel';
afterEach(() => cleanup());
-it('读取失败后的重试定时器会在卸载后清理', async () => {
- vi.useFakeTimers();
- const invoke = vi.fn(async () => {
- throw new Error('temporarily unavailable');
- });
- const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout');
+const ACTIVE_TURN = {
+ projectPath: 'C:/projects/demo',
+ agentId: 'project-supervisor',
+ runId: 'run-1',
+} as unknown as GameCreatorDirectActiveTurn;
- try {
- const { unmount } = renderHook(() =>
- useDirectActiveTurns({ invoke, enabled: true }),
+describe('useDirectActiveTurns', () => {
+ it('keeps the snapshot identity when the poll returns the same content', async () => {
+ // 回归点:轮询每次都 setActiveTurns(新数组) 会让所有依赖 activeTurns 的 effect
+ // 反复重跑(窗口标题栏的活动项目面板曾因此无限 setState)。
+ const invoke = vi.fn(async () => [ACTIVE_TURN]) as never;
+ const { result } = renderHook(() =>
+ useDirectActiveTurns({
+ invoke,
+ enabled: true,
+ pollIntervalMs: DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS,
+ }),
);
- await act(async () => {
- await vi.advanceTimersByTimeAsync(0);
- });
- expect(invoke).toHaveBeenCalledTimes(1);
- unmount();
- expect(clearTimeoutSpy).toHaveBeenCalled();
+ await waitFor(() => {
+ expect(result.current.activeTurns).toHaveLength(1);
+ });
+ const firstSnapshot = result.current.activeTurns;
await act(async () => {
- await vi.advanceTimersByTimeAsync(1_000);
+ await result.current.refreshActiveTurns();
+ await result.current.refreshActiveTurns();
});
+ expect(result.current.activeTurns).toBe(firstSnapshot);
+ });
+
+ it('clears to a stable empty snapshot when the hook is disabled', async () => {
+ let resolveSnapshot!: (turns: GameCreatorDirectActiveTurn[]) => void;
+ const snapshot = new Promise((resolve) => {
+ resolveSnapshot = resolve;
+ });
+ const invoke = vi.fn(() => snapshot) as never;
+ const { result, rerender } = renderHook(
+ ({ enabled }: { enabled: boolean }) =>
+ useDirectActiveTurns({ invoke, enabled, pollIntervalMs: 60_000 }),
+ { initialProps: { enabled: true } },
+ );
+
+ const emptySnapshot = result.current.activeTurns;
+ await act(async () => {
+ resolveSnapshot([]);
+ await snapshot;
+ });
+ expect(result.current.activeTurns).toBe(emptySnapshot);
+ rerender({ enabled: false });
+ expect(result.current.activeTurns).toBe(emptySnapshot);
+ });
+
+ it('读取失败后的重试定时器会在卸载后清理', async () => {
+ vi.useFakeTimers();
+ const invoke = vi.fn(async () => {
+ throw new Error('temporarily unavailable');
+ });
+ const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout');
+
+ try {
+ const { unmount } = renderHook(() =>
+ useDirectActiveTurns({ invoke, enabled: true }),
+ );
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0);
+ });
+ expect(invoke).toHaveBeenCalledTimes(1);
+
+ unmount();
+ expect(clearTimeoutSpy).toHaveBeenCalled();
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1_000);
+ });
+ expect(invoke).toHaveBeenCalledTimes(1);
+ } finally {
+ vi.useRealTimers();
+ clearTimeoutSpy.mockRestore();
+ }
+ });
+
+ it('停用后晚到的非空快照不能恢复活动回合,手动刷新也不发请求', async () => {
+ let complete!: (turns: GameCreatorDirectActiveTurn[]) => void;
+ const invoke = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ complete = resolve;
+ }),
+ );
+ const { result, rerender } = renderHook(
+ ({ enabled }) =>
+ useDirectActiveTurns({ invoke: invoke as never, enabled }),
+ { initialProps: { enabled: true } },
+ );
+ rerender({ enabled: false });
+ const empty = result.current.activeTurns;
+ await act(async () => {
+ complete([ACTIVE_TURN]);
+ await result.current.refreshActiveTurns();
+ });
+ expect(result.current.activeTurns).toBe(empty);
+ expect(result.current.snapshotReadFailed).toBe(false);
expect(invoke).toHaveBeenCalledTimes(1);
- } finally {
- vi.useRealTimers();
- clearTimeoutSpy.mockRestore();
- }
+ });
+
+ it('重新启用后读取新快照,旧请求晚到不能覆盖新快照', async () => {
+ let completeOld!: (turns: GameCreatorDirectActiveTurn[]) => void;
+ const invoke = vi
+ .fn()
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ completeOld = resolve;
+ }),
+ )
+ .mockResolvedValue([{ ...ACTIVE_TURN, runId: 'new-run' }]);
+ const { result, rerender } = renderHook(
+ ({ enabled }) =>
+ useDirectActiveTurns({ invoke: invoke as never, enabled }),
+ { initialProps: { enabled: true } },
+ );
+ rerender({ enabled: false });
+ rerender({ enabled: true });
+ await waitFor(() =>
+ expect(result.current.activeTurns[0]?.runId).toBe('new-run'),
+ );
+ const current = result.current.activeTurns;
+ await act(async () => {
+ completeOld([ACTIVE_TURN]);
+ });
+ expect(result.current.activeTurns).toBe(current);
+ expect(invoke).toHaveBeenCalledTimes(2);
+ });
});
-it('按开始时间展示正在运行的项目并支持进入项目', () => {
- const onOpenProject = vi.fn();
- render(
- ,
- );
+describe('ActiveProjectRunsPanel', () => {
+ it('按开始时间展示正在运行的项目并支持进入项目', () => {
+ const onOpenProject = vi.fn();
+ render(
+ ,
+ );
- const items = screen.getAllByRole('button');
- expect(items.map((item) => item.textContent?.includes('先开始'))).toEqual([
- true,
- false,
- ]);
- fireEvent.click(items[0]);
- expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first');
-});
-
-it('读取失败时保留明确的读取提示,不伪装成没有运行项目', () => {
- render();
-
- expect(screen.getByRole('status').textContent).toBe('未能读取正在运行的项目');
-});
-
-it('标题栏入口只显示最后开始的项目,展开后列出全部项目', () => {
- const onOpenProject = vi.fn();
- render(
- ,
- );
-
- expect(screen.getByRole('button', { name: /后开始/ })).toBeTruthy();
- expect(screen.queryByRole('menu')).toBeNull();
- fireEvent.click(screen.getByRole('button', { name: /后开始/ }));
- expect(screen.getByRole('menu')).toBeTruthy();
- expect(screen.getAllByRole('menuitem')).toHaveLength(2);
- fireEvent.click(screen.getByRole('menuitem', { name: /先开始/ }));
- expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first');
+ const items = screen.getAllByRole('button');
+ expect(items.map((item) => item.textContent?.includes('先开始'))).toEqual([
+ true,
+ false,
+ ]);
+ fireEvent.click(items[0]);
+ expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first');
+ });
+
+ it('读取失败时保留明确的读取提示,不伪装成没有运行项目', () => {
+ render();
+
+ expect(screen.getByRole('status').textContent).toBe(
+ '未能读取正在运行的项目',
+ );
+ });
+
+ it('标题栏入口只显示最后开始的项目,展开后列出全部项目', () => {
+ const onOpenProject = vi.fn();
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('button', { name: /后开始/ })).toBeTruthy();
+ expect(screen.queryByRole('menu')).toBeNull();
+ fireEvent.click(screen.getByRole('button', { name: /后开始/ }));
+ expect(screen.getByRole('menu')).toBeTruthy();
+ expect(screen.getAllByRole('menuitem')).toHaveLength(2);
+ fireEvent.click(screen.getByRole('menuitem', { name: /先开始/ }));
+ expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first');
+ });
});
diff --git a/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx b/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx
new file mode 100644
index 000000000..8c0f00a19
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx
@@ -0,0 +1,269 @@
+/** @vitest-environment jsdom */
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import type { DirectThreadHistorySlice } from '../src/features/project-workspace/directThreadEvents';
+import {
+ act,
+ App,
+ createGameCreationAppManifest,
+ fireEvent,
+ React,
+ render,
+ screen,
+ setComposerText,
+} from './appSurface/harness';
+
+const projectPath = '/tmp/direct-message-pages';
+const manifest = createGameCreationAppManifest(
+ 'direct-message-pages',
+ '历史分页',
+);
+const messages = Array.from({ length: 26 }, (_, index) => ({
+ id: `direct-codex:turn-${Math.floor(index / 2)}:${index % 2 ? 'assistant' : 'user'}`,
+ type: 'message',
+ role: index % 2 ? 'assistant' : 'user',
+ content: [
+ {
+ type: index % 2 ? 'output_text' : 'input_text',
+ text: `历史正文 ${index}`,
+ },
+ ],
+}));
+function page(
+ items: typeof messages,
+ hasMore: boolean,
+): DirectThreadHistorySlice {
+ return { items, hasMore, oldestItemId: items[0]?.id ?? null };
+}
+function deferred() {
+ let resolve!: (value: DirectThreadHistorySlice) => void;
+ let reject!: (error: Error) => void;
+ const promise = new Promise((yes, no) => {
+ resolve = yes;
+ reject = no;
+ });
+ return { promise, resolve, reject };
+}
+function install(
+ read: (
+ args: Record,
+ ) => DirectThreadHistorySlice | Promise,
+) {
+ const invoke = vi.fn(
+ async (command: string, args?: Record) => {
+ if (command === 'read_direct_project_history_slice') {
+ expect(args?.messagesOnly).toBe(true);
+ return read(args!);
+ }
+ if (command === 'read_project_permission_policy')
+ return {
+ path: '.agent/policy.json',
+ policy: { deniedCommands: [], confirmCommands: [] },
+ };
+ if (command === 'get_local_game_manifest') return manifest;
+ if (command === 'read_game_creator_app_config')
+ return {
+ config: {
+ selectedModelId: 'quality',
+ selectedModelIsDefault: true,
+ llm: { customEnabled: false },
+ },
+ };
+ if (
+ command === 'read_direct_tool_calls' ||
+ command === 'read_direct_turn_stream' ||
+ command === 'list_game_creator_direct_active_turns'
+ )
+ return [];
+ return null;
+ },
+ );
+ window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__;
+ return invoke;
+}
+function mount(path = projectPath) {
+ return render(
+