WIP: AGC 资源画布与替换改造 V3.0 #316

Draft
suzmii wants to merge 131 commits from feat/agc-canvas-resource-workbench-v3 into master
3 changed files with 170 additions and 0 deletions
Showing only changes of commit 6496601d12 - Show all commits
@@ -95,6 +95,15 @@ import {
resolveResourceCanvasPanelEntries,
resolveSelectedPanelEntries,
} from '../../features/resource-canvas/resourceCanvasAssetTransferModel';
import {
isResourceCanvasGenerationAvailable,
resourceCanvasGenerationOption,
resourceCanvasGenerationSourceId,
} from '../../features/resource-canvas/resourceCanvasGenerationModel';
import {
ResourceCanvasGenerationPanelView,
type ResourceCanvasGenerationSubmitInput,
} from '../../features/resource-canvas/ResourceCanvasGenerationPanelView';
import {
canRedoResourceCanvasHistory,
canUndoResourceCanvasHistory,
@@ -1247,6 +1256,7 @@ export default function ProjectDevelopmentView({
createResourceCanvasHistory,
);
const [resourcePanelOpen, setResourcePanelOpen] = useState(false);
const [resourceGenerationOpen, setResourceGenerationOpen] = useState(false);
const [resourcePanelNotice, setResourcePanelNotice] = useState('');
const [resourcePanelUploading, setResourcePanelUploading] = useState(false);
const [uiEditorRoute, setUiEditorRoute] = useState<UiEditorRoute | null>(
@@ -5065,6 +5075,86 @@ export default function ProjectDevelopmentView({
quickEditSourceLayer,
]);
/**
* 生成入口:从资源画布无源新建视频 / 音效 / 背景音乐。
*
* 产出物是一张全新的资源卡(`generationMode: 'create'`),源素材、画布布局与
* 已有 manifest 条目都不参与派生;成功后用 `pendingResourceFocusRef` 定位新卡。
*/
const submitResourceCanvasGeneration = useCallback(
async (input: ResourceCanvasGenerationSubmitInput) => {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
throw new Error('生成资源需要在客户端内执行');
}
const option = resourceCanvasGenerationOption(input.kind);
const actionProject = { projectPath, projectId: manifest.projectId };
const flowId = crypto.randomUUID();
const status = await invoke<{ revision: number }>(
'get_local_game_project_revision',
{ projectPath },
);
if (!Number.isSafeInteger(status.revision) || status.revision < 0) {
throw new Error('项目 revision 无效');
}
const result = await withPlatformSessionRefresh(() =>
invoke<DeriveLocalProjectResourceResult>(
'derive_local_project_resource',
{
input: {
projectPath,
expectedProjectId: actionProject.projectId,
expectedProjectRevision: status.revision,
operationId: input.operationId,
idempotencyKey: input.idempotencyKey,
editKind: option.editKind,
generationMode: 'create',
sourceResourceId: resourceCanvasGenerationSourceId(
input.operationId,
),
sourceAssetId: null,
sourcePath: null,
sourceMediaType: option.sourceMediaType,
sourceSubtype: null,
producerTaskId: null,
sourceVersionId: null,
prompt: input.prompt,
assetName: input.assetName,
},
},
),
);
if (
!result.asset ||
result.manifest.projectId !== actionProject.projectId
) {
throw new Error('生成资源结果与当前项目不一致');
}
onManifestChange?.(projectPath, result.manifest, {
projectId: result.manifest.projectId,
revision: result.committedProjectRevision,
source: 'asset-command',
commitId: result.operationId,
});
activeFocusFlowIdRef.current = flowId;
pendingResourceFocusRef.current = {
flowId,
saveAttemptId: result.operationId,
sessionId: result.operationId,
draftId: result.operationId,
commitId: result.operationId,
projectPath,
projectId: result.manifest.projectId,
focusGeneration: focusGenerationRef.current,
resourceId: `asset:${result.asset.id}`,
completed: false,
};
setResourceWorkbenchNotice('生成资源已保存,正在同步资源与布局…');
setResourceGenerationOpen(false);
},
[manifest.projectId, onManifestChange, projectPath],
);
const selectedResourceCardPreview = selectedResourcePreviewIdentity
? (resourceCardPreviews.previews.get(selectedResourcePreviewIdentity) ??
null)
@@ -5127,6 +5217,12 @@ export default function ProjectDevelopmentView({
canvasSize: resourceBookSceneSize,
})
: null;
// 没有客户端 invoke 桥或项目还没就绪时不渲染生成入口,避免留下点了没反应的按钮。
const resourceGenerationAvailable = isResourceCanvasGenerationAvailable({
hasRuntimeInvoke: Boolean(window.__TAURI__?.core?.invoke),
projectPath,
projectId: manifest.projectId,
});
return (
<section
@@ -5229,6 +5325,18 @@ export default function ProjectDevelopmentView({
<FolderTree size={15} aria-hidden="true" />
</button>
{resourceGenerationAvailable ? (
<button
type="button"
className="game-workbench-resource-panel-button"
aria-haspopup="dialog"
aria-expanded={resourceGenerationOpen}
onClick={() => setResourceGenerationOpen(true)}
>
<Sparkles size={15} aria-hidden="true" />
</button>
) : null}
{pendingResourceEdits.length > 0 ||
pendingResourceEditsLoadState === 'failed' ? (
<button
@@ -5965,6 +6073,13 @@ export default function ProjectDevelopmentView({
</footer>
) : null}
{resourceGenerationOpen ? (
<ResourceCanvasGenerationPanelView
onSubmit={submitResourceCanvasGeneration}
onClose={() => setResourceGenerationOpen(false)}
/>
) : null}
{resourcePanelOpen ? (
<ResourceCanvasPanelView
entries={resourcePanelEntries}
@@ -518,6 +518,59 @@ describe('project resource live canvas integration', () => {
expect(deriveCalls[1]).not.toHaveProperty('apiKey');
});
it('creates a brand new media asset from the canvas generation entry with a create-mode derive request', async () => {
const { deriveCalls } = installTauri({ failFirstDerive: true });
render(<DerivedWorkbench />);
fireEvent.click(await screen.findByRole('button', { name: '生成素材' }));
const panel = await screen.findByRole('dialog', { name: '生成素材' });
fireEvent.change(within(panel).getByLabelText('生成提示词'), {
target: { value: '一段片头动画,镜头缓慢推进' },
});
fireEvent.click(within(panel).getByRole('button', { name: '生成视频' }));
expect(await within(panel).findByRole('alert')).not.toBeNull();
fireEvent.click(
within(panel).getByRole('button', { name: '使用原请求重试' }),
);
await waitFor(() => expect(deriveCalls).toHaveLength(2));
const operationId = String(deriveCalls[0]?.operationId);
expect(deriveCalls[0]).toMatchObject({
projectPath,
expectedProjectId: 'live-canvas-project',
editKind: 'video',
generationMode: 'create',
sourceResourceId: `create:${operationId}`,
sourceAssetId: null,
sourcePath: null,
sourceMediaType: 'video/mp4',
sourceSubtype: null,
producerTaskId: null,
sourceVersionId: null,
prompt: '一段片头动画,镜头缓慢推进',
assetName: '新视频',
});
expect(deriveCalls[0]).not.toHaveProperty('accessToken');
expect(deriveCalls[1]?.operationId).toBe(operationId);
expect(deriveCalls[1]?.idempotencyKey).toBe(deriveCalls[0]?.idempotencyKey);
expect(screen.queryByRole('dialog', { name: '生成素材' })).toBeNull();
// 产出物是新素材:画布定位并选中新卡片。
expect(
(await findResourceSelectButton(`${operationId}-rules.md`)).getAttribute(
'aria-pressed',
),
).toBe('true');
});
it('hides the canvas generation entry when the client bridge is unavailable', async () => {
render(<DerivedWorkbench />);
expect(
await screen.findByRole('button', { name: '资源面板' }),
).not.toBeNull();
expect(screen.queryByRole('button', { name: '生成素材' })).toBeNull();
});
it('lists an unfinished edit and resumes it using only the private-ledger operation id', async () => {
const { resumeCalls } = installTauri({ pendingResourceEdit: true });
render(<DerivedWorkbench />);
@@ -1309,4 +1309,6 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过
- **Agent 资源检索投影**`agc_list_registered_assets` 的投影在 `mediaType` 之后固定新增 `category``tags`,取值直接透出 manifest 已解析的分类(**不在投影层重新按 kind 派生**,否则会覆盖用户显式分类);不改 manifest 结构、不改对外 OpenAPI 或 SpacetimeDB schema。
- **素材重命名**:新增 `rename_local_project_asset``project/asset_rename.rs`)。语义是**磁盘文件改名 + manifest `localPath` 更新、资产 `id` 不变**manifest 没有 `name` 字段,显示名来自 `fileName(localPath)`)。校验覆盖新名非空、不含路径分隔符与 `..`、不跨目录、扩展名必须一致、目标不得已存在、原文件必须是真实普通文件;持项目写锁按“读 manifest → 磁盘改名 → 更新 `localPath` → 写 manifest → 推进 revision”执行,**写失败把文件改回原名**,回滚也失败时两个错误都报出并标记 `reconciliation-required`;同名重命名是空操作。已知边界:**不改游戏源码里对旧 `assets/<name>` 的引用**,不做引用扫描与自动替换。
- **聊天引用与润色**`ResourceReferenceInput` 新增 `activeVersionId?: string | null``versions?: GameIterationVersion[]` 两个可选 prop`@` 面板两个页签(当前版本素材 / 全部画布素材)各自持有独立 `{query, filter, selectedResourceIds}`。版本解析口径 `resolveActiveIterationVersion` / `currentIterationVersionAssets`:传 `null` 回退 manifest 最新的版本,悬空绑定自然丢弃,空版本或空绑定显示空态而不报错。新增 `polish_local_project_prompt(prompt, context)`(提示词 `src-tauri/prompts/local-project-prompt-polish.md`),复用与 `suggest_automatic_project_name` 同一条短文本通道:`codex_app_server` 模式走 home direct codex,其余走 `LlmClient` 单轮请求;入参有界(4 000 / 1 000 字符)、输出 2 048 token 有界、空回复一律判失败以保留原文。**计费不自建**:泥点扣费仍在 `server-rs``/api/llm/chat/completions``/api/llm/responses` 路由(`prepare_llm_router_billing` / `settle_llm_router_usage`),本次 server-rs 零改动。发送前提醒是 portal 独立弹窗,判据为“提醒未关闭 + 本轮未确认 + 纯文本 trim 后 ≥ 40 字符 + 不以 `/` 开头”,“不再提醒”偏好存本机 `localStorage`,不进 manifest 与后端。
- **画布生成入口(C3 收口)**:资源画布新增「生成素材」入口,点击打开独立浮层面板(`ResourceCanvasGenerationPanelView` + `ThemedModal`,与资源面板同一套宿主 chrome,不在任何现有面板下面追加内容)。面板只提供本地契约真正支持无源生成的三类:视频 / 音效 / 背景音乐;提交走 `derive_local_project_resource``generationMode='create'``sourceResourceId='create:<operationId>'``sourceAssetId / sourcePath / sourceSubtype / producerTaskId / sourceVersionId` 全为 `null`,失败重试复用同一对 `operationId / idempotencyKey`;产出后经 `pendingResourceFocusRef` 定位并选中新素材卡。入口渲染门禁是 `isResourceCanvasGenerationAvailable`(客户端 invoke 桥存在 + `projectPath``manifest.projectId` 就绪),不满足时整块不渲染。**已知边界(本轮未完成项)**:`project/resource_editor.rs` 的 create 分支只放行 video / sound-effect / background-music,图片(`image-reference`)会被返回「当前资源类型不支持无源生成」;图片臂的远端请求固定走 `/api/editor/images/edits` 并要求正式源引用,入参结构体里也没有 model / aspectRatio / imageSize。因此本轮**不提供图片无源生成入口**,也**不把图片专属的模型 / 比例 / 尺寸 / 像素艺术 / 泥点价复用到这三类上**(那会渲染出不真实的图片价格)。后续要做图片生成入口,必须先扩展该命令的入参与该分支的端点路由。
- **C3 生成入口验证**AGC 前端全量 1100 passed / 4 skipped / 0 failed(该基线上 1092 + 本次新增 8 条);共享美术画布组件 1385 passed`npm run ai-game-creator-shell:typecheck``npm run check:encoding``git diff --check` 全绿;本轮 Rust 零改动。
- **验证**AGC 前端全量 1075 passed / 4 skipped / 0 failed;共享美术画布组件 1372 passed`cargo check --all-targets` 通过;定向 Rust 用例 `asset_delete` 7、`manifest::` 30、`assets::tests` 22、`asset_rename` 9、`local_project_prompt_polish` 4、`bridge_registered_resource` / `bridge_art_resource` 3 全绿;`npm run check:encoding``git diff --check` 干净。