把 AI 项目命名接进「生成任务」体系:账本 v2 任务类型、命名任务入队与推进、面板命名行

- 账本 v2:记录新增 taskType(asset-generation / project-naming,缺省 asset-generation,旧壳按素材任务读回),kind 改为可选;schema 升 agc-asset-generation-task.v2,读取同时接受 v1 与 v2。
- 新增 enqueue_local_project_naming_task / update_local_project_naming_task:命名任务入队时登记 live、终态写盘后摘除(写盘失败只回滚本轮插入),中断残留按命名口径收口为 failed(文案不含「目标素材」)。
- 首页命名链路改为推进这条任务:入队 → 命名中 → 已完成(已应用 AI 名称 / 建议名与现状一致 / 用户已手动改名而跳过)或失败(无可用名称 / 自动改名失败,均保留兜底名);账本只作展示旁路,推进失败只写诊断日志。
- 前端模型与面板支持命名行:按 taskType 把命名记录从素材任务分支剔除,命名行固定展示名「AI 项目命名」、只在终态显示结论、不渲染缩略图/提示词/定位动作;命令名写字面量以满足 check-config 静态枚举门禁。
- 工作台订阅账本变更事件做一次受控快照读取刷新命名行(不新增固定频率轮询);另修:命名行的 useLiveNow 收进独立组件,避免行种类变化导致 hooks 数量变化。
- 测试:homeProjectNamingAsync 12 例(新增入队→命名中→终态推进序列断言)、projectNamingGenerationTaskRow 3 例、appSurface 建项后命名行可见 + 事件刷新出终态结论 + 标题/列表同步;Rust asset_generation_task 22 例(含 naming_task_stays_in_flight_across_ledger_reads_until_terminal、legacy_v1_ledger_reads_back_*)。
- 文档:decision-log 改写为「生成任务体系里的后台任务」、pitfalls 新增 taskType/live 集合条目、实施计划专题补任务接入口径。
- 门禁:AGC 全量 1925 passed / 17 skipped;cargo 定向 22 + 6;npm run agc:typecheck(含 check:tests:types 与 check-config);cargo fmt --check;check:encoding;git diff --check。
This commit is contained in:
2026-10-04 16:59:59 +08:00
parent 459fe5b432
commit a554b0c7ca
13 changed files with 1325 additions and 70 deletions
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 = "生成完成但未登记素材";
@@ -653,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,
@@ -117,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 上传进项目并登记为资产,返回带项目相对路径的附件记录。
*
@@ -432,14 +484,18 @@ export function useHomeProjectCreation({
/**
* 后台把 AI 建议名落到已创建的工作区。
*
* 这条链路同时是「生成任务」列表里那条「AI 项目命名」任务的推进者:
* `enqueue_local_project_naming_task`(排队中)→ `update_…(running)` → 终态
* (completed / failed)。任务行与素材生成共用同一份账本、同一个变更事件与同一个侧栏,
* 状态流转对用户可见;账本推进失败只写诊断日志,绝不影响命名与建项本身。
*
* 与建项并行发起(`naming` 在建项前就已经打出请求);改名落盘本身不等任何东西,
* 但「项目上下文」与「最近项目行」这两项都建立在进项目之后:AI 比进项目更快时直接写上下文
* 会被随后的 `enterProjectDevelopment` 用兜底名覆盖,而最近项目行也只有进项目登记过才会被
* 重检。所以它们等 `entrySettled`(建项主体收尾,含进项目成功/失败/让位于别的项目)。
* - 宿主侧条件改名要求「项目 ID 相同」且「当前名称仍是这次创建时的兜底名称」;用户已手动改名时
* 返回 `renamed: false`,这里整体跳过,绝不覆盖用户输入。
* - AI 返回空/非法/失败(Promise 解析为 null)或用例里没有可改名空间时,保持兜底名称。
* - 任何失败都只写诊断日志,不改变已经进入的工作区与首轮创作。
* 返回 `renamed: false`,这里整体跳过、绝不覆盖用户输入,任务按「已完成(已跳过)」收口。
* - AI 返回空/非法/失败(Promise 解析为 null)时保持兜底名称,任务收口为 failed。
*/
async function applyAutomaticProjectName(
invoke: TauriInvoke,
@@ -448,14 +504,35 @@ export function useHomeProjectCreation({
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 || suggested === created.manifest.name) {
// 建议名就是兜底名:没有可改的,别白跑一次写操作,按完成收口并说明原因。
if (suggested === created.manifest.name) {
await updateProjectNamingTask(invoke, {
...namingTask,
status: 'completed',
phaseDetail: PROJECT_NAMING_UNCHANGED_PHASE,
});
return;
}
let outcome: ConditionalRenameLocalProjectResult;
@@ -473,11 +550,27 @@ export function useHomeProjectCreation({
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 重放)。
@@ -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),
@@ -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(
@@ -167,7 +167,10 @@ import {
createResourceCanvasAudioGenerationTask,
type LocalProjectAssetGenerationTaskRecord,
type ResourceCanvasAssetGenerationTask,
type ResourceCanvasProjectNamingTask,
resourceCanvasAssetGenerationTaskIsTerminal,
restoreResourceCanvasProjectNamingTask,
splitLocalProjectGenerationTaskRecords,
} from '../../features/resource-canvas/resourceCanvasAssetGenerationTaskModel';
import { ResourceCanvasAssetGenerationTasksPanelView } from '../../features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView';
import {
@@ -2084,6 +2087,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(工具点击发生在那之后,读到的总是最新一帧)。
@@ -8926,6 +8939,7 @@ export default function ProjectDevelopmentView({
const projectId = manifest.projectId;
let cancelled = false;
setResourceAssetGenerationTasksPanelOpen(false);
setProjectNamingTasks([]);
resourceAssetGenerationPanelSubmissionRef.current = null;
setResourceAssetGenerationPanelReopen(null);
// 切项目:生成浮层与它的占位都不跨项目(占位本身由占位 Hook 按 projectId 收口)。
@@ -8962,20 +8976,87 @@ 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;
};
}, [manifest.projectId, projectPath]);
/**
* 刷新「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]);
/**
* 「AI 项目命名」的推进发生在本组件之外(首页后台命名链路),所以这里只订阅账本变更事件,
* 在事件属于当前项目时做**一次**受控快照读取(团队口径:正式状态变化走事件 + 一次快照读取,
* 不新增固定频率轮询)。素材任务仍按各自既有的账本/队列路径推进,不在这里改。
*/
useEffect(() => {
if (!canSubscribeTauriEvents() || !projectPath.trim()) {
return undefined;
}
let disposed = false;
let unsubscribe: (() => void) | null = null;
void subscribeTauriEvent<{ projectPath?: string }>(
RESOURCE_CANVAS_ASSET_GENERATION_CHANGED_EVENT,
(event) => {
if (disposed || event.payload?.projectPath !== projectPath) {
return;
}
void refreshProjectNamingTasks();
},
)
.then((dispose) => {
if (disposed) {
dispose();
} else {
unsubscribe = dispose;
}
})
.catch(() => undefined);
return () => {
disposed = true;
unsubscribe?.();
};
}, [projectPath, refreshProjectNamingTasks]);
/**
* 「生成任务」面板里点一条已完成任务:复用既有聚焦链定位到它的素材卡。
*
@@ -11128,6 +11209,9 @@ export default function ProjectDevelopmentView({
!task.restored ||
!resourceCanvasResourceEditTaskIsTerminal(task),
)}
namingTasks={projectNamingTasks.filter(
(task) => task.projectId === manifest.projectId,
)}
open={resourceAssetGenerationTasksPanelOpen}
onToggleOpen={() =>
setResourceAssetGenerationTasksPanelOpen((current) => !current)
@@ -1820,6 +1820,28 @@ export function registerHomeProjectCreationTests() {
);
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) => {
@@ -1840,6 +1862,19 @@ export function registerHomeProjectCreationTests() {
}
// 命名与建项并行:建项拿不到名字,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,
@@ -1878,9 +1913,22 @@ export function registerHomeProjectCreationTests() {
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: chatHarness.listen },
event: { listen },
});
window.localStorage.setItem(
'genarrative-ai-game-creator.recent-workspaces.v1',
@@ -1907,6 +1955,17 @@ export function registerHomeProjectCreationTests() {
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('星轨夜航');
@@ -1919,6 +1978,21 @@ export function registerHomeProjectCreationTests() {
);
});
// 账本变更事件 → 一次受控快照读取:终态结论出现在同一行上(不用重开项目)。
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();
@@ -38,34 +38,47 @@ function mount(options: {
>;
}) {
const order: string[] = [];
const invoke = vi.fn(async (command: string) => {
if (command === 'capture_analytics_context') return {};
if (command === 'preflight_web_game_creation') return { status: 'ready' };
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('未预期的条件改名调用');
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;
}
return options.conditionalRename();
}
const extra = options.extraCommands?.[command];
if (extra) {
return extra();
}
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(() =>
@@ -77,7 +90,23 @@ function mount(options: {
refreshRecentWorkspace,
}),
);
return { ...hook, invoke, order, 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(
@@ -92,7 +121,9 @@ function createAutomatically(
it('enters the workspace even though the AI naming request never returns', async () => {
const naming = deferred<string | null>();
const { result, invoke, order } = mount({ naming: naming.promise });
const { result, invoke, order, namingTaskCalls } = mount({
naming: naming.promise,
});
let creation!: Promise<string>;
act(() => {
@@ -121,6 +152,11 @@ it('enters the workspace even though the AI naming request never returns', async
'rename_local_game_project_if_unchanged',
expect.anything(),
);
// 命名还没回来:任务已经入队并标成「命名中」,用户能在生成任务里看到它在跑。
expect(namingTaskStatusTrace(namingTaskCalls)).toEqual([
'enqueue:',
'update:running',
]);
await act(async () => {
naming.resolve(null);
@@ -130,7 +166,7 @@ it('enters the workspace even though the AI naming request never returns', async
it('applies a valid AI name after it arrives and refreshes the recent row', async () => {
const naming = deferred<string | null>();
const { result, invoke, refreshRecentWorkspace } = mount({
const { result, invoke, namingTaskCalls, refreshRecentWorkspace } = mount({
naming: naming.promise,
conditionalRename: async () => ({
renamed: true,
@@ -167,6 +203,20 @@ it('applies a valid AI name after it arrives and refreshes the recent row', asyn
);
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 () => {
@@ -226,7 +276,7 @@ it.each(['', ' ', null])(
'keeps the fallback name for a non-usable AI response (%s)',
async (response) => {
const naming = deferred<string | null>();
const { result, invoke } = mount({
const { result, invoke, namingTaskCalls } = mount({
naming: naming.promise,
conditionalRename: async () => {
throw new Error('空/非法 AI 名称不应触发改名');
@@ -248,12 +298,21 @@ it.each(['', ' ', null])(
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 } = mount({
const { result, namingTaskCalls } = mount({
naming: naming.promise,
// 用户已经手动改过名:宿主按「当前名称不再是兜底名」整体跳过。
conditionalRename: async () => ({
@@ -272,11 +331,20 @@ it('does not overwrite a name the user already changed', async () => {
});
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 } = mount({
const { result, namingTaskCalls } = mount({
naming: naming.promise,
conditionalRename: async () => {
throw new Error('项目正在被其他写操作占用');
@@ -295,6 +363,14 @@ it('keeps the created workspace when the conditional rename fails', async () =>
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 () => {
@@ -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',
);
});
});
@@ -3,10 +3,11 @@
## 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 不符、名称与现状相同一律跳过;空名 / 控制字符 / 超长由 `normalize_game_creation_project_name` 失败关闭。
- 边界:改名是簿记写入,与既有 `rename_local_game_project` 同口径**不推进项目 revision**(推 revision 会让运行时验证凭证无故漂移);返回的 `revision` 是当时盘上的值。后台改名结果写「当前项目上下文」与「最近项目行重检」必须等建项主体收尾(`entrySettled`):AI 比进项目更快时直接写上下文会被随后的 `enterProjectDevelopment` 用兜底名覆盖,最近项目行也要等进项目登记过才会被重检;写入前再过壳的生命周期守卫(`mounted` + 代次),关窗/卸载后只保留已落盘的改名、不写 UI 投影。「做方案」与手动选目录建项不发起自动命名。条件改名的 `expectedProjectId` / `expectedName` 为空时按仓库同类入口口径失败关闭(而不是静默 `renamed: false`)。
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{commands.rs,desktop.rs}`、`apps/ai-game-creator-shell/src/{app/types.ts,features/app-shell/{useHomeProjectCreation.ts,WorkspaceLauncher.tsx}}`、`apps/ai-game-creator-shell/tests/{homeProjectNamingAsync.test.tsx,appSurface/home.suite.ts}`。
- 验证:`npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx`(12 passed,含「命名请求永不返回仍进工作区」「AI 结果先于进项目落定仍不被兜底名覆盖」「手动改名不被覆盖」「非法/空响应保留兜底名」「做方案不发起命名」「切到别的工作区不被劫持」「卸载/pagehide 后不写上下文」);AGC 全量 `npm run test -- apps/ai-game-creator-shell/tests`(1922 passed / 17 skipped,合并 `origin/master` tip `ccd271572` 后复测);`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute conditional_project_rename_tests`(6 passed,含空 `expectedProjectId` / `expectedName` 失败关闭);`npm run agc:typecheck`(含 `check:tests:types`)、`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。
- 决策(建项与命名解耦):建项固定传 `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 路由。
- 影响范围:`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`(1925 passed / 17 skipped);`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute asset_generation_task`(22 passed,含 `naming_task_stays_in_flight_across_ledger_reads_until_terminal` 与 `legacy_v1_ledger_reads_back_with_the_default_asset_generation_task_type`)与 `conditional_project_rename_tests`(6 passed);`npm run agc:typecheck`(含 `check:tests:types`)、`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。
## 2026-10-03 AGC 栏目画布上传素材按入口栏目登记(Issue 359)
@@ -2,6 +2,15 @@
这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 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 项目命名」,只在终态显示结论。
- **判据/取证**:Rust `cargo test … asset_generation_task` 22 条(含 `naming_task_stays_in_flight_across_ledger_reads_until_terminal`、`legacy_v1_ledger_reads_back_with_the_default_asset_generation_task_type`、`interrupted_naming_task_is_settled_as_failed_with_naming_wording`);前端 `homeProjectNamingAsync.test.tsx`(入队→running→终态的推进序列)、`projectNamingGenerationTaskRow.test.tsx`(行渲染)、appSurface 的「syncs the workbench title and recent project list with the AI name after creation」(建项后行可见 + 事件驱动刷新出终态结论)。
- **关联**:`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 名字。
@@ -8,7 +8,8 @@
- 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 命名)。
- 回归入口:`npx vitest run apps/ai-game-creator-shell/tests/homeProjectNamingAsync.test.tsx`(时序用 promise 闸门,不用 sleep)、`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`、`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute conditional_project_rename_tests`。
- 自动命名是**项目工作台「生成任务」列表里的一条后台任务**:账本记录用 `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 执行面