- {hiddenConversationCount > 0 ? (
+ {hiddenConversationCount > 0 || hasEarlierConversationMessages ? (
) : null}
{visibleMessages.map((message, index) => (
diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx
index 1971660b5..14d2febcc 100644
--- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx
+++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx
@@ -70,6 +70,7 @@ import {
writeChatPromptPolishReminderDisabled,
} from './chatPromptPolish';
import { ChatPromptPolishReminder } from './ChatPromptPolishReminder';
+import type { DirectCodexUserContentPart } from './generated';
import {
$createResourceReferenceNode,
$isResourceReferenceNode,
@@ -176,7 +177,8 @@ function referenceListKey(references: ChatReference[]) {
function sameDraft(left: ChatComposerDraft, right: ChatComposerDraft) {
return (
left.text === right.text &&
- referenceListKey(left.references) === referenceListKey(right.references)
+ referenceListKey(left.references) === referenceListKey(right.references) &&
+ JSON.stringify(left.content ?? []) === JSON.stringify(right.content ?? [])
);
}
@@ -184,26 +186,50 @@ function collectDraftParts(
node: LexicalNode,
textParts: string[],
references: ChatReference[],
+ content: DirectCodexUserContentPart[],
) {
if ($isTextNode(node)) {
- textParts.push(node.getTextContent());
+ const text = node.getTextContent();
+ textParts.push(text);
+ if (text) content.push({ type: 'input_text', text });
return;
}
if ($isLineBreakNode(node)) {
textParts.push('\n');
+ content.push({ type: 'input_text', text: '\n' });
return;
}
if ($isResourceReferenceNode(node)) {
textParts.push(`@${node.__reference.label}`);
references.push(node.__reference);
+ content.push(
+ node.__reference.type === 'resource'
+ ? {
+ type: 'agc_resource_reference',
+ resourceId: node.__reference.resourceId,
+ }
+ : {
+ type: 'agc_runtime_region_reference',
+ label: node.__reference.label,
+ runId: node.__reference.runId,
+ versionId: node.__reference.versionId,
+ elementTag: node.__reference.elementTag,
+ elementRole: node.__reference.elementRole,
+ text: node.__reference.text,
+ width: node.__reference.width,
+ height: node.__reference.height,
+ resourceIds: node.__reference.resourceIds,
+ },
+ );
return;
}
if ($isElementNode(node)) {
node.getChildren().forEach((child, index) => {
if (index > 0 && node.getType() === 'root') {
textParts.push('\n');
+ content.push({ type: 'input_text', text: '\n' });
}
- collectDraftParts(child, textParts, references);
+ collectDraftParts(child, textParts, references, content);
});
}
}
@@ -212,10 +238,12 @@ function collectDraftParts(
function readDraftFromNodes(): ChatComposerDraft {
const textParts: string[] = [];
const references: ChatReference[] = [];
- collectDraftParts($getRoot(), textParts, references);
+ const content: DirectCodexUserContentPart[] = [];
+ collectDraftParts($getRoot(), textParts, references, content);
return {
text: textParts.join('').trim(),
references: dedupeChatReferences(references),
+ content,
};
}
diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx
index c4aa1640c..5e4a391e5 100644
--- a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx
+++ b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx
@@ -71,6 +71,7 @@ type SupervisorChatOnlyViewProps = {
transientReply: string;
hasConversationControls: boolean;
hiddenConversationCount: number;
+ hasEarlierConversationMessages?: boolean;
needsUserInput: boolean;
visibleMessages: ChatMessage[];
workspaceStatus: string;
@@ -108,6 +109,7 @@ export function SupervisorChatOnlyView({
transientReply,
hasConversationControls,
hiddenConversationCount,
+ hasEarlierConversationMessages = false,
needsUserInput,
visibleMessages,
workspaceStatus,
@@ -188,12 +190,16 @@ export function SupervisorChatOnlyView({
aria-label={directCodex ? '陶泥儿消息' : '项目总控消息'}
onScroll={handleScroll}
>
- {hiddenConversationCount > 0 ? (
+ {hiddenConversationCount > 0 || hasEarlierConversationMessages ? (
+ >
+ {hiddenConversationCount > 0
+ ? `显示更早 · 还有 ${hiddenConversationCount} 条对话`
+ : '显示更早的对话'}
+
) : null}
{visibleMessages.map((message, index) => (
;
+};
+
+export type DirectThreadSubscriptionBootstrap = {
+ subscriptionId: string;
+ lastCompletedItemId: string | null;
+ events: DirectThreadRawEvent[];
+};
+
+export type DirectThreadConsumeResult = {
+ events: DirectThreadRawEvent[];
+};
+
+export type DirectThreadHistorySlice = {
+ items: unknown[];
+ hasMore: boolean;
+};
+
+export function directThreadHistoryItemsToMessages(
+ items: unknown[],
+): LocalConversationMessageRecord[] {
+ return items.flatMap((raw) => {
+ if (!raw || typeof raw !== 'object') return [];
+ const item = raw as Record
;
+ const role = item.role;
+ if (role !== 'user' && role !== 'assistant') return [];
+ const messageRole = role as 'user' | 'assistant';
+ const content = Array.isArray(item.content)
+ ? item.content
+ .map((part) =>
+ part && typeof part === 'object' && 'text' in part
+ ? (part as { text?: unknown }).text
+ : null,
+ )
+ .filter((text): text is string => typeof text === 'string')
+ .join('')
+ : '';
+ if (!content) return [];
+ const messageId = typeof item.id === 'string' ? item.id : undefined;
+ return [
+ {
+ schemaVersion: 'agc-direct-project-context.v1',
+ role: messageRole,
+ content,
+ agentId: null,
+ messageId,
+ updatedAt: 0,
+ },
+ ];
+ });
+}
+
+export type DirectThreadReducerState = {
+ lastSeq: number;
+ turnId: string | null;
+ status:
+ | 'accepted'
+ | 'running'
+ | 'streaming'
+ | 'finalizing'
+ | 'completed'
+ | 'failed'
+ | null;
+ activeItemIds: Set;
+ accumulatedText: string;
+ progress: string;
+};
+
+export const emptyDirectThreadReducerState = (): DirectThreadReducerState => ({
+ lastSeq: 0,
+ turnId: null,
+ status: null,
+ activeItemIds: new Set(),
+ accumulatedText: '',
+ progress: '',
+});
+
+function itemType(event: DirectThreadRawEvent) {
+ const value = event.payload.itemType;
+ return typeof value === 'string' ? value : '';
+}
+
+function activityDetail(type: string) {
+ switch (type) {
+ case 'mcpToolCall':
+ return '正在调用工具';
+ case 'commandExecution':
+ return '正在执行命令';
+ case 'fileChange':
+ return '正在写入文件';
+ case 'webSearch':
+ return '正在搜索资料';
+ case 'contextCompaction':
+ return '正在整理上下文';
+ default:
+ return '正在处理';
+ }
+}
+
+export function reduceDirectThreadEvent(
+ state: DirectThreadReducerState,
+ event: DirectThreadRawEvent,
+): DirectThreadReducerState {
+ if (!Number.isSafeInteger(event.seq) || event.seq <= state.lastSeq) {
+ return state;
+ }
+ const next: DirectThreadReducerState = {
+ ...state,
+ lastSeq: event.seq,
+ turnId: event.turnId || state.turnId,
+ activeItemIds: new Set(state.activeItemIds),
+ };
+ switch (event.type) {
+ case 'turn.started':
+ next.status = 'running';
+ next.progress = '正在处理';
+ next.accumulatedText = '';
+ next.activeItemIds = new Set();
+ break;
+ case 'item.started':
+ if (event.itemId) next.activeItemIds.add(event.itemId);
+ next.status = 'running';
+ next.progress = activityDetail(itemType(event));
+ break;
+ case 'item.delta': {
+ const delta = event.payload.delta;
+ if (typeof delta === 'string' && delta) {
+ next.accumulatedText += delta;
+ next.status = 'streaming';
+ next.progress = '正在生成回复';
+ }
+ break;
+ }
+ case 'item.completed':
+ if (event.itemId) next.activeItemIds.delete(event.itemId);
+ if (next.status === null) next.status = 'running';
+ break;
+ case 'turn.completed': {
+ const status = event.payload.status;
+ next.status = status === 'completed' ? 'completed' : 'failed';
+ next.progress =
+ status === 'completed' ? '正在提交回复' : '正在记录失败原因';
+ break;
+ }
+ default:
+ break;
+ }
+ return next;
+}
+
+export function reduceDirectThreadEvents(
+ events: DirectThreadRawEvent[],
+ initial = emptyDirectThreadReducerState(),
+) {
+ return events.reduce(reduceDirectThreadEvent, initial);
+}
diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts
new file mode 100644
index 000000000..10c4e0f5a
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts
@@ -0,0 +1,10 @@
+// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart';
+
+export type DirectCodexUserContentPart =
+ | { type: 'input_text'; text: string }
+ | { type: 'agc_resource_reference'; resourceId: string }
+ | ({
+ type: 'agc_runtime_region_reference';
+ } & DirectCodexUserRuntimeRegionPart);
diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts
new file mode 100644
index 000000000..4c9ea8ca3
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts
@@ -0,0 +1,5 @@
+// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+import type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem';
+
+export type DirectCodexUserItem = DirectCodexUserMessageItem;
diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts
new file mode 100644
index 000000000..f7447dc5d
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts
@@ -0,0 +1,11 @@
+// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+import type { DirectCodexUserContentPart } from './DirectCodexUserContentPart';
+import type { DirectCodexUserRole } from './DirectCodexUserRole';
+
+export type DirectCodexUserMessageItem = {
+ type: 'message';
+ role: DirectCodexUserRole;
+ content: DirectCodexUserContentPart[];
+ id: string;
+};
diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts
new file mode 100644
index 000000000..e1dc59c59
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts
@@ -0,0 +1,3 @@
+// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type DirectCodexUserRole = 'user';
diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts
new file mode 100644
index 000000000..296c0d19a
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts
@@ -0,0 +1,13 @@
+// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type DirectCodexUserRuntimeRegionPart = {
+ label: string;
+ runId?: string;
+ versionId?: string;
+ elementTag?: string;
+ elementRole?: string;
+ text?: string;
+ width?: number;
+ height?: number;
+ resourceIds: string[];
+};
diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts
new file mode 100644
index 000000000..f1012a4c5
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts
@@ -0,0 +1,5 @@
+export type { DirectCodexUserContentPart } from './DirectCodexUserContentPart';
+export type { DirectCodexUserItem } from './DirectCodexUserItem';
+export type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem';
+export type { DirectCodexUserRole } from './DirectCodexUserRole';
+export type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart';
diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts
index 320fd9a15..c08fba49a 100644
--- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts
+++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts
@@ -10,6 +10,11 @@ import {
buildGameCreationAppAssetTagLibrary,
type GameCreationAppAssetTagLibraryEntry,
} from '../../../../../packages/shared/src/contracts/gameCreationAppAssetTagLibrary';
+import type {
+ DirectCodexUserContentPart,
+ DirectCodexUserItem,
+ DirectCodexUserMessageItem,
+} from './generated';
export type ResourceReferenceSource =
| 'asset-picker'
@@ -47,6 +52,8 @@ export type ChatReference = ResourceReference | RuntimeRegionReference;
export type ChatComposerDraft = {
text: string;
references: ChatReference[];
+ /** Lexical 顺序对应的 canonical user content;仅由编辑器读回时提供。 */
+ content?: DirectCodexUserContentPart[];
};
export const RESOURCE_REFERENCE_INSERT_EVENT = 'agc-resource-reference-insert';
@@ -84,8 +91,53 @@ export function isResourceReferenceOverlayTarget(target: EventTarget | null) {
export const EMPTY_CHAT_COMPOSER_DRAFT: ChatComposerDraft = {
text: '',
references: [],
+ content: [],
};
+export function chatReferenceToContentPart(
+ reference: ChatReference,
+): DirectCodexUserContentPart {
+ if (reference.type === 'resource') {
+ return { type: 'agc_resource_reference', resourceId: reference.resourceId };
+ }
+ return {
+ type: 'agc_runtime_region_reference',
+ label: reference.label,
+ runId: reference.runId,
+ versionId: reference.versionId,
+ elementTag: reference.elementTag,
+ elementRole: reference.elementRole,
+ text: reference.text,
+ width: reference.width,
+ height: reference.height,
+ resourceIds: reference.resourceIds,
+ };
+}
+
+export function chatComposerDraftToDirectCodexUserItem(
+ draft: ChatComposerDraft,
+ id: string,
+): DirectCodexUserItem {
+ const content = draft.content?.length
+ ? draft.content
+ : draft.references.length > 0
+ ? [
+ ...(draft.text
+ ? [{ type: 'input_text' as const, text: draft.text }]
+ : []),
+ ...draft.references.map(chatReferenceToContentPart),
+ ]
+ : draft.text
+ ? [{ type: 'input_text' as const, text: draft.text }]
+ : [];
+ return {
+ type: 'message',
+ role: 'user',
+ content,
+ id,
+ } satisfies DirectCodexUserMessageItem;
+}
+
export function resourceDisplayName(asset: GameCreationAppAssetManifestEntry) {
const fileName = asset.localPath.split(/[\\/]/u).pop() ?? asset.id;
return fileName.replace(/\.[^.]+$/u, '').trim() || asset.id;
diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts
index 54c4c27e8..c0b5e0750 100644
--- a/apps/ai-game-creator-shell/src/services/clientAuth.ts
+++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts
@@ -72,6 +72,24 @@ export function getClientAuthRefreshOperation(apiBaseUrl: string) {
const CLIENT_AUTH_NETWORK_ERROR_MESSAGE =
'无法连接登录服务,请确认配套后端或 API 代理已启动后重试';
+function getClientAuthHttpErrorMessage(status: number, fallback: string) {
+ switch (status) {
+ case 408:
+ case 504:
+ return '登录服务响应超时,请检查服务器地址和网络后重试';
+ case 429:
+ return '登录请求过于频繁,请稍后重试';
+ case 500:
+ return '登录服务内部错误(HTTP 500),请稍后重试';
+ case 502:
+ return '登录服务暂不可用:上游服务请求失败,请稍后重试';
+ case 503:
+ return '登录服务暂不可用(HTTP 503),服务器可能正在维护,请稍后重试';
+ default:
+ return fallback;
+ }
+}
+
function getClientAuthNetworkErrorMessage(error: unknown) {
const detail =
error instanceof Error ? error.message.trim() : String(error).trim();
@@ -121,24 +139,26 @@ export function getClientAuthErrorMessage(error: unknown, fallback: string) {
}
async function readAuthErrorMessage(response: Response, fallback: string) {
+ const httpFallback = getClientAuthHttpErrorMessage(response.status, fallback);
const text = await readClientHttpResponseText(response, {
url: 'auth error response',
});
if (!text.trim()) {
- return fallback;
+ return httpFallback;
}
let parsed: unknown;
try {
parsed = JSON.parse(text) as unknown;
} catch {
- return fallback;
+ return httpFallback;
}
try {
unwrapApiResponse(parsed);
} catch (error) {
- return error instanceof Error ? error.message : fallback;
+ const message = error instanceof Error ? error.message.trim() : '';
+ return message && message !== '请求失败' ? message : httpFallback;
}
- return fallback;
+ return httpFallback;
}
async function requestAuthJson(
diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css
index f05138012..90af98be2 100644
--- a/apps/ai-game-creator-shell/src/styles.css
+++ b/apps/ai-game-creator-shell/src/styles.css
@@ -903,6 +903,80 @@ textarea {
overflow: hidden;
}
+.launcher-runs-panel {
+ position: absolute;
+ top: 16px;
+ left: 24px;
+ z-index: 3;
+ width: min(280px, calc(100% - 260px));
+ padding: 10px 12px;
+ border: 1px solid var(--platform-subpanel-border);
+ border-radius: 12px;
+ background: color-mix(in srgb, var(--platform-subpanel-fill) 94%, transparent);
+ box-shadow: 0 8px 24px rgb(31 24 16 / 8%);
+}
+
+.launcher-runs-panel-header {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ color: var(--platform-text-strong);
+ font-size: 12px;
+}
+
+.launcher-runs-panel-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: #4d9f54;
+ box-shadow: 0 0 0 3px rgb(77 159 84 / 14%);
+}
+
+.launcher-runs-panel-list {
+ display: grid;
+ gap: 4px;
+ margin: 7px 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.launcher-runs-panel-item {
+ display: grid;
+ width: 100%;
+ gap: 2px;
+ padding: 6px 7px;
+ border: 0;
+ border-radius: 8px;
+ background: transparent;
+ color: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+
+.launcher-runs-panel-item:hover,
+.launcher-runs-panel-item[aria-current='true'] {
+ background: rgb(255 255 255 / 72%);
+}
+
+.launcher-runs-panel-item:disabled {
+ cursor: default;
+}
+
+.launcher-runs-panel-name {
+ overflow: hidden;
+ color: var(--platform-text-strong);
+ font-size: 12px;
+ font-weight: 650;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.launcher-runs-panel-meta,
+.launcher-runs-panel-note {
+ color: var(--platform-text-muted);
+ font-size: 11px;
+}
+
.launcher-promo {
display: flex;
align-items: center;
diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts
index ffeb58e41..bfb771cb0 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts
@@ -1272,6 +1272,37 @@ export function registerAuthTests() {
).toHaveLength(1);
});
+ it('shows the HTTP maintenance error when startup auth receives a 503', async () => {
+ window.localStorage.setItem(
+ 'genarrative.auth.access-token.v1',
+ 'existing-token',
+ );
+ vi.spyOn(globalThis, 'fetch').mockImplementation(
+ async (input: RequestInfo | URL) => {
+ if (String(input) === '/api/auth/me') {
+ return new Response(
+ '503 Service Unavailable',
+ { status: 503, headers: { 'Content-Type': 'text/html' } },
+ );
+ }
+ throw new Error(`unexpected fetch ${String(input)}`);
+ },
+ );
+
+ render(
+ React.createElement(AuthenticatedClient, null, () =>
+ React.createElement('main', { 'aria-label': '已登录' }),
+ ),
+ );
+
+ expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull();
+ expect(
+ screen.getByText(
+ '登录服务暂不可用(HTTP 503),服务器可能正在维护,请稍后重试',
+ ),
+ ).not.toBeNull();
+ });
+
it('still calls logout when token refresh fails during logout retry', async () => {
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
diff --git a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx
new file mode 100644
index 000000000..c075aaef3
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx
@@ -0,0 +1,51 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, expect, it, vi } from 'vitest';
+
+import { ActiveProjectRunsPanel } from '../src/features/app-shell/ActiveProjectRunsPanel';
+
+afterEach(() => cleanup());
+
+it('按开始时间展示正在运行的项目并支持进入项目', () => {
+ const onOpenProject = vi.fn();
+ render(
+ ,
+ );
+
+ const items = screen.getAllByRole('button');
+ expect(items.map((item) => item.textContent?.includes('先开始'))).toEqual([
+ true,
+ false,
+ ]);
+ fireEvent.click(items[0]);
+ expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first');
+});
+
+it('读取失败时保留明确的读取提示,不伪装成没有运行项目', () => {
+ render();
+
+ expect(screen.getByRole('status').textContent).toBe('未能读取正在运行的项目');
+});
diff --git a/apps/ai-game-creator-shell/tests/resourceReferences.test.ts b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts
new file mode 100644
index 000000000..ebcdf8794
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ type ChatComposerDraft,
+ chatComposerDraftToDirectCodexUserItem,
+} from '../src/features/project-workspace/resourceReferences';
+
+describe('DirectProject user Response item', () => {
+ it('保留 Lexical content 的文本与引用交错顺序', () => {
+ const draft: ChatComposerDraft = {
+ text: '忽略的扁平摘要',
+ references: [],
+ content: [
+ { type: 'input_text', text: '先看 ' },
+ { type: 'agc_resource_reference', resourceId: 'asset-hero' },
+ { type: 'input_text', text: ' 再看 ' },
+ {
+ type: 'agc_runtime_region_reference',
+ label: '主画面',
+ runId: 'run-1',
+ versionId: 'version-1',
+ elementTag: 'canvas',
+ elementRole: 'playfield',
+ text: '可试玩区域',
+ width: 320,
+ height: 180,
+ resourceIds: ['asset-hero'],
+ },
+ ],
+ };
+
+ expect(
+ chatComposerDraftToDirectCodexUserItem(draft, 'turn-1:user'),
+ ).toEqual({
+ type: 'message',
+ role: 'user',
+ id: 'turn-1:user',
+ content: draft.content,
+ });
+ });
+
+ it('资源引用只投影稳定 resourceId,不携带展示字段', () => {
+ const draft: ChatComposerDraft = {
+ text: '请使用素材',
+ references: [
+ {
+ type: 'resource',
+ resourceId: 'asset-hero',
+ kind: 'character',
+ mediaType: 'image/png',
+ label: '主角',
+ category: 'character',
+ tags: ['hero'],
+ source: 'asset-picker',
+ },
+ ],
+ };
+
+ expect(
+ chatComposerDraftToDirectCodexUserItem(draft, 'turn-2:user'),
+ ).toEqual({
+ type: 'message',
+ role: 'user',
+ id: 'turn-2:user',
+ content: [
+ { type: 'input_text', text: '请使用素材' },
+ { type: 'agc_resource_reference', resourceId: 'asset-hero' },
+ ],
+ });
+ });
+});
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/【实施计划】DirectProject Thread Manager事件订阅-2026-09-15.md b/docs/project-memory/plans/【实施计划】DirectProject Thread Manager事件订阅-2026-09-15.md
new file mode 100644
index 000000000..639c30aab
--- /dev/null
+++ b/docs/project-memory/plans/【实施计划】DirectProject Thread Manager事件订阅-2026-09-15.md
@@ -0,0 +1,37 @@
+# 【实施计划】DirectProject Thread Manager 事件订阅
+
+| 字段 | 值 |
+| --- | --- |
+| Milestone | `docs/project-memory/plans/【里程碑】DirectProject Thread Manager事件订阅-2026-09-15.md` |
+| Status | ready |
+| Owner | Codex |
+
+## 修改边界
+
+- 允许修改:DirectProject Rust Thread Manager 深模块、app-server 事件适配、Tauri command/event 桥接、DirectProject 前端订阅/reducer/历史加载、对应测试和主规范。
+- 明确不修改:SpacetimeDB、HTTP API、非 DirectProject Runtime、Codex app-server durable thread、用户可见 JSONL 细节。
+- 保持已有 `.env` 未提交修改,不触碰个人配置。
+
+## 实现顺序
+
+1. 先新增独立 Rust queue/subscriber 深模块,只承载事件追加、逻辑队头回收、subscriber cursor 锁和纯单测。
+2. 将 app-server 公开事件安全标准化后接入 Thread Manager;在 item 完成持久化成功后追加完成事件,并追加 turn 生命周期事件。
+3. 增加 Tauri `subscribe/consume/readHistory` 命令与 notify 事件,固定错误和 bootstrap 原子边界。
+4. 前端改为 subscriptionId 驱动的 raw event reducer;重进/过期时先 bootstrap,完成后原子替换;历史按 itemId 懒加载。
+5. 移除 DirectProject legacy conversation 读取分支,补齐契约、并发、恢复和失败关闭测试;让初始历史切片的 `hasMore` 独立驱动“显示更早”按钮和滚动入口。
+6. 每个独立切片分别运行定向验证并形成中文小提交;里程碑验收后再清理临时计划。
+
+## 验证命令
+
+1. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` 的 DirectProject/Thread Manager 定向测试。
+2. 相关前端 Vitest 与类型检查。
+3. `npm run check:encoding`
+4. `npm run check:doc-index`
+5. `git diff --check`
+
+## 风险与回滚点
+
+- 现有 app-server 事件模型与公开 raw event envelope 不完全一致:先在适配层收口,不让协议细节泄漏到前端。
+- 单 Vec 队列不能中间删除;unfinished item 长时间不结束可能暂时 pin 住队头,必须保留可观测上限和测试。
+- Tauri command 无传输层断开回调,subscriber 只通过 queue eviction 失效;测试不能依赖 unsubscribe 或连接断开清理。
+- legacy 删除属于 breaking history 行为;失败关闭测试必须确认不会 fallback 或迁移。
diff --git a/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md b/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md
new file mode 100644
index 000000000..ad2f53f07
--- /dev/null
+++ b/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md
@@ -0,0 +1,36 @@
+# 【实施计划】DirectProject 用户 Response item 输入
+
+| 字段 | 值 |
+| --- | --- |
+| Milestone | `docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md` |
+| Status | in-progress |
+| Owner | Codex |
+
+## 修改边界
+
+- 允许修改:AGC 壳 Rust agent 输入合同、DirectProject 历史适配、前端聊天引用模型、ts-rs 生成配置、当前聊天素材文档。
+- 明确不修改:assistant 返回协议、工具 activity、附件/图片协议、SpacetimeDB、HTTP API。
+
+## 实现顺序
+
+1. 提取深模块:Rust canonical user item 的定义、校验和 Codex wire 转换。
+2. 生成并接入 ts-rs 类型,排除生成文件 lint/style。
+3. 将前端 Lexical 草稿从 `text + references[]` 改为 inline content parts。
+4. 修改 Tauri command 与 DirectProject turn:校验通过后持久化 canonical item,再发送转换后的 Codex 输入。
+5. 删除本链路对 legacy conversation 行的读取 fallback,保留标准 `response_item`。
+6. 补齐定向测试与文档验证;每个独立切片形成小提交。
+
+## 验证命令
+
+1. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_codex`
+2. 相关前端 Vitest 与类型检查。
+3. `npm run check:encoding`
+4. `npm run check:doc-index`
+5. `git diff --check`
+
+## 风险与回滚点
+
+- ts-rs 生成路径或 workspace lint 配置不一致:先固定生成入口,再接入业务。
+- 历史中 canonical item 含 AGC part 时,thread replay 必须经过同一 wire converter;转换失败不得启动 turn。
+- 共享工作树存在用户 `.env` 修改,禁止覆盖或提交。
+- 每个切片保持独立提交,出现协议问题时按提交粒度回滚。
diff --git a/docs/project-memory/plans/【实施计划】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md b/docs/project-memory/plans/【实施计划】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md
new file mode 100644
index 000000000..df94a1580
--- /dev/null
+++ b/docs/project-memory/plans/【实施计划】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md
@@ -0,0 +1,51 @@
+# 【实施计划】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15
+
+Version: 1
+Status: in-progress
+Date: 2026-09-15
+Milestone: `【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md`
+
+## 固定契约
+
+只读快照命令(Tauri 本地命令,`src-tauri/src/agent/direct_runtime/mod.rs`):
+
+- `list_game_creator_direct_active_turns() -> Vec`
+- 字段(camelCase):`projectPath`、`projectName`、`turnId`、`status`、`activity`(可空)、`startedAt`、`updatedAt`、`sequence`
+- `status` 取值集合与既有 Direct 回合事件一致:`accepted` / `running` / `streaming` / `finalizing` / `completed` / `failed`
+
+身份锁与快照共用同一份进程内注册表;注册表条目在回合进入时写入 `startedAt` 与 `projectName`,在每次回合事件发射时更新 `status` / `activity` / `sequence` / `updatedAt`,在回合结束(guard drop)时移除。
+
+## 代码边界
+
+- `apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs`:注册表结构扩展、快照读写、新命令、Rust 定向测试
+- `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs`:事件发射时投影到注册表
+- `apps/ai-game-creator-shell/src-tauri/src/main.rs`:命令注册
+- `apps/ai-game-creator-shell/src/App.tsx`:项目打开时重连、忙碌态与进度恢复、面板挂载
+- `apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts`:快照轮询与单飞刷新(面板与重连共用)
+- 面板组件(新文件,落在既有 feature 目录下)+ 对应测试
+- `apps/ai-game-creator-shell/src/features/agent-runtime/model.ts` + 测试:报错归类修正
+
+## 修改顺序
+
+1. Rust:扩展活动回合注册表并暴露只读快照命令,配定向用例(进入 / 进度 / 终态移除 / 多项目并存)。
+2. 前端:接入快照读取,实现“重新进入项目 → 恢复忙碌态与进度 → 以快照 sequence 续接 → 阻止并发提交”。
+3. 前端:在左上角空白区域挂载“正在运行的项目”面板,复用既有组件与设计 token。
+4. 报错归类:按审计结论修正会误导的映射,逐条加回归用例;真实权限拒绝保持原提示。
+5. 文档:主规范与共享记忆同步;里程碑验收后删除临时计划文件。
+
+## 验证命令
+
+- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_active_turns -- --test-threads=1`(名称按实际用例调整)
+- `npx vitest run apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts`
+- `npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`
+- 面板组件测试文件单独一条 vitest
+- `npm --prefix apps/ai-game-creator-shell run typecheck`
+- `npm run check:encoding`、`git diff --check`
+
+## 风险与回滚点
+
+- 快照命令暴露项目绝对路径给前端:与现有 `projectPath` 口径一致,不得额外泄露配置或 token;命令必须是只读、无副作用。
+- 续接基线 `sequence` 若取错,会让重新进入后的进度事件被丢弃或重复消费;取错时回滚“重连”部分,保留只读面板。
+- 忙碌态恢复不得与既有 `chatAgentBusy` 的失败清理互相覆盖;出现卡死忙碌态时优先回滚重连,不影响身份锁与后台回合本体。
+- 面板若在窄窗口挤压主内容,先按既有响应式约定隐藏面板,不改主布局。
+- 报错归类修正若与既有断言冲突,先确认断言锁的是“正确行为”还是历史错误文案,再决定改断言还是改实现。
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/【里程碑】DirectProject Thread Manager事件订阅-2026-09-15.md b/docs/project-memory/plans/【里程碑】DirectProject Thread Manager事件订阅-2026-09-15.md
new file mode 100644
index 000000000..656fae8f0
--- /dev/null
+++ b/docs/project-memory/plans/【里程碑】DirectProject Thread Manager事件订阅-2026-09-15.md
@@ -0,0 +1,53 @@
+# 【里程碑】DirectProject Thread Manager 事件订阅
+
+| 字段 | 值 |
+| --- | --- |
+| Version | 1.0 |
+| Status | ready |
+| Date | 2026-09-15 |
+| Parent Spec | `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md` |
+
+## 目标
+
+让 DirectProject 对话在页面离开、重进和短暂断线后仍能由前端重建运行态;运行态事件由 Tauri 进程级 Thread Manager 管理,已完成 item 继续以 `project.jsonl` 为持久化事实源。
+
+## 范围
+
+- 每 thread 一个全局 seq 和 append-only replay queue。
+- 每 subscriber 独立的 Rust 内部 cursor、并发安全消费和 notify 唤醒。
+- `subscribe` bootstrap、`consume`、`SUBSCRIPTION_EXPIRED` 和历史 item 锚点。
+- app-server 公开事件的安全标准化、item 持久化先于完成事件转发。
+- 前端 raw event reducer、历史懒加载和过期重订阅。
+- 删除本链路 legacy conversation 格式支持,不提供 fallback 或 migration。
+
+## 不在范围内
+
+- SpacetimeDB、HTTP API、Codex thread durable recovery。
+- 新的 item durable/status/pendingInteraction 字段或持久化确认事件。
+- 前端访问 JSONL 路径、格式或持久化细节。
+- 多 active turn;同一 thread 仍只有一个 active turn。
+
+## 依赖与前置条件
+
+- DirectProject 现有 app-server 事件解析和 `project.jsonl` 读写。
+- 当前 Tauri command/event 注册入口。
+- 现有前端 DirectProject 聊天 reducer 与历史加载入口。
+
+## 验收标准
+
+- [ ] 页面离开后 app-server 回合继续,重进页面能通过 subscribe 重建 unfinished item。
+- [ ] 同一 thread 的多个 subscriber 各自消费,不互相覆盖或重复推进 cursor。
+- [ ] `consume` 返回 cursor 之后的全局 raw events,通知不携带 payload。
+- [ ] queue eviction 只清理队头;落后 subscriber 得到 `SUBSCRIPTION_EXPIRED` 并可重新 subscribe。
+- [ ] item 完成先持久化,成功后才进入完成事件队列;失败不发送正常完成事件。
+- [ ] `turn.completed` 由 app-server 终态进入 raw queue,前端据此结束运行态。
+- [ ] subscribe 返回 item 历史锚点而不是完整 history;前端可按 itemId 懒加载。
+- [ ] 未完成 item 的每个 delta 可从 `item.started` 开始重放;不截断 active item。
+- [ ] legacy conversation 行直接失败关闭,无 fallback、无迁移。
+- [x] DirectProject 首页历史切片存在 `hasMore` 时,即使当前可见窗口没有隐藏消息,也提供按钮和滚动两种“显示更早”入口。
+
+## 证据要求
+
+- 自动化:queue/cursor/eviction 并发单测、事件标准化和持久化顺序测试、Tauri command 测试、前端 reducer 与重订阅测试。
+- 运行时:关闭/切页后重进 DirectProject;并发 item;短暂断线 consume;cursor 过期重订阅。
+- 边界:持久化失败、未知 subscription、queue 超限、多个 subscriber、turn 无 item 间隙、legacy 行拒绝。
diff --git a/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md b/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md
new file mode 100644
index 000000000..bcf6efc68
--- /dev/null
+++ b/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md
@@ -0,0 +1,51 @@
+# 【里程碑】DirectProject 用户 Response item 输入
+
+| 字段 | 值 |
+| --- | --- |
+| Version | 1.0 |
+| Status | proposed |
+| Date | 2026-09-15 |
+| Parent Spec | `docs/【功能说明】AGC聊天素材引用-2026-09-08.md` |
+
+## 目标
+
+将 DirectProject 的用户消息升级为受限 Response API `message` item:文本与 AGC 引用按编辑顺序内联,Rust 校验后持久化 canonical item,并转换为 Codex 可接受的输入。
+
+## 范围
+
+- Rust 定义 user item 与 content part,并通过 `ts-rs` 生成 TypeScript 绑定。
+- 前端 Lexical 草稿生成 inline `content[]`。
+- `agc_resource_reference` 只使用稳定 `resourceId`。
+- `agc_runtime_region_reference` 保留运行区域语义摘要。
+- Rust 在持久化前完成白名单、manifest 与路径校验。
+- 现有标准 `response_item` 原样兼容;legacy conversation 行不提供 fallback。
+- 保持 assistant 返回、工具 activity、附件/图片协议不变。
+
+## 不在范围内
+
+- assistant item 前端投影或 Tauri 返回值改造。
+- 工具 item、reasoning、file change、MCP item 的 UI 模型化。
+- 附件/图片 content part。
+- SpacetimeDB schema 或 HTTP API 变更。
+
+## 依赖与前置条件
+
+- DirectProject 现有 app-server thread/inject_items/turn/start 链路。
+- 项目 manifest 作为资源身份与路径权威。
+- 现有 `project.jsonl` 的 `response_item` envelope。
+
+## 验收标准
+
+- [x] 前端生成的 canonical user item 保留 Lexical 中文本与引用的相对顺序。
+- [x] `agc_resource_reference` 仅包含 `resourceId`,显示信息由 manifest 派生。
+- [x] runtime-region 字段经过 Rust 有界清洗并验证关联资源。
+- [x] 未知 part、失效资源或非法路径在持久化前失败关闭。
+- [x] canonical item 以 `response_item` 写入历史,标准旧 item 原样可读。
+- [x] Codex wire input 不含 AGC 私有 part,且顺序与 canonical content 一致。
+- [ ] assistant、附件和工具链路行为无变化。
+
+## 证据要求
+
+- 自动化:Rust item 校验/转换/历史测试;前端草稿顺序与类型测试;ts-rs 生成检查。
+- 运行时:DirectProject 本地 app-server smoke(如环境可用)。
+- 边界:未知 part、资源删除、非法路径、重复提交 clientTurnId、legacy 行拒绝。
diff --git a/docs/project-memory/plans/【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md b/docs/project-memory/plans/【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md
new file mode 100644
index 000000000..b8ead65f3
--- /dev/null
+++ b/docs/project-memory/plans/【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md
@@ -0,0 +1,33 @@
+# 【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15
+
+Version: 1
+Status: in-progress
+Date: 2026-09-15
+Parent Spec: `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`「2026-09-15 Direct 回合跨页面生命周期与运行中项目可见性」
+
+## 目标
+
+离开项目界面不再等于“回合消失”:后台继续跑的 Direct 回合必须能被前端重新发现并续接进度,同一项目在回合结束前不允许再发起第二条付费回合;壳层左上角提供“正在运行的项目”面板,列出当前确有在跑回合的项目并可点击进入。
+
+## 边界
+
+- 只读投影:新增命令只读当前 GUI 进程内的活动回合注册表,不写项目文件、不新增持久化账本。
+- 不新增取消入口;不改变身份锁排他性、项目写锁语义、计费与幂等身份。
+- 不新增跨端契约(Tauri 本地命令,不进 `packages/shared` / `shared-contracts` / OpenAPI)。
+- 面板与重连共用同一份快照,不各自维护第二份“谁在跑”的真相。
+- 报错归类修正只处理“说明与真相无关”的情况,不放宽身份锁、不吞真实失败。
+
+## 验收标准
+
+- 重新进入有在跑回合的项目后:界面进入“正在处理”、显示最近一次进度、以快照 `sequence` 续接后续事件;回合结束前提交第二条需求不会真正发起第二条付费回合。
+- 回合结束(completed / failed)后:忙碌态解除、可以再次发送;不重复追加助手消息。
+- 无在跑回合的项目:行为与今天一致(可正常发送,不出现额外提示或阻塞)。
+- 左上角面板:列出所有在跑项目,按 `startedAt` 升序,显示项目名(缺失时回退目录名)与状态/时长,点击进入对应项目;没有在跑回合时不渲染面板外壳。
+- 快照读取失败不得阻断发送、不得显示成业务失败。
+- 已修的错误映射不回归:`direct-codex-turn-already-running:` 与历史同义中文正文都归一到“仍在处理这个项目的上一条需求”;真正的 `项目权限策略拒绝执行:` 仍显示审批提示。
+
+## 未决事项
+
+- “离开页面即取消”仍是未采纳的另一种语义;本轮只实现后台继续。
+- 应用重启后的“未完成回合”恢复不在本里程碑范围(回合注册表是进程内状态);若未来要求跨重启恢复,需要另立里程碑并定义持久化身份与对账合同。
+- 面板是否需要展示非 Direct(专业 Agent / 策划 Agent)运行中的项目,本轮不做;先把 Direct 回合这条事实链路做正确。
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index d35c4436d..3fd4eeb46 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -8740,3 +8740,26 @@ 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` 与对应实施计划。
+## 2026-09-15 Direct 回合跨页面继续运行与活动项目面板
+
+- 决策:采用后台继续运行语义。Direct 回合由进程内项目身份锁持有,页面离开不取消;重进项目通过活动回合只读快照与 Thread Manager bootstrap/consume 恢复忙碌态和进度。左上角面板复用同一快照列出正在运行的 Direct 项目并支持进入。
+- 边界:快照不写项目文件、不进入公共 API、不跨应用重启恢复;读取失败保留上一份结果并单独提示,不改写成权限或审批失败。身份锁排他性、付费身份和项目写锁不变。
diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md
index 00c1fa366..35e266485 100644
--- a/docs/project-memory/shared-memory/pitfalls.md
+++ b/docs/project-memory/shared-memory/pitfalls.md
@@ -5596,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/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
index 657dcb9fc..7d6c1ca0b 100644
--- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
+++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
@@ -1,5 +1,11 @@
# AI 游戏创作智能体 App 实施计划
+## 2026-09-15 DirectProject 长回合平台会话保活
+
+DirectProject 的生图、素材处理、构建和试玩可能跨越短生命周期 access token 的有效期。普通 `/api/*` 请求和 Codex app-server 已有 401 刷新路径,但 AGC 工具由 Rust 工具桥直接使用客户端当前会话,工具内部的 401 不会自动触发前端刷新。客户端在 DirectProject 回合处于 busy 状态时每 5 分钟调用现有 `requestPlatformSessionRefresh()`;刷新仍复用单飞请求、generation 校验和 native session 安装,不改变凭据来源,也不把 401 降级为成功。刷新失败保持静默,由原始 AGC 工具错误按现有鉴权失败合同返回,避免后台保活覆盖真实错误。
+
+完成门禁同时允许已登记的普通平台图片作为运行时素材。此前只把 canonical art-spec、背景、图集和图集切片加入来源白名单;`agc_generate_image` 生成的 `assets/neon-*.png` 即使已经登记并被源码引用,也会被判成“未引用平台图片”,触发同一回合的重复修复。浏览器预览把本地图片 URL 改写成 UUID 路径时,验收按每个视口的已渲染本地图片数量与源码引用数量做有界匹配;仍要求两个视口都有对应观察,空视口继续进入修复。
+
## 2026-09-12 已有项目打开响应性
DirectProject 工作区只恢复自身对话,不按专业 Agent 默认任务占位行批量读取旧会话或生成专业 Agent 文本回执。专业 Agent 结果加载 effect 必须以当前 Runtime 模式为边界,并在模式切换时清空旧结果。仍供开发入口使用的 `read_local_conversation` 在 blocking worker 内完整执行权限校验、会话目录解析和历史读取,避免文件访问或锁等待阻塞 Tauri 窗口线程。
@@ -57,6 +63,7 @@ npm 游戏的可预览产物固定为对应 package 目录下的 `dist/index.htm
- AGC 客户端启动恢复按“读取本地凭据 → 刷新会话(无 token 或失效时)→ 读取当前用户 → Tauri 本地运行时会话安装”阶段执行。界面必须展示当前阶段和已等待时间;不能以无期限的单一 loading 文案隐藏网络或 Runner 故障。
- 客户端 HTTP 传输默认使用 15 秒超时并通过独立 `AbortController` 终止请求;调用方可为确需长耗时的请求显式传入 `timeoutMs: null`。调用方主动取消仍保留原始 `AbortError`,超时使用稳定的 `ClientHttpTimeoutError`,由认证层转换为可操作的中文提示。
- 会话恢复或本地 Runner 连接超时后必须进入登录页并提供“重试登录状态检查”。重试递增恢复代次并以运行标识忽略旧恢复任务的迟到 UI 写回;不得清除仍可用于后续重试的 access token,也不得重复并发刷新同一服务器的 refresh 请求。
+- AGC 壳启动认证遇到空响应、非 JSON 维护页或 5xx 时,必须按 HTTP 状态生成可操作的中文提示(503 明确标记服务暂不可用/可能维护),不能退化为 `读取当前用户失败`;后端返回的结构化错误 message 仍优先展示。
- Tauri Runner 的启动与 IPC 超时继续以 `runner/protocol.rs` 的 30 秒启动、10 秒读写为权威;启动等待循环会把 endpoint 探测预算裁剪到剩余启动期限,避免单次 ping 把 30 秒门禁延长。考虑复用旧 endpoint 前可能先消耗一次 IPC 等待,前端 UI 兜底取 45 秒,不改变 Runner 协议、启动策略或认证接口。
## 2026-08-26 运行中自主扩图提案
@@ -1379,3 +1386,23 @@ 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 美化或增加重试预算;不能用延长超时、删除完成门或把失败投影为成功来规避问题。
+
+## 2026-09-15 Direct 回合跨页面生命周期与运行中项目可见性
+
+Direct 回合的所有权属于进程内项目身份锁,不属于当前页面。离开工作台或切换到首页时,正在运行的回合继续执行;重新进入项目时,前端先读取同一份只读活动回合快照,再通过 Thread Manager 订阅 bootstrap 和后续事件恢复忙碌态、进度与未完成回复。活动回合结束后移除快照并解除发送阻断;没有活动回合的项目保持原有发送行为。
+
+壳层左上角的“正在运行”面板只呈现活动 Direct 回合快照,按开始时间排序,显示项目名、状态、活动时长并允许进入对应项目。快照读取失败只显示读取失败并保留上一份结果,不得改写成权限、审批或业务失败;面板不建立第二份运行真相。应用重启后的恢复、取消入口和非 Direct Agent 项目不在本合同内。
+
+活动回合快照命令是进程内 Tauri 只读命令,不进入公共 API 或持久化协议;字段包含 `projectPath / projectName / turnId / status / activity / startedAt / updatedAt / sequence`,状态和序号与既有 Direct 回合进度事件一致。
diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md
index bf46784af..45accf8b4 100644
--- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md
+++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md
@@ -1,6 +1,6 @@
# DirectProject Codex 原始历史与异常恢复
-更新时间:`2026-09-11`
+更新时间:`2026-09-15`
## 目标
@@ -16,16 +16,18 @@ DirectProject 只使用 `.agent/conversations/project.jsonl` 作为对话历史
{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"你好"}]}}
```
-`payload` 必须是未经改写的 Responses item。Direct 回合不由浏览器预写用户 message;Codex 返回的 `rawResponseItem/completed.params.item` 原样追加。显式的本地 user/assistant 补写只能通过受权限保护的 `append_direct_project_conversation_message` 命令完成。native 工具、MCP 工具、reasoning、调用参数和调用结果都保留完整内容,不截断、不摘要、不保存 delta/started 事件。
+`project.jsonl` 的 `payload` 必须是未经改写的 Responses item。AGC 前端 user input 先以 canonical user message item 形式写入;发送给 app-server 前由 Rust 投影为 Codex 可接受的 `message` item,AGC 私有 content part 不会穿透到 wire。Codex 返回的 `rawResponseItem/completed.params.item` 原样追加。native 工具、MCP 工具、reasoning、调用参数和调用结果都保留完整内容,不截断、不摘要、不保存运行态 delta/started 事件。
+
+Thread Manager 的运行态事件是另一份内存协议:app-server 通知先经过安全投影,再只发送 item 类型、item ID、delta 文本和 turn 终态等必要字段;不得把完整 item、工具参数或调用结果转发到前端。完整 item 仍只通过上述 JSONL 历史读取。
DirectProject 自己的写侧只写新格式:格式切换(#282)时仍会写旧行的路径已收口——显式 Codex 返回只落在自己的 journal `.agent/conversations/codex-responses.jsonl`,不再投影进 `project.jsonl`。
-读侧白名单兼容旧 `{role,content}` 行:格式切换前,DirectProject 主对话由通用对话写入器落到同一份 `project.jsonl`,存量用户项目的历史文件整份都是这种行。读取时把**明确枚举的那一种**旧行形状(`schemaVersion=game-creator-conversation.v1`、无 `type`、role 在 legacy 写入器自己的角色集合 `user`/`assistant`/`tool` 内、content 为非空字符串)认下来:`user`/`assistant` 投影成与 `direct_project_local_message_item` 同形状的 message item,`role` 与 `content` 逐字节保留;`tool` 行已识别但不进 Codex 上下文(它不是 Responses item,无法还原成真正的工具 item,聊天投影本来也只展示 user/assistant),与 developer/system item 同样过滤。角色集合取的是 `project/conversation.rs` 里那条 `matches!(role, "user" | "assistant" | "tool")` 校验,所以「legacy 写入器能写出的行」被完整覆盖;白名单之外的角色、换了 `schemaVersion`、带 `type`、content 非字符串或为空、缺 `payload`、坏 JSON 仍按损坏失败关闭。这条兼容是只读的,不迁移、不改写历史文件。
+读取只接受 `response_item` envelope;旧 `game-creator-conversation.v1` 行不提供迁移或 fallback,直接失败关闭。未知或无法投影的 canonical item 在发送前失败,不能产生本轮新增历史。
## 正常回合
1. 启动 `ephemeral: true` 线程,并启用 `experimentalRawEvents: true`。
-2. 新线程先把历史 item 数组一次注入;注入成功后执行新的 `turn/start`。本轮用户 item 只接受 Codex 回传的 `rawResponseItem/completed`,不由 AGC 预写。
+2. 新线程先把历史 item 数组逐项投影为 Codex 可接受 item 后一次注入;注入成功后执行新的 `turn/start`。本轮 canonical user item 在发送前完成同样的投影校验,再写入项目历史。
3. 收到 `rawResponseItem/completed` 后立即追加其 `params.item` 并 flush。
4. 正常 `turn/completed: completed` 不生成额外记录。
@@ -55,12 +57,75 @@ Codex 启动时注入的 `host_skills.instructions`、`permissions.instructions`
DirectProject 的浏览器层只负责显示和乐观状态,不再调用通用对话写入器。历史读写与回合累计分别位于 `agent/direct_project_history.rs` 和 `agent/direct_project_turn_history.rs`。
-`project.jsonl` 不是 DirectProject 独享的写入方:通用对话链(`project/conversation.rs`)把「项目主对话」(`agent_id=None`)映射到同一份文件,非 DirectProject 模式(`codex_cli`/`provider`)的项目对话、以及 Agent Runtime 的项目级公开状态消息(`agent/runtime_state.rs` 的 public status 写入点)都由它追加 `game-creator-conversation.v1` 行。两条链的行形状不同但**互读兼容**:DirectProject 侧投影旧行(见上),通用对话侧跳过 `type=response_item` 且带 `payload` 的行(不把它二次投影成自己的记录,DirectProject 侧已经拥有那份投影),其余坏行两侧都失败关闭。因此同一份文件里出现两种行不会让任何一侧失败。
-
-这里**故意不给通用对话写入器加「文件已属于 DirectProject 就拒绝追加」的硬报错**:这些写入点不是尽力而为的旁路——`agent/runtime_driver/task_start.rs` 在 `ensure_game_creator_agent_runtime_accepted_public_status_at` 返回 `Err` 时会直接中止本次后台任务(「后台任务启动确认落盘失败,任务未执行」),`agent/runtime_protocol/steering.rs` 的三处调用也用 `?` 上抛。加硬报错会把「旧行噪声」换成「任务起不来」,比它要解决的问题更糟;而毒化本身已经不可能发生——legacy 写入器的行形状与角色集合都被 `conversation.rs` 的校验穷举,全部落在 DirectProject 的读侧白名单内。
+`project.jsonl` 的 DirectProject 现行合同只允许 `response_item` envelope。其它模式产生的旧 conversation 行不属于本合同,不得注入 DirectProject。
## 写入与损坏边界
-写入使用 `write_all + flush`。读取时允许丢弃文件末尾一条不完整 JSON 行;白名单化的旧行投影成 message item;其余中间坏行直接失败。兼容只发生在读取侧,不对旧格式做数据迁移或改写。
+写入使用 `write_all + flush`。读取时允许丢弃文件末尾一条不完整 JSON 行;非 `response_item` 行和无法投影的 item 直接失败,不做数据迁移或 fallback。
该失败有专门恢复提示,并按不可重试处理:同一份历史文件每次读都会得到同一结论,重试不会改变结果,因此不会向用户显示「可直接重试」。
+
+## Thread Manager 运行态事件订阅
+
+DirectProject 的页面不是回合执行的所有者。Tauri 进程内的 Thread Manager 按 thread 维护运行态事件,并允许同一 thread 存在多个独立 subscriber。事件队列只服务运行期间和短期断线恢复,不替代 `project.jsonl` 历史事实源。
+
+### 公开契约
+
+概念接口如下:
+
+```ts
+subscribe(threadId) -> {
+ subscriptionId,
+ lastCompletedItemId: string | null,
+ events: RawEvent[],
+}
+
+consume(subscriptionId) -> {
+ events: RawEvent[],
+}
+
+notify -> { subscriptionId }
+
+readHistory(threadId, { beforeItemId?, limit }) -> {
+ items: CompletedItem[],
+ hasMore: boolean,
+}
+```
+
+`subscribe` 不返回完整历史。`lastCompletedItemId` 只是历史读取锚点,前端自行按 item ID 懒加载需要的历史切片。`events` 是当前运行态重建所需的未完成 item 原始事件,以及当前 turn 的生命周期锚点;前端用同一个 reducer 重放 bootstrap 和后续事件。Rust 不保存或理解前端 reducer state。只要 DirectProject 历史切片返回 `hasMore`,聊天视图必须显示“显示更早的对话”入口,并允许按钮或滚动触发下一页,即使当前可见消息窗口没有隐藏消息。
+
+`consume` 不接收或返回 cursor。每个 subscriber 在 Rust 内部持有自己的 cursor,并在加锁的临界区内完成过期判断、读取和 cursor 前进。前端只持有 `subscriptionId` 与 reducer state。并发 `consume` 不重复返回同一批事件。
+
+`notify` 只负责唤醒,不携带事件、cursor 或持久化状态。前端收到通知后调用 `consume`;通知可合并、重复或丢失,事件完整性由 `consume` 保证。
+
+### 事件和顺序
+
+Thread 内所有公开事件共用一个单调递增 seq;seq 允许跳号,前端不要求连续。事件 envelope 至少包含:
+
+```ts
+{
+ seq: number,
+ type: string,
+ turnId: string,
+ itemId?: string,
+ payload: unknown,
+}
+```
+
+进入 Thread Manager 的是已经完成安全过滤和协议标准化的公开 raw event,不是未经审查的 app-server JSON。事件可交错包含多个并发 item:`item.started`、`item.delta`、`item.completed`、approval/request/resolved 事件,以及 `turn.started`、`turn.completed` 生命周期事件。前端按 `turnId` / `itemId` 分发并 reduce,不需要 item 级 cursor 或第二套 reducer。
+
+一个 thread 同时最多有一个 active turn;一个 turn 内允许多个并发 item。`turn.completed` 必须在该 turn 的完成 item 均成功持久化后进入队列,前端据此结束运行态;不能用“不存在 unfinished item”猜测 turn 是否完成。
+
+### 队列、subscriber 和回收
+
+每个 thread 一个 Vec-based append-only replay queue,使用逻辑 head 偏移清理前缀,不做中间删除。完成 item 的事件在持久化成功后才可进入普通 replay 回收流程;unfinished item 的事件必须保留到 item 完成,不能被普通上限截断。
+
+队列有内部最大事件数和最大序列化字节数。超限时先标记长期落后的 subscriber 为 expired,并将其移出有效 subscriber 的最小 cursor 计算;随后只能清理队头连续、已无有效 subscriber 需要且所属 item 已持久化的事件。没有 subscriber 时,已持久化完成 item 的事件副本可以直接清理。未完成 item 的事件仍保留。
+
+subscriber 不依赖 `unsubscribe` 或传输层断开清理。每次 `subscribe` 都创建新的独立 subscription;同一 thread 的其它 subscriber 不受影响。旧 subscription 只有在 queue eviction 后才失效,调用 `consume` 返回统一错误 `SUBSCRIPTION_EXPIRED`。前端保留旧 reducer state,重新 subscribe 完成 bootstrap 后再原子替换。
+
+### Bootstrap 原子性和恢复
+
+`subscribe` 必须在同一个 Thread Manager 边界注册 subscriber、捕获 queue 尾部、确定历史锚点和当前运行态事件;bootstrap 期间产生的新事件由该 subscriber 的内部 cursor 继续通过 `consume` 获取,不能丢失。
+
+断线恢复优先调用 `consume(subscriptionId)`。subscription 仍有效时只返回该 subscriber 尚未消费的 queue 事件;subscription 已过期或 Thread Manager 重启后统一走新的 `subscribe`,再由前端按 `lastCompletedItemId` 从历史懒加载。Rust 不提供 `getItemSnapshot(itemId)`,已完成 item 始终通过历史读取。
diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md
index e8a921a69..ca8874954 100644
--- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md
+++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md
@@ -13,7 +13,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。
确认后素材以 `@素材名` 芯片插入编辑器,用户可以在芯片前后继续编辑自然语言,也可以单独删除芯片。芯片内部保存稳定 `resourceId`,展示名称只用于界面,不参与引用解析;资源改名后,编辑区已有芯片与候选列表都会按 `resourceId` 刷新成 manifest 的最新显示名,并同步回父级草稿。
-提交时前端同时发送用户文本和 `references` 数组。Rust 在发起 Agent 回合前读取当前项目 manifest,逐项复核资源是否存在、路径是否安全,并以 manifest 中的 `id / kind / mediaType / localPath` 作为权威投影;客户端传入的路径、名称和类型不会被直接信任。已删除或不存在的资源会阻止发送并提示用户移除后重新选择。
+提交时前端把 Lexical 草稿直接编码为受限 Response API user `message` item:`input_text` 与 AGC 引用 part 按编辑顺序内联在同一个 `content[]` 中。资源引用只携带稳定 `resourceId`;运行画面引用携带区域语义摘要及关联资源 ID。Rust 是唯一 schema source(通过 `ts-rs` 生成 TypeScript 绑定),在发起回合前完成 item 白名单、字段边界、manifest 归属和路径安全校验;校验失败时本轮不持久化、不发送。通过校验的 canonical item 以 `response_item` envelope 写入项目历史,随后由 Rust 将 AGC part 临时转换为 Codex 可接受的 `input_text`,保持原始 content 顺序。已有标准 `response_item` 原样读取与复用;旧 legacy conversation 行不再提供 fallback。
当前已完成:
@@ -24,9 +24,9 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。
- 素材芯片可插入、编辑和删除;
- 资源画布素材卡的选中工具条提供「引用」入口:图标本身就是 `@`,可见文案与 `title` 都只写「引用」,插入对话里的仍是 `@素材名` 芯片;
- 运行画面提供“点选素材”,可选中 HTML 区域并生成 `runtime-region` 引用;
-- 提交请求携带结构化 `references`;
-- Rust 按 manifest 二次校验并生成安全投影;
+- 提交请求携带 canonical user message item;
+- Rust 按 manifest 二次校验、持久化 canonical item,并生成 Codex wire input;
- 普通无引用消息保持原有行为;
- 素材选择面板的「当前版本素材 / 全部画布素材」两个页签与独立筛选、搜索状态;
- 资源改名后引用芯片与候选列表的显示名自动刷新;
-- 切换 / 重开会话恢复草稿后光标落在文本末尾,引用按顺序追加到文本之后。
+- 切换 / 重开会话恢复草稿后光标落在文本末尾,引用按原 content 顺序恢复为 inline 芯片。
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..1ca7a142f 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 自动验收。
@@ -431,7 +431,7 @@ UI 相关修改要重点验证:
npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.service --restart-service-after genarrative-api.service --restart-service-after genarrative-external-generation-worker@1.service --restart-service-after genarrative-external-generation-controller.service
```
-脚本会将数据目录打包成 `tar.gz`,上传到 `oss://///-.tar.gz`。生产建议做冷备份:传入 `--stop-service spacetimedb.service`,脚本会在打包前停止服务、打包后恢复服务,再上传 OSS;因 `genarrative-api.service`、`genarrative-external-generation-worker@*.service` 和 `genarrative-external-generation-controller.service` 都依赖 `spacetimedb.service`,生产定时冷备份还必须传入对应的 `--restart-service-after`,确保备份后 API、保底 worker 和 controller 随数据库一起恢复。`2026-06-10` release 故障就是现场 unit 漏掉 API 重启参数,`03:20` 冷备份停止 SpacetimeDB 后 API 被依赖关系一并停止,备份脚本只恢复了 SpacetimeDB,API 直到人工重启前都不可用;`2026-06-24` release 又出现同类依赖停机后只恢复 API、未恢复外部生成 worker/controller,导致图片画布生成任务长期停留在队列中。后续现场变更、provision 模板和 Jenkins 归档都必须通过 `npm run check:production-ops` 防止回退。由于 OSS 上传可能受服务器带宽限制,`Genarrative-Stdb-Module-Publish` 默认使用 `DATABASE_BACKUP_MODE=async`:先在 publish 前用 `--defer-upload` 生成本地冷备份和 `.manifest.json`,随后继续执行 publish;发布脚本退出前会用独立 `systemd-run` transient service 执行 `--upload-deferred-dir `,串行补传该目录内同库的 `deferred/pending` 归档,不依赖 Jenkins 作业进程树存活。任一归档只有在 OSS archive、manifest 和 baseline state 全部上传并验真后,才按 `keep-local` 规则删除;失败归档保留原 manifest,由下次 publish 重试。`Genarrative-Full-Build-And-Deploy` 必须显式暴露并透传同一个 `DATABASE_BACKUP_MODE`,不得静默使用下游 `async`;release 已有验真冷备且明确禁止再上传时,Full 必须选择 `skip`。发布脚本在校验 wasm 后、执行 `spacetime publish` 前会等待显式 `SPACETIME_SERVER_URL` 的 `/v1/ping` 就绪,默认最多等待 `60` 秒;如生产机器冷备份恢复 `spacetimedb.service` 较慢,可临时设置 `GENARRATIVE_STDB_PUBLISH_READY_TIMEOUT_SECONDS` 调整等待时间。需要强一致发布闸门时改用 `DATABASE_BACKUP_MODE=sync`(等价脚本参数 `--backup-mode sync`),备份会在 publish 前同步打包并上传,失败会阻断 publish;确认已有其他备份窗口时才使用 `DATABASE_BACKUP_MODE=skip`(兼容脚本参数 `--skip-backup`)。若业务不能接受停机窗口,应先规划 SpacetimeDB 原生快照或主备策略,不要直接在写入中的数据目录上做热拷贝并当作强一致备份。
+脚本会将数据目录打包成 `tar.gz`,上传到 `oss://///-.tar.gz`。备份使用同库进程锁:仍存活的备份进程会阻断并发执行;持有锁的进程已退出时,脚本会自动清理失效锁并重试获取,不需要人工删除锁文件。生产建议做冷备份:传入 `--stop-service spacetimedb.service`,脚本会在打包前停止服务、打包后恢复服务,再上传 OSS;因 `genarrative-api.service`、`genarrative-external-generation-worker@*.service` 和 `genarrative-external-generation-controller.service` 都依赖 `spacetimedb.service`,生产定时冷备份还必须传入对应的 `--restart-service-after`,确保备份后 API、保底 worker 和 controller 随数据库一起恢复。`2026-06-10` release 故障就是现场 unit 漏掉 API 重启参数,`03:20` 冷备份停止 SpacetimeDB 后 API 被依赖关系一并停止,备份脚本只恢复了 SpacetimeDB,API 直到人工重启前都不可用;`2026-06-24` release 又出现同类依赖停机后只恢复 API、未恢复外部生成 worker/controller,导致图片画布生成任务长期停留在队列中。后续现场变更、provision 模板和 Jenkins 归档都必须通过 `npm run check:production-ops` 防止回退。由于 OSS 上传可能受服务器带宽限制,`Genarrative-Stdb-Module-Publish` 默认使用 `DATABASE_BACKUP_MODE=async`:先在 publish 前用 `--defer-upload` 生成本地冷备份和 `.manifest.json`,随后继续执行 publish;发布脚本退出前会用独立 `systemd-run` transient service 执行 `--upload-deferred-dir `,串行补传该目录内同库的 `deferred/pending` 归档,不依赖 Jenkins 作业进程树存活。任一归档只有在 OSS archive、manifest 和 baseline state 全部上传并验真后,才按 `keep-local` 规则删除;失败归档保留原 manifest,由下次 publish 重试。`Genarrative-Full-Build-And-Deploy` 必须显式暴露并透传同一个 `DATABASE_BACKUP_MODE`,不得静默使用下游 `async`;release 已有验真冷备且明确禁止再上传时,Full 必须选择 `skip`。发布脚本在校验 wasm 后、执行 `spacetime publish` 前会等待显式 `SPACETIME_SERVER_URL` 的 `/v1/ping` 就绪,默认最多等待 `60` 秒;如生产机器冷备份恢复 `spacetimedb.service` 较慢,可临时设置 `GENARRATIVE_STDB_PUBLISH_READY_TIMEOUT_SECONDS` 调整等待时间。需要强一致发布闸门时改用 `DATABASE_BACKUP_MODE=sync`(等价脚本参数 `--backup-mode sync`),备份会在 publish 前同步打包并上传,失败会阻断 publish;确认已有其他备份窗口时才使用 `DATABASE_BACKUP_MODE=skip`(兼容脚本参数 `--skip-backup`)。若业务不能接受停机窗口,应先规划 SpacetimeDB 原生快照或主备策略,不要直接在写入中的数据目录上做热拷贝并当作强一致备份。
生产环境变量模板在 `deploy/env/api-server.env.example`:
@@ -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/check-database-backup-to-oss.mjs b/scripts/check-database-backup-to-oss.mjs
index b42f5dbfc..44060f394 100644
--- a/scripts/check-database-backup-to-oss.mjs
+++ b/scripts/check-database-backup-to-oss.mjs
@@ -76,7 +76,7 @@ async function main() {
await assertManifestUploadUsesShaAndHeadVerification();
assertHistoryDiscoversDevAndProductionLayoutsWithMultipleReplicas();
assertHistoryRequiresBaselineAndProducesDeterministicDeferredBatch();
- assertHistoryBackupLockRejectsLiveAndStaleOwners();
+ assertHistoryBackupLockRejectsLiveAndCleansStaleOwners();
assertHistorySkipsReplicaWithoutSnapshotAndRejectsMalformedNames();
assertHistoryStatDriftPreventsAnyCleanup();
await assertHistoryUploadFailureDoesNotDeleteSources();
@@ -2140,7 +2140,7 @@ function assertHistoryRequiresBaselineAndProducesDeterministicDeferredBatch() {
}
}
-function assertHistoryBackupLockRejectsLiveAndStaleOwners() {
+function assertHistoryBackupLockRejectsLiveAndCleansStaleOwners() {
const liveOwner = createHistoryFixture('history-live-lock', {
nestedData: false,
});
@@ -2176,17 +2176,17 @@ function assertHistoryBackupLockRejectsLiveAndStaleOwners() {
const staleResult = runHistoryCommand(staleOwner, ['--defer-upload']);
assertStatus(
staleResult,
- 1,
- '失效 owner pid 的 backup lock 也必须失败关闭,避免并发抢锁。',
+ 0,
+ '失效 owner pid 的 backup lock 应自动清理并成功获取新锁。',
);
assertIncludes(
- staleResult.stderr,
- '拒绝自动抢锁',
- '失效 backup lock 应要求人工核对 multipart 与进程。',
+ `${staleResult.stdout}\n${staleResult.stderr}`,
+ '失效数据库备份锁',
+ '自动清理失效 backup lock 时应输出可审计日志。',
);
assertTrue(
- existsSync(staleLockPath),
- '失效 backup lock 未经人工核对不得自动删除。',
+ !existsSync(staleLockPath),
+ '成功获取并释放新锁后,失效 backup lock 不得残留。',
);
}
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/database-backup-to-oss.mjs b/scripts/database-backup-to-oss.mjs
index c64e8657f..bc215509f 100644
--- a/scripts/database-backup-to-oss.mjs
+++ b/scripts/database-backup-to-oss.mjs
@@ -456,43 +456,69 @@ function acquireBackupLock({ workDir, database }) {
workDir,
`${sanitizeObjectPart(database, 'spacetimedb')}.backup.lock`,
);
- try {
- const fd = openSync(lockPath, 'wx', 0o600);
- writeFileSync(fd, `${process.pid}\n`, 'utf8');
- closeSync(fd);
- const release = () => {
- try {
- const ownerPid = Number(String(readFileSync(lockPath, 'utf8')).trim());
- if (ownerPid === process.pid) {
- rmSync(lockPath, { force: true });
+ for (let attempt = 0; attempt < 5; attempt += 1) {
+ try {
+ const fd = openSync(lockPath, 'wx', 0o600);
+ writeFileSync(fd, `${process.pid}\n`, 'utf8');
+ closeSync(fd);
+ const release = () => {
+ try {
+ const ownerPid = Number(
+ String(readFileSync(lockPath, 'utf8')).trim(),
+ );
+ if (ownerPid === process.pid) {
+ rmSync(lockPath, { force: true });
+ }
+ } catch {
+ // The lock may already have been removed by the normal exit path.
}
- } catch {
- // The lock may already have been removed by the normal exit path.
+ };
+ process.once('exit', release);
+ for (const signal of ['SIGINT', 'SIGTERM']) {
+ process.once(signal, () => {
+ release();
+ process.exit(signal === 'SIGINT' ? 130 : 143);
+ });
+ }
+ return lockPath;
+ } catch (error) {
+ if (error?.code !== 'EEXIST') {
+ throw error;
}
- };
- process.once('exit', release);
- for (const signal of ['SIGINT', 'SIGTERM']) {
- process.once(signal, () => {
- release();
- process.exit(signal === 'SIGINT' ? 130 : 143);
- });
}
- return lockPath;
- } catch (error) {
- if (error?.code !== 'EEXIST') {
+ let ownerPid = 0;
+ try {
+ ownerPid = Number(String(readFileSync(lockPath, 'utf8')).trim());
+ } catch (error) {
+ if (error?.code === 'ENOENT') {
+ continue;
+ }
throw error;
}
- }
- const ownerPid = Number(String(readFileSync(lockPath, 'utf8')).trim());
- if (
- Number.isSafeInteger(ownerPid) &&
- ownerPid > 0 &&
- processIsAlive(ownerPid)
- ) {
- throw new Error(`已有数据库备份进程持有锁: ${lockPath} pid=${ownerPid}`);
+ if (
+ Number.isSafeInteger(ownerPid) &&
+ ownerPid > 0 &&
+ processIsAlive(ownerPid)
+ ) {
+ throw new Error(`已有数据库备份进程持有锁: ${lockPath} pid=${ownerPid}`);
+ }
+ const staleLockPath = `${lockPath}.stale-${process.pid}-${attempt}-${Date.now()}`;
+ try {
+ // rename 是原子的:若另一个进程已先清理并重新建锁,这里只会得到 ENOENT,
+ // 不会把新进程的锁误删。
+ renameSync(lockPath, staleLockPath);
+ rmSync(staleLockPath, { force: true });
+ console.log(
+ `[database-backup] 已清理失效数据库备份锁,准备重新获取: ${lockPath} pid=${ownerPid || ''}`,
+ );
+ } catch (error) {
+ if (error?.code !== 'ENOENT') {
+ throw error;
+ }
+ }
}
throw new Error(
- `发现失效数据库备份锁,拒绝自动抢锁;请核对 OSS multipart 与进程后手工删除: ${lockPath} pid=${ownerPid || ''}`,
+ `数据库备份锁在清理后仍无法获取,可能存在并发进程: ${lockPath}`,
);
}
@@ -4517,6 +4543,21 @@ if (
for (const line of describeError(error)) {
console.error(`[database-backup] ${line}`);
}
+ // 即使初始化失败(例如已有进程持有锁),也保持 --result-file 可被机器读取。
+ // 调用方可以检查错误载荷并跳过延后上传,避免对空的临时文件执行 JSON.parse。
+ try {
+ const parsed = parseArgs(process.argv.slice(2));
+ if (parsed.resultFile) {
+ atomicWriteJson(resolvePath(parsed.resultFile), {
+ uploadStatus: 'failed',
+ error: error instanceof Error ? error.message : String(error),
+ archivePath: '',
+ manifestPath: '',
+ });
+ }
+ } catch {
+ // 参数解析或状态文件写入失败时,不能掩盖原始备份错误。
+ }
process.exit(1);
});
}
diff --git a/scripts/deploy/production-stdb-publish.sh b/scripts/deploy/production-stdb-publish.sh
index f34492476..c102e45ec 100644
--- a/scripts/deploy/production-stdb-publish.sh
+++ b/scripts/deploy/production-stdb-publish.sh
@@ -519,7 +519,9 @@ prepare_async_backup() {
restart_service_args+=(--restart-service-after genarrative-api.service)
fi
- ASYNC_BACKUP_STATUS_FILE="$(mktemp /tmp/genarrative-stdb-backup-status.XXXXXX.json)"
+ task_tmp_dir="${HOME}/data/tmp"
+ mkdir -p "${task_tmp_dir}"
+ ASYNC_BACKUP_STATUS_FILE="$(mktemp "${task_tmp_dir}/genarrative-stdb-backup-status.XXXXXX.json")"
echo "[production-stdb-publish] publish 前生成本地冷备份,随后会异步上传 OSS"
node -- "${ASYNC_BACKUP_SCRIPT}" \
--env-file /etc/genarrative/api-server.env \
@@ -536,14 +538,32 @@ start_async_backup_upload() {
local node_binary=""
local unit_name=""
local unit_suffix=""
+ local backup_paths=""
if [[ -z "${ASYNC_BACKUP_STATUS_FILE}" || ! -f "${ASYNC_BACKUP_STATUS_FILE}" ]]; then
echo "[production-stdb-publish] 警告:未找到可上传的本地备份状态文件,跳过异步上传" >&2
return 0
fi
- ASYNC_BACKUP_ARCHIVE="$(node -e 'const fs=require("node:fs"); const p=process.argv[1]; const o=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(o.archivePath || "");' "${ASYNC_BACKUP_STATUS_FILE}")"
- ASYNC_BACKUP_MANIFEST="$(node -e 'const fs=require("node:fs"); const p=process.argv[1]; const o=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(o.manifestPath || "");' "${ASYNC_BACKUP_STATUS_FILE}")"
+ # 备份进程可能因数据库锁冲突在写入状态文件前退出,或只留下空/截断文件。
+ # 解析失败时保留状态文件并让调用方继续处理发布失败,不能再让 node JSON.parse
+ # 的堆栈噪声覆盖原始备份错误。
+ if ! backup_paths="$(node -e '
+ const fs = require("node:fs");
+ const p = process.argv[1];
+ let o;
+ try {
+ o = JSON.parse(fs.readFileSync(p, "utf8"));
+ } catch {
+ process.exit(2);
+ }
+ process.stdout.write(`${o.archivePath || ""}\n${o.manifestPath || ""}`);
+ ' "${ASYNC_BACKUP_STATUS_FILE}" 2>/dev/null)"; then
+ echo "[production-stdb-publish] 警告:备份状态文件为空或不是有效 JSON,跳过异步上传并保留状态文件: ${ASYNC_BACKUP_STATUS_FILE}" >&2
+ return 1
+ fi
+ ASYNC_BACKUP_ARCHIVE="${backup_paths%%$'\n'*}"
+ ASYNC_BACKUP_MANIFEST="${backup_paths#*$'\n'}"
if [[ -z "${ASYNC_BACKUP_ARCHIVE}" || -z "${ASYNC_BACKUP_MANIFEST}" ]]; then
echo "[production-stdb-publish] 警告:备份状态文件缺少 archivePath 或 manifestPath,跳过异步上传" >&2
return 0
@@ -727,7 +747,9 @@ if [[ -n "${RUN_AS_USER}" && "$(id -u)" -eq 0 ]]; then
echo "[production-stdb-publish] 发布用户不存在: ${RUN_AS_USER}" >&2
exit 1
fi
- PUBLISH_TMP_DIR="$(mktemp -d /tmp/genarrative-stdb-publish.XXXXXX)"
+ task_tmp_dir="${HOME}/data/tmp"
+ mkdir -p "${task_tmp_dir}"
+ PUBLISH_TMP_DIR="$(mktemp -d "${task_tmp_dir}/genarrative-stdb-publish.XXXXXX")"
install -m 0644 "${SOURCE_DIR}/spacetime_module.wasm" "${PUBLISH_TMP_DIR}/spacetime_module.wasm"
chown -R "${RUN_AS_USER}:${RUN_AS_USER}" "${PUBLISH_TMP_DIR}"
PUBLISH_ARGS=(
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