From 41461584f92cc96cd2e48143b700fb0c3823c538 Mon Sep 17 00:00:00 2001 From: suzmii Date: Wed, 2 Sep 2026 15:21:51 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E8=A1=A5=E5=85=85=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E5=90=8D=E7=A7=B0=E9=93=BE=E8=B7=AF=E6=8A=80=E6=9C=AF=E5=90=88?= =?UTF-8?q?=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增加项目名称重命名、LLM 自动提炼、失败回退与一致性边界 --- .../【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index a44724132..a09fdab67 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,12 @@ # AI 游戏创作智能体 App 实施计划 +## 2026-09-02 项目名称显示与自动提炼 + +- `.agent/manifest.json` 的 `name` 仍是本地项目显示名唯一事实源;项目组页行尾更多菜单提供行内重命名,保存必须走 Tauri 受控命令、项目写锁、manifest 写锁与既有 ACL/权限校验。重命名只更新 manifest,不改变项目目录、`projectId`、项目类型、任务、资源、版本或远端同步状态;保存成功后当前项目上下文、窗口标题和最近项目检查结果必须回读新 manifest 并保持一致。 +- 项目名称统一 trim 后非空、最多 80 个字符且不得包含控制字符。空名称、超长、控制字符或未初始化项目必须失败关闭,原 manifest 保持可用;失败提示只展示安全错误,不泄露宿主路径之外的新内部信息。 +- 首页自动创建工作区前允许一次受限 LLM 名称提炼:输入只包含用户文本需求和附件元数据摘要,输出只允许一个简短中文项目名。Rust 侧统一规范化并在空值、控制字符、超长或多行格式时返回失败;前端把有效名称传给自动建项命令,失败时继续使用现有 `GameAgent 项目 {短ID}` 默认名并照常进入创作,不重试、不阻断、不额外消耗 Provider 请求。 +- 手动“新建项目”继续默认使用所选文件夹名,不额外调用 LLM;项目创建后用户可通过项目组页重命名修正显示名。自动建项命令的自定义名称必须走同一校验,未提供名称时保持现有默认名,保证旧调用与失败回退路径不变。 + ## 2026-08-26 运行中自主扩图提案 - `agent-runtime-orchestration` 提供严格 serde 的 `GraphProposal`、`TaskProposal`、`GraphEdge` 与 `GraphLimits`。宿主可把 LLM function-call arguments 解析后交给 `TaskGraph::apply_proposal`,在内存中得到新的、完整校验过的候选 DAG。 -- 2.52.0 From 4a03e7bdb84d63f65e0ed222f323288f82d0ec07 Mon Sep 17 00:00:00 2001 From: suzmii Date: Wed, 2 Sep 2026 15:58:00 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E5=90=8D=E7=A7=B0=E4=BF=AE=E6=94=B9=E4=B8=8E=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=8F=90=E7=82=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增项目重命名 Tauri 命令、名称校验和 manifest 写入边界 - 项目页支持行内重命名并同步当前上下文、窗口标题和最近项目 - 首页自动建项前增加单次 LLM 命名提炼和失败回退 - 补充共享命令契约与 Rust、前端回归测试 --- .../src-tauri/src/commands.rs | 172 +++++++++++++- .../src-tauri/src/main.rs | 2 + .../src-tauri/src/project/manifest.rs | 26 ++- .../src-tauri/src/tests/project.rs | 142 +++++++++++- apps/ai-game-creator-shell/src/app/types.ts | 5 + .../features/app-shell/ProjectCreation.tsx | 219 ++++++++++++++---- .../features/app-shell/WorkspaceLauncher.tsx | 6 +- .../app-shell/useHomeProjectCreation.ts | 66 +++++- .../features/app-shell/useRecentProjects.ts | 42 +++- apps/ai-game-creator-shell/src/styles.css | 74 +++++- .../tests/appSurface/home.suite.ts | 123 ++++++++++ .../src/contracts/gameCreationApp.test.ts | 7 +- .../shared/src/contracts/gameCreationApp.ts | 1 + .../shared-contracts/src/game_creation_app.rs | 14 +- 14 files changed, 830 insertions(+), 69 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 55f86012f..cbfb9024a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -13,6 +13,117 @@ const UI_EDITOR_IMAGE_MAX_COUNT: usize = 100; // imageSrc 与签名地址只在本文件的客户端下载阶段存在,绝不进入 Agent observation。 const AGENT_EDITOR_ASSET_LIBRARY_MAX_ITEMS: usize = 500; const AGENT_EDITOR_ASSET_ID_MAX_CHARS: usize = 512; +const AUTOMATIC_PROJECT_NAME_MAX_PROMPT_CHARS: usize = 8_000; +const AUTOMATIC_PROJECT_NAME_MAX_ATTACHMENTS: usize = 20; +const AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS: u32 = 64; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct AutomaticProjectNameAttachment { + pub(crate) name: String, + pub(crate) media_type: Option, +} + +pub(crate) fn normalize_suggested_project_name(value: &str) -> Option { + let value = value.trim(); + let mut lines = value.lines(); + let first_line = lines.next()?.trim(); + if first_line.is_empty() || lines.next().is_some() { + return None; + } + let first_line = first_line + .strip_prefix("项目名称:") + .or_else(|| first_line.strip_prefix("项目名称:")) + .or_else(|| first_line.strip_prefix("项目名:")) + .or_else(|| first_line.strip_prefix("项目名:")) + .unwrap_or(first_line) + .trim(); + let bytes = first_line.as_bytes(); + let stripped = if bytes.len() >= 2 { + let first = first_line.chars().next()?; + let last = first_line.chars().last()?; + if (first == '"' && last == '"') + || (first == '“' && last == '”') + || (first == '\'' && last == '\'') + || (first == '`' && last == '`') + { + first_line[first.len_utf8()..first_line.len() - last.len_utf8()].trim() + } else { + first_line + } + } else { + first_line + }; + if stripped.chars().count() < 2 { + return None; + } + normalize_game_creation_project_name(stripped).ok() +} + +pub(crate) fn build_automatic_project_name_prompt( + prompt: &str, + attachments: &[AutomaticProjectNameAttachment], +) -> Result { + let prompt = prompt.trim(); + let mut attachment_lines = Vec::new(); + for attachment in attachments + .iter() + .take(AUTOMATIC_PROJECT_NAME_MAX_ATTACHMENTS) + { + let name = attachment.name.trim(); + if name.is_empty() || name.chars().any(char::is_control) { + continue; + } + let name: String = name.chars().take(160).collect(); + let media_type = attachment + .media_type + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty() && !value.chars().any(char::is_control)) + .map(|value| value.chars().take(80).collect::()); + attachment_lines.push(match media_type { + Some(media_type) => format!("- 附件:{name}({media_type})"), + None => format!("- 附件:{name}"), + }); + } + if prompt.is_empty() && attachment_lines.is_empty() { + return Err("首页创作需求为空,不能提炼项目名称".to_string()); + } + let bounded_prompt: String = prompt + .chars() + .take(AUTOMATIC_PROJECT_NAME_MAX_PROMPT_CHARS) + .collect(); + Ok(match attachment_lines.is_empty() { + true => bounded_prompt, + false if prompt.is_empty() => attachment_lines.join("\n"), + false => format!("{bounded_prompt}\n{}", attachment_lines.join("\n")), + }) +} + +async fn request_automatic_project_name( + prompt: &str, + attachments: &[AutomaticProjectNameAttachment], +) -> Result, String> { + let user_prompt = build_automatic_project_name_prompt(prompt, attachments)?; + let app_config = load_game_creator_app_config()?; + let mut llm = app_config.llm.clone(); + llm.max_retries = 0; + let client = build_game_creator_llm_client_from_llm_config(&llm, "llm")?; + let request = LlmRunRequest::single_turn( + "你是项目命名助手。只根据用户输入提炼一个原创、简短、中文的项目名称;不要使用知名作品名、路径、URL、凭据、Markdown、引号或解释。输出必须有且只有一行纯文本,长度为 2 到 16 个字符。信息不足时输出:未命名游戏原型", + user_prompt, + ) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_model(llm.model.clone()) + .with_request_timeout_ms(llm.request_timeout_ms) + .with_max_output_tokens(AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS) + .with_web_search(false); + let response = client + .run(request) + .await + .map_err(|error| format!("自动项目命名失败:{error}"))?; + Ok(normalize_suggested_project_name(response.text.trim())) +} #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -284,7 +395,11 @@ fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result, ) -> Result { + let requested_name = requested_name + .map(normalize_game_creation_project_name) + .transpose()?; if projects_root.as_os_str().is_empty() || !projects_root.is_absolute() { return Err("自动工作区根目录必须是绝对路径".to_string()); } @@ -303,7 +418,9 @@ pub(crate) fn create_automatic_local_game_project_at( for _ in 0..16 { let workspace_id = uuid::Uuid::new_v4().simple().to_string(); let short_id = &workspace_id[..8]; - let project_name = format!("GameAgent 项目 {short_id}"); + let project_name = requested_name + .clone() + .unwrap_or_else(|| format!("GameAgent 项目 {short_id}")); let project_root = projects_root.join(format!("gameagent-{short_id}")); match fs::create_dir(&project_root) { Ok(()) => { @@ -341,8 +458,12 @@ pub(crate) fn create_automatic_local_game_project_at( #[tauri::command] pub(crate) fn create_automatic_local_game_project( app: tauri::AppHandle, + name: Option, ) -> Result { - create_automatic_local_game_project_at(&automatic_local_game_projects_root(&app)?) + create_automatic_local_game_project_at( + &automatic_local_game_projects_root(&app)?, + name.as_deref(), + ) } #[tauri::command] @@ -357,6 +478,40 @@ pub(crate) fn init_local_game_project( init_local_game_project_at(root, project_id.trim(), name.trim()) } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RenameLocalProjectResult { + pub(crate) manifest: GameCreationAppManifest, + pub(crate) revision: u64, +} + +#[tauri::command] +pub(crate) fn rename_local_game_project( + project_path: String, + name: String, +) -> Result { + let next_name = normalize_game_creation_project_name(name.trim())?; + let root = validated_local_project_directory_path(project_path.trim())?; + enforce_project_permission_policy(&root, "project.rename")?; + let _lock = acquire_project_write_lock(&root, "project.rename")?; + let current = read_existing_manifest_for_project(&root)?; + if current.name == next_name { + return Ok(RenameLocalProjectResult { + manifest: current, + revision: read_game_creator_agent_runtime_project_revision(&root)?.revision, + }); + } + + let manifest = mutate_manifest_at(&root, |manifest| { + manifest.name = next_name; + Ok(manifest.clone()) + })?; + Ok(RenameLocalProjectResult { + manifest, + revision: read_game_creator_agent_runtime_project_revision(&root)?.revision, + }) +} + #[tauri::command] pub(crate) fn import_local_godot_project( project_path: String, @@ -589,6 +744,9 @@ pub(crate) fn validated_local_project_directory_path( if !path.is_absolute() { return Err("项目目录必须是绝对路径".to_string()); } + if project_path_has_control_chars(path) { + return Err("项目目录不能包含控制字符".to_string()); + } if !path.exists() { return Err("项目目录不存在".to_string()); } @@ -1824,6 +1982,16 @@ pub(crate) fn check_game_creator_llm_config() -> GameCreatorLlmConfigStatus { check_game_creator_llm_config_from_config() } +#[tauri::command] +pub(crate) async fn suggest_automatic_project_name( + prompt: String, + attachments: Option>, +) -> Result, String> { + request_automatic_project_name(prompt.trim(), attachments.as_deref().unwrap_or_default()) + .await + .map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320)) +} + #[tauri::command] pub(crate) fn read_platform_account_session_generation() -> u64 { current_platform_session_generation() 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 a415b02a9..e19adaa4c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2401,6 +2401,8 @@ fn main() { is_local_project_directory_non_empty, inspect_local_project_directory, pick_local_project_directory, + rename_local_game_project, + suggest_automatic_project_name, pick_local_file, pick_client_extension_file, pick_client_extension_directory, 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 8b2efaf66..6059702a5 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 @@ -7,6 +7,24 @@ const MANIFEST_LOCK_WAIT_MILLIS: u64 = 10; static MANIFEST_LOCK_OPEN_GUARD: OnceLock> = OnceLock::new(); +pub(crate) const GAME_CREATION_PROJECT_NAME_MAX_CHARS: usize = 80; + +pub(crate) fn normalize_game_creation_project_name(value: &str) -> Result { + let name = value.trim(); + if name.is_empty() { + return Err("项目名称不能为空".to_string()); + } + if name.chars().any(char::is_control) { + return Err("项目名称不能包含控制字符".to_string()); + } + if name.chars().count() > GAME_CREATION_PROJECT_NAME_MAX_CHARS { + return Err(format!( + "项目名称最多支持 {GAME_CREATION_PROJECT_NAME_MAX_CHARS} 个字符" + )); + } + Ok(name.to_string()) +} + #[cfg(windows)] fn godot_metadata_is_reparse_point(metadata: &fs::Metadata) -> bool { use std::os::windows::fs::MetadataExt; @@ -424,9 +442,7 @@ pub(crate) fn init_local_game_project_at( if project_id.is_empty() { return Err("项目 ID 不能为空".to_string()); } - if name.is_empty() { - return Err("项目名称不能为空".to_string()); - } + let name = normalize_game_creation_project_name(name)?; prepare_game_creator_project_root_for_read(root, true, "本地项目目录")?; for relative in ["game", "assets", "memory", "memory/agents", "exports"] { @@ -506,9 +522,7 @@ pub(crate) fn import_local_godot_project_at( if project_id.is_empty() { return Err("项目 ID 不能为空".to_string()); } - if name.is_empty() { - return Err("项目名称不能为空".to_string()); - } + let name = normalize_game_creation_project_name(name)?; let manifest_path = root.join(".agent/manifest.json"); if manifest_storage_exists(&manifest_path)? { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 33759f02d..5a5dc0881 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -1499,12 +1499,14 @@ fn project_picker_uses_the_closest_existing_parent_for_a_suggested_new_path() { fn automatic_local_game_project_allocates_unique_initialized_workspaces() { let projects_root = unique_project_path(); - let first = create_automatic_local_game_project_at(&projects_root) + let first = create_automatic_local_game_project_at(&projects_root, None) .expect("create first automatic workspace"); - let second = create_automatic_local_game_project_at(&projects_root) + let second = create_automatic_local_game_project_at(&projects_root, Some("自定义项目名")) .expect("create second automatic workspace"); assert_ne!(first.project_path, second.project_path); + assert!(first.manifest.name.starts_with("GameAgent 项目 ")); + assert_eq!(second.manifest.name, "自定义项目名"); for result in [first, second] { let root = PathBuf::from(&result.project_path); assert_eq!(root.parent(), Some(projects_root.as_path())); @@ -1514,12 +1516,144 @@ fn automatic_local_game_project_allocates_unique_initialized_workspaces() { .is_some_and(|name| name.starts_with("gameagent-"))); assert!(root.join(".agent/manifest.json").is_file()); assert!(root.join("game/index.html").is_file()); - assert!(result.manifest.name.starts_with("GameAgent 项目 ")); } fs::remove_dir_all(projects_root).ok(); } +#[test] +fn automatic_local_game_project_accepts_only_a_safe_custom_name() { + let projects_root = unique_project_path(); + + let result = create_automatic_local_game_project_at(&projects_root, Some(" 星轨夜航 ")) + .expect("create named automatic workspace"); + assert_eq!(result.manifest.name, "星轨夜航"); + + for name in [ + " \n\t", + "名称\n包含控制字符", + &"长".repeat(GAME_CREATION_PROJECT_NAME_MAX_CHARS + 1), + ] { + assert!( + create_automatic_local_game_project_at(&projects_root, Some(name)).is_err(), + "unsafe name must fail: {name:?}" + ); + } + + fs::remove_dir_all(projects_root).ok(); +} + +#[test] +fn rename_local_game_project_updates_only_the_manifest_name() { + let root = unique_project_path(); + let before = + init_local_game_project_at(&root, "rename-project", "旧项目名").expect("project init"); + let revision = read_game_creator_agent_runtime_project_revision(&root).expect("read revision"); + let task_count = before.manifest.tasks.len(); + let asset_count = before.manifest.assets.len(); + + let renamed = rename_local_game_project( + root.to_string_lossy().into_owned(), + " 新项目名 ".to_string(), + ) + .expect("rename project"); + + assert_eq!(renamed.manifest.name, "新项目名"); + assert_eq!(renamed.manifest.project_id, before.manifest.project_id); + assert_eq!(renamed.manifest.tasks.len(), task_count); + assert_eq!(renamed.manifest.assets.len(), asset_count); + assert_eq!(renamed.revision, revision.revision); + assert_eq!( + renamed.manifest, + read_manifest_for_project(&root).expect("reread manifest") + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn rename_local_game_project_rejects_unsafe_names_and_uninitialized_projects() { + let root = unique_project_path(); + init_local_game_project_at(&root, "rename-invalid", "原项目名").expect("project init"); + + for name in [ + " \n\t".to_string(), + "名称\n包含控制字符".to_string(), + "长".repeat(GAME_CREATION_PROJECT_NAME_MAX_CHARS + 1), + ] { + let error = rename_local_game_project(root.to_string_lossy().into_owned(), name) + .expect_err("unsafe rename must fail"); + assert!(!error.is_empty()); + } + assert_eq!( + read_manifest_for_project(&root) + .expect("reread manifest") + .name, + "原项目名" + ); + + let empty_root = unique_project_path(); + fs::create_dir_all(&empty_root).expect("create empty root"); + assert!(rename_local_game_project( + empty_root.to_string_lossy().into_owned(), + "未初始化".to_string(), + ) + .is_err()); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(empty_root).ok(); +} + +#[test] +fn automatic_project_name_suggestions_are_normalized_fail_closed() { + assert_eq!( + normalize_suggested_project_name(" 项目名称:《星轨夜航》 "), + Some("《星轨夜航》".to_string()) + ); + assert_eq!( + normalize_suggested_project_name("星轨夜航\n解释"), + None, + "multi-line suggestions must fail closed" + ); + for value in [ + "", + " \t\r\n", + "名", + "项目名:名", + "名称\n控制字符", + &"长".repeat(81), + ] { + assert!( + normalize_suggested_project_name(value).is_none(), + "invalid suggestion: {value:?}" + ); + } +} + +#[test] +fn automatic_project_name_prompt_is_bounded_and_uses_attachment_metadata_only() { + let prompt = build_automatic_project_name_prompt( + "做一个在月球邮局送信的解谜游戏", + &[ + AutomaticProjectNameAttachment { + name: " 月球邮局参考.png ".to_string(), + media_type: Some("image/png".to_string()), + }, + AutomaticProjectNameAttachment { + name: "bad\nname".to_string(), + media_type: Some("image/png".to_string()), + }, + ], + ) + .expect("build prompt"); + + assert!(prompt.contains("做一个在月球邮局送信的解谜游戏")); + assert!(prompt.contains("附件:月球邮局参考.png(image/png)")); + assert!(!prompt.contains("bad\nname")); + + assert!(build_automatic_project_name_prompt("", &[]).is_err()); +} + #[cfg(unix)] #[test] fn automatic_local_game_project_rejects_symlinked_projects_root() { @@ -1531,7 +1665,7 @@ fn automatic_local_game_project_rejects_symlinked_projects_root() { fs::create_dir_all(&target).expect("create automatic workspace target"); symlink(&target, &projects_root).expect("symlink automatic workspace root"); - let error = create_automatic_local_game_project_at(&projects_root) + let error = create_automatic_local_game_project_at(&projects_root, None) .expect_err("symlinked automatic workspace root must fail"); assert!(error.contains("不能包含符号链接")); diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 12b177d3a..6ff949606 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -114,6 +114,11 @@ export interface InitLocalProjectResult { manifest: GameCreationAppManifest; } +export interface RenameLocalProjectResult { + manifest: GameCreationAppManifest; + revision: number; +} + export interface LocalProjectDirectoryStatus { projectPath: string; exists: boolean; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx b/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx index f783c2693..2aad6e5c3 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx @@ -1,14 +1,22 @@ import { + Check, CircleAlert, Ellipsis, FolderKanban, FolderOpen, FolderPlus, Gamepad2, + PenLine, Search, X, } from 'lucide-react'; -import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { + type FormEvent, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; import { closeDialogOnEscape } from '../../app/dialogs'; import type { RecentProjectRow } from './model'; @@ -40,9 +48,11 @@ function projectStatusTone(project: RecentProjectRow) { function ProjectMoreMenu({ project, recentProjects, + onRename, }: { project: RecentProjectRow; recentProjects: RecentProjectsController; + onRename: (project: RecentProjectRow) => void; }) { const [open, setOpen] = useState(false); const [dropUp, setDropUp] = useState(false); @@ -118,6 +128,18 @@ function ProjectMoreMenu({ className={`launcher-project-more-menu${dropUp ? ' launcher-project-more-menu-drop-up' : ''}`} role="menu" > + + )} + + {projectKindLabel(project)} - - {project.name} - {project.path} + + {projectStatusTone(project) === 'warning' ? ( + - - - {projectKindLabel(project)} - - - {projectStatusTone(project) === 'warning' ? ( - - - - )) + {renaming ? ( +
+ + +
+ ) : ( + + )} + + ); + }) ) : hasStoredProjects ? (
-- 2.52.0 From 77746c63d25e543840ebbec07461489b22ff832f Mon Sep 17 00:00:00 2001 From: suzmii Date: Wed, 2 Sep 2026 20:38:46 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E4=B8=A5=E6=A0=BC=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E9=A1=B9=E7=9B=AE=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自动命名仅接受二至十六个汉字。 补充格式、路径、凭据和长度边界回归测试。 --- .../src-tauri/src/commands.rs | 45 +++++++------------ .../src-tauri/src/tests/project.rs | 22 ++++++++- 2 files changed, 35 insertions(+), 32 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 3bb33d389..87a713346 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -26,40 +26,25 @@ pub(crate) struct AutomaticProjectNameAttachment { pub(crate) media_type: Option, } +fn is_chinese_project_name_character(value: char) -> bool { + matches!( + value, + '\u{3400}'..='\u{4DBF}' + | '\u{4E00}'..='\u{9FFF}' + | '\u{F900}'..='\u{FAFF}' + | '\u{20000}'..='\u{2FA1F}' + | '\u{30000}'..='\u{323AF}' + ) +} + pub(crate) fn normalize_suggested_project_name(value: &str) -> Option { let value = value.trim(); - let mut lines = value.lines(); - let first_line = lines.next()?.trim(); - if first_line.is_empty() || lines.next().is_some() { + let character_count = value.chars().count(); + if !(2..=16).contains(&character_count) || !value.chars().all(is_chinese_project_name_character) + { return None; } - let first_line = first_line - .strip_prefix("项目名称:") - .or_else(|| first_line.strip_prefix("项目名称:")) - .or_else(|| first_line.strip_prefix("项目名:")) - .or_else(|| first_line.strip_prefix("项目名:")) - .unwrap_or(first_line) - .trim(); - let bytes = first_line.as_bytes(); - let stripped = if bytes.len() >= 2 { - let first = first_line.chars().next()?; - let last = first_line.chars().last()?; - if (first == '"' && last == '"') - || (first == '“' && last == '”') - || (first == '\'' && last == '\'') - || (first == '`' && last == '`') - { - first_line[first.len_utf8()..first_line.len() - last.len_utf8()].trim() - } else { - first_line - } - } else { - first_line - }; - if stripped.chars().count() < 2 { - return None; - } - normalize_game_creation_project_name(stripped).ok() + normalize_game_creation_project_name(value).ok() } pub(crate) fn build_automatic_project_name_prompt( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 5a5dc0881..31650cbc1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -1607,8 +1607,16 @@ fn rename_local_game_project_rejects_unsafe_names_and_uninitialized_projects() { #[test] fn automatic_project_name_suggestions_are_normalized_fail_closed() { assert_eq!( - normalize_suggested_project_name(" 项目名称:《星轨夜航》 "), - Some("《星轨夜航》".to_string()) + normalize_suggested_project_name(" 星轨夜航 "), + Some("星轨夜航".to_string()) + ); + assert_eq!( + normalize_suggested_project_name("星河"), + Some("星河".to_string()) + ); + assert_eq!( + normalize_suggested_project_name("一二三四五六七八九十甲乙丙丁戊己"), + Some("一二三四五六七八九十甲乙丙丁戊己".to_string()) ); assert_eq!( normalize_suggested_project_name("星轨夜航\n解释"), @@ -1621,6 +1629,16 @@ fn automatic_project_name_suggestions_are_normalized_fail_closed() { "名", "项目名:名", "名称\n控制字符", + "**星轨夜航**", + "https://example.com", + "C:\\projects\\game", + "Bearer sk-secret", + "“星轨夜航”", + "《星轨夜航》", + "项目名称:星轨夜航", + "星轨夜航2", + "星轨Game", + &"长".repeat(17), &"长".repeat(81), ] { assert!( -- 2.52.0 From f16ce4997d060c536870bab915b63db48fcdee4f Mon Sep 17 00:00:00 2001 From: suzmii Date: Thu, 3 Sep 2026 11:09:09 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=E8=BF=81=E7=A7=BB=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=91=BD=E5=90=8D=E6=8F=90=E7=A4=BA=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将自动项目命名提示词迁移到 prompts 目录并由 Rust 编译期引入 说明取名附件采用独立窄 DTO 的输入边界原因 增加提示词关键约束回归测试 --- .../prompts/automatic-project-name.md | 1 + .../src-tauri/src/commands.rs | 19 +++++++++++-------- .../src-tauri/src/tests/project.rs | 4 ++++ 3 files changed, 16 insertions(+), 8 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/prompts/automatic-project-name.md diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/automatic-project-name.md b/apps/ai-game-creator-shell/src-tauri/prompts/automatic-project-name.md new file mode 100644 index 000000000..b7369c0f5 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/prompts/automatic-project-name.md @@ -0,0 +1 @@ +你是项目命名助手。只根据用户输入提炼一个原创、简短、中文的项目名称;不要使用知名作品名、路径、URL、凭据、Markdown、引号或解释。输出必须有且只有一行纯文本,长度为 2 到 16 个字符。信息不足时输出:未命名游戏原型 diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 87a713346..76949b928 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -17,8 +17,10 @@ const AUTOMATIC_PROJECT_NAME_MAX_PROMPT_CHARS: usize = 8_000; const AUTOMATIC_PROJECT_NAME_MAX_ATTACHMENTS: usize = 20; const AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS: u32 = 64; const AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT: &str = - "你是项目命名助手。只根据用户输入提炼一个原创、简短、中文的项目名称;不要使用知名作品名、路径、URL、凭据、Markdown、引号或解释。输出必须有且只有一行纯文本,长度为 2 到 16 个字符。信息不足时输出:未命名游戏原型"; + include_str!("../prompts/automatic-project-name.md"); +// 自动命名只需要附件名称和媒体类型提示。保持独立的窄 DTO 并拒绝未知字段, +// 避免把 Direct 回合附件中的本地路径、导入状态和文件大小引入无项目命名边界。 #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct AutomaticProjectNameAttachment { @@ -95,7 +97,7 @@ async fn request_automatic_project_name( let app_config = load_game_creator_app_config()?; if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER { let reply = crate::agent::direct_game_creator_home_codex_chat( - AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT.to_string(), + AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT.trim().to_string(), user_prompt, ) .await?; @@ -104,12 +106,13 @@ async fn request_automatic_project_name( let mut llm = app_config.llm.clone(); llm.max_retries = 0; let client = build_game_creator_llm_client_from_llm_config(&llm, "llm")?; - let request = LlmRunRequest::single_turn(AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT, user_prompt) - .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) - .with_model(llm.model.clone()) - .with_request_timeout_ms(llm.request_timeout_ms) - .with_max_output_tokens(AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS) - .with_web_search(false); + let request = + LlmRunRequest::single_turn(AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT.trim(), user_prompt) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_model(llm.model.clone()) + .with_request_timeout_ms(llm.request_timeout_ms) + .with_max_output_tokens(AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS) + .with_web_search(false); let response = client .run(request) .await diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 31650cbc1..3dce6ada7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -1650,6 +1650,10 @@ fn automatic_project_name_suggestions_are_normalized_fail_closed() { #[test] fn automatic_project_name_prompt_is_bounded_and_uses_attachment_metadata_only() { + let system_prompt = include_str!("../../prompts/automatic-project-name.md"); + assert!(system_prompt.contains("长度为 2 到 16 个字符")); + assert!(system_prompt.contains("不要使用知名作品名、路径、URL、凭据、Markdown、引号或解释")); + let prompt = build_automatic_project_name_prompt( "做一个在月球邮局送信的解谜游戏", &[ -- 2.52.0 From 5ac29ee691ab63c11034c8581e1c53054444829f Mon Sep 17 00:00:00 2001 From: suzmii Date: Thu, 3 Sep 2026 11:37:49 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=91=BD=E5=90=8D?= =?UTF-8?q?=E4=BB=85=E4=BD=BF=E7=94=A8=E7=94=A8=E6=88=B7=E9=9C=80=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除自动项目命名专用附件结构体和附件元数据拼接 前端取名请求只传用户需求 更新前后端回归测试以固定输入边界 --- .../src-tauri/src/commands.rs | 57 +++---------------- .../src-tauri/src/tests/project.rs | 28 +++------ .../app-shell/useHomeProjectCreation.ts | 4 -- .../tests/appSurface/home.suite.ts | 6 -- 4 files changed, 16 insertions(+), 79 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 9e01b71b9..99eeb1fa9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -14,20 +14,10 @@ const UI_EDITOR_IMAGE_MAX_COUNT: usize = 100; const AGENT_EDITOR_ASSET_LIBRARY_MAX_ITEMS: usize = 500; const AGENT_EDITOR_ASSET_ID_MAX_CHARS: usize = 512; const AUTOMATIC_PROJECT_NAME_MAX_PROMPT_CHARS: usize = 8_000; -const AUTOMATIC_PROJECT_NAME_MAX_ATTACHMENTS: usize = 20; const AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS: u32 = 64; const AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT: &str = include_str!("../prompts/automatic-project-name.md"); -// 自动命名只需要附件名称和媒体类型提示。保持独立的窄 DTO 并拒绝未知字段, -// 避免把 Direct 回合附件中的本地路径、导入状态和文件大小引入无项目命名边界。 -#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct AutomaticProjectNameAttachment { - pub(crate) name: String, - pub(crate) media_type: Option, -} - fn is_chinese_project_name_character(value: char) -> bool { matches!( value, @@ -49,51 +39,19 @@ pub(crate) fn normalize_suggested_project_name(value: &str) -> Option { normalize_game_creation_project_name(value).ok() } -pub(crate) fn build_automatic_project_name_prompt( - prompt: &str, - attachments: &[AutomaticProjectNameAttachment], -) -> Result { +pub(crate) fn build_automatic_project_name_prompt(prompt: &str) -> Result { let prompt = prompt.trim(); - let mut attachment_lines = Vec::new(); - for attachment in attachments - .iter() - .take(AUTOMATIC_PROJECT_NAME_MAX_ATTACHMENTS) - { - let name = attachment.name.trim(); - if name.is_empty() || name.chars().any(char::is_control) { - continue; - } - let name: String = name.chars().take(160).collect(); - let media_type = attachment - .media_type - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty() && !value.chars().any(char::is_control)) - .map(|value| value.chars().take(80).collect::()); - attachment_lines.push(match media_type { - Some(media_type) => format!("- 附件:{name}({media_type})"), - None => format!("- 附件:{name}"), - }); - } - if prompt.is_empty() && attachment_lines.is_empty() { + if prompt.is_empty() { return Err("首页创作需求为空,不能提炼项目名称".to_string()); } - let bounded_prompt: String = prompt + Ok(prompt .chars() .take(AUTOMATIC_PROJECT_NAME_MAX_PROMPT_CHARS) - .collect(); - Ok(match attachment_lines.is_empty() { - true => bounded_prompt, - false if prompt.is_empty() => attachment_lines.join("\n"), - false => format!("{bounded_prompt}\n{}", attachment_lines.join("\n")), - }) + .collect()) } -async fn request_automatic_project_name( - prompt: &str, - attachments: &[AutomaticProjectNameAttachment], -) -> Result, String> { - let user_prompt = build_automatic_project_name_prompt(prompt, attachments)?; +async fn request_automatic_project_name(prompt: &str) -> Result, String> { + let user_prompt = build_automatic_project_name_prompt(prompt)?; let app_config = load_game_creator_app_config()?; if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER { let reply = crate::agent::direct_game_creator_home_codex_chat( @@ -1992,9 +1950,8 @@ pub(crate) fn check_game_creator_llm_config() -> GameCreatorLlmConfigStatus { #[tauri::command] pub(crate) async fn suggest_automatic_project_name( prompt: String, - attachments: Option>, ) -> Result, String> { - request_automatic_project_name(prompt.trim(), attachments.as_deref().unwrap_or_default()) + request_automatic_project_name(prompt.trim()) .await .map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320)) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 3dce6ada7..54f8eae14 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -1649,31 +1649,21 @@ fn automatic_project_name_suggestions_are_normalized_fail_closed() { } #[test] -fn automatic_project_name_prompt_is_bounded_and_uses_attachment_metadata_only() { +fn automatic_project_name_prompt_is_bounded_and_uses_user_requirement_only() { let system_prompt = include_str!("../../prompts/automatic-project-name.md"); assert!(system_prompt.contains("长度为 2 到 16 个字符")); assert!(system_prompt.contains("不要使用知名作品名、路径、URL、凭据、Markdown、引号或解释")); - let prompt = build_automatic_project_name_prompt( - "做一个在月球邮局送信的解谜游戏", - &[ - AutomaticProjectNameAttachment { - name: " 月球邮局参考.png ".to_string(), - media_type: Some("image/png".to_string()), - }, - AutomaticProjectNameAttachment { - name: "bad\nname".to_string(), - media_type: Some("image/png".to_string()), - }, - ], - ) - .expect("build prompt"); + let prompt = build_automatic_project_name_prompt("做一个在月球邮局送信的解谜游戏") + .expect("build prompt"); - assert!(prompt.contains("做一个在月球邮局送信的解谜游戏")); - assert!(prompt.contains("附件:月球邮局参考.png(image/png)")); - assert!(!prompt.contains("bad\nname")); + assert_eq!(prompt, "做一个在月球邮局送信的解谜游戏"); + assert_eq!( + build_automatic_project_name_prompt(&"长".repeat(8_001)).expect("bound prompt"), + "长".repeat(8_000) + ); - assert!(build_automatic_project_name_prompt("", &[]).is_err()); + assert!(build_automatic_project_name_prompt("").is_err()); } #[cfg(unix)] 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 59f6a7155..da4bbd7b2 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 @@ -78,10 +78,6 @@ async function suggestAutomaticProjectName( try { return await invoke('suggest_automatic_project_name', { prompt: draft.prompt, - attachments: draft.attachments.map((attachment) => ({ - name: attachment.file.name, - mediaType: attachment.file.type || null, - })), }); } catch { // 项目命名是增强能力:配置缺失、Provider 失败或非法输出都回退默认名, 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 9da686a9a..aadd02977 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1490,12 +1490,6 @@ export function registerHomeProjectCreationTests() { if (command === 'suggest_automatic_project_name') { expect(args).toEqual({ prompt: '按这个角色做游戏', - attachments: [ - { - name: '角色参考.png', - mediaType: 'image/png', - }, - ], }); return '角色参考游戏'; } -- 2.52.0