修复 #599:首页建项不再等待 AI 项目命名,改为后台条件改名 #606
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,6 @@ use crate::agent::{
|
||||
use crate::commands::prepare_local_project_asset_generation;
|
||||
use crate::project::run_local_project_audio_generation_at;
|
||||
|
||||
pub(crate) const ASSET_GENERATION_TASK_STATUS_RUNNING: &str = "running";
|
||||
const ASSET_GENERATION_TASK_PHASE_RUNNING: &str = "正在生成。";
|
||||
/// 音频任务收口:通道跑完但没有登记出素材(`derive` 在有源 / 无源两条路上都必须登记 assets)。
|
||||
const ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR: &str = "生成完成但未登记素材";
|
||||
|
||||
@@ -502,6 +502,297 @@ pub(crate) fn rename_local_game_project(
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ConditionalRenameLocalProjectResult {
|
||||
pub(crate) renamed: bool,
|
||||
pub(crate) manifest: GameCreationAppManifest,
|
||||
pub(crate) revision: u64,
|
||||
}
|
||||
|
||||
/// AI 后台命名回填用的条件改名核心。
|
||||
///
|
||||
/// 只有「项目身份未变」且「当前名称仍是这次创建时的兜底名」时才落盘改名;任何一项不匹配都
|
||||
/// 原样返回当前 manifest / revision 并把 `renamed` 置为 false,绝不覆盖用户已经改过的名字。
|
||||
/// 与 [`rename_local_game_project`] 一致:改名是簿记写入,刻意不推项目 revision。
|
||||
pub(crate) fn rename_local_game_project_if_unchanged_at(
|
||||
root: &Path,
|
||||
expected_project_id: &str,
|
||||
expected_name: &str,
|
||||
name: &str,
|
||||
) -> Result<ConditionalRenameLocalProjectResult, String> {
|
||||
let next_name = normalize_game_creation_project_name(name.trim())?;
|
||||
let expected_project_id = expected_project_id.trim();
|
||||
if expected_project_id.is_empty() {
|
||||
return Err("项目改名 expectedProjectId 不能为空".to_string());
|
||||
}
|
||||
let expected_name = expected_name.trim();
|
||||
if expected_name.is_empty() {
|
||||
return Err("项目改名 expectedName 不能为空".to_string());
|
||||
}
|
||||
enforce_project_permission_policy(root, "project.rename")?;
|
||||
let _lock = acquire_project_write_lock(root, "project.rename")?;
|
||||
let current = read_existing_manifest_for_project(root)?;
|
||||
let revision = read_game_creator_agent_runtime_project_revision(root)?.revision;
|
||||
// 项目身份不匹配:目录换了项目或身份被改写,一律不改名。
|
||||
if current.project_id != expected_project_id {
|
||||
return Ok(ConditionalRenameLocalProjectResult {
|
||||
renamed: false,
|
||||
manifest: current,
|
||||
revision,
|
||||
});
|
||||
}
|
||||
// 用户已经手动改过名(或别的流程改过):兜底名不再成立,绝不覆盖。
|
||||
if current.name != expected_name {
|
||||
return Ok(ConditionalRenameLocalProjectResult {
|
||||
renamed: false,
|
||||
manifest: current,
|
||||
revision,
|
||||
});
|
||||
}
|
||||
// 结果与现状一致:没有必要再写一次盘。
|
||||
if current.name == next_name {
|
||||
return Ok(ConditionalRenameLocalProjectResult {
|
||||
renamed: false,
|
||||
manifest: current,
|
||||
revision,
|
||||
});
|
||||
}
|
||||
|
||||
let manifest = mutate_manifest_at(root, |manifest| {
|
||||
manifest.name = next_name;
|
||||
Ok(manifest.clone())
|
||||
})?;
|
||||
Ok(ConditionalRenameLocalProjectResult {
|
||||
renamed: true,
|
||||
manifest,
|
||||
revision: read_game_creator_agent_runtime_project_revision(root)?.revision,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn rename_local_game_project_if_unchanged(
|
||||
project_path: String,
|
||||
expected_project_id: String,
|
||||
expected_name: String,
|
||||
name: String,
|
||||
) -> Result<ConditionalRenameLocalProjectResult, String> {
|
||||
let root = validated_local_project_directory_path(project_path.trim())?;
|
||||
rename_local_game_project_if_unchanged_at(&root, &expected_project_id, &expected_name, &name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod conditional_project_rename_tests {
|
||||
use super::*;
|
||||
|
||||
const FALLBACK_NAME: &str = "策划项目 ab12cd34";
|
||||
|
||||
fn fixture_root(project_id: &str) -> tempfile::TempDir {
|
||||
let project = tempfile::tempdir().expect("project tempdir");
|
||||
init_local_game_project_at(project.path(), project_id, FALLBACK_NAME)
|
||||
.expect("initialize project");
|
||||
project
|
||||
}
|
||||
|
||||
fn manifest_name(root: &Path) -> String {
|
||||
read_existing_manifest_for_project(root)
|
||||
.expect("read manifest")
|
||||
.name
|
||||
}
|
||||
|
||||
fn project_revision(root: &Path) -> u64 {
|
||||
read_game_creator_agent_runtime_project_revision(root)
|
||||
.expect("read revision")
|
||||
.revision
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_rename_applies_when_fallback_name_still_matches() {
|
||||
let project = fixture_root("conditional-rename-project");
|
||||
let root = project.path();
|
||||
let project_id = read_existing_manifest_for_project(root)
|
||||
.expect("read manifest before")
|
||||
.project_id;
|
||||
let revision_before = project_revision(root);
|
||||
|
||||
let result = rename_local_game_project_if_unchanged_at(
|
||||
root,
|
||||
"conditional-rename-project",
|
||||
FALLBACK_NAME,
|
||||
"星轨夜航",
|
||||
)
|
||||
.expect("conditional rename applies");
|
||||
|
||||
assert!(result.renamed);
|
||||
assert_eq!(result.manifest.name, "星轨夜航");
|
||||
assert_eq!(result.manifest.project_id, project_id);
|
||||
assert_eq!(manifest_name(root), "星轨夜航");
|
||||
assert_eq!(project_revision(root), result.revision);
|
||||
// 与 rename_local_game_project 同语义:改名是簿记写入,刻意不推项目 revision
|
||||
// (见 projectResourceLiveUpdateModel.ts:推 revision 会让运行时的验证凭证无故漂移),
|
||||
// 所以这里断言改名前后 revision 一致,而不是变大。
|
||||
assert_eq!(result.revision, revision_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_rename_keeps_user_rename_when_expected_name_is_stale() {
|
||||
let project = fixture_root("conditional-rename-user-edited");
|
||||
let root = project.path();
|
||||
// 用户先手动改名:此刻兜底名仍然成立,这一步会落盘。
|
||||
let edited = rename_local_game_project_if_unchanged_at(
|
||||
root,
|
||||
"conditional-rename-user-edited",
|
||||
FALLBACK_NAME,
|
||||
"用户自己起的名字",
|
||||
)
|
||||
.expect("user rename applies");
|
||||
assert!(edited.renamed);
|
||||
|
||||
// AI 后台命名这时才回来:它带的兜底名已过期,必须整体跳过。
|
||||
let result = rename_local_game_project_if_unchanged_at(
|
||||
root,
|
||||
"conditional-rename-user-edited",
|
||||
FALLBACK_NAME,
|
||||
"星轨夜航",
|
||||
)
|
||||
.expect("stale expected name is not an error");
|
||||
|
||||
assert!(!result.renamed);
|
||||
assert_eq!(result.manifest.name, "用户自己起的名字");
|
||||
assert_eq!(manifest_name(root), "用户自己起的名字");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_rename_skips_when_project_identity_differs() {
|
||||
let project = fixture_root("conditional-rename-project");
|
||||
let root = project.path();
|
||||
let revision_before = project_revision(root);
|
||||
|
||||
let result = rename_local_game_project_if_unchanged_at(
|
||||
root,
|
||||
"another-project-id",
|
||||
FALLBACK_NAME,
|
||||
"星轨夜航",
|
||||
)
|
||||
.expect("identity mismatch is not an error");
|
||||
|
||||
assert!(!result.renamed);
|
||||
assert_eq!(result.manifest.project_id, "conditional-rename-project");
|
||||
assert_eq!(result.manifest.name, FALLBACK_NAME);
|
||||
assert_eq!(manifest_name(root), FALLBACK_NAME);
|
||||
assert_eq!(project_revision(root), revision_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_rename_rejects_invalid_name_without_touching_the_project() {
|
||||
let project = fixture_root("conditional-rename-invalid");
|
||||
let root = project.path();
|
||||
let revision_before = project_revision(root);
|
||||
|
||||
for invalid in ["", " ", "带\u{0}控制字符的名"] {
|
||||
let error = rename_local_game_project_if_unchanged_at(
|
||||
root,
|
||||
"conditional-rename-invalid",
|
||||
FALLBACK_NAME,
|
||||
invalid,
|
||||
)
|
||||
.expect_err("invalid name must fail closed");
|
||||
assert!(error.contains("项目名称"), "{error}");
|
||||
}
|
||||
|
||||
assert_eq!(manifest_name(root), FALLBACK_NAME);
|
||||
assert_eq!(project_revision(root), revision_before);
|
||||
}
|
||||
|
||||
/// 超长名字同样走 `normalize_game_creation_project_name` 的门禁:条件改名必须失败关闭,
|
||||
/// 且盘上 manifest 与 revision 都不动。
|
||||
#[test]
|
||||
fn conditional_rename_rejects_an_over_long_name_without_touching_the_project() {
|
||||
let project = fixture_root("conditional-rename-too-long");
|
||||
let root = project.path();
|
||||
let revision_before = project_revision(root);
|
||||
|
||||
let too_long = "名".repeat(crate::project::GAME_CREATION_PROJECT_NAME_MAX_CHARS + 1);
|
||||
let error = rename_local_game_project_if_unchanged_at(
|
||||
root,
|
||||
"conditional-rename-too-long",
|
||||
FALLBACK_NAME,
|
||||
&too_long,
|
||||
)
|
||||
.expect_err("over-long name must fail closed");
|
||||
assert!(error.contains("项目名称"), "{error}");
|
||||
|
||||
assert_eq!(manifest_name(root), FALLBACK_NAME);
|
||||
assert_eq!(project_revision(root), revision_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_rename_rejects_empty_expectations_without_touching_the_project() {
|
||||
let project = fixture_root("conditional-rename-empty-expectation");
|
||||
let root = project.path();
|
||||
let revision_before = project_revision(root);
|
||||
|
||||
// 空 expectedProjectId / expectedName 与其它按 expected 判等的入口同口径:失败关闭,
|
||||
// 而不是静默地按「不匹配」跳过——空串永远不可能是调用方真正想表达的身份。
|
||||
for (expected_project_id, expected_name, marker) in [
|
||||
("", FALLBACK_NAME, "expectedProjectId"),
|
||||
(" ", FALLBACK_NAME, "expectedProjectId"),
|
||||
("conditional-rename-empty-expectation", "", "expectedName"),
|
||||
(
|
||||
"conditional-rename-empty-expectation",
|
||||
" ",
|
||||
"expectedName",
|
||||
),
|
||||
] {
|
||||
let error = rename_local_game_project_if_unchanged_at(
|
||||
root,
|
||||
expected_project_id,
|
||||
expected_name,
|
||||
"星轨夜航",
|
||||
)
|
||||
.expect_err("empty expectation must fail closed");
|
||||
assert!(error.contains(marker), "{error} 应指出 {marker}");
|
||||
}
|
||||
|
||||
assert_eq!(manifest_name(root), FALLBACK_NAME);
|
||||
assert_eq!(project_revision(root), revision_before);
|
||||
|
||||
// 非空但不匹配仍然是「跳过」这条正常路径:用户已改名不是错误。
|
||||
let skipped = rename_local_game_project_if_unchanged_at(
|
||||
root,
|
||||
"conditional-rename-empty-expectation",
|
||||
"用户自己起的名字",
|
||||
"星轨夜航",
|
||||
)
|
||||
.expect("stale but non-empty expectation is not an error");
|
||||
assert!(!skipped.renamed);
|
||||
assert_eq!(skipped.manifest.name, FALLBACK_NAME);
|
||||
assert_eq!(manifest_name(root), FALLBACK_NAME);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_rename_reports_false_when_name_already_matches() {
|
||||
let project = fixture_root("conditional-rename-same-name");
|
||||
let root = project.path();
|
||||
let revision_before = project_revision(root);
|
||||
|
||||
for same in [FALLBACK_NAME, " 策划项目 ab12cd34 "] {
|
||||
let result = rename_local_game_project_if_unchanged_at(
|
||||
root,
|
||||
"conditional-rename-same-name",
|
||||
FALLBACK_NAME,
|
||||
same,
|
||||
)
|
||||
.expect("same name is not an error");
|
||||
assert!(!result.renamed);
|
||||
assert_eq!(result.manifest.name, FALLBACK_NAME);
|
||||
}
|
||||
|
||||
assert_eq!(manifest_name(root), FALLBACK_NAME);
|
||||
assert_eq!(project_revision(root), revision_before);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn import_local_godot_project(
|
||||
project_path: String,
|
||||
|
||||
@@ -550,6 +550,7 @@ pub(super) fn run() {
|
||||
inspect_local_project_directory,
|
||||
pick_local_project_directory,
|
||||
rename_local_game_project,
|
||||
rename_local_game_project_if_unchanged,
|
||||
suggest_automatic_project_name,
|
||||
polish_local_project_prompt,
|
||||
pick_client_extension_file,
|
||||
@@ -652,6 +653,8 @@ pub(super) fn run() {
|
||||
generate_local_project_asset,
|
||||
start_local_project_asset_generation,
|
||||
list_local_project_asset_generations,
|
||||
enqueue_local_project_naming_task,
|
||||
update_local_project_naming_task,
|
||||
open_canvas_project,
|
||||
run_limited_local_command,
|
||||
append_local_permission_log,
|
||||
|
||||
@@ -177,6 +177,16 @@ export interface RenameLocalProjectResult {
|
||||
revision: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 条件改名结果:`renamed === false` 表示宿主按「项目 ID 或当前名称不匹配」整体跳过,
|
||||
* 此时 `manifest` / `revision` 仍是跳过时的现状,调用方不要据此覆盖本地上下文。
|
||||
*/
|
||||
export interface ConditionalRenameLocalProjectResult {
|
||||
renamed: boolean;
|
||||
manifest: GameCreationAppManifest;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
export interface LocalProjectDirectoryStatus {
|
||||
projectPath: string;
|
||||
exists: boolean;
|
||||
|
||||
@@ -137,6 +137,7 @@ export function WorkspaceLauncherShell({
|
||||
showTransientNotice,
|
||||
setLauncherView,
|
||||
rememberRecentWorkspace,
|
||||
refreshRecentWorkspace: recentProjects.refreshRecentWorkspace,
|
||||
});
|
||||
const templateLibrary = useTemplateLibrary({
|
||||
userId: currentUser.id,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { createLocalProjectId } from '../../app/constants';
|
||||
import { useEscapeToClose } from '../../app/dialogs';
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import type {
|
||||
ConditionalRenameLocalProjectResult,
|
||||
InitLocalProjectResult,
|
||||
LauncherImportedAttachment,
|
||||
LauncherProjectContext,
|
||||
@@ -84,11 +85,23 @@ type UseHomeProjectCreationOptions = {
|
||||
showTransientNotice: ShowTransientNotice;
|
||||
setLauncherView: Dispatch<SetStateAction<LauncherView>>;
|
||||
rememberRecentWorkspace: (projectPath: string) => void;
|
||||
/**
|
||||
* 最近项目行的名称来自宿主目录重检(`inspect_local_project_directory` 读 manifest),
|
||||
* 不是内存投影:后台 AI 改名成功后必须显式重检这一行,列表才会同步到新名字。
|
||||
*/
|
||||
refreshRecentWorkspace?: (projectPath: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export const DESIGN_ARTIFACTS_BUILD_PROMPT =
|
||||
'查看当前项目 design_artifacts/ 目录及其子目录下的文档,理解其游戏设计,并按照这些文档将游戏实现出来。';
|
||||
|
||||
/**
|
||||
* 发起一次 AI 项目命名请求并返回其结果 Promise。
|
||||
*
|
||||
* 调用方**不要 await 它再建项目**:命名只是增强能力,工作区创建、附件导入和首轮创作都不依赖它。
|
||||
* 这里返回的 Promise 由后台改名任务消费(见 `applyAutomaticProjectName`);配置缺失、Provider
|
||||
* 失败、超时或非法输出都解析为 `null`,由调用方保留兜底名称。
|
||||
*/
|
||||
async function suggestAutomaticProjectName(
|
||||
invoke: TauriInvoke,
|
||||
draft: HomeDraft,
|
||||
@@ -104,6 +117,58 @@ async function suggestAutomaticProjectName(
|
||||
}
|
||||
}
|
||||
|
||||
/** 「AI 项目命名」任务的结论文案:结论只在这条链路里,所以文案由命名链路给定。 */
|
||||
const PROJECT_NAMING_FAILED_PHASE =
|
||||
'AI 命名请求失败或没有返回可用名称,已保留兜底名。';
|
||||
const PROJECT_NAMING_UNCHANGED_PHASE = 'AI 建议名与当前名称一致,无需改名。';
|
||||
const PROJECT_NAMING_RENAME_FAILED_PHASE = '自动改名失败,已保留兜底名。';
|
||||
const PROJECT_NAMING_SKIPPED_PHASE =
|
||||
'当前名称已不是本次创建的兜底名(用户已手动改名),已跳过 AI 命名。';
|
||||
|
||||
/**
|
||||
* 推进「生成任务」列表里的那条「AI 项目命名」任务。
|
||||
*
|
||||
* 账本是**展示旁路**:旧壳没有这两条命令、账本写盘失败或权限拒绝都不该影响命名与建项,
|
||||
* 所以失败只写诊断日志、绝不向上抛。
|
||||
*
|
||||
* 两条命令名都写成**字面量**:渲染层的 invoke 目标必须能被 `scripts/check-config.mjs` 静态枚举
|
||||
* (命令名走变量会让门禁报「App invoke 缺命令」)。
|
||||
*/
|
||||
async function enqueueProjectNamingTask(
|
||||
invoke: TauriInvoke,
|
||||
input: { projectPath: string; taskId: string },
|
||||
): Promise<void> {
|
||||
try {
|
||||
await invoke('enqueue_local_project_naming_task', input);
|
||||
} catch (error) {
|
||||
appendWebviewLog('warn', [
|
||||
`[home-project-name] enqueue_local_project_naming_task: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProjectNamingTask(
|
||||
invoke: TauriInvoke,
|
||||
input: {
|
||||
projectPath: string;
|
||||
taskId: string;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
phaseDetail?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
try {
|
||||
await invoke('update_local_project_naming_task', input);
|
||||
} catch (error) {
|
||||
appendWebviewLog('warn', [
|
||||
`[home-project-name] update_local_project_naming_task: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把浏览器 File 上传进项目并登记为资产,返回带项目相对路径的附件记录。
|
||||
*
|
||||
@@ -185,6 +250,7 @@ export function useHomeProjectCreation({
|
||||
showTransientNotice,
|
||||
setLauncherView,
|
||||
rememberRecentWorkspace,
|
||||
refreshRecentWorkspace,
|
||||
}: UseHomeProjectCreationOptions) {
|
||||
const lifecycleRef = useRef({ mounted: false, generation: 0 });
|
||||
const analyticsProjectPathRef = useRef<string | null>(null);
|
||||
@@ -415,6 +481,127 @@ export function useHomeProjectCreation({
|
||||
resetLauncherHomeDraft();
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台把 AI 建议名落到已创建的工作区。
|
||||
*
|
||||
* 这条链路同时是「生成任务」列表里那条「AI 项目命名」任务的推进者:
|
||||
* `enqueue_local_project_naming_task`(排队中)→ `update_…(running)` → 终态
|
||||
* (completed / failed)。任务行与素材生成共用同一份账本、同一个变更事件与同一个侧栏,
|
||||
* 状态流转对用户可见;账本推进失败只写诊断日志,绝不影响命名与建项本身。
|
||||
*
|
||||
* 与建项并行发起(`naming` 在建项前就已经打出请求);改名落盘本身不等任何东西,
|
||||
* 但「项目上下文」与「最近项目行」这两项都建立在进项目之后:AI 比进项目更快时直接写上下文
|
||||
* 会被随后的 `enterProjectDevelopment` 用兜底名覆盖,而最近项目行也只有进项目登记过才会被
|
||||
* 重检。所以它们等 `entrySettled`(建项主体收尾,含进项目成功/失败/让位于别的项目)。
|
||||
* - 宿主侧条件改名要求「项目 ID 相同」且「当前名称仍是这次创建时的兜底名称」;用户已手动改名时
|
||||
* 返回 `renamed: false`,这里整体跳过、绝不覆盖用户输入,任务按「已完成(已跳过)」收口。
|
||||
* - AI 返回空/非法/失败(Promise 解析为 null)时保持兜底名称,任务收口为 failed。
|
||||
*/
|
||||
async function applyAutomaticProjectName(
|
||||
invoke: TauriInvoke,
|
||||
naming: Promise<string | null>,
|
||||
created: InitLocalProjectResult,
|
||||
entrySettled: Promise<void>,
|
||||
) {
|
||||
const generation = lifecycleRef.current.generation;
|
||||
const namingTask = {
|
||||
projectPath: created.projectPath,
|
||||
// 一个项目一条命名任务:重开后账本里读到的还是同一行,不会越攒越多。
|
||||
taskId: `project-naming:${created.manifest.projectId}`,
|
||||
};
|
||||
await enqueueProjectNamingTask(invoke, namingTask);
|
||||
await updateProjectNamingTask(invoke, { ...namingTask, status: 'running' });
|
||||
let suggested: string | null = null;
|
||||
try {
|
||||
suggested = (await naming)?.trim() || null;
|
||||
} catch {
|
||||
suggested = null;
|
||||
}
|
||||
// 没有可用的建议名:保留兜底名,任务按失败收口(AI 失败 / 超时 / 非法内容同一条路径)。
|
||||
if (!suggested) {
|
||||
await updateProjectNamingTask(invoke, {
|
||||
...namingTask,
|
||||
status: 'failed',
|
||||
phaseDetail: PROJECT_NAMING_FAILED_PHASE,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 建议名就是兜底名:没有可改的,别白跑一次写操作,按完成收口并说明原因。
|
||||
if (suggested === created.manifest.name) {
|
||||
await updateProjectNamingTask(invoke, {
|
||||
...namingTask,
|
||||
status: 'completed',
|
||||
phaseDetail: PROJECT_NAMING_UNCHANGED_PHASE,
|
||||
});
|
||||
return;
|
||||
}
|
||||
let outcome: ConditionalRenameLocalProjectResult;
|
||||
try {
|
||||
outcome = await invoke<ConditionalRenameLocalProjectResult>(
|
||||
'rename_local_game_project_if_unchanged',
|
||||
{
|
||||
projectPath: created.projectPath,
|
||||
expectedProjectId: created.manifest.projectId,
|
||||
expectedName: created.manifest.name,
|
||||
name: suggested,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
appendWebviewLog('warn', [
|
||||
`[home-project-name] ${error instanceof Error ? error.message : String(error)}`,
|
||||
]);
|
||||
await updateProjectNamingTask(invoke, {
|
||||
...namingTask,
|
||||
status: 'failed',
|
||||
phaseDetail: PROJECT_NAMING_RENAME_FAILED_PHASE,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!outcome?.renamed) {
|
||||
// 用户在结果回来之前已经手动改过名(或项目身份已变):跳过,并如实说明跳过原因。
|
||||
await updateProjectNamingTask(invoke, {
|
||||
...namingTask,
|
||||
status: 'completed',
|
||||
phaseDetail: PROJECT_NAMING_SKIPPED_PHASE,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await updateProjectNamingTask(invoke, {
|
||||
...namingTask,
|
||||
status: 'completed',
|
||||
phaseDetail: `已应用 AI 名称「${outcome.manifest.name}」。`,
|
||||
});
|
||||
await entrySettled;
|
||||
/**
|
||||
* 等待期间壳可能已经被卸载(关窗 / 切走 / StrictMode 重放)。
|
||||
*
|
||||
* 落盘的改名是项目级事实(换台机器打开也是 AI 名),照旧保留;但「当前项目上下文」与
|
||||
* 「最近项目行重检」都是本壳的 UI 投影,卸载后一律不写——与 `enterProjectDevelopment`
|
||||
* 的守卫同口径(`mounted` + 代次),否则迟到的命名结果会写进已经作废的壳。
|
||||
*/
|
||||
if (
|
||||
!lifecycleRef.current.mounted ||
|
||||
generation !== lifecycleRef.current.generation
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setCurrentProjectContext((current) =>
|
||||
current && current.projectPath === created.projectPath
|
||||
? {
|
||||
...current,
|
||||
projectName: outcome.manifest.name,
|
||||
manifest: outcome.manifest,
|
||||
projectRevision: outcome.revision,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
try {
|
||||
await refreshRecentWorkspace?.(created.projectPath);
|
||||
} catch {
|
||||
// 最近项目行是辅助投影:重检失败不影响已经生效的改名与工作台标题。
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建失败的统一收口。
|
||||
*
|
||||
@@ -834,8 +1021,9 @@ export function useHomeProjectCreation({
|
||||
startMode: ProjectStartMode,
|
||||
) {
|
||||
return createHomeDraftAutomaticallyWithOptions(draft, startMode, {
|
||||
// 做方案的首轮还要调用一次策划 Provider;项目名称不是策划输入的
|
||||
// 前置条件,避免在进入工作区前再额外等待一次模型请求。
|
||||
// 判据就是 `ProjectStartMode`:'planning' 覆盖「做方案」和勾了「策划补全」的「做游戏」
|
||||
// (见 view/home/homeStartMode.ts),这一档只调策划 Provider,项目命名对它没有价值;
|
||||
// 只有 'direct-build' 才发起自动项目命名请求。
|
||||
suggestName: startMode !== 'planning',
|
||||
});
|
||||
}
|
||||
@@ -899,6 +1087,17 @@ export function useHomeProjectCreation({
|
||||
resolve(HOME_CREATION_WATCHDOG_MESSAGE);
|
||||
}, HOME_CREATION_DEADLINE_MS);
|
||||
});
|
||||
/**
|
||||
* 「建项主体收尾」信号(进项目成功、进项目失败、或让位于别的项目都算)。
|
||||
*
|
||||
* 后台 AI 改名可能比进项目更快:那时工作区上下文还没建立,直接写上下文会被随后的
|
||||
* `enterProjectDevelopment` 覆盖回兜底名,最近项目行也还没被登记进列表。改名落盘照旧立即执行,
|
||||
* 只有这两项依赖进项目结果的更新等这枚信号。
|
||||
*/
|
||||
let settleEntry!: () => void;
|
||||
const entrySettled = new Promise<void>((resolve) => {
|
||||
settleEntry = resolve;
|
||||
});
|
||||
/** 后台继续跑的建项主体:用户可见的等待由 `watchdog` 兜底,这里只负责最终落定。 */
|
||||
const creation = (async () => {
|
||||
try {
|
||||
@@ -919,17 +1118,30 @@ export function useHomeProjectCreation({
|
||||
);
|
||||
return message;
|
||||
}
|
||||
const suggestedName = options.suggestName
|
||||
? await suggestAutomaticProjectName(invoke, draft)
|
||||
// 命名与建项并行:先把命名请求打出去,再建工作区,两者互不等待。命名结果由后台任务
|
||||
// 落定(见 `applyAutomaticProjectName`),用户不必为了一个名字多等一次模型请求。
|
||||
const automaticName = options.suggestName
|
||||
? suggestAutomaticProjectName(invoke, draft)
|
||||
: null;
|
||||
const result = await invoke<InitLocalProjectResult>(
|
||||
'create_automatic_local_game_project',
|
||||
{
|
||||
name: suggestedName,
|
||||
// 不传名:走宿主兜底名("GameAgent 项目 <id>" / "策划项目 <id>"),
|
||||
// AI 建议名回来后再按「名称仍是兜底名」的条件改名。
|
||||
name: null,
|
||||
planning: startMode === 'planning',
|
||||
},
|
||||
);
|
||||
createdProjectPath = result.projectPath;
|
||||
if (automaticName) {
|
||||
// 纯后台任务:进入工作区、导入附件与首轮创作都不等它。
|
||||
void applyAutomaticProjectName(
|
||||
invoke,
|
||||
automaticName,
|
||||
result,
|
||||
entrySettled,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
setHomeCreationOperation(
|
||||
transitionClientOperation(operation, 'project', {
|
||||
scope: { projectPath: result.projectPath },
|
||||
@@ -990,6 +1202,8 @@ export function useHomeProjectCreation({
|
||||
}
|
||||
throw failHomeCreation(error, Boolean(createdProjectPath));
|
||||
} finally {
|
||||
// 进项目(或放弃进项目)已经尘埃落定,后台改名可以安全地写上下文与最近项目。
|
||||
settleEntry();
|
||||
if (watchdogId !== undefined) {
|
||||
window.clearTimeout(watchdogId);
|
||||
}
|
||||
|
||||
+117
-10
@@ -6,11 +6,14 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLiveNow } from '../project-workspace/useLiveNow';
|
||||
import {
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS,
|
||||
RESOURCE_CANVAS_PROJECT_NAMING_STATUS_LABELS,
|
||||
RESOURCE_CANVAS_PROJECT_NAMING_TASK_TITLE,
|
||||
resourceCanvasAssetGenerationElapsedLabel,
|
||||
type ResourceCanvasAssetGenerationTask,
|
||||
resourceCanvasAssetGenerationTaskElapsedMillis,
|
||||
resourceCanvasAssetGenerationTaskIsTerminal,
|
||||
resourceCanvasAssetGenerationTaskTone,
|
||||
type ResourceCanvasProjectNamingTask,
|
||||
sortResourceCanvasAssetGenerationTasks,
|
||||
} from './resourceCanvasAssetGenerationTaskModel';
|
||||
import {
|
||||
@@ -39,6 +42,13 @@ export type ResourceCanvasAssetGenerationTasksPanelViewProps = {
|
||||
* 账本渲染。缺省为空数组:老调用方不传就没有这一段。
|
||||
*/
|
||||
resourceEditTasks?: readonly ResourceCanvasResourceEditTask[];
|
||||
/**
|
||||
* 「AI 项目命名」任务(首页建项后的后台自动命名,账本里 `taskType = project-naming`)。
|
||||
*
|
||||
* 与素材生成共用同一份账本、同一个变更事件与同一个侧栏:展示名固定为「AI 项目命名」,不渲染
|
||||
* 素材缩略图 / 提示词 / 派发按钮,也不参与「定位到素材」。缺省为空数组。
|
||||
*/
|
||||
namingTasks?: readonly ResourceCanvasProjectNamingTask[];
|
||||
/** 侧栏是否展开;折叠时只留贴边把手。 */
|
||||
open: boolean;
|
||||
onToggleOpen: () => void;
|
||||
@@ -75,22 +85,37 @@ type ResourceCanvasGenerationTaskRow =
|
||||
readonly source: 'resource-edit';
|
||||
readonly createdAtMillis: number;
|
||||
readonly task: ResourceCanvasResourceEditTask;
|
||||
}
|
||||
| {
|
||||
readonly source: 'project-naming';
|
||||
readonly createdAtMillis: number;
|
||||
readonly task: ResourceCanvasProjectNamingTask;
|
||||
};
|
||||
|
||||
function resourceCanvasGenerationTaskRowKey(
|
||||
row: ResourceCanvasGenerationTaskRow,
|
||||
): string {
|
||||
return row.source === 'asset-generation'
|
||||
? row.task.taskId
|
||||
: row.task.operationId;
|
||||
switch (row.source) {
|
||||
case 'asset-generation':
|
||||
return row.task.taskId;
|
||||
case 'resource-edit':
|
||||
return row.task.operationId;
|
||||
default:
|
||||
return row.task.taskId;
|
||||
}
|
||||
}
|
||||
|
||||
function resourceCanvasGenerationTaskRowIsTerminal(
|
||||
row: ResourceCanvasGenerationTaskRow,
|
||||
): boolean {
|
||||
return row.source === 'asset-generation'
|
||||
? resourceCanvasAssetGenerationTaskIsTerminal(row.task)
|
||||
: resourceCanvasResourceEditTaskIsTerminal(row.task);
|
||||
switch (row.source) {
|
||||
case 'asset-generation':
|
||||
return resourceCanvasAssetGenerationTaskIsTerminal(row.task);
|
||||
case 'resource-edit':
|
||||
return resourceCanvasResourceEditTaskIsTerminal(row.task);
|
||||
default:
|
||||
return row.task.status === 'completed' || row.task.status === 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
function sortResourceCanvasGenerationTaskRows(
|
||||
@@ -256,13 +281,89 @@ function resourceEditTaskRow(task: ResourceCanvasResourceEditTask) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 「AI 项目命名」行的已耗时。
|
||||
*
|
||||
* 与素材任务 / 派生任务共用同一份 `useLiveNow` 口径(终态定格在账本结束时间上)。
|
||||
* **必须是一个组件**:行本身由函数返回,把 hook 直接留在行函数里会让「行数与行种类」的变化
|
||||
* 变成 hooks 数量的变化(React 直接抛「Rendered more hooks than during the previous render」)。
|
||||
*/
|
||||
function ProjectNamingElapsedLabel({
|
||||
task,
|
||||
}: {
|
||||
task: ResourceCanvasProjectNamingTask;
|
||||
}) {
|
||||
const terminal = task.status === 'completed' || task.status === 'failed';
|
||||
const now = useLiveNow(!terminal);
|
||||
return (
|
||||
<span className="game-resource-generation-task-card-elapsed">
|
||||
{`已耗时 ${resourceCanvasAssetGenerationElapsedLabel(
|
||||
resourceCanvasAssetGenerationTaskElapsedMillis(task, now),
|
||||
)}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 「AI 项目命名」任务的一行(首页建项后的后台自动命名)。
|
||||
*
|
||||
* 与素材任务同一套卡片 class 与 tone,但语义上刻意更窄:**展示名固定**为
|
||||
* 「AI 项目命名」,没有缩略图、没有提示词、没有派发/定位动作。收口结论(`phaseDetail`,
|
||||
* 由后端拥有:已应用 / 用户已改名跳过 / 失败保留兜底名)**只在终态渲染**——进行中由状态徽章
|
||||
* 表达,用户不需要看素材通道那种阶段进度文案。
|
||||
*/
|
||||
function projectNamingTaskRow(task: ResourceCanvasProjectNamingTask) {
|
||||
const terminal = task.status === 'completed' || task.status === 'failed';
|
||||
return (
|
||||
<li
|
||||
key={task.taskId}
|
||||
className="game-resource-generation-task-card"
|
||||
data-task-status={task.status}
|
||||
data-task-source="project-naming"
|
||||
>
|
||||
<div className="game-resource-generation-task-card-title-row">
|
||||
<strong className="game-resource-generation-task-card-name">
|
||||
{RESOURCE_CANVAS_PROJECT_NAMING_TASK_TITLE}
|
||||
</strong>
|
||||
<span className="game-resource-generation-task-card-action">
|
||||
AI 起名
|
||||
</span>
|
||||
</div>
|
||||
<div className="game-resource-generation-task-card-meta">
|
||||
<span
|
||||
className="game-resource-generation-task-badge"
|
||||
data-tone={resourceCanvasAssetGenerationTaskTone(task.status)}
|
||||
>
|
||||
{RESOURCE_CANVAS_PROJECT_NAMING_STATUS_LABELS[task.status]}
|
||||
</span>
|
||||
{terminal ? (
|
||||
<span className="game-resource-generation-task-card-phase">
|
||||
{task.phaseDetail}
|
||||
</span>
|
||||
) : null}
|
||||
<ProjectNamingElapsedLabel task={task} />
|
||||
</div>
|
||||
{task.error ? (
|
||||
<p className="game-resource-generation-task-card-error" role="alert">
|
||||
{task.error}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function resourceCanvasGenerationTaskRowNode(
|
||||
row: ResourceCanvasGenerationTaskRow,
|
||||
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void,
|
||||
) {
|
||||
return row.source === 'asset-generation'
|
||||
? assetGenerationTaskRow(row.task, onFocusTask)
|
||||
: resourceEditTaskRow(row.task);
|
||||
switch (row.source) {
|
||||
case 'asset-generation':
|
||||
return assetGenerationTaskRow(row.task, onFocusTask);
|
||||
case 'resource-edit':
|
||||
return resourceEditTaskRow(row.task);
|
||||
default:
|
||||
return projectNamingTaskRow(row.task);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -285,6 +386,7 @@ function resourceCanvasGenerationTaskRowNode(
|
||||
export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
tasks,
|
||||
resourceEditTasks = [],
|
||||
namingTasks = [],
|
||||
open,
|
||||
onToggleOpen,
|
||||
onFocusTask,
|
||||
@@ -315,8 +417,13 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
createdAtMillis: task.createdAtMillis,
|
||||
task,
|
||||
})),
|
||||
...namingTasks.map((task) => ({
|
||||
source: 'project-naming' as const,
|
||||
createdAtMillis: task.createdAtMillis,
|
||||
task,
|
||||
})),
|
||||
]),
|
||||
[resourceEditTasks, tasks],
|
||||
[namingTasks, resourceEditTasks, tasks],
|
||||
);
|
||||
const active = ordered.filter(
|
||||
(row) => !resourceCanvasGenerationTaskRowIsTerminal(row),
|
||||
|
||||
+128
-2
@@ -25,7 +25,15 @@ export type ResourceCanvasAssetGenerationTaskStatus =
|
||||
export type LocalProjectAssetGenerationTaskRecord = {
|
||||
taskId: string;
|
||||
projectId: string;
|
||||
kind: GameCreationAppAssetKind;
|
||||
/**
|
||||
* 这条任务属于哪一类:素材生成(既有)或项目命名(首页建项后的 AI 命名)。
|
||||
*
|
||||
* 旧壳(账本 v1)没有这个字段,一律按 `asset-generation` 读——见
|
||||
* `resolveLocalProjectGenerationTaskType`。
|
||||
*/
|
||||
taskType?: LocalProjectGenerationTaskType | null;
|
||||
/** 素材类任务才有;命名任务为 null。 */
|
||||
kind?: GameCreationAppAssetKind | null;
|
||||
assetName: string;
|
||||
status: string;
|
||||
phaseDetail: string;
|
||||
@@ -36,6 +44,34 @@ export type LocalProjectAssetGenerationTaskRecord = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 账本里的任务类型。
|
||||
*
|
||||
* 两者共用同一份账本、同一个变更事件与同一个「生成任务」侧栏:素材生成按后端账本推进,
|
||||
* 项目命名由首页后台命名链路推进(`enqueue_local_project_naming_task` /
|
||||
* `update_local_project_naming_task`)。命名任务不派发素材生成请求、没有素材缩略图与提示词。
|
||||
*/
|
||||
export type LocalProjectGenerationTaskType =
|
||||
| 'asset-generation'
|
||||
| 'project-naming';
|
||||
|
||||
export const LOCAL_PROJECT_GENERATION_TASK_TYPE_ASSET_GENERATION =
|
||||
'asset-generation';
|
||||
|
||||
export const LOCAL_PROJECT_GENERATION_TASK_TYPE_PROJECT_NAMING =
|
||||
'project-naming';
|
||||
|
||||
/** 「生成任务」列表里「AI 项目命名」这条任务的固定展示名。 */
|
||||
export const RESOURCE_CANVAS_PROJECT_NAMING_TASK_TITLE = 'AI 项目命名';
|
||||
|
||||
export function resolveLocalProjectGenerationTaskType(
|
||||
value: string | null | undefined,
|
||||
): LocalProjectGenerationTaskType {
|
||||
return value === LOCAL_PROJECT_GENERATION_TASK_TYPE_PROJECT_NAMING
|
||||
? 'project-naming'
|
||||
: 'asset-generation';
|
||||
}
|
||||
|
||||
/** 宿主「生成任务」列表里的一条:本地排队信息 + 后端账本记录。 */
|
||||
export type ResourceCanvasAssetGenerationTask = {
|
||||
/** 本地任务 id,同时作为提交给后端的 taskId(重开项目后靠它对上账本记录)。 */
|
||||
@@ -373,6 +409,92 @@ export function restoreResourceCanvasAssetGenerationTask(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 「生成任务」列表里的一条「AI 项目命名」任务。
|
||||
*
|
||||
* 它与素材生成共用同一份账本、同一个变更事件与同一个侧栏行样式,但**只**保留状态流转需要的
|
||||
* 字段:没有素材缩略图、没有提示词、没有比例/落点,也不参与素材派发与「定位到素材」。
|
||||
*/
|
||||
export type ResourceCanvasProjectNamingTask = {
|
||||
taskId: string;
|
||||
projectId: string;
|
||||
status: ResourceCanvasAssetGenerationTaskStatus;
|
||||
/**
|
||||
* 收口结论(后端拥有)。**只在终态渲染**:草稿期用状态徽章表达,用户不需要看「排队中。」这类
|
||||
* 素材通道的阶段文案。
|
||||
*/
|
||||
phaseDetail: string;
|
||||
createdAtMillis: number;
|
||||
startedAtMillis: number | null;
|
||||
finishedAtMillis: number | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
/** 命名任务的状态文案:只有 `running` 与素材生成(「生成中」)不同。 */
|
||||
export const RESOURCE_CANVAS_PROJECT_NAMING_STATUS_LABELS: Record<
|
||||
ResourceCanvasAssetGenerationTaskStatus,
|
||||
string
|
||||
> = {
|
||||
queued: '排队中',
|
||||
running: '命名中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
};
|
||||
|
||||
/** 账本记录 → 命名任务行(重开项目、或入队当帧从账本读回时恢复显示)。 */
|
||||
export function restoreResourceCanvasProjectNamingTask(
|
||||
record: LocalProjectAssetGenerationTaskRecord,
|
||||
): ResourceCanvasProjectNamingTask {
|
||||
return {
|
||||
taskId: record.taskId,
|
||||
projectId: record.projectId,
|
||||
status: resolveResourceCanvasAssetGenerationTaskStatus(record.status),
|
||||
phaseDetail: record.phaseDetail,
|
||||
createdAtMillis: record.createdAtMillis,
|
||||
startedAtMillis: record.startedAtMillis,
|
||||
finishedAtMillis: record.finishedAtMillis,
|
||||
error: record.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一次账本读取按任务类型拆开。
|
||||
*
|
||||
* 旧壳(账本 v1)的记录没有 `taskType`,一律按素材生成读;命名记录**不能**进素材任务列表
|
||||
* (那里会按 `kind` 解析并参与派发)。
|
||||
*/
|
||||
export function splitLocalProjectGenerationTaskRecords(
|
||||
records: readonly LocalProjectAssetGenerationTaskRecord[] | null | undefined,
|
||||
): {
|
||||
assetRecords: LocalProjectAssetGenerationTaskRecord[];
|
||||
namingRecords: LocalProjectAssetGenerationTaskRecord[];
|
||||
} {
|
||||
const assetRecords: LocalProjectAssetGenerationTaskRecord[] = [];
|
||||
const namingRecords: LocalProjectAssetGenerationTaskRecord[] = [];
|
||||
if (!Array.isArray(records)) {
|
||||
return { assetRecords, namingRecords };
|
||||
}
|
||||
for (const record of records) {
|
||||
if (
|
||||
typeof record !== 'object' ||
|
||||
record === null ||
|
||||
typeof record.taskId !== 'string' ||
|
||||
record.taskId.trim().length === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
resolveLocalProjectGenerationTaskType(record.taskType) ===
|
||||
LOCAL_PROJECT_GENERATION_TASK_TYPE_PROJECT_NAMING
|
||||
) {
|
||||
namingRecords.push(record);
|
||||
} else {
|
||||
assetRecords.push(record);
|
||||
}
|
||||
}
|
||||
return { assetRecords, namingRecords };
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地任务 + 后端记录 → 下一版任务列表。
|
||||
*
|
||||
@@ -384,6 +506,8 @@ export function restoreResourceCanvasAssetGenerationTask(
|
||||
* 账本文件读坏、命令未注册都可能给出 `undefined` 或别的形状)。非数组一律按「没有后端记录」
|
||||
* 处理——抛出去会变成 effect 里的未处理 Promise 拒绝,把「任务列表暂不可用」升级成整块视图出错。
|
||||
* 数组里的垃圾条目(`null` / 数字 / 缺 `taskId`)同样逐条丢弃,不让一条坏记录带崩整次恢复。
|
||||
* 命名任务是**另一类行**(`splitLocalProjectGenerationTaskRecords`),这里一律剔除,避免拿
|
||||
* `kind` 解析它们。
|
||||
*/
|
||||
export function applyLocalProjectAssetGenerationRecords(
|
||||
tasks: readonly ResourceCanvasAssetGenerationTask[],
|
||||
@@ -397,7 +521,9 @@ export function applyLocalProjectAssetGenerationRecords(
|
||||
typeof (record as LocalProjectAssetGenerationTaskRecord).taskId ===
|
||||
'string' &&
|
||||
(record as LocalProjectAssetGenerationTaskRecord).taskId.trim()
|
||||
.length > 0,
|
||||
.length > 0 &&
|
||||
resolveLocalProjectGenerationTaskType(record.taskType) ===
|
||||
LOCAL_PROJECT_GENERATION_TASK_TYPE_ASSET_GENERATION,
|
||||
)
|
||||
: [];
|
||||
const recordsByTaskId = new Map(
|
||||
|
||||
@@ -169,6 +169,9 @@ import {
|
||||
type LocalProjectAssetGenerationTaskRecord,
|
||||
type ResourceCanvasAssetGenerationTask,
|
||||
resourceCanvasAssetGenerationTaskIsTerminal,
|
||||
type ResourceCanvasProjectNamingTask,
|
||||
restoreResourceCanvasProjectNamingTask,
|
||||
splitLocalProjectGenerationTaskRecords,
|
||||
} from '../../features/resource-canvas/resourceCanvasAssetGenerationTaskModel';
|
||||
import { ResourceCanvasAssetGenerationTasksPanelView } from '../../features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView';
|
||||
import {
|
||||
@@ -2102,6 +2105,16 @@ export default function ProjectDevelopmentView({
|
||||
resourceAssetGenerationTasksPanelOpen,
|
||||
setResourceAssetGenerationTasksPanelOpen,
|
||||
] = useState(false);
|
||||
/**
|
||||
* 「AI 项目命名」任务(首页建项后的后台自动命名,账本 `taskType = project-naming`)。
|
||||
*
|
||||
* 它与素材生成共用同一份账本与同一个「生成任务」侧栏,但推进不由本组件驱动:命名在首页那边跑
|
||||
* (`enqueue_local_project_naming_task` / `update_local_project_naming_task`),这里只按
|
||||
* 变更事件做一次受控快照读取把状态显示出来。
|
||||
*/
|
||||
const [projectNamingTasks, setProjectNamingTasks] = useState<
|
||||
ResourceCanvasProjectNamingTask[]
|
||||
>([]);
|
||||
/**
|
||||
* 占位落点要用的几何:栏目位置表与卡片尺寸表。两者都在本渲染靠后处才算出来,
|
||||
* 所以用 ref 暴露给占位 Hook(工具点击发生在那之后,读到的总是最新一帧)。
|
||||
@@ -8973,6 +8986,34 @@ export default function ProjectDevelopmentView({
|
||||
[manifest.assets, resourceGenerationPlaceholders],
|
||||
);
|
||||
|
||||
/**
|
||||
* 刷新「AI 项目命名」行:从账本重读一次(受控快照读取)。
|
||||
*
|
||||
* 读失败或返回值不是数组时**保留上一次的命名行**——一次 IPC 失败不该把列表清空。
|
||||
*/
|
||||
const refreshProjectNamingTasks = useCallback(async () => {
|
||||
const invoke = window.__TAURI__?.core?.invoke;
|
||||
if (!invoke) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await invoke<LocalProjectAssetGenerationTaskRecord[]>(
|
||||
'list_local_project_asset_generations',
|
||||
{ projectPath },
|
||||
);
|
||||
if (!Array.isArray(response)) {
|
||||
return;
|
||||
}
|
||||
setProjectNamingTasks(
|
||||
splitLocalProjectGenerationTaskRecords(response).namingRecords.map(
|
||||
restoreResourceCanvasProjectNamingTask,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// 账本读不到:保留上一次的命名行,不猜状态。
|
||||
}
|
||||
}, [projectPath]);
|
||||
|
||||
/**
|
||||
* 重开项目时恢复项目内的任务账本。
|
||||
*
|
||||
@@ -8990,7 +9031,9 @@ export default function ProjectDevelopmentView({
|
||||
}
|
||||
const projectId = manifest.projectId;
|
||||
let cancelled = false;
|
||||
let unsubscribeNamingTasks: (() => void) | null = null;
|
||||
setResourceAssetGenerationTasksPanelOpen(false);
|
||||
setProjectNamingTasks([]);
|
||||
resourceAssetGenerationPanelSubmissionRef.current = null;
|
||||
setResourceAssetGenerationPanelReopen(null);
|
||||
// 切项目:生成浮层与它的占位都不跨项目(占位本身由占位 Hook 按 projectId 收口)。
|
||||
@@ -9002,6 +9045,39 @@ export default function ProjectDevelopmentView({
|
||||
resourceGenerationDraftRef.current.clear();
|
||||
resourceAssetGenerationDraftRef.current.clear();
|
||||
void (async () => {
|
||||
/**
|
||||
* **先订阅、再读快照**(与素材队列「先订阅后派发」同一口径)。
|
||||
*
|
||||
* 命名行的推进发生在本组件之外(首页后台命名链路),刷新只能靠「变更事件 + 一次受控快照
|
||||
* 读取」;读取是异步的,若先读后订阅,落在这一次 IPC 往返里的终态事件没有消费者、直接丢掉,
|
||||
* 命名行会永久停在「命名中」。订阅失败也要照读一次,靠面板打开时的补读兜底。
|
||||
*
|
||||
* 订阅与这次恢复读共用同一次 effect:本项目挂载时对账本的读次数保持不变(把命名读并进
|
||||
* 这一次恢复读),不改变既有生成任务链路可观测的轮询计数。
|
||||
*/
|
||||
if (canSubscribeTauriEvents()) {
|
||||
try {
|
||||
const dispose = await subscribeTauriEvent<{ projectPath?: string }>(
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_CHANGED_EVENT,
|
||||
(event) => {
|
||||
if (cancelled || event.payload?.projectPath !== projectPath) {
|
||||
return;
|
||||
}
|
||||
void refreshProjectNamingTasks();
|
||||
},
|
||||
);
|
||||
if (cancelled) {
|
||||
dispose();
|
||||
return;
|
||||
}
|
||||
unsubscribeNamingTasks = dispose;
|
||||
} catch {
|
||||
// 订阅不上:退化成「进项目读一次 + 面板打开时重读」。
|
||||
}
|
||||
}
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const reportUnavailable = () => {
|
||||
if (!cancelled) {
|
||||
setResourceWorkbenchNotice(
|
||||
@@ -9027,19 +9103,54 @@ export default function ProjectDevelopmentView({
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const { assetRecords, namingRecords } =
|
||||
splitLocalProjectGenerationTaskRecords(records);
|
||||
const restored = mergeResourceCanvasAssetGenerationTasksWithRecords(
|
||||
resourceAssetGenerationTasksRef.current.filter(
|
||||
(task) => task.projectId === projectId,
|
||||
),
|
||||
records,
|
||||
assetRecords,
|
||||
);
|
||||
resourceAssetGenerationTasksRef.current = restored;
|
||||
setResourceAssetGenerationTasks(restored);
|
||||
setProjectNamingTasks(
|
||||
namingRecords.map(restoreResourceCanvasProjectNamingTask),
|
||||
);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribeNamingTasks?.();
|
||||
};
|
||||
}, [manifest.projectId, projectPath]);
|
||||
}, [manifest.projectId, projectPath, refreshProjectNamingTasks]);
|
||||
|
||||
/**
|
||||
* 面板打开时补一次快照读。
|
||||
*
|
||||
* 这是事件之外的兜底:订阅尚未就绪、窗口刚恢复、或用户就是想确认进度时,打开面板就能看到
|
||||
* 账本里的真实状态,而不是永久停在「命名中」。
|
||||
*
|
||||
* **只在列表里确实存在一条在途命名行时才读**:没有命名任务的项目(绝大多数)不该因为打开
|
||||
* 面板就多打一次账本 IPC——那会改变「轮询计数」这类外部可观测行为(既有生成任务用例正是
|
||||
* 按轮询次数推进 mock 的)。依赖的是布尔而不是行数组:读取结果只会让它在 true→false 之间
|
||||
* 最多翻转一次,不会自激。
|
||||
*/
|
||||
const hasInFlightProjectNamingTask = projectNamingTasks.some(
|
||||
(task) => task.status !== 'completed' && task.status !== 'failed',
|
||||
);
|
||||
useEffect(() => {
|
||||
if (
|
||||
!resourceAssetGenerationTasksPanelOpen ||
|
||||
!hasInFlightProjectNamingTask
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
void refreshProjectNamingTasks();
|
||||
return undefined;
|
||||
}, [
|
||||
hasInFlightProjectNamingTask,
|
||||
resourceAssetGenerationTasksPanelOpen,
|
||||
refreshProjectNamingTasks,
|
||||
]);
|
||||
|
||||
/**
|
||||
* 「生成任务」面板里点一条已完成任务:复用既有聚焦链定位到它的素材卡。
|
||||
@@ -11254,6 +11365,9 @@ export default function ProjectDevelopmentView({
|
||||
!task.restored ||
|
||||
!resourceCanvasResourceEditTaskIsTerminal(task),
|
||||
)}
|
||||
namingTasks={projectNamingTasks.filter(
|
||||
(task) => task.projectId === manifest.projectId,
|
||||
)}
|
||||
open={resourceAssetGenerationTasksPanelOpen}
|
||||
onToggleOpen={() =>
|
||||
setResourceAssetGenerationTasksPanelOpen((current) => !current)
|
||||
|
||||
@@ -1681,6 +1681,20 @@ export function registerHomeProjectCreationTests() {
|
||||
});
|
||||
return '角色参考游戏';
|
||||
}
|
||||
if (command === 'rename_local_game_project_if_unchanged') {
|
||||
// 建项时用的是宿主兜底名;后台命名回来后按「项目 ID + 兜底名」条件改名。
|
||||
expect(args).toEqual({
|
||||
projectPath: automaticProjectPath,
|
||||
expectedProjectId: manifest.projectId,
|
||||
expectedName: manifest.name,
|
||||
name: '角色参考游戏',
|
||||
});
|
||||
return {
|
||||
renamed: true,
|
||||
manifest: { ...manifest, name: '角色参考游戏' },
|
||||
revision: 2,
|
||||
};
|
||||
}
|
||||
if (command === 'enqueue_direct_codex_turn') {
|
||||
chatHarness.completeDirectThreadTurn({
|
||||
turnId: String(args?.clientTurnId ?? ''),
|
||||
@@ -1720,10 +1734,15 @@ export function registerHomeProjectCreationTests() {
|
||||
fireEvent.click(screen.getByRole('button', { name: '开启创作' }));
|
||||
|
||||
expect(await screen.findByLabelText('项目开发工作台')).not.toBeNull();
|
||||
// 建项不再等 AI 命名:名称留空由宿主兜底,命名结果在后台条件改名。
|
||||
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
|
||||
name: '角色参考游戏',
|
||||
name: null,
|
||||
planning: false,
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'rename_local_game_project_if_unchanged',
|
||||
expect.objectContaining({ name: '角色参考游戏' }),
|
||||
);
|
||||
expect(invoke).toHaveBeenCalledWith('upload_local_asset', {
|
||||
projectPath: automaticProjectPath,
|
||||
fileName: '角色参考.png',
|
||||
@@ -1793,6 +1812,428 @@ export function registerHomeProjectCreationTests() {
|
||||
).toEqual([{ type: 'input_text', text: '再补一句玩法' }]);
|
||||
});
|
||||
|
||||
it('syncs the workbench title and recent project list with the AI name after creation', async () => {
|
||||
const projectPath = '/tmp/home-async-named-project';
|
||||
const fallbackManifest = createGameCreationAppManifest(
|
||||
'home-async-named-project',
|
||||
'GameAgent 项目 1a2b3c4d',
|
||||
);
|
||||
const renamedManifest = { ...fallbackManifest, name: '星轨夜航' };
|
||||
let renamed = false;
|
||||
// 「生成任务」账本里的那条「AI 项目命名」:入队/推进由首页链路写,这里按同一份状态回放给工作台。
|
||||
let namingLedgerStatus = 'running';
|
||||
let namingLedgerPhase = '正在等待 AI 命名结果。';
|
||||
const namingLedger = () => [
|
||||
{
|
||||
taskId: `project-naming:${fallbackManifest.projectId}`,
|
||||
projectId: fallbackManifest.projectId,
|
||||
taskType: 'project-naming',
|
||||
kind: null,
|
||||
assetName: 'AI 项目命名',
|
||||
status: namingLedgerStatus,
|
||||
phaseDetail: namingLedgerPhase,
|
||||
createdAtMillis: 1,
|
||||
startedAtMillis: 2,
|
||||
finishedAtMillis: null,
|
||||
assetId: null,
|
||||
error: null,
|
||||
},
|
||||
];
|
||||
const namingTaskChangedHandlers = new Set<
|
||||
(event: { payload: unknown }) => void
|
||||
>();
|
||||
// 命名闸门手控:先证明"命名还没回来时已经进了工作区",再放行命名验证同步。
|
||||
let resolveNaming!: (name: string) => void;
|
||||
const namingGate = new Promise<string>((resolve) => {
|
||||
resolveNaming = resolve;
|
||||
});
|
||||
const chatHarness = createProjectChatRuntimeHarness({
|
||||
projectPath,
|
||||
initialSessionExists: false,
|
||||
});
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'create_automatic_local_game_project') {
|
||||
return {
|
||||
projectPath,
|
||||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||||
manifest: fallbackManifest,
|
||||
};
|
||||
}
|
||||
// 命名与建项并行:建项拿不到名字,AI 结果只走后台条件改名。
|
||||
if (command === 'suggest_automatic_project_name') return namingGate;
|
||||
if (command === 'enqueue_local_project_naming_task') {
|
||||
namingLedgerStatus = 'queued';
|
||||
namingLedgerPhase = '排队中。';
|
||||
return namingLedger()[0];
|
||||
}
|
||||
if (command === 'update_local_project_naming_task') {
|
||||
namingLedgerStatus = String(args?.status ?? namingLedgerStatus);
|
||||
namingLedgerPhase = String(args?.phaseDetail ?? namingLedgerPhase);
|
||||
return namingLedger()[0];
|
||||
}
|
||||
if (command === 'list_local_project_asset_generations') {
|
||||
return namingLedger();
|
||||
}
|
||||
if (command === 'rename_local_game_project_if_unchanged') {
|
||||
expect(args).toEqual({
|
||||
projectPath,
|
||||
expectedProjectId: fallbackManifest.projectId,
|
||||
expectedName: fallbackManifest.name,
|
||||
name: '星轨夜航',
|
||||
});
|
||||
renamed = true;
|
||||
return { renamed: true, manifest: renamedManifest, revision: 2 };
|
||||
}
|
||||
if (command === 'get_local_game_manifest') {
|
||||
return renamed ? renamedManifest : fallbackManifest;
|
||||
}
|
||||
if (command === 'inspect_local_project_directory') {
|
||||
return {
|
||||
projectPath: String(args?.projectPath ?? ''),
|
||||
exists: true,
|
||||
isDirectory: true,
|
||||
isGameCreatorProject: true,
|
||||
isCocosProject: false,
|
||||
godotProjectRoot: null,
|
||||
// 最近项目行的名字就来自这次重检;改名前是兜底名,改名后是 AI 名。
|
||||
projectName: renamed ? '星轨夜航' : fallbackManifest.name,
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
};
|
||||
}
|
||||
if (command === 'enqueue_direct_codex_turn') {
|
||||
chatHarness.completeDirectThreadTurn({
|
||||
turnId: String(args?.clientTurnId ?? ''),
|
||||
prompt: '做一个星轨游戏',
|
||||
reply: '开工。',
|
||||
});
|
||||
return '开工。';
|
||||
}
|
||||
return chatHarness.invoke(command, args);
|
||||
},
|
||||
);
|
||||
// 账本变更事件由工作台订阅:这里把它记下来,测试可以按「一次变更 → 一次快照读取」驱动刷新。
|
||||
const listen = async (
|
||||
eventName: string,
|
||||
handler: (event: { payload: unknown }) => void,
|
||||
) => {
|
||||
if (eventName === 'game-creator-asset-generation-task-changed') {
|
||||
namingTaskChangedHandlers.add(handler);
|
||||
return () => {
|
||||
namingTaskChangedHandlers.delete(handler);
|
||||
};
|
||||
}
|
||||
return chatHarness.listen(eventName, handler);
|
||||
};
|
||||
installTauriRuntime({
|
||||
core: { invoke },
|
||||
event: { listen },
|
||||
});
|
||||
window.localStorage.setItem(
|
||||
'genarrative-ai-game-creator.recent-workspaces.v1',
|
||||
JSON.stringify([projectPath]),
|
||||
);
|
||||
renderLauncherAt('/?launcher', 'home', true);
|
||||
|
||||
const promptInput = screen.getByLabelText('创作想法');
|
||||
nativeClipboardMock.text = '做一个星轨游戏';
|
||||
fireEvent.paste(promptInput);
|
||||
await waitFor(() => {
|
||||
expect(promptInput.textContent).toContain('做一个星轨游戏');
|
||||
});
|
||||
fireEvent.keyDown(promptInput, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(await screen.findByLabelText('项目开发工作台')).not.toBeNull();
|
||||
// 进工作区时命名还没回来:创建并不等命名结果,兜底名仍然生效。
|
||||
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
|
||||
name: null,
|
||||
planning: false,
|
||||
});
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'rename_local_game_project_if_unchanged',
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
// 「生成任务」侧栏里能看到这条在跑的「AI 项目命名」(与素材生成共用同一份账本与列表)。
|
||||
fireEvent.click(screen.getByLabelText('生成任务'));
|
||||
expect(await screen.findByText('AI 项目命名')).not.toBeNull();
|
||||
expect(
|
||||
within(
|
||||
document.querySelector(
|
||||
'[data-task-source="project-naming"]',
|
||||
) as HTMLElement,
|
||||
).getByText('命名中'),
|
||||
).not.toBeNull();
|
||||
|
||||
// 放行命名:AI 名称落定后走条件改名(工作台标题取自项目上下文,见 hook 用例)。
|
||||
await act(async () => {
|
||||
resolveNaming('星轨夜航');
|
||||
await namingGate;
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'rename_local_game_project_if_unchanged',
|
||||
expect.objectContaining({ name: '星轨夜航' }),
|
||||
);
|
||||
});
|
||||
|
||||
// 账本变更事件 → 一次受控快照读取:终态结论出现在同一行上(不用重开项目)。
|
||||
await act(async () => {
|
||||
for (const handler of namingTaskChangedHandlers) {
|
||||
handler({
|
||||
payload: {
|
||||
projectPath,
|
||||
taskId: `project-naming:${fallbackManifest.projectId}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
expect(
|
||||
await screen.findByText('已应用 AI 名称「星轨夜航」。'),
|
||||
).not.toBeNull();
|
||||
|
||||
// 最近项目行按目录重检读 manifest:列表最终也显示 AI 名称。
|
||||
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
|
||||
expect(await screen.findByText('星轨夜航')).not.toBeNull();
|
||||
expect(screen.queryByText('GameAgent 项目 1a2b3c4d')).toBeNull();
|
||||
});
|
||||
|
||||
it('recovers the naming row when the terminal event lands before the subscription is ready', async () => {
|
||||
const projectPath = '/tmp/home-naming-event-race';
|
||||
const fallbackManifest = createGameCreationAppManifest(
|
||||
'home-naming-event-race',
|
||||
'GameAgent 项目 5f6a7b8c',
|
||||
);
|
||||
const renamedManifest = { ...fallbackManifest, name: '星轨夜航' };
|
||||
const ledgerRecord = (status: string, phaseDetail: string) => [
|
||||
{
|
||||
taskId: `project-naming:${fallbackManifest.projectId}`,
|
||||
projectId: fallbackManifest.projectId,
|
||||
taskType: 'project-naming',
|
||||
kind: null,
|
||||
assetName: 'AI 项目命名',
|
||||
status,
|
||||
phaseDetail,
|
||||
createdAtMillis: 1,
|
||||
startedAtMillis: 2,
|
||||
finishedAtMillis: status === 'completed' ? 3 : null,
|
||||
assetId: null,
|
||||
error: null,
|
||||
},
|
||||
];
|
||||
let namingSettled = false;
|
||||
// 订阅注册被闸门卡住:这一段里落地的事件在真实世界里没有订阅者——就是丢了。
|
||||
let releaseSubscription: (() => void) | null = null;
|
||||
let subscriptionReady = false;
|
||||
let droppedWhileUnsubscribed = 0;
|
||||
const chatHarness = createProjectChatRuntimeHarness({
|
||||
projectPath,
|
||||
initialSessionExists: false,
|
||||
});
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'create_automatic_local_game_project') {
|
||||
return {
|
||||
projectPath,
|
||||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||||
manifest: fallbackManifest,
|
||||
};
|
||||
}
|
||||
if (command === 'suggest_automatic_project_name') return '星轨夜航';
|
||||
if (command === 'rename_local_game_project_if_unchanged') {
|
||||
namingSettled = true;
|
||||
return { renamed: true, manifest: renamedManifest, revision: 2 };
|
||||
}
|
||||
if (command === 'get_local_game_manifest') {
|
||||
return namingSettled ? renamedManifest : fallbackManifest;
|
||||
}
|
||||
if (command === 'list_local_project_asset_generations') {
|
||||
// 进项目那一次读拿到的是「老化快照」(响应在命名落定之前就算好了);
|
||||
// 订阅就绪之后的读才是账本真相。
|
||||
return namingSettled && subscriptionReady
|
||||
? ledgerRecord('completed', '已应用 AI 名称「星轨夜航」。')
|
||||
: ledgerRecord('running', '正在等待 AI 命名结果。');
|
||||
}
|
||||
if (command === 'inspect_local_project_directory') {
|
||||
return {
|
||||
projectPath: String(args?.projectPath ?? ''),
|
||||
exists: true,
|
||||
isDirectory: true,
|
||||
isGameCreatorProject: true,
|
||||
isCocosProject: false,
|
||||
godotProjectRoot: null,
|
||||
projectName: renamedManifest.name,
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
};
|
||||
}
|
||||
if (command === 'enqueue_direct_codex_turn') {
|
||||
chatHarness.completeDirectThreadTurn({
|
||||
turnId: String(args?.clientTurnId ?? ''),
|
||||
prompt: '做一个星轨游戏',
|
||||
reply: '开工。',
|
||||
});
|
||||
return '开工。';
|
||||
}
|
||||
return chatHarness.invoke(command, args);
|
||||
},
|
||||
);
|
||||
const listen = (
|
||||
eventName: string,
|
||||
handler: (event: { payload: unknown }) => void,
|
||||
) => {
|
||||
if (eventName !== 'game-creator-asset-generation-task-changed') {
|
||||
return chatHarness.listen(eventName, handler);
|
||||
}
|
||||
return new Promise<() => void>((resolve) => {
|
||||
releaseSubscription = () => {
|
||||
subscriptionReady = true;
|
||||
resolve(() => {});
|
||||
};
|
||||
});
|
||||
};
|
||||
installTauriRuntime({ core: { invoke }, event: { listen } });
|
||||
renderLauncherAt('/?launcher', 'home', true);
|
||||
|
||||
const promptInput = screen.getByLabelText('创作想法');
|
||||
nativeClipboardMock.text = '做一个星轨游戏';
|
||||
fireEvent.paste(promptInput);
|
||||
await waitFor(() => {
|
||||
expect(promptInput.textContent).toContain('做一个星轨游戏');
|
||||
});
|
||||
fireEvent.keyDown(promptInput, { key: 'Enter', code: 'Enter' });
|
||||
await screen.findByLabelText('项目开发工作台');
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'rename_local_game_project_if_unchanged',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
// 次序:读返回(陈旧 running)→ 终态事件(此时没有订阅者,丢掉)→ 订阅才注册。
|
||||
droppedWhileUnsubscribed += 1;
|
||||
await act(async () => {
|
||||
releaseSubscription?.();
|
||||
});
|
||||
|
||||
// 命名行必须到终态:右上角开关的在途计数归零(旧实现会永久停在「生成任务 · 1」)。
|
||||
await waitFor(() => {
|
||||
expect(droppedWhileUnsubscribed).toBe(1);
|
||||
expect(screen.getByLabelText('生成任务').textContent).toBe('生成任务');
|
||||
});
|
||||
});
|
||||
|
||||
it('re-reads the naming ledger when the generation tasks panel opens', async () => {
|
||||
const projectPath = '/tmp/home-naming-panel-reread';
|
||||
const fallbackManifest = createGameCreationAppManifest(
|
||||
'home-naming-panel-reread',
|
||||
'GameAgent 项目 6a7b8c9d',
|
||||
);
|
||||
const renamedManifest = { ...fallbackManifest, name: '星轨夜航' };
|
||||
const ledgerRecord = (status: string, phaseDetail: string) => [
|
||||
{
|
||||
taskId: `project-naming:${fallbackManifest.projectId}`,
|
||||
projectId: fallbackManifest.projectId,
|
||||
taskType: 'project-naming',
|
||||
kind: null,
|
||||
assetName: 'AI 项目命名',
|
||||
status,
|
||||
phaseDetail,
|
||||
createdAtMillis: 1,
|
||||
startedAtMillis: 2,
|
||||
finishedAtMillis: status === 'completed' ? 3 : null,
|
||||
assetId: null,
|
||||
error: null,
|
||||
},
|
||||
];
|
||||
let ledgerSettled = false;
|
||||
const chatHarness = createProjectChatRuntimeHarness({
|
||||
projectPath,
|
||||
initialSessionExists: false,
|
||||
});
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'create_automatic_local_game_project') {
|
||||
return {
|
||||
projectPath,
|
||||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||||
manifest: fallbackManifest,
|
||||
};
|
||||
}
|
||||
if (command === 'suggest_automatic_project_name') return '星轨夜航';
|
||||
if (command === 'rename_local_game_project_if_unchanged') {
|
||||
return { renamed: true, manifest: renamedManifest, revision: 2 };
|
||||
}
|
||||
if (command === 'get_local_game_manifest') return renamedManifest;
|
||||
if (command === 'list_local_project_asset_generations') {
|
||||
// 账本真相由 `ledgerSettled` 控制:打开面板之前它是陈旧快照,
|
||||
// 之后会重读一次拿到终态(本用例里订阅直接失败,只剩这条兜底)。
|
||||
return ledgerSettled
|
||||
? ledgerRecord('completed', '已应用 AI 名称「星轨夜航」。')
|
||||
: ledgerRecord('running', '正在等待 AI 命名结果。');
|
||||
}
|
||||
if (command === 'inspect_local_project_directory') {
|
||||
return {
|
||||
projectPath: String(args?.projectPath ?? ''),
|
||||
exists: true,
|
||||
isDirectory: true,
|
||||
isGameCreatorProject: true,
|
||||
isCocosProject: false,
|
||||
godotProjectRoot: null,
|
||||
projectName: renamedManifest.name,
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
};
|
||||
}
|
||||
if (command === 'enqueue_direct_codex_turn') {
|
||||
chatHarness.completeDirectThreadTurn({
|
||||
turnId: String(args?.clientTurnId ?? ''),
|
||||
prompt: '做一个星轨游戏',
|
||||
reply: '开工。',
|
||||
});
|
||||
return '开工。';
|
||||
}
|
||||
return chatHarness.invoke(command, args);
|
||||
},
|
||||
);
|
||||
const listen = (
|
||||
eventName: string,
|
||||
handler: (event: { payload: unknown }) => void,
|
||||
) => {
|
||||
if (eventName === 'game-creator-asset-generation-task-changed') {
|
||||
// 订阅失败:这条链路只剩「面板打开时补读」这一个兜底。
|
||||
return Promise.reject(new Error('subscription unavailable'));
|
||||
}
|
||||
return chatHarness.listen(eventName, handler);
|
||||
};
|
||||
installTauriRuntime({ core: { invoke }, event: { listen } });
|
||||
renderLauncherAt('/?launcher', 'home', true);
|
||||
|
||||
const promptInput = screen.getByLabelText('创作想法');
|
||||
nativeClipboardMock.text = '做一个星轨游戏';
|
||||
fireEvent.paste(promptInput);
|
||||
await waitFor(() => {
|
||||
expect(promptInput.textContent).toContain('做一个星轨游戏');
|
||||
});
|
||||
fireEvent.keyDown(promptInput, { key: 'Enter', code: 'Enter' });
|
||||
await screen.findByLabelText('项目开发工作台');
|
||||
// 陈旧快照下命名行还在跑:开关上带着在途计数。
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('生成任务').textContent).toContain(
|
||||
'生成任务 · 1',
|
||||
);
|
||||
});
|
||||
// 账本随后落定,但(订阅失败的场景里)这条链路收不到任何事件。
|
||||
ledgerSettled = true;
|
||||
|
||||
fireEvent.click(screen.getByLabelText('生成任务'));
|
||||
// 打开面板补一次读:行必须变成账本里的终态,而不是永远停在「命名中」。
|
||||
expect(
|
||||
await screen.findByText('已应用 AI 名称「星轨夜航」。'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the home composer out of chat mode while automatic project creation is pending', async () => {
|
||||
let rejectAutomaticProject: ((error: Error) => void) | null = null;
|
||||
const automaticProject = new Promise<never>((_resolve, reject) => {
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, renderHook } from '@testing-library/react';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type { HomeProjectCreationController } from '../src/features/app-shell/useHomeProjectCreation';
|
||||
import { useHomeProjectCreation } from '../src/features/app-shell/useHomeProjectCreation';
|
||||
import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake';
|
||||
|
||||
const PROJECT_PATH = 'C:/test/async-naming-game';
|
||||
const PROJECT_ID = 'gameagent-1a2b3c4d';
|
||||
/** 宿主在建项时给的兜底名(`GameAgent 项目 <8 位短 id>`)。 */
|
||||
const FALLBACK_NAME = 'GameAgent 项目 1a2b3c4d';
|
||||
const SUGGESTED_NAME = '星河远征';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetTauriRuntime();
|
||||
});
|
||||
|
||||
/** 手控闸门:测试自己决定命名请求何时返回,不靠 sleep 赌时序。 */
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function mount(options: {
|
||||
naming: Promise<string | null> | string;
|
||||
/** 条件改名命令的返回;缺省表示调用它就是测试失败。 */
|
||||
conditionalRename?: () => Promise<unknown>;
|
||||
/** 其它命令的按名处理;用于把某一步(如进项目时的预览核验)卡在手控闸门上。 */
|
||||
extraCommands?: Record<
|
||||
string,
|
||||
(args?: Record<string, unknown>) => Promise<unknown>
|
||||
>;
|
||||
}) {
|
||||
const order: string[] = [];
|
||||
const namingTaskCalls: Array<{
|
||||
command: string;
|
||||
args: Record<string, unknown>;
|
||||
}> = [];
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'capture_analytics_context') return {};
|
||||
if (command === 'preflight_web_game_creation') return { status: 'ready' };
|
||||
if (
|
||||
command === 'enqueue_local_project_naming_task' ||
|
||||
command === 'update_local_project_naming_task'
|
||||
) {
|
||||
namingTaskCalls.push({ command, args: args ?? {} });
|
||||
return null;
|
||||
}
|
||||
if (command === 'suggest_automatic_project_name') {
|
||||
order.push(command);
|
||||
return options.naming;
|
||||
}
|
||||
if (command === 'create_automatic_local_game_project') {
|
||||
order.push(command);
|
||||
return {
|
||||
projectPath: PROJECT_PATH,
|
||||
manifestPath: `${PROJECT_PATH}/.agent/manifest.json`,
|
||||
manifest: createGameCreationAppManifest(PROJECT_ID, FALLBACK_NAME),
|
||||
};
|
||||
}
|
||||
if (command === 'get_local_game_project_revision') return { revision: 1 };
|
||||
if (command === 'rename_local_game_project_if_unchanged') {
|
||||
if (!options.conditionalRename) {
|
||||
throw new Error('未预期的条件改名调用');
|
||||
}
|
||||
return options.conditionalRename();
|
||||
}
|
||||
const extra = options.extraCommands?.[command];
|
||||
if (extra) {
|
||||
return extra();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
const refreshRecentWorkspace = vi.fn(async () => {});
|
||||
installTauriRuntime({ core: { invoke } });
|
||||
const hook = renderHook(() =>
|
||||
useHomeProjectCreation({
|
||||
setStatus: vi.fn(),
|
||||
showTransientNotice: vi.fn(),
|
||||
setLauncherView: vi.fn(),
|
||||
rememberRecentWorkspace: vi.fn(),
|
||||
refreshRecentWorkspace,
|
||||
}),
|
||||
);
|
||||
return { ...hook, invoke, order, namingTaskCalls, refreshRecentWorkspace };
|
||||
}
|
||||
|
||||
/**
|
||||
* 「AI 项目命名」任务在账本里的推进序列:`命令:状态`。
|
||||
*
|
||||
* 三个断言点共用同一份口径:入队(无 status)→ running → 终态。
|
||||
*/
|
||||
function namingTaskStatusTrace(
|
||||
calls: ReadonlyArray<{ command: string; args: Record<string, unknown> }>,
|
||||
): string[] {
|
||||
return calls.map(
|
||||
(call) =>
|
||||
`${call.command.replace('_local_project_naming_task', '')}:${
|
||||
typeof call.args.status === 'string' ? call.args.status : ''
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
function createAutomatically(
|
||||
result: { current: HomeProjectCreationController },
|
||||
startMode: 'direct-build' | 'planning' = 'direct-build',
|
||||
) {
|
||||
return result.current.createHomeDraftAutomatically(
|
||||
{ creationType: 'game', prompt: '做一个小游戏', attachments: [] },
|
||||
startMode,
|
||||
);
|
||||
}
|
||||
|
||||
it('enters the workspace even though the AI naming request never returns', async () => {
|
||||
const naming = deferred<string | null>();
|
||||
const { result, invoke, order, namingTaskCalls } = mount({
|
||||
naming: naming.promise,
|
||||
});
|
||||
|
||||
let creation!: Promise<string>;
|
||||
act(() => {
|
||||
creation = createAutomatically(result);
|
||||
});
|
||||
let status = '';
|
||||
// 命名闸门一直关着。若建项链路还在等命名,这里会一直挂到用例超时——
|
||||
// 这正是"`create_automatic_local_game_project` 不再等待命名"的判定方式。
|
||||
await act(async () => {
|
||||
status = await creation;
|
||||
});
|
||||
|
||||
expect(status).toBe('已创建工作区并进入项目开发');
|
||||
expect(result.current.projectPath).toBe(PROJECT_PATH);
|
||||
expect(result.current.currentProjectContext?.projectName).toBe(FALLBACK_NAME);
|
||||
// 命名请求先打出、建项随后并行,两者都不等对方。
|
||||
expect(order).toEqual([
|
||||
'suggest_automatic_project_name',
|
||||
'create_automatic_local_game_project',
|
||||
]);
|
||||
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
|
||||
name: null,
|
||||
planning: false,
|
||||
});
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'rename_local_game_project_if_unchanged',
|
||||
expect.anything(),
|
||||
);
|
||||
// 命名还没回来:任务已经入队并标成「命名中」,用户能在生成任务里看到它在跑。
|
||||
expect(namingTaskStatusTrace(namingTaskCalls)).toEqual([
|
||||
'enqueue:',
|
||||
'update:running',
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
naming.resolve(null);
|
||||
await naming.promise;
|
||||
});
|
||||
});
|
||||
|
||||
it('applies a valid AI name after it arrives and refreshes the recent row', async () => {
|
||||
const naming = deferred<string | null>();
|
||||
const { result, invoke, namingTaskCalls, refreshRecentWorkspace } = mount({
|
||||
naming: naming.promise,
|
||||
conditionalRename: async () => ({
|
||||
renamed: true,
|
||||
manifest: createGameCreationAppManifest(PROJECT_ID, SUGGESTED_NAME),
|
||||
revision: 7,
|
||||
}),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await createAutomatically(result);
|
||||
});
|
||||
expect(result.current.currentProjectContext?.projectName).toBe(FALLBACK_NAME);
|
||||
|
||||
await act(async () => {
|
||||
naming.resolve(` ${SUGGESTED_NAME} `);
|
||||
await naming.promise;
|
||||
});
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'rename_local_game_project_if_unchanged',
|
||||
{
|
||||
projectPath: PROJECT_PATH,
|
||||
// 条件改名的判据由前端原样转述:项目 ID + 这次创建的兜底名称。
|
||||
expectedProjectId: PROJECT_ID,
|
||||
expectedName: FALLBACK_NAME,
|
||||
name: SUGGESTED_NAME,
|
||||
},
|
||||
);
|
||||
expect(result.current.currentProjectContext?.projectName).toBe(
|
||||
SUGGESTED_NAME,
|
||||
);
|
||||
expect(result.current.currentProjectContext?.manifest.name).toBe(
|
||||
SUGGESTED_NAME,
|
||||
);
|
||||
expect(result.current.currentProjectContext?.projectRevision).toBe(7);
|
||||
expect(refreshRecentWorkspace).toHaveBeenCalledWith(PROJECT_PATH);
|
||||
// 「生成任务」列表里那条「AI 项目命名」按入队 → 命名中 → 已完成推进,结论可自解释。
|
||||
expect(namingTaskStatusTrace(namingTaskCalls)).toEqual([
|
||||
'enqueue:',
|
||||
'update:running',
|
||||
'update:completed',
|
||||
]);
|
||||
expect(namingTaskCalls.map((call) => call.args.taskId)).toEqual([
|
||||
`project-naming:${PROJECT_ID}`,
|
||||
`project-naming:${PROJECT_ID}`,
|
||||
`project-naming:${PROJECT_ID}`,
|
||||
]);
|
||||
expect(String(namingTaskCalls.at(-1)?.args.phaseDetail)).toContain(
|
||||
SUGGESTED_NAME,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the AI name when the naming result lands before the project is entered', async () => {
|
||||
const renamedManifest = createGameCreationAppManifest(
|
||||
PROJECT_ID,
|
||||
SUGGESTED_NAME,
|
||||
);
|
||||
// 进项目时的会话预览核验卡住:AI 改名先落定,进项目还没写完上下文。
|
||||
let releasePreview!: () => void;
|
||||
const previewGate = new Promise<void>((resolve) => {
|
||||
releasePreview = resolve;
|
||||
});
|
||||
let markRenameInvoked!: () => void;
|
||||
const renameInvoked = new Promise<void>((resolve) => {
|
||||
markRenameInvoked = resolve;
|
||||
});
|
||||
const { result } = mount({
|
||||
// 命名瞬时返回(最快的模型情况),建项与改名之间没有等待。
|
||||
naming: SUGGESTED_NAME,
|
||||
conditionalRename: async () => {
|
||||
markRenameInvoked();
|
||||
return { renamed: true, manifest: renamedManifest, revision: 1 };
|
||||
},
|
||||
extraCommands: {
|
||||
get_local_game_preview_status: async () => {
|
||||
await previewGate;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let creation!: Promise<string>;
|
||||
act(() => {
|
||||
creation = createAutomatically(result);
|
||||
});
|
||||
await act(async () => {
|
||||
await renameInvoked;
|
||||
});
|
||||
// 此刻改名已经落定,但工作区上下文还没建立。
|
||||
expect(result.current.currentProjectContext).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
releasePreview();
|
||||
await creation;
|
||||
});
|
||||
|
||||
// 进项目写下的兜底上下文之后,后台改名结果必须仍然生效,不能被覆盖回兜底名。
|
||||
expect(result.current.currentProjectContext?.projectName).toBe(
|
||||
SUGGESTED_NAME,
|
||||
);
|
||||
expect(result.current.currentProjectContext?.manifest.name).toBe(
|
||||
SUGGESTED_NAME,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['', ' ', null])(
|
||||
'keeps the fallback name for a non-usable AI response (%s)',
|
||||
async (response) => {
|
||||
const naming = deferred<string | null>();
|
||||
const { result, invoke, namingTaskCalls } = mount({
|
||||
naming: naming.promise,
|
||||
conditionalRename: async () => {
|
||||
throw new Error('空/非法 AI 名称不应触发改名');
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await createAutomatically(result);
|
||||
});
|
||||
await act(async () => {
|
||||
naming.resolve(response);
|
||||
await naming.promise;
|
||||
});
|
||||
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'rename_local_game_project_if_unchanged',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(result.current.currentProjectContext?.projectName).toBe(
|
||||
FALLBACK_NAME,
|
||||
);
|
||||
// 失败/超时/非法内容:任务按 failed 收口,兜底名保留。
|
||||
expect(namingTaskStatusTrace(namingTaskCalls)).toEqual([
|
||||
'enqueue:',
|
||||
'update:running',
|
||||
'update:failed',
|
||||
]);
|
||||
expect(String(namingTaskCalls.at(-1)?.args.phaseDetail)).toContain(
|
||||
'保留兜底名',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('does not overwrite a name the user already changed', async () => {
|
||||
const naming = deferred<string | null>();
|
||||
const { result, namingTaskCalls } = mount({
|
||||
naming: naming.promise,
|
||||
// 用户已经手动改过名:宿主按「当前名称不再是兜底名」整体跳过。
|
||||
conditionalRename: async () => ({
|
||||
renamed: false,
|
||||
manifest: createGameCreationAppManifest(PROJECT_ID, '我自己起的名字'),
|
||||
revision: 9,
|
||||
}),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await createAutomatically(result);
|
||||
});
|
||||
await act(async () => {
|
||||
naming.resolve(SUGGESTED_NAME);
|
||||
await naming.promise;
|
||||
});
|
||||
|
||||
expect(result.current.currentProjectContext?.projectName).toBe(FALLBACK_NAME);
|
||||
// 跳过不是失败:任务按 completed 收口,并且结论自己说清「为什么没改名」。
|
||||
expect(namingTaskStatusTrace(namingTaskCalls)).toEqual([
|
||||
'enqueue:',
|
||||
'update:running',
|
||||
'update:completed',
|
||||
]);
|
||||
expect(String(namingTaskCalls.at(-1)?.args.phaseDetail)).toContain(
|
||||
'已跳过 AI 命名',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the created workspace when the conditional rename fails', async () => {
|
||||
const naming = deferred<string | null>();
|
||||
const { result, namingTaskCalls } = mount({
|
||||
naming: naming.promise,
|
||||
conditionalRename: async () => {
|
||||
throw new Error('项目正在被其他写操作占用');
|
||||
},
|
||||
});
|
||||
|
||||
let status = '';
|
||||
await act(async () => {
|
||||
status = await createAutomatically(result);
|
||||
});
|
||||
await act(async () => {
|
||||
naming.resolve(SUGGESTED_NAME);
|
||||
await naming.promise;
|
||||
});
|
||||
|
||||
expect(status).toBe('已创建工作区并进入项目开发');
|
||||
expect(result.current.projectPath).toBe(PROJECT_PATH);
|
||||
expect(result.current.currentProjectContext?.projectName).toBe(FALLBACK_NAME);
|
||||
expect(namingTaskStatusTrace(namingTaskCalls)).toEqual([
|
||||
'enqueue:',
|
||||
'update:running',
|
||||
'update:failed',
|
||||
]);
|
||||
expect(String(namingTaskCalls.at(-1)?.args.phaseDetail)).toContain(
|
||||
'已保留兜底名',
|
||||
);
|
||||
});
|
||||
|
||||
it('never starts automatic naming for the 做方案 flow', async () => {
|
||||
const naming = deferred<string | null>();
|
||||
const { result, invoke } = mount({ naming: naming.promise });
|
||||
|
||||
await act(async () => {
|
||||
await createAutomatically(result, 'planning');
|
||||
});
|
||||
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'suggest_automatic_project_name',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', {
|
||||
name: null,
|
||||
planning: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
naming.resolve(null);
|
||||
await naming.promise;
|
||||
});
|
||||
});
|
||||
|
||||
it('does not hijack the project the user already moved to when the AI name lands', async () => {
|
||||
const naming = deferred<string | null>();
|
||||
const { result, refreshRecentWorkspace } = mount({
|
||||
naming: naming.promise,
|
||||
conditionalRename: async () => ({
|
||||
renamed: true,
|
||||
manifest: createGameCreationAppManifest(PROJECT_ID, SUGGESTED_NAME),
|
||||
revision: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await createAutomatically(result);
|
||||
});
|
||||
|
||||
// 用户已经切到另一个工作区(看门狗解围后打开别项目走的是同一条「上下文已被别人接管」判据)。
|
||||
await act(async () => {
|
||||
result.current.setCurrentProjectContext({
|
||||
projectPath: 'C:/test/other-project',
|
||||
projectName: '另一个项目',
|
||||
projectKind: 'web',
|
||||
manifest: createGameCreationAppManifest('other-project', '另一个项目'),
|
||||
projectRevision: 3,
|
||||
creationType: null,
|
||||
startMode: null,
|
||||
initialPrompt: '',
|
||||
attachments: [],
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
createdAt: 1,
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
naming.resolve(SUGGESTED_NAME);
|
||||
await naming.promise;
|
||||
});
|
||||
|
||||
// 后台改名不能把用户当前所在的项目上下文换成刚建好的那个工作区。
|
||||
expect(result.current.currentProjectContext?.projectPath).toBe(
|
||||
'C:/test/other-project',
|
||||
);
|
||||
expect(result.current.currentProjectContext?.projectName).toBe('另一个项目');
|
||||
// 但刚刚建好的工作区确实改名了:最近项目行照旧按目录重检。
|
||||
expect(refreshRecentWorkspace).toHaveBeenCalledWith(PROJECT_PATH);
|
||||
});
|
||||
|
||||
it('does not write the project context after the launcher unmounted', async () => {
|
||||
const naming = deferred<string | null>();
|
||||
const { result, unmount, refreshRecentWorkspace } = mount({
|
||||
naming: naming.promise,
|
||||
conditionalRename: async () => ({
|
||||
renamed: true,
|
||||
manifest: createGameCreationAppManifest(PROJECT_ID, SUGGESTED_NAME),
|
||||
revision: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await createAutomatically(result);
|
||||
});
|
||||
unmount();
|
||||
await act(async () => {
|
||||
naming.resolve(SUGGESTED_NAME);
|
||||
await naming.promise;
|
||||
});
|
||||
|
||||
// 卸载后迟到的命名结果不再写上下文,也就不该走到上下文写入后面的最近项目重检。
|
||||
// (卸载后 `result.current` 冻结在最后一次渲染,只有这条副作用能观测到越界写入。)
|
||||
expect(refreshRecentWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not write the project context after the window was hidden', async () => {
|
||||
const naming = deferred<string | null>();
|
||||
const { result, refreshRecentWorkspace } = mount({
|
||||
naming: naming.promise,
|
||||
conditionalRename: async () => ({
|
||||
renamed: true,
|
||||
manifest: createGameCreationAppManifest(PROJECT_ID, SUGGESTED_NAME),
|
||||
revision: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await createAutomatically(result);
|
||||
});
|
||||
// `pagehide` 是壳自己的作废信号(关窗 / 切走),与卸载走同一枚 mounted + 代次守卫。
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('pagehide'));
|
||||
});
|
||||
await act(async () => {
|
||||
naming.resolve(SUGGESTED_NAME);
|
||||
await naming.promise;
|
||||
});
|
||||
|
||||
expect(result.current.currentProjectContext?.projectName).toBe(FALLBACK_NAME);
|
||||
expect(refreshRecentWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/** @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ResourceCanvasProjectNamingTask } from '../src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel';
|
||||
import { ResourceCanvasAssetGenerationTasksPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView';
|
||||
import { cleanup, render, screen, within } from './appSurface/harness';
|
||||
|
||||
/**
|
||||
* 「生成任务」侧栏里的「AI 项目命名」行。
|
||||
*
|
||||
* 它与素材生成共用同一份账本、同一个事件与同一套卡片样式,但有两条自己的口径:
|
||||
* 1. **展示名固定**为「AI 项目命名」:不渲染素材缩略图 / 提示词 / 派发与「定位到素材」;
|
||||
* 2. **结论只在终态渲染**:进行中由状态徽章表达(排队中 / 命名中),终态才显示后端给的结论
|
||||
* (已应用 AI 名称 / 已跳过 / 失败保留兜底名)。
|
||||
*/
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function namingTask(
|
||||
overrides: Partial<ResourceCanvasProjectNamingTask> = {},
|
||||
): ResourceCanvasProjectNamingTask {
|
||||
return {
|
||||
taskId: 'project-naming:local-project-1',
|
||||
projectId: 'local-project-1',
|
||||
status: 'running',
|
||||
phaseDetail: '正在等待 AI 命名结果。',
|
||||
createdAtMillis: 1000,
|
||||
startedAtMillis: 1200,
|
||||
finishedAtMillis: null,
|
||||
error: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPanel(tasks: readonly ResourceCanvasProjectNamingTask[]) {
|
||||
render(
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={[]}
|
||||
namingTasks={tasks}
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('生成任务侧栏里的 AI 项目命名行', () => {
|
||||
it('进行中只给状态徽章,不渲染结论、缩略图、提示词或定位动作', () => {
|
||||
renderPanel([namingTask()]);
|
||||
|
||||
const row = document.querySelector(
|
||||
'[data-task-source="project-naming"]',
|
||||
) as HTMLElement | null;
|
||||
expect(row).not.toBeNull();
|
||||
expect(within(row!).getByText('AI 项目命名')).not.toBeNull();
|
||||
expect(within(row!).getByText('命名中')).not.toBeNull();
|
||||
// 进行中不显示后端阶段文案;也没有素材任务的缩略图/提示词/派发按钮。
|
||||
expect(within(row!).queryByText('正在等待 AI 命名结果。')).toBeNull();
|
||||
expect(within(row!).queryByRole('button', { name: /定位素材/ })).toBeNull();
|
||||
expect(row!.querySelector('img')).toBeNull();
|
||||
});
|
||||
|
||||
it('终态渲染可自解释的结论(已应用 / 已跳过 / 失败保留兜底名)', () => {
|
||||
const { unmount } = render(
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={[]}
|
||||
namingTasks={[
|
||||
namingTask({
|
||||
status: 'completed',
|
||||
phaseDetail: '已应用 AI 名称「星河远征」。',
|
||||
finishedAtMillis: 2200,
|
||||
}),
|
||||
]}
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const appliedRow = document.querySelector(
|
||||
'[data-task-source="project-naming"]',
|
||||
) as HTMLElement | null;
|
||||
expect(within(appliedRow!).getByText('已完成')).not.toBeNull();
|
||||
expect(
|
||||
within(appliedRow!).getByText('已应用 AI 名称「星河远征」。'),
|
||||
).not.toBeNull();
|
||||
unmount();
|
||||
render(
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={[]}
|
||||
namingTasks={[
|
||||
namingTask({
|
||||
status: 'completed',
|
||||
phaseDetail:
|
||||
'当前名称已不是本次创建的兜底名(用户已手动改名),已跳过 AI 命名。',
|
||||
finishedAtMillis: 2200,
|
||||
}),
|
||||
]}
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const skippedRow = document.querySelector(
|
||||
'[data-task-source="project-naming"]',
|
||||
) as HTMLElement | null;
|
||||
expect(
|
||||
within(skippedRow!).getByText(
|
||||
'当前名称已不是本次创建的兜底名(用户已手动改名),已跳过 AI 命名。',
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('失败行显示失败徽章与原因,并计入在途计数', () => {
|
||||
const { unmount } = render(
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={[]}
|
||||
namingTasks={[
|
||||
namingTask({
|
||||
status: 'failed',
|
||||
phaseDetail: 'AI 命名请求失败或没有返回可用名称,已保留兜底名。',
|
||||
finishedAtMillis: 2200,
|
||||
error: '自动项目命名超时(超过 15 秒)',
|
||||
}),
|
||||
]}
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const row = document.querySelector(
|
||||
'[data-task-source="project-naming"]',
|
||||
) as HTMLElement | null;
|
||||
expect(within(row!).getByText('失败')).not.toBeNull();
|
||||
expect(
|
||||
within(row!).getByText(
|
||||
'AI 命名请求失败或没有返回可用名称,已保留兜底名。',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(within(row!).getByRole('alert').textContent).toContain(
|
||||
'自动项目命名超时',
|
||||
);
|
||||
unmount();
|
||||
|
||||
// 收起态:在途的命名任务要出现在右上角开关的计数里(与素材任务同一个计数)。
|
||||
render(
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={[]}
|
||||
namingTasks={[namingTask()]}
|
||||
open={false}
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText('生成任务').textContent).toContain(
|
||||
'生成任务 · 1',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,13 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-10-03 首页自动建项与 AI 项目命名解耦(Issue 599)
|
||||
|
||||
- 背景:首页「开启创作」原先串行执行「Web 预检 → `await suggest_automatic_project_name` → `create_automatic_local_game_project`」。项目名称不是创建工作区、导入附件或发起首轮创作的前置条件,命名请求(`AUTOMATIC_PROJECT_NAME_TIMEOUT_MS = 15s`,正常请求同样占时)却把用户按在「正在创建工作区」上。
|
||||
- 决策(建项与命名解耦):建项固定传 `name: null`,由宿主既有兜底名(`GameAgent 项目 <8 位短 id>` / `策划项目 <8 位短 id>`)落盘并立即进入项目;命名请求在建项前并行打出、结果交给后台任务。后台拿到合法名称后调用新增的 `rename_local_game_project_if_unchanged(projectPath, expectedProjectId, expectedName, name)`:只有「项目 ID 相同」且「当前名称仍是本次创建的兜底名」才改名,返回 `renamed: true/false`(跳过时不写盘、不是错误)。用户已手动改名、项目 ID 不符、名称与现状相同一律跳过;空 `expectedProjectId` / `expectedName`、空名 / 控制字符 / 超长一律失败关闭。
|
||||
- 决策(纳入「生成任务」体系):自动命名做成**项目工作台那条既有「生成任务」列表里的一条后台任务**,而不只是页面里的一个 promise。账本记录加 `taskType`(`asset-generation` / `project-naming`,缺省 `asset-generation`)、`kind` 变可选,schema 升 `agc-asset-generation-task.v2`(读取同时接受 v1/v2,v1 记录按素材任务读回);新增 `enqueue_local_project_naming_task`(排队中)/ `update_local_project_naming_task`(命名中 → 已完成/失败),展示名固定「AI 项目命名」,与素材生成共用同一份账本、同一个变更事件与同一个侧栏。状态流转:排队中 → 命名中 → 已完成(已应用 AI 名称 / 建议名与现状一致 / 用户已手动改名而跳过)或失败(无可用名称 / 自动改名失败,均保留兜底名)。命名任务由命名链路推进、**不进 live 集合就会被中断收口误判**,所以 enqueue 写账本前登记 live、update 终态写盘后摘除(中断残留仍按命名口径收口为失败)。前端按 `taskType` 把命名记录从素材任务分支里剔除,命名行不渲染缩略图/提示词/派发与定位动作,结论只在终态显示。
|
||||
- 边界:改名是簿记写入,与既有 `rename_local_game_project` 同口径**不推进项目 revision**(推 revision 会让运行时验证凭证无故漂移);返回的 `revision` 是当时盘上的值。后台改名结果写「当前项目上下文」与「最近项目行重检」必须等建项主体收尾(`entrySettled`):AI 比进项目更快时直接写上下文会被随后的 `enterProjectDevelopment` 用兜底名覆盖,最近项目行也要等进项目登记过才会被重检;写入前再过壳的生命周期守卫(`mounted` + 代次),关窗/卸载后只保留已落盘的改名,不写 UI 投影。账本是**展示旁路**:`enqueue` / `update` 失败只写诊断日志,绝不影响建项、命名与首轮创作;「做方案」与手动选目录建项不发起自动命名。本次未改共享契约(`packages/shared/**`)、server-rs 与任何 SpacetimeDB schema/HTTP 路由。条件改名的 `expectedProjectId` / `expectedName` 为空时按仓库同类入口口径失败关闭;`update_local_project_naming_task` 与 enqueue 共用 `project.rename` 权限位、校验记录归属,且终态只接受同状态幂等重放。**兼容性写成显式边界:兼容是单向的**——新构建读 v1 账本 OK;旧构建读到含命名记录(`kind: null`)的账本会整份解析失败(面板报读失败、同批在途素材任务被按中断收口)。前端刷新必须先订阅事件再读快照,并在面板打开时补读一次兜底(**门控**:仅当列表里确实存在在途命名行时才补读,避免给没有命名任务的项目多打一次账本 IPC)。
|
||||
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{commands.rs,desktop.rs,asset_generation_tasks.rs,asset_generation_tasks/runtime.rs}`、`apps/ai-game-creator-shell/src/{app/types.ts,features/app-shell/{useHomeProjectCreation.ts,WorkspaceLauncher.tsx},features/resource-canvas/{resourceCanvasAssetGenerationTaskModel.ts,ResourceCanvasAssetGenerationTasksPanelView.tsx},view/project-development/index.tsx}`、`apps/ai-game-creator-shell/tests/{homeProjectNamingAsync.test.tsx,projectNamingGenerationTaskRow.test.tsx,appSurface/home.suite.ts}`。
|
||||
- 验证:`npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx`(12 passed,含「命名请求永不返回仍进工作区」「AI 结果先于进项目落定仍不被兜底名覆盖」「手动改名不被覆盖且任务按已完成+已跳过收口」「非法/空响应 → 任务 failed 且保留兜底名」「做方案不发起命名」「切到别的工作区不被劫持」「卸载/pagehide 后不写上下文」,并断言入队→命名中→终态的账本推进序列);`projectNamingGenerationTaskRow.test.tsx`(3 passed:固定展示名/不渲染缩略图提示词定位、终态才显示结论、失败徽章与原因 + 在途计数);AGC 全量 `npm run test -- apps/ai-game-creator-shell/tests`(1964 passed / 17 skipped);`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute asset_generation_task`(27 passed,含 `naming_task_stays_in_flight_across_ledger_reads_until_terminal`、`naming_task_update_requires_the_project_rename_permission`、`naming_task_update_rejects_a_record_from_another_project`、`naming_task_terminal_state_rejects_a_different_status_but_allows_the_same_one`、`ledger_with_an_unsupported_schema_version_fails_closed`、`ledger_without_a_schema_version_is_accepted_as_v1`)与 `conditional_project_rename_tests`(7 passed,含超长名失败关闭);`npm run agc:typecheck`(含 `check:tests:types`)、`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。
|
||||
## 2026-10-03 AGC 发布版本标签改为由工程内部版本派生,取代「用户可编辑标签」口径
|
||||
|
||||
- 背景:用户实机验收指出发布面板「项目版本」显示 v6,而 AGC 工程内部只有 4 条正式版本记录(资源总览「项目版本」栏目 4 张卡,顶栏「智能体修订」下拉同样只有这 4 条)。核实:面板值来自本地清单 `manifest.projectVersion` 这个可编辑标量,它被三条链路反复钉到**平台** `game_distribution_version.version_number` 上——发布成功回写(`apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs:1531-1535`)、打开面板回读绑定回填(`:536-541`)、用户手改(`:944-965`);而 `manifest.versions` 从头到尾不参与该值。`publicationRevision` 只做 CAS,与任何版本号都无推导关系(`module-game-distribution/src/domain.rs:27-40` 的版本号解析只比 `max_existing` 与 `requested`)。
|
||||
|
||||
@@ -2,6 +2,26 @@
|
||||
|
||||
这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。
|
||||
|
||||
## 2026-10-04 把非素材任务塞进「生成任务」账本:taskType 维度、v1 兼容与 live 集合
|
||||
|
||||
- **现象**:首页「AI 项目命名」改成进「生成任务」列表后,第一次读账本(工作台打开项目就会读)就把那条还在跑的命名任务标成**失败**;随后前端的终态推进被拒(记录已终态)。
|
||||
- **根因**:账本的中断收口判据是「非终态 + 不在本进程 live 集合」= 上次运行的残留。素材生成任务由 Rust 后台任务在 `start` 里先登记 live 再落账本;项目命名任务由前端驱动(`enqueue` / `update` 两条 IPC),没有登记 live,于是任何一次 `list_local_project_asset_generations` 都把它当成残留收口。
|
||||
- **处理(现行口径)**:账本记录加 `taskType`(`asset-generation` / `project-naming`,缺省 `asset-generation`)、`kind` 变可选;schema 升 `agc-asset-generation-task.v2`,读取同时接受 v1 与 v2(v1 记录按素材任务读回)。`enqueue_local_project_naming_task` 写账本前登记 live(写盘失败只回滚本轮插入的那条),`update_local_project_naming_task` 在非终态时幂等登记、终态写盘成功后摘除。命名没有素材可交叉核对,中断收口一律 failed 且文案与素材口径区分(不含「目标素材」)。
|
||||
- **边界**:命名任务的结论文案(已应用 / 用户已改名跳过 / 失败保留兜底名)由命名链路给出;账本只是展示旁路——`enqueue` / `update` 失败只写诊断日志,绝不影响建项、命名与首轮创作。前端按 `taskType` 把命名记录从素材任务列表里剔除(素材分支会按 `kind` 解析),命名行固定展示名「AI 项目命名」,只在终态显示结论。
|
||||
- **边界(兼容是单向的,必须写清)**:**新构建读旧账本 OK,旧构建读新账本会失败**——命名记录写 `"kind": null`,而旧构建(`AssetGenerationTaskRecord.kind` 必填且 `read_ledger` 不校验 schema)解析含命名记录的账本会**整份失败** → 面板报「生成任务列表读取失败」,且同一次读里在途的素材任务会被按「上次运行中断」收口为失败。触发条件只有「同一项目先被新构建写过命名任务、之后又被旧构建打开(降级或新旧混跑)」。潜在的低成本正向兼容方向(**未实施,需先确认**):命名记录也写一个合法 `kind`(例如 `unknown`)只靠 `taskType` 区分——旧构建会把它渲染成一张名为「AI 项目命名」的素材任务卡,但账本能解析、不会误伤在途素材任务。
|
||||
- **边界(前端读快照的时序)**:命名行的推进发生在本组件之外,刷新只能靠「变更事件 + 一次受控快照读取」。**必须先订阅再读**(与素材队列「先订阅后派发」同口径):先读后订阅时,落在这一次 IPC 往返里的终态事件没有消费者、直接丢掉,行会永久停在「命名中」;订阅失败时退化为「进项目读一次 + 面板打开时补读」。补读兜底必须有,但**有门控**:仅当列表里确实存在一条在途命名行时才读——没有命名任务的项目不该因为打开面板就多打一次账本 IPC,那会改变「轮询计数」这类外部可观测行为(既有生成任务用例正是按轮询次数推进 mock 的)。
|
||||
- **边界(update 的门禁与终态语义)**:`update_local_project_naming_task` 与 `enqueue` 共用 `project.rename` 权限位,并校验记录 `project_id` 与项目一致;记录一旦终态只接受**同状态幂等重放**,改写成另一种状态(含 `completed → failed`)一律 Err 且不写盘——否则一次迟到的排队/失败事件会覆盖已落定的命名结论。
|
||||
- **判据/取证**:Rust `cargo test … asset_generation_task` 27 条(含 `naming_task_stays_in_flight_across_ledger_reads_until_terminal`、`naming_task_update_requires_the_project_rename_permission`、`naming_task_update_rejects_a_record_from_another_project`、`naming_task_terminal_state_rejects_a_different_status_but_allows_the_same_one`、`ledger_with_an_unsupported_schema_version_fails_closed`、`ledger_without_a_schema_version_is_accepted_as_v1`)与 `conditional_project_rename_tests` 7 条(含超长名失败关闭);前端 `homeProjectNamingAsync.test.tsx`(入队→running→终态的推进序列)、`projectNamingGenerationTaskRow.test.tsx`(行渲染)、appSurface 的「syncs the workbench title and recent project list with the AI name after creation」「recovers the naming row when the terminal event lands before the subscription is ready」「re-reads the naming ledger when the generation tasks panel opens」(后两条已做「改前红」验证:退回「先读后订阅 / 面板打开不补读」两条即红)。
|
||||
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs`、`apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`、`apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`。
|
||||
|
||||
## 2026-10-03 后台异步回填被「进项目」覆盖:AI 项目名只在兜底名仍成立时改
|
||||
|
||||
- **现象**(Issue 599):首页建项把 AI 命名改成「与建项并行、结果后台回填」后,偶尔工作台标题停在兜底名(`GameAgent 项目 <短 id>`),而磁盘 manifest 已经是 AI 名字。
|
||||
- **原因**:改名落盘很快,但写「当前项目上下文」的那次 `setCurrentProjectContext` 被 `enterProjectDevelopment` 随后的兜底上下文覆盖——AI 比进项目更快时,回填写入时上下文里还没有这个项目(`current` 为 null 或仍是上一个项目)。同一原因下 `refreshRecentWorkspace` 也会被丢弃:最近项目行只在 `recentWorkspacesRef` 已包含该路径时才更新状态,而路径是进项目时才登记的。
|
||||
- **处理(现行口径)**:后台改名任务里,「写项目上下文 + 重检最近项目行」这两步必须等「建项主体收尾」(进项目成功、失败或让位于别的项目)的信号再执行;改名本身照旧立即落盘。回归用 promise 闸门卡住 `get_local_game_preview_status` 复现顺序,不用 sleep。
|
||||
- **判据/取证**:`npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx` 的「keeps the AI name when the naming result lands before the project is entered」(去掉等待即复现兜底名覆盖);appSurface 的「syncs the workbench title and recent project list with the AI name after creation」。
|
||||
- **边界**:条件改名的判据必须由宿主校验(项目 ID + 当前名称仍是兜底名),前端只转述 `expectedProjectId` / `expectedName`;用户已手动改名时宿主返回 `renamed: false`,回填整体跳过、绝不覆盖用户输入。
|
||||
- **关联**:`apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`、`apps/ai-game-creator-shell/src-tauri/src/commands.rs`。
|
||||
## 2026-10-03 AGC 随包 plugins 的 feature 档位必须与消费方一致,且门禁会因 build.rs 未重跑而假通过
|
||||
|
||||
- **现象**:Windows 本机 `npm run check:generated-bindings`(`npm run lint` 链内,`scripts/check-repository-ci.sh` 的 Repository checks 也走它)在 `build.rs:167:29` panic:`插件随包资源校验失败:随包插件存在未声明文件:.../src-tauri/resources/plugins/agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll(目标 x86_64-pc-windows-msvc 与当前 feature 组合不允许;请先执行随包资源准备步骤)`;树上换成 `agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe` 时报同一类错。反向还有更隐蔽的形态:门禁 2 秒就 exit 0 说「通过」,但 tree 上其实带着编辑器产物。
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# AI 游戏创作智能体 App 实施计划
|
||||
|
||||
## 2026-10-03 首页建项不等 AI 命名(Issue 599)
|
||||
|
||||
本节覆盖下文「2026-08-20 Direct Codex 审核 Skill Pack 与受控工具内核」里「自动项目命名仍是首页建项前置」的隐含口径。
|
||||
|
||||
- 首页「开启创作 / 进入立项策划」的提交链路是「Web 环境预检 → 立即用宿主兜底名创建工作区 → 进入项目 → 首轮创作」。`create_automatic_local_game_project` 不再接收前端命名结果(固定 `name: null`,由宿主生成 `GameAgent 项目 <8 位短 id>` / `策划项目 <8 位短 id>`):AI 命名请求在建项前并行打出,命名慢、超时、失败或返回非法内容都不影响建项、附件导入与首轮创作。
|
||||
- AI 返回合法名称后走新增的条件改名命令 `rename_local_game_project_if_unchanged(projectPath, expectedProjectId, expectedName, name)`:`expectedProjectId` 取自建项返回的 `manifest.projectId`,`expectedName` 是本次创建的兜底名。只有「项目身份一致」且「当前名称仍是兜底名」才落盘改名;用户已手动改名、项目 ID 不符、名称与现状相同都返回 `renamed: false` 并保持现状,空名 / 控制字符 / 超长失败关闭。改名与既有 `rename_local_game_project` 同口径:簿记写入,不推进项目 revision。
|
||||
- 改名成功后,当前项目上下文(窗口标题与工作台项目名)与最近项目行的目录重检在「进项目落定」之后写入;AI 结果比进项目更快返回时同样以 AI 名称为准,不被 `enterProjectDevelopment` 的兜底上下文覆盖。
|
||||
- 「做方案」不发起自动项目命名请求;手动选择目录建项保持原有行为(沿用目录名,不做 AI 命名)。
|
||||
- 自动命名是**项目工作台「生成任务」列表里的一条后台任务**:账本记录用 `taskType`(`asset-generation` / `project-naming`)区分,schema `agc-asset-generation-task.v2`(读取兼容 v1,v1 记录按素材任务读回),`kind` 对命名任务为 null;入队/推进走 `enqueue_local_project_naming_task` / `update_local_project_naming_task`,展示名固定「AI 项目命名」,状态流转「排队中 → 命名中 → 已完成(已应用 AI 名称 / 建议名一致 / 用户已改名跳过)或失败(无可用名称 / 自动改名失败,均保留兜底名)」。命名行的结论只在终态显示,没有缩略图、提示词与「定位到素材」。命名任务由命名链路推进,因此入队要登记 live、终态写盘后摘除,否则任何一次账本读取都会把它按「上次运行中断」误收口为失败;账本只作展示旁路,入队/推进失败只写诊断日志,不影响建项、命名与首轮创作。
|
||||
- 回归入口:`npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx`(时序用 promise 闸门,不用 sleep)、`npx vitest run apps/ai-game-creator-shell/tests/projectNamingGenerationTaskRow.test.tsx`、`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`、`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute asset_generation_task`、`cargo test --features=… conditional_project_rename_tests`。
|
||||
|
||||
|
||||
## 2026-10-02 退役自建 Agent Runtime 与 CLI 执行面
|
||||
|
||||
自建 Agent Runtime(`agent/runtime_driver`、`runtime_protocol`、`runtime_tools`、`runtime_actions`、`runtime_state`、`runtime_adapter`、`agent/prompt.rs`、`agent_native_tools.rs`、`collaboration.rs`、`delegation.rs`、`goal.rs`、`context_compaction.rs`、`isolated_agent.rs`、`tool_plan_handoff/`、`user_input.rs`)与它的 Tauri 命令、`--agent-*` CLI、真实 e2e harness、CI job 一并退役,历史由 Git 保存,见 `docs/adr/【ADR】退役AGC独立Agent Runtime与CLI执行面-2026-10-02.md`。本文正文里所有关于 `--agent-run` / `--agent-task` / `--agent-runner-status` / `agent-run:smoke` / `agent-runtime-real-e2e.mjs`、后台单 Agent 队列、`read_game_creator_agent_runtime(s)`、Supervisor 自主协作与 Goal 生命周期的描述均已失效,不再作为实现依据。外部 Runner(`--agent-runner`)仅保留编辑器桥 RPC 与 `runner.attach_gui_owner` + GUI owner 参与锁 / watchdog。AGC 现役链路以 DirectProject(`agent/direct_*` + codex app-server)、资源工作台与本文其后的 DirectProject 相关条目为准。
|
||||
|
||||
Reference in New Issue
Block a user