diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx
index 637ab9103..281ce517c 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx
+++ b/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx
@@ -1,10 +1,17 @@
-import { FileCode2, Image as ImageIcon, Music2, Video } from 'lucide-react';
+import {
+ FileCode2,
+ Image as ImageIcon,
+ Music2,
+ SlidersHorizontal,
+ Video,
+} from 'lucide-react';
import { useEffect, useRef } from 'react';
import {
projectResourceCardPreviewKind,
type ProjectResourceCardPreviewState,
projectResourceCardPreviewVariant,
+ projectResourceJsonPresentation,
} from './resourceCardPreviewModel';
import type { ProjectResource } from './resourceProjectionModel';
@@ -90,6 +97,16 @@ export function ResourcePreviewMedia({
const sourceUrl =
preview.status === 'loaded' ? (preview.preview.sourceUrl ?? null) : null;
const visual = (() => {
+ const jsonPresentation = resource
+ ? projectResourceJsonPresentation(resource, preview)
+ : null;
+ if (jsonPresentation) {
+ return jsonPresentation === 'ui-design' ? (
+
+ ) : (
+
+ );
+ }
if (sourceUrl && (kind === 'raster-image' || kind === 'media-image')) {
return (
![]()
{
+ if (jsonPresentation) {
+ return (
+
+ {jsonPresentation === 'ui-design' ? (
+
+ ) : (
+
+ )}
+
+ {jsonPresentation === 'ui-design' ? 'UI 编辑器' : 'JSON'}
+
+
+ );
+ }
if ((kind === 'raster-image' || kind === 'media-image') && sourceUrl) {
return (
- {resourceCategoryLabel}
+ {cardTypeLabel}
{lineage ? (
// 文字是给人看的关系,`data-resource-lineage` 是给端到端验收的稳定判据
@@ -1345,6 +1370,7 @@ function ResourceBookScene({
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
+ onLostPointerCapture={onPointerCancel}
>
>(new Set());
const resourceCanvasPanRef = useRef<{
pointerId: number;
+ captureTarget: HTMLElement;
category: ResourceBookTarget;
startClientX: number;
startClientY: number;
@@ -3366,15 +3394,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(() => {
@@ -4752,6 +4779,8 @@ export default function ProjectDevelopmentView({
};
const handleBlur = () => {
resourceCanvasSpacePanRef.current = false;
+ cancelResourceCanvasPan();
+ setResourceCanvasMarquee(null);
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
@@ -4761,7 +4790,7 @@ export default function ProjectDevelopmentView({
window.removeEventListener('keyup', handleKeyUp);
window.removeEventListener('blur', handleBlur);
};
- }, []);
+ }, [cancelResourceCanvasPan]);
const handleResourceBookWheel = useCallback(
(event: ReactWheelEvent
| WheelEvent) => {
@@ -4858,7 +4887,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;
@@ -4874,6 +4906,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,
@@ -5099,11 +5132,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;
}
// 点选态下空白处的左键不起框选、也不清画布焦点/选中:点选只认资源卡,
@@ -5117,12 +5158,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,
@@ -5295,12 +5337,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;
@@ -5321,7 +5373,13 @@ export default function ProjectDevelopmentView({
: {}),
});
},
- [advanceFocusGeneration, manifest.assets, openUiDesignEditor],
+ [
+ advanceFocusGeneration,
+ manifest.assets,
+ openUiDesignEditor,
+ resourceCardPreviews.identityByResourceId,
+ resourceCardPreviews.previews,
+ ],
);
/**
@@ -6205,8 +6263,7 @@ export default function ProjectDevelopmentView({
setMode('run');
}
- const showRunUnavailableHint =
- !runAvailable && selectedResourceIds.length === 0 && !uiEditorRoute;
+ const showRunUnavailableHint = !runAvailable && !uiEditorRoute;
const renderResourceBookCard = useCallback(
(
@@ -7460,9 +7517,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({
@@ -7850,10 +7914,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();
@@ -7927,7 +8005,8 @@ export default function ProjectDevelopmentView({
预览
) : null}
- {selectedResourceOpensUiEditor ? (
+ {selectedResource &&
+ selectedResourceOpensUiEditor ? (
{sortMode === 'dependency' &&
dependencyRelationshipDescriptions.length > 0 ? (
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 c97057461..979015c52 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
@@ -58,6 +58,8 @@ export type ProjectResourceCardPreviewPayload = {
hasAlpha?: boolean;
sourceUrl?: string;
content?: string;
+ /** 原生侧完整校验过的 UI State 资产身份,不由前端解析 JSON 推断。 */
+ uiDesignAssetId?: string;
};
export type ProjectResourceCardPreviewTransportPayload = Omit<
@@ -219,7 +221,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 +239,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,7 +283,7 @@ export function projectResourceCardPreviewKind(
return 'document';
}
// Markdown / 代码在扩展名这一层就分流,不再依赖上游登记类型:
- // 上游把 JSON 规格登记成「文档」,卡面按文档预览;代码文件按扩展名分流。
+ // JSON 使用文档读取通道完成原生语义识别;源码文件按扩展名分流。
if (markdownExtension.test(resource.path)) {
return 'document';
}
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/useProjectResourceCardPreviews.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts
index f175264ba..729658ceb 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
@@ -167,6 +167,7 @@ function materializeProjectResourceCardPreview(
pixelHeight: transport.pixelHeight,
hasAlpha: transport.hasAlpha,
content: transport.content,
+ uiDesignAssetId: transport.uiDesignAssetId,
},
retainedBytes:
transport.content === undefined
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 01e5f217d..47e386b36 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
@@ -182,24 +182,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();
@@ -2450,7 +2524,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') {
@@ -2536,7 +2610,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 f751841f6..636ec3d74 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
@@ -5853,6 +5853,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,
);
@@ -5985,7 +5999,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,
@@ -6049,6 +6063,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}`);
},
);
@@ -9130,6 +9153,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 d78f10a0d..78c1da3c1 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
@@ -1034,7 +1034,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..bd89c3a3a 100644
--- a/apps/ai-game-creator-shell/tests/appUpdate.test.ts
+++ b/apps/ai-game-creator-shell/tests/appUpdate.test.ts
@@ -1,47 +1,137 @@
-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..521cf634a 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,179 @@ 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(invoke).toHaveBeenCalledTimes(1);
- } finally {
- vi.useRealTimers();
- clearTimeoutSpy.mockRestore();
- }
+ expect(result.current.activeTurns).toBe(firstSnapshot);
+ });
+
+ it('clears to a stable empty snapshot when the hook is disabled', async () => {
+ const invoke = vi.fn(
+ async () => [] as GameCreatorDirectActiveTurn[],
+ ) as never;
+ const { result, rerender } = renderHook(
+ ({ enabled }: { enabled: boolean }) =>
+ useDirectActiveTurns({ invoke, enabled, pollIntervalMs: 60_000 }),
+ { initialProps: { enabled: true } },
+ );
+
+ await waitFor(() => {
+ expect(result.current.activeTurns).toEqual([]);
+ });
+ const emptySnapshot = result.current.activeTurns;
+ 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('按开始时间展示正在运行的项目并支持进入项目', () => {
- 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(
+ ,
+ );
+}
+const earlier = () => screen.getByRole('button', { name: /显示更早/ });
+afterEach(() => {
+ delete window.__TAURI__;
+});
+
+describe('Direct 聊天历史分页集成', () => {
+ it('加载旧页后重读历史回到最新页,再翻页仍按原顺序且不重复', async () => {
+ const invoke = install((args) =>
+ args.beforeItemId
+ ? page(messages.slice(0, 6), false)
+ : page(messages.slice(6), true),
+ );
+ mount();
+ await screen.findByText('历史正文 25');
+ fireEvent.click(earlier());
+ await screen.findByText('历史正文 0');
+ await setComposerText(screen.getByLabelText('陶泥儿对话内容'), '/history');
+ fireEvent.click(screen.getByRole('button', { name: '发送' }));
+ await act(async () => {
+ await Promise.resolve();
+ });
+ expect(screen.queryByText('历史正文 0')).toBeNull();
+ expect(screen.getByText('历史正文 25')).not.toBeNull();
+ fireEvent.click(earlier());
+ await screen.findByText('历史正文 0');
+ for (let index = 0; index < messages.length; index += 1) {
+ expect(screen.getAllByText(`历史正文 ${index}`)).toHaveLength(1);
+ if (index > 0) {
+ expect(
+ screen
+ .getByText(`历史正文 ${index - 1}`)
+ .compareDocumentPosition(screen.getByText(`历史正文 ${index}`)) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).not.toBe(0);
+ }
+ }
+ expect(
+ invoke.mock.calls.filter(
+ ([command]) => command === 'read_direct_project_history_slice',
+ ),
+ ).toHaveLength(4);
+ });
+
+ it('首屏按消息加载20条,更早消息使用原生游标,原始工具记录不占页', async () => {
+ const invoke = install((args) =>
+ args.beforeItemId
+ ? page(messages.slice(0, 6), false)
+ : page(messages.slice(6), true),
+ );
+ mount();
+ await screen.findByText('历史正文 25');
+ expect(screen.queryByText('历史正文 0')).toBeNull();
+ fireEvent.click(earlier());
+ await screen.findByText('历史正文 0');
+ expect(screen.queryByRole('button', { name: /显示更早/ })).toBeNull();
+ expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', {
+ projectPath,
+ beforeItemId: messages[6]!.id,
+ limit: 20,
+ messagesOnly: true,
+ });
+ });
+
+ it('重复点击单飞,失败保留游标可重试,重叠消息不重复', async () => {
+ const pending = deferred();
+ let attempts = 0;
+ const invoke = install((args) => {
+ if (!args.beforeItemId) return page(messages.slice(6), true);
+ attempts += 1;
+ return attempts === 1
+ ? pending.promise
+ : page(messages.slice(0, 8), false);
+ });
+ mount();
+ await screen.findByText('历史正文 25');
+ fireEvent.click(earlier());
+ fireEvent.click(earlier());
+ expect(attempts).toBe(1);
+ await act(async () => pending.reject(new Error('模拟读取失败')));
+ expect(screen.getByText('历史正文 25')).not.toBeNull();
+ fireEvent.click(earlier());
+ await screen.findByText('历史正文 0');
+ expect(screen.getAllByText('历史正文 6')).toHaveLength(1);
+ expect(screen.getAllByText('历史正文 7')).toHaveLength(1);
+ const loads = invoke.mock.calls.filter(
+ ([command, args]) =>
+ command === 'read_direct_project_history_slice' && args?.beforeItemId,
+ );
+ expect(loads.map(([, args]) => args?.beforeItemId)).toEqual([
+ messages[6]!.id,
+ messages[6]!.id,
+ ]);
+ });
+
+ it('同项目重新加载使旧翻页失效,旧 finally 不解除新请求的单飞', async () => {
+ const old = deferred();
+ const fresh = deferred();
+ let fullLoads = 0;
+ let olderLoads = 0;
+ const recent = messages.slice(6).map((item) => ({
+ ...item,
+ content: [{ type: 'output_text', text: `重读 ${item.content[0]!.text}` }],
+ }));
+ install((args) => {
+ if (!args.beforeItemId) {
+ fullLoads += 1;
+ return page(fullLoads === 1 ? messages.slice(6) : recent, true);
+ }
+ olderLoads += 1;
+ return olderLoads === 1 ? old.promise : fresh.promise;
+ });
+ mount();
+ await screen.findByText('历史正文 25');
+ fireEvent.click(earlier());
+ await setComposerText(screen.getByLabelText('陶泥儿对话内容'), '/history');
+ fireEvent.click(screen.getByRole('button', { name: '发送' }));
+ await screen.findByText('重读 历史正文 25');
+ fireEvent.click(earlier());
+ await act(async () =>
+ old.resolve(
+ page(
+ [
+ {
+ ...messages[0]!,
+ id: 'stale',
+ content: [{ type: 'input_text', text: '过期消息' }],
+ },
+ ],
+ false,
+ ),
+ ),
+ );
+ expect(screen.queryByText('过期消息')).toBeNull();
+ fireEvent.click(earlier());
+ expect(olderLoads).toBe(2);
+ await act(async () => fresh.resolve(page(messages.slice(0, 6), false)));
+ await screen.findByText('历史正文 0');
+ });
+
+ it('离开项目并重进后,旧请求不能污染新实例', async () => {
+ const old = deferred();
+ const invoke = install((args) =>
+ args.beforeItemId ? old.promise : page(messages.slice(6), true),
+ );
+ const first = mount();
+ await screen.findByText('历史正文 25');
+ fireEvent.click(earlier());
+ first.unmount();
+ const other = mount('/tmp/another-project');
+ await screen.findByText('历史正文 25');
+ other.unmount();
+ mount();
+ await screen.findByText('历史正文 25');
+ await act(async () =>
+ old.resolve(
+ page(
+ [
+ {
+ ...messages[0]!,
+ id: 'stale',
+ content: [{ type: 'input_text', text: '旧项目迟到消息' }],
+ },
+ ],
+ false,
+ ),
+ ),
+ );
+ expect(screen.queryByText('旧项目迟到消息')).toBeNull();
+ expect(earlier()).not.toBeNull();
+ expect(
+ invoke.mock.calls.filter(
+ ([command]) => command === 'read_direct_project_history_slice',
+ ),
+ ).toHaveLength(4);
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts
index 128cdf135..ff7849dfc 100644
--- a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts
+++ b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts
@@ -2,10 +2,59 @@ import { describe, expect, it } from 'vitest';
import {
directThreadHistoryItemsToMessages,
+ directThreadHistoryPage,
isDirectTurnInProgress,
+ prependDirectHistoryMessages,
} from '../src/features/project-workspace/directThreadEvents';
describe('Direct 回合状态与历史时间', () => {
+ it('游标来自原始切片,不从没有聊天消息的工具页倒推', () => {
+ const page = directThreadHistoryPage(
+ {
+ items: [{ id: 'tool-older', type: 'function_call_output' }],
+ hasMore: true,
+ oldestItemId: 'tool-older',
+ },
+ 'message-newer',
+ );
+ expect(page.messages).toEqual([]);
+ expect(page.cursor).toBe('tool-older');
+ });
+ it('空历史结束,无 ID 或不前进的非终页明确失败而不循环', () => {
+ expect(directThreadHistoryPage({ items: [], hasMore: false })).toEqual({
+ messages: [],
+ cursor: null,
+ hasMore: false,
+ });
+ expect(() => directThreadHistoryPage({ items: [], hasMore: true })).toThrow(
+ '游标未前进',
+ );
+ expect(() =>
+ directThreadHistoryPage(
+ {
+ items: [{ id: 'same' }],
+ hasMore: true,
+ oldestItemId: 'same',
+ },
+ 'same',
+ ),
+ ).toThrow('游标未前进');
+ });
+ it('重叠页按原始 ID 去重,保留当前正文,旧无 ID 消息不删除', () => {
+ const current = [
+ { role: 'assistant' as const, text: '完整正文', messageId: 'a' },
+ ];
+ const old = { role: 'user' as const, text: '用户输入', messageId: 'u' };
+ expect(
+ prependDirectHistoryMessages(current, [
+ old,
+ old,
+ { role: 'assistant', text: '旧快照', messageId: 'a' },
+ { role: 'assistant', text: '无身份旧消息' },
+ ]),
+ ).toEqual([old, { role: 'assistant', text: '无身份旧消息' }, ...current]);
+ });
+
it('终态和空状态不恢复为活动回合', () => {
for (const status of [
'completed',
diff --git a/apps/ai-game-creator-shell/tests/featureFlags.test.ts b/apps/ai-game-creator-shell/tests/featureFlags.test.ts
new file mode 100644
index 000000000..1ee1092b4
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/featureFlags.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest';
+
+import { resolveAppUpdateCheckEnabled } from '../src/app/featureFlags';
+
+describe('AGC 客户端特性开关', () => {
+ it('开发态(agc 启动)默认关闭检测更新', () => {
+ expect(resolveAppUpdateCheckEnabled('', true)).toBe(false);
+ });
+
+ it('正式包默认开启检测更新', () => {
+ expect(resolveAppUpdateCheckEnabled('', false)).toBe(true);
+ });
+
+ it('显式配置的开关优先于环境默认值', () => {
+ expect(resolveAppUpdateCheckEnabled('1', true)).toBe(true);
+ expect(resolveAppUpdateCheckEnabled('0', false)).toBe(false);
+ });
+
+ it('忽略无法识别的开关取值并回落到环境默认值', () => {
+ expect(resolveAppUpdateCheckEnabled('2', true)).toBe(false);
+ expect(resolveAppUpdateCheckEnabled(' ', false)).toBe(true);
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx
index f3a58a319..22018450b 100644
--- a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx
+++ b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx
@@ -14,6 +14,7 @@ import {
canDismissResourceCanvasQuickEdit,
isResourceCanvasHostOverlayOpen,
isResourceCanvasInteractionTarget,
+ isResourceCanvasPanTarget,
isResourceCanvasWheelOverlayTarget,
resolveResourceCanvasFloatingPanelDismissOpen,
resolveResourceCanvasFloatingPanelOpen,
@@ -41,6 +42,38 @@ const RESOURCE_FOCUS_SOURCE_LAYER: CanvasLayer = {
};
describe('resourceCanvasFocusModel', () => {
+ test('右键抓手允许卡面和选中按钮,不抢媒体控件、编辑器及浮层', () => {
+ document.body.innerHTML = `
+
+
+
+
+
+
+
+ 筛选
+ 操作
+
+ `;
+ for (const id of ['blank', 'card', 'select']) {
+ expect(isResourceCanvasPanTarget(document.getElementById(id))).toBe(true);
+ }
+ for (const id of [
+ 'play',
+ 'video',
+ 'input',
+ 'editor',
+ 'filter',
+ 'toolbar',
+ 'title',
+ ]) {
+ expect(isResourceCanvasPanTarget(document.getElementById(id))).toBe(
+ false,
+ );
+ }
+ expect(isResourceCanvasPanTarget(null)).toBe(false);
+ });
+
test('点在资源卡、交互控件与画布浮层里时不清画布焦点', () => {
document.body.innerHTML = `
diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx
index 49b9c3dfa..e1de7b91e 100644
--- a/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx
+++ b/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx
@@ -340,6 +340,323 @@ afterEach(() => {
vi.restoreAllMocks();
});
+async function mountPointerWorkbench(
+ target: 'main' | 'character' | 'all' = 'character',
+) {
+ const projectId = 'pointer-workbench';
+ const projectPath = '/tmp/pointer-workbench';
+ const tauri = installLayoutTauri({
+ projectIdsByPath: { [projectPath]: projectId },
+ });
+ render(
+
,
+ );
+ await settleFocusChain();
+ const manager = document.querySelector
(
+ '.game-resource-book-manager',
+ )!;
+ vi.spyOn(manager, 'getBoundingClientRect').mockReturnValue({
+ x: 0,
+ y: 0,
+ left: 0,
+ top: 0,
+ right: 800,
+ bottom: 600,
+ width: 800,
+ height: 600,
+ toJSON: () => ({}),
+ } as DOMRect);
+ act(() => window.dispatchEvent(new Event('resize')));
+ if (target !== 'main') {
+ fireEvent.click(
+ screen.getByRole('button', {
+ name: target === 'all' ? '打开所有资源' : '打开角色与对象',
+ }),
+ );
+ }
+ await settleFocusChain();
+ const surface = manager.querySelector(
+ target === 'main'
+ ? '.game-resource-book-main'
+ : '.game-resource-book-scene',
+ )!;
+ const capture = new Set();
+ Object.defineProperties(surface, {
+ setPointerCapture: {
+ configurable: true,
+ value: vi.fn((id: number) => capture.add(id)),
+ },
+ hasPointerCapture: {
+ configurable: true,
+ value: (id: number) => capture.has(id),
+ },
+ releasePointerCapture: {
+ configurable: true,
+ value: vi.fn((id: number) => capture.delete(id)),
+ },
+ });
+ const world = manager.querySelector(
+ '.game-resource-book-scene-world',
+ )!;
+ const viewport = () =>
+ Array.from(world.style.transform.matchAll(/-?\d+(?:\.\d+)?/g), (m) =>
+ Number(m[0]),
+ );
+ return { manager, surface, world, viewport, tauri };
+}
+
+describe('资源画布指针与运行提示', () => {
+ it('资源卡左键拖动仍提交手动坐标,不平移视口', async () => {
+ const { manager, viewport, tauri } = await mountPointerWorkbench();
+ const card = manager.querySelector(
+ '.is-expanded .game-resource-card[data-resource-card-id="asset:pointer-a"]',
+ )!;
+ const before = viewport();
+ const writes = tauri.layoutWrites.length;
+ fireEvent.pointerDown(card, {
+ pointerId: 30,
+ button: 0,
+ clientX: 100,
+ clientY: 100,
+ });
+ fireEvent.pointerMove(card, {
+ pointerId: 30,
+ buttons: 1,
+ clientX: 180,
+ clientY: 140,
+ });
+ fireEvent.pointerUp(card, {
+ pointerId: 30,
+ button: 0,
+ clientX: 180,
+ clientY: 140,
+ });
+ await waitFor(() =>
+ expect(tauri.layoutWrites.length).toBeGreaterThan(writes),
+ );
+ expect(tauri.layoutWrites.at(-1)?.positions).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ resourceId: 'asset:pointer-a',
+ manuallyPlaced: true,
+ }),
+ ]),
+ );
+ expect(viewport()).toEqual(before);
+ expect(
+ document.querySelector('.genarrative-image-canvas__selection-overlay'),
+ ).toBeNull();
+ });
+
+ it('资源选中不移除运行不可用提示或改变运行能力', async () => {
+ const { manager } = await mountPointerWorkbench();
+ const hint = '首个可运行原型尚未完成,运行视图暂不可用';
+ expect(screen.getByText(hint)).not.toBeNull();
+ fireEvent.click(
+ manager.querySelector(
+ '.is-expanded [data-resource-id="asset:pointer-a"]',
+ )!,
+ );
+ expect(screen.getByText(hint)).not.toBeNull();
+ const run = screen.getByRole('tab', { name: '运行' });
+ expect(run.getAttribute('data-unavailable')).toBe('true');
+ expect(run.getAttribute('aria-describedby')).toBe('run-unavailable-hint');
+ fireEvent.click(run);
+ expect(run.getAttribute('aria-selected')).toBe('false');
+ });
+
+ it.each(['main', 'character', 'all'] as const)(
+ '%s 的空白处支持右键平移且不写资源布局',
+ async (target) => {
+ const { surface, viewport, tauri } = await mountPointerWorkbench(target);
+ const before = viewport();
+ const writes = tauri.layoutWrites.length;
+ fireEvent.pointerDown(surface, {
+ pointerId: 31,
+ button: 2,
+ clientX: 100,
+ clientY: 100,
+ });
+ fireEvent.pointerMove(surface, {
+ pointerId: 31,
+ buttons: 2,
+ clientX: 170,
+ clientY: 140,
+ });
+ expect(viewport()).toEqual([before[0]! + 70, before[1]! + 40, before[2]]);
+ expect(fireEvent.contextMenu(surface, { button: 2 })).toBe(false);
+ fireEvent.pointerUp(surface, {
+ pointerId: 31,
+ button: 2,
+ clientX: 170,
+ clientY: 140,
+ });
+ expect(
+ document.querySelector('.genarrative-image-canvas__selection-overlay'),
+ ).toBeNull();
+ expect(tauri.layoutWrites).toHaveLength(writes);
+ },
+ );
+
+ it('资源卡右键平移保留选中,左键空白拖动仍框选', async () => {
+ const { manager, surface, viewport, tauri } = await mountPointerWorkbench();
+ const card = manager.querySelector(
+ '.is-expanded [data-resource-id="asset:pointer-a"]',
+ )!;
+ fireEvent.click(card);
+ const before = viewport();
+ const writes = tauri.layoutWrites.length;
+ fireEvent.pointerDown(card, {
+ pointerId: 32,
+ button: 2,
+ clientX: 100,
+ clientY: 100,
+ });
+ fireEvent.pointerMove(surface, {
+ pointerId: 32,
+ buttons: 2,
+ clientX: 150,
+ clientY: 160,
+ });
+ fireEvent.pointerUp(surface, {
+ pointerId: 32,
+ button: 2,
+ clientX: 150,
+ clientY: 160,
+ });
+ expect(viewport()).toEqual([before[0]! + 50, before[1]! + 60, before[2]]);
+ expect(card.getAttribute('aria-pressed')).toBe('true');
+ expect(tauri.layoutWrites).toHaveLength(writes);
+ const panned = viewport();
+ fireEvent.pointerDown(surface, {
+ pointerId: 33,
+ button: 0,
+ clientX: 10,
+ clientY: 10,
+ });
+ fireEvent.pointerMove(surface, {
+ pointerId: 33,
+ buttons: 1,
+ clientX: 780,
+ clientY: 580,
+ });
+ expect(
+ document.querySelector('.genarrative-image-canvas__selection-overlay'),
+ ).not.toBeNull();
+ expect(selectedResourceIdsInDom()).toContain('asset:pointer-a');
+ expect(viewport()).toEqual(panned);
+ fireEvent.pointerUp(surface, { pointerId: 33, button: 0 });
+ expect(
+ document.querySelector('.genarrative-image-canvas__selection-overlay'),
+ ).toBeNull();
+ });
+
+ it.each(['cancel', 'capture', 'blur'] as const)(
+ '%s 后右键平移不会继续跟随指针',
+ async (reason) => {
+ const { surface, viewport } = await mountPointerWorkbench();
+ const before = viewport();
+ fireEvent.pointerDown(surface, {
+ pointerId: 34,
+ button: 2,
+ clientX: 100,
+ clientY: 100,
+ });
+ fireEvent.pointerMove(surface, {
+ pointerId: 34,
+ buttons: 2,
+ clientX: 110,
+ clientY: 120,
+ });
+ const moved = viewport();
+ expect(moved).toEqual([before[0]! + 10, before[1]! + 20, before[2]]);
+ if (reason === 'cancel')
+ fireEvent.pointerCancel(surface, { pointerId: 34 });
+ else if (reason === 'capture')
+ fireEvent.lostPointerCapture(surface, { pointerId: 34 });
+ else act(() => window.dispatchEvent(new Event('blur')));
+ fireEvent.pointerMove(surface, {
+ pointerId: 34,
+ clientX: 300,
+ clientY: 300,
+ });
+ expect(viewport()).toEqual(moved);
+ },
+ );
+
+ it('总览失焦终止右键平移,子画布切换释放实际捕获节点', async () => {
+ const { surface, viewport } = await mountPointerWorkbench('main');
+ const before = viewport();
+ fireEvent.pointerDown(surface, {
+ pointerId: 36,
+ button: 2,
+ clientX: 10,
+ clientY: 10,
+ });
+ fireEvent.pointerMove(surface, {
+ pointerId: 36,
+ buttons: 2,
+ clientX: 40,
+ clientY: 40,
+ });
+ const moved = viewport();
+ expect(moved).toEqual([before[0]! + 30, before[1]! + 30, before[2]]);
+ act(() => window.dispatchEvent(new Event('blur')));
+ expect(surface.releasePointerCapture).toHaveBeenCalledWith(36);
+ fireEvent.pointerMove(surface, {
+ pointerId: 36,
+ clientX: 100,
+ clientY: 100,
+ });
+ expect(viewport()).toEqual(moved);
+ fireEvent.pointerDown(surface, {
+ pointerId: 37,
+ button: 2,
+ clientX: 10,
+ clientY: 10,
+ });
+ fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' }));
+ expect(surface.releasePointerCapture).toHaveBeenCalledWith(37);
+ });
+
+ it('控件右键不被画布接管,初次打开不整理也能连续双指平移', async () => {
+ const { manager, surface, viewport } = await mountPointerWorkbench();
+ const zoom = screen.getByRole('button', { name: '放大画布' });
+ const before = viewport();
+ fireEvent.pointerDown(zoom, {
+ pointerId: 35,
+ button: 2,
+ clientX: 100,
+ clientY: 100,
+ });
+ fireEvent.pointerMove(surface, {
+ pointerId: 35,
+ buttons: 2,
+ clientX: 200,
+ clientY: 200,
+ });
+ expect(viewport()).toEqual(before);
+ expect(fireEvent.contextMenu(zoom, { button: 2 })).toBe(true);
+ await act(async () => {
+ for (let i = 0; i < 100; i++) {
+ fireEvent.wheel(manager, { deltaX: 3, deltaY: 6 });
+ }
+ });
+ expect(viewport()).toEqual([before[0]! - 300, before[1]! - 600, before[2]]);
+ });
+});
+
describe('资源画布手动重排口径', () => {
it('hook:rederiveNow 按 rederive 策略重算自动坐标并写回一次', async () => {
const projectId = 'manual-rederive-project';
diff --git a/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts b/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts
index bd46086ea..127bcc9db 100644
--- a/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts
+++ b/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts
@@ -206,7 +206,7 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => {
// `artKind`(其中含 `ui`)而被判成 art,于是角标显示「图片」,而预览调度按 art 走
// 图像分支、又因 mediaType 不是图像而兜底成 placeholder —— 卡片永不发起读取,
// 表现为「标着图片却只有占位图标」。类型判定改为 mediaType/扩展名优先、kind 只做
- // 兜底后,它们正确落文档分支,卡面渲染 JSON 文本摘要。
+ // 兜底后,它们进入文本读取通道;卡面再消费原生识别结果显示 UI 设计或 JSON。
expect(byPreviewKind).toEqual({
'raster-image': 52,
document: 8,
diff --git a/apps/ai-game-creator-shell/tests/resourceDocumentPreviewModel.test.ts b/apps/ai-game-creator-shell/tests/resourceDocumentPreviewModel.test.ts
index c89d79e99..00a6bd53e 100644
--- a/apps/ai-game-creator-shell/tests/resourceDocumentPreviewModel.test.ts
+++ b/apps/ai-game-creator-shell/tests/resourceDocumentPreviewModel.test.ts
@@ -40,19 +40,21 @@ describe('resourceDocumentPreviewMarkdown', () => {
['game/main.ts', 'typescript'],
['game/main.js', 'javascript'],
['game/main.py', 'python'],
+ ['assets/data.json', 'json'],
+ ['assets/DESIGN.JSON', 'json'],
])('%s 包为 %s 代码块', (path, language) => {
expect(
resourceDocumentPreviewMarkdown(resource(path), ' source\n\n\n'),
).toBe(`\`\`\`${language}\n source\n\n\n\`\`\``);
});
- it('JSON 规格按文档原文预览,不包成代码块', () => {
+ it('JSON 规格按原文代码块预览,不把字段值当 Markdown', () => {
expect(
resourceDocumentPreviewMarkdown(
resource('assets/data.json'),
- ' source\n\n\n',
+ '{ "text": "# 不应成为标题" }\n',
),
- ).toBe(' source\n\n\n');
+ ).toBe('```json\n{ "text": "# 不应成为标题" }\n```');
});
it('正文包含 Markdown 围栏时不会逃出代码块', () => {
diff --git a/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx b/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx
new file mode 100644
index 000000000..7e04d094e
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx
@@ -0,0 +1,205 @@
+/** @vitest-environment jsdom */
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ createGameCreationAppManifest,
+ fireEvent,
+ installResizeObserverStub,
+ ProjectDevelopmentView,
+ React,
+ render,
+ screen,
+ waitFor,
+ within,
+} from './appSurface/harness';
+
+vi.mock('@tauri-apps/api/core', async () => ({
+ ...(await vi.importActual(
+ '@tauri-apps/api/core',
+ )),
+ invoke: (command: string, args?: Record) =>
+ window.__TAURI__!.core.invoke(command, args),
+}));
+
+const projectId = 'json-canvas';
+const projectPath = '/tmp/json-canvas';
+const emptyState = {
+ ui_trees: [],
+ ui_design_images: {},
+ sprite_assets: {},
+ font_assets: {},
+};
+
+async function mountJsonCanvas(kind = 'ui', rejectPreview = false) {
+ installResizeObserverStub();
+ const manifest = createGameCreationAppManifest(projectId, 'JSON 资源测试');
+ manifest.assets = [
+ { id: 'design', kind, localPath: 'ui/design.json' },
+ { id: 'ordinary', kind: 'UI', localPath: 'assets/ordinary.json' },
+ { id: 'spoof', kind: 'UI', localPath: 'ui/spoof.json' },
+ ].map((asset) => ({
+ ...asset,
+ mediaType: 'application/json',
+ category: 'document' as const,
+ source: { kind: 'generated' as const },
+ }));
+ const invoke = vi.fn(
+ async (command: string, args?: Record) => {
+ if (command === 'read_local_project_resource_graph') {
+ const ids = (args?.resources as Array<{ resourceId: string }>).map(
+ (item) => item.resourceId,
+ );
+ return {
+ resourceIds: ids,
+ referenceEdges: [],
+ taskFlows: [],
+ producerAssignments: [],
+ dependencyDepths: ids.map((resourceId) => ({
+ resourceId,
+ dependencyDepth: 0,
+ })),
+ connectionIndex: ids.map((resourceId) => ({
+ resourceId,
+ upstreamReferenceResourceIds: [],
+ downstreamReferenceResourceIds: [],
+ referenceEdgeIds: [],
+ taskFlowIds: [],
+ })),
+ unresolvedReferenceResourceIds: [],
+ cyclicResourceIds: [],
+ cyclicTaskIds: [],
+ producerMappingTruncated: false,
+ };
+ }
+ if (
+ command === 'read_local_project_resource_canvas_layout' ||
+ command === 'update_local_project_resource_canvas_layout'
+ ) {
+ const layout = {
+ schemaVersion: 'game-creator-resource-layout.v1',
+ projectId,
+ mode: args?.mode,
+ revision: Number(args?.expectedRevision ?? 0) + 1,
+ positions: args?.positions ?? [],
+ updatedAt: 1,
+ };
+ return command.startsWith('update_')
+ ? { status: 'updated', layout }
+ : layout;
+ }
+ if (command === 'read_local_project_text_preview') {
+ if (rejectPreview) throw new Error('文档读取失败');
+ const path = args?.relativePath;
+ return {
+ path,
+ mediaType: 'application/json',
+ byteLen: 20,
+ content:
+ path === 'assets/ordinary.json'
+ ? '{"text":"# 不应当作标题"}'
+ : JSON.stringify({
+ schemaVersion: 'game-creator-ui-design-state.v1',
+ state: emptyState,
+ }),
+ ...(path === 'ui/design.json' ? { uiDesignAssetId: 'design' } : {}),
+ };
+ }
+ if (command === 'load_ui_design_state')
+ return { revision: 0, state: emptyState };
+ if (
+ command === 'list_pending_local_project_resource_edits' ||
+ command === 'list_local_project_asset_generations'
+ )
+ return [];
+ throw new Error(`unexpected command ${command}`);
+ },
+ );
+ window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__;
+ const onManifestChange = vi.fn();
+ render(
+ 对话 }
+ onManifestChange={onManifestChange}
+ />,
+ );
+ fireEvent.click(await screen.findByRole('button', { name: '打开文档' }));
+ const card = (id: string) =>
+ document.querySelector(
+ `.game-resource-book-scene-card.is-expanded [data-resource-card-id="asset:${id}"]`,
+ )!;
+ await waitFor(() =>
+ expect(card('design')?.getAttribute('data-preview-status')).toBe(
+ rejectPreview ? 'failed' : 'loaded',
+ ),
+ );
+ return { card, invoke, onManifestChange };
+}
+
+afterEach(() => {
+ delete window.__TAURI__;
+});
+
+describe('JSON 画布卡片与入口', () => {
+ it.each(['ui', 'document'])(
+ '合法 State 的 %s 登记显示 UI 设计,进入现有编辑器且不修改 manifest',
+ async (kind) => {
+ const { card, invoke, onManifestChange } = await mountJsonCanvas(kind);
+ const design = card('design');
+ expect(design.getAttribute('data-json-presentation')).toBe('ui-design');
+ expect(within(design).getByText('UI 设计')).not.toBeNull();
+ expect(within(design).getByText('UI 编辑器')).not.toBeNull();
+ expect(design.textContent).not.toContain('schemaVersion');
+ fireEvent.click(design.querySelector('button')!);
+ fireEvent.click(await screen.findByRole('button', { name: 'UI 编辑器' }));
+ await waitFor(() =>
+ expect(invoke).toHaveBeenCalledWith('load_ui_design_state', {
+ input: {
+ projectPath,
+ expectedProjectId: projectId,
+ assetId: 'design',
+ },
+ }),
+ );
+ expect(onManifestChange).not.toHaveBeenCalled();
+ },
+ );
+
+ it('普通 JSON 和伪 UI 文本保持 JSON 展示,无编辑器入口,详情按代码渲染', async () => {
+ const { card } = await mountJsonCanvas();
+ for (const id of ['ordinary', 'spoof']) {
+ const element = card(id);
+ expect(element.getAttribute('data-json-presentation')).toBe('json');
+ expect(
+ element.querySelector('[data-resource-type="JSON"]'),
+ ).not.toBeNull();
+ expect(element.textContent).not.toContain('schemaVersion');
+ fireEvent.click(element.querySelector('button')!);
+ expect(screen.queryByRole('button', { name: 'UI 编辑器' })).toBeNull();
+ }
+ fireEvent.click(card('ordinary').querySelector('button')!);
+ fireEvent.click(await screen.findByRole('button', { name: '预览' }));
+ const dialog = await screen.findByRole('dialog', { name: '文档预览' });
+ await waitFor(() =>
+ expect(dialog.querySelector('pre code')?.textContent).toContain(
+ '{"text":"# 不应当作标题"}',
+ ),
+ );
+ expect(
+ within(dialog).queryByRole('heading', { name: '不应当作标题' }),
+ ).toBeNull();
+ });
+
+ it('JSON 读取失败不授予编辑入口,仍可查看明确的读取错误', async () => {
+ const { card } = await mountJsonCanvas('UI', true);
+ fireEvent.click(card('design').querySelector('button')!);
+ expect(screen.queryByRole('button', { name: 'UI 编辑器' })).toBeNull();
+ fireEvent.click(screen.getByRole('button', { name: '预览' }));
+ expect(await screen.findByRole('alert')).not.toBeNull();
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/resourceJsonPresentation.test.ts b/apps/ai-game-creator-shell/tests/resourceJsonPresentation.test.ts
new file mode 100644
index 000000000..ecc685ed7
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/resourceJsonPresentation.test.ts
@@ -0,0 +1,87 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ type ProjectResourceCardPreviewState,
+ projectResourceJsonPresentation,
+} from '../src/view/project-development/resourceCardPreviewModel';
+import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel';
+
+const resource: ProjectResource = {
+ id: 'asset:json',
+ manifestAssetId: 'json',
+ path: 'ui/design.json',
+ label: '设计',
+ mediaType: 'application/json',
+ subtype: 'UI',
+ category: 'document',
+ sourceLabel: '',
+ taskTitle: null,
+ producerTaskId: null,
+ externalResourceId: null,
+ referenceResourceIds: [],
+ dependencies: [],
+ dependencyDepth: 0,
+};
+const verified: ProjectResourceCardPreviewState = {
+ status: 'loaded',
+ preview: {
+ path: resource.path,
+ mediaType: 'application/json',
+ byteLen: 2,
+ content: '{}',
+ uiDesignAssetId: 'json',
+ },
+};
+
+describe('JSON 卡片呈现', () => {
+ it.each(['UI', 'ui', 'ui-design', 'document'])(
+ '合法识别不依赖 %s 标签',
+ (subtype) => {
+ expect(
+ projectResourceJsonPresentation({ ...resource, subtype }, verified),
+ ).toBe('ui-design');
+ expect(
+ projectResourceJsonPresentation(
+ { ...resource, subtype },
+ {
+ ...verified,
+ preview: { ...verified.preview, uiDesignAssetId: undefined },
+ },
+ ),
+ ).toBe('json');
+ },
+ );
+ it('原生读取未完成、失败或身份不匹配时保持普通 JSON,不推断编辑能力', () => {
+ for (const preview of [
+ null,
+ { status: 'loading' },
+ { status: 'failed', error: '读取失败', retryable: true },
+ ] as const) {
+ expect(projectResourceJsonPresentation(resource, preview)).toBe('json');
+ }
+ expect(
+ projectResourceJsonPresentation(
+ { ...resource, manifestAssetId: 'other' },
+ verified,
+ ),
+ ).toBe('json');
+ expect(
+ projectResourceJsonPresentation(
+ { ...resource, path: 'other.json' },
+ verified,
+ ),
+ ).toBe('json');
+ expect(
+ projectResourceJsonPresentation(
+ { ...resource, manifestAssetId: null },
+ verified,
+ ),
+ ).toBe('json');
+ expect(
+ projectResourceJsonPresentation(
+ { ...resource, path: 'image.png', mediaType: 'image/png' },
+ verified,
+ ),
+ ).toBeNull();
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/templateLibraryGrid.test.ts b/apps/ai-game-creator-shell/tests/templateLibraryGrid.test.ts
new file mode 100644
index 000000000..2ddf16993
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/templateLibraryGrid.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ buildTemplateRows,
+ computeTemplateGridColumns,
+ computeTemplateGridLayout,
+ computeTemplateRowHeight,
+ TEMPLATE_CARD_GAP,
+ TEMPLATE_CARD_MIN_WIDTH,
+ TEMPLATE_CARD_TEXT_HEIGHT,
+} from '../src/features/template-library/templateLibraryGrid';
+import type { GameTemplateEntry } from '../src/features/template-library/templateLibraryModel';
+
+function entry(id: string): GameTemplateEntry {
+ return {
+ id,
+ title: id,
+ summary: '',
+ tags: [],
+ runtime: 'html',
+ engine: 'phaser',
+ engineVersion: '4.2.1',
+ templateVersion: '0.1.0',
+ updatedAt: '2026-09-17T00:00:00Z',
+ entry: 'game/index.html',
+ zipUrl: `https://oss.example/templates/v1/${id}/template.zip`,
+ zipSizeBytes: 1024,
+ zipSha256: 'a'.repeat(64),
+ coverUrl: `https://oss.example/templates/v1/${id}/cover.svg`,
+ coverWidth: 960,
+ coverHeight: 540,
+ installed: false,
+ installedVersion: null,
+ installedAtMillis: null,
+ };
+}
+
+describe('computeTemplateGridColumns', () => {
+ it('fits as many min-width columns as the container allows', () => {
+ expect(computeTemplateGridColumns(0)).toBe(1);
+ expect(computeTemplateGridColumns(200)).toBe(1);
+ expect(computeTemplateGridColumns(TEMPLATE_CARD_MIN_WIDTH)).toBe(1);
+ // 两列边界:2*250 + 14 = 514
+ const twoColumnWidth = 2 * TEMPLATE_CARD_MIN_WIDTH + TEMPLATE_CARD_GAP;
+ expect(computeTemplateGridColumns(twoColumnWidth)).toBe(2);
+ expect(computeTemplateGridColumns(twoColumnWidth - 1)).toBe(1);
+ // 1200px:4 列((1200+14)/(250+14) = 4.59)
+ expect(computeTemplateGridColumns(1200)).toBe(4);
+ });
+});
+
+describe('computeTemplateRowHeight', () => {
+ it('keeps cover ratio + fixed text block', () => {
+ // 列宽 300 → 卡片 286 → 封面 286*9/16 = 160.875 → 161
+ expect(computeTemplateRowHeight(300)).toBe(
+ 161 + TEMPLATE_CARD_TEXT_HEIGHT + TEMPLATE_CARD_GAP,
+ );
+ // 极窄时按最小卡片宽度兜底,避免行高被压成 0
+ expect(computeTemplateRowHeight(10)).toBeGreaterThan(
+ TEMPLATE_CARD_TEXT_HEIGHT,
+ );
+ });
+});
+
+describe('computeTemplateGridLayout', () => {
+ it('derives columns, row height and row count for a big library', () => {
+ const layout = computeTemplateGridLayout({
+ containerWidth: 1200,
+ itemCount: 1000,
+ });
+ expect(layout.columnCount).toBe(4);
+ expect(layout.columnWidth).toBe(300);
+ expect(layout.rowHeight).toBe(
+ 161 + TEMPLATE_CARD_TEXT_HEIGHT + TEMPLATE_CARD_GAP,
+ );
+ expect(layout.rowCount).toBe(250);
+ // 虚拟列表只渲染可视行,滚动高度仍由总行数决定
+ expect(layout.rowCount * layout.rowHeight).toBeGreaterThan(80000);
+ });
+
+ it('handles inline and empty libraries', () => {
+ expect(
+ computeTemplateGridLayout({ containerWidth: 0, itemCount: 5 }),
+ ).toEqual({
+ columnCount: 1,
+ columnWidth: 1,
+ rowHeight: computeTemplateRowHeight(1),
+ rowCount: 5,
+ });
+ expect(
+ computeTemplateGridLayout({ containerWidth: 1200, itemCount: 0 })
+ .rowCount,
+ ).toBe(0);
+ });
+});
+
+describe('buildTemplateRows', () => {
+ it('chunks entries per row and pads the tail with nulls', () => {
+ const rows = buildTemplateRows([entry('a'), entry('b'), entry('c')], 2);
+ expect(rows).toHaveLength(2);
+ expect(rows[0]?.map((item) => item?.id)).toEqual(['a', 'b']);
+ expect(rows[1]?.map((item) => item?.id ?? null)).toEqual(['c', null]);
+ });
+
+ it('returns no rows for an invalid column count', () => {
+ expect(buildTemplateRows([entry('a')], 0)).toEqual([]);
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/templateLibraryModel.test.ts b/apps/ai-game-creator-shell/tests/templateLibraryModel.test.ts
new file mode 100644
index 000000000..5eb52ca01
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/templateLibraryModel.test.ts
@@ -0,0 +1,183 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ collectGameTemplateRuntimes,
+ collectGameTemplateTags,
+ EMPTY_TEMPLATE_LIBRARY_FILTERS,
+ filterGameTemplates,
+ formatGameTemplateSize,
+ type GameTemplateEntry,
+ isTemplateLibraryFiltersEmpty,
+ needsTemplateDownload,
+ templateMatchesQuery,
+ templateRuntimeLabel,
+ toggleGameTemplateTag,
+} from '../src/features/template-library/templateLibraryModel';
+
+function template(
+ overrides: Partial & Pick,
+): GameTemplateEntry {
+ return {
+ title: '未命名模板',
+ summary: '',
+ tags: [],
+ runtime: 'html',
+ engine: 'phaser',
+ engineVersion: '4.2.1',
+ templateVersion: '1.0.0',
+ updatedAt: '2026-09-17T00:00:00Z',
+ entry: 'game/index.html',
+ zipUrl:
+ 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/templates/v1/demo/template.zip',
+ zipSizeBytes: 2048,
+ zipSha256: 'a'.repeat(64),
+ coverUrl:
+ 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/templates/v1/demo/cover.png',
+ coverWidth: 960,
+ coverHeight: 540,
+ installed: false,
+ installedVersion: null,
+ installedAtMillis: null,
+ ...overrides,
+ };
+}
+
+const matchThree = template({
+ id: 'match-3',
+ title: '三消经营',
+ summary: '三消与模拟经营的融合模板',
+ tags: ['三消', '经营'],
+ engine: 'phaser',
+ installed: true,
+ installedVersion: '1.0.0',
+});
+const pixelFarm = template({
+ id: 'pixel-farm',
+ title: '像素农场',
+ summary: '像素风种植玩法',
+ tags: ['经营', '像素'],
+ engine: 'godot',
+ runtime: 'godot',
+ templateVersion: '2.0.0',
+ installed: true,
+ installedVersion: '1.0.0',
+});
+const spaceShooter = template({
+ id: 'space-shooter',
+ title: '太空射击',
+ summary: '纵版弹幕射击',
+ tags: ['射击'],
+ runtime: 'unity',
+ engine: 'unity',
+ installed: false,
+});
+
+const templates: GameTemplateEntry[] = [matchThree, pixelFarm, spaceShooter];
+
+describe('templateMatchesQuery', () => {
+ it('matches title, summary, tags and engine case-insensitively', () => {
+ expect(templateMatchesQuery(matchThree, '三消')).toBe(true);
+ expect(templateMatchesQuery(matchThree, '经营')).toBe(true);
+ expect(templateMatchesQuery(matchThree, 'PHASER')).toBe(true);
+ expect(templateMatchesQuery(matchThree, '弹幕')).toBe(false);
+ });
+
+ it('requires every whitespace separated term to match', () => {
+ expect(templateMatchesQuery(pixelFarm, '像素 种植')).toBe(true);
+ expect(templateMatchesQuery(pixelFarm, '像素 弹幕')).toBe(false);
+ expect(templateMatchesQuery(pixelFarm, ' ')).toBe(true);
+ });
+});
+
+describe('filterGameTemplates', () => {
+ it('filters by tag, runtime, query and installed state together', () => {
+ expect(
+ filterGameTemplates(templates, {
+ ...EMPTY_TEMPLATE_LIBRARY_FILTERS,
+ tags: ['经营'],
+ }).map((entry) => entry.id),
+ ).toEqual(['match-3', 'pixel-farm']);
+
+ expect(
+ filterGameTemplates(templates, {
+ ...EMPTY_TEMPLATE_LIBRARY_FILTERS,
+ runtime: 'godot',
+ }).map((entry) => entry.id),
+ ).toEqual(['pixel-farm']);
+
+ expect(
+ filterGameTemplates(templates, {
+ ...EMPTY_TEMPLATE_LIBRARY_FILTERS,
+ installedOnly: true,
+ query: '经营',
+ tags: ['像素'],
+ }).map((entry) => entry.id),
+ ).toEqual(['pixel-farm']);
+ });
+
+ it('returns everything when no filter is active', () => {
+ expect(
+ filterGameTemplates(templates, EMPTY_TEMPLATE_LIBRARY_FILTERS),
+ ).toHaveLength(3);
+ expect(isTemplateLibraryFiltersEmpty(EMPTY_TEMPLATE_LIBRARY_FILTERS)).toBe(
+ true,
+ );
+ expect(
+ isTemplateLibraryFiltersEmpty({
+ ...EMPTY_TEMPLATE_LIBRARY_FILTERS,
+ query: ' x ',
+ }),
+ ).toBe(false);
+ });
+});
+
+describe('tag and runtime options', () => {
+ it('orders tags by frequency and drops blanks', () => {
+ const withBlank = [
+ ...templates,
+ template({ id: 'blank-tag', tags: ['', ' ', '经营'] }),
+ ];
+ expect(collectGameTemplateTags(withBlank)).toEqual([
+ '经营',
+ '三消',
+ '射击',
+ '像素',
+ ]);
+ });
+
+ it('collects distinct runtimes and labels them', () => {
+ expect(collectGameTemplateRuntimes(templates)).toEqual([
+ 'godot',
+ 'html',
+ 'unity',
+ ]);
+ expect(templateRuntimeLabel('html')).toBe('网页');
+ expect(templateRuntimeLabel('cocos')).toBe('Cocos');
+ expect(templateRuntimeLabel('')).toBe('未标注运行时');
+ expect(templateRuntimeLabel('custom-engine')).toBe('custom-engine');
+ });
+
+ it('toggles tags without mutating the previous filters', () => {
+ const next = toggleGameTemplateTag(EMPTY_TEMPLATE_LIBRARY_FILTERS, '经营');
+ expect(next.tags).toEqual(['经营']);
+ expect(toggleGameTemplateTag(next, '经营').tags).toEqual([]);
+ expect(EMPTY_TEMPLATE_LIBRARY_FILTERS.tags).toEqual([]);
+ });
+});
+
+describe('needsTemplateDownload', () => {
+ it('requires a download when missing or when the installed version is stale', () => {
+ expect(needsTemplateDownload(matchThree)).toBe(false);
+ expect(needsTemplateDownload(pixelFarm)).toBe(true);
+ expect(needsTemplateDownload(spaceShooter)).toBe(true);
+ });
+});
+
+describe('formatGameTemplateSize', () => {
+ it('formats bytes, kilobytes and megabytes', () => {
+ expect(formatGameTemplateSize(0)).toBe('--');
+ expect(formatGameTemplateSize(512)).toBe('512 B');
+ expect(formatGameTemplateSize(2048)).toBe('2.0 KB');
+ expect(formatGameTemplateSize(5 * 1024 * 1024)).toBe('5.0 MB');
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx b/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx
new file mode 100644
index 000000000..4182d8c1e
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx
@@ -0,0 +1,494 @@
+// @vitest-environment jsdom
+import { fireEvent, render, screen, within } from '@testing-library/react';
+import { beforeAll, describe, expect, it, vi } from 'vitest';
+
+import { computeTemplateGridLayout } from '../src/features/template-library/templateLibraryGrid';
+import type {
+ GameTemplateEntry,
+ TemplateLibraryFilters,
+} from '../src/features/template-library/templateLibraryModel';
+import {
+ collectGameTemplateTags,
+ EMPTY_TEMPLATE_LIBRARY_FILTERS,
+ filterGameTemplates,
+} from '../src/features/template-library/templateLibraryModel';
+import type { TemplateLibraryController } from '../src/features/template-library/useTemplateLibrary';
+import TemplateRecommendations from '../src/view/home/TemplateRecommendations';
+import TemplateLibraryView from '../src/view/template-library';
+
+const OSS_BASE = 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com';
+
+function template(
+ overrides: Partial & Pick,
+): GameTemplateEntry {
+ return {
+ title: '未命名模板',
+ summary: '',
+ tags: [],
+ runtime: 'html',
+ engine: 'phaser',
+ engineVersion: '4.2.1',
+ templateVersion: '0.1.0',
+ updatedAt: '2026-09-17T00:00:00Z',
+ entry: 'game/index.html',
+ zipUrl: `${OSS_BASE}/templates/v1/${overrides.id}/template.zip`,
+ zipSizeBytes: 2048,
+ zipSha256: 'a'.repeat(64),
+ coverUrl: `${OSS_BASE}/templates/v1/${overrides.id}/cover.svg`,
+ coverWidth: 960,
+ coverHeight: 540,
+ installed: false,
+ installedVersion: null,
+ installedAtMillis: null,
+ ...overrides,
+ };
+}
+
+const blankWeb = template({
+ id: 'blank-web',
+ title: '空白网页工程',
+ summary: '零依赖最小网页工程',
+ tags: ['空白', '网页'],
+ engine: 'none',
+ engineVersion: '',
+ installed: true,
+ installedVersion: '0.1.0',
+ installedAtMillis: 1789000000000,
+});
+const blankCanvas = template({
+ id: 'blank-2d-canvas',
+ title: '空白二维画布工程',
+ tags: ['空白', '2d', 'canvas'],
+ engine: 'canvas',
+ engineVersion: '',
+});
+const templates = [blankCanvas, blankWeb];
+
+function controller(
+ overrides: Partial = {},
+): TemplateLibraryController {
+ const filters: TemplateLibraryFilters = {
+ query: '',
+ tags: [],
+ runtime: '',
+ installedOnly: false,
+ };
+ return {
+ snapshot: null,
+ status: 'ready',
+ error: '',
+ notice: '',
+ templates,
+ visibleTemplates: templates,
+ tagOptions: ['空白', '2d', 'canvas', '网页'],
+ runtimeOptions: ['html'],
+ installedCount: 1,
+ filters,
+ filtersActive: false,
+ setQuery: vi.fn(),
+ selectRuntime: vi.fn(),
+ toggleTag: vi.fn(),
+ setInstalledOnly: vi.fn(),
+ clearFilters: vi.fn(),
+ busyTemplateId: null,
+ busyKind: null,
+ refresh: vi.fn(),
+ downloadTemplate: vi.fn(async () => undefined),
+ createProjectFromTemplate: vi.fn(async () => undefined),
+ clearNotice: vi.fn(),
+ ...overrides,
+ } as unknown as TemplateLibraryController;
+}
+
+function cardFor(title: string): HTMLElement {
+ const heading = screen.getByText(title);
+ const card = heading.closest('article');
+ if (!card) throw new Error(`找不到卡片:${title}`);
+ return card;
+}
+
+// 虚拟列表需要可测量的视口:jsdom 没有布局,这里给网格容器固定尺寸并补 ResizeObserver/scrollTo。
+const GRID_VIEWPORT = { width: 1200, height: 800 };
+
+beforeAll(() => {
+ const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
+ Element.prototype.getBoundingClientRect = function getBoundingClientRect() {
+ const element = this as HTMLElement;
+ if (element.dataset?.templateGridViewport === 'true') {
+ return {
+ width: GRID_VIEWPORT.width,
+ height: GRID_VIEWPORT.height,
+ top: 0,
+ left: 0,
+ right: GRID_VIEWPORT.width,
+ bottom: GRID_VIEWPORT.height,
+ x: 0,
+ y: 0,
+ toJSON: () => ({}),
+ } as DOMRect;
+ }
+ return originalGetBoundingClientRect.call(this);
+ };
+ if (typeof Element.prototype.scrollTo !== 'function') {
+ Element.prototype.scrollTo = () => undefined;
+ }
+ class ResizeObserverStub {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ }
+ vi.stubGlobal('ResizeObserver', ResizeObserverStub);
+});
+
+describe('TemplateLibraryView', () => {
+ it('renders a card per template with cover, meta, tags and installed badge', () => {
+ render( {}} />);
+
+ expect(screen.getByRole('heading', { name: '模板库' })).toBeTruthy();
+ expect(screen.getByText('共 2 个模板 · 已下载 1 个')).toBeTruthy();
+
+ const blankWebCard = cardFor('空白网页工程');
+ const cover = blankWebCard.querySelector('img');
+ expect(cover?.getAttribute('src')).toBe(
+ `${OSS_BASE}/templates/v1/blank-web/cover.svg`,
+ );
+ expect(blankWebCard.textContent).toContain('已下载');
+ expect(blankWebCard.textContent).toContain('网页 · none · v0.1.0 · 2.0 KB');
+ expect(blankWebCard.textContent).toContain('空白');
+ expect(blankWebCard.textContent).toContain('零依赖最小网页工程');
+ // 已下载且版本一致:不再显示下载入口,只留「使用模板」。
+ expect(
+ within(blankWebCard).queryByRole('button', { name: /下载/ }),
+ ).toBeNull();
+ expect(
+ within(blankWebCard).getByRole('button', { name: /使用模板/ }),
+ ).toBeTruthy();
+
+ const blankCanvasCard = cardFor('空白二维画布工程');
+ expect(blankCanvasCard.textContent).not.toContain('已下载');
+ expect(blankCanvasCard.querySelector('img')?.getAttribute('src')).toBe(
+ `${OSS_BASE}/templates/v1/blank-2d-canvas/cover.svg`,
+ );
+ expect(
+ within(blankCanvasCard).getByRole('button', { name: /下载/ }),
+ ).toBeTruthy();
+ });
+
+ it('keeps the launcher theme contract and owns its own scroll viewport', () => {
+ // 回归点:`.launcher-main` 只给带 platform-theme 的直接子元素 height:100%;
+ // 卡片列表改由虚拟网格自己的视口滚动(整页不再随模板数量变长)。
+ const { container } = render(
+ {}} />,
+ );
+ const page = container.querySelector('section[aria-label="模板库"]');
+ expect(page?.className).toContain('platform-theme');
+ const viewport = container.querySelector(
+ '[data-template-grid-viewport="true"]',
+ );
+ expect(viewport).not.toBeNull();
+ expect(viewport?.querySelector('article')).not.toBeNull();
+ });
+
+ it('offers 更新 instead of 下载 when the installed version is stale', () => {
+ const stale = template({
+ id: 'blank-web',
+ title: '空白网页工程',
+ summary: '零依赖最小网页工程',
+ tags: ['空白', '网页'],
+ installed: true,
+ installedVersion: '0.0.9',
+ installedAtMillis: 1789000000000,
+ });
+ render(
+ {}}
+ />,
+ );
+
+ const card = cardFor('空白网页工程');
+ expect(within(card).getByRole('button', { name: /更新/ })).toBeTruthy();
+ expect(within(card).queryByRole('button', { name: /^下载/ })).toBeNull();
+ });
+
+ it('routes search, tag, runtime and installed-only controls through the controller', () => {
+ const setQuery = vi.fn();
+ const toggleTag = vi.fn();
+ const selectRuntime = vi.fn();
+ const setInstalledOnly = vi.fn();
+ const clearFilters = vi.fn();
+ render(
+ {}}
+ />,
+ );
+
+ fireEvent.change(screen.getByLabelText('搜索模板'), {
+ target: { value: '空白 网页' },
+ });
+ expect(setQuery).toHaveBeenCalledWith('空白 网页');
+
+ fireEvent.click(screen.getByRole('button', { name: '标签筛选 canvas' }));
+ expect(toggleTag).toHaveBeenCalledWith('canvas');
+
+ fireEvent.click(screen.getByRole('button', { name: '运行时筛选 网页' }));
+ expect(selectRuntime).toHaveBeenCalledWith('html');
+
+ fireEvent.click(screen.getByRole('button', { name: '仅看已下载' }));
+ expect(setInstalledOnly).toHaveBeenCalledWith(true);
+
+ fireEvent.click(screen.getByRole('button', { name: '清除筛选' }));
+ expect(clearFilters).toHaveBeenCalled();
+ });
+
+ it('starts a download and a template project from the card actions', () => {
+ const downloadTemplate = vi.fn(async () => undefined);
+ const createProjectFromTemplate = vi.fn(async () => undefined);
+ render(
+ {}}
+ />,
+ );
+
+ fireEvent.click(
+ within(cardFor('空白二维画布工程')).getByRole('button', { name: /下载/ }),
+ );
+ expect(downloadTemplate).toHaveBeenCalledWith(blankCanvas);
+
+ fireEvent.click(
+ within(cardFor('空白二维画布工程')).getByRole('button', {
+ name: /使用模板/,
+ }),
+ );
+ expect(createProjectFromTemplate).toHaveBeenCalledWith(blankCanvas);
+ });
+
+ it('disables card actions while a template is busy', () => {
+ render(
+ {}}
+ />,
+ );
+
+ const busyCard = cardFor('空白二维画布工程');
+ const buttons = Array.from(busyCard.querySelectorAll('button'));
+ expect(buttons.every((button) => button.hasAttribute('disabled'))).toBe(
+ true,
+ );
+ expect(busyCard.textContent).toContain('正在创建项目');
+ expect(cardFor('空白网页工程').textContent).not.toContain('正在创建项目');
+ });
+
+ it('shows empty, no-match, error and notice states', () => {
+ const { unmount } = render(
+ {}}
+ />,
+ );
+ expect(screen.getByText('模板库暂时还没有可用的模板。')).toBeTruthy();
+ unmount();
+
+ const { unmount: unmountNoMatch } = render(
+ {}}
+ />,
+ );
+ expect(screen.getByText('没有符合当前筛选的模板')).toBeTruthy();
+ unmountNoMatch();
+
+ render(
+ {}}
+ />,
+ );
+ expect(screen.getByRole('alert').textContent).toContain(
+ '模板库返回 HTTP 503',
+ );
+ expect(
+ screen.getByText('远端清单暂时读不到,当前展示本机缓存'),
+ ).toBeTruthy();
+ });
+
+ it('renders process notices as a floating toast outside the page and auto-dismisses it', () => {
+ vi.useFakeTimers();
+ const clearNotice = vi.fn();
+ try {
+ const { container } = render(
+ {}}
+ />,
+ );
+
+ const toast = document.body.querySelector(
+ '[data-template-library-toast="true"]',
+ );
+ expect(toast).not.toBeNull();
+ expect(toast?.textContent).toContain('已下载模板「空白网页工程」');
+ expect(toast?.querySelector('[role="status"]')?.textContent).toContain(
+ '已下载模板「空白网页工程」',
+ );
+ // 提示不再占用页面内位置。
+ expect(
+ container.querySelector('[data-template-library-toast]'),
+ ).toBeNull();
+
+ vi.advanceTimersByTime(2600);
+ expect(clearNotice).toHaveBeenCalledTimes(1);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
+
+describe('大库量渲染(1000 条假数据)', () => {
+ const bulk = Array.from({ length: 1000 }, (_, index) =>
+ template({
+ id: `bulk-${index}`,
+ title: `批量模板 ${index}`,
+ summary: '压测条目',
+ tags: ['起步工程', `批次-${String(index % 20).padStart(2, '0')}`],
+ installed: index % 3 === 0,
+ installedVersion: index % 3 === 0 ? '0.1.0' : null,
+ }),
+ );
+
+ it('virtualizes a 1000 entries library instead of rendering every card', () => {
+ const { container } = render(
+ entry.installed).length,
+ tagOptions: collectGameTemplateTags(bulk),
+ })}
+ onBack={() => {}}
+ />,
+ );
+
+ // 虚拟列表只渲染可视区域(1200×800 视口 → 4 列 × 约 3 行 + 2 行 overscan)。
+ const renderedCards = container.querySelectorAll('article').length;
+ expect(renderedCards).toBeGreaterThan(0);
+ expect(renderedCards).toBeLessThanOrEqual(40);
+ expect(screen.getByText('共 1000 个模板 · 已下载 334 个')).toBeTruthy();
+ // 滚动高度仍按全部行数计算。
+ const layout = computeTemplateGridLayout({
+ containerWidth: GRID_VIEWPORT.width,
+ itemCount: bulk.length,
+ });
+ expect(layout.columnCount).toBe(4);
+ expect(layout.rowCount).toBe(250);
+ const totalHeight = `${250 * layout.rowHeight}px`;
+ const hasSpacer = Array.from(container.querySelectorAll('div')).some(
+ (element) => (element as HTMLElement).style.height === totalHeight,
+ );
+ expect(hasSpacer).toBe(true);
+ // 标签筛选条会随库量膨胀,这里先记录当前聚合出来的规模(1000 条 × 批次标签)。
+ const tagButtons = screen
+ .getAllByRole('button')
+ .filter((button) =>
+ button.getAttribute('aria-label')?.startsWith('标签筛选'),
+ );
+ expect(tagButtons.length).toBeGreaterThan(20);
+
+ // 纯前端筛选在大库量下仍然是 O(n) 的一遍过滤,数量与已安装态自洽。
+ const installedOnly = filterGameTemplates(bulk, {
+ ...EMPTY_TEMPLATE_LIBRARY_FILTERS,
+ installedOnly: true,
+ });
+ expect(installedOnly).toHaveLength(334);
+ expect(
+ filterGameTemplates(bulk, {
+ ...EMPTY_TEMPLATE_LIBRARY_FILTERS,
+ tags: ['批次-07'],
+ }),
+ ).toHaveLength(50);
+ expect(
+ filterGameTemplates(bulk, {
+ ...EMPTY_TEMPLATE_LIBRARY_FILTERS,
+ query: '批量模板 999',
+ }).map((entry) => entry.id),
+ ).toEqual(['bulk-999']);
+ });
+});
+
+describe('TemplateRecommendations', () => {
+ it('renders the recommended templates and opens the library', () => {
+ const onOpenLibrary = vi.fn();
+ render(
+ ,
+ );
+
+ const recommendation = screen.getByRole('button', {
+ name: '查看模板 空白网页工程',
+ });
+ expect(recommendation.querySelector('img')?.getAttribute('src')).toBe(
+ `${OSS_BASE}/templates/v1/blank-web/cover.svg`,
+ );
+ expect(recommendation.textContent).toContain('已下载');
+ fireEvent.click(recommendation);
+ expect(onOpenLibrary).toHaveBeenCalled();
+ });
+
+ it('falls back to an empty state with a library entry', () => {
+ const onOpenLibrary = vi.fn();
+ render(
+ ,
+ );
+
+ expect(screen.getByText('需要在陶泥儿客户端内运行')).toBeTruthy();
+ fireEvent.click(screen.getByRole('button', { name: '打开模板库' }));
+ expect(onOpenLibrary).toHaveBeenCalled();
+ });
+
+ it('renders a loading state before the first snapshot arrives', () => {
+ render(
+ {}}
+ />,
+ );
+ expect(screen.getByText('正在读取模板库…')).toBeTruthy();
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts
index 6e821454f..01876526e 100644
--- a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts
+++ b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts
@@ -141,6 +141,60 @@ afterEach(() => {
});
describe('useProjectResourceCardPreviews', () => {
+ it('JSON 识别结果经过现有预取缓存保留,换项目后不复用旧编辑能力', async () => {
+ const json = resource('design', {
+ path: 'ui/design.json',
+ subtype: 'ui',
+ mediaType: 'application/json',
+ });
+ const invoke = vi.fn(
+ async (_command: string, args?: Record) => ({
+ path: json.path,
+ mediaType: json.mediaType,
+ byteLen: 2,
+ content: '{}',
+ ...(args?.projectPath === '/tmp/first-project'
+ ? { uiDesignAssetId: 'design' }
+ : {}),
+ }),
+ );
+ window.__TAURI__ = { core: { invoke } };
+ const resources = [json];
+ const canvasRef = { current: document.createElement('div') };
+ const { result, rerender } = renderHook(
+ ({ projectPath }) =>
+ useProjectResourceCardPreviews({
+ projectPath,
+ projectId: projectPath,
+ mode: 'dependency',
+ resources,
+ canvasRef,
+ eagerPreviewLimit: 12,
+ }),
+ { initialProps: { projectPath: '/tmp/first-project' } },
+ );
+ const identity = () => result.current.identityByResourceId.get(json.id)!;
+ await waitFor(() =>
+ expect(result.current.previews.get(identity())).toMatchObject({
+ status: 'loaded',
+ preview: { uiDesignAssetId: 'design' },
+ }),
+ );
+ rerender({ projectPath: '/tmp/second-project' });
+ await waitFor(() =>
+ expect(result.current.previews.get(identity())?.status).toBe('loaded'),
+ );
+ const state = result.current.previews.get(identity());
+ expect(
+ state?.status === 'loaded' && state.preview.uiDesignAssetId,
+ ).toBeUndefined();
+ expect(
+ invoke.mock.calls.filter(
+ ([command]) => command === 'read_local_project_text_preview',
+ ),
+ ).toHaveLength(2);
+ });
+
it('代码卡不预取正文,显式详情复用文本预览队列与缓存', async () => {
const code = resource('code', {
path: 'game/main.ts',
diff --git a/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx
new file mode 100644
index 000000000..457f61417
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx
@@ -0,0 +1,111 @@
+/** @vitest-environment jsdom */
+import type { ReactNode } from 'react';
+import { useCallback } from 'react';
+import { vi } from 'vitest';
+
+import { WindowChrome } from '../src/components/WindowChrome';
+import {
+ useWindowChrome,
+ type WindowChromeActiveProjectRuns,
+ WindowChromeContext,
+} from '../src/components/windowChromeContext';
+import { WorkspaceLauncherShell } from '../src/features/app-shell/WorkspaceLauncher';
+import {
+ act,
+ expect,
+ it,
+ React,
+ render,
+ testAuthUser,
+} from './appSurface/harness';
+
+const homeProjectOverride = vi.hoisted(() => ({
+ openProject: null as
+ | null
+ | ((path: string, mode: 'open' | 'create') => Promise),
+}));
+
+vi.mock('../src/features/app-shell/useHomeProjectCreation', async () => {
+ const actual = await vi.importActual<
+ typeof import('../src/features/app-shell/useHomeProjectCreation')
+ >('../src/features/app-shell/useHomeProjectCreation');
+ return {
+ ...actual,
+ useHomeProjectCreation(
+ ...args: Parameters
+ ) {
+ const result = actual.useHomeProjectCreation(...args);
+ return homeProjectOverride.openProject
+ ? { ...result, openProject: homeProjectOverride.openProject }
+ : result;
+ },
+ };
+});
+
+it('真实窗口与工作台状态同步收敛,回调读取最新处理器且卸载才清理', async () => {
+ const publications: WindowChromeActiveProjectRuns[] = [];
+ let cleanups = 0;
+ // 仍经过真实 WindowChrome 的 setState/Context;上限只防止回归时测试无限循环。
+ function BoundedWindowBridge({ children }: { children: ReactNode }) {
+ const chrome = useWindowChrome();
+ const { setActiveProjectRuns } = chrome;
+ const publish = useCallback(
+ (next: WindowChromeActiveProjectRuns | null) => {
+ if (next) publications.push(next);
+ else cleanups += 1;
+ if (publications.length < 12) setActiveProjectRuns(next);
+ },
+ [setActiveProjectRuns],
+ );
+ return (
+
+ {children}
+
+ );
+ }
+ const supervisor = () => null;
+ const view = (displayName = '测试用户') => (
+
+
+ undefined}
+ initialView="projects"
+ ProjectSupervisor={supervisor}
+ />
+
+
+ );
+ delete window.__TAURI__;
+ const rendered = render(view());
+ try {
+ await act(async () => {
+ await Promise.resolve();
+ });
+ // 无原生 invoke 时 active-turn Hook 会把初始快照归一为空数组一次。
+ expect(publications).toHaveLength(2);
+ expect(cleanups).toBe(0);
+ expect(new Set(publications.map((item) => item.onOpenProject)).size).toBe(
+ 1,
+ );
+ const openProject = publications[0]!.onOpenProject!;
+ const latestOpen = vi.fn(async () => undefined);
+ homeProjectOverride.openProject = latestOpen;
+ rendered.rerender(view('更改显示名'));
+ expect(publications).toHaveLength(2);
+ act(() => openProject('/tmp/window-latest-project'));
+ expect(latestOpen).toHaveBeenCalledWith(
+ '/tmp/window-latest-project',
+ 'open',
+ );
+ expect(publications).toHaveLength(2);
+ expect(cleanups).toBe(0);
+ rendered.unmount();
+ expect(cleanups).toBe(1);
+ } finally {
+ homeProjectOverride.openProject = null;
+ rendered.unmount();
+ }
+});
diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example
index 9b508dac1..eefb1ae55 100644
--- a/deploy/env/api-server.env.example
+++ b/deploy/env/api-server.env.example
@@ -162,6 +162,16 @@ ALIYUN_OSS_POST_EXPIRE_SECONDS=600
ALIYUN_OSS_POST_MAX_SIZE_BYTES=20971520
ALIYUN_OSS_SUCCESS_ACTION_STATUS=200
+# AGC 项目定时快照上传目标。对象只落在服务端私有前缀
+# agc/project-snapshots/v1/{user}/{project}/ 下;AccessKey 为空时回退 ALIYUN_OSS_ACCESS_KEY_*,
+# 因此回退凭据必须对目标 bucket 具备该前缀的 PutObject/GetObject/DeleteObject 权限。
+# bucket 未单独配置时默认 agc-dev,未配置凭据时 api-server 跳过该客户端,
+# 接口返回 503 且客户端失败关闭(不写空对象、不推进本地索引)。
+GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET=agc-dev
+GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT=oss-rg-china-mainland.aliyuncs.com
+GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID=
+GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET=
+
# SpacetimeDB 数据目录 OSS 冷备份配置。可由 cron / Jenkins 调用发布包内 scripts/database-backup-to-oss.mjs。
GENARRATIVE_DATABASE_BACKUP_DATA_DIR=/stdb
GENARRATIVE_DATABASE_BACKUP_WORK_DIR=/var/lib/genarrative/database-backups
diff --git a/docs/README.md b/docs/README.md
index 244df1992..3de2b212c 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -39,6 +39,7 @@
- [AGC 通用插件宿主与编辑器适配](./technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md):通用插件宿主、SDK、权限审计、UI 挂载和 Cocos 编辑器适配边界。
- [AGC Cocos Creator 编辑器桥接模块](<./technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md>):独立 crate、feature 开关、目标校验与 Windows 注入边界。
- [AGC 客户端更新检查与下载](./technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md):启动版本检测、OSS 清单格式和下载约定。
+- [AGC 模板库与模板建项](./technical/【技术方案】AGC模板库与模板建项-2026-09-17.md):`templates/` 前缀的模板库契约、下载安装与「用模板建项目」链路。
- [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。
- [Direct 回合行为审计账本](./technical/【技术方案】Direct回合行为审计账本-2026-08-31.md):Direct GUI 回合把 native 读 / MCP / 写文件落成项目内有界时间线,用于判断有没有打开本轮附件。
- [项目开发工作台 PRD](./prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md):当前工作台页面和验收边界。
diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json
index a70761fa3..c120926f0 100644
--- a/docs/openapi/genarrative-external-v1.openapi.json
+++ b/docs/openapi/genarrative-external-v1.openapi.json
@@ -3363,7 +3363,7 @@
},
"EditorIconSpritesheetGenerationRequest": {
"type": "object",
- "required": ["referenceId", "iconDescriptions"],
+ "required": ["referenceId", "iconDescriptions", "sliceMode"],
"properties": {
"referenceId": {
"type": "string",
@@ -3395,26 +3395,25 @@
"connected-components",
"grid"
],
- "default": "connected-components",
- "description": "图集切分模式。connected-components 按透明像素 alpha 连通域识别独立素材;grid 按用户提供的 gridX/gridY 划分网格槽。省略时使用 connected-components。"
+ "description": "必填,没有默认值:必须在引用解析、定价、入队和任何 provider / OSS 副作用之前显式声明切分模式。需求明确要求等分网格、固定槽位或指定行列数时传 grid,并用 gridX/gridY 传入来自需求本身的行列数;自由排布、数量不定或只要求一张图集时传 connected-components,需要约束素材张数时用 sliceCount。connected-components 不接受 gridX/gridY,grid 必须同时提供 gridX/gridY(各 1..32)。省略、null 或空字符串返回 400(field=sliceMode),模式与网格参数互相矛盾返回 400(field=gridX/gridY),两者都不会产生计费、入队或 provider 调用。响应中的 sliceMode 回显本次实际采用的模式。"
},
"gridX": {
"type": "integer",
"minimum": 1,
"maximum": 32,
- "description": "grid 模式的横向网格数量。"
+ "description": "grid 模式的横向网格数量,只能与 sliceMode=grid 同时出现;与 connected-components 同时提交返回 400。"
},
"gridY": {
"type": "integer",
"minimum": 1,
"maximum": 32,
- "description": "grid 模式的纵向网格数量。"
+ "description": "grid 模式的纵向网格数量,只能与 sliceMode=grid 同时出现;与 connected-components 同时提交返回 400。"
},
"sliceCount": {
"type": "integer",
"minimum": 1,
- "maximum": 100,
- "description": "connected-components 模式下可选的目标切片数量;省略时按图像内容自动识别。grid 模式的切片数量由 gridX×gridY 决定。"
+ "maximum": 256,
+ "description": "connected-components 模式下可选的目标切片数量(1..256);省略时按图像内容自动识别上限。识别结果与该目标数量不一致、为 0 或超过 256 时返回 422 并给出实际识别数量,不会静默截断。grid 模式的切片数量由 gridX×gridY 决定,不接受该字段。"
},
"screenColor": {
"type": ["string", "null"],
@@ -3649,7 +3648,7 @@
"connected-components",
"grid"
],
- "description": "实际采用的图集切分模式。"
+ "description": "本次实际采用的图集切分模式,与请求显式声明的 sliceMode 一致;图集生成入口不回退到任何默认模式。"
},
"gridX": {
"type": "integer",
@@ -3664,7 +3663,7 @@
"sliceCount": {
"type": "integer",
"minimum": 0,
- "maximum": 100,
+ "maximum": 256,
"description": "实际生成的切片数量。"
},
"sliceWarning": {
diff --git a/docs/project-memory/plans/【实施计划】AGC客户端更新切换到官方更新插件-2026-09-17.md b/docs/project-memory/plans/【实施计划】AGC客户端更新切换到官方更新插件-2026-09-17.md
new file mode 100644
index 000000000..4e93b3844
--- /dev/null
+++ b/docs/project-memory/plans/【实施计划】AGC客户端更新切换到官方更新插件-2026-09-17.md
@@ -0,0 +1,37 @@
+# 【实施计划】AGC 客户端更新切换到官方更新插件
+
+| 字段 | 值 |
+| --------- | ----------------------------------------------------------------------------------- |
+| Milestone | `docs/project-memory/plans/【里程碑】AGC客户端更新切换到官方更新插件-2026-09-17.md` |
+| Status | ready |
+| Owner | Codex |
+
+## 修改边界
+
+- 允许修改:AGC 客户端原生侧(依赖、插件注册、更新相关命令与其测试)、AGC 前端更新服务与更新提示、「关于」页检查入口、capability 与 Tauri 配置、AGC 客户端测试、主规范与开发运维文档。
+- 明确不修改:发布脚本与 Jenkins(渠道化属于下一个里程碑)、OSS 对象布局、SpacetimeDB、`/api/external/v1`、网站与其它 App。
+
+## 实现顺序
+
+1. 生成发布签名密钥对:私钥落在仓库外 `%USERPROFILE%\.tauri\`,公钥写入客户端配置(公钥发布后不可更换)。
+2. 原生侧:加入官方更新插件依赖并注册;删除自研更新下载命令、下载进度事件、安装器启动逻辑与其专属测试;新增供 macOS 安装后重启的应用命令。
+3. 配置与权限:打开更新产物生成,写入公钥、渠道端点(默认 Windows 渠道)与 Windows 静默安装模式;capability 增加更新权限,并移除只为自研清单放行的 OSS 白名单与 CSP 连接项。
+4. 前端:更新服务改为调用官方插件(检查、下载、进度、安装、重启收敛),删除自研清单解析、版本比较与下载实现;更新提示改用插件进度回调;保留开发态特性开关语义。
+5. 测试:改写更新服务定向用例(开关关闭不发请求、更新元数据映射、失败静默、进度与重启、无待装更新时失败关闭)。
+6. 文档:更新技术方案与开发运维说明,删除自研链路描述。
+
+## 验证命令
+
+1. `npm --prefix apps/ai-game-creator-shell run typecheck`(含 `check-config.mjs` 与 skill-pack 校验)
+2. `npx vitest run apps/ai-game-creator-shell/tests/appUpdate.test.ts apps/ai-game-creator-shell/tests/featureFlags.test.ts apps/ai-game-creator-shell/tests/dev-feature-flags.test.ts`
+3. `cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`
+4. `npx eslint` / `npx prettier --check`(改动文件)
+5. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check`
+6. 运行时:`npm run agc` 启动不产生更新清单请求;检索确认自研命令、事件与白名单条目无残留。
+
+## 风险与回滚点
+
+- 公钥不可更换:密钥已生成但尚未发布任何签名版本,若需要带密码的私钥仍可在首次发布前重新生成。
+- Windows 安装模式由插件配置决定(本里程碑固定 `quiet`,与旧 PowerShell `/S` 一致);若改为 `passive` 会多出安装进度条 UI。
+- 插件在 Windows 上安装成功后自行退出进程,前端不再有机会更新界面;提示面板的完成态只在 macOS / Linux 可见。
+- 回滚点:改动集中在客户端与配置,回滚后即可退回自研链路;旧 OSS `agc/latest.json` 在发布管线渠道化前不删除。
diff --git a/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md b/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md
new file mode 100644
index 000000000..db90eebeb
--- /dev/null
+++ b/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md
@@ -0,0 +1,26 @@
+# AGC 对话历史分页恢复实施计划
+
+- Date: 2026-09-16
+- Status: awaiting-runtime-acceptance
+- Milestone: [AGC 对话历史分页恢复](./【里程碑】AGC对话历史分页恢复-2026-09-16.md)
+
+## 实施
+
+1. 原生历史读取复用逐行解析,增加消息模式,过滤后分页并返回已有原始消息 ID 游标;保持原始接口默认行为和路径权限。
+2. 工作台首屏与更早消息读取显式请求消息模式,消费游标;加载代次隔离、单飞与 ID 去重。
+3. 合成记录测试复现原始工具页卡住的形状,覆盖旧无 ID、坏行、时间、失败/重复/切项目;临时目录只读重放用户日志。
+4. 前端/原生定向测试、类型、Lint、编码、文档和差异检查通过后,更新问题表及 PR 草稿并本地提交。
+5. 在临时消息投影中标记历史来源,保留 Runtime 所有权语义;刷新合并时只保留非历史来源的待回读消息。补齐「先加载旧页,再 /history,再翻页」的顺序与去重回归,保持尚未落盘用户输入的保留逻辑。
+
+## 边界与停止条件
+
+不调整 Direct 消息呈现归属、不修未证明的写入丢失、不上传日志、不触碰用户项目。必要 API 变化仅为本地 IPC 可选参数和游标字段;无 OpenAPI、SpacetimeDB 或持久化迁移。远程推送/PR/WIP 操作仍待额外确认。
+
+## 验收证据
+
+- 历史消息模型、回合呈现及分页集成共 24 个前端测试通过;包含真实 App 的首屏/更早页、单飞、失败重试、重叠消息、同项目重新加载及离开再进入的迟到响应。
+- 16 个历史原生测试通过;人工日志重放用例在 CI 默认忽略,已在本地单独执行通过。
+- 使用用户提供的原始日志运行修复后的原生读取:44 条现存聊天消息(含 2 条用户消息)分 3 页取回,逐项内容与顺序一致,原文件字节未变;未在仓库保存原始日志。
+- 工作台/Direct 恢复与画布导航的 8 个定向回归通过。真实客户端重新进入与向上翻页尚待用户验收;本次涉及 Rust IPC,需重新构建并启动原生端。
+- 与前面画布/JSON 修复联合复验:119 个前端定向测试、30 个原生测试通过;AGC TypeScript、修改文件 ESLint、编码、文档索引和差异检查通过。
+- 状态只覆盖日志中已经证明的分页卡页,不据此宣称其它可能的未落盘消息也已恢复。
diff --git a/docs/project-memory/plans/【实施计划】AGC更新发布管线渠道化-2026-09-17.md b/docs/project-memory/plans/【实施计划】AGC更新发布管线渠道化-2026-09-17.md
new file mode 100644
index 000000000..94fb23092
--- /dev/null
+++ b/docs/project-memory/plans/【实施计划】AGC更新发布管线渠道化-2026-09-17.md
@@ -0,0 +1,37 @@
+# 【实施计划】AGC 更新发布管线渠道化
+
+| 字段 | 值 |
+| --------- | ------------------------------------------------------------------------- |
+| Milestone | `docs/project-memory/plans/【里程碑】AGC更新发布管线渠道化-2026-09-17.md` |
+| Status | ready |
+| Owner | Codex |
+
+## 修改边界
+
+- 允许修改:AGC 发布脚本(`apps/ai-game-creator-shell/scripts/build-release.mjs`、`release-upload.mjs` 及其测试)、AGC 发布流水线 `jenkins/Jenkinsfile.ai-game-creator-shell-build`、开发运维与技术方案文档。
+- 明确不修改:客户端插件接入与前端更新服务(上一里程碑已完成)、SpacetimeDB、`/api/external/v1`、网站与其它 App、其它 Jenkins Job。
+- 不执行 OSS 上传:本里程碑只交付脚本、流水线定义与本地可验证产物;真实发布需要单独授权与凭据。
+
+## 实现顺序
+
+1. 发布脚本:解析并校验渠道(渠道与目标平台绑定,未显式指定时按平台取默认渠道),把渠道写进远端清单地址与构建期端点配置。
+2. 清单生成:按渠道产出官方更新插件清单(版本、发布说明、发布时间、平台键与签名),universal macOS 产物同时挂两个平台键;缺少签名或签名为空时失败关闭。
+3. 迁移桥:Windows 渠道额外产出旧协议 sha256 清单,指向同一渠道的最新安装包,供已发布客户端升级到新协议。
+4. 上传:按渠道写版本目录(安装包与签名)与渠道 latest 指针,旧协议指针单独覆盖写。
+5. 流水线:新增渠道参数与签名凭据注入,归档安装包、签名、渠道清单与 commit。
+6. 测试与文档:更新发布脚本单测(渠道校验、清单结构、签名缺失失败关闭、旧协议清单),同步开发运维与技术方案。
+
+## 验证命令
+
+1. `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs apps/ai-game-creator-shell/scripts/cargo-features.test.mjs`
+2. 本地清单 smoke:伪造 bundle 目录 + 真实签名私钥,断言渠道清单与旧协议清单结构、缺少签名时失败关闭
+3. `npm --prefix apps/ai-game-creator-shell run typecheck`
+4. `npm run ai-game-creator-shell:build -- --no-bundle`(渠道端点注入后的构建 smoke;不改版本、不读远端清单、不生成清单)
+5. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check`、prettier 与 eslint(改动文件)
+
+## 风险与回滚点
+
+- 版本递增按渠道独立:`dev-win` 与 `dev-mac` 的清单地址不同,互不影响;旧协议指针只由 `dev-win` 写入。
+- 签名缺失即失败关闭:构建机未注入签名私钥时发布中止,不产生半成品清单。
+- 渠道端点写进产物:渠道名一旦发布不可改名(改名等于已发布客户端再也找不到更新)。
+- 回滚点:发布脚本与流水线都在本里程碑内,回滚后客户端仍可用原先的自研清单协议;迁移桥可独立停用。
diff --git a/docs/project-memory/plans/【实施计划】AGC模板库客户端接入-2026-09-17.md b/docs/project-memory/plans/【实施计划】AGC模板库客户端接入-2026-09-17.md
new file mode 100644
index 000000000..1b7315703
--- /dev/null
+++ b/docs/project-memory/plans/【实施计划】AGC模板库客户端接入-2026-09-17.md
@@ -0,0 +1,59 @@
+# AGC 模板库客户端接入实施计划
+
+Version: 1.0
+Status: active
+Date: 2026-09-17
+Milestone Spec: `docs/project-memory/plans/【里程碑】AGC模板库客户端接入-2026-09-17.md`
+
+## 步骤
+
+1. **OSS 库布局与契约**
+ - 在 `agc-dev` 落地 `templates/` 前缀:`index.json`、`v1//{template.json,template.zip,cover.*}`。
+ - 清单补齐 `tags`、`coverKey/coverWidth/coverHeight/coverSha256`,正文改为 zip(zip 根 == 项目根)。
+ - 模板源落在 `apps/ai-game-creator-shell/template-library/v1//{meta.json,project/**,cover.*}`;zip 由 `scripts/agc-template-library-publish.mjs` 现场打包(不落仓库)。
+ - 交付:发布脚本(校验 + 打包 + 上传 + 回读校验,支持 `--dry-run` / `--prune`)、`templates/README.md`,以及 5 个模板(3 个空白 + 2 个起步工程)。
+ - 验收:匿名 `GET templates/index.json` 可读,每个 `zipKey` 回读 SHA-256 与清单一致。
+
+2. **Rust 模板库模块**
+ - 新增 `src-tauri/src/template_library.rs`:清单解析与校验、受信任 base、缓存/安装目录、zip 安全解压、安装记录、由模板建项目。
+ - 注册命令 `fetch_game_template_library`、`download_game_template`、`create_automatic_local_game_project_from_template`。
+ - 交付:模块内 8 项单测(schema/重复模板、键前缀、base 校验、解压逃逸、摘要与大小、安装记录、建项目与失败清理)。
+ - 验收:`cargo test --bin genarrative-ai-game-creator-shell template_library` 全绿。
+
+3. **前端状态链路**
+ - `src/features/template-library/templateLibraryModel.ts`(类型与搜索/筛选纯函数)与 `useTemplateLibrary.ts`(拉取、下载、建项目、就地更新已下载状态)。
+ - `useHomeProjectCreation` 增加 `enterCreatedTemplateProject`,复用既有进项目通道。
+ - 交付:9 项模型单测。
+ - 验收:`npx vitest run src/features/template-library` 全绿。
+
+4. **界面接入**
+ - 新增 `src/view/template-library/index.tsx` 全屏页;`LauncherView` 增加 `template-library`;左侧导航加模板库入口。
+ - 首页「灵感推荐」替换为 `TemplateRecommendations`;删除 `InspirationGallery.tsx` 与 `assets/inspiration/`。
+ - `tauri.conf.json` 的 `img-src` 放行受信任 OSS 主机以加载封面。
+ - 验收:模板库页可搜索、筛选、下载、显示已下载并成功建项目;首页推荐位可跳转。
+
+5. **文档与共享记忆**
+ - 主规范 `docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md`,并在 `docs/README.md` 建索引。
+ - 本里程碑与实施计划;`decision-log.md` 记录库路径、清单 schema、缓存目录与 CSP 约定。
+ - 验收:`node scripts/check-doc-index.mjs` 通过。
+
+## 验证命令
+
+```bash
+cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml
+cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell template_library
+cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell template_library -- --ignored
+cd apps/ai-game-creator-shell && npx tsc -p tsconfig.json --noEmit
+npx vitest run apps/ai-game-creator-shell/tests/templateLibraryModel.test.ts apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx
+node scripts/agc-template-library-publish.mjs --source --dry-run
+npm run check:encoding
+node scripts/check-doc-index.mjs
+git diff --check
+```
+
+## 风险与回退
+
+- **封面走 WebView 直连**:仅放行受信任 OSS 主机;若日后改用后端签名,清单的 `coverKey` 不变。
+- **模板包体积**:下载上限 512 MiB、解压文件数 4096、单文件 256 MiB;超限直接拒绝,不落盘。
+- **清单漂移**:客户端只信「受信任主机 + 对象键」,清单中的地址字段不参与请求。
+- **回退**:清空 `templates/` 前缀即回到空模板库;客户端保留错误与空态展示,不阻断其它功能。
diff --git a/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md b/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md
new file mode 100644
index 000000000..9902abd0b
--- /dev/null
+++ b/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md
@@ -0,0 +1,44 @@
+# AGC 画布交互稳定性修复实施计划
+
+- Date: 2026-09-16
+- Status: awaiting-runtime-acceptance
+- Milestone: [画布交互稳定性修复](./【里程碑】AGC画布交互稳定性修复-2026-09-16.md)
+
+## 修改顺序与边界
+
+1. 将临时诊断收敛成正式回归测试,覆盖窗口 Context 反馈、提示条件和鼠标/触摸板事件。
+2. 稳定工作台打开项目的转发回调,保持最新处理器语义,不改项目加载逻辑。
+3. 解耦运行提示与选择;在原有画布事件链加入右键平移、菜单边界和中断清理,不另建控制器。
+4. 执行定向验证并检查首次加载、平移和原有框选/卡片拖动回归。
+
+## 验证
+
+- 定向 Vitest:窗口工作台、画布交互、布局与原有导航用例。
+- 运行提示的源码契约与交互回归同时覆盖:提示仅取决于运行能力和 UI 编辑器路由,不依赖资源选择;选中资源后提示及 `aria-describedby` 保留,点击运行仍不能进入不可用视图。
+- AGC `tsc --noEmit`;`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。
+- 真实客户端首次进入和触摸板操作无法以 jsdom 代替;未实测时保持待验收。
+
+## 风险与停止条件
+
+窗口反馈测试必须有更新次数上限,避免未修复代码让测试失控。右键仅接管画布背景/卡片,不抢输入控件与浮层。新增问题只有影响本次验收才扩大范围;不根据猜测修改 B01/B02 的布局与动画。
+
+回滚仅限本批局部补丁;不重置工作树,不覆盖其它修改。完成自动化验证后停在真实客户端验收,不推进额外功能。
+
+## 问题状态
+
+| 编号 | 问题 | 状态与证据 |
+| --- | --- | --- |
+| B01 | 刷新后首次进入画布元素抖动 | 已优化;用户在本轮反馈未再复现,按用户要求更新状态。不宣称所有布局/动画原因均已排除。 |
+| B02 | 初次进入双指平移无效,整理后恢复 | 已优化;用户在本轮反馈未再复现,按用户要求更新状态。隔离组件连续平移通过。 |
+| B03 | 快速平移触发更新深度错误 | 已修复已确认的窗口 Context 反馈循环,回归验证收敛;真实操作继续观察。 |
+| B04 | 资源选中后运行不可用提示消失 | 已修复,提示与选择解耦,自动化验证通过。 |
+| B05 | 对话记录偶发丢失 | 已修复日志复现的历史分页卡点:消息模式过滤后分页、原生游标与读取代次隔离。原生只读重放分 3 页取回全部 44 条现存消息,原文件未变;真实客户端待验收,不扩大为其它未落盘记录已恢复。 |
+| B06 | JSON 文档未正确识别展示 | 已按用户确认完成本地修复:合法 UI State 由原生完整校验,卡片显示 UI 设计并进入现有编辑器;普通 JSON 显示 JSON 并可代码预览。自动化验证通过,待重建原生客户端验收;详见 JSON 语义识别实施计划。 |
+| C01 | 右键平移,保留左键框选 | 已实现,卡片左键拖动、框选、指针取消/失焦/捕获丢失及控件边界测试通过。 |
+
+## 已取得证据与剩余门禁
+
+- 修复前新增回归测试能检出外壳重复发布、运行提示消失和右键无效;修复后窗口/画布定向测试通过,现有导航、框选、指针点击/取消、UI 编辑器返回平移和素材定位用例通过。
+- AGC TypeScript、修改文件 ESLint、编码、文档索引及差异空白检查通过。
+- 测试仍有既有 React 列表 key、旧用例 act/IPC 桩告警,未作为本批功能修复扩大范围。
+- 用户反馈 B01/B02 本轮未再复现,记为已优化;右键手感与其它真实客户端细节继续观察。对话历史分页与 JSON 双路径均已本地修复并通过定向验证,待重建原生端后真实客户端验收。本计划保持开放。
diff --git a/docs/project-memory/plans/【实施计划】AGC资源JSON语义识别-2026-09-16.md b/docs/project-memory/plans/【实施计划】AGC资源JSON语义识别-2026-09-16.md
new file mode 100644
index 000000000..5c2947540
--- /dev/null
+++ b/docs/project-memory/plans/【实施计划】AGC资源JSON语义识别-2026-09-16.md
@@ -0,0 +1,27 @@
+# AGC 资源 JSON 语义识别实施计划
+
+- Date: 2026-09-16
+- Status: awaiting-runtime-acceptance
+- Milestone: [AGC 资源 JSON 语义识别](./【里程碑】AGC资源JSON语义识别-2026-09-16.md)
+
+## 实施边界
+
+1. 原生 UI 持久化模块抽取可复用的内容解析与只读识别;文本预览返回可选的已验证 UI 资产身份。
+2. 保留新设计初始化的严格 UI 资产门禁;已有 State 的加载/保存/生成按登记资源和真实文档校验,不依赖标签精确大小写。
+3. 前端预览缓存透传识别结果;工作台卡片与编辑器入口消费同一结果,普通 JSON 详情使用代码块。
+4. 单元、组件与原生测试覆盖合法/普通/损坏/跨身份/未登记和保存边界;保持先前画布修复。
+
+## 验证与停止条件
+
+- 定向 Vitest、Tauri persistence/resource preview 定向 Rust 测试、AGC TypeScript、修改文件 ESLint、编码、文档索引及 `git diff --check`。
+- 不读取或修改用户项目原文件,不将日志或真实对话作为仓库测试夹具。
+- 原生构建/真实客户端受环境限制时记录实际证据,不能以 TS 测试代替原生验证。
+- 完成上述范围后停止;B05 已有分页卡点证据,本轮不顺带修改历史合同。
+
+## 验证结果
+
+- 92 个前端定向测试通过,覆盖原生结果驱动的卡片/编辑器路由、普通 JSON 代码预览、伪 UI 文本拒绝、读取失败、缓存切项目,以及原有画布导航与指针回归。
+- 14 个 UI 持久化原生测试通过,包含有效 State 对多种登记标签的识别/加载/保存、未知 schema/字段与坏结构拒绝、项目/资产身份、普通 JSON 防覆盖及原有恢复/CAS 边界。
+- 原生测试在 macOS 默认 `/var` 临时路径触发既有拒绝符号链接门禁;改用真实 `/private/tmp` 后通过,未放宽产品路径安全校验。
+- AGC TypeScript、修改文件 ESLint 已通过。测试仍有既有 React key/act 告警及 Rust 未使用代码警告,不影响本批断言。
+- 真实客户端的图片/UI State 体验仍待验收。本次包含 Rust 预览字段,必须重新构建并启动原生端,不能仅刷新前端就认为识别结果已更新。
diff --git a/docs/project-memory/plans/【实施计划】AGC项目定时快照上传-2026-09-17.md b/docs/project-memory/plans/【实施计划】AGC项目定时快照上传-2026-09-17.md
new file mode 100644
index 000000000..3fe5a8630
--- /dev/null
+++ b/docs/project-memory/plans/【实施计划】AGC项目定时快照上传-2026-09-17.md
@@ -0,0 +1,43 @@
+# AGC 项目定时快照上传实施计划
+
+Version: 1.0
+Status: active
+Date: 2026-09-17
+Parent Milestone: `【里程碑】AGC项目定时快照上传-2026-09-17.md`
+
+## 修改边界
+
+1. `server-rs/crates/shared-contracts/src/`:新增 `agc_project_snapshots` DTO(单文件上传请求/响应、同步清单信封),只放共享字段,不放 OSS 细节。
+2. `apps/ai-game-creator-shell/src-tauri/src/project_snapshot/`:新增客户端模块,包含扫描与排除规则、索引读写、差异对比、上传编排、状态与日志;不修改 `project/` 下既有 manifest 与写锁语义。
+3. `apps/ai-game-creator-shell/src-tauri/src/main.rs`:注册新模块、命令与生命周期钩子;`windows.rs` 的窗口关闭与应用退出路径接入触发调用,不改变现有窗口创建/关闭顺序。
+4. `server-rs/crates/platform-oss/src/lib.rs`:新增项目快照私有前缀常量与(必要时)独立 bucket 配置入口;不改动既有前缀枚举语义与资源写路径。
+5. `server-rs/crates/api-server/src/project_snapshots.rs`:新增路由、鉴权、校验与 OSS 写入;不改动 `error_reports` 与 `assets` 既有路由。
+6. `.env.example`:补充 `GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_*` 说明与默认值。
+7. `docs/`:主规范已更新;完成后把持久结论合并回主规范并删除本计划。
+
+## 实现顺序
+
+1. 先写 `shared-contracts` DTO 与客户端差异引擎(扫描、排除、索引、diff)及单测,此时无网络依赖,可独立验证。
+2. 接上传编排:按差异集合逐文件提交,成功后再提交清单,最后推进索引;用本地 TCP stub server 覆盖成功、幂等跳过、鉴权失败与部分失败路径。
+3. 接触发接线:周期定时器、工作区窗口关闭与应用退出;确认关闭路径的有界超时和串行化。
+4. 最后接服务端路由与 OSS 写入,补参数校验与幂等跳过测试;服务端完成前客户端按"未配置即失败关闭、不写入索引"处理。
+
+每一步都保留既有失败关闭行为;新模块默认不改变其它同步路径(Runner、项目写锁、Resource Editor)。
+
+## 验证命令
+
+- `cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check`
+- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml project_snapshot -- --nocapture`
+- `cargo test -p api-server --bin api-server project_snapshots -- --nocapture`
+- `cargo fmt -p api-server -p shared-contracts -p platform-oss -- --check`
+- `npm run --prefix apps/ai-game-creator-shell typecheck`(若触及前端)
+- `npm run check:encoding`
+- `git diff --check`
+- 运行时按需:`npm run agc` 打开项目观察索引写入与同步日志,关闭窗口确认关闭触发。
+
+## 风险与回滚
+
+- 上传体积与带宽:首轮全量可能很大,先设单文件与单次同步总量上限并把超限项记入跳过清单;不静默截断。
+- 数据出境边界:只上传项目目录内普通文件,排除 `.agent/runtime`、`.agent/logs`、`.git`、构建产物与临时文件;凭据类文件不在白名单内。
+- 服务端未配置 bucket 时客户端必须失败关闭,不能把本地索引推进成"已同步",否则后续同步会漏传。
+- 回滚:客户端可停用触发接线(保留模块与测试)即可回到无上传行为;服务端路由与配置项可单独移除,不影响既有 OSS 前缀与错误报告链路。
diff --git a/docs/project-memory/plans/【实施计划】图集切片模式显式决策-2026-09-17.md b/docs/project-memory/plans/【实施计划】图集切片模式显式决策-2026-09-17.md
new file mode 100644
index 000000000..d365f6539
--- /dev/null
+++ b/docs/project-memory/plans/【实施计划】图集切片模式显式决策-2026-09-17.md
@@ -0,0 +1,39 @@
+# 【实施计划】图集切片模式显式决策
+
+| 字段 | 值 |
+| --- | --- |
+| Milestone | `docs/project-memory/plans/【里程碑】图集切片模式显式决策-2026-09-17.md` |
+| Status | ready |
+| Owner | Codex |
+
+## 修改边界
+
+- 允许修改:`server-rs/crates/api-server`(图标图集生成入口、错误体、画板 Agent 工具装配、OpenAPI 契约测试)、平台画板前端(`src/services/image-editor`、`src/components/image-editor`)、AGC 客户端(`apps/ai-game-creator-shell/src-tauri` 的 MCP 工具说明、桥接校验、原生工具 schema、图集生成选项与调用方、AGC Skill)、`.codex/skills/genarrative-external-editor-api`、`docs/openapi/genarrative-external-v1.openapi.json`、主规范与共享记忆。
+- 明确不修改 `platform-editor-agent`:画板 Agent 的工具参数不变,其链路在装配层固定显式声明 `connected-components`,画板因此不具备网格生成入口。
+- 明确不修改:拆分 / 去背 / 像素规整算法、切片上限、手动拆分入口行为、SpacetimeDB schema、旧版本客户端兼容分支。
+
+## 实现顺序
+
+1. 平台入口:`sliceMode` 由可选改必填并校验模式自洽性,失败发生在引用解析、定价、入队之前。
+2. 公开契约:OpenAPI 请求体去掉默认值、补必填与失败语义,并补契约测试。
+3. 平台自有调用方显式声明模式:画板 Agent 工具装配(固定连通域)、画板前端提交计划(固定连通域)。
+4. AGC 客户端:MCP 工具说明与桥接校验、原生工具 schema 与观察器、图集生成选项与全部调用方、AGC Skill 与外部 MCP 说明。
+5. 错误可执行性:切片模式按原始字符串接收后逐项校验,统一返回 `field`、允许取值与决策分支;`sliceCount` 契约上限与切片上限对齐。
+6. 反馈闭环:生成结果回显生效声明与切片路径,严格图集在本地提交前校验回显与请求一致。
+7. 标准美术包显式声明 `connected-components` + `sliceCount=4`,用途映射前校验切片数量正好为四。
+8. 测试环境:为提权 Windows 主机上的 `%TEMP%` 所有者偏差补测试构建专用的所有者初始化重试(仅限临时目录内、且失败原因为所有者不匹配)。
+9. 文档与共享记忆同步,最后运行定向验证与编码 / diff 检查。
+
+## 验证命令
+
+1. `cargo test -p api-server editor_icon_spritesheet`(名称按实际测试筛选)
+2. `cargo test -p platform-editor-agent`
+3. `npm run test -- src/services/image-editor/editorProjectClient.test.ts`(按仓库既有前端测试入口)
+4. `cargo test -p ai-game-creator-shell` 定向筛选 `slice_mode` / `generate_image`
+5. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check`
+
+## 风险与回滚点
+
+- 风险 1:已发布的 AGC 客户端与第三方外部 API 调用方在未更新前会因缺失 `sliceMode` 收到 `400`。回滚点为「恢复服务端兜底读取连通域」,但该兜底与本次里程碑目标冲突,需产品确认后再引入过渡期。
+- 风险 2:AGC 原生工具 schema 从“可选”改为“显式声明”,自主运行时可能出现一轮可修复的工具参数失败。回滚点为「保留 schema 字段但收回 description 中的强制措辞」。
+- 风险 3:画板前端显式声明模式后,画板自身不再具备网格生成能力;需要网格时改用外部 API 或后续单独开放画板入口。
diff --git a/docs/project-memory/plans/【里程碑】AGC macOS渠道更新落地-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC macOS渠道更新落地-2026-09-17.md
new file mode 100644
index 000000000..b9b84ef2e
--- /dev/null
+++ b/docs/project-memory/plans/【里程碑】AGC macOS渠道更新落地-2026-09-17.md
@@ -0,0 +1,46 @@
+# 【里程碑】AGC macOS 渠道更新落地
+
+| 字段 | 值 |
+| ----------- | ------------------------------------------------------------------ |
+| Version | 1.0 |
+| Status | deferred |
+| Date | 2026-09-17 |
+| Parent Spec | `docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md` |
+
+## 目标
+
+`dev-mac` 渠道可产出并发布 macOS 更新包,客户端在 macOS 上完成检查、安装与重启接管新版本。
+
+## 范围
+
+- macOS 更新产物:按 universal 目标构建(Intel 与 Apple Silicon 共用一个包),更新包与其签名按渠道约定生成并上传,清单把同一对象挂到两个 macOS 平台键。
+- macOS 安装后的重启收敛:安装完成后由客户端重启进程运行新版本,不依赖安装程序代为重启。
+- macOS 代码签名与公证依赖的确认与记录:未签名或未公证的产物视为不可发布。
+- macOS 构建执行环境(本机 mac 或新增 macOS 节点)与渠道发布的衔接方式。
+
+## 不在范围内
+
+- Windows 渠道行为调整。
+- 微软商店或 App Store 分发。
+- 更新包体积优化与增量更新。
+
+## 依赖与前置条件
+
+- 客户端插件化与发布管线渠道化两个里程碑已验收。
+- macOS 签名证书与公证凭据可用;若不满足,本里程碑只能交付构建与清单能力,并明确标注未验证项。
+- macOS 通用包所需的双架构工具链(两个 darwin 目标)在构建机上可用。
+
+本里程碑暂缓执行:macOS 构建机与签名 / 公证凭据尚未就绪,改由后续独立变更承接;暂缓期间 dev-mac 渠道不发布。
+
+## 验收标准
+
+- [ ] `dev-mac` 渠道清单包含两个 macOS 平台条目且指向同一个 universal 安装包与签名,对象在 OSS 上一致可下载。
+- [ ] macOS 客户端能完成一次真实更新:检查、下载、安装、重启后运行新版本,且升级后产物仍是 universal 包。
+- [ ] 覆盖写渠道 latest 指针后,旧版本 macOS 客户端可升级到新版本;Windows 与 macOS 渠道互不干扰。
+- [ ] 未签名或未公证产物在发布阶段失败关闭,或在不满足条件时明确记录为未验证项而非静默通过。
+
+## 证据要求
+
+- 自动化:macOS 更新产物选择与清单生成用例、仓库门禁。
+- 运行时:macOS 上一次真实更新闭环(含重启后版本核对),OSS 对象与清单核对。
+- 边界:签名校验失败、公证缺失、渠道缺少 macOS 平台条目、跨架构不匹配时的表现。
diff --git a/docs/project-memory/plans/【里程碑】AGC客户端更新切换到官方更新插件-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC客户端更新切换到官方更新插件-2026-09-17.md
new file mode 100644
index 000000000..48930772e
--- /dev/null
+++ b/docs/project-memory/plans/【里程碑】AGC客户端更新切换到官方更新插件-2026-09-17.md
@@ -0,0 +1,45 @@
+# 【里程碑】AGC 客户端更新切换到官方更新插件
+
+| 字段 | 值 |
+| ----------- | ------------------------------------------------------------------ |
+| Version | 1.0 |
+| Status | approved |
+| Date | 2026-09-17 |
+| Parent Spec | `docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md` |
+
+## 目标
+
+客户端自动更新的检查、下载、签名校验与安装改由 Tauri 官方更新插件承担,前端只保留触发与展示,并按渠道读取清单;开发态继续不检查更新。
+
+## 范围
+
+- 官方更新插件在客户端两侧接入:原生侧注册与配置,前端调用官方 API 替代自研检查与下载。
+- 渠道作为构建期常量进入客户端:每个渠道的产物只读该渠道清单,运行期不切换渠道。
+- 保留并复核现有开发态特性开关语义:开发态不检查更新、不显示更新入口。
+- 更新能力只授予客户端主窗口。
+
+## 不在范围内
+
+- 发布管线与 OSS 对象布局的渠道化改造。
+- macOS 产物落地、签名与公证。
+- 旧客户端迁移桥(是否保留旧清单指针)。
+
+## 依赖与前置条件
+
+- 发布签名公钥可用;公钥写入客户端配置,来源见主规范未决问题。
+- 渠道清单地址与对象布局按主规范约定确定,渠道集合固定为 `dev-win` 与 `dev-mac`。
+- 官方插件版本与当前 Tauri 主版本兼容。
+
+## 验收标准
+
+- [ ] 正式包走官方更新插件的检查与安装路径;更新包校验失败时必须拒绝安装并清理临时文件。
+- [ ] 客户端只请求本渠道清单,且不因清单缺失、格式错误或网络失败阻塞启动。
+- [ ] 开发态启动不产生任何更新清单请求,也不显示更新入口。
+- [ ] 更新能力只授予客户端主窗口,其它窗口调用被拒绝。
+- [ ] 自研清单解析、下载命令、下载进度事件与相应的 CSP / HTTP 白名单放行整条删除,无残留兼容分支。
+
+## 证据要求
+
+- 自动化:前端定向用例(渠道映射、开发态开关、失败关闭)、原生侧定向用例、类型检查与仓库门禁。
+- 运行时:`agc` 开发启动无清单请求;使用测试渠道清单完成一次真实检查与安装闭环(含升级后重启)。
+- 边界:签名不匹配、下载中断、清单 404、渠道缺少当前平台条目、非主窗口调用。
diff --git a/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md b/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md
new file mode 100644
index 000000000..4b8e244c4
--- /dev/null
+++ b/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md
@@ -0,0 +1,20 @@
+# AGC 对话历史分页恢复
+
+- Version: 1
+- Status: reviewed
+- Date: 2026-09-16
+- Parent Spec: [AGC 实施计划:DirectProject 回合展示唯一归属](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)
+
+## 范围与评审
+
+用户已要求修复 B05。已用用户提供的原始日志只读复验:原始切片被工具/推理填满时,聊天投影为空,消息游标不推进。修复聊天读取与分页,不改历史写入、不删除记录、不修改模型上下文,不扩大到其它尚无证据的对话丢失原因。
+
+采用已有切片接口的显式消息模式与原生游标。原始模式缺省行为保持不变,旧无 ID 消息保留;前端以项目和加载代次隔离结果。本轮不包含远程写入。
+
+## 验收
+
+1. 工具/推理密集、末尾无消息、纯工具历史均不产生空页死循环。
+2. 消息正文、原始 ID、时间和顺序保持不变,翻页能到达早期用户提问及最终回答,不重复。
+3. 连点、请求失败重试、项目切换和同项目重新加载不会污染消息或游标。
+4. 原始切片默认模式回归通过,用户日志只读重放可以取回全部现存消息;不把真实日志或对话正文提交到仓库。
+5. 加载更早消息后重新读取历史,旧页不出现在最新回复之后;再次翻页保持顺序且不重复。未落盘的实时用户输入不因历史刷新被丢弃。
diff --git a/docs/project-memory/plans/【里程碑】AGC更新发布管线渠道化-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC更新发布管线渠道化-2026-09-17.md
new file mode 100644
index 000000000..5191b0129
--- /dev/null
+++ b/docs/project-memory/plans/【里程碑】AGC更新发布管线渠道化-2026-09-17.md
@@ -0,0 +1,47 @@
+# 【里程碑】AGC 更新发布管线渠道化
+
+| 字段 | 值 |
+| ----------- | ------------------------------------------------------------------ |
+| Version | 1.0 |
+| Status | approved |
+| Date | 2026-09-17 |
+| Parent Spec | `docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md` |
+
+## 目标
+
+构建与发布管线按渠道产出官方更新插件要求的清单与签名产物并上传到渠道路径,发布入口可通过渠道参数在渠道之间切换。
+
+## 范围
+
+- 构建期按渠道生成清单:版本按渠道独立递增,清单包含该渠道平台的下载地址与签名;`dev-mac` 的 universal 包按同一地址与签名同时写入 `darwin-aarch64` 与 `darwin-x86_64`。
+- 构建期生成更新产物签名,并在缺少签名私钥或私钥不可用时失败关闭。
+- 渠道参数与目标平台绑定校验:Windows 目标只能发布 `dev-win`,macOS 目标只能发布 `dev-mac`;未显式指定时按目标平台取默认渠道。
+- 上传按渠道落位:安装包与签名进版本目录,清单覆盖写渠道路径的 latest 指针。
+- Jenkins 流水线增加渠道参数与签名凭据注入,凭据不落盘、不进日志、不进归档。
+
+## 不在范围内
+
+- 客户端侧的更新链路改造。
+- macOS 构建环境建设与 mac 产物签名、公证。
+- 旧客户端迁移桥;若决定保留,作为本里程碑的可选增量单独评审。
+
+## 依赖与前置条件
+
+- 客户端切换到官方更新插件的里程碑已验收:清单格式、公钥与客户端期望一致。
+- 签名密钥对已生成并进入构建凭据,公钥已写入客户端配置。
+- OSS 上传凭据与既有发布入口可复用。
+
+## 验收标准
+
+- [ ] 指定渠道发布时该渠道清单版本按渠道独立递增,另一个渠道清单不受影响。
+- [ ] 渠道与目标平台不匹配、缺少签名私钥或私钥密码错误时发布失败关闭,不产生半成品清单。
+- [ ] 发布后 OSS 上安装包、签名与渠道清单三者一致:清单内地址指向已存在的对象,签名与安装包匹配。
+- [ ] universal macOS 产物的两个平台键指向同一对象同一签名,不存在只挂单一架构键或指向不存在对象的情况。
+- [ ] Jenkins 归档与日志中不出现签名私钥内容,凭据只注入构建进程。
+- [ ] 未显式指定渠道时按目标平台取默认渠道,且 `--no-bundle` smoke 路径仍不读远端版本、不改版本、不生成清单。
+
+## 证据要求
+
+- 自动化:发布脚本单测(渠道解析与校验、版本递增、清单结构、签名缺失失败关闭)、仓库门禁。
+- 运行时:一次真实渠道发布加 OSS 对象核对(清单、安装包、签名),并用该清单触发一次客户端更新闭环。
+- 边界:渠道与平台不匹配、签名密钥缺失、远端清单 404、远端清单格式非法、重复发布时的 latest 覆盖。
diff --git a/docs/project-memory/plans/【里程碑】AGC模板库客户端接入-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC模板库客户端接入-2026-09-17.md
new file mode 100644
index 000000000..f6483d226
--- /dev/null
+++ b/docs/project-memory/plans/【里程碑】AGC模板库客户端接入-2026-09-17.md
@@ -0,0 +1,40 @@
+# AGC 模板库客户端接入
+
+Version: 1.0
+Status: active
+Date: 2026-09-17
+Parent Spec: `docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md`
+
+## 目标
+
+AGC 客户端能读取公共 OSS 上的游戏模板库,并把「浏览 → 筛选 → 下载 → 用模板建项目」做成一条可用链路,模板更新不再依赖客户端发版。
+
+## 范围
+
+- OSS `templates/` 前缀的库布局、清单 schema、封面与 zip 元数据契约。
+- Rust 侧读取清单、下载安装、由模板创建项目的命令与安全边界。
+- 模板库全屏页(搜索、标签/运行时筛选、仅看已下载)、首页模板推荐位、左侧导航入口。
+- 仓库内发布脚本与文档、模板库定向单测与类型检查。
+
+## 不做
+
+- 不做模板制作工具、模板审核、模板计费与推荐算法。
+- 不做模板增量更新(按 `templateVersion` 全量重下)。
+- 不把模板回填进已创建项目,也不改写用户项目内容。
+- 不放宽现有项目私有 DACL、下载摘要校验和受信任 OSS 主机边界。
+
+## 验收标准
+
+1. `fetch_game_template_library` 能读到清单并合并本机已安装状态;远端不可用时回退本机缓存并标明 `source=cache`。
+2. `download_game_template` 对字节数与 SHA-256 不一致、越界对象键、非 `templates/` 前缀的包一律拒绝,且不落半成品目录。
+3. zip 解压拒绝绝对路径、`..`、盘符、符号链接;安装完成后才写 `installed.json` 作为已下载判据。
+4. `create_automatic_local_game_project_from_template` 建出的项目同时具备模板文件、`.agent` 清单与标准目录;失败时不留项目目录。
+5. 模板库页可按关键词、标签、运行时与「仅看已下载」筛选;卡片显示封面与已下载徽标;已下载且版本一致时不再显示下载入口(落后显示「更新」);过程提示以浮层 toast 呈现,不占页面内位置;「使用模板」在版本落后时先重下再建项。
+6. 首页推荐位展示模板库内容并可进入模板库页;左侧导航有模板库入口且为独立全屏页。
+7. 定向 Rust 单测 9 项(含线上清单 fixture)、前端模型 9 项 + 页面/推荐位 8 项、`tsc` 类型检查、`npm run check:encoding`、`git diff --check` 全部通过;可选真连检查能读线上清单、下载安装线上模板并据此建项目。
+
+## 依赖
+
+- 现有自动工作区建项链路(`create_automatic_local_game_project_at` / `init_local_game_project_at`)。
+- 现有 AGC 更新通道使用的受信任 OSS 主机与 CSP 白名单口径。
+- 仓库 OSS 凭据(本机 `.env.secrets.local` 的 `ALIYUN_OSS_*`)与 `scripts/agc-template-library-publish.mjs`。
diff --git a/docs/project-memory/plans/【里程碑】AGC画布交互稳定性修复-2026-09-16.md b/docs/project-memory/plans/【里程碑】AGC画布交互稳定性修复-2026-09-16.md
new file mode 100644
index 000000000..de82fa9ef
--- /dev/null
+++ b/docs/project-memory/plans/【里程碑】AGC画布交互稳定性修复-2026-09-16.md
@@ -0,0 +1,26 @@
+# AGC 画布交互稳定性修复
+
+- Version: 1
+- Status: reviewed
+- Date: 2026-09-16
+- Parent Spec: [AGC 实施计划:资源画布交互与工作台状态同步](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)
+
+## 目标与范围
+
+完成工作台标题栏状态更新循环、运行提示随选择消失、右键平移三项本地修复,保持左键框选与卡片拖动。不修改布局算法、持久化和后端;不开远程 Issue/PR、不推送。
+
+## 评审
+
+依据已完成的源码追踪、客户端错误日志及有界复现自检:状态归属仍在窗口与工作台原有边界内;交互修改只影响画布手势;没有数据迁移、权限或 API 变化。用户已确认右键平移及保留左键框选,并授权先尝试本地修复。真实客户端首次进入抖动与触摸板平移仍需另行验收。
+
+## 验收
+
+1. 工作台发布窗口状态后收敛;重复渲染不持续更新或清理,打开项目回调使用最新处理器,卸载清理有效。
+2. 选中资源后运行不可用提示仍在,运行能力不变。
+3. 子画布空白处及卡片右键平移不改变选择或坐标;左键框选与卡片拖动保留;总览支持右键平移。
+4. 指针取消、失去捕获、窗口失焦终止平移;控件与浮层保留原有交互。
+5. 连续平移测试、定向测试、类型检查、编码与文档门禁完成并记录限制;B01/B02 仅在真实复测后决定关闭。
+
+## 依赖
+
+当前源码与已安装测试依赖;真实 AGC 客户端验收环境。没有外部写操作依赖。
diff --git a/docs/project-memory/plans/【里程碑】AGC资源JSON语义识别-2026-09-16.md b/docs/project-memory/plans/【里程碑】AGC资源JSON语义识别-2026-09-16.md
new file mode 100644
index 000000000..119c04f27
--- /dev/null
+++ b/docs/project-memory/plans/【里程碑】AGC资源JSON语义识别-2026-09-16.md
@@ -0,0 +1,26 @@
+# AGC 资源 JSON 语义识别
+
+- Version: 1
+- Status: reviewed
+- Date: 2026-09-16
+- Parent Spec: [AGC 实施计划:文档与代码素材预览](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)
+
+## 目标与边界
+
+按用户确认同时支持普通 JSON 与 UI 设计 JSON:前者显示 JSON 并可代码预览,后者显示 UI 设计并进入原有编辑器。原生侧复用现有文档校验,前端不推断正式状态。不修改 manifest 分类,不迁移用户文件,不改历史分页,不做远程写入。
+
+## 评审
+
+已核对原生受控文本预览、UI State 持久化合同和工作台卡片/编辑器路由。复用完整读取结果识别,未知 schema/坏结构/身份不符均不授予编辑入口;已有 UI 资产的大小写标签不应阻断真实合法 State。新增可选预览字段只承载原生识别结果;不引入新的状态文件或平行编辑器。
+
+## 验收标准
+
+1. 普通 JSON 显示 JSON,不渲染卡面原文,不出现 UI 编辑器入口,显式预览为 JSON 代码块。
+2. 完整合法且身份匹配的 UI State 显示 UI 设计并进入现有编辑器;大小写标签或文档标签不影响内容识别。
+3. 损坏 JSON、伪 schema、未知字段、坏 State、跨项目/资产、未登记文件不能获得 UI 编辑入口;原文查看或读取错误仍可见。
+4. 识别不产生写入;普通 JSON 不能通过编辑器保存或初始化被覆盖。有效已有设计仍支持原有 CAS 保存。
+5. 预览缓存能传递原生结果并随资源身份失效;原有卡片、框选/平移和图片预览回归通过。
+
+## 依赖
+
+本地前端与 Tauri 源码、现有测试依赖;真实客户端体验单独验收。
diff --git a/docs/project-memory/plans/【里程碑】AGC项目定时快照上传-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC项目定时快照上传-2026-09-17.md
new file mode 100644
index 000000000..25f0c802e
--- /dev/null
+++ b/docs/project-memory/plans/【里程碑】AGC项目定时快照上传-2026-09-17.md
@@ -0,0 +1,49 @@
+# AGC 项目定时快照上传
+
+Version: 1.0
+Status: active
+Date: 2026-09-17
+Parent Spec: `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的“2026-09-17 AGC 项目定时快照上传(agc-dev)”
+
+## 目标
+
+AGC 在项目打开期间按周期把用户项目增量上传到 OSS `agc-dev`,并在项目关闭时立即补一次同步;只上传内容发生变化的文件,重复内容不重复上传,远端缺少对应对象时才新建。
+
+## 范围
+
+- 客户端 `src-tauri/src/project_snapshot/`(`scan.rs` / `diff.rs` / `index.rs` / `transport.rs`):项目扫描、排除规则、增量索引与差异对比、上传编排、状态查询。
+- 触发接线:工作区窗口存活周期定时器、工作区窗口关闭(`CloseRequested`);应用退出只做有界等待,不重复发起同步。
+- 服务端 `POST /api/agc/project-snapshots/files` 与 `POST /api/agc/project-snapshots/manifest`:登录态鉴权、参数校验、私有前缀 OSS 写入、HEAD 幂等跳过。
+- 目标 bucket 配置:`GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_*`,默认 `agc-dev`。
+- 契约:`shared-contracts::agc_project_snapshots` 新增请求/响应 DTO 与项目 ID、相对路径、摘要校验函数。
+- 客户端增量索引:`/project-snapshots//index.json`,按用户身份判等,换号后按冷启动全量重算。
+- 排除口径:复用 `should_skip_project_snapshot_path`(整个 `.agent`、`.git`、构建与依赖目录、凭据目录、敏感后缀、符号链接与重解析点)。
+
+## 不做
+
+- 不做云端下载/恢复、跨设备合并、版本回滚。
+- 不保留多版本历史:清单写入成功后回收不再被引用的旧对象,同一路径只保留当前内容。
+- 不下发 bucket 生命周期策略;不做跨节点的用户级总量配额与计费口径。
+- 不新增 SpacetimeDB 表或 procedure,不修改 `/api/external/v1` 与 External OpenAPI。
+- 不在客户端暴露上传状态、时间线或入口按钮;状态只落本机诊断日志,排障走 native-only 命令。
+
+## 验收标准
+
+1. 首次同步上传项目内全部符合条件的普通文件;再次同步在无改动时上传 0 个文件。
+2. 只修改一个文件时,差异集合恰好包含一个修改项;删除一个文件时上传集合为空且清单中不再包含该文件。
+3. `(字节数, 修改时间)` 未变的文件复用已存摘要,不重复读取内容计算摘要。
+4. 排除规则命中项(`.agent/runtime`、`.agent/logs`、`.git`、`node_modules`、构建产物、临时文件、符号链接)与超限文件进入跳过清单,不进入上传集合。
+5. 任一次同步失败(非鉴权类)不推进本地索引,下一次触发重算并重试;鉴权/权限类失败不自动重试。
+6. 同一项目的并发触发串行执行,不产生两路重复上传。
+7. 工作区窗口关闭与应用退出都会触发一次同步,且关闭路径不因同步失败而阻塞退出超过超时上限。
+8. 服务端拒绝越界 `projectId`、相对路径与摘要;相同摘要重复提交走跳过分支且不写入新对象。
+9. 新增日志与错误文案不含 Access Token、AccessKey、绝对路径与项目内容。
+10. 清单写入成功后,上一版清单里不再被引用的对象被回收;上一版清单不可读时整轮不删除任何对象。
+11. 单项目超过 2 GiB 时客户端明确失败、服务端按 413 拒绝;超过服务端小时配额或 5 秒最小间隔时返回 429 且带 `Retry-After`。
+12. 同步期间被改写的文件既不上传也不推进索引,沿用上一轮记录,且不会被误判成删除。
+
+## 依赖
+
+- 现有 `platform_session`(用户身份与 Access Token)、项目 manifest(稳定 `project_id`)。
+- 现有 `platform-oss`(PUT/HEAD、私有访问)、`api-server` 登录态中间件与 `shared-contracts`。
+- 现有 AppData 私有文件写入与目录解析工具。
diff --git a/docs/project-memory/plans/【里程碑】图集切片模式显式决策-2026-09-17.md b/docs/project-memory/plans/【里程碑】图集切片模式显式决策-2026-09-17.md
new file mode 100644
index 000000000..49925b314
--- /dev/null
+++ b/docs/project-memory/plans/【里程碑】图集切片模式显式决策-2026-09-17.md
@@ -0,0 +1,49 @@
+# 【里程碑】图集切片模式显式决策
+
+| 字段 | 值 |
+| --- | --- |
+| Version | 1.0 |
+| Status | proposed |
+| Date | 2026-09-17 |
+| Parent Spec | `docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md` |
+
+## 目标
+
+图标图集生成的切分模式不再具备任何隐式默认:平台入口、AGC 客户端自有流程、画板前端和所有 Agent / 工具说明都必须在请求中显式声明 `sliceMode`,并在同一份决策要求下选择 `connected-components` 或 `grid`。
+
+## 范围
+
+- `sliceMode` 在图标图集生成入口成为必填;缺失、`null`、空字符串在副作用之前失败关闭。
+- `grid` 与 `connected-components` 的参数自洽性:`grid` 必须带行列数,连通域不得携带网格尺寸。
+- 决策要求写入主规范、公开契约、MCP / Agent 工具说明、Skill 与客户端自有路径,口径一致。
+- 依赖平台默认值的自有调用方全部改为显式声明,且不新增兜底分支。
+- 失败信息可执行:所有拒绝路径都带字段名与决策要求,`sliceCount` 的目标数量与上限语义在契约中写清。
+- 端到端可证明:生成结果回显生效的切分声明与切片路径,严格图集在本地提交前校验回显与请求一致。
+- 标准美术包显式声明四张 canonical 切片的切分声明,并在用途映射前校验切片数量正好为四。
+
+## 不在范围内
+
+- 不改动图集生成、去背、像素规整、拆分算法本身和切片上限。
+- 不新增切分模式,不恢复已退役的固定网格契约。
+- 不改动手动 `拆分图集` 入口的既有行为。
+- 不为旧版本客户端保留过渡性兜底。
+
+## 依赖与前置条件
+
+- 无外部依赖;`sliceMode`、`gridX`、`gridY` 契约字段已在现行版本存在。
+
+## 验收标准
+
+- [ ] 省略 / `null` / 空字符串 `sliceMode` 的图集生成请求在定价、入队、扣费和 provider 调用之前返回 `400`,错误体含 `field=sliceMode`。
+- [ ] `grid` 缺 `gridX` 或 `gridY`、越界、乘积超限时 `400`;`connected-components` 携带 `gridX`/`gridY` 时 `400`。
+- [ ] 公开契约、MCP / Agent 工具说明、Skill 与画板前端类型都要求显式声明,且不再声明任何默认值。
+- [ ] AGC 客户端与画板前端的所有图集生成路径都显式传入模式,不再依赖平台兜底。
+- [ ] 响应回显的 `sliceMode` 与请求声明一致;`grid` 时同时回显行列数。
+- [ ] 拒绝信息包含字段名、允许取值与决策分支;`sliceCount` 契约上限与切片上限一致。
+- [ ] 标准美术包声明 `sliceCount=4`,数量不符时在写入用途清单前失败关闭。
+
+## 证据要求
+
+- 自动化:平台定向测试(缺失、空串、连通域带网格尺寸、grid 缺维度、正常两种模式)、OpenAPI 契约测试、前端与 AGC 客户端定向测试。
+- 运行时:本地 `api-server` smoke 提交一次缺字段请求,确认返回 `400` 且无扣费 / 入队记录。
+- 边界:确认失败发生在引用解析、定价、入队与 OSS 副作用之前;确认响应字段与请求一致。
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index 75116aadb..f003b5be3 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -9,6 +9,27 @@
- 决策:抠图请求及工具回执统一使用 `prepare_external_canvas_generation_context` 返回的远端 `context.project_id`;本地 manifest 项目 ID 只用于绑定键和本地状态,不得作为主站业务请求的 `projectId`。
- 验证:客户端定向 Rust 测试、格式、编码和 diff 检查通过;未修改主站路由或 BgFilter。
+## 2026-09-17 图集切分模式改为显式声明
+
+- 决策:`sliceMode` 在图标图集生成入口成为必填字段且不保留任何默认值。省略、`null` 或空字符串必须在引用解析、定价、入队和 provider / OSS 副作用之前返回 `400`(`field=sliceMode`);`grid` 必须同时提供 `gridX`/`gridY`,`connected-components` 不得携带网格尺寸,二者矛盾同样在副作用前失败关闭。
+- 决策要求:只有用户或需求明确要求等分网格、固定槽位或指定行列数时才使用 `grid`,且行列数必须来自该需求;自由排布、数量不定或只要求一张图集时显式传 `connected-components`,需要约束素材张数时用 `sliceCount`,不得用网格参数表达张数,也不得用固定 `2×2` 表达“四类素材”。
+- 影响面:平台两个图集生成入口(`/api/editor/...` 与 `/api/external/v1/editor/...`)、OpenAPI、画板 Agent 工具、画板前端提交计划、AGC 客户端 MCP 工具说明与桥接校验、AGC 原生工具 schema 与观察器、AGC Skill 与外部编辑器 Skill。
+- 迁移影响:省略 `sliceMode` 的旧调用方(含已发布但未更新的 AGC 客户端和第三方外部 API 调用方)会在图集生成上收到 `400`;本次同时把仓库内自有调用方改为显式声明,不为旧客户端保留兜底分支。
+- 错误可执行性:缺失、空白、未知取值都以 `400` + `field=sliceMode` 返回允许取值和决策分支,`grid` 缺维度提示 `sliceCount` 才是张数约束;`sliceCount` 的公开契约上限与切片上限统一为 `256`(识别数量与目标不一致返回 `422` 并回报实际数量)。
+- 反馈闭环:图集生成结果回显生效的 `sliceMode`/`gridX`/`gridY` 与 `slicePaths`;严格图集提交前必须证明平台回显的模式(`grid` 时含行列数)与请求显式声明一致,缺失或不一致一律失败关闭。
+- 标准美术包:客户端显式声明 `sliceMode=connected-components` + `sliceCount=4`,本地按用途位置写四张 canonical 切片前再次校验数量正好为四,数量不符时失败关闭,禁止截断或补位。
+- 测试环境:在提权 shell 的 Windows 主机上,`%TEMP%` 下新建目录的默认所有者是 `BUILTIN\Administrators` 而不是当前 TokenUser,AGC 的所有者校验会拒绝测试自己创建的项目根;测试构建对该情形(仅限 `%TEMP%` 内、且失败原因为所有者不匹配)先按“本调用创建的对象”初始化所有者后重试,临时目录之外的越权所有者继续失败关闭。
+- 权威合同:[画板图标素材生成入口设计](../../【编辑器】画板图标素材生成入口设计-2026-06-15.md)。
+## 2026-09-17 `agc_tools` 媒体资源提示词上限收敛为单一口径,并按 kind 暴露给模型
+
+- 背景:有人反馈「客户端没法由 agent 调用图片快速编辑功能以及背景音乐生成功能」。核查后工具本身都在(`agc_edit_image` / `agc_create_or_derive_resource`),图片快速编辑在 2026-09-14 的真实项目日志里也有成功记录;但存在三类真实缺陷:① `agc_create_or_derive_resource` 的 `prompt` 在 schema 里只声明 4000,真实上限却是按 kind 分的(背景音乐 140、音效 1900、视频/角色动画 4000、图片 32000),MCP 层还额外写死了一条 140 判断,模型从 schema 与 skill 都看不出 140/1900,写一句正常长度的背景音乐描述就当场被拒;② 客户端 UI 用同一口径但会截断并提示,agent 侧却只有硬拒,形成「UI 能做、agent 调不动」的观感;③ `sourceLocalAssetId` 不是已登记资源时只报「不属于当前项目已登记资源」,模型会原地重试而不会先登记。
+- 决策一(单一口径):提示词上限只由 `resource_edit_prompt_max_chars` 给出,MCP 工具层、客户端受控工具桥与提交校验全部从它取数;超限文案复用 `resource_edit_prompt_limit_error`,保证模型看到的数字就是真实生效的数字。传输层边界只在信封级生效,不再用一个更小的通用常量先于按 kind 上限误报。
+- 决策二(按 kind 暴露):`agc_create_or_derive_resource` 的 schema 用 `allOf[oneOf]` 逐 kind 声明 `prompt.maxLength`(background-music / sound-effect / video+character-animation),顶层 `maxLength` 等于各 kind 上限的最大值,`prompt` 描述里写明每个数字;`agc_edit_image` 继续用图片口径 32000。skill 包 `agc-client-projection`(SKILL.md 与 `references/projection-contract.md`)同步写明四个数字,并说明超限要在本地收敛而不是原样重发。
+- 决策三(可执行的前置提示):源资源未登记时统一返回「先用 `agc_list_registered_assets` 选已有 localAssetId;文件只在项目里时先用 `agc_list_project_files` 确认 `assetImportable=true`,再用 `agc_import_account_assets.localPaths` 登记后重试」。本轮不放开「已完成任务产物」在 agent 侧的隐式正规化:登记是带副作用与 revision 推进的事务,必须由模型显式发起。
+- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`(上限与文案的唯一口径)、`agent/direct_tool_bridge.rs`(按 kind 判定与未登记源资源提示)、`agent/direct_tools_mcp.rs`(schema 与校验)、`resources/agc-skills/agc-client-projection/**` 与清单指纹(version `2026-08-26.18`)。**未改** `/api/external/v1` 契约与 OpenAPI、SpacetimeDB schema、前端 TS 侧 `resourceEditPromptMaxLength` 数字、客户端 UI 行为。
+- 验证方式:新增 `tool_prompt_limits_agree_with_the_client_authority`(四个 kind 的 schema 上限、MCP 校验与客户端权威口径同数字,超限文案带真实上限)、`bridge_resource_prompt_limits_follow_the_client_authority`(工具桥侧同类门禁,含图片编辑的 32000 边界)、`edit_image_tool_reaches_the_platform_image_edit_route` 与 `background_music_tool_reaches_the_platform_audio_route`(MCP 工具层 → 真实工具桥 → 假平台,断言 `/api/editor/images/edits` 与 `/api/editor/audios/background-music/generations` 的路径、Bearer、Idempotency-Key、正文与派生资源落盘,图片编辑正文不得回填 assetKind)、`background_music_prompt_over_the_limit_is_rejected_before_any_bridge_call`(超限在桥请求之前失败)、`unregistered_source_reports_the_registration_follow_up_tools`;`agent::direct_tools_mcp` 22 passed、`agent::skill_pack` 4 passed、`agent::direct_tool_bridge` 17 passed(7 条本机既有失败见下)、`npm run agc:skill-pack:check` 与 `skill-pack:test` 通过。本机 `tempfile::tempdir()` 归属校验失败导致的既有用例(`project::resource_editor` 45 条、`agent::direct_tool_bridge` 7 条)在本轮改动前后**同为失败**(stash 基线复跑确认),与本次无关。
+- 关联文档:[AI游戏创作智能体App实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)、[踩坑记录](pitfalls.md)。
+
## 2026-09-16 抠图模式与背景色契约
- External v1 抠图和 AGC `agc_remove_background` 支持 `complex`(语义分割识别前景)与 `flat`(纯色背景抠图);明确纯色背景优先 flat,模式缺省仍为 complex,主站前端保持现有行为。
@@ -8819,6 +8840,16 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 决策:采用后台继续运行语义。Direct 回合由进程内项目身份锁持有,页面离开不取消;重进项目通过活动回合只读快照与 Thread Manager bootstrap/consume 恢复忙碌态和进度。左上角面板复用同一快照列出正在运行的 Direct 项目并支持进入。
- 边界:快照不写项目文件、不进入公共 API、不跨应用重启恢复;读取失败保留上一份结果并单独提示,不改写成权限或审批失败。身份锁排他性、付费身份和项目写锁不变。
+## 2026-09-17 AGC 模板库落在 oss://agc-dev/templates/
+
+- 背景:AGC 需要「真·游戏模板」库,让用户能浏览、筛选、下载模板并直接由模板创建项目,且模板内容更新不依赖客户端发版。
+- 决策:模板库固定在 bucket `agc-dev`(endpoint `oss-rg-china-mainland.aliyuncs.com`)的 `templates/` 前缀,公共读;**不再嵌套 `agc/` 这一层**(`agc/` 继续只放客户端安装包与 `latest.json` 更新通道)。目录为 `templates/index.json` + `templates/v1//{template.json,template.zip,cover.png}`,zip 根等于 AGC 项目根。
+- 决策:清单 schema 为 `agc-template-library.v1`,每条模板带 `tags`、`coverKey/coverWidth/coverHeight/coverSha256`、`zipKey/zipSizeBytes/zipSha256` 与 `templateVersion`;客户端只信「受信任 OSS 主机 + 对象键」自行拼 URL,清单里的地址字段不参与请求。
+- 决策:客户端缓存与安装根为 `/templates/`(`index.json` 缓存 + `installed///`,安装完成才写 `installed.json`);建项目在 `/projects/` 下走既有自动工作区规则,先铺模板文件再补 `.agent` 清单。
+- 决策:模板库首页推荐位替换原「灵感推荐」本机图片目录(已删除 `InspirationGallery.tsx` 与 `assets/inspiration/`);左侧导航新增模板库入口,打开独立全屏页。`tauri.conf.json` 的 `img-src` 放行受信任 OSS 主机用于封面图。
+- 关联规范:`docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md`;开发期计划见 `docs/project-memory/plans/【里程碑】AGC模板库客户端接入-2026-09-17.md` 与对应实施计划。
+- 验证:Rust 模板库 8 项定向单测、前端模型 9 项单测、AGC `tsc` 类型检查通过;`templates/index.json` 匿名可读且每个 `zipKey` 回读 SHA-256 与清单一致;发布脚本 `scripts/agc-template-library-publish.mjs` 支持 `--dry-run` 与上传后回读校验。
+
## 2026-09-16 CI 宿主 CPU 上限:Jenkins 16 核 / Gitea Actions runner 12 核
- 背景:`genarrative-station`(32 逻辑核)上 Jenkins Built-In Node 与 Gitea Actions runner 共用同一宿主。Jenkins `jenkins.service` 原先没有任何 CPU 限制(`cpu.max=max`),构建期 Web / Api / Stdb 三分支并行(Vitest 8 线程 + 两次默认 32 job 的 cargo)把整机顶到 80%~95%;`gitea-runner` 容器 `--cpus=24`(75%)在 push 触发的 CI 波峰里实测峰值 24.8~25.3 核,是同一时间窗里更大的单一消耗方。
diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md
index 36fbbe575..0003dbf5e 100644
--- a/docs/project-memory/shared-memory/development-workflow.md
+++ b/docs/project-memory/shared-memory/development-workflow.md
@@ -51,6 +51,8 @@
## 验证路由
+AGC 运行时配置默认值调整时,同步核对 Rust 默认值、分发配置模板、设置弹窗默认草稿和 `runtime-settings.suite.ts` 的恢复默认断言;显式传入旧值的配置读取用例仍验证原值保留,不批量替换测试数据。
+
AGC 测试构造单 HTML 项目时,必须在初始化之前写入 HTML,避免自动建立 npm 工程;npm 预览和导出测试应提供 dist 产物。已有图片生成 pending/operation 属于持久化恢复合同,修改工具默认参数后仍须验证旧动作恢复不重复提交、不因默认值变化被误判为新意图。
SpacetimeDB 任务统一先读取 `.codex/skills/genarrative-spacetimedb/SKILL.md`;该项目适配层按需调用已安装的官方 `spacetimedb` 插件 skill,插件提供通用 SDK/CLI/MCP 知识,项目 skill 负责 Genarrative 架构边界和验证门禁。
diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md
index 74092e946..52b39d70a 100644
--- a/docs/project-memory/shared-memory/pitfalls.md
+++ b/docs/project-memory/shared-memory/pitfalls.md
@@ -1,5 +1,34 @@
# 踩坑与排障记录
+## 2026-09-17 AGC 输入盒的「推理档」弹层被祖先裁切:要放开裁切而不是挪弹层
+
+- **现象**:窄窗口下(视口 ≤1000px 时右侧对话面板只有 280px 宽)点开输入盒右下角的「推理档」,弹层是个**空盒子**:档位文字(默认 / 低 / 中 / 高 / 最高)整片看不见,只剩一个方框。
+- **成因**:推理档是控制排里最靠左的弹层锚点,`.conversation-model-menu` 默认 `right: 0` 贴触发钮右缘**向左**展开;触发钮右边还压着模型选择、语音、发送三颗钮,所以 150px 宽的弹层在 280px 面板里会伸到面板左侧 42px 之外。`.game-workbench-chat`、`.project-supervisor-surface.is-direct-codex`、`.project-supervisor-conversation` 三层各自的 `overflow: hidden` 沿自己的溢出边界裁掉它,而档位文字起点才 14px(面板左内边距 5px + 按钮左内边距 9px),正好落在被裁掉的那半边。
+- **处理(用户指定口径)**:不挪弹层位置——只让 direct-codex 那三层不再裁切:`.game-workbench-chat:has(.project-supervisor-composer.is-direct-codex)`、`.game-workbench-chat .project-supervisor-surface.is-direct-codex`、`.game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-conversation` 三条 `overflow: visible`。弹层的 `right: 0`、尺寸和触发钮锚点全不变,只是允许它盖到左侧资源面板上完整显示。消息列表自带 `overflow-y: auto`(另一轴按规范计算为 auto),消息内容仍由列表自身裁剪。
+- **易错点**:① 把弹层改成 `left: 0` 或往右挪也能让它可见,但那是改变展开方向,弹层会跑到触发钮右边(用户明确否决);② 只放开最外层聊天列不够——surface 与 conversation 各自都会裁,三层必须同时放开;③ 只按宽度比大小会误判:280px 面板里控制排本身也超出(发送钮右侧溢出 22px,被窗口右缘吃掉),那不是本条的原因,别顺手去改控制排布局。
+- **验证**:`apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` 的 `keeps the landscape workbench edge-to-edge with internal chat scrolling` 钉住三条 override 声明在场(删掉任一条即红)。真机几何用 playwright-cli 打开一份只含真实 `styles.css` 与真实 composer DOM 的最小复现页实测(视口 1000×700、面板 280px):弹层 rect 修复前后都是 `[-42, 108]`(位置未动),`elementFromPoint` 的命中区间从修复前的 `[2, 108]` 变成整块;档位文字在截图中完整可见。
+- **关联**:`apps/ai-game-creator-shell/src/styles.css`(`面板纵向布局(2026-07 Codex 风格改造)` 区块之后)、`apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`。
+
+## DirectProject 历史不能按工具条目切页再按消息推进游标
+
+原始 `response_item` 历史同时含用户/助手消息、推理与工具输出。若原生每次取 20 个原始条目、前端过滤聊天消息后再找最旧 ID,纯工具页会让消息集合为空且游标不动,看起来历史丢失。聊天读取固定显式请求 `messagesOnly: true`,原生逐行过滤后按消息分页并返回 `oldestItemId`;默认原始模式留给原始条目消费者。无 ID 旧消息保留并扩展到可寻址边界,不能造 ID。前端保留项目与读取代次、单飞及 ID 去重,旧请求的成功、失败与 finally 都不能覆盖新读取;真实日志只在临时目录只读重放,不能提交正文夹具。
+
+## JSON 卡片显示与 UI 编辑能力必须同源
+
+JSON 的文本读取分支不等于卡面应该展示原始 State 摘要。卡片、缩略图及编辑器入口共同消费受控文本预览的 `uiDesignAssetId`;只有原生复用 UI 持久化合同校验 schema、完整 State 和项目/资产身份后才设置它。普通 JSON 保留 JSON 代码预览,不按 `kind: UI/ui` 或 schema 字符串片段猜测编辑能力。已有合法 UI State 的加载/保存不依赖 kind 精确大小写,但新建初始化仍保留正式 UI 资产门禁;缓存与项目切换须保留现有身份隔离。
+
+## 窗口 Context 发布不得依赖每次渲染新建的业务回调
+
+工作台向窗口标题栏发布运行项目时,若 effect 依赖普通函数派生的回调,发布 Context 会重新渲染工作台,进而再次发布并清理,形成更新深度循环。转发入口须稳定,并在提交阶段更新实际处理器引用;发布数据变化与卸载清理分开。回归测试必须组合真实窗口 Provider 和工作台消费者,只有独立画布测试无法覆盖这条反馈链;回归时用有界发布次数阻止测试失控。画布快速操作时暴露的更新深度错误,也须检查外层状态同步,不能直接归因于滚轮频率。
+
+## 2026-09-17 工具 schema 声明的上限与真实校验不一致,会表现成「agent 调不动这个功能」
+
+- **现象**:用户反馈「客户端没法由 agent 调用图片快速编辑功能以及背景音乐生成功能」。查工具目录时两个工具都在(`agc_edit_image`、`agc_create_or_derive_resource`),图片快速编辑在真实项目日志里还有成功记录;但 agent 侧写一句正常长度的背景音乐描述就失败,而客户端 UI 用同一个提示词却只是被截断加提示。
+- **原因**:`agc_create_or_derive_resource.prompt` 在 MCP schema 里只声明 `maxLength: 4000`,真实上限按 kind 分(背景音乐 140 / 音效 1900 / 视频、角色动画 4000 / 图片 32000),MCP 层还额外写死一条 `kind == background-music && > 140` 的判断;skill 包没有任何一处写这两个数字。模型从 schema 与 skill 都无法得知 140,于是必然踩一次硬拒。同类隐患还有两处:客户端工具桥用通用 4000 校验 prompt,会把 4000 以上的图片编辑提示词误报成「超出安全边界」;按 kind 校验散落在 MCP 与桥两处,新增类型容易只改一处。
+- **处理**:上限收敛到 `resource_edit_prompt_max_chars` 单一权威(工具层、桥、提交校验共用),超限文案复用 `resource_edit_prompt_limit_error`;工具 schema 用 `allOf[oneOf]` 逐 kind 声明 `prompt.maxLength` 并在描述里写明数字;prompt 的传输层边界退到信封级,避免通用常量先于按 kind 上限报错;两端 skill 文档同步写明四个数字。新增 `tool_prompt_limits_agree_with_the_client_authority` 作为门禁:四类 kind 的 schema 上限、桥上限与权威口径必须同数字,且超限文案必须带真实上限。
+- **验证**:`cargo test --bin genarrative-ai-game-creator-shell -- --test-threads=1 agent::direct_tools_mcp::tests`(22 passed,含两条走 MCP 工具层 → 真实工具桥 → 假平台的媒体工具契约用例与一条超限零请求用例)、`agent::direct_tool_bridge::tests`(17 passed,含新增的按 kind 上限门禁;另有 7 条本机既有失败)、`agent::skill_pack`(4 passed)、`npm run agc:skill-pack:check`。本机 `tempfile::tempdir()` 归属校验失败会让 `project::resource_editor` 45 条与 `agent::direct_tool_bridge` 7 条既有用例失败,改动前后同为失败,不要据此误判回归。
+- **关联**:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`、`src-tauri/src/agent/direct_tool_bridge.rs`、`src-tauri/src/agent/direct_tools_mcp.rs`、`src-tauri/resources/agc-skills/agc-client-projection/`。
+
## 2026-09-16 从 Codex 里启动 AGC 客户端会看到被重定向的 `%APPDATA%`
- **现象**:在 Codex 会话里用 `Start-Process` 启动 `genarrative-ai-game-creator-shell.exe` 做排障时,子进程写 `C:\Users\\AppData\Roaming\world.genarrative.ai-game-creator\...` 的内容会落到 `C:\Users\\AppData\Local\Packages\OpenAI.Codex_2p2nqsd0c76g0\LocalCache\Roaming\...`;同一个 `Test-Path` / `Get-ChildItem` 命中的是重定向视图,只有 `\\?\C:\Users\...` 形式能区分真实路径。
@@ -5649,3 +5678,11 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **处理(现行口径)**:不要把重写结果当改动提交。跑过 `cargo test` 或构建后先 `git checkout -- apps/ai-game-creator-shell/src/features/project-workspace/generated`,再删掉多出来的 `DirectCodexUserMessageEnvelope.ts`,然后才做 typecheck / 打包;绑定与前端形状冲突时以**已提交的绑定 + 前端**为基准排查。
- **验证**:恢复提交版本后 `npm run ai-game-creator-shell:typecheck` exit 0(`[skill-pack] OK`);保留重写结果时同一条命令 exit 2。release 构建本身还会在 `src/features/ui-editor/types/` 落下 `BindingChange.ts` / `BindingDTO.ts` 两个无人引用的未跟踪文件,属同类生成产物。
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/`(ts-rs 导出源)、`apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts`、`apps/ai-game-creator-shell/scripts/build-release.mjs`(`beforeBuildCommand`)。
+
+## 2026-09-17 AGC 壳首页无限 setState:effect 依赖了每次渲染都换身份的普通函数
+
+- **现象**:dev 客户端停在首页、不点任何东西也会持续刷 `WEBVIEW error webview: Maximum update depth exceeded …`(5 秒涨 ~8.5 KB 日志),对应 WebView2 renderer 工作集涨到 **4.2 GB**、CPU 持续累计(约 0.7–1.5 核);表现上很像"模板库卡片太多/滚动卡",实际与页面内容无关。
+- **原因**:`WorkspaceLauncher` 里发布"活动项目面板"数据的 effect,依赖数组里带了 `openActiveProject`;它由 `useCallback([openProject, setProjectPath])` 生成,而 `openProject` 来自 `useHomeProjectCreation` 的**普通函数声明**(每次渲染都是新身份)→ `openActiveProject` 每渲染都变 → effect 每渲染重跑 → cleanup/主体调 `setActiveProjectRuns` 改 `WindowChrome` 的 state → 标题栏重渲染 → 又一轮。`WindowChrome` 的 context value 当时还是内联对象,进一步放大了连带重渲染。日志里没有组件栈,是靠在 `console.error` 包装里抓 `new Error().stack`(该日志与 setState 同栈)才定位到 `WorkspaceLauncher.tsx` 的 `commitHookEffectListUnmount → dispatchSetState`。
+- **处理(现行口径)**:① 依赖里只放数据,回调走 ref(`openActiveProjectRef`)——effect 不再因回调换身份而重跑;② `useDirectActiveTurns` 轮询只在快照内容变化时才 `setActiveTurns`(并给空态做引用稳定),避免每 5 秒换一次数组身份去带动下游 effect;③ `WindowChrome` 的 context value 用 `useMemo` 收口。判断类问题的通行判据:**凡是把"每次渲染新生成的函数/对象"写进 effect 依赖的,一律视为 bug**。
+- **验证**:修复后同一台机器、同一路径下 35 秒内新增 `Maximum update depth` **0 条**,renderer 工作集 **254 MB**(修复前 4.2–4.4 GB);`apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx` 断言轮询返回值不变时快照引用不变。
+- **关联**:`apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx`、`apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts`、`apps/ai-game-creator-shell/src/components/WindowChrome.tsx`、`apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`。
diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md
index 29eae7a7f..61a738e77 100644
--- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md
+++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md
@@ -1,68 +1,131 @@
# AGC 客户端更新检查与下载
-## 交付范围
+更新时间:`2026-09-17`
-AGC 每次启动时由根窗口检查一次公开 OSS 更新清单。清单默认位于
-`https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/latest.json`,构建时可用
-`VITE_AGC_UPDATE_MANIFEST_URL` 覆盖为同一受信任 OSS 域名下的 HTTPS 地址。客户端版本取
-`apps/ai-game-creator-shell/package.json`,通过 `version` 与清单版本比较;只有远端版本更高时显示更新提示。
+本文件是 AGC 客户端自动更新的主规范:更新能力由 Tauri 官方插件 `tauri-plugin-updater` 承担,并按下文渠道分发。
-清单格式:
+## 目标
+
+- 客户端自动更新改用 Tauri 官方 `tauri-plugin-updater`:清单请求、版本比较、更新包下载、签名校验、安装与退出全部在原生侧完成;前端只负责触发、展示和渠道选择。
+- 更新按渠道分发。当前渠道集合为 `dev-win`(Windows x64)与 `dev-mac`(macOS);构建管线按渠道产出并上传清单,客户端只读取自己渠道的清单。
+- 更新链路的信任来源从「清单里的 sha256 + 受信域名」升级为「发布签名 + 受信域名」:清单里的 `signature` 由构建期私钥生成,客户端用内置公钥校验,校验不过就拒绝安装。
+
+## 非目标
+
+- 不做灰度放量、分批更新、强制更新和自动回滚;渠道只决定「取哪份清单」。
+- 不做后台静默自动安装:是否下载安装始终由用户在更新提示里确认(仅「是否显示提示」受渠道与开发态开关影响)。
+- 不支持应用商店分发(Microsoft Store / App Store)、移动端更新和企业内网自建更新服务。
+- 不为自研 sha256 清单协议保留长期实现;迁移桥(见「契约与迁移」)只用于把已发布客户端带到新协议,随后整条删除。
+
+## 入口与边界
+
+- 用户入口:
+ - 客户端启动时在根窗口检查一次渠道清单,发现新版本时显示更新提示,用户可下载并安装。
+ - 运行时设置「关于」页提供手动检查更新(强制刷新)。
+- 涉及模块:AGC 客户端(Rust `src-tauri`、前端 `src/`)、AGC 构建与发布脚本(`apps/ai-game-creator-shell/scripts/`)、Jenkins 发布流水线、OSS 对象布局。
+- 正式状态来源:
+ - 客户端当前版本以 Tauri app version 为唯一权威来源(`tauri.conf.json`,由发布脚本与 `package.json`、`Cargo.toml`、`Cargo.lock` 同步递增)。
+ - 远端最新版本取自当前渠道的 `latest.json`。
+- 信任边界:清单地址在构建期确定并烘焙进产物;客户端不接受用户输入、后端响应或项目文件提供的更新地址,也不回退到其它渠道或旧协议地址。
+
+## 必须成立的行为
+
+### 正常路径
+
+- 正式包启动时检查一次渠道清单;仅当清单版本高于当前版本时显示更新提示,提示包含目标版本与发布说明。
+- 用户确认后下载更新包:下载期间显示进度与已下载字节数;下载完成后按平台安装。
+- Windows 使用静默安装模式(NSIS `quiet`),安装启动成功后客户端退出并由安装程序重启新版本;macOS 由客户端在安装完成后重启进程接管新版本。
+- 渠道在构建期确定并烘焙进产物:`dev-win` 产物只读 `dev-win` 清单,`dev-mac` 产物只读 `dev-mac` 清单,同一份二进制不会在运行期跨渠道切换。
+- 开发态(`npm run agc` / `agc:serve` 由 Vite dev server 提供前端)不检查更新、不显示更新入口,也不下载任何更新包。
+
+### 失败、重试与幂等
+
+- 清单请求失败均静默忽略,不阻塞客户端启动:网络错误、TLS 错误、404(渠道尚未发布版本)、格式非法、渠道没有当前平台条目、远端版本不高于当前版本。
+- 同一客户端生命周期内只自动检查一次;手动检查可强制刷新。
+- 签名校验失败、下载中断或写入失败必须失败关闭:删除临时文件、不启动安装程序,并给出可读错误文案;不接受「校验失败但继续安装」。
+- 重复点击下载或安装不产生并发安装;安装开始后客户端不再接受新的更新操作。
+
+### 权限、归属与数据边界
+
+- 更新能力通过 Tauri capability 显式授予客户端主窗口,其它窗口(调试窗口等)不得授予。
+- 客户端只允许访问渠道清单声明的地址,只允许安装清单声明且签名校验通过的对象。
+- 清单与安装包在 OSS 上保持公开可读;签名私钥与 OSS 凭据只存在于构建环境(Jenkins 凭据、本机发布配置),不写入仓库、日志、构建产物或客户端包。
+- 客户端不记录更新地址以外的敏感信息;失败文案不回显凭据、绝对路径或响应正文。
+
+## 契约与迁移
+
+- 清单格式(Tauri updater v2):
```json
{
- "version": "0.1.13",
- "downloadUrl": "https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/0.1.13/Genarrative-AI-Game-Creator.exe",
- "sha256": "<64位十六进制摘要>",
- "size": 123456789,
- "releaseNotes": "修复与改进"
+ "version": "0.1.48",
+ "notes": "发布说明,可为空",
+ "pub_date": "2026-09-17T00:00:00Z",
+ "platforms": {
+ "windows-x86_64": {
+ "signature": "<.sig 文件内容>",
+ "url": "https:///agc/dev-win/0.1.48/<安装包文件名>"
+ }
+ }
}
```
-`downloadUrl` 必须是 HTTPS;如提供 `sha256` / `size`,Tauri 下载时会校验摘要和字节数。点击“下载更新”后,客户端将安装包流式写入系统临时目录并显示进度,校验成功后通过 Windows UAC 提权启动 NSIS 静默安装并退出旧客户端。
+- 渠道与平台映射:
-## 启动与失败策略
+| 渠道 | 构建目标 | 清单平台键 | 更新包 | 清单地址 |
+| --------- | ------------------------ | ---------------------------------------------- | ------------------------ | ------------------------------------ |
+| `dev-win` | `x86_64-pc-windows-msvc` | `windows-x86_64` | NSIS `.exe` + `.exe.sig` | `/agc/dev-win/latest.json` |
+| `dev-mac` | `universal-apple-darwin` | `darwin-aarch64` + `darwin-x86_64`(同一对象) | `*.app.tar.gz` + `.sig` | `/agc/dev-mac/latest.json` |
-- 检查挂在 `WindowChrome` 根组件,覆盖首页、工作台和调试窗口;网络错误、格式错误或版本不高于当前版本均静默忽略,不阻塞客户端启动。
-- 更新请求使用单例 Promise,React StrictMode 或同一窗口重复挂载不会重复请求。
-- Tauri HTTP capability 与 CSP 仅放行默认 OSS 域名;若更换域名,需同步更新 `capabilities/main.json`、`tauri.conf.json` 和发布环境配置。
+- 对象布局:清单固定写成 `agc//latest.json`;安装包与签名写成 `agc///` 与 `.sig`。
+- macOS 使用 universal 包:`dev-mac` 按 universal 目标构建(Intel 与 Apple Silicon 共用一个包),清单把同一个 `.app.tar.gz` 与同一个签名分别写入 `darwin-aarch64` 与 `darwin-x86_64`,升级后仍是 universal 包。这是 Tauri 官方发布工具对 universal 产物的既有写法。
+- 上一条的两个键不能合成单一 `darwin-universal` 键:更新插件按运行时实际架构解析清单键(Apple Silicon 命中 `darwin-aarch64`,Intel 命中 `darwin-x86_64`),不存在自动命中 `darwin-universal` 的情形。将来真要单独发该键,必须在客户端同时设置自定义 target,否则清单里这一项永远不会被读取。
+- 构建期要求:打开 `bundle.createUpdaterArtifacts` 以生成 `.sig`;构建环境提供签名私钥与密码(私钥内容不得入库);公钥写入客户端配置。公钥在首个带更新能力的版本发布后不可更换,更换等于放弃自动更新(只能手动重装)。
+- 版本递增按渠道独立进行:发布脚本读取该渠道远端 `latest.json` 的 `version`,与本地版本取较高者递增 patch;两个渠道的版本号互不影响。
+- 迁移(旧协议 → 渠道清单):
+ - 迁移起点:已发布客户端(含当前线上版本)内置自研清单地址 `agc/latest.json`(sha256 格式),下载与安装由自研 Rust 命令完成。
+ - 迁移策略见「未决问题与决策」。迁移完成后,自研清单解析、下载命令、下载进度事件以及为此放行的 CSP / HTTP 白名单条目按「四不写」整条删除,不留兼容分支与墓碑说明。
-## 发布约定
+## 构建与发布
-当前发布目标固定为 Windows x64 NSIS。执行 `npm run ai-game-creator-shell:build` 会先读取
-`VITE_AGC_UPDATE_MANIFEST_URL`(默认 `https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/latest.json`)的
-`latest.json`,取本地与 OSS 的较高版本并递增一个 patch,然后同步更新 package、Tauri 和 Cargo
-版本后再向 Tauri 传入 `--target x86_64-pc-windows-msvc` 构建。OSS 清单首次不存在时按本地版本递增;
-OSS 请求失败、清单格式错误或版本无效会终止发布,避免覆盖线上版本。构建完成后自动扫描 `.exe`
-安装包,并在 `apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/latest.json`
-生成包含版本、下载地址、大小和 SHA-256 的清单。可通过 `AGC_BUILD_TARGET` 显式覆盖目标(发布仍应使用
-Windows x64),通过 `AGC_UPDATE_ARTIFACT` 指定要发布的安装包,通过 `AGC_UPDATE_OSS_BASE_URL` 指定
-OSS 前缀,通过 `AGC_RELEASE_VERSION` 指定三段版本号(仅在明确需要复现指定版本时使用),通过
-`AGC_UPDATE_RELEASE_NOTES` 写入发布说明,支持多行文本且保留内部换行;`--no-bundle` smoke 构建不会读取 OSS、修改版本或生成清单。
+- 发布入口:`npm run ai-game-creator-shell:release:upload`(构建 + 按渠道上传);仅构建不发布的 smoke 使用 `--no-bundle` 分支,不读远端版本、不改版本、不生成清单。
+- 渠道由构建参数显式指定,并按目标平台校验:Windows 目标只允许 `dev-win`,macOS 目标只允许 `dev-mac`;未显式指定时按目标平台取默认渠道。
+- 上传:安装包与 `.sig` 上传到 `agc///`,清单以 `--force` 覆盖上传到 `agc//latest.json`,保证 latest 指针与清单内 URL 指向已存在的对象。
+- Jenkins 流水线需要新增渠道参数与签名凭据;签名私钥与密码只以受保护凭据注入当前进程,不写入 workspace、日志或归档产物。
+- 归档证据:安装包、`.sig`、渠道清单与源码 commit。
-每次发布安装包上传完成后,再使用 ossutil 的 `--force` 覆盖上传同一目录生成的 `latest.json`,确保固定的 latest 指针和 `downloadUrl` 指向已存在的 OSS 对象;未显式强制覆盖时,ossutil 在目标已存在时会交互询问并按默认值跳过,不能作为 Jenkins 非交互发布方式。清单和安装包均使用公开可读对象,不在清单中保存凭据、签名或本地路径。构建脚本本身不负责上传 OSS,发布流水线通过 `release:upload` 完成上传。
+## 验收标准与证据
-如需一键构建并上传,可执行 `npm run ai-game-creator-shell:release:upload`。该命令要求本机已安装并配置 `ossutil`,
-先按上述规则比较 OSS 版本、递增 patch、构建 Windows x64 NSIS,再上传安装包和 `latest.json`。默认上传到
-`agc-dev` / `oss-rg-china-mainland.aliyuncs.com`,也可用 `AGC_OSS_BUCKET`、`AGC_OSS_ENDPOINT` 和 `OSSUTIL_BIN`
-覆盖;本机执行时凭据由 ossutil 本机配置读取,不能写入仓库或命令行参数。
+已获得的证据:
-## Jenkins Windows 构建节点
+| 条款 | 验收方式 | 证据 |
+| -------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
+| 渠道与端点映射、渠道校验 | `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` | 通过(默认渠道、错配失败关闭、未知渠道失败关闭) |
+| universal 包挂两个平台键 | 同上 + 本地发布烟测(伪造 bundle) | 通过(两键同 URL 同签名,不生成迁移清单) |
+| 缺签名时失败关闭 | 同上 | 通过 |
+| 开发态不检查更新 | `vitest run apps/ai-game-creator-shell/tests/appUpdate.test.ts` | 通过(开关关闭时不请求清单) |
+| 旧自研链路整条删除 | 代码检索无残留命令、事件与白名单条目 | 通过(`download_agc_update` / 下载事件 / 清单常量均无残留) |
+| 清单与对象布局符合渠道约定 | `Genarrative-Agc-Windows-Build` #68(2026-09-17,SUCCESS) | 通过:`agc/dev-win/latest.json` = 0.1.48 + `windows-x86_64`;`agc/dev-win/0.1.48/陶泥儿_0.1.48_x64-setup.exe` 与同名 `.sig` 公网可读 |
+| 清单签名与签名对象一致 | 取回 `.sig` 对象与渠道清单 `signature` 比对 | 通过(逐字相同,420 字节) |
+| 安装包与清单登记一致 | 下载安装包实算 SHA-256 与尺寸后与迁移桥清单比对 | 通过(size `104678031`、sha256 `1f67…4fd0` 一致) |
+| 旧协议迁移桥 | 公网读取 `agc/latest.json` | 通过(0.1.48,`downloadUrl` 指向同一对象,含 `sha256` / `size`) |
-AGC 发布流水线使用 `jenkins/Jenkinsfile.ai-game-creator-shell-build`,当前节点标签为
-`windows && win2022`。节点应为 Windows Server 2022 x64 虚拟机,预装 Node.js 22、npm
-10.9.7、Rust 1.96.0、Visual Studio Build Tools(MSVC 与 Windows SDK)、Git 和 ossutil;
-Jenkins Agent 服务必须能在同一用户环境中找到这些命令。Tauri Windows bundler 使用
-`tauri.windows.conf.json` 中的 `bundle.useLocalToolsDir: true`,把固定版本的 NSIS 工具缓存到
-`src-tauri/target/.tauri/NSIS`,不依赖 Jenkins 服务账户的 `%LOCALAPPDATA%\tauri` 或 PATH 中的系统 NSIS。
-Jenkins Checkout 的 `git clean -fdx` 会清理该构建目录,因此每次全新工作区可能重新下载 NSIS;这只影响构建耗时,不改变工具来源或执行权限要求。
-流水线参数 `AGC_UPDATE_RELEASE_NOTES` 使用 Jenkins `text` 类型,可直接输入多行发布说明;执行根 workspace 的 `npm ci`,然后调用
-`npm run ai-game-creator-shell:release:upload`,并归档 Windows 安装包、`latest.json` 与源码 commit。
-流水线会将未导出的空参数按空字符串处理:`COMMIT_HASH` 留空时沿用 Jenkins SCM 当前提交,`OSSUTIL_BIN` 留空时使用节点 PATH 中的 `ossutil`,不会因 PowerShell 对空环境变量调用 `.Trim()` 而提前失败。
+待执行证据(首次渠道发布后回填):
-Jenkins Job 在“Build and upload”阶段通过受保护凭据 ID `AliyunAccessKeyId` 和
-`AliyunaccessKeySecret` 注入 AccessKey,仅在当前进程运行时传给 ossutil,不写入仓库、workspace 或构建日志;
-本机运行仍使用 ossutil 配置。凭据必须具备 `PutObject` 权限;OSS 对客户端保持公共读即可,公共读本身不授予
-Jenkins 上传权限。由于版本号取决于 OSS 当前清单,Job 已关闭并发构建;若 Jenkins
-上存在多个 AGC 发布 Job,还应使用同一个 Lockable Resource 串行化发布。Job 参数
-`AGC_RELEASE_VERSION` 留空时自动递增,填写后会使用指定版本并更新对应的 `latest.json`,因此回滚或测试旧版本前应确认不会覆盖线上更新入口。
+| 条款 | 验收方式 | 证据 |
+| ---------------------------- | --------------------------------------------------------------------- | ------ |
+| 真实更新闭环(含升级后重启) | 0.1.47 客户端升级到新版本,再启动不再提示;`npm run agc` 仍无更新入口 | 待执行 |
+| 签名校验失败拒绝安装 | 篡改渠道清单 `signature` 后观察客户端拒绝安装的表现 | 待执行 |
+
+## 未决问题与决策
+
+已决策:
+
+- macOS 采用 universal 包,同一产物同时挂 `darwin-aarch64` 与 `darwin-x86_64` 两个清单键(见「契约与迁移」)。
+- 旧客户端迁移桥:保留一个版本周期。渠道清单上线后,发布管线同时把旧的 `agc/latest.json`(sha256 格式)指向 `dev-win` 最新安装包,让已发布客户端自动升级到新协议;下个周期整条删除。
+- 签名密钥:由本仓库维护者生成并保管,私钥保存在仓库外(`%USERPROFILE%\.tauri\genarrative-agc-updater.key`),只有公钥进入客户端配置;Jenkins 用受保护凭据 `AgcUpdaterSigningKey` 与 `AgcUpdaterSigningKeyPassword` 注入为 Tauri 打包器读取的 `TAURI_SIGNING_PRIVATE_KEY` 与 `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`,本机可用 `TAURI_SIGNING_PRIVATE_KEY_PATH` 指向同一私钥。当前密钥不带密码;首次发布前仍可重新生成,首次发布后不可更换。
+- macOS 发布方式:`dev-mac` 产物在本机 mac 上执行发布入口上传,Jenkins 暂不新增 macOS 节点;macOS 代码签名与公证凭据未确认前,相关闭环记为未验证项,不静默通过。
+
+待办:
+
+- macOS `dev-mac` 渠道落地(macOS 构建机、签名与公证、安装后重启验证、是否接入 Jenkins macOS 节点)暂缓,由后续独立变更单独完成;在此之前 `dev-mac` 渠道只有构建与清单能力,不发布。
diff --git a/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md b/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md
new file mode 100644
index 000000000..b3e74d35f
--- /dev/null
+++ b/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md
@@ -0,0 +1,136 @@
+# 【技术方案】AGC 模板库与模板建项
+
+## 交付范围
+
+AGC 客户端接入公共 OSS 上的**游戏模板库**(真·游戏模板,正文是 zip),并让用户能浏览、搜索、筛选、下载模板,直接由模板创建项目。模板更新不再依赖客户端发版。
+
+- OSS 侧:bucket `agc-dev`(endpoint `oss-rg-china-mainland.aliyuncs.com`)下的 `templates/` 前缀,公共读。
+- 客户端侧:Rust `template_library` 模块(读清单、下载、安装、建项目)+ 模板库全屏页 + 首页模板推荐 + 左侧导航入口。
+- 不在本次范围:模板制作工具、模板审核、模板计费、增量更新、已建项目的模板回填。
+
+## OSS 契约
+
+```text
+templates/
+ index.json # 模板库清单,客户端唯一读取入口
+ v1//
+ template.json # 单模板元数据(含文件级摘要)
+ template.zip # 模板正文,zip 根 == AGC 项目根(如 game/index.html)
+ cover.(png|jpg|webp|svg) # 封面图(卡片展示,客户端
直接取)
+```
+
+`index.json`(schema `agc-template-library.v1`):
+
+| 字段 | 说明 |
+| --- | --- |
+| `schemaVersion` | 固定 `agc-template-library.v1`;破坏性变更换 schema,不原地改语义 |
+| `library` / `libraryVersion` / `updatedAt` | 库标识、库格式版本、本次更新时间 |
+| `templates[].id` | 稳定标识,`[a-z0-9][a-z0-9._-]{0,63}`,同时是目录名 |
+| `templates[].title/summary/tags[]` | 展示与搜索/筛选用文案;`tags` 参与标签筛选与关键词命中 |
+| `templates[].runtime` | `html` / `unity` / `godot` / `cocos` |
+| `templates[].engine` / `engineVersion` | 引擎标识与版本(如 `phaser` 4.2.1、`three.js` 0.180.0) |
+| `templates[].templateVersion` / `updatedAt` | 模板内容版本;客户端按它判断是否需要重新下载 |
+| `templates[].entry` | 解压后的项目入口相对路径,如 `game/index.html` |
+| `templates[].zipKey` / `zipSizeBytes` / `zipSha256` | 模板包对象键、字节数、SHA-256(下载后强校验) |
+| `templates[].coverKey` / `coverWidth` / `coverHeight` / `coverSha256` | 封面对象键与尺寸/摘要 |
+| `templates[].metadataKey` | 单模板元数据对象键(`template.json`) |
+
+约束:
+
+- 所有对象键必须落在 `templates/` 前缀内;客户端只用「受信任 OSS 主机 + 对象键」自行拼 URL,**不直接信任清单里的地址**。
+- 任何一项校验失败(schema、标识符、sha256、尺寸、键前缀)都让整次清单读取失败,前端拿到的是全有或全无的清单。
+- 模板源在仓库 `apps/ai-game-creator-shell/template-library/`:`v1//{meta.json, project/**, cover.(png|jpg|webp|svg)}`,`template.zip` **不落仓库**,由脚本按 `project/` 现场打包(条目排序、固定时间戳,同内容重复打包摘要一致)。
+- 上传与校验由 [`scripts/agc-template-library-publish.mjs`](../../scripts/agc-template-library-publish.mjs) 完成:`--source apps/ai-game-creator-shell/template-library [--dry-run] [--prune]`,脚本生成 `template.json` 与 `index.json`、上传后回读 zip 摘要;`--prune` 清理该模板前缀下本次没有产出的旧对象(例如换封面扩展名后的残留)。
+- 当前模板:`blank-web`(空白网页)、`blank-2d-canvas`(空白二维画布)、`blank-3d-scene`(空白三维场景)、`phaser-2d-starter`(Phaser 2D 起步工程)、`threejs-3d-starter`(Three.js 3D 起步工程)。
+- 客户端可用 `AGC_TEMPLATE_LIBRARY_BASE_URL` 覆盖库地址;只接受 `https://agc-dev.oss-rg-china-mainland.aliyuncs.com`(拒绝其他主机、路径、http)。
+
+## 客户端实现
+
+### Rust:`apps/ai-game-creator-shell/src-tauri/src/template_library.rs`
+
+| 命令 | 行为 |
+| --- | --- |
+| `fetch_game_template_library` | 读 `templates/index.json`(≤4 MiB),校验后缓存到 `/templates/index.json`;网络失败时回退本机缓存并在 `source` 标 `cache` |
+| `download_game_template` | 取清单里对应条目,流式下载 zip(≤512 MiB),校验字节数与 SHA-256,解压到 `/templates/installed///`,最后写 `installed.json` 作为安装完成的唯一标记 |
+| `create_automatic_local_game_project_from_template` | 需要时先安装模板,然后在 `/projects/` 下按既有自动工作区规则建目录:先复制模板文件,再走 `init_local_game_project_at` 补 `.agent` 清单与标准目录 |
+
+安全与健壮性:
+
+- 解压只接受普通文件与目录:拒绝绝对路径、`..`、盘符、反斜杠、符号链接,并有文件数(4096)与单文件大小(256 MiB)上限。
+- 安装目录名由标识符白名单拼出,不拼接远端字符串;重装时只清理该模板自己的安装目录。
+- 模板文件与安装记录统一走 `write_game_creator_private_file` / `ensure_game_creator_private_directory_tree`,保持项目目录的私有 DACL 口径。
+- 建项目失败时删除刚创建的项目目录,不留半成品。
+
+### 前端
+
+- `src/features/template-library/templateLibraryModel.ts`:清单类型、搜索(空白分隔多关键词「与」)、标签/运行时/已下载筛选、标签选项聚合、体积格式化等纯函数。
+- `src/features/template-library/useTemplateLibrary.ts`:一次拉清单,暴露筛选状态、下载与「用模板建项目」;下载成功后只就地更新该条目的已下载状态。
+- `src/view/template-library/index.tsx`:模板库全屏页(返回、刷新、搜索、运行时/标签筛选、仅看已下载、卡片显示封面与已下载徽标、下载/使用模板)。
+ - 卡片动作按安装状态收口:已下载且版本一致时**不再显示下载入口**,只留「使用模板」;版本落后才显示「更新」;缺包显示「下载」。
+ - 过程提示(下载完成、开始建项目)走浮层 toast(复用 `packages/shared` 的 `PlatformRuntimeStatusToast`,`document.body` 浮层 + 2.6 秒自动消失),不再占用页面内位置;页面内只保留可操作的错误与空态。
+- 首页「灵感推荐」替换为「模板库」推荐位(`src/view/home/TemplateRecommendations.tsx`):只展示封面、标题、运行时与已下载徽标,点击进入模板库页面;首页不再直接触发建项目。
+- 左侧导航新增模板库入口(`LauncherView = 'template-library'`)。
+- `src-tauri/tauri.conf.json` 的 `csp` / `devCsp` 在 `img-src` 放行 `https://agc-dev.oss-rg-china-mainland.aliyuncs.com`,用于封面图;`connect-src` 原本已放行同一域名。
+- 旧的本机灵感图目录 `src/view/home/assets/inspiration/` 与 `InspirationGallery.tsx` 一并删除,不再保留退役实现。
+
+## 验收与验证
+
+## 本地压测假数据注入(feature 控制)
+
+模板库的数据源在 Rust 侧(清单校验、安装状态、下载与建项目都在这里),TS 只消费快照做渲染,所以假数据注入也放在 Rust 侧,走与真实完全一致的链路。
+
+- 开关:Cargo feature `template-library-fixtures`(**默认关闭**)。关闭时 `apply_template_library_fixtures` 是恒等透传,正式产物里不存在注入分支,并有单测保证这一点。
+- 条数:环境变量 `AGC_TEMPLATE_LIBRARY_SYNTHETIC_COUNT`(默认 1000;`0` 表示不注入;上限 20000)。
+- 假数据特征:真实条目保留在最前,其余按真实条目循环复制;`id`/标题唯一,封面地址追加 `?synthetic=N`(强制逐张请求,模拟“每个模板各自封面”);标签追加 `批次-00..19`;安装态按 1/3 混合。
+- 运行方式:
+
+```bash
+# 本机 dev 客户端(保留 Windows 默认 feature)
+AGC_DEV_CARGO_FEATURES=cocos-editor-execute,template-library-fixtures npm run dev
+# 直接跑二进制
+cargo run --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --features template-library-fixtures
+# 覆盖条数
+AGC_TEMPLATE_LIBRARY_SYNTHETIC_COUNT=300 AGC_DEV_CARGO_FEATURES=template-library-fixtures npm run dev
+```
+
+两种编译模式都要过模板库单测:默认构建跑「恒等透传」用例,`--features template-library-fixtures` 跑「补齐到配置条数」用例。
+
+### 1000 条实测结论
+
+### 卡片列表虚拟滚动(react-window)
+
+- 列表改用 workspace 里已有的 `react-window@1.8.11` 的 `FixedSizeGrid`(`react-arborist` 已在用同一版本,不引入新包;类型来自 devDependency `@types/react-window`)。
+- 布局契约收在纯函数 `templateLibraryGrid.ts`(单测覆盖):列数 = `floor((容器宽 + gap) / (最小卡宽 + gap))`、列宽 = 容器宽 / 列数、行高 = `卡片宽 × 9/16 + 文字区 150 + gap`;`buildTemplateRows` 按行切分并在行尾补 `null` 占位。
+- 卡片抽成 `TemplateCard`(`memo`),网格只渲染可视行 + 2 行 overscan;筛选条件(关键词/标签/运行时/仅看已下载)变化时把滚动位置复位到顶部,避免"从筛选切回全量后停在空白处"。
+- **页面高度契约**:页面根节点的高度按**父级 `.launcher-main` 的实测高度**内联设置,既不用百分比也不用 `100vh`。原因:外壳样式 `.launcher-main > .platform-theme { height: 100% }` 特异性高于 Tailwind 工具类,而这条百分比在 `.launcher-shell { min-height: 100vh }` 链路上是不定高,页面会退化成内容高度(虚拟网格视口高度 0、卡片区整片空白);`100vh` 又比真实舞台高一个标题栏高度(窗口 100vh=800 / 舞台 750),底部会被裁掉。
+- 筛选区(运行时/标签)改成可独立滚动的区块(`max-h-[24vh]`),标签数量随库量增长时不再把卡片区挤出窗口。
+- 回归:`templateLibraryGrid.test.ts` 覆盖列数/行高/行数/切行;页面测试用固定视口断言「1000 条只渲染 ≤ 40 张卡片,滚动高度仍按 250 行计算」。
+
+- 页面能正常渲染 1000 张卡片(头部显示「共 1000 个模板 · 已下载 335 个」),并且滚动容器生效(窗口高度压到 430px 时右侧出现滚动条,页面内容被裁切而不是溢出到窗口外)。
+- 需要后续收口的两点(本次未改):① 标签筛选条随库量膨胀——1000 条时聚合出 35 个标签、占三行;② 一次性渲染 1000 个卡片节点并触发 1000 次封面请求。建议标签只展示 Top N + 「更多」,卡片列表加分页或虚拟滚动。
+- 前端回归:1000 条渲染 + 已安装过滤(334)/标签过滤(50)/关键词过滤数量自洽,见 `apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx`。
+
+```bash
+# 模板库单测(清单校验、键安全、解压路径逃逸、安装与建项目)
+cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell template_library
+# 模板库真连检查(可选,需要网络):读线上清单、下载安装线上模板包并据此建项目
+cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell template_library -- --ignored
+# 前端模型与页面单测(AGC 测试统一在 apps/ai-game-creator-shell/tests/)
+npx vitest run apps/ai-game-creator-shell/tests/templateLibraryModel.test.ts apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx
+# 类型检查 / 编码 / 空白
+cd apps/ai-game-creator-shell && npm run typecheck
+npm run check:encoding
+git diff --check
+# OSS 侧匿名可读
+curl -s https://agc-dev.oss-rg-china-mainland.aliyuncs.com/templates/index.json
+```
+
+手工验收:打开模板库 → 搜索与筛选 → 下载(出现「已下载」徽标)→ 「使用模板」→ 进入项目工作台且项目里已有模板文件。
+
+运行态核验(客户端真的拉过清单时):本机缓存 `/templates/index.json` 与线上 `templates/index.json` 逐字节一致;`/templates/installed///installed.json` 出现即表示该模板已下载完成。
+
+## 失败与回退
+
+- 清单读不到且没有本机缓存:模板库页显示错误与重试,首页推荐位显示「模板库暂时没有可用的模板」。
+- 版本落后:`installedVersion != templateVersion` 视为需要重新下载,点「使用模板」会先重下再建项目。
+- 需要回退整条链路时,删除 `templates/` 前缀即可让客户端回到"空模板库";客户端代码路径不受影响。
diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
index 5eb7d09f2..457c89436 100644
--- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
+++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
@@ -1,5 +1,24 @@
# AI 游戏创作智能体 App 实施计划
+## 策划 Agent 批量局部修改
+
+`patch_file` 的所有 edits 均匹配同一份原文件,参数顺序不影响结果。完成唯一匹配与不重叠校验后,按原文起点升序拼接未修改片段与替换文本,最后一次性写入;任一校验失败时不写文件。回归用例覆盖乱序 edits、中文内容与替换长度增减,并核对完整落盘内容。此行为仅属于策划 Agent 文件工具。
+
+## 策划 Agent 运行 panic 边界
+
+`finish_design_command` 的运行段包在 `catch_unwind` 边界内:任何运行期 panic 被转成一次普通失败,由既有错误分支从最后一个持久检查点恢复,向会话写入固定公开文案"策划运行发生内部错误,本轮已中断,可直接重试;若反复出现请反馈。",并以 `running=false、可重试、lastError 有值` 的视图正常返回。panic 负载只写入私有 design_debug,不进入用户可见消息;continue、recover_uncertain 与 decide 三个入口共享同一边界。边界不改变工具错误、Provider 瞬态重试和批次不确定恢复的既有语义。
+
+运行段通过 task-local 携带项目根;首次运行时安装的 panic hook 在 panic 瞬间把代码位置(file:line:column)和负载追加写入私有 design_debug,随后交给原 hook 维持既有 stderr 输出。hook 只在策划运行段内生效(其它任务无 task-local 上下文时直接透传),多线程 runtime 下 future 跨 worker 迁移也能正确归因。
+
+## 资源画布交互与工作台状态同步
+
+- 工作台向窗口标题栏发布正在运行的项目时,输入未变化不得形成重复发布与清理的渲染循环;打开项目动作始终使用当前工作台处理逻辑,退出工作台后清除其标题栏状态。
+- 资源子画布(含「所有资源」)保留空白处左键框选、资源卡左键选中/拖动、触摸板双指平移及捏合缩放;右键按住空白处或资源卡拖动时平移画布,不改变资源选择与布局。中键和空格抓手继续可用。总览保留既有左键平移,并支持右键平移。
+- 画布接管的右键手势不弹出原生菜单;输入框、媒体操作、工具条和独立浮层不被画布抢占。指针取消、捕获丢失或窗口失焦后终止平移,不能继续跟随指针。
+- 运行不可用提示只取决于运行能力与 UI 编辑器状态,不因资源选中、取消选中或框选而消失,且不改变运行入口的真实可用性。
+- 本次边界不包含布局算法、持久化坐标、预览读取预算或后端契约调整。首次进入抖动与平移异常须在更新循环消除后单独实测,不能仅凭状态循环修复宣称已解决。
+- 验收包含真实窗口 Context 与工作台的状态同步回归、运行提示与选中并存、左右键分流、平移中断及连续滚轮事件;真实客户端首次进入与触摸板手感为独立人工验收项。
+
## 资源卡选中工具栏与导出
- 共享选中工具栏按实际显示的快速编辑、编辑动作、改造、导出与宿主动作组生成分隔线;空组不产生分隔线,不依赖宿主 CSS 隐藏重复线。
@@ -8,6 +27,10 @@
## 文档与代码素材预览
+- JSON 资源的卡片按内容语义分流:普通 JSON 显示 JSON 图标/标签,详情按 JSON 代码块显示;UI 设计 JSON 显示 UI 设计标识并提供现有 UI 编辑器入口,不再把 State 原文铺在卡面上。功能分类、manifest kind 和资产身份不因识别而改写。
+- UI 识别由原生文本预览在已登记、受控、完整读取的同一份 UTF-8 内容上完成,复用编辑器的 `game-creator-ui-design-state.v1` 契约解析、canonical 校验、revision/State 校验及项目/资产身份校验。前端只消费识别结果,不按文件名、kind 大小写或正文片段自行认定 UI;普通、损坏、未知 schema、跨项目或跨资产 JSON 不获得 UI 编辑能力。
+- JSON 为完成内容识别继续走现有受限预取队列、并发/容量和 2 MiB 原生文本读取上限。识别失败不写文件、不生成空 State、不做格式迁移;能读取的原文仍可按 JSON 查看,读取失败仍展示原有错误。非 JSON 代码卡继续仅按用户详情请求读取。
+- 编辑器加载/保存/代码生成复核当前项目的已登记 JSON 资产及完整文档,不以 kind 必须精确等于 `UI` 阻断合法已有设计;创建新 UI 状态仍要求正式 UI 资源,不能借普通 JSON 预览初始化或覆盖文件。现有 State 保存锁、CAS、revision 与恢复边界不变。
- 文档与代码素材选中工具栏提供「预览」,打开独立、可滚动的只读弹窗;关闭、切换素材或项目后不残留旧内容。加载中、空文件与读取失败分别呈现,允许重试可重试的错误。
- 复用资源预览队列、身份缓存、项目 scope、失效与权限校验,继续调用 `read_local_project_text_preview`。代码卡不做可见性预取,仅用户显式打开详情时读取;不新增 IPC,不扩大可读取文件范围,不增加编辑/保存能力。
- 文档正文统一使用现有 Markdown 渲染器;代码文件以按扩展名标注语言的 Markdown 围栏代码块呈现。围栏必须长于正文内的反引号串,正文空行与缩进保持原样,不把源码当 Markdown 正文或 HTML 执行。
@@ -19,6 +42,10 @@
## 2026-09-16 DirectProject 回合展示唯一归属
+- 聊天历史使用 `read_direct_project_history_slice` 的 `messagesOnly: true` 模式,按有正文的 user/assistant 消息分页,默认 20 条;工具/推理原始记录不占聊天页名额、不进入聊天分页响应,也不从磁盘删除。接口省略该选项时维持原始 item 切片语义。响应给出明确的 `oldestItemId` 游标;消息投影不能重新发明分页位置。
+- 消息模式读取逐行过滤原始记录,不在内存中积累整份工具输出;正文、原始 ID 和信封时间原样保留。旧的无 ID 消息不能凭空生成身份,必要时向前扩展到已有消息 ID 边界;没有更早消息时结束分页。
+- 首屏和加载更早消息共用分页解析;重复点击只发一个请求,重叠消息按原始 ID 去重并保留当前显示版本。切项目、同项目重新加载及 A→B→A 的迟到响应不得覆盖当前消息、游标或加载状态;失败保留已有消息与分页位置并允许重试。
+- 历史读取来源与 Runtime 所有权分开记录;从磁盘分页读出的消息不能当作未落盘实时消息保留到新页末尾。重新读取历史时回到最新页,旧页仍可从原生游标再次向前加载;真正尚未回读到的实时消息继续保留,不改原始日志、时间或内容。
- 交付合同:实时消息、历史回读、工具详情与最终回复先归一为按 `clientTurnId` 唯一的回合,再渲染一次。用户消息始终保留;同一回合的正文、工具和耗时不能从消息、实时尾部、未归属尾部等多个出口重复展示。
- 归属来自 `direct-codex:{clientTurnId}:{role}`、文本流中保留的原始 item ID,以及项目历史内明确用户记录之后的 assistant 记录。先在已加载的完整消息集合中关联,再做可见分页;持久历史继续通过 canonical item 切片懒加载,`hasMore` 为真时,未加载回合的工具流不得漂到当前页尾部。禁止将第 N 个有工具回合配给第 N 条用户消息,禁止按文本长度、标点或时间窗猜测归属。缺身份的旧记录保留,不猜造其与其它回合的关联。
- 有回合流时正文与工具位置仅来自 item 边界与 `seq`,工具详情按该回合的 `callId` 关联;没有流时同一个回合容器显示历史消息与工具。整轮累计文本仅在活动回合尚无流和持久 assistant 时作兜底,不另建实时消息出口。
@@ -133,6 +160,7 @@ npm 游戏的可预览产物固定为对应 package 目录下的 `dist/index.htm
## 2026-08-24 Direct Codex 已登记资源查询与媒体生成语义工具
- `agc_tools` 新增 `agc_list_registered_assets` 与 `agc_create_or_derive_resource`。前者按 `kind / assetId / offset / limit` 有界查询客户端权威 manifest,并可显式返回角色动画正式序列帧的稳定 objectKey、assetObjectId 和尺寸;结果不包含完整 manifest、prompt、model、provider route、签名 URL、宿主路径或凭据。后者只接受 `kind / mode / sourceLocalAssetId / prompt / assetName`,`create` 仅允许无源视频、音效和背景音乐,`derive` 必须引用当前项目已登记的 localAssetId,角色动画固定为 derive。
+- `prompt` 上限按 `kind` 分别生效,且工具 schema、MCP 校验、客户端工具桥与提交校验共用同一权威口径(`resource_edit_prompt_max_chars`):背景音乐 140、音效 1900、视频与角色动画 4000、图片编辑 32000。schema 逐 kind 声明 `maxLength` 并在 `prompt` 描述里写明数字,超限必须在发起任何桥请求与付费提交之前失败并回报真实上限;`sourceLocalAssetId` 不是当前项目已登记资源时,错误文案必须直接给出 `agc_list_registered_assets` 与 `agc_list_project_files` → `agc_import_account_assets.localPaths` 两步后续动作。
- 项目路径、projectId、当前 revision、源文件路径与媒体类型、operationId、Idempotency-Key、登录态、项目锁、付费提交、轮询恢复、下载校验与 manifest 事务全部由客户端持有。模型不能提交或覆盖这些字段。同一 Direct `clientTurnId + 规范语义参数` 生成稳定 UUID v4 身份;单回合同参重试复用原 operation,不同请求串行且最多四项。跨回合存在完全匹配的 pending 账本时优先恢复原 operation,不能换键重发。
- 资源查询同时投影未完成 operation 的安全状态。媒体工具成功只返回 operation、本地相对路径、资源类型、Canvas/resource/asset/task 身份、正式序列帧以及脱敏后的 `warnings / sliceWarnings`;错误继续使用统一脱敏边界。客户端资源账本持久化 completed 结果的两类告警,committed replay 不能把历史告警伪装成空集合。
- 角色动画、视频、音效和背景音乐在构造新的远端请求前统一准备当前项目同名画布与素材目录上下文,并在端点支持时携带 `projectId / assetFolderId / canvasCompletion`。角色动画 placeholder 使用源图片真实宽高,避免非方形角色进入画布时失真;正式 resource/asset 与序列帧继续直接复用 External 返回身份,不从首帧伪造重复资源。已有冻结 request body 或已受理 operation 保持不变,不因本次升级重建请求或重复扣费。
@@ -1502,3 +1530,56 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面
- 两个窗口同时对同一项目发起 Runtime 写请求时,用户体验仍由项目级写锁串行决定;本次不引入跨窗口排队提示。
- 平台会话在窗口间传播依赖共享 localStorage 与 Runner 权威;渲染层不做跨窗口事件推送,另一个窗口在下一次会话校验或刷新时收敛。
+
+## 2026-09-17 AGC 项目定时快照上传(agc-dev)
+
+### 目标与非目标
+
+- 目标:AGC 在项目工作区打开期间按固定周期把用户项目增量上传到 OSS `agc-dev`,并在项目关闭时立即补一次同步;重复内容不重复上传,远端占用跟随当前清单收敛。
+- 非目标:不做云端下载/恢复、不做跨设备合并、不保留多版本历史、不新增面向用户的上传界面、不修改 `/api/external/v1` 与 OpenAPI、不新增 SpacetimeDB 表。
+- 非目标:不把 OSS AccessKey 放进客户端;客户端不直连 OSS。
+
+### 参与入口、状态与跨模块边界
+
+- 触发入口有两个:工作区窗口 `main` 存活期间的周期定时器、工作区窗口关闭事件(`CloseRequested`)。两者共用同一个进程内同步器,同一项目的同步串行执行,周期触发在已有同步进行时直接让位,不排队堆积。
+- 应用退出(`RunEvent::Exit`)不重复发起同步:该时刻窗口已销毁,按窗口重新枚举项目只会得到空集;退出路径只负责在有界预算(15 秒)内等待在途同步收尾,让关窗触发的那一次同步能写完索引再退出。
+- 客户端扫描、差异对比、索引持久化与上传编排都在 Tauri Rust 进程(`src-tauri/src/project_snapshot/`);WebView 只读状态,不参与差异计算。
+- 本地索引是增量对比的唯一依据:`/project-snapshots//index.json` 保存上次成功同步的相对路径、校验和、字节数和修改时间。项目根使用现有 manifest 的稳定 `project_id` 作为远端身份,路径不再作为身份。
+- 可观测性按产品口径收敛到本机日志:同步结果、失败分类、延后与跳过计数只写入 AppData 诊断日志(`project_snapshot.sync.*` 前缀),客户端界面不暴露上传状态、时间线或入口按钮。`read_local_project_snapshot_state` 与 `sync_local_project_snapshot` 两条命令仅作为 native-only 的排障与联调入口登记,不在渲染层调用。
+- 远端写入经 `api-server`,客户端只持平台登录态 Access Token。两条登录态路由:`POST /api/agc/project-snapshots/files`(单文件,正文为原始字节,元数据走查询串)与 `POST /api/agc/project-snapshots/manifest`(本次同步后的完整清单)。
+- 对象键与清单由服务端决定:文件键为 `agc/project-snapshots/v1/{userId}/{projectId}/files/{sizeBytes}-{checksumDigest}/{relPath}`,清单键为 `agc/project-snapshots/v1/{userId}/{projectId}/manifest.json`。键里带字节数与摘要,因此"对象已存在且长度一致"可以作为内容一致的判据;路径按原始大小写保留,不走 `put_object` 的低位规范化。`agc` 前缀继续是服务端专用私有前缀,通用对象键解析与客户端直传票据都不覆盖它。
+- 目标 bucket 使用独立配置 `GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET` / `_ENDPOINT` / `_ACCESS_KEY_ID` / `_ACCESS_KEY_SECRET`,默认 `agc-dev` + `oss-rg-china-mainland.aliyuncs.com`,未配置时回退 `ALIYUN_OSS_*`;与"资源 bucket 与备份 bucket 分离"的既有口径一致。
+
+### 正常、失败、重试与幂等行为
+
+- 差异对比口径:先按 `相对路径 + 字节数 + 修改时间` 判定是否候选变更,命中旧记录则复用已存 `sha256`,只有 `(size, mtime)` 变化才重算摘要。产出新增、修改、删除三类集合,只上传新增与修改的文件。
+- 每次成功同步的最后一步上传该项目的 `manifest.json`(当前全量文件清单:相对路径、摘要、字节数、同步序号)。清单描述的是项目当前全量内容,因此清单体积就是该项目在 OSS 上的常驻占用。
+- 远端回收:清单写入成功后,服务端读取上一版清单,按 `(路径, 字节数, 摘要)` 反推出不再被当前清单引用的对象键并删除。只处理上一版清单登记过的键,不做 LIST,因此不可能误删其它项目或其它功能的对象;单次最多回收 2000 个对象,剩余部分留到下一次清单写入继续;上一版清单读不到或解析失败时整轮跳过回收(fail-closed)。单个删除失败只记日志,不影响本次同步语义。
+- 因此本功能是"当前状态镜像 + 清单",不保留历史版本:同一路径的内容变化会覆盖式替换远端对象,回滚能力不在本轮范围内。
+- 幂等:同一摘要与字节数的对象重复提交由服务端 HEAD 校验后跳过;探测失败按"未存在"处理并照常 PUT,宁可多传一次也不漏传。索引只在清单写入成功后推进,失败时保留旧索引以便下次重算。
+- 与项目写锁解耦:同步不持有项目写锁,也不阻塞 Agent 写入。读取摘要后与上传前各按 `(字节数, 修改时间)` 复核一次,任一处不一致就判定该文件"同步期间发生变化":本轮不上传、不写入索引;若该路径上一轮已同步过则沿用旧记录,避免被误判成删除而触发远端回收。这类文件在下一个周期或下次关窗时重算重试。
+- 失败关闭:单个文件失败不推进该文件的索引项,失败文件与剩余文件在下一次周期或下次关闭时重试。鉴权失败(401/403)与格式类拒绝(400/413)是确定性失败,停止本轮剩余请求并等待用户处理后重试;服务端配额或频率拒绝(429)与传输类失败按可重试处理。
+- 配额与限流:单文件 64 MiB、单次同步上传预算 512 MiB(超出部分延后到下一次)、单项目常驻上限 2 GiB(客户端在扫描后先判,超限直接给出明确失败;服务端按清单累计体积复核并返回 413);服务端按用户做进程内小时配额(文件 3000 次、清单 120 次)并对同一项目强制 5 秒最小清单间隔,超限返回 429 且带 `Retry-After`。进程内配额只用于抑制异常客户端与失控重试,跨节点配额由"单项目上限 + 清单引用回收"保证。
+- 生命周期:同步有界超时(单文件与整次同步分别设上限),项目关闭与应用退出路径不因同步失败而阻塞或延迟退出超过超时上限。
+- 上传内容边界:复用项目索引与 checkpoint 同一份 `should_skip_project_snapshot_path` 口径——整个 `.agent`(含 runtime、logs、checkpoint、manifest、project.lock)、版本控制目录、`node_modules`/`target`/`dist`/`build`/`coverage`/`.cache`、凭据目录与 `.pem`/`.key` 等敏感后缀都不参与同步;符号链接与重解析点同样跳过。单文件(64 MiB)与单次同步总量(512 MiB)各有上限,超限文件进入跳过或延后清单而不是静默丢弃。
+
+### 契约与兼容
+
+- 新增登录态内部路由 `POST /api/agc/project-snapshots/files`,请求 DTO 放在 `shared-contracts`;不属于 `/api/external/v1`,因此不更新 External OpenAPI,与 `/api/error-reports` 同类。
+- 服务端校验 `projectId` 形态(拒绝路径分隔符、`..`、控制字符与超长值)、相对路径规范(正斜杠、拒绝绝对路径与穿越)、摘要形态(`fnv1a64:` + 16 位十六进制)和字节数上限,任何越界返回 4xx 而不是写入 OSS。
+- 不改变客户端与 Runner 的本机协议、平台会话语义、项目写锁与 manifest 结构;新增索引文件位于 AppData,不进入用户项目目录。
+
+### 验收标准与证据来源
+
+- 定向 Rust 测试:首次同步全量、仅改一个文件时只产生一个修改项、删除文件只体现在清单、`(size,mtime)` 未变时复用旧摘要、排除规则与上限跳过、同步失败不推进索引、同一项目并发触发串行化。
+- 服务端测试:越界 `projectId`/相对路径/摘要被拒;相同摘要重复提交走跳过分支;超过单项目上限返回 413;超过用户小时配额返回 429;鉴权缺失返回 401;OSS 未配置返回明确的 5xx 而不是写入空对象。
+- 运行时 smoke:AGC 开发态打开项目、观察索引写入与同步日志、关闭工作区窗口后确认关闭触发的那次同步执行;报告为"客户端 diff 已验证 / 服务端已配置环境联调"两层,不合并成一句"已通"。
+- 边界:新增日志与错误文案不含 Access Token、AccessKey、绝对路径与项目内容。
+
+### 未决问题
+
+- 用户侧看不到同步状态与失败原因(界面按产品口径不暴露),排障只能读 AppData 诊断日志或调用 native-only 命令;如果后续要支持用户自助排查,需要先确认是否允许在客户端出现上传相关 UI。
+- 历史版本:本轮只保留"当前状态镜像 + 清单",旧内容对象在清单写入成功后即被回收,没有回滚能力;要保留历史版本需要先定"保留几个 revision + 由谁回收"的策略。
+- 用户级配额:跨节点的用户总量配额与计费口径未定;当前用单项目 2 GiB 上限 + 清单引用回收保证常驻占用有界,用户级总量只能靠项目数间接约束。
+- 目标 bucket 的生命周期规则(例如转低频/过期删除)需要在部署环境确认后单独收口;功能本身已不再依赖它来控制增长。
+- 大项目(素材数量多、单文件大)的首轮全量上传耗时与带宽占用未实测;单次预算 512 MiB 会把超出部分留到下一次同步,但并发上限与断点续传仍未引入。
diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
index 0d1af4ab8..d13920f03 100644
--- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
+++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
@@ -70,7 +70,7 @@ Linux 本机多用户并发开发时,`npm run dev`、`npm run dev:*` 单模块
后端日志默认写入 `logs/api-server/`,独立 BgFilter worker 日志默认写入 `logs/bgfilter-worker/`。后端 API smoke 使用 `npm run dev:api-server`,先检查 BgFilter worker `/readyz`,再检查 API `/healthz`;需要确认 API 实例可接生产流量时检查 API `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。
-AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。任一配套服务在启动阶段进入 `failed` 时,外层启动器必须立即报告具体服务和退出原因,不能继续等待前端地址超时。端口健康不等于归属正确:复用前还必须证明端口上的监听进程属于当前工作树(Windows 按 `server-rs/target/debug/api-server.exe` 绝对路径与 SpacetimeDB `--data-dir` 校验,探测不可用时退化为旧行为),无法证明归属时一律不复用,改为启动本工作树自己的后端并在需要时端口漂移;否则上个工作树 Ctrl+C 残留的后端会被当成自己的后端复用,改了数据库的工作树会连到旧库。启动器在创建原生窗口前预检最终地址;AGC Vite marker 同时提供 `repoRoot + processId + port`,与 `.app/dev-stack.json` 的 `instanceId` 和 API target 交叉核对;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。
+AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。任一配套服务在启动阶段进入 `failed` 时,外层启动器必须立即报告具体服务和退出原因,不能继续等待前端地址超时。端口健康不等于归属正确:复用前还必须证明端口上的监听进程属于当前工作树(Windows 按 `server-rs/target/debug/api-server.exe` 绝对路径与 SpacetimeDB `--data-dir` 校验,探测不可用时退化为旧行为),无法证明归属时一律不复用,改为启动本工作树自己的后端并在需要时端口漂移;否则上个工作树 Ctrl+C 残留的后端会被当成自己的后端复用,改了数据库的工作树会连到旧库。启动器在创建原生窗口前预检最终地址;AGC Vite marker 同时提供 `repoRoot + processId + port`,与 `.app/dev-stack.json` 的 `instanceId` 和 API target 交叉核对;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。开发态客户端不检查更新:启动器给 AGC Vite 注入 `VITE_AGC_ENABLE_APP_UPDATE_CHECK=0`,客户端不请求 OSS 更新清单、也不显示更新入口;需要联调更新流程时显式传 `VITE_AGC_ENABLE_APP_UPDATE_CHECK=1`。
AGC 开发态还会按后台 Web 的端口约定额外拉起 `apps/admin-web`:Linux 用当前用户端口段的 `start + 3` 槽位,非 Linux 以 `3102` 为兼容首选并允许统一漂移,`ADMIN_WEB_PORT` 可显式指定且必须避开已解析的 AGC Vite 端口;设置 `AGC_DEV_ADMIN_WEB=0` 可关闭。后台 Vite 与 AGC Vite 一样由 `start-dev-stack.mjs` 直接持有并随启动器退出收束,不走 `npm run dev:admin-web`——后者会整体重写 `.app/dev-stack.json`,覆盖本次配套后端的归属状态;后台 Web 的端口解析、启动失败或运行中意外退出都只打印告警,不阻断也不连带停止 AGC 客户端与配套后端。前端与配套后端就绪后,启动器会打印一行 `[ai-game-creator-shell] 启动汇总:`,依次给出前端、后端、后台、数据库与 `bgfilter-worker` 的实际地址;端口漂移或默认端口被其它工作树占用时,以这一行为准。
@@ -540,6 +540,50 @@ curl -fsS --max-time 5 http://127.0.0.1/api/editor/showcase/resources >/dev/null
角色动画源帧 PUT、透明帧 PUT 和最终帧 HEAD 使用 `AppState` 内同一个 OSS HTTP Client/连接池,并受进程级 8 路 OSS permit 保护;BgFilter、阿里云抠图和本地处理不占用该 permit。每个 OSS attempt 最多 3 次(首次 + 2 次重试),退避为 250ms、500ms;只重试 timeout、无 HTTP 响应传输错误、OSS PutObject 的 `400 + RequestTimeout`、PUT 400 错误体读取失败(未解析出 `Code`,按 timeout/transport 归类)、408、429 和 500–599。动作帧 PUT 收到 400 时只读取最多 16 KiB OSS 错误 XML,提取 `Code` 和 `RequestId`;`oss_request_id` 优先使用响应头 `x-oss-request-id`,XML 字段只作回退。错误体读取超时/断流不再按确定性 400 处理:已解析出的 `Code` 优先生效;未解析出 `Code` 时按读取失败原因置 `timeout`/`transport` 并重试,message 追加「错误响应体读取失败」。日志字段包括 `frame_index`、`object_key`、`operation=source_put|final_put|final_head`、`attempt`、`max_attempts`、`retryable`、`will_retry`、`retry_delay_ms`、`permit_wait_ms`、`timeout`、`connect`、`transport`、`oss_code`、`oss_request_id`、`status` 和 `elapsed_ms`。`请求 OSS 失败` 时,`timeout/connect/transport=true` 表示传输类失败;`status=400, oss_code=RequestTimeout, timeout=true`、`status=429` 或 `500–599` 表示暂时性失败,PUT 的 `status=400`、`oss_code` 为空且 `timeout=true` 或 `transport=true`(message 含「错误响应体读取失败」)同样是暂时性失败。除 `RequestTimeout` 和该错误体读取失败两类例外外,其他 400、401/403/404、配置、URL 和签名错误是确定性失败,不会重试。最终帧 HEAD 失败只会重试 HEAD,不会重复 PUT;如果任一帧最终失败,确认整段动作已排空已启动 Future,并检查任务按现有契约退款且没有发布缺帧动画。
+### AGC 项目快照上传目标
+
+AGC 客户端按周期与项目关闭时机把用户项目增量上传到 `agc-dev`。客户端只持有平台登录态 Access
+Token,经 `POST /api/agc/project-snapshots/files`(单文件原始字节)与
+`POST /api/agc/project-snapshots/manifest`(本次同步清单)交给 `api-server`,由服务端写入私有前缀
+`agc/project-snapshots/v1/{user}/{project}/`;客户端不直连 OSS,也不持有 OSS 凭据。
+
+```env
+GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET=agc-dev
+GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT=oss-rg-china-mainland.aliyuncs.com
+GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID=
+GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET=
+```
+
+专用凭据为空时回退 `ALIYUN_OSS_ACCESS_KEY_ID` / `ALIYUN_OSS_ACCESS_KEY_SECRET`,bucket 与 endpoint
+仍默认指向 AGC 发行 bucket,因此回退凭据必须具备目标 bucket 该前缀的 `PutObject` / `GetObject` /
+`DeleteObject` 权限;`api-server` 启动时会打印一行 `AGC 项目快照 OSS 客户端已启用`(含 bucket、
+endpoint 与凭据来源,不含密钥),凭据缺失或只配一半则跳过该客户端、接口返回 `503`,客户端按失败关闭
+处理:不写空对象,也不推进本地增量索引,下一次触发重算重试。
+
+远端占用按当前清单收敛:每次清单写入成功后,服务端用上一版清单反推不再被引用的对象键并删除,单次最多
+回收 2000 个,上一版清单不可读时整轮跳过(不会误删)。因此不需要额外配置 bucket 生命周期来防止无界
+增长;`agc/project-snapshots/v1/` 下同一路径只保留当前内容,历史版本不保留。配额口径:单文件 64 MiB、
+单次同步上传预算 512 MiB、单项目常驻 2 GiB;超限分别表现为跳过/延后/413。服务端另有进程内小时配额
+(文件 3000 次、清单 120 次)与同一项目 5 秒最小清单间隔,超限返回 `429` 并带 `Retry-After`。
+
+两层可重复的现场验证:
+
+```bash
+# 1. 存储层:直接对真实 bucket 做内部前缀写入、读回与清理,并在结束时删除探针对象。
+cargo run -p platform-oss --example agc_project_snapshot_live_smoke --manifest-path server-rs/Cargo.toml
+
+# 2. 客户端链路:真实差异引擎 → 本地 api-server → 真实 OSS。第一次必须 synced 且上传 > 0,
+# 紧接着的第二次必须 no-op 且上传 0 个文件;需要先取得登录态并指定项目与索引目录。
+cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml \
+ project_snapshot_live_sync -- --ignored --nocapture
+```
+
+客户端冒烟读取 `GENARRATIVE_AGC_PROJECT_SNAPSHOT_LIVE_PROJECT`(项目绝对路径,建议用可丢弃的副本)、
+`..._LIVE_TOKEN`、`..._LIVE_API_BASE_URL`、`..._LIVE_USER_ID` 与 `..._LIVE_CONFIG_DIR`(索引目录,
+`cargo test` 进程没有窗口 setup 初始化 AppData 配置目录,必须显式指定);未设置时用例自我跳过。
+本地联调可用 `npm run dev:api-server` 起 api-server,并用密码登录(开发态默认允许未知手机号自动注册)
+取得 Access Token。
+
## 生产运维
生产部署当前口径:
diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md
index fec3db0e7..b5c244b6e 100644
--- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md
+++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md
@@ -27,6 +27,7 @@
- 下面的工具选择口径属于 Agent 规划 prompt / function-calling 约束,不是侧边栏 UI 说明文案;侧边栏面板不展示这些规则解释。
- 用户要求“规范图 / 视觉规范图 / 风格规范图 / 素材规范展板”时,规划默认选择 `generate_image`,并在 prompt 中明确要求生成规范展板,包含统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等可落地的视觉规范元素。
- 用户要求“角色规范图”且语义是角色的规范展板、风格展板或设定板时,仍走 `generate_image`,不要误分流到 `generate_character`;只有实际生成角色立绘、角色主形象或角色视觉资产时才走 `generate_character`。用户要求多个图标素材、图集或 spritesheet 时才走 `generate_icon_spritesheet`。
+- 画布 Agent 的 `generate-icon-spritesheet` 不暴露切分模式参数,链路固定显式传 `sliceMode=connected-components`;等分网格或固定槽位需求必须由外部 API 调用方显式传 `sliceMode=grid` 与来自需求的 `gridX`/`gridY`,画板工具栏的 `拆分图集` 仍只做连通域拆分。禁止在工具描述、确认卡或回复里承诺按 `2×2` 等网格切分。
- 所有生成必须走 `execute_billable_asset_operation_with_cost` 与模型定价配置,禁止绕过定价收口。
- function-calling 的 JSON Schema 必须与参数默认值和运行时校验保持一致,不能只在 description 中提示会被运行时拒绝的组合。`generate-ui-design` 固定 `gpt-image-2`,因此 `image_size` 只暴露 `1K / 2K`;其它可切换图片模型的工具通过共享条件 schema 在显式选择 `gpt-image-2` 时同样把 `image_size` 限制为 `1K / 2K`,省略模型时仍按默认 nanobanana2 允许 `0.5K`。`generate-video` 省略 `model` 时按默认 `seedance2.0-fast` 约束 `resolution` 为 `480p / 720p`,显式选择其它模型时仍使用其现有分辨率范围。运行时强类型校验继续作为最终防线。
- `generate-sound-effect` 与站内 / External v1 的 SFX V2 契约一致:Prompt 使用 ECMAScript `String.trim()` 等值 canonicalization 且限制 `1–2048` Unicode code points,model 固定 `eleven_text_to_sound_v2`,`duration` 缺省为手动 `5s`、显式 `null` 为自动时长、数值范围为有限 `0.5–30` 小数,`loop` 缺省 false。显式 `duration:null` 必须绕过通用“顶层 null 当缺省”兼容层,不能在 job payload 中变回 `5s`;确认后的 canonical payload 继续进入现有 `editor_sound_effect_generation` Worker,不新增 Agent 专属音频链路。
diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md
index ae769d8b3..b362711d4 100644
--- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md
+++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md
@@ -2,7 +2,7 @@
日期:`2026-06-15`
-更新时间:`2026-08-10`
+更新时间:`2026-09-17`
## 背景
@@ -40,7 +40,15 @@
## 生成契约
- 前端提交到 `POST /api/editor/icon-spritesheets/generations`。
-- 图集拆分通过 `sliceMode` 显式选择:`connected-components` 按透明像素连通域切分(默认),`grid` 按用户提供的 `gridX × gridY` 网格切分。
+- 图集拆分模式必须由调用方显式声明,任何入口都不得存在隐式默认值:`sliceMode` 是图标图集生成请求的必填字段,`connected-components` 按透明像素连通域切分,`grid` 按调用方提供的 `gridX × gridY` 网格切分。
+- 请求缺失 `sliceMode`、传 `null` 或空字符串时,`POST /api/editor/icon-spritesheets/generations` 与 `POST /api/external/v1/editor/icon-spritesheets/generations` 都必须在引用解析、定价、入队和任何 provider / OSS 副作用之前返回 `400`,错误体带 `field=sliceMode`,message 复述本节的决策要求;服务端不得用兜底模式继续执行,也不得为该字段保留默认值。
+- `grid` 必须同时提供 `gridX` 与 `gridY`(各 `1..32`,乘积不得超过当时生效的图集切片上限);只提供其中一个、越界或乘积超限同样在副作用之前 `400`。
+- `connected-components` 不得同时携带 `gridX` / `gridY`:连通域切分不接受网格尺寸,二者同时出现时按请求自相矛盾在副作用之前返回 `400`(`field=gridX/gridY`),避免调用方以为网格已生效而实际按连通域执行。
+- 决策要求(服务端、客户端、Agent、工具说明和 Skill 必须一致):只有在用户或需求明确要求等分网格、固定槽位或指定行列数时,才使用 `grid`,并把该行列数作为 `gridX` / `gridY` 传入;行列数必须来自用户或需求本身,不得由生成方自行假定,也不得用固定 `2×2` 表达“四类素材”。自由排布、数量不定或只要求“一张图集”时,显式传 `connected-components`;需要约束素材张数时使用 `sliceCount`,不得用网格参数表达张数。调用方、客户端和 Agent 都不得依赖、补齐或推断省略值。
+- 响应继续回显实际采用的 `sliceMode`,`grid` 时同时回显生效的 `gridX` / `gridY`。
+- 错误必须可执行:缺失、空白和未知取值统一返回 `400` 且带 `field=sliceMode`,`grid` 与网格参数的矛盾带 `field=gridX/gridY`,message 说明允许取值、缺参时该走哪条决策分支,以及 `sliceCount` 才是张数约束;不得只回报通用 JSON 解析错误。
+- `sliceCount` 只约束 `connected-components` 的目标张数,取值 `1..256`;识别结果与该目标不一致、为 `0` 或超过上限时返回 `422` 并回报实际识别数量,`grid` 不接受该字段。
+- 客户端的标准美术包(四类 canonical 素材)必须显式声明 `sliceMode=connected-components` 与 `sliceCount=4`:平台要么给出四张切片,要么以可执行的 `422` 说明实际识别数量;本地按用途位置映射前必须再次校验切片数量正好是四张,数量不符时失败关闭,禁止靠截断或补位写出用途错位的切片清单。
- 图标规范生成在 inline 模式下也必须先建立带稳定请求指纹的 generation operation,并由编辑器生成 durable billing 边界包住共享执行器;不得在 `operation=None` 时调用 provider 后再进入原子结果持久化。
- 图标 spritesheet 的入队与实际执行路径都必须在引用解析、generation input 重建、定价和 provider / OSS 副作用之前预检 owner、项目和最终素材目录,并将返回的 canonical `projectId + assetFolderId` 回写到后续流程;请求省略目录时按实际写入的 owner 默认目录预检,worker 不得只信任入队时的旧校验结果。
- queued 图标规范生成由共享原子结果持久化使用 worker caller 中的 lease 一并完成任务并清理 lease;共享执行器返回成功后 worker 只能返回 `Ok(())`,不得再次调用 job completion。
@@ -89,7 +97,7 @@
- 透明背景处理正常成功时,父流程把带背景原图和经完整解码 / 尺寸守卫验证的透明 spritesheet 写入 OSS、项目资源和账号素材库,再识别 alpha 连通域并执行附加拆分。BgFilter 最终失败或后续 Alpha / 尺寸恢复、原图回读、透明图完整解码失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,不创建透明图集,也不继续拆分,`iconImageSrcs=[]`、`sliceWarning=null`。该收口不捕获 phase 上报、provider 原图持久化或 `canvasCompletion` 写回错误;provider 原图本身解码失败时在首次持久化前失败,不允许用 `512×512` 伪造元数据。
- 自动拆分只在透明图集成功后执行,属于 best-effort 附加动作,不参与图集生成的成功判定。连通域识别或切片持久化失败时,接口仍返回并回填整张透明图集,`iconImageSrcs=[]`,并通过 `sliceWarning.code/reason` 暴露非阻断原因;`sliceWarning` 与透明背景最终失败使用的通用 `warning` 互斥,因为透明背景失败时不会进入拆分,但可与风格归一化或像素规整产生的通用 `warning` 并存。前者只表示透明图集成功但自动拆分失败,`sliceWarning.reason` 原始契约保持不变。前端在 inline、worker 队列完成和刷新恢复三条路径统一显示对应 warning toast,用户可在图集工具栏手动重试。
- 响应通过 `iconImageSrcs` 返回成功切片素材。图标自动拆分、手动 `拆分图集` 和 UI 提取复用同一个 bounded CPU helper 和 platform 实现:全部原始连通域(包括随后过滤的噪点)最多 `4096` 个,辅助部件通过 `64px` 空间网格只检查最大 `48px` 邻域候选;有效输出按视觉阅读顺序命名为 `素材 N`。
-- 三条拆分路径共同限制单边最多 `4096` 像素、总像素最多 `2048×2048`、最多 `64` 个输出;输出限制在排序、裁剪和 PNG 编码前检查。整段图片 CPU 工作在 2 路 semaphore、30 秒本地上限与请求 deadline 共同保护的 `spawn_blocking` 中执行,permit 由 blocking 闭包持有。自动拆分超限以稳定 `sliceWarning` 非阻断降级且不产生切片 PUT、资源或画布切片;手动拆分超限在首次持久化前返回 `422`。
+- 三条拆分路径共同限制单边最多 `4096` 像素、总像素最多 `2048×2048`、最多 `256` 个输出;输出上限与 `grid` 的 `gridX × gridY` 上限、`sliceCount` 上限取同一个值,并在排序、裁剪和 PNG 编码前检查。整段图片 CPU 工作在 2 路 semaphore、30 秒本地上限与请求 deadline 共同保护的 `spawn_blocking` 中执行,permit 由 blocking 闭包持有。自动拆分超限以稳定 `sliceWarning` 非阻断降级且不产生切片 PUT、资源或画布切片;手动拆分超限在首次持久化前返回 `422`。
## 前端铺放规则
@@ -110,3 +118,4 @@
- 选中透明图集图层时显示 `拆分图集`;点击后源图集显示扫描蒙层与 `拆图中` 状态,工具栏按钮同步切换为旋转图标和 `拆图中` 并禁用重复提交。完成后恢复工具栏,不新增第二张图集,只在 provider 原图右侧追加自动识别的独立素材,并同步写入素材库。
- 把同源派生图层从其它标签改为“图集”时,在项目资源返回新 `resourceId` 前“拆分图集”保持禁用;持久化成功后拆分请求必须指向 `assetKind: "icon-spritesheet"` 的新资源,失败时标签回滚且不发起拆分请求。
- 生成图标素材的提交体不包含 `priceMudPoints`;后端必须按归一化后的模型和尺寸计算价格,不信任客户端声明。queue 任务的计费、退款和结果投影使用入队时冻结的同一价格。
+- 图集生成请求省略 `sliceMode`(或显式传 `null` / 空字符串)时返回 `400` 且 `field=sliceMode`,不产生定价、入队、扣费、provider 调用或 OSS 写入;`sliceMode=connected-components` 同时携带 `gridX`/`gridY` 时同样在副作用之前 `400`;`grid` 缺任一维度时 `400`。响应回显的 `sliceMode` 必须与请求声明一致。
diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-build b/jenkins/Jenkinsfile.ai-game-creator-shell-build
index 3e304cf63..8ac2484e2 100644
--- a/jenkins/Jenkinsfile.ai-game-creator-shell-build
+++ b/jenkins/Jenkinsfile.ai-game-creator-shell-build
@@ -21,8 +21,10 @@ pipeline {
parameters {
string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '源码分支')
string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit')
- string(name: 'AGC_RELEASE_VERSION', defaultValue: '', description: '可选,指定三段版本号;留空则按 OSS 与本地版本自动递增 patch')
- text(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选,支持多行文本,写入 latest.json 的发布说明')
+ string(name: 'AGC_RELEASE_VERSION', defaultValue: '', description: '可选,指定三段版本号;留空则按该渠道 OSS 与本地版本自动递增 patch')
+ choice(name: 'AGC_UPDATE_CHANNEL', choices: ['dev-win', 'dev-mac'], description: 'AGC 发布渠道;dev-win 在 Windows 节点执行,dev-mac 需在 macOS 构建机本地执行')
+ booleanParam(name: 'AGC_RELEASE_DRY_RUN', defaultValue: false, description: '勾选后只构建并打印将要执行的上传命令,不写入 OSS')
+ text(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选,支持多行文本,写入渠道清单的发布说明')
string(name: 'OSSUTIL_BIN', defaultValue: 'ossutil', description: 'ossutil 或 ossutil.exe 的绝对路径/命令名')
}
@@ -123,11 +125,15 @@ pipeline {
withCredentials([
string(credentialsId: 'AliyunAccessKeyId', variable: 'AGC_OSS_ACCESS_KEY_ID'),
string(credentialsId: 'AliyunaccessKeySecret', variable: 'AGC_OSS_ACCESS_KEY_SECRET'),
+ string(credentialsId: 'AgcUpdaterSigningKey', variable: 'TAURI_SIGNING_PRIVATE_KEY'),
+ string(credentialsId: 'AgcUpdaterSigningKeyPassword', variable: 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD'),
]) {
withEnv([
"PATH=${env.AGC_WINDOWS_PATH}",
"OSSUTIL_BIN=${params.OSSUTIL_BIN}",
"AGC_RELEASE_VERSION=${params.AGC_RELEASE_VERSION}",
+ "AGC_UPDATE_CHANNEL=${params.AGC_UPDATE_CHANNEL}",
+ "AGC_RELEASE_DRY_RUN=${params.AGC_RELEASE_DRY_RUN ? '1' : '0'}",
"AGC_UPDATE_RELEASE_NOTES=${params.AGC_UPDATE_RELEASE_NOTES}",
]) {
powershell '''
@@ -153,14 +159,16 @@ pipeline {
stage('Archive release') {
steps {
- archiveArtifacts artifacts: 'apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.exe,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/latest.json,.jenkins-source-commit', fingerprint: true
+ archiveArtifacts artifacts: 'apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.exe,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.sig,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/latest.json,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/legacy-latest.json,.jenkins-source-commit', fingerprint: true
}
}
}
post {
success {
- echo 'AGC Windows x64 安装包已构建并上传 OSS。'
+ echo params.AGC_RELEASE_DRY_RUN
+ ? "AGC ${params.AGC_UPDATE_CHANNEL} 渠道演练完成:已构建并生成清单,未写入 OSS。"
+ : "AGC ${params.AGC_UPDATE_CHANNEL} 渠道安装包、签名与渠道清单已构建并上传 OSS。"
}
}
}
diff --git a/package-lock.json b/package-lock.json
index 54339f53d..ccb4be9ec 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -108,6 +108,7 @@
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-http": "^2.5.9",
"@tauri-apps/plugin-opener": "~2",
+ "@tauri-apps/plugin-updater": "2.11.0",
"@vitejs/plugin-react": "^5.0.4",
"focus-trap-react": "^12.0.3",
"lexical": "^0.47.0",
@@ -118,6 +119,7 @@
"react-colorful": "^5.8.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
+ "react-window": "^1.8.11",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"vite": "^6.2.0",
@@ -131,6 +133,7 @@
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
+ "@types/react-window": "^1.8.8",
"tailwindcss": "^4.1.14",
"typescript": "~5.8.2",
"vitest": "^0.34.6"
@@ -8091,6 +8094,15 @@
"@tauri-apps/api": "^2.11.0"
}
},
+ "node_modules/@tauri-apps/plugin-updater": {
+ "version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.11.0.tgz",
+ "integrity": "sha512-AE36XkOoSna24G40jZMY15nzAnkXEPL/73tGoseGrtGOHuI/cZwWzHpZFLjKXDPgzYZ435z1gHu28LgrsBwIxQ==",
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "@tauri-apps/api": "^2.11.0"
+ }
+ },
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -8421,6 +8433,16 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/@types/react-window": {
+ "version": "1.8.8",
+ "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz",
+ "integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/react": "*"
+ }
+ },
"node_modules/@types/semver": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
@@ -26447,10 +26469,12 @@
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-http": "^2.5.9",
"@tauri-apps/plugin-opener": "~2",
+ "@tauri-apps/plugin-updater": "2.11.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
+ "@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^5.0.4",
"focus-trap-react": "^12.0.3",
"lexical": "^0.47.0",
@@ -26461,6 +26485,7 @@
"react-colorful": "^5.8.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
+ "react-window": "^1.8.11",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"tailwindcss": "^4.1.14",
@@ -28212,6 +28237,14 @@
"@tauri-apps/api": "^2.11.0"
}
},
+ "@tauri-apps/plugin-updater": {
+ "version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.11.0.tgz",
+ "integrity": "sha512-AE36XkOoSna24G40jZMY15nzAnkXEPL/73tGoseGrtGOHuI/cZwWzHpZFLjKXDPgzYZ435z1gHu28LgrsBwIxQ==",
+ "requires": {
+ "@tauri-apps/api": "^2.11.0"
+ }
+ },
"@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -28483,6 +28516,15 @@
"peer": true,
"requires": {}
},
+ "@types/react-window": {
+ "version": "1.8.8",
+ "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz",
+ "integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==",
+ "dev": true,
+ "requires": {
+ "@types/react": "*"
+ }
+ },
"@types/semver": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
diff --git a/scripts/agc-template-library-publish.mjs b/scripts/agc-template-library-publish.mjs
new file mode 100644
index 000000000..aa0281caa
--- /dev/null
+++ b/scripts/agc-template-library-publish.mjs
@@ -0,0 +1,522 @@
+#!/usr/bin/env node
+/**
+ * 打包并发布 AGC 模板库到 OSS。
+ *
+ * 用法:
+ * node scripts/agc-template-library-publish.mjs --source