diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index f2f206f72..05b25f127 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -13,7 +13,7 @@ mod codex_app_server; mod codex_cli; mod codex_provider_proxy; mod design_runtime; -mod design_tools; +pub(crate) mod design_tools; mod direct_codex_attachments; mod direct_codex_audit; mod direct_codex_user_item; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs index cf9501888..460ac0dad 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs @@ -4,10 +4,12 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::BTreeMap; use std::fs; +use std::io::Write; use std::path::{Path, PathBuf}; use tauri::Manager; const DESIGN_WORKSPACE_ROOT: &str = "design_artifacts"; +const DESIGN_REFERENCES_ROOT: &str = "references"; const SEARCH_HIT_LIMIT: usize = 200; #[derive(Clone, Debug, Serialize)] @@ -547,6 +549,123 @@ pub(crate) fn read_design_workspace_file_at(root: &Path, path: &str) -> Result, +) -> Result { + let root = PathBuf::from(project_path.trim()); + let relative_path = import_design_workspace_file_at(&root, &file_name, &bytes)?; + let _ = app.emit( + "design-agent-update", + serde_json::json!({ + "projectPath": root.to_string_lossy(), + "clientTurnId": "", + "kind": "workspace", + "messageId": null, + "text": null, + "view": null, + }), + ); + Ok(relative_path) +} + +fn import_design_workspace_file_at( + root: &Path, + file_name: &str, + bytes: &[u8], +) -> Result { + enforce_project_permission_policy(root, "conversation.write")?; + read_existing_manifest_for_project(root)?; + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "conversation.write", + )?; + + if file_name.contains(['/', '\\']) { + return Err("附件文件名必须是单个文件名,不能包含目录".to_string()); + } + let normalized_name = + normalize_relative_path(file_name).map_err(|error| format!("附件文件名无效:{error}"))?; + if normalized_name != file_name { + return Err("附件文件名无效".to_string()); + } + + let (_, references) = resolve_design_workspace_path(root, DESIGN_REFERENCES_ROOT)?; + crate::ensure_game_creator_private_directory_tree(&references, "策划参考附件目录")?; + crate::prepare_game_creator_private_path_for_read(&references, true, "策划参考附件目录")?; + + let mut sequence = 1_u64; + loop { + let candidate_name = design_reference_file_name(&normalized_name, sequence); + let relative_path = format!("{DESIGN_REFERENCES_ROOT}/{candidate_name}"); + let (_, target) = resolve_design_workspace_path(root, &relative_path)?; + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW).mode(0o600); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut file = match options.open(&target) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + sequence = sequence + .checked_add(1) + .ok_or_else(|| "无法为同名附件分配新序号".to_string())?; + continue; + } + Err(error) => { + return Err(format!( + "创建策划参考附件失败:{}: {error}", + target.display() + )); + } + }; + if let Err(error) = + crate::harden_new_game_creator_private_path(&target, false, "策划参考附件") + { + drop(file); + let _ = fs::remove_file(&target); + return Err(error); + } + let write_result = file.write_all(bytes).and_then(|_| file.sync_all()); + drop(file); + if let Err(error) = write_result { + let _ = fs::remove_file(&target); + return Err(format!( + "写入策划参考附件失败:{}: {error}", + target.display() + )); + } + return Ok(relative_path); + } +} + +fn design_reference_file_name(file_name: &str, sequence: u64) -> String { + if sequence == 1 { + return file_name.to_string(); + } + let path = Path::new(file_name); + let stem = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or(file_name); + match path.extension().and_then(|value| value.to_str()) { + Some(extension) if !extension.is_empty() => { + format!("{stem} ({sequence}).{extension}") + } + _ => format!("{stem} ({sequence})"), + } +} + fn load_design_catalog(root: &Path) -> Result, String> { let catalog_path = root.join("resources/catalog.json"); let data: DesignCatalogFile = serde_json::from_str( @@ -756,6 +875,13 @@ mod tests { tempfile::tempdir().expect("tempdir") } + fn initialized_test_root() -> tempfile::TempDir { + let temp = test_root(); + init_local_game_project_at(temp.path(), "design-import-test", "策划附件导入测试") + .expect("init project"); + temp + } + fn pack_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("design-agent") } @@ -868,6 +994,138 @@ mod tests { ); } + #[test] + fn imported_reference_is_visible_to_workspace_list_and_read() { + let temp = initialized_test_root(); + let root = temp.path(); + let manifest_before = read_existing_manifest_for_project(root).expect("read manifest"); + let revision_before = + read_game_creator_agent_runtime_project_revision(root).expect("read revision"); + + let relative = + import_design_workspace_file_at(root, "玩法构想.txt", "横版解谜\n".as_bytes()) + .expect("import text reference"); + + assert_eq!(relative, "references/玩法构想.txt"); + assert!(list_design_workspace_files(root) + .expect("list workspace") + .iter() + .any(|entry| entry.path == relative && entry.kind == "file")); + assert_eq!( + read_design_workspace_file_at(root, &relative).expect("read imported reference"), + "横版解谜\n" + ); + assert_eq!( + read_existing_manifest_for_project(root).expect("read manifest after import"), + manifest_before + ); + assert_eq!( + read_game_creator_agent_runtime_project_revision(root) + .expect("read revision after import"), + revision_before + ); + } + + #[test] + fn import_keeps_existing_names_and_accepts_empty_and_binary_bytes() { + let temp = initialized_test_root(); + let root = temp.path(); + + let first = import_design_workspace_file_at(root, "brief.md", b"first") + .expect("import first reference"); + let second = import_design_workspace_file_at(root, "brief.md", b"second") + .expect("import repeated reference"); + let empty = import_design_workspace_file_at(root, "empty.bin", b"") + .expect("import empty reference"); + let binary_bytes = [0_u8, 0xff, 0x10, 0x80]; + let binary = import_design_workspace_file_at(root, "bytes.bin", &binary_bytes) + .expect("import binary reference"); + + assert_eq!(first, "references/brief.md"); + assert_eq!(second, "references/brief (2).md"); + assert_eq!(empty, "references/empty.bin"); + assert_eq!(binary, "references/bytes.bin"); + assert_eq!( + fs::read(root.join("design_artifacts").join(&first)).expect("read first"), + b"first" + ); + assert_eq!( + fs::read(root.join("design_artifacts").join(&second)).expect("read second"), + b"second" + ); + assert!(fs::read(root.join("design_artifacts").join(&empty)) + .expect("read empty") + .is_empty()); + assert_eq!( + fs::read(root.join("design_artifacts").join(&binary)).expect("read binary"), + binary_bytes + ); + } + + #[test] + fn import_rejects_unsafe_names_and_denied_project_permission() { + let temp = initialized_test_root(); + let root = temp.path(); + for file_name in [ + "../outside.txt", + "nested/file.txt", + r"nested\file.txt", + "C:stream", + ] { + let error = import_design_workspace_file_at(root, file_name, b"blocked") + .expect_err("reject unsafe file name"); + assert!(error.contains("文件名"), "unexpected error: {error}"); + } + assert!(!root.join("outside.txt").exists()); + + let mut policy = ProjectPermissionPolicy::default(); + policy + .denied_commands + .push("conversation.write".to_string()); + write_project_permission_policy_at(root, policy).expect("deny conversation write"); + let error = import_design_workspace_file_at(root, "denied.txt", b"blocked") + .expect_err("respect project permission policy"); + assert!(error.contains("conversation.write")); + assert!(!root.join("design_artifacts/references/denied.txt").exists()); + } + + #[cfg(unix)] + #[test] + fn import_rejects_linked_references_directory() { + use std::os::unix::fs::symlink; + + let temp = initialized_test_root(); + let root = temp.path(); + let outside = tempfile::tempdir().expect("outside tempdir"); + fs::create_dir_all(root.join("design_artifacts")).expect("create workspace"); + symlink(outside.path(), root.join("design_artifacts/references")) + .expect("link references directory"); + + let error = import_design_workspace_file_at(root, "escape.txt", b"blocked") + .expect_err("reject linked references directory"); + assert!(error.contains("符号链接") || error.contains("reparse point")); + assert!(!outside.path().join("escape.txt").exists()); + } + + #[cfg(windows)] + #[test] + fn import_rejects_windows_linked_references_directory_when_supported() { + use std::os::windows::fs::symlink_dir; + + let temp = initialized_test_root(); + let root = temp.path(); + let outside = tempfile::tempdir().expect("outside tempdir"); + fs::create_dir_all(root.join("design_artifacts")).expect("create workspace"); + if symlink_dir(outside.path(), root.join("design_artifacts/references")).is_err() { + return; + } + + let error = import_design_workspace_file_at(root, "escape.txt", b"blocked") + .expect_err("reject linked references directory"); + assert!(error.contains("符号链接") || error.contains("reparse point")); + assert!(!outside.path().join("escape.txt").exists()); + } + #[test] fn phase_context_injects_current_skill_only() { let resources = DesignResources::new(pack_root()).expect("pack"); 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 d123ec186..679bffa1d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -157,6 +157,7 @@ mod tool_plan_handoff; mod user_input; mod windows; +use agent::design_tools::*; use agent::*; use agent_native_tools::*; use asset_generation_tasks::*; @@ -2613,6 +2614,7 @@ fn main() { debug_fast_forward_design_session, continue_design_agent_session, decide_design_phase, + import_design_workspace_file, list_design_workspace, read_design_workspace_file, start_game_creator_agent_runtime_task, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 4d28f041a..5285bb3c0 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -254,6 +254,7 @@ import { type DirectThreadSubscriptionBootstrap, } from './features/project-workspace/directThreadEvents'; import type { DirectCodexUserContentPart } from './features/project-workspace/generated'; +import { importDesignFiles } from './features/project-workspace/importDesignFiles'; import { appendMemoryContent, memoryScopeLabel, @@ -489,6 +490,7 @@ type AppProps = { initialSupervisorMessageClaimScope?: string; initialCreationType?: HomeCreationType | null; initialAttachments?: LauncherImportedAttachment[]; + onDesignFilesImportingChange?: ProjectSupervisorComponentProps['onDesignFilesImportingChange']; playRequest?: ProjectSupervisorComponentProps['playRequest']; onPlayRequestHandled?: ProjectSupervisorComponentProps['onPlayRequestHandled']; onManifestChange?: ( @@ -539,6 +541,7 @@ export function App({ initialSupervisorMessageClaimScope = '', initialCreationType = null, initialAttachments = [], + onDesignFilesImportingChange, playRequest = null, onPlayRequestHandled, onManifestChange, @@ -707,6 +710,7 @@ export function App({ DirectCodexTurnAttachment[] >([]); const [chatAttachmentNotice, setChatAttachmentNotice] = useState(''); + const [chatFilesImporting, setChatFilesImporting] = useState(false); /** 回合运行中再次发送的消息:FIFO 本地队列,当前回合结束后依次发出。 */ const [chatTurnQueue, setChatTurnQueue] = useState([]); const chatTurnQueueRef = useRef([]); @@ -11470,8 +11474,7 @@ export function App({ } /** - * 输入盒上传本地文件:复用首页建项目那条 `upload_local_asset` 链路把文件写进项目, - * 再以**项目相对路径**生成回合附件(绝对路径会被 Rust 侧附件规则判为失败)。 + * 策划文件直接导入工作区;游戏文件沿用资产上传与回合附件链路。 */ async function handleChatComposerUploadFiles(files: readonly File[]) { const invoke = resolveTauriInvoke(); @@ -11480,6 +11483,22 @@ export function App({ setChatAttachmentNotice('需要先打开本地项目,才能上传文件'); return; } + if (designAgentActiveRef.current) { + if (chatFilesImporting || files.length === 0) return; + setChatFilesImporting(true); + onDesignFilesImportingChange?.(nextProjectPath, true); + setChatAttachmentNotice('正在导入文件'); + try { + const notice = await importDesignFiles(invoke, nextProjectPath, files); + if (localProjectPathRef.current === nextProjectPath) { + setChatAttachmentNotice(notice); + } + } finally { + setChatFilesImporting(false); + onDesignFilesImportingChange?.(nextProjectPath, false); + } + return; + } const remaining = MAX_CHAT_COMPOSER_ATTACHMENTS - chatAttachments.length; const accepted = files.slice(0, Math.max(remaining, 0)); if (accepted.length === 0) { @@ -11822,6 +11841,7 @@ export function App({ activeVersionId={chatActiveVersionId} attachments={chatAttachments} attachmentNotice={chatAttachmentNotice} + importingFiles={chatFilesImporting} chatInput={chatInput} chatReferences={chatReferences} composerNotice={chatComposerNotice} diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index d7d33af39..32b993a58 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -143,6 +143,8 @@ export type LauncherProjectContext = { startMode: ProjectStartMode | null; initialPrompt: string; attachments: LauncherImportedAttachment[]; + /** 仅用于工作区界面,不进入 Agent 消息。 */ + fileImportNotice?: string; recentRunStatus: string | null; recentRunStopReason: string | null; createdAt: number; 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 6d016e649..a017ced52 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 @@ -528,7 +528,24 @@ export function WorkspaceLauncherShell({ }); } + const designImportProjectRef = useRef(null); + const handleDesignFilesImportingChange = useCallback( + (projectPath: string, importing: boolean) => { + if (importing) designImportProjectRef.current = projectPath; + else if (designImportProjectRef.current === projectPath) + designImportProjectRef.current = null; + }, + [], + ); + async function switchToGameRuntime(nextProjectPath: string) { + if (designImportProjectRef.current === nextProjectPath) { + setLauncherNotice({ + title: '正在导入文件', + message: '文件导入完成后,再做成游戏。', + }); + return; + } const invoke = resolveTauriInvoke(); if (!invoke) throw new Error('需要在陶泥儿客户端内运行'); await invoke('set_design_agent_runtime_mode', { @@ -638,6 +655,25 @@ export function WorkspaceLauncherShell({ /> ) : launcherView === 'project-development' && currentProjectContext ? ( <> + {currentProjectContext.fileImportNotice ? ( +
+
+ {currentProjectContext.fileImportNotice} + +
+
+ ) : null} {manifestMergeNotice ? (
void; orchestrationMode?: 'single-supervisor' | 'professional-dag'; projectSupervisorOnly?: boolean; planningStartMode?: boolean; 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 fb5d736ae..3e34d6c2f 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 @@ -47,6 +47,7 @@ import { isAbsoluteProjectPath, projectPathHasControlCharacter, } from '../project-summary/projectSummary'; +import { importDesignFiles } from '../project-workspace/importDesignFiles'; import { ensureHomeWebCreationEnvironment, HOME_WEB_PREFLIGHT_FAILURE, @@ -323,11 +324,18 @@ export function useHomeProjectCreation({ activeRuntime: 'design', }); } - const importedAttachments = await importHomeAttachments( - invoke, - result.projectPath, - attachments, - ); + const fileImportNotice = + startMode === 'planning' + ? await importDesignFiles( + invoke, + result.projectPath, + attachments.map((item) => item.file), + ) + : undefined; + const importedAttachments = + startMode === 'planning' + ? [] + : await importHomeAttachments(invoke, result.projectPath, attachments); await enterProjectDevelopment({ projectPath: result.projectPath, projectName: @@ -342,10 +350,11 @@ export function useHomeProjectCreation({ startMode, initialPrompt: prompt.trim() || - (attachments.length > 0 + (attachments.length > 0 && startMode !== 'planning' ? '用户上传了参考附件,等待后续补充需求。' : ''), attachments: importedAttachments, + fileImportNotice, recentRunStatus: null, recentRunStopReason: null, createdAt: Date.now(), 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 72658093c..f5fd83ec0 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 @@ -2,6 +2,7 @@ import { ArrowUp, AtSign, ChevronDown, + FileUp, Lightbulb, Loader2, Settings, @@ -155,6 +156,7 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { attachments?: DirectCodexTurnAttachment[]; /** 上传/校验附件的提示文案(失败与成功都用它,空串不渲染)。 */ attachmentNotice?: string; + importingFiles?: boolean; chatInput: string; chatReferences: ChatReference[]; chatProjectAssets: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[]; @@ -218,6 +220,7 @@ export function ProjectSupervisorView({ activeVersionId = null, attachments = [], attachmentNotice = '', + importingFiles = false, chatInput, chatReferences, chatProjectAssets, @@ -238,7 +241,7 @@ export function ProjectSupervisorView({ onCancelTurn, onCancelQueuedTurn, onRemoveAttachment, - onUploadFiles: _onUploadFiles, + onUploadFiles, queuedTurns = [], composerNotice = '', turnCancelling = false, @@ -287,6 +290,7 @@ export function ProjectSupervisorView({ const [modelReady, setModelReady] = useState(false); const [modelValidating, setModelValidating] = useState(false); const modelSelectRef = useRef(null); + const designFileInputRef = useRef(null); const modelValidateInFlightRef = useRef(false); const runBusy = runtimePanelProps.controlBusy || submitting; const directTurns = directCodex @@ -397,6 +401,7 @@ export function ProjectSupervisorView({ title={submitLabel} disabled={ runtimePanelProps.controlBusy || + (designAgentActive && importingFiles) || needsUserInput || Boolean(designView?.session.pendingApproval) || Boolean(designView?.session.pendingClarification) || @@ -419,7 +424,7 @@ export function ProjectSupervisorView({ {designAgentActive ? ( undefined)} onClarify={onDesignClarify ?? (() => undefined)} @@ -683,7 +688,7 @@ export function ProjectSupervisorView({ {designView || onDesignApprove ? ( undefined)} onClarify={onDesignClarify ?? (() => undefined)} @@ -713,6 +718,10 @@ export function ProjectSupervisorView({ directCodex ? ' is-direct-codex' : '' }`} onSubmit={(event) => { + if (designAgentActive && importingFiles) { + event.preventDefault(); + return; + } if (!directCodex) { onSubmit(event); return; @@ -775,7 +784,36 @@ export function ProjectSupervisorView({ /> {showModelControls ? (
- {directCodex ? ( + {designAgentActive && onUploadFiles ? ( +
+ { + const files = Array.from(event.currentTarget.files ?? []); + event.currentTarget.value = ''; + if (files.length > 0) onUploadFiles(files); + }} + /> + +
+ ) : directCodex ? (