优化项目名称显示与命名链路 #248

Merged
kdletters merged 13 commits from feat/project-name into master 2026-09-03 15:50:56 +08:00
18 changed files with 809 additions and 72 deletions
@@ -0,0 +1 @@
你是项目命名助手。只根据用户输入提炼一个原创、简短、中文的项目名称;不要使用知名作品名、路径、URL、凭据、Markdown、引号或解释。输出必须有且只有一行纯文本,长度为 2 到 16 个字符。信息不足时输出:未命名游戏原型
@@ -13,6 +13,70 @@ 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_OUTPUT_TOKENS: u32 = 64;
const AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT: &str =
include_str!("../prompts/automatic-project-name.md");
suzmii marked this conversation as resolved Outdated
Outdated
Review

提示词放到上级目录中的prompts文件夹里

提示词放到上级目录中的prompts文件夹里
fn is_chinese_project_name_character(value: char) -> bool {
matches!(
value,
'\u{3400}'..='\u{4DBF}'
Outdated
Review

为什么要单独创建一个取名用的附件结构体

为什么要单独创建一个取名用的附件结构体
Outdated
Review

已去除附件结构体,只使用用户提示词(单个string)生成项目标题

已去除附件结构体,只使用用户提示词(单个string)生成项目标题
| '\u{4E00}'..='\u{9FFF}'
| '\u{F900}'..='\u{FAFF}'
| '\u{20000}'..='\u{2FA1F}'
| '\u{30000}'..='\u{323AF}'
)
suzmii marked this conversation as resolved Outdated
Outdated
Review

[P1] 这里仅校验单行、至少 2 字符和通用的 80 字符上限,但上面的命名契约要求输出为 2–16 个中文字符且拒绝 Markdown、URL、路径、凭据、引号和解释文本;例如 **foo**https://example.com 或 17–80 字符结果会被直接写入 manifest,非法模型输出不会回退默认名。请在 Rust 侧补齐严格的 fail-closed 校验,并为这些边界补测试。

[P1] 这里仅校验单行、至少 2 字符和通用的 80 字符上限,但上面的命名契约要求输出为 2–16 个中文字符且拒绝 Markdown、URL、路径、凭据、引号和解释文本;例如 `**foo**`、`https://example.com` 或 17–80 字符结果会被直接写入 manifest,非法模型输出不会回退默认名。请在 Rust 侧补齐严格的 fail-closed 校验,并为这些边界补测试。
}
pub(crate) fn normalize_suggested_project_name(value: &str) -> Option<String> {
let value = value.trim();
let character_count = value.chars().count();
if !(2..=16).contains(&character_count) || !value.chars().all(is_chinese_project_name_character)
{
return None;
}
normalize_game_creation_project_name(value).ok()
}
pub(crate) fn build_automatic_project_name_prompt(prompt: &str) -> Result<String, String> {
let prompt = prompt.trim();
if prompt.is_empty() {
return Err("首页创作需求为空,不能提炼项目名称".to_string());
}
Ok(prompt
.chars()
.take(AUTOMATIC_PROJECT_NAME_MAX_PROMPT_CHARS)
.collect())
}
async fn request_automatic_project_name(prompt: &str) -> Result<Option<String>, 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(
AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT.trim().to_string(),
user_prompt,
)
.await?;
return Ok(normalize_suggested_project_name(reply.trim()));
}
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.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
.map_err(|error| format!("自动项目命名失败:{error}"))?;
Ok(normalize_suggested_project_name(response.text.trim()))
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -284,7 +348,11 @@ fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result<PathBuf,
pub(crate) fn create_automatic_local_game_project_at(
projects_root: &Path,
requested_name: Option<&str>,
) -> Result<InitLocalProjectResult, String> {
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 +371,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 +411,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<String>,
) -> Result<InitLocalProjectResult, String> {
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 +431,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<RenameLocalProjectResult, String> {
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 +697,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());
}
@@ -1836,6 +1947,15 @@ 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,
) -> Result<Option<String>, String> {
request_automatic_project_name(prompt.trim())
.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()
@@ -2475,6 +2475,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,
@@ -7,6 +7,24 @@ const MANIFEST_LOCK_WAIT_MILLIS: u64 = 10;
static MANIFEST_LOCK_OPEN_GUARD: OnceLock<Mutex<()>> = OnceLock::new();
pub(crate) const GAME_CREATION_PROJECT_NAME_MAX_CHARS: usize = 80;
pub(crate) fn normalize_game_creation_project_name(value: &str) -> Result<String, String> {
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)? {
@@ -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,156 @@ 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("星河"),
Some("星河".to_string())
);
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控制字符",
"**星轨夜航**",
"https://example.com",
"C:\\projects\\game",
"Bearer sk-secret",
"“星轨夜航”",
"《星轨夜航》",
"项目名称:星轨夜航",
"星轨夜航2",
"星轨Game",
&"".repeat(17),
&"".repeat(81),
] {
assert!(
normalize_suggested_project_name(value).is_none(),
"invalid suggestion: {value:?}"
);
}
}
#[test]
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("做一个在月球邮局送信的解谜游戏")
.expect("build prompt");
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());
}
#[cfg(unix)]
#[test]
fn automatic_local_game_project_rejects_symlinked_projects_root() {
@@ -1531,7 +1677,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("不能包含符号链接"));
@@ -114,6 +114,11 @@ export interface InitLocalProjectResult {
manifest: GameCreationAppManifest;
}
export interface RenameLocalProjectResult {
manifest: GameCreationAppManifest;
revision: number;
}
export interface LocalProjectDirectoryStatus {
projectPath: string;
exists: boolean;
@@ -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"
>
<button
type="button"
role="menuitem"
disabled={!project.canOpen}
onClick={() => {
setOpen(false);
onRename(project);
}}
>
<PenLine size={15} aria-hidden="true" />
</button>
<button
type="button"
role="menuitem"
@@ -159,6 +181,60 @@ export function ProjectsPage({
}) {
const rows = recentProjects.filteredProjectRows;
const hasStoredProjects = recentProjects.projectRows.length > 0;
const [renamingPath, setRenamingPath] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const [renameSaving, setRenameSaving] = useState(false);
const [renameError, setRenameError] = useState<string | null>(null);
const renameInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (!renamingPath) {
return;
}
renameInputRef.current?.focus();
renameInputRef.current?.select();
}, [renamingPath]);
function startRename(project: RecentProjectRow) {
setRenamingPath(project.path);
setRenameValue(project.name);
setRenameError(null);
}
function cancelRename() {
setRenamingPath(null);
setRenameValue('');
setRenameSaving(false);
setRenameError(null);
}
async function submitRename(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!renamingPath || renameSaving) {
return;
}
const nextName = renameValue.trim();
if (!nextName) {
setRenameError('项目名称不能为空');
return;
}
if (nextName.length > 80) {
setRenameError('项目名称最多支持 80 个字符');
return;
}
setRenameSaving(true);
setRenameError(null);
try {
await homeProject.renameProject(renamingPath, nextName);
await recentProjects.refreshRecentWorkspace(renamingPath);
cancelRename();
} catch (error) {
setRenameError(error instanceof Error ? error.message : '重命名项目失败');
} finally {
setRenameSaving(false);
}
}
return (
<section className="launcher-page launcher-projects-page">
<header className="launcher-projects-toolbar">
@@ -227,47 +303,110 @@ export function ProjectsPage({
</div>
<div className="launcher-project-table">
{rows.length > 0 ? (
rows.map((project) => (
<article key={project.path}>
<button
type="button"
className="launcher-project-row-main"
aria-label={`打开项目 ${project.name}`}
disabled={!project.canOpen}
onClick={() => {
homeProject.setProjectPath(project.path);
void homeProject.openProject(project.path, 'open');
}}
>
<span className="launcher-project-kind-icon">
{project.projectKind === 'godot' ? (
<Gamepad2 size={18} aria-hidden="true" />
) : (
<FolderKanban size={18} aria-hidden="true" />
)}
rows.map((project) => {
const renaming = renamingPath === project.path;
return (
<article key={project.path}>
{renaming ? (
<form
className="launcher-project-rename"
id={`project-rename-${project.path}`}
onSubmit={submitRename}
>
<span className="launcher-project-kind-icon">
{project.projectKind === 'godot' ? (
<Gamepad2 size={18} aria-hidden="true" />
) : (
<FolderKanban size={18} aria-hidden="true" />
)}
</span>
<div className="launcher-project-name-cell">
<label htmlFor={`project-name-${project.path}`}>
</label>
<input
ref={renameInputRef}
id={`project-name-${project.path}`}
value={renameValue}
maxLength={80}
autoComplete="off"
onChange={(event) =>
setRenameValue(event.target.value)
}
/>
<small title={project.path}>{project.path}</small>
{renameError ? (
<small
className="launcher-project-rename-error"
role="alert"
>
{renameError}
</small>
) : null}
</div>
</form>
) : (
<button
type="button"
className="launcher-project-row-main"
aria-label={`打开项目 ${project.name}`}
disabled={!project.canOpen}
onClick={() => {
homeProject.setProjectPath(project.path);
void homeProject.openProject(project.path, 'open');
}}
>
<span className="launcher-project-kind-icon">
{project.projectKind === 'godot' ? (
<Gamepad2 size={18} aria-hidden="true" />
) : (
<FolderKanban size={18} aria-hidden="true" />
)}
</span>
<span className="launcher-project-name-cell">
<strong>{project.name}</strong>
<small title={project.path}>{project.path}</small>
</span>
</button>
)}
<span className="launcher-project-kind-cell">
{projectKindLabel(project)}
</span>
<span className="launcher-project-name-cell">
<strong>{project.name}</strong>
<small title={project.path}>{project.path}</small>
<span
className={`launcher-project-status launcher-project-status-${projectStatusTone(project)}`}
>
{projectStatusTone(project) === 'warning' ? (
<CircleAlert size={14} aria-hidden="true" />
) : null}
{project.status}
</span>
</button>
<span className="launcher-project-kind-cell">
{projectKindLabel(project)}
</span>
<span
className={`launcher-project-status launcher-project-status-${projectStatusTone(project)}`}
>
{projectStatusTone(project) === 'warning' ? (
<CircleAlert size={14} aria-hidden="true" />
) : null}
{project.status}
</span>
<ProjectMoreMenu
project={project}
recentProjects={recentProjects}
/>
</article>
))
{renaming ? (
<div
className="launcher-project-rename-actions"
aria-label="重命名操作"
>
<button
type="submit"
form={`project-rename-${project.path}`}
disabled={renameSaving}
>
<Check size={15} aria-hidden="true" />
{renameSaving ? '保存中' : '保存'}
</button>
<button type="button" onClick={cancelRename}>
</button>
</div>
) : (
<ProjectMoreMenu
project={project}
recentProjects={recentProjects}
onRename={startRename}
/>
)}
</article>
);
})
) : hasStoredProjects ? (
<div className="launcher-empty-projects">
<Search size={26} aria-hidden="true" />
@@ -106,7 +106,11 @@ export function WorkspaceLauncherShell({
manifest: current.manifest,
source: 'initial',
});
}, [currentProjectContext?.createdAt, currentProjectContext?.projectPath]);
}, [
currentProjectContext?.createdAt,
currentProjectContext?.projectName,
currentProjectContext?.projectPath,
]);
const applyManifestSnapshot = useCallback(
(snapshot: ProjectManifestSnapshot) => {
@@ -22,6 +22,7 @@ import type {
LocalProjectFileResult,
PendingNonEmptyProject,
ProjectStartMode,
RenameLocalProjectResult,
TauriInvoke,
UploadLocalAssetResult,
} from '../../app/types';
@@ -70,6 +71,21 @@ function createTextAttachmentFile(content: string) {
return file;
}
async function suggestAutomaticProjectName(
invoke: TauriInvoke,
draft: HomeDraft,
) {
try {
return await invoke<string | null>('suggest_automatic_project_name', {
prompt: draft.prompt,
});
} catch {
// 项目命名是增强能力:配置缺失、Provider 失败或非法输出都回退默认名,
// 不阻断用户真正请求的自动创建和首轮创作。
return null;
}
}
export function useHomeProjectCreation({
setStatus,
setLauncherView,
@@ -140,7 +156,7 @@ export function useHomeProjectCreation({
);
const file = createTextAttachmentFile(result.content);
await createHomeDraftAutomatically(
await createHomeDraftAutomaticallyWithOptions(
{
creationType: 'game',
prompt: APPROVED_GDD_BUILD_PROMPT,
@@ -152,6 +168,7 @@ export function useHomeProjectCreation({
],
},
'direct-build',
{ suggestName: false },
);
} finally {
approvedGddStartInFlightRef.current = false;
@@ -555,14 +572,31 @@ export function useHomeProjectCreation({
async function createHomeDraftAutomatically(
draft: HomeDraft,
startMode: ProjectStartMode,
) {
return createHomeDraftAutomaticallyWithOptions(draft, startMode, {
suggestName: true,
});
}
async function createHomeDraftAutomaticallyWithOptions(
draft: HomeDraft,
startMode: ProjectStartMode,
options: { suggestName: boolean },
) {
const invoke = resolveTauriInvoke();
if (!invoke) {
throw new Error('需要在陶泥儿客户端内运行');
}
setStatus(options.suggestName ? '正在提炼项目名称' : '正在创建工作区');
const suggestedName = options.suggestName
? await suggestAutomaticProjectName(invoke, draft)
: null;
setStatus('正在创建工作区');
const result = await invoke<InitLocalProjectResult>(
'create_automatic_local_game_project',
{
name: suggestedName,
},
);
try {
await enterCreatedHomeProject(
@@ -654,6 +688,31 @@ export function useHomeProjectCreation({
}
}
async function renameProject(nextProjectPath: string, nextName: string) {
const invoke = resolveTauriInvoke();
if (!invoke) {
throw new Error('需要在 Tauri App 内运行');
}
const result = await invoke<RenameLocalProjectResult>(
'rename_local_game_project',
{
projectPath: nextProjectPath,
name: nextName,
},
);
setCurrentProjectContext((current) =>
current && current.projectPath === nextProjectPath
? {
...current,
projectName: result.manifest.name,
manifest: result.manifest,
projectRevision: result.revision,
}
: current,
);
return result;
}
return {
projectPath,
setProjectPath,
@@ -673,6 +732,7 @@ export function useHomeProjectCreation({
createHomeDraft,
createHomeDraftAutomatically,
openProject,
renameProject,
pickAndOpenProject,
pickAndCreateProject,
confirmCreateInNonEmptyFolder,
@@ -30,6 +30,21 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
useState(false);
const [projectSearchQuery, setProjectSearchQuery] = useState('');
async function inspectRecentWorkspace(
invoke: NonNullable<ReturnType<typeof resolveTauriInvoke>>,
workspace: string,
): Promise<[string, LocalProjectDirectoryStatus | null]> {
try {
const result = await invoke<LocalProjectDirectoryStatus>(
'inspect_local_project_directory',
{ projectPath: workspace },
);
return [workspace, result];
} catch {
return [workspace, null];
}
}
useEffect(() => {
const invoke = resolveTauriInvoke();
if (!invoke || recentWorkspaces.length === 0) {
@@ -40,17 +55,9 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
let disposed = false;
setRecentWorkspaceRefreshing(true);
void Promise.all(
recentWorkspaces.map(async (workspace) => {
try {
const result = await invoke<LocalProjectDirectoryStatus>(
'inspect_local_project_directory',
{ projectPath: workspace },
);
return [workspace, result] as const;
} catch {
return [workspace, null] as const;
}
}),
recentWorkspaces.map((workspace) =>
inspectRecentWorkspace(invoke, workspace),
),
).then((entries) => {
if (disposed) {
return;
@@ -68,6 +75,18 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
setRecentWorkspaceRefreshKey((current) => current + 1);
}
async function refreshRecentWorkspace(projectPath: string) {
const invoke = resolveTauriInvoke();
if (!invoke) {
return;
}
const [, status] = await inspectRecentWorkspace(invoke, projectPath);
setRecentWorkspaceStatuses((current) => ({
...current,
[projectPath]: status,
}));
}
function handleRecentWorkspaceRemove(projectPath: string) {
setRecentWorkspaces(removeRecentWorkspace(projectPath));
setRecentWorkspaceStatuses((current) => {
@@ -137,6 +156,7 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
.filter((project) => project.canOpen)
.slice(0, 3),
rememberRecentWorkspace,
refreshRecentWorkspace,
handleRecentWorkspaceRemove,
handleRevealProjectDirectory,
};
@@ -1,4 +1,4 @@
import { ArrowUp } from 'lucide-react';
import { ArrowUp, Loader2 } from 'lucide-react';
import type {
ComponentProps,
Dispatch,
@@ -108,6 +108,7 @@ export function ProjectSupervisorView({
: runtimePanelProps.controlBusy
? '思考中'
: '发送';
const submitting = runtimePanelProps.controlBusy && !needsUserInput;
return (
<section
className={`project-supervisor-surface${directCodex ? ' is-direct-codex' : ''}`}
@@ -264,7 +265,11 @@ export function ProjectSupervisorView({
title={submitLabel}
disabled={runtimePanelProps.controlBusy || needsUserInput}
>
<ArrowUp size={16} aria-hidden="true" />
{submitting ? (
<Loader2 size={16} aria-hidden="true" className="animate-spin" />
) : (
<ArrowUp size={16} aria-hidden="true" />
)}
</button>
</form>
<small className="project-supervisor-workspace-status">
+73 -1
View File
@@ -1169,7 +1169,7 @@ textarea {
display: grid;
grid-template-columns:
minmax(360px, 1fr) minmax(120px, 160px) minmax(150px, 190px)
56px;
104px;
align-items: center;
min-width: 0;
}
@@ -1235,6 +1235,78 @@ textarea {
cursor: pointer;
}
.launcher-project-table article > .launcher-project-rename {
display: flex;
align-items: center;
justify-content: flex-start;
width: 100%;
height: 100%;
min-width: 0;
gap: 12px;
}
.launcher-project-rename label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.launcher-project-rename input {
width: min(100%, 320px);
min-width: 0;
min-height: 30px;
padding: 0 8px;
border: 1px solid var(--platform-surface-border);
border-radius: 7px;
color: var(--platform-text-strong);
font-size: 13px;
font-weight: 700;
}
.launcher-project-rename input:focus-visible {
border-color: var(--platform-warm-text);
outline: 2px solid transparent;
}
.launcher-project-rename-error {
color: #a64735 !important;
}
.launcher-project-rename-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 5px;
justify-self: end;
width: 100%;
}
.launcher-project-rename-actions button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
gap: 3px;
padding: 0 7px;
border-radius: 7px;
font-size: 11px;
font-weight: 700;
}
.launcher-project-rename-actions button:first-child {
border: 0;
background: var(--platform-warm-text);
color: #fff;
}
.launcher-project-rename-actions button:last-child {
border: 1px solid var(--platform-surface-border);
background: transparent;
color: var(--platform-text-strong);
}
.launcher-project-table article > .launcher-project-row-main:disabled {
cursor: not-allowed;
opacity: 0.62;
@@ -5,6 +5,7 @@ import {
FolderOpen,
Gamepad2,
Image,
Loader2,
type LucideIcon,
Sparkles,
} from 'lucide-react';
@@ -248,7 +249,15 @@ export default function HomeView({
}
disabled={homeCreationBusy}
>
<ArrowUp size={16} aria-hidden="true" />
{homeCreationBusy ? (
<Loader2
size={16}
aria-hidden="true"
className="animate-spin"
/>
) : (
<ArrowUp size={16} aria-hidden="true" />
)}
</button>
</div>
</RichInputArea>
@@ -1434,6 +1434,9 @@ export function registerHomeProjectCreationTests() {
([command]) => command === 'create_automatic_local_game_project',
),
).toHaveLength(1);
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
name: null,
});
expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', {
projectPath: automaticProjectPath,
prompt: '你好,今天多少号',
@@ -1484,6 +1487,12 @@ export function registerHomeProjectCreationTests() {
manifestPath: `${automaticProjectPath}\\.agent\\manifest.json`,
};
}
if (command === 'suggest_automatic_project_name') {
expect(args).toEqual({
prompt: '按这个角色做游戏',
});
return '角色参考游戏';
}
if (command === 'chat_with_game_creator_direct_codex') {
return '附件已经进入当前项目。';
}
@@ -1518,6 +1527,9 @@ export function registerHomeProjectCreationTests() {
fireEvent.click(screen.getByRole('button', { name: '开启创作' }));
expect(await screen.findByLabelText('项目开发工作台')).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
name: '角色参考游戏',
});
expect(invoke).toHaveBeenCalledWith('upload_local_asset', {
projectPath: automaticProjectPath,
fileName: '角色参考.png',
@@ -2755,6 +2767,111 @@ export function registerRecentProjectsTests() {
expect(window.localStorage.length).toBe(0);
});
it('renames a recent project and refreshes the inspected manifest name', async () => {
const projectPath = '/tmp/rename-game';
const renamedManifest = createGameCreationAppManifest(
'rename-game',
'星轨夜航',
);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
const renamed =
invoke.mock.calls.filter(
([candidate]) => candidate === 'rename_local_game_project',
).length > 0;
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: renamed ? '星轨夜航' : '旧项目名',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'rename_local_game_project') {
expect(args).toEqual({
projectPath,
name: '星轨夜航',
});
return { manifest: renamedManifest, revision: 3 };
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
window.localStorage.setItem(
'genarrative-ai-game-creator.recent-workspaces.v1',
JSON.stringify([projectPath]),
);
renderLauncherProjectsAt('/?launcher');
expect(await screen.findByText('旧项目名')).not.toBeNull();
fireEvent.click(
within(openProjectMoreMenu('旧项目名')).getByRole('menuitem', {
name: '重命名',
}),
);
const nameInput = screen.getByLabelText('项目名称');
fireEvent.change(nameInput, { target: { value: '星轨夜航' } });
fireEvent.click(screen.getByRole('button', { name: '保存' }));
expect(await screen.findByText('星轨夜航')).not.toBeNull();
expect(screen.queryByText('旧项目名')).toBeNull();
expect(invoke).toHaveBeenCalledWith('rename_local_game_project', {
projectPath,
name: '星轨夜航',
});
});
it('keeps rename editing open when the native command fails', async () => {
const projectPath = '/tmp/rename-failed-game';
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: '原项目',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'rename_local_game_project') {
expect(args?.name).toBe('失败后的名称');
throw new Error('manifest 写入失败');
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
window.localStorage.setItem(
'genarrative-ai-game-creator.recent-workspaces.v1',
JSON.stringify([projectPath]),
);
renderLauncherProjectsAt('/?launcher');
expect(await screen.findByText('原项目')).not.toBeNull();
fireEvent.click(
within(openProjectMoreMenu('原项目')).getByRole('menuitem', {
name: '重命名',
}),
);
fireEvent.change(screen.getByLabelText('项目名称'), {
target: { value: '失败后的名称' },
});
fireEvent.click(screen.getByRole('button', { name: '保存' }));
expect(await screen.findByRole('alert')).not.toBeNull();
expect(screen.getByRole('alert').textContent).toBe('manifest 写入失败');
expect((screen.getByLabelText('项目名称') as HTMLInputElement).value).toBe(
'失败后的名称',
);
});
it('opens a recent launcher project directory in the system file manager', async () => {
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
@@ -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-09-02 客户端会话恢复可观测性与超时兜底
- AGC 客户端启动恢复按“读取本地凭据 → 刷新会话(无 token 或失效时)→ 读取当前用户 → Tauri 本地运行时会话安装”阶段执行。界面必须展示当前阶段和已等待时间;不能以无期限的单一 loading 文案隐藏网络或 Runner 故障。
@@ -19,7 +19,7 @@ describe('AI 游戏创作 App 共享契约', () => {
it('keeps command permissions explicit', () => {
const commandIds = GAME_CREATION_APP_COMMANDS.map((command) => command.id);
expect(GAME_CREATION_APP_COMMANDS).toHaveLength(63);
expect(GAME_CREATION_APP_COMMANDS).toHaveLength(64);
expect(commandIds).toContain('project.git_inspect');
expect(commandIds).toContain('project.git_commit');
expect(commandIds).toContain('project.patchset');
@@ -60,6 +60,11 @@ describe('AI 游戏创作 App 共享契约', () => {
(command) => command.id === 'command.run_limited',
)?.permission,
).toBe('confirm');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'project.rename',
)?.permission,
).toBe('confirm');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'command.exec',
@@ -15,6 +15,7 @@ export interface GameCreationAppCommandDescriptor {
export const GAME_CREATION_APP_COMMANDS = [
{ id: 'help.show', permission: 'auto' },
{ id: 'project.create', permission: 'confirm' },
{ id: 'project.rename', permission: 'confirm' },
{ id: 'project.status', permission: 'auto' },
{ id: 'project.index', permission: 'auto' },
{ id: 'project.checkpoint', permission: 'confirm' },
@@ -21,9 +21,10 @@ pub struct GameCreationAppCommandDescriptor {
pub permission: GameCreationAppPermission,
}
pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 63] = [
pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 64] = [
command("help.show", GameCreationAppPermission::Auto),
command("project.create", GameCreationAppPermission::Confirm),
command("project.rename", GameCreationAppPermission::Confirm),
command("project.status", GameCreationAppPermission::Auto),
command("project.index", GameCreationAppPermission::Auto),
command("project.checkpoint", GameCreationAppPermission::Confirm),
@@ -965,7 +966,7 @@ mod tests {
#[test]
fn command_contract_keeps_expected_permissions() {
assert_eq!(GAME_CREATION_APP_COMMANDS.len(), 63);
assert_eq!(GAME_CREATION_APP_COMMANDS.len(), 64);
let command_ids = GAME_CREATION_APP_COMMANDS
.iter()
@@ -1018,6 +1019,15 @@ mod tests {
.expect("command should exist");
assert_eq!(command.permission, GameCreationAppPermission::Confirm);
let project_rename = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "project.rename")
.expect("project.rename should exist");
assert_eq!(
project_rename.permission,
GameCreationAppPermission::Confirm
);
let command_exec = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "command.exec")