From c0db2984495b4863eedd4a1fb7d81ba6b348f92d Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 16 Sep 2026 11:27:07 +0800 Subject: [PATCH 01/16] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E6=80=81=E7=9B=91=E5=90=AC=20Rust=20=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E7=9B=AE=E5=BD=95=E5=AF=BC=E8=87=B4=E7=9A=84=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E7=BC=93=E6=85=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 Vite 中排除 src-tauri/target,保留业务源码与共享组件热更新。 增加实际 Vite watcher 回归,验证构建目录排除及源码变更通知。 同步开发运维文档和共享排障记录,关联 Issue #324。 --- .../scripts/vite-watch.test.mjs | 113 ++++++++++++++++++ apps/ai-game-creator-shell/vite.config.ts | 3 + docs/project-memory/shared-memory/pitfalls.md | 4 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 + 4 files changed, 122 insertions(+) create mode 100644 apps/ai-game-creator-shell/scripts/vite-watch.test.mjs diff --git a/apps/ai-game-creator-shell/scripts/vite-watch.test.mjs b/apps/ai-game-creator-shell/scripts/vite-watch.test.mjs new file mode 100644 index 000000000..42fa9a5d4 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/vite-watch.test.mjs @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { setTimeout as delay } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; + +import { createServer, loadConfigFromFile, normalizePath } from 'vite'; + +test( + 'AGC 排除 Rust 构建目录且保留源码与共享组件监听', + { timeout: 30_000 }, + async () => { + const loaded = await loadConfigFromFile( + { command: 'serve', mode: 'development' }, + fileURLToPath(new URL('../vite.config.ts', import.meta.url)), + ); + assert.ok(loaded); + assert.notEqual(loaded.config.server?.watch, null); + assert.notEqual(loaded.config.server?.hmr, false); + assert.ok( + [loaded.config.server?.watch?.ignored] + .flat() + .includes('**/src-tauri/target/**'), + ); + + const fixture = await mkdtemp(join(tmpdir(), 'agc-vite-watch-')); + const root = join(fixture, 'apps', 'ai-game-creator-shell'); + const source = join(root, 'src', 'main.js'); + const css = join(root, 'src', 'styles.css'); + const shared = join(fixture, 'packages', 'shared', 'src', 'component.js'); + const target = join(root, 'src-tauri', 'target'); + const artifact = join(target, 'debug', 'incremental', 'cache.bin'); + let server; + try { + for (const file of [source, css, shared, artifact]) { + await mkdir(dirname(file), { recursive: true }); + await writeFile( + file, + file === css ? 'body { color: red; }' : 'export default 1;', + ); + } + // 使用真实 Vite watcher 和实际配置,仅将扫描根替换为小型夹具; + // 不加载业务插件、后端或原生窗口,也不扫描开发机上的大型 target。 + server = await createServer({ + configFile: false, + envFile: false, + root, + logLevel: 'silent', + server: { + watch: loaded.config.server?.watch, + middlewareMode: true, + hmr: false, + fs: { allow: [fixture] }, + }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + const waitForWatchedFile = async (file) => { + const normalized = normalizePath(file); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ( + Object.entries(server.watcher.getWatched()).some( + ([directory, names]) => + names.some( + (name) => normalizePath(join(directory, name)) === normalized, + ), + ) + ) + return; + await delay(50); + } + assert.fail(`源码必须仍被监听:${normalized}`); + }; + await waitForWatchedFile(source); + + // 真实模块转换应将 root 外的共享源码加入监听。 + await server.transformRequest(`/@fs/${normalizePath(shared)}`); + for (const file of [source, css, shared]) { + const normalized = normalizePath(file); + await waitForWatchedFile(file); + const changed = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + server.watcher.off('change', onChange); + reject(new Error(`未收到源码变更:${normalized}`)); + }, 5_000); + function onChange(path) { + if (normalizePath(path) !== normalized) return; + clearTimeout(timer); + server.watcher.off('change', onChange); + resolve(); + } + server.watcher.on('change', onChange); + }); + await writeFile( + file, + file === css ? 'body { color: blue; }' : 'export default 2;', + ); + await changed; + } + const targetPath = normalizePath(target); + const targetDirectories = Object.keys(server.watcher.getWatched()) + .map(normalizePath) + .filter( + (path) => path === targetPath || path.startsWith(`${targetPath}/`), + ); + assert.deepEqual(targetDirectories, [], 'Rust target 不应创建目录监听器'); + } finally { + await server?.close(); + await rm(fixture, { recursive: true, force: true }); + } + }, +); diff --git a/apps/ai-game-creator-shell/vite.config.ts b/apps/ai-game-creator-shell/vite.config.ts index 6e68153ba..fa2246ed0 100644 --- a/apps/ai-game-creator-shell/vite.config.ts +++ b/apps/ai-game-creator-shell/vite.config.ts @@ -154,6 +154,9 @@ export default defineConfig({ host: '127.0.0.1', port: 3080, strictPort: true, + watch: { + ignored: ['**/src-tauri/target/**'], + }, fs: { allow: [repoRoot], }, diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index e8d132d9f..99b49877b 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,9 @@ # 踩坑与排障记录 +## AGC Windows 开发态首次页面加载缓慢 + +Vite 默认监听应用根下的 Rust `src-tauri/target`,构建产物较多时会创建大量 Windows 文件监听器。AGC 配置通过 `server.watch.ignored: ['**/src-tauri/target/**']` 排除此目录,不关闭业务源码、CSS、共享组件监听或 HMR。排查时区分后端就绪、Vite 扫描和原生窗口首绘;监听目录回归不能代替实机首绘测量,验证入口见本地开发运维文档。 + ## Windows 已登记生图资产未刷新 Direct 工具桥会 canonicalize 项目根,事件中的路径可能带 `\\?\` / `\\?\UNC\`,而前端项目路径仍是普通盘符或 UNC。失效监听不能直接比较原始字符串;识别为同一项目后,用当前项目路径重读 manifest,保留项目切换与 revision 门禁。普通 `agc_generate_image` 成功提交也必须发出失效通知,不能依赖整轮 Agent 结束。回归需覆盖两种 Windows 前缀、其它项目事件拒收,以及 Agent 尚未结束和后续失败时已登记图片卡片仍可见。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index e036f5205..181ca4bfc 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -20,6 +20,8 @@ Stdb 发布以 root 准备文件、再切换 `spacetimedb` 用户执行时,WAS ## 本地启动 +AGC Vite 的 `server.watch.ignored` 排除 `**/src-tauri/target/**`,避免递归监听 Rust 构建产物、在 Windows 上创建大量文件监听器并拖慢首次页面加载。保留业务源码、CSS 与仓库共享组件的监听及热更新;不通过关闭 watcher 或 HMR 规避问题。监听回归使用 `node --test apps/ai-game-creator-shell/scripts/vite-watch.test.mjs`,验证构建目录被排除、应用源码及根目录外的共享源码仍能触发变更;原生窗口首绘耗时另行实测,不把监听测试耗时当作启动性能指标。 + AGC `backend` 模式与 `all` / `api-server` 一样,必须同时探测 API 和 BgFilter worker 端口,漂移后的 worker 地址同时传给 API、worker 和 readiness 检查。不能因为旧 worker 的 `/readyz` 可访问,就把新启动失败的同端口 worker 视为就绪;AGC 前端会等待完整配套后端,worker 失败可能最终表现为 Tauri 等待前端 180 秒超时。 `npm run agc` 外层启动器先执行 `agc:serve` 并等待前端与配套后端就绪,再启动 Tauri,同时清空本次 CLI 的 `beforeDevCommand`,避免重复拉起服务和把数据库发布时间计入 Tauri 的 180 秒前端等待。准备阶段最多等待 660 秒(后端门禁仍为 600 秒),退出时清理本次启动的服务树,不停止复用的服务。AGC 自动发布显式使用 `--preserve-database`,schema 冲突须人工确认迁移,不自动清空数据。 From 7edc452c6ac615f074a627069b480d3a5dfe5eb3 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 18:08:38 +0800 Subject: [PATCH 02/16] =?UTF-8?q?=E9=87=8D=E6=9E=84AGC=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E8=8F=9C=E5=8D=95=E4=B8=8E=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=EF=BC=88#409=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主菜单保留前五项,其余操作通过向上展开的更多菜单访问 类型与信息入口下沉资源卡片,并复用共享画布角标 补充菜单键盘、滚轮归属、换选及资源操作链路回归 同步方案A规范与待人工验收记录 --- .../resourceCanvasFocusModel.ts | 2 +- apps/ai-game-creator-shell/src/styles.css | 27 -- .../src/view/project-development/index.tsx | 90 +++-- .../tests/appSurface/home.suite.ts | 4 +- .../appSurface/project-development.suite.ts | 26 +- .../projectResourceLiveIntegration.test.tsx | 9 +- .../resourceCanvasFloatingDismiss.test.tsx | 2 + .../tests/resourceVersionReplacement.test.tsx | 97 ++++- .../【实施计划】AGC资源菜单收纳-2026-09-17.md | 10 + .../【里程碑】AGC资源菜单收纳-2026-09-17.md | 31 ++ .../shared-memory/team-conventions.md | 1 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 + .../components/CanvasCardCornerActions.tsx | 67 ++++ .../src/components/OverflowActions.test.tsx | 206 ++++++++++ .../shared/src/components/OverflowActions.tsx | 237 ++++++++++++ packages/shared/src/components/index.ts | 2 + packages/shared/src/components/styles.css | 123 ++++++ ...ageCanvasSelectedLayerToolbarView.test.tsx | 44 +++ .../ImageCanvasSelectedLayerToolbarView.tsx | 359 +++++++++--------- .../ImageCanvasWorldView.test.tsx | 11 +- .../image-editor/ImageCanvasWorldView.tsx | 57 +-- vitest.config.ts | 7 + 22 files changed, 1068 insertions(+), 346 deletions(-) create mode 100644 docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md create mode 100644 docs/project-memory/plans/【里程碑】AGC资源菜单收纳-2026-09-17.md create mode 100644 packages/shared/src/components/CanvasCardCornerActions.tsx create mode 100644 packages/shared/src/components/OverflowActions.test.tsx create mode 100644 packages/shared/src/components/OverflowActions.tsx diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts index 92ba12136..a61125c41 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts @@ -49,7 +49,7 @@ export function isResourceCanvasPanTarget( return Boolean( target.closest('.game-resource-card') && !target.closest( - 'button:not(.game-resource-card-select), input, textarea, select, a, audio, video', + 'button:not(.game-resource-card-select), [role="button"], input, textarea, select, a, audio, video', ), ); } diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 613ec51e4..ed82b7507 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -7494,33 +7494,6 @@ iframe.preview-frame { visibility: visible; } -.game-resource-card-type-badge { - position: absolute; - top: 8px; - right: 8px; - z-index: 2; - display: inline-flex; - max-width: calc(100% - 16px); - align-items: center; - min-width: 0; - padding: 4px 8px; - overflow: hidden; - border: 1px solid rgb(255 255 255 / 72%); - border-radius: 999px; - background: rgb(75 48 38 / 84%); - color: #fff; - font-size: 10px; - font-weight: 900; - line-height: 1; - letter-spacing: 0.02em; - pointer-events: none; - text-overflow: ellipsis; - white-space: nowrap; - box-shadow: 0 8px 18px rgb(96 62 47 / 20%); - transform: scale(var(--genarrative-image-canvas-inverse-scale, 1)); - transform-origin: top right; -} - /* 替换血缘标注(本次会话内有效):源素材卡「已被 … 替换」/ 替换素材卡「替换自 …」。 左下角是卡片上唯一空闲的角(右上角标是类型、右下是媒体播放钮),用「当前版本」同一支橙色 把这条关系与光环联系起来。 */ 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 e660c2c73..cd0e4276a 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 @@ -15,6 +15,7 @@ import { SelectionOverlay, } from '@genarrative/image-canvas-react'; import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; +import { CanvasCardCornerActions } from '@genarrative/shared/components'; import { AtSign, Crosshair, @@ -42,7 +43,6 @@ import { Replace, RotateCcw, Search, - Shapes, SlidersHorizontal, Sparkles, Trash2, @@ -695,6 +695,9 @@ const ResourceCard = memo(function ResourceCard({ cardSize, activeMediaIdentity, onSelect, + onShowInfo, + onChangeType, + infoPressed, onPointerDown, onPointerMove, onPointerUp, @@ -724,6 +727,9 @@ const ResourceCard = memo(function ResourceCard({ cardSize: ResourceCanvasCardSize; activeMediaIdentity: string | null; onSelect: (resourceId: string, options?: { append?: boolean }) => void; + onShowInfo: (resource: ProjectResource) => void; + onChangeType: (assetId: string) => void; + infoPressed: boolean; onPointerDown: ( event: ReactPointerEvent, resource: ProjectResource, @@ -1072,13 +1078,19 @@ const ResourceCard = memo(function ResourceCard({ - - {cardTypeLabel} - + onChangeType(resource.manifestAssetId!) + : undefined + } + onInfoClick={() => onShowInfo(resource)} + /> {lineage ? ( // 文字是给人看的关系,`data-resource-lineage` 是给端到端验收的稳定判据 // (稳定 id 见卡上的 `data-resource-replaced-by` / `data-resource-replacement-of`)。 @@ -1604,7 +1616,12 @@ export default function ProjectDevelopmentView({ const [characterAnimationPanel, setCharacterAnimationPanel] = useState(null); /** 画布上的只读信息浮层(「信息」动作的落点),与运行页签的信息面板同源。 */ - const [resourceInfoPanelOpen, setResourceInfoPanelOpen] = useState(false); + const [resourceInfoResourceId, setResourceInfoResourceId] = useState< + string | null + >(null); + const resourceInfoPanelOpen = + resourceInfoResourceId !== null && + selectedResourceIds[0] === resourceInfoResourceId; const [resourceCanvasMarquee, setResourceCanvasMarquee] = useState(null); /** 资源卡组织操作历史:只回滚布局坐标,不回滚素材。 */ @@ -1909,7 +1926,7 @@ export default function ProjectDevelopmentView({ */ const clearResourceCanvasFocus = useCallback(() => { setSelectedResourceIds([]); - setResourceInfoPanelOpen(false); + setResourceInfoResourceId(null); if (!canDismissResourceCanvasQuickEdit(quickEditPanelRef.current)) { return; } @@ -2333,7 +2350,9 @@ export default function ProjectDevelopmentView({ * 是因为选中本身有多个清空入口(清焦点、切视图、切项目)。 */ useEffect(() => { - setResourceInfoPanelOpen(false); + setResourceInfoResourceId((current) => + current === selectedResourceId ? current : null, + ); }, [selectedResourceId]); const projectVersions = useMemo( () => manifest.versions ?? [], @@ -6262,6 +6281,16 @@ export default function ProjectDevelopmentView({ const showRunUnavailableHint = !runAvailable && !uiEditorRoute; + const showResourceCardInfo = useCallback( + (resource: ProjectResource) => { + handleResourceSelect(resource.id); + setResourceInfoResourceId((current) => + current === resource.id ? null : resource.id, + ); + }, + [handleResourceSelect], + ); + const renderResourceBookCard = useCallback( ( resource: ProjectResource, @@ -6319,6 +6348,9 @@ export default function ProjectDevelopmentView({ } activeMediaIdentity={activeCardMediaIdentity} onSelect={handleResourceSelect} + onShowInfo={showResourceCardInfo} + onChangeType={setResourceTypeAssetId} + infoPressed={resourceInfoResourceId === resource.id} onPointerDown={(event) => handleResourceCardPointerDown( event, @@ -6350,6 +6382,8 @@ export default function ProjectDevelopmentView({ handleResourceCardPointerMove, handleResourceCardPointerUp, handleResourceSelect, + showResourceCardInfo, + resourceInfoResourceId, resourceCardDragPreview, resourceCardPreviews, resourceReplacementLineageBadgeMap, @@ -7976,6 +8010,7 @@ export default function ProjectDevelopmentView({ ) || selectedResourceOpensUiEditor) ? ( 引用 ) : null} - {selectedResource ? ( - } - onClick={() => - setResourceInfoPanelOpen((open) => !open) - } - > - 信息 - - ) : null} {selectedResource?.manifestAssetId ? ( 编辑标签 ) : null} - {selectedResource?.manifestAssetId ? ( - } - onClick={() => - setResourceTypeAssetId( - selectedResource.manifestAssetId, - ) - } - > - 素材类型 - - ) : null} {selectedResource?.manifestAssetId ? ( setResourceInfoPanelOpen(false)} + onClose={() => setResourceInfoResourceId(null)} /> ) : null} 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..6de24b699 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -338,7 +338,7 @@ export function registerClientHomeTests() { expect(await findResourceSelectButton('live-hero.png')).not.toBeNull(); await openResourceBookCategory('项目版本'); expect( - await screen.findByRole('button', { name: /版本 1/ }), + await findResourceSelectButton('版本 1'), ).not.toBeNull(); expect(runButton.getAttribute('data-unavailable')).toBeNull(); await waitFor(() => { @@ -509,7 +509,7 @@ export function registerClientHomeTests() { ).not.toBeNull(); await openResourceBookCategory('项目版本'); expect( - await screen.findByRole('button', { name: /版本 1/ }), + await findResourceSelectButton('版本 1'), ).not.toBeNull(); expect(runButton.getAttribute('data-unavailable')).toBeNull(); await waitFor(() => { 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 636ec3d74..f4b95519e 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 @@ -3817,21 +3817,10 @@ export function registerProjectWorkbenchFoundationTests() { ]; await openResourceBookCategory('角色与对象'); - fireEvent.click(await findResourceSelectButton('hero.png')); - const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); - const toolbarLabels = within(toolbar) - .getAllByRole('button') - .map((button) => button.getAttribute('aria-label') ?? ''); - const infoButton = within(toolbar).getByRole('button', { name: '信息' }); - // 位置固定在「引用」之后、「编辑标签」之前:工具条上的顺序即功能顺序。 - expect(toolbarLabels.indexOf('信息')).toBeGreaterThan( - toolbarLabels.indexOf('引用资源 hero.png'), - ); - expect(toolbarLabels.indexOf('信息')).toBeLessThan( - toolbarLabels.indexOf('编辑标签'), - ); + const infoButton = await screen.findByRole('button', { name: '查看hero.png资源信息' }); expect(infoButton.getAttribute('aria-pressed')).toBe('false'); + // 未选中的卡片直接打开信息,不被选中变化 effect 立即关闭。 fireEvent.click(infoButton); const canvasPanel = await screen.findByRole('dialog', { name: '资源信息', @@ -3863,11 +3852,11 @@ export function registerProjectWorkbenchFoundationTests() { // Esc 与快速编辑浮层同一口径:既收浮层也清选中,整个工具条一起收起。 fireEvent.click(await findResourceSelectButton('hero.png')); - const reopenedToolbar = await screen.findByRole('toolbar', { + await screen.findByRole('toolbar', { name: '图片工具栏', }); fireEvent.click( - within(reopenedToolbar).getByRole('button', { name: '信息' }), + screen.getByRole('button', { name: '查看hero.png资源信息' }), ); expect( await screen.findByRole('dialog', { name: '资源信息' }), @@ -4075,10 +4064,7 @@ export function registerProjectWorkbenchFoundationTests() { ), ).toBe(false); // 音频资源的选中工具条复用美术画布的音频分支(aria-label「素材工具栏」), - // 并且只渲染宿主编排层真实接通的动作:「引用」(从卡片挪进工具条的引用入口, - // 资源卡上的圆钮已删除)「信息」(只读信息浮层)「编辑标签」(面板只编辑 manifest - // `assets[].tags`)「素材类型」(功能分类的独立入口,与标签面板分家)「重命名」 - // 已接面板「删除素材」(破坏性动作放末位,前置共享分隔线,复用素材删除流程) + // 并且只渲染宿主编排层真实接通的五个动作;信息与类型由卡片角标承接。 // 「导出」复用资源面板同一条落盘链路,「改造」在宿主编排层仍是空回调, // 不能再渲染成点了没反应的按钮。 const audioToolbar = screen.getByRole('toolbar', { @@ -4093,9 +4079,7 @@ export function registerProjectWorkbenchFoundationTests() { .map((button) => button.getAttribute('aria-label')), ).toEqual([ '引用资源 bgm.mp3', - '信息', '编辑标签', - '素材类型', '重命名', '导出', '删除素材', diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx index 48938bee7..4234d82b8 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx @@ -1914,9 +1914,8 @@ describe('project resource live canvas integration', () => { await openResourceBookCategory('角色与对象'); expect(await cardBadgeText('hero.png')).toBe('角色与对象'); - fireEvent.click(await findResourceSelectButton('hero.png')); - const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); - fireEvent.click(within(toolbar).getByRole('button', { name: '素材类型' })); + // 未选中资源也能直接从卡片类型角标进入,不依赖工具栏存在。 + fireEvent.click(screen.getByRole('button', { name: '素材类型:hero.png' })); const dialog = await screen.findByRole('dialog', { name: '设置素材类型', }); @@ -1993,7 +1992,7 @@ describe('project resource live canvas integration', () => { const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); // 类型面板:先点外部(DOM 上落在画布管理区之外),浮层与选中都不受影响。 - fireEvent.click(within(toolbar).getByRole('button', { name: '素材类型' })); + fireEvent.click(screen.getByRole('button', { name: '素材类型:hero.png' })); await screen.findByRole('dialog', { name: '设置素材类型' }); fireEvent.click(document.body); expect(screen.getByRole('dialog', { name: '设置素材类型' })).not.toBeNull(); @@ -2073,7 +2072,7 @@ describe('project resource live canvas integration', () => { await openResourceBookCategory('角色与对象'); fireEvent.click(await findResourceSelectButton('hero.png')); const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); - fireEvent.click(within(toolbar).getByRole('button', { name: '信息' })); + fireEvent.click(screen.getByRole('button', { name: '查看hero.png资源信息' })); const infoPanel = await screen.findByRole('dialog', { name: '资源信息' }); // 分类值本身仍是只读文本(`dd` 里只有值,入口按钮在它外面)。 diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx index 22018450b..98ee615bc 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx @@ -48,6 +48,7 @@ describe('resourceCanvasFocusModel', () => {
+ 信息
@@ -60,6 +61,7 @@ describe('resourceCanvasFocusModel', () => { } for (const id of [ 'play', + 'corner', 'video', 'input', 'editor', diff --git a/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx b/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx index 380692d66..cfbc324f9 100644 --- a/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx @@ -375,6 +375,13 @@ function renderReplacementWorkbench(options: RenderOptions = {}) { return { invoke, onActiveVersionChange, onPlay, onManifestChange }; } +function toolbarAction(toolbar: HTMLElement, name: string) { + const visible = within(toolbar).queryByRole('button', { name }); + if (visible) return visible; + fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' })); + return within(screen.getByRole('group', { name: '更多操作' })).getByRole('button', { name }); +} + async function selectCardAndOpenToolbar(label: string) { await waitFor(() => expect( @@ -538,13 +545,61 @@ function installResourceCardIntersectionObserver() { } describe('版本级资源替换', () => { + it('更多浮层滚轮不平移画布,Escape 只收菜单且换选不残留', async () => { + renderReplacementWorkbench(); + const toolbar = await selectCardAndOpenToolbar('legacy.png'); + fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' })); + const menu = screen.getByRole('group', { name: '更多操作' }); + const viewport = () => document.querySelector('[data-resource-viewport]') + ?.getAttribute('data-resource-viewport'); + const before = viewport(); + expect(before).toBeTruthy(); + const wheel = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 120 }); + act(() => { menu.dispatchEvent(wheel); }); + expect(wheel.defaultPrevented).toBe(false); + expect(viewport()).toBe(before); + + fireEvent.keyDown(document.body, { key: 'Escape' }); + expect(screen.queryByRole('group', { name: '更多操作' })).toBeNull(); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBe(toolbar); + fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' })); + fireEvent.click(await findResourceSelectButton('late.png')); + expect(screen.queryByRole('group', { name: '更多操作' })).toBeNull(); + + const scene = document.querySelector('.game-resource-book-scene')!; + act(() => { + scene.dispatchEvent(new WheelEvent('wheel', { + bubbles: true, cancelable: true, deltaY: 120, clientX: 90, clientY: 70, + })); + }); + expect(viewport()).not.toBe(before); + }); + + it('信息从未选中卡打开并跟随资源身份,普通换选会关闭', async () => { + renderReplacementWorkbench(); + await selectCardAndOpenToolbar('legacy.png'); + const lateInfo = screen.getByRole('button', { name: '查看late.png资源信息' }); + fireEvent.pointerDown(lateInfo, { button: 0 }); + fireEvent.click(lateInfo); + const panel = screen.getByRole('dialog', { name: '资源信息' }); + expect(within(panel).getByText('late.png')).toBeTruthy(); + expect(lateInfo.getAttribute('aria-pressed')).toBe('true'); + fireEvent.click(screen.getByRole('button', { name: '查看legacy.png资源信息' })); + const switched = screen.getByRole('dialog', { name: '资源信息' }); + expect(within(switched).getByText('legacy.png')).toBeTruthy(); + expect(within(switched).queryByText('late.png')).toBeNull(); + fireEvent.click(await findResourceSelectButton('late.png')); + expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull(); + }); + it('入口只在素材被当前版本绑定时渲染,未绑定素材不给假按钮', async () => { const { invoke } = renderReplacementWorkbench(); // 未被初始版本绑定的素材(版本创建之后才登记):工具条照常出现,但没有「替换素材」。 const lateToolbar = await selectCardAndOpenToolbar('late.png'); + fireEvent.mouseEnter(within(lateToolbar).getByRole('button', { name: '更多' })); expect( - within(lateToolbar).queryByRole('button', { name: '替换素材' }), + screen.queryByRole('button', { name: '替换素材' }), ).toBeNull(); expect( within(lateToolbar).getByRole('button', { name: '快速编辑' }), @@ -560,7 +615,7 @@ describe('版本级资源替换', () => { // 被当前版本绑定的素材:入口出现。 const sourceToolbar = await selectCardAndOpenToolbar('legacy.png'); expect( - within(sourceToolbar).getByRole('button', { name: '替换素材' }), + toolbarAction(sourceToolbar, '替换素材'), ).not.toBeNull(); }); @@ -569,7 +624,7 @@ describe('版本级资源替换', () => { renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); await waitFor(() => expect( @@ -675,7 +730,7 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -709,7 +764,7 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); await waitFor(() => expect( @@ -735,7 +790,7 @@ describe('版本级资源替换', () => { const { invoke } = renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -787,7 +842,7 @@ describe('版本级资源替换', () => { ).length; const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -837,8 +892,11 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - const toolbarLabels = within(toolbar) - .getAllByRole('button') + const deleteButton = toolbarAction(toolbar, '删除素材'); + const toolbarLabels = [ + ...within(toolbar).getAllByRole('button'), + ...within(screen.getByRole('group', { name: '更多操作' })).getAllByRole('button'), + ] .map((button) => button.getAttribute('aria-label') ?? ''); // 末位:在最后一个非破坏性动作(替换素材)之后、共享导出按钮之前。 expect(toolbarLabels.indexOf('删除素材')).toBeGreaterThan( @@ -848,9 +906,6 @@ describe('版本级资源替换', () => { toolbarLabels.indexOf('导出'), ); // 与前面隔开:紧邻的前一个兄弟就是共享工具条那套分隔线,不是新造的分隔符。 - const deleteButton = within(toolbar).getByRole('button', { - name: '删除素材', - }); const divider = deleteButton.previousElementSibling; expect(divider?.getAttribute('class')).toMatch( /(?:image-canvas-editor__floating-toolbar-divider|genarrative-image-canvas__chrome-button)/, @@ -914,7 +969,7 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '删除素材' })); + fireEvent.click(toolbarAction(toolbar, '删除素材')); const dialog = await screen.findByRole('dialog', { name: '确认删除资源' }); fireEvent.click( within(dialog).getByRole('checkbox', { @@ -976,7 +1031,7 @@ describe('版本级资源替换', () => { ).toBeNull(); // 同一条工具条仍在(只读动作不受 manifest 身份影响),证明不是"整条工具条没渲染"。 expect( - within(toolbar).getByRole('button', { name: '信息' }), + screen.getByRole('button', { name: '查看草稿.png资源信息' }), ).not.toBeNull(); }); @@ -1056,7 +1111,7 @@ describe('版本级资源替换', () => { const { invoke } = renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1084,7 +1139,7 @@ describe('版本级资源替换', () => { const { invoke, onManifestChange } = renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1155,7 +1210,7 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1238,7 +1293,7 @@ describe('版本级资源替换', () => { renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1274,7 +1329,7 @@ describe('版本级资源替换', () => { const { invoke } = renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1357,7 +1412,7 @@ describe('版本级资源替换', () => { // 第一次:legacy → final。 const legacyToolbar = await selectCardAndOpenToolbar('legacy.png'); fireEvent.click( - within(legacyToolbar).getByRole('button', { name: '替换素材' }), + toolbarAction(legacyToolbar, '替换素材'), ); let dialog = await screen.findByRole('dialog', { name: '选择替换素材', @@ -1377,7 +1432,7 @@ describe('版本级资源替换', () => { // 第二次:final → final.webp(同一个工作台会话内)。 const finalToolbar = await selectCardAndOpenToolbar('final.png'); fireEvent.click( - within(finalToolbar).getByRole('button', { name: '替换素材' }), + toolbarAction(finalToolbar, '替换素材'), ); dialog = await screen.findByRole('dialog', { name: '选择替换素材' }); fireEvent.click( 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..96333cf43 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md @@ -0,0 +1,10 @@ +# AGC 资源菜单收纳实施计划 + +对应:[里程碑](./【里程碑】AGC资源菜单收纳-2026-09-17.md),Issue #409,产品已确认方案 A。 + +1. 在 shared 扩展通用操作收纳及卡片角标控件;共用工具栏只给 AGC 开启 5 项限制,Web 卡片迁移共用角标而不改现有回调。 +2. AGC 卡片承接类型和信息,保留当前面板与命令链;信息使用资源身份防止换选竞态。 +3. 补工具栏/工作台定向回归,检查禁用、移入、Escape、换选和卡片事件边界。 +4. 并行执行定向 Vitest、AGC 类型检查、编码与文档索引检查,再自审整体调用链。 + +风险:portal 浮层点击外部判定、缩放角标与拖拽冲突、原测试依赖完整工具栏。回滚仅撤销本分支 UI 与文档修改;无数据迁移。首个检查点为组件用例通过,第二个为工作台集成与类型检查。真实客户端未测则明确保留待验收状态。 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..1c2ac43c9 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC资源菜单收纳-2026-09-17.md @@ -0,0 +1,31 @@ +# AGC 资源菜单收纳 + +- Version: 1 +- Status: implemented,本地自动化通过,待真实客户端验收 +- Date: 2026-09-17 +- Parent Spec: ../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md + +## 范围与评审 + +仅调整前端入口和临时浮层状态,不修改资源命令、权限、持久化、后端或 Web 默认菜单行为。主菜单保留前 5 项,其余悬停/点击向上展开;类型与信息下沉卡片。产品已选定方案 A,边界与既有资源操作合同无冲突,按单里程碑实施。对应 Issue #409,已获得创建 Issue 与本地实施授权;推送、PR 与飞书写入仍需单独确认。 + +## 验收 + +- 动作顺序、禁用状态和回调保持一致,少于等于 5 项不出现更多。 +- 更多支持鼠标移入浮层、点击、键盘、外部关闭与视口约束。 +- 卡片类型、信息入口不触发拖拽;未选中卡直接看信息,换选不残留旧信息。 +- 定向组件与工作台测试、类型检查、编码/文档索引/diff 检查通过;真实客户端视觉和触摸板手感单独验收。 + +## 产品结论与验收待办 + +正式实现采用方案 A;方案 B 不进入工作台,A/B 演示只留在忽略目录供本地参考。 + +完整工作台回归的 2 项失败已定位为新增信息按钮导致“版本 1”模糊匹配重复,改为精确查询资源选中按钮,完整重跑通过。 + +## 验收证据 + +- `appSurface.test.ts`:450 项通过、20 项跳过。 +- 收纳/卡片、Web 工具栏/卡片、资源类型实时链路、替换/重命名、浮层判据、动作可用性:178 项通过;追加真实工作台「更多滚轮不平移画布、Escape 仅收菜单、换选收起」与「跨卡片信息身份」2 项通过。 +- 根目录类型检查与 AGC 类型检查(含 skill-pack / config 检查)通过;编码、文档索引与 diff 检查通过。 +- 浏览器中真实组件预览已检查上方展开、执行回调后关闭、卡片信息入口;预览仅用演示数据,不替代真实 AGC 客户端。 +- 剩余:真实客户端、原生保存对话框、触摸板操作人工验收。未推送,未创建 PR,未更新飞书。 diff --git a/docs/project-memory/shared-memory/team-conventions.md b/docs/project-memory/shared-memory/team-conventions.md index 98662a4e2..6f64a3000 100644 --- a/docs/project-memory/shared-memory/team-conventions.md +++ b/docs/project-memory/shared-memory/team-conventions.md @@ -16,6 +16,7 @@ ## 开发中 +- 画布卡片类型与信息角标共用 `CanvasCardCornerActions`;菜单收纳共用 `OverflowActions`,宿主决定展示数量和资源命令。AGC 选中菜单前 5 项直显,Web 默认不折叠;浮层 portal 继续接入现有画布关闭与滚轮归属判据。 - 修改范围保持聚焦;优先扩展现有系统、页面、组件、DTO 和脚本,不新建平行入口或业务真相。 - UI 开发优先复用现有公共组件;跨页面或跨端重复的视觉/交互模式应沉淀到 `packages/shared`,由现有页面迁移使用,禁止在业务页复制同类 UI。共享组件只承载通用表现与交互,不下沉领域规则、后端副作用或正式业务状态。 - AGC 当前 Agent 与策划 Agent 的消息层级共用 `packages/shared` 的 `AgentMessageContent`:正文使用 `body`,思考、中间输出与工具调用使用 `process`;宿主不按 Agent 类型重新定义过程字号和颜色,错误状态保留语义色。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index dec89f82b..5bb7d5633 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -15,6 +15,8 @@ ## 资源卡选中工具栏与导出 +- AGC 选中工具栏按既有动作顺序最多直接显示前 5 项(不计分隔线),剩余动作进入「更多」。悬停、点击及键盘均可展开独立纵向浮层,优先向上展开,窗口顶边空间不足时向下避让;浮层限制在窗口内,超高时自行滚动,不带动画布。动作执行、点击外部、Escape 或换选资源后关闭;禁用状态和原处理链路保持不变。Web 美术画布默认不折叠。 +- 「素材类型」与「信息」不占工具栏名额,改为资源卡右上角的类型标签和信息圆钮,与 Web 美术画布共用卡片控件。未选中卡片可直接打开信息;类型入口仅对 manifest 资产可用。控件不触发卡片拖拽或多选,信息面板仍复用运行页签的字段。 - 共享选中工具栏按实际显示的快速编辑、编辑动作、改造、导出与宿主动作组生成分隔线;空组不产生分隔线,不依赖宿主 CSS 隐藏重复线。 - AGC 所有具有本地文件路径的素材都显示带文字的「导出」按钮,位于工具栏末组的「删除素材」之前,两者之间不插入分隔线;「重命名」继续保留在前面的常规动作组。工具栏宽度上限为 `min(92vw, 800px)`,窄屏仍可横向滚动。图片、视频、音频、动画、UI、文档及其它文件共用 `isResourceCanvasExportable`,不按媒体类型限制导出;无文件路径及虚拟项目版本不提供文件导出入口。 - 导出继续复用 `saveProjectResourcesToDisk`:原生保存对话框选择路径,`save_local_project_asset_file` 复制原始文件字节,不转图片、不重编码、不另建 IPC。后端继续校验源文件、敏感路径和目标路径;取消不写文件,失败通过工作台提示。 diff --git a/packages/shared/src/components/CanvasCardCornerActions.tsx b/packages/shared/src/components/CanvasCardCornerActions.tsx new file mode 100644 index 000000000..4199f474c --- /dev/null +++ b/packages/shared/src/components/CanvasCardCornerActions.tsx @@ -0,0 +1,67 @@ +import { Info } from 'lucide-react'; +import type { CSSProperties, Ref } from 'react'; +import { PlatformIconButton } from './PlatformIconButton'; + +/** 画布卡片共用的类型标签与信息入口,不承接资源业务状态。 */ +export function CanvasCardCornerActions({ + kindLabel, + kindAriaLabel, + kindClassName, + infoLabel, + style, + kindRef, + onKindClick, + onInfoClick, + infoPressed, +}: { + kindLabel?: string | null; + kindAriaLabel?: string; + kindClassName?: string; + infoLabel: string; + style?: CSSProperties; + kindRef?: Ref; + onKindClick?: () => void; + onInfoClick: () => void; + infoPressed?: boolean; +}) { + return ( + event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + {kindLabel ? ( + { + if (onKindClick && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault(); + onKindClick(); + } + }} + > + {kindLabel} + + ) : null} + + ); +} diff --git a/packages/shared/src/components/OverflowActions.test.tsx b/packages/shared/src/components/OverflowActions.test.tsx new file mode 100644 index 000000000..5f25b8d3a --- /dev/null +++ b/packages/shared/src/components/OverflowActions.test.tsx @@ -0,0 +1,206 @@ +/** @vitest-environment jsdom */ +import { + cleanup, + fireEvent, + render, + screen, + within, +} from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { OverflowActions } from './OverflowActions'; +import { CanvasCardCornerActions } from './CanvasCardCornerActions'; + +afterEach(cleanup); +describe('操作收纳与卡片角标', () => { + it('具名入口可收纳全部动作,并优先向上展开', () => { + render( + + + + , + ); + const trigger = screen.getByRole('button', { name: '素材处理' }); + expect(screen.getAllByRole('button')).toHaveLength(1); + vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({ + left: 200, + right: 300, + top: 350, + bottom: 380, + width: 100, + height: 30, + x: 200, + y: 350, + toJSON: () => ({}), + }); + const height = vi + .spyOn(HTMLElement.prototype, 'scrollHeight', 'get') + .mockReturnValue(100); + fireEvent.mouseEnter(trigger); + const group = screen.getByRole('group', { name: '素材处理操作' }); + expect(parseFloat(group.style.top)).toBeLessThan(350); + expect( + within(group) + .getAllByRole('button') + .map((b) => b.textContent), + ).toEqual(['快速编辑', '改造']); + fireEvent.keyDown(document.body, { key: 'Escape' }); + expect(screen.queryByRole('group')).toBeNull(); + height.mockRestore(); + vi.restoreAllMocks(); + }); + it('前五项可见,跳过分隔符和空 fragment,悬停展示剩余项并执行原回调', () => { + const click = vi.fn(); + render( + + <> + + , + ); + expect(screen.getAllByRole('button')).toHaveLength(6); + expect(screen.queryByText('六')).toBeNull(); + fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' })); + const group = screen.getByRole('group', { name: '更多操作' }); + expect( + within(group) + .getAllByRole('button') + .map((b) => b.textContent), + ).toEqual(['六', '七']); + expect((screen.getByText('七') as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(screen.getByText('六')); + expect(click).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('group')).toBeNull(); + }); + it('悬停后点击仍展开、移入浮层不消失、外部关闭,Escape 恢复焦点', () => { + vi.useFakeTimers(); + render( + + + + , + ); + const more = screen.getByRole('button', { name: '更多' }); + fireEvent.mouseEnter(more); + fireEvent.click(more); + fireEvent.mouseLeave(more); + fireEvent.mouseEnter(screen.getByRole('group')); + vi.advanceTimersByTime(200); + expect(screen.getByText('二')).toBeTruthy(); + fireEvent.keyDown(screen.getByText('二'), { key: 'Escape' }); + expect(screen.queryByRole('group')).toBeNull(); + expect(document.activeElement).toBe(more); + fireEvent.click(more); + fireEvent.pointerDown(document.body); + expect(screen.queryByRole('group')).toBeNull(); + vi.useRealTimers(); + }); + it('不溢出不显示更多,默认保持 Web 原样', () => { + const view = render( + + + + , + ); + expect(screen.queryByText('更多')).toBeNull(); + view.rerender( + + {Array.from({ length: 10 }, (_, i) => ( + + ))} + , + ); + expect(screen.getAllByRole('button')).toHaveLength(10); + }); + it('动作减少至不溢出后关闭浮层,恢复动作不会自动重开', () => { + const view = render( + + + + , + ); + fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' })); + expect(screen.getByRole('group')).toBeTruthy(); + view.rerender( + + + , + ); + expect(screen.queryByRole('group')).toBeNull(); + view.rerender( + + + + , + ); + expect(screen.queryByRole('group')).toBeNull(); + }); + it('浮层在窗口右下边界向上展开,方向键跳过禁用项', () => { + render( + + + + + + , + ); + const more = screen.getByRole('button', { name: '更多' }); + vi.spyOn(more, 'getBoundingClientRect').mockReturnValue({ + left: window.innerWidth - 35, + right: window.innerWidth, + top: window.innerHeight - 40, + bottom: window.innerHeight - 10, + width: 35, + height: 30, + x: 0, + y: 0, + toJSON: () => ({}), + }); + // jsdom 没有真实布局,提供浮层测量以验证向上定位。 + const height = vi + .spyOn(HTMLElement.prototype, 'scrollHeight', 'get') + .mockReturnValue(200); + fireEvent.click(more); + const panel = screen.getByRole('group'); + expect(parseFloat(panel.style.top)).toBeLessThan(window.innerHeight - 40); + screen.getByText('二').focus(); + fireEvent.keyDown(screen.getByText('二'), { key: 'ArrowDown' }); + expect(document.activeElement).toBe(screen.getByText('四')); + fireEvent.keyDown(screen.getByText('四'), { key: 'Home' }); + expect(document.activeElement).toBe(screen.getByText('二')); + height.mockRestore(); + vi.restoreAllMocks(); + }); + it('卡片角标阻断指针和键盘冒泡,类型及信息分别执行', () => { + const parent = vi.fn(), + kind = vi.fn(), + info = vi.fn(); + render( +
+ +
, + ); + const label = screen.getByRole('button', { name: '素材类型' }); + fireEvent.pointerDown(label); + fireEvent.click(label); + fireEvent.keyDown(label, { key: 'Enter' }); + fireEvent.click(screen.getByRole('button', { name: '资源信息' })); + expect(kind).toHaveBeenCalledTimes(2); + expect(info).toHaveBeenCalledOnce(); + expect(parent).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/components/OverflowActions.tsx b/packages/shared/src/components/OverflowActions.tsx new file mode 100644 index 000000000..d79421308 --- /dev/null +++ b/packages/shared/src/components/OverflowActions.tsx @@ -0,0 +1,237 @@ +import { + Children, + cloneElement, + Fragment, + isValidElement, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from 'react'; +import { createPortal } from 'react-dom'; + +type ActionProps = { children?: ReactNode; 'aria-hidden'?: boolean | 'true' }; + +function flatten(nodes: ReactNode, prefix = ''): ReactNode[] { + return Children.toArray(nodes).flatMap((node, index) => + isValidElement(node) && node.type === Fragment + ? flatten(node.props.children, `${prefix}${index}.`) + : [ + isValidElement(node) + ? cloneElement(node, { key: `${prefix}${index}` }) + : node, + ], + ); +} + +function isDivider(node: ReactNode) { + return ( + isValidElement(node) && + (node.props['aria-hidden'] === true || node.props['aria-hidden'] === 'true') + ); +} + +/** 只负责展示收纳;动作权限、禁用与执行仍由调用方提供。 */ +export function OverflowActions({ + children, + maxVisible = Infinity, + label = '更多', +}: { + children: ReactNode; + maxVisible?: number; + label?: string; +}) { + const [open, setOpen] = useState(false); + const [position, setPosition] = useState({ left: 8, top: 8, maxHeight: 320 }); + const trigger = useRef(null); + const panel = useRef(null); + const timer = useRef | null>(null); + const id = useId(); + const limit = Number.isFinite(maxVisible) + ? Math.max(0, Math.floor(maxVisible)) + : Infinity; + const nodes = flatten(children); + let count = 0; + const split = nodes.findIndex((node) => !isDivider(node) && ++count > limit); + const primary = split < 0 ? nodes : nodes.slice(0, split); + while (primary.length && isDivider(primary[primary.length - 1])) + primary.pop(); + const overflow = + split < 0 ? [] : nodes.slice(split).filter((node) => !isDivider(node)); + useEffect(() => { + if (overflow.length === 0) setOpen(false); + }, [overflow.length]); + const cancelClose = () => { + if (timer.current !== null) clearTimeout(timer.current); + timer.current = null; + }; + const show = () => { + cancelClose(); + setOpen(true); + }; + const scheduleClose = () => { + cancelClose(); + timer.current = setTimeout(() => { + if (!panel.current?.contains(document.activeElement)) setOpen(false); + }, 150); + }; + useEffect( + () => () => { + if (timer.current !== null) clearTimeout(timer.current); + }, + [], + ); + useLayoutEffect(() => { + if (!open || !overflow.length) return; + const update = () => { + const anchor = trigger.current?.getBoundingClientRect(); + if (!anchor) return; + const width = panel.current?.getBoundingClientRect().width ?? 200; + const height = panel.current?.scrollHeight ?? 320; + const below = window.innerHeight - anchor.bottom - 12; + const above = anchor.top - 12; + // 优先上展,留出卡片预览;窗口顶边空间不足时才向下避让。 + const down = above < 80 && below > above; + const maxHeight = Math.max(40, Math.min(360, down ? below : above)); + setPosition({ + left: Math.max( + 8, + Math.min(anchor.right - width, window.innerWidth - width - 8), + ), + top: down + ? anchor.bottom + 4 + : Math.max(8, anchor.top - Math.min(height, maxHeight) - 4), + maxHeight, + }); + }; + update(); + window.addEventListener('resize', update); + window.addEventListener('scroll', update, true); + return () => { + window.removeEventListener('resize', update); + window.removeEventListener('scroll', update, true); + }; + }, [open, overflow.length, children]); + useEffect(() => { + if (!open) return; + const outside = (event: PointerEvent) => { + if ( + event.target instanceof Node && + !trigger.current?.contains(event.target) && + !panel.current?.contains(event.target) + ) { + setOpen(false); + } + }; + const escape = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented) return; + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + trigger.current?.focus(); + }; + document.addEventListener('pointerdown', outside); + document.addEventListener('keydown', escape); + return () => { + document.removeEventListener('pointerdown', outside); + document.removeEventListener('keydown', escape); + }; + }, [open]); + if (!overflow.length) return <>{children}; + return ( + <> + {primary} + + {open + ? createPortal( +
event.stopPropagation()} + onClick={(event) => { + event.stopPropagation(); + if ((event.target as Element).closest('button:not(:disabled)')) + setOpen(false); + }} + onBlur={(event) => { + if ( + !event.currentTarget.contains(event.relatedTarget) && + event.relatedTarget !== trigger.current + ) + setOpen(false); + }} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + trigger.current?.focus(); + } + if ( + ['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key) + ) { + event.preventDefault(); + const buttons = Array.from( + event.currentTarget.querySelectorAll( + 'button:not(:disabled)', + ), + ); + const index = buttons.indexOf( + document.activeElement as HTMLButtonElement, + ); + const next = + event.key === 'Home' + ? 0 + : event.key === 'End' + ? buttons.length - 1 + : (index + + (event.key === 'ArrowDown' ? 1 : -1) + + buttons.length) % + buttons.length; + buttons[next]?.focus(); + } + }} + > + {overflow} +
, + document.body, + ) + : null} + + ); +} diff --git a/packages/shared/src/components/index.ts b/packages/shared/src/components/index.ts index f2b1cee94..327a40757 100644 --- a/packages/shared/src/components/index.ts +++ b/packages/shared/src/components/index.ts @@ -220,6 +220,8 @@ export { Textarea } from './ui/textarea'; // existing application adapters while keeping the public API product-neutral. export type { AgentMessageTone } from './AgentMessageContent'; export { AgentMessageContent } from './AgentMessageContent'; +export { CanvasCardCornerActions } from './CanvasCardCornerActions'; +export { OverflowActions } from './OverflowActions'; export type { ButtonProps as PlatformButtonProps, SwitchProps as PlatformSwitchProps, diff --git a/packages/shared/src/components/styles.css b/packages/shared/src/components/styles.css index 2c969af7d..aa9301eec 100644 --- a/packages/shared/src/components/styles.css +++ b/packages/shared/src/components/styles.css @@ -1310,3 +1310,126 @@ textarea.genarrative-ui-text-field__control { border-radius: 1.25rem 1.25rem 0 0; } } +/* 画布卡片角标:缩放由宿主传入,两个入口保持一致的视觉尺寸。 */ +.shared-canvas-card-corners { + position: absolute; + top: 6px; + right: 6px; + z-index: 3; + display: flex; + align-items: center; + gap: 4px; + max-width: calc(100% - 12px); + transform: scale( + var( + --image-canvas-editor-inverse-scale, + var(--genarrative-image-canvas-inverse-scale, 1) + ) + ); + transform-origin: top right; +} +.shared-canvas-card-kind { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 4px 8px; + border: 1px solid rgb(255 255 255 / 72%); + border-radius: 999px; + background: rgb(199 101 61 / 92%); + color: #fff; + font-size: 11px; + font-weight: 850; + line-height: 1; +} +.shared-canvas-card-kind[role='button'] { + cursor: pointer; +} +.shared-canvas-card-info { + display: grid; + flex: 0 0 22px; + width: 22px; + height: 22px; + place-items: center; + border: 1px solid rgb(255 255 255 / 42%); + border-radius: 50%; + background: rgb(199 101 61 / 92%); + color: #fff; + cursor: pointer; +} +.shared-canvas-card-info:hover, +.shared-canvas-card-kind[role='button']:hover { + background: #b95d3a; +} +.shared-canvas-card-corners [role='button']:focus-visible { + outline: 2px solid #fff; + outline-offset: 2px; +} +.shared-overflow-trigger { + flex: none; + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 30px; + padding: 0 10px; + border: 0; + border-radius: 6px; + background: transparent; + color: inherit; + font-size: 12px; + font-weight: 700; + white-space: nowrap; + cursor: pointer; +} +.shared-overflow-trigger:hover, +.shared-overflow-trigger[aria-expanded='true'] { + background: #f4e7df; +} +.shared-overflow-panel { + position: fixed; + z-index: 1500; + display: flex; + flex-direction: column; + gap: 3px; + width: 200px; + max-width: calc(100vw - 16px); + overflow-y: auto; + overscroll-behavior: contain; + padding: 6px; + border: 1px solid #e7d5c8; + border-radius: 9px; + background: #fff; + color: #4b3026; + box-shadow: 0 10px 28px rgb(52 30 22 / 18%); +} +.shared-overflow-panel > button { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + flex: 0 0 auto; + width: 100%; + min-height: 34px; + padding: 6px 10px; + border: 0; + border-radius: 5px; + background: transparent; + color: inherit; + font-size: 12px; + text-align: left; + cursor: pointer; +} +.shared-overflow-panel > button:hover, +.shared-overflow-panel > button:focus-visible { + background: #f4e7df; +} +.shared-overflow-panel > button:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.shared-overflow-panel + > button.genarrative-image-canvas__chrome-button:not( + .genarrative-image-canvas__chrome-button--with-label + )::after { + content: attr(aria-label); +} diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx index 8b5a462fc..a2f6a607e 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -70,6 +70,50 @@ function renderSelectedToolbar( } describe('ImageCanvasSelectedLayerToolbarView', () => { + it('AGC 收纳包含宿主末组,换选后关闭旧资源更多菜单', () => { + const props = renderSelectedToolbar({ + maxVisibleActions: 5, + supportedActions: new Set([ + 'quick-edit', + 'character-animation', + 'download', + ]), + downloadLabel: '导出', + extraActions: ( + <> + + + + + ), + endActions: , + }); + const toolbar = screen.getByRole('toolbar'); + expect( + within(toolbar) + .getAllByRole('button') + .map((button) => button.textContent), + ).toEqual(['快速编辑', '生成动画', '引用', '编辑标签', '重命名', '更多 ▴']); + fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' })); + const group = screen.getByRole('group', { name: '更多操作' }); + expect( + within(group) + .getAllByRole('button') + .map((button) => button.textContent), + ).toEqual(['导出', '删除素材']); + fireEvent.click(within(group).getByRole('button', { name: '导出' })); + expect(props.onDownloadLayer).toHaveBeenCalledWith(props.selectedLayer); + cleanup(); + const view = render(); + fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' })); + view.rerender( + , + ); + expect(screen.queryByRole('group', { name: '更多操作' })).toBeNull(); + }); it('常规动作与末组分别分隔,导出紧邻删除且位于删除之前', () => { const props = renderSelectedToolbar({ supportedActions: new Set(['quick-edit', 'download']), diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx index 6587025c0..f61909628 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx @@ -1,4 +1,5 @@ import { CanvasChromeButton } from '@genarrative/image-canvas-react'; +import { OverflowActions } from '@genarrative/shared/components'; import { Crop, Download, @@ -38,6 +39,7 @@ export type ImageCanvasSelectedToolbarAction = | 'download'; type ImageCanvasSelectedLayerToolbarViewProps = { + maxVisibleActions?: number; /** * 宿主显式声明的可用动作集合。 * @@ -78,6 +80,7 @@ function hasToolbarActions(actions: ReactNode): boolean { } export function ImageCanvasSelectedLayerToolbarView({ + maxVisibleActions, supportedActions = null, extraActions, endActions, @@ -148,22 +151,24 @@ export function ImageCanvasSelectedLayerToolbarView({ aria-label="素材工具栏" onPointerDown={(event) => event.stopPropagation()} > - {canRedraw ? ( - } - onClick={() => onOpenRedrawPanel(selectedLayer)} - > - 改造 - - ) : null} - {canRedraw && hasExtraActions ? divider : null} - {extraActions} - {(canRedraw || hasExtraActions) && hasEndActions ? divider : null} - {downloadAction} - {endActions} + + {canRedraw ? ( + } + onClick={() => onOpenRedrawPanel(selectedLayer)} + > + 改造 + + ) : null} + {canRedraw && hasExtraActions ? divider : null} + {extraActions} + {(canRedraw || hasExtraActions) && hasEndActions ? divider : null} + {downloadAction} + {endActions} + ); } @@ -208,170 +213,172 @@ export function ImageCanvasSelectedLayerToolbarView({ aria-label="图片工具栏" onPointerDown={(event) => event.stopPropagation()} > - {showQuickEdit ? ( - } - onClick={() => onOpenQuickEditPanel(selectedLayer)} - > - 快速编辑 - - ) : null} - {showQuickEdit && hasEditingActions ? divider : null} - {showCropExpand ? ( - onOpenCropExpandPanel(selectedLayer)} - /> - ) : null} - {showRemoveBackground ? ( - onRemoveBackground(selectedLayer)} - /> - ) : null} - {showPerfectPixel ? ( - - ) : ( - - ) - } - // 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和 - // sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端 - // resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。 - // 与相邻的拆分图集按钮保持同一套门禁。 - disabled={ - isPersistingAssetKind || - isPerfectPixelProcessing || - isPerfectPixelPendingConfirmation - } - aria-busy={isPersistingAssetKind || isPerfectPixelProcessing} - onClick={() => onPerfectPixel(selectedLayer)} - > - - {isPersistingAssetKind - ? '保存中' - : isPerfectPixelProcessing - ? '处理中' - : isPerfectPixelPendingConfirmation - ? '待确认' - : '完美像素'} - - - ) : null} - {showSplitIconSpritesheet ? ( - - ) : ( - - ) - } - disabled={isPersistingAssetKind || isSplittingIconSpritesheet} - aria-busy={isPersistingAssetKind || isSplittingIconSpritesheet} - onClick={() => onSplitIconSpritesheet(selectedLayer)} - > - - {isPersistingAssetKind - ? '保存中' - : isSplittingIconSpritesheet - ? '拆图中' - : '拆分图集'} - - - ) : null} - {showExtractUiDesign ? ( - } - onClick={() => onExtractUiDesignAssets(selectedLayer)} - > - 提取素材 - - ) : null} - {showCharacterAnimation ? ( - } - onClick={() => onOpenCharacterAnimationPanel(selectedLayer)} - > - 生成动画 - - ) : null} - {canRedraw ? ( - <> - {showQuickEdit || hasEditingActions ? divider : null} + + {showQuickEdit ? ( } - onClick={() => onOpenRedrawPanel(selectedLayer)} + label="快速编辑" + title="快速编辑" + icon={} + onClick={() => onOpenQuickEditPanel(selectedLayer)} > - 改造 + 快速编辑 - - ) : null} - {(showQuickEdit || hasEditingActions || canRedraw) && hasExtraActions - ? divider - : null} - {extraActions} - {(showQuickEdit || hasEditingActions || canRedraw || hasExtraActions) && - hasEndActions - ? divider - : null} - {downloadAction} - {endActions} + ) : null} + {showQuickEdit && hasEditingActions ? divider : null} + {showCropExpand ? ( + onOpenCropExpandPanel(selectedLayer)} + /> + ) : null} + {showRemoveBackground ? ( + onRemoveBackground(selectedLayer)} + /> + ) : null} + {showPerfectPixel ? ( + + ) : ( + + ) + } + // 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和 + // sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端 + // resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。 + // 与相邻的拆分图集按钮保持同一套门禁。 + disabled={ + isPersistingAssetKind || + isPerfectPixelProcessing || + isPerfectPixelPendingConfirmation + } + aria-busy={isPersistingAssetKind || isPerfectPixelProcessing} + onClick={() => onPerfectPixel(selectedLayer)} + > + + {isPersistingAssetKind + ? '保存中' + : isPerfectPixelProcessing + ? '处理中' + : isPerfectPixelPendingConfirmation + ? '待确认' + : '完美像素'} + + + ) : null} + {showSplitIconSpritesheet ? ( + + ) : ( + + ) + } + disabled={isPersistingAssetKind || isSplittingIconSpritesheet} + aria-busy={isPersistingAssetKind || isSplittingIconSpritesheet} + onClick={() => onSplitIconSpritesheet(selectedLayer)} + > + + {isPersistingAssetKind + ? '保存中' + : isSplittingIconSpritesheet + ? '拆图中' + : '拆分图集'} + + + ) : null} + {showExtractUiDesign ? ( + } + onClick={() => onExtractUiDesignAssets(selectedLayer)} + > + 提取素材 + + ) : null} + {showCharacterAnimation ? ( + } + onClick={() => onOpenCharacterAnimationPanel(selectedLayer)} + > + 生成动画 + + ) : null} + {canRedraw ? ( + <> + {showQuickEdit || hasEditingActions ? divider : null} + } + onClick={() => onOpenRedrawPanel(selectedLayer)} + > + 改造 + + + ) : null} + {(showQuickEdit || hasEditingActions || canRedraw) && hasExtraActions + ? divider + : null} + {extraActions} + {(showQuickEdit || hasEditingActions || canRedraw || hasExtraActions) && + hasEndActions + ? divider + : null} + {downloadAction} + {endActions} + ); } diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index ebc0b3b75..bd03cd7ab 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -814,7 +814,7 @@ describe('ImageCanvasWorldView', () => { expect( ( - within(layerButton).getByText('角色') as HTMLElement + within(layerButton).getByText('角色').parentElement as HTMLElement ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( @@ -825,6 +825,7 @@ describe('ImageCanvasWorldView', () => { expect( within(layerButton) .getByRole('button', { name: '查看角色主图图片信息' }) + .parentElement! .style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( @@ -1118,12 +1119,8 @@ describe('ImageCanvasWorldView', () => { name: '查看角色主图图片信息', }); - expect(badge.className).toContain( - 'image-canvas-editor__kind-badge--beside-info', - ); - expect(metadataButton.className).not.toContain( - 'image-canvas-editor__metadata-corner--beside-kind', - ); + expect(badge.parentElement?.className).toBe('shared-canvas-card-corners'); + expect(badge.nextElementSibling).toBe(metadataButton); }); it('keeps the layer type menu scrollable and closes it when clicking outside', () => { diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index 42315907a..a9e1e9622 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -3,13 +3,13 @@ import { LayerRenderer, SelectionOverlay, } from '@genarrative/image-canvas-react'; +import { CanvasCardCornerActions } from '@genarrative/shared/components'; import { AppWindow, Clapperboard, ClipboardList, Grid2X2, ImageIcon, - Info, Megaphone, Mountain, Music, @@ -44,7 +44,6 @@ import { PlatformFloatingMenu, PlatformFloatingMenuItem, } from '../common/PlatformFloatingMenu'; -import { PlatformIconButton } from '../common/PlatformIconButton'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { @@ -367,17 +366,6 @@ function handleLayerKeyboardActivation(event: ReactKeyboardEvent) { event.currentTarget.click(); } -function handleLayerTagKeyboardActivation( - event: ReactKeyboardEvent, -) { - if (event.key !== 'Enter' && event.key !== ' ') { - return; - } - event.preventDefault(); - event.stopPropagation(); - event.currentTarget.click(); -} - function stopMediaControlKeyPropagation( event: ReactKeyboardEvent, ) { @@ -1012,38 +1000,19 @@ const MemoizedCanvasLayerNode = memo(function CanvasLayerNode({ mediaTransform={mediaTransform} /> )} - {kindLabel ? ( - event.stopPropagation()} - onClick={(event) => { - event.stopPropagation(); - handlers.setOpenLayerKindMenuId((currentId) => - currentId === layer.id ? null : layer.id, - ); - }} - onKeyDown={handleLayerTagKeyboardActivation} - > - {kindLabel} - - ) : null} - } + { - event.stopPropagation(); - handlers.onOpenLayerMetadata(layer); - }} - onPointerDown={(event) => event.stopPropagation()} + onKindClick={() => + handlers.setOpenLayerKindMenuId((currentId) => + currentId === layer.id ? null : layer.id, + ) + } + onInfoClick={() => handlers.onOpenLayerMetadata(layer)} /> {isHovered && layer.mediaType !== 'audio' ? ( Date: Thu, 17 Sep 2026 18:47:45 +0800 Subject: [PATCH 03/16] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E9=93=BE=E5=9B=9E=E9=80=80=E5=B9=B6=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E9=AB=98=E6=B0=B4=E4=BD=8D=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 发布脚本版本来源改为渠道清单与旧协议迁移指针取较大值,避免渠道启用初期把版本链改小 - 旧迁移指针只服务 dev-win,其它渠道不参与比较,旧指针 404 后自动只剩渠道清单 - 补充三条定向用例:旧指针高水位、两者取高、非 Windows 渠道不读旧指针 - 主规范记录版本高水位规则与 2026-09-17 首次渠道发布 0.1.57 退回 0.1.48、后以 0.1.60 纠偏的事实 --- .../scripts/build-release.mjs | 44 +++++++++++--- .../scripts/build-release.test.mjs | 58 +++++++++++++++++++ ...方案】AGC客户端更新检查与下载-2026-08-31.md | 1 + 3 files changed, 96 insertions(+), 7 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 2bfce8d02..579220319 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -143,27 +143,57 @@ export function resolveManifestPlatformKeys(target = releaseTarget) { throw new Error(`不支持的发布目标:${target}`); } -async function readRemoteVersion(channel = resolveReleaseChannel()) { - const manifestUrl = updateManifestUrl(channel); +/** 旧协议迁移指针:只在迁移窗口内存在,是历史版本高水位的来源。 */ +function legacyBridgeManifestUrl() { + return `${ossBaseUrl()}/latest.json`; +} + +async function readManifestVersion(manifestUrl, label) { let response; try { response = await fetch(manifestUrl, { headers: { Accept: 'application/json' }, }); } catch (error) { - throw new Error(`读取 OSS 渠道清单失败:${error.message}`); + throw new Error(`读取 ${label} 失败:${error.message}`); } if (response.status === 404) return null; if (!response.ok) { - throw new Error(`读取 OSS 渠道清单失败:HTTP ${response.status}`); + throw new Error(`读取 ${label} 失败:HTTP ${response.status}`); } let manifest; try { manifest = await response.json(); } catch (error) { - throw new Error(`OSS 渠道清单不是有效 JSON:${error.message}`); + throw new Error(`${label} 不是有效 JSON:${error.message}`); } - return parseVersion(manifest?.version, 'OSS渠道清单 version'); + return parseVersion(manifest?.version, `${label} version`); +} + +/** + * 版本高水位:渠道清单与旧协议迁移指针取较大值。 + * + * 只看渠道清单会在「渠道刚启用、旧指针还停在更高版本」时把版本链改小 —— + * 2026-09-17 首次渠道发布就是这样把 0.1.57 退回 0.1.48 的。旧指针只服务 + * Windows 渠道,其它渠道不参与比较;旧指针 404(迁移窗口结束)后自动只剩渠道清单。 + */ +export async function resolveRemoteHighWaterVersion( + channel = resolveReleaseChannel(), +) { + const channelVersion = await readManifestVersion( + updateManifestUrl(channel), + 'OSS 渠道清单', + ); + if (channel !== 'dev-win') return channelVersion; + const legacyVersion = await readManifestVersion( + legacyBridgeManifestUrl(), + 'OSS 迁移指针', + ); + if (channelVersion == null) return legacyVersion; + if (legacyVersion == null) return channelVersion; + return compareVersions(channelVersion, legacyVersion) >= 0 + ? channelVersion + : legacyVersion; } function replaceVersionLine(source, version, pattern, label) { @@ -174,7 +204,7 @@ function replaceVersionLine(source, version, pattern, label) { export async function prepareReleaseVersion() { const channel = resolveReleaseChannel(); const localVersion = parseVersion(readPackageJson().version, '本地版本'); - const remoteVersion = await readRemoteVersion(channel); + const remoteVersion = await resolveRemoteHighWaterVersion(channel); const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim(); const nextVersion = requestedVersion ? parseVersion(requestedVersion, '指定版本') diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index 904bb079f..f085a10d0 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -13,6 +13,7 @@ import { nextPatchVersion, resolveManifestPlatformKeys, resolveReleaseChannel, + resolveRemoteHighWaterVersion, selectReleaseArtifact, updateManifestUrl, } from './build-release.mjs'; @@ -49,6 +50,22 @@ function withSignedArtifact(fileName, run) { } } +function jsonResponse(body, status = 200) { + return { + status, + ok: status >= 200 && status < 300, + json: async () => body, + }; +} + +function withStubbedFetch(handler, run) { + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => handler(String(url)); + return Promise.resolve(run()).finally(() => { + globalThis.fetch = originalFetch; + }); +} + test('selects an explicit release artifact when configured', () => { const artifactPath = fileURLToPath( new URL('../package.json', import.meta.url), @@ -173,6 +190,47 @@ test('next release version follows the higher local or channel version', () => { assert.equal(nextPatchVersion('0.1.12', null), '0.1.13'); }); +test('version high water keeps the legacy pointer during the migration window', async () => { + await withStubbedFetch( + (url) => + url.endsWith('/agc/dev-win/latest.json') + ? jsonResponse({}, 404) + : jsonResponse({ version: '0.1.57' }), + async () => { + assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.57'); + // 旧指针 0.1.57 已是高水位,下一次发布必须是 0.1.58,不能退回渠道本地版本。 + assert.equal(nextPatchVersion('0.1.47', '0.1.57'), '0.1.58'); + }, + ); +}); + +test('version high water takes the higher of channel and legacy pointer', async () => { + await withStubbedFetch( + (url) => + url.endsWith('/agc/dev-win/latest.json') + ? jsonResponse({ version: '0.1.60' }) + : jsonResponse({ version: '0.1.57' }), + async () => { + assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.60'); + }, + ); +}); + +test('version high water ignores the windows migration pointer for other channels', async () => { + await withStubbedFetch( + (url) => { + assert.ok( + !url.endsWith('/agc/latest.json'), + 'non-windows channel must not read the windows migration pointer', + ); + return jsonResponse({ version: '0.1.12' }); + }, + async () => { + assert.equal(await resolveRemoteHighWaterVersion('dev-mac'), '0.1.12'); + }, + ); +}); + test('release upload forces overwrite for artifact, signature and channel pointers', () => { const source = readFileSync( new URL('./release-upload.mjs', import.meta.url), diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 61a738e77..314b98419 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -82,6 +82,7 @@ - 上一条的两个键不能合成单一 `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 白名单条目按「四不写」整条删除,不留兼容分支与墓碑说明。 From 6017d46088c04199e99cf89f347b12d67591475e Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:00:45 +0800 Subject: [PATCH 04/16] =?UTF-8?q?AGC=20=E5=AE=9A=E6=97=B6=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E5=A2=9E=E5=8A=A0=E5=8F=98=E6=9B=B4=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E8=BF=87=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 调度管线新增 AGC 发布范围判定:比较上一轮 revision 与本次 revision 的变更路径 - 只有客户端、共享包、server-rs/crates、AGC 插件、桌面壳图标或根依赖清单变化时才触发 AGC Windows Build - 纯文档与流水线提交只触发 Full Build,不再推高客户端版本号 - 判定失败或浅取窗口取不到该 revision 时按需要发布处理,FORCE_TRIGGER 仍强制两条都触发 - 同步开发运维文档、技术方案与调度 job 配置描述 --- ...方案】AGC客户端更新检查与下载-2026-08-31.md | 1 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- .../Jenkinsfile.scheduled-revision-trigger | 70 ++++++++++++++++++- .../scheduled-revision-trigger-job-config.xml | 2 +- 4 files changed, 71 insertions(+), 4 deletions(-) diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 314b98419..ec42456a8 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -91,6 +91,7 @@ - 发布入口:`npm run ai-game-creator-shell:release:upload`(构建 + 按渠道上传);仅构建不发布的 smoke 使用 `--no-bundle` 分支,不读远端版本、不改版本、不生成清单。 - 渠道由构建参数显式指定,并按目标平台校验:Windows 目标只允许 `dev-win`,macOS 目标只允许 `dev-mac`;未显式指定时按目标平台取默认渠道。 +- 定时调度只在本轮到达的提交包含 AGC 相关路径(客户端、共享包、`server-rs/crates`、AGC 插件、桌面壳图标、根依赖清单)时才触发渠道发布;纯文档或流水线自身的提交只跑 Full Build,不推高客户端版本号。判定失败或勾选强制触发时按"需要发布"处理。 - 上传:安装包与 `.sig` 上传到 `agc///`,清单以 `--force` 覆盖上传到 `agc//latest.json`,保证 latest 指针与清单内 URL 指向已存在的对象。 - Jenkins 流水线需要新增渠道参数与签名凭据;签名私钥与密码只以受保护凭据注入当前进程,不写入 workspace、日志或归档产物。 - 归档证据:安装包、`.sig`、渠道清单与源码 commit。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index d13920f03..5784a9a5c 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -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` 可强制两条都触发。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 仍会继续触发。 diff --git a/jenkins/Jenkinsfile.scheduled-revision-trigger b/jenkins/Jenkinsfile.scheduled-revision-trigger index 621a35129..e403d407a 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,63 @@ pipeline { } } + // 只有在「本轮到达的提交」里出现 AGC 相关路径时,Windows 客户端才发布新版本; + // 纯文档或流水线自身的提交仍然触发 Full Build,但不再推高客户端版本号。 + stage('Resolve AGC 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 scope = 'changed' + try { + scope = 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 + echo changed + 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}" || { echo changed; exit 0; } + fi + if ! git -C "${AGC_SCOPE_CACHE_DIR}" cat-file -e "${previous}^{commit}" 2>/dev/null; then + echo "浅取窗口内没有 ${previous},按需要发布处理" >&2 + echo changed + exit 0 + fi + changed_paths="$(git -C "${AGC_SCOPE_CACHE_DIR}" diff --name-only "${previous}" "${REMOTE_REVISION}" 2>/dev/null || true)" + 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) + echo changed + exit 0 + ;; + esac + done <<< "${changed_paths}" + echo unchanged + ''', returnStdout: true).trim() + } catch (error) { + echo "AGC 发布范围判定失败,按需要发布处理:${error}" + scope = 'changed' + } + env.AGC_RELEASE_SCOPE = (scope == 'unchanged') ? 'unchanged' : 'changed' + echo "AGC 发布范围:${env.AGC_RELEASE_SCOPE}(上一轮已触发 revision=${env.LAST_TRIGGERED_REVISION ?: '无'})" + } + } + } + } + stage('Trigger Downstream Pipelines') { when { expression { return env.REVISION_CHANGED == 'true' } @@ -71,9 +129,17 @@ pipeline { 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 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)}" + currentBuild.description = agcTriggered + ? "已触发 ${env.FULL_BUILD_JOB_NAME} 与 ${env.AGC_BUILD_JOB_NAME}:${env.SOURCE_BRANCH}@${pinnedRevision.take(12)}" + : "已触发 ${env.FULL_BUILD_JOB_NAME}(AGC 渠道未发布:本次提交不含 AGC 相关路径):${env.SOURCE_BRANCH}@${pinnedRevision.take(12)}" echo currentBuild.description } } diff --git a/jenkins/scheduled-revision-trigger-job-config.xml b/jenkins/scheduled-revision-trigger-job-config.xml index 70a48e081..5b9e25f3a 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 触发 Full Build;AGC Windows Build 额外按变更路径过滤,只有本轮提交触及客户端相关路径时才触发。 false From ba089980b1d635ea1a9c35d63d92c574f8b81537 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:26:11 +0800 Subject: [PATCH 05/16] =?UTF-8?q?AGC=20=E5=8F=91=E5=B8=83=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E7=94=9F=E6=88=90=E6=9B=B4=E6=96=B0=E6=91=98=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 发布脚本读取渠道清单的 commit 字段,把上一次发布到本次之间客户端相关路径的提交标题写进清单 notes - 更新摘要同步写入旧协议清单 releaseNotes,并落盘 release-notes.txt 供 Jenkins 归档 - AGC_UPDATE_RELEASE_NOTES 非空时以手动文案为准,缺少上一次 commit 时不写摘要 - 提交取数固定在仓库根执行,避免 pathspec 相对应用目录解析导致漏取 - 新增摘要格式、真实临时仓库取数与调度管线路径表一致性用例 - 同步开发运维文档与技术方案的构建发布条款 --- .../scripts/build-release.mjs | 164 ++++++++++++++++-- .../scripts/build-release.test.mjs | 123 ++++++++++++- .../scripts/release-upload.mjs | 2 +- ...方案】AGC客户端更新检查与下载-2026-08-31.md | 2 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- .../Jenkinsfile.ai-game-creator-shell-build | 4 +- 6 files changed, 280 insertions(+), 17 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 579220319..e8c8e026d 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -1,4 +1,4 @@ -import { spawnSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; @@ -11,6 +11,8 @@ import { } from './cargo-features.mjs'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); +// 提交摘要里的 pathspec 与 `git log` 都以仓库根为基准,不能在应用目录里执行。 +const repoRoot = path.resolve(appRoot, '..', '..'); const defaultReleaseTarget = 'x86_64-pc-windows-msvc'; const releaseTarget = process.env.AGC_BUILD_TARGET?.trim() || defaultReleaseTarget; @@ -39,6 +41,20 @@ const releaseChannels = { 'dev-mac': 'darwin', }; +/** + * 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须 + * 保持一致 —— `build-release.test.mjs` 有守卫用例逐条比对两边。 + */ +export const agcReleasePathPatterns = [ + 'apps/ai-game-creator-shell/', + 'packages/', + 'server-rs/crates/', + 'plugins/agc-cocos-editor/', + 'apps/desktop-shell/src-tauri/icons/', + 'package.json', + 'package-lock.json', +]; + function ossBaseUrl() { return ( process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl @@ -148,7 +164,7 @@ function legacyBridgeManifestUrl() { return `${ossBaseUrl()}/latest.json`; } -async function readManifestVersion(manifestUrl, label) { +async function fetchManifest(manifestUrl, label) { let response; try { response = await fetch(manifestUrl, { @@ -161,13 +177,32 @@ async function readManifestVersion(manifestUrl, label) { if (!response.ok) { throw new Error(`读取 ${label} 失败:HTTP ${response.status}`); } - let manifest; try { - manifest = await response.json(); + return await response.json(); } catch (error) { throw new Error(`${label} 不是有效 JSON:${error.message}`); } - return parseVersion(manifest?.version, `${label} version`); +} + +async function readManifestVersion(manifestUrl, label) { + const manifest = await fetchManifest(manifestUrl, label); + return manifest == null + ? null + : parseVersion(manifest?.version, `${label} version`); +} + +/** 上一次发布的渠道清单:拿版本做高水位、拿 commit 生成自动更新摘要。 */ +async function readRemoteChannelManifest(channel = resolveReleaseChannel()) { + return fetchManifest(updateManifestUrl(channel), 'OSS 渠道清单'); +} + +export async function resolvePreviousReleaseCommit( + channel = resolveReleaseChannel(), +) { + const manifest = await readRemoteChannelManifest(channel); + const commit = + typeof manifest?.commit === 'string' ? manifest.commit.trim() : ''; + return /^[0-9a-f]{7,40}$/u.test(commit) ? commit : null; } /** @@ -398,6 +433,8 @@ export function createUpdateManifest( channel = resolveReleaseChannel(), target = releaseTarget, publishedAt = new Date().toISOString(), + notes = readReleaseNotes(), + commit = readHeadCommit(), } = {}, ) { const signature = readUpdaterSignature(artifactPath); @@ -408,24 +445,103 @@ export function createUpdateManifest( for (const key of resolveManifestPlatformKeys(target)) { platforms[key] = { signature, url }; } - const notes = readReleaseNotes(); return { version, ...(notes ? { notes } : {}), pub_date: publishedAt, platforms, + // 非标准字段:更新插件会忽略,发布脚本用它定位下一次自动更新摘要的起点。 + ...(commit ? { commit } : {}), }; } +function readHeadCommit() { + try { + return execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repoRoot, + encoding: 'utf8', + }).trim(); + } catch { + return ''; + } +} + +/** + * 上一次发布到本次之间的客户端相关提交。 + * + * 返回 null 表示无法判定(没有上一次 commit,或本地没有该提交),此时不生成摘要。 + */ +export function collectReleaseCommits( + previousCommit, + headCommit = 'HEAD', + { cwd = repoRoot, paths = agcReleasePathPatterns } = {}, +) { + if (!previousCommit) return null; + try { + for (const revision of [previousCommit, headCommit]) { + execFileSync('git', ['rev-parse', '--verify', `${revision}^{commit}`], { + cwd, + stdio: 'pipe', + }); + } + } catch { + return null; + } + let output; + try { + output = execFileSync( + 'git', + [ + 'log', + '--no-merges', + '--format=%h%x09%s', + `${previousCommit}..${headCommit}`, + '--', + ...paths, + ], + { cwd, encoding: 'utf8' }, + ); + } catch { + return null; + } + return output + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [sha = '', ...subject] = line.split('\t'); + return { sha, subject: subject.join('\t') }; + }); +} + +/** 自动更新摘要:逐条列客户端相关改动,超过上限时折叠并整体截断。 */ +export function formatReleaseNotes( + commits, + { limit = 12, subjectLength = 80, maxLength = 900 } = {}, +) { + if (!commits || commits.length === 0) return ''; + const lines = commits.slice(0, limit).map(({ sha, subject }) => { + const trimmed = + subject.length > subjectLength + ? `${subject.slice(0, subjectLength - 1)}…` + : subject; + return `- ${trimmed}(${sha})`; + }); + if (commits.length > limit) { + lines.push(`- 其余 ${commits.length - limit} 项客户端改动省略`); + } + const text = lines.join('\n'); + return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text; +} + /** 旧协议(sha256)清单:只用于把已发布客户端带到新渠道协议,一个版本周期后整条删除。 */ export function createLegacyUpdateManifest( artifactPath, - { channel = resolveReleaseChannel() } = {}, + { channel = resolveReleaseChannel(), notes = readReleaseNotes() } = {}, ) { const bytes = fs.readFileSync(artifactPath); const version = readPackageJson().version; const fileName = path.basename(artifactPath); - const notes = readReleaseNotes(); return { version, downloadUrl: `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`, @@ -435,18 +551,32 @@ export function createLegacyUpdateManifest( }; } -export function generateUpdateManifest() { +export async function generateUpdateManifest() { const channel = resolveReleaseChannel(); const artifact = selectReleaseArtifact(listFiles(bundleRoot)); if (!artifact) { throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`); } - const manifest = createUpdateManifest(artifact, { channel }); + const manualNotes = readReleaseNotes(); + const previousCommit = await resolvePreviousReleaseCommit(channel); + const commits = collectReleaseCommits(previousCommit); + const notes = manualNotes || formatReleaseNotes(commits); + if (!manualNotes && !notes) { + console.log( + `[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'})`, + ); + } + const manifest = createUpdateManifest(artifact, { channel, notes }); const manifestPath = path.join(bundleRoot, 'latest.json'); fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + const notesPath = path.join(bundleRoot, 'release-notes.txt'); + fs.writeFileSync( + notesPath, + notes ? `${notes}\n` : '(本次没有可用的更新摘要)\n', + ); const legacyManifest = channel === 'dev-win' - ? createLegacyUpdateManifest(artifact, { channel }) + ? createLegacyUpdateManifest(artifact, { channel, notes }) : null; const legacyManifestPath = legacyManifest ? path.join(bundleRoot, 'legacy-latest.json') @@ -461,6 +591,12 @@ export function generateUpdateManifest() { `[ai-game-creator-shell] 渠道 ${channel}:已生成 ${manifestPath}`, ); console.log(`[ai-game-creator-shell] 安装包:${artifact}`); + console.log( + manualNotes + ? '[ai-game-creator-shell] 更新摘要:使用 AGC_UPDATE_RELEASE_NOTES 手动文案' + : `[ai-game-creator-shell] 更新摘要:自动汇总 ${commits ? commits.length : 0} 条客户端相关提交(起点 ${previousCommit ?? '无'})`, + ); + console.log(`[ai-game-creator-shell] 更新摘要文件:${notesPath}`); if (legacyManifestPath) { console.log( `[ai-game-creator-shell] 旧协议迁移清单:${legacyManifestPath}`, @@ -471,6 +607,10 @@ export function generateUpdateManifest() { artifact, manifest, manifestPath, + notes, + notesPath, + previousCommit, + commits, legacyManifest, legacyManifestPath, }; @@ -483,5 +623,5 @@ if ( const args = process.argv.slice(2); if (!args.includes('--no-bundle')) await prepareReleaseVersion(); runTauriBuild(args); - if (!args.includes('--no-bundle')) generateUpdateManifest(); + if (!args.includes('--no-bundle')) await generateUpdateManifest(); } diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index f085a10d0..911bfb22f 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -1,15 +1,25 @@ import assert from 'node:assert/strict'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; import { + agcReleasePathPatterns, + collectReleaseCommits, compareVersions, createChannelConfig, createLegacyUpdateManifest, createUpdateManifest, + formatReleaseNotes, nextPatchVersion, resolveManifestPlatformKeys, resolveReleaseChannel, @@ -243,3 +253,114 @@ test('release upload forces overwrite for artifact, signature and channel pointe assert.match(source, /agc\/\$\{channel\}\/latest\.json/u); assert.match(source, /agc\/latest\.json/u); }); + +test('release notes list client commits with short sha and bound their size', () => { + const notes = formatReleaseNotes([ + { sha: 'a5fd25f1', subject: '客户端更新切换到官方更新插件' }, + { sha: '55af6014', subject: '修'.repeat(120) }, + ]); + const lines = notes.split('\n'); + assert.equal(lines.length, 2); + assert.match(lines[0], /^- 客户端更新切换到官方更新插件(a5fd25f1)$/u); + const truncatedSubject = lines[1] + .replace(/^- /u, '') + .replace(/(55af6014)$/u, ''); + assert.equal(truncatedSubject.length, 80, `主题应截断到 80 字:${lines[1]}`); + assert.match(truncatedSubject, /…$/u); + assert.match(lines[1], /(55af6014)$/u); + + const many = formatReleaseNotes( + Array.from({ length: 20 }, (_, index) => ({ + sha: `sha${index}`, + subject: `改动 ${index}`, + })), + ); + assert.match(many, /- 其余 8 项客户端改动省略$/u); + assert.equal(formatReleaseNotes([]), ''); + assert.equal(formatReleaseNotes(null), ''); +}); + +test('release commits cover only client paths and skip merge commits', () => { + const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-changelog-git-')); + const git = (...args) => + execFileSync('git', args, { cwd: directory, encoding: 'utf8' }); + try { + git('init', '--quiet'); + git('config', 'user.email', 'release@example.test'); + git('config', 'user.name', 'release test'); + git('commit', '--allow-empty', '--quiet', '-m', '基点'); + const base = git('rev-parse', 'HEAD').trim(); + + mkdirSync(path.join(directory, 'apps/ai-game-creator-shell'), { + recursive: true, + }); + mkdirSync(path.join(directory, 'docs'), { recursive: true }); + writeFileSync( + path.join(directory, 'apps/ai-game-creator-shell/main.rs'), + 'fn main() {}\n', + ); + git('add', '.'); + git('commit', '--quiet', '-m', '客户端:新增更新插件接入'); + + writeFileSync(path.join(directory, 'docs/readme.md'), '# 文档\n'); + git('add', '.'); + git('commit', '--quiet', '-m', '文档:补充说明'); + + writeFileSync( + path.join(directory, 'apps/ai-game-creator-shell/other.rs'), + 'fn other() {}\n', + ); + git('add', '.'); + git('commit', '--quiet', '-m', '客户端:修复版本回退'); + + git('checkout', '--quiet', '-b', 'side'); + writeFileSync( + path.join(directory, 'apps/ai-game-creator-shell/side.rs'), + 'fn side() {}\n', + ); + git('add', '.'); + git('commit', '--quiet', '-m', '客户端:侧分支改动'); + git('checkout', '--quiet', 'master'); + git('merge', '--quiet', '--no-ff', '--no-edit', 'side'); + + const commits = collectReleaseCommits(base, 'HEAD', { cwd: directory }); + assert.ok(commits, '应能在临时仓库里收集提交'); + const subjects = commits.map((entry) => entry.subject); + // 合并提交本身被 --no-merges 排除,但它带入的客户端改动仍然计入。 + assert.deepEqual(subjects, [ + '客户端:侧分支改动', + '客户端:修复版本回退', + '客户端:新增更新插件接入', + ]); + assert.ok(commits.every((entry) => /^[0-9a-f]{7,}$/u.test(entry.sha))); + + assert.equal( + collectReleaseCommits('1234567890abcdef', 'HEAD', { cwd: directory }), + null, + ); + assert.equal(collectReleaseCommits(null, 'HEAD', { cwd: directory }), null); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test('scheduler path filter stays in sync with client release paths', () => { + const jenkinsfile = readFileSync( + new URL( + '../../../jenkins/Jenkinsfile.scheduled-revision-trigger', + import.meta.url, + ), + 'utf8', + ); + const filterLine = jenkinsfile + .split('\n') + .find((line) => line.includes('apps/ai-game-creator-shell/*|')); + assert.ok(filterLine, '调度管线里应存在发布范围过滤模式'); + for (const pattern of agcReleasePathPatterns) { + const bashPattern = pattern.includes('/') ? `${pattern}*` : pattern; + assert.ok( + filterLine.includes(bashPattern), + `调度管线过滤缺少 ${bashPattern}`, + ); + } +}); diff --git a/apps/ai-game-creator-shell/scripts/release-upload.mjs b/apps/ai-game-creator-shell/scripts/release-upload.mjs index 08c83c5dd..3d1cff921 100644 --- a/apps/ai-game-creator-shell/scripts/release-upload.mjs +++ b/apps/ai-game-creator-shell/scripts/release-upload.mjs @@ -54,7 +54,7 @@ function runOssutil(args) { await prepareReleaseVersion(); runTauriBuild([]); const { artifact, channel, legacyManifestPath, manifest, manifestPath } = - generateUpdateManifest(); + await generateUpdateManifest(); const artifactKey = `agc/${channel}/${manifest.version}/${path.basename(artifact)}`; // Jenkins/ossutil 默认会在目标对象已存在时交互询问并按默认值跳过; // 发布清单是固定的 latest 指针,必须显式覆盖,否则流水线会误报成功但远端仍保留旧版本。 diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index ec42456a8..53d215a59 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -92,6 +92,8 @@ - 发布入口:`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` 是非标准字段:更新插件忽略未知字段,发布脚本用它定位下一次摘要的起点。 - 上传:安装包与 `.sig` 上传到 `agc///`,清单以 `--force` 覆盖上传到 `agc//latest.json`,保证 latest 指针与清单内 URL 指向已存在的对象。 - Jenkins 流水线需要新增渠道参数与签名凭据;签名私钥与密码只以受保护凭据注入当前进程,不写入 workspace、日志或归档产物。 - 归档证据:安装包、`.sig`、渠道清单与源码 commit。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 5784a9a5c..acbaf6f82 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -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 造成漂移。两条下游管线自身不带任何定时触发器,也不在管线内部做版本比较。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` 可强制两条都触发。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` 可强制两条都触发。客户端渠道清单的更新摘要同样自动生成:发布脚本读取上一份渠道清单的 `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 仍会继续触发。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-build b/jenkins/Jenkinsfile.ai-game-creator-shell-build index 8ac2484e2..db0bbf23b 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-build @@ -24,7 +24,7 @@ pipeline { 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: '可选,支持多行文本,写入渠道清单的发布说明') + text(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选,支持多行文本;留空则由本次发布的客户端相关提交自动生成更新摘要') string(name: 'OSSUTIL_BIN', defaultValue: 'ossutil', description: 'ossutil 或 ossutil.exe 的绝对路径/命令名') } @@ -159,7 +159,7 @@ 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/**/*.sig,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/latest.json,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/legacy-latest.json,.jenkins-source-commit', fingerprint: true + 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 } } } From 5ccc46ac1ae3f6b6029d9388da63d21b6cdf317c Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:31:19 +0800 Subject: [PATCH 06/16] =?UTF-8?q?=E8=B0=83=E5=BA=A6=E7=AE=A1=E7=BA=BF?= =?UTF-8?q?=E6=8C=89=E8=B7=AF=E5=BE=84=E8=BF=87=E6=BB=A4=20Full=20Build=20?= =?UTF-8?q?=E5=B9=B6=E5=9B=9E=E5=A1=AB=E5=8D=87=E7=BA=A7=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 调度管线同时判定 AGC 与 Full Build 两条下游的发布范围,并分别决定是否触发 - Full Build 采用与线上站点/后端无关的路径黑名单,改动落在黑名单外照常部署,避免漏发 - 两条同时跳过时只推进 revision 状态、不触发任何发布,构建描述写明实际触发的下游 - 新增 Full Build 跳过模式守卫用例,并补齐开发运维与技术方案说明 - 主规范回填真实更新闭环证据:0.1.47 客户端完成提示、下载、安装与重启 --- .../scripts/build-release.test.mjs | 27 ++++++++ ...方案】AGC客户端更新检查与下载-2026-08-31.md | 31 ++++----- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- .../Jenkinsfile.scheduled-revision-trigger | 66 +++++++++++++------ .../scheduled-revision-trigger-job-config.xml | 2 +- 5 files changed, 91 insertions(+), 37 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index 911bfb22f..cc2379f01 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -364,3 +364,30 @@ test('scheduler path filter stays in sync with client release paths', () => { ); } }); + +test('scheduler skips the full build only for non-deploy paths', () => { + const jenkinsfile = readFileSync( + new URL( + '../../../jenkins/Jenkinsfile.scheduled-revision-trigger', + import.meta.url, + ), + 'utf8', + ); + const skipLine = jenkinsfile + .split('\n') + .find((line) => line.includes('docs/*|.codex/*|jenkins/*')); + assert.ok(skipLine, '调度管线里应存在 Full Build 跳过模式'); + for (const pattern of [ + 'docs/*', + '.codex/*', + 'jenkins/*', + 'apps/ai-game-creator-shell/*', + 'apps/mobile-shell/*', + 'apps/desktop-shell/*', + 'apps/preview-deployer-web/*', + 'tools/*', + '*.md', + ]) { + assert.ok(skipLine.includes(pattern), `Full Build 跳过模式缺少 ${pattern}`); + } +}); diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 53d215a59..6b2f0841b 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -102,24 +102,25 @@ 已获得的证据: -| 条款 | 验收方式 | 证据 | -| -------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| 渠道与端点映射、渠道校验 | `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`) | +| 条款 | 验收方式 | 证据 | +| ---------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| 渠道与端点映射、渠道校验 | `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 用户实测:提示 → 下载 → 安装 → 关于页显示新版本,再次检查为已是最新) | 待执行证据(首次渠道发布后回填): -| 条款 | 验收方式 | 证据 | -| ---------------------------- | --------------------------------------------------------------------- | ------ | -| 真实更新闭环(含升级后重启) | 0.1.47 客户端升级到新版本,再启动不再提示;`npm run agc` 仍无更新入口 | 待执行 | -| 签名校验失败拒绝安装 | 篡改渠道清单 `signature` 后观察客户端拒绝安装的表现 | 待执行 | +| 条款 | 验收方式 | 证据 | +| -------------------- | --------------------------------------------------- | ------ | +| 签名校验失败拒绝安装 | 篡改渠道清单 `signature` 后观察客户端拒绝安装的表现 | 待执行 | +| 签名校验失败拒绝安装 | 篡改渠道清单 `signature` 后观察客户端拒绝安装的表现 | 待执行 | ## 未决问题与决策 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index acbaf6f82..0b5d624d2 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -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 造成漂移。两条下游管线自身不带任何定时触发器,也不在管线内部做版本比较。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` 可强制两条都触发。客户端渠道清单的更新摘要同样自动生成:发布脚本读取上一份渠道清单的 `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`。 +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 仍会继续触发。 diff --git a/jenkins/Jenkinsfile.scheduled-revision-trigger b/jenkins/Jenkinsfile.scheduled-revision-trigger index e403d407a..4c9173c30 100644 --- a/jenkins/Jenkinsfile.scheduled-revision-trigger +++ b/jenkins/Jenkinsfile.scheduled-revision-trigger @@ -58,24 +58,25 @@ pipeline { } } - // 只有在「本轮到达的提交」里出现 AGC 相关路径时,Windows 客户端才发布新版本; - // 纯文档或流水线自身的提交仍然触发 Full Build,但不再推高客户端版本号。 - stage('Resolve AGC Release Scope') { + // 按「本轮到达的提交」判定两条下游各自是否需要跑: + // - 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 scope = 'changed' + // 判定失败一律按「两条都要跑」处理,避免这段逻辑影响下游发布。 + def output = 'agc=changed\nfull=changed' try { - scope = sh(script: '''#!/usr/bin/env bash + 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 - echo changed + printf 'agc=changed\nfull=changed\n' exit 0 fi mkdir -p "${AGC_SCOPE_CACHE_DIR}" @@ -85,31 +86,46 @@ pipeline { 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}" || { echo changed; exit 0; } + 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 - echo changed + 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) - echo changed - exit 0 + 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}" - echo unchanged + printf 'agc=%s\nfull=%s\n' "${agc_scope}" "${full_scope}" ''', returnStdout: true).trim() } catch (error) { - echo "AGC 发布范围判定失败,按需要发布处理:${error}" - scope = 'changed' + echo "发布范围判定失败,按需要发布处理:${error}" + output = 'agc=changed\nfull=changed' } - env.AGC_RELEASE_SCOPE = (scope == 'unchanged') ? 'unchanged' : 'changed' - echo "AGC 发布范围:${env.AGC_RELEASE_SCOPE}(上一轮已触发 revision=${env.LAST_TRIGGERED_REVISION ?: '无'})" + 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 ?: '无'})" } } } @@ -128,7 +144,13 @@ 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 + 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 @@ -137,9 +159,13 @@ pipeline { echo "本轮提交不含 AGC 相关路径,跳过 ${env.AGC_BUILD_JOB_NAME};需要强制发布时勾选 FORCE_TRIGGER" } writeFile file: env.REVISION_STATE_FILE, text: pinnedRevision - currentBuild.description = agcTriggered - ? "已触发 ${env.FULL_BUILD_JOB_NAME} 与 ${env.AGC_BUILD_JOB_NAME}:${env.SOURCE_BRANCH}@${pinnedRevision.take(12)}" - : "已触发 ${env.FULL_BUILD_JOB_NAME}(AGC 渠道未发布:本次提交不含 AGC 相关路径):${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 5b9e25f3a..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 From e146859ea27eb2054f6098dfdf98badf0dfc2863 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:42:10 +0800 Subject: [PATCH 07/16] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=91=98=E8=A6=81?= =?UTF-8?q?=E9=94=9A=E7=82=B9=E6=94=AF=E6=8C=81=20CI=20=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 摘要锚点优先取渠道清单 commit,缺失时回退 CI 传入的上一次成功构建 COMMIT_HASH - Jenkins 构建阶段解析上一次成功构建的 COMMIT_HASH 并注入 AGC_UPDATE_PREVIOUS_COMMIT,读取失败保持为空 - 新增锚点优先级与非法值忽略的定向用例 - 技术方案补充锚点兜底说明 --- .../scripts/build-release.mjs | 12 +++++++ .../scripts/build-release.test.mjs | 36 +++++++++++++++++++ ...方案】AGC客户端更新检查与下载-2026-08-31.md | 2 +- .../Jenkinsfile.ai-game-creator-shell-build | 17 +++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index e8c8e026d..4459fbe92 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -196,9 +196,21 @@ async function readRemoteChannelManifest(channel = resolveReleaseChannel()) { return fetchManifest(updateManifestUrl(channel), 'OSS 渠道清单'); } +/** + * 摘要锚点:上次发布对应的提交。 + * + * 首选渠道清单里的 `commit`(发布产物自己的事实来源);清单缺该字段时(首次启用 + * 摘要、或更换渠道后清单还没带过 commit)回退到 CI 传入的 `AGC_UPDATE_PREVIOUS_COMMIT` + * —— 它是上一次成功构建的 COMMIT_HASH,同样指向用户拿到的那个版本。 + */ export async function resolvePreviousReleaseCommit( channel = resolveReleaseChannel(), + { override = process.env.AGC_UPDATE_PREVIOUS_COMMIT } = {}, ) { + const explicit = override?.trim(); + if (explicit && /^[0-9a-f]{7,40}$/u.test(explicit)) { + return explicit; + } const manifest = await readRemoteChannelManifest(channel); const commit = typeof manifest?.commit === 'string' ? manifest.commit.trim() : ''; diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index cc2379f01..eb583e380 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -22,6 +22,7 @@ import { formatReleaseNotes, nextPatchVersion, resolveManifestPlatformKeys, + resolvePreviousReleaseCommit, resolveReleaseChannel, resolveRemoteHighWaterVersion, selectReleaseArtifact, @@ -241,6 +242,41 @@ test('version high water ignores the windows migration pointer for other channel ); }); +test('release notes anchor prefers the explicit commit and falls back to the manifest', async () => { + await withStubbedFetch( + () => jsonResponse({ version: '0.1.61', commit: 'abcdef1234567890' }), + async () => { + assert.equal( + await resolvePreviousReleaseCommit('dev-win', { + override: '6017d46088c04199e99cf89f347b12d67591475e', + }), + '6017d46088c04199e99cf89f347b12d67591475e', + ); + // 覆盖值非法时忽略,继续用清单里的 commit。 + assert.equal( + await resolvePreviousReleaseCommit('dev-win', { + override: 'not-a-sha', + }), + 'abcdef1234567890', + ); + assert.equal( + await resolvePreviousReleaseCommit('dev-win', { override: ' ' }), + 'abcdef1234567890', + ); + }, + ); + + await withStubbedFetch( + () => jsonResponse({ version: '0.1.61' }), + async () => { + assert.equal( + await resolvePreviousReleaseCommit('dev-win', { override: undefined }), + null, + ); + }, + ); +}); + test('release upload forces overwrite for artifact, signature and channel pointers', () => { const source = readFileSync( new URL('./release-upload.mjs', import.meta.url), diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 6b2f0841b..3c7201a22 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -92,7 +92,7 @@ - 发布入口:`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` 字段(上一次发布的提交)到本次提交之间、且只覆盖客户端相关路径的提交列表生成 `notes`(每条 `- 提交标题(短 SHA)`,最多 12 条、主题 80 字、整体 900 字,超出折叠或截断),同时写入旧协议清单的 `releaseNotes` 和归档文件 `release-notes.txt`。`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准;无法判定起点(缺少上次 `commit` 或本地没有该提交)时不写摘要。清单缺少 `commit` 时回退用上一次成功构建的 `COMMIT_HASH`(CI 通过 `AGC_UPDATE_PREVIOUS_COMMIT` 传入)作为锚点,因此首次启用摘要或更换渠道后也能立即产出摘要。 - 清单里的 `commit` 是非标准字段:更新插件忽略未知字段,发布脚本用它定位下一次摘要的起点。 - 上传:安装包与 `.sig` 上传到 `agc///`,清单以 `--force` 覆盖上传到 `agc//latest.json`,保证 latest 指针与清单内 URL 指向已存在的对象。 - Jenkins 流水线需要新增渠道参数与签名凭据;签名私钥与密码只以受保护凭据注入当前进程,不写入 workspace、日志或归档产物。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-build b/jenkins/Jenkinsfile.ai-game-creator-shell-build index db0bbf23b..11010b29b 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-build @@ -122,6 +122,22 @@ 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'), @@ -134,6 +150,7 @@ pipeline { "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 ''' From 724d94c95c77132bf0e084da6d7494465e005fee Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 20:00:50 +0800 Subject: [PATCH 08/16] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=91=98=E8=A6=81?= =?UTF-8?q?=E9=99=8D=E7=BA=A7=E4=B8=BA=E4=B8=8D=E9=98=BB=E6=96=AD=E5=8F=91?= =?UTF-8?q?=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 摘要锚点读取失败时降级为无锚点并告警,不再让整个发布失败 - 无锚点时列出最近 8 条客户端相关提交,并注明可能与上一版重复 - 新增锚点读取失败降级与最近提交兜底的定向用例 - 技术方案补充摘要不阻断发布的约束 --- .../scripts/build-release.mjs | 61 ++++++++++++++++--- .../scripts/build-release.test.mjs | 57 +++++++++++++++++ ...方案】AGC客户端更新检查与下载-2026-08-31.md | 2 +- 3 files changed, 112 insertions(+), 8 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 4459fbe92..fc4bbc8f4 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -211,10 +211,18 @@ export async function resolvePreviousReleaseCommit( if (explicit && /^[0-9a-f]{7,40}$/u.test(explicit)) { return explicit; } - const manifest = await readRemoteChannelManifest(channel); - const commit = - typeof manifest?.commit === 'string' ? manifest.commit.trim() : ''; - return /^[0-9a-f]{7,40}$/u.test(commit) ? commit : null; + try { + const manifest = await readRemoteChannelManifest(channel); + const commit = + typeof manifest?.commit === 'string' ? manifest.commit.trim() : ''; + return /^[0-9a-f]{7,40}$/u.test(commit) ? commit : null; + } catch (error) { + // 摘要只是附注:清单读不到(网络抖动等)不能把发布带崩,降级为「没有锚点」。 + console.warn( + `[ai-game-creator-shell] 读取摘要锚点失败,本次不写自动摘要:${error.message}`, + ); + return null; + } } /** @@ -546,6 +554,39 @@ export function formatReleaseNotes( return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text; } +/** 无锚点时的兜底:列出最近的客户端相关提交,并注明可能与上一版重复。 */ +export function collectRecentReleaseCommits({ + cwd = repoRoot, + paths = agcReleasePathPatterns, + limit = 8, +} = {}) { + let output; + try { + output = execFileSync( + 'git', + ['log', '--no-merges', `-n${limit}`, '--format=%h%x09%s', '--', ...paths], + { cwd, encoding: 'utf8' }, + ); + } catch { + return null; + } + const commits = output + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [sha = '', ...subject] = line.split('\t'); + return { sha, subject: subject.join('\t') }; + }); + return commits.length > 0 ? commits : null; +} + +export function formatRecentReleaseNotes(commits) { + const notes = formatReleaseNotes(commits, { limit: 8 }); + if (!notes) return ''; + return `最近客户端改动(未定位到上一次发布提交,可能与上一版重复):\n${notes}`; +} + /** 旧协议(sha256)清单:只用于把已发布客户端带到新渠道协议,一个版本周期后整条删除。 */ export function createLegacyUpdateManifest( artifactPath, @@ -572,10 +613,14 @@ export async function generateUpdateManifest() { const manualNotes = readReleaseNotes(); const previousCommit = await resolvePreviousReleaseCommit(channel); const commits = collectReleaseCommits(previousCommit); - const notes = manualNotes || formatReleaseNotes(commits); + const recentCommits = previousCommit ? null : collectRecentReleaseCommits(); + const notes = + manualNotes || + formatReleaseNotes(commits) || + formatRecentReleaseNotes(recentCommits); if (!manualNotes && !notes) { console.log( - `[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'})`, + `[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'},最近提交=${recentCommits ? recentCommits.length : '不可判定'})`, ); } const manifest = createUpdateManifest(artifact, { channel, notes }); @@ -606,7 +651,9 @@ export async function generateUpdateManifest() { console.log( manualNotes ? '[ai-game-creator-shell] 更新摘要:使用 AGC_UPDATE_RELEASE_NOTES 手动文案' - : `[ai-game-creator-shell] 更新摘要:自动汇总 ${commits ? commits.length : 0} 条客户端相关提交(起点 ${previousCommit ?? '无'})`, + : notes && !previousCommit + ? `[ai-game-creator-shell] 更新摘要:无锚点,列出最近 ${recentCommits ? recentCommits.length : 0} 条客户端相关提交` + : `[ai-game-creator-shell] 更新摘要:自动汇总 ${commits ? commits.length : 0} 条客户端相关提交(起点 ${previousCommit ?? '无'})`, ); console.log(`[ai-game-creator-shell] 更新摘要文件:${notesPath}`); if (legacyManifestPath) { diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index eb583e380..de0134267 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -14,11 +14,13 @@ import { fileURLToPath } from 'node:url'; import { agcReleasePathPatterns, + collectRecentReleaseCommits, collectReleaseCommits, compareVersions, createChannelConfig, createLegacyUpdateManifest, createUpdateManifest, + formatRecentReleaseNotes, formatReleaseNotes, nextPatchVersion, resolveManifestPlatformKeys, @@ -277,6 +279,61 @@ test('release notes anchor prefers the explicit commit and falls back to the man ); }); +test('release notes anchor degrades to null when the manifest cannot be read', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + throw new Error('fetch failed'); + }; + try { + assert.equal( + await resolvePreviousReleaseCommit('dev-win', { override: undefined }), + null, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('recent commit fallback marks that entries may repeat the previous release', () => { + const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-recent-git-')); + const git = (...args) => + execFileSync('git', args, { cwd: directory, encoding: 'utf8' }); + try { + git('init', '--quiet'); + git('config', 'user.email', 'release@example.test'); + git('config', 'user.name', 'release test'); + mkdirSync(path.join(directory, 'apps/ai-game-creator-shell'), { + recursive: true, + }); + for (const name of ['one', 'two']) { + writeFileSync( + path.join(directory, `apps/ai-game-creator-shell/${name}.rs`), + `fn ${name}() {}\n`, + ); + git('add', '.'); + git('commit', '--quiet', '-m', `客户端:${name}`); + } + writeFileSync(path.join(directory, 'README.md'), '# 文档\n'); + git('add', '.'); + git('commit', '--quiet', '-m', '文档:说明'); + + const recent = collectRecentReleaseCommits({ cwd: directory, limit: 5 }); + assert.deepEqual( + recent.map((entry) => entry.subject), + ['客户端:two', '客户端:one'], + ); + const notes = formatRecentReleaseNotes(recent); + assert.match( + notes, + /^最近客户端改动(未定位到上一次发布提交,可能与上一版重复):\n- 客户端:two/u, + ); + assert.equal(formatRecentReleaseNotes([]), ''); + assert.equal(formatRecentReleaseNotes(null), ''); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + test('release upload forces overwrite for artifact, signature and channel pointers', () => { const source = readFileSync( new URL('./release-upload.mjs', import.meta.url), diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 3c7201a22..97c7474ea 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -92,7 +92,7 @@ - 发布入口:`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` 传入)作为锚点,因此首次启用摘要或更换渠道后也能立即产出摘要。 +- 更新摘要自动生成:发布脚本用渠道清单里的 `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、日志或归档产物。 From c329c438d9bf34bc90cc2f04ca0df25c6721c754 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 20:17:26 +0800 Subject: [PATCH 09/16] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E7=94=BB=E5=B8=83PR=E7=9A=84CI=E5=9B=9E=E5=BD=92=EF=BC=88#410?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复活动回合空快照引用及停用后晚到请求覆盖问题 对齐共享卡片角标与窗口发布次数回归断言 同步原生HTTP权限检查与图集显式切片测试契约 整理菜单组件导入顺序并记录本地验证范围 --- .../src-tauri/src/tests/project.rs | 21 ++++++- .../src-tauri/src/tests/provider.rs | 10 +++- .../agent-runtime/directActiveTurns.ts | 27 ++++++--- .../src/view/project-development/index.tsx | 2 +- .../tests/directActiveTurns.test.tsx | 60 ++++++++++++++++++- .../tests/workspaceWindowSync.test.tsx | 8 +-- .../【实施计划】AGC资源菜单收纳-2026-09-17.md | 6 ++ docs/project-memory/shared-memory/pitfalls.md | 2 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 1 + .../components/CanvasCardCornerActions.tsx | 1 + .../src/components/OverflowActions.test.tsx | 3 +- .../shared/src/components/OverflowActions.tsx | 2 +- scripts/check-native-shells.mjs | 1 - ...CanvasEditorGenerationIntegration.test.tsx | 4 +- .../ImageCanvasEditorView.test.tsx | 4 +- 15 files changed, 123 insertions(+), 29 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 5d746c7cc..b2f1bdac8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -728,13 +728,17 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { "tool": "canvas.asset_generate", "reason": "生成可用于首版原型的主角素材", "input": { - "prompt": "透明 PNG 像素月光主角,适合厨房弹幕游戏", + "prompt": "透明 PNG 像素月光主角图集,按 2 行 2 列等分网格排布,适合厨房弹幕游戏", "outputPath": "assets/art-spritesheet.png", "aspectRatio": "1:1", "imageSize": "1K", "assetKind": "art-spritesheet", "assetLabel": "游戏首版核心美术素材", - "replaceExisting": false + "replaceExisting": false, + "sliceMode": "grid", + "gridX": 2, + "gridY": 2, + "sliceCount": null } } ], @@ -775,7 +779,7 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { start_game_creator_agent_background_task_at( &root, "art-asset-plan", - "为月光厨房生成首版主角素材", + "为月光厨房生成首版主角素材图集,按 2 行 2 列等分网格排布", "art-generate-run", ) .expect("start background task"); @@ -933,6 +937,17 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { let generation_idempotency_key = request_header(generation_request, "idempotency-key").expect("generation idempotency key"); assert!(uuid::Uuid::parse_str(&generation_idempotency_key).is_ok()); + let generation_body: Value = serde_json::from_str( + generation_request + .split_once("\r\n\r\n") + .expect("generation request body") + .1, + ) + .expect("generation request json"); + assert_eq!(generation_body["sliceMode"], "grid"); + assert_eq!(generation_body["gridX"], 2); + assert_eq!(generation_body["gridY"], 2); + assert!(generation_body["sliceCount"].is_null()); assert!(generation_request.contains(r#""source":"ai-game-creator-client""#)); assert_eq!( canvas_requests diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index dd4946ea4..7bc72b7af 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -6916,7 +6916,11 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() { "imageSize", "assetKind", "assetLabel", - "replaceExisting" + "replaceExisting", + "sliceMode", + "gridX", + "gridY", + "sliceCount" ]) ); assert_eq!( @@ -6927,6 +6931,10 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() { canvas_asset.parameters["properties"]["input"]["properties"]["imageSize"]["enum"], serde_json::json!(["0.5K", "1K", "2K", null]) ); + assert_eq!( + canvas_asset.parameters["properties"]["input"]["properties"]["sliceMode"]["enum"], + serde_json::json!(["connected-components", "grid", null]) + ); assert_eq!( canvas_asset.parameters["properties"]["input"]["properties"]["assetKind"]["enum"], serde_json::json!([ diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts index a9d4c478e..f7b69de22 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts @@ -45,7 +45,8 @@ export function useDirectActiveTurns({ * 换掉数组身份:所有依赖 `activeTurns` 的 effect 都会跟着重跑(窗口标题栏的活动项目 * 面板就是这么被反复重发布的)。这里只在内容真的变了才更新状态。 */ - const lastSnapshotSignatureRef = useRef(''); + const lastSnapshotSignatureRef = useRef('[]'); + const requestGenerationRef = useRef(0); const retryTimerRef = useRef(null); useEffect(() => { @@ -60,13 +61,16 @@ export function useDirectActiveTurns({ }, []); const refreshActiveTurns = useCallback(async () => { - if (!invoke) { + if (!enabled || !invoke) { return; } // 单飞:轮询与"回合刚开始/刚结束"的主动刷新不叠成两个在途请求。 if (inFlightRef.current) { return inFlightRef.current; } + const generation = requestGenerationRef.current; + const isCurrent = () => + mountedRef.current && generation === requestGenerationRef.current; const request = (async () => { for ( let attempt = 1; @@ -77,7 +81,7 @@ export function useDirectActiveTurns({ const turns = await invoke( 'list_game_creator_direct_active_turns', ); - if (!mountedRef.current) { + if (!isCurrent()) { return; } const nextTurns = Array.isArray(turns) ? turns : []; @@ -90,6 +94,7 @@ export function useDirectActiveTurns({ inFlightRef.current = null; return; } catch { + if (!isCurrent()) return; if (attempt < DIRECT_ACTIVE_TURNS_READ_ATTEMPTS) { await new Promise((resolve) => { retryTimerRef.current = window.setTimeout(() => { @@ -97,22 +102,23 @@ export function useDirectActiveTurns({ resolve(); }, DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt); }); + if (!isCurrent()) return; } } } // 三次都读不到:保留上一份快照(读不到不等于没有在跑),只标记"本次没读到"。 - if (mountedRef.current) { + if (isCurrent()) { setSnapshotReadFailed(true); + inFlightRef.current = null; } - inFlightRef.current = null; })(); inFlightRef.current = request; return request; - }, [invoke]); + }, [enabled, invoke]); useEffect(() => { if (!enabled || !invoke) { - lastSnapshotSignatureRef.current = ''; + lastSnapshotSignatureRef.current = '[]'; // 空态也要保持引用稳定:已经空了就不要再换一个新数组。 setActiveTurns((current) => (current.length === 0 ? current : [])); setSnapshotReadFailed((current) => (current ? false : current)); @@ -123,7 +129,12 @@ export function useDirectActiveTurns({ () => void refreshActiveTurns(), Math.max(1_000, pollIntervalMs), ); - return () => window.clearInterval(timer); + return () => { + window.clearInterval(timer); + // 停用或切换读取器后,旧请求不得覆盖新状态,也不能占住新一轮单飞。 + requestGenerationRef.current += 1; + inFlightRef.current = null; + }; }, [enabled, invoke, pollIntervalMs, refreshActiveTurns]); return { activeTurns, refreshActiveTurns, snapshotReadFailed }; 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 cd0e4276a..1675dabb4 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 @@ -14,8 +14,8 @@ import { CanvasChromeButton, SelectionOverlay, } from '@genarrative/image-canvas-react'; -import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; import { CanvasCardCornerActions } from '@genarrative/shared/components'; +import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; import { AtSign, Crosshair, diff --git a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx index 521cf634a..f7acdba4e 100644 --- a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx +++ b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx @@ -60,10 +60,11 @@ describe('useDirectActiveTurns', () => { { initialProps: { enabled: true } }, ); - await waitFor(() => { - expect(result.current.activeTurns).toEqual([]); - }); const emptySnapshot = result.current.activeTurns; + await act(async () => { + await result.current.refreshActiveTurns(); + }); + expect(result.current.activeTurns).toBe(emptySnapshot); rerender({ enabled: false }); expect(result.current.activeTurns).toBe(emptySnapshot); }); @@ -96,6 +97,59 @@ describe('useDirectActiveTurns', () => { clearTimeoutSpy.mockRestore(); } }); + + it('停用后晚到的非空快照不能恢复活动回合,手动刷新也不发请求', async () => { + let complete!: (turns: GameCreatorDirectActiveTurn[]) => void; + const invoke = vi.fn( + () => + new Promise((resolve) => { + complete = resolve; + }), + ); + const { result, rerender } = renderHook( + ({ enabled }) => + useDirectActiveTurns({ invoke: invoke as never, enabled }), + { initialProps: { enabled: true } }, + ); + rerender({ enabled: false }); + const empty = result.current.activeTurns; + await act(async () => { + complete([ACTIVE_TURN]); + await result.current.refreshActiveTurns(); + }); + expect(result.current.activeTurns).toBe(empty); + expect(result.current.snapshotReadFailed).toBe(false); + expect(invoke).toHaveBeenCalledTimes(1); + }); + + it('重新启用后读取新快照,旧请求晚到不能覆盖新快照', async () => { + let completeOld!: (turns: GameCreatorDirectActiveTurn[]) => void; + const invoke = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + completeOld = resolve; + }), + ) + .mockResolvedValue([{ ...ACTIVE_TURN, runId: 'new-run' }]); + const { result, rerender } = renderHook( + ({ enabled }) => + useDirectActiveTurns({ invoke: invoke as never, enabled }), + { initialProps: { enabled: true } }, + ); + rerender({ enabled: false }); + rerender({ enabled: true }); + await waitFor(() => + expect(result.current.activeTurns[0]?.runId).toBe('new-run'), + ); + const current = result.current.activeTurns; + await act(async () => { + completeOld([ACTIVE_TURN]); + }); + expect(result.current.activeTurns).toBe(current); + expect(invoke).toHaveBeenCalledTimes(2); + }); }); describe('ActiveProjectRunsPanel', () => { diff --git a/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx index 457f61417..897a71ac9 100644 --- a/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx +++ b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx @@ -84,8 +84,8 @@ it('真实窗口与工作台状态同步收敛,回调读取最新处理器且 await act(async () => { await Promise.resolve(); }); - // 无原生 invoke 时 active-turn Hook 会把初始快照归一为空数组一次。 - expect(publications).toHaveLength(2); + // 无原生 invoke 时空快照引用不变,只发布一次。 + expect(publications).toHaveLength(1); expect(cleanups).toBe(0); expect(new Set(publications.map((item) => item.onOpenProject)).size).toBe( 1, @@ -94,13 +94,13 @@ it('真实窗口与工作台状态同步收敛,回调读取最新处理器且 const latestOpen = vi.fn(async () => undefined); homeProjectOverride.openProject = latestOpen; rendered.rerender(view('更改显示名')); - expect(publications).toHaveLength(2); + expect(publications).toHaveLength(1); act(() => openProject('/tmp/window-latest-project')); expect(latestOpen).toHaveBeenCalledWith( '/tmp/window-latest-project', 'open', ); - expect(publications).toHaveLength(2); + expect(publications).toHaveLength(1); expect(cleanups).toBe(0); rendered.unmount(); expect(cleanups).toBe(1); diff --git a/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md b/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md index 96333cf43..6a8060348 100644 --- a/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md +++ b/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md @@ -2,6 +2,12 @@ 对应:[里程碑](./【里程碑】AGC资源菜单收纳-2026-09-17.md),Issue #409,产品已确认方案 A。 +## PR #410 CI 修复 + +以远端合并提交 959beebf 为基线:修复菜单文件 import 排序、Web 角标结构断言、活动回合空快照与晚到请求竞态;窗口发布次数断言对齐稳定快照合同。原生 HTTP scope 检查对齐官方 updater 当前权限,不恢复退役 OSS 白名单;Rust 图集测试补齐显式切片模式与 strict schema 字段,不放宽正式校验。按故障项定向测试后运行前端全套及原生契约检查;Rust 使用独立 target,实际未执行的检查必须单独列出。推送需再次确认。 + +本地修复验证:`npm test` 342 个文件通过(4137 项通过、37 项跳过),窗口与空快照最后一次定向复验 9 项通过;`lint:eslint`、根目录/AGC 类型检查、原生 contract 检查、Rust fmt、编码、文档索引与 diff 检查通过。Rust 工具目录 schema 用例及后台平台美术生成用例均在 Windows 独立 target 下通过;生成用例同时检查真实 mock 请求中的 grid、2×2 参数与响应匹配。未执行全量 Rust 分片、Linux CI、生产服务或真实客户端手感验收。 + 1. 在 shared 扩展通用操作收纳及卡片角标控件;共用工具栏只给 AGC 开启 5 项限制,Web 卡片迁移共用角标而不改现有回调。 2. AGC 卡片承接类型和信息,保留当前面板与命令链;信息使用资源身份防止换选竞态。 3. 补工具栏/工作台定向回归,检查禁用、移入、Escape、换选和卡片事件边界。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 52b39d70a..633245b1a 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -21,6 +21,8 @@ JSON 的文本读取分支不等于卡面应该展示原始 State 摘要。卡 工作台向窗口标题栏发布运行项目时,若 effect 依赖普通函数派生的回调,发布 Context 会重新渲染工作台,进而再次发布并清理,形成更新深度循环。转发入口须稳定,并在提交阶段更新实际处理器引用;发布数据变化与卸载清理分开。回归测试必须组合真实窗口 Provider 和工作台消费者,只有独立画布测试无法覆盖这条反馈链;回归时用有界发布次数阻止测试失控。画布快速操作时暴露的更新深度错误,也须检查外层状态同步,不能直接归因于滚轮频率。 +活动回合快照的初始签名须与初始空数组一致,首次异步返回空数组不能额外换引用。停用、重新启用或切换读取器时应使旧请求失效,避免晚到结果覆盖新快照;测试需控制 Promise 完成时机,不能用“初始数组已为空”当作请求已结束。无原生读取器时窗口只发布一次空状态。 + ## 2026-09-17 工具 schema 声明的上限与真实校验不一致,会表现成「agent 调不动这个功能」 - **现象**:用户反馈「客户端没法由 agent 调用图片快速编辑功能以及背景音乐生成功能」。查工具目录时两个工具都在(`agc_edit_image`、`agc_create_or_derive_resource`),图片快速编辑在真实项目日志里还有成功记录;但 agent 侧写一句正常长度的背景音乐描述就失败,而客户端 UI 用同一个提示词却只是被截断加提示。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 177465f2b..b1ee2415e 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -12,6 +12,7 @@ ## 资源画布交互与工作台状态同步 +- 活动回合轮询的初始空快照与后续空结果保持同一引用;停用或切换读取器使旧请求失效,晚到快照不得恢复已停用的活动回合或覆盖新轮询结果。无原生读取器时窗口只发布一次空状态,不通过额外空数组触发重复发布。 - 工作台向窗口标题栏发布正在运行的项目时,输入未变化不得形成重复发布与清理的渲染循环;打开项目动作始终使用当前工作台处理逻辑,退出工作台后清除其标题栏状态。 - 资源子画布(含「所有资源」)保留空白处左键框选、资源卡左键选中/拖动、触摸板双指平移及捏合缩放;右键按住空白处或资源卡拖动时平移画布,不改变资源选择与布局。中键和空格抓手继续可用。总览保留既有左键平移,并支持右键平移。 - 画布接管的右键手势不弹出原生菜单;输入框、媒体操作、工具条和独立浮层不被画布抢占。指针取消、捕获丢失或窗口失焦后终止平移,不能继续跟随指针。 diff --git a/packages/shared/src/components/CanvasCardCornerActions.tsx b/packages/shared/src/components/CanvasCardCornerActions.tsx index 4199f474c..9996e8923 100644 --- a/packages/shared/src/components/CanvasCardCornerActions.tsx +++ b/packages/shared/src/components/CanvasCardCornerActions.tsx @@ -1,5 +1,6 @@ import { Info } from 'lucide-react'; import type { CSSProperties, Ref } from 'react'; + import { PlatformIconButton } from './PlatformIconButton'; /** 画布卡片共用的类型标签与信息入口,不承接资源业务状态。 */ diff --git a/packages/shared/src/components/OverflowActions.test.tsx b/packages/shared/src/components/OverflowActions.test.tsx index 5f25b8d3a..85ad1d46a 100644 --- a/packages/shared/src/components/OverflowActions.test.tsx +++ b/packages/shared/src/components/OverflowActions.test.tsx @@ -7,8 +7,9 @@ import { within, } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { OverflowActions } from './OverflowActions'; + import { CanvasCardCornerActions } from './CanvasCardCornerActions'; +import { OverflowActions } from './OverflowActions'; afterEach(cleanup); describe('操作收纳与卡片角标', () => { diff --git a/packages/shared/src/components/OverflowActions.tsx b/packages/shared/src/components/OverflowActions.tsx index d79421308..23c4d222a 100644 --- a/packages/shared/src/components/OverflowActions.tsx +++ b/packages/shared/src/components/OverflowActions.tsx @@ -3,12 +3,12 @@ import { cloneElement, Fragment, isValidElement, + type ReactNode, useEffect, useId, useLayoutEffect, useRef, useState, - type ReactNode, } from 'react'; import { createPortal } from 'react-dom'; diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 6748f9628..7b5447faf 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -2582,7 +2582,6 @@ function assertAiGameCreatorShellUserDevBoundary() { JSON.stringify([ { url: 'https://dev.genarrative.world/api/*' }, { url: 'https://www.genarrative.world/api/*' }, - { url: 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/*' }, { url: 'https://*/api/*' }, { url: 'http://localhost:*/*' }, { url: 'http://127.0.0.1:*/*' }, diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index cdbef119d..1c3c42bf5 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -3875,9 +3875,7 @@ describe('ImageCanvasEditorView generation integration', () => { if (!metadataCornerButton) { throw new Error('metadata corner button should exist'); } - expect(metadataCornerButton.className).toContain( - 'image-canvas-editor__metadata-corner', - ); + expect(metadataCornerButton.className).toContain('shared-canvas-card-info'); fireEvent.click(metadataCornerButton); const metadataDialog = screen.getByRole('dialog', { name: '图片信息' }); diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 32d777569..92b947c88 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -1387,9 +1387,7 @@ describe('ImageCanvasEditorView', () => { const infoButton = screen.getByRole('button', { name: '查看拼图素材图片信息', }); - expect(infoButton.className).toContain( - 'image-canvas-editor__metadata-corner', - ); + expect(infoButton.className).toContain('shared-canvas-card-info'); fireEvent.click(infoButton); const infoPanel = screen.getByRole('dialog', { name: '图片信息' }); From d5743cd4b871f0107e3a9dd6e163d469dea4d65d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AE=B5=E8=88=92=E5=BA=B7?= Date: Thu, 17 Sep 2026 20:28:39 +0800 Subject: [PATCH 10/16] =?UTF-8?q?=E8=B5=84=E6=BA=90=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=BC=95=E6=93=8E=E8=B5=84=E6=BA=90=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E4=B8=8E=E6=A8=A1=E5=9E=8B=E4=BA=A4=E4=BA=92=E9=A2=84?= =?UTF-8?q?=E8=A7=88=20(#413)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 目的 资源画布支持引擎(Cocos Creator)资源的**只读预览**:能被发现、登记、进入画布,并按类型出预览。 ## 主要改动 - 发现层识别 Cocos 资源并区分 `model` / `binary` 两个发现类别;`.meta` 等导入侧车文件仍只可发现 - 登记层按扩展名写入**既有** canonical kind(模型/场景/预制体 → `scene`,动画 → `character-animation`,材质/特效 → `code`,图集与容器 → `document`),不新增 manifest 契约字段 - 引擎工程的 `library/` / `temp/` / `profiles/` / `local/` 不再进入发现结果(仅当目录确实是 Cocos 工程),同名目录在非引擎工程里照常列出 - 资源画布新增三个卡面分支:模型缩略图(整页共用一个 WebGL 上下文)、结构摘要(Cocos 序列化资源)、类型卡(客户端解不了的容器) - 新增模型放大预览浮层:左键旋转 / 右键或中键平移 / 滚轮缩放 / 复位视角 - 模型预览上限 32 MiB;缩略图按布局尺寸 2 倍超采样,缓存键改用稳定身份 - 引擎图像容器(tga/tif/tiff/hdr)在原生侧转码成 PNG 后复用既有图片预览链路 - 修复资源卡状态边框吃掉内容盒导致的卡面与角标位移 - 同步 Agent 提示词与 AGC 技能文档,并补里程碑与踩坑文档 ## 验证 - `cargo check`、`cargo fmt --check` - Rust 定向:`cargo test … cocos` 9 条、`resource_inspect::tests` 7 条、`agent_asset_import_tests` 9 条、生成目录过滤用例 - 前端:`resource` + `project` 套件 52 文件 / 546 用例;`appSurface` 450 通过 / 20 跳过;app `tsc --noEmit` - `npm run check:encoding`、`git diff --check`、`npm run check:doc-index` - 真机:在真实客户端内打开本地 Cocos 夹具工程,逐栏核对模型缩略图 / 结构摘要 / TGA 转码 / 类型卡,并量过卡片在指针移开、悬停、选中三态下几何完全一致 ## 未验证 - 模型缩略图与放大预览的真机视觉只覆盖自带夹具工程;多文件 glTF(外部 .bin/贴图)与超大模型仍是类型卡 --------- Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/413 --- apps/ai-game-creator-shell/package.json | 2 + .../src-tauri/Cargo.toml | 6 +- .../agc-skills/agc-client-projection/SKILL.md | 2 +- .../references/projection-contract.md | 2 +- .../resources/agc-skills/manifest.json | 4 +- .../src-tauri/src/agent/direct_runtime/mod.rs | 2 +- .../src-tauri/src/agent/direct_tool_bridge.rs | 95 +++- .../src-tauri/src/agent/direct_tools_mcp.rs | 8 +- .../src/agent/generation/prompt_context.rs | 36 +- .../src-tauri/src/commands.rs | 341 ++++++++++++- .../src-tauri/src/main.rs | 1 + .../src-tauri/src/project/filesystem.rs | 30 ++ .../src-tauri/src/resource_inspect.rs | 423 +++++++++++++++- .../src-tauri/src/tests/project.rs | 59 +++ .../resource-canvas/resourceCanvasChrome.css | 88 ++++ apps/ai-game-creator-shell/src/styles.css | 106 +++- .../ResourceModelPreview.tsx | 149 ++++++ .../ResourceModelPreviewDialog.tsx | 114 +++++ .../ResourceModelViewer.tsx | 192 ++++++++ .../ResourcePreviewMedia.tsx | 57 +++ .../src/view/project-development/index.tsx | 154 ++++++ .../resourceCardPreviewModel.ts | 157 +++++- .../project-development/resourceModelScene.ts | 118 +++++ .../resourceModelThumbnail.ts | 164 +++++++ .../resourceProjectionModel.ts | 89 +++- .../useProjectResourceCardPreviews.ts | 35 +- .../appSurface/project-development.suite.ts | 23 +- .../resourceCocosPreviewContract.test.tsx | 461 ++++++++++++++++++ ...程碑】资源画布支持引擎资源预览-2026-09-17.md | 57 +++ .../shared-memory/decision-log.md | 15 + docs/project-memory/shared-memory/pitfalls.md | 10 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- package-lock.json | 111 +++++ 33 files changed, 3060 insertions(+), 53 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreview.tsx create mode 100644 apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreviewDialog.tsx create mode 100644 apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx create mode 100644 apps/ai-game-creator-shell/src/view/project-development/resourceModelScene.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/resourceModelThumbnail.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx create mode 100644 docs/project-memory/plans/【里程碑】资源画布支持引擎资源预览-2026-09-17.md diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 442b1342e..3d56329d7 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -61,6 +61,7 @@ "react-window": "^1.8.11", "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", + "three": "^0.184.0", "vite": "^6.2.0", "zustand": "^5.0.14" }, @@ -72,6 +73,7 @@ "@testing-library/user-event": "^14.6.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/three": "^0.184.1", "@types/react-window": "^1.8.8", "tailwindcss": "^4.1.14", "typescript": "~5.8.2", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index ba259ccf3..f5cc8b673 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -33,7 +33,11 @@ chromiumoxide = "0.9.1" futures = "0.3" getrandom = "0.3" http = "1" -image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } +# `tga` / `tiff` / `hdr` 只服务资源画布的只读预览:引擎图像容器(Cocos 的 +# .tga/.tif/.hdr 等)在浏览器里没有解码器,必须先在原生侧转码成 PNG 再送给前端。 +# 刻意不开 `exr`:它要求 `exr ^1.74.0`,当前依赖源只能到 1.73,打不开就先让 +# `.exr` 走「类型卡」而不是留一半解不出来的预览分支。 +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "tga", "tiff", "hdr"] } jsonschema = { version = "0.49.3", default-features = false } oxc_allocator = "0.143.0" oxc_ast = "0.143.0" diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md index 84fb51e53..f25ab993d 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md @@ -10,7 +10,7 @@ Let the client derive projections from real disk changes and trusted tool result ## Workflow 1. Write executable source to `index.html`, `style.css`, and `game.js` in the current cwd. Use only relative paths returned by approved tools for media. -2. Before using or deriving an existing registered asset, call `agc_list_registered_assets` and select its `localAssetId`. If the user points to an existing project file that is not listed, first call `agc_list_project_files`; only entries with `assetImportable=true` (recognized image, font, audio, video, document, or code files) may be passed to `agc_import_account_assets.localPaths`. Then re-read `agc_list_registered_assets`; never infer a source identity from a filename or fabricate a localAssetId. +2. Before using or deriving an existing registered asset, call `agc_list_registered_assets` and select its `localAssetId`. If the user points to an existing project file that is not listed, first call `agc_list_project_files`; only entries with `assetImportable=true` (recognized image, font, audio, video, document, code, or engine asset such as a Cocos `.glb`/`.prefab`/`.anim`/`.mtl`/`.plist`/`.texture`) may be passed to `agc_import_account_assets.localPaths`. Then re-read `agc_list_registered_assets`; never infer a source identity from a filename or fabricate a localAssetId. 3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list. 4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization. 5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image. Keep `prompt` inside the per-kind limit that the client really enforces: background music at most 140 characters, sound effect at most 1900, video and character animation at most 4000. A longer prompt is rejected before submission, so write the short version first instead of retrying the same text. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md index 63ddd9a82..9129efc1a 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md @@ -8,7 +8,7 @@ The client projects three distinct facts: Do not collapse these facts. A playable file can exist before projection refresh, a registered image can exist without being used by the game, and browser success does not create platform provenance. -`agc_list_project_files` is the bounded Direct discovery path for real project files. It may report an unregistered project-relative path with size/MIME metadata, but that observation is not a resource identity and carries no provenance. Its `assetImportable` field is true for the file types accepted by the current local registration contract: PNG/JPEG/WEBP/GIF/SVG/AVIF/BMP images, TTF/OTF/WOFF fonts, MP3/WAV/OGG/FLAC/M4A/AAC/OPUS audio, MP4/WEBM/MOV video, recognized text documents, and recognized source-code files. `agc_import_account_assets.localPaths` is the controlled bridge that validates and registers an importable project-local resource. `agc_list_registered_assets` remains the authoritative Direct read path for manifest resource identity; only its stable identifiers may be passed to generation/derivation tools. +`agc_list_project_files` is the bounded Direct discovery path for real project files. It may report an unregistered project-relative path with size/MIME metadata, but that observation is not a resource identity and carries no provenance. Its `assetImportable` field is true for the file types accepted by the current local registration contract: PNG/JPEG/WEBP/GIF/SVG/AVIF/BMP/TGA/TIFF/HDR images, TTF/OTF/WOFF fonts, MP3/WAV/OGG/FLAC/M4A/AAC/OPUS/PCM audio, MP4/WEBM/MOV video, recognized text documents, recognized source-code files, and engine (Cocos Creator) assets such as `.glb`/`.gltf`/`.fbx`/`.mesh`/`.skeleton` models, `.anim`/`.animation`/`.animgraph`/`.animgraphvari`/`.animask` animation clips, `.scene`/`.fire`/`.prefab`/`.tmx`/`.terrain`, `.mtl`/`.material`/`.pmtl`/`.effect`/`.chunk`, `.plist`/`.labelatlas`/`.atlas`/`.fnt`/`.pac`, and engine containers such as `.texture`/`.cubemap`/`.rt`/`.dbbin`/`.bin`/`.skel`/`.psd`/`.znt`/`.exr`. `asset.list`/`kind` filtering classifies models as `model` and undecodable engine containers as `binary`; both are discovery categories, not manifest kinds. Registered engine assets are read-only previews on the resource canvas — models render a thumbnail, serialized assets show a structure summary, and containers that the client cannot decode show a type card. `agc_import_account_assets.localPaths` is the controlled bridge that validates and registers an importable project-local resource. `agc_list_registered_assets` remains the authoritative Direct read path for manifest resource identity; only its stable identifiers may be passed to generation/derivation tools. Read scopes remain separate: `asset.list` is the current project's local manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is authoritative for resources visible on that canvas. A library result must not be presented as the complete canvas list. `canvas.asset_import` accepts safe account/canvas asset IDs or project-relative local paths; receipts expose only bounded counts, safe IDs, relative paths, sources, redacted failures, and `revisionAdvanceCount`. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 385454c47..6debfefb3 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.19", + "version": "2026-08-26.20", "skills": [ { "name": "agc-game-production-workflow", @@ -123,7 +123,7 @@ "agents/openai.yaml", "references/projection-contract.md" ], - "sha256": "0700d4a7a18ee6151811f38786211ad416863f2e425fdc2ded67555a0a1923a1" + "sha256": "93210c0eeb73b279d35aa85c201c226139b0bdf041f3300ac2c6e2c1bdd63afe" } ] } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 945b16c1e..706015112 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -5305,7 +5305,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) })?; if prepare_art { - system_prompt.push_str("\n本回合已由陶泥儿平台准备并登记真实资源。请按需读取当前 cwd 的游戏源码;正式素材先用 `agc_list_registered_assets` 选择。如果发现项目中实际存在但清单没有的已识别图片、字体、音频、视频、文档或代码文件,先用 `agc_list_project_files` 发现,再把项目相对路径交给 `agc_import_account_assets.localPaths` 登记,随后重新读取 `agc_list_registered_assets`;不要从文件名伪造 assetId/localAssetId,也不要假设四切片一定存在或伪造缺失衍生物。客户端会在回合后启动真实 desktop/mobile 浏览器试玩,把结构化截图、Canvas、控制台、网络和交互证据发回同一会话;请依据证据自行决定是否继续修复。"); + system_prompt.push_str("\n本回合已由陶泥儿平台准备并登记真实资源。请按需读取当前 cwd 的游戏源码;正式素材先用 `agc_list_registered_assets` 选择。如果发现项目中实际存在但清单没有的已识别图片、字体、音频、视频、文档、代码或引擎资源(Cocos 的模型、动画、预制体、材质、图集、压缩纹理等),先用 `agc_list_project_files` 发现,再把项目相对路径交给 `agc_import_account_assets.localPaths` 登记,随后重新读取 `agc_list_registered_assets`;不要从文件名伪造 assetId/localAssetId,也不要假设四切片一定存在或伪造缺失衍生物。客户端会在回合后启动真实 desktop/mobile 浏览器试玩,把结构化截图、Canvas、控制台、网络和交互证据发回同一会话;请依据证据自行决定是否继续修复。"); emit_direct_game_creator_progress(root, "codex.start", "美术素材已准备,正在生成游戏代码"); } else { system_prompt.push_str("\n这是已有游戏的继续编辑回合:不要生成、下载或请求任何新美术,也不要创建新项目。直接读取当前 cwd 的游戏源码,并按用户需求最小修改;随后通过 `agc_browser_playtest` 获取真实 desktop/mobile 浏览器证据。客户端会把结构化证据回灌同一会话。"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 9e569eaea..f31a3cdd4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -1380,6 +1380,33 @@ fn bridge_project_file_class(path: &str) -> (&'static str, Option<&'static str>) "gd" | "rs" | "py" | "go" | "java" | "kt" | "kts" | "c" | "cc" | "cpp" | "h" | "hpp" | "cs" | "swift" | "php" | "rb" | "lua" | "sh" | "bash" | "zsh" | "sql" | "graphql" | "gql" | "vue" | "svelte" => ("code", Some("text/plain")), + /* + * Cocos Creator 资源(3.8.8 的 `engine-extends` 贡献的 `asset-handler` 表)。 + * + * 这里只做**发现分类**:`model` / `binary` 是发现层新词,与 manifest 资产 `kind` + * 不是同一套口径(登记时的 kind 见 `commands.rs::agent_local_project_file_type`)。 + * 只有 `mediaType` 非空的条目才会 `assetImportable=true`,因此这张表必须与登记层 + * 的白名单同步增删;`prompt_context.rs::prompt_context_media_type` 是同一套扩展名的 + * 第三份投影,同样要跟改。 + */ + "glb" => ("model", Some("model/gltf-binary")), + "gltf" => ("model", Some("model/gltf+json")), + "fbx" => ("model", Some("application/octet-stream")), + "mesh" | "skeleton" => ("model", Some("application/json")), + "scene" | "fire" | "prefab" | "anim" | "animation" | "animgraph" | "animgraphvari" + | "animask" | "mtl" | "material" | "pmtl" | "terrain" | "labelatlas" | "pac" => { + ("document", Some("application/json")) + } + "tmx" | "plist" => ("document", Some("application/xml")), + "effect" | "chunk" | "fnt" | "atlas" => ("document", Some("text/plain")), + "tga" => ("image", Some("image/x-tga")), + "tif" | "tiff" => ("image", Some("image/tiff")), + "hdr" => ("image", Some("image/vnd.radiance")), + "exr" => ("image", Some("image/x-exr")), + "dbbin" | "bin" | "skel" | "texture" | "cubemap" | "rt" | "psd" | "znt" => { + ("binary", Some("application/octet-stream")) + } + "pcm" => ("audio", Some("audio/pcm")), _ => ("other", None), } } @@ -1421,8 +1448,10 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value { .map(|value| value.to_lowercase()); let requested_kind = bridge_optional_bounded_string(arguments, "kind", 16)? .unwrap_or_else(|| "all".to_string()); - if !["all", "image", "font", "audio", "video", "document", "code"] - .contains(&requested_kind.as_str()) + if ![ + "all", "image", "font", "audio", "video", "document", "code", "model", "binary", + ] + .contains(&requested_kind.as_str()) { return Err("工具参数 kind 不是受支持的项目文件类别".to_string()); } @@ -3181,7 +3210,9 @@ mod tests { "supported raster image should be importable: {path}" ); } - for path in ["assets/theme.bin", "assets/unknown.xyz"] { + // `.bin` 现在是引擎的 BufferAsset 载体(Cocos 资源表里的 `buffer` handler), + // 因此不再属于「未识别文件」;真正未识别的扩展名仍然只能被发现。 + for path in ["assets/theme.dat", "assets/unknown.xyz"] { assert!( !bridge_project_file_is_asset_importable(path), "unsupported project file must not be advertised as importable: {path}" @@ -3201,6 +3232,64 @@ mod tests { } } + /// Cocos Creator 资源在发现层必须同时满足两件事:给出可筛选的类别、且 `mediaType` + /// 非空(`assetImportable` 由它推导,是 Agent 唯一能提交登记的入口)。 + /// + /// 变异验证:把任一扩展名从 `bridge_project_file_class` 删掉,本用例必须变红。 + #[test] + fn bridge_project_file_class_covers_cocos_creator_assets() { + for (path, expected_class, expected_media_type) in [ + ("assets/model/hero.glb", "model", "model/gltf-binary"), + ("assets/model/hero.gltf", "model", "model/gltf+json"), + ("assets/model/hero.fbx", "model", "application/octet-stream"), + ("assets/model/hero.mesh", "model", "application/json"), + ("assets/model/hero.skeleton", "model", "application/json"), + ("assets/scene/main.scene", "document", "application/json"), + ("assets/scene/enemy.prefab", "document", "application/json"), + ("assets/anim/walk.anim", "document", "application/json"), + ( + "assets/anim/graph.animgraph", + "document", + "application/json", + ), + ("assets/mtl/hero.mtl", "document", "application/json"), + ("assets/shader/glow.effect", "document", "text/plain"), + ("assets/atlas/hero.plist", "document", "application/xml"), + ("assets/map/level.tmx", "document", "application/xml"), + ("assets/font/bitmap.fnt", "document", "text/plain"), + ("assets/atlas/auto.pac", "document", "application/json"), + ("assets/tex/grass.tga", "image", "image/x-tga"), + ("assets/tex/height.hdr", "image", "image/vnd.radiance"), + ( + "assets/tex/hero.texture", + "binary", + "application/octet-stream", + ), + ( + "assets/spine/hero.skel", + "binary", + "application/octet-stream", + ), + ("assets/audio/voice.pcm", "audio", "audio/pcm"), + ] { + let (class, media_type) = bridge_project_file_class(path); + assert_eq!(class, expected_class, "{path}"); + assert_eq!(media_type, Some(expected_media_type), "{path}"); + assert!( + bridge_project_file_is_asset_importable(path), + "Cocos 资源必须可登记:{path}" + ); + } + // 引擎工程里的导入缓存不是资源:`.meta` 与未知扩展名仍然只能被发现。 + for path in ["assets/tex/hero.png.meta", "assets/world.unknown"] { + assert_eq!(bridge_project_file_class(path).0, "other", "{path}"); + assert!( + !bridge_project_file_is_asset_importable(path), + "非资源文件不得可登记:{path}" + ); + } + } + #[test] fn bridge_project_file_listing_projects_importability_per_file() { let temporary = tempfile::tempdir().expect("create project file listing root"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index f30331490..e9d9415df 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -369,7 +369,8 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab }, "kind": { "type": "string", - "enum": ["all", "image", "font", "audio", "video", "document", "code"] + "enum": ["all", "image", "font", "audio", "video", "document", "code", "model", "binary"], + "description": "image/font/audio/video/document/code 是通用类别;model 是 Cocos 等引擎的三维模型数据,binary 是只能发现、当前无法在客户端预览的二进制资源" }, "offset": { "type": "integer", "minimum": 0, "maximum": 500 }, "limit": { "type": "integer", "minimum": 1, "maximum": 100 } @@ -771,7 +772,10 @@ fn validate_project_file_list_arguments(arguments: &Value) -> Result<(), String> } if arguments.get("kind").is_some() { let kind = bounded_tool_string(arguments, "kind", 16)?; - if !["all", "image", "font", "audio", "video", "document", "code"].contains(&kind.as_str()) + if ![ + "all", "image", "font", "audio", "video", "document", "code", "model", "binary", + ] + .contains(&kind.as_str()) { return Err("工具参数 kind 不是受支持的项目文件类别".to_string()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs index 9d1f102c9..dc09f659e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs @@ -161,7 +161,7 @@ pub(crate) fn render_local_asset_prompt_context(root: &Path) -> Result Option<&'static str> { "js" | "mjs" | "cjs" | "ts" | "tsx" | "gd" | "rs" | "py" | "go" | "java" | "kt" | "kts" | "c" | "cc" | "cpp" | "h" | "hpp" | "cs" | "swift" | "php" | "rb" | "lua" | "sh" | "bash" | "zsh" | "sql" | "graphql" | "gql" | "vue" | "svelte" => Some("text/plain"), + /* + * Cocos Creator 资源。这里只是给未登记文件清单标注媒体类型,取值必须与 + * `agent/direct_tool_bridge.rs::bridge_project_file_class` 和 + * `commands.rs::agent_local_project_file_type` 一致:少写一个扩展名, + * Agent 的提示词就会把「其实可以登记」的资源说成只能发现。 + */ + "glb" => Some("model/gltf-binary"), + "gltf" => Some("model/gltf+json"), + "fbx" | "dbbin" | "bin" | "skel" | "texture" | "cubemap" | "rt" | "psd" | "znt" => { + Some("application/octet-stream") + } + "scene" | "fire" | "prefab" | "anim" | "animation" | "animgraph" | "animgraphvari" + | "animask" | "mtl" | "material" | "pmtl" | "terrain" | "labelatlas" | "pac" | "mesh" + | "skeleton" => Some("application/json"), + "tmx" | "plist" => Some("application/xml"), + "effect" | "chunk" | "fnt" | "atlas" => Some("text/plain"), + "tga" => Some("image/x-tga"), + "tif" | "tiff" => Some("image/tiff"), + "hdr" => Some("image/vnd.radiance"), + "exr" => Some("image/x-exr"), + "pcm" => Some("audio/pcm"), _ => None, } } @@ -246,10 +267,21 @@ mod tests { ("assets/data.json", "application/json"), ("game/index.html", "text/html"), ("game/main.rs", "text/plain"), + // 引擎资源同样要在未登记清单里被标出可登记:漏一个扩展名, + // Agent 就会把「其实可以登记」的 Cocos 资源说成只能发现。 + ("assets/model/hero.glb", "model/gltf-binary"), + ("assets/model/hero.fbx", "application/octet-stream"), + ("assets/anim/walk.anim", "application/json"), + ("assets/scene/main.scene", "application/json"), + ("assets/shader/glow.effect", "text/plain"), + ("assets/atlas/hero.plist", "application/xml"), + ("assets/tex/grass.tga", "image/x-tga"), + ("assets/audio/voice.pcm", "audio/pcm"), ] { assert_eq!(prompt_context_media_type(path), Some(expected), "{path}"); } - assert_eq!(prompt_context_media_type("assets/unknown.bin"), None); + // `.bin` 现在是引擎 BufferAsset 的载体,不再是「未识别」;真正未知的扩展名才返回 None。 + assert_eq!(prompt_context_media_type("assets/unknown.dat"), None); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 739dc5a3f..a2e86d06e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -3092,7 +3092,7 @@ mod agent_asset_import_tests { #[test] fn local_project_asset_import_registers_multiple_types_and_is_idempotent() { - let project = tempfile::tempdir().expect("create project directory"); + let project = crate::tests::canonical_test_tempdir("agent-local-import-"); let root = project.path(); init_local_game_project_at(root, "agent-local-import", "Agent local import") .expect("initialize project"); @@ -3185,7 +3185,7 @@ mod agent_asset_import_tests { #[test] fn local_project_asset_import_rejects_absolute_and_case_insensitive_agent_paths() { - let project = tempfile::tempdir().expect("create project directory"); + let project = crate::tests::canonical_test_tempdir("agent-local-import-"); let root = project.path(); init_local_game_project_at(root, "agent-local-import", "Agent local import") .expect("initialize project"); @@ -3202,7 +3202,7 @@ mod agent_asset_import_tests { #[test] fn local_project_asset_import_rejects_hidden_and_build_tree_sources() { - let project = tempfile::tempdir().expect("create project directory"); + let project = crate::tests::canonical_test_tempdir("agent-local-import-"); let root = project.path(); init_local_game_project_at(root, "agent-local-import", "Agent local import") .expect("initialize project"); @@ -3232,20 +3232,147 @@ mod agent_asset_import_tests { #[test] fn local_project_asset_import_rejects_unknown_and_invalid_text_files() { - let project = tempfile::tempdir().expect("create project directory"); + let project = crate::tests::canonical_test_tempdir("agent-local-import-"); let root = project.path(); init_local_game_project_at(root, "agent-local-import", "Agent local import") .expect("initialize project"); fs::create_dir_all(root.join("assets")).expect("create assets directory"); - fs::write(root.join("assets/unknown.bin"), b"bytes").expect("write unknown file"); + fs::write(root.join("assets/unknown.dat"), b"bytes").expect("write unknown file"); fs::write(root.join("assets/broken.js"), [0xff, 0xfe]).expect("write invalid source"); assert!( - import_local_project_assets_for_agent(root, &["assets/unknown.bin".to_string()]) + import_local_project_assets_for_agent(root, &["assets/unknown.dat".to_string()]) .is_err() ); assert!( import_local_project_assets_for_agent(root, &["assets/broken.js".to_string()]).is_err() ); + // `.bin` 是引擎的 BufferAsset 载体,属于「已识别但只能出类型卡」的一类: + // 登记必须成功,否则引擎工程里的 BufferAsset 永远进不了资源画布。 + fs::write(root.join("assets/blob.bin"), [0x00, 0x01, 0x02]).expect("write buffer asset"); + let imported = + import_local_project_assets_for_agent(root, &["assets/blob.bin".to_string()]) + .expect("import engine buffer asset"); + assert_eq!(imported.assets.len(), 1); + assert_eq!(imported.assets[0].asset_kind.as_deref(), Some("document")); + } + + /// Cocos Creator 资源登记:模型、动画、序列化资源与引擎容器都要能进 manifest, + /// 且 `kind` 只落在**既有 canonical 词表**里(不新增契约值,旧客户端仍能读 manifest)。 + /// + /// 变异验证:把任一扩展名从 `agent_local_project_file_type` 删掉即变红。 + #[test] + fn local_project_asset_import_registers_cocos_creator_assets() { + // 工程自带助手:canonicalize + 目录 owner 归当前用户,避免 `%TEMP%` 临时目录 + // 在 Windows owner 校验下直接失败。 + let project = crate::tests::canonical_test_tempdir("cocos-asset-import-"); + let root = project.path(); + init_local_game_project_at(root, "cocos-import", "Cocos import") + .expect("initialize project"); + for directory in [ + "model", "anim", "scene", "mtl", "shader", "atlas", "map", "tex", "audio", + ] { + fs::create_dir_all(root.join("assets").join(directory)).expect("create assets subdir"); + } + let mut glb = b"glTF".to_vec(); + glb.extend_from_slice(&[2, 0, 0, 0, 12, 0, 0, 0]); + let mut fbx = b"Kaydara FBX Binary \x00".to_vec(); + fbx.extend_from_slice(&[0; 16]); + for (path, bytes) in [ + ("assets/model/hero.glb", glb), + ("assets/model/hero.fbx", fbx), + ( + "assets/anim/walk.anim", + b"[{\"__type__\":\"cc.AnimationClip\"}]".to_vec(), + ), + ( + "assets/anim/graph.animgraph", + b"{\"__type__\":\"cc.animation.AnimationGraph\"}".to_vec(), + ), + ( + "assets/scene/main.scene", + b"[{\"__type__\":\"cc.SceneAsset\"}]".to_vec(), + ), + ( + "assets/scene/enemy.prefab", + b"[{\"__type__\":\"cc.Prefab\"}]".to_vec(), + ), + ( + "assets/mtl/hero.mtl", + b"{\"__type__\":\"cc.Material\"}".to_vec(), + ), + ("assets/shader/glow.effect", b"CCEffect %{\n}".to_vec()), + ( + "assets/atlas/hero.plist", + b"".to_vec(), + ), + ( + "assets/map/level.tmx", + b"".to_vec(), + ), + ("assets/tex/hero.texture", vec![0xff, 0x00, 0x01]), + ("assets/audio/voice.pcm", vec![0x00, 0x01, 0x02]), + ] { + fs::write(root.join(path), bytes).expect("write cocos asset"); + } + + let relative_paths = [ + "assets/model/hero.glb", + "assets/model/hero.fbx", + "assets/anim/walk.anim", + "assets/anim/graph.animgraph", + "assets/scene/main.scene", + "assets/scene/enemy.prefab", + "assets/mtl/hero.mtl", + "assets/shader/glow.effect", + "assets/atlas/hero.plist", + "assets/map/level.tmx", + "assets/tex/hero.texture", + "assets/audio/voice.pcm", + ] + .map(str::to_string) + .to_vec(); + let imported = + import_local_project_assets_for_agent(root, &relative_paths).expect("import cocos"); + let kinds = imported + .assets + .iter() + .map(|asset| (asset.local_path.as_str(), asset.asset_kind.as_deref())) + .collect::>(); + assert_eq!(kinds.get("assets/model/hero.glb"), Some(&Some("scene"))); + assert_eq!(kinds.get("assets/model/hero.fbx"), Some(&Some("scene"))); + assert_eq!( + kinds.get("assets/anim/walk.anim"), + Some(&Some("character-animation")) + ); + assert_eq!( + kinds.get("assets/anim/graph.animgraph"), + Some(&Some("character-animation")) + ); + assert_eq!(kinds.get("assets/scene/main.scene"), Some(&Some("scene"))); + assert_eq!(kinds.get("assets/scene/enemy.prefab"), Some(&Some("scene"))); + assert_eq!(kinds.get("assets/mtl/hero.mtl"), Some(&Some("code"))); + assert_eq!(kinds.get("assets/shader/glow.effect"), Some(&Some("code"))); + assert_eq!( + kinds.get("assets/atlas/hero.plist"), + Some(&Some("document")) + ); + // 瓦片地图与场景/预制体同栏(`scene`),不是「文档」:它描述的是可摆放的地图。 + assert_eq!(kinds.get("assets/map/level.tmx"), Some(&Some("scene"))); + assert_eq!( + kinds.get("assets/tex/hero.texture"), + Some(&Some("document")) + ); + assert_eq!(kinds.get("assets/audio/voice.pcm"), Some(&Some("audio"))); + + // 非 UTF-8 的 `.prefab` 会被 `document` 分支拒绝:结构化文本资源必须是 UTF-8, + // 否则「结构预览」拿到的是一堆乱码。 + fs::write(root.join("assets/scene/broken.prefab"), [0xff, 0xfe]) + .expect("write invalid prefab"); + assert!(import_local_project_assets_for_agent( + root, + &["assets/scene/broken.prefab".to_string()] + ) + .is_err()); } /// 平台导入(账户素材库 / 网页项目画布)落盘的 `kind`:**有真实类型就用真实类型**, @@ -3962,6 +4089,140 @@ fn agent_local_project_file_type( }, max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), + /* + * Cocos Creator(3.8.8)资源:三维模型、动画、材质/特效、场景/预制体、图集与 + * 压缩纹理容器。登记边界只做两件事:给出可判定的 `asset_kind` 与**预览通道** + * 能支撑的 `media_type`。 + * + * - `document`:Cocos 自己序列化的文本/JSON(要过 UTF-8 校验),卡面按结构预览; + * - `binary`:客户端无法解码的容器(模型、压缩纹理、Spine 二进制等),只要求非空; + * - `image`:可用原生解码转码成 PNG 再预览的图像容器(tga/tif/tiff/hdr/exr)。 + * + * 这些扩展名必须与 `agent/direct_tool_bridge.rs::bridge_project_file_class` 和 + * `agent/generation/prompt_context.rs::prompt_context_media_type` 同步,否则会出现 + * 「发现得了、登记不了」或「登记得了、Agent 看不见」的分叉。 + */ + "scene" | "fire" | "prefab" | "terrain" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "scene", + media_type: "application/json", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "tmx" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "scene", + media_type: "application/xml", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "anim" | "animation" | "animgraph" | "animgraphvari" | "animask" => { + Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "character-animation", + media_type: "application/json", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }) + } + "mtl" | "material" | "pmtl" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "code", + media_type: "application/json", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "effect" | "chunk" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "code", + media_type: "text/plain", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "plist" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "document", + media_type: "application/xml", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "labelatlas" | "pac" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "document", + media_type: "application/json", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "fnt" | "atlas" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "document", + media_type: "text/plain", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "glb" => Some(AgentLocalProjectFileType { + category: "binary", + asset_kind: "scene", + media_type: "model/gltf-binary", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "gltf" => Some(AgentLocalProjectFileType { + category: "binary", + asset_kind: "scene", + media_type: "model/gltf+json", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "fbx" => Some(AgentLocalProjectFileType { + category: "binary", + asset_kind: "scene", + media_type: "application/octet-stream", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "mesh" | "skeleton" => Some(AgentLocalProjectFileType { + // Cocos 的 `.mesh` / `.skeleton` 是网格与骨骼的实例化数据,多数工程里是 + // JSON、但也存在二进制变体,因此只按「非空」校验;能不能当文本预览由 + // 结构化预览读取自己判定(非 UTF-8 时降级成类型卡)。 + category: "binary", + asset_kind: "scene", + media_type: "application/json", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "dbbin" | "bin" | "skel" | "texture" | "cubemap" | "rt" => { + Some(AgentLocalProjectFileType { + category: "binary", + asset_kind: "document", + media_type: "application/octet-stream", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }) + } + "psd" | "znt" => Some(AgentLocalProjectFileType { + category: "binary", + asset_kind: "image", + media_type: "application/octet-stream", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "tga" => Some(AgentLocalProjectFileType { + category: "image", + asset_kind: "image", + media_type: "image/x-tga", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "tif" | "tiff" => Some(AgentLocalProjectFileType { + category: "image", + asset_kind: "image", + media_type: "image/tiff", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "hdr" => Some(AgentLocalProjectFileType { + category: "image", + asset_kind: "image", + media_type: "image/vnd.radiance", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "exr" => Some(AgentLocalProjectFileType { + category: "image", + asset_kind: "image", + media_type: "image/x-exr", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "pcm" => Some(AgentLocalProjectFileType { + category: "audio", + asset_kind: "audio", + media_type: "audio/pcm", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), _ => None, }; let file_type = file_type.ok_or_else(|| format!("本地文件类型不受支持:{relative_path}"))?; @@ -5055,6 +5316,65 @@ pub(crate) fn read_local_project_text_preview_at( Ok(preview) } +/** + * 读取引擎(Cocos)序列化资源的**只读结构预览**。 + * + * 与文本预览分开的理由:`.prefab` / `.scene` / `.anim` / `.effect` 这些扩展名不属于 + * 「可编辑文本资源」白名单(那份名单同时服务 UI 编辑器),把它们并进去会顺手改变 + * UI 编辑链路的准入;这里只服务资源画布的卡面预览,且允许非 UTF-8 的二进制变体 + * 降级成类型卡(`content: null`),不把「不能预览」报成错误。 + */ +#[tauri::command] +pub(crate) async fn read_local_project_structured_preview( + preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, + project_path: String, + relative_path: String, + scope_id: String, + request_id: String, +) -> Result { + preview_manager + .run(&scope_id, &request_id, move |cancellation| { + read_local_project_structured_preview_at(&project_path, &relative_path, cancellation) + }) + .await +} + +pub(crate) fn read_local_project_structured_preview_at( + project_path: &str, + relative_path: &str, + cancellation: &ProjectResourcePreviewScopeCancellation, +) -> Result { + cancellation.check()?; + let root = Path::new(project_path.trim()); + enforce_project_auto_permission_policy(root, "file.read")?; + cancellation.check()?; + let normalized_path = normalize_relative_path(relative_path.trim())?; + let manifest = read_manifest_cached_for_preview(&root.join(".agent/manifest.json"))?; + cancellation.check()?; + let registered_media_type = manifest + .assets + .iter() + .find(|asset| asset.local_path == normalized_path) + .map(|asset| asset.media_type.clone()); + let is_registered_structured = registered_media_type.as_deref().is_some_and(|media_type| { + is_supported_project_structured_resource(&normalized_path, media_type) + }) || manifest.tasks.iter().any(|task| { + task.status == GameCreationAppTaskStatus::Completed + && task.artifacts.iter().any(|path| path == &normalized_path) + && is_supported_project_structured_resource(&normalized_path, "") + }); + if !is_registered_structured { + return Err("只能读取当前项目已登记的引擎资源".to_string()); + } + cancellation.check()?; + load_local_project_structured_preview_with_cancellation( + root, + &normalized_path, + registered_media_type.as_deref().unwrap_or(""), + cancellation, + ) +} + #[tauri::command] pub(crate) async fn read_local_project_media_preview( preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, @@ -5094,7 +5414,8 @@ pub(crate) fn read_local_project_media_preview_at( let kind = match category.trim() { "art" => ProjectMediaPreviewKind::Art, "audio" => ProjectMediaPreviewKind::Audio, - _ => return Err("媒体预览类别只支持 art 或 audio".to_string()), + "model" => ProjectMediaPreviewKind::Model, + _ => return Err("媒体预览类别只支持 art、audio 或 model".to_string()), }; let is_registered_media = manifest.assets.iter().any(|asset| { asset.local_path == normalized_path @@ -5105,6 +5426,9 @@ pub(crate) fn read_local_project_media_preview_at( ProjectMediaPreviewKind::Audio => { is_supported_project_audio_resource(&asset.local_path, &asset.media_type) } + ProjectMediaPreviewKind::Model => { + is_supported_project_model_resource(&asset.local_path, &asset.media_type) + } } }) || manifest.tasks.iter().any(|task| { task.status == GameCreationAppTaskStatus::Completed @@ -5116,6 +5440,9 @@ pub(crate) fn read_local_project_media_preview_at( ProjectMediaPreviewKind::Audio => { is_supported_project_audio_resource(&normalized_path, "") } + ProjectMediaPreviewKind::Model => { + is_supported_project_model_resource(&normalized_path, "") + } } }); if !is_registered_media { diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 60d3113a3..68ba228eb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2630,6 +2630,7 @@ fn main() { read_local_project_image_preview, save_local_project_asset_file, read_local_project_text_preview, + read_local_project_structured_preview, read_local_project_media_preview, cancel_local_project_resource_preview_scope, write_local_project_file, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index 6f067628b..1e634bbda 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -1,5 +1,21 @@ use super::*; +/** + * Cocos Creator 工程根目录下的**生成目录**(导入缓存、构建临时目录、编辑器本地配置)。 + * + * 判定刻意收窄到「工程根的**直接子目录**且名字是这四个之一」:`assets/library/` 是资源 + * 目录里的普通文件夹,不属于这里。 + */ +fn is_engine_generated_root_directory(relative_path: &str) -> bool { + if relative_path.contains('/') { + return false; + } + matches!( + relative_path.to_ascii_lowercase().as_str(), + "library" | "temp" | "profiles" | "local" + ) +} + pub(crate) fn list_local_project_files_at( root: &Path, ) -> Result { @@ -11,6 +27,15 @@ pub(crate) fn list_local_project_files_at( }); } + /* + * 引擎生成目录的过滤只在**当前目录确实是 Cocos Creator 工程**时生效。 + * + * 这里不能把 `library` / `temp` / `profiles` / `local` 加进全局跳过表:这些名字在 + * 别的工程里可能是真实源码目录(例如自带 `library/` 的库工程)。而 Cocos 工程的 + * `library/` 是导入缓存,常有上万条生成文件,既会挤满 Agent 的发现窗口,也会让 + * 前端资源树加载一堆永远用不上的条目。 + */ + let skip_engine_generated_directories = discover_local_cocos_project_root(root)?.is_some(); let mut files = Vec::new(); let mut dirs = vec![root.to_path_buf()]; while let Some(dir) = dirs.pop() { @@ -41,6 +66,11 @@ pub(crate) fn list_local_project_files_at( .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) .unwrap_or(0); if file_type.is_dir() { + if skip_engine_generated_directories + && is_engine_generated_root_directory(&relative_path) + { + continue; + } files.push(LocalProjectFileEntry { path: relative_path, kind: "directory".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs index d7ca4b4ba..3a666b253 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs @@ -13,6 +13,18 @@ use std::path::Path; const PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024; const PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES: u64 = 32 * 1024 * 1024; +/** + * 模型预览的字节上限。 + * + * 模型要整份送进渲染器才能出预览,而预览载荷是 base64 data URL(约放大 1/3): + * 与通用媒体上限(`PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES`)取同一个 32 MiB, + * 覆盖绝大多数 `.glb` / `.fbx`,同时把单条 IPC 载荷压在约 43 MiB 以内。 + * + * 超过上限的模型不报错、也不半渲染:卡面直接降级成类型卡(见前端 + * `projectResourceCardPreviewKind` 的 `model` 分支)。真要再往上放宽,得先把预览载荷 + * 从 base64 JSON 换成 Tauri 的原始字节通道,否则 JS 侧的字符串副本会先炸掉内存。 + */ +const PROJECT_MODEL_PREVIEW_MAX_FILE_BYTES: u64 = 32 * 1024 * 1024; const PROJECT_RESOURCE_PREVIEW_READ_CHUNK_BYTES: usize = 64 * 1024; const PROJECT_MEDIA_PREVIEW_MAX_DIMENSION: u32 = 8_192; const PROJECT_MEDIA_PREVIEW_MAX_PIXELS: u64 = 32 * 1024 * 1024; @@ -46,6 +58,68 @@ pub(crate) struct LocalProjectMediaPreview { pub(crate) enum ProjectMediaPreviewKind { Art, Audio, + /// 三维模型(`.glb` / `.gltf` / `.fbx`):整份字节交给前端渲染器出预览。 + Model, +} + +/** + * 引擎资源结构预览的读取结果。 + * + * `content` 为 `None` 表示该资源不是 UTF-8 文本(例如二进制的 `.pac` 变体)—— + * 这是**正常降级**而不是错误:卡面据此画类型卡,不再向用户报「预览失败」。 + */ +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LocalProjectStructuredPreview { + pub(crate) path: String, + pub(crate) media_type: String, + pub(crate) byte_len: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) content: Option, +} + +/** + * 引擎(Cocos 等)序列化资源的结构预览准入。 + * + * 与 `is_supported_project_text_resource` **刻意分开**:那份白名单同时服务 UI 编辑器 + * 的「可编辑文本资源」判定,把 `.prefab` / `.scene` / `.anim` 塞进去会让它们在这条 + * 编辑链路里被当成普通文本资产;这里只服务资源画布的只读结构预览。 + */ +pub(crate) fn is_supported_project_structured_resource(path: &str, media_type: &str) -> bool { + if !matches!( + path_extension(path).as_deref(), + Some( + "scene" + | "fire" + | "prefab" + | "anim" + | "animation" + | "animgraph" + | "animgraphvari" + | "animask" + | "mtl" + | "material" + | "pmtl" + | "effect" + | "chunk" + | "tmx" + | "plist" + | "labelatlas" + | "atlas" + | "fnt" + | "pac" + | "mesh" + | "skeleton" + ) + ) { + return false; + } + let media_type = media_type.trim().to_ascii_lowercase(); + !(media_type.starts_with("audio/") + || media_type.starts_with("video/") + || media_type.starts_with("image/") + || media_type.starts_with("model/") + || media_type.starts_with("font/")) } pub(crate) fn is_supported_project_text_resource(path: &str, media_type: &str) -> bool { @@ -116,11 +190,33 @@ pub(crate) fn is_supported_project_art_media_resource(path: &str, media_type: &s let media_type = media_type.trim().to_ascii_lowercase(); matches!( path_extension(path).as_deref(), - Some("gif" | "svg" | "avif" | "bmp" | "mp4" | "webm" | "mov") + Some( + "gif" + | "svg" + | "avif" + | "bmp" + | "mp4" + | "webm" + | "mov" + // 引擎图像容器:浏览器解不了,先由原生侧转码成 PNG 再走同一条预览链路。 + | "tga" + | "tif" + | "tiff" + | "hdr" + ) ) || media_type.starts_with("video/") || media_type == "image/svg+xml" } +/// 模型预览准入:引擎三维模型与网格数据(Cocos 的 `.glb` / `.gltf` / `.fbx`)。 +pub(crate) fn is_supported_project_model_resource(path: &str, media_type: &str) -> bool { + let media_type = media_type.trim().to_ascii_lowercase(); + matches!( + path_extension(path).as_deref(), + Some("glb" | "gltf" | "fbx") + ) || media_type.starts_with("model/") +} + pub(crate) fn is_supported_project_audio_resource(path: &str, media_type: &str) -> bool { let media_type = media_type.trim().to_ascii_lowercase(); matches!( @@ -184,6 +280,56 @@ pub(crate) fn load_local_project_media_preview( ) } +/** + * 读取引擎序列化资源的结构预览。 + * + * `media_type` 由调用方从 manifest 登记项透传(任务产物没有登记项,传空串): + * 这条读取**不自己维护第五份扩展名表**,准入判定与登记口径共用同一个函数。 + */ +pub(crate) fn load_local_project_structured_preview_with_cancellation( + root: &Path, + relative_path: &str, + media_type: &str, + cancellation: &ProjectResourcePreviewScopeCancellation, +) -> Result { + cancellation.check()?; + let normalized = normalize_relative_path(relative_path.trim())?; + reject_sensitive_project_file_read(&normalized)?; + if !is_supported_project_structured_resource(&normalized, media_type) { + return Err("结构预览只支持引擎序列化资源".to_string()); + } + let bytes = read_stable_project_resource( + root, + &normalized, + PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES, + "引擎资源", + cancellation, + )?; + cancellation.check()?; + let byte_len = bytes.len() as u64; + let media_type = project_structured_media_type(&normalized); + Ok(LocalProjectStructuredPreview { + path: normalized, + media_type: media_type.to_string(), + byte_len, + // 非 UTF-8 的二进制变体不是错误:返回 `None`,由卡面降级成类型卡。 + content: String::from_utf8(bytes).ok(), + }) +} + +fn project_structured_media_type(path: &str) -> &'static str { + match path_extension(path).as_deref() { + Some("effect" | "chunk" | "fnt" | "atlas") => "text/plain", + Some("tmx" | "plist") => "application/xml", + Some( + "scene" | "fire" | "prefab" | "terrain" | "anim" | "animation" | "animgraph" + | "animgraphvari" | "animask" | "mtl" | "material" | "pmtl" | "labelatlas" | "pac" + | "mesh" | "skeleton", + ) => "application/json", + _ => "application/octet-stream", + } +} + pub(crate) fn load_local_project_media_preview_with_cancellation( root: &Path, relative_path: &str, @@ -193,29 +339,107 @@ pub(crate) fn load_local_project_media_preview_with_cancellation( cancellation.check()?; let normalized = normalize_relative_path(relative_path.trim())?; reject_sensitive_project_file_read(&normalized)?; - let bytes = read_stable_project_resource( - root, - &normalized, - PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES, - "项目媒体资源", - cancellation, - )?; + let (max_bytes, label) = match kind { + ProjectMediaPreviewKind::Model => (PROJECT_MODEL_PREVIEW_MAX_FILE_BYTES, "项目模型资源"), + ProjectMediaPreviewKind::Art | ProjectMediaPreviewKind::Audio => { + (PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES, "项目媒体资源") + } + }; + let bytes = read_stable_project_resource(root, &normalized, max_bytes, label, cancellation)?; if bytes.is_empty() { return Err("媒体文件为空,无法预览".to_string()); } cancellation.check()?; - let media_type = detect_project_media_type(&normalized, &bytes, kind)?; - cancellation.check()?; - let dimensions = (kind == ProjectMediaPreviewKind::Art) - .then(|| detect_project_art_dimensions(&bytes, media_type)) - .flatten(); + let source_byte_len = bytes.len() as u64; + /** + * 图像容器(TGA / TIFF / HDR / EXR)在浏览器里没有解码器:先在原生侧转成 PNG, + * 再走与 GIF / BMP / AVIF 完全相同的「数据 URL + 图片卡」链路。转码是**只读**的, + * 不改工程文件;像素尺寸仍受既有的尺寸与像素总量上限约束,不会因为多一层解码 + * 就放宽任何一条既有边界。 + */ + let transcoded = (kind == ProjectMediaPreviewKind::Art) + .then(|| transcode_project_art_container(&normalized, &bytes)) + .flatten() + .transpose()?; + let (media_type, dimensions, payload) = match transcoded { + Some(transcoded) => ( + transcoded.media_type, + Some((transcoded.pixel_width, transcoded.pixel_height)), + transcoded.bytes, + ), + None => { + let media_type = detect_project_media_type(&normalized, &bytes, kind)?; + cancellation.check()?; + let dimensions = (kind == ProjectMediaPreviewKind::Art) + .then(|| detect_project_art_dimensions(&bytes, media_type)) + .flatten(); + (media_type, dimensions, bytes) + } + }; Ok(LocalProjectMediaPreview { path: normalized, media_type: media_type.to_string(), - byte_len: bytes.len() as u64, + // `byteLen` 始终是**源文件**的大小:转码只影响预览载荷,不改变「这是多大的资源」。 + byte_len: source_byte_len, pixel_width: dimensions.map(|(width, _)| width), pixel_height: dimensions.map(|(_, height)| height), - data_url: encode_project_resource_preview_data_url(media_type, &bytes, cancellation)?, + data_url: encode_project_resource_preview_data_url(media_type, &payload, cancellation)?, + }) +} + +struct TranscodedProjectArtContainer { + bytes: Vec, + media_type: &'static str, + pixel_width: u32, + pixel_height: u32, +} + +fn transcode_project_art_container( + relative_path: &str, + bytes: &[u8], +) -> Option> { + let format = match path_extension(relative_path).as_deref()? { + "tga" => image::ImageFormat::Tga, + "tif" | "tiff" => image::ImageFormat::Tiff, + "hdr" => image::ImageFormat::Hdr, + _ => return None, + }; + Some(transcode_project_art_container_with_format( + relative_path, + bytes, + format, + )) +} + +fn transcode_project_art_container_with_format( + relative_path: &str, + bytes: &[u8], + format: image::ImageFormat, +) -> Result { + let decoded = image::load_from_memory_with_format(bytes, format) + .map_err(|error| format!("图像容器解码失败,无法在客户端预览:{relative_path}: {error}"))? + // HDR / EXR 是浮点像素格式,PNG 只能装整数像素:统一降到 8 位 RGBA, + // 与其它预览图(含 alpha 版)保持同一种像素口径。 + .to_rgba8(); + let (pixel_width, pixel_height) = decoded.dimensions(); + let pixels = u64::from(pixel_width) * u64::from(pixel_height); + if pixel_width == 0 + || pixel_height == 0 + || pixel_width > PROJECT_MEDIA_PREVIEW_MAX_DIMENSION + || pixel_height > PROJECT_MEDIA_PREVIEW_MAX_DIMENSION + || pixels > PROJECT_MEDIA_PREVIEW_MAX_PIXELS + { + return Err("图像尺寸过大,无法在客户端预览".to_string()); + } + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(decoded) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .map_err(|error| format!("图像容器转码失败:{relative_path}: {error}"))?; + Ok(TranscodedProjectArtContainer { + bytes: png, + media_type: "image/png", + pixel_width, + pixel_height, }) } @@ -430,6 +654,23 @@ fn detect_project_media_type( bytes: &[u8], kind: ProjectMediaPreviewKind, ) -> Result<&'static str, String> { + if kind == ProjectMediaPreviewKind::Model { + // 只按文件签名判定,不看扩展名:登记的是 `.glb` 却塞了别的内容时宁可报错, + // 也不要让渲染器去猜。 + if bytes.starts_with(b"glTF") { + return Ok("model/gltf-binary"); + } + if std::str::from_utf8(bytes).is_ok_and(|text| { + let trimmed = text.trim_start(); + trimmed.starts_with('{') && trimmed.contains("\"asset\"") + }) { + return Ok("model/gltf+json"); + } + if bytes.starts_with(b"Kaydara FBX Binary") || bytes.starts_with(b"; FBX") { + return Ok("application/octet-stream"); + } + return Err("模型预览只支持 GLB、glTF 或 FBX 文件".to_string()); + } if kind == ProjectMediaPreviewKind::Art && path_extension(path).as_deref() == Some("svg") { validate_safe_svg(bytes)?; return Ok("image/svg+xml"); @@ -623,7 +864,7 @@ mod tests { #[test] fn text_preview_requires_utf8_and_a_supported_extension() { - let root = tempfile::tempdir().expect("temp root"); + let root = crate::tests::canonical_test_tempdir("resource-preview-fixture-"); fs::create_dir_all(root.path().join("docs")).expect("docs dir"); fs::write(root.path().join("docs/design.md"), "# 设计\n\n正文").expect("markdown"); fs::write(root.path().join("docs/legacy.txt"), [0xff, 0xfe]).expect("legacy text"); @@ -645,7 +886,7 @@ mod tests { #[test] fn media_preview_accepts_safe_svg_and_rejects_active_svg() { - let root = tempfile::tempdir().expect("temp root"); + let root = crate::tests::canonical_test_tempdir("resource-preview-fixture-"); fs::create_dir_all(root.path().join("assets")).expect("assets dir"); fs::write( root.path().join("assets/icon.svg"), @@ -689,7 +930,7 @@ mod tests { #[test] fn media_preview_reports_safe_dimensions_for_extended_art_images() { - let root = tempfile::tempdir().expect("temp root"); + let root = crate::tests::canonical_test_tempdir("resource-preview-fixture-"); fs::create_dir_all(root.path().join("assets")).expect("assets dir"); let mut gif = b"GIF89a".to_vec(); @@ -735,12 +976,156 @@ mod tests { } } + /// 引擎图像容器(Cocos 的 `.tga` / `.tif` / `.hdr`)在浏览器里没有解码器: + /// 原生侧必须把它转成 PNG 再交给前端,并把真实像素尺寸一并带出去。 + #[test] + fn art_container_preview_transcodes_tga_to_png() { + // 用工程自带的临时目录助手:它会 canonicalize 并把目录 owner 归到当前用户, + // 否则 `%TEMP%` 下的临时目录在 Windows 上会被 owner 校验直接拒绝。 + let root = crate::tests::canonical_test_tempdir("engine-art-container-"); + fs::create_dir_all(root.path().join("assets")).expect("assets dir"); + + let mut tga = vec![0_u8; 18]; + tga[2] = 2; // 未压缩真彩色 + tga[12..14].copy_from_slice(&2_u16.to_le_bytes()); // 宽 2 + tga[14..16].copy_from_slice(&2_u16.to_le_bytes()); // 高 2 + tga[16] = 24; // 24 位像素 + tga[17] = 0x20; // 左上角原点 + tga.extend_from_slice(&[ + 0, 0, 255, // BGR:红 + 0, 255, 0, // 绿 + 255, 0, 0, // 蓝 + 255, 255, 255, // 白 + ]); + fs::write(root.path().join("assets/grass.tga"), tga).expect("tga"); + + let preview = load_local_project_media_preview( + root.path(), + "assets/grass.tga", + ProjectMediaPreviewKind::Art, + ) + .expect("transcoded tga preview"); + assert_eq!(preview.media_type, "image/png"); + assert_eq!(preview.pixel_width, Some(2)); + assert_eq!(preview.pixel_height, Some(2)); + assert!(preview.data_url.starts_with("data:image/png;base64,")); + } + + /// 引擎序列化资源:UTF-8 的给正文(前端再抽结构摘要),非 UTF-8 的**降级**成 + /// `content: null`(卡面画类型卡),不是报错;未登记进白名单的扩展名一律拒绝。 + #[test] + fn structured_preview_reads_cocos_serialized_assets_and_degrades_binary() { + let root = crate::tests::canonical_test_tempdir("engine-structured-preview-"); + let cancellation = ProjectResourcePreviewScopeCancellation::uncancelled(); + fs::create_dir_all(root.path().join("assets/anim")).expect("anim dir"); + fs::write( + root.path().join("assets/anim/walk.anim"), + "[{\"__type__\":\"cc.AnimationClip\",\"_duration\":1.25}]", + ) + .expect("animation clip"); + fs::write( + root.path().join("assets/anim/broken.pac"), + [0xff, 0xfe, 0x00], + ) + .expect("binary pac variant"); + fs::write( + root.path().join("assets/anim/glow.effect"), + "CCEffect %{\n techniques: []\n}", + ) + .expect("effect"); + + let clip = load_local_project_structured_preview_with_cancellation( + root.path(), + "assets/anim/walk.anim", + "application/json", + &cancellation, + ) + .expect("animation clip preview"); + assert_eq!(clip.media_type, "application/json"); + assert!(clip + .content + .as_deref() + .is_some_and(|content| content.contains("cc.AnimationClip"))); + + let effect = load_local_project_structured_preview_with_cancellation( + root.path(), + "assets/anim/glow.effect", + "text/plain", + &cancellation, + ) + .expect("effect preview"); + assert_eq!(effect.media_type, "text/plain"); + assert!(effect.content.is_some()); + + let degraded = load_local_project_structured_preview_with_cancellation( + root.path(), + "assets/anim/broken.pac", + "application/json", + &cancellation, + ) + .expect("binary variant degrades instead of failing"); + assert_eq!(degraded.content, None); + assert_eq!(degraded.byte_len, 3); + + assert!(load_local_project_structured_preview_with_cancellation( + root.path(), + "assets/anim/walk.anim", + "audio/mpeg", + &cancellation, + ) + .is_err()); + } + + /// 模型预览按**文件签名**判定媒体类型:GLB / glTF / FBX 各走各的,别的内容一律拒绝。 + #[test] + fn media_preview_detects_engine_model_media_types() { + let root = crate::tests::canonical_test_tempdir("engine-model-media-"); + fs::create_dir_all(root.path().join("assets/model")).expect("model dir"); + let mut glb = b"glTF".to_vec(); + glb.extend_from_slice(&2_u32.to_le_bytes()); + glb.extend_from_slice(&12_u32.to_le_bytes()); + fs::write(root.path().join("assets/model/hero.glb"), glb).expect("glb"); + fs::write( + root.path().join("assets/model/hero.gltf"), + "{\"asset\":{\"version\":\"2.0\"},\"meshes\":[]}", + ) + .expect("gltf"); + let mut fbx = b"Kaydara FBX Binary \x00".to_vec(); + fbx.extend_from_slice(&[0; 16]); + fs::write(root.path().join("assets/model/hero.fbx"), fbx).expect("fbx"); + fs::write(root.path().join("assets/model/broken.glb"), b"not-a-model") + .expect("broken model"); + + for (path, expected) in [ + ("assets/model/hero.glb", "model/gltf-binary"), + ("assets/model/hero.gltf", "model/gltf+json"), + ("assets/model/hero.fbx", "application/octet-stream"), + ] { + let preview = + load_local_project_media_preview(root.path(), path, ProjectMediaPreviewKind::Model) + .expect("model preview"); + assert_eq!(preview.media_type, expected, "{path}"); + assert!( + preview + .data_url + .starts_with(&format!("data:{expected};base64,")), + "{path}" + ); + } + assert!(load_local_project_media_preview( + root.path(), + "assets/model/broken.glb", + ProjectMediaPreviewKind::Model, + ) + .is_err()); + } + #[cfg(unix)] #[test] fn resource_preview_rejects_symlink_and_hardlink_files() { use std::os::unix::fs::symlink; - let root = tempfile::tempdir().expect("temp root"); + let root = crate::tests::canonical_test_tempdir("resource-preview-fixture-"); let outside = tempfile::tempdir().expect("outside"); fs::create_dir_all(root.path().join("docs")).expect("docs dir"); let source = outside.path().join("source.md"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 5d746c7cc..3fa66b97f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -2989,6 +2989,65 @@ fn local_project_file_commands_read_write_list_and_delete_text_files() { fs::remove_dir_all(root).ok(); } +/// 引擎生成目录不进发现结果,但**只在当前目录确实是 Cocos Creator 工程时**生效: +/// 同名目录在别的工程里可能是真实源码目录,全局跳过会把用户代码从发现结果里删掉。 +#[test] +fn local_project_file_listing_skips_engine_generated_directories_only_for_engine_projects() { + let cocos = canonical_test_tempdir("engine-generated-dirs-"); + let cocos = cocos.path(); + fs::create_dir_all(cocos.join("assets")).expect("create assets"); + fs::create_dir_all(cocos.join("library/imported")).expect("create library"); + fs::create_dir_all(cocos.join("temp/programming")).expect("create temp"); + fs::create_dir_all(cocos.join("profiles/v2")).expect("create profiles"); + fs::create_dir_all(cocos.join("local")).expect("create local"); + fs::write(cocos.join("assets/hero.glb"), b"glTF").expect("write model"); + fs::write(cocos.join("library/imported/hero.json"), b"{}").expect("write library file"); + fs::write(cocos.join("temp/programming/packer.cpp"), b"//").expect("write temp file"); + fs::write(cocos.join("profiles/v2/user.json"), b"{}").expect("write profile file"); + fs::write(cocos.join("local/settings.json"), b"{}").expect("write local file"); + fs::write( + cocos.join("package.json"), + r#"{ "name": "cocos-project", "creator": { "version": "3.8.8" } }"#, + ) + .expect("write cocos package.json"); + + let listed = list_local_project_files_at(cocos).expect("list cocos project files"); + let paths = listed + .files + .iter() + .map(|file| file.path.as_str()) + .collect::>(); + assert!(paths.contains(&"assets/hero.glb"), "{paths:?}"); + for skipped in [ + "library", + "library/imported/hero.json", + "temp", + "temp/programming/packer.cpp", + "profiles", + "profiles/v2/user.json", + "local", + "local/settings.json", + ] { + assert!( + !paths.contains(&skipped), + "引擎生成目录必须被过滤:{skipped} / {paths:?}" + ); + } + + let plain = canonical_test_tempdir("plain-project-dirs-"); + let plain = plain.path(); + fs::create_dir_all(plain.join("library")).expect("create plain library"); + fs::write(plain.join("library/index.ts"), b"//").expect("write plain library file"); + let listed = list_local_project_files_at(plain).expect("list plain project files"); + assert!( + listed + .files + .iter() + .any(|file| file.path == "library/index.ts"), + "非引擎工程的同名目录不得被过滤" + ); +} + #[test] fn local_project_export_package_uses_runtime_whitelist_and_records() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css index 40de7843a..a7f485eaf 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css @@ -942,6 +942,94 @@ overscroll-behavior: contain; } +/* + * 引擎模型放大预览:与文档预览同一套浮层口径(宽度 / 圆角 / 头尾结构), + * 只有主体换成交互式三维视口 —— 视口自己吃掉指针与滚轮事件。 + */ +.game-resource-model-preview { + display: flex; + flex-direction: column; + width: min(1040px, calc(100vw - 32px)); + max-height: calc(100dvh - 32px); + min-width: 0; + border: 1px solid var(--platform-surface-border); + border-radius: 16px; + overflow: hidden; +} + +.game-resource-model-preview__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 20px; + border-bottom: 1px solid var(--platform-surface-border); +} + +.game-resource-model-preview__header strong { + min-width: 0; + overflow-wrap: anywhere; +} + +.game-resource-model-preview__actions { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 8px; +} + +.game-resource-model-preview__body { + display: flex; + flex-direction: column; + gap: 10px; + min-height: 0; + min-width: 0; + padding: 16px 20px 20px; +} + +.game-resource-model-preview__hint { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0; + color: var(--platform-warm-text, #8b5b45); + font-size: 12px; +} + +.game-resource-model-viewer { + position: relative; + flex: 1 1 auto; + min-height: min(62dvh, 560px); + border: 1px solid var(--platform-surface-border); + border-radius: 12px; + background: linear-gradient(145deg, #f7f2ec, #e6ddd2); + overflow: hidden; + /* 视口内的指针手势一律给三维视角,不冒泡去拖动画布。 */ + touch-action: none; +} + +.game-resource-model-viewer__canvas { + display: block; + width: 100%; + height: 100%; + cursor: grab; +} + +.game-resource-model-viewer__canvas:active { + cursor: grabbing; +} + +.game-resource-model-viewer__notice { + display: grid; + height: 100%; + margin: 0; + padding: 24px; + place-items: center; + color: #8b5b45; + font-size: 13px; + text-align: center; +} + /* 资源面板:独立浮层面板(预览 / 上传 / 下载 / 多选)。 */ .game-resource-panel { width: min(880px, calc(100% - 32px)); diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 613ec51e4..c5fa3bde4 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -7326,7 +7326,16 @@ iframe.preview-frame { min-height: var(--resource-card-height); padding: 0; overflow: visible; - border: 0; + /* + * 边框**常驻 1px 透明**,状态只改颜色。 + * + * 卡片是 `box-sizing: border-box`,且卡面(`.game-resource-card-visual`)与角标都是 + * 绝对定位、以 **padding box** 为包含块:底态若是 `border: 0`,悬停 / 选中时才出现的 + * 1px 边框会把内容盒四边各吃掉 1px,卡面与角标当场位移并缩小 2px —— 用户看到的就是 + * 「一悬浮,卡片里面的东西跟着动」。常驻透明边框让所有状态的 padding box 完全一致, + * 外尺寸仍然等于 `--resource-card-width/height`(border-box),画布坐标不受影响。 + */ + border: 1px solid transparent; border-radius: 12px; background: #fff; color: #4e382f; @@ -7348,9 +7357,8 @@ iframe.preview-frame { 0 0 0 2px rgb(216 115 66 / 24%); } -/* 卡片本体是 `border: 0`(下面那条基规则)。`border-color` 单独写没有意义——0 宽的边框 - 画不出颜色,所以选中 / 悬停 / 聚焦都必须写成完整的 `border`,否则这三个状态在视觉上 - 完全看不出来。宽度与圆角保持 1px / 12px,`box-sizing: border-box` 下不会改变卡片尺寸。 */ +/* 基态已经是 1px 透明边框,这里只把颜色点亮;宽度与圆角保持 1px / 12px, + padding box 不变,因此卡面与角标在状态切换时不会位移。 */ .game-resource-card:hover, .game-resource-card:focus-within, .game-resource-card.is-selected { @@ -7686,6 +7694,96 @@ iframe.preview-frame { gap: 10px; } +/* + * 引擎资源卡(引擎三维模型 / 序列化资源 / 无法解码的容器)。 + * + * 三者的共同点:卡面要么是渲染出来的缩略图,要么是「类型 + 扩展名」的类型卡, + * 都必须与既有卡片同族 —— 因此沿用同一套浅色渐变底与居中排布,不新造一套视觉语言。 + */ +.game-resource-card-model-visual { + /* + * 绝对定位铺满卡面:`width/height: 100%` 在 `place-items: center` 的网格里会因为 + * 行高不确定而退化成"按内容定高",缩略图就以自然比例溢出卡面被裁掉(看起来像被拉伸)。 + * 用 `inset: 0` 把盒子定死,图片才能在固定框里做 letterbox。 + */ + position: absolute; + inset: 0; + display: grid; + width: 100%; + height: 100%; + background: linear-gradient(145deg, #f7f2ec, #e6ddd2); + place-items: center; +} + +.game-resource-card-model-visual img { + /* + * 图片绝对定位铺满宿主:宿主是 `place-items: center` 的网格,网格项的高度会退化成 + * "按内容定高"(`height: 100%` 解析成 auto),缩略图就会按自身比例长过卡片并被裁掉。 + * 铺满 + `object-fit: contain` 才是「不拉伸、不裁切」的口径。 + */ + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; +} + +.game-resource-card-engine-visual { + position: absolute; + inset: 0; + display: grid; + gap: 8px; + width: 100%; + height: 100%; + padding: 12px; + background: linear-gradient(145deg, #f7f2ec, #e6ddd2); + color: #8b5b45; + align-content: center; + place-items: center; +} + +.game-resource-card-engine-label { + max-width: 100%; + padding: 1px 7px; + border: 1px solid rgb(139 91 69 / 28%); + border-radius: 999px; + background: rgb(255 255 255 / 72%); + color: #8b5b45; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.04em; + text-align: center; + word-break: break-word; +} + +/* 引擎序列化资源:结构摘要按"最多三行"截断,与文档卡的卡面口径一致。 */ +.game-resource-card-structured-visual { + position: absolute; + inset: 0; + display: grid; + gap: 8px; + width: 100%; + height: 100%; + padding: 14px; + background: linear-gradient(145deg, #fff8f1, #f2ded2); + color: #8b5b45; + align-content: center; + place-items: center; +} + +.game-resource-card-structured-text { + display: -webkit-box; + max-height: 100%; + overflow: hidden; + color: #674c41; + font-size: 11px; + line-height: 1.55; + text-align: left; + word-break: break-word; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + .game-resource-card-version-relations { color: #8e6f62; font-size: 9px; diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreview.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreview.tsx new file mode 100644 index 000000000..189d0f8ba --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreview.tsx @@ -0,0 +1,149 @@ +import { useEffect, useRef, useState } from 'react'; + +import { renderResourceModelThumbnail } from './resourceModelThumbnail'; + +type ResourceModelPreviewProps = { + /** 预览管线给出的模型字节 URL(blob URL)。 */ + sourceUrl: string; + /** 原生侧判定的媒体类型(`model/gltf-binary` / `model/gltf+json` / FBX 的 octet-stream)。 */ + mediaType: string; + /** 缩略图缓存身份(见 `resourceModelThumbnailIdentity`):blob URL 会变,身份不会。 */ + identity: string; + /** 渲染不出来时画在同一个位置上的类型卡文案(例如「模型 · GLB」)。 */ + fallbackLabel: string; +}; + +/** + * 缩略图超采样倍数。 + * + * 资源画布用 `transform: scale(--resource-section-zoom)` 放大栏目视图(真机 1.5 倍),而 + * `ResizeObserver` 与 `offsetWidth` 都只看**布局盒**:按布局尺寸 1:1 渲染出来的图,被画布 + * 放大后就是糊的。按 2 倍超采样既有余量覆盖画布缩放,也不至于让单张缩略图失控。 + */ +const RESOURCE_MODEL_THUMBNAIL_SUPERSAMPLE = 2; +const RESOURCE_MODEL_THUMBNAIL_MAX_EDGE = 1024; + +/** + * 资源卡上的引擎模型缩略图。 + * + * 这一层只关心「把渲染结果画出来」,渲染本身在 `resourceModelThumbnail` 的单例队列里 + * (整页只有一个 WebGL 上下文)。渲染失败**不算预览失败**:卡面就地降级成类型卡, + * 预览管线里已经读到的字节与状态保持不变,排障信息通过 + * `data-model-preview-status` 暴露在 DOM 上。 + */ +export function ResourceModelPreview({ + sourceUrl, + mediaType, + identity, + fallbackLabel, +}: ResourceModelPreviewProps) { + const hostRef = useRef(null); + const [thumbnail, setThumbnail] = useState(null); + const [status, setStatus] = useState<'loading' | 'ready' | 'failed'>( + 'loading', + ); + /** + * 渲染尺寸按**宿主真实几何**(含画布缩放后的视觉尺寸)取,并且跟着宿主的尺寸变化重渲。 + * + * 卡片在总览小图与栏目大图里是同一个组件:只在挂载时量一次,会拿到当时那个更小的盒子, + * 之后卡片放大也不会重画 —— 表现为缩略图被放大到发虚、比例与卡面不一致。 + */ + const [renderSize, setRenderSize] = useState<{ + width: number; + height: number; + } | null>(null); + + useEffect(() => { + const host = hostRef.current; + if (!host) { + return undefined; + } + const measure = () => { + /* + * 取**布局尺寸**(`offsetWidth` 不受祖先 transform 影响)而不是 `getBoundingClientRect`: + * 画布缩放是 transform,像素值会随缩放变化;布局尺寸才是稳定的口径,再乘超采样倍数。 + */ + const width = Math.min( + RESOURCE_MODEL_THUMBNAIL_MAX_EDGE, + Math.max( + 192, + Math.round( + (host.offsetWidth || 320) * RESOURCE_MODEL_THUMBNAIL_SUPERSAMPLE, + ), + ), + ); + const height = Math.min( + RESOURCE_MODEL_THUMBNAIL_MAX_EDGE, + Math.max( + 144, + Math.round( + (host.offsetHeight || 240) * RESOURCE_MODEL_THUMBNAIL_SUPERSAMPLE, + ), + ), + ); + setRenderSize((current) => + current && current.width === width && current.height === height + ? current + : { width, height }, + ); + }; + measure(); + // 测试用的 jsdom 没有 ResizeObserver:没有它就退化成"只在挂载时量一次"。 + if (typeof ResizeObserver === 'undefined') { + return undefined; + } + const observer = new ResizeObserver(measure); + observer.observe(host); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (!renderSize) { + return undefined; + } + let disposed = false; + setThumbnail(null); + setStatus('loading'); + const request = { + sourceUrl, + mediaType, + identity, + width: renderSize.width, + height: renderSize.height, + }; + void renderResourceModelThumbnail(request) + .then((dataUrl) => { + if (disposed) { + return; + } + setThumbnail(dataUrl); + setStatus('ready'); + }) + .catch(() => { + if (!disposed) { + setStatus('failed'); + } + }); + return () => { + disposed = true; + }; + }, [identity, mediaType, renderSize, sourceUrl]); + + return ( + + {thumbnail ? ( + + ) : ( + {fallbackLabel} + )} + + ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreviewDialog.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreviewDialog.tsx new file mode 100644 index 000000000..503ccaed8 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreviewDialog.tsx @@ -0,0 +1,114 @@ +import { Maximize2, RotateCcw, X } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton'; +import { PlatformIconButton } from '../../../../../packages/shared/src/components/PlatformIconButton'; +import { ThemedModal } from '../../components/modal/ThemedModal'; +import type { ProjectResourceCardPreviewState } from './resourceCardPreviewModel'; +import { + ResourceModelViewer, + type ResourceModelViewerHandle, +} from './ResourceModelViewer'; +import type { ProjectResource } from './resourceProjectionModel'; + +/** + * 引擎模型的**放大预览浮层**:在独立浮层里给出与 3D 建模软件一致的视角操作。 + * + * 卡片上仍然是静态缩略图(一张画布上十几张模型卡共用缩略图那一个 WebGL 上下文); + * 只有打开这个浮层时才会新建一个交互式上下文,关闭即释放。浮层内的指针与滚轮事件由 + * three 的 OrbitControls 独占:左键旋转、右键/中键平移、滚轮推拉。 + */ +export function ResourceModelPreviewDialog({ + resource, + identity, + preview, + onRequestPreview, + onClose, +}: { + resource: ProjectResource; + identity: string; + preview: ProjectResourceCardPreviewState; + onRequestPreview: ( + resource: ProjectResource, + identity: string, + reason: 'detail', + ) => void; + onClose: () => void; +}) { + const [resetSignal, setResetSignal] = useState(0); + const viewerHandleRef = useRef(null); + + useEffect(() => { + onRequestPreview(resource, identity, 'detail'); + }, [resource, identity, onRequestPreview]); + + const handleReady = useCallback((handle: ResourceModelViewerHandle) => { + viewerHandleRef.current = handle; + }, []); + + const sourceUrl = + preview.status === 'loaded' ? (preview.preview.sourceUrl ?? null) : null; + + return ( + +
+ {resource.label} +
+ setResetSignal((current) => current + 1)} + > + +
+
+
+ {preview.status === 'failed' ? ( + <> +

{preview.error}

+ {preview.retryable ? ( + onRequestPreview(resource, identity, 'detail')} + > + 重试 + + ) : null} + + ) : preview.status === 'loaded' && !sourceUrl ? ( +

没有可预览的模型内容

+ ) : sourceUrl ? ( + + ) : ( +

正在加载模型…

+ )} +

+

+
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx new file mode 100644 index 000000000..4b06525cc --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx @@ -0,0 +1,192 @@ +import { useEffect, useRef, useState } from 'react'; + +import { + addResourceModelLights, + disposeResourceModelObject, + frameResourceModelInCamera, + loadResourceModelObject, + type ResourceModelSource, +} from './resourceModelScene'; + +export type ResourceModelViewerHandle = { + /** 复位视角(回到打开时的取景);供浮层上的「复位视角」按钮调用。 */ + resetView: () => void; +}; + +type ResourceModelViewerProps = { + source: ResourceModelSource; + /** 复位视角按钮的点击计数:变化即复位,避免把 three 对象提到 React 状态里。 */ + resetSignal: number; + onReady: (handle: ResourceModelViewerHandle) => void; +}; + +/** + * 交互式模型预览画布:视角操作与 3D 建模软件一致。 + * + * - 左键拖拽:旋转(Orbit) + * - 右键 / 中键拖拽:平移(Pan) + * - 滚轮:推拉(Zoom) + * + * 与缩略图共用 `resourceModelScene` 的加载 / 取景 / 释放口径,所以「卡片上看得见、放大后 + * 看不见」这类分叉不会出现。渲染器是本浮层自己的 WebGL 上下文,关闭时立即 `dispose`: + * 资源画布上十几张模型卡仍然只共用缩略图那一个上下文(见 `resourceModelThumbnail`)。 + */ +export function ResourceModelViewer({ + source, + resetSignal, + onReady, +}: ResourceModelViewerProps) { + const hostRef = useRef(null); + const resetRef = useRef<(() => void) | null>(null); + /** + * 用 ref 保存 source:调用方通常直接传对象字面量,把它放进依赖数组会让模型每次渲染 + * 都重建一次 WebGL 上下文;真正的重建条件只有「换了模型」。 + */ + const sourceRef = useRef(source); + sourceRef.current = source; + const [status, setStatus] = useState<'loading' | 'ready' | 'failed'>( + 'loading', + ); + const [error, setError] = useState(''); + + useEffect(() => { + let disposed = false; + let cleanup: (() => void) | undefined; + setStatus('loading'); + setError(''); + const host = hostRef.current; + if (!host) { + return undefined; + } + void (async () => { + try { + const THREE = await import('three'); + const { OrbitControls } = await import( + 'three/examples/jsm/controls/OrbitControls.js' + ); + if (disposed) { + return; + } + const width = Math.max(240, Math.round(host.clientWidth)); + const height = Math.max(200, Math.round(host.clientHeight)); + const modelSource = { + sourceUrl: sourceRef.current.sourceUrl, + mediaType: sourceRef.current.mediaType, + }; + const renderer = new THREE.WebGLRenderer({ + alpha: true, + antialias: true, + }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + renderer.setSize(width, height, false); + renderer.domElement.className = 'game-resource-model-viewer__canvas'; + host.appendChild(renderer.domElement); + + const scene = new THREE.Scene(); + addResourceModelLights(THREE, scene); + const camera = new THREE.PerspectiveCamera( + 35, + width / height, + 0.01, + 10_000, + ); + + const controls = new OrbitControls(camera, renderer.domElement); + // 阻尼让拖动有惯性,手感与建模软件一致;它要求每帧 update,因此下面常驻 rAF。 + controls.enableDamping = true; + controls.dampingFactor = 0.08; + controls.screenSpacePanning = true; + controls.zoomSpeed = 0.9; + // 允许贴到模型内部看细节,但不允许转到背面时被裁剪面切掉模型。 + controls.minDistance = 0.01; + controls.maxDistance = Number.POSITIVE_INFINITY; + + const object = await loadResourceModelObject(modelSource); + if (disposed) { + disposeResourceModelObject(object); + renderer.dispose(); + renderer.domElement.remove(); + return; + } + scene.add(object); + const frame = () => { + const { center } = frameResourceModelInCamera(THREE, object, camera); + // 轨道中心 = 模型包围盒中心:缩放与旋转都绕着模型本身,和建模软件的取景一致。 + controls.target.copy(center); + controls.update(); + }; + frame(); + controls.update(); + + const resizeObserver = new ResizeObserver(() => { + const nextWidth = Math.max(240, Math.round(host.clientWidth)); + const nextHeight = Math.max(200, Math.round(host.clientHeight)); + camera.aspect = nextWidth / nextHeight; + camera.updateProjectionMatrix(); + renderer.setSize(nextWidth, nextHeight, false); + }); + resizeObserver.observe(host); + + let frameHandle = 0; + const renderLoop = () => { + controls.update(); + renderer.render(scene, camera); + frameHandle = window.requestAnimationFrame(renderLoop); + }; + frameHandle = window.requestAnimationFrame(renderLoop); + + resetRef.current = () => { + frame(); + }; + onReady({ resetView: () => resetRef.current?.() }); + setStatus('ready'); + + cleanup = () => { + window.cancelAnimationFrame(frameHandle); + resizeObserver.disconnect(); + controls.dispose(); + scene.remove(object); + disposeResourceModelObject(object); + renderer.dispose(); + renderer.domElement.remove(); + resetRef.current = null; + }; + } catch (viewerError) { + if (!disposed) { + setStatus('failed'); + setError( + viewerError instanceof Error + ? viewerError.message + : String(viewerError), + ); + } + } + })(); + return () => { + disposed = true; + cleanup?.(); + }; + }, [onReady, source.mediaType, source.sourceUrl]); + + useEffect(() => { + if (resetSignal > 0) { + resetRef.current?.(); + } + }, [resetSignal]); + + return ( +
+ {status === 'ready' ? null : ( +

+ {status === 'loading' + ? '正在加载模型…' + : `当前环境无法渲染三维预览${error ? `:${error}` : ''}`} +

+ )} +
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx index 281ce517c..7001825a5 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx @@ -12,8 +12,13 @@ import { type ProjectResourceCardPreviewState, projectResourceCardPreviewVariant, projectResourceJsonPresentation, + projectResourcePathExtension, + projectResourceStructuredPreviewText, } from './resourceCardPreviewModel'; +import { ResourceModelPreview } from './ResourceModelPreview'; +import { resourceModelThumbnailIdentity } from './resourceModelThumbnail'; import type { ProjectResource } from './resourceProjectionModel'; +import { projectResourceTypeLabel } from './resourceProjectionModel'; type ResourcePreviewMediaProps = { /** @@ -96,7 +101,59 @@ export function ResourcePreviewMedia({ : null; const sourceUrl = preview.status === 'loaded' ? (preview.preview.sourceUrl ?? null) : null; + const engineBadgeLabel = (() => { + if (!resource) { + return '引擎资源'; + } + const extension = projectResourcePathExtension(resource.path); + const typeLabel = projectResourceTypeLabel(resource); + return extension ? `${typeLabel} · ${extension.toUpperCase()}` : typeLabel; + })(); const visual = (() => { + // 引擎资源的三个分支与资源卡同源(判据都来自 `projectResourceCardPreviewKind`), + // 差别只在尺寸:面板里的缩略图更大,因此模型缩略图按面板几何渲染。 + if (kind === 'model') { + return sourceUrl ? ( + + ) : ( + + {engineBadgeLabel} + + ); + } + if (kind === 'structured' || kind === 'binary') { + const structuredText = + kind === 'structured' + ? projectResourceStructuredPreviewText( + preview.status === 'loaded' ? preview.preview.content : undefined, + ) + : ''; + return structuredText ? ( + + {structuredText} + + ) : ( + + {engineBadgeLabel} + + ); + } const jsonPresentation = resource ? projectResourceJsonPresentation(resource, preview) : null; 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 e660c2c73..fec1f8dc6 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 @@ -17,6 +17,7 @@ import { import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; import { AtSign, + Box, Crosshair, Eye, FileCode2, @@ -299,6 +300,8 @@ import { projectResourceCodeTypeLabel, projectResourceDocumentPreviewText, projectResourceJsonPresentation, + projectResourcePathExtension, + projectResourceStructuredPreviewText, } from './resourceCardPreviewModel'; import { ResourceClassificationPanel } from './ResourceClassificationPanel'; import { @@ -331,6 +334,9 @@ import { ResourceInfoFieldsView, ResourceInfoPanelView, } from './ResourceInfoPanelView'; +import { ResourceModelPreview } from './ResourceModelPreview'; +import { ResourceModelPreviewDialog } from './ResourceModelPreviewDialog'; +import { resourceModelThumbnailIdentity } from './resourceModelThumbnail'; import { ResourcePreviewMedia } from './ResourcePreviewMedia'; import { type ProjectAgentResultSummary, @@ -803,6 +809,25 @@ const ResourceCard = memo(function ResourceCard({ previewVariant === 'markdown', ) : ''; + /** + * 引擎资源的卡面口径。 + * + * - `engineTypeLabel`:模型 / 动画 / 材质 这类**引擎语义**的类型名(按扩展名判定, + * 不复用"图片 / 文档"的通用角标); + * - `structuredPreviewText`:场景、预制体、动画剪辑等序列化资源的结构摘要;原生判定为 + * 二进制变体时(`content === undefined`)为空串,卡面画类型卡而不是报错。 + */ + const engineTypeLabel = projectResourceTypeLabel(resource); + const engineExtension = projectResourcePathExtension(resource.path); + const engineBadgeLabel = engineExtension + ? `${engineTypeLabel} · ${engineExtension.toUpperCase()}` + : engineTypeLabel; + const structuredPreviewText = + kind === 'structured' + ? projectResourceStructuredPreviewText( + preview.status === 'loaded' ? preview.preview.content : undefined, + ) + : ''; useEffect(() => { const element = cardRef.current; @@ -832,6 +857,66 @@ const ResourceCard = memo(function ResourceCard({ }, [previewIdentity, sourceUrl]); const visual = (() => { + /* + * 引擎资源三个分支排在图片 / 视频 / 音频之前: + * - `model`:交给单例模型渲染器出缩略图,渲染不出来就地降级成类型卡; + * - `structured`:显示结构摘要(场景节点数、动画时长、材质类型…),没有可读正文时同样是类型卡; + * - `binary`:客户端解不了的容器,直接画类型卡,不读字节。 + */ + if (kind === 'model') { + return sourceUrl ? ( + + ) : ( + + + ); + } + if (kind === 'structured') { + return structuredPreviewText ? ( + + + ) : ( + + + ); + } + if (kind === 'binary') { + return ( + + + ); + } if (jsonPresentation) { return ( @@ -1614,6 +1699,14 @@ export default function ProjectDevelopmentView({ const [resourcePanelOpen, setResourcePanelOpen] = useState(false); const [resourceDocumentPreviewIdentity, setResourceDocumentPreviewIdentity] = useState(null); + /** + * 引擎模型的放大预览浮层身份(同时是当前选中资源的预览身份)。 + * + * 与文档预览浮层一样只在 `resources` 视图、且身份仍等于当前选中资源时渲染; + * 它是**只读预览**,不参与编辑、不写回 manifest。 + */ + const [resourceModelPreviewIdentity, setResourceModelPreviewIdentity] = + useState(null); /** * 「生成素材」浮层的本次放行类型。 * @@ -6423,10 +6516,18 @@ export default function ProjectDevelopmentView({ ) { setResourceDocumentPreviewIdentity(null); } + if ( + mode !== 'resources' || + uiEditorRoute || + resourceModelPreviewIdentity !== selectedResourcePreviewIdentity + ) { + setResourceModelPreviewIdentity(null); + } }, [ mode, uiEditorRoute, resourceDocumentPreviewIdentity, + resourceModelPreviewIdentity, selectedResourcePreviewIdentity, ]); @@ -6436,6 +6537,12 @@ export default function ProjectDevelopmentView({ return () => protectResourceCardPreview(null); }, [protectResourceCardPreview, resourceDocumentPreviewIdentity]); + useEffect(() => { + if (!resourceModelPreviewIdentity) return; + protectResourceCardPreview(resourceModelPreviewIdentity); + return () => protectResourceCardPreview(null); + }, [protectResourceCardPreview, resourceModelPreviewIdentity]); + /** * 解析一次资源派生的源身份:取项目 revision,必要时把任务产物正规化成正式素材。 * @@ -8002,6 +8109,36 @@ export default function ProjectDevelopmentView({ 预览
) : null} + {/* + * 引擎模型:进「放大预览」浮层做交互式视角操作。 + * 判据与卡面同源(`projectResourceCardPreviewKind`), + * 因此不会出现「卡片是模型卡、却没有 3D 入口」的分叉。 + */} + {selectedResource && + selectedResourcePreviewIdentity && + projectResourceCardPreviewKind( + selectedResource, + ) === 'model' ? ( + } + onClick={() => { + stopActiveCardMedia(); + resourceCardPreviews.requestPreview( + selectedResource, + selectedResourcePreviewIdentity, + 'detail', + ); + setResourceModelPreviewIdentity( + selectedResourcePreviewIdentity, + ); + }} + > + 3D 预览 + + ) : null} {selectedResource && selectedResourceOpensUiEditor ? ( setResourceDocumentPreviewIdentity(null)} /> ) : null} + {mode === 'resources' && + !uiEditorRoute && + selectedResource && + resourceModelPreviewIdentity && + resourceModelPreviewIdentity === selectedResourcePreviewIdentity ? ( + setResourceModelPreviewIdentity(null)} + /> + ) : null} {resourcePanelOpen ? ( => + typeof entry === 'object' && entry !== null && !Array.isArray(entry), + ); + const typeCounts = new Map(); + for (const entry of entries) { + const type = entry['__type__']; + if (typeof type === 'string' && type) { + typeCounts.set(type, (typeCounts.get(type) ?? 0) + 1); + } + } + const rootType = entries + .map((entry) => entry['__type__']) + .find( + (type): type is string => typeof type === 'string' && type.length > 0, + ); + if (!rootType) { + return null; + } + const parts: string[] = [rootType]; + const nodeCount = typeCounts.get('cc.Node') ?? 0; + if (nodeCount > 0) { + parts.push(`${nodeCount} 个节点`); + } + const duration = entries + .map((entry) => entry['_duration']) + .find((value): value is number => typeof value === 'number' && value > 0); + if (duration !== undefined) { + parts.push(`${duration.toFixed(2)} 秒`); + } + const name = entries + .map((entry) => entry['_name']) + .find( + (value): value is string => typeof value === 'string' && value.length > 0, + ); + if (name) { + parts.push(name); + } + const assetReferences = entries.filter( + (entry) => entry['__uuid__'] !== undefined, + ).length; + if (assetReferences > 0) { + parts.push(`${assetReferences} 处资源引用`); + } + parts.push(`${typeCounts.size} 种类型`); + return parts.join(' · '); +} + +/** + * 引擎序列化资源的卡面预览文本:优先结构摘要,其次前几行正文。 + * + * `content` 为 `undefined`(原生读取判定的二进制变体)时返回空串,卡面画类型卡。 + */ +export function projectResourceStructuredPreviewText( + content: string | undefined, +): string { + if (content === undefined) { + return ''; + } + return ( + projectResourceCocosStructureSummary(content) ?? + projectResourceDocumentPreviewText(content, false) + ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceModelScene.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceModelScene.ts new file mode 100644 index 000000000..6d74551b8 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceModelScene.ts @@ -0,0 +1,118 @@ +import type * as ThreeTypes from 'three'; + +/** + * 引擎三维模型的**共用场景能力**:加载、取景、释放。 + * + * 卡片缩略图(`resourceModelThumbnail`)和交互式预览浮层(`ResourceModelViewer`)都走这里, + * 避免出现「缩略图能打开、放大后打不开」这种两套加载逻辑的分叉 —— 格式支持面、取景算法和 + * 释放口径必须逐字一致。 + */ +export type ResourceModelSource = { + /** 预览管线给出的 blob URL(模型整份字节)。 */ + sourceUrl: string; + /** `model/gltf-binary`、`model/gltf+json` 或 `application/octet-stream`(FBX)。 */ + mediaType: string; +}; + +export function isResourceModelFbx(source: ResourceModelSource) { + return ( + source.mediaType === 'application/octet-stream' || + source.sourceUrl.toLowerCase().includes('.fbx') + ); +} + +/** + * 按媒体类型加载模型。 + * + * 只支持**自包含**的模型:`.glb`、单文件 `.gltf`(buffer 内嵌)、`.fbx`。多文件 glTF + * (`baseURI` 指向外部 `.bin` / 贴图)在 blob URL 下无法解析相对路径,加载失败由调用方 + * 降级成类型卡,不做静默半渲染。 + */ +export async function loadResourceModelObject( + source: ResourceModelSource, +): Promise { + if (isResourceModelFbx(source)) { + const { FBXLoader } = await import( + 'three/examples/jsm/loaders/FBXLoader.js' + ); + return new FBXLoader().loadAsync(source.sourceUrl); + } + const { GLTFLoader } = await import( + 'three/examples/jsm/loaders/GLTFLoader.js' + ); + const gltf = await new GLTFLoader().loadAsync(source.sourceUrl); + if (!gltf.scene) { + throw new Error('模型内容为空'); + } + return gltf.scene; +} + +/** + * 把相机摆到能完整看见整个模型的位置,并返回模型中心与尺寸。 + * + * 取景口径与 3D 软件一致:按包围盒最大边计算距离,留 35% 余量,从右上前方俯视。 + * 缩略图与交互预览共用同一条公式,尺寸窗口不同也不会出现「一边看得见、一边看不见」。 + */ +export function frameResourceModelInCamera( + THREE: typeof ThreeTypes, + object: ThreeTypes.Object3D, + camera: ThreeTypes.PerspectiveCamera, +) { + const box = new THREE.Box3().setFromObject(object); + const size = box.getSize(new THREE.Vector3()); + const center = box.getCenter(new THREE.Vector3()); + const maxDimension = Math.max(size.x, size.y, size.z); + if (!Number.isFinite(maxDimension) || maxDimension <= 0) { + throw new Error('模型几何尺寸无效'); + } + const distance = + (maxDimension / 2 / Math.tan((camera.fov * Math.PI) / 360)) * 1.35; + camera.position.set( + center.x + distance * 0.55, + center.y + distance * 0.42, + center.z + distance * 0.75, + ); + camera.near = Math.max(distance / 500, 0.001); + camera.far = distance * 20; + camera.lookAt(center); + camera.updateProjectionMatrix(); + return { center, size, maxDimension, distance }; +} + +/** 环境光照:半球光 + 一盏主光,保证没有材质贴图的模型也有体积感。 */ +export function addResourceModelLights( + THREE: typeof ThreeTypes, + scene: ThreeTypes.Scene, +) { + scene.add(new THREE.HemisphereLight(0xffffff, 0x445566, 2.2)); + const keyLight = new THREE.DirectionalLight(0xffffff, 2.0); + keyLight.position.set(2, 3, 4); + scene.add(keyLight); +} + +/** 释放模型对象占用的几何、材质与贴图,避免反复开关预览时显存只涨不降。 */ +export function disposeResourceModelObject(object: ThreeTypes.Object3D) { + object.traverse((child) => { + const mesh = child as ThreeTypes.Mesh; + mesh.geometry?.dispose?.(); + const material = mesh.material; + const materials = Array.isArray(material) + ? material + : material + ? [material] + : []; + for (const entry of materials) { + for (const value of Object.values( + entry as unknown as Record, + )) { + const texture = value as + | { isTexture?: boolean; dispose?: () => void } + | undefined; + if (texture?.isTexture && typeof texture.dispose === 'function') { + texture.dispose(); + } + } + (entry as { dispose?: () => void }).dispose?.(); + } + }); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceModelThumbnail.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceModelThumbnail.ts new file mode 100644 index 000000000..4ab1e0ecd --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceModelThumbnail.ts @@ -0,0 +1,164 @@ +import type * as ThreeTypes from 'three'; + +import { + addResourceModelLights, + disposeResourceModelObject, + frameResourceModelInCamera, + loadResourceModelObject, +} from './resourceModelScene'; + +/** + * 引擎三维模型缩略图渲染器(模块级单例)。 + * + * 为什么是单例:一张资源画布上可能同时停着十几张模型卡,而浏览器能给一个页面的 + * WebGL 上下文是**有上限**的(超出后最早的上下文会被丢弃,表现为"有些卡片莫名其妙变白")。 + * 这里只保留**一个**离屏渲染器 + 一个串行队列:谁需要缩略图谁排队,渲染完把像素画进 + * 卡片自己的 2D canvas。因此无论画布上有多少张模型卡,WebGL 上下文始终只有 1 个。 + * + * 渲染是**一次性**的静态帧(不跑动画循环):卡片只是缩略图,让十几张卡各自跑一个 + * requestAnimationFrame 循环会白烧 GPU 和电量。 + */ +export type ResourceModelThumbnailRequest = { + /** 预览管线给出的 blob URL(模型整份字节)。 */ + sourceUrl: string; + /** `model/gltf-binary`、`model/gltf+json` 或 `application/octet-stream`(FBX)。 */ + mediaType: string; + width: number; + height: number; + /** + * 缩略图的**稳定身份**(资源身份 + 路径 + 字节数)。 + * + * blob URL 每次读取都会变,拿它当缓存键会让「预览缓存淘汰后重读同一个模型」变成 + * 一次重新解析 + 重新渲染;用稳定身份作键,重读只会重新取字节,缩略图直接命中缓存。 + */ + identity: string; +}; + +const THUMBNAIL_CACHE_LIMIT = 24; +const thumbnailCache = new Map(); + +type RendererBundle = { + renderer: ThreeTypes.WebGLRenderer; + scene: ThreeTypes.Scene; + camera: ThreeTypes.PerspectiveCamera; +}; + +let rendererPromise: Promise | null = null; +let renderQueue: Promise = Promise.resolve(); + +export function resourceModelThumbnailCacheSize() { + return thumbnailCache.size; +} + +/** + * 缩略图缓存键:**不含 blob URL**。 + * + * 预览缓存淘汰后同一份模型会被重新读取、拿到新的 blob URL;若把 URL 放进键里, + * 每一次重读都会变成一次重新解析 + 重新渲染。用稳定身份作键,重读只会重新取字节。 + */ +export function resourceModelThumbnailCacheKey( + request: Pick< + ResourceModelThumbnailRequest, + 'mediaType' | 'identity' | 'width' | 'height' + >, +) { + return `${request.mediaType}|${request.identity}|${request.width}x${request.height}`; +} + +export function resourceModelThumbnailIdentity(input: { + resourceKey: string; + path: string; + byteLen?: number; +}) { + return `${input.resourceKey}|${input.path}|${input.byteLen ?? 0}`; +} + +function rememberThumbnail(cacheKey: string, dataUrl: string) { + thumbnailCache.delete(cacheKey); + thumbnailCache.set(cacheKey, dataUrl); + while (thumbnailCache.size > THUMBNAIL_CACHE_LIMIT) { + const oldest = thumbnailCache.keys().next().value; + if (oldest === undefined) { + break; + } + thumbnailCache.delete(oldest); + } +} + +async function createRendererBundle(): Promise { + // 动态导入:三维渲染器只在真的出现模型卡时才加载,不进主包、不影响其它卡片的启动成本。 + const THREE = await import('three'); + const canvas = document.createElement('canvas'); + const renderer = new THREE.WebGLRenderer({ + canvas, + alpha: true, + antialias: true, + // 没有它,`toDataURL` 拿到的可能是被清空的缓冲(否则渲染后立刻被交换掉)。 + preserveDrawingBuffer: true, + }); + renderer.setClearColor(0x000000, 0); + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(35, 1, 0.01, 10_000); + addResourceModelLights(THREE, scene); + return { renderer, scene, camera }; +} + +function rendererBundle() { + rendererPromise ??= createRendererBundle().catch((error: unknown) => { + // 失败不缓存:下一次卡片进入视口时还有机会(例如 WebGL 上下文被临时耗尽)。 + rendererPromise = null; + throw error; + }); + return rendererPromise; +} + +async function renderOnce( + request: ResourceModelThumbnailRequest, +): Promise { + const THREE = await import('three'); + const { renderer, scene, camera } = await rendererBundle(); + const width = Math.max(64, Math.round(request.width)); + const height = Math.max(64, Math.round(request.height)); + const object = await loadResourceModelObject(request); + try { + scene.add(object); + /** + * 相机宽高比必须跟着这次渲染的像素尺寸走。 + * + * 这个相机是单例复用件(默认 `aspect = 1`),而卡片是宽扁的:不更新宽高比时, + * 方形投影会被塞进非方形的绘制缓冲,模型在卡面上就是**被拉伸**的 —— 与 3D 软件里 + * 同一个模型的比例对不上。浮层里的交互相机已经在创建 / 改尺寸时设过,这里补齐缩略图。 + */ + camera.aspect = width / height; + frameResourceModelInCamera(THREE, object, camera); + renderer.setPixelRatio(1); + renderer.setSize(width, height, false); + renderer.render(scene, camera); + return renderer.domElement.toDataURL('image/png'); + } finally { + scene.remove(object); + disposeResourceModelObject(object); + } +} + +/** + * 取一张模型缩略图(同一份字节只渲染一次)。 + * + * 队列是串行的:渲染器只有一个,两个模型同时渲染会互相覆盖同一个 canvas。 + */ +export async function renderResourceModelThumbnail( + request: ResourceModelThumbnailRequest, +): Promise { + const cacheKey = resourceModelThumbnailCacheKey(request); + const cached = thumbnailCache.get(cacheKey); + if (cached) { + rememberThumbnail(cacheKey, cached); + return cached; + } + const task = renderQueue.then(() => renderOnce(request)); + // 队列本身不能因为某一张失败就断掉:失败向调用方抛,队列继续排下一个。 + renderQueue = task.catch(() => undefined); + const dataUrl = await task; + rememberThumbnail(cacheKey, dataUrl); + return dataUrl; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts index 998c5b9f5..4e305c3b0 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts @@ -74,6 +74,20 @@ export type ProjectResourceTypeLabel = | 'Agent 回执' | '项目版本' | '游戏代码' + | '模型' + | '动画' + | '骨骼动画' + | '场景' + | '预制体' + | '瓦片地图' + | '材质' + | '特效' + | '图集' + | '位图字体' + | '纹理' + | '自动图集' + | '音频片段' + | '二进制' | '未知'; const documentExtension = @@ -82,6 +96,20 @@ const gameCodeExtension = /\.(html?|css|scss|less|m?[jt]sx?|cjs|rs|py|go|java|kt|kts|c|cc|cpp|h|hpp|cs|swift|php|rb|lua|sh|bash|zsh|sql|graphql|gql|vue|svelte)$/iu; const artExtension = /\.(png|jpe?g|webp|gif|svg|avif|bmp|mp4|webm|mov)$/iu; const audioExtension = /\.(mp3|wav|ogg|m4a|aac|flac|opus)$/iu; +/* + * 引擎(Cocos 3.8.8)资源扩展名。四组扩展名与原生侧的三份表 + * (`bridge_project_file_class` / `agent_local_project_file_type` / `prompt_context_media_type`) + * 必须同步:少一个扩展名在这里,资源就会「登记得了、画布不显示」,而且没有任何报错。 + */ +/** 三维模型与网格数据。 */ +const engineModelExtension = /\.(glb|gltf|fbx|mesh|skeleton)$/iu; +/** 图像容器(浏览器解不了,原生侧转码成 PNG 后按图片显示)。 */ +const engineImageContainerExtension = /\.(tga|tif|tiff|hdr|exr|psd|znt)$/iu; +/** 材质与特效:登记为 `code`,卡面另行按结构预览。 */ +const engineMaterialExtension = /\.(mtl|material|pmtl|effect|chunk)$/iu; +/** 场景、动画、图集与容器:登记为 `document`,卡面按结构预览或类型卡。 */ +const engineStructuredExtension = + /\.(scene|fire|prefab|anim|animation|animgraph|animgraphvari|animask|tmx|terrain|plist|labelatlas|atlas|fnt|pac|dbbin|bin|skel|texture|cubemap|rt)$/iu; const gameCodeKind = /(?:^|[-_])(game-(?:entry|style|script)|code|source)(?:$|[-_])/iu; const artKind = @@ -132,10 +160,17 @@ export function projectedResourceKind(input: { if ( normalizedMediaType.startsWith('image/') || normalizedMediaType.startsWith('video/') || - artExtension.test(normalizedPath) + artExtension.test(normalizedPath) || + engineModelExtension.test(normalizedPath) || + engineImageContainerExtension.test(normalizedPath) ) { return 'art'; } + // 引擎材质 / 特效排在文档之前:它们的 mediaType 多为 `application/json`, + // 一旦先落到文档分支,画布分类就和登记时的 kind 对不上。 + if (engineMaterialExtension.test(normalizedPath)) { + return 'code'; + } if ( normalizedMediaType === 'text/html' || normalizedMediaType === 'text/css' || @@ -149,7 +184,8 @@ export function projectedResourceKind(input: { normalizedMediaType.includes('json') || normalizedMediaType.includes('yaml') || normalizedMediaType.startsWith('text/') || - documentExtension.test(normalizedPath) + documentExtension.test(normalizedPath) || + engineStructuredExtension.test(normalizedPath) ) { return 'document'; } @@ -203,6 +239,55 @@ export function projectResourceTypeLabel( if (subtype === 'agent-result') { return 'Agent 回执'; } + /* + * 引擎资源的类型角标先按扩展名判定。 + * + * 它们在 manifest 里复用的是既有 canonical kind(模型/场景 → `scene`,动画 → + * `character-animation`,材质/特效 → `code`,容器 → `document`),只用 kind 反推 + * 会把这些资源一律说成「图片」「文档」或「游戏代码」,用户看不出这是什么。 + */ + if (/\.(glb|gltf|fbx|mesh)$/iu.test(path)) { + return '模型'; + } + if (/\.(anim|animation|animgraph|animgraphvari|animask)$/iu.test(path)) { + return '动画'; + } + if (/\.(skeleton|skel|dbbin)$/iu.test(path)) { + return '骨骼动画'; + } + if (/\.(scene|fire|terrain)$/iu.test(path)) { + return '场景'; + } + if (/\.prefab$/iu.test(path)) { + return '预制体'; + } + if (/\.tmx$/iu.test(path)) { + return '瓦片地图'; + } + if (/\.(mtl|material|pmtl)$/iu.test(path)) { + return '材质'; + } + if (/\.(effect|chunk)$/iu.test(path)) { + return '特效'; + } + if (/\.(plist|atlas|labelatlas)$/iu.test(path)) { + return '图集'; + } + if (/\.fnt$/iu.test(path)) { + return '位图字体'; + } + if (/\.(texture|cubemap|rt)$/iu.test(path)) { + return '纹理'; + } + if (/\.pac$/iu.test(path)) { + return '自动图集'; + } + if (/\.pcm$/iu.test(path)) { + return '音频片段'; + } + if (/\.bin$/iu.test(path)) { + return '二进制'; + } if (kind === 'code') { return '游戏代码'; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts index 729658ceb..508a16e11 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts @@ -95,6 +95,12 @@ function resourceReadKindLabel(kind: ProjectResourceCardPreviewKind) { if (kind === 'code') { return '游戏代码'; } + if (kind === 'structured') { + return '引擎资源'; + } + if (kind === 'model') { + return '模型'; + } return '资源'; } @@ -532,6 +538,22 @@ export function useProjectResourceCardPreviews(input: { }, ); } + /** + * 引擎序列化资源走独立读取:准入白名单与服务 UI 编辑器的那份**分开**, + * 且允许「不是 UTF-8」的文件正常返回 `content: null`(卡面降级成类型卡), + * 而不是把它报成一次预览失败。 + */ + if (kind === 'structured') { + return invoke( + 'read_local_project_structured_preview', + { + projectPath: input.projectPath, + relativePath: job.resource.path, + scopeId: job.scopeId, + requestId: createProjectResourcePreviewRequestId(), + }, + ); + } return invoke( 'read_local_project_media_preview', { @@ -643,6 +665,9 @@ export function useProjectResourceCardPreviews(input: { if ( kind === 'version' || kind === 'placeholder' || + // 类型卡不读字节:客户端解不了的容器(压缩纹理、Spine 二进制、PSD/EXR、裸 PCM) + // 读进来也画不出东西,占读取槽只会挤掉真正能出图的卡片。 + kind === 'binary' || (kind === 'audio' && reason !== 'play') ) { return; @@ -1090,7 +1115,12 @@ export function useProjectResourceCardPreviews(input: { continue; } const kind = projectResourceCardPreviewKind(resource); - if (kind === 'version' || kind === 'placeholder' || kind === 'audio') { + if ( + kind === 'version' || + kind === 'placeholder' || + kind === 'audio' || + kind === 'binary' + ) { continue; } const element = elementsByIdentity.get(identity); @@ -1122,7 +1152,8 @@ export function useProjectResourceCardPreviews(input: { identity && kind !== 'version' && kind !== 'placeholder' && - kind !== 'audio' + kind !== 'audio' && + kind !== 'binary' ) { requestPreview(resource, identity, 'visible'); fallbackRequested += 1; diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 636ec3d74..6c6d4d9b9 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 @@ -3460,8 +3460,27 @@ export function registerProjectWorkbenchFoundationTests() { resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'), 'utf8', ); - // 卡片本体不再自带描边;只有被当前版本绑定的素材才有边框。 - expect(styles).toMatch(/\.game-resource-card\s*\{[^}]*border:\s*0;/s); + /* + * 卡片本体只保留**透明**边框,可见描边仍然只属于「当前版本绑定」等状态。 + * + * 透明而不是 `border: 0`:卡片是 `box-sizing: border-box`,卡面与角标又都是以 padding + * box 为包含块的绝对定位元素 —— 底态 0 宽、状态态 1px 宽时,一次悬停就会把内容盒四边 + * 各吃掉 1px,卡片里的东西跟着位移。常驻 1px 透明边框让所有状态的 padding box 一致, + * 视觉上仍"本体无描边"。 + */ + expect(styles).toMatch( + /\.game-resource-card\s*\{[^}]*border:\s*1px solid transparent;/s, + ); + // 状态态只允许点亮颜色,不允许改动宽度:宽度一变就又回到"内容跟着动"。 + expect(styles).toMatch( + /\.game-resource-card:hover,[^{]*\{[^}]*border:\s*1px solid/s, + ); + expect(styles).not.toMatch( + /\.game-resource-card[^{,]*\{[^}]*border-width:/s, + ); + expect(styles).not.toMatch( + /\.game-resource-card[^{,]*\{[^}]*border:\s*[2-9]px/s, + ); expect(styles).toMatch( /\.game-resource-card\.is-current-version\s*\{[^}]*border:\s*1px solid/s, ); diff --git a/apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx b/apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx new file mode 100644 index 000000000..81f031887 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx @@ -0,0 +1,461 @@ +// @vitest-environment jsdom +import { render, screen, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +// 浮层外壳(portal + 焦点陷阱)不在本用例关注面内:与文档预览浮层的用例同口径只渲染子节点。 +vi.mock('../src/components/modal/ThemedModal', () => ({ + ThemedModal: ({ + children, + ariaLabel, + }: { + children: ReactNode; + ariaLabel: string; + }) => ( +
+ {children} +
+ ), +})); + +import { + projectResourceCardPreviewKind, + projectResourceCardPreviewReadsContent, + projectResourceCocosStructureSummary, + projectResourceMediaPreviewCategory, + projectResourceStructuredPreviewText, +} from '../src/view/project-development/resourceCardPreviewModel'; +import { ResourceModelPreview } from '../src/view/project-development/ResourceModelPreview'; +import { ResourceModelPreviewDialog } from '../src/view/project-development/ResourceModelPreviewDialog'; +import { + resourceModelThumbnailCacheKey, + resourceModelThumbnailIdentity, +} from '../src/view/project-development/resourceModelThumbnail'; +import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel'; +import { + projectedResourceKind, + projectResourceTypeLabel, +} from '../src/view/project-development/resourceProjectionModel'; + +type EngineCase = { + path: string; + mediaType: string; + kind: string; + projectedKind: 'art' | 'audio' | 'code' | 'document'; + previewKind: + | 'model' + | 'structured' + | 'binary' + | 'media-image' + | 'audio' + | 'document' + | 'code'; + typeLabel: string; + readsContent: boolean; +}; + +/** + * Cocos Creator 资源在资源画布上的**准入 + 卡面分支 + 类型角标**三合一决策表。 + * + * 这张表是前后端三份扩展名表(原生发现 / 原生登记 / 前端投影)的交叉契约:表里任何一行 + * 对不上,都会表现为「登记得了但画布不显示」或「显示了但预览是坏图」。 + */ +const ENGINE_CASES: EngineCase[] = [ + { + path: 'assets/model/hero.glb', + mediaType: 'model/gltf-binary', + kind: 'scene', + projectedKind: 'art', + previewKind: 'model', + typeLabel: '模型', + readsContent: true, + }, + { + path: 'assets/model/hero.gltf', + mediaType: 'model/gltf+json', + kind: 'scene', + projectedKind: 'art', + previewKind: 'model', + typeLabel: '模型', + readsContent: true, + }, + { + path: 'assets/model/hero.fbx', + mediaType: 'application/octet-stream', + kind: 'scene', + projectedKind: 'art', + previewKind: 'model', + typeLabel: '模型', + readsContent: true, + }, + { + path: 'assets/model/hero.mesh', + mediaType: 'application/json', + kind: 'scene', + projectedKind: 'art', + previewKind: 'structured', + typeLabel: '模型', + readsContent: true, + }, + { + path: 'assets/model/hero.skeleton', + mediaType: 'application/json', + kind: 'scene', + projectedKind: 'art', + previewKind: 'structured', + typeLabel: '骨骼动画', + readsContent: true, + }, + { + path: 'assets/anim/walk.anim', + mediaType: 'application/json', + kind: 'character-animation', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '动画', + readsContent: true, + }, + { + path: 'assets/anim/graph.animgraph', + mediaType: 'application/json', + kind: 'character-animation', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '动画', + readsContent: true, + }, + { + path: 'assets/scene/main.scene', + mediaType: 'application/json', + kind: 'scene', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '场景', + readsContent: true, + }, + { + path: 'assets/scene/enemy.prefab', + mediaType: 'application/json', + kind: 'scene', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '预制体', + readsContent: true, + }, + { + path: 'assets/map/level.tmx', + mediaType: 'application/xml', + kind: 'scene', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '瓦片地图', + readsContent: true, + }, + { + path: 'assets/mtl/hero.mtl', + mediaType: 'application/json', + kind: 'code', + projectedKind: 'code', + previewKind: 'structured', + typeLabel: '材质', + readsContent: true, + }, + { + path: 'assets/shader/glow.effect', + mediaType: 'text/plain', + kind: 'code', + projectedKind: 'code', + previewKind: 'structured', + typeLabel: '特效', + readsContent: true, + }, + { + path: 'assets/atlas/hero.plist', + mediaType: 'application/xml', + kind: 'document', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '图集', + readsContent: true, + }, + { + path: 'assets/font/bitmap.fnt', + mediaType: 'text/plain', + kind: 'document', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '位图字体', + readsContent: true, + }, + { + path: 'assets/tex/grass.tga', + mediaType: 'image/x-tga', + kind: 'image', + projectedKind: 'art', + previewKind: 'media-image', + typeLabel: '图片', + readsContent: true, + }, + { + path: 'assets/tex/height.hdr', + mediaType: 'image/vnd.radiance', + kind: 'image', + projectedKind: 'art', + previewKind: 'media-image', + typeLabel: '图片', + readsContent: true, + }, + { + path: 'assets/tex/hero.texture', + mediaType: 'application/octet-stream', + kind: 'document', + projectedKind: 'document', + previewKind: 'binary', + typeLabel: '纹理', + readsContent: false, + }, + { + path: 'assets/spine/hero.skel', + mediaType: 'application/octet-stream', + kind: 'document', + projectedKind: 'document', + previewKind: 'binary', + typeLabel: '骨骼动画', + readsContent: false, + }, + { + path: 'assets/audio/voice.pcm', + mediaType: 'audio/pcm', + kind: 'audio', + projectedKind: 'audio', + previewKind: 'binary', + typeLabel: '音频片段', + readsContent: false, + }, +]; + +describe('Cocos 资源在资源画布上的准入与预览契约', () => { + it('每个引擎资源都能进画布、落到预期卡面分支与类型角标', () => { + for (const entry of ENGINE_CASES) { + const resource = { + id: entry.path, + path: entry.path, + mediaType: entry.mediaType, + subtype: entry.kind, + }; + expect( + projectedResourceKind({ + path: entry.path, + mediaType: entry.mediaType, + kind: entry.kind, + }), + `${entry.path} 必须能进画布`, + ).toBe(entry.projectedKind); + expect(projectResourceCardPreviewKind(resource), entry.path).toBe( + entry.previewKind, + ); + expect(projectResourceTypeLabel(resource), entry.path).toBe( + entry.typeLabel, + ); + expect( + projectResourceCardPreviewReadsContent(resource), + `${entry.path} 是否能读字节`, + ).toBe(entry.readsContent); + } + }); + + it('模型走 model 读取分支、图片容器走 art,二进制容器不读字节', () => { + expect( + projectResourceMediaPreviewCategory({ + id: 'glb', + path: 'assets/model/hero.glb', + mediaType: 'model/gltf-binary', + subtype: 'scene', + }), + ).toBe('model'); + expect( + projectResourceMediaPreviewCategory({ + id: 'tga', + path: 'assets/tex/grass.tga', + mediaType: 'image/x-tga', + subtype: 'image', + }), + ).toBe('art'); + }); +}); + +describe('Cocos 序列化资源的结构摘要', () => { + it('从序列化数组里抽出根类型、节点数、时长与资源引用', () => { + const summary = projectResourceCocosStructureSummary( + JSON.stringify([ + { __type__: 'cc.AnimationClip', _name: 'walk', _duration: 1.25 }, + { __type__: 'cc.Node', _name: 'root' }, + { __type__: 'cc.Node', _name: 'child' }, + { __uuid__: 'abc' }, + ]), + ); + expect(summary).toContain('cc.AnimationClip'); + expect(summary).toContain('2 个节点'); + expect(summary).toContain('1.25 秒'); + expect(summary).toContain('walk'); + expect(summary).toContain('1 处资源引用'); + }); + + it('不是序列化数组时不做结构摘要,回退到前几行文本', () => { + const effect = 'CCEffect %{\n techniques: []\n}'; + expect(projectResourceCocosStructureSummary(effect)).toBeNull(); + expect(projectResourceStructuredPreviewText(effect)).toContain('CCEffect'); + }); + + it('二进制变体(原生读不到正文)返回空串,卡面画类型卡', () => { + expect(projectResourceStructuredPreviewText(undefined)).toBe(''); + }); +}); + +describe('模型卡渲染失败时的降级', () => { + it('缩略图缓存键按稳定身份计算,不随 blob URL 变化', () => { + const identity = resourceModelThumbnailIdentity({ + resourceKey: 'asset:hero', + path: 'assets/model/hero.glb', + byteLen: 1024, + }); + const first = resourceModelThumbnailCacheKey({ + mediaType: 'model/gltf-binary', + identity, + width: 320, + height: 240, + }); + const second = resourceModelThumbnailCacheKey({ + mediaType: 'model/gltf-binary', + identity, + width: 320, + height: 240, + }); + expect(first).toBe(second); + // 同一份资源换了内容(字节数变化)或换了尺寸,都必须重新渲染。 + expect( + resourceModelThumbnailCacheKey({ + mediaType: 'model/gltf-binary', + identity: resourceModelThumbnailIdentity({ + resourceKey: 'asset:hero', + path: 'assets/model/hero.glb', + byteLen: 2048, + }), + width: 320, + height: 240, + }), + ).not.toBe(first); + expect( + resourceModelThumbnailCacheKey({ + mediaType: 'model/gltf-binary', + identity, + width: 480, + height: 360, + }), + ).not.toBe(first); + }); + + it('渲染不出缩略图时显示类型卡,不冒泡成预览失败', async () => { + // jsdom 没有 WebGL:这正是"渲染不可用"的真实路径,用来钉住降级行为。 + render( + , + ); + expect(screen.getByText('模型 · GLB')).toBeDefined(); + await waitFor(() => { + expect( + document.querySelector('[data-model-preview-status="failed"]'), + ).not.toBeNull(); + }); + }); +}); + +describe('模型放大预览浮层', () => { + const modelResource: ProjectResource = { + id: 'asset:model', + path: 'assets/model/cube.glb', + label: 'cube.glb', + mediaType: 'model/gltf-binary', + category: 'scene', + subtype: 'scene', + manifestAssetId: 'asset:model', + sourceLabel: '', + taskTitle: null, + producerTaskId: null, + externalResourceId: null, + referenceResourceIds: [], + dependencies: [], + dependencyDepth: 0, + }; + + it('打开即按详情理由请求字节,并给出与建模软件一致的视角操作提示', () => { + const onRequestPreview = vi.fn(); + render( + undefined} + />, + ); + expect(onRequestPreview).toHaveBeenCalledWith( + modelResource, + 'asset:model|assets/model/cube.glb|1548', + 'detail', + ); + expect(screen.getByRole('status').textContent).toContain('正在加载模型'); + expect(document.body.textContent).toContain('左键拖拽旋转'); + expect(document.body.textContent).toContain('滚轮缩放'); + expect(document.body.textContent).toContain('复位视角'); + }); + + it('渲染器不可用时明确说明,不假装已经渲染出模型', async () => { + // jsdom 没有 WebGL:这正是「无法渲染」的真实路径。 + render( + undefined} + onClose={() => undefined} + />, + ); + await waitFor(() => { + expect( + document.querySelector('[data-model-viewer-status="failed"]'), + ).not.toBeNull(); + }); + expect(document.body.textContent).toContain('无法渲染三维预览'); + }); + + it('预览失败时给出错误与重试,不留下空白浮层', () => { + render( + undefined} + onClose={() => undefined} + />, + ); + expect(screen.getByRole('alert').textContent).toContain('模型预览只支持'); + expect(screen.getByText('重试')).toBeDefined(); + }); +}); 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..2eba81369 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】资源画布支持引擎资源预览-2026-09-17.md @@ -0,0 +1,57 @@ +# 【里程碑】资源画布支持引擎资源预览 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | implemented; 真机 Cocos 工程验收待补 | +| Date | 2026-09-17 | +| Parent Spec | `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` | + +## 目标 + +引擎(Cocos Creator 等)工程里已经存在的资源,可以被登记进 manifest,并作为资源画布的卡片**只读预览**:模型出缩略图、序列化资源出结构摘要、客户端解不了的容器出类型卡。 + +## 范围 + +- 登记与发现:模型、动画、场景/预制体/瓦片地图/地形、材质/特效、图集与字体配置、图像容器、引擎二进制容器。 +- 生成目录过滤:Cocos 工程的 `library/`、`temp/`、`profiles/`、`local/` 不进发现结果(仅当当前目录确实是引擎工程时生效)。 +- 画布准入与卡面:新增「引擎资源」三个卡面分支(模型 / 结构摘要 / 类型卡),并按扩展名给出引擎语义的类型角标。 +- 模型放大预览:选中模型卡后用工具条的「3D 预览」打开浮层,给出与三维建模软件一致的视角操作(左键旋转 / 右键或中键平移 / 滚轮缩放 / 复位视角)。 +- 只读预览通道:模型字节读取、引擎序列化文本读取(非 UTF-8 时降级)、图像容器原生转码。 + +## 不在范围内 + +- 引擎资源的编辑、派生、生成与回写(画布上仍是只读预览;「快速编辑」等现有工具链不承接这些类型)。 +- 引擎私有格式的解码:压缩纹理(`.texture` / `.cubemap` / `.rt`)、Spine 二进制(`.skel`)、DragonBones 二进制(`.dbbin`)、PSD、EXR、裸 PCM 只出类型卡。 +- 新增 manifest 契约字段(`cocosUuid` 等)、新增 canonical kind、新增画布分类轴:本轮复用既有 kind,避免旧客户端读不出 manifest(`deny_unknown_fields`)。 +- 多文件 glTF(`baseURI` 指向外部 `.bin` / 贴图)的渲染:卡面只渲染自包含的 `.glb` / 单文件 `.gltf` / `.fbx`,其余降级成类型卡(本轮选择放宽字节上限,不做资源路径解析)。 +- 项目快照 / 版本指纹 / 检查点对生成目录的口径:本轮只过滤**发现结果**,不改快照语义。 + +## 依赖与前置条件 + +- 现有四道闸门:发现(`bridge_project_file_class`)、登记(`agent_local_project_file_type`)、画布准入(`projectedResourceKind`)、卡面读取(`resourceCardPreviewModel` + 原生预览命令)。 +- 既有预览管线(可见性门禁、3 槽并发、LRU 预算、取消与失败语义)不改口径。 +- 前端新增 `three` 运行时依赖(缩略图渲染器)与 `@types/three` 类型依赖。 + +## 验收标准 + +- [x] 表内引擎资源都能通过发现层拿到非空 `mediaType`,并按 `model` / `binary` / `image` / `document` / `audio` 等类别被筛出。 +- [x] 表内引擎资源都能登记进 manifest,`kind` 只落在既有 canonical 词表内。 +- [x] 表内引擎资源都能进入资源画布,并落到预期的卡面分支与类型角标。 +- [x] 模型卡在渲染不可用时降级成类型卡,不冒泡成预览失败。 +- [x] 引擎序列化资源的非 UTF-8 变体降级成类型卡,不报错。 +- [x] 图像容器(`.tga` / `.tif` / `.tiff` / `.hdr`)由原生侧转码成 PNG 后按图片卡显示。 +- [x] 引擎工程的生成目录不出现在发现结果里,同名目录在非引擎工程里照常列出。 +- [x] 模型预览上限放宽到与通用媒体预览一致(32 MiB),超出后仍是类型卡而不是半渲染。 +- [x] 模型卡可以放大到独立浮层里交互查看:拖动与滚轮都真实改变画面,「复位视角」回到打开时的取景。 +- [x] 真机 Cocos 工程(含模型与动画资源)在客户端内滚动浏览的视觉验收。 + +## 证据要求 + +- 自动化:`cargo check`;`cargo fmt --check`;`cargo test --bin genarrative-ai-game-creator-shell cocos`(9 通过,含发现分类 / 登记 / 提示词 / 插件门禁);`cargo test … resource_inspect::tests`(7 通过:结构化预览与二进制降级、TGA→PNG 转码、模型媒体类型签名 + 既有文本 / SVG / 尺寸用例);`cargo test … agent_asset_import_tests`(9 通过);`cargo test … local_project_file_listing_skips_engine_generated_directories_only_for_engine_projects`;`npx vitest run apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx`(7 通过);`npx vitest run apps/ai-game-creator-shell/tests/resource apps/ai-game-creator-shell/tests/project`(52 文件 / 543 用例);`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`(450 通过 / 20 跳过);`npm run typecheck`(app);`npm run check:encoding`;`git diff --check`;`npm run check:doc-index`。 +- 运行时(2026-09-17 完成):在真实客户端(`npm run agc` 起的 Tauri 客户端 + WebView2 CDP 驱动)里打开一个**本地临时 Cocos 夹具工程**(含模型 / 动画 / 序列化资源 / TGA / 引擎容器,不入库),经 `import_local_cocos_project` 与受控登记导入 10 个引擎资源后逐栏核对:模型卡出真实三维缩略图(`data-model-preview-status=ready`,卡面是渲染出来的 PNG data URL);`.anim` / `.scene` / `.prefab` / `.plist` / `.effect` 出结构摘要或文本预览;`.tga` 出真实图片(原生转码生效);`.texture` / `.bin` / `.pcm` 出「纹理 · TEXTURE」「二进制 · BIN」「音频片段 · PCM」类型卡。验收截图(本地留存,不入库):场景与环境栏(模型 + 摘要)、文档栏(类型卡)、待归类栏(TGA 转码)、音频栏、模型放大预览。 +- 运行时(目录过滤):同一现场调 `list_local_project_files`,根级 `library/` / `temp/` / `profiles/` **0 条**,而 `assets/library/inside.bin` 正常列出。 +- 运行时(模型交互):同一现场选中 `cube.glb` → 工具条出现「3D 预览」→ 浮层内 `data-model-viewer-status=ready`、canvas 已挂载;真实鼠标拖拽与滚轮各产生一次不同的渲染结果(三张截图 MD5 互不相同),点「复位视角」后的截图与打开时**逐字节一致**(MD5 `A6531331206456C666909F80384B53AD`)。证据:`agc-model-viewer-initial.png` / `agc-viewer-rotated.png` / `agc-viewer-zoomed-out.png` / `agc-viewer-reset.png`。 +- 运行时(卡面几何):同一现场量 `cube.glb` 卡的卡片盒 / 卡面 / 类型角标 / 缩略图四个矩形的 `x/y/w/h`,在**指针移开、悬停、选中**三种状态下**完全一致**(此前悬停会把卡面四边各吃 1px);缩略图渲染尺寸为 `356x252`(布局盒 178×126 × 2 超采样),绘制比 1.4127 与卡面盒 267×189 的比例一致,缩放后不发虚、不裁切。证据:`agc-fix-hover.png` / `agc-fix-selected.png`。 +- 边界:`.meta` 仍然只可发现、不可登记;引擎工程的 `library/` / `temp/` / `profiles/` / `local/` 不进发现结果,同名目录在非引擎工程里照常列出;`.pcm` / `.texture` 等容器不发起读取。 +- 未验证:真机客户端内的视觉验收(本机没有可用的 Cocos 工程实例)。Rust 侧其余仍用 `tempfile::tempdir()` 的既有用例在本机仍被 `Windows 安全对象不属于当前用户` 阻断(本次把预览与导入这两个模块的用例改用工程自带的 `crate::tests::canonical_test_tempdir`,它们已能真实跑通)。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ddb8c3926..f1edf3f71 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -22,6 +22,21 @@ - 影响范围:`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-17 资源画布支持引擎资源只读预览 + +- 背景:Cocos Creator 工程里已有的引擎资源(模型、动画、预制体、材质、图集、压缩纹理…)此前在发现层就止步:`.glb` / `.prefab` / `.anim` / `.texture` 等扩展名既不可登记,也不进资源画布,工程导入后画布上只看得到位图、音频与脚本。 +- 决策(范围):本轮只做**只读预览**。引擎资源可以被发现、登记进 manifest、进入资源画布并按类型出预览;不承接编辑、派生、生成与回写,也不解码引擎私有容器(`.texture` / `.cubemap` / `.rt` / `.skel` / `.dbbin` / `.psd` / `.exr` / `.pcm` 只出类型卡)。 +- 决策(契约):**不新增 manifest 契约字段、不新增 canonical kind、不新增画布分类轴**。引擎资源复用既有 kind(模型/场景/预制体/地形 → `scene`,动画 → `character-animation`,材质/特效 → `code`,图集与容器 → `document`,图像容器 → `image`,裸 PCM → `audio`),避免 `deny_unknown_fields` 让旧客户端读不出整份 manifest;引擎语义由**类型角标**(模型 / 动画 / 材质 / 图集 / 纹理…)表达,不复用「图片 / 文档」。 +- 决策(卡面与读取):新增三个卡面分支 —— `model`(`.glb` / `.gltf` / `.fbx`,由单例 WebGL 渲染器出缩略图,整页只保留一个 WebGL 上下文)、`structured`(Cocos 序列化资源的结构摘要,非 UTF-8 变体降级成类型卡而不是报错)、`binary`(不发起任何读取,不占预览读取槽)。图像容器(`.tga` / `.tif` / `.tiff` / `.hdr`)先在原生侧转码成 PNG,再走既有图片预览链路。 +- 边界:`.meta` 等引擎导入侧车文件仍然只可发现、不可登记;发现层新增 `model` / `binary` 两个**发现类别**(不是 manifest kind)。多文件 glTF(外部 `.bin` / 贴图)与超限模型降级成类型卡;预览管线既有语义(可见性门禁、3 槽并发、LRU 预算、取消与重试口径)不变。 +- 决策(发现过滤):引擎工程的 `library/` / `temp/` / `profiles/` / `local/` 不再进发现结果,判定收窄为「工程根直接子目录 + 当前目录确实是 Cocos Creator 工程(`package.json.creator.version` + `assets/`)」。**不放进全局跳过表**:这些名字在别的工程里可能是真实源码目录。过滤落在唯一一份目录遍历(`list_local_project_files_at`)上,因此 Agent 发现、前端资源树与提示词里的未登记清单同步生效;项目快照 / 版本指纹 / 检查点的语义本轮不动。 +- 决策(模型上限与缓存键):模型预览字节上限从 16 MiB 放宽到 32 MiB,与通用媒体预览取同一上限(base64 载荷约 43 MiB);超过上限仍是类型卡,不做半渲染。缩略图缓存键改用**稳定身份**(资源身份 + 路径 + 字节数)而不是 blob URL:预览缓存淘汰后重读同一模型不会重新解析 + 重新渲染。要再往上放宽,必须先把预览载荷换成 Tauri 原始字节通道。 +- 决策(模型放大预览):模型卡可以在工具条打开「3D 预览」独立浮层,浮层内是**交互式视角**(OrbitControls:左键旋转 / 右键或中键平移 / 滚轮缩放 / 复位视角),与三维建模软件同一套操作习惯。加载 / 取景 / 释放三条口径抽到共用模块 `resourceModelScene`,缩略图与浮层不许各写一套;画布上的卡片仍然是静态缩略图并继续共用**唯一**一个 WebGL 上下文,只有打开浮层时才新建交互式上下文,关闭即 dispose。浮层仍是只读预览:不写 manifest、不参与编辑与派生。 +- 验证:`cargo check`、`cargo fmt --check` 通过;`cargo test … cocos` 9 条通过(发现分类 / 登记 / 提示词投影 / 插件门禁);`cargo test … resource_inspect::tests` 7 条通过(含结构化预览与二进制降级、TGA→PNG 转码、模型签名判定);`cargo test … agent_asset_import_tests` 9 条通过;生成目录过滤用例通过(引擎工程过滤、非引擎工程不过滤);`tests/resourceCocosPreviewContract.test.tsx` 7 条通过(含模型卡渲染不可用时的降级、稳定缓存键);`npx vitest run apps/ai-game-creator-shell/tests/resource apps/ai-game-creator-shell/tests/project` 52 文件 / 543 用例通过;`appSurface.test.ts` 450 通过 / 20 跳过;app `tsc --noEmit`、`npm run check:encoding`、`git diff --check`、`npm run check:doc-index` 通过。 +- 真机验收(2026-09-17 补):在真实客户端内打开一个含模型 / 动画 / 序列化资源 / TGA / 引擎容器的 Cocos 夹具工程,模型卡出三维缩略图、序列化资源出结构摘要、TGA 出转码后的真实图片、引擎容器出类型卡;同现场 `list_local_project_files` 对根级 `library/` / `temp/` / `profiles/` 返回 0 条、`assets/library/` 正常列出。证据见里程碑文档「证据要求」。 +- 未验证:本机其余仍用 `tempfile::tempdir()` 的既有 Rust 用例继续被 `Windows 安全对象不属于当前用户` 阻断(与本决策无关;根因与手工夹具相同,已记入 `pitfalls.md`)。 +- 关联文档:`docs/project-memory/plans/【里程碑】资源画布支持引擎资源预览-2026-09-17.md`。 + ## 2026-09-16 抠图模式与背景色契约 - External v1 抠图和 AGC `agc_remove_background` 支持 `complex`(语义分割识别前景)与 `flat`(纯色背景抠图);明确纯色背景优先 flat,模式缺省仍为 complex,主站前端保持现有行为。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 52b39d70a..be2d7f4b7 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -8,6 +8,16 @@ - **易错点**:① 把弹层改成 `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`。 +## 2026-09-17 资源卡「内容跟着边框动」与「模型缩略图被拉伸」是两条不同的几何陷阱 + +- **内容跟着状态边框位移**:卡片底态是 `border: 0`,悬停 / 选中才加 1px 边框;卡片是 `box-sizing: border-box`,而卡面(`.game-resource-card-visual`)与角标都是 `position: absolute; inset: 0`(包含块 = **padding box**)⇒ 状态一切换,内容盒四边各被吃掉 1px,卡面与角标整体位移并缩小 2px。修法:底态写成 `border: 1px solid transparent;`,状态只点亮 `border-color`;契约用例改成断言「资源卡规则里不得出现非 1px 的 `border` / `border-width`」。 +- **模型缩略图看起来被拉伸 / 被裁**:三个原因叠在一起 —— ① 缩略图渲染器的 `PerspectiveCamera` 是单例复用件,`aspect` 默认 `1` 且从没更新,方形投影被塞进宽扁缓冲;② 卡面里 `width/height: 100%` 的图片挂在 `place-items: center` 的网格里,网格项高度会退化成"按内容定高"(百分比高度解析成 `auto`),图片按自然比例长过卡片、被 `overflow: hidden` 裁掉,看起来就像被拉伸;③ 栏目画布用 `transform: scale(var(--resource-section-zoom))` 放大(真机 1.5 倍),而 `ResizeObserver` / `offsetWidth` 只看**布局盒**,缩放变化既不触发 observer,按布局尺寸 1:1 渲染的图也会被放大到发虚。修法:渲染前设 `camera.aspect`;图片改 `position: absolute; inset: 0` + `object-fit: contain`;渲染尺寸取 `offsetWidth/offsetHeight × 2` 超采样(上限 1024),并把实际渲染尺寸暴露到 `data-model-render-size` 方便排障。 + +## 2026-09-17 从提权会话创建的目录会被 AGC 的 Windows owner 校验直接拒绝 + +- **现象**:在 Codex 会话里手工创建的工程目录(例如 `C:\Users\\Documents\Codex\...\cocos-preview-fixture`),用客户端打开时报 `Windows 安全对象不属于当前用户:`;Rust 侧同样用 `tempfile::tempdir()` 建夹具的用例也成片失败在同一句上。 +- **原因**:这个 shell 以管理员身份运行,`New-Item` / `tempfile` 新建目录的 owner 是 `BUILTIN\Administrators`,而 AGC 的校验要求 owner 等于当前用户 SID(`KDLETTERS\`)。`Get-Acl | Select Owner` 与 `whoami` 一比就能定性;同一台机器上由客户端自己创建的目录 owner 正确,所以「客户端自己建的项目能用、手建的不能用」。 +- **处理**:手工夹具先 `icacls /setowner "\" /T`;Rust 用例改用工程自带的 `crate::tests::canonical_test_tempdir(prefix)`(它会 canonicalize 并重置目录 owner),不要直接用 `tempfile::tempdir()`。判「用例失败与本改动无关」时,先确认失败信息是不是这一条。 ## DirectProject 历史不能按工具条目切页再按消息推进游标 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 550e75f0c..fe8462063 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -152,7 +152,7 @@ npm 游戏的可预览产物固定为对应 package 目录下的 `dist/index.htm - 素材读取区分三类来源:`asset.list` / `agc_list_registered_assets` 是当前项目本地 manifest,`agc_list_project_files` / `file.list` 只发现项目目录中实际存在但可能未登记的文件,`asset.library.list` 是当前登录账号素材库,项目画布资源读取是当前网页项目/画布的完整图片清单;账户素材库不能替代项目画布清单。 - Agent 只接收稳定素材 ID、类型、尺寸和项目相对路径等安全投影。客户端负责重新校验账号/项目归属、换签下载、媒体校验,以及 manifest/画布原子登记;不得向 Agent 暴露绝对路径、签名 URL、objectKey、token 或 Cookie。 -- `canvas.asset_import` 支持账户/画布资源 ID 和项目内本地相对路径。项目文件发现结果以 `assetImportable` 明确区分当前可登记的已识别图片、字体、音频、视频、文档和代码文件与其它文件;Agent 只能提交前者。导入拒绝路径穿越、`.agent`、符号链接/reparse point 及敏感配置文件;外部宿主文件须由 UI 原生文件选择器授权后导入,不开放任意绝对路径。 +- `canvas.asset_import` 支持账户/画布资源 ID 和项目内本地相对路径。项目文件发现结果以 `assetImportable` 明确区分当前可登记的已识别图片、字体、音频、视频、文档、代码与**引擎资源**(Cocos Creator 的模型、动画、场景/预制体、材质/特效、图集与压缩纹理容器)与其它文件;Agent 只能提交前者。引擎资源在资源画布上是**只读预览**:模型出缩略图、序列化资源出结构摘要、客户端解不了的容器出类型卡,不承接编辑与派生;`.meta`、`library/`、`temp/` 等引擎生成物仍然只可发现、不可登记。导入拒绝路径穿越、`.agent`、符号链接/reparse point 及敏感配置文件;外部宿主文件须由 UI 原生文件选择器授权后导入,不开放任意绝对路径。 - Runtime `asset.list` 与 `file.list` 的详情使用文件上下文上限,而不是普通工具短摘要上限,确保有界候选/目录清单不会因前部内容较长而整体丢失;`asset.list` 超出 48 项或 `file.list` 超出 40 项时仍显式返回剩余数量,Agent 再按候选父目录(例如 `assets`、`game/assets`)缩小范围查询。 - 结果仅返回成功/跳过/失败数量、安全 ID、相对路径、来源、脱敏失败摘要和实际 `revisionAdvanceCount`;幂等跳过不得虚增 revision,部分失败仍须准确记录已发生的 revision 变化。 - 普通 Prompt 上下文与错误诊断必须使用分离的脱敏边界:Prompt 继续对疑似凭据行整体隐藏;错误诊断保留 HTTP 状态以及 `code / field / message / reason / detail` 等安全字段,仅替换 Token、Cookie、私钥、配置名、URL 和宿主路径等敏感值。`agc_create_or_derive_resource.assetName` 是必填的人类可读资源显示名称,不接受项目路径、URL、objectKey、Token 或其它凭据。 diff --git a/package-lock.json b/package-lock.json index ccb4be9ec..ab6ee8ab6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -122,6 +122,7 @@ "react-window": "^1.8.11", "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", + "three": "^0.184.0", "vite": "^6.2.0", "zustand": "^5.0.14" }, @@ -134,6 +135,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/react-window": "^1.8.8", + "@types/three": "^0.184.1", "tailwindcss": "^4.1.14", "typescript": "~5.8.2", "vitest": "^0.34.6" @@ -1497,6 +1499,13 @@ } } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", @@ -8234,6 +8243,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -8449,6 +8465,28 @@ "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", "dev": true }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.184.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz", + "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -8461,6 +8499,13 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -12678,6 +12723,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -15697,6 +15749,13 @@ "node": ">= 8" } }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "dev": true, + "license": "MIT" + }, "node_modules/metro": { "version": "0.84.4", "resolved": "https://registry.npmjs.org/metro/-/metro-0.84.4.tgz", @@ -23896,6 +23955,12 @@ "react-icons": "^5.4.0" } }, + "@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true + }, "@esbuild/aix-ppc64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", @@ -26475,6 +26540,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/react-window": "^1.8.8", + "@types/three": "^0.184.1", "@vitejs/plugin-react": "^5.0.4", "focus-trap-react": "^12.0.3", "lexical": "^0.47.0", @@ -26489,6 +26555,7 @@ "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "tailwindcss": "^4.1.14", + "three": "^0.184.0", "typescript": "~5.8.2", "vite": "^6.2.0", "vitest": "^0.34.6", @@ -28333,6 +28400,12 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true + }, "@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -28531,6 +28604,26 @@ "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", "dev": true }, + "@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true + }, + "@types/three": { + "version": "0.184.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz", + "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==", + "dev": true, + "requires": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, "@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -28541,6 +28634,12 @@ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" }, + "@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true + }, "@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -31379,6 +31478,12 @@ "integrity": "sha512-e6eB7zN6UBSwGVwrbWVH+gdLnkW9WwHhmq2YDK1Sh30pzx1onRVGBvogTlUeWxwTa+L86NYdo4hFkh7O8ZjSnA==", "dev": true }, + "fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true + }, "figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -33354,6 +33459,12 @@ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true }, + "meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "dev": true + }, "metro": { "version": "0.84.4", "resolved": "https://registry.npmjs.org/metro/-/metro-0.84.4.tgz", From fce546403f988e0c590b7eb3fb98b8150174dc89 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 17 Sep 2026 20:23:55 +0800 Subject: [PATCH 11/16] =?UTF-8?q?=E6=96=87=E6=A1=A3=E8=AE=B0=E5=BD=95=20Je?= =?UTF-8?q?nkins=E3=80=81=E9=A2=84=E8=A7=88=E6=8E=A7=E5=88=B6=E9=9D=A2?= =?UTF-8?q?=E4=B8=8E=E9=A2=84=E8=A7=88=E5=AE=9E=E4=BE=8B=E7=9A=84=E5=85=AC?= =?UTF-8?q?=E7=BD=91=E5=85=A5=E5=8F=A3=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在【开发运维】本地开发验证与生产运维补充 jenkins.genarrative.world 与 build.genarrative.world 的公网入口、反向隧道端口、白名单依赖与排障顺序 - 在 shared-memory/decision-log.md 记录 Jenkins 公网入口、预览部署控制面公网入口、预览控制面公网预览地址口径三组决策及各自验证证据 --- .../shared-memory/decision-log.md | 27 +++++++++++++++++++ ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 ++ 2 files changed, 29 insertions(+) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f1edf3f71..1b57435a0 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8864,3 +8864,30 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 边界:Deploy 阶段在远端 dev / release agent 执行,不受该上限约束。调整只动这两处:`systemctl set-property / revert jenkins.service`、`docker update --cpus= gitea-runner` 加同步 compose(备份 `/opt/gitea-stack/compose.yml.bak-<时间戳>`)。 - 验证:限速后 `Genarrative-Full-Build-And-Deploy` #289 / #290 SUCCESS;采样期 Jenkins 峰值 10.2~10.5 核、限流不足 2s(可忽略),runner 峰值 12.07 核且持续出现 throttling,整机回落到 2.6%~19.8%。 - 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)。 + +## 2026-09-17 Jenkins 公网入口 jenkins.genarrative.world 复用 router 反向隧道口径 + +- 背景:Jenkins controller 实际与 Gitea 同机运行在 `genarrative-station`(`jenkins.service`,`--httpPort=8080 --prefix=/jenkins`,`JENKINS_HOME=/var/lib/jenkins`),此前只有内网入口 `http://192.168.35.82:8080/jenkins/`;`router.genarrative.world` 已有「dev Nginx → dev loopback → station 反向隧道」的成熟口径。 +- 决策:沿用 router 口径,不新增网关组件。`genarrative-station` 的 `gitea-reverse-tunnel.service` 增加 `-R 127.0.0.1:18085:127.0.0.1:8080`(dev loopback `18085` → station Jenkins `127.0.0.1:8080`);dev 新增 `/etc/nginx/conf.d/jenkins.genarrative.world.conf`:`80` 只做 ACME webroot 与 `301`,`443` 用 Certbot 证书反代 `http://127.0.0.1:18085` 并保留 `Upgrade` / `X-Forwarded-*`;证书按 router 口径用 `certbot certonly --webroot -w /var/www/html -d jenkins.genarrative.world --renew-hook 'systemctl reload nginx'` 申请。 +- 路径口径:Jenkins 固定 `--prefix=/jenkins`,域名根路径 `302` 到 `https://jenkins.genarrative.world/jenkins/login`,`/jenkins` 补斜杠,其余未带前缀路径 `302` 到 `/jenkins$request_uri`;证书不复制到 Pingora 私有目录,公网 `80/443` 仍由 dev Nginx 监听。 +- 边界:本次只暴露 HTTP/HTTPS UI,`slaveAgentPort` 保持 `-1`(agent 继续由 Jenkins 用 SSH launcher 连 dev / release),不改 Jenkins `jenkinsUrl` 与鉴权策略;Jenkins 登录页因此进入公网可达面,访问控制继续依赖 Jenkins 自身账号体系。 +- 验证:dev `nginx -t` 与 `systemctl reload nginx` 通过;`curl -sI https://jenkins.genarrative.world/` 返回 `302 /jenkins/login`、`/jenkins/login` 返回 `200`、登录页静态资源 `200`、`http://` 入口 `301`;Let's Encrypt 证书 `CN=jenkins.genarrative.world` 到期 `2026-12-16`;公网探测 `82.157.175.59` 仍只开放 `80/443/22`。 +- 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)。 + +## 2026-09-17 预览部署控制面公网入口 build.genarrative.world + +- 背景:多人内网预览控制面(`preview-deployer-server` + `shared/Genarrative-Preview-Deployer` Job)此前只在内网 `http://192.168.35.82/build/` 提供,2026-08-15 决策明确「不配置公网域名」;本次要求给它加公网入口。 +- 决策:沿用 router / Jenkins 同一口径新增 `build.genarrative.world` 作为控制面公网入口,并把 `preview.genarrative.world` 作为 `*.preview.genarrative.world` 规划的父域名先落一张落地页(实例本身仍只在内网)。station `gitea-reverse-tunnel.service` 增加 `-R 127.0.0.1:18086:127.0.0.1:8410`;dev 新增 `/etc/nginx/conf.d/build.genarrative.world.conf`(`80` ACME+`301`,`443` 把 `/build/`、`/api/preview-deployer/` 反代到 `127.0.0.1:18086`,`/` 跳 `/build/`,公网侧 `proxy_cookie_flags ~ secure`)与 `/etc/nginx/conf.d/preview.genarrative.world.conf`(单域名证书 + 落地页)。 +- 白名单:控制面按 `Host` 精确匹配、对非 GET 的 `/api/*` 精确匹配 `Origin`,因此同步改为 `GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_HOSTS=192.168.35.82,build.genarrative.world`、`GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_ORIGINS=http://192.168.35.82,https://build.genarrative.world`;`GENARRATIVE_PREVIEW_DEPLOYER_SECURE_COOKIE` 保持 `false`(内网 HTTP 入口继续可用),公网 cookie 的 `Secure` 由 dev nginx 强制。重启 `genarrative-preview-deployer.service` 会清空内存会话,内网用户需重新输入口令。 +- 边界:本次只暴露控制面(触发/查看构建、卸载),预览实例不暴露;`preview.genarrative.world` 没有通配记录,实例地址仍是内网 `http://192.168.35.82:84xx`,控制面页面展示的 `webUrl` 也仍是内网地址。要变成公网实例地址,需要 `*.preview.genarrative.world` 通配证书(只能 DNS-01)、station 侧按 Host 分发和页面 URL 口径改造。 +- 验证:`nginx -t` 与 reload 通过;`https://build.genarrative.world/` `302 → /build/`、`/build/` `200`、SPA 资源 `200`、`/api/preview-deployer/session` 返回 `{"authenticated":false}`;错误或缺失 `Origin` 的 POST `403`、错误口令 `401`、字段名不符 `422`;两个新域名证书到期 `2026-12-16`;内网 `Host: 192.168.35.82` 仍 `200`、未知 Host `403`;jenkins / dev / git 入口回归正常。 +- 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)、[Jenkins容器预览部署控制面技术方案](../../technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md)。 + +## 2026-09-17 预览控制面增加公网预览地址口径(代码已实现,待随控制面发布) + +- 背景:控制面页面此前只展示内网地址 `http://192.168.35.82:`;公网通配域名 `*.preview.genarrative.world` 已解析到 dev,需要页面能显示对应的公网入口。 +- 决策:公网地址由控制面自己派生,不接受 Jenkins 产物或状态文件提供的任意地址。`preview-deployer-server` 新增可选配置 `GENARRATIVE_PREVIEW_DEPLOYER_WEB_DOMAIN`(如 `preview.genarrative.world`,只接受小写字母、数字、短横线和点号,不带协议与端口),并在公开 DTO 新增 `webPublicUrl`:仅当记录已有内网 `webUrl` 时取 `https://.`(实例 ID 仍由分支派生,形如 `preview-<16位hex>`),卸载时与 `webUrl` / `webPort` 一起清空;状态文件里的旧值在加载时被重新派生覆盖。 +- 前端:`apps/preview-deployer-web` 在存在公网地址时把「打开公网预览」作为主入口,内网地址降级为次级链接;未配置时行为与之前一致。 +- 上线依赖(本次未完成):`*.preview.genarrative.world` 通配证书(Let's Encrypt 通配只能走 DNS-01,域名在 DNSPod,certbot 无官方插件,需要 DNSPod API Token 配合 acme.sh)、station 侧按 Host 分发到 `84xx` 端口、dev 通配 vhost 与隧道;控制面本体需在 station 用 `scripts/deploy/preview-deployer-install.sh` 重建发布。 +- 验证:`cargo test -p preview-deployer-server`(13 项)、`apps/preview-deployer-web` vitest(13 项,含新增公网地址用例)、`npx tsc --noEmit`、`npm run preview-deployer:web:build`(`PREVIEW_DEPLOYER_WEB_BASE=/build/`)、`npm run check:preview-deployer`、`npm run check:encoding`、`git diff --check` 全部通过。 +- 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)、[Jenkins容器预览部署控制面技术方案](../../technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md)。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 0b5d624d2..f13c743d5 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -783,6 +783,8 @@ npm run container:down `npm run container:config` 默认只做 quiet 校验,避免把本地 env 中的 token 展开到终端;确需排查完整 compose 时再传 `-- --print`。 多人内网预览入口固定为 `http://192.168.35.82/build/`,不配置公网域名。该独立 Jenkins 容器预览部署控制面不让浏览器直接操作 Docker 或持有 Jenkins Token;SPA 通过同源代理触发固定 `shared/Genarrative-Preview-Deployer` Job。分支和 commit 输入框通过受认证的控制服务搜索固定内网 Git 仓库并展示下拉结果;提交构建前控制服务重新确认分支存在、可选 commit 存在且属于目标分支,失败时不触发 Jenkins,Jenkins checkout 仍保留最终复核。每个分支使用稳定的内部 `deploymentId` 和独立 Compose project,Web 端口从 `8400..8499` 在文件锁内分配,同一分支换 commit 优先复用端口,卸载后释放;页面记录 ID 使用 Jenkins 构建编号。Jenkins 用 `preview-result.json` 向页面提供 resolved commit、发布结果和内网 Web URL,页面刷新时由控制服务实时复核 Web 健康;构建详情链接固定使用局域网 Jenkins 地址,不暴露 loopback 地址。失败/取消且不可卸载的记录保留 7 天,停止记录保留 30 天,仍可卸载的失败记录不会自动清理。安装资产为 `deploy/systemd/genarrative-preview-deployer.service`、`deploy/env/preview-deployer.env.example` 和 `deploy/nginx/genarrative-preview-deployer-lan.conf`;完整合同见 `docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md`。 +Jenkins controller 与 Gitea 同机运行在 `genarrative-station`,除内网入口 `http://192.168.35.82:8080/jenkins/` 外,公网入口为 `https://jenkins.genarrative.world/jenkins/`:dev 的 `/etc/nginx/conf.d/jenkins.genarrative.world.conf` 把 `443` 反代到 `http://127.0.0.1:18085`,该 loopback 端口由同机 `gitea-reverse-tunnel.service` 新增的 `-R 127.0.0.1:18085:127.0.0.1:8080` 落到 station 的 `jenkins.service`(`--prefix=/jenkins`,因此域名根路径 `302` 到 `/jenkins/login`)。排障顺序:dev `ss -tlnp | grep 18085` 必须有 `sshd` 监听,`curl -sI https://jenkins.genarrative.world/jenkins/login` 必须 `200`,`systemctl status gitea-reverse-tunnel.service` 必须 `active`,且公网 `80/443` 仍由 Nginx 监听;`slaveAgentPort=-1` 保持关闭,agent 仍走 SSH launcher,不新增入库端口。证书按 router 口径用 Certbot webroot(`/var/www/html`)维护,续期 hook 为 `systemctl reload nginx`。 +预览部署控制面的公网入口为 `https://build.genarrative.world/build/`(`/` 与 `/build` 分别 `302`/`301` 到 `/build/`,API 走同源 `/api/preview-deployer/`):dev 的 `/etc/nginx/conf.d/build.genarrative.world.conf` 反代到 `127.0.0.1:18086`,该 loopback 端口由 station `gitea-reverse-tunnel.service` 的 `-R 127.0.0.1:18086:127.0.0.1:8410` 落到控制面 `preview-deployer-server`。控制面按 `Host` 精确匹配白名单、对非 GET 的 `/api/*` 精确匹配 `Origin`,公网域名必须同时出现在 `GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_HOSTS` 和 `GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_ORIGINS` 中;公网 cookie 的 `Secure` 由 dev nginx 的 `proxy_cookie_flags ~ secure` 强制,因此 `GENARRATIVE_PREVIEW_DEPLOYER_SECURE_COOKIE` 保持 `false`,内网 `http://192.168.35.82/build/` 的登录态不受影响。排障顺序:dev `ss -tlnp | grep 18086` 有 `sshd` 监听;`curl -s https://build.genarrative.world/api/preview-deployer/session` 返回 `{"authenticated":false}`;缺失或错误 `Origin` 的 POST 必须 `403`,错误口令必须 `401`。`preview.genarrative.world` 当前只是通配规划下的落地页,预览实例仍只在内网 `http://192.168.35.82:84xx`;要暴露实例需要 `*.preview.genarrative.world` 通配证书(只能 DNS-01)、station 侧按 Host 分发和页面 `webUrl` 口径改造。 隔离验证 worker 队列和 API-only 更新时使用 `npm run container:worker-smoke -- smoke`。该命令不复用 `deploy/container/api-server.env`,会在 `deploy/container/worker-smoke/` 生成本机专用 env 与端口 state,并且只使用 unsupported job 验证 worker claim / fail 回写,不覆盖 BgFilter 成功、失败或 fallback 链路,也不需要真实外部生成密钥;本机 crates.io 网络不稳时使用 `--local-binary`,由容器内 Cargo 复用本机 Cargo 缓存构建,并把产物放进 Debian bookworm smoke runtime。 独立 BgFilter worker 的本机全进程验证先运行 `cargo build -p api-server --manifest-path server-rs/Cargo.toml`,再依次运行 `npm run bgfilter-worker:smoke-test`、`npm run bgfilter-worker:load-smoke` 和 `npm run bgfilter-worker:fault-smoke`。三条命令只使用动态 loopback 端口、假 OSS 签名配置和本地 mock provider;不会读取仓库 `.env*` 或请求真实 BgFilter / OSS。自定义或 WSL binary 通过 `GENARRATIVE_BGFILTER_SMOKE_BINARY` 指定。当前 fault 范围包含 overload、queue deadline、两类 HTTP 状态顺序重试结果,以及 provider 成功响应 body 中途 reset 后第二次 attempt 串行成功;慢读、大响应、父侧客户端断连与 SIGTERM 排空另行验证。 From f5b4293e806a07bf6e80be8f0d0cade2ebb69f46 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 17 Sep 2026 20:24:11 +0800 Subject: [PATCH 12/16] =?UTF-8?q?=E9=A2=84=E8=A7=88=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E9=9D=A2=E6=94=AF=E6=8C=81=E5=B1=95=E7=A4=BA=E5=85=AC=E7=BD=91?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - preview-deployer-server 新增可选配置 GENARRATIVE_PREVIEW_DEPLOYER_WEB_DOMAIN,并按「实例 ID + 预览域名」派生公开字段 webPublicUrl,卸载时与 webUrl/webPort 一起清空、加载状态文件时重新派生覆盖 - preview-deployer-web 在存在公网地址时把「打开公网预览」作为主入口,内网地址降级为次级链接,未配置时行为不变 - deploy/env/preview-deployer.env.example 补充 WEB_DOMAIN 键与留空语义说明 - 补充 cargo 与 vitest 用例覆盖公网地址派生、域名配置校验与页面展示 --- .../src/PreviewDeployerApp.test.tsx | 26 +++++++++++ .../src/PreviewDeployerApp.tsx | 10 +++++ apps/preview-deployer-web/src/types.ts | 1 + deploy/env/preview-deployer.env.example | 2 + .../preview-deployer-server/src/config.rs | 34 +++++++++++++++ .../crates/preview-deployer-server/src/lib.rs | 27 ++++++++++++ .../preview-deployer-server/src/tests.rs | 43 +++++++++++++++++++ 7 files changed, 143 insertions(+) diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx index 4a366fdaa..29a4dbb5d 100644 --- a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx +++ b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx @@ -59,6 +59,32 @@ test('requires an access token before showing deployments', async () => { expect(await screen.findByText('构建并发布一个分支')).toBeTruthy(); }); +test('prefers the public preview domain when the control plane exposes one', async () => { + vi.mocked(api.listDeployments).mockResolvedValue([ + { + id: '77', + branch: 'feature/public-preview', + resolvedCommit: '1234567890abcdef', + status: 'running', + health: 'healthy', + webPort: 8400, + webUrl: 'http://192.168.35.82:8400', + webPublicUrl: + 'https://preview-63d38d3da6bc9b06.preview.genarrative.world', + createdAt: 1_787_270_400, + updatedAt: 1_787_270_460, + }, + ]); + render(); + + const publicLink = await screen.findByRole('link', { + name: /打开公网预览/u, + }); + expect(publicLink.getAttribute('href')).toBe( + 'https://preview-63d38d3da6bc9b06.preview.genarrative.world', + ); +}); + test('submits a branch with an optional commit hash', async () => { const user = userEvent.setup(); render(); diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx index ec5e30773..ea23c3836 100644 --- a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx +++ b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx @@ -797,6 +797,16 @@ function DeploymentCard({
+ {deployment.webUrl && deployment.webPublicUrl ? ( + + 打开公网预览 + + ) : null} {deployment.webUrl ? ( .<后缀>,留空则只展示内网地址。 +GENARRATIVE_PREVIEW_DEPLOYER_WEB_DOMAIN=preview.genarrative.world GENARRATIVE_PREVIEW_DEPLOYER_STATE_FILE=/var/lib/genarrative/preview-deployer/state.json GENARRATIVE_PREVIEW_DEPLOYER_STATIC_DIR=/opt/genarrative/preview-deployer/web GENARRATIVE_PREVIEW_DEPLOYER_SECURE_COOKIE=false diff --git a/server-rs/crates/preview-deployer-server/src/config.rs b/server-rs/crates/preview-deployer-server/src/config.rs index f693697bb..ff8d637ab 100644 --- a/server-rs/crates/preview-deployer-server/src/config.rs +++ b/server-rs/crates/preview-deployer-server/src/config.rs @@ -18,6 +18,7 @@ pub struct Config { pub allowed_hosts: Vec, pub allowed_origins: Vec, pub preview_web_host: String, + pub preview_web_domain: Option, pub secure_cookie: bool, pub static_dir: Option, pub state_file: PathBuf, @@ -49,6 +50,7 @@ impl fmt::Debug for Config { .field("allowed_hosts", &self.allowed_hosts) .field("allowed_origins", &self.allowed_origins) .field("preview_web_host", &self.preview_web_host) + .field("preview_web_domain", &self.preview_web_domain) .field("secure_cookie", &self.secure_cookie) .field("static_dir", &self.static_dir) .field("state_file", &self.state_file) @@ -116,6 +118,15 @@ impl Config { .to_string(), ); } + let preview_web_domain = env::var("GENARRATIVE_PREVIEW_DEPLOYER_WEB_DOMAIN") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(|value| { + validate_preview_web_domain(&value) + .map_err(|reason| format!("GENARRATIVE_PREVIEW_DEPLOYER_WEB_DOMAIN {reason}")) + }) + .transpose()?; for origin in &allowed_origins { let parsed = Url::parse(origin).map_err(|_| format!("无效 allowed origin: {origin}"))?; @@ -162,6 +173,7 @@ impl Config { allowed_hosts, allowed_origins, preview_web_host, + preview_web_domain, secure_cookie, static_dir, state_file, @@ -217,6 +229,28 @@ fn validate_state_file(path: &std::path::Path) -> Result<(), String> { Ok(()) } +pub(crate) fn validate_preview_web_domain(value: &str) -> Result { + if value.len() > 253 || !value.contains('.') { + return Err("必须是不带协议和端口的 DNS 域名".to_string()); + } + for label in value.split('.') { + if label.is_empty() || label.len() > 63 { + return Err("必须是不带协议和端口的 DNS 域名".to_string()); + } + let bytes = label.as_bytes(); + if bytes[0] == b'-' || bytes[bytes.len() - 1] == b'-' { + return Err("每段标签不能以短横线开头或结尾".to_string()); + } + if !bytes + .iter() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-') + { + return Err("只允许小写字母、数字、短横线和点号".to_string()); + } + } + Ok(value.to_string()) +} + fn required(name: &str) -> Result { env::var(name) .ok() diff --git a/server-rs/crates/preview-deployer-server/src/lib.rs b/server-rs/crates/preview-deployer-server/src/lib.rs index 223255b1b..e0165799b 100644 --- a/server-rs/crates/preview-deployer-server/src/lib.rs +++ b/server-rs/crates/preview-deployer-server/src/lib.rs @@ -115,6 +115,8 @@ pub struct Deployment { pub web_port: Option, #[serde(skip_serializing_if = "Option::is_none")] pub web_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web_public_url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub jenkins_build_url: Option, pub created_at: u64, @@ -633,6 +635,7 @@ async fn create_deployment( health: HealthStatus::Pending, web_port: None, web_url: None, + web_public_url: None, jenkins_build_url: None, created_at: now, updated_at: now, @@ -959,6 +962,14 @@ async fn apply_outcome(state: &AppState, id: &str, outcome: JenkinsOutcome) { if let Some(value) = result.web_port { record.public.web_port = Some(value); } + record.public.web_public_url = if record.public.web_url.is_some() { + public_web_url( + state.config.preview_web_domain.as_deref(), + &record.instance_id, + ) + } else { + None + }; if let Some(value) = result.health { record.public.health = value; } @@ -1005,6 +1016,7 @@ async fn apply_outcome(state: &AppState, id: &str, outcome: JenkinsOutcome) { record.public.health = HealthStatus::Unknown; record.public.web_port = None; record.public.web_url = None; + record.public.web_public_url = None; record.public.can_uninstall = false; if record.public.message.is_none() { record.public.message = Some("预览实例已卸载".to_string()); @@ -1090,6 +1102,11 @@ fn load_deployments(config: &Config) -> Result if record.public.web_port.is_none() { record.public.web_port = url_port; } + record.public.web_public_url = if record.public.web_url.is_some() { + public_web_url(config.preview_web_domain.as_deref(), &record.instance_id) + } else { + None + }; if deployments .insert(record.instance_id.clone(), record) .is_some() @@ -1326,6 +1343,16 @@ fn map_artifact_health(value: &str) -> Option { } } +// 公网入口由控制面自己派生,主机名固定为「实例 ID + 配置的预览域名」, +// 不接受 Jenkins 产物或状态文件提供的任意地址。 +fn public_web_url(domain: Option<&str>, instance_id: &str) -> Option { + let domain = domain?; + if validate_deployment_id(instance_id).is_err() { + return None; + } + Some(format!("https://{instance_id}.{domain}")) +} + fn is_safe_web_url(value: &str, expected_host: &str) -> bool { url::Url::parse(value).is_ok_and(|url| { url.scheme() == "http" diff --git a/server-rs/crates/preview-deployer-server/src/tests.rs b/server-rs/crates/preview-deployer-server/src/tests.rs index 98b2b5511..7874d5169 100644 --- a/server-rs/crates/preview-deployer-server/src/tests.rs +++ b/server-rs/crates/preview-deployer-server/src/tests.rs @@ -171,6 +171,7 @@ fn test_config(jenkins_base_url: Url) -> Config { allowed_hosts: vec![HOST.to_string()], allowed_origins: vec![ORIGIN.to_string()], preview_web_host: "192.168.35.82".to_string(), + preview_web_domain: Some("preview.genarrative.world".to_string()), secure_cookie: false, static_dir: None, state_file: std::env::temp_dir().join(format!( @@ -378,6 +379,10 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() { ); assert_eq!(deployment.health, super::HealthStatus::Healthy); assert_eq!(deployment.web_port, Some(8400)); + assert_eq!( + deployment.web_public_url.as_deref(), + Some("https://preview-63d38d3da6bc9b06.preview.genarrative.world") + ); assert_eq!( deployment.web_url.as_deref(), Some("http://192.168.35.82:8400") @@ -436,6 +441,7 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() { assert_eq!(deployment.health, super::HealthStatus::Unknown); assert_eq!(deployment.web_port, None); assert_eq!(deployment.web_url, None); + assert_eq!(deployment.web_public_url, None); assert!(!deployment.can_uninstall); let list_request = axum::http::Request::builder() @@ -605,6 +611,7 @@ async fn duplicate_active_branch_is_rejected_without_second_jenkins_trigger() { health: super::HealthStatus::Pending, web_port: None, web_url: None, + web_public_url: None, jenkins_build_url: None, created_at: now, updated_at: now, @@ -652,6 +659,40 @@ async fn wait_for_status( Err(()) } +#[test] +fn public_web_url_is_derived_from_instance_id_and_configured_domain() { + assert_eq!( + super::public_web_url( + Some("preview.genarrative.world"), + "preview-63d38d3da6bc9b06" + ) + .as_deref(), + Some("https://preview-63d38d3da6bc9b06.preview.genarrative.world") + ); + assert_eq!( + super::public_web_url(None, "preview-63d38d3da6bc9b06"), + None + ); + assert_eq!( + super::public_web_url(Some("preview.genarrative.world"), "feature/demo"), + None + ); +} + +#[test] +fn preview_web_domain_config_is_strict() { + assert!(super::config::validate_preview_web_domain("preview.genarrative.world").is_ok()); + assert!(super::config::validate_preview_web_domain("preview.genarrative.world:443").is_err()); + assert!( + super::config::validate_preview_web_domain("https://preview.genarrative.world").is_err() + ); + assert!(super::config::validate_preview_web_domain("Preview.Genarrative.World").is_err()); + assert!(super::config::validate_preview_web_domain("preview").is_err()); + assert!(super::config::validate_preview_web_domain("-preview.genarrative.world").is_err()); + assert!(super::config::validate_preview_web_domain("preview..genarrative.world").is_err()); + assert!(super::config::validate_preview_web_domain("").is_err()); +} + #[test] fn branch_commit_and_web_url_validation_are_strict() { assert!(super::validate_branch("feature/preview-ui").is_ok()); @@ -758,6 +799,7 @@ async fn expired_terminal_records_are_pruned_but_uninstallable_failures_are_reta health: super::HealthStatus::Unknown, web_port: None, web_url: None, + web_public_url: None, jenkins_build_url: None, created_at: old, updated_at: old, @@ -781,6 +823,7 @@ async fn expired_terminal_records_are_pruned_but_uninstallable_failures_are_reta health: super::HealthStatus::Unknown, web_port: Some(8401), web_url: Some("http://192.168.35.82:8401".to_string()), + web_public_url: None, jenkins_build_url: None, created_at: old, updated_at: old, From 0deaabfee66e0871324d5f9e6ecb3125400cc8cd Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 20:28:05 +0800 Subject: [PATCH 13/16] =?UTF-8?q?=E5=9B=9E=E5=A1=AB=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=91=98=E8=A6=81=E7=AB=AF=E5=88=B0=E7=AB=AF=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 主规范记录 0.1.62 渠道清单自动摘要与客户端提示展示的用户实测结论 --- docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 97c7474ea..53233a8de 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -114,6 +114,7 @@ | 安装包与清单登记一致 | 下载安装包实算 SHA-256 与尺寸后与迁移桥清单比对 | 通过(size `104678031`、sha256 `1f67…4fd0` 一致) | | 旧协议迁移桥 | 公网读取 `agc/latest.json` | 通过(0.1.48,`downloadUrl` 指向同一对象,含 `sha256` / `size`) | | 真实更新闭环(含升级后重启) | 0.1.47 客户端按提示下载安装并重启 | 通过(2026-09-17 用户实测:提示 → 下载 → 安装 → 关于页显示新版本,再次检查为已是最新) | +| 更新摘要端到端展示 | 公网读取渠道清单 `notes` 与客户端更新提示 | 通过(2026-09-17 用户实测:0.1.62 清单带 8 条自动摘要,客户端提示正常显示多行内容) | 待执行证据(首次渠道发布后回填): From 133bb2f350cd703e2d64f7b2749f816a956f7617 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:05:18 +0800 Subject: [PATCH 14/16] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20AGC=20=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=20HTTP=20=E5=A5=91=E7=BA=A6=E5=AE=88=E5=8D=AB?= =?UTF-8?q?=E4=B8=8E=E6=9B=B4=E6=96=B0=E6=91=98=E8=A6=81=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 契约守卫不再要求 http 插件放行 OSS 域名:更新清单与安装包下载已改由 tauri-plugin-updater 在原生侧完成 - 主规范回填 0.1.62 自动更新摘要与客户端提示展示的用户实测证据 --- scripts/check-native-shells.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 7b5447faf..b7eecde13 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -2588,6 +2588,8 @@ function assertAiGameCreatorShellUserDevBoundary() { ]) ) { throw new Error( + // 更新清单与安装包下载已改由 tauri-plugin-updater 在原生侧完成, + // 不再需要为 webview 的 http 插件放行 OSS 域名。 'AI game creator native HTTP scope must match the release, dev, custom HTTPS, and loopback API boundary', ); } From 195b7dd5dd143375c9b6700855ef1d39df9c101c Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 17 Sep 2026 21:37:23 +0800 Subject: [PATCH 15/16] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8A=A0=E5=9B=BE401?= =?UTF-8?q?=20404=E9=97=AE=E9=A2=98=20(#394)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: http://genarrative-station/git/GenarrativeAI/Genarrative/pulls/394 Co-authored-by: Linghong Co-committed-by: Linghong --- .../agc-skills/agc-client-projection/SKILL.md | 2 +- .../references/projection-contract.md | 4 +- .../resources/agc-skills/manifest.json | 4 +- .../src-tauri/src/agent/direct_tool_bridge.rs | 177 ++--- .../src-tauri/src/project/resource_editor.rs | 297 +++++-- .../background_removal_tests.rs | 725 ++++++++++++++++++ .../src/view/project-development/index.tsx | 19 +- .../tests/appSurface/home.suite.ts | 8 +- .../appSurface/project-development.suite.ts | 12 +- .../tests/directActiveTurns.test.tsx | 11 +- .../projectResourceLiveIntegration.test.tsx | 42 +- .../tests/resourceVersionReplacement.test.tsx | 68 +- .../shared-memory/decision-log.md | 20 +- docs/project-memory/shared-memory/pitfalls.md | 4 + ...方案】AGC抠图模式与背景色透传-2026-09-16.md | 24 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 5 +- .../crates/api-server/src/editor_project.rs | 4 +- .../ImageCanvasWorldView.test.tsx | 5 +- 18 files changed, 1216 insertions(+), 215 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md index f25ab993d..5614ef9d4 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md @@ -14,7 +14,7 @@ Let the client derive projections from real disk changes and trusted tool result 3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list. 4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization. 5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image. Keep `prompt` inside the per-kind limit that the client really enforces: background music at most 140 characters, sound effect at most 1900, video and character animation at most 4000. A longer prompt is rejected before submission, so write the short version first instead of retrying the same text. -6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and `assetName`. Optional `backgroundMode` is `complex` (semantic foreground segmentation; default) or `flat` (solid-colour background removal). Prefer `flat` when the background is known to be solid. Only `flat` accepts optional `screenColor`: `auto`, `#RRGGBB`, or omitted for automatic detection by the service. Do not select a colour on behalf of `auto`. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state. +6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and `assetName`. Optional `backgroundMode` is `complex` (semantic foreground segmentation; default) or `flat` (solid-colour background removal). Prefer `flat` when the background is known to be solid. Only `flat` accepts optional `screenColor`: `auto`, `#RRGGBB`, or omitted for automatic detection by the service. Do not select a colour on behalf of `auto`. The client requires the signed-in account, owns canvas/folder context and task identity, waits for the accepted operation, downloads and registers the completed local asset, and preserves the operation for recovery when the remote result is not yet known. 7. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable. 8. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand. 9. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md index 9129efc1a..7374d1149 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md @@ -16,4 +16,6 @@ Read scopes remain separate: `asset.list` is the current project's local manifes `prompt` limits are per kind and are enforced before any paid submission: background music accepts 1-140 characters, sound effect 1-1900, video and character animation 1-4000, and image editing (`agc_edit_image`) 1-32000. The client composes the submitted request from a fixed prefix plus your prompt, so an over-limit prompt fails locally with the exact limit; shorten the text rather than resubmitting the same value. `agc_edit_image` remains the image path; this tool never generates or edits still images. -`agc_remove_background` accepts a registered image `sourceLocalAssetId`, `assetName`, and optional `backgroundMode` and `screenColor`. `complex` uses semantic segmentation to identify the foreground; `flat` removes a solid-colour background. Prefer `flat` when the background is known to be solid; omitting the mode selects `complex`. Only `flat` accepts a colour: `auto`, `#RRGGBB`, or omitted for automatic service detection. Never infer a concrete colour for `auto`. Empty or invalid values and colour without `flat` are rejected. The client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Mode and colour are part of request identity. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response. +`agc_remove_background` accepts a registered image `sourceLocalAssetId`, `assetName`, and optional `backgroundMode` and `screenColor`. `complex` uses semantic segmentation to identify the foreground; `flat` removes a solid-colour background. Prefer `flat` when the background is known to be solid; omitting the mode selects `complex`. Only `flat` accepts a colour: `auto`, `#RRGGBB`, or omitted for automatic service detection. Never infer a concrete colour for `auto`. Empty or invalid values and colour without `flat` are rejected. The client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated request. Ordinary account mode maps the External v1 shaped route to `/api/editor/images/background-removals`; ExternalDeveloper mode uses `/api/external/v1/editor/images/background-removals`. Mode and colour are part of request identity. After acceptance, the client polls the authenticated generation status route, downloads the completed media, and commits it to the local manifest. If completion is unknown, it retains the same local operation for recovery; it never retries with a new identity or exposes internal worker details. + +After an interrupted call, inspect `agc_list_registered_assets.pendingOperations`. Calling `agc_remove_background` again with the same source, name, mode, and colour resumes the matching pending operation. A submission marked `reconciliation-required` needs client-side reconciliation and cannot be automatically resumed. Do not change parameters to bypass a pending task. A queued receipt, fixed progress value, or absent local file does not establish that the background-removal provider is waiting in a queue; report only the observed state. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 6debfefb3..26790f360 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.20", + "version": "2026-08-26.23", "skills": [ { "name": "agc-game-production-workflow", @@ -123,7 +123,7 @@ "agents/openai.yaml", "references/projection-contract.md" ], - "sha256": "93210c0eeb73b279d35aa85c201c226139b0bdf041f3300ac2c6e2c1bdd63afe" + "sha256": "247787975944ce8b21d7c879c39c60ec13608056cff9426ac374c9299937d475" } ] } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index f31a3cdd4..9984ff4d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -1294,7 +1294,10 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value { .map(|asset| bridge_registered_resource(asset, include_sequence_frames)) .collect::>(); let next_offset = (offset + resources.len() < total).then_some(offset + resources.len()); - let pending = list_pending_local_project_resource_edits_at( + let platform_session = (editor_api_mode() == EditorApiMode::PlatformAccount) + .then(current_platform_session) + .flatten(); + let pending = list_pending_local_project_resource_edits_for_session_at( ListPendingLocalProjectResourceEditsInput { project_path: root .to_str() @@ -1302,6 +1305,7 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value { .to_string(), expected_project_id: manifest.project_id, }, + platform_session.as_ref(), )? .into_iter() .map(|edit| { @@ -1311,6 +1315,8 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value { "mode": edit.generation_mode, "sourceResourceId": edit.source_resource_id, "assetName": edit.asset_name, + "backgroundMode": edit.background_mode, + "screenColor": edit.screen_color, "phase": edit.phase, "createdAt": edit.created_at, }) @@ -1801,8 +1807,8 @@ async fn bridge_import_account_assets(state: &DirectToolBridgeState, arguments: fn bridge_completed_resource_result( root: &Path, - kind: DirectResourceGenerationKind, - mode: DirectResourceGenerationMode, + kind: &str, + mode: &str, result: DeriveLocalProjectResourceResult, ) -> Result { let asset = result @@ -1814,8 +1820,8 @@ fn bridge_completed_resource_result( Ok(json!({ "status": "completed", "operationId": result.operation_id, - "kind": kind.as_str(), - "mode": mode.as_str(), + "kind": kind, + "mode": mode, "sourceResourceId": result.source_resource_id, "committedProjectRevision": result.committed_project_revision, "resource": bridge_registered_resource(asset, true), @@ -1910,10 +1916,17 @@ async fn bridge_create_or_derive_resource( source_version_id: None, prompt: input.prompt.clone(), asset_name: input.asset_name.clone(), + background_mode: None, + screen_color: None, }; with_direct_editor_api_credentials(derive_local_project_resource_at(request)).await? }; - bridge_completed_resource_result(&state.root, input.kind, input.mode, completed) + bridge_completed_resource_result( + &state.root, + input.kind.as_str(), + input.mode.as_str(), + completed, + ) } .await; match result { @@ -1927,7 +1940,8 @@ async fn bridge_create_or_derive_resource( } async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Value) -> Value { - let result = async { + let _generation_guard = state.resource_generation_gate.lock().await; + let result = with_direct_editor_api_credentials(async { super::direct_tools_mcp::validate_remove_background_arguments(arguments)?; enforce_project_permission_policy(&state.root, "canvas.asset_generate")?; enforce_project_permission_policy(&state.root, "asset.register")?; @@ -1948,74 +1962,72 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val if !source_asset.media_type.starts_with("image/") { return Err("抠图工具只接受当前项目已登记的图片资源".to_string()); } - let source_resource_id = source_asset - .source - .resource_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty() && !value.starts_with("local-asset:")) - .ok_or_else(|| "图片资源缺少可供抠图服务使用的正式 resourceId".to_string())? - .to_string(); - let (api_base_url, api_key, session) = resolve_canvas_sync_api_credentials(None, None)?; - let access = ExternalEditorBindingAccess::new(&api_base_url, &api_key, session.as_ref())?; - let client = crate::http_client::agc_main_site_client_builder() - .build() - .map_err(|_| "创建抠图服务连接失败".to_string())?; - let context = - prepare_external_canvas_generation_context(&state.root, &client, &access).await?; + let background_mode = background_mode.unwrap_or("complex").to_string(); + let source_resource_id = bridge_asset_canonical_resource_id(source_asset); let fingerprint = background_removal_request_fingerprint( &source_asset_id, &asset_name, - background_mode, + Some(background_mode.as_str()), screen_color, ); - let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?; - let route = "/api/external/v1/editor/images/background-removals"; - let mut request_body = json!({ - "sourceImageSrc": source_resource_id, - "projectId": manifest.project_id, - "assetKind": source_asset.kind, - "assetFolderId": context.asset_folder_id, - "assetLabel": asset_name, - "sourceResourceId": source_resource_id, - }); - if background_mode == Some("flat") { - request_body["backgroundMode"] = json!("flat"); + let (_, _, platform_session) = resolve_canvas_sync_api_credentials(None, None)?; + let pending = list_pending_local_project_resource_edits_for_session_at( + ListPendingLocalProjectResourceEditsInput { + project_path: state.root.to_string_lossy().into_owned(), + expected_project_id: manifest.project_id.clone(), + }, + platform_session.as_ref(), + )?; + let matching_pending = pending + .into_iter() + .filter(|pending| { + pending.edit_kind == LocalProjectResourceEditKind::BackgroundRemoval + && (pending.source_asset_id.as_deref() == Some(source_asset_id.as_str()) + || pending.source_resource_id == format!("local-asset:{source_asset_id}")) + && pending.asset_name == asset_name + && pending.background_mode.as_deref().unwrap_or("complex") + == background_mode.as_str() + && pending.screen_color.as_deref() == screen_color + }) + .collect::>(); + if matching_pending.len() > 1 { + return Err("存在多个相同抠图 operation,必须先在客户端完成对账".to_string()); } - if let Some(color) = screen_color { - request_body["screenColor"] = json!(color); - } - let response = crate::http_client::with_agc_main_site_marker( - client - .post(format!("{}{}", api_base_url, route)) - .bearer_auth(api_key) - .header("Idempotency-Key", idempotency_key) - .json(&request_body), - ) - .send() - .await - .map_err(|error| format!("抠图服务提交失败:{error}"))?; - let status = response.status(); - let payload = response - .json::() - .await - .map_err(|error| format!("抠图服务响应无法解析:{error}"))?; - if !status.is_success() { - if status == reqwest::StatusCode::UNAUTHORIZED { - return Err("authentication-required: 抠图服务提交失败:HTTP 401".to_string()); - } - return Err(format!("抠图服务提交失败:HTTP {}", status.as_u16())); - } - let queue_state = external_editor_response_data(&payload).clone(); - Ok::<_, String>(json!({ - "status": "queued", - "sourceLocalAssetId": source_asset_id, - "assetName": asset_name, - "projectId": manifest.project_id, - "assetFolderId": context.asset_folder_id, - "queueState": bridge_safe_queue_state(queue_state), - })) - } + let completed = if let Some(pending) = matching_pending.into_iter().next() { + resume_local_project_resource_edit_at(ResumeLocalProjectResourceEditInput { + project_path: state.root.to_string_lossy().into_owned(), + expected_project_id: manifest.project_id.clone(), + operation_id: pending.operation_id, + }) + .await? + } else { + let (operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?; + let revision = read_game_creator_agent_runtime_project_revision(&state.root)?.revision; + let request = DeriveLocalProjectResourceInput { + project_path: state.root.to_string_lossy().into_owned(), + expected_project_id: manifest.project_id.clone(), + expected_project_revision: revision, + operation_id, + idempotency_key, + edit_kind: LocalProjectResourceEditKind::BackgroundRemoval, + generation_mode: LocalProjectResourceGenerationMode::Derive, + source_resource_id, + source_asset_id: Some(source_asset_id.clone()), + source_path: Some(source_asset.local_path.clone()), + source_media_type: Some(source_asset.media_type.clone()), + source_subtype: Some(source_asset.kind.clone()), + producer_task_id: source_asset.source.task_id.clone(), + source_version_id: None, + prompt: "去除背景".to_string(), + asset_name: asset_name.clone(), + background_mode: Some(background_mode), + screen_color: screen_color.map(str::to_string), + }; + derive_local_project_resource_at(request).await? + }; + emit_game_creator_manifest_invalidated(&state.root, "direct-background-removal"); + bridge_completed_resource_result(&state.root, "background-removal", "derive", completed) + }) .await; match result { Ok(value) => bridge_tool_result(value.to_string(), Vec::new(), false), @@ -2041,17 +2053,6 @@ fn background_removal_request_fingerprint( } } -fn bridge_safe_queue_state(value: Value) -> Value { - let object = value.as_object(); - json!({ - "operationId": object.and_then(|value| value.get("operationId")).and_then(Value::as_str), - "status": object.and_then(|value| value.get("status")).and_then(Value::as_str), - "phaseLabel": object.and_then(|value| value.get("phaseLabel")).and_then(Value::as_str), - "progress": object.and_then(|value| value.get("progress")).and_then(Value::as_u64), - "updatedAtMicros": object.and_then(|value| value.get("updatedAtMicros")).and_then(Value::as_u64), - }) -} - fn bridge_art_resources( root: &Path, asset_paths: &[String], @@ -3844,20 +3845,4 @@ mod tests { ); } } - - #[test] - fn bridge_background_removal_queue_projection_is_bounded() { - let projection = bridge_safe_queue_state(json!({ - "operationId": "background-removal-1", - "status": "queued", - "phaseLabel": "排队中", - "progress": 0, - "updatedAtMicros": 1, - "error": "private provider detail", - "signedUrl": "https://private.invalid/result" - })); - assert_eq!(projection["operationId"], "background-removal-1"); - assert!(projection.get("error").is_none()); - assert!(projection.get("signedUrl").is_none()); - } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index 46bb81c24..145330f56 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -113,6 +113,7 @@ fn resource_edit_project_mutation_lock(root: &Path) -> Result &'static str { match self { Self::ImageReference => "image-reference", + Self::BackgroundRemoval => "background-removal", Self::Svg => "svg", Self::CharacterAnimation => "character-animation", Self::Video => "video", @@ -188,6 +191,10 @@ pub(crate) struct DeriveLocalProjectResourceInput { pub(crate) source_version_id: Option, pub(crate) prompt: String, pub(crate) asset_name: String, + #[serde(default)] + pub(crate) background_mode: Option, + #[serde(default)] + pub(crate) screen_color: Option, } #[derive(Clone, Debug, PartialEq, Serialize)] @@ -220,6 +227,10 @@ pub(crate) struct PendingLocalProjectResourceEdit { pub(crate) source_resource_id: String, pub(crate) source_asset_id: Option, pub(crate) asset_name: String, + #[serde(default)] + pub(crate) background_mode: Option, + #[serde(default)] + pub(crate) screen_color: Option, pub(crate) prompt_sha256: String, pub(crate) phase: String, pub(crate) created_at: u64, @@ -385,6 +396,10 @@ struct ResourceEditLedger { prompt: String, asset_name: String, #[serde(default)] + background_mode: Option, + #[serde(default)] + screen_color: Option, + #[serde(default)] provider_request_issued_at: Option, #[serde(default)] access_scheme: Option, @@ -829,7 +844,7 @@ fn resource_edit_request_fingerprint( prompt: &str, asset_name: &str, ) -> Result { - let payload = serde_json::to_vec(&serde_json::json!({ + let mut identity = serde_json::json!({ "schemaVersion": RESOURCE_EDIT_SCHEMA_VERSION, "projectId": input.expected_project_id, "operationId": input.operation_id, @@ -840,8 +855,13 @@ fn resource_edit_request_fingerprint( "sourceSha256": source.source_sha256, "prompt": prompt, "assetName": asset_name, - })) - .map_err(|error| format!("序列化资源编辑请求失败:{error}"))?; + }); + if input.edit_kind == LocalProjectResourceEditKind::BackgroundRemoval { + identity["backgroundMode"] = serde_json::json!(input.background_mode); + identity["screenColor"] = serde_json::json!(input.screen_color); + } + let payload = serde_json::to_vec(&identity) + .map_err(|error| format!("序列化资源编辑请求失败:{error}"))?; Ok(sha256_hex(&payload)) } @@ -851,7 +871,7 @@ fn legacy_resource_edit_request_fingerprint( prompt: &str, asset_name: &str, ) -> Result { - let payload = serde_json::to_vec(&serde_json::json!({ + let mut identity = serde_json::json!({ "schemaVersion": RESOURCE_EDIT_SCHEMA_VERSION, "projectId": input.expected_project_id, "expectedProjectRevision": input.expected_project_revision, @@ -862,8 +882,13 @@ fn legacy_resource_edit_request_fingerprint( "sourceSha256": source.source_sha256, "prompt": prompt, "assetName": asset_name, - })) - .map_err(|error| format!("序列化旧资源编辑请求失败:{error}"))?; + }); + if input.edit_kind == LocalProjectResourceEditKind::BackgroundRemoval { + identity["backgroundMode"] = serde_json::json!(input.background_mode); + identity["screenColor"] = serde_json::json!(input.screen_color); + } + let payload = serde_json::to_vec(&identity) + .map_err(|error| format!("序列化旧资源编辑请求失败:{error}"))?; Ok(sha256_hex(&payload)) } @@ -1106,9 +1131,21 @@ fn infer_resource_edit_source_media_type( .and_then(|value| value.to_str()) .map(str::to_ascii_lowercase); let media_type = match (edit_kind, extension.as_deref()) { - (LocalProjectResourceEditKind::ImageReference, Some("png")) => "image/png", - (LocalProjectResourceEditKind::ImageReference, Some("jpg" | "jpeg")) => "image/jpeg", - (LocalProjectResourceEditKind::ImageReference, Some("webp")) => "image/webp", + ( + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval, + Some("png"), + ) => "image/png", + ( + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval, + Some("jpg" | "jpeg"), + ) => "image/jpeg", + ( + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval, + Some("webp"), + ) => "image/webp", (LocalProjectResourceEditKind::Svg, Some("svg")) => "image/svg+xml", (LocalProjectResourceEditKind::Video, Some("mp4")) => "video/mp4", (LocalProjectResourceEditKind::Video, Some("webm")) => "video/webm", @@ -1145,7 +1182,8 @@ fn infer_resource_edit_source_media_type( fn infer_resource_edit_source_asset_kind(edit_kind: &LocalProjectResourceEditKind) -> String { match edit_kind { - LocalProjectResourceEditKind::ImageReference => "art-image", + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval => "art-image", LocalProjectResourceEditKind::Svg => "svg", LocalProjectResourceEditKind::CharacterAnimation => "character-animation", LocalProjectResourceEditKind::Video => "video", @@ -1373,8 +1411,11 @@ fn resolve_resource_edit_source( { return Err("音频编辑只能用于音频资源".to_string()); } - LocalProjectResourceEditKind::ImageReference if !lower_media_type.starts_with("image/") => { - return Err("图片参考编辑只能用于图片资源".to_string()); + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval + if !lower_media_type.starts_with("image/") => + { + return Err("此操作只能用于图片资源".to_string()); } _ => {} } @@ -2023,8 +2064,11 @@ fn validate_resource_edit_source_recovery_state( || (ledger.source_remote_asset_object_id.is_some() && !ledger.source_upload_completed) || (ledger.source_remote_resource_id.is_some() && ledger.source_remote_asset_object_id.is_none()) - || (input.edit_kind != LocalProjectResourceEditKind::ImageReference - && ledger.source_remote_resource_id.is_some()) + || (!matches!( + input.edit_kind, + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval + ) && ledger.source_remote_resource_id.is_some()) { return Err("result-unknown: 源资源远端恢复阶段不完整,禁止自动重放".to_string()); } @@ -2081,7 +2125,8 @@ async fn ensure_resource_edit_source_reference( &source_identity, )? { let stable_reference = match input.edit_kind { - LocalProjectResourceEditKind::ImageReference => binding + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval => binding .remote_resource_id .filter(|reference| is_registered_editor_reference_id(reference)), LocalProjectResourceEditKind::Video @@ -2188,45 +2233,48 @@ async fn ensure_resource_edit_source_reference( confirmed.value }; - let (stable_reference, remote_resource_id, width, height) = - if input.edit_kind == LocalProjectResourceEditKind::ImageReference { - let bytes = source - .bytes - .as_deref() - .ok_or_else(|| "登记源图片缺少文件内容".to_string())?; - let decoded = image::load_from_memory(bytes) - .map_err(|_| "登记源图片前无法解析图片尺寸".to_string())?; - let resource_id = if let Some(resource_id) = ledger.source_remote_resource_id.clone() { - resource_id - } else { - let registered = register_resource_edit_source_image( - client, - access, - source, - local_asset_id, - &binding_key, - &canvas_context.project_id, - &object_key, - &asset_object_id, - ) - .await?; - let resource_id = registered.value.0; - ledger.source_remote_resource_id = Some(resource_id.clone()); - write_resource_edit_source_stage(root, ledger, "源图片项目资源登记")?; - if let Some(error) = registered.post_response_session_error { - return Err(error); - } - resource_id - }; - ( - resource_id.clone(), - Some(resource_id), - Some(decoded.width()), - Some(decoded.height()), - ) + let (stable_reference, remote_resource_id, width, height) = if matches!( + input.edit_kind, + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval + ) { + let bytes = source + .bytes + .as_deref() + .ok_or_else(|| "登记源图片缺少文件内容".to_string())?; + let decoded = image::load_from_memory(bytes) + .map_err(|_| "登记源图片前无法解析图片尺寸".to_string())?; + let resource_id = if let Some(resource_id) = ledger.source_remote_resource_id.clone() { + resource_id } else { - (object_key.clone(), None, None, None) + let registered = register_resource_edit_source_image( + client, + access, + source, + local_asset_id, + &binding_key, + &canvas_context.project_id, + &object_key, + &asset_object_id, + ) + .await?; + let resource_id = registered.value.0; + ledger.source_remote_resource_id = Some(resource_id.clone()); + write_resource_edit_source_stage(root, ledger, "源图片项目资源登记")?; + if let Some(error) = registered.post_response_session_error { + return Err(error); + } + resource_id }; + ( + resource_id.clone(), + Some(resource_id), + Some(decoded.width()), + Some(decoded.height()), + ) + } else { + (object_key.clone(), None, None, None) + }; access.validate_frozen_session()?; let binding = new_external_editor_resource_binding( &input.expected_project_id, @@ -2330,6 +2378,28 @@ fn resource_edit_remote_request( "generationInputs": generation_inputs, }), )), + LocalProjectResourceEditKind::BackgroundRemoval => { + let background_mode = input.background_mode.as_deref().unwrap_or("complex"); + validate_background_removal_options(background_mode, input.screen_color.as_deref())?; + let mut body = serde_json::json!({ + "sourceImageSrc": source_reference + .ok_or_else(|| "抠图缺少正式源资源 ID".to_string())?, + "sourceResourceId": source_reference + .ok_or_else(|| "抠图缺少正式源资源 ID".to_string())?, + "assetKind": source.asset_kind, + "assetLabel": asset_name, + "backgroundMode": background_mode, + "generationInputs": generation_inputs, + }); + if let Some(screen_color) = input.screen_color.as_deref() { + body["screenColor"] = serde_json::json!(screen_color); + } + if let Some(context) = canvas_context { + body["projectId"] = serde_json::json!(context.project_id); + body["assetFolderId"] = serde_json::json!(context.asset_folder_id); + } + Ok(("/api/external/v1/editor/images/background-removals", body)) + } LocalProjectResourceEditKind::CharacterAnimation => { let mut body = serde_json::json!({ "sourceLayerId": format!("resource-{}", input.operation_id), @@ -2436,6 +2506,31 @@ fn resource_edit_remote_request( } } +fn validate_background_removal_options( + background_mode: &str, + screen_color: Option<&str>, +) -> Result<(), String> { + if !matches!(background_mode, "complex" | "flat") { + return Err("抠图 backgroundMode 必须是 complex 或 flat".to_string()); + } + if background_mode == "complex" && screen_color.is_some() { + return Err("complex 抠图不能携带 screenColor".to_string()); + } + if let Some(screen_color) = screen_color { + let valid_hex = screen_color.len() == 7 + && screen_color.starts_with('#') + && screen_color.as_bytes()[1..].iter().all(|byte| { + byte.is_ascii_digit() + || (b'a'..=b'f').contains(byte) + || (b'A'..=b'F').contains(byte) + }); + if screen_color != "auto" && !valid_hex { + return Err("flat 抠图 screenColor 必须是 auto 或 #RRGGBB".to_string()); + } + } + Ok(()) +} + fn resource_edit_operation_id(payload: &serde_json::Value) -> Option { let data = external_editor_response_data(payload); json_string_field(data, "operationId").or_else(|| { @@ -2464,11 +2559,13 @@ fn is_external_resource_edit_endpoint(endpoint: &str) -> bool { matches!( endpoint, "/api/editor/images/edits" + | "/api/editor/images/background-removals" | "/api/editor/character-animations/generations" | "/api/editor/videos/generations" | "/api/editor/audios/sound-effects/generations" | "/api/editor/audios/background-music/generations" | "/api/external/v1/editor/images/edits" + | "/api/external/v1/editor/images/background-removals" | "/api/external/v1/editor/character-animations/generations" | "/api/external/v1/editor/videos/generations" | "/api/external/v1/editor/audios/sound-effects/generations" @@ -2954,6 +3051,17 @@ fn validate_downloaded_media( let is_jpeg = starts(&[0xff, 0xd8, 0xff]); let is_webp = bytes.len() >= 12 && starts(b"RIFF") && &bytes[8..12] == b"WEBP"; match edit_kind { + LocalProjectResourceEditKind::BackgroundRemoval => { + if !is_png { + return Err("抠图结果必须是带透明通道的 PNG".to_string()); + } + let decoded = image::load_from_memory_with_format(bytes, image::ImageFormat::Png) + .map_err(|_| "抠图结果不是有效的 PNG".to_string())?; + if !decoded.color().has_alpha() { + return Err("抠图结果缺少透明通道".to_string()); + } + Ok(("image/png".to_string(), "png".to_string())) + } LocalProjectResourceEditKind::ImageReference => { if is_png { Ok(("image/png".to_string(), "png".to_string())) @@ -3246,6 +3354,7 @@ async fn prepare_remote_resource_edit( let prepared_source = if matches!( input.edit_kind, LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval | LocalProjectResourceEditKind::CharacterAnimation ) || (input.edit_kind == LocalProjectResourceEditKind::Video && input.generation_mode == LocalProjectResourceGenerationMode::Derive) @@ -3275,6 +3384,7 @@ async fn prepare_remote_resource_edit( } else if matches!( input.edit_kind, LocalProjectResourceEditKind::CharacterAnimation + | LocalProjectResourceEditKind::BackgroundRemoval | LocalProjectResourceEditKind::Video | LocalProjectResourceEditKind::SoundEffect | LocalProjectResourceEditKind::BackgroundMusic @@ -4444,8 +4554,11 @@ fn write_resource_edit_result_binding( let Some(asset_object_id) = ledger.remote_asset_object_id.as_deref() else { return Ok(()); }; - if input.edit_kind == LocalProjectResourceEditKind::ImageReference - && ledger.remote_resource_id.is_none() + if matches!( + input.edit_kind, + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval + ) && ledger.remote_resource_id.is_none() { return Ok(()); } @@ -4455,7 +4568,11 @@ fn write_resource_edit_result_binding( media_read_limit(&input.edit_kind), "派生资源 binding 文件", )?; - let (width, height) = if input.edit_kind == LocalProjectResourceEditKind::ImageReference { + let (width, height) = if matches!( + input.edit_kind, + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval + ) { let decoded = image::load_from_memory(&bytes) .map_err(|_| "派生图片 binding 无法解析尺寸".to_string())?; (Some(decoded.width()), Some(decoded.height())) @@ -4775,6 +4892,17 @@ fn commit_resource_edit_version( pub(crate) fn list_pending_local_project_resource_edits_at( input: ListPendingLocalProjectResourceEditsInput, +) -> Result, String> { + let current_platform_session = current_platform_session(); + list_pending_local_project_resource_edits_for_session_at( + input, + current_platform_session.as_ref(), + ) +} + +pub(crate) fn list_pending_local_project_resource_edits_for_session_at( + input: ListPendingLocalProjectResourceEditsInput, + platform_session: Option<&PlatformSessionSnapshot>, ) -> Result, String> { let root = Path::new(input.project_path.trim()); validate_project_root(root)?; @@ -4782,8 +4910,7 @@ pub(crate) fn list_pending_local_project_resource_edits_at( if manifest.project_id != input.expected_project_id { return Err("project-identity-conflict".to_string()); } - let current_platform_session = current_platform_session(); - let _platform_session_lease = current_platform_session + let _platform_session_lease = platform_session .as_ref() .map(|session| acquire_platform_session_identity_lease(&session.identity())) .transpose()?; @@ -4825,10 +4952,8 @@ pub(crate) fn list_pending_local_project_resource_edits_at( if !matches!( ledger.phase, ResourceEditLedgerPhase::Committed | ResourceEditLedgerPhase::Archived - ) && resource_edit_pending_is_visible_to_current_principal( - &ledger, - current_platform_session.as_ref(), - ) { + ) && resource_edit_pending_is_visible_to_current_principal(&ledger, platform_session) + { pending.push(PendingLocalProjectResourceEdit { operation_id: ledger.operation_id, edit_kind: ledger.edit_kind, @@ -4836,6 +4961,8 @@ pub(crate) fn list_pending_local_project_resource_edits_at( source_resource_id: ledger.source_resource_id, source_asset_id: ledger.source_asset_id, asset_name: ledger.asset_name, + background_mode: ledger.background_mode, + screen_color: ledger.screen_color, prompt_sha256: sha256_hex(ledger.prompt.as_bytes()), phase: ledger.phase.as_str().to_string(), created_at: ledger.created_at, @@ -5186,6 +5313,8 @@ pub(crate) async fn resume_local_project_resource_edit_at( source_version_id, prompt: ledger.prompt, asset_name: ledger.asset_name, + background_mode: ledger.background_mode, + screen_color: ledger.screen_color, }) .await } @@ -5195,6 +5324,12 @@ pub(crate) async fn derive_local_project_resource_at( ) -> Result { validate_resource_edit_uuid(&input.operation_id, "operationId")?; validate_resource_edit_uuid(&input.idempotency_key, "idempotencyKey")?; + if input.edit_kind == LocalProjectResourceEditKind::BackgroundRemoval { + validate_background_removal_options( + input.background_mode.as_deref().unwrap_or("complex"), + input.screen_color.as_deref(), + )?; + } if input.expected_project_revision > 9_007_199_254_740_991 { return Err("expectedProjectRevision 超出 JavaScript 安全整数范围".to_string()); } @@ -5281,6 +5416,8 @@ pub(crate) async fn derive_local_project_resource_at( source_sha256: source.source_sha256.clone(), prompt: prompt.clone(), asset_name: asset_name.clone(), + background_mode: input.background_mode.clone(), + screen_color: input.screen_color.clone(), provider_request_issued_at: None, access_scheme: None, api_identity_scheme: None, @@ -5484,6 +5621,9 @@ pub(crate) async fn derive_local_project_resource_at( Ok(result) } +#[cfg(test)] +mod background_removal_tests; + #[cfg(test)] mod tests { use super::*; @@ -5963,6 +6103,8 @@ mod tests { source_version_id: None, prompt: "保留原意并补充红发角色设定".to_string(), asset_name: "规则编辑版".to_string(), + background_mode: None, + screen_color: None, } } @@ -6000,6 +6142,8 @@ mod tests { source_sha256: source.source_sha256.clone(), prompt: input.prompt.clone(), asset_name: input.asset_name.clone(), + background_mode: input.background_mode.clone(), + screen_color: input.screen_color.clone(), provider_request_issued_at: None, access_scheme: input .edit_kind @@ -8539,6 +8683,37 @@ mod tests { serde_json::json!("stable-image-reference") ); assert!(image_body.get("sourceImageSrc").is_none()); + let mut background = image; + background.edit_kind = LocalProjectResourceEditKind::BackgroundRemoval; + background.background_mode = Some("flat".to_string()); + background.screen_color = Some("auto".to_string()); + let (background_endpoint, background_body) = resource_edit_remote_request( + &background, + &ResourceEditSourceSnapshot { + media_type: "image/png".to_string(), + asset_kind: "art-image".to_string(), + source_path: Some("assets/source.png".to_string()), + bytes: Some(resource_editor_test_png()), + ..source.clone() + }, + "去除背景", + "透明图", + Some("stable-image-reference"), + Some(&ExternalCanvasGenerationContext { + project_id: "project-1".to_string(), + asset_folder_id: "folder-1".to_string(), + canvas_name: "测试画板".to_string(), + }), + ) + .expect("build background removal request"); + assert_eq!( + background_endpoint, + "/api/external/v1/editor/images/background-removals" + ); + assert_eq!(background_body["backgroundMode"], "flat"); + assert_eq!(background_body["screenColor"], "auto"); + assert_eq!(background_body["projectId"], "project-1"); + assert_eq!(background_body["assetFolderId"], "folder-1"); assert!(is_external_resource_edit_endpoint( "/api/editor/videos/generations" )); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs new file mode 100644 index 000000000..aa731aeef --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs @@ -0,0 +1,725 @@ +use super::*; + +use std::io::{Cursor, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::{Arc, Mutex}; + +const TEST_PROJECT_ID: &str = "background-removal-project"; +const TEST_API_KEY: &str = "background-removal-key"; +const REMOTE_PROJECT_ID: &str = "background-removal-remote-project"; +const REMOTE_FOLDER_ID: &str = "background-removal-folder"; +const SOURCE_RESOURCE_ID: &str = "editor-resource-background-removal-source"; + +struct BackgroundRemovalFixture { + directory: tempfile::TempDir, + request: DeriveLocalProjectResourceInput, + source_asset_id: String, + source_bytes: Vec, +} + +#[tokio::test] +async fn background_removal_platform_account_uses_runtime_job_and_platform_read_url_routes() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind platform account server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("platform address") + ); + let _session_guard = install_test_platform_session( + "background-removal-owner", + "background-removal-platform-token", + &base_url, + ); + let session = current_platform_session().expect("platform account session"); + let fixture = background_removal_fixture_for_session(&base_url, Some(&session)); + let generated_png = test_png(); + let signed_url = format!("{base_url}/platform-result.png"); + let captured = Arc::new(Mutex::new(Vec::new())); + let server_captured = Arc::clone(&captured); + let server_png = generated_png.clone(); + let server = std::thread::spawn(move || { + for index in 0..6 { + let mut stream = accept_request(&listener, index); + let request = read_request(&mut stream); + server_captured + .lock() + .expect("capture requests") + .push(request.clone()); + if respond_canvas_context_request(&mut stream, &request) { + continue; + } + let line = request.lines().next().unwrap_or_default(); + if index != 5 { + assert!(request + .to_ascii_lowercase() + .contains("authorization: bearer background-removal-platform-token")); + } + match index { + 2 => { + assert!(line.starts_with("POST /api/editor/images/background-removals ")); + assert_submission(&request); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"queueState": { + "operationId": "platform-background-removal", "status": "queued", "pollAfterMs": 0 + }}}), + ); + } + 3 => { + assert!(line.starts_with( + "GET /api/runtime/external-generation/jobs/platform-background-removal " + )); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"job": { + "operationId": "platform-background-removal", "status": "completed", + "result": {"resource": { + "resourceId": "editor-resource-platform-background-removal", + "projectId": REMOTE_PROJECT_ID, + "objectKey": "generated/platform-background-removal.png", + "assetObjectId": "platform-background-removal-object" + }} + }}}), + ); + } + 4 => { + assert!(line.starts_with("GET /api/assets/read-url?")); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"read": {"signedUrl": signed_url}}}), + ); + } + 5 => { + assert!(line.starts_with("GET /platform-result.png ")); + write_bytes(&mut stream, "image/png", &server_png); + } + _ => unreachable!(), + } + } + }); + + let result = derive_local_project_resource_at(fixture.request.clone()) + .await + .expect("complete platform account background removal"); + server.join().expect("join platform account server"); + let requests = captured.lock().expect("read platform requests"); + assert_eq!( + requests + .iter() + .filter(|request| request.starts_with("POST /api/editor/images/background-removals ")) + .count(), + 1 + ); + assert_eq!( + fs::read( + fixture + .root() + .join(result.asset.expect("platform asset").local_path) + ) + .expect("read platform result"), + generated_png + ); +} + +impl BackgroundRemovalFixture { + fn root(&self) -> &Path { + self.directory.path() + } +} + +fn test_png() -> Vec { + let mut output = Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(2, 2) + .write_to(&mut output, image::ImageFormat::Png) + .expect("encode test PNG"); + output.into_inner() +} + +#[test] +fn background_removal_download_rejects_non_png_and_png_without_alpha() { + let malformed = validate_downloaded_media( + &LocalProjectResourceEditKind::BackgroundRemoval, + "image/png", + b"not-a-png", + ) + .expect_err("background removal must reject non-PNG bytes"); + assert!(malformed.contains("PNG")); + + let mut rgb_png = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgb8(image::RgbImage::new(2, 2)) + .write_to(&mut rgb_png, image::ImageFormat::Png) + .expect("encode RGB PNG"); + let missing_alpha = validate_downloaded_media( + &LocalProjectResourceEditKind::BackgroundRemoval, + "image/png", + rgb_png.get_ref(), + ) + .expect_err("background removal must reject PNG without alpha channel"); + assert!(missing_alpha.contains("透明通道")); +} + +fn background_removal_fixture(base_url: &str) -> BackgroundRemovalFixture { + background_removal_fixture_for_session(base_url, None) +} + +fn background_removal_fixture_for_session( + base_url: &str, + platform_session: Option<&PlatformSessionSnapshot>, +) -> BackgroundRemovalFixture { + let directory = tempfile::tempdir().expect("create background removal fixture"); + let root = directory.path(); + init_local_game_project_at(root, TEST_PROJECT_ID, "抠图账本闭环测试") + .expect("initialize local project"); + let source_bytes = test_png(); + let uploaded = upload_local_asset_at(root, "source.png", "image/png", &source_bytes) + .expect("register source image"); + let manifest = read_existing_manifest_for_project(root).expect("read source manifest"); + let source_asset = manifest + .assets + .iter() + .find(|asset| asset.id == uploaded.id) + .expect("find source asset") + .clone(); + let revision = read_game_creator_agent_runtime_project_revision(root) + .expect("read source project revision") + .revision; + let request = DeriveLocalProjectResourceInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: TEST_PROJECT_ID.to_string(), + expected_project_revision: revision, + operation_id: Uuid::new_v4().to_string(), + idempotency_key: Uuid::new_v4().to_string(), + edit_kind: LocalProjectResourceEditKind::BackgroundRemoval, + generation_mode: LocalProjectResourceGenerationMode::Derive, + source_resource_id: format!("local-asset:{}", source_asset.id), + source_asset_id: Some(source_asset.id.clone()), + source_path: Some(source_asset.local_path.clone()), + source_media_type: Some(source_asset.media_type.clone()), + source_subtype: Some(source_asset.kind.clone()), + producer_task_id: source_asset.source.task_id.clone(), + source_version_id: None, + prompt: "去除背景".to_string(), + asset_name: "透明角色".to_string(), + background_mode: Some("flat".to_string()), + screen_color: Some("auto".to_string()), + }; + let source = resolve_resource_edit_source(root, &manifest, &request) + .expect("resolve background removal source"); + let bearer_token = platform_session + .map(|session| session.access_token.as_str()) + .unwrap_or(TEST_API_KEY); + let access = ExternalEditorBindingAccess::new(base_url, bearer_token, platform_session) + .expect("create developer access"); + let principal = + external_editor_binding_principal(&access).expect("resolve developer principal"); + write_external_editor_project_binding_at( + root, + &new_external_editor_project_binding( + TEST_PROJECT_ID, + &principal, + REMOTE_PROJECT_ID, + REMOTE_FOLDER_ID, + unix_timestamp(), + ) + .expect("build project binding"), + ) + .expect("write project binding"); + let source_identity = new_external_editor_source_identity( + &source_asset.id, + &source.source_sha256, + &source.media_type, + &source.asset_kind, + ) + .expect("build source identity"); + write_external_editor_resource_binding_at( + root, + &new_external_editor_resource_binding( + TEST_PROJECT_ID, + &principal, + REMOTE_PROJECT_ID, + &source_identity, + Some(SOURCE_RESOURCE_ID), + "registered/source.png", + "registered-source-object", + Some(2), + Some(2), + unix_timestamp(), + ) + .expect("build source binding"), + ) + .expect("write source binding"); + BackgroundRemovalFixture { + directory, + request, + source_asset_id: source_asset.id, + source_bytes, + } +} + +async fn with_test_credentials( + base_url: &str, + operation: impl std::future::Future, +) -> T { + let _platform_session_guard = clear_test_platform_session(); + crate::assets::with_external_editor_api_credentials( + crate::assets::external_editor_api_credentials_for_test( + base_url.to_string(), + TEST_API_KEY.to_string(), + ), + operation, + ) + .await +} + +fn accept_request(listener: &TcpListener, index: usize) -> TcpStream { + listener + .set_nonblocking(true) + .expect("set background removal listener nonblocking"); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + match listener.accept() { + Ok((stream, _)) => { + stream + .set_nonblocking(false) + .expect("restore background removal stream blocking mode"); + return stream; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for background removal request {index}" + ); + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("accept background removal request {index}: {error}"), + } + } +} + +fn read_request(stream: &mut TcpStream) -> String { + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("set request read timeout"); + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 4096]; + let (header_end, content_length) = loop { + let read = stream.read(&mut buffer).expect("read HTTP request"); + assert!(read > 0, "request closed before headers"); + bytes.extend_from_slice(&buffer[..read]); + let Some(header_end) = bytes.windows(4).position(|part| part == b"\r\n\r\n") else { + continue; + }; + let headers = String::from_utf8_lossy(&bytes[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + break (header_end + 4, content_length); + }; + while bytes.len() < header_end + content_length { + let read = stream.read(&mut buffer).expect("read HTTP request body"); + assert!(read > 0, "request closed before body"); + bytes.extend_from_slice(&buffer[..read]); + } + String::from_utf8_lossy(&bytes).into_owned() +} + +fn write_json(stream: &mut TcpStream, status: &str, value: serde_json::Value) { + let body = value.to_string(); + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .expect("write JSON response"); +} + +fn write_bytes(stream: &mut TcpStream, media_type: &str, bytes: &[u8]) { + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: {media_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + bytes.len() + ) + .expect("write media headers"); + stream.write_all(bytes).expect("write media body"); +} + +fn completed_status(operation_id: &str) -> serde_json::Value { + serde_json::json!({"data": { + "operationId": operation_id, + "status": "completed", + "result": {"resource": { + "resourceId": "editor-resource-background-removal-result", + "projectId": REMOTE_PROJECT_ID, + "objectKey": "generated/background-removal.png", + "assetObjectId": "background-removal-result-object" + }} + }}) +} + +fn assert_submission(request: &str) { + assert!( + request.starts_with("POST /api/editor/images/background-removals ") + || request.starts_with("POST /api/external/v1/editor/images/background-removals ") + ); + let lower = request.to_ascii_lowercase(); + assert!(lower.contains("idempotency-key:")); + let body = request.split("\r\n\r\n").nth(1).expect("submission body"); + let payload: serde_json::Value = serde_json::from_str(body).expect("parse submission body"); + assert_eq!(payload["sourceImageSrc"], SOURCE_RESOURCE_ID); + assert_eq!(payload["sourceResourceId"], SOURCE_RESOURCE_ID); + assert_eq!(payload["projectId"], REMOTE_PROJECT_ID); + assert_eq!(payload["assetFolderId"], REMOTE_FOLDER_ID); + assert_eq!(payload["backgroundMode"], "flat"); + assert_eq!(payload["screenColor"], "auto"); +} + +fn assert_developer_submission(request: &str) { + assert!(request.starts_with("POST /api/external/v1/editor/images/background-removals ")); + assert!(request + .to_ascii_lowercase() + .contains("authorization: bearer background-removal-key")); + assert_submission(request); +} + +fn respond_canvas_context_request(stream: &mut TcpStream, request: &str) -> bool { + let line = request.lines().next().unwrap_or_default(); + if line.starts_with("GET /api/external/v1/editor/projects ") + || line.starts_with("GET /api/editor/projects ") + { + write_json( + stream, + "200 OK", + serde_json::json!({"data": {"projects": [{"projectId": REMOTE_PROJECT_ID}]}}), + ); + true + } else if line.starts_with("GET /api/external/v1/editor/assets/library ") + || line.starts_with("GET /api/editor/assets/library ") + { + write_json( + stream, + "200 OK", + serde_json::json!({"data": {"library": {"folders": [{"folderId": REMOTE_FOLDER_ID}]}}}), + ); + true + } else { + false + } +} + +#[tokio::test] +async fn background_removal_derives_through_poll_download_and_manifest_commit() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind background removal server"); + let base_url = format!("http://{}", listener.local_addr().expect("server address")); + let fixture = background_removal_fixture(&base_url); + let generated_png = test_png(); + let signed_url = format!("{base_url}/generated.png"); + let server_png = generated_png.clone(); + let server_signed_url = signed_url.clone(); + let requests = Arc::new(Mutex::new(Vec::new())); + let server_requests = Arc::clone(&requests); + let server = std::thread::spawn(move || { + for index in 0..8 { + let mut stream = accept_request(&listener, index); + let request = read_request(&mut stream); + server_requests + .lock() + .expect("capture requests") + .push(request.clone()); + let line = request.lines().next().unwrap_or_default(); + if respond_canvas_context_request(&mut stream, &request) { + continue; + } + match index { + 2 => { + assert_developer_submission(&request); + write_json( + &mut stream, + "202 Accepted", + serde_json::json!({"data": { + "operationId": "background-removal-operation", + "status": "queued", + "pollAfterMs": 0 + }}), + ); + } + 3 => write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": { + "operationId": "background-removal-operation", "status": "queued", "pollAfterMs": 0 + }}), + ), + 4 => write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": { + "operationId": "background-removal-operation", "status": "running", "pollAfterMs": 0 + }}), + ), + 5 => write_json( + &mut stream, + "200 OK", + completed_status("background-removal-operation"), + ), + 6 => { + assert!(line.starts_with("GET /api/external/v1/assets/read-url?")); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"read": { + "signedUrl": server_signed_url + }}}), + ); + } + 7 => { + assert!(line.starts_with("GET /generated.png ")); + write_bytes(&mut stream, "image/png", &server_png); + } + _ => unreachable!(), + } + } + }); + + let result = with_test_credentials( + &base_url, + derive_local_project_resource_at(fixture.request.clone()), + ) + .await + .expect("complete background removal"); + server + .join() + .expect("join complete background removal server"); + let captured = requests.lock().expect("read captured requests"); + assert_eq!(captured.len(), 8); + assert_eq!( + captured + .iter() + .filter(|request| request.starts_with("POST ")) + .count(), + 1 + ); + let derived = result.asset.expect("derived background removal asset"); + assert_ne!(derived.id, fixture.source_asset_id); + assert_eq!( + fs::read(fixture.root().join(&derived.local_path)).expect("read result"), + generated_png + ); + assert_eq!( + fs::read( + fixture.root().join( + result + .manifest + .assets + .iter() + .find(|asset| asset.id == fixture.source_asset_id) + .expect("source preserved") + .local_path + .clone() + ) + ) + .expect("read source"), + fixture.source_bytes + ); + let ledger = read_resource_edit_ledger(fixture.root(), &fixture.request.operation_id) + .expect("read ledger") + .expect("ledger exists"); + assert_eq!(ledger.phase, ResourceEditLedgerPhase::Committed); + assert_eq!( + ledger.remote_canvas_project_id.as_deref(), + Some(REMOTE_PROJECT_ID) + ); +} + +#[tokio::test] +async fn background_removal_resumes_accepted_operation_without_reposting() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind resume server"); + let base_url = format!("http://{}", listener.local_addr().expect("server address")); + let fixture = background_removal_fixture(&base_url); + let generated_png = test_png(); + let signed_url = format!("{base_url}/resumed.png"); + let captured = Arc::new(Mutex::new(Vec::new())); + let server_captured = Arc::clone(&captured); + let server_png = generated_png.clone(); + let server = std::thread::spawn(move || { + for index in 0..7 { + let mut stream = accept_request(&listener, index); + let request = read_request(&mut stream); + server_captured + .lock() + .expect("capture resume requests") + .push(request.clone()); + let line = request.lines().next().unwrap_or_default(); + if respond_canvas_context_request(&mut stream, &request) { + continue; + } + match index { + 2 => { + assert_developer_submission(&request); + write_json( + &mut stream, + "202 Accepted", + serde_json::json!({"data": { + "operationId": "resume-background-removal", "status": "queued", "pollAfterMs": 0 + }}), + ); + } + 3 => write_json( + &mut stream, + "500 Internal Server Error", + serde_json::json!({"error": "interrupted"}), + ), + 4 => write_json( + &mut stream, + "200 OK", + completed_status("resume-background-removal"), + ), + 5 => { + assert!(line.starts_with("GET /api/external/v1/assets/read-url?")); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"read": {"signedUrl": signed_url}}}), + ); + } + 6 => { + assert!(line.starts_with("GET /resumed.png ")); + write_bytes(&mut stream, "image/png", &server_png); + } + _ => unreachable!(), + } + } + }); + + let first_error = with_test_credentials( + &base_url, + derive_local_project_resource_at(fixture.request.clone()), + ) + .await + .expect_err("first polling attempt is interrupted"); + assert!(first_error.contains("result-unknown")); + let accepted = read_resource_edit_ledger(fixture.root(), &fixture.request.operation_id) + .expect("read accepted ledger") + .expect("accepted ledger exists"); + assert_eq!(accepted.phase, ResourceEditLedgerPhase::Accepted); + assert_eq!( + accepted.remote_operation_id.as_deref(), + Some("resume-background-removal") + ); + let pending = list_pending_local_project_resource_edits_for_session_at( + ListPendingLocalProjectResourceEditsInput { + project_path: fixture.root().to_string_lossy().into_owned(), + expected_project_id: TEST_PROJECT_ID.to_string(), + }, + None, + ) + .expect("list developer pending operations"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].operation_id, fixture.request.operation_id); + assert_eq!(pending[0].background_mode.as_deref(), Some("flat")); + assert_eq!(pending[0].screen_color.as_deref(), Some("auto")); + let result = with_test_credentials( + &base_url, + resume_local_project_resource_edit_at(ResumeLocalProjectResourceEditInput { + project_path: fixture.root().to_string_lossy().into_owned(), + expected_project_id: TEST_PROJECT_ID.to_string(), + operation_id: fixture.request.operation_id.clone(), + }), + ) + .await + .expect("resume accepted background removal"); + server.join().expect("join resume server"); + let captured = captured.lock().expect("read resume requests"); + assert_eq!( + captured + .iter() + .filter(|request| request.starts_with("POST ")) + .count(), + 1 + ); + assert_eq!(result.manifest.assets.len(), 2); + assert!(list_pending_local_project_resource_edits_for_session_at( + ListPendingLocalProjectResourceEditsInput { + project_path: fixture.root().to_string_lossy().into_owned(), + expected_project_id: TEST_PROJECT_ID.to_string(), + }, + None, + ) + .expect("list pending after resume") + .is_empty()); + assert_eq!( + fs::read( + fixture + .root() + .join(result.asset.expect("resumed asset").local_path) + ) + .expect("read resumed result"), + generated_png + ); +} + +#[tokio::test] +async fn background_removal_remote_failure_keeps_manifest_without_result() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind failure server"); + let base_url = format!("http://{}", listener.local_addr().expect("server address")); + let fixture = background_removal_fixture(&base_url); + let server = std::thread::spawn(move || { + for index in 0..4 { + let mut stream = accept_request(&listener, index); + let request = read_request(&mut stream); + if respond_canvas_context_request(&mut stream, &request) { + continue; + } + if index == 2 { + assert_developer_submission(&request); + write_json( + &mut stream, + "202 Accepted", + serde_json::json!({"data": { + "operationId": "failed-background-removal", "status": "queued", "pollAfterMs": 0 + }}), + ); + } else { + assert!(request + .starts_with("GET /api/external/v1/generations/failed-background-removal ")); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": { + "operationId": "failed-background-removal", "status": "failed" + }}), + ); + } + } + }); + + let error = with_test_credentials( + &base_url, + derive_local_project_resource_at(fixture.request.clone()), + ) + .await + .expect_err("remote failure must be returned"); + server.join().expect("join failure server"); + assert!(error.contains("remote-terminal-failed")); + let manifest = + read_existing_manifest_for_project(fixture.root()).expect("read unchanged manifest"); + assert_eq!(manifest.assets.len(), 1); + assert_eq!(manifest.assets[0].id, fixture.source_asset_id); + let ledger = read_resource_edit_ledger(fixture.root(), &fixture.request.operation_id) + .expect("read failed ledger") + .expect("failed ledger exists"); + assert_eq!(ledger.phase, ResourceEditLedgerPhase::RemoteFailed); + assert!(ledger.result_asset_id.is_none()); + assert!( + read_optional_resource_edit_staging(fixture.root(), &fixture.request.operation_id) + .expect("read absent staging") + .is_none() + ); +} 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 1bd650976..184007760 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 @@ -44,6 +44,7 @@ import { Replace, RotateCcw, Search, + Shapes, SlidersHorizontal, Sparkles, Trash2, @@ -427,6 +428,8 @@ type PendingLocalProjectResourceEdit = { editKind: string; sourceResourceId: string; assetName: string; + backgroundMode?: string | null; + screenColor?: string | null; phase: string; createdAt: number; }; @@ -467,8 +470,20 @@ type ResourceEditServiceIdentityConfirmation = { expiresAt: number; }; -function pendingResourceEditKindLabel(editKind: string) { +function pendingResourceEditKindLabel({ + editKind, + backgroundMode, + screenColor, +}: PendingLocalProjectResourceEdit) { if (editKind === 'image') return '图片编辑'; + if (editKind === 'background-removal') { + if (backgroundMode === 'complex') return '图片抠图 · 复杂背景'; + if (backgroundMode === 'flat') { + const color = screenColor === 'auto' ? '自动背景色' : screenColor; + return `图片抠图 · 平面背景${color ? ` · ${color}` : ''}`; + } + return '图片抠图'; + } if (editKind === 'text') return '文本编辑'; if (editKind === 'agent-result') return '智能体结果编辑'; return '资源编辑'; @@ -9268,7 +9283,7 @@ export default function ProjectDevelopmentView({
{pending.assetName} - {pendingResourceEditKindLabel(pending.editKind)} ·{' '} + {pendingResourceEditKindLabel(pending)} ·{' '} {pendingResourceEditCreatedAtLabel(pending.createdAt)} 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 a31d69f89..475242de0 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -411,9 +411,7 @@ export function registerClientHomeTests() { await openResourceBookCategory('UI 交互'); expect(await findResourceSelectButton('live-hero.png')).not.toBeNull(); await openResourceBookCategory('项目版本'); - expect( - await findResourceSelectButton('版本 1'), - ).not.toBeNull(); + expect(await findResourceSelectButton('版本 1')).not.toBeNull(); expect(runButton.getAttribute('data-unavailable')).toBeNull(); await waitFor(() => { expect(invoke).toHaveBeenCalledWith( @@ -582,9 +580,7 @@ export function registerClientHomeTests() { }), ).not.toBeNull(); await openResourceBookCategory('项目版本'); - expect( - await findResourceSelectButton('版本 1'), - ).not.toBeNull(); + expect(await findResourceSelectButton('版本 1')).not.toBeNull(); expect(runButton.getAttribute('data-unavailable')).toBeNull(); await waitFor(() => { expect( 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 ae09f6798..18bda4813 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 @@ -3836,7 +3836,9 @@ export function registerProjectWorkbenchFoundationTests() { ]; await openResourceBookCategory('角色与对象'); - const infoButton = await screen.findByRole('button', { name: '查看hero.png资源信息' }); + const infoButton = await screen.findByRole('button', { + name: '查看hero.png资源信息', + }); expect(infoButton.getAttribute('aria-pressed')).toBe('false'); // 未选中的卡片直接打开信息,不被选中变化 effect 立即关闭。 @@ -4096,13 +4098,7 @@ export function registerProjectWorkbenchFoundationTests() { within(audioToolbar) .getAllByRole('button') .map((button) => button.getAttribute('aria-label')), - ).toEqual([ - '引用资源 bgm.mp3', - '编辑标签', - '重命名', - '导出', - '删除素材', - ]); + ).toEqual(['引用资源 bgm.mp3', '编辑标签', '重命名', '导出', '删除素材']); // 工具条的「导出」必须真的走通落盘链路:原生保存对话框 + Rust 分块复制, // 而不是只渲染一个按钮。原生对话框由入口文件 mock 成"用户选了 diff --git a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx index f7acdba4e..043918975 100644 --- a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx +++ b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx @@ -51,9 +51,11 @@ describe('useDirectActiveTurns', () => { }); it('clears to a stable empty snapshot when the hook is disabled', async () => { - const invoke = vi.fn( - async () => [] as GameCreatorDirectActiveTurn[], - ) as never; + let resolveSnapshot!: (turns: GameCreatorDirectActiveTurn[]) => void; + const snapshot = new Promise((resolve) => { + resolveSnapshot = resolve; + }); + const invoke = vi.fn(() => snapshot) as never; const { result, rerender } = renderHook( ({ enabled }: { enabled: boolean }) => useDirectActiveTurns({ invoke, enabled, pollIntervalMs: 60_000 }), @@ -62,7 +64,8 @@ describe('useDirectActiveTurns', () => { const emptySnapshot = result.current.activeTurns; await act(async () => { - await result.current.refreshActiveTurns(); + resolveSnapshot([]); + await snapshot; }); expect(result.current.activeTurns).toBe(emptySnapshot); rerender({ enabled: false }); diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx index 4234d82b8..ef129cd12 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx @@ -109,6 +109,8 @@ type PendingResourceEditFixture = { editKind: string; sourceResourceId: string; assetName: string; + backgroundMode?: string | null; + screenColor?: string | null; phase: string; createdAt: number; }; @@ -1294,6 +1296,42 @@ describe('project resource live canvas integration', () => { }); }); + it('恢复面板显示同名抠图的模式和颜色,缺失字段不推断默认值', async () => { + installTauri({ + pendingResourceEdits: [ + { backgroundMode: 'complex' }, + { backgroundMode: 'flat', screenColor: 'auto' }, + { backgroundMode: 'flat', screenColor: '#AABBCC' }, + { backgroundMode: 'flat' }, + {}, + ].map((options, index) => ({ + operationId: `background-${index}`, + editKind: 'background-removal', + sourceResourceId: 'source-art', + assetName: '透明底', + phase: 'accepted', + createdAt: 1, + ...options, + })), + }); + render(); + fireEvent.click( + await screen.findByRole('button', { name: '管理未完成编辑 (5)' }), + ); + const labels = screen + .getAllByText('透明底') + .map((name) => name.nextElementSibling?.textContent); + expect( + labels.map((label) => label?.split(' · ').slice(0, -1).join(' · ')), + ).toEqual([ + '图片抠图 · 复杂背景', + '图片抠图 · 平面背景 · 自动背景色', + '图片抠图 · 平面背景 · #AABBCC', + '图片抠图 · 平面背景', + '图片抠图', + ]); + }); + it('恢复面板可跳过首条失败任务继续任意 operation,且对账项不会被重放', async () => { const failedOperationId = '11111111-1111-4111-8111-111111111111'; const resumableOperationId = '22222222-2222-4222-8222-222222222222'; @@ -2072,7 +2110,9 @@ describe('project resource live canvas integration', () => { await openResourceBookCategory('角色与对象'); fireEvent.click(await findResourceSelectButton('hero.png')); const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); - fireEvent.click(screen.getByRole('button', { name: '查看hero.png资源信息' })); + fireEvent.click( + screen.getByRole('button', { name: '查看hero.png资源信息' }), + ); const infoPanel = await screen.findByRole('dialog', { name: '资源信息' }); // 分类值本身仍是只读文本(`dd` 里只有值,入口按钮在它外面)。 diff --git a/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx b/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx index cfbc324f9..e8f2b5e4b 100644 --- a/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx @@ -379,7 +379,10 @@ function toolbarAction(toolbar: HTMLElement, name: string) { const visible = within(toolbar).queryByRole('button', { name }); if (visible) return visible; fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' })); - return within(screen.getByRole('group', { name: '更多操作' })).getByRole('button', { name }); + return within(screen.getByRole('group', { name: '更多操作' })).getByRole( + 'button', + { name }, + ); } async function selectCardAndOpenToolbar(label: string) { @@ -550,12 +553,20 @@ describe('版本级资源替换', () => { const toolbar = await selectCardAndOpenToolbar('legacy.png'); fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' })); const menu = screen.getByRole('group', { name: '更多操作' }); - const viewport = () => document.querySelector('[data-resource-viewport]') - ?.getAttribute('data-resource-viewport'); + const viewport = () => + document + .querySelector('[data-resource-viewport]') + ?.getAttribute('data-resource-viewport'); const before = viewport(); expect(before).toBeTruthy(); - const wheel = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 120 }); - act(() => { menu.dispatchEvent(wheel); }); + const wheel = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaY: 120, + }); + act(() => { + menu.dispatchEvent(wheel); + }); expect(wheel.defaultPrevented).toBe(false); expect(viewport()).toBe(before); @@ -568,9 +579,15 @@ describe('版本级资源替换', () => { const scene = document.querySelector('.game-resource-book-scene')!; act(() => { - scene.dispatchEvent(new WheelEvent('wheel', { - bubbles: true, cancelable: true, deltaY: 120, clientX: 90, clientY: 70, - })); + scene.dispatchEvent( + new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaY: 120, + clientX: 90, + clientY: 70, + }), + ); }); expect(viewport()).not.toBe(before); }); @@ -578,13 +595,17 @@ describe('版本级资源替换', () => { it('信息从未选中卡打开并跟随资源身份,普通换选会关闭', async () => { renderReplacementWorkbench(); await selectCardAndOpenToolbar('legacy.png'); - const lateInfo = screen.getByRole('button', { name: '查看late.png资源信息' }); + const lateInfo = screen.getByRole('button', { + name: '查看late.png资源信息', + }); fireEvent.pointerDown(lateInfo, { button: 0 }); fireEvent.click(lateInfo); const panel = screen.getByRole('dialog', { name: '资源信息' }); expect(within(panel).getByText('late.png')).toBeTruthy(); expect(lateInfo.getAttribute('aria-pressed')).toBe('true'); - fireEvent.click(screen.getByRole('button', { name: '查看legacy.png资源信息' })); + fireEvent.click( + screen.getByRole('button', { name: '查看legacy.png资源信息' }), + ); const switched = screen.getByRole('dialog', { name: '资源信息' }); expect(within(switched).getByText('legacy.png')).toBeTruthy(); expect(within(switched).queryByText('late.png')).toBeNull(); @@ -597,10 +618,10 @@ describe('版本级资源替换', () => { // 未被初始版本绑定的素材(版本创建之后才登记):工具条照常出现,但没有「替换素材」。 const lateToolbar = await selectCardAndOpenToolbar('late.png'); - fireEvent.mouseEnter(within(lateToolbar).getByRole('button', { name: '更多' })); - expect( - screen.queryByRole('button', { name: '替换素材' }), - ).toBeNull(); + fireEvent.mouseEnter( + within(lateToolbar).getByRole('button', { name: '更多' }), + ); + expect(screen.queryByRole('button', { name: '替换素材' })).toBeNull(); expect( within(lateToolbar).getByRole('button', { name: '快速编辑' }), ).not.toBeNull(); @@ -614,9 +635,7 @@ describe('版本级资源替换', () => { // 被当前版本绑定的素材:入口出现。 const sourceToolbar = await selectCardAndOpenToolbar('legacy.png'); - expect( - toolbarAction(sourceToolbar, '替换素材'), - ).not.toBeNull(); + expect(toolbarAction(sourceToolbar, '替换素材')).not.toBeNull(); }); it('从入口一路走到写入:候选弹窗禁用硬门禁项、给出格式提示、直接替换且不产生新版本', async () => { @@ -895,9 +914,10 @@ describe('版本级资源替换', () => { const deleteButton = toolbarAction(toolbar, '删除素材'); const toolbarLabels = [ ...within(toolbar).getAllByRole('button'), - ...within(screen.getByRole('group', { name: '更多操作' })).getAllByRole('button'), - ] - .map((button) => button.getAttribute('aria-label') ?? ''); + ...within(screen.getByRole('group', { name: '更多操作' })).getAllByRole( + 'button', + ), + ].map((button) => button.getAttribute('aria-label') ?? ''); // 末位:在最后一个非破坏性动作(替换素材)之后、共享导出按钮之前。 expect(toolbarLabels.indexOf('删除素材')).toBeGreaterThan( toolbarLabels.indexOf('替换素材'), @@ -1411,9 +1431,7 @@ describe('版本级资源替换', () => { // 第一次:legacy → final。 const legacyToolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click( - toolbarAction(legacyToolbar, '替换素材'), - ); + fireEvent.click(toolbarAction(legacyToolbar, '替换素材')); let dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1431,9 +1449,7 @@ describe('版本级资源替换', () => { // 第二次:final → final.webp(同一个工作台会话内)。 const finalToolbar = await selectCardAndOpenToolbar('final.png'); - fireEvent.click( - toolbarAction(finalToolbar, '替换素材'), - ); + fireEvent.click(toolbarAction(finalToolbar, '替换素材')); dialog = await screen.findByRole('dialog', { name: '选择替换素材' }); fireEvent.click( within(dialog).getByRole('option', { name: '选择替换素材final.webp' }), diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1b57435a0..0be34b555 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2,6 +2,13 @@ > 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。 > 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。 + +## 2026-09-17 AGC 抠图提交使用远端画布项目身份 + +- 背景:AGC 已通过本地项目 ID 建立并持久化本地项目到主站远端画布项目的绑定,但 `agc_remove_background` 提交请求仍把本地 `manifest.project_id` 放入 `projectId`;`assetFolderId` 已使用远端素材目录 ID。主站因此按项目不存在或不属于当前账号返回 404,主站抠图和 BgFilter 本身均正常。 +- 决策:抠图请求及工具回执统一使用 `prepare_external_canvas_generation_context` 返回的远端 `context.project_id`;本地 manifest 项目 ID 只用于绑定键和本地状态,不得作为主站业务请求的 `projectId`。 +- 验证:客户端定向 Rust 测试、格式、编码和 diff 检查通过;未修改主站路由或 BgFilter。 + ## 2026-09-17 图集切分模式改为显式声明 - 决策:`sliceMode` 在图标图集生成入口成为必填字段且不保留任何默认值。省略、`null` 或空字符串必须在引用解析、定价、入队和 provider / OSS 副作用之前返回 `400`(`field=sliceMode`);`grid` 必须同时提供 `gridX`/`gridY`,`connected-components` 不得携带网格尺寸,二者矛盾同样在副作用前失败关闭。 @@ -22,6 +29,7 @@ - 影响范围:`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-17 资源画布支持引擎资源只读预览 - 背景:Cocos Creator 工程里已有的引擎资源(模型、动画、预制体、材质、图集、压缩纹理…)此前在发现层就止步:`.glb` / `.prefab` / `.anim` / `.texture` 等扩展名既不可登记,也不进资源画布,工程导入后画布上只看得到位图、音频与脚本。 @@ -8211,8 +8219,8 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-08-24 AGC Direct 抠图语义工具 -- 决策:将 External v1 `/api/external/v1/editor/images/background-removals` 通过 `agc_remove_background` 加入受控 `agc_tools`。工具只接受当前 manifest 的图片 `sourceLocalAssetId` 与结果名称;客户端负责正式 resourceId、画布/素材目录、稳定 operation/idempotency 身份、权限和错误脱敏,不向 Codex 暴露内部 BgFilter worker、凭据或任意 API。 -- 约束:异步结果只投影有界队列状态,不允许模型自行构造源 URL 或在不确定提交后更换请求身份;External v1 负责 API Key、幂等接收与统一 operation 查询,客户端不得绕过该契约。 +- 决策:将抠图能力通过 `agc_remove_background` 加入受控 `agc_tools`。工具只接受当前 manifest 的图片 `sourceLocalAssetId` 与结果名称;普通登录态使用账号鉴权的 `/api/editor/images/background-removals`,ExternalDeveloper 模式使用 External v1 `/api/external/v1/editor/images/background-removals`。客户端负责正式 resourceId、画布/素材目录、稳定 operation/idempotency 身份、权限和错误脱敏,不向 Codex 暴露内部 BgFilter worker、凭据或任意 API。 +- 约束:异步结果与恢复语义以本文「2026-09-17 AGC 抠图接入本地资源编辑恢复闭环」决策为准。不允许模型自行构造源 URL 或在不确定提交后更换请求身份;两种路由都接收客户端稳定幂等身份,External v1 继续负责 API Key、幂等接收与统一 operation 查询,客户端不得绕过该契约。 ## 2026-08-24 资源详情动作、空态滚动与最终图多步恢复 @@ -8865,6 +8873,14 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证:限速后 `Genarrative-Full-Build-And-Deploy` #289 / #290 SUCCESS;采样期 Jenkins 峰值 10.2~10.5 核、限流不足 2s(可忽略),runner 峰值 12.07 核且持续出现 throttling,整机回落到 2.6%~19.8%。 - 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)。 +## 2026-09-17 AGC 抠图接入本地资源编辑恢复闭环 + +- 背景:`agc_remove_background` 原先只提交 `/api/editor/images/background-removals` 并返回 `queued`,没有轮询远端任务、下载完成媒体或写入本地 manifest;BgFilter 已成功处理但 Agent 因此永远只能看到受理回执。 +- 决策:抠图作为 `LocalProjectResourceEditKind::BackgroundRemoval` 接入现有资源编辑账本,模式和背景色写入 operation 身份;提交后复用同一套轮询、结果下载、staging、manifest 提交和恢复逻辑。已有账本优先恢复,禁止在未知结果时换 operation/idempotency 重发。 +- 边界:主站异步队列、BgFilter 和 SpacetimeDB schema 不变;Agent 只获得本地完成资源和安全身份投影,不接触内部 worker 或凭据。 +- 恢复:已受理任务中断后按原 operation 续查;提交结果不确定时保留账本并人工对账。升级前无账本的 queued 回执不自动迁移或重发,已有远端成果通过正式素材导入恢复。 +- 展示:恢复面板按账本显示抠图模式,平面背景模式同时显示已记录的自动背景色或颜色值,缺失字段不推断默认值,帮助区分同名待处理任务。 + ## 2026-09-17 Jenkins 公网入口 jenkins.genarrative.world 复用 router 反向隧道口径 - 背景:Jenkins controller 实际与 Gitea 同机运行在 `genarrative-station`(`jenkins.service`,`--httpPort=8080 --prefix=/jenkins`,`JENKINS_HOME=/var/lib/jenkins`),此前只有内网入口 `http://192.168.35.82:8080/jenkins/`;`router.genarrative.world` 已有「dev Nginx → dev loopback → station 反向隧道」的成熟口径。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index d59e89037..2a440a3b8 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,9 @@ # 踩坑与排障记录 +## AGC 空快照测试必须等待请求完成 + +`waitFor(() => expect(activeTurns).toEqual([]))` 在 Hook 初始状态就能成功,不能证明首次异步读取已经完成。引用稳定性回归应显式控制 Promise 完成,并同时检查首次空响应与禁用后的引用;快照签名初值必须与初始空数组一致。窗口同步测试应验证未变化状态不重复发布,不能依赖一次多余的空态更新。 + ## AGC Windows 开发态首次页面加载缓慢 Vite 默认监听应用根下的 Rust `src-tauri/target`,构建产物较多时会创建大量 Windows 文件监听器。AGC 配置通过 `server.watch.ignored: ['**/src-tauri/target/**']` 排除此目录,不关闭业务源码、CSS、共享组件监听或 HMR。排查时区分后端就绪、Vite 扫描和原生窗口首绘;监听目录回归不能代替实机首绘测量,验证入口见本地开发运维文档。 diff --git a/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md b/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md index a7e9525fd..f32fcffa6 100644 --- a/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md +++ b/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md @@ -61,10 +61,25 @@ OpenAPI 与客户端工具的对外说明只描述模式用途、参数约束和 } ``` -客户端保留旧参数调用;新字段不填时不改变旧调用语义。客户端不读取图片、不自动选色、不把 `auto` 改写为具体颜色,使用原有 Bearer 认证、幂等键和队列返回模型。 +客户端保留旧参数调用;新字段不填时不改变模式语义。客户端不自动选色、不把 `auto` 改写为具体颜色;源资源校验、认证、异步任务查询、结果下载和本地登记由客户端负责。 工具 schema、桥接参数校验和随包 `agc-client-projection` Skill/契约说明必须保持一致。模式与颜色属于请求意图,必须参与客户端幂等指纹;同一图片与名称的不同模式不能复用同一次请求。缺省 complex 且没有颜色时保留既有指纹。主站在默认值归一化之前计算 External 请求指纹,缺失的新字段不序列化,避免旧请求重放发生冲突。 +客户端提交前建立的本地项目绑定会返回主站远端 `projectId` 与 `assetFolderId`,抠图请求必须使用这两个远端身份;本地 manifest `projectId` 仅用于绑定和本地状态,不能直接提交给主站。 + +### 本地结果与恢复合同 + +`agc_remove_background` 复用本地资源编辑账本,类型为 `background-removal`。源图片保持不变,抠图结果作为新资源写入项目。模式和背景色随账本持久化并参与请求指纹,其它编辑类型的历史指纹保持不变。 + +1. 客户端建立源图片的正式资源绑定,在提交前保存 operation、幂等键和请求意图。普通登录态提交 `/api/editor/images/background-removals`,从 `data.queueState.operationId` 读取受理身份;开发者模式提交 External v1 对应路由,从 `data.operationId` 读取身份。 +2. 受理后持续查询账号路由 `/api/runtime/external-generation/jobs/{operationId}` 或 External v1 对应状态路由。`queued`、`running` 只描述远端返回状态;固定进度值、本地文件缺失或 pending 清单为空均不能证明 BgFilter 排队。 +3. 远端 completed 后按稳定资源身份换取有效下载 URL,校验结果为带 alpha 通道的有效 PNG,随后复用 staging、manifest 和 revision 提交。只在本地登记完成后向 Agent 返回 `completed`、`operationId`、`resource.localAssetId`、相对路径和安全告警,不暴露临时 URL 或凭据。 +4. 轮询中断、超时或下载失败保留已受理 operation;`agc_list_registered_assets.pendingOperations` 与客户端恢复面板可见。相同源资源、结果名称、模式和颜色的后续调用优先恢复同一任务,不再次提交。不同账号不能恢复原账号任务;切回原账号后按既有恢复规则续接。 +5. 远端 failed 明确失败;提交响应不确定且无法确认 operation 时进入人工对账状态,不自动换键重发。失败/未知均不得伪造透明图或自动切换本地抠图方式。 +6. 升级前仅返回 queued、没有本地账本的任务不自动迁移;已有远端结果须通过正式资源查询和导入恢复,不据旧回执重新发起付费请求。 + +本修复只扩展 AGC 客户端现有工作流,不修改主站队列、BgFilter 或 SpacetimeDB schema。验收覆盖账号与开发者两种响应封装、queued/running/completed、已受理中断恢复不重复 POST、远端失败不登记结果,以及既有资源编辑回归。 + ## 实施任务 ### 任务一:冻结 BgFilter 契约 @@ -93,6 +108,13 @@ OpenAPI 与客户端工具的对外说明只描述模式用途、参数约束和 ## 验收证据 +2026-09-17 客户端闭环验证: + +- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell project::resource_editor:: -- --test-threads=1` 通过。新增 HTTP fixture 覆盖开发者 queued/running/completed、账号 HTTP 200 queueState 与 `data.job`、换签下载、PNG alpha 校验、原图保留和新资源提交。 +- 已受理任务的首次轮询失败后,pending 保留模式与颜色;恢复只查询原 operation,整个流程只 POST 一次,成功后清除 pending。远端 failed 不新增结果资源。既有账号隔离、提交原子性和崩溃恢复用例通过。 +- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell agent::direct_tool_bridge::tests -- --test-threads=1`、AGC 类型检查、技能包校验、Rust 格式、编码、文档索引与 diff 检查通过。 +- 本轮未运行真实登录客户端 → 本地主站 → BgFilter 的端到端 smoke;当前本地后端已停止,自动化证据使用模拟 HTTP 服务。此前 BgFilter 成功日志只证明上游处理完成,不证明主站结果持久化或客户端导入成功。 + 2026-09-16 实测: - 主站 `cargo test -p api-server background_removal`:36 项通过,覆盖非法请求入队前拒绝、缺省 complex、队列参数保留、旧请求指纹、父侧内部 RPC 和 provider multipart。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 77c5d8108..3c864437b 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -12,6 +12,9 @@ ## 资源画布交互与工作台状态同步 +- 未完成抠图的恢复项在现有类型行显示账本中的复杂/平面背景模式;平面模式显示已记录的自动背景色或颜色值,缺失模式/颜色不补默认值,恢复仍按原 operation 身份执行。 +- 活动回合初始空快照、首次成功读取的空结果及禁用后的空态保持同一数组引用;快照签名初值与空态重置值均为 `[]`。无原生 invoke 的窗口测试只期待首次状态发布,异步空结果测试显式控制请求完成,不以初始空数组作为请求已完成的证据。 + - 活动回合轮询的初始空快照与后续空结果保持同一引用;停用或切换读取器使旧请求失效,晚到快照不得恢复已停用的活动回合或覆盖新轮询结果。无原生读取器时窗口只发布一次空状态,不通过额外空数组触发重复发布。 - 工作台向窗口标题栏发布正在运行的项目时,输入未变化不得形成重复发布与清理的渲染循环;打开项目动作始终使用当前工作台处理逻辑,退出工作台后清除其标题栏状态。 - 资源子画布(含「所有资源」)保留空白处左键框选、资源卡左键选中/拖动、触摸板双指平移及捏合缩放;右键按住空白处或资源卡拖动时平移画布,不改变资源选择与布局。中键和空格抓手继续可用。总览保留既有左键平移,并支持右键平移。 @@ -167,7 +170,7 @@ npm 游戏的可预览产物固定为对应 package 目录下的 `dist/index.htm - 项目路径、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 保持不变,不因本次升级重建请求或重复扣费。 -- 抠图通过新增 `agc_remove_background` 语义工具开放:模型只提交当前 manifest 的图片 `sourceLocalAssetId` 与结果名称;客户端解析稳定 `resourceId`,准备同名画布/素材目录并生成稳定 operation/idempotency 身份,调用 External v1 `/api/external/v1/editor/images/background-removals` 后只返回有界队列状态。抠图服务仍由客户端和服务端负责源校验、BgFilter、素材登记与画布事务,Codex 不获得内部 worker、凭据或任意 API 调用权。 +- 抠图通过新增 `agc_remove_background` 语义工具开放:模型只提交当前 manifest 的图片 `sourceLocalAssetId` 与结果名称;客户端解析稳定 `resourceId`,准备同名画布/素材目录并生成稳定 operation/idempotency 身份。普通登录态使用账号鉴权的 `/api/editor/images/background-removals`,ExternalDeveloper 模式使用 External v1 `/api/external/v1/editor/images/background-removals`;客户端接收异步受理后轮询任务状态,下载完成媒体并登记到本地 manifest,未知结果保留同一 operation 供恢复。抠图服务仍由客户端和服务端负责源校验、BgFilter、素材登记与画布事务,Codex 不获得内部 worker、凭据或任意 API 调用权。 ## 2026-08-23 AGC 资源生成补齐(视频 / 动画 / 音效 / 背景音乐) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 5f92b841a..bc3f69744 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -6266,17 +6266,19 @@ pub(crate) async fn edit_editor_image_for_owner_with_source_snapshot( pub async fn remove_editor_image_background( State(state): State, + headers: HeaderMap, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { let caller = EditorGenerationCaller::from_authenticated(&authenticated); + let idempotency_key = optional_editor_idempotency_key(&headers)?; let queue_job = enqueue_editor_background_removal_for_owner( &state, &request_context, &caller, payload, - None, + idempotency_key, ) .await?; Ok(json_success_body( diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index bd03cd7ab..d4ffc48d5 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -825,8 +825,9 @@ describe('ImageCanvasWorldView', () => { expect( within(layerButton) .getByRole('button', { name: '查看角色主图图片信息' }) - .parentElement! - .style.getPropertyValue('--image-canvas-editor-inverse-scale'), + .parentElement!.style.getPropertyValue( + '--image-canvas-editor-inverse-scale', + ), ).toBe(inverseScale); expect( ( From 2e15a70b14113bc3888dd8bdc33a95ab3b19db3b Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Fri, 18 Sep 2026 00:00:54 +0800 Subject: [PATCH 16/16] =?UTF-8?q?Provider=20=E7=9E=AC=E6=80=81=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E6=AC=A1=E6=95=B0=E4=B8=A5=E6=A0=BC=E6=8C=89=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E6=89=A7=E8=A1=8C=20=E5=88=A0=E9=99=A4=E8=87=AA?= =?UTF-8?q?=E4=B8=BB=E6=9E=84=E5=BB=BA=E6=A1=A3=E4=BD=8D=2012~16=20?= =?UTF-8?q?=E7=9A=84=E9=87=8D=E8=AF=95=E5=8C=BA=E9=97=B4=E4=B8=8E=20standa?= =?UTF-8?q?rd=20=E6=A1=A3=E4=BD=8D=203=20=E6=AC=A1=E4=B8=8A=E9=99=90?= =?UTF-8?q?=EF=BC=8CmaxRetries=20=E4=B8=A5=E6=A0=BC=E5=86=B3=E5=AE=9A?= =?UTF-8?q?=E7=89=A9=E7=90=86=E9=87=8D=E8=AF=95=E6=AC=A1=E6=95=B0=20?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8=20400=20=E4=B8=8D=E5=86=8D=E5=8D=95=E7=8B=AC?= =?UTF-8?q?=E6=94=B6=E7=AA=84=E5=88=B0=202=20=E6=AC=A1=EF=BC=8C=E4=B8=8E?= =?UTF-8?q?=E5=85=B6=E5=AE=83=E7=9E=AC=E6=80=81=E9=94=99=E8=AF=AF=E5=85=B1?= =?UTF-8?q?=E7=94=A8=E8=AE=BE=E7=BD=AE=E9=87=8C=E7=9A=84=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E4=BB=BD=E9=A2=84=E7=AE=97=20=E8=BF=90=E8=A1=8C=E6=A1=A3?= =?UTF-8?q?=E4=BD=8D=E6=94=B9=E4=B8=BA=E5=8F=AA=E5=86=B3=E5=AE=9A=E4=B8=8A?= =?UTF-8?q?=E6=B8=B8=20400=20=E6=98=AF=E5=90=A6=E7=AE=97=E7=9E=AC=E6=80=81?= =?UTF-8?q?=EF=BC=8C=E9=87=8D=E8=AF=95=E6=AC=A1=E6=95=B0=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=A2=AB=E6=A1=A3=E4=BD=8D=E6=94=B9=E5=86=99=20=E9=87=8D?= =?UTF-8?q?=E5=86=99=E6=A1=A3=E4=BD=8D=E9=87=8D=E8=AF=95=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=E4=B8=BA=E8=AE=BE=E7=BD=AE=E5=80=BC=E7=9B=B4=E5=8F=96=EF=BC=8C?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=20upstream-400=20=E7=94=A8=E4=BE=8B=20decisi?= =?UTF-8?q?on-log=20=E8=AE=B0=E5=BD=95=E6=9C=AC=E6=AC=A1=E5=8F=A3=E5=BE=84?= =?UTF-8?q?=E5=8F=98=E6=9B=B4=E4=B8=8E=E6=9C=AC=E6=9C=BA=E6=97=A2=E6=9C=89?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src-tauri/src/agent/runtime_driver.rs | 4 - .../src-tauri/src/agent/runtime_protocol.rs | 2 +- .../agent/runtime_protocol/provider_retry.rs | 33 ++---- .../runtime_protocol/run_configuration.rs | 24 +++-- .../src-tauri/src/tests/provider.rs | 100 +++++++++--------- .../shared-memory/decision-log.md | 9 +- 6 files changed, 88 insertions(+), 84 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index 02b48e807..9cc2fb6eb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -416,10 +416,6 @@ pub(super) const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_POLL_MS: u64 = 50; pub(super) const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_MAX_TTL_MS: u64 = 10 * 60 * 1_000; pub(super) const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_ERROR: &str = "agent-runtime-real-e2e-tool-plan-handoff-checkpoint-needs-reconciliation"; -pub(super) const AGENT_RUNTIME_PROVIDER_TRANSIENT_RETRY_LIMIT: u32 = 3; -pub(super) const AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR: u32 = 12; -pub(super) const AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_LIMIT: u32 = 16; -pub(crate) const AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT: u32 = 2; pub(super) const AGENT_RUNTIME_AUTONOMOUS_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS: usize = 4; pub(super) const AGENT_RUNTIME_AUTONOMOUS_FORCED_ACTION_MAX_OUTPUT_TOKENS: u32 = 2_000; pub(crate) const AGENT_RUNTIME_AUTONOMOUS_SCAFFOLD_MAX_OUTPUT_TOKENS: u32 = 2_600; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs index 65b883879..0bae6dc31 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs @@ -84,7 +84,7 @@ pub(crate) use response_stream::{ pub(crate) use run_configuration::{ agent_runtime_run_profile_identity_at, bind_game_creator_agent_runtime_run_profile_at, game_creator_agent_runtime_project_revision_path, - game_creator_agent_runtime_provider_transient_max_retries_at, + game_creator_agent_runtime_provider_transient_retry_policy_at, game_creator_agent_runtime_run_profile_binding_path, read_game_creator_agent_runtime_run_profile_binding, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs index 1541acc41..1f2be5915 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs @@ -717,14 +717,14 @@ where Fut: std::future::Future>, H: FnOnce(&platform_llm::LlmRunResponse) -> platform_llm::LlmRunResponse, { - let max_retries = game_creator_agent_runtime_provider_transient_max_retries_at( + let retry_policy = game_creator_agent_runtime_provider_transient_retry_policy_at( root, &provider_snapshot.agent_id, &provider_snapshot.run_id, llm.max_retries, )?; - let retry_autonomous_upstream_400 = - max_retries >= AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR; + let max_retries = retry_policy.max_retries; + let retry_autonomous_upstream_400 = retry_policy.retry_upstream_400; let identity = game_creator_agent_runtime_provider_retry_identity_for_mode( provider_snapshot, llm, @@ -1365,17 +1365,9 @@ where )?; return Err("Provider 瞬态错误编码损坏".to_string()); }; - let error_max_retries = if error_kind == "upstream-400" { - effective_max_retries - .min(AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT) - } else { - effective_max_retries - }; - if existing - .as_ref() - .is_some_and(|record| record.max_retries != error_max_retries) - || attempt >= error_max_retries - { + // 所有瞬态错误共用设置里的重试预算,上游 400 不再单独收窄上限。 + let error_max_retries = effective_max_retries; + if attempt >= error_max_retries { crate::provider_retry::remove_at( root, &provider_snapshot.agent_id, @@ -1517,14 +1509,14 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi operation: &str, request: &LlmRunRequest, ) -> Result, String> { - let max_retries = game_creator_agent_runtime_provider_transient_max_retries_at( + let retry_policy = game_creator_agent_runtime_provider_transient_retry_policy_at( root, &provider_snapshot.agent_id, &provider_snapshot.run_id, llm.max_retries, )?; - let retry_autonomous_upstream_400 = - max_retries >= AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR; + let max_retries = retry_policy.max_retries; + let retry_autonomous_upstream_400 = retry_policy.retry_upstream_400; for attempt in 0..=max_retries { let request_slot = if attempt == 0 { provider_snapshot.request_slot.clone() @@ -1585,11 +1577,8 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi let Some((error_kind, public_error)) = encoded.split_once('\n') else { return Err("Provider 瞬态错误编码损坏".to_string()); }; - let error_max_retries = if error_kind == "upstream-400" { - max_retries.min(AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT) - } else { - max_retries - }; + // 所有瞬态错误共用设置里的重试预算,上游 400 不再单独收窄上限。 + let error_max_retries = max_retries; if attempt >= error_max_retries { return Err(game_creator_agent_runtime_provider_retry_exhausted_error( public_error, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs index 61396cd9e..7d560ad5f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs @@ -395,12 +395,22 @@ pub(crate) fn agent_runtime_run_profile_identity_at( Ok((profile, String::new())) } -pub(crate) fn game_creator_agent_runtime_provider_transient_max_retries_at( +/// 当前持久 run 的 Provider 瞬态重试策略。 +/// +/// 重试次数严格使用设置值:运行档位不再把 `maxRetries` 收进固定区间, +/// 只决定上游 400 是否算瞬态错误。 +#[derive(Debug)] +pub(crate) struct AgentRuntimeProviderTransientRetryPolicy { + pub(crate) max_retries: u32, + pub(crate) retry_upstream_400: bool, +} + +pub(crate) fn game_creator_agent_runtime_provider_transient_retry_policy_at( root: &Path, agent_id: &str, run_id: &str, configured_max_retries: u32, -) -> Result { +) -> Result { let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; let stored_identity = read_latest_game_creator_agent_runtime_task_by_run_id(root, &agent_id, run_id)? @@ -416,10 +426,8 @@ pub(crate) fn game_creator_agent_runtime_provider_transient_max_retries_at( stored_profile, stored_binding_fingerprint, )?; - if profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { - return Ok(configured_max_retries - .max(AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR) - .min(AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_LIMIT)); - } - Ok(configured_max_retries.min(AGENT_RUNTIME_PROVIDER_TRANSIENT_RETRY_LIMIT)) + Ok(AgentRuntimeProviderTransientRetryPolicy { + max_retries: configured_max_retries, + retry_upstream_400: profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + }) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index 7bc72b7af..279b3e743 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -662,7 +662,7 @@ async fn chat_with_game_creator_role_agent_stream_does_not_fallback_on_upstream_ } #[test] -fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() { +fn provider_transient_retry_uses_configured_max_retries_for_every_run_profile() { let root = unique_project_path(); init_local_game_project_at( &root, @@ -671,16 +671,16 @@ fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() { ) .expect("project init"); - assert_eq!( - game_creator_agent_runtime_provider_transient_max_retries_at( - &root, - "design-director", - "legacy-standard-run", - 99, - ) - .expect("legacy standard retry policy"), - 3 - ); + // 历史 standard run 仍走同一身份校验,但重试次数不再被收进区间。 + let legacy_standard = game_creator_agent_runtime_provider_transient_retry_policy_at( + &root, + "design-director", + "legacy-standard-run", + 99, + ) + .expect("legacy standard retry policy"); + assert_eq!(legacy_standard.max_retries, 99); + assert!(!legacy_standard.retry_upstream_400); let standard = bind_game_creator_agent_runtime_run_profile_at( &root, "design-director", @@ -691,25 +691,25 @@ fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() { ) .expect("bind standard profile"); assert_eq!( - game_creator_agent_runtime_provider_transient_max_retries_at( + game_creator_agent_runtime_provider_transient_retry_policy_at( &root, &standard.agent_id, &standard.run_id, 0, ) - .expect("standard zero retry policy"), + .expect("standard zero retry policy") + .max_retries, 0 ); - assert_eq!( - game_creator_agent_runtime_provider_transient_max_retries_at( - &root, - &standard.agent_id, - &standard.run_id, - 99, - ) - .expect("standard capped retry policy"), - 3 - ); + let standard_configured = game_creator_agent_runtime_provider_transient_retry_policy_at( + &root, + &standard.agent_id, + &standard.run_id, + 99, + ) + .expect("standard configured retry policy"); + assert_eq!(standard_configured.max_retries, 99); + assert!(!standard_configured.retry_upstream_400); let parent = bind_game_creator_agent_runtime_run_profile_at( &root, @@ -720,17 +720,16 @@ fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() { None, ) .expect("bind autonomous parent profile"); - for (configured, expected) in [(0, 12), (14, 14), (99, 16)] { - assert_eq!( - game_creator_agent_runtime_provider_transient_max_retries_at( - &root, - &parent.agent_id, - &parent.run_id, - configured, - ) - .expect("autonomous parent retry policy"), - expected - ); + for (configured, expected) in [(0, 0), (5, 5), (99, 99)] { + let policy = game_creator_agent_runtime_provider_transient_retry_policy_at( + &root, + &parent.agent_id, + &parent.run_id, + configured, + ) + .expect("autonomous parent retry policy"); + assert_eq!(policy.max_retries, expected); + assert!(policy.retry_upstream_400); } let child_link = AgentRuntimeTaskLink { @@ -758,14 +757,25 @@ fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() { append_game_creator_agent_runtime_task(&root, &child_state) .expect("append autonomous child task projection"); assert_eq!( - game_creator_agent_runtime_provider_transient_max_retries_at( + game_creator_agent_runtime_provider_transient_retry_policy_at( &root, &child.agent_id, &child.run_id, 0, ) - .expect("autonomous child retry policy"), - 12 + .expect("autonomous child retry policy") + .max_retries, + 0 + ); + assert!( + game_creator_agent_runtime_provider_transient_retry_policy_at( + &root, + &child.agent_id, + &child.run_id, + 0, + ) + .expect("autonomous child retry policy") + .retry_upstream_400 ); fs::remove_file(game_creator_agent_runtime_run_profile_binding_path( @@ -775,7 +785,7 @@ fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() { )) .expect("remove autonomous child binding"); assert!( - game_creator_agent_runtime_provider_transient_max_retries_at( + game_creator_agent_runtime_provider_transient_retry_policy_at( &root, &child.agent_id, &child.run_id, @@ -4558,7 +4568,7 @@ async fn provider_transient_retry_provider_error_is_not_retried() { } #[tokio::test] -async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_budget() { +async fn provider_transient_retry_autonomous_upstream_400_uses_configured_budget() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "自主构建 Provider 400 重试测试") .expect("project init"); @@ -4631,7 +4641,7 @@ async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_b "model": "supervisor-autonomous-upstream-400-model", "apiKind": "openai_chat", "stream": false, - "maxRetries": 0, + "maxRetries": 2, "retryBackoffMs": 1 }} }} @@ -4676,10 +4686,7 @@ async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_b .expect("initial autonomous upstream 400 request"); assert_eq!(waiting.error_kind, "upstream-400"); assert_eq!(waiting.next_attempt, 1); - assert_eq!( - waiting.max_retries, - AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT - ); + assert_eq!(waiting.max_retries, 2); provider_retry::force_provider_retry_due_for_test_at(&root, &waiting.identity) .expect("force autonomous upstream 400 retry due"); @@ -4725,10 +4732,7 @@ async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_b .collect::>(); assert_eq!(retry_audits.len(), 1); assert_eq!(retry_audits[0]["errorKind"], "upstream-400"); - assert_eq!( - retry_audits[0]["maxRetries"], - AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT - ); + assert_eq!(retry_audits[0]["maxRetries"], 2); let lifecycle = records .iter() .filter(|record| { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 0be34b555..ccd014d82 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3,12 +3,19 @@ > 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。 > 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。 +## 2026-09-18 Provider 瞬态重试次数严格按设置执行(游戏开发 Agent 与策划 Agent 不再被档位收进区间) + +- 背景:AGC 客户端此前把 `agentLlm..maxRetries` 按运行档位重新收进固定区间——`autonomous-game-build` 档位(自主构建的游戏开发 Agent 及其继承档位的专业子 Agent)被抬到 12~16,`standard` 档位(含立项策划入口的策划 Agent 与普通 Agent 对话)被压到最多 3;瞬态分类里的上游 400 还在同一预算上再收窄到 2 次。现场把 `maxRetries` 设成 5 时,游戏开发与策划两条链路都不按设置执行。 +- 决策:删除这三处区间限制,`maxRetries` 严格等于允许的物理重试次数(显式 `0` 仍表示不重试)。运行档位只保留「上游 400 是否算瞬态错误」的判定(autonomous 档位才重试 400),不再改写次数:`AGENT_RUNTIME_PROVIDER_TRANSIENT_RETRY_LIMIT`、`AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR`、`AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_LIMIT`、`AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT` 四个常量与 `game_creator_agent_runtime_provider_transient_max_retries_at` 一并删除,换成返回 `{max_retries, retry_upstream_400}` 的 `game_creator_agent_runtime_provider_transient_retry_policy_at`。 +- 边界:只改重试次数的来源。瞬态错误分类(`timeout / connectivity / transport / 408 / 429 / 5xx / empty-response / deserialize / stream-unavailable`,以及 autonomous 档位下的 400)、`retryBackoffMs` 指数退避(仍封顶 30s)、durable retry sidecar / lifecycle / `-transient-N` slot 身份、cancel / steer / goal 门禁、耗尽后的 reconciliation 口径与「泥点不足不重试」都不变;前端设置面板与 `agentLlm..maxRetries` 契约不变。 +- 验证方式:`provider_transient_retry_` 7 项中重写后的档位用例与 upstream-400 用例通过(断言 `maxRetries` 直取设置值、400 与其它瞬态共用同一预算),`provider_retry_` 其余 26/28 通过;该组 2 项(`provider_transient_retry_transport_failure_closes_then_stable_retry_succeeds`、`provider_transient_retry_backoff_is_exponential_and_capped_at_thirty_seconds`)与 `provider_retry_waiting_final_reply_*` 2 项在本机改动前后同为失败(`stash` 基线复跑确认,现象是等待自动重试唤醒超时)。本机串行全量套件另有既有环境失败(`tempfile::tempdir()` 归属校验、缺少 npm 构建产物、Windows 启动失败 MessageBox 阻塞 `startup_log_slot_fail_without_path...`);抽查其中 5 项在 `stash` 基线上同样失败,与本次改动无关。仓库 `cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。 +- 关联文档:[AI游戏创作智能体App实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)、[踩坑记录](pitfalls.md)。 + ## 2026-09-17 AGC 抠图提交使用远端画布项目身份 - 背景:AGC 已通过本地项目 ID 建立并持久化本地项目到主站远端画布项目的绑定,但 `agc_remove_background` 提交请求仍把本地 `manifest.project_id` 放入 `projectId`;`assetFolderId` 已使用远端素材目录 ID。主站因此按项目不存在或不属于当前账号返回 404,主站抠图和 BgFilter 本身均正常。 - 决策:抠图请求及工具回执统一使用 `prepare_external_canvas_generation_context` 返回的远端 `context.project_id`;本地 manifest 项目 ID 只用于绑定键和本地状态,不得作为主站业务请求的 `projectId`。 - 验证:客户端定向 Rust 测试、格式、编码和 diff 检查通过;未修改主站路由或 BgFilter。 - ## 2026-09-17 图集切分模式改为显式声明 - 决策:`sliceMode` 在图标图集生成入口成为必填字段且不保留任何默认值。省略、`null` 或空字符串必须在引用解析、定价、入队和 provider / OSS 副作用之前返回 `400`(`field=sliceMode`);`grid` 必须同时提供 `gridX`/`gridY`,`connected-components` 不得携带网格尺寸,二者矛盾同样在副作用前失败关闭。