{focusedResourceIsImage ? (
diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts
new file mode 100644
index 000000000..fdf27fdf4
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts
@@ -0,0 +1,224 @@
+import type { ProjectResource } from './resourceProjectionModel';
+
+export type LocalProjectResourceEditKind =
+ | 'image-reference'
+ | 'svg'
+ | 'video'
+ | 'sound-effect'
+ | 'background-music'
+ | 'text'
+ | 'agent-result'
+ | 'version';
+
+export type ProjectResourceEditCapability =
+ | {
+ route: 'image-canvas';
+ sourceAssetId: string;
+ normalizeSource: false;
+ mediaType: 'image/png' | 'image/jpeg' | 'image/webp';
+ }
+ | {
+ route: 'image-canvas';
+ sourceAssetId: null;
+ normalizeSource: true;
+ mediaType: 'image/png' | 'image/jpeg' | 'image/webp';
+ }
+ | {
+ route: 'derive';
+ editKind: LocalProjectResourceEditKind;
+ sourceMediaType: string;
+ requiresAccessToken: boolean;
+ semanticNotice: string | null;
+ };
+
+const extensionMediaTypes: Record
= {
+ png: 'image/png',
+ jpg: 'image/jpeg',
+ jpeg: 'image/jpeg',
+ webp: 'image/webp',
+ gif: 'image/gif',
+ svg: 'image/svg+xml',
+ avif: 'image/avif',
+ bmp: 'image/bmp',
+ mp4: 'video/mp4',
+ webm: 'video/webm',
+ mov: 'video/quicktime',
+ mp3: 'audio/mpeg',
+ wav: 'audio/wav',
+ ogg: 'audio/ogg',
+ m4a: 'audio/mp4',
+ aac: 'audio/aac',
+ flac: 'audio/flac',
+ opus: 'audio/opus',
+ md: 'text/markdown',
+ markdown: 'text/markdown',
+ mdx: 'application/mdx',
+ txt: 'text/plain',
+ json: 'application/json',
+ yaml: 'application/yaml',
+ yml: 'application/yaml',
+ toml: 'application/toml',
+ html: 'text/html',
+ htm: 'text/html',
+ css: 'text/css',
+ scss: 'text/css',
+ less: 'text/css',
+ js: 'text/javascript',
+ jsx: 'text/javascript',
+ mjs: 'text/javascript',
+ cjs: 'text/javascript',
+ ts: 'text/typescript',
+ tsx: 'text/typescript',
+ rs: 'text/x-rust',
+ py: 'text/x-python',
+ go: 'text/x-go',
+ java: 'text/plain',
+ kt: 'text/plain',
+ kts: 'text/plain',
+ c: 'text/plain',
+ cc: 'text/plain',
+ cpp: 'text/plain',
+ h: 'text/plain',
+ hpp: 'text/plain',
+ cs: 'text/plain',
+ swift: 'text/plain',
+ php: 'text/plain',
+ rb: 'text/plain',
+ lua: 'text/plain',
+ sh: 'text/plain',
+ bash: 'text/plain',
+ zsh: 'text/plain',
+ sql: 'text/plain',
+ graphql: 'text/plain',
+ gql: 'text/plain',
+ xml: 'text/plain',
+ csv: 'text/csv',
+ ini: 'text/plain',
+ conf: 'text/plain',
+ vue: 'text/plain',
+ svelte: 'text/plain',
+};
+
+function pathExtension(path: string) {
+ return path.split(/[?#]/u, 1)[0]?.split('.').pop()?.toLowerCase() ?? '';
+}
+
+export function canonicalProjectedResourceMediaType(resource: ProjectResource) {
+ const declared = resource.mediaType.trim().toLowerCase();
+ if (/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/u.test(declared)) {
+ return declared;
+ }
+ return extensionMediaTypes[pathExtension(resource.path)] ?? declared;
+}
+
+function isBackgroundMusic(resource: ProjectResource) {
+ const semanticSource = `${resource.subtype} ${resource.path}`.toLowerCase();
+ return ['background', 'bgm', 'music', 'theme'].some((marker) =>
+ semanticSource.includes(marker),
+ );
+}
+
+export function resolveProjectResourceEditCapability(
+ resource: ProjectResource,
+): ProjectResourceEditCapability {
+ if (resource.version) {
+ return {
+ route: 'derive',
+ editKind: 'version',
+ sourceMediaType: 'application/vnd.genarrative.project-version+json',
+ requiresAccessToken: false,
+ semanticNotice: null,
+ };
+ }
+ if (resource.subtype === 'agent-result') {
+ return {
+ route: 'derive',
+ editKind: 'agent-result',
+ sourceMediaType: 'text/markdown',
+ requiresAccessToken: false,
+ semanticNotice: null,
+ };
+ }
+
+ const mediaType = canonicalProjectedResourceMediaType(resource);
+ if (['image/png', 'image/jpeg', 'image/webp'].includes(mediaType)) {
+ return resource.manifestAssetId
+ ? {
+ route: 'image-canvas',
+ sourceAssetId: resource.manifestAssetId,
+ normalizeSource: false,
+ mediaType: mediaType as 'image/png' | 'image/jpeg' | 'image/webp',
+ }
+ : {
+ route: 'image-canvas',
+ sourceAssetId: null,
+ normalizeSource: true,
+ mediaType: mediaType as 'image/png' | 'image/jpeg' | 'image/webp',
+ };
+ }
+ if (mediaType === 'image/svg+xml') {
+ return {
+ route: 'derive',
+ editKind: 'svg',
+ sourceMediaType: mediaType,
+ requiresAccessToken: false,
+ semanticNotice: null,
+ };
+ }
+ if (mediaType.startsWith('image/')) {
+ return {
+ route: 'derive',
+ editKind: 'image-reference',
+ sourceMediaType: mediaType,
+ requiresAccessToken: true,
+ semanticNotice: null,
+ };
+ }
+ if (mediaType.startsWith('video/')) {
+ return {
+ route: 'derive',
+ editKind: 'video',
+ sourceMediaType: mediaType,
+ requiresAccessToken: true,
+ semanticNotice: null,
+ };
+ }
+ if (resource.category === 'audio' || mediaType.startsWith('audio/')) {
+ return {
+ route: 'derive',
+ editKind: isBackgroundMusic(resource)
+ ? 'background-music'
+ : 'sound-effect',
+ sourceMediaType: mediaType,
+ requiresAccessToken: true,
+ semanticNotice: '音频将基于原资源语义派生重制,不会修改原音频波形。',
+ };
+ }
+ return {
+ route: 'derive',
+ editKind: 'text',
+ sourceMediaType: mediaType,
+ requiresAccessToken: false,
+ semanticNotice: null,
+ };
+}
+
+export function defaultDerivedResourceName(resource: ProjectResource) {
+ const baseName = resource.label.replace(/\.[^.]+$/u, '').trim();
+ return `${baseName || '资源'}-编辑版`;
+}
+
+export function resourceEditPromptMaxLength(
+ editKind: LocalProjectResourceEditKind,
+) {
+ switch (editKind) {
+ case 'background-music':
+ return 140;
+ case 'sound-effect':
+ return 1_900;
+ case 'video':
+ return 4_000;
+ default:
+ return 32_000;
+ }
+}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts
index 6cc98c8e5..344f67de3 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts
+++ b/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts
@@ -47,7 +47,8 @@ export type ProjectResource = {
version?: ProjectVersionResourceSummary;
};
-const documentExtension = /\.(md|markdown|mdx|txt|json|ya?ml|toml)$/iu;
+const documentExtension =
+ /\.(md|markdown|mdx|txt|json|ya?ml|toml|html?|css|scss|less|m?[jt]sx?|cjs|rs|py|go|java|kt|kts|c|cc|cpp|h|hpp|cs|swift|php|rb|lua|sh|bash|zsh|sql|graphql|gql|xml|csv|ini|conf|vue|svelte)$/iu;
const artExtension = /\.(png|jpe?g|webp|gif|svg|avif|bmp|mp4|webm|mov)$/iu;
const audioExtension = /\.(mp3|wav|ogg|m4a|aac|flac|opus)$/iu;
const artKind =
@@ -120,7 +121,7 @@ export function projectResourcesFromReadModels(
}
for (const path of task.artifacts) {
const category = classifyProjectedResource({ path, mediaType: '' });
- if (!category || category === 'audio') {
+ if (!category) {
continue;
}
resources.push({
diff --git a/apps/ai-game-creator-shell/tests/ResourceEditSurface.test.tsx b/apps/ai-game-creator-shell/tests/ResourceEditSurface.test.tsx
new file mode 100644
index 000000000..4ff959c69
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/ResourceEditSurface.test.tsx
@@ -0,0 +1,54 @@
+/** @vitest-environment jsdom */
+import React from 'react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { ResourceEditSurface } from '../src/view/project-development/ResourceEditSurface';
+import { fireEvent, render, screen, waitFor } from './appSurface/harness';
+
+describe('统一资源编辑面板', () => {
+ it('失败后锁定原请求字段并使用相同输入重试', async () => {
+ const submit = vi
+ .fn<(input: { prompt: string; assetName: string }) => Promise>()
+ .mockRejectedValueOnce(new Error('网络暂不可用'))
+ .mockResolvedValueOnce(undefined);
+ render(
+ undefined}
+ onSubmit={submit}
+ />,
+ );
+
+ const name = screen.getByLabelText('派生资源名称');
+ const prompt = screen.getByLabelText('编辑提示词');
+ fireEvent.change(name, { target: { value: '红发角色规则' } });
+ fireEvent.change(prompt, { target: { value: '把角色头发设定改为红色' } });
+ fireEvent.click(screen.getByRole('button', { name: '生成派生资源' }));
+
+ expect((await screen.findByRole('alert')).textContent).toContain(
+ '网络暂不可用',
+ );
+ expect((name as HTMLInputElement).disabled).toBe(true);
+ expect((prompt as HTMLTextAreaElement).disabled).toBe(true);
+ fireEvent.click(screen.getByRole('button', { name: '使用原请求重试' }));
+ await waitFor(() => expect(submit).toHaveBeenCalledTimes(2));
+ expect(submit.mock.calls[0]?.[0]).toEqual(submit.mock.calls[1]?.[0]);
+ });
+
+ it('对音频显示语义派生边界', () => {
+ render(
+ undefined}
+ onSubmit={async () => undefined}
+ />,
+ );
+ expect(screen.getByText(/不会修改原音频波形/)).not.toBeNull();
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx
index 23ede5e9f..cf83fceda 100644
--- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx
+++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx
@@ -211,6 +211,69 @@ function LiveWorkbench() {
);
}
+function DerivedWorkbench() {
+ const initial = createGameCreationAppManifest(
+ 'live-canvas-project',
+ '实时画布项目',
+ );
+ initial.assets = [
+ {
+ id: 'source-rules',
+ kind: 'game-rules',
+ mediaType: 'text/markdown',
+ localPath: 'docs/rules.md',
+ source: { kind: 'generated', resourceId: 'rules-resource' },
+ },
+ ];
+ const [manifest, setManifest] = useState(initial);
+ canvasFixture.manifest = manifest;
+
+ return (
+ Supervisor }
+ onHomeOpen={() => undefined}
+ onProjectsOpen={() => undefined}
+ onManifestChange={(_path, nextManifest) => setManifest(nextManifest)}
+ />
+ );
+}
+
+function TaskImageWorkbench() {
+ const initial = createGameCreationAppManifest(
+ 'live-canvas-project',
+ '实时画布项目',
+ );
+ const task = initial.tasks.find(
+ (candidate) => candidate.id === 'art-asset-plan',
+ );
+ if (!task) throw new Error('missing art task fixture');
+ task.status = 'completed';
+ task.artifacts = ['assets/task-hero.png'];
+ const [manifest, setManifest] = useState(initial);
+ canvasFixture.manifest = manifest;
+
+ return (
+