把 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:
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 重放)。
|
||||
|
||||
+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(
|
||||
|
||||
@@ -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 执行面
|
||||
|
||||
Reference in New Issue
Block a user