+
+ {homeCreationType === 'game' ? (
+
+ ) : null}
{
const projectPath =
'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\live-direct-art';
+ const eventProjectPath = `\\\\?\\${projectPath}`;
const manifest = createGameCreationAppManifest(
'live-direct-art',
'直连美术实时刷新',
@@ -2347,7 +2348,7 @@ export function registerHomeProjectCreationTests() {
taskId: 'direct-codex-art-art-spritesheet',
},
},
- ];
+ ].map((asset) => ({ ...asset, category: 'ui-interaction' as const }));
for (let index = 0; index < committedAssets.length; index += 1) {
const refreshCountBeforeEvent = invoke.mock.calls.filter(
@@ -2357,8 +2358,12 @@ export function registerHomeProjectCreationTests() {
...currentManifest,
assets: committedAssets.slice(0, index + 1),
};
+ runtimeHarness.setProjectRevision(index + 1);
act(() => {
- runtimeHarness.emitManifestInvalidated('direct-codex-art');
+ runtimeHarness.emitManifestInvalidated(
+ 'direct-codex-art',
+ eventProjectPath,
+ );
});
await waitFor(() => {
expect(
@@ -2373,6 +2378,12 @@ export function registerHomeProjectCreationTests() {
),
).toHaveLength(1);
}
+ await openResourceBookCategory('UI 交互');
+ expect(getResourceSelectButton('art-spec.png')).not.toBeNull();
+ expect(
+ getResourceSelectButton('direct-game-background.png'),
+ ).not.toBeNull();
+ expect(getResourceSelectButton('art-spritesheet.png')).not.toBeNull();
const refreshCountBeforeFailure = invoke.mock.calls.filter(
([command]) => command === 'get_local_game_manifest',
diff --git a/apps/ai-game-creator-shell/tests/clientApi.test.ts b/apps/ai-game-creator-shell/tests/clientApi.test.ts
index 7b31137a0..91d2a90c6 100644
--- a/apps/ai-game-creator-shell/tests/clientApi.test.ts
+++ b/apps/ai-game-creator-shell/tests/clientApi.test.ts
@@ -10,6 +10,12 @@ import {
getClientAuthRefreshOperation,
refreshClientAuthAccessToken,
} from '../src/services/clientAuth';
+import { CLIENT_HTTP_DEFAULT_TIMEOUT_MS } from '../src/services/clientHttp';
+import {
+ cachedLlmModelCatalog,
+ refreshLlmModelCatalog,
+ resetLlmModelCatalogCacheForTest,
+} from '../src/services/llmModelCatalog';
import {
beginPlatformSessionTransition,
commitAuthenticatedPlatformSession,
@@ -18,6 +24,10 @@ import {
} from '../src/services/platformSession';
vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() }));
+vi.mock(
+ '../../../packages/shared/src',
+ () => import('../../../packages/shared/src/http'),
+);
vi.mock('../src/services/errorReporting', () => ({
captureClientError: vi.fn(),
}));
@@ -29,6 +39,7 @@ const json = (value: unknown, status = 200) =>
new Response(JSON.stringify(value), { status });
beforeEach(async () => {
+ resetLlmModelCatalogCacheForTest();
resetPlatformSessionStateForTests();
window.localStorage.clear();
nativeInvoke.mockClear();
@@ -42,12 +53,60 @@ beforeEach(async () => {
});
afterEach(() => {
+ vi.useRealTimers();
+ resetLlmModelCatalogCacheForTest();
resetPlatformSessionStateForTests();
window.localStorage.clear();
delete window.__TAURI__;
vi.restoreAllMocks();
});
+it.each([200, 503])(
+ '模型目录 HTTP %s 响应体卡住后超时,保留缓存且能再次刷新',
+ async (status) => {
+ vi.useFakeTimers();
+ const previous = { ...catalog, defaultModelId: 'quality', revision: 1 };
+ const updated = {
+ defaultModelId: 'fast',
+ models: [{ id: 'fast', displayName: '快速' }],
+ revision: 2,
+ };
+ let body!: ReadableStreamDefaultController;
+ const stalledResponse = new Response(
+ new ReadableStream({
+ start(controller) {
+ body = controller;
+ },
+ }),
+ { status },
+ );
+ const fetch = vi
+ .spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(json(previous))
+ .mockResolvedValueOnce(stalledResponse)
+ .mockResolvedValueOnce(json(updated));
+ await expect(refreshLlmModelCatalog()).resolves.toEqual(previous);
+ let failure: unknown;
+ const pending = refreshLlmModelCatalog().catch((error: unknown) => {
+ failure = error;
+ });
+ try {
+ await vi.advanceTimersByTimeAsync(CLIENT_HTTP_DEFAULT_TIMEOUT_MS);
+ expect(failure).toMatchObject({ code: 'CLIENT_HTTP_TIMEOUT' });
+ await pending;
+ expect(cachedLlmModelCatalog()).toEqual(previous);
+ await expect(refreshLlmModelCatalog()).resolves.toEqual(updated);
+ expect(fetch).toHaveBeenCalledTimes(3);
+ } finally {
+ // 迟到的响应不能在新刷新完成后覆盖缓存,同时释放测试流。
+ body.enqueue(new TextEncoder().encode(JSON.stringify(previous)));
+ body.close();
+ await pending;
+ }
+ expect(cachedLlmModelCatalog()).toEqual(updated);
+ },
+);
+
it('并发模型请求共享续期,并在安装 Rust 会话后使用新 token 重试', async () => {
let refreshCalls = 0;
let modelCalls = 0;
diff --git a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx
index 4a9c7731f..9ee9e06d0 100644
--- a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx
+++ b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx
@@ -16,11 +16,36 @@ import {
ConversationModelSelect,
type ConversationModelSelectHandle,
} from '../src/features/project-workspace/ConversationModelSelect';
-import { loadClientLlmModels } from '../src/services/clientApi';
+import {
+ ClientAuthRequestError,
+ type ClientLlmModelCatalog,
+ loadClientLlmModels,
+} from '../src/services/clientApi';
+import { ClientHttpTimeoutError } from '../src/services/clientHttp';
import { resetLlmModelCatalogCacheForTest } from '../src/services/llmModelCatalog';
vi.mock('../src/app/tauri', () => ({ resolveTauriInvoke: vi.fn() }));
-vi.mock('../src/services/clientApi', () => ({ loadClientLlmModels: vi.fn() }));
+const MockClientAuthRequestError = vi.hoisted(
+ () =>
+ class MockClientAuthRequestError extends Error {
+ readonly status: number | null;
+ readonly networkError: boolean;
+
+ constructor(
+ message: string,
+ options: { status?: number | null; networkError?: boolean } = {},
+ ) {
+ super(message);
+ this.status = options.status ?? null;
+ this.networkError = options.networkError ?? false;
+ }
+ },
+);
+vi.mock('../src/services/clientApi', () => ({
+ ClientAuthRequestError: MockClientAuthRequestError,
+ loadClientLlmModels: vi.fn(),
+}));
+vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() }));
const invoke = vi.fn();
let savedModelId = 'quality';
let savedModelIsDefault = true;
@@ -54,6 +79,122 @@ beforeEach(() => {
});
afterEach(cleanup);
+async function renderReadyModelMenu() {
+ const onReady = vi.fn();
+ render();
+ await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
+ });
+ return onReady;
+}
+
+test('shows manual refresh progress immediately without clearing the selected model', async () => {
+ const onReady = await renderReadyModelMenu();
+ let resolveRefresh!: (catalog: ClientLlmModelCatalog) => void;
+ vi.mocked(loadClientLlmModels).mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveRefresh = resolve;
+ }),
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
+ expect(screen.getByRole('status').textContent).toBe('正在刷新模型列表');
+ const refreshButton = screen.getByRole('button', { name: '刷新模型列表' });
+ expect(refreshButton.textContent).toBe('刷新中…');
+ expect(refreshButton).toHaveProperty('disabled', true);
+ expect(
+ screen.getByRole('button', { name: '对话模型' }).textContent,
+ ).toContain('高质量');
+ expect(onReady).toHaveBeenLastCalledWith(false);
+
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(screen.getByRole('status').textContent).toBe('正在刷新模型列表');
+ await act(async () => {
+ resolveRefresh({
+ defaultModelId: 'quality',
+ models: [{ id: 'quality', displayName: '高质量' }],
+ revision: 1,
+ });
+ });
+ expect(screen.getByRole('status').textContent).toBe('模型列表已刷新');
+ expect(savedModelId).toBe('quality');
+ expect(onReady).toHaveBeenLastCalledWith(true);
+});
+
+test('confirms a manual refresh even when the catalog revision is unchanged', async () => {
+ await renderReadyModelMenu();
+ expect(screen.queryByRole('status')).toBeNull();
+
+ fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
+ await screen.findByText('模型列表已刷新');
+ expect(loadClientLlmModels).toHaveBeenCalledTimes(3);
+ expect(
+ screen
+ .getByRole('option', { name: /高质量/ })
+ .getAttribute('aria-selected'),
+ ).toBe('true');
+ expect(screen.getByRole('option', { name: '快速' })).not.toBeNull();
+ expect(screen.getByRole('button', { name: '刷新模型列表' })).toHaveProperty(
+ 'disabled',
+ false,
+ );
+});
+
+test.each([
+ [
+ 'HTTP 404',
+ new ClientAuthRequestError('private server detail', { status: 404 }),
+ '模型列表加载失败(HTTP 404)',
+ ],
+ [
+ 'HTTP 401',
+ new ClientAuthRequestError('private server detail', { status: 401 }),
+ '模型列表加载失败(HTTP 401)',
+ ],
+ [
+ 'timeout',
+ new ClientHttpTimeoutError('https://private.example/models', 15000),
+ '模型列表请求超时,请重试',
+ ],
+ ['unknown', new Error('private server detail'), '模型列表加载失败'],
+])(
+ 'reports a safe %s failure with cached models and permits retry without claiming success',
+ async (_label, failure, message) => {
+ const onReady = await renderReadyModelMenu();
+ fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
+ await screen.findByText('模型列表已刷新');
+
+ vi.mocked(loadClientLlmModels).mockRejectedValueOnce(failure);
+ fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
+ await waitFor(() =>
+ expect(screen.getByRole('alert').textContent).toBe(message),
+ );
+ expect(screen.queryByText('模型列表已刷新')).toBeNull();
+ expect(screen.queryByText('正在刷新模型列表')).toBeNull();
+ expect(document.body.textContent).not.toContain('private');
+ expect(
+ screen.getByRole('button', { name: '对话模型' }).textContent,
+ ).toContain('高质量');
+ expect(
+ screen
+ .getByRole('option', { name: /高质量/ })
+ .getAttribute('aria-selected'),
+ ).toBe('true');
+ expect(savedModelId).toBe('quality');
+ expect(onReady).toHaveBeenLastCalledWith(true);
+ expect(screen.getByRole('button', { name: '刷新模型列表' })).toHaveProperty(
+ 'disabled',
+ false,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
+ await screen.findByText('模型列表已刷新');
+ expect(screen.queryByRole('alert')).toBeNull();
+ },
+);
+
test('only displays aliases and persists selection through the native command', async () => {
const onReady = vi.fn();
render();
diff --git a/apps/ai-game-creator-shell/tests/designWorkspaceDebug.test.tsx b/apps/ai-game-creator-shell/tests/designWorkspaceDebug.test.tsx
index f5198b50e..bc5db7f75 100644
--- a/apps/ai-game-creator-shell/tests/designWorkspaceDebug.test.tsx
+++ b/apps/ai-game-creator-shell/tests/designWorkspaceDebug.test.tsx
@@ -23,9 +23,11 @@ afterEach(() => {
});
it('prepares debug fixtures from the header and refreshes the phase and files without a manual refresh', async () => {
- vi.stubEnv('VITE_GENARRATIVE_AGC_DESIGN_DEBUG', '1');
let prepared = false;
const invoke = vi.fn(async (command: string) => {
+ if (command === 'is_design_agent_debug_enabled') {
+ return true;
+ }
if (command === 'debug_fast_forward_design_session') {
prepared = true;
return { activeRuntime: 'design' };
diff --git a/apps/ai-game-creator-shell/tests/homeStartMode.test.ts b/apps/ai-game-creator-shell/tests/homeStartMode.test.ts
new file mode 100644
index 000000000..3050522a2
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/homeStartMode.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from 'vitest';
+
+import { resolveHomeStartMode } from '../src/view/home/homeStartMode';
+
+describe('AGC 首页启动模式', () => {
+ it('做游戏勾选策划补全时进入策划 runtime', () => {
+ expect(resolveHomeStartMode('game', true)).toBe('planning');
+ });
+
+ it('做游戏未勾选策划补全时保持直接创作', () => {
+ expect(resolveHomeStartMode('game', false)).toBe('direct-build');
+ });
+
+ it('做方案始终进入策划 runtime,其他入口不受影响', () => {
+ expect(resolveHomeStartMode('doc', false)).toBe('planning');
+ expect(resolveHomeStartMode('art', true)).toBe('direct-build');
+ });
+});
diff --git a/apps/ai-game-creator-shell/tests/projectPathNotification.test.ts b/apps/ai-game-creator-shell/tests/projectPathNotification.test.ts
new file mode 100644
index 000000000..6de9adad1
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/projectPathNotification.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest';
+
+import { projectPathsMatchForInvalidation } from '../src/features/project-summary/projectPath';
+
+describe('项目刷新事件路径', () => {
+ it.each([
+ ['C:\\Projects\\game', '\\\\?\\C:\\Projects\\game'],
+ ['\\\\?\\C:\\Projects\\game', 'c:/Projects/game/'],
+ ['\\\\server\\share\\game', '\\\\?\\UNC\\server\\share\\game'],
+ ['/tmp/game', '/tmp/game'],
+ ])('识别同一项目 %s 与 %s', (eventPath, activePath) => {
+ expect(projectPathsMatchForInvalidation(eventPath, activePath)).toBe(true);
+ });
+
+ it.each([
+ ['\\\\?\\C:\\Projects\\game-other', 'C:\\Projects\\game'],
+ ['\\\\?\\C:\\Projects\\game\\child', 'C:\\Projects\\game'],
+ ['\\\\?\\UNC\\other\\share\\game', '\\\\server\\share\\game'],
+ ['\\\\.\\C:\\Projects\\game', 'C:\\Projects\\game'],
+ ['/tmp/Game', '/tmp/game'],
+ ['', ''],
+ ['C:\\Projects\\game', null],
+ ])('拒绝其它项目或空作用域 %s 与 %s', (eventPath, activePath) => {
+ expect(projectPathsMatchForInvalidation(eventPath, activePath)).toBe(false);
+ });
+});
diff --git a/deploy/container/api-server.env.example b/deploy/container/api-server.env.example
index 6dddc3e82..b4e0286b1 100644
--- a/deploy/container/api-server.env.example
+++ b/deploy/container/api-server.env.example
@@ -62,7 +62,7 @@ GENARRATIVE_SPACETIME_POOL_SIZE=8
GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=45
GENARRATIVE_LLM_PROVIDER=openai-compatible
-GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1
+GENARRATIVE_LLM_BASE_URL=https://api.tiantoken.com/v1
GENARRATIVE_LLM_API_KEY=
GENARRATIVE_LLM_MODEL=gpt-5.4-mini
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=
diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example
index dd543cd4f..9b508dac1 100644
--- a/deploy/env/api-server.env.example
+++ b/deploy/env/api-server.env.example
@@ -79,7 +79,7 @@ GENARRATIVE_SPACETIME_POOL_SIZE=8
GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=45
GENARRATIVE_LLM_PROVIDER=openai-compatible
-GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1
+GENARRATIVE_LLM_BASE_URL=https://api.tiantoken.com/v1
GENARRATIVE_LLM_API_KEY=
GENARRATIVE_LLM_MODEL=gpt-5.4-mini
# LLM Router 正式账号链路:production 固定使用官方地址/模型;管理员 Token 只读受保护文件。
@@ -87,9 +87,11 @@ GENARRATIVE_LLM_ROUTER_BASE_URL=https://router.genarrative.world/v1
GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET_FILE=/etc/genarrative/secrets/llm-router-provisioning.secret
GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET_FILE=/etc/genarrative/secrets/llm-router-api-key-encryption.secret
GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN_FILE=/etc/genarrative/secrets/llm-router-admin.token
+TIANTOKEN_BASE_URL=https://api.tiantoken.com
+TIANTOKEN_API_KEY=
+TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS=1000000
VECTOR_ENGINE_BASE_URL=https://api.vectorengine.cn
VECTOR_ENGINE_API_KEY=
-VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1000000
VECTOR_ENGINE_AUDIO_REQUEST_TIMEOUT_MS=180000
ELEVENLABS_BASE_URL=https://api.elevenlabs.io
ELEVENLABS_API_KEY=
diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json
index 507747e57..0a28b1ab6 100644
--- a/docs/openapi/genarrative-external-v1.openapi.json
+++ b/docs/openapi/genarrative-external-v1.openapi.json
@@ -3370,16 +3370,32 @@
"maxLength": 200
}
},
- "sliceLayout": {
+ "sliceMode": {
"type": "string",
- "deprecated": true,
- "description": "历史兼容字段,新的调用请使用 sliceCount。"
+ "enum": [
+ "connected-components",
+ "grid"
+ ],
+ "default": "connected-components",
+ "description": "图集切分模式。connected-components 按透明像素 alpha 连通域识别独立素材;grid 按用户提供的 gridX/gridY 划分网格槽。省略时使用 connected-components。"
+ },
+ "gridX": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 32,
+ "description": "grid 模式的横向网格数量。"
+ },
+ "gridY": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 32,
+ "description": "grid 模式的纵向网格数量。"
},
"sliceCount": {
"type": "integer",
"minimum": 1,
"maximum": 100,
- "description": "可选的目标切片数量;省略时按图像内容自动识别。"
+ "description": "connected-components 模式下可选的目标切片数量;省略时按图像内容自动识别。grid 模式的切片数量由 gridX×gridY 决定。"
},
"screenColor": {
"type": ["string", "null"],
@@ -3603,15 +3619,28 @@
},
"iconImageSrcs": {
"type": "array",
- "description": "识别图集中有效 alpha 连通域并持久化的独立素材,按视觉阅读顺序命名为“素材 N”;可通过 sliceCount 指定目标数量。",
+ "description": "按 sliceMode 识别或裁切并持久化的独立素材,按视觉阅读顺序命名为“素材 N”;connected-components 模式可通过 sliceCount 指定目标数量。",
"items": {
"$ref": "#/components/schemas/EditorIconSpritesheetIconResult"
}
},
- "sliceLayout": {
+ "sliceMode": {
"type": "string",
- "deprecated": true,
- "description": "历史兼容字段。"
+ "enum": [
+ "connected-components",
+ "grid"
+ ],
+ "description": "实际采用的图集切分模式。"
+ },
+ "gridX": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 32
+ },
+ "gridY": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 32
},
"sliceCount": {
"type": "integer",
@@ -3628,7 +3657,7 @@
"type": "null"
}
],
- "description": "可信透明图集已成功持久化,但全连通域自动拆分未完成时返回;此时 iconImageSrcs 为空,调用方仍应使用整张图集。原始连通域、输出数量或 CPU 预算超限不会产生切片 PUT、资源或画布切片。透明处理、Alpha/尺寸恢复、provider 原图修复性回读或透明图完整解码失败时走 provider 原图 source-only,sliceWarning 为 null。"
+ "description": "可信透明图集已成功持久化,但所选 sliceMode 的自动拆分未完成时返回;此时 iconImageSrcs 为空,调用方仍应使用整张图集。原始连通域、输出数量、网格裁切或 CPU 预算超限不会产生切片 PUT、资源或画布切片。透明处理、Alpha/尺寸恢复、provider 原图修复性回读或透明图完整解码失败时走 provider 原图 source-only,sliceWarning 为 null。"
},
"prompt": {
"type": "string"
diff --git a/docs/project-memory/plans/【实施计划】AGC统一错误诊断与验收反馈-2026-09-15.md b/docs/project-memory/plans/【实施计划】AGC统一错误诊断与验收反馈-2026-09-15.md
new file mode 100644
index 000000000..031cd576c
--- /dev/null
+++ b/docs/project-memory/plans/【实施计划】AGC统一错误诊断与验收反馈-2026-09-15.md
@@ -0,0 +1,35 @@
+# AGC 统一错误诊断与验收反馈实施计划
+
+Version: 1.0
+Status: active
+Date: 2026-09-15
+Parent Milestone: `【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md`
+
+## 修改边界
+
+1. 新增 `agent/runtime_error.rs`,承载统一事件字段、code/stage 白名单、脱敏后的 public projection、项目错误 JSONL/sidecar 落库和 detail 读取边界。
+2. `direct_runtime.rs` 使用统一事件替代仅写 `failure.json` 的路径;失败 assistant 投影带稳定 ID,下一轮 prompt 注入最近失败事件摘要。
+3. `codex_app_server.rs` 将 failed turn、idle/hard timeout、transport close、invalid terminal 和 stderr tail 转成稳定事件字段;不公开原始 detail。
+4. `direct_tool_bridge.rs` 与 `direct_tools_mcp.rs` 让 attempt 由客户端回合状态约束,越界请求返回终态工具错误;不扩展重试预算。
+5. `direct_runtime.rs` 的素材扫描递归覆盖可执行源码模块,基于 manifest 身份和浏览器 URL 映射判定;补充模块引用回归测试。
+6. 前端读取后端 `publicText/detailRef`,在现有 Runtime 错误面板中加入详情入口;不在 React 侧重新分类错误。
+
+## 实现顺序
+
+先写统一事件模型和 Rust 单测,再接 direct failure/app-server/tool bridge,随后接 prompt/history 与前端详情,最后修素材验收和 attempt 生命周期。每一步保留原有脱敏和失败关闭行为。
+
+## 验证命令
+
+- `cargo fmt --check`
+- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml runtime_error direct_runtime codex_app_server direct_tool_bridge`
+- `npm run --prefix apps/ai-game-creator-shell typecheck`
+- `npm run check:encoding`
+- `git diff --check`
+- 必要时运行 AGC deterministic playable E2E;真实 Provider smoke 与浏览器双视口 smoke 单独报告。
+
+## 风险与回滚
+
+- 统一事件 schema 只新增项目内文件和对话投影,不修改已有 manifest、公开 API 或 SpacetimeDB schema。
+- 若前端详情读取失败,仍展示安全 `publicText`,不阻塞错误终态。
+- 若素材身份无法映射,继续失败关闭并记录明确 code,不回退为路径字符串通过。
+- 回滚可删除新事件写入和详情入口,保留旧 `failure.json` 读取兼容。
diff --git a/docs/project-memory/plans/【实施计划】AGC首页策划补全入口-2026-09-15.md b/docs/project-memory/plans/【实施计划】AGC首页策划补全入口-2026-09-15.md
new file mode 100644
index 000000000..c2d0fbf4f
--- /dev/null
+++ b/docs/project-memory/plans/【实施计划】AGC首页策划补全入口-2026-09-15.md
@@ -0,0 +1,20 @@
+Version: 1
+Status: active
+Date: 2026-09-15
+Parent Spec: 【里程碑】AGC首页策划补全入口-2026-09-15.md
+
+## 修改顺序
+
+1. 在 `view/home/index.tsx` 增加本地复选框状态与入口切换清理。
+2. 将游戏勾选状态映射到既有 `ProjectStartMode`。
+3. 增加启动模式纯函数测试,覆盖模式分流;首页显示边界和切换清理作为后续组件测试补充项。
+
+## 验证
+
+- AGC 首页相关定向测试。
+- AGC 前端 typecheck。
+- `npm run check:encoding` 与 `git diff --check`。
+
+## 当前验证边界
+
+本次已交付测试覆盖 `planning` / `direct-build` 模式分流;“策划补全”复选框的显示边界及切换创作类型后的状态清理尚未有组件级自动化测试,需后续补充 `HomeView` 测试时完成。
diff --git a/docs/project-memory/plans/【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md b/docs/project-memory/plans/【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md
new file mode 100644
index 000000000..bcc95ef90
--- /dev/null
+++ b/docs/project-memory/plans/【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md
@@ -0,0 +1,39 @@
+# AGC 统一错误诊断与验收反馈
+
+Version: 1.0
+Status: active
+Date: 2026-09-15
+Parent Spec: `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的“2026-09-15 AGC 统一错误事件、诊断落库与验收反馈”
+
+## 目标
+
+让 DirectProject 和共享 Agent Runtime 对失败使用同一份安全、可追踪、可恢复的错误事件合同;用户追问失败原因时能够读取上一轮证据;构建与浏览器验收只依据真实源码、manifest 身份和运行时证据判断。
+
+## 范围
+
+- 统一错误事件模型与项目内诊断落库。
+- DirectProject 失败 assistant 投影、下一轮诊断上下文和前端详情入口。
+- app-server 终态/超时、内置 MCP 工具错误和试玩 attempt 上限的分类。
+- 游戏源码模块素材扫描、manifest 身份映射与浏览器观察映射。
+- 定向 Rust/前端回归和现有 AGC 运行时门禁。
+
+## 不做
+
+- 不改变 Provider、External Editor 或 app-server 的 wire 协议。
+- 不放宽项目写锁、凭据隔离、工具白名单或完成门安全边界。
+- 不迁移历史项目文件;旧诊断只读兼容,新增事件使用新 schema。
+- 不把原始 stderr、请求正文或绝对路径展示给用户。
+
+## 验收标准
+
+1. 任一 DirectProject 失败均生成统一事件、稳定 `eventId` 和有界诊断引用;落库失败不覆盖原始错误。
+2. 失败安全投影写入对话历史,下一轮能读取 `publicText / code / stage / detailRef`,不会因追问而自动试玩。
+3. 结构化 failed turn、idle/hard timeout、transport close、MCP 参数错误和 `other` 各有稳定 code 与 recoveryHint。
+4. `attempt` 由客户端按回合分配并有上限;越界调用不会让回合继续等待。
+5. `game/src` 下模块引用已登记素材、Vite dist 稳定映射和浏览器实际观察均能通过;未登记素材仍失败。
+6. 脱敏测试证明 Token、Cookie、URL/query、私钥、宿主绝对路径和 stderr 私密内容不会进入用户文本。
+
+## 依赖
+
+- 现有 `direct_project_history`、`runtime_state`、`codex_app_server`、`direct_tool_bridge` 与浏览器 validation 证据。
+- 现有 DirectProject 诊断 sidecar 和 manifest 资源身份。
diff --git a/docs/project-memory/plans/【里程碑】AGC首页策划补全入口-2026-09-15.md b/docs/project-memory/plans/【里程碑】AGC首页策划补全入口-2026-09-15.md
new file mode 100644
index 000000000..04197703c
--- /dev/null
+++ b/docs/project-memory/plans/【里程碑】AGC首页策划补全入口-2026-09-15.md
@@ -0,0 +1,20 @@
+Version: 1
+Status: active
+Date: 2026-09-15
+Parent Spec: AGC 首页与 Agent Runtime 入口
+
+## 范围
+
+在首页“做游戏”输入框下增加“策划补全”复选框;勾选后复用现有 `planning` 启动模式进入策划 Agent Runtime。
+
+## 验收标准
+
+- 仅“做游戏”显示复选框。
+- 勾选时提交 `planning`,未勾选时提交 `direct-build`。
+- “做方案”原有 `planning` 行为保持不变。
+- 切换到其它创作类型时清除游戏专属勾选状态。
+
+## 不做项
+
+- 不新增 runtime 类型、后端接口或持久化字段。
+- 不改变现有策划 runtime 内部流程。
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index d35c4436d..bd8416d40 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -8740,3 +8740,23 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 决策(面板可关 + 非模态任务面板):两块生成浮层在提交期间放开 × / 遮罩 / Esc,提交按钮旁给「后台运行并关闭」;**关闭 ≠ 取消**(表单的 `await` 挂在该任务的终局上,不是面板生命周期)。新增「生成任务」非模态浮层(不铺遮罩、不做焦点陷阱、**不进** `isResourceCanvasFloatingPanelOpen` / `resourceCanvasHostGenerationPanelOpen` 遮挡判据),入口按钮 `aria-label="生成任务"`;已完成的条目按 `assetId` 复用既有 `pendingResourceFocusRef` 聚焦链定位素材卡。
- 影响范围:新增 `apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs`(+ `main.rs` 注册)、`src/features/resource-canvas/{resourceCanvasAssetGenerationTaskModel.ts,resourceCanvasAssetGenerationQueue.ts,ResourceCanvasAssetGenerationTasksPanelView.tsx}`;改动 `ResourceCanvasAssetGenerationPanelView.tsx` / `ResourceCanvasGenerationPanelView.tsx` / `src/view/project-development/index.tsx`;测试改动 `tests/{resourceCanvasAssetGenerationBackgroundClose.test.tsx,resourceCanvasAssetGenerationQueue.test.ts,resourceCanvasAssetGenerationTasksPanel.test.tsx}`(新增)与 `tests/appSurface/project-development.suite.ts`(把「每个入口一次 `generate_local_project_asset`」改成 `start_local_project_asset_generation` + `list_...` 轮询桩,载荷断言逐字不变)。**未动**:external v1 / OpenAPI、`packages/`、SpacetimeDB、音频入口的 pending-edit 账本语义、生成参数与 IPC 载荷字段名。
- 关联文档:`docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md`(§4 / §4a / §8)、`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`(S11a / §7.3)。
+
+
+## 2026-09-15 非 Suno 的 VectorEngine 能力切换到 Tiantoken
+
+- 决策:新增本地私密环境变量 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY`(图片 timeout 可独立配置),承载原 VectorEngine 的文本和图片;`VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 仅保留给 Suno 背景音乐与 Suno 音效。编辑器 SFX V2 继续走 ElevenLabs。
+- 实现边界:api-server 在创建状态时冻结 Tiantoken 配置,LLM、图片和旧版非 Suno 音频按该配置路由;Suno 的提交 / 轮询仍使用旧 VectorEngine 配置。旧 `vector_engine_*` 测试构造保留为 Tiantoken fallback,生产新环境变量优先。
+- 验证:Tiantoken `/v1/models` 返回 HTTP 200(126 个模型,含 `gpt-image-2`、`gpt-5.4-mini`);api-server Tiantoken 配置单测、platform-audio 全量测试、图片定向测试、前端 `apiClient` 定向测试、`npm run typecheck`、`npm run check:api-server-env`、编码 / fmt / diff 检查通过。未对音频上游提交生成任务,模型列表未列出 audio / Vidu 条目。
+
+## 2026-09-15 删除旧版 Vidu 音效实现
+
+- 决策:旧版 Vidu `audio1.0` 的 submit / poll / download builder、旧视觉小说与创建音效死代码、对应 platform-audio 请求类型和测试全部删除。历史素材的 `audio1.0` 展示与定价兼容数据保留;新编辑器音效仍只走 ElevenLabs,Suno 音乐链路不变。
+- 验证:platform-audio 全量测试 55 条通过,api-server `cargo check` 通过,fmt / 编码 / diff 检查通过;仓库现役源码不再包含 `VIDU_AUDIO_MODEL`、`AudioTaskKind::SoundEffect` 或 Vidu submit/poll 实现。
+
+## 2026-09-15 AGC 统一错误事件与项目诊断落库
+
+- 背景:DirectProject 的 app-server 超时、MCP 参数错误、浏览器完成门误判和普通 Agent Runtime 失败分别投影为短文案;失败正文没有稳定落库,下一轮模型看不到上一轮失败证据,用户追问原因时可能继续试玩或重复修改。
+- 决策:新增 `agent/runtime_error.rs` 作为统一错误事件与有界诊断 sidecar 边界。DirectProject 失败、Agent Runtime terminal failure 均持久化 `.agent/runtime/errors/.json`,并将脱敏 assistant 终态写回 `project.jsonl`;前端只通过 `read_agent_runtime_error_detail` 读取脱敏详情。旧 `failure.json` 保留兼容,不把原始 stderr、凭据、URL/query、宿主绝对路径写入用户文本。
+- 决策:错误使用稳定 `source / stage / code / retryable / publicText / recoveryHint / detailRef` 字段;试玩 attempt 越界返回终态错误并停止继续等待。素材完成门扫描实际 npm 源码模块,并把 manifest 中合法的自定义 art-spritesheet 路径纳入候选,构建和浏览器观察仍需通过既有完成门。
+- 关联规范:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的“2026-09-15 AGC 统一错误事件、诊断落库与验收反馈”;开发期计划见 `docs/project-memory/plans/【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md` 与对应实施计划。
+
diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md
index 3db0f4784..35e266485 100644
--- a/docs/project-memory/shared-memory/pitfalls.md
+++ b/docs/project-memory/shared-memory/pitfalls.md
@@ -1,5 +1,9 @@
# 踩坑与排障记录
+## Windows 已登记生图资产未刷新
+
+Direct 工具桥会 canonicalize 项目根,事件中的路径可能带 `\\?\` / `\\?\UNC\`,而前端项目路径仍是普通盘符或 UNC。失效监听不能直接比较原始字符串;识别为同一项目后,用当前项目路径重读 manifest,保留项目切换与 revision 门禁。普通 `agc_generate_image` 成功提交也必须发出失效通知,不能依赖整轮 Agent 结束。回归需覆盖两种 Windows 前缀、其它项目事件拒收,以及 Agent 尚未结束和后续失败时已登记图片卡片仍可见。
+
## 2026-09-14 严格 IPC 桩缺登记新命令时,症状可能是「unhandled rejection + 不相干的提示断言」,而不是同一处报错
- **现象**:`ProjectDevelopmentView` 新增「项目打开时读生成任务账本」(`list_local_project_asset_generations`)后,两个**别的关注点**的用例同时红:`resourceCanvasManualLayout.test.tsx` 报 `AssertionError: expected [ Array(1) ] to deeply equal []`(严格桩把新命令记进 `unexpectedCommands`),并伴随 7 条 `Unhandled Rejection: TypeError: Cannot read properties of undefined (reading 'map')`;`appSurface/project-development.suite.ts` 的「布局读时提示」用例则因为新命令被当成 unexpected invoke 抛错、触发了新的提示条,导致 `queryBySelector('.game-resource-live-notice')` 断言失败。
@@ -22,6 +26,11 @@
- **验证**:`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts` 新增两条——「探测脚本使用 netstat 且不再出现 Get-NetTCPConnection」「命令行按 PID 缓存后随请求下发、TTL 过期即失效」;定向 vitest 55 passed。本机实测:不含 SpacetimeDB 端口的探测 368 ms(原约 22 秒)、含 SpacetimeDB 端口 3.8 秒、命中缓存 368 ms;`npm run agc:serve` 的 `starting backend stack` → `backend ready` 由约 80 秒降到 16.7 秒(其中归属校验只占 4.4 秒,其余是 SpacetimeDB + api-server 的真实启动时间)。
- **残留**:这台机器上首次 WMI 调用本身仍是秒级(曾见 18 秒),所以「新 SpacetimeDB PID 的第一次探测」仍可能多花几秒;命令行在进程存活期内不变,TTL 只用来限制 PID 复用造成的误判窗口。
- **关联**:`apps/ai-game-creator-shell/scripts/start-dev-stack.mjs`(`readWindowsPortOwnerIdentities`)、`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts`、`apps/ai-game-creator-shell/scripts/dev-windows-process.mjs`(退出清理仍走整份 `Win32_Process` 快照,自带 1 秒缓存,不在本次范围)。
+## 2026-09-15 AGC JSON API 的响应体也必须有等待上限
+
+- `fetchClientHttp` 的超时只覆盖请求到响应头返回;随后直接等待 `response.text()` 仍可能无限挂起。模型目录共用一个在途 Promise,响应体卡住会使后续刷新复用同一挂起请求、选择器持续忙碌。
+- 成功 JSON 与错误响应体均复用 `readClientHttpResponseText` 的 15 秒上限;超时后保留最后一次有效目录并释放在途请求,手动重试重新发起请求。迟到的响应不得覆盖重试获得的新目录。
+- 排查时区分接口未挂载(404)、未授权(401)、网络或响应体超时以及刷新无变化但缺少反馈;不能仅凭客户端启动 IPC 回退警告判断刷新失败原因。
## 2026-09-14 AGC 壳 Rust 套件按「一片一 job」拆分,且分片必须自校验覆盖
@@ -5587,3 +5596,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- 原因:健康检查只能证明“有服务响应”,不能证明服务属于当前工作树;旧 `.app/dev-stack.json` 可能没有当前 `repoRoot`、`instanceId` 和服务级 dataDir 身份。
- 处理:先读取 `.app/dev-stack.json`,核对顶层 `repoRoot + instanceId`,再核对服务 `repoRoot + instanceId + dataDir + pid + port`;AGC Vite marker 还必须带 `repoRoot + processId + port`。任何字段缺失或不匹配都拒绝静默复用,改为启动当前工作树自己的服务或明确提示清理。
- 验证:`scripts/dev.test.ts`、`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts` 覆盖 snapshot identity 和旧状态拒绝复用;运行时记录实际端口、进程命令行和 dataDir,不要只记录 HTTP 200。
+
+## 2026-09-15 登录失败提示必须保留接口返回原因
+
+- **现象**:账号登录失败时页面只显示“登录失败”,用户无法判断是手机号、验证码、密码还是服务状态问题。
+- **原因**:统一错误解析器只处理标准 `error.message/details` 结构;部分网关或旧兼容响应使用字符串 `error`,解析失败后回落到登录接口传入的通用文案。
+- **处理**:`parseApiErrorMessage` 同时支持字符串 `error`,标准嵌套结构保持原有优先级;未知或空响应继续使用通用兜底。
+- **验证**:`src/services/apiClient.test.ts` 新增字符串错误响应回归用例,定向测试 32 项通过,`npm run typecheck` 通过。
diff --git a/docs/project-memory/shared-memory/project-overview.md b/docs/project-memory/shared-memory/project-overview.md
index dcc0aa077..311f8b2e3 100644
--- a/docs/project-memory/shared-memory/project-overview.md
+++ b/docs/project-memory/shared-memory/project-overview.md
@@ -51,6 +51,8 @@ SpacetimeDB crate、SDK、CLI / standalone 与生成 bindings 按 `2.8.3` 对齐
## AGC DirectProject 与 UI workflow
+- AGC 安装产品名统一为“陶泥儿”,由 Tauri `productName` 控制安装项、快捷方式与 EXE 产品描述;Windows 内置 Codex 安装到顶层 `coding-agent/win-x64/`,打包资源映射与运行时查找路径必须一致。内部可执行文件名与应用 identifier 保持稳定。
+
- 新 Web 游戏为 `game/` 下的 npm + Vite + Phaser 4.2.1 工程,使用包导入且允许其它依赖;npm 预览与导出只读取 dist,运行素材需纳入构建。单 HTML → Phaser 迁移固定走 DirectProject:文件落盘后先用受控 `project.bootstrap` 在 `game` 执行无参数 `npm install`,再用支持相对 cwd 的 `project.verify` 构建并确认 `game/dist/index.html`,已有单 HTML/Godot 不通过 JSON Generator 伪装成 npm 工程。
- 通用 Agent Rust 分层为 `agent-runtime-core`(catalog、执行生命周期、ToolHost/spawn/all-join/Provider 契约)、`agent-runtime-orchestration`(动态无环任务图、ready、依赖波次、返工下游闭包和受限自主扩图提案)与 `platform-agent` 游戏适配器;循环返工通过新 pass / epoch 表达,不在单张依赖图中建立回边。LLM 可经宿主结构化 function call 提出新增节点/边,编排层只生成经校验的新候选图,epoch 与持久化仍由宿主掌控。
diff --git a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md
index b8e1a5335..48f119e49 100644
--- a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md
+++ b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md
@@ -7,6 +7,8 @@
- `GET/PUT /admin/api/agc-models` 仅 owner 可用,返回完整配置;PUT 携带上次读取的 revision,冲突拒绝覆盖。
- `GET /api/llm/models` 返回启用项的 `id/displayName`、`defaultModelId` 和目录 `revision`,不返回实际模型名、Router 目录、凭据或能力原始数据。
- 客户端缓存最近 `revision`,在项目切换 / 对话表面挂载 / 下拉展开 / 窗口聚焦时条件刷新:`revision` 未变化不更新界面,同一时刻只保留一个在途请求,刷新失败保留上一次有效目录与本地选择。发起对话前用同一份快照校验所选模型仍启用,已停用或删除则回退默认模型并提示。
+- 手动刷新立即显示进行中状态;真实刷新成功后显示完成反馈,即使 `revision` 未变化也有反馈。失败沿用有效缓存时仍显示失败,不能报告刷新成功;HTTP 状态和超时使用可辨认的提示。
+- 模型目录与其它客户端 JSON API 的成功、失败响应体读取均复用 `readClientHttpResponseText` 的 15 秒上限;响应头已返回但响应体卡住时必须结束本次等待、释放目录在途请求并允许重试,迟到的响应不得覆盖新目录。
- AGC Responses 请求的 `model` 是稳定目录标识。服务端按当前目录映射实际模型名;未知、停用项拒绝,不回退其它模型。旧客户端无 AGC 标记时使用后台默认项。
- 输入框右下角选择模型,只显示别名;选择保存到客户端配置 `selectedModelId` 与 `selectedModelIsDefault`(当前选择是否来自平台默认项),从下一次请求生效。加载失败或选项停用时禁用提交并允许刷新,不显示实际 ID 作为兜底文案。
- `selectedModelIsDefault` 为真表示选择由平台默认项驱动(首次进入、默认项变化、所选模型失效回退),后台默认项变化时客户端跟随切换并提示;用户手动选择后置为假,不再被默认项变化覆盖。
@@ -19,4 +21,5 @@
- 目录领域校验、未知/停用模型拒绝、客户端响应不包含实际模型名。
- 后台鉴权、持久化 revision 冲突处理;客户端选择保存后重新读取,设置保存不覆盖选择。
- 目录 `revision` 条件刷新与并发触发去重、发送前回退默认模型、刷新失败可恢复。
+- 响应体超时保留有效缓存、再次刷新重新请求、迟到响应不覆盖新目录;手动刷新进行中、同版本成功与缓存兜底失败反馈。
- AGC/admin-web 类型检查与定向测试、编码检查、Rust 定向检查、schema 一致性与 diff 检查。
diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
index 2a3504a0e..3f091c118 100644
--- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
+++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
@@ -265,7 +265,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创
- 模式合同:客户端 AppData 配置新增全局 `agentMode`,只接受 `codex_cli / provider`。缺省和新安装默认使用 `codex_cli`,原有 HTTP LLM Provider 路径完整保留并可显式切回 `provider`;切换只影响下一次节点请求,不新增 Runner、任务图、会话库、配置库或业务事实源。
- 调度边界:正式 DAG、manifest、Agent task/session/run 身份、队列、锁、委派、all-join、完成门、Provider lifecycle、持久 retry/handoff 与 `needs-reconciliation` 继续由现有 AGC Runtime 掌控。每个被调度节点在 `codex_cli` 模式下直接启动一次非交互 `codex exec` 充当该节点的推理 Agent;Codex 返回当前 Runtime 广告函数的结构化调用,Runtime 仍是唯一 ToolHost,不允许 CLI 自己写项目、执行命令、调用 MCP 或形成第二套 revision / verification 真相。
- 安装包侧车:Windows x64 release 固定随 Tauri resource 打包 `@openai/codex@0.147.0` 的原生 `codex.exe`;Rust build script 从 AGC 子包锁定依赖 stage 到 resource,并写入版本与 SHA-256 清单。Windows 侧车映射只写入 `tauri.windows.conf.json`,通用 `tauri.conf.json` 不得让 Linux / macOS 构建依赖未生成的 Windows 二进制。运行时只在文件摘要和 `codex-cli` 版本同时匹配清单时优先选内置侧车;缺失、损坏或版本漂移时跳过它,按既有 npm 安装、PATH 顺序回退。安装包同时携带 Apache-2.0 第三方声明;API Key、`auth.json`、Cookie、Token、用户 `CODEX_HOME`、用户配置和项目数据绝不打包。
-- Windows x64 release 安装包只生成 NSIS,不生成 MSI:`tauri.windows.conf.json` 的 `bundle.targets` 固定为 `["nsis"]`,通用配置继续保留其它平台的默认打包目标。
+- Windows x64 release 安装包只生成 NSIS,不生成 MSI:`tauri.windows.conf.json` 的 `bundle.targets` 固定为 `["nsis"]`,通用配置继续保留其它平台的默认打包目标。安装后的产品名、开始菜单 / 桌面快捷方式和 EXE 产品描述统一由 `tauri.conf.json` 的 `productName: "陶泥儿"` 生成;应用 identifier 与内部可执行文件名保持稳定。内置 Codex 资源安装到顶层 `coding-agent/win-x64/`,运行时从同一路径查找 `bin/codex.exe` 与 `manifest.json`;仓库 staging 仍使用 `resources/codex/win-x64/`,包内子目录、组件名、版本和完整性校验保持原合同。
- CLI 安全边界:CLI 固定使用 argv 启动,禁止 shell 拼接;工作目录使用本次请求专用的空临时目录,不把游戏项目绝对路径写入 prompt、stdout、stderr 或持久记录。调用固定使用 ephemeral、忽略用户配置和 exec rules、read-only sandbox、never approval,并关闭 Codex shell tool;只继承 CLI 运行和认证所需的最小环境,显式移除宿主 `CODEX_API_KEY`。用户级 Codex 登录态继续由本机 Codex 自己读取,API Key、auth 文件、Cookie、Token、`CODEX_HOME` 私有内容不得复制到项目配置、Runtime sidecar、Agent DB、conversation 或日志;stdout / stderr 无换行时也受硬上限约束,stderr 诊断只记录固定分类、字节数和 SHA-256。
- 协议边界:Runtime 把既有 `LlmRunRequest` 的消息和当前函数目录编码为有界 prompt,并从同一函数 JSON Schema 生成 Codex structured-output schema。CLI 输出转换为现有 `LlmRunResponse / LlmToolCall` 后,继续经过 native tool / MCP 参数校验、动作上限、权限、pending、receipt、验证与格式修复链;最终回复仍走唯一提交路径,不新增平行响应协议。
- 取消与恢复:Codex 子进程绑定当前 Provider request lifecycle,取消、暂停、Runner draining 或 GUI owner 丢失时终止并回收当前进程;started 后没有可信终态仍沿现有 Provider reconciliation 处理。`agentMode`、CLI 可执行身份和影响输出的 Codex 参数进入 `providerConfigFingerprint`,模式切换不得消费另一模式遗留的 retry/handoff。
@@ -1167,7 +1167,7 @@ game-project/
- `.agent/manifest.json` 的存储写边界使用同目录持久文件锁跨线程、跨进程串行化;锁必须覆盖旧 manifest 读取、不可变版本前缀校验、临时文件安装和安装后回读一致性校验。锁文件拒绝符号链接、非普通文件和异常所有权 / 硬链接;Windows 使用不共享写句柄,Unix 使用 `O_NOFOLLOW + flock`。旧快照在新版本安装后只能被拒绝,不能覆盖已追加版本。
- 后台 Agent 的 manifest 变化以共用 Runtime 状态投影 / 终态 emitter 作为失效因果点:`game-creator-agent-runtime-update` 的 Rust / TypeScript DTO 固定携带 `manifestInvalidated`,且 App 必须在 Supervisor、selected agent、session 和 run 身份的任何 early return 之前处理失效。GUI 进程内 Runtime 直接发该事件;External Runner 是独立进程、没有 GUI `AppHandle`,因此 Runner 协议 v5 的 `runner.attach_gui_owner` 必须登记 GUI 创建的随机 loopback 端口和 64 位随机令牌,Runner 的同一 emitter 通过受令牌保护的短连接转发 `game-creator-manifest-invalidated`。两条路径都只传项目路径与 Agent 身份,不复制 manifest,也不靠轮询补偿。
-- Direct Codex 不伪造普通 Agent Runtime state。每张平台美术在本地文件与 manifest 提交成功后,统一通过 standalone `game-creator-manifest-invalidated` 发送 `projectPath + direct-codex-art`;只读恢复的已付费源图同样在 `register_local_asset_at` 成功后发送,下载、解码、文件写入或登记失败时不得发送成功失效。前端仍把 `game-creator-agent-progress` 仅用于进度文案;Direct Codex 整体命令成功、失败或超时 reject 后都追加一次 manifest 最终对账,只有完整成功才启动本地预览。
+- Direct Codex 不伪造普通 Agent Runtime state。每张平台美术在本地文件与 manifest 提交成功后,统一通过 standalone `game-creator-manifest-invalidated` 发送 `projectPath + direct-codex-art`;普通 `agc_generate_image` 同样在生成通道成功返回后、工具结果组装前发出通知,不能只覆盖标准美术包。只读恢复的已付费源图同样在 `register_local_asset_at` 成功后发送,下载、解码、文件写入或登记失败时不得发送成功失效。失效事件匹配当前项目时统一 Windows 盘符、UNC 与对应 verbatim 前缀的写法,实际重读始终使用当前项目保存的路径;该比较仅用于刷新提示,不替代后端路径与权限校验。前端仍把 `game-creator-agent-progress` 仅用于进度文案;Direct Codex 整体命令成功、失败或超时 reject 后都追加一次 manifest 最终对账,只有完整成功才启动本地预览。
- App 收到当前项目的 Runtime / relay 失效后重新调用 `get_local_game_manifest`。重读按项目 single-flight 合并事件风暴;读取中再到达失效只追加一轮串行重读,不并发提交同项目响应。应用结果同时校验组件仍挂载、当前项目路径和项目 scope version;项目切换、组件卸载或旧 scope 的迟到响应不得覆盖新项目。Project Supervisor 对外发布前以“revision 前读 -> manifest -> revision 后读”取得一致快照,再通过 `onManifestChange(projectPath, manifest, metadata)` 携带 `projectId + revision + source`;启动器按 `projectPath + projectId` 只接受更高 revision,同 revision 只接受内容一致的重复,旧轮询和同 revision 分叉都不得覆盖。资源列表、依赖图输入、任务状态、运行入口和正式版本卡必须在当前页面实时重投影,不要求关闭或重开项目。集成测试记录“事件未重新打开项目”的调用基线前,必须先等待项目写入最近列表后触发的只读目录状态刷新完成,不能把这项合法后台检查误算成失效事件副作用。
- `.agent/agent.db` 有界尾部读取报告截断时,审计 producer 映射失败关闭,不生成基于不完整审计的 producer、task flow 或对应任务环。前端收到截断 DTO 时只剔除 `producerAssignments`、`taskFlows` 与对应 `cyclicTaskIds`;Rust 根据当前 manifest、精确资源引用和仍可信任务深度下限返回的 `dependencyDepths` 继续保留,前端只校验资源仍存在且深度为非负安全整数,不得自行重算或压平权威深度。精确引用边、reference connection index、`cyclicResourceIds` 与 unresolved references 同样继续保留。
-- 资源依赖 SVG 继续作为不可交互装饰层隐藏,但 dependency 画布通过 `aria-describedby` 提供当前可见精确引用和任务流的文本等价列表。中央资源聚焦按稳定 `resourceId` 驱动焦点状态:仅 `null -> id` 或 `idA -> idB` 聚焦详情 region,同一 ID 的 manifest 重投影不得抢走音频、视频、链接或关闭按钮焦点;显式收起和 Escape 恢复画布滚动并优先聚焦原触发卡片。聚焦资源被删除时清理 stale focused / selected ID,关闭详情并把焦点落到资源搜索框;项目切换或运行视图切换清除旧恢复意图,不得恢复旧项目卡片。橙色引用线及箭头使用对 `#fffdfa` 画布达到至少 `3:1` 的颜色。
@@ -1379,3 +1379,15 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过
## 2026-09-14 新游戏策划到真实美术接入的连续交付
DirectProject 在收到完整游戏策划或游戏制作请求后,必须把视觉素材作为同一交付链路处理:先读取当前项目已登记资源;策划案包含角色、对象、背景、特效、界面或其它视觉实体且现有资源不满足时,Codex 必须在同一游戏实现任务中调用审核的 `agc_tools` 生图或编辑工具,读取返回的资源身份与相对路径,把真实产物接入游戏源码,再构建并验证实际渲染。生成了素材但源码仍使用 emoji、CSS 形状或临时占位图替代策划要求的视觉元素,不能报告游戏完成。只有策划明确不需要视觉素材,或现有已登记素材完全满足需求时,才允许跳过生图;图片生成、处理、登记和接入不因用户没有重复输入“生图”而降级为可选建议。
+
+## 2026-09-15 AGC 统一错误事件、诊断落库与验收反馈
+
+DirectProject、Agent Runtime、Provider、app-server、内置 MCP、命令执行、构建和浏览器试玩的失败必须先转换为统一的 `AgentRuntimeErrorEvent`,再分别投影到用户消息、运行面板和项目诊断文件;业务模块不得自行拼接只有一句“执行失败”的终态文案。统一事件至少包含 `schemaVersion / eventId / clientTurnId / source / stage / code / retryable / occurredAt / elapsedMs / publicText / recoveryHint / detailRef`,其中 `publicText` 是脱敏后的可行动摘要,`detailRef` 指向项目内有界诊断记录;Token、Cookie、URL/query、私钥、宿主绝对路径、原始请求正文和未脱敏 stderr 不得进入对话或用户可见文本。
+
+项目内统一落库目录为 `.agent/runtime/errors/`,事件记录采用幂等 JSONL 或 JSON sidecar;写入失败不能覆盖原始业务错误,但必须在事件中标记 `persistenceFailed`。DirectProject 对话历史必须持久化本轮用户消息、终态错误的安全 assistant 投影和诊断引用,使下一轮能够读取上一轮失败证据。前端只展示 `publicText`,点击详情后按 `detailRef` 读取有界、脱敏的诊断,不直接展示私有 `detail`。
+
+`turn/completed` 等待超时必须区分 `idle-timeout`、`hard-timeout`、`transport-closed`、`failed-turn`、`invalid-terminal` 和 `tool-error`;收到内置工具参数错误后必须结束当前工具调用并进入可行动终态,不能继续使用越界的试玩 `attempt` 或无限等待。试玩次数由客户端按当前 `clientTurnId` 持久化分配,模型不能自由递增;超过上限必须返回一次终态并停止回合。
+
+游戏素材完成门必须扫描实际参与构建的 `game/` 源码模块,读取 manifest 的登记身份与相对路径,并把构建后的 URL 映射回登记身份。固定素材路径只能作为兼容候选,不能作为唯一准入。已登记且被真实源码引用、被构建纳入并在浏览器证据中观察到的资源通过;未登记、来源不匹配或只存在于设计规范中的资源继续失败关闭。
+
+验收至少覆盖:普通错误、结构化 app-server failed turn、idle/hard timeout、MCP 参数错误、历史落库失败、脱敏边界、下一轮诊断上下文、源码子模块素材引用、Vite 构建 URL 映射以及试玩次数上限。统一错误事件和诊断落库先于 UI 美化或增加重试预算;不能用延长超时、删除完成门或把失败投影为成功来规避问题。
diff --git a/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md b/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md
index 956aaa46f..ce891e8fc 100644
--- a/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md
+++ b/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md
@@ -344,7 +344,7 @@ UI 使用“批准”和“继续修改”两个文字按钮,分别配 Lucide
项目运行模式通过 `.agent/runtime-mode.json` 持久化。新建策划项目在进入工作台前写入 `design`;“做成游戏”先登记 `design_artifacts` 中尚未登记或登记信息已变化的策划产物,若 manifest 实际发生变化则在同一项目写锁范围内推进一次项目 revision,再写入 `game`。重复切换不重复登记或推进 revision。重新打开项目时通过 `get_design_agent_runtime_mode` 读取模式,完成后一次性挂载对应工作台,不能先挂载 GameAgent 再切回策划。旧项目缺少模式文件但存在策划会话时按 `design` 恢复;明确的 `game` 标记优先于残留策划会话。无模式也无策划会话的项目仍使用游戏工作台。
-开发构建的策划工作区页头在“刷新”旁提供“快速准备做成游戏测试”按钮。该入口与策划 Debug 日志共用 `GENARRATIVE_AGC_DESIGN_DEBUG=1` 开关:开关未启用时按钮不显示,命令也不可执行。入口仅进行本地 fixture 和会话状态写入,不调用 Provider;完成后自动刷新文件树与阶段,通过 `design-agent-update` 状态事件同步右侧审批/阶段操作区。随后仍需点击正常的“做成游戏”按钮执行资产登记与运行时切换。
+开发构建的策划工作区页头在“刷新”旁提供“快速准备做成游戏测试”按钮。策划 Debug 日志和该入口共用运行时环境变量 `GENARRATIVE_AGC_DESIGN_DEBUG=1`;前端通过 Tauri 查询当前构建是否具备 Debug 入口,只有 Debug 构建且变量启用时显示按钮,命令也不可执行。入口仅进行本地 fixture 和会话状态写入,不调用 Provider;完成后自动刷新文件树与阶段,通过 `design-agent-update` 状态事件同步右侧审批/阶段操作区。随后仍需点击正常的“做成游戏”按钮执行资产登记与运行时切换。
## 15. 策划 Agent reasoning 展示
diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
index becec30de..f21c0516b 100644
--- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
+++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
@@ -258,8 +258,9 @@ npm run check:server-rs-ddd
- 已有图片完美像素化:登录态 `POST /api/editor/images/pixel-art-snaps` 使用 `sourceImageSrc` 承载 `objectKey / resourceId / assetId` 候选稳定引用,要求 `projectId / canvasCompletion` 且 `canvasCompletion.dialogId` 必须非空,并可携带 `sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel`;BFF 必须在下载前将候选解析为当前 owner 已登记的私有 OSS object key,并校验 project / resource / asset 归属,拒绝 `data:` / `blob:`、signed URL、普通外链和音频、视频、图片序列等非静态栅格输入。归属校验有两条等价路径:带 `sourceResourceId` 且 `sourceImageSrc` 能免查确认指向同一张图(本身即该 objectKey 或就是该 resourceId)时,来源资源已随 owner-scoped 项目读取完成鉴权,直接断言 `resource.ownerUserId` 与 `resource.projectId` 后取用其 objectKey,不再按注册 ID 做全账号项目与素材库扫描;两个字段指向不同图片必须直接拒绝而不是退回扫描。其余情况仍走完整解析。跨记录的 asset_kind 扫描随扫描一并省略,按 `(bucket, objectKey)` 的存储类型点查两条路径都保留,动图仍由下载后的静态编码门禁按实际字节拒绝。编码门禁只接受静态 PNG / JPEG / WebP,明确拒绝 GIF、带 `acTL` 的 APNG 及带动画标志 / `ANIM` / `ANMF` chunk 的 WebP。处理复用 `platform-image` 纯内存 snapper、单边 `10000` 与总像素 `8294400` 上限,并发控制分两层:端点级并发闸最大 `4`、等待队列上限 `2048`,在首次 IO 之前取得,队列满返回 `503` 并带 `Retry-After`,等待超预算返回 `504`;内层是与生成风格共享的进程级 CPU 并发 `2`。30 秒总预算从 handler 入口起算,覆盖归属校验读取、OSS 下载、两层排队与规整全过程。OSS 读写共用带 `connect 10s / total 120s` 的进程级 HTTP 客户端。strict 与生成风格使用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码,唯一差异是横纵两轴都未检测到步长时,不执行 `min(width,height)/64` 统一网格兜底而返回不适用。任一轴已检测到步长时,两条路径行为和输出必须一致。读取、解码、校验、排队、规整、PNG 编码任一步失败 / 超时 / 不适用时,在最终持久化前返回错误,OSS PUT、asset object、project resource、账号素材和画布 layer 增量都必须为零。成功结果保留源图,只对最终 PNG 做一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`;源图已有正式 project resource 时,结果资源以 `source_resource_id` 关联该资源,再按 `canvasCompletion` 尝试写入一个右侧派生 layer。completion 读取的权威 dialog 已删除时沿用现有语义跳过画布写入,不得用请求中的旧 placeholder 复活图层;已经成功落库的 resource / asset 可以保留。客户端回包时若本地 dialog 已删除,不应用完成快照;现有布局 CAS 没有 deletion tombstone,completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。客户端不得为该 unsafe POST 配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发送后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前拒绝请求后的既有认证恢复继续保留。结果未知时先 GET 权威项目 / 素材快照。
- 完美像素持久化边界:所有可判定的稳定引用、owner、项目、来源资源、素材类型、静态编码、元数据、网格适用性、排队、CPU、解码、规整和编码校验都必须在首个最终 PNG PUT 前完成。handler 先用纯 prepare 生成精确 object key 和候选 project resource,再调用只读 `preflight_editor_pixel_art_result_and_return`;preflight 校验自定义素材目录归属(尚未创建的默认目录允许通过)、复用权威 canvas completion planner,并对 legacy / structured 候选布局执行 2 MiB 总量和 512 KiB 单项门禁。preflight 与后续 PUT / HEAD / 原子 persist 共用同一份 60 秒绝对 deadline;preflight 失败或超时不得发送 PUT,也不得附加 `resultPersistenceStarted`。最终 PNG 的 OSS PUT / HEAD 仍位于数据库事务外;确认上传结果后,`asset_object + editor_project_resource + editor_asset + optional canvas completion` 必须由 `persist_editor_pixel_art_result_and_return` 在一次 `try_with_tx` 中原子提交,handler 不得先调用 `confirm_asset_object` 或三个旧分段 helper。最终 procedure 必须重新校验目录、布局、幂等身份和 revision,不能把 preflight 结果当成提交凭证。preflight 不创建锁或 reservation,因此通过后若目录或画布被并发修改,最终事务仍可能在 PUT 后拒绝并留下无引用 OSS object;当前不做破坏性删除补偿或历史孤儿清理。该原子保证只覆盖本次结果事实;前置 owner-scoped 项目 / 素材读取仍可沿用既有默认 canvas / folder 懒建语义,不把整个请求声明为数据库只读。operation 以规范化 `canvasCompletion.dialogId` 表示并由 owner / project 限定作用域;task ID 可由前端直接推导,object / resource / asset ID 按同一 operation 稳定派生,object key 必须包含覆盖规范输入、来源 / 输出摘要与算法版本的 64 位 fingerprint。owner-scoped 项目快照发现同一 operation 的稳定 result `resourceId` 时,HTTP 路径必须在来源解析、OSS 下载、规整、preflight 和 PUT 前直接返回 `409`,携带 `operationResultAlreadyExists=true` 与 `resultResourceId`,并由客户端 GET-only 对账;本次请求不得附加 `resultPersistenceStarted`。`AlreadyApplied` 仅在 early guard 与最终 procedure 并发相遇时作为底层幂等兜底,复用既有 commit 且不得再次执行 layout CAS 或推进 revision;同 operation 输入漂移、稳定 ID / object location 冲突或 object/resource/asset 只有部分存在时必须整笔失败关闭并映射 `409`,不得补写或覆盖第一次事实。权威 dialog 已删除时 object/resource/asset 仍在同一事务提交,canvas / revision 不变并返回 `DialogMissing`。HTTP timeout/drop 不能撤销已经发往远端的 procedure,因此首个 PUT 后仍设置 `resultPersistenceStarted=true` 并按稳定身份对账;该标记不再表示数据库可能部分提交。
- 完美像素 unknown 与并发闸测试边界:上一条末句“结果未知时先 GET 权威项目 / 素材快照”的旧表述已撤回,项目 GET 才是唯一结果 verdict;素材刷新只允许在项目终态后 best-effort 触发,不能参与成功判断。无 dialog 只有同时存在匹配稳定 task 的唯一 resource 时才是 asset-only 成功,否则保持 unknown。过期预算用例只断言返回 `504`,不得读取进程级 `EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH` 的 before/after;queue guard 的 Drop 归还由独立用例覆盖。不得用相对断言、`--test-threads=1` 或全局串行锁掩盖并行竞态。
-- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;`platform-llm` 文本请求默认走 Responses,旧 `/api/llm/chat/completions` 代理和少数旧运行态聊天显式保留 Chat Completions 兼容协议;创意 Agent `gpt-5` Responses / Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/responses`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。
-- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5.4-mini 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。
+- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;`platform-llm` 文本请求默认走 Responses,旧 `/api/llm/chat/completions` 代理和少数旧运行态聊天显式保留 Chat Completions 兼容协议;创意 Agent 文本链路使用 Tiantoken,使用 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 Tiantoken base URL 规范化到 `/v1`。VectorEngine 只保留给 Suno 音乐任务;后续排障时优先确认 Tiantoken `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。
+- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路读取 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY`,通用 `/api/llm/chat/completions` 代理可使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.tiantoken.com/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时由 Tiantoken 凭据承接。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent 客户端来源。
+- 当前 provider 路由:`TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 承载原 VectorEngine 的文本和图片能力;旧版 Vidu 音效代码已移除;`VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 仅保留给 Suno 背景音乐及 Suno 音效任务;新编辑器 SFX V2 继续独立使用 ElevenLabs。
### 平台适配器:`platform-llm` 公共能力与三协议工具契约
diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
index 7855d1a72..066bc0a5d 100644
--- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
+++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
@@ -214,7 +214,7 @@ spacetime sql "SELECT * FROM runtime_setting LIMIT 1" --server http:/
本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.8.3`,CLI / standalone commit 固定核对为 `8e410d2842147bd8e5a32a9589cc00c19f7478e2`。若版本或 commit 错配,procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`,api-server 最终表现为现役 settings、editor project 或 profile procedure 超时。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml` 的 `spacetimedb = "..."`;其它版本可执行 `spacetime version install && spacetime version use `,升级后重启 `npm run dev:spacetime` 再重试。当前 `scripts/dev.mjs` 会把 tool version 和 commit 一起写入 `dev-spacetime-tool-version`,启动新 standalone 与复用已有本地进程时都要求 `2.8.3 + 8e410d28...` 同时匹配;旧版本或旧单行版本记录会拒绝复用并要求重启。2.6.1 修复了 procedure context 中调用者 `Identity` / `ConnectionId` 始终为空的回归,依赖 `ctx.sender` 鉴权时必须同时确认宿主已升级。
-本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查图片编辑器 VectorEngine 生成链路时,确认 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 是单次 attempt 的配置上限,默认 `1000000`;配置加载层允许显式值低于该默认值,不再在读取环境变量时强制抬高。业务模型和 VectorEngine provider 首选请求都使用 `gpt-image-2`,符合条件时才回退到兜底模型 `gpt-image-2-c`;图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`,`api-server` 只做编辑器请求编排、OSS / asset 持久化、计费和失败审计落库。`platform-image` 会在 JSON 生成和 multipart 编辑请求发送前按同一 GPT-image-2 family 规则归一显式像素尺寸;若请求发送失败,先按同一 `request_id` 查看 provider 日志与 `external_api_call_failure.metadata_json.errorSource`,当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。
+本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查图片编辑器 Tiantoken 生成链路时,确认 `TIANTOKEN_BASE_URL`、`TIANTOKEN_API_KEY` 和 `TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine 配置仅保留给 Suno 音乐任务。`TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` 是单次 attempt 的配置上限,默认 `1000000`;配置加载层允许显式值低于该默认值,不再在读取环境变量时强制抬高。业务模型和 Tiantoken provider 首选请求都使用 `gpt-image-2`,符合条件时才回退到兜底模型 `gpt-image-2-c`;图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`,`api-server` 只做编辑器请求编排、OSS / asset 持久化、计费和失败审计落库。`platform-image` 会在 JSON 生成和 multipart 编辑请求发送前按同一 GPT-image-2 family 规则归一显式像素尺寸;若请求发送失败,先按同一 `request_id` 查看 provider 日志与 `external_api_call_failure.metadata_json.errorSource`,当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。
编辑器 ElevenLabs 音效生成只从服务端读取 `ELEVENLABS_BASE_URL`、`ELEVENLABS_API_KEY` 和 `ELEVENLABS_REQUEST_TIMEOUT_MS`,timeout 默认 `180000ms`;base URL 或 Key 缺失时失败关闭,不回退 Vidu。生产 API 与 external-generation worker 通过共享 API env 取得同一配置,模板见 `deploy/env/api-server.env.example`;Key 不得进入 Web/Vite 环境、命令参数、日志、fixture 或仓库。普通测试只使用 loopback mock,禁止把真实付费请求作为 T3 自动验收。
@@ -790,14 +790,14 @@ PowerShell 下按测试文件头部示例依次设置三个必填变量,并按
该用例只从进程环境变量读取凭据,不读 `.env.secrets.local`,也不会写入任何文件。真实 API Key 一律不得提交进仓库,也不要写进 `docs/`、脚本默认值或测试 fixture;临时密钥用完应在上游及时吊销。
-创意 Agent `gpt-5` 文本链路已从 APIMart 切到 VectorEngine:`api-server` 读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible LLM client,并自动补齐 `/v1` 前缀用于 Responses 协议。排查或切换密钥后,可在本地运行:
-创意 Agent `gpt-5.4-mini` 文本链路已从 APIMart 切到 VectorEngine:`api-server` 读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible LLM client,并自动补齐 `/v1` 前缀后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`,未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY`。排查或切换密钥后,可在本地运行:
+创意 Agent 文本链路使用 Tiantoken:`api-server` 读取 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 构造 OpenAI-compatible LLM client,并自动补齐 `/v1` 前缀用于文本协议。排查或切换密钥后,可在本地运行:
+创意 Agent `gpt-5.4-mini` 文本链路使用 Tiantoken:`api-server` 读取 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 构造 OpenAI-compatible LLM client,并自动补齐 `/v1` 前缀后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.tiantoken.com/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`,未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 Tiantoken 凭据。排查或切换密钥后,可在本地运行:
```bash
node scripts/test-ve-llm.mjs
```
-该脚本读取仓库根目录 `.env.secrets.local` 中的 `VECTOR_ENGINE_BASE_URL` 和 `VECTOR_ENGINE_API_KEY`,依次探测 `/v1/models`、`/v1/chat/completions`、`/v1/responses`、`gpt-5.4-mini` Chat Completions 和基础 JSON 输出能力;脚本只输出 HTTP 状态、耗时、模型和截断摘要,不应打印密钥。若 `.env.secrets.local` 不存在,先补本地 secrets 文件再运行,不要把 secrets 提交进仓库。
+该脚本读取仓库根目录 `.env.secrets.local` 中的 `TIANTOKEN_BASE_URL` 和 `TIANTOKEN_API_KEY`,依次探测 `/v1/models`、`/v1/chat/completions`、`/v1/responses`、`gpt-5.4-mini` Chat Completions 和基础 JSON 输出能力;脚本只输出 HTTP 状态、耗时、模型和截断摘要,不应打印密钥。若 `.env.secrets.local` 不存在,先补本地 secrets 文件再运行,不要把 secrets 提交进仓库。
### 手机验证码短信
diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md
index 895286d30..ae769d8b3 100644
--- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md
+++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md
@@ -40,6 +40,7 @@
## 生成契约
- 前端提交到 `POST /api/editor/icon-spritesheets/generations`。
+- 图集拆分通过 `sliceMode` 显式选择:`connected-components` 按透明像素连通域切分(默认),`grid` 按用户提供的 `gridX × gridY` 网格切分。
- 图标规范生成在 inline 模式下也必须先建立带稳定请求指纹的 generation operation,并由编辑器生成 durable billing 边界包住共享执行器;不得在 `operation=None` 时调用 provider 后再进入原子结果持久化。
- 图标 spritesheet 的入队与实际执行路径都必须在引用解析、generation input 重建、定价和 provider / OSS 副作用之前预检 owner、项目和最终素材目录,并将返回的 canonical `projectId + assetFolderId` 回写到后续流程;请求省略目录时按实际写入的 owner 默认目录预检,worker 不得只信任入队时的旧校验结果。
- queued 图标规范生成由共享原子结果持久化使用 worker caller 中的 lease 一并完成任务并清理 lease;共享执行器返回成功后 worker 只能返回 `Ok(())`,不得再次调用 job completion。
diff --git a/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md b/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md
index b81239a14..7e5133dd9 100644
--- a/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md
+++ b/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md
@@ -30,7 +30,7 @@ SFX V2 已完成产品与技术口径冻结及 T0–T6 工程实施;这不表
- `prompt`:用户在输入框确认的原始语言音效描述,语义为 `userPrompt`,继续复用对外请求和持久化字段 `prompt`。消费动作前按 ECMAScript `String.trim()` 语义只删除首尾空白和行终止符,包含 `U+FEFF`;不改写内部空白、换行、标点、零宽字符或 Unicode 形式。首尾 `U+0085` 不属于该删除集合。按 Unicode code point 计数,合法范围为 `1-2048`。空值和全空白不得回退“游戏音效”。
- `actualPrompt`:Worker 在正式生成时对冻结的 `userPrompt` 进行统一英文化并严格验收后得到的英文 Prompt,继续复用持久化字段 `actual_prompt`。前端不提交 `actualPrompt`,ElevenLabs 只接收验收通过的 `actualPrompt`。
-- `model`:新任务固定 `eleven_text_to_sound_v2`,UI 以禁用态模型胶囊显示 `ElevenLabs`,客户端不决定模型。历史 `audio1.0` 只作旧素材展示和其它未迁移调用方的兼容标识,不是新编辑器 SFX 任务的 alias 或 fallback。
+- `model`:新任务固定 `eleven_text_to_sound_v2`,UI 以禁用态模型胶囊显示 `ElevenLabs`,客户端不决定模型。历史 `audio1.0` 只作旧素材展示标识,不是新编辑器 SFX 任务的 alias 或 fallback。
- `duration`:`null` 表示自动时长,有限数值表示手动时长。首次打开面板默认处于自动模式,并预置最近手动值为 `5s`;手动范围 `0.5-30s`,UI 步进 `0.1s`。自动模式禁用 slider 但保留最近手动值,关闭自动后恢复该值。服务端只校验有限值与范围,不把 UI 步进扩大成 provider 精度限制。
- `loop`:独立布尔参数,默认 `false`,由页面开关原样冻结并传入 ElevenLabs。Prompt 是自由文本,系统不从 Prompt 推断、同步或校验 Loop;Prompt 文本与 Loop 开关不建立业务一致性门禁。
- `prompt_influence`:服务端固定 `0.3`,不向前端开放滑杆或请求字段。输出格式固定为 query `output_format=mp3_44100_128`。
@@ -468,7 +468,7 @@ POST / api / editor / audios / background - music / prompts / simplifications;
- 背景音乐在 body builder 边界防御性执行幂等 canonicalization,再按 canonical Prompt 检查至少一个有效字符和最多 200 个 Unicode code point;校验通过后用 canonical Prompt 构造 Suno body。不得复用语义不同的 `normalize_limited_text`,不得提供默认 Prompt。
- Suno 音乐接口路径固定为 `/suno/submit/music`;`VECTOR_ENGINE_BASE_URL` 即使配置为带 `/v1` 的图片接口根,也要在 `platform-audio` 中归一为根路径后再拼接,避免误请求 `/v1/suno/submit/music`。
- 新编辑器音效使用独立 ElevenLabs 直接二进制 adapter,不伪装成 Vidu / Suno 的 submit + poll 任务。adapter 负责 endpoint 归一、`xi-api-key` header、固定 query / body、单次 POST、有界二进制读取、MP3 验证和时长探测。
- - Vidu `audio1.0` 的 body builder、轮询和下载能力仅保留给历史展示和其它未迁移调用方;新 `audio-sound-effect` 任务不进入 `/ent/v2/text2audio` 或 `/ent/v2/tasks/{taskId}/creations`,也不使用 Suno `task: "sound"`。
+ - 旧版 Vidu `audio1.0` 的 body builder、轮询和下载代码已移除;历史 `audio1.0` 只读展示,新 `audio-sound-effect` 任务不进入 `/ent/v2/text2audio` 或 `/ent/v2/tasks/{taskId}/creations`,也不使用 Suno `task: "sound"`。
- Suno 提交成功后的任务 ID 兼容与 wav clip 轮询逻辑只保留给背景音乐链路;`/suno/fetch/{taskId}` 返回 `audiopipe.suno.ai/?item_id=...` 时,该地址只作为 clip id 来源,不作为最终下载文件,后端继续调用 `/suno/act/wav/{clipId}` 获取稳定 wav URL,避免 worker 在不完整 chunked body 上卡满超时。
- VectorEngine 音频响应的 `code` 需要兼容 `"success"`、`"ok"`、`"0"`、`"200"` 以及数字 `0` / `200`;HTTP 非 2xx 时后端错误信息应透出安全的上游状态和短响应摘要,避免前端只显示笼统提交失败。
- 在 `api-server/src/vector_engine_audio_generation/generation.rs` 原地演进现有编辑器音频 generation handler;以下正式路由保持原路径和现有注册,不新增平行 BFF:
@@ -526,7 +526,7 @@ POST / api / editor / audios / background - music / prompts / simplifications;
- BGM 边界测试覆盖 200 / 201 个纯 Unicode `White_Space` 均归一为空并禁止三动作、大量边界空白包围 `A` 后只允许生成、`A` 加 199 个内部空格再加 `B` 后只允许简化、201 个 U+200B 或 U+FEFF 只允许简化、边界空白包围 200 个 `A` 后允许补全和生成,以及 TypeScript 与 Rust 对 U+0085、U+200B 和 U+FEFF 的一致行为。
- BGM 助手入口测试覆盖 canonical 2000 字允许简化、2001 字返回 `400` 且不调用 LLM;两个助手路由 body 超过 `32 KiB` 时返回 `413`;连续合法请求不因本功能新增限流器返回 `429`。
- 两个助手成功路由分别产生 `editor_background_music_prompt_completion` / `editor_background_music_prompt_simplification` tracking event,均为 `module_key = editor`、User scope;失败响应沿用普通 route tracking 只记录成功的现状。
-- BGM Suno body 仍只包含 `mv`、`gpt_description_prompt`、`make_instrumental`,固定 `Suno` 胶囊和动态泥点价格不变;历史和其它未迁移 Vidu 调用方的 builder / 轮询能力保持可用,但新编辑器 SFX 任务只调用 ElevenLabs。
+- BGM Suno body 仍只包含 `mv`、`gpt_description_prompt`、`make_instrumental`,固定 `Suno` 胶囊和动态泥点价格不变;旧版 Vidu builder / 轮询能力已移除,历史素材只读展示,新编辑器 SFX 任务只调用 ElevenLabs。
- `audio-sound-effect` 与 `audio-background-music` 必须由同一个音频 composer 渲染,并在组件内通过 `isSoundEffect` 分支。SFX 不得渲染 BGM 的补全 / 简化、Suno 模型和 BGM 字符规则;BGM 不得渲染 SFX 的一键优化、Loop、ElevenLabs 模型和 SFX 时长控件。两个 mode 相互切换时,菜单、预设滚动、锁、快照和助手状态不得跨分支泄漏。
- SFX V2 翻译失败时 ElevenLabs 请求数为 0;成功时每个平台 job 最多一次 ElevenLabs POST,`prompt / actual_prompt`、实际时长、Loop、model、provider 和 Task ID 在队列、素材、画布、响应、刷新和重绘后保持权威一致。
- SFX 两类 LLM 请求契约测试分别断言:一键优化每次请求为 Luna + Medium + `max_completion_tokens = 8192`,翻译每次业务尝试为 Luna + Low + `max_completion_tokens = 8192`,两者都不含 `max_tokens`、`max_output_tokens` 或 temperature;测试命名和说明必须把 `8192` 解释为包含 reasoning 的 completion tokens 总预算。优化遇到 `finish_reason = length` 直接失败且不写回;翻译首轮 `length` 只重试一次,第二轮 `length` 最终失败且 ElevenLabs 请求数为 0。
diff --git a/packages/shared/src/http.ts b/packages/shared/src/http.ts
index 9aa01fafa..84e73fa8e 100644
--- a/packages/shared/src/http.ts
+++ b/packages/shared/src/http.ts
@@ -177,23 +177,34 @@ export function parseApiErrorMessage(rawText: string, fallbackMessage: string) {
const parsed = JSON.parse(rawText) as
| ApiErrorResponse
| {
- error?: {
- message?: string;
- code?: string;
- details?: Record | null;
- };
+ error?:
+ | string
+ | {
+ message?: string;
+ code?: string;
+ details?: Record | null;
+ };
message?: string;
code?: string;
};
- const detailMessage = readApiErrorDetailMessage(parsed.error?.details);
+ const detailMessage =
+ typeof parsed.error === 'object' && parsed.error !== null
+ ? readApiErrorDetailMessage(parsed.error.details)
+ : '';
if (detailMessage) {
return detailMessage;
}
+ if (typeof parsed.error === 'string' && parsed.error.trim()) {
+ return parsed.error.trim();
+ }
+
if (
- typeof parsed.error?.message === 'string' &&
+ typeof parsed.error === 'object' &&
+ parsed.error !== null &&
+ typeof parsed.error.message === 'string' &&
parsed.error.message.trim()
) {
return parsed.error.message.trim();
@@ -209,7 +220,10 @@ export function parseApiErrorMessage(rawText: string, fallbackMessage: string) {
}
const errorCode =
- typeof parsed.error?.code === 'string' && parsed.error.code.trim()
+ typeof parsed.error === 'object' &&
+ parsed.error !== null &&
+ typeof parsed.error.code === 'string' &&
+ parsed.error.code.trim()
? parsed.error.code.trim()
: 'code' in parsed &&
typeof parsed.code === 'string' &&
diff --git a/scripts/bgfilter-worker-load-smoke.test.mjs b/scripts/bgfilter-worker-load-smoke.test.mjs
index 635c09cfd..919dedcef 100644
--- a/scripts/bgfilter-worker-load-smoke.test.mjs
+++ b/scripts/bgfilter-worker-load-smoke.test.mjs
@@ -24,7 +24,7 @@ describe('bgfilter worker smoke harness', () => {
GENARRATIVE_BGFILTER_INTERNAL_TOKEN: 'real-internal-token',
GENARRATIVE_EDITOR_BGFILTER_TOKEN: 'real-provider-token',
PATH: '/safe/bin',
- VECTOR_ENGINE_API_KEY: 'real-vector-secret',
+ TIANTOKEN_API_KEY: 'real-tiantoken-secret',
},
providerBaseUrl: 'http://127.0.0.1:19001',
tempRoot: '/tmp/bgfilter-load-smoke-test',
@@ -40,10 +40,10 @@ describe('bgfilter worker smoke harness', () => {
assert.equal(env.ALIYUN_OSS_ENDPOINT, 'oss-cn-shanghai.invalid');
assert.notEqual(env.ALIYUN_OSS_ACCESS_KEY_SECRET, 'real-oss-secret');
assert.equal(env.GENARRATIVE_EDITOR_BGFILTER_TOKEN, undefined);
- assert.equal(env.VECTOR_ENGINE_API_KEY, undefined);
+ assert.equal(env.TIANTOKEN_API_KEY, undefined);
assert.ok(!Object.values(env).includes('real-internal-token'));
assert.ok(!Object.values(env).includes('real-provider-token'));
- assert.ok(!Object.values(env).includes('real-vector-secret'));
+ assert.ok(!Object.values(env).includes('real-tiantoken-secret'));
});
test('loopback mock 完整读取 multipart 后记录并发并返回合法 PNG 字节', async () => {
diff --git a/scripts/check-api-server-env.mjs b/scripts/check-api-server-env.mjs
index 8b4d24e5f..21e8f3cf6 100644
--- a/scripts/check-api-server-env.mjs
+++ b/scripts/check-api-server-env.mjs
@@ -1,8 +1,8 @@
import { mergeApiServerEnv } from './dev-utils.mjs';
const REQUIRED_FOR_PUZZLE_GENERATION = [
- 'VECTOR_ENGINE_BASE_URL',
- 'VECTOR_ENGINE_API_KEY',
+ 'TIANTOKEN_BASE_URL',
+ 'TIANTOKEN_API_KEY',
'ALIYUN_OSS_BUCKET',
'ALIYUN_OSS_ENDPOINT',
'ALIYUN_OSS_ACCESS_KEY_ID',
diff --git a/scripts/container-worker-smoke.mjs b/scripts/container-worker-smoke.mjs
index 881224570..1beed7036 100644
--- a/scripts/container-worker-smoke.mjs
+++ b/scripts/container-worker-smoke.mjs
@@ -514,11 +514,11 @@ GENARRATIVE_SPACETIME_POOL_SIZE=2
GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=15
GENARRATIVE_LLM_PROVIDER=openai-compatible
-GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1
+GENARRATIVE_LLM_BASE_URL=https://api.tiantoken.com/v1
GENARRATIVE_LLM_API_KEY=
GENARRATIVE_LLM_MODEL=gpt-5.4-mini
-VECTOR_ENGINE_BASE_URL=
-VECTOR_ENGINE_API_KEY=
+TIANTOKEN_BASE_URL=
+TIANTOKEN_API_KEY=
ALIYUN_OSS_BUCKET=
ALIYUN_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com
ALIYUN_OSS_ACCESS_KEY_ID=
diff --git a/scripts/export-match3d-resource-pipeline.mjs b/scripts/export-match3d-resource-pipeline.mjs
index 1f192395f..872f1b8d6 100644
--- a/scripts/export-match3d-resource-pipeline.mjs
+++ b/scripts/export-match3d-resource-pipeline.mjs
@@ -52,12 +52,12 @@ function timestamp() {
function resolveEnv() {
const env = mergeApiServerEnv(repoRoot, process.env);
return {
- baseUrl: String(env.VECTOR_ENGINE_BASE_URL || '')
+ baseUrl: String(env.TIANTOKEN_BASE_URL || '')
.trim()
.replace(/\/+$/u, ''),
- apiKey: String(env.VECTOR_ENGINE_API_KEY || '').trim(),
+ apiKey: String(env.TIANTOKEN_API_KEY || '').trim(),
timeoutMs: Number.parseInt(
- String(env.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
+ String(env.TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
10,
),
};
@@ -160,12 +160,12 @@ async function fetchJson(url, options, timeoutMs) {
});
const text = await response.text();
if (!response.ok) {
- throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
+ throw new Error(`Tiantoken ${response.status}: ${text.slice(0, 600)}`);
}
return JSON.parse(text);
} catch (error) {
if (error?.name === 'AbortError') {
- throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
+ throw new Error(`Tiantoken request timed out after ${timeoutMs}ms`);
}
throw error;
} finally {
@@ -202,7 +202,7 @@ async function imageBytesFromPayload(payload, env) {
if (b64Images[0]) {
return Buffer.from(b64Images[0], 'base64');
}
- throw new Error('VectorEngine returned no image');
+ throw new Error('Tiantoken returned no image');
}
async function generateImage(env, { prompt, negativePrompt, size, outPath }) {
@@ -320,7 +320,7 @@ async function main() {
{
mode: 'dry-run',
outDir,
- message: '加 --live 才会真实调用 VectorEngine。',
+ message: '加 --live 才会真实调用 Tiantoken。',
prompts,
},
null,
@@ -332,7 +332,7 @@ async function main() {
const env = resolveEnv();
if (!env.baseUrl || !env.apiKey) {
- throw new Error('Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY');
+ throw new Error('Missing TIANTOKEN_BASE_URL or TIANTOKEN_API_KEY');
}
console.log(`[match3d-export] 1/4 生成关卡整图 -> ${outDir}`);
diff --git a/scripts/generate-edutainment-road-town-map-concepts.mjs b/scripts/generate-edutainment-road-town-map-concepts.mjs
index 84d8264c6..14c181782 100644
--- a/scripts/generate-edutainment-road-town-map-concepts.mjs
+++ b/scripts/generate-edutainment-road-town-map-concepts.mjs
@@ -127,12 +127,12 @@ function resolveEnv() {
...process.env,
};
return {
- baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '')
+ baseUrl: String(loaded.TIANTOKEN_BASE_URL || '')
.trim()
.replace(/\/+$/u, ''),
- apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(),
+ apiKey: String(loaded.TIANTOKEN_API_KEY || '').trim(),
timeoutMs: Number.parseInt(
- String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
+ String(loaded.TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
10,
),
};
@@ -249,12 +249,12 @@ async function fetchJson(url, options, timeoutMs) {
});
const text = await response.text();
if (!response.ok) {
- throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
+ throw new Error(`Tiantoken ${response.status}: ${text.slice(0, 600)}`);
}
return JSON.parse(text);
} catch (error) {
if (error?.name === 'AbortError') {
- throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
+ throw new Error(`Tiantoken request timed out after ${timeoutMs}ms`);
}
throw error;
} finally {
@@ -335,7 +335,7 @@ async function generateOne(env, concept, size, references) {
extension: inferExtensionFromBytes(bytes),
};
} else {
- throw new Error(`VectorEngine returned no image for ${concept.id}`);
+ throw new Error(`Tiantoken returned no image for ${concept.id}`);
}
mkdirSync(outDir, { recursive: true });
@@ -401,7 +401,7 @@ if (!env.baseUrl || !env.apiKey) {
console.error(
JSON.stringify({
ok: false,
- error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY',
+ error: 'Missing TIANTOKEN_BASE_URL or TIANTOKEN_API_KEY',
hasBaseUrl: Boolean(env.baseUrl),
hasApiKey: Boolean(env.apiKey),
}),
diff --git a/scripts/generate-edutainment-toca-world-map-concepts.mjs b/scripts/generate-edutainment-toca-world-map-concepts.mjs
index c6bce5c54..ed9f37ab0 100644
--- a/scripts/generate-edutainment-toca-world-map-concepts.mjs
+++ b/scripts/generate-edutainment-toca-world-map-concepts.mjs
@@ -107,12 +107,12 @@ function resolveEnv() {
...process.env,
};
return {
- baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '')
+ baseUrl: String(loaded.TIANTOKEN_BASE_URL || '')
.trim()
.replace(/\/+$/u, ''),
- apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(),
+ apiKey: String(loaded.TIANTOKEN_API_KEY || '').trim(),
timeoutMs: Number.parseInt(
- String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
+ String(loaded.TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
10,
),
};
@@ -226,12 +226,12 @@ async function fetchJson(url, options, timeoutMs) {
});
const text = await response.text();
if (!response.ok) {
- throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
+ throw new Error(`Tiantoken ${response.status}: ${text.slice(0, 600)}`);
}
return JSON.parse(text);
} catch (error) {
if (error?.name === 'AbortError') {
- throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
+ throw new Error(`Tiantoken request timed out after ${timeoutMs}ms`);
}
throw error;
} finally {
@@ -314,7 +314,7 @@ async function generateOne(env, concept, size) {
extension: inferExtensionFromBytes(bytes),
};
} else {
- throw new Error(`VectorEngine returned no image for ${concept.id}`);
+ throw new Error(`Tiantoken returned no image for ${concept.id}`);
}
mkdirSync(outDir, { recursive: true });
@@ -375,7 +375,7 @@ if (!env.baseUrl || !env.apiKey) {
console.error(
JSON.stringify({
ok: false,
- error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY',
+ error: 'Missing TIANTOKEN_BASE_URL or TIANTOKEN_API_KEY',
hasBaseUrl: Boolean(env.baseUrl),
hasApiKey: Boolean(env.apiKey),
}),
diff --git a/scripts/make-taonier-hand-spirit-transparent.mjs b/scripts/make-taonier-hand-spirit-transparent.mjs
index 58cf53605..b70a9a9d0 100644
--- a/scripts/make-taonier-hand-spirit-transparent.mjs
+++ b/scripts/make-taonier-hand-spirit-transparent.mjs
@@ -87,12 +87,12 @@ function resolveEnv() {
...process.env,
};
return {
- baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '')
+ baseUrl: String(loaded.TIANTOKEN_BASE_URL || '')
.trim()
.replace(/\/+$/u, ''),
- apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(),
+ apiKey: String(loaded.TIANTOKEN_API_KEY || '').trim(),
timeoutMs: Number.parseInt(
- String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || timeoutMsDefault),
+ String(loaded.TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS || timeoutMsDefault),
10,
),
};
@@ -180,12 +180,12 @@ async function fetchJson(url, options, timeoutMs) {
});
const text = await response.text();
if (!response.ok) {
- throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
+ throw new Error(`Tiantoken ${response.status}: ${text.slice(0, 600)}`);
}
return JSON.parse(text);
} catch (error) {
if (error?.name === 'AbortError') {
- throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
+ throw new Error(`Tiantoken request timed out after ${timeoutMs}ms`);
}
throw error;
} finally {
@@ -235,7 +235,7 @@ async function generateChromaSource() {
throw new Error(
JSON.stringify({
ok: false,
- error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY',
+ error: 'Missing TIANTOKEN_BASE_URL or TIANTOKEN_API_KEY',
hasBaseUrl: Boolean(env.baseUrl),
hasApiKey: Boolean(env.apiKey),
}),
@@ -263,7 +263,7 @@ async function generateChromaSource() {
} else if (b64Images[0]) {
bytes = Buffer.from(b64Images[0], 'base64');
} else {
- throw new Error('VectorEngine returned no image');
+ throw new Error('Tiantoken returned no image');
}
mkdirSync(outputDir, { recursive: true });
diff --git a/scripts/test-ve-llm.mjs b/scripts/test-ve-llm.mjs
index ea1b1f76a..e21cec718 100644
--- a/scripts/test-ve-llm.mjs
+++ b/scripts/test-ve-llm.mjs
@@ -28,12 +28,11 @@ function loadEnv(path) {
const env = loadEnv(resolve(root, '.env.secrets.local'));
const BASE =
- env.VECTOR_ENGINE_BASE_URL?.replace(/\/+$/, '') ||
- 'https://api.vectorengine.cn';
-const KEY = env.VECTOR_ENGINE_API_KEY || '';
+ env.TIANTOKEN_BASE_URL?.replace(/\/+$/, '') || 'https://api.tiantoken.com';
+const KEY = env.TIANTOKEN_API_KEY || '';
if (!KEY) {
- console.error('未找到 VECTOR_ENGINE_API_KEY');
+ console.error('未找到 TIANTOKEN_API_KEY');
process.exit(1);
}
@@ -90,7 +89,7 @@ async function test(name, method, path, body = null) {
}
}
-console.log(`VectorEngine LLM 能力探测`);
+console.log(`Tiantoken LLM 能力探测`);
console.log(`目标: ${BASE}\n`);
const tests = [
@@ -185,12 +184,12 @@ console.log(
// 结论
if (pass >= 3) {
- console.log('\n✅ VectorEngine 支持 LLM 文本调用,可替代 Apimart。');
+ console.log('\n✅ Tiantoken 支持 LLM 文本调用,可替代 Apimart。');
console.log(
- ' 将 .env.secrets.local 中 VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY 配好即可。',
+ ' 将 .env.secrets.local 中 TIANTOKEN_BASE_URL / TIANTOKEN_API_KEY 配好即可。',
);
} else if (pass <= 1) {
- console.log('\n❌ VectorEngine 不支持 LLM 文本调用。');
+ console.log('\n❌ Tiantoken 不支持 LLM 文本调用。');
} else {
console.log('\n⚠️ 部分支持,需进一步评估。');
}
diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs
index f3b6057f5..94ea4d4e4 100644
--- a/server-rs/crates/api-server/src/config.rs
+++ b/server-rs/crates/api-server/src/config.rs
@@ -1175,6 +1175,7 @@ impl AppConfig {
read_first_non_empty_env(&[
"GENARRATIVE_LLM_API_KEY",
"LLM_API_KEY",
+ "TIANTOKEN_API_KEY",
"VECTOR_ENGINE_API_KEY",
"ARK_API_KEY",
])
@@ -1183,6 +1184,7 @@ impl AppConfig {
"GENARRATIVE_LLM_API_KEY",
"LLM_API_KEY",
"ARK_API_KEY",
+ "TIANTOKEN_API_KEY",
"VECTOR_ENGINE_API_KEY",
])
};
@@ -1275,11 +1277,12 @@ impl AppConfig {
config.vector_engine_api_key = read_first_non_empty_env(&["VECTOR_ENGINE_API_KEY"]);
- if let Some(vector_engine_image_request_timeout_ms) =
- read_first_positive_u64_env(&["VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS"])
- {
+ if let Some(tiantoken_image_request_timeout_ms) = read_first_positive_u64_env(&[
+ "TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS",
+ "VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS",
+ ]) {
// 单次 attempt 上限允许按环境收短;worker 调用还会受整次任务的绝对 deadline 约束。
- config.vector_engine_image_request_timeout_ms = vector_engine_image_request_timeout_ms;
+ config.vector_engine_image_request_timeout_ms = tiantoken_image_request_timeout_ms;
}
if let Some(vector_engine_audio_request_timeout_ms) =
@@ -1425,6 +1428,20 @@ impl AppConfig {
}
}
+/// Tiantoken 是图片、文本和旧版非 Suno 音频生成的新 provider。
+///
+/// 这里保留对 `AppConfig.vector_engine_*` 的回退,方便测试构造的旧配置继续工作;
+/// 生产环境一旦设置了新的 `TIANTOKEN_*` 变量,就不会再把非 Suno 请求发往 VectorEngine。
+pub(crate) fn tiantoken_base_url(config: &AppConfig) -> String {
+ read_first_non_empty_env(&["TIANTOKEN_BASE_URL"])
+ .unwrap_or_else(|| config.vector_engine_base_url.clone())
+}
+
+pub(crate) fn tiantoken_api_key(config: &AppConfig) -> Option {
+ read_first_non_empty_env(&["TIANTOKEN_API_KEY"])
+ .or_else(|| config.vector_engine_api_key.clone())
+}
+
fn read_first_non_empty_env(keys: &[&str]) -> Option {
keys.iter().find_map(|key| {
env::var(key).ok().and_then(|value| {
@@ -1730,6 +1747,7 @@ mod tests {
DEFAULT_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS,
DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS, ExternalGenerationMode,
LlmProvider, ProcessRole, parse_bool, parse_external_generation_mode, parse_process_role,
+ tiantoken_api_key, tiantoken_base_url,
};
use std::{
fs,
@@ -1822,6 +1840,40 @@ mod tests {
}
}
+ #[test]
+ fn tiantoken_provider_prefers_new_env_names_over_legacy_vector_engine_config() {
+ let _guard = ENV_LOCK
+ .get_or_init(|| Mutex::new(()))
+ .lock()
+ .expect("env lock should not poison");
+ let mut config = AppConfig::default();
+ config.vector_engine_base_url = "https://vector.example.invalid".to_string();
+ config.vector_engine_api_key = Some("legacy-vector-key".to_string());
+ unsafe {
+ std::env::set_var("TIANTOKEN_BASE_URL", " https://api.tiantoken.example/ ");
+ std::env::set_var("TIANTOKEN_API_KEY", " tiantoken-key ");
+ }
+
+ assert_eq!(
+ tiantoken_base_url(&config),
+ "https://api.tiantoken.example/"
+ );
+ assert_eq!(tiantoken_api_key(&config).as_deref(), Some("tiantoken-key"));
+
+ unsafe {
+ std::env::remove_var("TIANTOKEN_BASE_URL");
+ std::env::remove_var("TIANTOKEN_API_KEY");
+ }
+ assert_eq!(
+ tiantoken_base_url(&config),
+ "https://vector.example.invalid"
+ );
+ assert_eq!(
+ tiantoken_api_key(&config).as_deref(),
+ Some("legacy-vector-key")
+ );
+ }
+
#[test]
fn llm_router_key_encryption_secret_prefers_dedicated_secret_or_derives_from_jwt() {
let mut config = AppConfig::default();
diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs
index 949fadf99..0805fb4d4 100644
--- a/server-rs/crates/api-server/src/editor_agent/tool.rs
+++ b/server-rs/crates/api-server/src/editor_agent/tool.rs
@@ -864,7 +864,9 @@ impl EditorAgentTool for GenerateIconSpritesheetTool {
reference_image_srcs: Some(reference_image_srcs),
icon_descriptions: args.icon_descriptions,
slice_count: None,
- slice_layout: None,
+ slice_mode: None,
+ grid_x: None,
+ grid_y: None,
style: None,
model: Some(args.model),
screen_color: Some("auto".to_string()),
diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs
index b6b2b1477..d740cdfea 100644
--- a/server-rs/crates/api-server/src/editor_project.rs
+++ b/server-rs/crates/api-server/src/editor_project.rs
@@ -93,7 +93,8 @@ use crate::{
},
editor_project_icon::{
EditorIconSpritesheetGenerationResponse, EditorIconSpritesheetIconResponse,
- PersistEditorSpritesheetSlicesInput, editor_icon_spritesheet_slice_warning_from_error,
+ EditorIconSpritesheetSliceMode, PersistEditorSpritesheetSlicesInput,
+ editor_icon_spritesheet_slice_warning_from_error,
editor_icon_spritesheet_warning_after_persist_error,
prepare_editor_spritesheet_slices_for_generation, slice_editor_icon_spritesheet_all,
},
@@ -1267,7 +1268,7 @@ fn compact_external_api_generation_result(result: Value) -> Value {
| "spritesheetWidth"
| "spritesheetHeight"
| "iconImageSrcs"
- | "sliceLayout"
+ | "sliceMode"
| "frames"
| "frameCount"
| "frameWidth"
@@ -8578,7 +8579,9 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
spritesheet_width: source_width,
spritesheet_height: source_height,
icon_image_srcs: Vec::new(),
- slice_layout: None,
+ slice_mode: None,
+ grid_x: None,
+ grid_y: None,
slice_count: None,
slice_warning: None,
prompt,
@@ -8645,7 +8648,9 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
spritesheet_width: source_width,
spritesheet_height: source_height,
icon_image_srcs: Vec::new(),
- slice_layout: None,
+ slice_mode: None,
+ grid_x: None,
+ grid_y: None,
slice_count: None,
slice_warning: None,
prompt,
@@ -8730,8 +8735,10 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
let (mut icon_image_srcs, slice_items, slice_warning) = match slice_editor_icon_spritesheet_all(
slice_source,
request_context.external_call_deadline(),
+ EditorIconSpritesheetSliceMode::ConnectedComponents,
None,
- None,
+ 0,
+ 0,
)
.await
{
@@ -8893,7 +8900,9 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
spritesheet_width,
spritesheet_height,
icon_image_srcs,
- slice_layout: None,
+ slice_mode: None,
+ grid_x: None,
+ grid_y: None,
slice_count: None,
slice_warning,
prompt,
@@ -19063,10 +19072,17 @@ mod tests {
.checked_sub(Duration::from_millis(1))
.expect("expired deadline should be representable");
- let error = slice_editor_icon_spritesheet_all(source, Some(expired), None, None)
- .await
- .err()
- .expect("expired CPU budget must fail before decoding");
+ let error = slice_editor_icon_spritesheet_all(
+ source,
+ Some(expired),
+ EditorIconSpritesheetSliceMode::ConnectedComponents,
+ None,
+ 0,
+ 0,
+ )
+ .await
+ .err()
+ .expect("expired CPU budget must fail before decoding");
assert_eq!(error.status_code(), StatusCode::GATEWAY_TIMEOUT);
assert_eq!(
@@ -19751,7 +19767,9 @@ mod tests {
spritesheet_width: 512,
spritesheet_height: 512,
icon_image_srcs: Vec::new(),
- slice_layout: None,
+ slice_mode: None,
+ grid_x: None,
+ grid_y: None,
slice_count: None,
slice_warning: Some(EditorIconSpritesheetSliceWarningResponse {
code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS,
@@ -19984,7 +20002,6 @@ mod tests {
fn atomic_job_result_keeps_only_the_target_consumer_contract() {
let result = json!({
"ok": true,
- "sliceLayout": "grid-2x2",
"imageSrc": "/api/assets/object/generated.png",
"objectKey": "generated/image.png",
"width": 512,
@@ -20069,7 +20086,6 @@ mod tests {
);
assert_eq!(external_payload["result"]["asset"]["assetId"], "asset-1");
assert_eq!(external_payload["result"]["ok"], true);
- assert_eq!(external_payload["result"]["sliceLayout"], "grid-2x2");
assert_eq!(external_payload["result"]["prompt"], "用户可见提示词");
assert_eq!(
external_payload["result"]["actualPrompt"],
diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs
index f7f318b86..cfecc85be 100644
--- a/server-rs/crates/api-server/src/editor_project_icon.rs
+++ b/server-rs/crates/api-server/src/editor_project_icon.rs
@@ -14,7 +14,7 @@ use platform_image::{
generated_asset_sheets::{
GeneratedAssetSheetConnectedIcon, GeneratedAssetSheetConnectedIconPlan,
GeneratedAssetSheetError, prepare_generated_icon_spritesheet_all_by_connected_components,
- prepare_generated_icon_spritesheet_grid_2x2,
+ prepare_generated_icon_spritesheet_grid,
},
};
use platform_llm::{EDITOR_AGENT_GPT5_MODEL, LlmMessage, LlmRunRequest};
@@ -82,6 +82,7 @@ pub(crate) const EDITOR_ICON_SPRITESHEET_MEMORY_MAX_CONCURRENCY: usize = 2;
pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY: usize = 2;
pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS: u64 =
EDITOR_ICON_SPRITESHEET_MAX_PIXELS * 4;
+const EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS: u32 = 32;
pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_PROCESSING_DURATION: Duration =
@@ -254,8 +255,13 @@ pub(crate) struct EditorIconSpritesheetGenerationRequest {
/// 用户要求的切片数量;未提供时按图像中的连通素材自动识别。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) slice_count: Option,
+ /// 图集切分模式;省略时使用连通域切分。
#[serde(default, skip_serializing_if = "Option::is_none")]
- pub(crate) slice_layout: Option,
+ pub(crate) slice_mode: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub(crate) grid_x: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub(crate) grid_y: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) style: Option,
pub(crate) model: Option,
@@ -270,11 +276,50 @@ pub(crate) struct EditorIconSpritesheetGenerationRequest {
pub(crate) canvas_completion: Option,
}
-/// Deprecated compatibility layout. New callers should use `sliceCount`。
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
-pub(crate) enum EditorIconSpritesheetSliceLayout {
- #[serde(rename = "grid-2x2")]
- Grid2x2,
+#[serde(rename_all = "kebab-case")]
+pub(crate) enum EditorIconSpritesheetSliceMode {
+ ConnectedComponents,
+ Grid,
+}
+
+impl Default for EditorIconSpritesheetSliceMode {
+ fn default() -> Self {
+ Self::ConnectedComponents
+ }
+}
+
+fn resolve_editor_icon_spritesheet_slice_mode(
+ slice_mode: Option,
+) -> EditorIconSpritesheetSliceMode {
+ slice_mode.unwrap_or_default()
+}
+
+fn resolve_editor_icon_spritesheet_grid_dimensions(
+ mode: EditorIconSpritesheetSliceMode,
+ grid_x: Option,
+ grid_y: Option,
+) -> Result<(u32, u32), AppError> {
+ if mode == EditorIconSpritesheetSliceMode::ConnectedComponents {
+ return Ok((0, 0));
+ }
+ let (Some(grid_x), Some(grid_y)) = (grid_x, grid_y) else {
+ return Err(
+ AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
+ "field": "gridX/gridY",
+ "message": "grid 模式必须同时提供 gridX 与 gridY。",
+ })),
+ );
+ };
+ if !(1..=EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS).contains(&grid_x)
+ || !(1..=EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS).contains(&grid_y)
+ {
+ return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
+ "field": "gridX/gridY",
+ "message": format!("gridX 与 gridY 必须在 1 到 {} 之间。", EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS),
+ })));
+ }
+ Ok((grid_x, grid_y))
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -313,7 +358,11 @@ pub(crate) struct EditorIconSpritesheetGenerationResponse {
pub(crate) spritesheet_height: u32,
pub(crate) icon_image_srcs: Vec,
#[serde(skip_serializing_if = "Option::is_none")]
- pub(crate) slice_layout: Option,
+ pub(crate) slice_mode: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) grid_x: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) grid_y: Option,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) slice_count: Option,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -1579,6 +1628,12 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
.or_else(|| payload.project_id.clone()),
);
let http_client = build_openai_image_http_client(&settings)?;
+ let requested_slice_mode = resolve_editor_icon_spritesheet_slice_mode(payload.slice_mode);
+ let (grid_x, grid_y) = resolve_editor_icon_spritesheet_grid_dimensions(
+ requested_slice_mode,
+ payload.grid_x,
+ payload.grid_y,
+ )?;
// TODO(legacy-icon-spritesheet-billing-boundary): 该计费边界继承自 master 的历史实现;
// Provider 成功后 operation 即提交,后续解码、OSS、资源与画布持久化失败时缺少可对账中间态。
// 调整前需先定义 provider_succeeded/persistence_pending 等状态、稳定幂等键和补偿语义,
@@ -1618,15 +1673,18 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
)
.await?;
let screen_color = screen_background_decision.color;
- let prompt = match payload.slice_layout {
- Some(EditorIconSpritesheetSliceLayout::Grid2x2) => {
- crate::prompt::icon_spec::build_grid_2x2_spritesheet_prompt(
+ let slice_mode = requested_slice_mode;
+ let prompt = match slice_mode {
+ EditorIconSpritesheetSliceMode::Grid => {
+ crate::prompt::icon_spec::build_grid_spritesheet_prompt(
&spritesheet_prompt,
screen_color,
icon_spec_genre,
+ grid_x,
+ grid_y,
)
}
- None => crate::prompt::icon_spec::build_spritesheet_prompt(
+ EditorIconSpritesheetSliceMode::ConnectedComponents => crate::prompt::icon_spec::build_spritesheet_prompt(
&spritesheet_prompt,
screen_color,
icon_spec_genre,
@@ -1786,7 +1844,11 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
spritesheet_width: source_width,
spritesheet_height: source_height,
icon_image_srcs: Vec::new(),
- slice_layout: payload.slice_layout,
+ slice_mode: Some(requested_slice_mode),
+ grid_x: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid)
+ .then_some(grid_x),
+ grid_y: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid)
+ .then_some(grid_y),
slice_count: Some(0),
slice_warning: None,
prompt,
@@ -1868,7 +1930,11 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
spritesheet_width: source_width,
spritesheet_height: source_height,
icon_image_srcs: Vec::new(),
- slice_layout: payload.slice_layout,
+ slice_mode: Some(requested_slice_mode),
+ grid_x: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid)
+ .then_some(grid_x),
+ grid_y: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid)
+ .then_some(grid_y),
slice_count: Some(0),
slice_warning: None,
prompt,
@@ -1966,8 +2032,10 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
let (mut icon_image_srcs, slice_items, slice_warning) = match slice_editor_icon_spritesheet_all(
slice_source,
request_context.external_call_deadline(),
- payload.slice_layout,
+ requested_slice_mode,
payload.slice_count,
+ grid_x,
+ grid_y,
)
.await
{
@@ -2059,7 +2127,12 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
"spritesheetWidth": spritesheet_width,
"spritesheetHeight": spritesheet_height,
"iconImageSrcs": &icon_image_srcs,
- "sliceLayout": payload.slice_layout,
+
+ "sliceMode": requested_slice_mode,
+ "gridX": (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid)
+ .then_some(grid_x),
+ "gridY": (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid)
+ .then_some(grid_y),
"sliceCount": payload.slice_count,
"sliceWarning": &slice_warning,
"warning": &generation_warning,
@@ -2137,7 +2210,12 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
spritesheet_width,
spritesheet_height,
icon_image_srcs,
- slice_layout: payload.slice_layout,
+
+ slice_mode: Some(requested_slice_mode),
+ grid_x: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid)
+ .then_some(grid_x),
+ grid_y: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid)
+ .then_some(grid_y),
slice_count: Some(slice_count),
slice_warning,
prompt,
@@ -2332,8 +2410,10 @@ pub async fn split_editor_icon_spritesheet(
source,
processing_deadline,
memory_admission,
+ EditorIconSpritesheetSliceMode::ConnectedComponents,
None,
- None,
+ 0,
+ 0,
)
.await?;
let prompt = source_resource
@@ -2408,8 +2488,10 @@ pub async fn split_editor_icon_spritesheet(
pub(crate) async fn slice_editor_icon_spritesheet_all(
source: DownloadedImage,
request_deadline: Option,
- slice_layout: Option,
+ slice_mode: EditorIconSpritesheetSliceMode,
slice_count: Option,
+ grid_x: u32,
+ grid_y: u32,
) -> Result {
let processing_deadline =
resolve_editor_icon_spritesheet_processing_deadline(Instant::now(), request_deadline);
@@ -2419,8 +2501,10 @@ pub(crate) async fn slice_editor_icon_spritesheet_all(
source,
processing_deadline,
memory_admission,
- slice_layout,
+ slice_mode,
slice_count,
+ grid_x,
+ grid_y,
)
.await
}
@@ -2458,8 +2542,10 @@ async fn slice_editor_icon_spritesheet_all_with_memory_admission(
source: DownloadedImage,
processing_deadline: Instant,
memory_admission: Arc,
- slice_layout: Option,
+ slice_mode: EditorIconSpritesheetSliceMode,
slice_count: Option,
+ grid_x: u32,
+ grid_y: u32,
) -> Result {
if Instant::now() >= processing_deadline {
return Err(editor_icon_spritesheet_processing_timeout_error());
@@ -2492,17 +2578,19 @@ async fn slice_editor_icon_spritesheet_all_with_memory_admission(
return Err(editor_icon_spritesheet_processing_timeout_error());
}
validate_editor_icon_spritesheet_source(&source)?;
- match slice_layout {
- Some(EditorIconSpritesheetSliceLayout::Grid2x2) => {
- prepare_generated_icon_spritesheet_grid_2x2(&source)
+ match slice_mode {
+ EditorIconSpritesheetSliceMode::Grid => {
+ prepare_generated_icon_spritesheet_grid(&source, grid_x, grid_y)
+ }
+ EditorIconSpritesheetSliceMode::ConnectedComponents => {
+ prepare_generated_icon_spritesheet_all_by_connected_components(
+ &source,
+ slice_count
+ .unwrap_or(EDITOR_ICON_SPRITESHEET_MAX_SLICES)
+ .min(EDITOR_ICON_SPRITESHEET_MAX_SLICES),
+ EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS,
+ )
}
- None => prepare_generated_icon_spritesheet_all_by_connected_components(
- &source,
- slice_count
- .unwrap_or(EDITOR_ICON_SPRITESHEET_MAX_SLICES)
- .min(EDITOR_ICON_SPRITESHEET_MAX_SLICES),
- EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS,
- ),
}
.map_err(map_editor_icon_spritesheet_platform_error)
});
@@ -2909,7 +2997,7 @@ mod tests {
}
#[tokio::test]
- async fn grid_2x2_slicing_returns_exactly_four_quadrants_with_detached_details() {
+ async fn grid_slicing_returns_one_slice_per_declared_cell_with_detached_details() {
use image::{ImageBuffer, ImageFormat, Rgba};
let mut image: image::RgbaImage = ImageBuffer::from_pixel(128, 128, Rgba([0, 255, 0, 255]));
@@ -2939,15 +3027,54 @@ mod tests {
let prepared = slice_editor_icon_spritesheet_all(
source,
None,
- Some(EditorIconSpritesheetSliceLayout::Grid2x2),
+ EditorIconSpritesheetSliceMode::Grid,
None,
+ 2,
+ 2,
)
.await
- .expect("declared 2x2 sheet should slice");
+ .expect("declared grid sheet should slice");
assert_eq!(prepared.plan.len(), 4);
}
+ #[test]
+ fn slice_mode_defaults_to_connected_components_and_accepts_explicit_modes() {
+ assert_eq!(
+ resolve_editor_icon_spritesheet_slice_mode(None),
+ EditorIconSpritesheetSliceMode::ConnectedComponents
+ );
+ assert_eq!(
+ resolve_editor_icon_spritesheet_grid_dimensions(
+ EditorIconSpritesheetSliceMode::Grid,
+ Some(3),
+ Some(2),
+ )
+ .expect("grid dimensions should validate"),
+ (3, 2)
+ );
+ let connected: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({
+ "referenceId": "spec",
+ "iconDescriptions": ["素材"],
+ "sliceMode": "connected-components"
+ }))
+ .expect("explicit connected-components mode should deserialize");
+ assert_eq!(
+ connected.slice_mode,
+ Some(EditorIconSpritesheetSliceMode::ConnectedComponents)
+ );
+ let grid: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({
+ "referenceId": "spec",
+ "iconDescriptions": ["素材"],
+ "sliceMode": "grid",
+ "gridX": 3,
+ "gridY": 2
+ }))
+ .expect("grid mode should deserialize");
+ assert_eq!(grid.grid_x, Some(3));
+ assert_eq!(grid.grid_y, Some(2));
+ }
+
#[test]
fn spritesheet_genre_requires_exact_game_type_title() {
assert_eq!(
diff --git a/server-rs/crates/api-server/src/editor_screen_background_decision.rs b/server-rs/crates/api-server/src/editor_screen_background_decision.rs
index 434e8f8a3..45c42d631 100644
--- a/server-rs/crates/api-server/src/editor_screen_background_decision.rs
+++ b/server-rs/crates/api-server/src/editor_screen_background_decision.rs
@@ -117,7 +117,7 @@ pub(crate) async fn resolve_editor_screen_background_color(
.map(|report| report.allowed.clone())
.unwrap_or_else(|| EDITOR_SCREEN_BACKGROUND_COLORS.to_vec());
- // 决策统一走 VectorEngine gpt-5-mini:有图用视觉档、无图用文本档(两个独立常量),
+ // 决策统一走 Tiantoken gpt-5-mini:有图用视觉档、无图用文本档(两个独立常量),
// 都不继承 Ark 默认文本模型(豆包,选色能力弱)。仅当 gpt5 客户端未配置时才降级回默认
// llm_client;该默认客户端是纯文本模型(Ark),收到图片分片会被上游 400 拒绝,故先丢弃图片分片。
let (llm_client, decision_model) = match vision_llm_client {
@@ -860,10 +860,10 @@ mod tests {
);
}
- // 真机联调:按 build_editor_agent_llm_client 的方式组 VectorEngine 客户端,直接跑
+ // 真机联调:按 build_editor_agent_llm_client 的方式组 Tiantoken 客户端,直接跑
// resolve_editor_screen_background_color 的完整代码路径(无图文本档 + 有图视觉档),
- // 验证决策请求真的打到 VectorEngine 并被解析成候选色(decision.fallback == false)。
- // 凭证从仓库根 .env.local / .env.secrets.local 读,需要真实 VECTOR_ENGINE_* 才有意义。
+ // 验证决策请求真的打到 Tiantoken 并被解析成候选色(decision.fallback == false)。
+ // 凭证从仓库根 .env.local / .env.secrets.local 读,需要真实 TIANTOKEN_* 才有意义。
// 运行:cargo test -p api-server --manifest-path server-rs/Cargo.toml \
// editor_screen_background_decision::tests::live -- --ignored --nocapture
fn read_live_env(key: &str) -> Option {
@@ -895,11 +895,11 @@ mod tests {
std::env::var(key).ok().or_else(|| map.get(key).cloned())
}
- fn build_live_vector_engine_client() -> Option {
+ fn build_live_tiantoken_client() -> Option {
use platform_llm::{LlmConfig, LlmProvider};
- let base_url = read_live_env("VECTOR_ENGINE_BASE_URL")?;
- let api_key = read_live_env("VECTOR_ENGINE_API_KEY")?;
+ let base_url = read_live_env("TIANTOKEN_BASE_URL")?;
+ let api_key = read_live_env("TIANTOKEN_API_KEY")?;
// 与 state.rs build_editor_agent_llm_client 一致:规整到以 /v1 结尾。
let base_url = if base_url.trim_end_matches('/').ends_with("/v1") {
base_url.trim_end_matches('/').to_string()
@@ -915,8 +915,8 @@ mod tests {
0,
500,
)
- .expect("live VectorEngine LlmConfig should build");
- Some(LlmClient::new(config).expect("live VectorEngine LlmClient should build"))
+ .expect("live Tiantoken LlmConfig should build");
+ Some(LlmClient::new(config).expect("live Tiantoken LlmClient should build"))
}
fn solid_source_image_data_url() -> String {
@@ -938,10 +938,10 @@ mod tests {
}
#[tokio::test]
- #[ignore = "真机联调:需要 .env.local / .env.secrets.local 中真实 VECTOR_ENGINE_* 凭证"]
- async fn live_screen_background_decision_hits_vector_engine_without_image() {
- let Some(client) = build_live_vector_engine_client() else {
- panic!("缺少 VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY,无法真机联调");
+ #[ignore = "真机联调:需要 .env.local / .env.secrets.local 中真实 TIANTOKEN_* 凭证"]
+ async fn live_screen_background_decision_hits_tiantoken_without_image() {
+ let Some(client) = build_live_tiantoken_client() else {
+ panic!("缺少 TIANTOKEN_BASE_URL / TIANTOKEN_API_KEY,无法真机联调");
};
let decision = resolve_editor_screen_background_color(
None,
@@ -970,16 +970,16 @@ mod tests {
assert_eq!(decision.mode, EditorScreenBackgroundDecisionMode::Auto);
assert!(
!decision.fallback,
- "若走到兜底说明 LLM 没答复(VectorEngine 未被成功调用或响应解析失败)"
+ "若走到兜底说明 LLM 没答复(Tiantoken 未被成功调用或响应解析失败)"
);
assert!(decision.attempts >= 1);
}
#[tokio::test]
- #[ignore = "真机联调:需要 .env.local / .env.secrets.local 中真实 VECTOR_ENGINE_* 凭证"]
- async fn live_screen_background_decision_hits_vector_engine_with_image() {
- let Some(client) = build_live_vector_engine_client() else {
- panic!("缺少 VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY,无法真机联调");
+ #[ignore = "真机联调:需要 .env.local / .env.secrets.local 中真实 TIANTOKEN_* 凭证"]
+ async fn live_screen_background_decision_hits_tiantoken_with_image() {
+ let Some(client) = build_live_tiantoken_client() else {
+ panic!("缺少 TIANTOKEN_BASE_URL / TIANTOKEN_API_KEY,无法真机联调");
};
let decision = resolve_editor_screen_background_color(
None,
@@ -1008,7 +1008,7 @@ mod tests {
assert_eq!(decision.mode, EditorScreenBackgroundDecisionMode::Auto);
assert!(
!decision.fallback,
- "若走到兜底说明视觉 LLM 没答复(VectorEngine 未被成功调用或响应解析失败)"
+ "若走到兜底说明视觉 LLM 没答复(Tiantoken 未被成功调用或响应解析失败)"
);
assert!(decision.attempts >= 1);
}
diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs
index de48a2d15..eeddb4371 100644
--- a/server-rs/crates/api-server/src/external_editor_api.rs
+++ b/server-rs/crates/api-server/src/external_editor_api.rs
@@ -2620,6 +2620,14 @@ mod tests {
icon_spritesheet_request["properties"]["sliceCount"]["minimum"],
json!(1)
);
+ assert_eq!(
+ icon_spritesheet_request["properties"]["sliceMode"]["enum"],
+ json!(["connected-components", "grid"])
+ );
+ assert_eq!(
+ icon_spritesheet_request["properties"]["gridX"]["maximum"],
+ json!(32)
+ );
let icon_style_schema = &parsed["components"]["schemas"]["EditorIconSpritesheetGenerationRequest"]
["properties"]["style"];
assert_eq!(icon_style_schema["anyOf"][0]["type"], "string");
diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs
index 722213247..6c89e8fb2 100644
--- a/server-rs/crates/api-server/src/external_generation_worker.rs
+++ b/server-rs/crates/api-server/src/external_generation_worker.rs
@@ -1364,7 +1364,9 @@ fn compact_external_api_generation_result(result: Value) -> Value {
| "spritesheetWidth"
| "spritesheetHeight"
| "iconImageSrcs"
- | "sliceLayout"
+ | "sliceMode"
+ | "gridX"
+ | "gridY"
| "sliceCount"
| "frames"
| "frameCount"
@@ -1905,8 +1907,8 @@ mod tests {
"code": "UPSTREAM_ERROR",
"message": "上游服务请求失败",
"details": {
- "provider": "vector-engine",
- "reason": "VECTOR_ENGINE_API_KEY 未配置",
+ "provider": "tiantoken",
+ "reason": "TIANTOKEN_API_KEY 未配置",
"message": "提交编辑器音效任务失败:missing field sound"
}
},
@@ -1918,7 +1920,7 @@ mod tests {
let message = response_error_message(response).await;
- assert_eq!(message, "VECTOR_ENGINE_API_KEY 未配置");
+ assert_eq!(message, "TIANTOKEN_API_KEY 未配置");
}
#[tokio::test]
@@ -2436,7 +2438,9 @@ mod tests {
let mut job = external_generation_job_record_fixture(Some("lease-1"));
job.dedupe_key = "external-api-generation:conversation-1:7:icon-spritesheet".to_string();
let response = json!({
- "sliceLayout": "grid-2x2",
+ "sliceMode": "grid",
+ "gridX": 2,
+ "gridY": 2,
"iconImageSrcs": [
{ "name": "素材 1", "imageSrc": "/api/assets/object/one.png" },
{ "name": "素材 2", "imageSrc": "/api/assets/object/two.png" },
@@ -2449,7 +2453,7 @@ mod tests {
serde_json::from_str(&editor_generation_result_payload_json(&job, &response))
.expect("worker result should be valid JSON");
- assert_eq!(payload["result"]["sliceLayout"], json!("grid-2x2"));
+ assert_eq!(payload["result"]["sliceMode"], json!("grid"));
assert_eq!(
payload["result"]["iconImageSrcs"].as_array().map(Vec::len),
Some(4)
@@ -2705,7 +2709,9 @@ mod tests {
"spritesheetImageSrc": "/api/assets/object/core-sheet.png",
"spritesheetWidth": 1024,
"spritesheetHeight": 1024,
- "sliceLayout": "grid-2x2",
+ "sliceMode": "grid",
+ "gridX": 2,
+ "gridY": 2,
"spritesheetResource": {
"resourceId": "sheet-resource-1",
"objectKey": "users/user-1/core-sheet.png",
@@ -2729,7 +2735,7 @@ mod tests {
serde_json::from_str(&editor_generation_result_payload_json(&job, &response))
.expect("游戏创作客户端完成结果应持久化为合法 JSON");
- assert_eq!(payload["result"]["sliceLayout"], json!("grid-2x2"));
+ assert_eq!(payload["result"]["sliceMode"], json!("grid"));
assert_eq!(
payload["result"]["iconImageSrcs"].as_array().map(Vec::len),
Some(4)
diff --git a/server-rs/crates/api-server/src/external_mcp.rs b/server-rs/crates/api-server/src/external_mcp.rs
index 5ee492476..6b6c469f7 100644
--- a/server-rs/crates/api-server/src/external_mcp.rs
+++ b/server-rs/crates/api-server/src/external_mcp.rs
@@ -54,7 +54,7 @@ const SKILL_REQUESTS_AND_OUTPUTS_URI: &str =
"genarrative://external-editor/skill/references/requests-and-outputs.md";
const MAX_MCP_REST_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
-const MCP_INSTRUCTIONS: &str = r#"陶泥儿外部编辑器工具。先创建或复用画布项目,并创建与画布同名的素材文件夹;生成结果应同时写入画布和素材库。参考本地文件时先走上传票据和对象确认,不要把 Data URL、Blob URL 或临时签名 URL写入生成参数。所有生成工具都是异步提交:必须提供 idempotencyKey,提交后按 pollAfterMs 调用 get_external_editor_generation_job,只有 status=completed 时消费 result;查询超时不能重新提交。warning 表示主结果可用但存在降级,sliceWarning 表示完整透明图集可用但切片未完成。详细说明、OpenAPI、Skill 主入口和分主题 references 见 resources/list;需要本地文件编排或不支持 MCP 时再下载 skill.zip。"#;
+const MCP_INSTRUCTIONS: &str = r#"陶泥儿外部编辑器工具。先创建或复用画布项目,并创建与画布同名的素材文件夹;生成结果应同时写入画布和素材库。参考本地文件时先走上传票据和对象确认,不要把 Data URL、Blob URL 或临时签名 URL写入生成参数。所有生成工具都是异步提交:必须提供 idempotencyKey,提交后按 pollAfterMs 调用 get_external_editor_generation_job,只有 status=completed 时消费 result;查询超时不能重新提交。图集生成可用 sliceMode=connected-components(默认连通域切分)或 grid(必须同时提供 gridX/gridY)。warning 表示主结果可用但存在降级,sliceWarning 表示完整透明图集可用但切片未完成。详细说明、OpenAPI、Skill 主入口和分主题 references 见 resources/list;需要本地文件编排或不支持 MCP 时再下载 skill.zip。"#;
#[derive(Clone, Debug)]
struct McpOperation {
diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs
index f0fa7591c..2cf90c425 100644
--- a/server-rs/crates/api-server/src/openai_image_generation.rs
+++ b/server-rs/crates/api-server/src/openai_image_generation.rs
@@ -75,34 +75,29 @@ impl std::fmt::Debug for OpenAiImageSettings {
}
}
-// 中文注释:api-server 只负责配置、审计和 HTTP envelope,VectorEngine 协议细节统一由 platform-image provider 承接。
+// 中文注释:api-server 只负责配置、审计和 HTTP envelope,Tiantoken 的 OpenAI-compatible
+// 图片协议细节统一由 platform-image provider 承接。
pub(crate) fn require_openai_image_settings(
state: &AppState,
) -> Result {
- let base_url = state
- .config
- .vector_engine_base_url
- .trim()
- .trim_end_matches('/');
+ let base_url = state.tiantoken_base_url().trim().trim_end_matches('/');
if base_url.is_empty() {
return Err(
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
- "provider": VECTOR_ENGINE_PROVIDER,
- "reason": "VECTOR_ENGINE_BASE_URL 未配置",
+ "provider": "tiantoken",
+ "reason": "TIANTOKEN_BASE_URL 未配置",
})),
);
}
let api_key = state
- .config
- .vector_engine_api_key
- .as_deref()
+ .tiantoken_api_key()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
- "provider": VECTOR_ENGINE_PROVIDER,
- "reason": "VECTOR_ENGINE_API_KEY 未配置",
+ "provider": "tiantoken",
+ "reason": "TIANTOKEN_API_KEY 未配置",
}))
})?;
diff --git a/server-rs/crates/api-server/src/prompt/icon_spec.rs b/server-rs/crates/api-server/src/prompt/icon_spec.rs
index 51469fb1b..c68cacb2e 100644
--- a/server-rs/crates/api-server/src/prompt/icon_spec.rs
+++ b/server-rs/crates/api-server/src/prompt/icon_spec.rs
@@ -276,13 +276,17 @@ pub(crate) fn build_spritesheet_prompt(
)
}
-pub(crate) fn build_grid_2x2_spritesheet_prompt(
+pub(crate) fn build_grid_spritesheet_prompt(
user_prompt: &ValidatedEditorIconSpritesheetPrompt,
screen_color: EditorScreenBackgroundColor,
genre: Option,
+ columns: u32,
+ rows: u32,
) -> String {
format!(
- "{}\n\n固定 2×2 游戏核心素材图集合同:画面必须严格分为左上、右上、左下、右下四个等大的独立槽位;每个槽位只放一个完整、可单独用于游戏运行时的主体。四个槽位必须按用户给出的四条素材需求顺序对应,且每格都必须有清晰可见的主体。禁止生成任何额外图标、同一主体的多个姿势、序列帧、棋盘、场景、边框、流程箭头、标签、文字、Logo、装饰小物或第五个素材;禁止主体跨格、触碰或重叠。输出须是单张图集,背景只使用统一纯色以便透明化。",
+ "{}\n\n固定网格游戏核心素材图集合同:画面必须严格分为 {}×{} 个等大的独立槽位;每个槽位只放一个完整、可单独用于游戏运行时的主体。素材按从左到右、从上到下顺序对应,禁止主体跨格、触碰或重叠。输出须是单张图集,背景只使用统一纯色以便透明化。",
+ columns,
+ rows,
build_spritesheet_prompt(user_prompt, screen_color, genre),
)
}
diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs
index dc7b08503..b4030cd62 100644
--- a/server-rs/crates/api-server/src/state.rs
+++ b/server-rs/crates/api-server/src/state.rs
@@ -303,6 +303,9 @@ pub struct AppStateInner {
editor_generation_pricing_store: EditorGenerationPricingStore,
llm_client: Option,
vector_engine_llm_client: Option,
+ /// 非 Suno 的文本、图片和旧版音频生成 provider 配置。
+ tiantoken_base_url: String,
+ tiantoken_api_key: Option,
matting_client: Option,
bgfilter_provider_http_client: reqwest::Client,
bgfilter_worker_http_client: reqwest::Client,
@@ -600,8 +603,14 @@ impl AppState {
config.editor_generation_pricing_override_path.clone(),
)
.map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?;
+ let tiantoken_base_url = crate::config::tiantoken_base_url(&config);
+ let tiantoken_api_key = crate::config::tiantoken_api_key(&config);
let llm_client = build_llm_client(&config)?;
- let vector_engine_llm_client = build_vector_engine_llm_client(&config)?;
+ let vector_engine_llm_client = build_vector_engine_llm_client(
+ &config,
+ &tiantoken_base_url,
+ tiantoken_api_key.as_deref(),
+ )?;
let matting_client = build_matting_client(&config)?;
let bgfilter_provider_http_client = build_bgfilter_provider_http_client(&config)?;
let bgfilter_worker_http_client = build_bgfilter_worker_http_client(&config)?;
@@ -677,6 +686,8 @@ impl AppState {
editor_generation_pricing_store,
llm_client,
vector_engine_llm_client,
+ tiantoken_base_url,
+ tiantoken_api_key,
matting_client,
bgfilter_provider_http_client,
bgfilter_worker_http_client,
@@ -1579,6 +1590,14 @@ impl AppState {
self.vector_engine_llm_client.as_ref()
}
+ pub fn tiantoken_base_url(&self) -> &str {
+ self.tiantoken_base_url.as_str()
+ }
+
+ pub fn tiantoken_api_key(&self) -> Option<&str> {
+ self.tiantoken_api_key.as_deref()
+ }
+
pub fn matting_client(&self) -> Option<&MattingClient> {
self.matting_client.as_ref()
}
@@ -2490,21 +2509,21 @@ fn build_llm_client(config: &AppConfig) -> Result