From 8e78be766ee3aae68148bb5ba61150b669e167f2 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:11:51 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E4=B8=8E=E9=A6=96=E6=9D=BF=E7=89=88=E6=9C=AC=E7=99=BB=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复旧模型定价覆盖缺少ElevenLabs音效模型导致启动恢复持续失败 支持对话框回车发送并保留Shift换行和输入法组合态 重试受理后立即清理旧失败投影并展示新Run状态 阻止Windows后台Codex探测反复弹出控制台窗口 首板试玩持久回执通过后幂等登记初始项目版本 补充定价、交互、Windows与版本登记回归测试和文档 --- .../src-tauri/src/agent/codex_cli.rs | 3 +- .../src/agent/runtime_tools/preview.rs | 78 +++++++++++++ .../src-tauri/src/config.rs | 3 +- .../src-tauri/src/main.rs | 1 + .../src-tauri/src/project/manifest.rs | 47 ++++++++ .../src/project/manifest/recovery_tests.rs | 52 +++++++++ .../src-tauri/src/windows.rs | 67 +++++++++++ .../src/features/agent-runtime/model.ts | 53 ++++++++- .../ProjectSupervisorView.tsx | 10 ++ .../tests/agentRuntimeModel.test.ts | 73 ++++++++++++ .../appSurface/project-development.suite.ts | 100 ++++++++++++++--- ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 2 +- .../shared-memory/decision-log.md | 5 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 3 +- ...编辑器】模型定价配置管理方案-2026-06-22.md | 2 +- .../src/editor_generation_config.rs | 105 +++++++++++++++++- 16 files changed, 573 insertions(+), 31 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs index d0c2877d2..ca5ce15c3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs @@ -84,7 +84,8 @@ fn game_creator_codex_cli_executable_candidates() -> Vec { } fn game_creator_codex_cli_version_at(executable: &Path) -> Result { - let output = std::process::Command::new(executable) + let mut command = crate::new_windows_background_std_command(executable); + let output = command .arg("--version") .stdin(Stdio::null()) .stderr(Stdio::null()) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs index 3fd2ab07a..33c6cd649 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs @@ -394,6 +394,84 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( }; } }; + let durable_receipt = match read_autonomous_playtest_receipt(root, contract) { + Ok(Some(receipt)) if receipt.revision == revision_after.revision => receipt, + Ok(Some(_)) => { + return AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "浏览器试玩已通过,但持久回执不属于当前 revision".to_string(), + detail: None, + }; + } + Ok(None) => { + return AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "浏览器试玩已通过,但持久回执回读失败".to_string(), + detail: None, + }; + } + Err(error) => { + return AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "浏览器试玩已通过,但持久回执无法验证".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } + }; + { + let _project_lock = + match acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "preview.validate.initial-version", + ) { + Ok(lock) => lock, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "浏览器试玩已通过,但首个可玩版本暂时无法登记".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } + }; + let locked_revision = match read_game_creator_agent_runtime_project_revision(root) { + Ok(revision) if revision.revision == durable_receipt.revision => revision, + Ok(revision) => { + return AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "浏览器试玩回执落盘后项目 revision 已变化,未登记旧版本" + .to_string(), + detail: Some(format!( + "receiptRevision={}, currentRevision={}", + durable_receipt.revision, revision.revision + )), + }; + } + Err(error) => { + return AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "浏览器试玩已通过,但登记版本前无法复核项目 revision" + .to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } + }; + if let Err(error) = + ensure_initial_game_iteration_version_at(root, locked_revision.revision) + { + return AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "浏览器试玩已通过,但首个可玩版本无法登记".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } + } if contract_belongs_to_runtime { if let Err(error) = clear_agent_runtime_failed_playtest_at( root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 04ce73338..4404da0e7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -373,7 +373,8 @@ pub(crate) fn check_game_creator_codex_cli_available() -> Result<(), String> { fn check_game_creator_codex_app_server_available() -> Result<(), String> { let executable = crate::agent::game_creator_codex_cli_executable_path()?; - let output = std::process::Command::new(executable) + let mut command = crate::new_windows_background_std_command(executable); + let output = command .args(["app-server", "--help"]) .stdin(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) 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 2d533e865..81681f082 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -32,6 +32,7 @@ use shared_contracts::game_creation_app::{ GameCreationAppCommandRunStatus, GameCreationAppLimitedRunCommandDescriptor, GameCreationAppManifest, GameCreationAppPermission, GameCreationAppPreviewState, GameCreationAppPreviewStatus, GameCreationAppTaskState, GameCreationAppTaskStatus, + GameIterationVersion, GameIterationVersionCreatedReason, GameIterationVersionResourceBinding, ProjectResourceCanvasLayout, ProjectResourceCanvasLayoutMode, ProjectResourceCanvasPosition, UpdateProjectResourceCanvasLayoutResult, UpdateProjectResourceCanvasLayoutStatus, GAME_CREATION_AGENT_CAPABILITIES, GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, 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 c078f5b02..85c449ad9 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 @@ -371,6 +371,53 @@ pub(crate) fn read_existing_manifest_for_project( Ok(manifest) } +/// Registers the first formally playable project version after the current +/// revision has produced a durable successful browser-playtest receipt. +/// Replays are idempotent: once any formal version exists, validation never +/// rewrites or appends another initial record. +pub(crate) fn ensure_initial_game_iteration_version_at( + root: &Path, + project_revision: u64, +) -> Result { + if project_revision == 0 { + return Err("首个可玩版本必须绑定大于 0 的项目 revision".to_string()); + } + let (manifest_path, mut manifest) = read_or_create_manifest(root)?; + if !manifest.versions.is_empty() { + return Ok(false); + } + let resource_bindings = manifest + .assets + .iter() + .map(|asset| GameIterationVersionResourceBinding { + slot_id: format!("asset:{}", asset.id), + resource_id: asset.id.clone(), + }) + .collect(); + manifest.versions.push(GameIterationVersion { + version_id: format!("initial-{project_revision}"), + parent_version_id: None, + project_revision, + resource_bindings, + created_reason: GameIterationVersionCreatedReason::Initial, + created_at: unix_timestamp(), + edit_prompt: None, + }); + match write_manifest(&manifest_path, &manifest) { + Ok(()) => Ok(true), + Err(error) => { + // Another writer may have committed the same logical transition + // after our read. Treat an installed formal version as a replay; + // every other storage failure remains visible to the Runtime. + if read_manifest(&manifest_path).is_ok_and(|current| !current.versions.is_empty()) { + Ok(false) + } else { + Err(error) + } + } + } +} + pub(crate) fn ensure_manifest_has_seed_tasks( root: &Path, goal: Option<&str>, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs index f4fceaf1c..0e6bdd66e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs @@ -63,6 +63,58 @@ fn version_fixture( } } +#[test] +fn successful_first_playable_registration_creates_one_initial_version_with_asset_bindings() { + let root = unique_manifest_test_root("first-playable-version"); + let manifest_path = root.join(".agent/manifest.json"); + let mut manifest = new_game_creation_app_manifest("project-first-playable", "首板项目"); + manifest.assets.push(GameCreationAppAssetManifestEntry { + id: "asset-player".to_string(), + kind: "character".to_string(), + media_type: "image/png".to_string(), + local_path: "assets/player.png".to_string(), + source: GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: Some("art-asset-plan".to_string()), + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + }); + write_manifest(&manifest_path, &manifest).expect("write first playable manifest"); + + let created = ensure_initial_game_iteration_version_at(&root, 7) + .expect("register verified first playable"); + assert!(created); + let replayed = ensure_initial_game_iteration_version_at(&root, 7) + .expect("replay verified first playable registration"); + assert!(!replayed); + + let installed = read_manifest(&manifest_path).expect("read versioned manifest"); + assert_eq!(installed.versions.len(), 1); + assert_eq!(installed.versions[0].version_id, "initial-7"); + assert_eq!(installed.versions[0].parent_version_id, None); + assert_eq!(installed.versions[0].project_revision, 7); + assert_eq!( + installed.versions[0].created_reason, + GameIterationVersionCreatedReason::Initial + ); + assert_eq!( + installed.versions[0].resource_bindings, + vec![GameIterationVersionResourceBinding { + slot_id: "asset:asset-player".to_string(), + resource_id: "asset-player".to_string(), + }] + ); + + fs::remove_dir_all(root).ok(); +} + #[test] fn manifest_versions_are_append_only_at_the_storage_boundary() { let root = unique_manifest_test_root("versions-append-only"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/windows.rs b/apps/ai-game-creator-shell/src-tauri/src/windows.rs index b2bc08304..ce93187e4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/windows.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/windows.rs @@ -41,6 +41,15 @@ pub(crate) fn configure_windows_background_std_command( ) { } +pub(crate) fn new_windows_background_std_command(program: S) -> std::process::Command +where + S: AsRef, +{ + let mut command = std::process::Command::new(program); + configure_windows_background_std_command(&mut command, false); + command +} + pub(crate) fn configure_windows_background_tokio_command( command: &mut tokio::process::Command, create_process_group: bool, @@ -48,6 +57,64 @@ pub(crate) fn configure_windows_background_tokio_command( configure_windows_background_std_command(command.as_std_mut(), create_process_group); } +#[cfg(all(test, windows))] +mod windows_background_command_tests { + use super::*; + use std::fs; + use std::process::Stdio; + + const CHILD_ENV: &str = "GENARRATIVE_WINDOWS_NO_CONSOLE_CHILD"; + const RESULT_ENV: &str = "GENARRATIVE_WINDOWS_NO_CONSOLE_RESULT"; + const FIXTURE_TEST: &str = + "windows::windows_background_command_tests::background_command_console_fixture"; + + #[test] + #[ignore = "child-process fixture"] + fn background_command_console_fixture() { + if std::env::var_os(CHILD_ENV).is_none() { + return; + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetConsoleWindow() -> windows_sys::Win32::Foundation::HWND; + } + let result_path = std::env::var_os(RESULT_ENV).expect("result path"); + let has_console_window = unsafe { !GetConsoleWindow().is_null() }; + fs::write( + result_path, + if has_console_window { + "console" + } else { + "hidden" + }, + ) + .expect("write console-window result"); + } + + #[test] + fn background_std_command_does_not_allocate_a_console_window() { + let directory = tempfile::tempdir().expect("create no-console test directory"); + let result_path = directory.path().join("console-window.txt"); + let mut command = new_windows_background_std_command( + std::env::current_exe().expect("current test binary"), + ); + command + .args(["--exact", FIXTURE_TEST, "--ignored"]) + .env(CHILD_ENV, "1") + .env(RESULT_ENV, &result_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let status = command.status().expect("run no-console child fixture"); + assert!(status.success(), "no-console child fixture must succeed"); + assert_eq!( + fs::read_to_string(result_path).expect("read console-window result"), + "hidden", + "CREATE_NO_WINDOW must keep background command probes from flashing a console window" + ); + } +} + #[cfg(all(windows, feature = "game-chat-release"))] pub(crate) fn configure_windows_suspended_background_std_command( command: &mut std::process::Command, diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index 88d45c55c..8cfb64d71 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -413,16 +413,59 @@ export function agentRuntimeStateFromResult( result: AgentRuntimeResult, previous?: AgentRuntimeState | null, ): AgentRuntimeState { + const acceptedRunId = result.acceptedRunId?.trim(); + const acceptedTask = acceptedRunId + ? (result.recentTasks ?? result.state.recentTasks ?? []).find( + (task) => task.runId === acceptedRunId, + ) + : null; + const state = + acceptedTask && result.state.runId !== acceptedRunId + ? { + ...result.state, + agentId: acceptedTask.agentId, + taskId: acceptedTask.taskId, + sessionId: acceptedTask.sessionId, + runId: acceptedTask.runId, + source: acceptedTask.source, + parentAgentId: acceptedTask.parentAgentId ?? null, + parentRunId: acceptedTask.parentRunId ?? null, + delegationId: acceptedTask.delegationId ?? null, + goalId: acceptedTask.goalId ?? null, + goalRevision: acceptedTask.goalRevision ?? 0, + goalStatus: acceptedTask.goalStatus ?? null, + currentTask: acceptedTask.task, + currentGoal: acceptedTask.task, + status: acceptedTask.status, + phase: acceptedTask.phase, + currentAction: acceptedTask.currentAction, + waitingOn: agentRuntimeWaitingOnFromPhase(acceptedTask.phase), + nextStep: agentRuntimeNextStepFromPhase(acceptedTask.phase), + plan: [], + planRevision: undefined, + planExplanation: undefined, + planSteps: [], + activePlanStepIndex: null, + observations: [], + recentToolCalls: [], + pendingToolAction: null, + userInputRequest: null, + lastResponse: null, + error: acceptedTask.error, + startedAt: acceptedTask.updatedAt, + updatedAt: acceptedTask.updatedAt, + } + : result.state; return normalizeAgentRuntimeState( { - ...result.state, - taskQueue: result.taskQueue ?? result.state.taskQueue, - recentEvents: result.recentEvents ?? result.state.recentEvents, - recentTasks: result.recentTasks ?? result.state.recentTasks, + ...state, + taskQueue: result.taskQueue ?? state.taskQueue, + recentEvents: result.recentEvents ?? state.recentEvents, + recentTasks: result.recentTasks ?? state.recentTasks, userInputRequest: result.userInputRequest !== undefined ? result.userInputRequest - : result.state.userInputRequest, + : state.userInputRequest, }, previous, ); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index 220a31edf..963ba78d4 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -120,6 +120,16 @@ export function ProjectSupervisorView({ value={chatInput} placeholder="告诉项目总控接下来要做什么" onChange={(event) => onChatInputChange(event.currentTarget.value)} + onKeyDown={(event) => { + if ( + event.key === 'Enter' && + !event.shiftKey && + !event.nativeEvent.isComposing + ) { + event.preventDefault(); + event.currentTarget.form?.requestSubmit(); + } + }} /> - - -
- -
- - - - - -
-
+
+ + +
{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 7/7] =?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')", );