From d155e303bdca2e308915ab2d64418a10344971ea Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 14 Aug 2026 17:44:46 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E5=AE=9E=E7=8E=B0AGC=E5=9B=9E=E8=BD=A6?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=88=9B=E5=BB=BA=E5=B7=A5=E4=BD=9C=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 首页普通回车自动分配系统文档目录下的唯一工作区并启动项目总控 保留加号按钮手动选择目录和Shift回车换行语义 统一Windows Linux与macOS默认项目路径解析并移除产品态tmp默认值 补充自动工作区安全测试 首页交互回归与技术方案说明 --- .../src-tauri/src/commands.rs | 79 +++++++++ .../src-tauri/src/main.rs | 2 + .../src-tauri/src/tests/project.rs | 44 +++++ apps/ai-game-creator-shell/src/App.tsx | 21 +++ .../src/app/constants.ts | 3 +- .../features/app-shell/ProjectCreation.tsx | 6 + .../features/app-shell/WorkspaceLauncher.tsx | 2 + .../app-shell/useDeveloperAgentPanel.ts | 21 +++ .../app-shell/useHomeProjectCreation.ts | 160 +++++++++++++----- .../home/components/RichInputArea/index.tsx | 29 +++- .../src/view/home/index.tsx | 34 +++- .../tests/appSurface/home.suite.ts | 68 +++++--- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 13 files changed, 391 insertions(+), 80 deletions(-) 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 498616bd4..9b3990d3b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1,5 +1,84 @@ use super::*; +const AUTOMATIC_PROJECTS_DIRECTORY_NAME: &str = "Genarrative GameAgent"; + +fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result { + app.path() + .document_dir() + .map(|documents_root| documents_root.join(AUTOMATIC_PROJECTS_DIRECTORY_NAME)) + .map_err(|error| format!("无法读取系统文档目录:{error}")) +} + +#[tauri::command] +pub(crate) fn get_default_local_game_project_path(app: tauri::AppHandle) -> Result { + Ok(automatic_local_game_projects_root(&app)? + .join("gameagent-new") + .to_string_lossy() + .into_owned()) +} + +pub(crate) fn create_automatic_local_game_project_at( + projects_root: &Path, +) -> Result { + if projects_root.as_os_str().is_empty() || !projects_root.is_absolute() { + return Err("自动工作区根目录必须是绝对路径".to_string()); + } + fs::create_dir_all(projects_root).map_err(|error| { + format!( + "创建自动工作区根目录失败:{}: {error}", + projects_root.display() + ) + })?; + let metadata = fs::symlink_metadata(projects_root).map_err(|error| { + format!( + "读取自动工作区根目录失败:{}: {error}", + projects_root.display() + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("自动工作区根目录必须是普通文件夹".to_string()); + } + + for _ in 0..16 { + let workspace_id = uuid::Uuid::new_v4().simple().to_string(); + let short_id = &workspace_id[..8]; + let project_name = format!("GameAgent 项目 {short_id}"); + let project_root = projects_root.join(format!("gameagent-{short_id}")); + match fs::create_dir(&project_root) { + Ok(()) => { + let result = (|| { + enforce_project_permission_policy(&project_root, "project.create")?; + let _lock = acquire_project_write_lock(&project_root, "project.create")?; + init_local_game_project_at( + &project_root, + &format!("gameagent-{workspace_id}"), + &project_name, + ) + })(); + if result.is_err() { + let _ = fs::remove_dir_all(&project_root); + } + return result; + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(format!( + "创建自动工作区失败:{}: {error}", + project_root.display() + )); + } + } + } + Err("自动工作区命名冲突,请重试".to_string()) +} + +#[tauri::command] +pub(crate) fn create_automatic_local_game_project( + app: tauri::AppHandle, +) -> Result { + create_automatic_local_game_project_at(&automatic_local_game_projects_root(&app)?) +} + #[tauri::command] pub(crate) fn init_local_game_project( project_path: String, 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 81681f082..f3646fcff 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2178,6 +2178,8 @@ fn main() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + create_automatic_local_game_project, + get_default_local_game_project_path, init_local_game_project, import_local_godot_project, is_local_project_directory_non_empty, 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 7a8b75405..e25481651 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 @@ -1264,6 +1264,50 @@ fn init_local_game_project_creates_manifest_and_dirs() { fs::remove_dir_all(root).ok(); } +#[test] +fn automatic_local_game_project_allocates_unique_initialized_workspaces() { + let projects_root = unique_project_path(); + + let first = create_automatic_local_game_project_at(&projects_root) + .expect("create first automatic workspace"); + let second = create_automatic_local_game_project_at(&projects_root) + .expect("create second automatic workspace"); + + assert_ne!(first.project_path, second.project_path); + for result in [first, second] { + let root = PathBuf::from(&result.project_path); + assert_eq!(root.parent(), Some(projects_root.as_path())); + assert!(root + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("gameagent-"))); + assert!(root.join(".agent/manifest.json").is_file()); + assert!(root.join("game/index.html").is_file()); + assert!(result.manifest.name.starts_with("GameAgent 项目 ")); + } + + fs::remove_dir_all(projects_root).ok(); +} + +#[cfg(unix)] +#[test] +fn automatic_local_game_project_rejects_symlinked_projects_root() { + use std::os::unix::fs::symlink; + + let container = unique_project_path(); + let target = container.join("target"); + let projects_root = container.join("projects"); + fs::create_dir_all(&target).expect("create automatic workspace target"); + symlink(&target, &projects_root).expect("symlink automatic workspace root"); + + let error = create_automatic_local_game_project_at(&projects_root) + .expect_err("symlinked automatic workspace root must fail"); + + assert!(error.contains("普通文件夹")); + assert!(fs::read_dir(&target).expect("read target").next().is_none()); + fs::remove_dir_all(container).ok(); +} + #[test] fn init_local_game_project_requires_absolute_path() { let error = init_local_game_project_at(Path::new("relative-game"), "project-1", "demo") diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 2dc8e129b..f85be17dc 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -622,6 +622,27 @@ export function App({ ); const localProjectPathRef = useRef(null); localProjectPathRef.current = localProject?.projectPath ?? null; + + useEffect(() => { + if (projectPath || initialProjectPath) { + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + return; + } + let active = true; + void invoke('get_default_local_game_project_path') + .then((defaultPath) => { + if (active && defaultPath.trim()) { + setProjectPath(defaultPath); + } + }) + .catch(() => undefined); + return () => { + active = false; + }; + }, [initialProjectPath, projectPath]); const manifestRefreshMountedRef = useRef(true); const manifestRefreshStatesRef = useRef( new Map< diff --git a/apps/ai-game-creator-shell/src/app/constants.ts b/apps/ai-game-creator-shell/src/app/constants.ts index 371ec89cf..fb7ea0daa 100644 --- a/apps/ai-game-creator-shell/src/app/constants.ts +++ b/apps/ai-game-creator-shell/src/app/constants.ts @@ -5,7 +5,8 @@ export const seedManifest = createGameCreationAppManifest( '未命名游戏原型', ); -export const defaultProjectPath = '/tmp/genarrative-ai-game-draft'; +export const defaultProjectPath = + import.meta.env.MODE === 'test' ? '/tmp/genarrative-ai-game-draft' : ''; export const AGENT_RUN_HISTORY_MAX_COUNT = 100; export const AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT = 20; export const AGENT_RUN_HISTORY_VISIBLE_STEP = 20; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx b/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx index 42baedf55..cac4d6120 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx @@ -1,4 +1,5 @@ import { FolderKanban } from 'lucide-react'; +import { useEffect } from 'react'; import { closeDialogOnEscape } from '../../app/dialogs'; import type { HomeProjectCreationController } from './useHomeProjectCreation'; @@ -13,6 +14,11 @@ export function ProjectsPage({ homeProject: HomeProjectCreationController; recentProjects: RecentProjectsController; }) { + const loadDefaultProjectPath = homeProject.loadDefaultProjectPath; + useEffect(() => { + void loadDefaultProjectPath(); + }, [loadDefaultProjectPath]); + return (
diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 2fab75181..3505303dc 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -63,6 +63,7 @@ export function WorkspaceLauncherShell({ setAgentResults: setActiveProjectAgentResults, resetLauncherHomeDraft, createHomeDraft, + createHomeDraftAutomatically, openProject, } = homeProject; const activeProjectContextRef = useRef(currentProjectContext); @@ -259,6 +260,7 @@ export function WorkspaceLauncherShell({ homeAgentModeItems={homeAgentModeItems} recentProjectRows={recentProjectRows} onCreateDraft={createHomeDraft} + onCreateDraftAutomatically={createHomeDraftAutomatically} onProjectsOpen={() => setLauncherView('projects')} onProjectOpen={(path) => { setProjectPath(path); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts b/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts index b71ccccb8..fd9ee4672 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts @@ -126,6 +126,27 @@ export function useDeveloperAgentPanel(launcherView: LauncherView) { agentChatRuntimeSyncConversationRef, } = state; + useEffect(() => { + if (launcherView !== 'agent-chat' || agentChatProjectPath) { + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + return; + } + let active = true; + void invoke('get_default_local_game_project_path') + .then((defaultPath) => { + if (active && defaultPath.trim()) { + setAgentChatProjectPath(defaultPath); + } + }) + .catch(() => undefined); + return () => { + active = false; + }; + }, [agentChatProjectPath, launcherView, setAgentChatProjectPath]); + useEffect(() => { setAgentChatRunSubmitMode('steer'); }, [ diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 70b61e872..4cb35ffc5 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -2,6 +2,7 @@ import { type Dispatch, type FormEvent, type SetStateAction, + useCallback, useState, } from 'react'; @@ -73,6 +74,26 @@ export function useHomeProjectCreation({ (state) => state.reset, ); + const loadDefaultProjectPath = useCallback(async () => { + if (projectPath) { + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + return; + } + try { + const defaultPath = await invoke( + 'get_default_local_game_project_path', + ); + if (defaultPath.trim()) { + setProjectPath((currentPath) => currentPath || defaultPath); + } + } catch { + // The user can still choose a directory manually. + } + }, [projectPath]); + function validateProjectPath(nextProjectPath: string) { const trimmedProjectPath = nextProjectPath.trim(); if (!trimmedProjectPath || !isAbsoluteProjectPath(trimmedProjectPath)) { @@ -153,6 +174,57 @@ export function useHomeProjectCreation({ return imported; } + async function enterCreatedHomeProject( + invoke: TauriInvoke, + result: InitLocalProjectResult, + mode: HomeAgentMode, + prompt: string, + attachments: HomeAttachmentDraft[], + ) { + const importedAttachments = await importHomeAttachments( + invoke, + result.projectPath, + attachments, + ); + const sessionId = await ensureProjectSupervisorActiveSessionId( + invoke, + result.projectPath, + ); + if (!sessionId) { + throw new Error('项目总控 Agent active Session 不可用'); + } + await submitProjectSupervisorRuntimeTask({ + invoke, + projectPath: result.projectPath, + sessionId, + prompt: buildHomeConversationContent(mode, prompt, attachments), + runtime: null, + runProfile: 'autonomous-game-build', + }); + enterProjectDevelopment({ + projectPath: result.projectPath, + projectName: + result.manifest.name || projectNameFromPath(result.projectPath), + projectKind: 'web', + manifest: result.manifest, + projectRevision: await readCurrentProjectRevision( + invoke, + result.projectPath, + ), + mode, + initialPrompt: + prompt.trim() || + (attachments.length > 0 + ? '用户上传了参考附件,等待后续补充需求。' + : ''), + attachments: importedAttachments, + recentRunStatus: null, + recentRunStopReason: null, + createdAt: Date.now(), + }); + resetLauncherHomeDraft(); + } + async function createHomeProjectFromDirectory( nextProjectPath: string, mode: HomeAgentMode, @@ -196,27 +268,14 @@ export function useHomeProjectCreation({ name: projectNameFromPath(trimmedProjectPath), }, ); - const importedAttachments = await importHomeAttachments( - invoke, - result.projectPath, - attachments, - ); try { - const sessionId = await ensureProjectSupervisorActiveSessionId( + await enterCreatedHomeProject( invoke, - result.projectPath, + result, + mode, + prompt, + attachments, ); - if (!sessionId) { - throw new Error('项目总控 Agent active Session 不可用'); - } - await submitProjectSupervisorRuntimeTask({ - invoke, - projectPath: result.projectPath, - sessionId, - prompt: buildHomeConversationContent(mode, prompt, attachments), - runtime: null, - runProfile: 'autonomous-game-build', - }); setStatus('已创建项目并交给项目总控 Agent'); } catch (error) { throw new Error( @@ -225,28 +284,6 @@ export function useHomeProjectCreation({ }`, ); } - enterProjectDevelopment({ - projectPath: result.projectPath, - projectName: - result.manifest.name || projectNameFromPath(result.projectPath), - projectKind: 'web', - manifest: result.manifest, - projectRevision: await readCurrentProjectRevision( - invoke, - result.projectPath, - ), - mode, - initialPrompt: - prompt.trim() || - (attachments.length > 0 - ? '用户上传了参考附件,等待后续补充需求。' - : ''), - attachments: importedAttachments, - recentRunStatus: null, - recentRunStopReason: null, - createdAt: Date.now(), - }); - resetLauncherHomeDraft(); return '已创建项目并进入项目开发'; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -255,6 +292,30 @@ export function useHomeProjectCreation({ } } + async function finishAutomaticallyCreatedHomeProject( + result: InitLocalProjectResult, + mode: HomeAgentMode, + prompt: string, + attachments: HomeAttachmentDraft[], + ) { + const invoke = resolveTauriInvoke(); + if (!invoke) { + throw new Error('需要在 Tauri App 内运行'); + } + setStatus('正在启动项目总控 Agent'); + try { + await enterCreatedHomeProject(invoke, result, mode, prompt, attachments); + setStatus('已自动创建工作区并开始工作'); + return '已自动创建工作区并开始工作'; + } catch (error) { + const message = `工作区已创建;首条需求投递失败:${ + error instanceof Error ? error.message : String(error) + }`; + setStatus(message); + throw new Error(message); + } + } + async function createProjectFromProjectPage( nextProjectPath: string, skipNonEmptyCheck = false, @@ -437,6 +498,23 @@ export function useHomeProjectCreation({ ); } + async function createHomeDraftAutomatically(draft: HomeDraft) { + const invoke = resolveTauriInvoke(); + if (!invoke) { + throw new Error('需要在 Tauri App 内运行'); + } + setStatus('正在创建工作区'); + const result = await invoke( + 'create_automatic_local_game_project', + ); + return finishAutomaticallyCreatedHomeProject( + result, + draft.mode, + draft.prompt, + draft.attachments, + ); + } + async function handlePickProjectDirectory() { const invoke = resolveTauriInvoke(); if (!invoke) { @@ -540,7 +618,9 @@ export function useHomeProjectCreation({ setAgentResults, pendingNonEmptyProject, resetLauncherHomeDraft, + loadDefaultProjectPath, createHomeDraft, + createHomeDraftAutomatically, openProject, openGodotProject, handleSubmit, diff --git a/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/index.tsx b/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/index.tsx index f8a36c53e..4c45a8d75 100644 --- a/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/index.tsx +++ b/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/index.tsx @@ -13,6 +13,7 @@ import { COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_HIGH, createCommand, + KEY_ENTER_COMMAND, type LexicalCommand, PASTE_COMMAND, } from 'lexical'; @@ -20,15 +21,13 @@ import { Upload } from 'lucide-react'; import React, { useEffect, useRef } from 'react'; import type { Draft, HomeAttachmentDraft } from '../../useHomeDraftStore'; -import { - $createAttachmentNode, - AttachmentNode, -} from './attachmentNode'; +import { $createAttachmentNode, AttachmentNode } from './attachmentNode'; type RichInputAreaProps = { value: Draft; placeholder: string; onChange: (value: Draft) => void; + onEnter: () => void; children?: React.ReactNode; }; const INSERT_ATTACHMENTS_COMMAND: LexicalCommand = @@ -104,9 +103,27 @@ function selectEditableEndWhenNeeded() { function EditorPlugins({ onChange, -}: Pick) { + onEnter, +}: Pick) { const [editor] = useLexicalComposerContext(); + useEffect( + () => + editor.registerCommand( + KEY_ENTER_COMMAND, + (event) => { + if (!event || event.shiftKey || event.isComposing) { + return false; + } + event.preventDefault(); + onEnter(); + return true; + }, + COMMAND_PRIORITY_HIGH, + ), + [editor, onEnter], + ); + useEffect( () => editor.registerCommand( @@ -239,7 +256,7 @@ export default function RichInputArea(props: RichInputAreaProps) { /> {props.children} - + ); } diff --git a/apps/ai-game-creator-shell/src/view/home/index.tsx b/apps/ai-game-creator-shell/src/view/home/index.tsx index 768674a36..e39b52162 100644 --- a/apps/ai-game-creator-shell/src/view/home/index.tsx +++ b/apps/ai-game-creator-shell/src/view/home/index.tsx @@ -5,12 +5,10 @@ import { Plus, Sparkles, } from 'lucide-react'; -import type { - FormEvent, -} from 'react'; -import { useState } from 'react'; +import type { FormEvent } from 'react'; +import { useRef, useState } from 'react'; -import BRAND_ICON from '../../../../../packages/shared/src/icons/taonier-product-ip.png' +import BRAND_ICON from '../../../../../packages/shared/src/icons/taonier-product-ip.png'; import RichInputArea, { UploadButton } from './components/RichInputArea'; import { richTextToAttachments, @@ -49,6 +47,7 @@ type HomeViewProps = { homeAgentModeItems: readonly HomeAgentModeItem[]; recentProjectRows: readonly HomeProjectRow[]; onCreateDraft: (draft: HomeDraft) => Promise; + onCreateDraftAutomatically: (draft: HomeDraft) => Promise; onProjectsOpen: () => void; onProjectOpen: (path: string) => void; onGodotProjectOpen: () => void; @@ -61,6 +60,7 @@ export default function HomeView({ homeAgentModeItems, recentProjectRows, onCreateDraft, + onCreateDraftAutomatically, onProjectsOpen, onProjectOpen, onGodotProjectOpen, @@ -72,6 +72,7 @@ export default function HomeView({ (state) => state.setRichText, ); const [homeCreationBusy, setHomeCreationBusy] = useState(false); + const homeCreationBusyRef = useRef(false); const activeHomeMode = homeAgentModeItems.find((item) => item.mode === homeAgentMode) ?? homeAgentModeItems[0]; @@ -80,8 +81,13 @@ export default function HomeView({ return null; } - async function handleHomeSubmit(event: FormEvent) { - event.preventDefault(); + async function createFromHome( + createDraft: (draft: HomeDraft) => Promise, + pendingStatus: string, + ) { + if (homeCreationBusyRef.current) { + return; + } const referencedAttachments = richTextToAttachments(homeRichText); const prompt = richTextToPrompt(homeRichText); if (!prompt) { @@ -91,11 +97,12 @@ export default function HomeView({ ); return; } + homeCreationBusyRef.current = true; setHomeCreationBusy(true); - onStatusChange('请选择项目目录'); + onStatusChange(pendingStatus); try { onStatusChange( - await onCreateDraft({ + await createDraft({ mode: homeAgentMode, prompt, attachments: referencedAttachments, @@ -104,10 +111,16 @@ export default function HomeView({ } catch (error) { onStatusChange(error instanceof Error ? error.message : String(error)); } finally { + homeCreationBusyRef.current = false; setHomeCreationBusy(false); } } + function handleHomeSubmit(event: FormEvent) { + event.preventDefault(); + void createFromHome(onCreateDraft, '请选择项目目录'); + } + return (
{ + void createFromHome(onCreateDraftAutomatically, '正在创建工作区'); + }} >
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 58e090dfd..f4046af9b 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1240,27 +1240,23 @@ export function registerHomeProjectCreationTests() { expect(screen.getAllByText('已取消')).not.toHaveLength(0); }); - it('creates a project from home and sends the first requirement once to the active Project Supervisor Session', async () => { + it('creates an automatic workspace on Enter and sends the first requirement once to the active Project Supervisor Session', async () => { + const automaticProjectPath = + 'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\gameagent-a1b2c3d4'; const manifest = createGameCreationAppManifest( 'local-project-draft', 'home-created-game', ); const supervisorHarness = createProjectSupervisorRuntimeHarness({ - projectPath: '/tmp/home-created-game', + projectPath: automaticProjectPath, initialSessionExists: false, }); const invoke = vi.fn( async (command: string, args?: Record) => { - if (command === 'pick_local_project_directory') { - return '/tmp/home-created-game'; - } - if (command === 'is_local_project_directory_non_empty') { - return false; - } - if (command === 'init_local_game_project') { + if (command === 'create_automatic_local_game_project') { return { - projectPath: String(args?.projectPath ?? ''), - manifestPath: `${String(args?.projectPath ?? '')}/.agent/manifest.json`, + projectPath: automaticProjectPath, + manifestPath: `${automaticProjectPath}\\.agent\\manifest.json`, manifest, }; } @@ -1268,8 +1264,8 @@ export function registerHomeProjectCreationTests() { return { id: 'asset-upload-1', localPath: 'assets/uploads/reference.png', - absolutePath: '/tmp/home-created-game/assets/uploads/reference.png', - manifestPath: '/tmp/home-created-game/.agent/manifest.json', + absolutePath: `${automaticProjectPath}\\assets\\uploads\\reference.png`, + manifestPath: `${automaticProjectPath}\\.agent\\manifest.json`, }; } return supervisorHarness.invoke(command, args); @@ -1307,7 +1303,28 @@ export function registerHomeProjectCreationTests() { screen.getByRole('img', { name: 'reference.png' }).getAttribute('src'), ).toBe('blob:mock-attachment-preview'); - fireEvent.click(screen.getByRole('button', { name: '开启创作' })); + fireEvent.keyDown(promptInput, { + key: 'Enter', + code: 'Enter', + shiftKey: true, + }); + fireEvent.keyDown(promptInput, { + key: 'Enter', + code: 'Enter', + isComposing: true, + }); + expect(invoke).not.toHaveBeenCalledWith( + 'create_automatic_local_game_project', + ); + + fireEvent.keyDown(promptInput, { + key: 'Enter', + code: 'Enter', + }); + fireEvent.keyDown(promptInput, { + key: 'Enter', + code: 'Enter', + }); expect(await screen.findByLabelText('项目总控对话')).not.toBeNull(); expect(screen.getByLabelText('项目总控消息').textContent).toContain( @@ -1321,23 +1338,28 @@ export function registerHomeProjectCreationTests() { name: '打开资源详情:美术资源 reference.png', }), ).not.toBeNull(); - expect(invoke).toHaveBeenCalledWith('init_local_game_project', { - projectPath: '/tmp/home-created-game', - projectId: 'local-project-draft', - name: 'home-created-game', - }); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'create_automatic_local_game_project', + ), + ).toHaveLength(1); + expect(invoke).not.toHaveBeenCalledWith('pick_local_project_directory'); + expect(invoke).not.toHaveBeenCalledWith( + 'is_local_project_directory_non_empty', + expect.anything(), + ); expect(invoke).toHaveBeenCalledWith('upload_local_asset', { - projectPath: '/tmp/home-created-game', + projectPath: automaticProjectPath, fileName: 'reference.png', mediaType: 'image/png', bytes: fileBytes, }); expect(invoke).toHaveBeenCalledWith('list_game_creator_agent_sessions', { - projectPath: '/tmp/home-created-game', + projectPath: automaticProjectPath, agentId: 'project-supervisor', }); expect(invoke).toHaveBeenCalledWith('create_game_creator_agent_session', { - projectPath: '/tmp/home-created-game', + projectPath: automaticProjectPath, agentId: 'project-supervisor', title: '项目总控', }); @@ -1346,7 +1368,7 @@ export function registerHomeProjectCreationTests() { ); expect(startCalls).toHaveLength(1); expect(startCalls[0]?.[1]).toMatchObject({ - projectPath: '/tmp/home-created-game', + projectPath: automaticProjectPath, sessionId: supervisorHarness.sessionId, task: expect.stringContaining('初始意图:art / 做素材'), runId: expect.stringMatching(/^project-supervisor-task-/), diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 300e30c82..af8ef4d7b 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -572,7 +572,7 @@ game-project/ - 短期记忆、长期记忆、项目黑板和角色私有记忆按授权本地项目路径读写;普通用户仍只通过聊天命令访问短期 / 长期 / 黑板记忆,角色私有记忆只在单 agent 对话和生成 loop 中按目标 agent 读取。 - 结构化对话记录按授权本地项目路径追加 JSONL;正式聊天读取 Supervisor active Session 与只读 legacy 项目历史,开发单 Agent 对话读取对应 Agent Session。开发入口已支持本地 Session 新建、切换、归档和分叉,但不提供云端同步。 - Agent 状态列表从 `.agent/manifest.json` 的任务 / 角色清单、`.agent/run.latest.json` / `.agent/runs/.json` 的 step、taskGraph、passPlans、lifecycleStatus,以及 `read_game_creator_agent_runtimes` 批量读取的 `.agent/runtime/agents/.json` 和最近任务派生;v1 不新增独立状态数据库,也不承诺完整后台 runner。 -- App 启动先检查平台登录态;登录后进入同一个客户端首页,不再有面向用户的启动器 / 主窗口切换概念。首页按 `做游戏` / `做素材` / `做方案` 保存 `game` / `art` / `doc` 初始意图,输入状态按文字与附件 token 的顺序保存,附件以文件名 token 内嵌在输入框中而非堆叠在下方;提交时才将 token 转为 LLM 可读的 `{1st attachment}` 引用。发送时弹出原生目录选择,目标目录存在且非空时必须二次确认;确认后调用 `init_local_game_project` 初始化本地项目、`upload_local_asset` 导入附件,再把首条需求直接投递给 active `project-supervisor` Session 的后台 Runtime,写入最近项目并切到项目开发页。缺少 active Session 时先创建并激活;成功后清空首页草稿,取消或创建失败时在首页回显状态,首页响应式断点与应用外壳统一为 `760px`。本流程不调用 `generate_local_game_draft`、`generate_platform_art_asset`、一次性 `chat_with_game_creator_agent` 或 legacy 项目对话 append。 +- App 启动先检查平台登录态;登录后进入同一个客户端首页,不再有面向用户的启动器 / 主窗口切换概念。首页按 `做游戏` / `做素材` / `做方案` 保存 `game` / `art` / `doc` 初始意图,输入状态按文字与附件 token 的顺序保存,附件以文件名 token 内嵌在输入框中而非堆叠在下方;提交时才将 token 转为 LLM 可读的 `{1st attachment}` 引用。普通 `Enter` 在输入法组合态结束后自动在系统“文档/Genarrative GameAgent”下原子分配唯一工作区、初始化本地项目、导入附件,并把首条需求直接投递给 active `project-supervisor` Session 的后台 Runtime,随后写入最近项目并切到项目开发页;`Shift+Enter` 保留换行。Windows 使用系统 Documents 路径,Linux 使用 XDG Documents,macOS 使用用户 Documents;前端不得显示或持久化 `/tmp` 作为默认项目路径。原“开启创作”加号按钮保持手动选择目录:点击后弹出原生目录选择,目标目录存在且非空时必须二次确认,再调用 `init_local_game_project` 完成同一后续链路。缺少 active Session 时先创建并激活;成功后清空首页草稿,取消或创建失败时在首页回显状态,首页响应式断点与应用外壳统一为 `760px`。两条流程都不调用 `generate_local_game_draft`、`generate_platform_art_asset`、一次性 `chat_with_game_creator_agent` 或 legacy 项目对话 append。 - 首页发送、项目组目录选择和本地文件选择必须使用 Tauri 非阻塞原生 picker,并把选择器绑定到当前 `client` 窗口;禁止在同步 command 中调用 `blocking_pick_folder` / `blocking_pick_file` 阻塞 WebView 事件循环。选择器打开期间保留首页草稿可编辑,取消后恢复“开启创作”按钮并回显“已取消”。 - 当前尚未定义 GameAgent 独立的灵感数据源;首页保留“灵感推荐”区块并显示无数据状态,但不得展示或请求主站 `/creation` 的陶泥儿精选 `/api/editor/showcase/resources`。后续接入前必须先明确独立数据契约和交互验收。 - debug 构建启动后在用户 `client` 窗口之外额外打开 `developer` 窗口;该窗口用于开发者单独选择 Agent、管理对应 active/archived Session 并读取历史,用户消息和真实 Agent 回复只持久化到 `.agent/conversations/agents//` 下的规范 Session。普通用户窗口不得出现 `Agent 聊天` 导航、picker 或工具台入口。 From d901945c772c7460894ae275ad7ecde46463c0ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 14 Aug 2026 18:52:56 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E5=BF=AB=E9=80=9F=E7=BC=96=E8=BE=91=E6=94=AF=E6=8C=81=20(#157)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当前实现: 视频快速编辑错误地复用了图片快速编辑的panel, 然后发起一个实际是生成视频的请求 策划建议删除. 因为后端没有实际意义上的视频快速编辑, 所以api 后端代码没有改动, 只在前端删除入口和处理 --------- Co-authored-by: kdletters Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/157 Co-authored-by: 王德宇 Co-committed-by: 王德宇 --- .../src-tauri/src/tests/runtime_state.rs | 2 +- .../shared-memory/decision-log.md | 10 +- docs/project-memory/shared-memory/pitfalls.md | 4 +- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 30 +-- ...端架构】外部生成Worker化方案-2026-06-03.md | 2 +- ...】生成类面板Lovart统一改造方案-2026-06-17.md | 22 +- .../ImageCanvasContextMenusView.test.tsx | 5 + .../ImageCanvasGenerationComposerView.tsx | 1 - .../ImageCanvasGenerationModel.test.ts | 9 +- .../ImageCanvasGenerationModel.ts | 2 - ...ageCanvasGenerationSubmissionModel.test.ts | 24 ++ .../ImageCanvasGenerationSubmissionModel.ts | 4 + .../ImageCanvasQuickEditPanelView.test.tsx | 19 -- .../ImageCanvasQuickEditPanelView.tsx | 6 +- ...ageCanvasSelectedLayerToolbarView.test.tsx | 2 +- ...anvasGenerationSubmissionWorkflow.test.tsx | 128 ++++------- ...ImageCanvasGenerationSubmissionWorkflow.ts | 207 ++---------------- .../useImageCanvasGenerationWorkflow.test.tsx | 22 ++ .../useImageCanvasGenerationWorkflow.ts | 2 - 19 files changed, 160 insertions(+), 341 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs index e6908bf47..d77a1e92c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs @@ -2593,7 +2593,7 @@ async fn runtime_v11_closure_repository_context_drift_replans_before_auto_mutati } assert_eq!( fs::read_to_string(root.join("AGENTS.md")).expect("read drifted rules"), - "drifted rules\\n" + "drifted rules\n" ); fs::remove_dir_all(root).ok(); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1c69eb86c..3cea9b74a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6821,7 +6821,7 @@ - 决策:用户动作 `改造` 的语义是恢复原生成输入、编辑并生成新产物。沿用 `generation_inputs_json`,V2 以稳定 `action`、`fields[].id`、`references[].id/refType/refId` 作为唯一执行契约,`title` / `label` 只用于展示,字段值保留基础类型。引用只匹配当前已 hydrate 的画布图层,媒体类型取匹配图层的运行时数据,不重复写入快照,也不新增 owner-only 工程资源 / 素材库 resolver。面板直接上传引用和已移出画布的引用不恢复:可重新选择的槽位留空并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的 action 仍按 capability 保留改造按钮,source 缺失时点击后显示明确错误并拒绝改造,运行期来源变化时仍必须复检。有效 V2 不因引用缺失降级到 legacy adapter。提交前参数只归一一次,请求与持久快照共用同一归一值。 - 兼容:恢复优先级为有效 V2 → 完整历史生成对话框 → legacy adapter。legacy 允许使用 `assetKind/mediaType`、历史标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 和当前默认值,但必须显示恢复告警;不回填存量数据,不做 SpacetimeDB schema 迁移。 - 边界:独立裁扩、手动去背景和手动图集拆分结果不继承生成输入,不显示 `改造`;原生成任务内自动后处理产物可保留原输入。Owner 读取保留 V2 执行字段;匿名公开素材 payload 暂不返回 `generationInputs`,不沿用 owner 可执行配方 DTO。 -- 影响范围:图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作、生成型图片编辑和 UI 素材提取;不影响作品详情“作品改造”、`AI重绘` 或常规 `快速编辑`。 +- 影响范围:图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作和 UI 素材提取;`image.edit` 只保留为历史已知配方读取,不提供改造;不影响作品详情“作品改造”、`AI重绘` 或常规 `快速编辑`。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`。 ## 2026-08-05 画布图层元数据以资源行为准,读边界补齐 sourceType @@ -13789,14 +13789,16 @@ ## 2026-08-04 图片画布快速编辑改用统一正向白名单(2026-08-05 修订) - 背景:画布生成结果统一显示快速编辑,但部分角色动作 / 序列帧和音频结果会进入不受支持的图片编辑链路;不同入口各自判断时也容易继续漂移。 -- 决策:快速编辑只支持普通静态图片、角色图、规范图、完整图标图集、图标规范、UI 设计图、宣发图、游戏场景图和视频。单个拆分图标、角色动作 / 序列帧、音效与背景音乐不支持;新增媒体或素材类型默认不开放。浮动工具栏、图层右键菜单、独立图片菜单、打开面板入口和提交门禁统一调用同一个正向白名单;后端图片编辑 BFF 基于目标图层的有效素材类型与媒体类型执行同一正向门禁,视频快速编辑只走视频生成接口。 +- 决策:快速编辑只支持普通静态图片、角色图、规范图、完整图标图集、图标规范、UI 设计图、宣发图和游戏场景图。视频、单个拆分图标、角色动作 / 序列帧、音效与背景音乐不支持;新增媒体或素材类型默认不开放。浮动工具栏、图层右键菜单、独立图片菜单、打开面板入口和提交门禁统一调用同一个正向白名单;后端图片编辑 BFF 基于目标图层的有效素材类型与媒体类型执行同一正向门禁。 - 边界:角色动作继续通过对应的动作生成链路处理,不再把当前帧当作可快速编辑图片。 - 2026-08-06 修订:画布 Agent 的 `edit_image` 只接受图片输入,新任务以 `assetKind=null` 表示普通静态图片,不再使用 synthetic `editor_agent_edit_image`。worker 仅按服务端生成的 `editor-agent:` dedupe namespace 识别并归一历史排队 payload;普通调用伪造旧值继续被拒绝。已持久化资源中的旧值只有在后端从真实目标图层 / 项目资源解析后才兼容为空类型,避免历史 Agent 结果失去快速编辑能力,同时不扩大请求白名单。 - 2026-08-07 修订:站内与 External v1 图片编辑请求统一只接受必填 `sourceReferenceId`,且该值必须是当前账号已登记的项目资源 ID 或素材 ID;objectKey、URL、Data URL、Blob URL 以及旧 `sourceImageSrc/sourceResourceId/assetKind` 字段全部返回 400,不提供兼容别名。后端用共享窄查询分别按两张表主键定点解析,双表同 ID、未命中、跨账号、对象缺失或越权均失败关闭;权威类型完全来自业务记录,只允许普通静态图片、规范图、角色图、完整图标图集、图标规范、宣发图和 UI 设计图。请求带 `targetLayerId` 时必须同时带 `projectId`,来源与目标优先比较 `assetObjectId`,任一方缺失才比较 canonical `(bucket, objectKey)`,且来源默认类型必须与目标资源默认类型一致;最终类型取目标覆盖值或目标资源类型。HTTP 入队写入版本化服务端解析快照,worker 执行前按同一业务 ID 再次定点解析,身份或类型漂移即失败关闭。旧任务只把已有资源 ID 或旧来源字符串本身当业务 ID 迁移,绝不按 objectKey 反查。Canvas Agent 必须从 `ImageMetadata.reference_id` 取主来源;红框标注上传图只作为辅助 `referenceImageSrcs`,不能冒充被编辑资源。仅以素材 ID 编辑时,队列审计与 `generationInputs.references` 保留素材 ID,不伪造项目资源关系。 - 2026-08-08 修订:`scene` 是单张静态图片素材,加入前端快速编辑正向白名单和 api-server 权威来源白名单;编辑结果继续保留 `scene`。用户标签覆盖侧的前端菜单与 SpacetimeDB 结构化布局白名单也必须显式覆盖 `scene`。通用图片生成接口仍拒绝 `scene`,避免绕过结构化场景生成契约。 - 2026-08-08 修订:画布 Agent 的 `edit_image` 工具内部可先形成待确认的 `EditorImageEditRequest`,但确认接口必须在通用入队前复用站内图片编辑的来源解析与目标预检,写入 `{ version, request, source }` 服务端快照。worker 只解析正式 versioned payload 与既有历史 payload,不接受当前 direct request 作为 fallback;这样未上线的 Agent 路径在生产端原位修正,不扩大消费端协议。 - 2026-08-08 修订:图片编辑业务引用按 `objectKey` 找到权威 `asset_object` 后,若资源或素材记录同时保存了 `assetObjectId`,必须验证两者指向同一对象;不一致时在 SpacetimeDB resolver 边界失败关闭,不能把未验证的记录 ID 与已验证的对象路径组合进 snapshot。缺少 ID 的历史记录继续以 canonical `(bucket, objectKey)` 作为对象身份。 -- 验证:模型测试覆盖允许与拒绝类型,工具栏和两类右键菜单测试覆盖单个拆分图标、角色动作及音频不展示,提交工作流测试覆盖单个拆分图标和角色动作绕过入口时仍拒绝;后端表驱动测试覆盖全部现役素材 / 媒体类型与未知类型,锁定图片编辑端点失败关闭。 +- 2026-08-10 永久修订:画布视频快速编辑永久下线,上传、生成和历史视频图层均不再展示入口,程序化打开与直接提交同样失败关闭;不设置 feature flag、兼容桥或数据迁移。普通视频生成、视频“改造”、下载、`referenceVideoSrcs` 以及 V2 `videoReference` 恢复保持不变。External v1 从未正式声明“视频快速编辑”,只提供带可选 `referenceVideoSrcs` 的通用视频生成,因此不修改 `/api/external/v1` 路由、DTO、异步语义或 OpenAPI。当前服务已完全停止且无在途 / 可重试任务,不新增旧任务收口逻辑。 +- 2026-08-14 改造边界澄清:`image.edit` 是图片快速编辑通过 `targetLayerId` 原位替换后的配方记录,只属于已知 action,不属于可改造 action;结果图继续显示快速编辑,不显示改造。其它可改造生成产物按原 `action` 恢复对应 generation dialog 并生成新产物,不恢复旧 quick-edit 改造面板,也不额外承诺提交成功后保持面板打开。 +- 验证:模型测试覆盖允许与拒绝类型,工具栏和两类右键菜单覆盖视频、单个拆分图标、角色动作及音频不展示,打开与提交工作流覆盖视频等不支持类型绕过入口时仍拒绝;后端表驱动测试覆盖全部现役图片素材 / 媒体类型与未知类型,锁定图片编辑端点失败关闭。 - 关联:`src/components/image-editor/ImageCanvasGenerationModel.ts`、`ImageCanvasSelectedLayerToolbarView.tsx`、`ImageCanvasContextMenusView.tsx`、`useImageCanvasGenerationWorkflow.ts`、`useImageCanvasGenerationSubmissionWorkflow.ts`。 ## 2026-08-05 game-chat Canvas 区间证明与 parent wake 持久收口 @@ -13836,7 +13838,7 @@ - 决策:用户动作 `改造` 的语义是恢复原生成输入、编辑并生成新产物。沿用 `generation_inputs_json`,V2 以稳定 `action`、`fields[].id`、`references[].id/refType/refId` 作为唯一执行契约,`title` / `label` 只用于展示,字段值保留基础类型。引用只匹配当前已 hydrate 的画布图层,媒体类型取匹配图层的运行时数据,不重复写入快照,也不新增 owner-only 工程资源 / 素材库 resolver。面板直接上传引用和已移出画布的引用不恢复:可重新选择的槽位留空并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的 action 仍按 capability 保留改造按钮,source 缺失时点击后显示明确错误并拒绝改造,运行期来源变化时仍必须复检。有效 V2 不因引用缺失降级到 legacy adapter。提交前参数只归一一次,请求与持久快照共用同一归一值。 - 兼容:恢复优先级为有效 V2 → 完整历史生成对话框 → legacy adapter。legacy 允许使用 `assetKind/mediaType`、历史标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 和当前默认值,但必须显示恢复告警;不回填存量数据,不做 SpacetimeDB schema 迁移。 - 边界:独立裁扩、手动去背景和手动图集拆分结果不继承生成输入,不显示 `改造`;原生成任务内自动后处理产物可保留原输入。Owner 读取保留 V2 执行字段;匿名公开素材 payload 暂不返回 `generationInputs`,不沿用 owner 可执行配方 DTO。 -- 影响范围:图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作、生成型图片编辑和 UI 素材提取;不影响作品详情“作品改造”、`AI重绘` 或常规 `快速编辑`。 +- 影响范围:图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作和 UI 素材提取;`image.edit` 只保留为历史已知配方读取,不提供改造;不影响作品详情“作品改造”、`AI重绘` 或常规 `快速编辑`。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`。 ## 2026-08-05 画布图层元数据以资源行为准,读边界补齐 sourceType diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 94be7941e..1123d1c11 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -844,11 +844,11 @@ - 验证:`npm run test -- src/components/image-editor/ImageCanvasOverlayModel.test.ts src/components/image-editor/useImageCanvasGenerationSurface.test.tsx`。 - 关联:`src/components/image-editor/ImageCanvasOverlayModel.ts`、`src/components/image-editor/useImageCanvasGenerationSurface.tsx`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。 -## 图片画布重绘创建独立占位,快速编辑不要新建生成器 +## 图片画布改造按原 action 生成新产物,快速编辑不要新建生成器 - 现象:用户点击图片素材的“快速编辑”后,画布上额外出现 `Quick Edit Generator` 占位,像是新建了一个生成器;但用户预期是在原图下方框选区域、填写一个提示词和模型,然后直接修改当前图。 - 原因:快速编辑入口和提交链路误用了 `createQuickEditGenerationDialogDraft(...)` / `CanvasGenerationDialogState`,把“覆盖源图”的快速编辑伪装成会产出新图层的生成器占位。 -- 处理:图片快速编辑必须走 `QuickEditPanelState`,打开时归档当前 active generation dialog 但不创建新的 `mode="quick-edit"` dialog;提交时调用 `/api/editor/images/edits`,主来源始终使用当前图片已登记的 `resourceId` 或 `sourceAssetId`。带编号标注的图片上传后只作为辅助 `referenceImageSrcs`,不能替换主来源身份;成功后覆盖源图,失败时保留快速编辑面板。快速编辑任务进入 `generating` 后必须移除框选工具和覆盖层,禁止继续新增框选;失败恢复面板后可继续调整框选再重试。图片重绘、去背景、视频快速编辑等会产出新图层或异步占位的入口仍可走 generation dialog / placement 链路。 +- 处理:图片快速编辑必须走 `QuickEditPanelState`,打开时归档当前 active generation dialog 但不创建新的 `mode="quick-edit"` dialog;提交时调用 `/api/editor/images/edits`,主来源始终使用当前图片已登记的 `resourceId` 或 `sourceAssetId`。带编号标注的图片上传后只作为辅助 `referenceImageSrcs`,不能替换主来源身份;成功后覆盖源图,失败时保留快速编辑面板。快速编辑任务进入 `generating` 后必须移除框选工具和覆盖层,禁止继续新增框选;失败恢复面板后可继续调整框选再重试。`image.edit` 结果不允许改造;其它可改造产物按原 `action` 恢复 generation dialog 并生成新产物,去背景等异步入口继续使用各自现役 dialog / placement 链路,不恢复独立 quick-edit 改造面板。 - 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx -- --runInBand`,以及按需运行 `npm run test -- src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx -t "快速编辑|quick edit" -- --runInBand`。 - 关联:`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/components/image-editor/useImageCanvasGenerationWorkflow.ts`、`src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/services/image-editor/editorImageReference.ts`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 5c9479dc3..db422cec6 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -18,11 +18,13 @@ - 默认工具为选择模式;底部工具栏采用 AI 画布工作流工具组:选择、抓手、上传、生成图片、生成视频、生成音乐、生成规范、生成角色形象、生成图标素材、生成 UI 设计图。底部栏不再展示文字工具、形状标注工具和导出工具;上传与生成图片之间、生成音乐与生成规范之间各有一个半图标高度分割线。 - 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。 - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 -- 快速编辑使用统一正向白名单:只支持普通静态图片、角色图、规范图、完整图标图集、图标规范、UI 设计图、宣发图、游戏场景图和视频;单个拆分图标、角色动作 / 序列帧、音效与背景音乐不支持。浮动工具栏、图层右键菜单、独立图片菜单、打开面板入口和提交门禁必须复用同一白名单,新增媒体或素材类型默认不开放。图片编辑请求只提交必填 `sourceReferenceId`,取正式 `resourceId ?? sourceAssetId`;没有正式业务 ID 的本地图层、生成对话临时图层或未登记上传图必须先走既有资源登记,否则前端失败关闭。objectKey、URL、Data URL、Blob URL 不得作为主来源身份,旧 `sourceImageSrc/sourceResourceId/assetKind` 也不得发送。带红框的快速编辑仍以原图业务 ID 为主来源,上传后的编号标注图只放入辅助 `referenceImageSrcs`。`/api/editor/images/edits` 入队前通过当前账号窄查询解析来源对象与权威类型;带 `targetLayerId` 时必须同时带 `projectId`,目标图层须关联有效项目资源,双方对象和默认类型必须一致,最终有效类型取目标覆盖值或目标资源类型。来源默认类型只用于绑定一致性,不得在应用同媒体族标签覆盖前提前拒绝;因此默认 `icon` 且无覆盖时仍禁止快速编辑,但同一图片图层覆盖为 `scene` 或 `spec` 后必须按覆盖类型放行。视频快速编辑只能走视频生成接口,角色动作只能走对应动作生成链路。画布 Agent 的 `edit_image` 必须从 `ImageMetadata.reference_id` 获取项目资源 ID 或素材 ID,缺失时拒绝创建任务,不得用 `image_data_key` 或 objectKey 代替。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 -- 生成类产物的工具栏动作 `改造` 不在原产物上原地修改,而是把当次生成输入恢复到对应面板,允许编辑后生成新产物。按钮显隐只按 action capability 判断:确定性派生等不支持改造的 action 隐藏;支持改造的 action 即使参数损坏或必需来源丢失也保留按钮,点击后再显示准确错误,禁止用恢复状态静默隐藏入口。`image.edit` 明确不属于可改造 action:图片快速编辑使用 `targetLayerId` 原位替换画布层,结果层就是被覆盖后的原层,其配方 source 指向替换前的编辑目标,不能再可靠恢复为“改造”新产物;这类结果继续显示“快速编辑”,不显示“改造”。 +- 快速编辑使用统一正向白名单:只支持普通静态图片、角色图、规范图、完整图标图集、图标规范、UI 设计图、宣发图和游戏场景图;视频、单个拆分图标、角色动作 / 序列帧、音效与背景音乐不支持。浮动工具栏、图层右键菜单、独立图片菜单、打开面板入口和提交门禁必须复用同一白名单,新增媒体或素材类型默认不开放。图片编辑请求只提交必填 `sourceReferenceId`,取正式 `resourceId ?? sourceAssetId`;没有正式业务 ID 的本地图层、生成对话临时图层或未登记上传图必须先走既有资源登记,否则前端失败关闭。objectKey、URL、Data URL、Blob URL 不得作为主来源身份,旧 `sourceImageSrc/sourceResourceId/assetKind` 也不得发送。带红框的快速编辑仍以原图业务 ID 为主来源,上传后的编号标注图只放入辅助 `referenceImageSrcs`。`/api/editor/images/edits` 入队前通过当前账号窄查询解析来源对象与权威类型;带 `targetLayerId` 时必须同时带 `projectId`,目标图层须关联有效项目资源,双方对象和默认类型必须一致,最终有效类型取目标覆盖值或目标资源类型。来源默认类型只用于绑定一致性,不得在应用同媒体族标签覆盖前提前拒绝;因此默认 `icon` 且无覆盖时仍禁止快速编辑,但同一图片图层覆盖为 `scene` 或 `spec` 后必须按覆盖类型放行。视频仍支持普通生成、改造和下载,但不提供快速编辑;角色动作只能走对应动作生成链路。画布 Agent 的 `edit_image` 必须从 `ImageMetadata.reference_id` 获取项目资源 ID 或素材 ID,缺失时拒绝创建任务,不得用 `image_data_key` 或 objectKey 代替。 +- 对生成资源执行“改造”时,按该产物的原 `action` 恢复对应 generation dialog;用户再次提交后生成新产物,不在原产物上原位修改,也不使用独立的 quick-edit 改造面板。改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中可能存在的后端拼接 Prompt、固定生成模板或模型默认提示词。当前 BGM 是字段语义上的例外:其新记录中的 `prompt` 与 `actualPrompt` 必须等于输入框已经写回的 canonical `gpt_description_prompt`,不得包含隐藏内容;但改造输入仍只从 `generationInputs.fields` 恢复,不能因此放宽为从资源审计字段回退。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。generation dialog 的提交后显隐沿用原 action 的现役生成流程,不额外承诺“改造成功后保持面板打开”。 +- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和图片类改造保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,图片类改造在前端优先复用当前图层 objectKey;尚未登记的本地图片先上传 OSS,再把 objectKey 交给同一图片生成 BFF,并在原图右侧生成一张新图;普通图层改造作为 `quick-edit` 参考图提交,角色图层改造必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;普通图片、角色、图标图集、UI 设计图及其改造入口必须在比例或清晰度恢复、切换时同步把占位框 `width/height/originalWidth/originalHeight` 更新为目标像素尺寸,生成中不得继续显示默认 1K 框;UI 素材提取的 1K / 2K 图集占位和旧图片修改入口也分别使用本次目标尺寸与源图真实尺寸。待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和图片类改造在调用图片 BFF 前必须把当前图层图片解析为已上传的 objectKey 或资源 ID;浏览器临时图片需先上传 OSS;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材不提供 `快速编辑`,只能通过角色动作生成链路继续编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 +- 生成类产物的工具栏动作 `改造` 不在原产物上原地修改,而是按配方的原 `action` 恢复对应 generation dialog,允许编辑后生成新产物。按钮显隐只按 action capability 判断:确定性派生等不支持改造的 action 隐藏;支持改造的 action 即使参数损坏或必需来源丢失也保留按钮,点击后再显示准确错误,禁止用恢复状态静默隐藏入口。`image.edit` 明确不属于可改造 action:图片快速编辑使用 `targetLayerId` 原位替换画布层,结果层就是被覆盖后的原层,其配方 source 指向替换前的编辑目标,不能再可靠恢复为“改造”新产物;这类结果继续显示“快速编辑”,不显示“改造”。 - V2 恢复严格按顶层 `action`、`fields[].id` 和 `references[].id/refType/refId` 定位,`title` / `label` 只是展示快照,不作为参数键。引用只在当前已 hydrate 的画布图层中按 `refType/refId` 匹配,媒体类型取匹配图层的运行时数据,不重复写入 `generationInputs.references`,也不新增 owner-only 工程资源 / 素材库查询。面板直接上传的参考素材不进入素材库、不是画布图层,因此改造时不恢复;原参考图层已移出画布时同样不恢复。**引用在提交时是否必填,不是恢复时拒绝打开面板的依据**:凡目标面板提供重新选择入口的槽位,无论可选还是必填,缺失时都必须 best effort 打开面板、留空槽位并提示用户重新选择,最终由提交门禁校验;例如角色生成缺少必填角色规范、图标生成缺少必填图标规范、普通图片 / 视频缺少参考图时,都必须打开原 action 面板让用户补选。只有已支持改造但目标面板没有手动替换入口、缺少 source 就无法构造请求时才允许拒绝,当前严格限定为两个例外:`character-animation.generate` 的 source 决定角色动作面板绑定的原角色图层,面板内部不能换角色;`ui-design.extract-assets` 的 source 同时决定框选覆盖层的坐标系,素材提取面板不能改绑另一张设计图。两个例外仍按 action capability 保留改造按钮;source 缺失时点击按钮才显示“无法改造”并分别说明不可替换原因,点击后 source 变化也必须在恢复路径再次校验并拒绝;该失败规则不得用于任何可手动补选的槽位。 -- 改造恢复顺序为:有效 V2;完整的历史生成对话框;按 `assetKind/mediaType`、历史中英文标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 的 legacy adapter,最后缺失值使用当前默认值并显示“已按旧版数据恢复,部分参数可能使用当前默认值。”。legacy adapter 必须先按 `assetKind/mediaType` 映射角色、图标、UI、场景、视频、音频等原工具;只有图片族且 `assetKind` 为空或未知时才使用默认的普通图片生成工具。legacy adapter 只恢复映射后目标 action / 工具声明的输入,不得额外把当前图片附加为参考图,也不得把普通图片生成改写为 `quick-edit` 或 `character` 请求。无法映射到可用工具时准确提示无法改造。一旦识别为有效 V2,即使引用缺失也不得降级到 legacy adapter。禁止从中文标题、产物尺寸或 prompt 反推 V2 参数;legacy fallback 除外,但不得回退展示 `actualPrompt` 中的后端拼接 Prompt。当前 BGM 是字段语义上的例外:其新记录中的 `prompt` 与 `actualPrompt` 必须等于输入框已经写回的 canonical `gpt_description_prompt`,不得包含隐藏内容;但改造输入仍只从 `generationInputs.fields` 恢复,不能因此放宽为从资源审计字段回退。定向测试必须至少覆盖未知图片回退普通 `image.generate` 和一种已知 `assetKind` 恢复原工具的真实提交闭环,并校验请求输入、V2 action / references、结果落点及 dialog / composer 终态。 +- 改造恢复顺序为:有效 V2;完整的历史生成对话框;按 `assetKind/mediaType`、历史中英文标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 的 legacy adapter,最后缺失值使用当前默认值并显示旧版恢复告警;一旦识别为有效 V2,即使引用缺失也不得降级到 legacy adapter。禁止从中文标题、产物尺寸或 prompt 反推 V2 参数;legacy fallback 除外,但不得回退展示 `actualPrompt` 中的后端拼接 Prompt。当前 BGM 是字段语义上的例外:其新记录中的 `prompt` 与 `actualPrompt` 必须等于输入框已经写回的 canonical `gpt_description_prompt`,不得包含隐藏内容;但改造输入仍只从 `generationInputs.fields` 恢复,不能因此放宽为从资源审计字段回退。 - V2 资源 / 素材 hydrate 为前向兼容读取:顶层、`fields[]` 和 `references[]` 出现未来可选属性时只复制当前已知字段,不得让整份配方消失;完美像素操作快照等精确重放路径仍执行递归白名单,并保留历史空字符串的字节级兼容。hydrate 结果必须保留 `absent | valid | invalid-versioned` 运行时来源状态:只有 `absent` 可走 legacy adapter;结构损坏或版本不支持的版本化配方标为 `invalid-versioned`,所有恢复入口都必须拒绝自动降级,并提示“保存的生成参数版本不受支持或数据损坏,无法可靠恢复”。该标记不属于持久化 generationInputs 契约,也不得把原始损坏 JSON 回写。对模型、比例、尺寸、时长、开关等具有明确运行默认值的 V2 参数,缺失与非法值统一补写默认值并告警;允许为空的提示词 / 表单自由文本不因缺失产生参数失效告警。 - 图片画布抠图统一通过唯一、只监听 loopback 的 `bgfilter-worker` 调用 BgFilter provider。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;API 在入队前拒绝 `data:` / `blob:` 内联媒体,父流程将稳定引用解析为当前账号已登记且归属已校验的私有 OSS object key,并在同一轮账号项目 / 素材快照读取中同时恢复用户可见源模型,禁止为 object key、所有权和源模型分别重复拉取全量快照;随后只通过一次内部 HTTP RPC 传递 object key、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和固定的 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传源图字节、签名 URL、`file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 URL,承担默认 `Q=2048` admission 保险丝、provider 并发 `N=16`、严格最多两次顺序 attempt、响应字节与图片尺寸校验,并把成功图片作为内部 HTTP 二进制 body 直接返回;父流程同步等待该响应且不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`;attempt 按 `N × est × 2`、调用预算按 `2 × attempt + 1s` 派生,冻结 `est=5000ms` 时分别为 `160s / 321s`。complex 的真实 provider 失败会累计并打开自身熔断,但与 flat 状态隔离;complex 任意失败或熔断仍直接返回父流程失败,不接入阿里云 / 本地键色降级。provider 配置继续统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL` 和 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,父子共同使用 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY` 与 `GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS` 派生预算;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 provider token 兼容别名;内部调用另使用 `GENARRATIVE_BGFILTER_WORKER_BASE_URL` 和独立内部 Token。所有令牌只在服务端注入,前端不持有令牌。成功字节返回父流程后,仍由父流程完成最终处理、OSS / asset object 持久化、结果图层与最新项目快照写回;接口只向前端返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端。 - 编辑器自己生成的标准纯色背景抠图资产在保存源图后统一以 `background_mode=flat` 调用内部 `bgfilter-worker`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的用户路径都固定使用 `screenColor=auto`,但用户可见 `generationInputs.fields` 不记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和子 worker 发往 provider 的 `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。角色、图标 spritesheet 和 UI 设计图素材提取的同源画布请求由前端自动提交默认 `segModel=birefnet`,api-server 负责 allowlist 校验并在缺失时回落默认值;角色动作逐帧去背的 `seg_model` 由后端固定。四条 flat 路径再由 api-server 向 worker 显式传递 `background_mode=flat` 与 `cross_check`:角色形象生成、图标 spritesheet 和角色动作逐帧去背传 `on`,UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值。前端不展示抠图模型选择,`segModel` 不进入 `generationInputs`、响应、搜索、详情或导出;`background_mode`、`cross_check` 只存在于 api-server 到 worker 的内部 RPC。子 worker 为 flat / complex 分别维护独立进程级熔断,并对一次逻辑调用严格最多执行两次顺序 provider attempt;两种模式共享 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 和 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=120` 默认值,但失败和成功只更新当前模式;父侧至多让 worker 接收一次内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。flat 两次失败、熔断、overload、内部 deadline 或断连后,只要父业务预算仍有效,父流程才按同一 object key 进入“阿里云通用抠图 → 本地 `editor_green_screen` 键色”降级;阿里云 fallback 不属于 `bgfilter-worker`。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `内部 bgfilter-worker(background_mode=flat,cross_check=on)→ 父侧阿里云 → 父侧本地键色` 链路。 @@ -52,10 +54,10 @@ - 普通图片与角色在 provider 回图后先按统一业务像素矩阵尝试交付尺寸归一:允许无放大恢复时使用 Lanczos 重采样并居中裁切,无法安全恢复时保留 provider 实际尺寸并返回非阻断告警。普通图片随后以这张实际交付尺寸图同时作为网格分析源和 RGBA 采样源;角色先持久化同尺寸平底原图并交给 BgFilter,正常成功后把 Alpha 蒙版回贴到该平底原图,再以平底原图分析网格、以透明 RGBA 图采样。图标仍以已持久化的平底 provider 图尺寸为基准,BgFilter 成功并回贴 Alpha 后执行同样的双输入规整。固定首版参数为:分析色数 `16`、Alpha 覆盖阈值 `0.375`、像素格尺寸自动检测、相邻边缘峰间距使用线性插值 `P30` 估算步长、固定色板关闭、K-means 最大采样 `262144`。 - 像素规整 CPU 工作使用进程级最大并发 `2`;取得并发许可的排队时间与实际处理时间共享最多 `30` 秒预算,同时不得晚于当前请求 deadline,最终以两者中更早者为准。输入图片任一边不得超过 `10000` 像素,总像素不得超过 `8294400`;超限、排队超时或处理超时均按像素后处理失败的 best-effort 规则保留进入该步骤前的图片。 - 单格颜色按 `Σ(A × RGB) / ΣA` 进行 Alpha 加权;单格覆盖率按 `Σ(A / 255) / N` 计算。覆盖率大于等于 `0.375` 且 `ΣA > 0` 时输出硬 Alpha `255`,否则输出严格的 `[0,0,0,0]`;最终 Alpha 只允许 `0 / 255`。分析用 16 色只负责网格识别,不限制最终输出色数。 -- snapper 在内存中把每对相邻横纵切线围成的采样单元各压成一个输出像素,最终直接编码并持久化唯一的逻辑分辨率 PNG;不得再用 nearest 或其它插值把逻辑图恢复到当前 RGBA 输入尺寸或业务交付尺寸。普通图片和角色仍先执行既有 Lanczos 交付尺寸归一,无法安全归一时仍保留 provider 实际尺寸;图标输入仍以已持久化平底原图的实际尺寸为准。这些规则只定义网格检测与 RGBA 采样输入,不构成成功输出与输入同尺寸的承诺。角色和图标应复用 Alpha 回贴阶段已经读取的平底原图;确需重新读取时,最多增加一次对已有 provider 对象的 OSS GET,不得新增 OSS PUT。 -- 像素模式的持久化增量必须为零:普通图片仍只上传一张最终逻辑分辨率主图;角色仍只保留原有 provider 原图与一张最终透明逻辑分辨率主图;图标仍只保留原有 provider 原图、一张最终透明逻辑分辨率图集和实际成功的切片。禁止另存输入尺寸恢复版、像素化前后双份主图、预览图、网格诊断图或报告,禁止新增 asset / resource 类型、项目资源、画布 item、队列 job kind 或数据库字段。 +- 逻辑低分辨率图只存在于内存;snapper 在规整内部使用 nearest 恢复到当前 RGBA 输入尺寸,并直接替换原本即将持久化的最终图片字节。普通图片和角色的该输入已经过前置 Lanczos 交付尺寸归一,或在无法安全归一时保留 provider 实际尺寸;图标输入以已持久化平底原图的实际尺寸为准。nearest 不替代前置尺寸归一,规整完成后不再执行第二次 Lanczos 或其它尺寸恢复。角色和图标应复用 Alpha 回贴阶段已经读取的平底原图;确需重新读取时,最多增加一次对已有 provider 对象的 OSS GET,不得新增 OSS PUT。 +- 像素模式的持久化增量必须为零:普通图片仍只上传原有一张最终主图;角色仍只保留原有 provider 原图与透明主图;图标仍只保留原有 provider 原图、透明图集和实际成功的切片。禁止保存逻辑低分辨率图、像素化前后双份主图、预览图、网格诊断图或报告,禁止新增 asset / resource 类型、项目资源、画布 item、队列 job kind 或数据库字段。 - 像素后处理属于 best-effort:失败时保留进入该步骤前的图片,继续原有最终上传与画布完成,并通过既有通用 `warning` 返回非阻断原因,不把任务改为失败或退款。BgFilter 自身失败时仍按原 source-only fallback 收口,像素处理不运行;图标后处理成功后再执行原有自动拆分,拆分告警继续使用现有 `sliceWarning` 语义。 -- 选中已有静态栅格图层后的 `完美像素` 是独立的一键派生操作,不等同于生成请求上的 `style="pixelArt"`。它不打开参数面板,只处理当前活动图层,保留源图,并在源图右侧创建一张逻辑分辨率 PNG 派生结果;派生 PNG 的内在宽高取最终网格列数和行数,可以与源图及占位尺寸不同。音频、视频、图片序列和 `character-animation` 不显示该按钮。 +- 选中已有静态栅格图层后的 `完美像素` 是独立的一键派生操作,不等同于生成请求上的 `style="pixelArt"`。它不打开参数面板,只处理当前活动图层,保留源图,并在源图右侧创建同尺寸 PNG 派生结果;音频、视频、图片序列和 `character-animation` 不显示该按钮。 - 完美像素、手动去背景、裁扩和所有图集切片是确定性派生操作,分别保存 `image.perfect-pixel`、`image.remove-background`、`image.crop-expand`、`spritesheet.split` 的 V2 `generationInputs`。四者固定 `fields: []`;存在正式来源资源 / 素材行时只保存服务端权威的 `references[id="source"]`,引用仅用于来源溯源,不是算法参数或可编辑槽位,没有正式行时保存空数组。改造 capability 使用独立 allowlist,四者及历史 `pixel-art-snap-*` 结果永不允许改造;自动切片的 source 是实际被切的透明图集,整张生成图集仍保留原生成 action。 - 裁扩创建资源时,未携带 `sourceResourceId` 仍允许保存无正式来源引用的确定性结果;一旦携带该 ID,api-server 必须确认它属于当前 owner 和当前项目,否则以 `400` 拒绝,禁止同时持久化悬空 / 越权 `sourceResourceId` 和空来源配方。 - 已有图片像素规整固定调用登录态同源 `POST /api/editor/images/pixel-art-snaps`,复用同一纯内存 Rust snapper、CPU 并发许可和输入尺寸上限。该入口免费、只走当前 HTTP 请求内的 inline 处理,不创建 `external_generation_job`,不刷新或自动打开任务侧栏,也不进入泥点扣费 / 退款链路。它另有一层端点级并发闸(最大 4、等待队列上限 2048),设在首次 IO 之前;队列满返回 `503` 并带 `Retry-After`,等待超预算返回 `504`。30 秒总预算从 handler 入口起算,覆盖归属校验的 SpacetimeDB 读取、OSS 下载、两层排队与规整,不是只算 CPU 部分。 @@ -107,9 +109,9 @@ - 封面图层读取与栅格化集中在 `ImageCanvasProjectCoverSnapshotRenderer`:先排除不与封面输出区相交的图层,再以最多 6 路并发加载实际可绘制图层,最终仍按 z-index 顺序绘制。封面换签必须复用画布预览的 `taskId ?? resourceId` cache version,序列帧和视频 poster 继续使用同源后缀,以命中已有 pending / 内存 / session signed-URL 缓存并尽量复用同一 OSS URL 的浏览器缓存。封面上传只需 `objectKey / assetObjectId / legacyPublicPath`,使用 object-only 上传路径,不得为从未使用的立即预览 `src` 强制 `bypassCache` 换签。 - 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。`/api/assets/read-url` 属于页面展示层高频后台请求,前端统一在 `assetReadUrlService` 内做同 key pending 去重、session 缓存和跨组件节流;UI 设计切片、角色动画帧或大量素材恢复时不得绕过该服务并发换签,否则单页可在同一秒内打满发布入口 `genarrative_api_rps` burst。 - 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 canvas 本地渲染 PNG,也必须先上传 OSS / asset object 并创建 `editor_project_resource`,再把带正式 `resourceId/objectKey/assetObjectId` 的裁扩图层加入画布;不能先把 `local-resource-*` + Data URL 图层交给项目保存或后续去背景。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态;生成请求提交前必须把临时状态解析成 `objectKey`、项目资源 ID 或素材 ID,未登记的本地图片和普通图片路径先上传 OSS,不能直接提交 Data URL、Blob URL 或临时图片源。 -- 资源表保存资源和素材级元数据,其中 `editor_project_resource.asset_kind` 是资源默认类型;图层位置、层级、分组选中所需 ID、groupId 和可空 `assetKindOverride` 保存在画布布局。legacy 布局 JSON 是混合数组:普通图层按 `layerId/resourceId/assetKindOverride` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存;结构化模式下 `asset_kind_override` 必须是 `editor_canvas_layer` 的 typed 列,不得塞回 `item_json`。普通图层的新保存不再把资源默认 `assetKind` 或 `generationInputs` 写入布局;刷新时从 `editor_project_resource` 恢复资源默认类型,再与图层覆盖合并为 `effectiveAssetKind`,不得把合并结果反写为新的资源或覆盖。旧布局中的 `assetKind` 只用于迁移:与资源默认类型相同则归一为无 override,不同则迁移为图层 override;资源尚无默认值且允许补齐时先补资源默认值,不制造新的资源行。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成成功后仍保存对话框快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。V2 `generationInputs` 沿用现有 JSON 列,不新增表或字段:顶层为 `{ version: 2, action, fields, references }`,`fields` 只保存 `{ id, title, value }`,其中 `value` 为 `string | number | boolean`;`references` 只保存 `{ id, title, label?, refType, refId }`,`refType="project-resource"` 和 `refType="asset"` 仅作为当前画布图层的稳定匹配键,媒体类型从匹配图层获得,不据此额外查询资源行或素材库。提交前对面板值只归一一次,同一份归一值同时进入生成请求与 `fields/references`;Data URL、Blob URL、signed URL 和 `objectKey` 均不进入元数据。面板直接上传的引用即使为生成请求创建了工程资源,也不作为可长期恢复的素材库或画布引用。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 +- 资源表保存资源和素材级元数据,其中 `editor_project_resource.asset_kind` 是资源默认类型;图层位置、层级、分组选中所需 ID、groupId 和可空 `assetKindOverride` 保存在画布布局。legacy 布局 JSON 是混合数组:普通图层按 `layerId/resourceId/assetKindOverride` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存;结构化模式下 `asset_kind_override` 必须是 `editor_canvas_layer` 的 typed 列,不得塞回 `item_json`。普通图层的新保存不再把资源默认 `assetKind` 或 `generationInputs` 写入布局;刷新时从 `editor_project_resource` 恢复资源默认类型,再与图层覆盖合并为 `effectiveAssetKind`,不得把合并结果反写为新的资源或覆盖。旧布局中的 `assetKind` 只用于迁移:与资源默认类型相同则归一为无 override,不同则迁移为图层 override;资源尚无默认值且允许补齐时先补资源默认值,不制造新的资源行。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源改造也不再从 `generationInputs.fields` 恢复 `抠图背景色` 或 `抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成成功后仍保存对话框快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。V2 `generationInputs` 沿用现有 JSON 列,不新增表或字段:顶层为 `{ version: 2, action, fields, references }`,`fields` 只保存 `{ id, title, value }`,其中 `value` 为 `string | number | boolean`;`references` 只保存 `{ id, title, label?, refType, refId }`,`refType="project-resource"` 和 `refType="asset"` 仅作为当前画布图层的稳定匹配键,媒体类型从匹配图层获得,不据此额外查询资源行或素材库。提交前对面板值只归一一次,同一份归一值同时进入生成请求与 `fields/references`;Data URL、Blob URL、signed URL 和 `objectKey` 均不进入元数据。面板直接上传的引用即使为生成请求创建了工程资源,也不作为可长期恢复的素材库或画布引用。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 - 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。角色、图标图集、UI 提取和角色动作等多产物任务把实际产生的 provider 原始输出及后处理结果分别入库:所有条目沿用 `character`、`icon-spritesheet`、`character-animation` 等真实类型,provider 原始输出承载任务模型成本,后处理派生产物阶段成本为 0。后台素材查询以最终产物为父行、每个中间产物为可展开的独立子行,分页只计算父任务;手动重拆图集保留独立 `taskId` 用于存储隔离和日志排障,通过私有 provenance 从服务端生成账号素材的 source resource、asset object 或 Object Key 取得可信来源任务,并把它写入 `groupTaskId`,不信任客户端可提交的 resource `taskId/assetKind`;跨项目复用后仍可通过稳定媒体引用找回来源。没有可信来源的新拆分显式归到自身任务,不走历史资源链回溯。每个手动切片同时写入 `groupTaskExpectedAssetCount`,全部切片落库后写独立 cohort 完成事实;后台 read model 只让同一根任务的一个已完成拆分批次并入原图集父项,用户后来删除单片不会让批次脱组,部分失败批次和后续重复拆分批次按各自真实任务分页,避免残缺批次抢占根任务、单组无限增长或素材丢失。历史行在项目资源仍存在时兼容回溯,删除项目资源前只固化直接受影响行的真实来源字段,有界展示 ID 不反写数据库。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。 -- 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。图片生成、快速编辑、修改图片、视频、音频、角色、图标、UI / 自定义规范、角色动画和宣发素材描述统一直接使用公共 `AutoGrowTextArea`,不经 `PlatformTextField` 的 textarea 分支;`AutoGrowTextArea` 负责尺寸、溢出及统一的 disabled 光标 / 透明度反馈,画布内生成面板统一由 `image-canvas-editor__generation-prompt` 承载边框、背景、padding、圆角和 focus chrome,独立修改图片弹窗继续使用 `image-canvas-editor__generate-prompt`,用途专属类只追加最小高度等差异,不得新建第三套 textarea chrome。两套 chrome 均通过 `--auto-grow-scrollbar-radius` 与内部滚动条共用圆角;WebKit 轨道按该圆角上下内缩,滑块在透明横向 gutter 内保持浏览器原生长度比例,拉至顶部或底部不得越过输入框边界。Enter 继续输入换行而不提交表单。内部命名契约仍使用可选 `assetLabel`,最大 80 字符并在提交时 trim;历史状态或内部调用携带非空名称时,同一个名称必须贯穿 `assetLabel`、`canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样兼容该字段,中间原图使用主名称加固定后缀;图标拆分素材按全连通域视觉顺序自动命名为 `素材 N`。 +- 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。图片生成、快速编辑、改造、修改图片、视频、音频、角色、图标、UI / 自定义规范、角色动画和宣发素材描述统一直接使用公共 `AutoGrowTextArea`,不经 `PlatformTextField` 的 textarea 分支;`AutoGrowTextArea` 负责尺寸、溢出及统一的 disabled 光标 / 透明度反馈,画布内生成面板统一由 `image-canvas-editor__generation-prompt` 承载边框、背景、padding、圆角和 focus chrome,独立修改图片弹窗继续使用 `image-canvas-editor__generate-prompt`,用途专属类只追加最小高度等差异,不得新建第三套 textarea chrome。两套 chrome 均通过 `--auto-grow-scrollbar-radius` 与内部滚动条共用圆角;WebKit 轨道按该圆角上下内缩,滑块在透明横向 gutter 内保持浏览器原生长度比例,拉至顶部或底部不得越过输入框边界。Enter 继续输入换行而不提交表单。内部命名契约仍使用可选 `assetLabel`,最大 80 字符并在提交时 trim;历史状态或内部调用携带非空名称时,同一个名称必须贯穿 `assetLabel`、`canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样兼容该字段,中间原图使用主名称加固定后缀;图标拆分素材按全连通域视觉顺序自动命名为 `素材 N`。 - 画布 Agent 会话按“SpacetimeDB 元数据 + OSS 消息正文”存储:`editor_agent_conversation` 只保存 `conversationId/projectId/ownerUserId/title/messagesObjectKey/deleted/createdAt/updatedAt` 等会话元数据;消息正文整体保存为私有 OSS JSON 文档 `editor-agent/{conversationId}.json`。消息文档单对象上限为 2 MiB,同一会话的消息追加和工具结果回填由 api-server 按 `conversationId` 串行化,避免“读 OSS → 改消息 → 写 OSS”并发覆盖。前端只通过 api-server BFF 读取和发送会话,不直接读写 SpacetimeDB,也不直接读写 OSS。 - Agent 消息附件只允许引用当前工程画布资源或账号素材库图片,来源类型为 `canvas_resource` / `library_asset`,最多 9 张。附件请求可携带展示用 `imageSrc/thumbnailSrc/objectKey/width/height/label`,但持久化真相仍以后端校验后的 resource / asset 行和 OSS 对象为准;不得把 Data URL、signed URL 或 blob URL 当作会话长期事实。 - 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。 @@ -148,10 +150,10 @@ - `POST /api/editor/assets`:批量或单个创建账号级素材,登录态上传必须写入 OSS / asset object 引用和 `/` 轻量路径,不允许把 Data URL / signed URL 写入素材库。 - `PATCH /api/editor/assets/{assetId}`:重命名素材或移动素材到文件夹。 - `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。 -- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片。带 `model / aspectRatio / imageSize` 的用户生成以统一业务像素矩阵创建前端占位和最终画布资源,例如两种图片模型的 `2K·16:9` 都交付 `2048x1152`;不得先请求固定 1K 再放大为 2K。`gpt-image-2` 在 provider 边界使用其接口支持的对齐请求尺寸,该尺寸不是业务交付尺寸;`nanobanana2` 仍把比例和清晰度档位写入 `generateContent`。provider 回图大于业务目标且比例偏差在允许范围内时,在内存中缩小并轻微裁切到业务尺寸后只上传最终结果。任意一边小于业务目标或比例偏差过大时禁止放大或大幅裁切,只上传 provider 实际回图,以实际尺寸写入结果并通过通用 `warning` 提示用户。主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`;父流程在持久化带纯色背景原图前先将回图归一到业务交付尺寸,再以该原图的 object key 向唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC;子 worker 在每次真实 provider attempt 前签发短期 OSS URL,并向 BgFilter 传入 `screen_color=`、`seg_model=`。父流程不直连 BgFilter、不签发该 URL,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。带背景原图和透明结果必须使用同一实际像素尺寸,1K 的长边固定为 `1024`;若 provider 回图不允许无放大地恢复到业务尺寸,两张图一同保留 provider 实际尺寸并返回通用 `warning`。透明处理结果发生尺寸漂移时,只允许在宽高比偏差不超过 `5%` 时重采样 alpha 蒙版并应用回已归一原图 RGB;蒙版比例超限、回贴失败或尺寸验证失败时不保存透明图,只以已保存原图和同时保留尺寸原因的通用 `warning` 完成画布。最终失败时按前述多产物降级规则以原图主结果和通用 `warning` 收口。图标图集和 UI 图集的透明处理正常成功但返回尺寸与 provider 原图不同时,同样只重采样 alpha 蒙版并应用回 provider 原图,不放大低分辨率后处理成品。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`,并继续按固定交付像素处理。从既有图层重新打开生成器且没有仍存活的对话框快照时,前端按该图层真实 `originalWidth / originalHeight` 恢复比例和清晰度,不得回落到新建面板的 1K 默认值。图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成完成后在响应中返回实际产物的 project / resource / asset 快照。 +- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片。带 `model / aspectRatio / imageSize` 的用户生成以统一业务像素矩阵创建前端占位和最终画布资源,例如两种图片模型的 `2K·16:9` 都交付 `2048x1152`;不得先请求固定 1K 再放大为 2K。`gpt-image-2` 在 provider 边界使用其接口支持的对齐请求尺寸,该尺寸不是业务交付尺寸;`nanobanana2` 仍把比例和清晰度档位写入 `generateContent`。provider 回图大于业务目标且比例偏差在允许范围内时,在内存中缩小并轻微裁切到业务尺寸后只上传最终结果。任意一边小于业务目标或比例偏差过大时禁止放大或大幅裁切,只上传 provider 实际回图,以实际尺寸写入结果并通过通用 `warning` 提示用户。主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`;父流程在持久化带纯色背景原图前先将回图归一到业务交付尺寸,再以该原图的 object key 向唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC;子 worker 在每次真实 provider attempt 前签发短期 OSS URL,并向 BgFilter 传入 `screen_color=`、`seg_model=`。父流程不直连 BgFilter、不签发该 URL,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。带背景原图和透明结果必须使用同一实际像素尺寸,1K 的长边固定为 `1024`;若 provider 回图不允许无放大地恢复到业务尺寸,两张图一同保留 provider 实际尺寸并返回通用 `warning`。透明处理结果发生尺寸漂移时,只允许在宽高比偏差不超过 `5%` 时重采样 alpha 蒙版并应用回已归一原图 RGB;蒙版比例超限、回贴失败或尺寸验证失败时不保存透明图,只以已保存原图和同时保留尺寸原因的通用 `warning` 完成画布。最终失败时按前述多产物降级规则以原图主结果和通用 `warning` 收口。图标图集和 UI 图集的透明处理正常成功但返回尺寸与 provider 原图不同时,同样只重采样 alpha 蒙版并应用回 provider 原图,不放大低分辨率后处理成品。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`,并继续按固定交付像素处理。从既有图层重新打开生成器且没有仍存活的对话框快照时,前端按该图层真实 `originalWidth / originalHeight` 恢复比例和清晰度,不得回落到新建面板的 1K 默认值。图片类改造继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成完成后在响应中返回实际产物的 project / resource / asset 快照。 - `POST /api/editor/images/background-removals`:接收当前图片的 `objectKey`、`resourceId` 或 `assetId` 候选引用,登录态和稳定引用入口校验通过后创建外部生成任务,响应只返回 `queueState`。父 `external-generation-worker` 负责把候选引用解析为已登记、已校验当前账号归属的私有 OSS object key,只向唯一 `bgfilter-worker` 发起一次内部 HTTP RPC,传递 object key、`maxQueueWaitMs`、公式化 `callBudgetMs` 以及固定的 `background_mode=complex + seg_model=birefnet + cross_check=off`;父侧不下载原图、不签发 URL,也不发送 `file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 OSS URL,以默认 `Q=2048` admission 保险丝和 provider 并发 `N=16` 限流,取得 provider permit 后才启动 `callBudgetMs`,并对同一次逻辑调用最多执行两次顺序 provider attempt;成功图片以内部 HTTP 二进制 body 返回父流程,父侧不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。complex 任意最终失败都直接使父任务失败,不进入阿里云或本地键色 fallback。请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`;成功后仍由父流程完成最终 OSS / project resource 持久化,有 `canvasCompletion` 时按生成占位写入结果图层,否则沿用旧的目标图层替换路径。provider 令牌只在子 worker 服务端通过 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 注入,未配置时兼容回退旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`;父子内部调用另使用独立内部 Token。 - `POST /api/editor/icon-spritesheets/generations`:主图标规范使用必填 `referenceId`,只接受当前 owner 的项目资源 ID 或素材 ID,不接受 `objectKey`、URL、临时 key 或 `referenceImageSrc` 作为主规范引用;普通附加参考图仍可使用独立 `referenceImageSrcs`。画布前端把完整用户需求作为 `iconDescriptions` 的唯一数组元素提交,不按分隔符或语义枚举解析数量。api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet,并与手动 `POST /api/editor/icon-spritesheets/slices` 复用同一套全连通域识别:识别多少个有效素材就拆多少个,按视觉阅读顺序命名为 `素材 N`,不读取 `iconDescriptions` 数量决定切片数。两条拆分路径共同限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片。切片只在有界管线中按需编码,共享单个 HTTP client 并以最多 `2` 路并发执行 OSS `PUT + HEAD`;client 的连接与单请求超时分别固定为 `10s / 60s`,手动入口在下载最大 `32 MiB` 来源对象前取得 memory admission,上传收齐后立即释放整图 admission,不跨数据库等待持有。所有对象验证通过后,由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中批量确认 `asset_object`、创建 project resource / account asset 并写入 cohort 完成事实,不得逐片发起三组 procedure 或在部分素材落库后伪造完整批次。resource / asset ID 由 owner、task 与切片序号稳定派生;同一批次不确定结果后重放只能复用内容完全一致的素材,冲突内容必须拒绝,来源资源还必须存在且与派生资源属于同一 owner / project。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`projectId`、`assetFolderId` 和 `generationInputs`,不接受客户端 `priceMudPoints`;后端按归一化后的模型和尺寸从运行时定价配置计算价格,queue 入队时冻结该价格,worker 的预扣、退款和结果投影均使用同一入队价格;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但自动拆分失败时保留整张透明图并返回非阻断 `sliceWarning`,手动拆分失败时返回接口错误。响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`。 -- `POST /api/editor/images/generations` 与 `POST /api/editor/icon-spritesheets/generations` 还可携带可选 `style`;公开合法字符串为 `none / pixelArt`,兼容归一化、支持的 `kind`、非阻断告警和零新增持久化规则以“静态图片风格与像素规整边界”为准。前述“带背景原图和透明结果必须使用同一实际像素尺寸”只约束 BgFilter 输出校验、Alpha 回贴以及进入 snapper 前的内部共同坐标系;`pixelArt` 的 Alpha 回贴结果随后进入 snapper,最终逻辑分辨率 PNG 允许与带背景原图不同。`POST /api/editor/ui-designs/assets/extractions` 不接受该字段。 +- `POST /api/editor/images/generations` 与 `POST /api/editor/icon-spritesheets/generations` 还可携带可选 `style`;公开合法字符串为 `none / pixelArt`,兼容归一化、支持的 `kind`、非阻断告警和零新增持久化规则以“静态图片风格与像素规整边界”为准。`POST /api/editor/ui-designs/assets/extractions` 不接受该字段。 - `POST /api/editor/images/pixel-art-snaps`:对已登记的静态图片执行免费的同步完美像素化。请求使用 `sourceImageSrc` 承载当前 owner 可读取的 `objectKey / resourceId / assetId` 候选稳定引用,`projectId / canvasCompletion` 必填且 `canvasCompletion.dialogId` 必须非空,`sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel` 可选;拒绝内联媒体、未登记对象和非静态栅格素材。客户端提交的 `generationInputs` 必须与其余生成入口一样先经 `sanitize_editor_client_generation_inputs` 剥离 `screenColorHex / mattingProvider / mattingModel` 三个服务端保留审计字段,再进入任何 IO——这三项是背景色决策与 bgfilter 实际执行后由服务端写入的处理事实,不接受客户端声明;本端点是纯几何规整、不抠图,任何 matting 元数据出现在这类记录上本身就是伪造。源图已有正式 project resource 时前端应带上 `sourceResourceId`:该资源随 owner-scoped 项目读取一并鉴权,服务端可直接取用其 objectKey,省去按注册 ID 的全账号项目与素材库扫描;此时 `sourceImageSrc` 应传该 objectKey 或同一个 `resourceId`,两者指向不同图片会被直接拒绝。不带 `sourceResourceId` 时仍需按注册 ID 解析,但全账号项目与素材库只取一次快照,注册 ID 解析、归属校验和跨记录 `assetKind` 收集全部在该快照上用 `_from_records` 纯函数完成,命中已登记记录即短路、两份记录都查不到才回落 asset object 点查;不得再调用内部自带两轮扫描的 `resolve_editor_reference_object_key_for_owner`。`get_editor_project` 到来源解析结束整体套同一份绝对处理预算,超时返回 `504` 且文案指向归属校验——预算从 handler 入口起算不等于覆盖该阶段,裸 `await` 会让请求一路走到下载才发现预算耗尽,并全程占用端点准入名额。像素处理使用 strict 失败语义且不进入外部生成队列;成功时只持久化一张最终 PNG,并返回对应 project / resource / asset 快照。服务端把规范化 `canvasCompletion.dialogId` 作为 operationId,以 owner / project 共同限定作用域,并从该 operation 稳定派生 task、asset object、resource、asset 身份;请求 fingerprint 覆盖来源 object key、来源与输出字节摘要、来源资源、素材类型、规范目录 / 标签、规范 generationInputs、completion 和算法版本。OSS PUT / HEAD 之后只调用一次原子 SpacetimeDB procedure;权威 dialog 仍存在时在源图右侧完成占位,已删除时只提交 object / resource / asset 而不推进 canvas revision。完整同内容重放返回 `AlreadyApplied`,同 operation 输入漂移或只有部分记录存在返回幂等冲突。 - `POST /api/editor/ui-designs/assets/extractions`:前端把红色框选轮廓绘入本地临时图后,先将该图上传 OSS 并确认 asset object,再以返回的 `objectKey` 作为参考图入队;Data URL / Blob URL 只允许停留在上传前的浏览器临时态。接口固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet;api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet 并按连通域尝试拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `screenColor`、`segModel`、`aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但拆分失败时保留整张透明图并返回 `sliceWarning`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`;前端按后端快照落画布,不补造缺失产物。 - 图片生成请求边界:角色生成、图标 spritesheet 和 UI 素材提取的同源画布 request DTO 保留 `segModel`,前端不提供选择控件而是自动提交默认 `birefnet`;api-server 继续校验并在字段缺失时回落默认值。`background_mode`、`cross_check` 与角色动作逐帧去背的 `seg_model` 不属于前端请求字段,只在 api-server 到 loopback worker 的内部 RPC 中传递。请求中的 `segModel` 不进入用户可见 `generationInputs` 或任何普通用户响应。 @@ -180,7 +182,7 @@ - 生成图片点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。 - 选择 `1K / 2K` 或切换比例后,占位框在待生成和生成中阶段都必须立即显示对应目标像素尺寸;从普通图片、角色、图标图集或 UI 设计图进入改造时同样适用,完成落图前后不得从默认 1K 框跳变为 2K 成品。 - 普通图片、角色和图标面板显示 `像素艺术` 勾选项并正确提交 / 恢复 `style: "none" | "pixelArt"`;其它生成或编辑面板不显示该选项。旧 payload、未知字符串、不支持 `kind` 和非字符串输入分别按本方案约定的兼容或错误语义处理。 -- `pixelArt` 输出 Alpha 只包含 `0 / 255`;普通图片和角色仍先完成 Lanczos 交付尺寸归一,snapper 随后直接把一格一像素的逻辑网格编码为最终 PNG,不再 nearest 恢复到输入或交付尺寸,也不得执行其它后置尺寸插值。最终 PNG 宽高可以与规整输入不同,响应与资源快照按实际 PNG 尺寸记录。成功和后处理失败两条路径都不得比 `none` 增加 OSS PUT、项目资源、账号素材或画布 item。 +- `pixelArt` 输出 Alpha 只包含 `0 / 255`;普通图片和角色先完成 Lanczos 交付尺寸归一,再由 snapper 使用 nearest 把逻辑网格恢复到同一输入尺寸,规整后不得再次执行尺寸插值。成功和后处理失败两条路径都不得比 `none` 增加 OSS PUT、项目资源、账号素材或画布 item,逻辑低分辨率图不得出现在 OSS 或响应资源快照中。 - 生成中的占位图聚焦后支持键盘 `Delete` / `Backspace` 删除,不新增可见删除按钮;删除后对应异步回写必须按生成器 ID 判空并丢弃,不能把已删除素材重新落回画布。音乐 / 音频生成占位和已生成音频图层同样必须支持键盘删除。 - 画布常用快捷键必须与右上角快捷键弹窗一致;新增快捷键时应同步更新 `ImageCanvasShortcutModel`、快捷键 hook 单测和本方案。输入框、文本域和 contenteditable 聚焦时不得触发画布编辑快捷键。 - 撤销或恢复画布布局时不得覆盖同 ID 生成对象当前的任务生命周期、提示词、参考图和结果;上传持久化延迟回填内部资源 ID 不得把安全移动误判为素材替换。生成结果必须在加入画布前写入生成历史,自动适合视图不得覆盖这条栈顶记录。 @@ -198,15 +200,15 @@ - 发送消息后,面板先展示本地用户消息和请求等待态,再应用普通 JSON 响应中的 `deltaMessages`;客户端取消等待只终止本次 transport 等待,不把已经确认入队的外部生成任务改成停止态。 - Agent 工具任务完成并懒回填后,消息内缩略图不显示名称;前端通过编辑器作用域 Action Context 的 `refreshCanvas()` 直接重新读取工程快照和素材库,不从 Editor 经 Stage、Panel 和 MessageBubble 透传刷新 callback。图片、视频和音频结果携带有效 `resourceId` 时,在素材右键菜单显示“在画布中定位”;有效图片结果的普通单击也直接通过同一 Context 的 `focusResource(resourceId)` 请求画布在 `420ms` 内平滑 fit 到对应图层。结果卡片不声明按钮语义或 `tabIndex`,Enter 和 Space 不得触发定位;视频和音频的普通点击及原生播放器交互保持独立。定位只改变 viewport,不选择图层、不切换工具或侧栏、不收起 Agent 面板,也不避让面板覆盖区。缺少 `resourceId` 时单击无动作且不显示定位菜单项,目标图层已删除时保持无动作。对话入口触发生成时不创建“即将生成”画布占位,生成完成后由后端 `canvasCompletion` 落新图层。规划或工具失败时消息内必须保留可回读的失败状态和错误气泡,不能只弹一次性 toast 或返回瞬时 `errorMessage`。 - 画布 Agent 会话刷新后能从后端恢复会话标题、消息、附件和生成记录;前端不得根据本地临时状态伪造会话持久化结果。 -- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、完美像素按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、下载按钮。完美像素只对当前静态栅格图层一键执行,按钮在请求期间按 layer id 进入 disabled / busy,首个 await 前用同步 ref 抢占,连续点击不得重复提交;完成后保留源图并在右侧显示派生 PNG,明确失败的占位保留错误且释放 busy。该路由是 unsafe POST 且不得配置自动重放;纯校验、排队或预算等明确未进入结果持久化的响应可直接失败,transport、网关、abort、客户端超时或 `details.resultPersistenceStarted = true` 属于未知结果,必须按下列 durable operation 与 GET-only 契约收口。该图层的素材类型保存在途时(`persistingAssetKindLayerIds`)完美像素按钮同样必须 disabled / busy,并在 handler 里用同步 ref 二次拦截——请求同时携带 `assetKind` 与 `sourceResourceId`,本地类型已改而资源尚未落库时两者不一致,后端 `resolve_editor_pixel_art_snap_asset_kind` 直接返回 `400`,只留下需要手动清理的失败占位。这与相邻的拆分图集按钮共用同一套门禁,但保存态的无障碍名称必须区分(完美像素用 `完美像素等待素材类型保存`),否则 `icon-spritesheet` 图层上两个按钮会同时叫「素材类型保存中」。 -- `details.operationResultAlreadyExists = true` 是另一种必须 GET-only 收口的明确结果:它说明同一稳定 operation 的旧权威资源已经存在、当前请求尚未 PUT。客户端不得把它显示为普通失败、不得自动 POST 重放,也不得据此声称当前请求的持久化已开始。 +- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、完美像素按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、改造、下载按钮。完美像素只对当前静态栅格图层一键执行,按钮在请求期间按 layer id 进入 disabled / busy,首个 await 前用同步 ref 抢占,连续点击不得重复提交;完成后保留源图并在右侧显示派生 PNG,明确失败的占位保留错误且释放 busy。该路由是 unsafe POST 且不得配置自动重放;纯校验、排队或预算等明确未进入结果持久化的响应可直接失败,transport、网关、abort、客户端超时或 `details.resultPersistenceStarted = true` 属于未知结果,必须按下列 durable operation 与 GET-only 契约收口。该图层的素材类型保存在途时(`persistingAssetKindLayerIds`)完美像素按钮同样必须 disabled / busy,并在 handler 里用同步 ref 二次拦截——请求同时携带 `assetKind` 与 `sourceResourceId`,本地类型已改而资源尚未落库时两者不一致,后端 `resolve_editor_pixel_art_snap_asset_kind` 直接返回 `400`,只留下需要手动清理的失败占位。这与相邻的拆分图集按钮共用同一套门禁,但保存态的无障碍名称必须区分(完美像素用 `完美像素等待素材类型保存`),否则 `icon-spritesheet` 图层上两个按钮会同时叫「素材类型保存中」。 - 裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`;父流程解析并校验私有 OSS object key 后只调用一次唯一内部 `bgfilter-worker` 的 complex 链路,子 worker 负责签发 600 秒 URL、`N / Q` 限流和最多两次顺序 provider attempt,complex 失败不接入 fallback,成功二进制返回后仍由父流程完成最终持久化。有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词。透明处理及拆分正常完成时,透明 spritesheet 和拆分素材都按后端快照保留为画布图层;透明处理失败时仅原图作为主结果,既不要求透明图也不要求切片;透明图成功但拆分失败时保留整张透明图并展示拆分告警。三种完成结果都以后端项目快照为准。 - 2026-08-04 修订:完美像素前端已经让素材刷新退出 verdict,持久化 operation 请求快照与 `pending-confirmation`,并接入刷新后的 GET-only 恢复;是否成功只能由下面的项目 GET 正向证据判定。 - 完美像素以 durable operation 为提交边界:请求账本 `perfectPixelOperation = { version: 1, kind: "perfect-pixel", operationId, taskId, request, submittedAt, reconcileUntil }` 存在**本机** `perfectPixelOperationStore`(owner + project 双键的 localStorage),其中 `operationId` 等于规范化 dialog id、`taskId` 固定为 `pixel-art-snap-{operationId}`,`request` 是稳定源引用解析完成后的完整 `EditorPixelArtSnapInput`,其新请求配方固定为 `version: 2 / action: image.perfect-pixel / fields: []`。账本外层版本不升级;嵌套 generationInputs 以共享严格白名单恢复,V2 完整接受稳定 `id`、有限数字、布尔值和可省略 `label`,旧 `{fields,references}` 请求继续按原形恢复以维持历史 fingerprint,未知键或畸形 V2 失败关闭。`submittedAt / reconcileUntil` 构成 **从 POST 发出时刻起算**、不得被 POST 回包续期的 75 秒整链绝对窗口——pre-POST flush 没有整体上限,锚在它之前会让窗口在请求发出前就烧光。项目布局里只保留 `perfectPixelOperationId` 标记,用于把这类占位与队列型占位区分开。**标记与账本的寿命必须对齐**:账本在收口那一刻清除,因此收口态占位(带非空 `generatedLayerId` 且状态不是 `generating` / `pending-confirmation`)既不再写出标记,也不得因为「有标记、没账本」被判成无效——服务端完成 completion 时只做字段级改写、从不摘标记,任何忽略这一点的判据都会把每一次成功判成失败。账本读不到(换设备、清缓存、隐私模式、配额写满)时,**未收口**占位收口成可删除的失败态,不得据此阻断用户删除或重做。完美像素 dialog id 使用跨标签随机 identity,不能复用每个标签页都会从 1 开始的局部计数器。inline 源图以该 identity 作为稳定 upload ID,只执行 object-only 上传,不等待 signed URL;快照不得包含 Data URL、Blob URL 或 signed URL。POST 前仍需 `await` 一次 best-effort 布局保存(服务端要求占位此前已持久化,见上文 409 条款),但保存冲突、鉴权失败或重试耗尽**不再让 POST 为零**——账本已在本机、请求可被追溯,客户端照常发出,由服务端裁决。人工重试只能原样重放该快照与同一 operation,不得重新 placement、上传、读取当前图层字段或暗中换 identity;快照缺失、损坏或与 dialog / project / task / completion 不匹配时失败关闭。首次提交或人工重试在途期间若 owner、project 或组件生命周期已经变化,旧响应的素材写入、项目应用、提示与对账副作用必须全部忽略,不能把前一账号的结果写入当前账号状态。 - 完美像素 unknown-result 的 verdict 只来自项目 GET,POST 响应体不得直接判成功:找到唯一稳定 task resource 且 dialog 已收口、结果层精确指向该 resource 时为 `Applied`;resource 存在且 dialog 不存在时为 `DialogMissing`,结果只在素材库;dialog 仍 generating(包括匹配 resource 已先可见)或 dialog 不存在且无匹配 resource 时继续有界轮询;resource 与 dialog / layer 出现原子事务不可能产生的错配时保持待确认并提示冲突,禁止自动 POST。首个 GET 立即执行,此后退避不超过 5 秒;即使绝对窗口已过期也必须读取一次。GET 的绝对 deadline 从进入 `requestJson` 起覆盖鉴权恢复、所有 attempt 和响应体读取;不能把只覆盖响应头的 `timeoutMs` 当成整次读取上界。单次 deadline 到期按一次读取失败处理,随后由轮询返回 `pending`,首次提交必须进入 `finally` 释放 dialog ownership 与图层锁,hydrate 恢复必须清理 recovery controller。`refreshAssetLibrary` 只在终态后 best-effort 触发,不进入轮询 deadline、`Promise.all` 或成功判断,同步 throw、异步 reject 和永久挂起均不得阻塞。轮询到期或 GET 失败后 dialog 转 `pending-confirmation`,保留 operation 与请求快照并释放页面 busy,不得伪装成普通失败或声称素材已保存。 - 完美像素恢复只对账、不重新执行:项目 hydrate 后识别带有效 operation 账本的 `generating` / `pending-confirmation` dialog,只按稳定 task/resource 做 GET-only 轮询,绝不 POST、重新上传、重新准备来源或为了恢复而先写布局;owner/project 切换、卸载或更高 revision 到来时旧轮询结果不得生效。恢复还必须覆盖**孤儿账本**——本机有账本、布局里却没有对应占位,这正是「POST 已发、布局尽力保存没落盘、标签页关闭」的结局。孤儿走一次确定性的读(不轮询):`reconcileUntil` 只描述「结果可能还在飞」,而孤儿来自已经消失的会话,按它短路会让这条兜底分支在唯一的目标场景(稍后重开,必然晚于 75 秒)下永不生效。收口口径:`dialog-missing` 提示结果只进素材库;`applied` 静默刷新素材库(读到的就是当前权威状态,结果本就在眼前);未落库完全静默。三者都清账本,读失败不清——那是「不知道」而非「知道没有」。新写入的 v1 operation 固定使用 75 秒跨度;为兼容第一批和滚动升级中的旧标签页,hydrate 仍接受跨度及未来时钟偏差不超过 240 秒的旧 v1 journal。若旧 `submittedAt` 位于可接受的未来区间,先把它规范化到当前时间,再把 `reconcileUntil` 压到 `min(持久截止, 规范化 submittedAt + 75 秒, 当前时间 + 75 秒)`;写回形状必须继续满足 `reconcileUntil >= submittedAt`,确保下次 hydrate 仍保留同一 identity。带 operation 的占位不受 `requiresLiveSession` TTL 清理(系统不替用户删),但**用户主动删除始终允许**——两者是不同的事。TTL 只兼容完全没有 operation 标记字段的历史 inline 孤儿,字段存在但内容损坏时必须保留并失败关闭,清理 legacy 孤儿时必须同时更新 `project.layers` 与 `project.canvas.layers`。恢复到期仍持久保持 `pending-confirmation`,只有用户明确点击重试才进入 exact replay。滚动升级期间从布局读到的 legacy 内联账本必须在 hydrate 后一次性迁入本机账本——布局里的内联快照会在下一次保存时被剥成标记,不迁移就再没有任何路径能补写,部署那一刻仍在途的操作会在第二次加载失去 exact retry identity。**已知限制**:本会话的「已观察」记账按 operation 记录,用于避免同一次 operation 被并发轮询两遍;若用户删除仍在处理中的占位、待原请求收口后再 `Ctrl+Z` 撤销删除,复活的占位在**当前会话内**不会被重新对账,会一直显示处理中。刷新页面即自愈(hydrate 会按标记与账本重新判定),且用户随时可以再删一次。该记账同时承担「并发保护」与「本会话已驱动过」两种语义,要根治需先拆开这两件事;在此之前不接受以「删除路径显式清观察记录」等跨 hook 埋线的方式局部绕过。 - legacy inline 占位的本会话归属必须由同一份封装 ownership 管理:同步 Set 在首个 await 前完成 `claim`,保证到期判定即时可见;`claim / release` 仅在 membership 真变化时推进 React 可观察的 version,`release` 即使发生时 dialogs 与 callbacks identity 都不变,也必须立即唤醒到期 effect 重新判定。禁止重新暴露可变 Set ref 或直接修改 `.current`,React 不会因为 ref 内容变化而重跑 effect。 -- 快速编辑站内 public 示例图、历史 generated 图或 OSS generated 图时,优先复用当前图层已有 `objectKey` / `resourceId` / `sourceAssetId`;尚未登记且没有稳定引用的浏览器本地图片或普通 public 图片路径都必须先上传并取得 objectKey。前端不得再把正式对象下载成 `data:image/*;base64,...` 后提交,也不得把 Data URL / Blob URL 写入外部生成持久任务 JSON;后端收到引用后统一做 owner 归属校验并签名读取。 +- 改造图片类生成资源后,按原 action 的 generation dialog 生成新结果图层并自动调整视图;面板提交后的关闭或保留行为继续沿用该 action 的现役生成流程。 +- 快速编辑或图片类改造站内 public 示例图、历史 generated 图或 OSS generated 图时,优先复用当前图层已有 `objectKey` / `resourceId` / `sourceAssetId`;尚未登记且没有稳定引用的浏览器本地图片或普通 public 图片路径都必须先上传并取得 objectKey。前端不得再把正式对象下载成 `data:image/*;base64,...` 后提交,也不得把 Data URL / Blob URL 写入外部生成持久任务 JSON;后端收到引用后统一做 owner 归属校验并签名读取。 - 快速编辑不保留用户可添加的额外参考图入口;点击修改时把原图已登记业务 ID 作为 `/api/editor/images/edits` 的 `sourceReferenceId`,红框序号标注图上传后作为辅助 `referenceImageSrcs`。 - 素材文件夹可以新建、折叠、重命名和删除;删除普通文件夹后,其素材移动到“项目素材”。普通上传默认落入“上传素材”文件夹;素材库缺少该文件夹时,前端在首次普通上传前创建一次并复用,拖到指定文件夹或点击指定文件夹上传时仍进入目标文件夹。 - 上传按钮和拖拽上传都支持多文件;底部工具栏的上传入口选择文件后直接进入“上传素材”并在当前画布视口中心创建画布图层,素材栏文件夹内的上传入口只写入对应素材文件夹、不自动入画布;拖到文件夹或该文件夹内素材时进入目标文件夹;拖到画布时进入“上传素材”并在投放点创建画布图层。上传图片必须在创建占位素材、画布图层和账号级素材记录前先读取原图 Resolution,图层宽高、`originalWidth/originalHeight` 和素材库 `width/height` 都使用图片本身尺寸;上传视频同样在创建素材和图层前读取视频 metadata 宽高,保证单层下载或 ZIP 导出的真实视频文件重新导入后仍按文件自身尺寸入画布;仅在无法解析尺寸时才使用对应媒体兜底尺寸。普通静态图片的 `assetKind` 为空;`"image"` 只是前端 `CanvasMediaType`,不是 `CanvasAssetKind`,两者不得混用。OSS 直传确认成功后,即时占位素材、即时画布图层和补充图片尺寸探测必须使用本次换签得到的 signed URL,不得把私有对象的裸 `/generated-*` 兼容路径交给浏览器读取;账号素材持久化仍只写 `legacyPublicPath`、`objectKey` 和 `assetObjectId`,禁止把会过期的 signed URL 写入资源或素材记录。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 426e7df7f..2dffcf68f 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -205,7 +205,7 @@ controller 配置: - `editor_icon_spritesheet_generation`:图标素材 spritesheet 生成和拆分。 - `editor_ui_design_asset_extraction`:UI 设计图红框素材提取。 - `editor_character_animation_generation`:角色动作视频和帧素材生成。 -- `editor_video_generation`:画布视频生成和视频素材快速编辑。 +- `editor_video_generation`:画布视频生成。 - `editor_sound_effect_generation` / `editor_background_music_generation`:画布音效与背景音乐。 画板结果的业务真相仍是 `asset_object`、`editor_project_resource`、账号级 `editor_asset`、可选 `asset_entity_binding` 和对应的 legacy / structured canvas 表;`editor_generation_operation` 只是提交回执,不替代这些 read model。worker 在 Provider 与 OSS 完成后只做 prepare:使用 owner + operation kind + job ID + stable slot 派生 resource/asset ID,构造可选 object/binding、候选 layout 和 compact job result,然后一次调用 `persist_editor_generation_result_and_return`。该 procedure 必须在当前事务快照校验 owner、job kind、由 `request_payload_json` 重算的 fingerprint 与未过期 lease,最后与业务记录一起完成 job 和 receipt。任一验证、binding 或 canvas CAS 失败都回滚全部数据库事实;worker 不得随后再调用 `complete_external_generation_job_and_return`。前端轮询单 job 到 completed 后重新读取项目快照,不从队列 payload、receipt 或本地临时响应重建正式图层。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index 403a65806..02acb252b 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -17,7 +17,7 @@ - `生成角色动作` - `快速编辑` -`角色动画生成面板` 同步纳入本次生成类面板交互统一:点击角色图只聚焦图层,不自动弹出底部重绘或角色动画面板;点击 `生成动画` 后像新建图片一样创建 `角色动作` 画布占位,面板跟随占位底部,参考图首行、单文本无边界、参数按钮向上弹出、生成按钮明确展示泥点。 +`角色动画生成面板` 同步纳入本次生成类面板交互统一:点击角色图只聚焦图层,不自动弹出底部改造或角色动画面板;点击 `生成动画` 后像新建图片一样创建 `角色动作` 画布占位,面板跟随占位底部,参考图首行、单文本无边界、参数按钮向上弹出、生成按钮明确展示泥点。 `快速编辑` 由选中图片后的浮动工具栏显式打开,图片类素材统一进入框选区域 + 单提示词 + 比例 / 尺寸 + 模型选择的修改面板,不再恢复原来源生成器,也不展示额外参考图。单个拆分 `icon` 不支持快速编辑,完整 `icon-spritesheet` 与 `icon-spec` 图标规范仍按普通图片支持快速编辑。 ## 统一布局 @@ -42,7 +42,7 @@ 9. 生成规范下的角色规范、图标规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。 10. 图标规范只使用 `specType="icon"`,历史 `specType="ui"` 快照在恢复边界迁移为 `icon`。表单字段使用 `playSetting / artStyle`,界面标题继续使用「玩法设定 / 美术风格」。两项初始为空且必填,客户端提交前统一 trim 并拒绝空白值;每项独立支持一键优化、处理中锁定自身、成功后单次撤销,操作行最右侧按 Unicode 字符实时显示 `当前数/200`。撤销必须恢复优化前的原始输入(包括首尾空白);手工编辑后立即清除该字段已经失效的撤销快照,失败只保留当前文本与仍然有效的旧撤销快照。LLM 返回空文本、超长文本、Markdown / 结构化内容,或 finish reason 明确表示截断、过滤、失败时,后续有界重试必须携带上次无效输出和对应修正要求,不能把未完成前缀当作成功结果。优化请求必须绑定发起时的生成对象 ID 和请求代次;活动对象身份只在 React effect 提交后更新,并在 cleanup 中失效,丢弃的并发 render 不得改变请求归属;对象切换或新请求取代旧请求后,旧成功或失败结果都不得更新当前面板。任一项处理中或任一项为空时禁用生成。字段标题使用真实 label 关联 textarea,不得把优化 / 撤销按钮包进 label。控件继续使用平台默认样式,不新增图标规范专属 CSS。 11. 图标规范最终生成改走 `POST /api/editor/icon-specs/generations`。前端只提交业务字段和统一参考图 / 项目完成包络,不拼最终 prompt,不提交 `kind / assetKind / ExtraParam`;可选参考图字段为 `referenceId`,只允许当前 owner 的项目资源 ID 或素材 ID。后端固定以 `kind=spec / assetKind=icon-spec / gpt-image-2 / 16:9·2K` 执行图片生成。inline 路径校验业务字段和 `referenceId` 后,补全 `ExtraParam` 与最终 prompt 并交给共享图片生成执行器;queue 路径在预校验后按 `gpt-image-2 / 2K` 运行时定价冻结价格,再以独立 `editor_icon_spec_generation` job kind 入队原始业务载荷。worker 使用入队价格和当前 claim attempt 的计费上下文,重新解析载荷、校验当前 owner 与引用事实,再补全 `ExtraParam` 并调用同一共享图片生成执行器,避免排队期间状态变化产生 TOCTOU。 -12. 图片快速编辑不展示用户可添加的额外参考图入口;原图已登记的 `resourceId` 或 `sourceAssetId` 始终作为 `/api/editor/images/edits` 的 `sourceReferenceId`,绘制了红框和序号的标注图上传后只作为辅助 `referenceImageSrcs`。从生成型图片编辑 V2 快照恢复且在面板中可见的附加参考图也继续作为辅助引用,不得替代主来源身份。 +12. 图片快速编辑不展示用户可添加的额外参考图入口;原图已登记的 `resourceId` 或 `sourceAssetId` 始终作为 `/api/editor/images/edits` 的 `sourceReferenceId`,绘制了红框和序号的标注图上传后只作为辅助 `referenceImageSrcs`。`image.edit` 是原位替换记录,不提供“改造”恢复入口;用户需要继续修改时重新打开当前图片的快速编辑。 13. 快速编辑打开后,画布视口应调整到原图完整展示,且面板位于原图下方并不遮挡原图;原图右侧显示竖向框选工具,支持矩形、椭圆和画笔自由框选。快速编辑进入时不默认启用框选工具,点击工具后出现选中态并保持高亮,再点同一工具取消启用;红色圈选框使用细描边。每完成一次框选,红色圈选框按完成顺序标注 `1 / 2 / 3...`,并在快速编辑提示词中追加一行 `对N号红色圈选框里的内容做以下修改:`。 ## 参数交互 @@ -72,7 +72,7 @@ - 生成规范类图片固定使用 `16:9·2K · gpt-image-2`。这三个参数在面板底部沿用可编辑参数按钮的胶囊样式展示,但控件保持禁用不可点击,不提供比例、尺寸或模型修改入口。 - 宣发素材的 `游戏首图`、`详情五图`、`运营海报` 固定使用 `gpt-image-2`。面板底部只显示禁用态 `gpt-image-2` 模型胶囊和生成按钮,不出现 `nanobanana2` 选项;后端收到 `publication-material` 旧请求时也必须强制归一为 `gpt-image-2`。 - 图片快速编辑保留一个提示词输入框,并展示与常规图片生成一致的比例 / 尺寸和模型选择;提示词 placeholder 为 `你希望素材如何修改?`,提交按钮显示 `修改`,不展示额外参考图控件。打开面板时优先继承原图关联生成器记录的模型、比例和尺寸;没有关联生成器时使用图层模型,并按原图真实分辨率推导比例和尺寸;模型缺失或已不受支持时回落到当前默认图片模型。切换模型后只展示该模型支持的参数,不兼容的当前值回落到该模型默认值,按钮泥点按选定模型和尺寸同步刷新。提交时必须同时传递 `model / aspectRatio / imageSize`;后端按模型选择 provider 协议:`nanobanana2` 使用 `generateContent + inline_data`,`gpt-image-2` 使用 `/v1/images/edits` multipart,不能把 nanobanana 模型 ID 发往 GPT edits 端点。 -- 图片改造入口也要保持同样约束:恢复历史参数时优先使用 `generationInputs` 中保存的模型、比例和尺寸,失败回退到当前关联参数后再映射为实际 `size`,并一并回传到编辑请求;`canvasCompletion` 的落位尺寸也应与实际输出目标分辨率一致,避免使用源图尺寸伪造改造结果的参数。 +- 生成类产物的“改造”按原 `action` 恢复对应 generation dialog,并沿用该 action 的参数校验、生成请求、占位和完成回填合同;它生成新产物,不转入 `/api/editor/images/edits` 原位替换链路。`image.edit` 只属于已知配方,不属于可改造 action。 - 不再在底部常驻展开全部可选项。 ## 泥点显示 @@ -81,7 +81,7 @@ - 生成按钮内明确显示 `N泥点`,例如 `生成12泥点`、`生成40泥点`;不使用泥点图标替代文字。 - 画板内所有会提交外部生成任务的按钮,展示价格都必须从模型定价配置函数推导,不允许在按钮文案中散落固定泥点数字;生成请求不提交 `priceMudPoints`,修改后端模型定价配置后,后端实际扣费和前端下一次拉取到的按钮展示应同步变化。 - 后端所有编辑器外部生成入口必须按运行时模型定价配置计算价格后进入 `execute_billable_asset_operation_with_cost`:`/api/editor/icon-specs/generations`、`/api/editor/images/generations`、`/api/editor/images/edits`、`/api/editor/icon-spritesheets/generations`、`/api/editor/ui-designs/assets/extractions`、`/api/editor/videos/generations`、`/api/editor/character-animations/generations`、`/api/editor/audios/sound-effects/generations`、`/api/editor/audios/background-music/generations` 都不能只展示价格而不真实预扣钱包。 -- 当前前端展示价统一收口在 `ImageCanvasGenerationModel.ts`:生成图片、生成角色、快速编辑、重绘、宣发素材走 `calculateEditorImageModelPrice` / `calculateEditorImageGenerationPrice`;生成图标素材走 `calculateEditorIconSpritesheetPrice`;生成 UI 设计图走 `calculateEditorUiDesignPrice`;生成规范走 `calculateEditorSpecGenerationPrice`;生成视频走 `calculateEditorVideoPrice`;角色动作走 `calculateCharacterAnimationPrice`;音效 / 背景音乐分别走 `calculateEditorSoundEffectPrice` / `calculateEditorBackgroundMusicPrice`。这些函数启动时会被后端下发配置覆盖,接口失败时才使用内置兜底。定价配置只按模型区分,不按图片 / 规范、视频 / 动作用途拆分;图片类价格必须同时传入模型和 `imageSize`,规范固定读取 `gpt-image-2` 的 `2K` 定价。 +- 当前前端展示价统一收口在 `ImageCanvasGenerationModel.ts`:生成图片、生成角色、快速编辑、图片类改造、宣发素材走 `calculateEditorImageModelPrice` / `calculateEditorImageGenerationPrice`;生成图标素材走 `calculateEditorIconSpritesheetPrice`;生成 UI 设计图走 `calculateEditorUiDesignPrice`;生成规范走 `calculateEditorSpecGenerationPrice`;生成视频走 `calculateEditorVideoPrice`;角色动作走 `calculateCharacterAnimationPrice`;音效 / 背景音乐分别走 `calculateEditorSoundEffectPrice` / `calculateEditorBackgroundMusicPrice`。这些函数启动时会被后端下发配置覆盖,接口失败时才使用内置兜底。定价配置只按模型区分,不按图片 / 规范、视频 / 动作用途拆分;图片类价格必须同时传入模型和 `imageSize`,规范固定读取 `gpt-image-2` 的 `2K` 定价。 - 泥点配置默认值独立收口到 `server-rs/crates/api-server/config/editor-generation-pricing.default.json`,JSON 结构为 `models[model] = { unit, price | prices }`;后台“模型定价”页面通过 `POST /admin/api/editor-generation-pricing` 保存完整配置到 SpacetimeDB `editor_generation_pricing_config` 全局表,主站通过 `GET /api/editor/generation-pricing` 动态读取当前配置。后台必须展示定价单位:`perGeneration` 显示“按次”,`perSecond` 显示“按秒”。 - 生成图标素材、生成视频、角色动画、音效和背景音乐请求只提交生成参数,不提交价格字段;后端按归一后的模型、清晰度、时长或音频模型重新计算并扣费。 - 进入外部生成队列的任务在入队时冻结后端计算出的价格;worker 的预扣、退款、响应 `priceMudPoints` 和资产 `generationCostMudPoints` 必须使用同一入队价格,后台修改模型定价只影响之后入队的任务。 @@ -99,11 +99,11 @@ - 入口状态:新入口打开后设置对应 activeTool,清空画布选中、右键菜单、快速编辑、角色动画和其它生成来源选择状态;只切换当前面板,不删除已有生成占位。 - 生成中的占位图允许通过键盘 `Delete` / `Backspace` 删除;不额外增加画布上的可见删除按钮。用户删除后,后续异步成功或失败回写不得重新创建该生成对象。 - 待生成占位的空白样式按生成类型区分:视频使用视频图标和视频角标,角色形象使用角色图标和角色角标,角色动作使用角色动作图标和动作角标,音效使用音效图标和音效角标,背景音乐使用音乐图标和背景音乐角标。 -- 待生成占位的图标语义必须与底部入口或触发入口保持一致:生成图片用图片图标,生成规范用规范图标,生成角色形象用角色图标,生成图标素材用图标网格,生成 UI 设计图用应用窗口,宣发素材用宣发图标,快速编辑用闪光图标,不能默认全部回退为图片图标。 +- 待生成占位的图标语义必须与底部入口或触发入口保持一致:生成图片用图片图标,生成规范用规范图标,生成角色形象用角色图标,生成图标素材用图标网格,生成 UI 设计图用应用窗口,宣发素材用宣发图标,不能默认全部回退为图片图标。快速编辑不创建生成占位,因此不定义快速编辑占位图标。 - 图片类待生成占位尺寸必须与面板当前比例和尺寸同步:普通图片、角色形象、图标素材、UI 设计图按当前 `aspectRatio + imageSize` 的统一业务像素矩阵计算;所有共用规格都不因模型不同改变画布占地,例如 `nanobanana2` 与 `gpt-image-2` 的 `16:9·2K` 占位和普通最终结果都为 `2048 x 1152`。provider 请求尺寸可因接口合法值不同,但不得泄漏为正常完成的画布尺寸;回图更大时只允许缩小和轻微裁切,回图低于目标时保留实际像素并告警,禁止放大伪造所选档位;生成规范固定为 `16:9·2K`,占位为 `2048 x 1152`;宣发素材按 workflow 输出尺寸创建占位。 - 视频待生成占位必须与面板当前比例和清晰度同步:默认 `16:9 · 480p` 为 `854 x 480`,切换比例、`720p` 或 `1080p` 后按比例和清晰度重算偶数宽度;调整参数时保持占位中心点不变。 - 面板中用户修改比例、尺寸或清晰度后,已有空白待生成占位立即同步更新 `width / height / originalWidth / originalHeight`,且保持中心点不跳动。 -- 快速编辑点击修改后不创建独立 `Quick Edit Generator` 画布生成占位;当前快速编辑面板显示修改中,生成成功后结果直接覆盖源图,失败时保留当前面板并显示错误。用户选定的比例和尺寸是新的业务目标分辨率,覆盖旧的“始终保持源图精确分辨率”约束;替换时更新图层原始分辨率,并保持图层中心位置不跳动。需要新建占位的是生成图片、生成视频、重绘、去背景和角色动作等会产出新图层的入口。 +- 快速编辑点击修改后不创建独立 `Quick Edit Generator` 画布生成占位;当前快速编辑面板显示修改中,生成成功后结果直接覆盖源图,失败时保留当前面板并显示错误。用户选定的比例和尺寸是新的业务目标分辨率,覆盖旧的“始终保持源图精确分辨率”约束;替换时更新图层原始分辨率,并保持图层中心位置不跳动。需要新建占位的是生成图片、生成视频、按原 action 执行的改造、去背景和角色动作等会产出新图层的入口。 - 任何会打开画布内 composer / 面板的入口,必须在面板渲染后通过统一 overlay 可见性校正检查真实 DOM 矩形;如果面板超出画布视口,或底部工具栏 / 左下 dock 会遮住面板,就只平移当前 viewport 让面板完整进入安全区域。新增生成类入口不要在按钮 handler 里手写单独的避让偏移。 - 画布Agent对话入口例外:画布Agent对话(右侧对话面板)触发的生成不创建"即将生成"画布占位,生成中状态由对话消息流内的条目承载(阶段提示、模型标注、进行中动画);生成完成后结果图才按统一 placement 避让模型落画板为新图层,并在对话消息内显示缩略图。工具失败时也必须在对话消息内保留失败 generation record,而不是只弹一次性错误气泡。该例外仅限画布Agent对话入口,其余生成入口仍必须先落占位。详见 docs/【编辑器】画布Agent对话面板-2026-07-03.md。 @@ -113,10 +113,10 @@ - 占位图的生成器名称 / 原始尺寸、图片图层右上角素材类型标签、查看信息按钮和悬浮尺寸标签都按 viewport 反向缩放,画布缩小时保持屏幕可读尺寸。 - 查看信息按钮固定使用圆形 `i` 图标,不使用中括号、花括号或文本符号样式。 - 视频 / 角色 / 角色动作 / 音效 / 背景音乐待生成占位的角标同样按 viewport 反向缩放,不随画布缩放变小。 -- 已生成角色图、角色动作图或其它生成结果图被点击时只选中图层并收起已有生成输入框;重绘、快速编辑和生成动画面板必须由对应工具栏按钮或右键菜单显式打开。快速编辑使用统一正向白名单,只支持普通静态图片、角色图、规范图、完整图标图集、UI 设计图、宣发图和视频;单个拆分图标(backend reject)、角色动作 / 序列帧、音效与背景音乐不显示快速编辑入口,提交层也必须拒绝绕过入口的调用。 +- 已生成角色图、角色动作图或其它生成结果图被点击时只选中图层并收起已有生成输入框;改造、快速编辑和生成动画面板必须由对应工具栏按钮或右键菜单显式打开。快速编辑使用统一正向白名单,只支持普通静态图片、角色图、规范图、完整图标图集、图标规范、UI 设计图、宣发图和游戏场景图;视频、单个拆分图标(backend reject)、角色动作 / 序列帧、音效与背景音乐不显示快速编辑入口,程序化打开和提交层也必须拒绝绕过入口的调用。 - 角色图层打开“生成动作”后再点击“改造”,必须重新打开角色形象生成器;动作生成对话框只把角色图层作为输入来源,不得被识别为该角色图层自身的来源生成器。 - 角色动作结果图层点击“改造”时,V2 必须通过 `references[id="source"]` 找回原角色图层,legacy 数据才允许以 `sourceResourceId` 回退;关联原角色已不存在时应显示明确提示,不得无响应或降级成图片生成器。 -- `改造` 覆盖图片、规范、角色、图标、UI、宣发、游戏场景、视频、音效、背景音乐、角色动作和生成型图片编辑。有效 V2 只按 `action + fields[].id + references[].id` 恢复,引用只匹配当前画布图层;面板直接上传引用和已移出画布的引用不恢复。V2 不新增后续版本,读取时统一经 action 级 runtime decoder 原地收紧:已存在但未知、非法、已下线或与当前模型能力不兼容的参数统一回落到该 action 当前默认值,历史 Veo 也回落到当前默认视频模型;图片比例 / 尺寸按回落后的模型联动校验,角色动作帧数 / 时长按完整档位成对校验。发生参数回落时显示明确告警,再次提交和新快照只使用规范值并继续保存为 `version: 2`。服务端以实际媒体时长覆盖 V2 配方时必须保留 `fields[id="durationSeconds"]`,并写入归一后的有限数值,不能改写为无 `id` 的 legacy 展示字符串。可重新选择的引用缺失时打开面板、留空槽位并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的 action 也始终按 capability 保留改造入口,source 缺失时点击后显示不可替换原因并拒绝改造,运行期间来源变化时仍必须复检并拒绝。有效 V2 不得因引用缺失降级到 legacy。生成型图片编辑 V2 中已持久化的附加 `reference` 应恢复到可见参考槽,并让再次提交的模型、比例、尺寸、像素尺寸、参考图和新快照保持一致;普通快速编辑仍不得提交未展示的隐藏参考图。视频快速编辑必须把实际送入请求的源视频同步保存为 `references[id="videoReference"]`,不能只依赖 `sourceResourceId`;视频 V2 同步保存并恢复 `webSearchEnabled`。历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。V2 结构水合必须完整保留 `version/action`、字段与引用 `id`、有限数字、布尔值和无标签引用;一旦出现 `version` 或 `action` 却不满足 V2 合同,必须失败关闭,禁止降级成 legacy。 +- `改造` 覆盖图片、规范、角色、图标、UI、宣发、游戏场景、视频、音效、背景音乐和角色动作等可改造生成 action;它按原 `action` 恢复对应 generation dialog 并生成新产物。`image.edit` 是图片快速编辑的原位替换配方,只属于已知 action,不属于可改造 action;其结果继续通过快速编辑修改,不恢复成 generation dialog。有效 V2 只按 `action + fields[].id + references[].id` 恢复,引用只匹配当前画布图层;面板直接上传引用和已移出画布的引用不恢复。V2 不新增后续版本,读取时统一经 action 级 runtime decoder 原地收紧:已存在但未知、非法、已下线或与当前模型能力不兼容的参数统一回落到该 action 当前默认值,历史 Veo 也回落到当前默认视频模型;图片比例 / 尺寸按回落后的模型联动校验,角色动作帧数 / 时长按完整档位成对校验。发生参数回落时显示明确告警,再次提交和新快照只使用规范值并继续保存为 `version: 2`。服务端以实际媒体时长覆盖 V2 配方时必须保留 `fields[id="durationSeconds"]`,并写入归一后的有限数值,不能改写为无 `id` 的 legacy 展示字符串。可重新选择的引用缺失时打开面板、留空槽位并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的 action 也始终按 capability 保留改造入口,source 缺失时点击后显示不可替换原因并拒绝改造,运行期间来源变化时仍必须复检并拒绝。有效 V2 不得因引用缺失降级到 legacy。普通快速编辑不得提交未展示的隐藏参考图。普通视频生成继续按现有 V2 合同保存并恢复 `references[id="videoReference"]` 与 `webSearchEnabled`,该合同不表示支持视频快速编辑。历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。V2 结构水合必须完整保留 `version/action`、字段与引用 `id`、有限数字、布尔值和无标签引用;一旦出现 `version` 或 `action` 却不满足 V2 合同,必须失败关闭,禁止降级成 legacy。 - 配方元数据不等于改造 capability。完美像素、手动去背景、裁扩、手动图集拆分以及图标 / UI 自动切片分别保存 `image.perfect-pixel`、`image.remove-background`、`image.crop-expand`、`spritesheet.split`,统一使用 `fields: []`;有正式来源行时只保留不可编辑的 `references[id="source"]` 权威来源,没有正式行时保留空引用。这四个确定性 action 永不显示或执行“改造”,历史 `pixel-art-snap-*` 结果也按 task identity 拒绝改造。整张生成图集仍保留原生成 action;生成任务内部自动抠图仍属于同源后处理,不提升为独立用户 action。 - 任何会移除画布图层的入口,包括删除、右键剪切和素材库删除关联素材,都必须同步清理该图层关联的生成面板和派生状态,不得在保存或刷新后恢复成孤立占位。 @@ -125,7 +125,7 @@ - 生成占位图和生成器对话框不是临时浮层,必须作为画布布局数据保存。 - 保存时在现有画布布局数组中追加 `itemType: "generation-dialog"` 项,记录生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和 `generatedLayerId`。 - 生成成功后仍保留生成器快照;画布渲染优先用 `generatedLayerId` 锚定到成品图层,不再重复显示灰色占位框。 -- 一次生成任务产生多个可复用产物时,已实际生成的产物都必须由后端登记为项目资源并随同一次完成快照加入画布,不能由前端临时追加。角色形象、图标 spritesheet 和 UI 素材提取在透明背景处理正常成功时同时回填纯色背景原图与透明后处理结果,UI 素材提取继续一并回填拆分成功的素材;`generatedLayerId` 锚定透明后处理主结果,附属产物从主结果右侧开始错开放置。透明背景处理最终失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建不存在的透明处理图,图标和 UI 也不继续拆分;角色重绘遵循同一规则。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。 +- 一次生成任务产生多个可复用产物时,已实际生成的产物都必须由后端登记为项目资源并随同一次完成快照加入画布,不能由前端临时追加。角色形象、图标 spritesheet 和 UI 素材提取在透明背景处理正常成功时同时回填纯色背景原图与透明后处理结果,UI 素材提取继续一并回填拆分成功的素材;`generatedLayerId` 锚定透明后处理主结果,附属产物从主结果右侧开始错开放置。透明背景处理最终失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建不存在的透明处理图,图标和 UI 也不继续拆分;角色改造遵循同一规则。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。 - 多产物任务的可恢复中间产物还必须进入账号素材库,未传 `assetFolderId` 时落默认“项目”文件夹,并在抠图、尺寸恢复、抽帧或拆分前完成登记。图片修改保存模型对齐尺寸的原始输出;角色动作把绿幕预览视频保存为一个素材,逐帧绿幕源图只保留在同一任务 OSS 路径,避免素材库一次新增 32 至 48 张帧图。普通图片、去背景和音频等没有独立上游中间产物的任务不重复复制最终结果。 - 图标和 UI 图集自动拆分只在透明图集成功后执行,属于非阻断附加动作;识别或切片持久化失败时整张透明图集仍完成并回填,前端通过 `sliceWarning` toast 提示用户可手动重试。透明背景最终失败使用通用 `warning.code/reason`,该情况不会进入拆分,因此与 `sliceWarning` 互斥;风格归一化或像素规整产生的通用 `warning` 则可与 `sliceWarning` 并存,inline 与队列两条链路都必须把两者拼成同一条提示展示,不得只取通用告警。`sliceWarning` 只表示透明图集成功但自动拆分失败,其 `reason` 原始契约保持不变。inline 响应、worker 队列终态和刷新后的任务列表必须使用同一 warning 语义,不能把已完成或降级完成的任务标记为失败。 - 画布顶部的生成 / 参考图选择 warning toast 保留手动关闭按钮,并在每次 warning 事件进入显示态后 `3` 秒自动消失,避免一次错误提示持续遮挡画布。同样文案在未消失时再次触发也必须重新计时,不能沿用上一次事件的剩余时间。 @@ -194,12 +194,12 @@ - 图片快速编辑底部左侧展示比例 / 尺寸组合选择,右侧展示模型选择和 `修改` 按钮;原图已登记业务 ID 作为 `sourceReferenceId`,红框序号标注图作为内部辅助 `referenceImageSrcs`,不展示额外参考图条。单个拆分图标不展示快速编辑入口,完整图标图集与图标规范仍可快速编辑。 - 快速编辑打开后画布自动缩放平移到原图完整展示,并让面板位于原图下方且不遮挡原图;原图右侧出现竖向矩形 / 椭圆 / 画笔自由框选按钮。进入快速编辑不默认启用框选,点击工具启用并保持高亮,再点同一工具取消;完成框选后画布红色细框显示连续序号,输入框同步追加 `对N号红色圈选框里的内容做以下修改:`。 - 快速编辑提交前保留提示词里对原图的 `原图`、`当前图片`、`当前图` 或 `图1` 引用,不再改写成 `图N`。 -- 快速编辑提交给后端时主来源只使用原图已登记的 `sourceReferenceId`;已绘制红框和序号的标注图,以及从生成型图片编辑 V2 快照恢复且在面板中可见的附加参考图,均作为辅助 `referenceImageSrcs`,不得冒充目标图层主来源。 +- 快速编辑提交给后端时主来源只使用原图已登记的 `sourceReferenceId`;已绘制红框和序号的标注图作为辅助 `referenceImageSrcs`,不得冒充目标图层主来源。`image.edit` 不通过“改造”恢复历史附加引用。 - 生成中的占位图聚焦后可用 `Delete` / `Backspace` 删除;删除后异步结果不再落回画布,也不显示额外删除 UI。 - 快速编辑不创建生成中占位图;提交后当前面板显示修改中,异步结果只允许回填到源图。 - 生成视频 / 角色形象 / 角色动作 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon。 - 新建空白待生成占位的尺寸必须和面板参数一致;图片类修改比例 / 尺寸、视频修改清晰度后,画布空白占位同步变更且保持中心点。 -- 点击角色图只选中图层并显示工具栏,不自动弹出重绘、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。 +- 点击角色图只选中图层并显示工具栏,不自动弹出改造、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。 - 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后画布自动缩放平移到素材完整展示,并在素材下方显示 UI 素材提取面板。UI 素材提取默认启用矩形框选,右侧工具栏与快速编辑统一,当前启用工具按钮保持高亮,点击同一工具可取消启用态;面板提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选截图预览、固定模型 `gpt-image-2`、计划规格和提取按钮泥点,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用自动决策纯色背景素材提取提示词生成 spritesheet。框选数量不超过阈值时提交 `1:1·1K` 参数,超过阈值时提交 `1:1·2K` 参数;后端按 gpt-image-2 对应尺寸计算扣费,保存纯色背景源图后调用 BgFilter 按默认抠图模型 `birefnet` 透明化。正常透明化成功时复用图标素材拆分流程,把透明 spritesheet 图集和拆分成功的素材放到画布;透明背景处理最终失败时只把 provider 原图放到画布,不继续拆分,并显示通用 warning。 - 生成游戏音效面板底部不显示字段标题,左下角只有一个时长参数按钮,选项为 Vidu duration `2-10` 秒;右下角固定模型胶囊显示 `Vidu` 并紧贴生成按钮。 - 生成游戏背景音乐面板右下角固定模型胶囊显示 `Suno` 并紧贴生成按钮;`make_instrumental` 不在 UI 中展示。 diff --git a/src/components/image-editor/ImageCanvasContextMenusView.test.tsx b/src/components/image-editor/ImageCanvasContextMenusView.test.tsx index cd4e225ab..634ca8275 100644 --- a/src/components/image-editor/ImageCanvasContextMenusView.test.tsx +++ b/src/components/image-editor/ImageCanvasContextMenusView.test.tsx @@ -371,6 +371,11 @@ describe('ImageCanvasContextMenusView', () => { mediaType: 'audio' as const, assetKind: 'background-music' as const, }, + { + title: '视频', + mediaType: 'video' as const, + assetKind: 'video' as const, + }, ])('hides quick edit from both menus for $title', (overrides) => { const layer = createLayer(overrides); renderContextMenus({ diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index 313160036..e0a5406ae 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -1795,7 +1795,6 @@ export function ImageCanvasGenerationComposerView({ {quickEditPanel && quickEditSourceLayer && quickEditPanelStyle ? ( { isQuickEditSupportedLayer({ ...buildSourceLayer(), mediaType: 'video', + assetKind: null, + }), + ).toBe(false); + expect( + isQuickEditSupportedLayer({ + ...buildSourceLayer(), + mediaType: 'image', assetKind: 'video', }), - ).toBe(true); + ).toBe(false); }); it('adds reference image semantics and snapshots for spec generation references', () => { diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 409572301..6e7fee548 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -712,7 +712,6 @@ export function buildQuickEditModelOptions(currentModel: string) { const QUICK_EDIT_SUPPORTED_MEDIA_TYPES = new Set([ undefined, 'image', - 'video', ]); const QUICK_EDIT_SUPPORTED_ASSET_KINDS = new Set([ @@ -725,7 +724,6 @@ const QUICK_EDIT_SUPPORTED_ASSET_KINDS = new Set([ 'publication-material', 'ui-design', 'scene', - 'video', ]); export function isQuickEditSupportedLayer(layer: CanvasLayer) { diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index f77074663..7a7ec1dd8 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -424,6 +424,30 @@ describe('ImageCanvasGenerationSubmissionModel', () => { }); }); + it('rejects persisted video quick-edit dialogs during submission planning', () => { + const sourceLayer = createLayer({ + title: '历史视频', + src: 'https://assets.example.test/history.mp4', + mediaType: 'video', + assetKind: 'video', + originalWidth: 854, + originalHeight: 480, + }); + + expect(() => + buildImageGenerationSubmissionPlan({ + dialog: { + mode: 'quick-edit', + prompt: '把当前画面改成雨天', + status: 'idle', + sourceLayerId: sourceLayer.id, + }, + layers: [sourceLayer], + nextGeneratedIndex: 4, + }), + ).toThrow('当前素材类型不支持快速编辑'); + }); + it('does not send image style from an incomplete quick-edit snapshot', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index 14edd6af1..37e7c71bb 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -54,6 +54,7 @@ import { IMAGE_MODEL_GPT_IMAGE_2, inferEditorImageAspectRatio, inferEditorImageSizeLabel, + isQuickEditSupportedLayer, normalizeEditorImageModel, resolveCharacterAnimationSourceImageSrc, resolveEditorImageGenerationPixelSize, @@ -577,6 +578,9 @@ export function buildImageGenerationSubmissionPlan({ if (!sourceLayer) { throw new Error('未找到要改造的原图'); } + if (!isQuickEditSupportedLayer(sourceLayer)) { + throw new Error('当前素材类型不支持快速编辑'); + } const basePrompt = dialog.prompt.trim() || '快速编辑图片'; const normalizedQuickEditPrompt = basePrompt; const requestedImageModel = normalizeEditorImageModel( diff --git a/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx b/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx index a7d9e6b28..60b2234b6 100644 --- a/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx +++ b/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx @@ -6,30 +6,12 @@ import { describe, expect, it, vi } from 'vitest'; import { setPlainTextEditorValue } from '../common/AutoGrowTextArea.test-utils'; import type { - CanvasLayer, CharacterReferenceImage, QuickEditPanelState, UploadTarget, } from './ImageCanvasEditorTypes'; import { ImageCanvasQuickEditPanelView } from './ImageCanvasQuickEditPanelView'; -function createLayer(): CanvasLayer { - return { - id: 'layer-a', - resourceId: 'resource-a', - title: '源图', - src: 'data:image/png;base64,c291cmNl', - x: 0, - y: 0, - width: 320, - height: 180, - originalWidth: 320, - originalHeight: 180, - zIndex: 1, - sourceType: 'uploaded', - }; -} - function QuickEditPanelHarness({ initialPanel, onSubmit = vi.fn(), @@ -50,7 +32,6 @@ function QuickEditPanelHarness({
; isReferenceMenuOpen?: boolean; @@ -92,7 +90,6 @@ function syncPanelFromDialogUpdate( export function ImageCanvasQuickEditPanelView({ panel, - sourceLayer, style, referenceButtonRef, isReferenceMenuOpen = false, @@ -106,7 +103,6 @@ export function ImageCanvasQuickEditPanelView({ hasPendingImageReferenceUploads = false, onSubmit, }: ImageCanvasQuickEditPanelViewProps) { - const isImageQuickEdit = sourceLayer.mediaType !== 'video'; const referenceCount = panel.quickEditReferences?.length ?? 0; const canAddReferences = referenceCount < @@ -148,7 +144,7 @@ export function ImageCanvasQuickEditPanelView({ hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} onSubmit={onSubmit} dialogLabel="快速编辑图片" - includeDimensions={isImageQuickEdit} + includeDimensions={true} includeModel={true} includeReferences={false} promptLabel="快速编辑提示词" diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx index 2bf656347..2f2146ed2 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -444,7 +444,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { within(videoToolbar) .getAllByRole('button') .map((button) => button.getAttribute('aria-label')), - ).toEqual(['快速编辑', '改造', '下载按钮']); + ).toEqual(['改造', '下载按钮']); cleanup(); renderSelectedToolbar({ diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 7c6531de2..a16367761 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -337,14 +337,12 @@ function SubmissionWorkflowHarness({ quickEditPanel, quickEditSourceLayer, quickEditSelectionState: initialQuickEditSelectionState, - canvasGenerationDialogs: dialogs.canvasGenerationDialogs, setQuickEditPanel, characterAnimationPanel, characterAnimationDialog: activeCanvasDialog, characterAnimationSourceLayer, setCharacterAnimationPanel, setGenerateDialog: dialogs.setGenerateDialog, - openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog, updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById, hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById, getCanvasGenerationDialogById: dialogs.getCanvasGenerationDialogById, @@ -1496,47 +1494,15 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { expect(refreshWalletBalance).toHaveBeenCalledTimes(1); }); - it('submits video quick edits through the video API with the source video', async () => { - const applyProjectSnapshot = vi.fn(); - const upsertGeneratedAsset = vi.fn(); - generateEditorVideoMock.mockResolvedValueOnce( - createVideoGenerated({ - prompt: '把当前图改成雨天', - project: { - projectId: 'editor-project-1', - title: '项目', - coverImageSrc: null, - viewport: { x: 0, y: 0, scale: 1 }, - layers: [], - resources: [], - createdAt: '2026-06-27T00:00:00.000Z', - updatedAt: '2026-06-27T00:00:00.000Z', - }, - asset: { - assetId: 'asset-video-quick-edit', - folderId: 'user-1:asset-folder:project', - label: '源视频 快速编辑', - imageSrc: 'https://assets.example.test/generated-video.mp4', - thumbnailSrc: 'https://assets.example.test/generated-video-cover.png', - width: 854, - height: 480, - sourceType: 'generated', - assetKind: 'video', - }, - }), - ); + it('rejects direct video quick-edit submission without calling generation APIs', () => { render( { size: '854x480', model: 'gpt-image-2', status: 'idle', - quickEditReferences: [ - { - id: 'ref-style', - label: '风格参考', - src: 'https://assets.example.test/style.png', - }, - ], }} />, ); fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); - await waitFor(() => { - expect(generateEditorVideoMock).toHaveBeenCalledWith( - expect.objectContaining({ - prompt: '把当前图改成雨天', - model: 'seedance2.0-fast', - aspectRatio: '16:9', - durationSeconds: 4, - resolution: '480p', - mode: 'std', - sound: 'off', - referenceVideoSrcs: [ - 'generated-character-drafts/editor/generation-references/reference.png', - ], - generationInputs: expect.objectContaining({ - version: 2, - action: 'video.generate', - references: [ - { - id: 'videoReference', - title: '参考视频 1', - label: '源视频', - refType: 'project-resource', - refId: 'resource-source', - }, - ], - }), - assetFolderId: 'project', - assetLabel: '源视频 快速编辑', - }), - ); - }); - expect(generateEditorVideoMock.mock.calls[0]?.[0]).not.toHaveProperty( - 'referenceImageSrcs', - ); + expect(editEditorImageMock).not.toHaveBeenCalled(); expect(generateEditorImageMock).not.toHaveBeenCalled(); expect(generateEditorCharacterAnimationMock).not.toHaveBeenCalled(); + expect(generateEditorVideoMock).not.toHaveBeenCalled(); + expect(screen.getByTestId('quick-edit').textContent).toContain( + 'failed:把当前图改成雨天:当前素材类型不支持快速编辑', + ); + }); + + it('rejects a hydrated video quick-edit dialog before calling generation APIs', async () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); + fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); + await waitFor(() => { - expect(applyProjectSnapshot).toHaveBeenCalledWith( - expect.objectContaining({ projectId: 'editor-project-1' }), - ); - expect(upsertGeneratedAsset).toHaveBeenCalledWith( - expect.objectContaining({ - assetId: 'asset-video-quick-edit', - thumbnailSrc: 'https://assets.example.test/generated-video-cover.png', - }), + expect(screen.getByTestId('tracked-dialog').textContent).toContain( + 'quick-edit:把当前画面改成雨天:failed:open:当前素材类型不支持快速编辑', ); }); + expect(editEditorImageMock).not.toHaveBeenCalled(); + expect(generateEditorImageMock).not.toHaveBeenCalled(); + expect(generateEditorVideoMock).not.toHaveBeenCalled(); }); it('uploads inline video references and keeps image objectKey and asset URLs', async () => { diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index d97836218..dd34d9b14 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -50,7 +50,6 @@ import type { QuickEditPanelState, SidebarPanel, } from './ImageCanvasEditorTypes'; -import { createQuickEditGenerationDialogDraft } from './ImageCanvasGenerationDialogModel'; import { applyImageEditResultToSourceLayer, createAudioResultLayer, @@ -63,12 +62,9 @@ import { buildCharacterAnimationGenerationInputs, buildQuickEditGenerationInputs, buildUiDesignAssetExtractionGenerationInputs, - buildVideoGenerationInputs, - createCanvasLayerReference, DEFAULT_EDITOR_BGFILTER_SEG_MODEL, DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR, DEFAULT_IMAGE_MODEL, - DEFAULT_VIDEO_MODEL, inferEditorImageAspectRatio, inferEditorImageSizeLabel, isCanvasGenerationDialog, @@ -77,7 +73,6 @@ import { resolveEditorImageGenerationPixelSize, resolveImageGenerationErrorMessage, } from './ImageCanvasGenerationModel'; -import { chooseGenerationPlacement } from './ImageCanvasGenerationPlacementModel'; import { buildCharacterAnimationSubmissionPlan, buildIconSpritesheetGenerationSubmissionPlan, @@ -338,7 +333,6 @@ type GenerationSubmissionWorkflowOptions = { quickEditPanel: QuickEditPanelState | null; quickEditSourceLayer: CanvasLayer | null; quickEditSelectionState: UiAssetExtractionState | null; - canvasGenerationDialogs: CanvasGenerationDialogState[]; setQuickEditPanel: Dispatch>; characterAnimationPanel: CharacterAnimationPanelState | null; characterAnimationDialog: CanvasGenerationDialogState | null; @@ -347,9 +341,6 @@ type GenerationSubmissionWorkflowOptions = { SetStateAction >; setGenerateDialog: Dispatch>; - openCanvasGenerationDialog: ( - dialog: Omit, - ) => string; updateCanvasGenerationDialogById: ( dialogId: string, updater: CanvasGenerationDialogUpdater, @@ -650,10 +641,6 @@ function buildRightSideCanvasCompletionPlaceholder( }; } -function isVideoQuickEditSource(layer: CanvasLayer) { - return layer.mediaType === 'video' || layer.assetKind === 'video'; -} - export function useImageCanvasGenerationSubmissionWorkflow({ layers, canvasSize, @@ -662,14 +649,12 @@ export function useImageCanvasGenerationSubmissionWorkflow({ quickEditPanel, quickEditSourceLayer, quickEditSelectionState, - canvasGenerationDialogs, setQuickEditPanel, characterAnimationPanel, characterAnimationDialog, characterAnimationSourceLayer, setCharacterAnimationPanel, setGenerateDialog, - openCanvasGenerationDialog, updateCanvasGenerationDialogById, hasCanvasGenerationDialogById, getCanvasGenerationDialogById, @@ -1451,63 +1436,22 @@ export function useImageCanvasGenerationSubmissionWorkflow({ const normalizedQuickEditModel = normalizeEditorImageModel( quickEditPanel.model, ); - const quickEditOutputSize = isVideoQuickEditSource(quickEditSourceLayer) - ? { - width: quickEditSourceLayer.originalWidth, - height: quickEditSourceLayer.originalHeight, - } - : resolveEditorImageGenerationPixelSize({ - model: normalizedQuickEditModel, - aspectRatio: quickEditAspectRatio, - imageSize: quickEditImageSize, - }); + const quickEditOutputSize = resolveEditorImageGenerationPixelSize({ + model: normalizedQuickEditModel, + aspectRatio: quickEditAspectRatio, + imageSize: quickEditImageSize, + }); const quickEditSize = `${quickEditOutputSize.width}x${quickEditOutputSize.height}`; const normalizedPrompt = quickEditPanel.prompt.trim() || '快速编辑图片'; - let quickEditDialogId: string | undefined; - const shouldUseCanvasPlaceholder = - isVideoQuickEditSource(quickEditSourceLayer); - if (shouldUseCanvasPlaceholder) { - const quickEditDraft = createQuickEditGenerationDialogDraft({ - sourceLayer: quickEditSourceLayer, - prompt: normalizedPrompt, - status: 'generating', - references: quickEditReferences, - model: quickEditPanel.model, - aspectRatio: quickEditAspectRatio, - imageSize: quickEditImageSize, - frame: { - width: quickEditOutputSize.width, - height: quickEditOutputSize.height, - }, - assetLabel: quickEditResultTitle, - }); - const quickEditPlacement = quickEditDraft.placeholder - ? chooseGenerationPlacement({ - canvasSize, - viewport, - frame: quickEditDraft.placeholder, - layers, - generationDialogs: canvasGenerationDialogs, - }) - : quickEditDraft.placeholder; - quickEditDialogId = openCanvasGenerationDialog({ - ...quickEditDraft, - // 中文注释:视频快速编辑仍落到独立视频结果占位,图片快速编辑直接替换源图。 - placeholder: quickEditPlacement, - }); - setQuickEditPanel(null); - selectSingleLayer(null); - } else { - setQuickEditPanel({ - ...quickEditPanel, - aspectRatio: quickEditAspectRatio, - imageSize: quickEditImageSize, - size: quickEditSize, - prompt: normalizedPrompt, - status: 'generating', - errorMessage: undefined, - }); - } + setQuickEditPanel({ + ...quickEditPanel, + aspectRatio: quickEditAspectRatio, + imageSize: quickEditImageSize, + size: quickEditSize, + prompt: normalizedPrompt, + status: 'generating', + errorMessage: undefined, + }); try { const extraReferenceImageSrcs = await Promise.all( @@ -1515,105 +1459,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({ resolveEditorGenerationMediaReference(reference, 'image', projectId), ), ); - const quickEditFrame = quickEditDialogId - ? getGeneratingDialogPlaceholder({ - id: quickEditDialogId, - mode: 'quick-edit', - prompt: normalizedPrompt, - status: 'generating', - }) - : undefined; - const quickEditCanvasCompletionPlaceholder = - quickEditFrame ?? - buildRightSideCanvasCompletionPlaceholder(quickEditSourceLayer, { - width: quickEditOutputSize.width, - height: quickEditOutputSize.height, - originalWidth: quickEditOutputSize.width, - originalHeight: quickEditOutputSize.height, - }); - if (isVideoQuickEditSource(quickEditSourceLayer)) { - const videoModel = DEFAULT_VIDEO_MODEL; - const videoResolution = '480p' as const; - const videoDurationSeconds = 4 as const; - const sourceVideoSrc = await resolveEditorGenerationMediaReference( - quickEditSourceLayer, - 'video', - projectId, - { allowRegisteredIds: false }, - ); - const generationInputs = buildVideoGenerationInputs( - normalizedPrompt, - [createCanvasLayerReference(quickEditSourceLayer)], - { - model: videoModel, - aspectRatio: '16:9', - resolution: videoResolution, - durationSeconds: videoDurationSeconds, - sound: 'off', - }, - ); - const generated = await runEditorGenerationWithWalletRefresh( - generateEditorVideo({ - prompt: normalizedPrompt, - model: videoModel, - aspectRatio: '16:9', - durationSeconds: videoDurationSeconds, - resolution: videoResolution, - mode: 'std', - sound: 'off', - ...(extraReferenceImageSrcs.length - ? { referenceImageSrcs: extraReferenceImageSrcs } - : {}), - referenceVideoSrcs: [sourceVideoSrc], - projectId, - generationInputs, - sourceResourceId: quickEditSourceLayer.resourceId, - assetKind: quickEditSourceLayer.assetKind, - assetFolderId, - assetLabel: quickEditResultTitle, - ...(projectId && quickEditCanvasCompletionPlaceholder - ? { - canvasCompletion: { - dialogId: quickEditDialogId, - title: quickEditResultTitle, - placeholder: quickEditCanvasCompletionPlaceholder, - }, - } - : {}), - }), - onWalletBalanceMayHaveChanged, - ); - if ( - await applyQueuedEditorGenerationProject( - generated, - projectId, - applyProjectSnapshot, - onQueuedGenerationTask, - onWalletBalanceMayHaveChanged, - ) - ) { - return; - } - if (generated.project && applyProjectSnapshot) { - applyProjectSnapshot(generated.project); - if (generated.asset) { - upsertGeneratedAsset?.(generated.asset); - } - return; - } - addVideoResultLayer( - generated, - quickEditResultTitle, - generationInputs, - quickEditFrame, - quickEditDialogId, - { - sourceLayer: quickEditSourceLayer, - assetKind: quickEditSourceLayer.assetKind, - }, - ); - return; - } const quickEditSelectionMarks = quickEditSelectionState?.sourceLayerId === quickEditSourceLayer.id ? quickEditSelectionState.marks @@ -1700,22 +1545,10 @@ export function useImageCanvasGenerationSubmissionWorkflow({ generated, quickEditSourceLayer, generationInputs, - quickEditDialogId, + undefined, quickEditResultTitle, ); } catch (error) { - if (quickEditDialogId) { - updateCanvasGenerationDialogById( - quickEditDialogId, - (currentDialog) => ({ - ...currentDialog, - prompt: normalizedPrompt, - status: 'failed', - composerOpen: false, - errorMessage: resolveImageGenerationErrorMessage(error), - }), - ); - } setQuickEditPanel({ ...quickEditPanel, aspectRatio: quickEditAspectRatio, @@ -1727,26 +1560,16 @@ export function useImageCanvasGenerationSubmissionWorkflow({ }); } }, [ - addVideoResultLayer, applyQuickEditResultToSourceLayer, applyProjectSnapshot, - canvasGenerationDialogs, - canvasSize, - getGeneratingDialogPlaceholder, - layers, - openCanvasGenerationDialog, projectId, assetFolderId, - upsertGeneratedAsset, quickEditPanel, quickEditSourceLayer, quickEditSelectionState, - selectSingleLayer, setActiveSidebarPanel, setActiveTool, setQuickEditPanel, - updateCanvasGenerationDialogById, - viewport, onGenerationWarning, onQueuedGenerationTask, onWalletBalanceMayHaveChanged, diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 2c1c07237..0bbdb6dcb 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -1558,6 +1558,28 @@ describe('useImageCanvasGenerationWorkflow', () => { ); }); + it('does not open quick edit programmatically for video layers', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); + + expect(screen.getByTestId('tool').textContent).toBe('select'); + expect(screen.getByTestId('selected').textContent).toBe('-'); + expect(screen.getByTestId('quick-edit').textContent).toBe('-'); + expect(screen.getByTestId('quick-edit-selection').textContent).toBe('-'); + }); + it('calculates character animation button price from the model pricing config', () => { render(); diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 45ea3c89a..67341c4c6 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -3871,14 +3871,12 @@ export function useImageCanvasGenerationWorkflow({ quickEditPanel, quickEditSourceLayer, quickEditSelectionState, - canvasGenerationDialogs, setQuickEditPanel, characterAnimationPanel: effectiveCharacterAnimationPanel, characterAnimationDialog, characterAnimationSourceLayer, setCharacterAnimationPanel, setGenerateDialog, - openCanvasGenerationDialog, updateCanvasGenerationDialogById, hasCanvasGenerationDialogById, getCanvasGenerationDialogById, From 4d3f87cd3963b8e0a3e35ec433f78f800550f751 Mon Sep 17 00:00:00 2001 From: Linghong Date: Fri, 14 Aug 2026 20:42:58 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=8C=E7=BE=8E?= =?UTF-8?q?=E5=83=8F=E7=B4=A0=E6=97=A0=E6=B3=95=E8=AF=86=E5=88=AB=E8=87=AA?= =?UTF-8?q?=E8=BA=AB=E4=BA=A7=E7=89=A9=E7=9A=84=E7=BD=91=E6=A0=BC=20(#169)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 边缘 profile 用的是跨度 2 的中心差分,硬块边界落在列 b-1|b 之间时 profile[b-1] 与 profile[b] 逐位相等:两者跨的是同一条边,逐行被加数 相同、求和顺序相同。峰值判定两侧都用严格大于,会把这个 2 宽平台的 两端一起丢掉,于是最干净的输入反而 0 个峰、被判为未识别到网格—— 完美像素处理不了自己的输出。 estimate_step_size 左邻比较放宽为 >=,右邻保持严格 >:平台只保留 一个索引,且是右端,正好是左闭右开语义下的真实块边。两侧都放宽则 会把长度 K 的常数段整段判为峰。 walk 与 snap_uniform_cuts 的 argmax 同样改为 >=,取等值段的最后一 个索引。取第一个会让每条切线落在真实边界左边 1px,每个 cell 因此 混进 1/4 的邻块。实测在 266x253 的最近邻放大像素画上,切线命中真实 块边从 2.6% 升到 98.5%,左右镜像、上下翻转、180° 旋转下一致;龙图 内容区内 134/134 全中。 Co-Authored-By: Claude Opus 5 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/169 Co-authored-by: Linghong Co-committed-by: Linghong --- .../tests/appSurface/home.suite.ts | 4 ++-- .../platform-image/src/pixel_art_snapper.rs | 18 +++++++++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) 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 f4046af9b..ace0c0a08 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1515,7 +1515,7 @@ export function registerRecentProjectsTests() { 'open_game_creator_workspace_window', expect.anything(), ); - expect(screen.getByText('已打开项目目录')).not.toBeNull(); + expect(await screen.findByText('已打开项目目录')).not.toBeNull(); expect(window.localStorage.length).toBe(1); }); @@ -1540,7 +1540,7 @@ export function registerRecentProjectsTests() { projectPath: '/tmp/typed-game', }); }); - expect(screen.getByText('已打开项目目录')).not.toBeNull(); + expect(await screen.findByText('已打开项目目录')).not.toBeNull(); }); it('rejects invalid typed launcher project directories before opening them', () => { diff --git a/server-rs/crates/platform-image/src/pixel_art_snapper.rs b/server-rs/crates/platform-image/src/pixel_art_snapper.rs index e22f63c22..cca5e58a2 100644 --- a/server-rs/crates/platform-image/src/pixel_art_snapper.rs +++ b/server-rs/crates/platform-image/src/pixel_art_snapper.rs @@ -588,10 +588,18 @@ fn estimate_step_size(profile: &[f64], config: SnapConfig) -> Option { return None; } let threshold = maximum * config.peak_threshold_multiplier; + // The profile is a two-span central difference, so a hard block edge between + // columns `b - 1` and `b` makes `profile[b - 1]` and `profile[b]` bitwise equal: + // both straddle the same edge and sum the same per-row terms in the same order. + // A strict comparison on both sides rejects both ends of that plateau, which is + // why perfectly snapped pixel art used to yield zero peaks. Accepting equality on + // the left keeps exactly one index per plateau — the right end, which is the real + // boundary — while a strict comparison on the right still rejects the interior of + // wider constant runs. let peaks = (1..profile.len().saturating_sub(1)) .filter(|&index| { profile[index] > threshold - && profile[index] > profile[index - 1] + && profile[index] >= profile[index - 1] && profile[index] > profile[index + 1] }) .collect::>(); @@ -694,7 +702,10 @@ fn walk( let mut best_index = start; let mut best_value = -1.0f64; for (index, value) in profile.iter().enumerate().take(end).skip(start) { - if *value > best_value { + // `>=` keeps the last index of an equal run, matching the plateau end that + // `estimate_step_size` selects. Picking the first index instead would place + // every cut one pixel left of the real block boundary. + if *value >= best_value { best_value = *value; best_index = index; } @@ -878,7 +889,8 @@ fn snap_uniform_cuts( .take(end.min(profile.len().saturating_sub(1)) + 1) .skip(start) { - if *value > best_value { + // Same plateau-end convention as `walk`. + if *value >= best_value { best_value = *value; best_index = index; } From 578f8019fc96e06442d4866e7c94fa89e0d5d1b6 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 14 Aug 2026 21:33:33 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E4=BC=98=E5=8C=96AGC=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E5=B9=B6=E8=AF=86=E5=88=ABGodot=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 项目页只保留打开项目和新建项目并统一原生目录选择 按根目录及一层子目录识别project.godot并记录相对Godot根 保持.agent位于用户选择的工作区根并补齐Windows构建门禁 补充响应式布局、测试、技术方案和共享项目记忆 --- .../scripts/check-config.mjs | 20 +- apps/ai-game-creator-shell/src-tauri/build.rs | 11 + .../build_support/frontend_dist_guard.rs | 100 +++++ .../src-tauri/src/commands.rs | 52 ++- .../src-tauri/src/main.rs | 2 +- .../src-tauri/src/project/manifest.rs | 219 +++++++++- .../src/project/manifest/import_tests.rs | 343 ++++++++++++++- .../src-tauri/src/tests/project.rs | 79 ++++ .../src-tauri/tauri.conf.json | 2 +- .../tauri.game-chat-release.conf.json | 2 +- .../src-tauri/tests/frontend_dist_build.rs | 141 ++++++ apps/ai-game-creator-shell/src/App.tsx | 26 +- .../src/app/constants.ts | 2 - apps/ai-game-creator-shell/src/app/types.ts | 1 + .../features/app-shell/ProjectCreation.tsx | 118 ++--- .../features/app-shell/WorkspaceLauncher.tsx | 2 +- .../src/features/app-shell/model.ts | 9 - .../app-shell/useDeveloperAgentPanel.ts | 24 +- .../app-shell/useDeveloperAgentState.ts | 5 +- .../app-shell/useHomeProjectCreation.ts | 186 ++++---- .../features/app-shell/useRecentProjects.ts | 15 - apps/ai-game-creator-shell/src/styles.css | 163 +++++-- .../src/view/home/index.tsx | 8 +- .../tests/appSurface/harness.ts | 40 +- .../tests/appSurface/home.suite.ts | 404 ++++++++---------- .../appSurface/project-development.suite.ts | 60 +-- .../appSurface/runtime-settings.suite.ts | 44 +- ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 12 +- .../shared-memory/decision-log.md | 16 +- docs/project-memory/shared-memory/pitfalls.md | 8 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 14 +- .../src/contracts/gameCreationApp.test.ts | 13 + .../shared/src/contracts/gameCreationApp.ts | 1 + scripts/check-native-shells.mjs | 32 ++ .../shared-contracts/src/game_creation_app.rs | 6 + 35 files changed, 1567 insertions(+), 613 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/build_support/frontend_dist_guard.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/tests/frontend_dist_build.rs diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index a541abce3..d2cead3a5 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1237,10 +1237,10 @@ if ( clientWindow.width !== 1280 || clientWindow.height !== 800 || clientWindow.minWidth !== 1280 || - clientWindow.minHeight !== 800 + clientWindow.minHeight !== 720 ) { throw new Error( - 'AI game creator shell client window must keep the landscape workbench size', + 'AI game creator shell client window must default to 1280x800 and stay at least 1280x720', ); } @@ -1472,6 +1472,22 @@ if ( ); } +const gameChatReleaseWindows = gameChatReleaseTauriConfig.app?.windows ?? []; +const gameChatReleaseClientWindow = gameChatReleaseWindows[0]; +if ( + gameChatReleaseWindows.length !== 1 || + gameChatReleaseClientWindow?.label !== 'client' || + gameChatReleaseClientWindow?.url !== 'index.html' || + gameChatReleaseClientWindow?.width !== 1280 || + gameChatReleaseClientWindow?.height !== 800 || + gameChatReleaseClientWindow?.minWidth !== 1280 || + gameChatReleaseClientWindow?.minHeight !== 720 +) { + throw new Error( + 'AI game creator game-chat client window must default to 1280x800 and stay at least 1280x720', + ); +} + if ( tauriConfig.version !== '0.1.0' || packageConfig.version !== '0.1.0' || diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index 2b9088e97..2d16ab7be 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -1,3 +1,5 @@ +#[path = "build_support/frontend_dist_guard.rs"] +mod frontend_dist_guard; #[path = "build_support/runtime_prompt_bundle.rs"] mod runtime_prompt_bundle; @@ -64,5 +66,14 @@ fn main() { .join("agent_runtime_prompt_bundle.rs"); fs::write(&output_path, compiled.rust_source) .unwrap_or_else(|error| panic!("写入 Prompt Bundle 生成代码失败:{error}")); + if !tauri_build::is_dev() { + let frontend_dist = manifest_dir + .parent() + .expect("AI 游戏创作 Tauri manifest 必须位于应用目录下") + .join("dist"); + println!("cargo:rerun-if-changed={}", frontend_dist.display()); + frontend_dist_guard::validate_frontend_dist(&frontend_dist) + .unwrap_or_else(|error| panic!("生产 frontendDist 检查失败:{error}")); + } tauri_build::build() } diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/frontend_dist_guard.rs b/apps/ai-game-creator-shell/src-tauri/build_support/frontend_dist_guard.rs new file mode 100644 index 000000000..f8527c120 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/build_support/frontend_dist_guard.rs @@ -0,0 +1,100 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +const FORBIDDEN_TEST_PROJECT_PATH: &[u8] = b"/tmp/genarrative-ai-game-draft"; + +pub fn validate_frontend_dist(dist_root: &Path) -> Result<(), String> { + let root_metadata = fs::symlink_metadata(dist_root) + .map_err(|error| format!("读取生产 frontendDist 失败:.: {error}"))?; + if metadata_is_link(&root_metadata) { + return Err("生产 frontendDist 不能是符号链接:.".to_string()); + } + if !root_metadata.is_dir() { + return Err("生产 frontendDist 必须是目录:.".to_string()); + } + + validate_frontend_dist_directory(dist_root, dist_root) +} + +fn validate_frontend_dist_directory(dist_root: &Path, directory: &Path) -> Result<(), String> { + let mut entries = fs::read_dir(directory) + .map_err(|error| { + format!( + "读取生产 frontendDist 目录失败:{}: {error}", + relative_dist_path(dist_root, directory) + ) + })? + .map(|entry| { + entry.map(|entry| entry.path()).map_err(|error| { + format!( + "读取生产 frontendDist 目录项失败:{}: {error}", + relative_dist_path(dist_root, directory) + ) + }) + }) + .collect::, String>>()?; + entries.sort(); + + for path in entries { + let relative_path = relative_dist_path(dist_root, &path); + let metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("读取生产 frontendDist 资源失败:{relative_path}: {error}"))?; + if metadata_is_link(&metadata) { + return Err(format!( + "生产 frontendDist 不能包含符号链接:{relative_path}" + )); + } + if metadata.is_dir() { + validate_frontend_dist_directory(dist_root, &path)?; + continue; + } + if !metadata.is_file() { + return Err(format!( + "生产 frontendDist 只能包含普通文件和目录:{relative_path}" + )); + } + + let bytes = fs::read(&path) + .map_err(|error| format!("读取生产 frontendDist 文件失败:{relative_path}: {error}"))?; + if contains_bytes(&bytes, FORBIDDEN_TEST_PROJECT_PATH) { + return Err(format!( + "生产 frontendDist 包含禁止的测试默认路径 /tmp/genarrative-ai-game-draft:{relative_path}" + )); + } + } + + Ok(()) +} + +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() + && haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +fn relative_dist_path(dist_root: &Path, path: &Path) -> String { + path.strip_prefix(dist_root) + .ok() + .filter(|relative| !relative.as_os_str().is_empty()) + .map(|relative| relative.display().to_string()) + .unwrap_or_else(|| ".".to_string()) +} + +fn metadata_is_link(metadata: &fs::Metadata) -> bool { + if metadata.file_type().is_symlink() { + return true; + } + + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return true; + } + } + + false +} 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 9b3990d3b..3497aae4a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -9,12 +9,19 @@ fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result Result { - Ok(automatic_local_game_projects_root(&app)? - .join("gameagent-new") - .to_string_lossy() - .into_owned()) +pub(crate) fn closest_existing_project_picker_directory(path: &Path) -> Option { + let mut candidate = if path.exists() && path.is_dir() { + Some(path) + } else { + path.parent() + }; + while let Some(directory) = candidate { + if directory.is_dir() { + return Some(directory.to_path_buf()); + } + candidate = directory.parent(); + } + None } pub(crate) fn create_automatic_local_game_project_at( @@ -99,6 +106,9 @@ pub(crate) fn import_local_godot_project( ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "project.create")?; + if discover_local_godot_project_root(root)?.is_none() { + return Err("所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string()); + } let _lock = acquire_project_write_lock(root, "project.create")?; import_local_godot_project_at(root, project_id.trim(), name.trim()) } @@ -144,12 +154,14 @@ pub(crate) fn inspect_local_project_directory( return Err("项目目录不能包含控制字符".to_string()); } let recent_run_trace = recent_game_creator_run_trace(root); + let godot_project_root = discover_local_godot_project_root(root)?; Ok(LocalProjectDirectoryStatus { project_path: root.to_string_lossy().into_owned(), exists: root.exists(), is_directory: root.is_dir(), is_game_creator_project: is_game_creator_project_directory(root), - is_godot_project: is_godot_project_directory(root), + is_godot_project: godot_project_root.is_some(), + godot_project_root, project_name: game_creator_project_name(root), manifest_error: game_creator_project_manifest_error(root), recent_run_status: recent_run_trace.as_ref().map(|trace| trace.status.clone()), @@ -157,16 +169,6 @@ pub(crate) fn inspect_local_project_directory( }) } -pub(crate) fn is_godot_project_directory(root: &Path) -> bool { - if !root.is_dir() { - return false; - } - let project_file = root.join("project.godot"); - fs::symlink_metadata(project_file) - .map(|metadata| metadata.is_file() && !metadata.file_type().is_symlink()) - .unwrap_or(false) -} - pub(crate) fn is_game_creator_project_directory(root: &Path) -> bool { if !root.is_dir() { return false; @@ -206,9 +208,23 @@ pub(crate) fn recent_game_creator_run_trace(root: &Path) -> Option, ) -> Result, String> { let (sender, receiver) = tokio::sync::oneshot::channel(); let mut dialog = app.dialog().file().set_title("选择游戏项目目录"); + if let Some(initial_path) = initial_path + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + { + let initial_path = Path::new(initial_path); + if initial_path.is_absolute() && !project_path_has_control_chars(initial_path) { + let starting_directory = closest_existing_project_picker_directory(initial_path); + if let Some(starting_directory) = starting_directory { + dialog = dialog.set_directory(starting_directory); + } + } + } if let Some(window) = app.get_webview_window("client") { dialog = dialog.set_parent(&window); } @@ -295,7 +311,7 @@ pub(crate) fn get_local_game_manifest( return Err(format!("不支持通过 manifest 执行命令:{command_id}")); } enforce_project_permission_policy(root, command_id)?; - read_manifest_for_project(root) + read_manifest_for_project_with_godot_root_calibration(root) } #[tauri::command] 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 f3646fcff..bb23a8e46 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -128,6 +128,7 @@ struct LocalProjectDirectoryStatus { is_directory: bool, is_game_creator_project: bool, is_godot_project: bool, + godot_project_root: Option, project_name: Option, manifest_error: Option, recent_run_status: Option, @@ -2179,7 +2180,6 @@ fn main() { }) .invoke_handler(tauri::generate_handler![ create_automatic_local_game_project, - get_default_local_game_project_path, init_local_game_project, import_local_godot_project, is_local_project_directory_non_empty, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 85c449ad9..3725368d3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -1,10 +1,196 @@ use super::*; +use super::filesystem::validate_portable_project_path_component; + const MANIFEST_LOCK_WAIT_ATTEMPTS: usize = 500; const MANIFEST_LOCK_WAIT_MILLIS: u64 = 10; static MANIFEST_LOCK_OPEN_GUARD: OnceLock> = OnceLock::new(); +#[cfg(windows)] +fn godot_metadata_is_reparse_point(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +fn godot_metadata_is_reparse_point(_metadata: &fs::Metadata) -> bool { + false +} + +fn godot_metadata_is_link(metadata: &fs::Metadata) -> bool { + metadata.file_type().is_symlink() || godot_metadata_is_reparse_point(metadata) +} + +fn inspect_godot_project_marker(root: &Path) -> Result { + let project_file = root.join("project.godot"); + let metadata = match fs::symlink_metadata(&project_file) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(format!( + "读取 Godot 项目文件失败:{}: {error}", + project_file.display() + )); + } + }; + if godot_metadata_is_link(&metadata) { + return Err(format!( + "Godot 项目文件不能是符号链接或 reparse point:{}", + project_file.display() + )); + } + if !metadata.is_file() { + return Err(format!( + "Godot 项目文件必须是普通文件:{}", + project_file.display() + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if metadata.nlink() != 1 { + return Err(format!( + "Godot 项目文件不能是硬链接文件:{}", + project_file.display() + )); + } + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.number_of_links() != Some(1) { + return Err(format!( + "Godot 项目文件必须可确认是无硬链接普通文件:{}", + project_file.display() + )); + } + } + Ok(true) +} + +fn validate_godot_project_child_name(name: &std::ffi::OsStr) -> Result { + let name = name + .to_str() + .ok_or_else(|| "Godot 项目子目录名称必须是有效 UTF-8".to_string())?; + validate_portable_project_path_component(name) + .map_err(|error| format!("Godot 项目子目录名称不可跨平台使用:{error}"))?; + Ok(name.to_string()) +} + +fn validate_manifest_godot_project_root(value: Option<&str>) -> Result<(), String> { + let Some(value) = value else { + return Ok(()); + }; + if value == "." { + return Ok(()); + } + if value.is_empty() + || Path::new(value).is_absolute() + || value.contains('/') + || value.contains('\\') + || matches!(value, "..") + { + return Err("manifest godotProjectRoot 只能是 . 或单个安全相对目录名".to_string()); + } + validate_portable_project_path_component(value) + .map_err(|error| format!("manifest godotProjectRoot 无效:{error}")) +} + +pub(crate) fn discover_local_godot_project_root( + workspace_root: &Path, +) -> Result, String> { + if workspace_root.as_os_str().is_empty() { + return Err("项目目录不能为空".to_string()); + } + if !workspace_root.is_absolute() { + return Err("项目目录必须是绝对路径".to_string()); + } + if project_path_has_control_chars(workspace_root) { + return Err("项目目录不能包含控制字符".to_string()); + } + + let workspace_metadata = match fs::symlink_metadata(workspace_root) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "读取项目目录失败:{}: {error}", + workspace_root.display() + )); + } + }; + if godot_metadata_is_link(&workspace_metadata) { + return Err("项目目录不能是符号链接或 reparse point".to_string()); + } + if !workspace_metadata.is_dir() { + return Ok(None); + } + + if inspect_godot_project_marker(workspace_root)? { + return Ok(Some(".".to_string())); + } + + let mut matches = Vec::new(); + let entries = fs::read_dir(workspace_root).map_err(|error| { + format!( + "读取 Godot 工作区目录失败:{}: {error}", + workspace_root.display() + ) + })?; + for entry in entries { + let entry = entry.map_err(|error| { + format!( + "读取 Godot 工作区目录项失败:{}: {error}", + workspace_root.display() + ) + })?; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("读取 Godot 项目候选失败:{}: {error}", path.display()))?; + if godot_metadata_is_link(&metadata) { + continue; + } + if !metadata.is_dir() || !inspect_godot_project_marker(&path)? { + continue; + } + matches.push(validate_godot_project_child_name(&entry.file_name())?); + } + matches.sort(); + if matches.len() > 1 { + return Err(format!( + "工作区一层子目录中发现多个 Godot 项目:{}", + matches.join("、") + )); + } + let Some(relative_root) = matches.pop() else { + return Ok(None); + }; + + let candidate_root = workspace_root.join(&relative_root); + let candidate_metadata = fs::symlink_metadata(&candidate_root).map_err(|error| { + format!( + "复核 Godot 项目目录失败:{}: {error}", + candidate_root.display() + ) + })?; + if godot_metadata_is_link(&candidate_metadata) || !candidate_metadata.is_dir() { + return Err(format!( + "Godot 项目目录在发现期间发生替换或不是普通目录:{}", + candidate_root.display() + )); + } + if !inspect_godot_project_marker(&candidate_root)? { + return Err(format!( + "Godot 项目文件在发现期间消失:{}", + candidate_root.join("project.godot").display() + )); + } + Ok(Some(relative_root)) +} + #[derive(Debug)] struct ManifestWriteLock { _file: File, @@ -255,11 +441,11 @@ pub(crate) fn import_local_godot_project_at( return Err("项目目录不能包含控制字符".to_string()); } if !root.is_dir() { - return Err("Godot 项目目录不存在或不是文件夹".to_string()); - } - if !is_godot_project_directory(root) { - return Err("所选文件夹不是有效的 Godot 项目:缺少普通文件 project.godot".to_string()); + return Err("Godot 工作区目录不存在或不是文件夹".to_string()); } + let godot_project_root = discover_local_godot_project_root(root)?.ok_or_else(|| { + "所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string() + })?; if project_id.is_empty() { return Err("项目 ID 不能为空".to_string()); } @@ -269,7 +455,11 @@ pub(crate) fn import_local_godot_project_at( let manifest_path = root.join(".agent/manifest.json"); if manifest_storage_exists(&manifest_path)? { - let manifest = read_manifest(&manifest_path)?; + let mut manifest = read_manifest(&manifest_path)?; + if manifest.godot_project_root.as_deref() != Some(godot_project_root.as_str()) { + manifest.godot_project_root = Some(godot_project_root); + write_manifest(&manifest_path, &manifest)?; + } return Ok(InitLocalProjectResult { project_path: root.to_string_lossy().into_owned(), manifest_path: manifest_path.to_string_lossy().into_owned(), @@ -286,6 +476,7 @@ pub(crate) fn import_local_godot_project_at( "projectId": project_id, "name": name, "projectKind": "godot", + "godotProjectRoot": godot_project_root, }), )?; } @@ -299,7 +490,8 @@ pub(crate) fn import_local_godot_project_at( })?; } - let manifest = new_game_creation_app_manifest(project_id, name); + let mut manifest = new_game_creation_app_manifest(project_id, name); + manifest.godot_project_root = Some(godot_project_root); write_manifest(&manifest_path, &manifest)?; Ok(InitLocalProjectResult { @@ -361,6 +553,17 @@ pub(crate) fn read_manifest_for_project(root: &Path) -> Result Result { + let godot_project_root = discover_local_godot_project_root(root)?; + let (manifest_path, mut manifest) = read_or_create_manifest(root)?; + ensure_manifest_seed_tasks(root, &mut manifest); + manifest.godot_project_root = godot_project_root; + write_manifest(&manifest_path, &manifest)?; + Ok(manifest) +} + pub(crate) fn read_existing_manifest_for_project( root: &Path, ) -> Result { @@ -918,6 +1121,8 @@ pub(crate) fn read_manifest(path: &Path) -> Result( where F: FnOnce(), { + validate_manifest_godot_project_root(manifest.godot_project_root.as_deref()) + .map_err(|error| format!("校验 manifest Godot 项目根失败:{error}"))?; validate_game_iteration_versions(&manifest.versions) .map_err(|error| format!("校验 manifest 项目版本失败:{error}"))?; let payload = serde_json::to_string_pretty(manifest) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs index 367e46815..b226c4158 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs @@ -14,15 +14,29 @@ fn godot_import_test_path(label: &str) -> PathBuf { )) } +fn write_godot_project(root: &Path, name: &str) { + fs::create_dir_all(root).expect("create Godot project root"); + fs::write( + root.join("project.godot"), + format!("[application]\nconfig/name=\"{name}\"\n"), + ) + .expect("write project.godot"); +} + +fn assert_manifest_godot_root(root: &Path, expected: &str) { + let manifest = read_manifest(&root.join(".agent/manifest.json")).expect("read manifest"); + assert_eq!(manifest.godot_project_root.as_deref(), Some(expected)); + let payload: serde_json::Value = serde_json::from_str( + &fs::read_to_string(root.join(".agent/manifest.json")).expect("read manifest payload"), + ) + .expect("parse manifest payload"); + assert_eq!(payload["godotProjectRoot"], expected); +} + #[test] fn imports_godot_project_without_creating_parallel_game_directories() { let root = godot_import_test_path("valid"); - fs::create_dir_all(&root).expect("create Godot project root"); - fs::write( - root.join("project.godot"), - "[application]\nconfig/name=\"Existing\"\n", - ) - .expect("write project.godot"); + write_godot_project(&root, "Existing"); fs::create_dir(root.join("scenes")).expect("create existing scenes"); fs::write(root.join("scenes/main.tscn"), "[gd_scene format=3]\n") .expect("write existing scene"); @@ -32,8 +46,20 @@ fn imports_godot_project_without_creating_parallel_game_directories() { assert_eq!(result.project_path, root.to_string_lossy()); assert_eq!(result.manifest.project_id, "godot-project"); + assert_eq!(result.manifest.godot_project_root.as_deref(), Some(".")); assert!(root.join(".agent/manifest.json").is_file()); assert!(root.join(".agent/agent.db").is_file()); + let import_record: serde_json::Value = serde_json::from_str( + fs::read_to_string(root.join(".agent/agent.db")) + .expect("read Godot import Agent DB") + .lines() + .next() + .expect("Godot import Agent DB record"), + ) + .expect("parse Godot import Agent DB record"); + assert_eq!(import_record["recordType"], "project.import"); + assert_eq!(import_record["godotProjectRoot"], "."); + assert_manifest_godot_root(&root, "."); assert_eq!( fs::read_to_string(root.join("scenes/main.tscn")).expect("read preserved scene"), "[gd_scene format=3]\n" @@ -49,9 +75,314 @@ fn imports_godot_project_without_creating_parallel_game_directories() { .expect("reopen imported Godot project"); assert_eq!(reopened.manifest.project_id, "godot-project"); assert_eq!(reopened.manifest.name, "Existing"); + assert_eq!(reopened.manifest.godot_project_root.as_deref(), Some(".")); fs::remove_dir_all(root).ok(); } +#[test] +fn imports_a_unique_direct_child_godot_project_into_the_workspace_root() { + let workspace = godot_import_test_path("child"); + let godot_root = workspace.join("game-source"); + write_godot_project(&godot_root, "Nested"); + fs::create_dir(godot_root.join("scenes")).expect("create nested scenes"); + fs::write(godot_root.join("scenes/main.tscn"), "[gd_scene format=3]\n") + .expect("write nested scene"); + + let result = import_local_godot_project_at(&workspace, "nested-godot", "Nested") + .expect("import nested Godot project"); + + assert_eq!(result.project_path, workspace.to_string_lossy()); + assert_eq!( + result.manifest.godot_project_root.as_deref(), + Some("game-source") + ); + assert!(workspace.join(".agent/manifest.json").is_file()); + assert!(workspace.join(".agent/agent.db").is_file()); + let import_record: serde_json::Value = serde_json::from_str( + fs::read_to_string(workspace.join(".agent/agent.db")) + .expect("read nested Godot import Agent DB") + .lines() + .next() + .expect("nested Godot import Agent DB record"), + ) + .expect("parse nested Godot import Agent DB record"); + assert_eq!(import_record["godotProjectRoot"], "game-source"); + assert!(!godot_root.join(".agent").exists()); + assert_manifest_godot_root(&workspace, "game-source"); + assert_eq!( + fs::read_to_string(godot_root.join("scenes/main.tscn")).expect("read nested scene"), + "[gd_scene format=3]\n" + ); + + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn root_godot_project_takes_priority_over_direct_child_projects() { + let workspace = godot_import_test_path("root-priority"); + write_godot_project(&workspace, "Root"); + write_godot_project(&workspace.join("nested"), "Nested"); + + let result = import_local_godot_project_at(&workspace, "root-godot", "Root") + .expect("root Godot project should win"); + + assert_eq!(result.manifest.godot_project_root.as_deref(), Some(".")); + assert_manifest_godot_root(&workspace, "."); + assert!(!workspace.join("nested/.agent").exists()); + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn rejects_multiple_direct_child_godot_projects_before_writing_agent_metadata() { + let workspace = godot_import_test_path("multiple-children"); + write_godot_project(&workspace.join("alpha"), "Alpha"); + write_godot_project(&workspace.join("beta"), "Beta"); + + let error = import_local_godot_project_at(&workspace, "ambiguous", "Ambiguous") + .expect_err("multiple direct child Godot projects must fail"); + + assert!(error.contains("多个 Godot 项目"), "{error}"); + assert!(!workspace.join(".agent").exists()); + assert!(!workspace.join("alpha/.agent").exists()); + assert!(!workspace.join("beta/.agent").exists()); + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn ignores_godot_projects_below_the_first_child_level() { + let workspace = godot_import_test_path("too-deep"); + write_godot_project(&workspace.join("parent/nested"), "Too Deep"); + + let error = import_local_godot_project_at(&workspace, "too-deep", "Too Deep") + .expect_err("second-level Godot project must not be imported"); + + assert!(error.contains("根目录或一层子目录"), "{error}"); + assert!(!workspace.join(".agent").exists()); + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn calibrates_existing_manifest_to_the_discovered_godot_root() { + let workspace = godot_import_test_path("calibrate-existing"); + init_local_game_project_at(&workspace, "existing-project", "Existing Project") + .expect("initialize existing workspace"); + write_godot_project(&workspace.join("godot-game"), "Godot Game"); + let manifest_path = workspace.join(".agent/manifest.json"); + let mut stale_manifest = read_manifest(&manifest_path).expect("read existing manifest"); + stale_manifest.godot_project_root = Some("stale-root".to_string()); + write_manifest(&manifest_path, &stale_manifest).expect("write stale Godot root"); + + let result = import_local_godot_project_at(&workspace, "ignored", "Ignored") + .expect("reopen existing Godot workspace"); + + assert_eq!(result.manifest.project_id, "existing-project"); + assert_eq!(result.manifest.name, "Existing Project"); + assert_eq!( + result.manifest.godot_project_root.as_deref(), + Some("godot-game") + ); + assert_manifest_godot_root(&workspace, "godot-game"); + assert!(!workspace.join("godot-game/.agent").exists()); + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn ambiguous_layout_does_not_rewrite_an_existing_manifest() { + let workspace = godot_import_test_path("ambiguous-existing"); + init_local_game_project_at(&workspace, "existing-project", "Existing Project") + .expect("initialize existing workspace"); + write_godot_project(&workspace.join("game"), "Game"); + write_godot_project(&workspace.join("other"), "Other"); + let manifest_path = workspace.join(".agent/manifest.json"); + let original = fs::read(&manifest_path).expect("read original manifest"); + + let error = import_local_godot_project_at(&workspace, "ignored", "Ignored") + .expect_err("ambiguous existing workspace must fail"); + + assert!(error.contains("多个 Godot 项目"), "{error}"); + assert_eq!( + fs::read(&manifest_path).expect("read unchanged manifest"), + original + ); + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn opening_manifest_calibrates_missing_godot_root_without_moving_workspace_metadata() { + let workspace = godot_import_test_path("calibrate-on-open"); + init_local_game_project_at(&workspace, "existing-project", "Existing Project") + .expect("initialize existing workspace"); + write_godot_project(&workspace.join("godot-game"), "Godot Game"); + + let manifest = read_manifest_for_project_with_godot_root_calibration(&workspace) + .expect("open and calibrate Godot workspace"); + + assert_eq!(manifest.project_id, "existing-project"); + assert_eq!(manifest.godot_project_root.as_deref(), Some("godot-game")); + assert_manifest_godot_root(&workspace, "godot-game"); + assert!(!workspace.join("godot-game/.agent").exists()); + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn opening_plain_workspace_clears_a_stale_safe_godot_root() { + let workspace = godot_import_test_path("clear-stale-root"); + init_local_game_project_at(&workspace, "existing-project", "Existing Project") + .expect("initialize existing workspace"); + let manifest_path = workspace.join(".agent/manifest.json"); + let mut stale_manifest = read_manifest(&manifest_path).expect("read existing manifest"); + stale_manifest.godot_project_root = Some("old-game".to_string()); + write_manifest(&manifest_path, &stale_manifest).expect("write stale Godot root"); + + let manifest = read_manifest_for_project_with_godot_root_calibration(&workspace) + .expect("open and calibrate plain workspace"); + + assert_eq!(manifest.project_id, "existing-project"); + assert_eq!(manifest.godot_project_root, None); + let persisted = read_manifest(&manifest_path).expect("read calibrated manifest"); + assert_eq!(persisted.godot_project_root, None); + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn manifest_rejects_unsafe_godot_project_roots() { + let workspace = godot_import_test_path("unsafe-manifest-root"); + let manifest_path = workspace.join(".agent/manifest.json"); + let mut manifest = new_game_creation_app_manifest("unsafe", "Unsafe"); + manifest.godot_project_root = Some("../outside".to_string()); + + let error = write_manifest(&manifest_path, &manifest) + .expect_err("unsafe manifest Godot root must fail closed"); + + assert!(error.contains("godotProjectRoot"), "{error}"); + assert!(!manifest_path.exists()); + fs::remove_dir_all(workspace).ok(); +} + +#[test] +fn manifest_read_rejects_unsafe_persisted_godot_project_root() { + let workspace = godot_import_test_path("unsafe-persisted-root"); + let manifest_path = workspace.join(".agent/manifest.json"); + fs::create_dir_all(manifest_path.parent().expect("manifest parent")) + .expect("create manifest parent"); + let mut payload = serde_json::to_value(new_game_creation_app_manifest("unsafe", "Unsafe")) + .expect("serialize manifest fixture"); + payload["godotProjectRoot"] = serde_json::json!("../outside"); + fs::write( + &manifest_path, + format!( + "{}\n", + serde_json::to_string_pretty(&payload).expect("serialize unsafe manifest fixture") + ), + ) + .expect("write unsafe manifest fixture"); + + let error = read_manifest(&manifest_path) + .expect_err("unsafe persisted manifest Godot root must fail closed"); + + assert!(error.contains("godotProjectRoot"), "{error}"); + fs::remove_dir_all(workspace).ok(); +} + +#[cfg(unix)] +#[test] +fn rejects_symbolic_link_project_marker_without_writing_agent_metadata() { + use std::os::unix::fs::symlink; + + let workspace = godot_import_test_path("linked-marker"); + fs::create_dir_all(&workspace).expect("create linked marker workspace"); + fs::write(workspace.join("real.godot"), "[application]\n").expect("write real marker"); + symlink("real.godot", workspace.join("project.godot")).expect("link project marker"); + + let error = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect_err("linked project.godot must fail"); + + assert!(error.contains("符号链接"), "{error}"); + assert!(!workspace.join(".agent").exists()); + fs::remove_dir_all(workspace).ok(); +} + +#[cfg(unix)] +#[test] +fn ignores_symbolic_link_child_candidate_without_writing_agent_metadata() { + use std::os::unix::fs::symlink; + + let workspace = godot_import_test_path("linked-child"); + let target = godot_import_test_path("linked-child-target"); + fs::create_dir_all(&workspace).expect("create linked child workspace"); + write_godot_project(&target, "Linked Child"); + symlink(&target, workspace.join("linked-game")).expect("link Godot child"); + + let error = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect_err("linked Godot child must not be followed"); + + assert!(error.contains("根目录或一层子目录"), "{error}"); + assert!(!workspace.join(".agent").exists()); + fs::remove_dir_all(workspace).ok(); + fs::remove_dir_all(target).ok(); +} + +#[cfg(unix)] +#[test] +fn ignores_unrelated_symbolic_link_while_importing_a_unique_regular_child() { + use std::os::unix::fs::symlink; + + let workspace = godot_import_test_path("unrelated-linked-child"); + let unrelated_target = godot_import_test_path("unrelated-linked-target"); + write_godot_project(&workspace.join("game"), "Game"); + fs::create_dir_all(&unrelated_target).expect("create unrelated target"); + symlink(&unrelated_target, workspace.join("vendor-link")).expect("link unrelated directory"); + + let result = import_local_godot_project_at(&workspace, "regular", "Regular") + .expect("unrelated directory link must be ignored"); + + assert_eq!(result.manifest.godot_project_root.as_deref(), Some("game")); + assert_manifest_godot_root(&workspace, "game"); + fs::remove_dir_all(workspace).ok(); + fs::remove_dir_all(unrelated_target).ok(); +} + +#[cfg(unix)] +#[test] +fn rejects_linked_marker_inside_a_regular_child_candidate() { + use std::os::unix::fs::symlink; + + let workspace = godot_import_test_path("linked-child-marker"); + let godot_root = workspace.join("game"); + fs::create_dir_all(&godot_root).expect("create regular child candidate"); + fs::write(godot_root.join("real.godot"), "[application]\n").expect("write real marker"); + symlink("real.godot", godot_root.join("project.godot")).expect("link project marker"); + + let error = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect_err("linked marker in a regular child must fail"); + + assert!(error.contains("符号链接"), "{error}"); + assert!(!workspace.join(".agent").exists()); + fs::remove_dir_all(workspace).ok(); +} + +#[cfg(windows)] +#[test] +fn ignores_windows_reparse_child_candidate_without_writing_agent_metadata() { + let workspace = godot_import_test_path("linked-child"); + let target = godot_import_test_path("linked-child-target"); + fs::create_dir_all(&workspace).expect("create linked child workspace"); + write_godot_project(&target, "Linked Child"); + if std::os::windows::fs::symlink_dir(&target, workspace.join("linked-game")).is_err() { + fs::remove_dir_all(workspace).ok(); + fs::remove_dir_all(target).ok(); + return; + } + + let error = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect_err("reparse Godot child must not be followed"); + + assert!(error.contains("根目录或一层子目录"), "{error}"); + assert!(!workspace.join(".agent").exists()); + fs::remove_dir_all(workspace).ok(); + fs::remove_dir_all(target).ok(); +} + #[test] fn rejects_non_godot_directory_without_writing_agent_metadata() { let root = godot_import_test_path("invalid"); 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 e25481651..fc23efc69 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 @@ -1264,6 +1264,22 @@ fn init_local_game_project_creates_manifest_and_dirs() { fs::remove_dir_all(root).ok(); } +#[test] +fn project_picker_uses_the_closest_existing_parent_for_a_suggested_new_path() { + let documents_root = unique_project_path(); + fs::create_dir_all(&documents_root).expect("create documents fixture"); + let suggested_project_path = documents_root + .join("Genarrative GameAgent") + .join("gameagent-new"); + + assert_eq!( + closest_existing_project_picker_directory(&suggested_project_path), + Some(documents_root.clone()) + ); + + fs::remove_dir_all(documents_root).ok(); +} + #[test] fn automatic_local_game_project_allocates_unique_initialized_workspaces() { let projects_root = unique_project_path(); @@ -1369,6 +1385,7 @@ fn project_directory_status_distinguishes_missing_file_and_dir() { is_directory: false, is_game_creator_project: false, is_godot_project: false, + godot_project_root: None, project_name: None, manifest_error: None, recent_run_status: None, @@ -1382,6 +1399,8 @@ fn project_directory_status_distinguishes_missing_file_and_dir() { assert!(file_status.exists); assert!(!file_status.is_directory); assert!(!file_status.is_game_creator_project); + assert!(!file_status.is_godot_project); + assert_eq!(file_status.godot_project_root, None); assert_eq!(file_status.project_name, None); assert_eq!(file_status.manifest_error, None); assert_eq!(file_status.recent_run_status, None); @@ -1393,6 +1412,8 @@ fn project_directory_status_distinguishes_missing_file_and_dir() { assert!(dir_status.exists); assert!(dir_status.is_directory); assert!(!dir_status.is_game_creator_project); + assert!(!dir_status.is_godot_project); + assert_eq!(dir_status.godot_project_root, None); assert_eq!(dir_status.project_name, None); assert_eq!(dir_status.manifest_error, None); assert_eq!(dir_status.recent_run_status, None); @@ -1451,6 +1472,64 @@ fn project_directory_status_distinguishes_missing_file_and_dir() { fs::remove_dir_all(root).ok(); } +#[test] +fn project_directory_status_reports_workspace_relative_godot_root() { + let root = unique_project_path(); + let godot_root = root.join("godot-game"); + fs::create_dir_all(&godot_root).expect("create nested Godot project"); + fs::write(godot_root.join("project.godot"), "[application]\n") + .expect("write nested project.godot"); + + let status = inspect_local_project_directory(root.to_string_lossy().to_string()) + .expect("inspect nested Godot workspace"); + + assert!(status.exists); + assert!(status.is_directory); + assert!(status.is_godot_project); + assert_eq!(status.godot_project_root.as_deref(), Some("godot-game")); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_directory_status_rejects_ambiguous_direct_child_godot_projects() { + let root = unique_project_path(); + for child in ["alpha", "beta"] { + let godot_root = root.join(child); + fs::create_dir_all(&godot_root).expect("create nested Godot project"); + fs::write(godot_root.join("project.godot"), "[application]\n") + .expect("write nested project.godot"); + } + + let error = inspect_local_project_directory(root.to_string_lossy().to_string()) + .expect_err("ambiguous Godot workspace must fail inspection"); + + assert!(error.contains("多个 Godot 项目"), "{error}"); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn godot_import_command_rejects_ambiguity_before_creating_the_project_lock() { + let root = unique_project_path(); + for child in ["alpha", "beta"] { + let godot_root = root.join(child); + fs::create_dir_all(&godot_root).expect("create nested Godot project"); + fs::write(godot_root.join("project.godot"), "[application]\n") + .expect("write nested project.godot"); + } + + let error = import_local_godot_project( + root.to_string_lossy().into_owned(), + "ambiguous".to_string(), + "Ambiguous".to_string(), + ) + .expect_err("ambiguous Godot workspace must fail before locking"); + + assert!(error.contains("多个 Godot 项目"), "{error}"); + assert!(!root.join(PROJECT_WRITE_LOCK_PATH).exists()); + assert!(!root.join(".agent").exists()); + fs::remove_dir_all(root).ok(); +} + #[test] fn local_project_directory_open_path_requires_existing_absolute_directory() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json index c5f5c340a..fad771711 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -19,7 +19,7 @@ "width": 1280, "height": 800, "minWidth": 1280, - "minHeight": 800 + "minHeight": 720 } ], "security": { diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.game-chat-release.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.game-chat-release.conf.json index 5d1096ce0..41996c7dc 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.game-chat-release.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.game-chat-release.conf.json @@ -15,7 +15,7 @@ "width": 1280, "height": 800, "minWidth": 1280, - "minHeight": 800 + "minHeight": 720 } ] }, diff --git a/apps/ai-game-creator-shell/src-tauri/tests/frontend_dist_build.rs b/apps/ai-game-creator-shell/src-tauri/tests/frontend_dist_build.rs new file mode 100644 index 000000000..dd2fa3f64 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/tests/frontend_dist_build.rs @@ -0,0 +1,141 @@ +#[path = "../build_support/frontend_dist_guard.rs"] +mod frontend_dist_guard; + +use frontend_dist_guard::validate_frontend_dist; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static TEST_DIRECTORY_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +struct TestDirectory { + path: PathBuf, +} + +impl TestDirectory { + fn new(label: &str) -> Self { + let sequence = TEST_DIRECTORY_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "genarrative-frontend-dist-{label}-{}-{sequence}", + std::process::id() + )); + fs::create_dir(&path).expect("create frontend dist test directory"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +fn write_file(path: &Path, content: impl AsRef<[u8]>) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create frontend dist fixture parent"); + } + fs::write(path, content).expect("write frontend dist fixture"); +} + +#[test] +fn clean_frontend_dist_passes() { + let fixture = TestDirectory::new("clean"); + let dist = fixture.path().join("dist"); + write_file(&dist.join("index.html"), "
GameAgent
"); + write_file( + &dist.join("assets/index.js"), + "const defaultProjectPath = '';", + ); + + validate_frontend_dist(&dist).expect("clean frontendDist should pass"); +} + +#[test] +fn nested_frontend_asset_with_test_default_path_fails_with_relative_path() { + let fixture = TestDirectory::new("marker"); + let dist = fixture.path().join("dist"); + write_file(&dist.join("index.html"), "
GameAgent
"); + write_file( + &dist.join("assets/chunks/launcher.js"), + "const projectPath = '/tmp/genarrative-ai-game-draft';", + ); + + let error = validate_frontend_dist(&dist) + .expect_err("production frontendDist must reject the test default path"); + let portable_error = error.replace('\\', "/"); + assert!(error.contains("/tmp/genarrative-ai-game-draft")); + assert!(portable_error.contains("assets/chunks/launcher.js")); + assert!(!error.contains(&dist.display().to_string())); +} + +#[test] +fn test_fixture_outside_frontend_dist_does_not_fail_the_guard() { + let fixture = TestDirectory::new("fixture-scope"); + let dist = fixture.path().join("dist"); + write_file(&dist.join("assets/index.js"), "const projectPath = '';"); + write_file( + &fixture.path().join("tests/project-path.fixture.txt"), + "/tmp/genarrative-ai-game-draft", + ); + + validate_frontend_dist(&dist) + .expect("explicit test fixtures outside frontendDist must remain allowed"); +} + +#[test] +fn missing_frontend_dist_fails_without_leaking_an_absolute_path() { + let fixture = TestDirectory::new("missing"); + let dist = fixture.path().join("dist"); + + let error = validate_frontend_dist(&dist) + .expect_err("production frontendDist must exist before it can be embedded"); + assert!(error.contains("frontendDist")); + assert!(error.contains(":.")); + assert!(!error.contains(&dist.display().to_string())); +} + +#[cfg(unix)] +#[test] +fn frontend_dist_symlink_fails_with_relative_path() { + use std::os::unix::fs::symlink; + + let fixture = TestDirectory::new("symlink"); + let dist = fixture.path().join("dist"); + let target = fixture.path().join("fixture.js"); + write_file(&target, "const projectPath = '';"); + fs::create_dir_all(dist.join("assets")).expect("create symlink fixture parent"); + symlink(&target, dist.join("assets/linked.js")).expect("create frontend dist symlink"); + + let error = + validate_frontend_dist(&dist).expect_err("production frontendDist must reject symlinks"); + let portable_error = error.replace('\\', "/"); + assert!(error.contains("符号链接")); + assert!(portable_error.contains("assets/linked.js")); + assert!(!error.contains(&dist.display().to_string())); +} + +#[cfg(windows)] +#[test] +fn frontend_dist_symlink_fails_when_windows_allows_creating_it() { + use std::os::windows::fs::symlink_file; + + let fixture = TestDirectory::new("windows-symlink"); + let dist = fixture.path().join("dist"); + let target = fixture.path().join("fixture.js"); + write_file(&target, "const projectPath = '';"); + fs::create_dir_all(dist.join("assets")).expect("create symlink fixture parent"); + if symlink_file(&target, dist.join("assets/linked.js")).is_err() { + return; + } + + let error = validate_frontend_dist(&dist) + .expect_err("production frontendDist must reject Windows symlinks"); + let portable_error = error.replace('\\', "/"); + assert!(error.contains("符号链接")); + assert!(portable_error.contains("assets/linked.js")); + assert!(!error.contains(&dist.display().to_string())); +} diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index f85be17dc..e3a5c3cb7 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -32,7 +32,6 @@ import { AGENT_RUN_HISTORY_VISIBLE_STEP, CONVERSATION_INITIAL_VISIBLE_COUNT, CONVERSATION_VISIBLE_STEP, - defaultProjectPath, PROJECT_SUPERVISOR_AGENT_ID, seedManifest, } from './app/constants'; @@ -605,9 +604,7 @@ export function App({ ); const eagerSupervisorProject = projectSupervisorOnly && Boolean(initialProjectPath) && !gameChatOnly; - const [projectPath, setProjectPath] = useState( - initialProjectPath || defaultProjectPath, - ); + const [projectPath, setProjectPath] = useState(initialProjectPath); const [workspaceProjectKind, setWorkspaceProjectKind] = useState(initialProjectKind); const [localProject, setLocalProject] = @@ -623,26 +620,6 @@ export function App({ const localProjectPathRef = useRef(null); localProjectPathRef.current = localProject?.projectPath ?? null; - useEffect(() => { - if (projectPath || initialProjectPath) { - return; - } - const invoke = resolveTauriInvoke(); - if (!invoke) { - return; - } - let active = true; - void invoke('get_default_local_game_project_path') - .then((defaultPath) => { - if (active && defaultPath.trim()) { - setProjectPath(defaultPath); - } - }) - .catch(() => undefined); - return () => { - active = false; - }; - }, [initialProjectPath, projectPath]); const manifestRefreshMountedRef = useRef(true); const manifestRefreshStatesRef = useRef( new Map< @@ -3324,6 +3301,7 @@ export function App({ try { const selectedPath = await invoke( 'pick_local_project_directory', + projectPath.trim() ? { initialPath: projectPath.trim() } : undefined, ); if (gameChatProjectSelectionVersionRef.current !== selectionVersion) { return; diff --git a/apps/ai-game-creator-shell/src/app/constants.ts b/apps/ai-game-creator-shell/src/app/constants.ts index fb7ea0daa..909379253 100644 --- a/apps/ai-game-creator-shell/src/app/constants.ts +++ b/apps/ai-game-creator-shell/src/app/constants.ts @@ -5,8 +5,6 @@ export const seedManifest = createGameCreationAppManifest( '未命名游戏原型', ); -export const defaultProjectPath = - import.meta.env.MODE === 'test' ? '/tmp/genarrative-ai-game-draft' : ''; export const AGENT_RUN_HISTORY_MAX_COUNT = 100; export const AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT = 20; export const AGENT_RUN_HISTORY_VISIBLE_STEP = 20; diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index d97a3ca63..cbf05e886 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -96,6 +96,7 @@ export interface LocalProjectDirectoryStatus { isDirectory: boolean; isGameCreatorProject: boolean; isGodotProject: boolean; + godotProjectRoot: string | null; projectName: string | null; manifestError?: string | null; recentRunStatus: string | null; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx b/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx index cac4d6120..de9abe72a 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx @@ -1,5 +1,4 @@ -import { FolderKanban } from 'lucide-react'; -import { useEffect } from 'react'; +import { FolderKanban, FolderOpen, FolderPlus } from 'lucide-react'; import { closeDialogOnEscape } from '../../app/dialogs'; import type { HomeProjectCreationController } from './useHomeProjectCreation'; @@ -14,88 +13,52 @@ export function ProjectsPage({ homeProject: HomeProjectCreationController; recentProjects: RecentProjectsController; }) { - const loadDefaultProjectPath = homeProject.loadDefaultProjectPath; - useEffect(() => { - void loadDefaultProjectPath(); - }, [loadDefaultProjectPath]); - return (

项目组

-

+

{recentProjects.recentWorkspaceRefreshing ? '正在检查项目状态' : status}

-
- - -
-
- -
- - - - - -
-
+
+ + +
{recentProjects.projectRows.length > 0 ? ( recentProjects.projectRows.map((project) => ( @@ -150,14 +113,7 @@ export function ProjectsPage({
)}
diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 3505303dc..8073de31c 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -266,7 +266,7 @@ export function WorkspaceLauncherShell({ setProjectPath(path); void openProject(path, 'open'); }} - onGodotProjectOpen={() => void homeProject.openGodotProject()} + onProjectPick={() => void homeProject.pickAndOpenProject()} /> ) : launcherView === 'projects' ? ( { - if (launcherView !== 'agent-chat' || agentChatProjectPath) { - return; - } - const invoke = resolveTauriInvoke(); - if (!invoke) { - return; - } - let active = true; - void invoke('get_default_local_game_project_path') - .then((defaultPath) => { - if (active && defaultPath.trim()) { - setAgentChatProjectPath(defaultPath); - } - }) - .catch(() => undefined); - return () => { - active = false; - }; - }, [agentChatProjectPath, launcherView, setAgentChatProjectPath]); - useEffect(() => { setAgentChatRunSubmitMode('steer'); }, [ @@ -787,6 +766,9 @@ export function useDeveloperAgentPanel(launcherView: LauncherView) { try { const selectedPath = await invoke( 'pick_local_project_directory', + agentChatProjectPath.trim() + ? { initialPath: agentChatProjectPath.trim() } + : undefined, ); if (!selectedPath) { setAgentChatStatus('已取消'); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentState.ts b/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentState.ts index ca64ace7f..b57dc2872 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentState.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentState.ts @@ -1,6 +1,6 @@ import { useRef, useState } from 'react'; -import { defaultProjectPath, seedManifest } from '../../app/constants'; +import { seedManifest } from '../../app/constants'; import type { AgentBackgroundSubmitMode, AgentChatInteractionMode, @@ -18,8 +18,7 @@ import { deriveAgentStatusCards } from '../project-summary/agentPresentation'; export function useDeveloperAgentState() { const launcherAgentChatAgents = deriveAgentStatusCards(seedManifest, null); - const [agentChatProjectPath, setAgentChatProjectPath] = - useState(defaultProjectPath); + const [agentChatProjectPath, setAgentChatProjectPath] = useState(''); const [agentChatSelectedAgentId, setAgentChatSelectedAgentId] = useState( launcherAgentChatAgents[0]?.id ?? '', ); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 4cb35ffc5..871ad739c 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -1,8 +1,8 @@ import { type Dispatch, - type FormEvent, type SetStateAction, useCallback, + useRef, useState, } from 'react'; @@ -10,7 +10,7 @@ import type { GameCreationAppManifest, GameCreationAppPreviewState, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; -import { defaultProjectPath, seedManifest } from '../../app/constants'; +import { seedManifest } from '../../app/constants'; import { useEscapeToClose } from '../../app/dialogs'; import { resolveTauriInvoke } from '../../app/tauri'; import type { @@ -58,7 +58,15 @@ export function useHomeProjectCreation({ setAgentChatProjectPath, rememberRecentWorkspace, }: UseHomeProjectCreationOptions) { - const [projectPath, setProjectPath] = useState(defaultProjectPath); + const [projectPath, setProjectPathState] = useState(''); + const [projectAction, setProjectAction] = useState< + 'opening' | 'creating' | null + >(null); + const projectActionRef = useRef(projectAction); + projectActionRef.current = projectAction; + const setProjectPath = useCallback((nextProjectPath: string) => { + setProjectPathState(nextProjectPath); + }, []); const [currentProjectContext, setCurrentProjectContext] = useState(null); const [activeProjectPreview, setActiveProjectPreview] = @@ -74,26 +82,6 @@ export function useHomeProjectCreation({ (state) => state.reset, ); - const loadDefaultProjectPath = useCallback(async () => { - if (projectPath) { - return; - } - const invoke = resolveTauriInvoke(); - if (!invoke) { - return; - } - try { - const defaultPath = await invoke( - 'get_default_local_game_project_path', - ); - if (defaultPath.trim()) { - setProjectPath((currentPath) => currentPath || defaultPath); - } - } catch { - // The user can still choose a directory manually. - } - }, [projectPath]); - function validateProjectPath(nextProjectPath: string) { const trimmedProjectPath = nextProjectPath.trim(); if (!trimmedProjectPath || !isAbsoluteProjectPath(trimmedProjectPath)) { @@ -320,6 +308,9 @@ export function useHomeProjectCreation({ nextProjectPath: string, skipNonEmptyCheck = false, ) { + if (projectActionRef.current) { + return; + } const trimmedProjectPath = validateProjectPath(nextProjectPath); if (!trimmedProjectPath) { return; @@ -329,6 +320,8 @@ export function useHomeProjectCreation({ setStatus('需要在 Tauri App 内运行'); return; } + projectActionRef.current = 'creating'; + setProjectAction('creating'); setStatus('正在创建项目'); try { if (!skipNonEmptyCheck) { @@ -373,6 +366,11 @@ export function useHomeProjectCreation({ }); } catch (error) { setStatus(error instanceof Error ? error.message : String(error)); + } finally { + if (projectActionRef.current === 'creating') { + projectActionRef.current = null; + setProjectAction(null); + } } } @@ -381,6 +379,9 @@ export function useHomeProjectCreation({ await createProjectFromProjectPage(nextProjectPath); return; } + if (projectActionRef.current) { + return; + } const trimmedProjectPath = validateProjectPath(nextProjectPath); if (!trimmedProjectPath) { return; @@ -390,6 +391,8 @@ export function useHomeProjectCreation({ setStatus('需要在 Tauri App 内运行'); return; } + projectActionRef.current = 'opening'; + setProjectAction('opening'); setStatus('正在打开'); try { const directoryStatus = await invoke( @@ -404,18 +407,28 @@ export function useHomeProjectCreation({ setStatus('项目路径不是文件夹'); return; } - if (!directoryStatus.isGameCreatorProject) { - setStatus('这不是已初始化的 AI 游戏项目,请使用新建项目。'); - return; - } - let projectManifest = seedManifest; - try { + let projectManifest: GameCreationAppManifest; + if (directoryStatus.isGameCreatorProject) { projectManifest = await invoke( 'get_local_game_manifest', { projectPath: trimmedProjectPath }, ); - } catch { - // The Supervisor surface can still restore conversation/runtime state. + } else if ( + directoryStatus.godotProjectRoot !== null && + directoryStatus.godotProjectRoot !== undefined + ) { + const result = await invoke( + 'import_local_godot_project', + { + projectPath: trimmedProjectPath, + projectId: seedManifest.projectId, + name: projectNameFromPath(trimmedProjectPath), + }, + ); + projectManifest = result.manifest; + } else { + setStatus('这不是已初始化的 AI 游戏项目,请使用新建项目。'); + return; } setStatus('已打开项目'); enterProjectDevelopment({ @@ -423,7 +436,11 @@ export function useHomeProjectCreation({ projectName: directoryStatus.projectName || projectNameFromPath(trimmedProjectPath), - projectKind: directoryStatus.isGodotProject ? 'godot' : 'web', + projectKind: + directoryStatus.godotProjectRoot !== null && + directoryStatus.godotProjectRoot !== undefined + ? 'godot' + : 'web', manifest: projectManifest, projectRevision: await readCurrentProjectRevision( invoke, @@ -438,6 +455,11 @@ export function useHomeProjectCreation({ }); } catch (error) { setStatus(error instanceof Error ? error.message : String(error)); + } finally { + if (projectActionRef.current === 'opening') { + projectActionRef.current = null; + setProjectAction(null); + } } } @@ -474,11 +496,6 @@ export function useHomeProjectCreation({ pendingNonEmptyProject !== null, ); - function handleSubmit(event: FormEvent) { - event.preventDefault(); - void openProject(projectPath, 'open'); - } - async function createHomeDraft(draft: HomeDraft) { const invoke = resolveTauriInvoke(); if (!invoke) { @@ -486,6 +503,7 @@ export function useHomeProjectCreation({ } const selectedPath = await invoke( 'pick_local_project_directory', + projectPath.trim() ? { initialPath: projectPath.trim() } : undefined, ); if (!selectedPath) { return '已取消'; @@ -515,93 +533,73 @@ export function useHomeProjectCreation({ ); } - async function handlePickProjectDirectory() { + async function pickAndOpenProject() { + if (projectActionRef.current) { + return; + } const invoke = resolveTauriInvoke(); if (!invoke) { setStatus('需要在 Tauri App 内运行'); return; } - setStatus('正在选择'); + projectActionRef.current = 'opening'; + setProjectAction('opening'); + setStatus('正在选择项目'); try { const selectedPath = await invoke( 'pick_local_project_directory', + projectPath.trim() ? { initialPath: projectPath.trim() } : undefined, ); if (!selectedPath) { setStatus('已取消'); return; } setProjectPath(selectedPath); - setStatus('已选择项目目录'); + projectActionRef.current = null; + setProjectAction(null); + await openProject(selectedPath, 'open'); } catch (error) { setStatus(error instanceof Error ? error.message : String(error)); + } finally { + if (projectActionRef.current === 'opening') { + projectActionRef.current = null; + setProjectAction(null); + } } } - async function openGodotProject() { + async function pickAndCreateProject() { + if (projectActionRef.current) { + return; + } const invoke = resolveTauriInvoke(); if (!invoke) { setStatus('需要在 Tauri App 内运行'); return; } - setStatus('正在选择 Godot 项目'); + projectActionRef.current = 'creating'; + setProjectAction('creating'); + setStatus('正在选择新项目文件夹'); try { const selectedPath = await invoke( 'pick_local_project_directory', + projectPath.trim() ? { initialPath: projectPath.trim() } : undefined, ); if (!selectedPath) { setStatus('已取消'); return; } - const trimmedProjectPath = validateProjectPath(selectedPath); - if (!trimmedProjectPath) { - return; - } - const directoryStatus = await invoke( - 'inspect_local_project_directory', - { projectPath: trimmedProjectPath }, - ); - if (!directoryStatus.exists || !directoryStatus.isDirectory) { - setStatus('Godot 项目目录不存在或不是文件夹'); - return; - } - if (!directoryStatus.isGodotProject) { - setStatus('所选文件夹不是 Godot 项目'); - return; - } - const result = directoryStatus.isGameCreatorProject - ? { - projectPath: trimmedProjectPath, - manifestPath: `${trimmedProjectPath.replace(/[\\/]+$/, '')}/.agent/manifest.json`, - manifest: await invoke( - 'get_local_game_manifest', - { projectPath: trimmedProjectPath }, - ), - } - : await invoke('import_local_godot_project', { - projectPath: trimmedProjectPath, - projectId: seedManifest.projectId, - name: projectNameFromPath(trimmedProjectPath), - }); - setStatus('已打开 Godot 项目'); - enterProjectDevelopment({ - projectPath: result.projectPath, - projectName: - result.manifest.name || projectNameFromPath(result.projectPath), - projectKind: 'godot', - manifest: result.manifest, - projectRevision: await readCurrentProjectRevision( - invoke, - result.projectPath, - ), - mode: null, - initialPrompt: '', - attachments: [], - recentRunStatus: directoryStatus.recentRunStatus, - recentRunStopReason: directoryStatus.recentRunStopReason, - createdAt: Date.now(), - }); + setProjectPath(selectedPath); + projectActionRef.current = null; + setProjectAction(null); + await createProjectFromProjectPage(selectedPath); } catch (error) { setStatus(error instanceof Error ? error.message : String(error)); + } finally { + if (projectActionRef.current === 'creating') { + projectActionRef.current = null; + setProjectAction(null); + } } } @@ -616,15 +614,15 @@ export function useHomeProjectCreation({ setAgentRuntimeSummaries, activeProjectAgentResults, setAgentResults, + projectAction, + projectBusy: projectAction !== null, pendingNonEmptyProject, resetLauncherHomeDraft, - loadDefaultProjectPath, createHomeDraft, createHomeDraftAutomatically, openProject, - openGodotProject, - handleSubmit, - handlePickProjectDirectory, + pickAndOpenProject, + pickAndCreateProject, confirmCreateInNonEmptyFolder, cancelCreateInNonEmptyFolder, }; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts index 8540e268c..9475b7cfb 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts @@ -8,7 +8,6 @@ import { } from '../project-summary/projectSummary'; import { buildRecentProjectRows, - clearRecentWorkspaces, readRecentWorkspaces, removeRecentWorkspace, writeRecentWorkspace, @@ -62,13 +61,6 @@ export function useRecentProjects(setStatus: Dispatch>) { setRecentWorkspaceRefreshKey((current) => current + 1); } - function handleRecentWorkspaceRefresh() { - if (recentWorkspaces.length === 0 || recentWorkspaceRefreshing) { - return; - } - setRecentWorkspaceRefreshKey((current) => current + 1); - } - function handleRecentWorkspaceRemove(projectPath: string) { setRecentWorkspaces(removeRecentWorkspace(projectPath)); setRecentWorkspaceStatuses((current) => { @@ -77,11 +69,6 @@ export function useRecentProjects(setStatus: Dispatch>) { }); } - function handleRecentWorkspaceClear() { - setRecentWorkspaces(clearRecentWorkspaces()); - setRecentWorkspaceStatuses({}); - } - async function handleRevealProjectDirectory(projectPath: string) { const trimmedProjectPath = projectPath.trim(); if (!trimmedProjectPath || !isAbsoluteProjectPath(trimmedProjectPath)) { @@ -121,9 +108,7 @@ export function useRecentProjects(setStatus: Dispatch>) { .filter((project) => project.canOpen) .slice(0, 3), rememberRecentWorkspace, - handleRecentWorkspaceRefresh, handleRecentWorkspaceRemove, - handleRecentWorkspaceClear, handleRevealProjectDirectory, }; } diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index c9f977db6..9c97800d6 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -315,58 +315,89 @@ textarea { gap: 8px; } -.launcher-project-form { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: end; - gap: 10px; - margin: 16px 0 14px; - padding: 12px; - border: 1px solid #e5e7eb; - border-radius: 8px; - background: #fff; -} - -.launcher-project-form label { - display: grid; - gap: 6px; - min-width: 0; - color: #6b7280; - font-size: 12px; -} - -.launcher-project-form input { - min-width: 0; - height: 34px; - padding: 0 10px; - border: 1px solid #d8dde5; - border-radius: 8px; - color: #111827; - background: #fff; -} - +.launcher-project-actions button, .launcher-page-actions button, .launcher-page .launcher-project-list-actions button, .launcher-project-table article > button, .launcher-empty-projects button { - height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 36px; padding: 0 12px; - border: 1px solid #d8dde5; - border-radius: 8px; - background: #fff; - color: #111827; + border: 1px solid var(--platform-surface-border); + border-radius: 9px; + background: var(--platform-button-secondary-fill); + color: var(--platform-button-secondary-text); font-size: 12px; + font-weight: 700; + white-space: nowrap; + cursor: pointer; } -.launcher-page .launcher-project-list-actions button:disabled { +.launcher-project-actions button:disabled, +.launcher-page .launcher-project-list-actions button:disabled, +.launcher-project-table article > button:disabled { cursor: not-allowed; opacity: 0.55; } -.launcher-page-actions button:last-child { - border-color: #111827; - background: #111827; - color: #fff; +.launcher-project-actions button:focus-visible, +.launcher-project-table article > button:focus-visible { + outline: 3px solid var(--platform-warm-text); + outline-offset: 2px; +} + +.launcher-project-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin: 16px 0 14px; +} + +.launcher-project-actions button { + justify-content: flex-start; + min-height: 76px; + padding: 14px 16px; + text-align: left; +} + +.launcher-project-actions button > span { + display: grid; + gap: 4px; +} + +.launcher-project-actions button strong { + font-size: 14px; +} + +.launcher-project-actions button small { + color: var(--platform-text-soft); + font-size: 11px; + font-weight: 500; + white-space: normal; +} + +.launcher-project-actions .launcher-project-open-action { + border-color: var(--platform-surface-border); + background: var(--platform-button-secondary-fill); + color: var(--platform-button-secondary-text); +} + +.launcher-project-actions .launcher-project-create-action { + border-color: var(--platform-button-primary-border); + background: var(--platform-button-primary-fill); + color: var(--platform-button-primary-text); +} + +.launcher-project-actions .launcher-project-create-action small { + color: inherit; + opacity: 0.78; +} + +.launcher-project-page-status { + min-height: 18px; } .launcher-project-table { @@ -405,6 +436,9 @@ textarea { .launcher-project-table article small { display: block; + width: 100%; + min-width: 0; + max-width: 100%; margin-top: 4px; color: #8b8b8b; font-size: 12px; @@ -2677,14 +2711,61 @@ textarea { white-space: nowrap; } - .launcher-project-form { + .launcher-project-actions { grid-template-columns: 1fr; + gap: 9px; + } + + .launcher-project-actions button { + min-height: 66px; } .launcher-page { width: min(100%, calc(100vw - 76px)); } + .launcher-projects-page { + padding-top: 54px; + } + + .launcher-project-table article { + grid-template-columns: 1fr 1fr; + align-items: stretch; + gap: 8px; + } + + .launcher-project-table article > .launcher-project-row-main, + .launcher-project-table article > span { + grid-column: 1 / -1; + } + + .launcher-project-table article > .launcher-project-row-main { + width: 100%; + max-width: 100%; + } + + .launcher-project-table article > .launcher-project-row-main strong { + width: 100%; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .launcher-project-table article > span { + white-space: normal; + } + + .launcher-project-table article > button:not(.launcher-project-row-main) { + width: 100%; + min-width: 0; + padding: 0 8px; + } + + .launcher-project-table article > button:last-child { + grid-column: 1 / -1; + } + .launcher-agent-chat-page > header, .launcher-agent-chat-main > header { align-items: stretch; diff --git a/apps/ai-game-creator-shell/src/view/home/index.tsx b/apps/ai-game-creator-shell/src/view/home/index.tsx index e39b52162..0ffca6ee9 100644 --- a/apps/ai-game-creator-shell/src/view/home/index.tsx +++ b/apps/ai-game-creator-shell/src/view/home/index.tsx @@ -50,7 +50,7 @@ type HomeViewProps = { onCreateDraftAutomatically: (draft: HomeDraft) => Promise; onProjectsOpen: () => void; onProjectOpen: (path: string) => void; - onGodotProjectOpen: () => void; + onProjectPick: () => void; }; export default function HomeView({ @@ -63,7 +63,7 @@ export default function HomeView({ onCreateDraftAutomatically, onProjectsOpen, onProjectOpen, - onGodotProjectOpen, + onProjectPick, }: HomeViewProps) { const homeAgentMode = useLauncherHomeDraftStore((state) => state.mode); const homeRichText = useLauncherHomeDraftStore((state) => state.draft); @@ -213,10 +213,10 @@ export default function HomeView({ + {open ? ( +
+ + +
+ ) : null} +
+ ); +} + export function ProjectsPage({ status, homeProject, @@ -13,109 +157,137 @@ export function ProjectsPage({ homeProject: HomeProjectCreationController; recentProjects: RecentProjectsController; }) { + const rows = recentProjects.filteredProjectRows; + const hasStoredProjects = recentProjects.projectRows.length > 0; return (
-
-
-

项目组

+
+
+

项目

{recentProjects.recentWorkspaceRefreshing ? '正在检查项目状态' : status}

-
-
- + ) : null} + +
+ - + -
-
- {recentProjects.projectRows.length > 0 ? ( - recentProjects.projectRows.map((project) => ( -
- - {project.status} - - - -
- )) - ) : ( -
-
- )} +
+
+
+
+ 项目 + 类型 + 状态 + 操作 +
+
+ {rows.length > 0 ? ( + rows.map((project) => ( +
+ + + {projectKindLabel(project)} + + + {projectStatusTone(project) === 'warning' ? ( + + +
+ )) + ) : hasStoredProjects ? ( +
+
+ ) : ( +
+
+ )} +
); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/model.ts b/apps/ai-game-creator-shell/src/features/app-shell/model.ts index 13fd90fb8..f4fdf89aa 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/model.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/model.ts @@ -56,6 +56,10 @@ export type RecentProjectRow = { path: string; name: string; status: string; + projectKind: 'web' | 'godot' | 'unknown'; + godotProjectRoot: string | null; + recentRunStatus: string | null; + recentRunStopReason: string | null; canReveal: boolean; canOpen: boolean; }; @@ -275,15 +279,19 @@ export function buildRecentProjectRows( ? '不是文件夹' : directoryStatus?.manifestError ? '无法读取' - : directoryStatus?.isGameCreatorProject === false - ? '未初始化' - : directoryStatus?.recentRunStatus - ? `run: ${directoryStatus.recentRunStatus}${ - directoryStatus.recentRunStopReason - ? ` · ${directoryStatus.recentRunStopReason}` - : '' - }` - : '本地项目'; + : directoryStatus?.isGodotProject === true && + directoryStatus?.isGameCreatorProject === false + ? '可导入' + : directoryStatus?.isGameCreatorProject === false + ? '未初始化' + : directoryStatus?.recentRunStatus + ? formatRecentProjectRunStatus( + directoryStatus.recentRunStatus, + directoryStatus.recentRunStopReason, + ) + : directoryStatus?.isGodotProject + ? '可打开' + : '本地项目'; const canReveal = !recentWorkspaceRefreshing && Boolean(directoryStatus) && @@ -293,6 +301,14 @@ export function buildRecentProjectRows( path: workspace, name: projectName, status, + projectKind: directoryStatus?.isGodotProject + ? 'godot' + : directoryStatus?.isGameCreatorProject + ? 'web' + : 'unknown', + godotProjectRoot: directoryStatus?.godotProjectRoot ?? null, + recentRunStatus: directoryStatus?.recentRunStatus ?? null, + recentRunStopReason: directoryStatus?.recentRunStopReason ?? null, canReveal, canOpen: !recentWorkspaceRefreshing && @@ -300,7 +316,34 @@ export function buildRecentProjectRows( directoryStatus?.exists !== false && directoryStatus?.isDirectory !== false && !directoryStatus?.manifestError && - directoryStatus?.isGameCreatorProject !== false, + (directoryStatus?.isGameCreatorProject !== false || + directoryStatus?.isGodotProject === true), }; }); } + +function formatRecentProjectRunStatus( + status: string, + stopReason: string | null, +) { + const statusLabel = + { + completed: '已完成', + done: '已完成', + failed: '运行失败', + running: '运行中', + pending: '等待运行', + cancelled: '已取消', + }[status] ?? status; + const stopReasonLabel = stopReason + ? ({ + 'preview-running': '预览运行中', + completed: '已完成', + failed: '运行失败', + cancelled: '已取消', + }[stopReason] ?? stopReason) + : null; + return stopReasonLabel && stopReasonLabel !== statusLabel + ? `${statusLabel} · ${stopReasonLabel}` + : statusLabel; +} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts index 9475b7cfb..ba71d0520 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts @@ -1,4 +1,10 @@ -import { type Dispatch, type SetStateAction, useEffect, useState } from 'react'; +import { + type Dispatch, + type SetStateAction, + useEffect, + useMemo, + useState, +} from 'react'; import { resolveTauriInvoke } from '../../app/tauri'; import type { LocalProjectDirectoryStatus } from '../../app/types'; @@ -22,6 +28,7 @@ export function useRecentProjects(setStatus: Dispatch>) { const [recentWorkspaceRefreshKey, setRecentWorkspaceRefreshKey] = useState(0); const [recentWorkspaceRefreshing, setRecentWorkspaceRefreshing] = useState(false); + const [projectSearchQuery, setProjectSearchQuery] = useState(''); useEffect(() => { const invoke = resolveTauriInvoke(); @@ -99,11 +106,33 @@ export function useRecentProjects(setStatus: Dispatch>) { recentWorkspaceStatuses, recentWorkspaceRefreshing, ); + const filteredProjectRows = useMemo(() => { + const normalizedQuery = projectSearchQuery.trim().toLocaleLowerCase(); + if (!normalizedQuery) { + return projectRows; + } + return projectRows.filter((project) => + [ + project.name, + project.path, + project.status, + project.godotProjectRoot ?? '', + project.projectKind === 'godot' + ? 'godot' + : project.projectKind === 'web' + ? 'gameagent web' + : 'unknown', + ].some((value) => value.toLocaleLowerCase().includes(normalizedQuery)), + ); + }, [projectRows, projectSearchQuery]); return { recentWorkspaces, recentWorkspaceRefreshing, projectRows, + filteredProjectRows, + projectSearchQuery, + setProjectSearchQuery, recentProjectRows: projectRows .filter((project) => project.canOpen) .slice(0, 3), diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 9c97800d6..0ae68b823 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -281,6 +281,25 @@ textarea { padding: 118px 0 0; } +.launcher-projects-page { + align-content: start; + grid-template-rows: auto minmax(0, 1fr); + gap: 18px; + width: calc(100vw - var(--launcher-sidebar-width)); + max-width: none; + height: 100dvh; + min-height: 0; + margin: 0; + padding: 92px 28px 24px; + overflow: hidden; +} + +.launcher-main:has(.launcher-projects-page) { + height: 100dvh; + min-height: 0; + padding-bottom: 0; +} + .launcher-page header { display: flex; align-items: flex-end; @@ -315,10 +334,8 @@ textarea { gap: 8px; } -.launcher-project-actions button, .launcher-page-actions button, .launcher-page .launcher-project-list-actions button, -.launcher-project-table article > button, .launcher-empty-projects button { display: inline-flex; align-items: center; @@ -336,51 +353,104 @@ textarea { cursor: pointer; } -.launcher-project-actions button:disabled, .launcher-page .launcher-project-list-actions button:disabled, -.launcher-project-table article > button:disabled { +.launcher-empty-projects button:disabled { cursor: not-allowed; opacity: 0.55; } -.launcher-project-actions button:focus-visible, -.launcher-project-table article > button:focus-visible { +.launcher-projects-page button:focus-visible, +.launcher-project-search input:focus-visible { outline: 3px solid var(--platform-warm-text); outline-offset: 2px; } -.launcher-project-actions { +.launcher-projects-toolbar { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; - margin: 16px 0 14px; + grid-template-columns: minmax(160px, 1fr) auto; + align-items: center; + gap: 24px; +} + +.launcher-projects-heading { + min-width: 0; +} + +.launcher-projects-controls { + display: flex; + align-items: center; + gap: 10px; +} + +.launcher-project-search { + display: flex; + align-items: center; + width: clamp(260px, 28vw, 390px); + height: 38px; + gap: 8px; + padding: 0 10px; + border: 1px solid var(--platform-surface-border); + border-radius: 9px; + background: var(--platform-input-fill); + color: var(--platform-text-soft); +} + +.launcher-project-search:focus-within { + border-color: var(--platform-surface-hover-border); + box-shadow: 0 0 0 3px var(--platform-input-focus-ring); +} + +.launcher-project-search input { + min-width: 0; + flex: 1; + border: 0; + outline: 0; + background: transparent; + color: var(--platform-text-strong); + font: inherit; + font-size: 13px; +} + +.launcher-project-search button { + display: grid; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--platform-text-soft); + cursor: pointer; + place-items: center; +} + +.launcher-project-actions { + display: flex; + align-items: center; + gap: 8px; } .launcher-project-actions button { - justify-content: flex-start; - min-height: 76px; - padding: 14px 16px; - text-align: left; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 38px; + gap: 7px; + padding: 0 14px; + border: 1px solid var(--platform-surface-border); + border-radius: 9px; + font-size: 12px; + font-weight: 800; + white-space: nowrap; + cursor: pointer; } -.launcher-project-actions button > span { - display: grid; - gap: 4px; -} - -.launcher-project-actions button strong { - font-size: 14px; -} - -.launcher-project-actions button small { - color: var(--platform-text-soft); - font-size: 11px; - font-weight: 500; - white-space: normal; +.launcher-project-actions button:disabled { + cursor: not-allowed; + opacity: 0.55; } .launcher-project-actions .launcher-project-open-action { - border-color: var(--platform-surface-border); background: var(--platform-button-secondary-fill); color: var(--platform-button-secondary-text); } @@ -391,47 +461,119 @@ textarea { color: var(--platform-button-primary-text); } -.launcher-project-actions .launcher-project-create-action small { - color: inherit; - opacity: 0.78; -} - .launcher-project-page-status { min-height: 18px; } +.launcher-project-list-shell { + display: grid; + grid-template-rows: 42px minmax(0, 1fr); + min-height: 0; + overflow: hidden; + border: 1px solid var(--platform-surface-border); + border-radius: 12px; + background: var(--platform-subpanel-fill); + box-shadow: var(--platform-panel-shadow); +} + +.launcher-project-table-header, +.launcher-project-table article { + display: grid; + grid-template-columns: + minmax(360px, 1fr) minmax(120px, 160px) minmax(150px, 190px) + 56px; + align-items: center; + min-width: 0; +} + +.launcher-project-table-header { + height: 42px; + padding: 0 14px; + border-bottom: 1px solid var(--platform-surface-border); + background: color-mix( + in srgb, + var(--platform-subpanel-fill) 82%, + var(--platform-warm-bg) + ); + color: var(--platform-text-soft); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.03em; +} + +.launcher-project-table-header span:last-child { + text-align: center; +} + .launcher-project-table { display: grid; - gap: 8px; + align-content: start; + min-height: 0; + overflow-y: auto; } .launcher-project-table article { - display: grid; - grid-template-columns: minmax(0, 1fr) auto auto auto auto; - align-items: center; - gap: 10px; - min-height: 62px; - padding: 10px 12px; - border: 1px solid #e5e7eb; - border-radius: 8px; - background: #fff; + min-height: 66px; + padding: 0 14px; + border-bottom: 1px solid var(--platform-surface-border); + background: var(--platform-subpanel-fill); + transition: background 120ms ease; +} + +.launcher-project-table article:last-child { + border-bottom: 0; +} + +.launcher-project-table + article:has(.launcher-project-row-main:not(:disabled)):hover { + background: var(--platform-nav-item-hover-fill); } .launcher-project-table article > .launcher-project-row-main { - display: grid; - justify-items: start; - height: auto; + display: flex; + align-items: center; + justify-content: flex-start; + width: 100%; + height: 100%; min-width: 0; + gap: 12px; padding: 0; border: 0; background: transparent; + color: inherit; text-align: left; + cursor: pointer; +} + +.launcher-project-table article > .launcher-project-row-main:disabled { + cursor: not-allowed; + opacity: 0.62; +} + +.launcher-project-kind-icon { + display: grid; + width: 34px; + height: 34px; + flex: 0 0 auto; + border-radius: 9px; + background: var(--platform-warm-bg); + color: var(--platform-warm-text); + place-items: center; +} + +.launcher-project-name-cell { + display: grid; + min-width: 0; + gap: 4px; } .launcher-project-table article strong { display: block; - color: #111827; - font-size: 14px; + overflow: hidden; + color: var(--platform-text-strong); + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; } .launcher-project-table article small { @@ -439,29 +581,135 @@ textarea { width: 100%; min-width: 0; max-width: 100%; - margin-top: 4px; - color: #8b8b8b; - font-size: 12px; + margin: 0; + color: var(--platform-text-soft); + font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.launcher-project-table article span { - color: #6b7280; +.launcher-project-kind-cell { + overflow: hidden; + padding-right: 16px; + color: var(--platform-text-base); font-size: 12px; + text-overflow: ellipsis; white-space: nowrap; } +.launcher-project-status { + display: inline-flex; + align-items: center; + justify-self: start; + max-width: calc(100% - 12px); + min-height: 24px; + gap: 5px; + overflow: hidden; + padding: 0 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.launcher-project-status-ready { + background: color-mix(in srgb, #2d9d62 12%, transparent); + color: #237b4d; +} + +.launcher-project-status-checking { + background: color-mix(in srgb, var(--platform-warm-text) 12%, transparent); + color: var(--platform-warm-text); +} + +.launcher-project-status-warning { + background: color-mix(in srgb, #d15f45 12%, transparent); + color: #a64735; +} + +.launcher-project-more { + position: relative; + display: grid; + justify-self: center; +} + +.launcher-project-more-trigger { + display: grid; + width: 34px; + height: 34px; + padding: 0; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--platform-text-soft); + cursor: pointer; + place-items: center; +} + +.launcher-project-more-trigger:hover, +.launcher-project-more-trigger[aria-expanded='true'] { + background: var(--platform-nav-item-hover-fill); + color: var(--platform-text-strong); +} + +.launcher-project-more-menu { + position: absolute; + top: 38px; + right: 0; + z-index: 20; + display: grid; + width: 164px; + gap: 4px; + padding: 6px; + border: 1px solid var(--platform-surface-border); + border-radius: 10px; + background: var(--platform-subpanel-fill); + box-shadow: 0 16px 34px rgb(75 47 32 / 18%); +} + +.launcher-project-more-menu-drop-up { + top: auto; + bottom: 38px; +} + +.launcher-project-more-menu button { + display: flex; + align-items: center; + justify-content: flex-start; + min-height: 34px; + gap: 8px; + padding: 0 9px; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--platform-text-strong); + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.launcher-project-more-menu button:hover { + background: var(--platform-nav-item-hover-fill); +} + +.launcher-project-more-menu button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.launcher-project-more-menu .launcher-project-remove-action { + color: #a64735; +} + .launcher-empty-projects { display: grid; justify-items: center; gap: 12px; - min-height: 220px; - padding: 44px; - border: 1px dashed #d8dde5; - border-radius: 8px; - color: #6b7280; + min-height: 260px; + padding: 48px; + color: var(--platform-text-soft); } .launcher-empty-projects strong { @@ -2711,59 +2959,13 @@ textarea { white-space: nowrap; } - .launcher-project-actions { - grid-template-columns: 1fr; - gap: 9px; - } - - .launcher-project-actions button { - min-height: 66px; - } - .launcher-page { width: min(100%, calc(100vw - 76px)); } .launcher-projects-page { - padding-top: 54px; - } - - .launcher-project-table article { - grid-template-columns: 1fr 1fr; - align-items: stretch; - gap: 8px; - } - - .launcher-project-table article > .launcher-project-row-main, - .launcher-project-table article > span { - grid-column: 1 / -1; - } - - .launcher-project-table article > .launcher-project-row-main { - width: 100%; - max-width: 100%; - } - - .launcher-project-table article > .launcher-project-row-main strong { - width: 100%; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .launcher-project-table article > span { - white-space: normal; - } - - .launcher-project-table article > button:not(.launcher-project-row-main) { - width: 100%; - min-width: 0; - padding: 0 8px; - } - - .launcher-project-table article > button:last-child { - grid-column: 1 / -1; + width: calc(100vw - var(--launcher-sidebar-width)); + padding: 72px 18px 20px; } .launcher-agent-chat-page > header, 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 2c7d570ff..13fc0e0b4 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1497,6 +1497,105 @@ export function registerHomeProjectCreationTests() { } export function registerRecentProjectsTests() { + function openProjectMoreMenu(projectName: string) { + fireEvent.click( + screen.getByRole('button', { name: `${projectName}的更多操作` }), + ); + return screen.getByRole('menu'); + } + + it('renders and filters a compact project table with GameAgent and Godot metadata', async () => { + const paths = [ + 'C:\\Projects\\Skyrail', + 'C:\\Projects\\RootGodot', + 'C:\\Projects\\StudioWorkspace', + ]; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + const projectPath = String(args?.projectPath ?? ''); + if (projectPath === paths[0]) { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + isGodotProject: false, + godotProjectRoot: null, + projectName: '星轨工坊', + recentRunStatus: 'done', + recentRunStopReason: 'preview-running', + }; + } + if (projectPath === paths[1]) { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: false, + isGodotProject: true, + godotProjectRoot: '.', + projectName: null, + recentRunStatus: null, + recentRunStopReason: null, + }; + } + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + isGodotProject: true, + godotProjectRoot: 'engine', + projectName: '云岛工作区', + recentRunStatus: 'failed', + recentRunStopReason: null, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + window.localStorage.setItem( + 'genarrative-ai-game-creator.recent-workspaces.v1', + JSON.stringify(paths), + ); + + renderLauncherProjectsAt('/?launcher'); + + expect(await screen.findByText('星轨工坊')).not.toBeNull(); + expect(screen.getByRole('heading', { name: '项目' })).not.toBeNull(); + expect(screen.getByLabelText('项目列表')).not.toBeNull(); + expect(screen.getByText('GameAgent')).not.toBeNull(); + expect(screen.getByText('Godot')).not.toBeNull(); + expect(screen.getByText('Godot · engine')).not.toBeNull(); + expect(screen.getByText('已完成 · 预览运行中')).not.toBeNull(); + expect(screen.getByText('运行失败')).not.toBeNull(); + expect( + ( + screen.getByRole('button', { + name: '打开项目 RootGodot', + }) as HTMLButtonElement + ).disabled, + ).toBe(false); + + const search = screen.getByRole('searchbox', { name: '搜索项目' }); + fireEvent.change(search, { target: { value: 'godot' } }); + expect(screen.queryByText('星轨工坊')).toBeNull(); + expect(screen.getByText('RootGodot')).not.toBeNull(); + expect(screen.getByText('云岛工作区')).not.toBeNull(); + + fireEvent.change(search, { target: { value: 'engine' } }); + expect(screen.queryByText('RootGodot')).toBeNull(); + expect(screen.getByText('云岛工作区')).not.toBeNull(); + + fireEvent.change(search, { target: { value: '不存在的项目' } }); + expect(screen.getByText('没有匹配的项目')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '清除搜索' })); + expect(screen.getByText('星轨工坊')).not.toBeNull(); + expect(screen.getByText('RootGodot')).not.toBeNull(); + }); + it('removes recent launcher projects without opening them', () => { const invoke = vi.fn(async () => undefined); window.__TAURI__ = { core: { invoke } }; @@ -1516,10 +1615,7 @@ export function registerRecentProjectsTests() { expect(screen.getByLabelText('最近项目')).not.toBeNull(); expect(screen.getByText('暂无最近项目')).not.toBeNull(); expect( - screen.queryByRole('button', { name: '移除 /tmp/recent-one' }), - ).toBeNull(); - expect( - screen.queryByRole('button', { name: '显示 /tmp/recent-one' }), + screen.queryByRole('button', { name: 'recent-one的更多操作' }), ).toBeNull(); expect(screen.queryByRole('button', { name: '刷新' })).toBeNull(); expect(screen.queryByRole('button', { name: '清空' })).toBeNull(); @@ -1533,7 +1629,9 @@ export function registerRecentProjectsTests() { }); fireEvent.click( - screen.getByRole('button', { name: '移除 /tmp/recent-one' }), + within(openProjectMoreMenu('recent-one')).getByRole('menuitem', { + name: '从列表移除', + }), ); expect(screen.queryByText('/tmp/recent-one')).toBeNull(); @@ -1544,7 +1642,9 @@ export function registerRecentProjectsTests() { ); fireEvent.click( - screen.getByRole('button', { name: '移除 /tmp/recent-two' }), + within(openProjectMoreMenu('recent-two')).getByRole('menuitem', { + name: '从列表移除', + }), ); expect(screen.getByText('暂无项目')).not.toBeNull(); expect(window.localStorage.length).toBe(0); @@ -1582,11 +1682,13 @@ export function registerRecentProjectsTests() { expect(await screen.findByText('最近的项目')).not.toBeNull(); expect( - screen.queryByRole('button', { name: '显示 /tmp/recent-one' }), + screen.queryByRole('button', { name: '最近的项目的更多操作' }), ).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '项目组' })); fireEvent.click( - screen.getByRole('button', { name: '显示 /tmp/recent-one' }), + within(openProjectMoreMenu('最近的项目')).getByRole('menuitem', { + name: '显示目录', + }), ); await waitFor(() => { @@ -1683,7 +1785,7 @@ export function registerRecentProjectsTests() { expect(screen.getByText('无法读取')).not.toBeNull(); expect(screen.getByText('检查失败')).not.toBeNull(); expect(screen.getByText('厨房突围')).not.toBeNull(); - expect(screen.getByText('run: done · preview-running')).not.toBeNull(); + expect(screen.getByText('已完成 · 预览运行中')).not.toBeNull(); expect( (screen.getByText('missing-game').closest('button') as HTMLButtonElement) .disabled, @@ -1701,21 +1803,26 @@ export function registerRecentProjectsTests() { const brokenManifestOpenButton = screen .getByText('broken-manifest') .closest('button') as HTMLButtonElement; - const missingRevealButton = screen.getByRole('button', { - name: '显示 /tmp/missing-game', - }) as HTMLButtonElement; - const notAFolderRevealButton = screen.getByRole('button', { - name: '显示 /tmp/not-a-folder', - }) as HTMLButtonElement; - const plainFolderRevealButton = screen.getByRole('button', { - name: '显示 /tmp/plain-folder', - }) as HTMLButtonElement; - const brokenManifestRevealButton = screen.getByRole('button', { - name: '显示 /tmp/broken-manifest', - }) as HTMLButtonElement; - const brokenStatusRevealButton = screen.getByRole('button', { - name: '显示 /tmp/broken-status', - }) as HTMLButtonElement; + const missingRevealButton = within( + openProjectMoreMenu('missing-game'), + ).getByRole('menuitem', { name: '显示目录' }) as HTMLButtonElement; + fireEvent.keyDown(document, { key: 'Escape' }); + const notAFolderRevealButton = within( + openProjectMoreMenu('not-a-folder'), + ).getByRole('menuitem', { name: '显示目录' }) as HTMLButtonElement; + fireEvent.keyDown(document, { key: 'Escape' }); + const plainFolderRevealButton = within( + openProjectMoreMenu('plain-folder'), + ).getByRole('menuitem', { name: '显示目录' }) as HTMLButtonElement; + fireEvent.keyDown(document, { key: 'Escape' }); + const brokenManifestRevealButton = within( + openProjectMoreMenu('broken-manifest'), + ).getByRole('menuitem', { name: '显示目录' }) as HTMLButtonElement; + fireEvent.keyDown(document, { key: 'Escape' }); + const brokenStatusRevealButton = within( + openProjectMoreMenu('broken-status'), + ).getByRole('menuitem', { name: '显示目录' }) as HTMLButtonElement; + fireEvent.keyDown(document, { key: 'Escape' }); expect(missingOpenButton.disabled).toBe(true); expect(plainFolderOpenButton.disabled).toBe(true); expect(brokenManifestOpenButton.disabled).toBe(true); @@ -1746,7 +1853,7 @@ export function registerRecentProjectsTests() { { projectPath: '/tmp/broken-status' }, ); - fireEvent.click(screen.getByText('厨房突围').closest('button')!); + fireEvent.click(screen.getByRole('button', { name: '打开项目 厨房突围' })); await waitFor(() => { expect(screen.getByLabelText('项目总控对话')).not.toBeNull(); }); @@ -1792,7 +1899,7 @@ export function registerRecentProjectsTests() { expect(screen.queryByRole('button', { name: '刷新' })).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '项目组' })); expect(await screen.findByText('自动检查后的项目')).not.toBeNull(); - expect(screen.getByText('run: done · preview-running')).not.toBeNull(); + expect(screen.getByText('已完成 · 预览运行中')).not.toBeNull(); expect( ( screen @@ -1835,13 +1942,15 @@ export function registerRecentProjectsTests() { expect(screen.getByLabelText('最近项目')).not.toBeNull(); expect(screen.getByText('暂无最近项目')).not.toBeNull(); expect( - screen.queryByRole('button', { name: '移除 /tmp/slow-refresh-game' }), + screen.queryByRole('button', { name: 'slow-refresh-game的更多操作' }), ).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '项目组' })); expect(await screen.findByText('检查中')).not.toBeNull(); fireEvent.click( - screen.getByRole('button', { name: '移除 /tmp/slow-refresh-game' }), + within(openProjectMoreMenu('slow-refresh-game')).getByRole('menuitem', { + name: '从列表移除', + }), ); expect(screen.queryByText('/tmp/slow-refresh-game')).toBeNull(); @@ -1859,7 +1968,7 @@ export function registerRecentProjectsTests() { expect(screen.queryByText('/tmp/slow-refresh-game')).toBeNull(); expect(screen.queryByText('慢速刷新旧项目')).toBeNull(); - expect(screen.queryByText('run: done · late')).toBeNull(); + expect(screen.queryByText('已完成 · late')).toBeNull(); expect(screen.getByText('暂无项目')).not.toBeNull(); }); diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 1b5d02724..55b058981 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -1,6 +1,6 @@ # AI 游戏创作项目开发工作台 PRD -更新时间:`2026-08-11`(资源卡预览、分区布局与依赖聚类,图片素材无限画布及全类型非破坏性资源编辑收口;现有 Godot 项目入口与项目根绑定) +更新时间:`2026-08-15`(项目管理页紧凑表格与桌面可用性收口;资源卡预览、分区布局、非破坏性资源编辑及 Godot 双根合同保持不变) ## 1. 产品定位 @@ -119,6 +119,14 @@ - 首次打开只在工作区根创建 `.agent/` 元数据;已有 `.agent/manifest.json` 时读取并按当前唯一文件布局校准 `godotProjectRoot`。Godot 源码、场景、资源和项目配置继续使用原目录结构,不复制工程,也不建立 `game/`、`assets/`、`memory/`、`exports/` 平行目录。 - Godot 项目使用 `standard` Run Profile,避免套用 Web 原型的 `game/index.html`、本地 HTTP 预览和自主 Web 游戏完成门;本期不提供 Godot 内嵌运行预览。 +### 3.9 项目管理页 + +- 项目页采用“标题与状态 + 本地搜索 + 打开项目 / 新建项目 + 紧凑项目表格”的桌面信息架构。可以参考成熟项目管理器的信息层级和密度,但不得复制 Unity 等外部产品的品牌、Logo、深色皮肤、专有图标、列名或文案;页面继续使用 Genarrative 平台主题和陶泥儿客户端壳。 +- 表格只展示现有权威数据:项目名称与工作区绝对路径、GameAgent / Godot 类型、目录 / manifest / 最近 Runtime 状态和行级操作。当前目录检查合同没有修改时间、编辑器版本、收藏或云状态,前端不得伪造这些列。 +- 可打开项目的主行点击后进入项目;显示目录和从最近列表移除收进键盘可达的行尾更多菜单。Escape 关闭菜单并把焦点还给触发按钮;移除只修改本机 WebView 最近项目记录,不删除磁盘文件。 +- 搜索只在已加载项目行中匹配名称、路径、类型、Godot 相对根和状态,不改 localStorage、不触碰项目目录、不新增后端或 Tauri 命令。无匹配状态提供清除搜索;真正空状态仍只引导用户使用顶部打开或新建,不追加第三个目录选择入口。 +- 正式视觉验收只覆盖 `1280×720` 最小横屏和 `1280×800` 默认窗口:工具栏保持单行,表头与项目列对齐,项目列表内部滚动,document/body 不出现页面级横向或纵向溢出。截图必须使用含 Web、Godot 和无效状态的 populated fixture;视频必须演示搜索、清除、行尾菜单及可观察的项目操作结果,不能只录静止页面或无结果点击。 + ## 4. 工作台状态机 ### 4.1 主视窗 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 678525d25..fae730801 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7159,6 +7159,10 @@ ## 2026-08-10 AGC 打开现有 Godot 项目 +- 项目管理 UI 决策(2026-08-15):项目页改为 Genarrative 自有浅色视觉的紧凑桌面项目表格,成熟项目管理器只作为“标题 / 搜索 / 主操作 / 表头 / 项目行 / 行尾菜单”的信息架构参考,不复制外部品牌、皮肤、专有图标和文案。顶部只有搜索、打开项目和新建项目;搜索不是路径输入。可打开项目的主行直接进入项目,显示目录与从最近列表移除收进行尾更多菜单。 +- 投影边界:表格只展示目录检查、manifest 与 Runtime 已有权威字段,包括名称、工作区路径、GameAgent / Godot 类型、`godotProjectRoot` 和状态;没有修改时间、编辑器版本、收藏、云同步或版本警告事实时不得伪造这些列。搜索只过滤当前项目行,不改 storage 或项目目录。 +- 验收边界:正式项目页只按 `1280×720` 最小横屏和 `1280×800` 默认窗口验收,工具栏单行、列表内部滚动、页面无横纵溢出。截图使用 populated fixture;视频必须展示搜索、菜单和可观察操作结果,不再接受长时间静止或只有 picker 调用的录屏。窄屏 / 竖屏不属于当前产品合同。 + - 项目双根决策(2026-08-14 更新):项目组只保留通用“打开项目 / 新建项目”,不再提供独立 Godot 入口。用户选择目录始终是工作区根,也是 `.agent`、Session、Runner、沙箱、通用文件工具和外围资料的唯一授权根;实际 Godot 根由根目录或一层直接子目录中的普通文件 `project.godot` 唯一确定,并以工作区相对 `godotProjectRoot` 记录,根目录使用 `.`。不得把 Runtime 根切换成 Godot 子目录,也不得复制工程或建立第二套工作区。 - 发现与歧义决策:根目录命中优先;根未命中时只检查一层直接子目录,唯一命中才通过,多个命中在任何 `.agent` 写入前失败关闭。候选目录与工程文件拒绝符号链接和 Windows reparse point,二层及更深不递归。未来只有 Godot 专属命令显式使用经过校验的相对 Godot cwd。 - 元数据决策:首次导入只在工作区根创建并保留 `.agent/manifest.json`、`.agent/agent.db`、`.agent/logs/` 与 `.agent/runtime/`;不得创建默认 Web 原型的 `game/`、`assets/`、`memory/`、`exports/`。已有有效 `.agent` 项目继续复用身份;缺失或错误的可推导 `godotProjectRoot` 只在 Godot 打开边界按唯一文件布局校准,歧义时不改写。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index fcffd6da9..d34d969e6 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4388,9 +4388,17 @@ - 现象:源码已经移除项目页常驻路径输入框,Windows 客户端却仍显示 `/tmp/genarrative-ai-game-draft`,新“打开项目 / 新建项目”交互也没有出现。 - 原因:`apps/ai-game-creator-shell/dist/` 是 Git 忽略的本地构建产物,可能跨提交保留旧 JS;复用旧 `dist`、旧 EXE 或旧安装包时,Tauri 会继续嵌入旧前端。测试模式曾把 `/tmp` 同时当作产品初值,也让旧构建和测试夹具的边界难以辨认。 - 处理:产品前端项目页不持有默认路径;首页回车自动工作区仍只由原生 `document_dir()` 决定目标根,测试必须在 harness 内显式注入 `/tmp` fixture。普通与 game-chat release 在 Vite 构建后、Tauri 嵌入前扫描实际 `frontendDist`,命中精确旧路径、缺失产物或链接绕过时失败关闭;不能用仓库级源码扫描误伤合法测试 fixture。 -- 验证:运行 frontend dist guard 定向测试、AGC AppSurface 的双主按钮 / picker 防重复 / 首页回车自动创建回归、Tauri release `--no-bundle` smoke,并确认新 `dist` 不含旧路径;Windows 实机项目组不得出现常驻路径框或 `/tmp`,原生 picker 从系统默认位置打开。视觉验收同时检查桌面和窄屏双主按钮布局。 +- 验证:运行 frontend dist guard 定向测试、AGC AppSurface 的双主按钮 / picker 防重复 / 首页回车自动创建回归、Tauri release `--no-bundle` smoke,并确认新 `dist` 不含旧路径;Windows 实机项目组不得出现常驻路径框或 `/tmp`,原生 picker 从系统默认位置打开。视觉验收检查 `1280×720` 最小横屏与 `1280×800` 默认窗口的紧凑项目表格和原生 picker。 - 关联:`apps/ai-game-creator-shell/src/app/constants.ts`、`apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`、`apps/ai-game-creator-shell/src-tauri/src/commands.rs`、`apps/ai-game-creator-shell/src-tauri/build.rs`、`apps/ai-game-creator-shell/src-tauri/tauri.conf.json`。 +## 参考成熟项目管理器不能变成品牌复刻或伪数据列(2026-08-15) + +- 现象:根据外部产品截图重做项目页时,直接照搬其 Logo、深色皮肤、收藏 / 云图标、修改时间或编辑器版本列,页面看似成熟却展示 AGC 没有的数据真相,录屏也只剩静态摆拍。 +- 原因:把参考截图当成完整产品合同,没有先核对当前目录检查、manifest 和 Runtime 真正提供的字段,也没有定义视频必须证明的交互结果。 +- 处理:只借鉴标题、搜索、主操作、紧凑表头 / 项目行和行尾菜单的信息层级;继续使用 Genarrative theme/token,只展示项目名称、工作区路径、GameAgent / Godot 类型、Godot 相对根和真实状态。次要操作收进行尾菜单,搜索只做本地过滤。没有权威来源的列直接不做,不用占位或推断补齐。 +- 验证:DOM 与截图不得出现外部品牌或 unsupported 列;AppSurface 覆盖 populated / invalid / empty、搜索与菜单;Playwright 在 `1280×720` 测量无页面级溢出。视频控制在有用时长内,清楚展示搜索、清除、菜单、状态反馈和项目打开结果,每一段都有可观察变化。 +- 关联:`apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx`、`apps/ai-game-creator-shell/src/features/app-shell/model.ts`、`apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts`、`apps/ai-game-creator-shell/tests/appSurface/home.suite.ts`。 + ## game-chat 快车道首波与已提交回复不能被后续 revision 破坏(2026-08-03) - 现象:首波从单个美术任务扩展为三个 Director 后,hydration 若仍只容忍 seed lane 的第一个任务在 manifest 短暂恢复 `Pending` 时收束,另外两个已启动 Director 会被卡住。另外默认 `llm.stream=false` 下的专业 final reply 虽已由 finalization 提交,但后续阶段推进项目 revision 后,早期回复会从 Runtime 查询中消失。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 7bdfeac49..025732101 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -576,7 +576,7 @@ game-project/ - 首页发送、项目组目录选择和本地文件选择必须使用 Tauri 非阻塞原生 picker,并把选择器绑定到当前 `client` 窗口;禁止在同步 command 中调用 `blocking_pick_folder` / `blocking_pick_file` 阻塞 WebView 事件循环。选择器打开期间保留首页草稿可编辑,取消后恢复“开启创作”按钮并回显“已取消”。 - 当前尚未定义 GameAgent 独立的灵感数据源;首页保留“灵感推荐”区块并显示无数据状态,但不得展示或请求主站 `/creation` 的陶泥儿精选 `/api/editor/showcase/resources`。后续接入前必须先明确独立数据契约和交互验收。 - debug 构建启动后在用户 `client` 窗口之外额外打开 `developer` 窗口;该窗口用于开发者单独选择 Agent、管理对应 active/archived Session 并读取历史,用户消息和真实 Agent 回复只持久化到 `.agent/conversations/agents//` 下的规范 Session。普通用户窗口不得出现 `Agent 聊天` 导航、picker 或工具台入口。 -- 首页最近项目只展示最近 3 个有效项目;项目组页在同一窗口管理最近项目,并且顶部只常驻“打开项目”“新建项目”两个主动作,不常驻路径输入框、目录显示按钮或独立 Godot 入口。两个动作都先打开原生目录选择器:“打开项目”读取已有 GameAgent 项目,或自动识别根目录 / 一层直接子目录中的唯一 Godot 工程并导入;普通未初始化目录提示改用“新建项目”。“新建项目”在用户选择工作区根后沿用非空目录确认,不自动重建无效历史路径。最近项目行继续提供打开、显示和移除;空状态不再追加第三个目录选择入口。按钮在桌面端并排、窄屏下纵向全宽,状态反馈保持可访问且不暴露 Linux 测试默认路径。 +- 首页最近项目只展示最近 3 个有效项目;项目组页在同一窗口使用紧凑桌面项目表格管理最近项目。顶部工具栏只放本地搜索、“打开项目”“新建项目”,不常驻路径输入框、目录显示按钮或独立 Godot 入口。两个项目动作都先打开绑定当前 `client` 的非阻塞原生目录选择器:“打开项目”读取已有 GameAgent 项目,或自动识别根目录 / 一层直接子目录中的唯一 Godot 工程并导入;普通未初始化目录提示改用“新建项目”。“新建项目”在用户选择工作区根后沿用非空目录确认,不自动重建无效历史路径。项目表格只投影名称、路径、GameAgent / Godot 类型、目录 / manifest / Runtime 状态;可打开行点击进入项目,显示目录和移除最近记录收进行尾更多菜单。搜索仅过滤当前行,不修改 storage;空状态不追加第三个目录选择入口。正式验收只覆盖 `1280×720` 最小横屏和 `1280×800` 默认窗口,列表内部滚动且无页面级溢出。 - 项目开发页保留左侧栏和顶部栏,顶部展示项目名、路径和最近 run 状态;中间只挂载 active `project-supervisor` Session 的正式对话面、Runtime 状态、确认/Needs input 和专业 Agent 协作只读状态,底部保留附件导入结果。真正的项目开发画布仍未落地;专业 Agent picker、完整计划和工具台继续留在开发入口。 ## v1 验收证据矩阵 @@ -681,7 +681,7 @@ game-project/ - 2026-07-10 补充:当前工具箱还开放 `agent.delegate`,审计记录类型为 `agent.runtime.agent.delegate`;该工具只把任务投递到目标 Agent 既有后台队列,不创建平行 runtime,不绕过目标 Agent 的锁和队列,也受 `agent.delegate` 项目权限策略保护。 - 2026-07-10 补充:当前 Runtime 命令和后台工具箱都开放 `agent.schedule_ready`,审计记录类型为 `agent.runtime.ready_task.scheduled`;该命令把 manifest ready task 批量投递到对应 Agent 的既有后台队列,默认需要确认,确认前不会启动下游 Agent。 - 普通用户可在聊天框输入 `/project /绝对路径` 生成待确认的 `project.create` 命令,用于授权并初始化本地项目目录;相对路径不会生成待确认命令;开发窗口仍可直接编辑项目路径。 -- 单窗口首页和项目组页可选择、打开、新建或显示当前输入的项目绝对路径;最近项目行也可显示目录,非法或相对路径不会调用系统文件管理器。 +- 单窗口首页和项目组页不保留常驻路径输入;项目组只展示最近项目只读检查后的工作区绝对路径。打开 / 新建使用原生目录选择器,行尾菜单可显示已确认存在的项目目录,非法或相对路径不会调用系统文件管理器。 - 普通用户侧的生成、上传、运行、自检、预览状态 / 启动 / 打开 / 停止、记忆写入和画板资产导入都必须先完成 `/project` 初始化;未初始化时只提示设置本地项目,不落到默认临时目录。 - 终端可用 `npm run ai-game-creator-shell:llm-status` 检查 LLM 客户端配置是否就绪;桌面 App 主窗口“配置”面板可读写 Tauri 应用配置目录中的 `game-creator.config.json`,`/llm-status` / 生成入口读取同一份配置,CLI 开发入口无 AppHandle 时才回退读取仓库旁边的配置模板和 gitignored 本机覆盖文件;不请求上游、不显示 API Key,缺配置时以非零状态退出或在聊天里提示未就绪。 - 终端可用 `npm run ai-game-creator-shell:check` 跑 v1 开发验收:壳 typecheck、`platform-llm` 网关测试、共享契约测试、Tauri Rust 测试和无密钥本地 provider 端到端 smoke;已退役的 `platform-agent` 不再进入 workspace 或该门禁。 @@ -787,13 +787,13 @@ game-project/ - 聊天输入 `/internals` 只列出项目内部真相源读取命令,覆盖 `.agent/manifest.json`、`.agent/run.latest.json`、`.agent/spec.md`、`.agent/findings.md`、`.agent/policy.json`、`.agent/project.index.json`、`.agent/agent.db` 和 `.agent/conversations/project.jsonl`;该命令不调用 Tauri 读写、不读取文件、不新增普通用户内部文件面板,真正读取仍由用户发送 `/read` 并走 `file.read` 权限流。 - 聊天输入 `/publish` 只使用主窗口当前已加载的 manifest、最近 run trace、预览状态、资产来源和最近命令摘要,在聊天里生成发布准备清单,列出原型通过状态、预览、任务、资产、音频、包装说明和试玩包导出状态,并提供 `/run`、`/trace`、`/agent-resume ` 或 `/export` 草稿;该命令不调用 Tauri 读写、不启动或打开预览、不读取文件、不新增普通用户面板,真正导出仍由用户发送 `/export` 并走确认流。 - 开发窗口可从 Agent 状态列表进入单个专业 Agent 对话并管理其 Session;正式用户项目开发页只读展示底部专业 Agent 状态和悬浮详情,不提供单 Agent 对话入口、Session 控件或调度工具台。 -- v1 普通用户登录后直接进入单窗口客户端首页;同一窗口中切换首页、项目组、指南 / 反馈和项目开发页。项目组页管理最近项目、打开项目、新建项目和显示目录;打开项目只切换到项目开发页,不调用 `open_game_creator_workspace_window` 打开第二窗口。旧 Tauri 窗口 command 只保留兼容,不进入用户主流程。 +- v1 普通用户登录后直接进入单窗口客户端首页;同一窗口中切换首页、项目组、指南 / 反馈和项目开发页。项目组页以紧凑表格管理最近项目,顶部提供搜索、打开项目、新建项目,显示目录和移除最近记录进入行尾菜单;打开项目只切换到项目开发页,不调用 `open_game_creator_workspace_window` 打开第二窗口。旧 Tauri 窗口 command 只保留兼容,不进入用户主流程。 - 2026-07-16 补充,2026-07-17 扩展:普通用户窗口顶部账户资产统一复用 `PlatformMudPointWalletEntry`;首屏余额来自 `/api/profile/dashboard`,展开时按需读取 `/api/profile/recharge-center` 中的泥点拆分。顶部资产“充值”与侧栏账户菜单“充值泥点”共用 `PlatformProfileRechargeModal`,桌面壳固定使用 `wechat_native` 下单并在弹窗内完成扫码与到账确认,成功后同步刷新顶部余额和泥点拆分。“使用详情”统一打开 `packages/shared` 中的 `PlatformProfileWalletLedgerModal`,由各宿主分别维护打开、加载、失败重试状态并读取 `/api/profile/wallet-ledger`,共享组件只承接账单来源文案、金额与日期展示及 loading / empty / error 视图,不发请求、不持有账户事实。AI 游戏创作壳的 Tailwind 入口必须显式扫描 `packages/shared/src/components`,避免共享组件的 utility 样式在构建时被遗漏。 - 主窗口可通过系统文件管理器显示当前项目目录,也可在聊天输入 `/open-project` 走同一只读打开动作;该操作只打开本地目录,不初始化项目、不写项目文件、不切换工作区。主窗口头部显示最近 `.agent/run.latest.json` 的 run 状态摘要和当前项目预览状态,并通过“刷新状态”重新读取同一 trace,不新增状态数据库。 - 首页、项目组页和项目开发页共用单窗口壳的全局运行时配置弹窗,读写 Tauri 应用配置目录中的 `game-creator.config.json`;正式 Supervisor 项目页缺配置时只显示错误,不自动打开该弹窗。API Key 仍不进入本地项目、trace、manifest 或聊天记录。 -- 首页发送和项目组新建都通过 Tauri 原生目录选择器选择项目路径;用户取消目录选择时不覆盖已有输入或草稿。 -- 最近项目列表只来自本机 WebView storage,可移除单项或清空;这些操作不触碰项目目录,也不触发窗口切换。项目组页会只读检查最近项目路径,已初始化项目优先显示 `.agent/manifest.json` 里的项目名并保留路径副信息;如果 `.agent/run.latest.json` 可读,则显示最近 run 的 status / stopReason;缺失、非目录、未初始化或检查失败的条目禁用打开,用户可移除后重新选择。刷新最近项目只重新执行这组只读检查,不改项目目录或最近项目列表;刷新未完成时按钮显示“刷新中”并禁用,最近项目条目显示“检查中”且临时禁用打开 / 显示操作,避免重复触发并让用户知道检查仍在进行;“显示”只通过系统文件管理器打开已确认存在的本地目录,未初始化但存在的目录也可显示,缺失、非目录或检查失败时禁用,不初始化项目、不写最近项目。 -- 项目组页“打开”只进入已初始化且 `.agent/manifest.json` 可读的 AI 游戏项目;路径不存在、不是文件夹或只是普通文件夹时留在当前页提示,不自动创建目录。“新建项目”和聊天 `project.create` 新建入口会先检查目标目录,目标目录已经存在且非空时必须弹出确认提醒;用户继续后才调用 `init_local_game_project`,默认项目名取目标文件夹名,再写入最近项目并切到项目开发页;用户取消或初始化失败时不切换视图、也不写入最近项目。 +- 首页发送以及项目组打开 / 新建都通过绑定当前 `client` 的 Tauri 非阻塞原生目录选择器选择项目路径;用户取消目录选择时不覆盖已有输入或草稿。 +- 最近项目列表只来自本机 WebView storage,可逐行移除;该操作不触碰项目目录,也不触发窗口切换。项目组页自动只读检查最近项目路径,已初始化项目优先显示 `.agent/manifest.json` 里的项目名并保留路径副信息;如果 `.agent/run.latest.json` 可读,则把 status / stopReason 转成面向用户的状态。缺失、非目录、manifest 损坏或检查失败的条目禁用打开;目录仍存在时可从行尾菜单显示,检查中临时禁用打开 / 显示。页面不恢复清空或手动刷新按钮,本地搜索也不改项目列表。 +- 项目组页“打开”直接进入已初始化且 `.agent/manifest.json` 可读的 AI 游戏项目;发现根目录或一层子目录中的唯一 Godot 工程时允许导入并进入项目。路径不存在、不是文件夹、普通未初始化目录或 Godot 歧义时留在当前页提示,且不写 `.agent`。“新建项目”和聊天 `project.create` 新建入口会先检查目标目录,目标目录已经存在且非空时必须弹出确认提醒;用户继续后才调用 `init_local_game_project`,默认项目名取目标文件夹名,再写入最近项目并切到项目开发页;用户取消或初始化失败时不切换视图、也不写入最近项目。 - 聊天输入 `/commands` 会只读列出 Tauri runtime 暴露的受限命令白名单,读取失败或非 Tauri 环境下回退到共享契约默认列表;该命令不执行白名单命令,也不要求先初始化项目。 - 聊天输入 `/smoke` 会生成待确认的 `command.run_limited` 内置命令,当前只映射到白名单 `game.static_smoke`,不开放任意命令解析。 - 聊天输入 `/run` 会生成待确认的 `game.run_local` 内置命令,确认后复用白名单 `game.static_smoke` 运行当前 `game/index.html`,通过后启动只读本地 HTTP 预览并切换到客户端内运行视图;该命令不开放任意 shell。 From aa5221abcfd8a6803fdfb617833255656b358dcd Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 15 Aug 2026 14:13:16 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8DJenkins=20Web=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E5=AE=89=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web构建同时安装根目录与AGC独立lockfile依赖 Full流水线同步RUN_NPM_CI参数说明 生产运维门禁锁定双依赖安装顺序 运维文档补充故障原因与依赖边界 --- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 ++ ...nkinsfile.production-full-build-and-deploy | 2 +- jenkins/Jenkinsfile.production-web-build | 3 +- scripts/check-production-ops-guardrails.mjs | 32 +++++++++++++++++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 1ad4de044..e00c42bc1 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -591,6 +591,8 @@ Jenkins Copy Artifact 必须保持 `Production` 权限模式;产物生产者 `Genarrative-Web-Build` 的主站构建失败若出现 Rollup 报错 `"xxx" is not exported by "src/services/publicWorkCode.ts"`,优先按前端公开作品号工具缺失处理,而不是排查 Jenkins 节点环境。修复时要让 `publicWorkCode.ts` 的 `buildPublicWorkCode` 与 `isSamePublicWorkCode` 成对导出,并补 `src/services/publicWorkCode.test.ts` 覆盖对应玩法前缀;随后用 `npm run build:production-release -- --component web --name <临时名>` 复现 Jenkins web 构建路径。 +`Genarrative-Web-Build` 在运行根 Vitest 前必须分别按根 `package-lock.json` 和 `apps/ai-game-creator-shell/package-lock.json` 执行干净依赖安装,即先执行根 `npm ci`,再执行 `npm ci --prefix apps/ai-game-creator-shell`。根 Vitest 会收集 AI 游戏创作壳的测试,如果只安装根 lockfile,收集阶段会因缺少 `@tauri-apps/api`、`@tauri-apps/plugin-http` 等 Tauri guest 依赖而失败。这些依赖属于 AI 游戏创作壳的独立安装边界,不得为了让 Jenkins 通过而将它们加入根 H5 `package.json`;排查同类报错时先核对两份 lockfile 是否都已安装。 + `Genarrative-Web-Build` 会把 `build//web.tar.gz`、`web.tar.gz.sha256`、`release-manifest.json` 和 `scripts/deploy/production-web-deploy.sh` 直接归档为 Jenkins 构建产物;`Genarrative-Web-Deploy` 只通过 `copyArtifacts` 从指定上游构建复制这些产物和部署脚本,不再在目标机器 checkout Git,再执行随构建归档的 `scripts/deploy/production-web-deploy.sh`。Web 发布不再读取构建机本地缓存目录,也不再通过 release agent `rsync` 回构建机拉取大包;如果 deploy 找不到 `web.tar.gz`,应先检查上游 Web Build 是否按同一 `BUILD_VERSION` 成功归档产物。 `Genarrative-Api-Build` 的 Jenkins 归档产物必须包含 `build//api-server`、`api-server.sha256`、`release-manifest.json`、`build//scripts/deploy/production-api-deploy.sh`、`build//scripts/deploy/maintenance-on.sh`、`build//scripts/deploy/maintenance-off.sh`、`scripts/database-backup-to-oss.mjs`、`scripts/ops/production-health-patrol.mjs`、`scripts/ops/pingora-current-release-audit.mjs`、`scripts/ops/pingora-cutover-status-snapshot.mjs`、`scripts/ops/pingora-cutover-evidence-bundle.mjs`、`scripts/ops/pingora-cutover-command-evidence.mjs`、`scripts/ops/pingora-cutover-evidence-verify.mjs`、`scripts/ops/pingora-cutover-evidence-audit.mjs`、`scripts/check-pingora-direct-preflight.mjs`、`scripts/check-pingora-direct-live.mjs`、`scripts/check-pingora-canary-access-log-parity.mjs`、`scripts/check-production-health-patrol-env.mjs`、`scripts/deploy/pingora-direct-enable.sh`、`scripts/deploy/pingora-direct-rollback.sh`、`deploy/systemd/**`、`deploy/env/**` 和 `deploy/pingora/**`。`deploy/systemd/genarrative-database-backup.service` 从 `/opt/genarrative/current/scripts/database-backup-to-oss.mjs` 执行冷备份,`deploy/systemd/genarrative-health-patrol.service` 从 `/opt/genarrative/current/scripts/ops/production-health-patrol.mjs` 执行巡检;`Genarrative-Api-Deploy` 会从上游 API 构建产物复制并执行 `build//scripts/deploy/production-api-deploy.sh`,同目录的 `maintenance-on.sh` / `maintenance-off.sh` 也必须来自同一 build 产物;部署脚本会先写入 `${RELEASE_ROOT}/.${VERSION}.staging.$$`,把 `release-manifest.json` 校验后复制为 current release 的 `release-manifest.api-server.json`,并把备份脚本、巡检脚本、Pingora 直连启用 / 回退 / 预检 / live smoke / canary access log 对账 / health patrol env 复核 / current release 自审 / 状态快照 / 证据包 / 命令证据 / 证据验真 / 证据根目录审计脚本,以及 `deploy/systemd`、`deploy/env`、`deploy/pingora` 支撑配置全部复制完成后,才用非合并语义提升为 `${RELEASE_ROOT}/${VERSION}` 并用固定替换语义切换 `current` 符号链接,不再在目标机器 checkout Git,也不再执行部署工作区根部脚本。Pingora 直连启用脚本必须能从 `/opt/genarrative/current` 独立执行 preflight 和 direct live smoke,并默认读取 current release 随包 `deploy/systemd/genarrative-pingora-gateway-direct-entry.conf`,不依赖 Jenkins 工作区、源码 checkout 或 `/etc` 参考模板;`plan:pingora-direct-cutover` 必须能用同一组参数生成 current release 切换 / 回退 runbook。`production-api-deploy.sh` 对 release manifest、备份脚本、巡检脚本、env 示例目录和 Pingora 直连依赖都执行 fail-fast,且 `--release-root`、`--current-link`、`--api-env-file` 必须是绝对路径,`--version` 必须以数字或字母开头并只能包含数字、字母、点、下划线和短横线,禁止 `.` / `..` 点目录;发布产物缺少 manifest、manifest 未登记 `api-server`、缺少脚本 / 配置目录、同版本 release 目录已存在、current 路径不是符号链接、提升前 release 目录竞态出现或 staging 构建中失败时会在 current 切换前停止部署,清理 staging 并退出本次打开的维护模式;current 切换后的 Pingora 重启、worker 重启、controller 启动或 readiness 失败仍保留维护模式,避免暴露半发布版本;失败不会留下正式 release 目录,不再从部署机工作区兜底补文件,也不把旧同名 release 目录和新文件混合。如果 API 发布后 current release 中缺少这些脚本或目录,应先检查 `Genarrative-Api-Build` 的 `archiveArtifacts` 和 `Genarrative-Api-Deploy` 的 `copyArtifacts` 过滤器是否仍包含 `build//release-manifest.json`、`build//scripts/deploy/production-api-deploy.sh`、`build//scripts/deploy/maintenance-on.sh`、`build//scripts/deploy/maintenance-off.sh`、`build//scripts/database-backup-to-oss.mjs`、`build//scripts/ops/production-health-patrol.mjs`、`build//scripts/ops/pingora-current-release-audit.mjs`、`build//scripts/ops/pingora-cutover-status-snapshot.mjs`、`build//scripts/ops/pingora-cutover-evidence-bundle.mjs`、`build//scripts/ops/pingora-cutover-command-evidence.mjs`、`build//scripts/ops/pingora-cutover-evidence-verify.mjs`、`build//scripts/ops/pingora-cutover-evidence-audit.mjs`、`build//scripts/check-pingora-direct-preflight.mjs`、`build//scripts/check-pingora-direct-live.mjs`、`build//scripts/check-pingora-canary-access-log-parity.mjs`、`build//scripts/check-production-health-patrol-env.mjs`、`build//scripts/deploy/pingora-direct-enable.sh`、`build//scripts/deploy/pingora-direct-rollback.sh`、`build//deploy/systemd/**`、`build//deploy/env/**` 与 `build//deploy/pingora/**`,不要只在部署机工作区手工补文件。本机用 `npm run check:production-api-release` 通过临时 `CARGO_TARGET_DIR` 和假 `api-server` / `pingora-gateway` release binary 验证 `build-production-release.sh --component api-server --skip-api-build` 会把这些文件打进 API release,并验证显式 `--include-pingora-gateway --skip-pingora-gateway-build` 时发布包包含 `pingora-gateway`、`pingora-gateway.sha256` 和 manifest 登记;`npm run check:pingora-production-release-build` 则用假 `api-server` 和真实 `cargo build -p pingora-gateway --release --target x86_64-unknown-linux-gnu` 验证显式 include 路径能构出可执行网关二进制、checksum 和 manifest 登记;再用 `npm run check:production-api-deploy` 通过临时 release、fake `systemctl` / `curl` 验证从发布产物内执行 `production-api-deploy.sh` 会把这些文件复制到 current release,并验证缺少 release manifest、manifest 未登记 `api-server`、Pingora manifest / 二进制漂移、`--require-pingora-gateway` 缺少 Pingora、缺少数据库备份脚本、健康巡检脚本、健康巡检 env 复核脚本、current release 自审脚本、状态快照脚本、证据包脚本、证据验真脚本、证据根目录审计脚本、canary access log 对账脚本、env 示例目录或 direct live smoke 脚本时都会在 current 切换前失败并退出本次打开的维护模式,还会验证相对 release root / current link / api env file、点目录或点开头 version 被拒绝、失败时不留下 staging / 正式 release 目录、同版本 release 目录已存在、current 路径不是符号链接、提升前 release 目录竞态出现时拒绝覆盖 / 合并,以及 current 切换后的 readiness 失败会保留维护模式。Pingora 影子网关在 `Genarrative-Api-Build`、`Genarrative-Api-Deploy` 和 `Genarrative-Full-Build-And-Deploy` 中默认随 release 构建、归档、复制并用 `--require-pingora-gateway` 硬校验;只有显式取消 `INCLUDE_PINGORA_GATEWAY` 时才允许 API release 不带 `pingora-gateway` / `pingora-gateway.sha256`。本地 CLI 仍保留显式 `npm run build:production-release -- --component api-server --include-pingora-gateway`,用于在需要 Pingora 的手工发布包里登记 manifest 和 checksum;Jenkins 会先检查 `cmake`、C 编译器和 C++ 编译器,避免进入 Cargo 后才因 `libz-ng-sys` 构建依赖缺失失败。发布包包含 Pingora 时,deploy 会在提升 release 前读取 `systemctl cat genarrative-pingora-gateway.service` 和其 `EnvironmentFile`,拒绝 direct-entry `CAP_NET_BIND_SERVICE`、拒绝非 `127.0.0.1:18081` 的 shadow listen、拒绝 `TLS_LISTEN` / `HTTP_REDIRECT_LISTEN`,确认仍是本机 shadow 高端口后才切换 current;切换后执行 `systemctl restart genarrative-pingora-gateway.service` 并复核 active,让 shadow / canary 机器加载新网关二进制。该自动拉起不会启用公网 `80/443` 直连入口;已经进入 direct-entry 状态的机器应走正式直连 runbook 或先回退到 shadow。 diff --git a/jenkins/Jenkinsfile.production-full-build-and-deploy b/jenkins/Jenkinsfile.production-full-build-and-deploy index 64b032762..de098683f 100644 --- a/jenkins/Jenkinsfile.production-full-build-and-deploy +++ b/jenkins/Jenkinsfile.production-full-build-and-deploy @@ -22,7 +22,7 @@ pipeline { string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '源码分支,默认 master 最新提交') string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit') string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') - booleanParam(name: 'RUN_NPM_CI', defaultValue: true, description: 'Web 构建前是否执行 npm ci') + booleanParam(name: 'RUN_NPM_CI', defaultValue: true, description: 'Web 构建前是否按根 lockfile 与 AGC 独立 lockfile 执行 npm ci') string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送') string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', defaultValue: 'genarrative-spacetime-bootstrap-secret-dev-file', description: '必填:Stdb module 构建与发布共用的 Jenkins Secret File 凭据 ID') booleanParam(name: 'INCLUDE_PINGORA_GATEWAY', defaultValue: true, description: 'API release 是否构建、归档并部署 Pingora 影子网关;release 默认必须包含') diff --git a/jenkins/Jenkinsfile.production-web-build b/jenkins/Jenkinsfile.production-web-build index 200483d69..1413d6113 100644 --- a/jenkins/Jenkinsfile.production-web-build +++ b/jenkins/Jenkinsfile.production-web-build @@ -22,7 +22,7 @@ pipeline { string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit') string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送') - booleanParam(name: 'RUN_NPM_CI', defaultValue: true, description: '构建前是否执行 npm ci') + booleanParam(name: 'RUN_NPM_CI', defaultValue: true, description: '构建前是否按根 lockfile 与 AGC 独立 lockfile 执行 npm ci') booleanParam(name: 'PUBLISH_AFTER_BUILD', defaultValue: false, description: '构建成功后是否触发 Web 发布') string(name: 'DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Web-Deploy', description: 'Web 发布流水线作业名') choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: 'PUBLISH_AFTER_BUILD=true 时的逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent') @@ -91,6 +91,7 @@ pipeline { script { if (params.RUN_NPM_CI) { sh 'bash -lc "npm ci"' + sh 'bash -lc "npm ci --prefix apps/ai-game-creator-shell"' } } sh ''' diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index ee0975dc9..8caf8b132 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -7376,6 +7376,38 @@ if ((fullPipelineMaintenanceHoldCalls?.length ?? 0) !== 2) { ); } +const webBuildContent = readFileSync( + 'jenkins/Jenkinsfile.production-web-build', + 'utf8', +); +const webBuildStageOffset = webBuildContent.indexOf("stage('Build Web')"); +const webArchiveStageOffset = webBuildContent.indexOf( + "stage('Archive')", + webBuildStageOffset, +); +const webBuildStageContent = + webBuildStageOffset >= 0 && webArchiveStageOffset > webBuildStageOffset + ? webBuildContent.slice(webBuildStageOffset, webArchiveStageOffset) + : ''; +const webNpmCiBlock = `if (params.RUN_NPM_CI) { + sh 'bash -lc "npm ci"' + sh 'bash -lc "npm ci --prefix apps/ai-game-creator-shell"' + }`; +const webNpmCiBlockOffset = webBuildStageContent.indexOf(webNpmCiBlock); +const webTestOffset = webBuildStageContent.indexOf('npm run test'); +const webNpmCiCalls = webBuildStageContent.match(/\bnpm ci(?:\s|["'])/gu); +if ( + webNpmCiBlockOffset < 0 || + webTestOffset < 0 || + webNpmCiBlockOffset >= webTestOffset || + (webNpmCiCalls?.length ?? 0) !== 2 +) { + failed = true; + console.error( + '[check:production-ops] Web Build 必须在 RUN_NPM_CI 条件块内依次安装根 lockfile 与 apps/ai-game-creator-shell 独立 lockfile,并在 npm run test 前完成;RUN_NPM_CI=false 时两次安装必须一起跳过。', + ); +} + const exitMaintenanceStageOffset = fullPipelineContent.indexOf( "stage('Exit Maintenance')", );