diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx
index 572b887d9..20ac33841 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx
+++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx
@@ -78,7 +78,6 @@ import type {
GameCreationAppManifest,
GameCreationAppPreviewState,
GameIterationVersion,
- ProjectResourceCanvasCategory,
ProjectResourceCanvasLayoutMode,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { ImageCanvasCharacterAnimationPanelView } from '../../../../../src/components/image-editor/ImageCanvasCharacterAnimationPanelView';
@@ -113,9 +112,9 @@ import {
dispatchResourceReferenceInsert,
isResourceReferenceOverlayTarget,
resolveActiveIterationVersion,
+ type ResourceReference,
resourceReferenceCategoryLabel,
resourceReferenceFromAsset,
- type ResourceReference,
} from '../../features/project-workspace/resourceReferences';
import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker';
import {
@@ -123,24 +122,13 @@ import {
ResourceCanvasAssetGenerationPanelView,
type ResourceCanvasAssetGenerationSubmitInput,
} from '../../features/resource-canvas/ResourceCanvasAssetGenerationPanelView';
-import { resourceCanvasAssetGenerationReferenceIds } from '../../features/resource-canvas/resourceCanvasAssetGenerationReferenceModel';
-import { ResourceCanvasGenerationPlaceholderCardView } from '../../features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView';
-import {
- placeResourceCanvasGenerationPlaceholder,
- resolveResourceCanvasGenerationPanelStyle,
- type ResourceCanvasGenerationPlaceholder,
-} from '../../features/resource-canvas/resourceCanvasGenerationPlaceholderModel';
-import { useResourceCanvasGenerationPlaceholders } from '../../features/resource-canvas/useResourceCanvasGenerationPlaceholders';
-import {
- resolveResourceCanvasGenerationPanelPlacement,
- revealResourceCanvasGenerationContent,
-} from '../../features/resource-canvas/resourceCanvasGenerationVisibilityModel';
import {
createResourceCanvasAssetGenerationQueue,
mergeResourceCanvasAssetGenerationTasksWithRecords,
type ResourceCanvasAssetGenerationQueue,
type ResourceCanvasAssetGenerationSettlement,
} from '../../features/resource-canvas/resourceCanvasAssetGenerationQueue';
+import { resourceCanvasAssetGenerationReferenceIds } from '../../features/resource-canvas/resourceCanvasAssetGenerationReferenceModel';
import {
createResourceCanvasAssetGenerationTask,
type LocalProjectAssetGenerationTaskRecord,
@@ -184,6 +172,16 @@ import {
ResourceCanvasGenerationPanelView,
type ResourceCanvasGenerationSubmitInput,
} from '../../features/resource-canvas/ResourceCanvasGenerationPanelView';
+import { ResourceCanvasGenerationPlaceholderCardView } from '../../features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView';
+import {
+ placeResourceCanvasGenerationPlaceholder,
+ resolveResourceCanvasGenerationPanelStyle,
+ type ResourceCanvasGenerationPlaceholder,
+} from '../../features/resource-canvas/resourceCanvasGenerationPlaceholderModel';
+import {
+ resolveResourceCanvasGenerationPanelPlacement,
+ revealResourceCanvasGenerationContent,
+} from '../../features/resource-canvas/resourceCanvasGenerationVisibilityModel';
import {
canRedoResourceCanvasHistory,
canUndoResourceCanvasHistory,
@@ -227,6 +225,7 @@ import {
readVersionResourceReplacementCandidates,
replaceVersionResource,
} from '../../features/resource-canvas/resourceVersionReplacementTransport';
+import { useResourceCanvasGenerationPlaceholders } from '../../features/resource-canvas/useResourceCanvasGenerationPlaceholders';
import { ensureUiDesignResourceForPrototype } from '../../features/ui-editor/uiDesignResourceBridge';
import {
currentPlatformSessionGeneration,
@@ -294,7 +293,6 @@ import {
createResourceCanvasCardSizeByResourceId,
fitResourceCanvasViewportToContent,
normalizeInfiniteResourceCanvasViewport,
- type ResourceCanvasPositionWrite,
RESOURCE_CANVAS_CARD_HEIGHT,
RESOURCE_CANVAS_CARD_WIDTH,
RESOURCE_CANVAS_DRAG_THRESHOLD,
@@ -306,6 +304,7 @@ import {
type ResourceCanvasCardSize,
resourceCanvasCardSize,
resourceCanvasContentBounds,
+ type ResourceCanvasPositionWrite,
resourceCanvasSectionExtent,
} from './resourceCanvasLayoutModel';
import {
@@ -392,7 +391,7 @@ type OrchestrationMode = 'single-supervisor' | 'professional-dag';
* 过滤——被筛选藏起来的、以及停留在其他栏目里的残留选中都不参与位移,也不会被写回坐标。
* 起始坐标取拖动开始那一刻的布局局部坐标(`positions`),不随拖动过程中的重算漂移。
*/
-export function resolveResourceCardDragMoves({
+function resolveResourceCardDragMoves({
resource,
resources,
selectedResourceIds,
@@ -1907,6 +1906,18 @@ export default function ProjectDevelopmentView({
},
viewportScale: () => resourceCanvasSceneViewportRef.current.scale ?? 1,
});
+ /**
+ * 占位 Hook 返回值的最新一帧。
+ *
+ * 这个对象每次渲染都是新的(`placeholderByTaskId` / `placeholderByDraftId` 是当场创建的
+ * 箭头函数),整份放进回调依赖会让回调每帧重建。改动前任务收口回调按空依赖跑,闭包里读到的
+ * 也是同一批「方法内部只读 Hook 自己的 ref」的函数——所以这里改成显式读最新 ref,依赖表保持
+ * 空,语义不变。
+ */
+ const resourceGenerationPlaceholdersRef = useRef(
+ resourceGenerationPlaceholders,
+ );
+ resourceGenerationPlaceholdersRef.current = resourceGenerationPlaceholders;
/**
* 成功结果的落点意图:按草稿 ID 各自一条。
*
@@ -4420,6 +4431,7 @@ export default function ProjectDevelopmentView({
clearSkippedResourceCardClick,
manifest.projectId,
projectPath,
+ setResourceCanvasHistory,
]);
const isCurrentRecoveryProject = useCallback(
@@ -4899,6 +4911,7 @@ export default function ProjectDevelopmentView({
activeResourceLayout.layout.positions,
applyResourceCanvasLayoutSnapshot,
resourceCanvasHistory,
+ setResourceCanvasHistory,
]);
const redoResourceCanvasLayout = useCallback(() => {
@@ -4915,6 +4928,7 @@ export default function ProjectDevelopmentView({
activeResourceLayout.layout.positions,
applyResourceCanvasLayoutSnapshot,
resourceCanvasHistory,
+ setResourceCanvasHistory,
]);
const canUndoResourceCanvas = canUndoResourceCanvasHistory(
@@ -4968,7 +4982,11 @@ export default function ProjectDevelopmentView({
snapshot,
}),
);
- }, [activeResourceLayout, resourceOrganizeSections]);
+ }, [
+ activeResourceLayout,
+ resourceOrganizeSections,
+ setResourceCanvasHistory,
+ ]);
useEffect(() => {
const handleHistoryShortcut = (event: KeyboardEvent) => {
@@ -5535,6 +5553,7 @@ export default function ProjectDevelopmentView({
clearSkippedResourceCardClick,
deferSkippedResourceCardClickCleanup,
resourceCategoryScopeKey,
+ setResourceCanvasHistory,
],
);
@@ -7617,7 +7636,7 @@ export default function ProjectDevelopmentView({
submission.dispatchedImmediately
) {
// 后端从未受理:占位留在画布上,重开浮层带同一份草稿(含参考图)重试。
- resourceGenerationPlaceholders.failTask(
+ resourceGenerationPlaceholdersRef.current.failTask(
settlement.taskId,
settlement.error ?? '生成素材失败',
);
@@ -7636,7 +7655,7 @@ export default function ProjectDevelopmentView({
}
if (settlement.status !== 'completed' || !settlement.record?.assetId) {
// 受理之后才失败:占位保留(名称、位置与重试入口都还在),后台任务在账本里收口。
- resourceGenerationPlaceholders.failTask(
+ resourceGenerationPlaceholdersRef.current.failTask(
settlement.taskId,
settlement.error ?? '未知原因',
);
@@ -7657,7 +7676,9 @@ export default function ProjectDevelopmentView({
占位已经被用户删掉时没有位置可接管,保持既有自动落位 + 定位行为。
*/
const landingPlaceholder =
- resourceGenerationPlaceholders.placeholderByTaskId(settlement.taskId);
+ resourceGenerationPlaceholdersRef.current.placeholderByTaskId(
+ settlement.taskId,
+ );
if (landingPlaceholder) {
resourceGenerationLandingRef.current.set(landingPlaceholder.draftId, {
projectId: landingPlaceholder.projectId,
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/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 a1ab75aad..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();
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 f05b625e9..66e90103b 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
@@ -7,10 +7,6 @@ import type {
} from '../../../../packages/shared/src/contracts/gameCreationApp';
import { ProjectSupervisorView } from '../../src/features/project-workspace/ProjectSupervisorView';
import { RESOURCE_REFERENCE_INSERT_EVENT } from '../../src/features/project-workspace/resourceReferences';
-import {
- generationPromptText,
- typeGenerationPrompt,
-} from '../resourceGenerationPromptTestUtils';
import { ApprovalModeDialog } from '../../src/view/project-development/ApprovalModeDialog';
import { RESOURCE_BOOK_OVERVIEW_STACK_LIMIT } from '../../src/view/project-development/resourceBookLayout';
import {
@@ -23,6 +19,10 @@ import { normalizeProjectResourceGraph } from '../../src/view/project-developmen
import { ResourceDependencyOverlay } from '../../src/view/project-development/ResourceDependencyOverlay';
import type { ProjectAgentResultSummary } from '../../src/view/project-development/resourceProjectionModel';
import { projectResourcesFromReadModels } from '../../src/view/project-development/resourceProjectionModel';
+import {
+ generationPromptText,
+ typeGenerationPrompt,
+} from '../resourceGenerationPromptTestUtils';
import {
act,
agentRuntimeUserInputRequest,
@@ -5875,6 +5875,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,
);
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/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/resourceCanvasAssetGenerationReferences.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx
index 22ce65ddb..fe19a94c7 100644
--- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx
+++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx
@@ -4,6 +4,10 @@ import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp';
+import {
+ type ChatReference,
+ resourceReferenceFromAsset,
+} from '../src/features/project-workspace/resourceReferences';
import {
ResourceCanvasAssetGenerationPanelView,
type ResourceCanvasAssetGenerationSubmitInput,
@@ -20,10 +24,6 @@ import {
resourceCanvasAssetGenerationUserReferenceLimit,
} from '../src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel';
import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
-import {
- type ChatReference,
- resourceReferenceFromAsset,
-} from '../src/features/project-workspace/resourceReferences';
afterEach(() => {
cleanup();
diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx
index 8f8a3c477..2572d06a6 100644
--- a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx
+++ b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx
@@ -12,6 +12,7 @@ import {
EDITOR_IMAGE_DIMENSION_OPTIONS,
IMAGE_MODEL_NANOBANANA2,
} from '../../../src/components/image-editor/ImageCanvasGenerationModel';
+import type { ChatReference } from '../src/features/project-workspace/resourceReferences';
import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView';
import {
projectHasIconSpecReference,
@@ -30,7 +31,6 @@ import {
generationPromptText,
typeGenerationPrompt,
} from './resourceGenerationPromptTestUtils';
-import type { ChatReference } from '../src/features/project-workspace/resourceReferences';
afterEach(cleanup);
diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx
index a559ebca6..5b1dd62e4 100644
--- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx
+++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx
@@ -1,14 +1,14 @@
// @vitest-environment jsdom
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
+
import { cleanup, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView';
-import { ResourceCanvasGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasGenerationPanelView';
import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
-import { typeGenerationPrompt } from './resourceGenerationPromptTestUtils';
+import { ResourceCanvasGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasGenerationPanelView';
afterEach(cleanup);
diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx
index 49c7dd962..a3d05ff94 100644
--- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx
+++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx
@@ -8,8 +8,7 @@
*
* Tauri 只用最小假实现:未知命令返回 `undefined` 并记账,别的入口多调一个命令不该让整条链转红。
*/
-import { renderHook } from '@testing-library/react';
-import { useEffect, useState } from 'react';
+import { useState } from 'react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type {
diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationPlaceholder.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationPlaceholder.test.tsx
index bbae19c22..81c658289 100644
--- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationPlaceholder.test.tsx
+++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationPlaceholder.test.tsx
@@ -3,11 +3,8 @@ import { renderHook } from '@testing-library/react';
import { useEffect, useState } from 'react';
import { afterEach, describe, expect, test, vi } from 'vitest';
-// 指针事件与 DOM 尺寸的 jsdom 补丁统一由 appSurface harness 提供(`PointerEvent` 等),
-// 与既有画布手势用例同一套测试环境;不在这里另写一份补丁。
-import { act, cleanup, fireEvent, render, screen } from './appSurface/harness';
-
import { GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE } from '../../../packages/shared/src/contracts/gameCreationApp';
+import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
import { ResourceCanvasGenerationPlaceholderCardView } from '../src/features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView';
import {
bindResourceCanvasGenerationPlaceholderTask,
@@ -16,14 +13,16 @@ import {
placeResourceCanvasGenerationPlaceholder,
removeResourceCanvasGenerationPlaceholder,
resolveResourceCanvasGenerationPanelStyle,
+ type ResourceCanvasGenerationPlaceholder,
resourceCanvasGenerationPlaceholderByDraftId,
resourceCanvasGenerationPlaceholderByTaskId,
- resourceCanvasGenerationPlaceholderSize,
resourceCanvasGenerationPlaceholdersForProject,
- type ResourceCanvasGenerationPlaceholder,
+ resourceCanvasGenerationPlaceholderSize,
} from '../src/features/resource-canvas/resourceCanvasGenerationPlaceholderModel';
import { useResourceCanvasGenerationPlaceholders } from '../src/features/resource-canvas/useResourceCanvasGenerationPlaceholders';
-import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
+// 指针事件与 DOM 尺寸的 jsdom 补丁统一由 appSurface harness 提供(`PointerEvent` 等),
+// 与既有画布手势用例同一套测试环境;不在这里另写一份补丁。
+import { act, cleanup, fireEvent, render, screen } from './appSurface/harness';
afterEach(cleanup);
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/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-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-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-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-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 a4ca70a6f..ddb8c3926 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -2,6 +2,26 @@
> 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。
> 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。
+## 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,主站前端保持现有行为。
@@ -8812,6 +8832,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 06a2ed208..e18ff003f 100644
--- a/docs/project-memory/shared-memory/development-workflow.md
+++ b/docs/project-memory/shared-memory/development-workflow.md
@@ -53,6 +53,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 24a21eea8..52b39d70a 100644
--- a/docs/project-memory/shared-memory/pitfalls.md
+++ b/docs/project-memory/shared-memory/pitfalls.md
@@ -1,5 +1,14 @@
# 踩坑与排障记录
+## 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 都不能覆盖新读取;真实日志只在临时目录只读重放,不能提交正文夹具。
@@ -12,6 +21,14 @@ JSON 的文本读取分支不等于卡面应该展示原始 State 摘要。卡
工作台向窗口标题栏发布运行项目时,若 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\...` 形式能区分真实路径。
@@ -5661,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..97c7474ea 100644
--- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md
+++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md
@@ -1,68 +1,136 @@
# 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;两个渠道的版本号互不影响。
+- 版本高水位:发布脚本取「渠道清单版本」与「旧协议迁移指针版本」(迁移窗口内)中的较大值再递增。只看渠道清单会在渠道启用初期把版本链改小 —— 2026-09-17 首次渠道发布即把旧指针的 0.1.57 退回 0.1.48,随后以显式 0.1.60 纠偏;迁移窗口结束(旧指针 404)后自动只剩渠道清单,`dev-mac` 不参与旧指针比较。
+- 迁移(旧协议 → 渠道清单):
+ - 迁移起点:已发布客户端(含当前线上版本)内置自研清单地址 `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`;未显式指定时按目标平台取默认渠道。
+- 定时调度只在本轮到达的提交包含 AGC 相关路径(客户端、共享包、`server-rs/crates`、AGC 插件、桌面壳图标、根依赖清单)时才触发渠道发布;纯文档或流水线自身的提交只跑 Full Build,不推高客户端版本号。判定失败或勾选强制触发时按"需要发布"处理。
+- 更新摘要自动生成:发布脚本用渠道清单里的 `commit` 字段(上一次发布的提交)到本次提交之间、且只覆盖客户端相关路径的提交列表生成 `notes`(每条 `- 提交标题(短 SHA)`,最多 12 条、主题 80 字、整体 900 字,超出折叠或截断),同时写入旧协议清单的 `releaseNotes` 和归档文件 `release-notes.txt`。`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准;无法判定起点(缺少上次 `commit` 或本地没有该提交)时不写摘要。清单缺少 `commit` 时回退用上一次成功构建的 `COMMIT_HASH`(CI 通过 `AGC_UPDATE_PREVIOUS_COMMIT` 传入)作为锚点,因此首次启用摘要或更换渠道后也能立即产出摘要。锚点仍不可得(清单读取失败或没有 CI 锚点)时降级为「最近客户端改动」列表并注明可能与上一版重复 —— 摘要属于附注,任何情况下都不允许因为它让发布失败。
+- 清单里的 `commit` 是非标准字段:更新插件忽略未知字段,发布脚本用它定位下一次摘要的起点。
+- 上传:安装包与 `.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`) |
+| 真实更新闭环(含升级后重启) | 0.1.47 客户端按提示下载安装并重启 | 通过(2026-09-17 用户实测:提示 → 下载 → 安装 → 关于页显示新版本,再次检查为已是最新) |
-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`,因此回滚或测试旧版本前应确认不会覆盖线上更新入口。
+| 条款 | 验收方式 | 证据 |
+| -------------------- | --------------------------------------------------- | ------ |
+| 签名校验失败拒绝安装 | 篡改渠道清单 `signature` 后观察客户端拒绝安装的表现 | 待执行 |
+| 签名校验失败拒绝安装 | 篡改渠道清单 `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 7f1b4880c..0e21b256b 100644
--- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
+++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
@@ -18,6 +18,16 @@
- 当前素材名以现有正式命名链路为准:生成时 assetName 参与落盘名称,重命名更新文件名;卡片消费正式资源 label,不从临时输入或历史任务名覆盖后续重命名,不新增平行显示名持久化。若原有命名链路丢失 assetName,则修复原链路,而非只在卡片本地伪造。文档卡不显示任何正文摘要,但详情原文与 JSON 识别读取不变。
- 验收覆盖空素材项目进入工具、真实引用入参、成功/失败/重试与迟到响应、占位移动后落点、全类型名称、文档详情、当前栏目重排/撤销、不同缩放的多选移动/撤销以及其他栏目不变。自动化、真实客户端和真实 Provider 验证分别报告;未实际运行的路径不得标为通过。
+## 策划 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 迁移也能正确归因。
+
## 资源画布交互与工作台状态同步
- 工作台向窗口标题栏发布正在运行的项目时,输入未变化不得形成重复发布与清理的渲染循环;打开项目动作始终使用当前工作台处理逻辑,退出工作台后清除其标题栏状态。
@@ -168,6 +178,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 保持不变,不因本次升级重建请求或重复扣费。
@@ -1537,3 +1548,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..0b5d624d2 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` 的实际地址;端口漂移或默认端口被其它工作树占用时,以这一行为准。
@@ -137,7 +137,7 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去
`Genarrative-Scheduled-Revision-Trigger` 是唯一的定时入口,每小时检查一次(`H * * * *`,分钟由 Jenkins 按 Job 名散列,不等同于整点)。它只用 `git ls-remote` 解析 `SOURCE_BRANCH`(默认 `master`)的远端 HEAD,不 checkout 工作区;解析出的完整 commit 与上一次触发过的 revision 相同则标记 `NOT_BUILT` 并结束,不触发任何下游。
-revision 变化时,调度管线把同一个完整 commit 通过 `COMMIT_HASH` 同时传给 `Genarrative-Full-Build-And-Deploy` 与 `Genarrative-Agc-Windows-Build`,两条管线都按这个 commit 检出(Full Job 继续把 `env.SOURCE_COMMIT` 透传给 Web / API / Stdb 的 Build、Publish、Deploy),因此两个产物必然来自同一个版本,不会各自解析分支 HEAD 造成漂移。两条下游管线自身不带任何定时触发器,也不在管线内部做版本比较。Full Job 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate;三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。
+revision 变化时,调度管线把同一个完整 commit 通过 `COMMIT_HASH` 同时传给 `Genarrative-Full-Build-And-Deploy` 与 `Genarrative-Agc-Windows-Build`,两条管线都按这个 commit 检出(Full Job 继续把 `env.SOURCE_COMMIT` 透传给 Web / API / Stdb 的 Build、Publish、Deploy),因此两个产物必然来自同一个版本,不会各自解析分支 HEAD 造成漂移。两条下游管线自身不带任何定时触发器,也不在管线内部做版本比较。Windows 客户端发布额外按路径过滤:调度管线比较「上一轮已触发的 revision」与本次 revision 之间的变更路径,只有出现 `apps/ai-game-creator-shell/`、`packages/`、`server-rs/crates/`、`plugins/agc-cocos-editor/`、`apps/desktop-shell/src-tauri/icons/`、`package.json` 或 `package-lock.json` 时才触发 `Genarrative-Agc-Windows-Build`,纯文档或流水线自身的提交只触发 Full Build、不推高客户端版本号;判定取消或失败一律按「需要发布」处理,勾选 `FORCE_TRIGGER` 可强制两条都触发。两条下游各自判定:AGC Windows Build 采用「客户端相关路径白名单」,Full Build 采用「与线上站点 / 后端无关的路径黑名单」(`docs/`、`.codex/`、`jenkins/`、`apps/ai-game-creator-shell/`、`apps/mobile-shell/`、`apps/desktop-shell/`、`apps/preview-deployer-web/`、`tools/`、根级 `*.md`),改动只要落在黑名单之外就会照常部署,避免漏发线上站点或后端;两条同时被判为跳过时调度管线只推进 revision 状态、不触发任何发布。客户端渠道清单的更新摘要同样自动生成:发布脚本读取上一份渠道清单的 `commit` 字段,把该提交到本次提交之间触及客户端相关路径的提交标题逐条写进 `notes`(旧协议清单写入 `releaseNotes`,并落盘归档文件 `release-notes.txt`);`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准,缺少上一份 `commit` 时不写摘要。Full Job 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate;三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。
调度状态是调度 Job 工作区里的 `.jenkins-last-triggered-revision`,构建描述同时回显本次 revision 与结果。工作区被清理(例如 `Wipe Out Workspace`)或状态文件缺失时,下一次运行按“版本变化”处理并触发一次,之后恢复稳定;需要重建同一版本时勾选 `FORCE_TRIGGER`。Job 按仓库内 `jenkins/scheduled-revision-trigger-job-config.xml` 创建:`scriptPath=jenkins/Jenkinsfile.scheduled-revision-trigger`、Git 入口 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git`、凭据 `genarrative-local-gitea-ssh`、`` 留空(定时器写在 Jenkinsfile 里)。推送后必须让三个 live Job 各自加载一次新 Jenkinsfile,并只读核对 `config.xml`:Full 与 AGC 不再有 cron,定时只来自新调度 Job;只改 Jenkinsfile 而不确认 live 配置时,旧 cron 仍会继续触发。
@@ -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..11010b29b 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 的绝对路径/命令名')
}
@@ -120,14 +122,35 @@ pipeline {
stage('Build and upload') {
steps {
+ script {
+ // 摘要锚点兜底:清单里还没有 commit 字段时(首次启用摘要 / 换渠道),
+ // 用上一次成功构建的 COMMIT_HASH 作为「上次发布提交」。读取失败保持为空,
+ // 发布脚本会退回清单锚点或干脆不写摘要。
+ def anchor = ''
+ try {
+ def previousBuild = currentBuild.previousSuccessfulBuild
+ anchor = (previousBuild?.buildVariables?.COMMIT_HASH ?: '').toString().trim()
+ } catch (error) {
+ echo "读取上一次成功构建的 commit 失败,跳过摘要锚点兜底:${error}"
+ }
+ env.AGC_UPDATE_PREVIOUS_COMMIT = anchor
+ if (anchor) {
+ echo "更新摘要锚点兜底:${anchor.take(12)}"
+ }
+ }
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_PREVIOUS_COMMIT=${env.AGC_UPDATE_PREVIOUS_COMMIT ?: ''}",
"AGC_UPDATE_RELEASE_NOTES=${params.AGC_UPDATE_RELEASE_NOTES}",
]) {
powershell '''
@@ -153,14 +176,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,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/release-notes.txt,.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/jenkins/Jenkinsfile.scheduled-revision-trigger b/jenkins/Jenkinsfile.scheduled-revision-trigger
index 621a35129..4c9173c30 100644
--- a/jenkins/Jenkinsfile.scheduled-revision-trigger
+++ b/jenkins/Jenkinsfile.scheduled-revision-trigger
@@ -21,6 +21,7 @@ pipeline {
FULL_BUILD_JOB_NAME = 'Genarrative-Full-Build-And-Deploy'
AGC_BUILD_JOB_NAME = 'Genarrative-Agc-Windows-Build'
REVISION_STATE_FILE = '.jenkins-last-triggered-revision'
+ AGC_SCOPE_CACHE_DIR = '.agc-release-scope-cache'
}
parameters {
@@ -57,6 +58,79 @@ pipeline {
}
}
+ // 按「本轮到达的提交」判定两条下游各自是否需要跑:
+ // - AGC:只有出现客户端相关路径才发布 Windows 客户端(避免纯文档提交推高版本号)。
+ // - Full Build:只在改动全部落在「与线上站点/后端无关」的路径时跳过(fail-open 到部署)。
+ stage('Resolve Release Scope') {
+ when {
+ expression { return env.REVISION_CHANGED == 'true' }
+ }
+ steps {
+ withCredentials([sshUserPrivateKey(credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, keyFileVariable: 'GENARRATIVE_GIT_SSH_KEY')]) {
+ script {
+ // 判定失败一律按「两条都要跑」处理,避免这段逻辑影响下游发布。
+ def output = 'agc=changed\nfull=changed'
+ try {
+ output = sh(script: '''#!/usr/bin/env bash
+ set -uo pipefail
+ export GIT_SSH_COMMAND="ssh -i ${GENARRATIVE_GIT_SSH_KEY:?缺少 Git SSH 凭据} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
+ previous="$(cat "${REVISION_STATE_FILE}" 2>/dev/null || true)"
+ if [[ -z "${previous}" ]]; then
+ printf 'agc=changed\nfull=changed\n'
+ exit 0
+ fi
+ mkdir -p "${AGC_SCOPE_CACHE_DIR}"
+ if [[ ! -d "${AGC_SCOPE_CACHE_DIR}/.git" ]]; then
+ git -C "${AGC_SCOPE_CACHE_DIR}" init --quiet
+ git -C "${AGC_SCOPE_CACHE_DIR}" remote add origin "${GIT_REMOTE_URL}" 2>/dev/null || true
+ fi
+ refspec="+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}"
+ if ! git -C "${AGC_SCOPE_CACHE_DIR}" fetch --quiet --depth=200 --no-tags --filter=blob:none origin "${refspec}"; then
+ git -C "${AGC_SCOPE_CACHE_DIR}" fetch --quiet --depth=200 --no-tags origin "${refspec}" || { printf 'agc=changed\nfull=changed\n'; exit 0; }
+ fi
+ if ! git -C "${AGC_SCOPE_CACHE_DIR}" cat-file -e "${previous}^{commit}" 2>/dev/null; then
+ echo "浅取窗口内没有 ${previous},按需要发布处理" >&2
+ printf 'agc=changed\nfull=changed\n'
+ exit 0
+ fi
+ changed_paths="$(git -C "${AGC_SCOPE_CACHE_DIR}" diff --name-only "${previous}" "${REMOTE_REVISION}" 2>/dev/null || true)"
+ agc_scope=unchanged
+ full_scope=unchanged
+ while IFS= read -r changed_path; do
+ [[ -z "${changed_path}" ]] && continue
+ case "${changed_path}" in
+ apps/ai-game-creator-shell/*|packages/*|server-rs/crates/*|plugins/agc-cocos-editor/*|apps/desktop-shell/src-tauri/icons/*|package.json|package-lock.json)
+ agc_scope=changed
+ ;;
+ esac
+ case "${changed_path}" in
+ docs/*|.codex/*|jenkins/*|apps/ai-game-creator-shell/*|apps/mobile-shell/*|apps/desktop-shell/*|apps/preview-deployer-web/*|tools/*|*.md)
+ ;;
+ *)
+ full_scope=changed
+ ;;
+ esac
+ done <<< "${changed_paths}"
+ printf 'agc=%s\nfull=%s\n' "${agc_scope}" "${full_scope}"
+ ''', returnStdout: true).trim()
+ } catch (error) {
+ echo "发布范围判定失败,按需要发布处理:${error}"
+ output = 'agc=changed\nfull=changed'
+ }
+ def values = output.split('\n').collect { it.trim() }.findAll { it }
+ def readScope = { String key ->
+ def entry = values.find { it.startsWith(key + '=') }
+ def value = entry == null ? '' : entry.split('=')[1]
+ return (value == 'unchanged') ? 'unchanged' : 'changed'
+ }
+ env.AGC_RELEASE_SCOPE = readScope('agc')
+ env.FULL_BUILD_SCOPE = readScope('full')
+ echo "发布范围:AGC=${env.AGC_RELEASE_SCOPE} FullBuild=${env.FULL_BUILD_SCOPE}(上一轮已触发 revision=${env.LAST_TRIGGERED_REVISION ?: '无'})"
+ }
+ }
+ }
+ }
+
stage('Trigger Downstream Pipelines') {
when {
expression { return env.REVISION_CHANGED == 'true' }
@@ -70,10 +144,28 @@ pipeline {
string(name: 'COMMIT_HASH', value: pinnedRevision),
string(name: 'DATABASE_BACKUP_MODE', value: 'skip'),
]
- build job: env.FULL_BUILD_JOB_NAME, wait: false, propagate: false, parameters: pinnedParameters
- build job: env.AGC_BUILD_JOB_NAME, wait: false, propagate: false, parameters: pinnedParameters
+ def fullTriggered = false
+ if (params.FORCE_TRIGGER || env.FULL_BUILD_SCOPE != 'unchanged') {
+ build job: env.FULL_BUILD_JOB_NAME, wait: false, propagate: false, parameters: pinnedParameters
+ fullTriggered = true
+ } else {
+ echo "本轮提交全部与线上站点/后端无关,跳过 ${env.FULL_BUILD_JOB_NAME};需要强制发布时勾选 FORCE_TRIGGER"
+ }
+ def agcTriggered = false
+ if (params.FORCE_TRIGGER || env.AGC_RELEASE_SCOPE != 'unchanged') {
+ build job: env.AGC_BUILD_JOB_NAME, wait: false, propagate: false, parameters: pinnedParameters
+ agcTriggered = true
+ } else {
+ echo "本轮提交不含 AGC 相关路径,跳过 ${env.AGC_BUILD_JOB_NAME};需要强制发布时勾选 FORCE_TRIGGER"
+ }
writeFile file: env.REVISION_STATE_FILE, text: pinnedRevision
- currentBuild.description = "已触发 ${env.FULL_BUILD_JOB_NAME} 与 ${env.AGC_BUILD_JOB_NAME}:${env.SOURCE_BRANCH}@${pinnedRevision.take(12)}"
+ def triggered = []
+ if (fullTriggered) { triggered.add(env.FULL_BUILD_JOB_NAME) }
+ if (agcTriggered) { triggered.add(env.AGC_BUILD_JOB_NAME) }
+ def target = "${env.SOURCE_BRANCH}@${pinnedRevision.take(12)}"
+ currentBuild.description = triggered.isEmpty()
+ ? "本轮提交与两条下游都无关,未触发任何发布:${target}"
+ : "已触发 ${triggered.join(' 与 ')}:${target}"
echo currentBuild.description
}
}
diff --git a/jenkins/scheduled-revision-trigger-job-config.xml b/jenkins/scheduled-revision-trigger-job-config.xml
index 70a48e081..fba181962 100644
--- a/jenkins/scheduled-revision-trigger-job-config.xml
+++ b/jenkins/scheduled-revision-trigger-job-config.xml
@@ -1,7 +1,7 @@
- 按小时检查源码分支版本,只有版本变化时用同一个 commit 触发 Full Build 与 AGC Windows Build。
+ 按小时检查源码分支版本,只有版本变化时用同一个 commit 触发下游;两条下游各自按变更路径过滤:AGC Windows Build 仅在本轮提交触及客户端相关路径时触发,Full Build 仅在本轮提交不只是文档 / 流水线 / 客户端改动时触发。
false
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 [--dry-run] [--prune]
+ * [--bucket agc-dev] [--endpoint oss-rg-china-mainland.aliyuncs.com] [--prefix templates]
+ * [--index-out ]
+ *
+ * 源目录结构(仓库内为 `apps/ai-game-creator-shell/template-library/`):
+ * /v1//meta.json 模板元数据(title/summary/tags/runtime/engine/…)
+ * /v1//cover.(png|jpg|jpeg|webp|svg) 封面图
+ * /v1//project/** 模板正文(就是解压后的项目根内容)
+ *
+ * 脚本按 `project/` 现场打包 `template.zip`(zip 根 == AGC 项目根),再上传
+ * `v1//{template.zip,cover.*,template.json}` 与库清单 `index.json`;
+ * `--prune` 会删除该模板前缀下本次没有产出的旧对象(例如换了封面扩展名)。
+ * 上传前完成全部校验,任何一项不合法都不发请求。
+ */
+
+import { createHash, createHmac } from 'node:crypto';
+import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { join, resolve } from 'node:path';
+import { deflateRawSync } from 'node:zlib';
+
+const SCHEMA_VERSION = 'agc-template-library.v1';
+const TEMPLATE_SCHEMA_VERSION = 'agc-template.v1';
+const TEMPLATE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
+const TEMPLATE_VERSION_PATTERN = /^[a-z0-9][a-z0-9._-]{0,31}$/u;
+const RUNTIMES = new Set(['html', 'unity', 'godot', 'cocos']);
+const COVER_CONTENT_TYPES = new Map([
+ ['.png', 'image/png'],
+ ['.jpg', 'image/jpeg'],
+ ['.jpeg', 'image/jpeg'],
+ ['.webp', 'image/webp'],
+ ['.svg', 'image/svg+xml'],
+]);
+
+function usage() {
+ console.log(
+ [
+ '用法: node scripts/agc-template-library-publish.mjs --source [--dry-run] [--prune]',
+ ' [--bucket agc-dev] [--endpoint oss-rg-china-mainland.aliyuncs.com] [--prefix templates]',
+ ' [--index-out ]',
+ ].join('\n'),
+ );
+}
+
+function parseArgs(argv) {
+ const args = {
+ source: '',
+ bucket: process.env.AGC_TEMPLATE_LIBRARY_BUCKET?.trim() || 'agc-dev',
+ endpoint:
+ process.env.AGC_TEMPLATE_LIBRARY_ENDPOINT?.trim() ||
+ 'oss-rg-china-mainland.aliyuncs.com',
+ prefix: 'templates',
+ indexOut: '',
+ dryRun: false,
+ prune: false,
+ };
+ for (let index = 0; index < argv.length; index += 1) {
+ const value = argv[index];
+ if (value === '--source') args.source = argv[(index += 1)] ?? '';
+ else if (value === '--bucket') args.bucket = argv[(index += 1)] ?? '';
+ else if (value === '--endpoint') args.endpoint = argv[(index += 1)] ?? '';
+ else if (value === '--prefix') args.prefix = argv[(index += 1)] ?? '';
+ else if (value === '--index-out') args.indexOut = argv[(index += 1)] ?? '';
+ else if (value === '--dry-run') args.dryRun = true;
+ else if (value === '--prune') args.prune = true;
+ else if (value === '--help' || value === '-h') {
+ usage();
+ process.exit(0);
+ } else throw new Error(`未知参数:${value}`);
+ }
+ return args;
+}
+
+function loadAccessKeys() {
+ if (
+ process.env.ALIYUN_OSS_ACCESS_KEY_ID &&
+ process.env.ALIYUN_OSS_ACCESS_KEY_SECRET
+ ) {
+ return {
+ accessKeyId: process.env.ALIYUN_OSS_ACCESS_KEY_ID.trim(),
+ accessKeySecret: process.env.ALIYUN_OSS_ACCESS_KEY_SECRET,
+ };
+ }
+ const secretsPath = resolve('.env.secrets.local');
+ if (!existsSync(secretsPath)) {
+ throw new Error(
+ '缺少 OSS 凭据:请设置 ALIYUN_OSS_ACCESS_KEY_ID / ALIYUN_OSS_ACCESS_KEY_SECRET',
+ );
+ }
+ const secrets = Object.fromEntries(
+ readFileSync(secretsPath, 'utf8')
+ .split(/\r?\n/u)
+ .map((line) => /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(line.trim()))
+ .filter(Boolean)
+ .map((match) => [match[1], match[2].replace(/^"|"$/gu, '')]),
+ );
+ if (
+ !secrets.ALIYUN_OSS_ACCESS_KEY_ID ||
+ !secrets.ALIYUN_OSS_ACCESS_KEY_SECRET
+ ) {
+ throw new Error('仓库 .env.secrets.local 缺少 ALIYUN_OSS_* 凭据');
+ }
+ return {
+ accessKeyId: secrets.ALIYUN_OSS_ACCESS_KEY_ID,
+ accessKeySecret: secrets.ALIYUN_OSS_ACCESS_KEY_SECRET,
+ };
+}
+
+const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex');
+
+const CRC32_TABLE = (() => {
+ const table = new Uint32Array(256);
+ for (let index = 0; index < 256; index += 1) {
+ let value = index;
+ for (let bit = 0; bit < 8; bit += 1) {
+ value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
+ }
+ table[index] = value >>> 0;
+ }
+ return table;
+})();
+
+function crc32(buffer) {
+ let crc = 0xffffffff;
+ for (const byte of buffer) {
+ crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
+ }
+ return (crc ^ 0xffffffff) >>> 0;
+}
+
+/**
+ * 生成确定性的 ZIP:条目按路径排序、固定 DOS 时间戳、UTF-8 名称位标记。
+ * 同一份 `project/` 内容重复打包得到相同摘要,便于比对发布结果。
+ */
+function buildZip(entries) {
+ const chunks = [];
+ const central = [];
+ let offset = 0;
+ for (const entry of entries) {
+ const nameBytes = Buffer.from(entry.path, 'utf8');
+ const content = entry.bytes;
+ const compressed = deflateRawSync(content, { level: 9 });
+ const checksum = crc32(content);
+ const local = Buffer.alloc(30);
+ local.writeUInt32LE(0x04034b50, 0);
+ local.writeUInt16LE(20, 4);
+ local.writeUInt16LE(0x0800, 6);
+ local.writeUInt16LE(8, 8);
+ local.writeUInt16LE(0, 10);
+ local.writeUInt16LE(0x0021, 12);
+ local.writeUInt32LE(checksum, 14);
+ local.writeUInt32LE(compressed.length, 18);
+ local.writeUInt32LE(content.length, 22);
+ local.writeUInt16LE(nameBytes.length, 26);
+ local.writeUInt16LE(0, 28);
+ chunks.push(local, nameBytes, compressed);
+ const directory = Buffer.alloc(46);
+ directory.writeUInt32LE(0x02014b50, 0);
+ directory.writeUInt16LE(20, 4);
+ directory.writeUInt16LE(20, 6);
+ directory.writeUInt16LE(0x0800, 8);
+ directory.writeUInt16LE(8, 10);
+ directory.writeUInt16LE(0, 12);
+ directory.writeUInt16LE(0x0021, 14);
+ directory.writeUInt32LE(checksum, 16);
+ directory.writeUInt32LE(compressed.length, 20);
+ directory.writeUInt32LE(content.length, 24);
+ directory.writeUInt16LE(nameBytes.length, 28);
+ directory.writeUInt16LE(0, 30);
+ directory.writeUInt16LE(0, 32);
+ directory.writeUInt16LE(0, 34);
+ directory.writeUInt16LE(0, 36);
+ directory.writeUInt32LE(0, 38);
+ directory.writeUInt32LE(offset, 42);
+ central.push(directory, nameBytes);
+ offset += local.length + nameBytes.length + compressed.length;
+ }
+ const centralBytes = Buffer.concat(central);
+ const end = Buffer.alloc(22);
+ end.writeUInt32LE(0x06054b50, 0);
+ end.writeUInt16LE(0, 4);
+ end.writeUInt16LE(0, 6);
+ end.writeUInt16LE(entries.length, 8);
+ end.writeUInt16LE(entries.length, 10);
+ end.writeUInt32LE(centralBytes.length, 12);
+ end.writeUInt32LE(offset, 16);
+ end.writeUInt16LE(0, 20);
+ return Buffer.concat([...chunks, centralBytes, end]);
+}
+
+function readProjectFiles(projectRoot) {
+ const files = [];
+ const walk = (directory, prefix) => {
+ for (const entry of readdirSync(directory, { withFileTypes: true }).sort(
+ (left, right) => left.name.localeCompare(right.name),
+ )) {
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
+ const full = join(directory, entry.name);
+ if (entry.isDirectory()) walk(full, relative);
+ else if (entry.isFile())
+ files.push({ path: relative, bytes: readFileSync(full) });
+ else throw new Error(`模板正文包含不支持的条目:${full}`);
+ }
+ };
+ walk(projectRoot, '');
+ if (files.length === 0) throw new Error(`模板正文为空:${projectRoot}`);
+ return files;
+}
+
+function readMeta(templateRoot, templateId) {
+ const metaPath = join(templateRoot, 'meta.json');
+ if (!existsSync(metaPath)) throw new Error(`${templateId} 缺少 meta.json`);
+ const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
+ if (!TEMPLATE_ID_PATTERN.test(meta.id ?? '')) {
+ throw new Error(`${templateId} 的 meta.id 非法`);
+ }
+ if (meta.id !== templateId) {
+ throw new Error(`${templateId} 目录名与 meta.id 不一致:${meta.id}`);
+ }
+ if (typeof meta.title !== 'string' || !meta.title.trim()) {
+ throw new Error(`${templateId} 缺少 title`);
+ }
+ if (!TEMPLATE_VERSION_PATTERN.test(meta.templateVersion ?? '')) {
+ throw new Error(`${templateId} 缺少合法的 templateVersion`);
+ }
+ if (!RUNTIMES.has(meta.runtime ?? '')) {
+ throw new Error(
+ `${templateId} 的 runtime 必须是 ${[...RUNTIMES].join(' / ')}`,
+ );
+ }
+ if (
+ !Array.isArray(meta.tags) ||
+ meta.tags.length === 0 ||
+ meta.tags.some((tag) => typeof tag !== 'string' || !tag.trim())
+ ) {
+ throw new Error(`${templateId} 的 tags 必须是非空字符串数组`);
+ }
+ if (
+ typeof meta.entry !== 'string' ||
+ !meta.entry.trim() ||
+ meta.entry.includes('..') ||
+ meta.entry.startsWith('/')
+ ) {
+ throw new Error(`${templateId} 的 entry 非法`);
+ }
+ return meta;
+}
+
+function buildLibrary(source, prefix) {
+ const versionRoot = join(source, 'v1');
+ if (!existsSync(versionRoot))
+ throw new Error(`源目录缺少 v1/:${versionRoot}`);
+ const templateIds = readdirSync(versionRoot, { withFileTypes: true })
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => entry.name)
+ .sort((left, right) => left.localeCompare(right));
+ if (templateIds.length === 0) throw new Error('源目录 v1/ 下没有模板');
+
+ const updatedAt = new Date().toISOString().replace(/\.\d{3}Z$/u, 'Z');
+ const templates = [];
+ const objects = [];
+ const managedPrefixes = [];
+
+ for (const templateId of templateIds) {
+ const templateRoot = join(versionRoot, templateId);
+ const meta = readMeta(templateRoot, templateId);
+ const projectFiles = readProjectFiles(join(templateRoot, 'project'));
+ const zipBytes = buildZip(projectFiles);
+ const coverNames = readdirSync(templateRoot).filter((name) =>
+ COVER_CONTENT_TYPES.has(name.slice(name.lastIndexOf('.')).toLowerCase()),
+ );
+ if (coverNames.length !== 1) {
+ throw new Error(
+ `${templateId} 必须且只能有一张封面图(png/jpg/webp/svg)`,
+ );
+ }
+ const coverName = coverNames[0];
+ const coverBytes = readFileSync(join(templateRoot, coverName));
+
+ const zipKey = `${prefix}/v1/${templateId}/template.zip`;
+ const coverKey = `${prefix}/v1/${templateId}/${coverName}`;
+ const metadataKey = `${prefix}/v1/${templateId}/template.json`;
+
+ const templateMetadata = {
+ schemaVersion: TEMPLATE_SCHEMA_VERSION,
+ id: meta.id,
+ title: meta.title,
+ summary: meta.summary ?? '',
+ tags: meta.tags,
+ runtime: meta.runtime,
+ engine: meta.engine ?? '',
+ engineVersion: meta.engineVersion ?? '',
+ templateVersion: meta.templateVersion,
+ updatedAt,
+ entry: meta.entry,
+ zip: {
+ key: zipKey,
+ sizeBytes: zipBytes.length,
+ sha256: sha256(zipBytes),
+ },
+ cover: {
+ key: coverKey,
+ width: Number.isInteger(meta.coverWidth) ? meta.coverWidth : 0,
+ height: Number.isInteger(meta.coverHeight) ? meta.coverHeight : 0,
+ sha256: sha256(coverBytes),
+ },
+ files: projectFiles.map((file) => ({
+ path: file.path,
+ sizeBytes: file.bytes.length,
+ sha256: sha256(file.bytes),
+ })),
+ };
+
+ templates.push({
+ id: templateMetadata.id,
+ title: templateMetadata.title,
+ summary: templateMetadata.summary,
+ tags: templateMetadata.tags,
+ runtime: templateMetadata.runtime,
+ engine: templateMetadata.engine,
+ engineVersion: templateMetadata.engineVersion,
+ templateVersion: templateMetadata.templateVersion,
+ updatedAt,
+ entry: templateMetadata.entry,
+ zipKey,
+ zipSizeBytes: templateMetadata.zip.sizeBytes,
+ zipSha256: templateMetadata.zip.sha256,
+ coverKey,
+ coverWidth: templateMetadata.cover.width,
+ coverHeight: templateMetadata.cover.height,
+ coverSha256: templateMetadata.cover.sha256,
+ metadataKey,
+ });
+
+ managedPrefixes.push(`${prefix}/v1/${templateId}/`);
+ objects.push(
+ { key: zipKey, body: zipBytes, contentType: 'application/zip' },
+ {
+ key: coverKey,
+ body: coverBytes,
+ contentType: COVER_CONTENT_TYPES.get(
+ coverName.slice(coverName.lastIndexOf('.')).toLowerCase(),
+ ),
+ },
+ {
+ key: metadataKey,
+ body: Buffer.from(
+ `${JSON.stringify(templateMetadata, null, 2)}\n`,
+ 'utf8',
+ ),
+ contentType: 'application/json',
+ },
+ );
+ }
+
+ const indexJson = {
+ schemaVersion: SCHEMA_VERSION,
+ library: 'agc-game-templates',
+ libraryVersion: 1,
+ updatedAt,
+ templates,
+ };
+ objects.push({
+ key: `${prefix}/index.json`,
+ body: Buffer.from(`${JSON.stringify(indexJson, null, 2)}\n`, 'utf8'),
+ contentType: 'application/json',
+ });
+ return { objects, indexJson, managedPrefixes };
+}
+
+function createClient({ bucket, endpoint, accessKeyId, accessKeySecret }) {
+ function authorize(method, resourcePath, contentType) {
+ const date = new Date().toUTCString();
+ const stringToSign = `${method}\n\n${contentType}\n${date}\n${resourcePath}`;
+ const signature = createHmac('sha1', accessKeySecret)
+ .update(stringToSign, 'utf8')
+ .digest('base64');
+ return { date, authorization: `OSS ${accessKeyId}:${signature}` };
+ }
+
+ async function put(key, body, contentType) {
+ const { date, authorization } = authorize(
+ 'PUT',
+ `/${bucket}/${key}`,
+ contentType,
+ );
+ return fetch(`https://${bucket}.${endpoint}/${key}`, {
+ method: 'PUT',
+ headers: {
+ Date: date,
+ Authorization: authorization,
+ ...(contentType ? { 'Content-Type': contentType } : {}),
+ },
+ body,
+ });
+ }
+
+ async function get(key) {
+ const { date, authorization } = authorize('GET', `/${bucket}/${key}`, '');
+ return fetch(`https://${bucket}.${endpoint}/${key}`, {
+ headers: { Date: date, Authorization: authorization },
+ });
+ }
+
+ async function remove(key) {
+ const { date, authorization } = authorize(
+ 'DELETE',
+ `/${bucket}/${key}`,
+ '',
+ );
+ return fetch(`https://${bucket}.${endpoint}/${key}`, {
+ method: 'DELETE',
+ headers: { Date: date, Authorization: authorization },
+ });
+ }
+
+ async function listKeys(prefix) {
+ const { date, authorization } = authorize('GET', `/${bucket}/`, '');
+ const response = await fetch(
+ `https://${bucket}.${endpoint}/?prefix=${encodeURIComponent(prefix)}&max-keys=1000`,
+ { headers: { Date: date, Authorization: authorization } },
+ );
+ const body = await response.text();
+ if (!response.ok)
+ throw new Error(
+ `列举对象失败:HTTP ${response.status} ${body.slice(0, 200)}`,
+ );
+ return [...body.matchAll(/([\s\S]*?)<\/Key>/gu)].map(
+ (match) => match[1],
+ );
+ }
+
+ return { put, get, remove, listKeys };
+}
+
+async function main() {
+ const args = parseArgs(process.argv.slice(2));
+ if (!args.source) {
+ usage();
+ throw new Error('必须提供 --source');
+ }
+ const source = resolve(args.source);
+ const { objects, indexJson, managedPrefixes } = buildLibrary(
+ source,
+ args.prefix,
+ );
+ if (args.indexOut) {
+ writeFileSync(
+ resolve(args.indexOut),
+ `${JSON.stringify(indexJson, null, 2)}\n`,
+ 'utf8',
+ );
+ }
+ console.log(
+ `模板库:${indexJson.templates.length} 个模板 -> oss://${args.bucket}/${args.prefix}/`,
+ );
+ for (const template of indexJson.templates) {
+ console.log(
+ ` - ${template.id}@${template.templateVersion} tags=${template.tags.join('/')} zip=${template.zipSizeBytes}B sha256=${template.zipSha256.slice(0, 12)}…`,
+ );
+ }
+ if (args.dryRun) {
+ console.log('dry-run:未上传。计划上传对象:');
+ for (const object of objects)
+ console.log(` PUT ${object.key} (${object.body.length} B)`);
+ return;
+ }
+
+ const credentials = loadAccessKeys();
+ const client = createClient({ ...args, ...credentials });
+ for (const object of objects) {
+ const response = await client.put(
+ object.key,
+ object.body,
+ object.contentType,
+ );
+ if (!response.ok) {
+ throw new Error(
+ `上传失败 ${object.key}:HTTP ${response.status} ${await response.text()}`,
+ );
+ }
+ console.log(
+ `PUT ${object.key} (${object.body.length} B) -> ${response.status}`,
+ );
+ }
+
+ if (args.prune) {
+ const uploaded = new Set(objects.map((object) => object.key));
+ for (const prefix of managedPrefixes) {
+ for (const key of await client.listKeys(prefix)) {
+ if (uploaded.has(key) || key === prefix) continue;
+ const response = await client.remove(key);
+ console.log(`DELETE ${key} -> ${response.status}`);
+ }
+ }
+ }
+
+ const verify = await client.get(`${args.prefix}/index.json`);
+ if (!verify.ok) throw new Error(`回读清单失败:HTTP ${verify.status}`);
+ const liveIndex = JSON.parse(await verify.text());
+ for (const template of liveIndex.templates) {
+ const zipResponse = await client.get(template.zipKey);
+ const zipBytes = Buffer.from(await zipResponse.arrayBuffer());
+ const digest = sha256(zipBytes);
+ if (digest !== template.zipSha256) {
+ throw new Error(`回读校验失败:${template.zipKey}`);
+ }
+ console.log(
+ `verify ${template.zipKey} size=${zipBytes.length} sha256=${digest.slice(0, 12)}… ok`,
+ );
+ }
+ console.log('模板库发布完成。');
+}
+
+main().catch((error) => {
+ console.error(`[agc-template-library-publish] ${error.message}`);
+ process.exit(1);
+});
diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs
index d4d081884..4c34bda86 100644
--- a/server-rs/crates/api-server/src/app.rs
+++ b/server-rs/crates/api-server/src/app.rs
@@ -51,6 +51,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(modules::external_generation::router(state.clone()))
.merge(modules::platform_support::router(state.clone()))
.merge(modules::raw::router(state.clone()))
+ .merge(modules::project_snapshots::router(state.clone()))
.merge(crate::error_reports::router(state.clone()))
.route(
"/api/profile/recharge/wechat/notify",
diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs
index 94ea4d4e4..6a50319a2 100644
--- a/server-rs/crates/api-server/src/config.rs
+++ b/server-rs/crates/api-server/src/config.rs
@@ -27,6 +27,9 @@ const DEFAULT_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD: u32 = 3;
const DEFAULT_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS: u64 = 120;
const DEFAULT_ALIYUN_MATTING_ENDPOINT: &str = "imageseg.cn-shanghai.aliyuncs.com";
const DEFAULT_ALIYUN_MATTING_REQUEST_TIMEOUT_MS: u64 = 30_000;
+/// AGC 项目快照默认落到 AGC 发行 bucket;私有前缀由 platform-oss 单独限制。
+const DEFAULT_AGC_PROJECT_SNAPSHOT_OSS_BUCKET: &str = "agc-dev";
+const DEFAULT_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT: &str = "oss-rg-china-mainland.aliyuncs.com";
pub(crate) const OFFICIAL_LLM_ROUTER_BASE_URL: &str = "https://router.genarrative.world/v1";
pub(crate) const OFFICIAL_LLM_ROUTER_MODEL: &str = "gpt-6-astra";
const LLM_ROUTER_KEY_ENCRYPTION_DOMAIN: &[u8] = b"genarrative:llm-router-api-key-encryption:v1\0";
@@ -169,6 +172,12 @@ pub struct AppConfig {
pub oss_post_expire_seconds: u64,
pub oss_post_max_size_bytes: u64,
pub oss_success_action_status: u16,
+ /// AGC 项目快照上传目标。默认指向 AGC 发行用的公开 bucket,可用独立凭据覆盖;
+ /// 未单独配置时回退 `ALIYUN_OSS_*`,与数据库备份 bucket 的分离开关同口径。
+ pub project_snapshot_oss_bucket: String,
+ pub project_snapshot_oss_endpoint: String,
+ pub project_snapshot_oss_access_key_id: Option,
+ pub project_snapshot_oss_access_key_secret: Option,
pub spacetime_server_url: String,
pub spacetime_database: String,
pub spacetime_token: Option,
@@ -476,6 +485,10 @@ impl Default for AppConfig {
oss_post_expire_seconds: 10 * 60,
oss_post_max_size_bytes: 20 * 1024 * 1024,
oss_success_action_status: 200,
+ project_snapshot_oss_bucket: DEFAULT_AGC_PROJECT_SNAPSHOT_OSS_BUCKET.to_string(),
+ project_snapshot_oss_endpoint: DEFAULT_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT.to_string(),
+ project_snapshot_oss_access_key_id: None,
+ project_snapshot_oss_access_key_secret: None,
spacetime_server_url: "http://127.0.0.1:3000".to_string(),
spacetime_database: "genarrative-dev".to_string(),
spacetime_token: None,
@@ -1121,6 +1134,24 @@ impl AppConfig {
{
config.oss_success_action_status = oss_success_action_status;
}
+ config.project_snapshot_oss_bucket = read_first_non_empty_env(&[
+ "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET",
+ "ALIYUN_OSS_BUCKET",
+ ])
+ .unwrap_or_else(|| DEFAULT_AGC_PROJECT_SNAPSHOT_OSS_BUCKET.to_string());
+ config.project_snapshot_oss_endpoint = read_first_non_empty_env(&[
+ "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT",
+ "ALIYUN_OSS_ENDPOINT",
+ ])
+ .unwrap_or_else(|| DEFAULT_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT.to_string());
+ config.project_snapshot_oss_access_key_id = read_first_non_empty_env(&[
+ "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID",
+ "ALIYUN_OSS_ACCESS_KEY_ID",
+ ]);
+ config.project_snapshot_oss_access_key_secret = read_first_non_empty_env(&[
+ "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET",
+ "ALIYUN_OSS_ACCESS_KEY_SECRET",
+ ]);
if let Some(spacetime_server_url) =
read_first_non_empty_env(&["GENARRATIVE_SPACETIME_SERVER_URL"])
diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs
index 0805fb4d4..53d051abc 100644
--- a/server-rs/crates/api-server/src/editor_agent/tool.rs
+++ b/server-rs/crates/api-server/src/editor_agent/tool.rs
@@ -864,7 +864,9 @@ impl EditorAgentTool for GenerateIconSpritesheetTool {
reference_image_srcs: Some(reference_image_srcs),
icon_descriptions: args.icon_descriptions,
slice_count: None,
- slice_mode: None,
+ // 画板 Agent 只生成自由排布的图标表,因此显式声明连通域切分;等分网格或固定
+ // 槽位需求必须由调用方在外部 API 显式传 grid + gridX/gridY,不能依赖任何默认值。
+ slice_mode: Some("connected-components".to_string()),
grid_x: None,
grid_y: None,
style: None,
diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs
index d0e68e432..c109dab63 100644
--- a/server-rs/crates/api-server/src/editor_project_icon.rs
+++ b/server-rs/crates/api-server/src/editor_project_icon.rs
@@ -76,13 +76,13 @@ pub(crate) const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS: usize = 2_000;
pub(crate) const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES: usize = 6 * 1024;
pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_DIMENSION: u32 = 4096;
pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_PIXELS: u64 = 2048 * 2048;
-const EDITOR_ICON_SPRITESHEET_MAX_SLICES: usize = 256;
+pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_SLICES: usize = 256;
pub(crate) const EDITOR_ICON_SPRITESHEET_CPU_MAX_CONCURRENCY: usize = 2;
pub(crate) const EDITOR_ICON_SPRITESHEET_MEMORY_MAX_CONCURRENCY: usize = 2;
pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY: usize = 2;
pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS: u64 =
EDITOR_ICON_SPRITESHEET_MAX_PIXELS * 4;
-const EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS: u32 = 32;
+pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS: u32 = 32;
pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_PROCESSING_DURATION: Duration =
@@ -255,9 +255,11 @@ pub(crate) struct EditorIconSpritesheetGenerationRequest {
/// 用户要求的切片数量;未提供时按图像中的连通素材自动识别。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) slice_count: Option,
- /// 图集切分模式;省略时使用连通域切分。
+ /// 图集切分模式;必填且没有默认值,必须在任何副作用之前由调用方显式声明。
+ /// 这里按原始字符串接收,让业务校验能返回带 `field` 和决策要求的 400,
+ /// 而不是只让 serde 抛一个通用的 JSON 解析错误。
#[serde(default, skip_serializing_if = "Option::is_none")]
- pub(crate) slice_mode: Option,
+ pub(crate) slice_mode: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) grid_x: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -283,16 +285,57 @@ pub(crate) enum EditorIconSpritesheetSliceMode {
Grid,
}
-impl Default for EditorIconSpritesheetSliceMode {
- fn default() -> Self {
- Self::ConnectedComponents
+/// `sliceMode` 的显式决策要求:该字段没有默认值,缺失即拒绝。
+pub(crate) const EDITOR_ICON_SPRITESHEET_SLICE_MODE_DECISION_GUIDANCE: &str = "切分模式没有默认值,必须显式声明:需求明确要求等分网格、固定槽位或指定行列数时传 sliceMode=grid,并用 gridX/gridY 传入该行列数;自由排布、数量不定或只要求一张图集时传 sliceMode=connected-components,需要约束素材张数时使用 sliceCount。";
+
+fn editor_icon_spritesheet_slice_mode_error(message: String) -> AppError {
+ AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
+ "field": "sliceMode",
+ "message": message,
+ }))
+}
+
+/// 解析显式的切分模式声明:缺失、空白和未知取值都返回可执行的 400。
+fn parse_editor_icon_spritesheet_slice_mode(
+ slice_mode: Option<&str>,
+) -> Result {
+ let Some(value) = slice_mode.map(str::trim) else {
+ return Err(editor_icon_spritesheet_slice_mode_error(format!(
+ "sliceMode 不能省略:{EDITOR_ICON_SPRITESHEET_SLICE_MODE_DECISION_GUIDANCE}"
+ )));
+ };
+ match value {
+ "" => Err(editor_icon_spritesheet_slice_mode_error(format!(
+ "sliceMode 不能为空字符串:{EDITOR_ICON_SPRITESHEET_SLICE_MODE_DECISION_GUIDANCE}"
+ ))),
+ "connected-components" => Ok(EditorIconSpritesheetSliceMode::ConnectedComponents),
+ "grid" => Ok(EditorIconSpritesheetSliceMode::Grid),
+ other => Err(editor_icon_spritesheet_slice_mode_error(format!(
+ "sliceMode 不支持 {other},只允许 connected-components 或 grid:{EDITOR_ICON_SPRITESHEET_SLICE_MODE_DECISION_GUIDANCE}"
+ ))),
}
}
-fn resolve_editor_icon_spritesheet_slice_mode(
- slice_mode: Option,
-) -> EditorIconSpritesheetSliceMode {
- slice_mode.unwrap_or_default()
+/// 解析并校验图集切分声明:缺失模式、模式与网格参数互相矛盾都在此失败关闭。
+fn resolve_editor_icon_spritesheet_slice_request(
+ slice_mode: Option<&str>,
+ grid_x: Option,
+ grid_y: Option,
+) -> Result<(EditorIconSpritesheetSliceMode, u32, u32), AppError> {
+ let slice_mode = parse_editor_icon_spritesheet_slice_mode(slice_mode)?;
+ if slice_mode == EditorIconSpritesheetSliceMode::ConnectedComponents
+ && (grid_x.is_some() || grid_y.is_some())
+ {
+ return Err(
+ AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
+ "field": "gridX/gridY",
+ "message": "sliceMode=connected-components 不接受 gridX/gridY:网格尺寸只能与 sliceMode=grid 同时声明。",
+ })),
+ );
+ }
+ let (grid_x, grid_y) =
+ resolve_editor_icon_spritesheet_grid_dimensions(slice_mode, grid_x, grid_y)?;
+ Ok((slice_mode, grid_x, grid_y))
}
fn resolve_editor_icon_spritesheet_grid_dimensions(
@@ -307,7 +350,10 @@ fn resolve_editor_icon_spritesheet_grid_dimensions(
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"field": "gridX/gridY",
- "message": "grid 模式必须同时提供 gridX 与 gridY。",
+ "message": format!(
+ "sliceMode=grid 必须同时提供 gridX 与 gridY(各 1 到 {}):行列数必须来自需求本身;用网格参数表达素材张数时应改用 sliceMode=connected-components 加 sliceCount。",
+ EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS
+ ),
})),
);
};
@@ -1464,6 +1510,12 @@ pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner(
mut payload: EditorIconSpritesheetGenerationRequest,
external_idempotency_key: Option<&str>,
) -> Result {
+ // 切分模式没有默认值:必须在引用解析、定价和入队之前显式声明。
+ resolve_editor_icon_spritesheet_slice_request(
+ payload.slice_mode.as_deref(),
+ payload.grid_x,
+ payload.grid_y,
+ )?;
payload.generation_inputs =
sanitize_editor_queued_generation_inputs(payload.generation_inputs.take());
payload.icon_descriptions =
@@ -1545,6 +1597,12 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
caller: EditorGenerationCaller,
mut payload: EditorIconSpritesheetGenerationRequest,
) -> Result, AppError> {
+ // 切分模式没有默认值:必须在引用解析、定价和任何 provider / OSS 副作用之前显式声明。
+ let (requested_slice_mode, grid_x, grid_y) = resolve_editor_icon_spritesheet_slice_request(
+ payload.slice_mode.as_deref(),
+ payload.grid_x,
+ payload.grid_y,
+ )?;
payload.generation_inputs =
sanitize_editor_client_generation_inputs(payload.generation_inputs.take());
ensure_editor_reference_image_sources_are_stable(
@@ -1647,12 +1705,6 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
.or_else(|| payload.project_id.clone()),
);
let http_client = build_openai_image_http_client(&settings)?;
- let requested_slice_mode = resolve_editor_icon_spritesheet_slice_mode(payload.slice_mode);
- let (grid_x, grid_y) = resolve_editor_icon_spritesheet_grid_dimensions(
- requested_slice_mode,
- payload.grid_x,
- payload.grid_y,
- )?;
// TODO(legacy-icon-spritesheet-billing-boundary): 该计费边界继承自 master 的历史实现;
// Provider 成功后 operation 即提交,后续解码、OSS、资源与画布持久化失败时缺少可对账中间态。
// 调整前需先定义 provider_succeeded/persistence_pending 等状态、稳定幂等键和补偿语义,
@@ -3058,19 +3110,97 @@ mod tests {
}
#[test]
- fn slice_mode_defaults_to_connected_components_and_accepts_explicit_modes() {
+ fn slice_mode_must_be_declared_and_accepts_explicit_modes() {
+ let missing = resolve_editor_icon_spritesheet_slice_request(None, None, None)
+ .expect_err("omitted sliceMode must fail closed");
+ assert_eq!(missing.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(
- resolve_editor_icon_spritesheet_slice_mode(None),
- EditorIconSpritesheetSliceMode::ConnectedComponents
+ missing.details().and_then(|details| details.get("field")),
+ Some(&json!("sliceMode"))
+ );
+ assert!(
+ missing
+ .details()
+ .and_then(|details| details.get("message"))
+ .and_then(Value::as_str)
+ .is_some_and(|message| message.contains("没有默认值")
+ && message.contains("grid")
+ && message.contains("connected-components")),
+ "{:?}",
+ missing.details()
+ );
+ let empty = resolve_editor_icon_spritesheet_slice_request(Some(" "), None, None)
+ .expect_err("blank sliceMode must fail closed");
+ assert_eq!(
+ empty.details().and_then(|details| details.get("field")),
+ Some(&json!("sliceMode"))
+ );
+ assert!(
+ empty
+ .details()
+ .and_then(|details| details.get("message"))
+ .and_then(Value::as_str)
+ .is_some_and(|message| message.contains("不能为空字符串")
+ && message.contains("connected-components")),
+ "{:?}",
+ empty.details()
+ );
+ let unknown =
+ resolve_editor_icon_spritesheet_slice_request(Some("grid-2x2"), Some(2), Some(2))
+ .expect_err("unknown sliceMode must fail closed with its own message");
+ assert_eq!(
+ unknown.details().and_then(|details| details.get("field")),
+ Some(&json!("sliceMode"))
+ );
+ assert!(
+ unknown
+ .details()
+ .and_then(|details| details.get("message"))
+ .and_then(Value::as_str)
+ .is_some_and(
+ |message| message.contains("grid-2x2") && message.contains("没有默认值")
+ ),
+ "{:?}",
+ unknown.details()
);
assert_eq!(
- resolve_editor_icon_spritesheet_grid_dimensions(
- EditorIconSpritesheetSliceMode::Grid,
- Some(3),
- Some(2),
+ resolve_editor_icon_spritesheet_slice_request(
+ Some("connected-components"),
+ None,
+ None,
)
- .expect("grid dimensions should validate"),
- (3, 2)
+ .expect("explicit connected-components mode should validate"),
+ (EditorIconSpritesheetSliceMode::ConnectedComponents, 0, 0)
+ );
+ assert_eq!(
+ resolve_editor_icon_spritesheet_slice_request(Some("grid"), Some(3), Some(2),)
+ .expect("grid dimensions should validate"),
+ (EditorIconSpritesheetSliceMode::Grid, 3, 2)
+ );
+ let contradictory = resolve_editor_icon_spritesheet_slice_request(
+ Some("connected-components"),
+ Some(2),
+ Some(2),
+ )
+ .expect_err("grid dimensions must not accompany connected-components");
+ assert_eq!(contradictory.status_code(), StatusCode::BAD_REQUEST);
+ assert_eq!(
+ contradictory
+ .details()
+ .and_then(|details| details.get("field")),
+ Some(&json!("gridX/gridY"))
+ );
+ let grid_without_dimensions =
+ resolve_editor_icon_spritesheet_slice_request(Some("grid"), None, None)
+ .expect_err("grid without dimensions must fail closed");
+ assert!(
+ grid_without_dimensions
+ .details()
+ .and_then(|details| details.get("message"))
+ .and_then(Value::as_str)
+ .is_some_and(|message| message.contains("sliceCount")),
+ "{:?}",
+ grid_without_dimensions.details()
);
let connected: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({
"referenceId": "spec",
@@ -3079,8 +3209,22 @@ mod tests {
}))
.expect("explicit connected-components mode should deserialize");
assert_eq!(
- connected.slice_mode,
- Some(EditorIconSpritesheetSliceMode::ConnectedComponents)
+ connected.slice_mode.as_deref(),
+ Some("connected-components")
+ );
+ let omitted: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({
+ "referenceId": "spec",
+ "iconDescriptions": ["素材"]
+ }))
+ .expect("omitted sliceMode stays deserializable so the route can return its own 400");
+ assert_eq!(omitted.slice_mode, None);
+ assert!(
+ resolve_editor_icon_spritesheet_slice_request(
+ omitted.slice_mode.as_deref(),
+ omitted.grid_x,
+ omitted.grid_y,
+ )
+ .is_err()
);
let grid: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({
"referenceId": "spec",
@@ -3090,6 +3234,7 @@ mod tests {
"gridY": 2
}))
.expect("grid mode should deserialize");
+ assert_eq!(grid.slice_mode.as_deref(), Some("grid"));
assert_eq!(grid.grid_x, Some(3));
assert_eq!(grid.grid_y, Some(2));
}
diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs
index 99923530f..6a53d32d8 100644
--- a/server-rs/crates/api-server/src/external_editor_api.rs
+++ b/server-rs/crates/api-server/src/external_editor_api.rs
@@ -2644,13 +2644,37 @@ mod tests {
icon_spritesheet_request["properties"]["sliceCount"]["minimum"],
json!(1)
);
+ assert_eq!(
+ icon_spritesheet_request["properties"]["sliceCount"]["maximum"],
+ json!(crate::editor_project_icon::EDITOR_ICON_SPRITESHEET_MAX_SLICES)
+ );
assert_eq!(
icon_spritesheet_request["properties"]["sliceMode"]["enum"],
json!(["connected-components", "grid"])
);
+ assert!(
+ icon_spritesheet_request["properties"]["sliceMode"]
+ .get("default")
+ .is_none(),
+ "sliceMode must not advertise a default"
+ );
+ assert!(
+ icon_spritesheet_request["required"]
+ .as_array()
+ .is_some_and(|required| required.contains(&json!("sliceMode"))),
+ "sliceMode must be required"
+ );
+ assert!(
+ icon_spritesheet_request["properties"]["sliceMode"]["description"]
+ .as_str()
+ .is_some_and(|description| description.contains("没有默认值")
+ && description.contains("field=sliceMode")
+ && description.contains("gridX/gridY")),
+ "sliceMode description must carry the explicit decision requirement"
+ );
assert_eq!(
icon_spritesheet_request["properties"]["gridX"]["maximum"],
- json!(32)
+ json!(crate::editor_project_icon::EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS)
);
let icon_style_schema = &parsed["components"]["schemas"]["EditorIconSpritesheetGenerationRequest"]
["properties"]["style"];
diff --git a/server-rs/crates/api-server/src/external_mcp.rs b/server-rs/crates/api-server/src/external_mcp.rs
index 6b6c469f7..2042fa3d6 100644
--- a/server-rs/crates/api-server/src/external_mcp.rs
+++ b/server-rs/crates/api-server/src/external_mcp.rs
@@ -54,7 +54,7 @@ const SKILL_REQUESTS_AND_OUTPUTS_URI: &str =
"genarrative://external-editor/skill/references/requests-and-outputs.md";
const MAX_MCP_REST_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
-const MCP_INSTRUCTIONS: &str = r#"陶泥儿外部编辑器工具。先创建或复用画布项目,并创建与画布同名的素材文件夹;生成结果应同时写入画布和素材库。参考本地文件时先走上传票据和对象确认,不要把 Data URL、Blob URL 或临时签名 URL写入生成参数。所有生成工具都是异步提交:必须提供 idempotencyKey,提交后按 pollAfterMs 调用 get_external_editor_generation_job,只有 status=completed 时消费 result;查询超时不能重新提交。图集生成可用 sliceMode=connected-components(默认连通域切分)或 grid(必须同时提供 gridX/gridY)。warning 表示主结果可用但存在降级,sliceWarning 表示完整透明图集可用但切片未完成。详细说明、OpenAPI、Skill 主入口和分主题 references 见 resources/list;需要本地文件编排或不支持 MCP 时再下载 skill.zip。"#;
+const MCP_INSTRUCTIONS: &str = r#"陶泥儿外部编辑器工具。先创建或复用画布项目,并创建与画布同名的素材文件夹;生成结果应同时写入画布和素材库。参考本地文件时先走上传票据和对象确认,不要把 Data URL、Blob URL 或临时签名 URL写入生成参数。所有生成工具都是异步提交:必须提供 idempotencyKey,提交后按 pollAfterMs 调用 get_external_editor_generation_job,只有 status=completed 时消费 result;查询超时不能重新提交。图集生成必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求的 gridX/gridY,自由排布或数量不定时用 connected-components(可用 sliceCount 约束张数),connected-components 不接受 gridX/gridY;缺失、越界或自相矛盾在计费前返回 400。warning 表示主结果可用但存在降级,sliceWarning 表示完整透明图集可用但切片未完成。详细说明、OpenAPI、Skill 主入口和分主题 references 见 resources/list;需要本地文件编排或不支持 MCP 时再下载 skill.zip。"#;
#[derive(Clone, Debug)]
struct McpOperation {
diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs
index 25e42cd28..c0980823f 100644
--- a/server-rs/crates/api-server/src/main.rs
+++ b/server-rs/crates/api-server/src/main.rs
@@ -66,6 +66,7 @@ mod process_metrics;
mod profile_identity;
mod profile_recharge_expiration_listener;
mod profile_recharge_refund_reconciliation;
+mod project_snapshots;
mod prompt;
mod raw_image;
mod refresh_session;
diff --git a/server-rs/crates/api-server/src/modules.rs b/server-rs/crates/api-server/src/modules.rs
index 76f5958b7..0b833ab21 100644
--- a/server-rs/crates/api-server/src/modules.rs
+++ b/server-rs/crates/api-server/src/modules.rs
@@ -10,4 +10,5 @@ pub mod internal;
pub mod platform;
pub mod platform_support;
pub mod profile;
+pub mod project_snapshots;
pub mod raw;
diff --git a/server-rs/crates/api-server/src/modules/project_snapshots.rs b/server-rs/crates/api-server/src/modules/project_snapshots.rs
new file mode 100644
index 000000000..65683b00f
--- /dev/null
+++ b/server-rs/crates/api-server/src/modules/project_snapshots.rs
@@ -0,0 +1,31 @@
+use axum::{Router, extract::DefaultBodyLimit, middleware, routing::post};
+
+use crate::{
+ auth::require_bearer_auth,
+ project_snapshots::{
+ MAX_FILE_REQUEST_BODY_BYTES, MAX_MANIFEST_REQUEST_BODY_BYTES, upload_project_snapshot_file,
+ upload_project_snapshot_manifest,
+ },
+ state::AppState,
+};
+
+/// AGC 项目快照只接受登录态客户端;两条路由都带体积门禁,超限请求在进入业务
+/// 处理前就被拒绝。
+pub fn router(state: AppState) -> Router {
+ Router::new()
+ .route(
+ "/api/agc/project-snapshots/files",
+ post(upload_project_snapshot_file)
+ .route_layer(middleware::from_fn_with_state(
+ state.clone(),
+ require_bearer_auth,
+ ))
+ .layer(DefaultBodyLimit::max(MAX_FILE_REQUEST_BODY_BYTES)),
+ )
+ .route(
+ "/api/agc/project-snapshots/manifest",
+ post(upload_project_snapshot_manifest)
+ .route_layer(middleware::from_fn_with_state(state, require_bearer_auth))
+ .layer(DefaultBodyLimit::max(MAX_MANIFEST_REQUEST_BODY_BYTES)),
+ )
+}
diff --git a/server-rs/crates/api-server/src/project_snapshots.rs b/server-rs/crates/api-server/src/project_snapshots.rs
new file mode 100644
index 000000000..d745d7493
--- /dev/null
+++ b/server-rs/crates/api-server/src/project_snapshots.rs
@@ -0,0 +1,517 @@
+//! AGC 项目定时快照上传的服务端入口。
+//!
+//! 客户端只提交"某个项目里发生变化的文件内容"和"当前清单",对象键、bucket 与
+//! 存储凭据都由服务端决定。写入固定在内部前缀下,客户端直传票据不覆盖该前缀。
+
+use crate::{
+ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
+ request_context::RequestContext, state::AppState,
+};
+use axum::{
+ Json,
+ body::Bytes,
+ extract::{Extension, Query, State},
+ http::StatusCode,
+};
+use platform_oss::{
+ OssDeleteObjectRequest, OssGetObjectRequest, OssInternalPutObjectRequest, OssObjectAccess,
+ agc_project_snapshot_file_object_key, agc_project_snapshot_manifest_object_key,
+};
+use serde_json::Value;
+use shared_contracts::agc_project_snapshots::{
+ AGC_PROJECT_SNAPSHOT_MAX_FILE_BYTES, AGC_PROJECT_SNAPSHOT_MAX_MANIFEST_FILES,
+ AGC_PROJECT_SNAPSHOT_MAX_PROJECT_BYTES, AGC_PROJECT_SNAPSHOT_SCHEMA_VERSION,
+ AgcProjectSnapshotFileUploadQuery, AgcProjectSnapshotFileUploadResponse,
+ AgcProjectSnapshotManifestRequest, AgcProjectSnapshotManifestResponse,
+ validate_agc_project_snapshot_checksum, validate_agc_project_snapshot_project_id,
+ validate_agc_project_snapshot_relative_path,
+};
+use std::{
+ collections::{HashMap, HashSet},
+ sync::{Mutex, OnceLock},
+ time::{Duration, Instant, SystemTime, UNIX_EPOCH},
+};
+use tracing::{debug, info, warn};
+
+/// 单文件请求体上限比文件上限留一点余量,超限请求由 body limit 直接拒绝。
+pub(crate) const MAX_FILE_REQUEST_BODY_BYTES: usize =
+ AGC_PROJECT_SNAPSHOT_MAX_FILE_BYTES as usize + 1024;
+/// 清单请求体上限:条目数本身有上限,这里再给一个字节级兜底。
+pub(crate) const MAX_MANIFEST_REQUEST_BODY_BYTES: usize = 32 * 1024 * 1024;
+/// 清单里单条路径的字段长度上限(与契约校验口径一致)。
+const MAX_MANIFEST_PATH_CHARS: usize = 1024;
+/// 单个用户每小时允许的文件上传次数。进程内计数,用于抑制异常客户端,不是计费级配额。
+const MAX_FILE_UPLOADS_PER_USER_PER_HOUR: usize = 3_000;
+/// 单个用户每小时允许的清单写入次数。
+const MAX_MANIFEST_UPLOADS_PER_USER_PER_HOUR: usize = 120;
+/// 同一项目两次清单写入的最小间隔,避免异常客户端高频覆盖清单。
+const MIN_MANIFEST_INTERVAL_MS: u64 = 5_000;
+/// 单次清单写入最多回收多少个不再被引用的对象,避免一次请求做过量删除。
+const MAX_OBJECTS_RECLAIMED_PER_MANIFEST: usize = 2_000;
+
+/// 写入一次增量同步里的单个文件。
+///
+/// 对象键由字节数和内容摘要共同决定,因此"对象已存在且长度一致"就是内容已存在的
+/// 充分判据;探测失败按未存在处理,PUT 本身是幂等的。
+pub async fn upload_project_snapshot_file(
+ State(state): State,
+ Extension(ctx): Extension,
+ Extension(auth): Extension,
+ Query(query): Query,
+ body: Bytes,
+) -> Result, AppError> {
+ consume_user_upload_quota(auth.claims().user_id(), ProjectSnapshotUploadKind::File)?;
+ validate_agc_project_snapshot_project_id(&query.project_id).map_err(bad_request)?;
+ validate_agc_project_snapshot_relative_path(&query.relative_path).map_err(bad_request)?;
+ validate_agc_project_snapshot_checksum(&query.checksum).map_err(bad_request)?;
+ if body.is_empty() {
+ return Err(bad_request("项目快照文件内容不能为空"));
+ }
+ let size_bytes =
+ u64::try_from(body.len()).map_err(|_| bad_request("项目快照文件长度超出可支持范围"))?;
+ if size_bytes > AGC_PROJECT_SNAPSHOT_MAX_FILE_BYTES {
+ return Err(bad_request("项目快照文件超过单文件上限"));
+ }
+ if size_bytes != query.size_bytes {
+ return Err(bad_request("项目快照文件长度与声明不一致"));
+ }
+ let digest = query
+ .checksum
+ .strip_prefix("fnv1a64:")
+ .unwrap_or_default()
+ .to_string();
+ let object_key = agc_project_snapshot_file_object_key(
+ auth.claims().user_id(),
+ &query.project_id,
+ size_bytes,
+ &digest,
+ &query.relative_path,
+ )
+ .map_err(|error| bad_request(error.to_string()))?;
+
+ let oss = project_snapshot_oss(&state)?;
+ let skipped = match oss
+ .head_internal_object(state.editor_oss_http_client(), &object_key)
+ .await
+ {
+ Ok(Some(existing)) => existing.content_length == size_bytes,
+ // 确定不存在、或探测失败时都继续写入:PUT 幂等,宁可多传一次也不漏传。
+ Ok(None) | Err(_) => false,
+ };
+ if !skipped {
+ oss.put_internal_object(
+ state.editor_oss_http_client(),
+ OssInternalPutObjectRequest {
+ object_key: object_key.clone(),
+ content_type: Some("application/octet-stream".to_string()),
+ access: OssObjectAccess::Private,
+ metadata: Default::default(),
+ body: body.to_vec(),
+ },
+ )
+ .await
+ .map_err(|_| {
+ AppError::from_status(StatusCode::BAD_GATEWAY).with_message("项目快照文件上传失败")
+ })?;
+ }
+
+ Ok(json_success_body(
+ Some(&ctx),
+ AgcProjectSnapshotFileUploadResponse {
+ project_id: query.project_id,
+ relative_path: query.relative_path,
+ object_key,
+ skipped,
+ checksum: query.checksum,
+ size_bytes,
+ },
+ ))
+}
+
+/// 覆盖写入该项目的远端清单。删除文件只在这里消失,本期不删除远端对象。
+pub async fn upload_project_snapshot_manifest(
+ State(state): State,
+ Extension(ctx): Extension,
+ Extension(auth): Extension,
+ Json(payload): Json,
+) -> Result, AppError> {
+ consume_user_upload_quota(auth.claims().user_id(), ProjectSnapshotUploadKind::Manifest)?;
+ validate_manifest(&payload)?;
+ let user_id = auth.claims().user_id().to_string();
+ let object_key = agc_project_snapshot_manifest_object_key(&user_id, &payload.project_id)
+ .map_err(|error| bad_request(error.to_string()))?;
+ // 上一版清单同时承担两个职责:项目级写入频率闸门,以及本轮远端对象回收的引用基线。
+ // 读不到或解析失败时只跳过回收,绝不据此删除任何对象。
+ let previous = read_project_snapshot_manifest(&state, &object_key).await;
+ if let Some(previous) = previous.as_ref()
+ && unix_millis_now().saturating_sub(previous.synced_at_ms) < MIN_MANIFEST_INTERVAL_MS
+ {
+ return Err(AppError::from_status(StatusCode::TOO_MANY_REQUESTS)
+ .with_message("同一项目的项目快照写入过于频繁,请稍后重试")
+ .with_header("retry-after", axum::http::HeaderValue::from_static("5")));
+ }
+ let body = serde_json::to_vec(&payload)
+ .map_err(|error| internal(format!("序列化项目快照清单失败:{error}")))?;
+ let total_bytes = payload
+ .files
+ .iter()
+ .fold(0_u64, |total, file| total.saturating_add(file.size_bytes));
+ let oss = project_snapshot_oss(&state)?;
+ oss.put_internal_object(
+ state.editor_oss_http_client(),
+ OssInternalPutObjectRequest {
+ object_key: object_key.clone(),
+ content_type: Some("application/json".to_string()),
+ access: OssObjectAccess::Private,
+ metadata: Default::default(),
+ body,
+ },
+ )
+ .await
+ .map_err(|_| {
+ AppError::from_status(StatusCode::BAD_GATEWAY).with_message("项目快照清单上传失败")
+ })?;
+ // 清单写入成功之后再回收:任何时刻远端对象集合都是当前清单的超集,
+ // 不会出现清单引用了刚被删掉的对象。
+ reclaim_unreferenced_objects(&state, oss, &user_id, previous.as_ref(), &payload).await;
+
+ Ok(json_success_body(
+ Some(&ctx),
+ AgcProjectSnapshotManifestResponse {
+ project_id: payload.project_id,
+ sync_revision: payload.sync_revision,
+ object_key,
+ file_count: u32::try_from(payload.files.len()).unwrap_or(u32::MAX),
+ total_bytes,
+ },
+ ))
+}
+
+/// 单个用户在窗口内的上传计数。进程内计数,用于抑制异常客户端与失控重试;
+/// 跨节点配额由"单项目累计体积上限 + 清单引用回收"保证。
+#[derive(Clone, Copy, Default)]
+struct ProjectSnapshotUserWindow {
+ started_at: Option