修复素材画布登录态生成闭环
Project CI / Repository checks (pull_request) Failing after 7s
Project CI / Frontend tests (pull_request) Failing after 2m23s
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / Native shell tests (pull_request) Failing after 29s

复用客户端登录态与服务端环境配置,移除客户手填 API Key 的生成路径。

补齐外部生成幂等查询、泥点扣费结果回填与失败错误映射。

修复确认生成后返回原界面的问题,并完善生成中、成功和失败状态展示。

更新契约、测试、技术方案、PRD 与项目决策记录。
This commit is contained in:
2026-08-06 13:00:52 +08:00
parent f9847c8f15
commit f4fed35660
19 changed files with 1244 additions and 189 deletions
@@ -1634,7 +1634,8 @@ for (const snippet of [
"'write_game_creator_app_config'",
'aria-label="运行时配置"',
'LLM API Key',
'画板 API Key',
'showDeveloperEditorApi',
'开发者 External Editor API Key',
'runtime_config.save',
"'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'",
"'activate_local_game_preview'",
@@ -462,6 +462,23 @@ pub(crate) async fn resolve_canvas_resource_download(
.await
}
pub(crate) async fn resolve_authenticated_canvas_resource_download(
client: &reqwest::Client,
api_base_url: &str,
access_token: &str,
resource: &serde_json::Value,
) -> Result<Option<CanvasResourceDownload>, String> {
resolve_canvas_resource_download_with_limit_and_route(
client,
api_base_url,
access_token,
resource,
20 * 1024 * 1024,
"/api/assets/read-url",
)
.await
}
fn external_asset_url_same_origin(url: &url::Url, api_base_url: &str) -> bool {
let Ok(api_base_url) = url::Url::parse(api_base_url) else {
return false;
@@ -608,6 +625,25 @@ pub(crate) async fn resolve_canvas_resource_download_with_limit(
api_key: &str,
resource: &serde_json::Value,
max_bytes: usize,
) -> Result<Option<CanvasResourceDownload>, String> {
resolve_canvas_resource_download_with_limit_and_route(
_client,
api_base_url,
api_key,
resource,
max_bytes,
"/api/external/v1/assets/read-url",
)
.await
}
async fn resolve_canvas_resource_download_with_limit_and_route(
_client: &reqwest::Client,
api_base_url: &str,
bearer_token: &str,
resource: &serde_json::Value,
max_bytes: usize,
read_url_route: &str,
) -> Result<Option<CanvasResourceDownload>, String> {
if max_bytes == 0 {
return Err("画板资产剩余下载预算为 0,已拒绝同步".to_string());
@@ -623,23 +659,26 @@ pub(crate) async fn resolve_canvas_resource_download_with_limit(
let source_hint = object_key.as_deref().or(image_src.as_deref());
let (signed_url, came_from_stable_reference) = if let Some(object_key) = object_key.as_deref() {
let read_url = format!(
"{}/api/external/v1/assets/read-url?objectKey={}",
"{}{read_url_route}?objectKey={}",
api_base_url,
percent_encode_query_component(object_key)
);
(
Some(resolve_external_asset_signed_url(&secure_client, api_key, read_url).await?),
Some(resolve_external_asset_signed_url(&secure_client, bearer_token, read_url).await?),
true,
)
} else if let Some(image_src) = image_src.as_deref() {
if image_src.starts_with('/') {
let read_url = format!(
"{}/api/external/v1/assets/read-url?legacyPublicPath={}",
"{}{read_url_route}?legacyPublicPath={}",
api_base_url,
percent_encode_query_component(image_src)
);
(
Some(resolve_external_asset_signed_url(&secure_client, api_key, read_url).await?),
Some(
resolve_external_asset_signed_url(&secure_client, bearer_token, read_url)
.await?,
),
true,
)
} else if image_src.starts_with("http://") || image_src.starts_with("https://") {
File diff suppressed because it is too large Load Diff
+1
View File
@@ -11141,6 +11141,7 @@ export function App({
{runtimeConfigOpen ? (
<RuntimeConfigDialog
projectPath={localProject?.projectPath}
showDeveloperEditorApi={devMode}
onClose={() => setRuntimeConfigOpen(false)}
onLog={(entry) => setCommandLog((current) => [...current, entry])}
/>
@@ -40,6 +40,7 @@ import {
} from 'react';
import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { useWalletStore } from '../../stores/useWalletStore';
import type {
LocalAssetCommittedEvent,
TauriImageCanvasHostAdapter,
@@ -119,6 +120,48 @@ type DragState =
layerId: string;
};
const generationPhaseLabels: Record<
ImageCanvasGenerationProgressPhase,
string
> = {
'confirmation-required': '正在确认生成请求',
'generation-accepted': '请求已受理,正在排队',
'generation-running': '正在生成图片',
'remote-completed': '图片已生成,正在准备下载',
'media-downloaded': '图片已下载,正在保存到项目',
'asset-durable-committed': '图片已保存到项目',
'manifest-projected': '资源已登记,正在更新画布',
'layout-ready': '资源布局已更新',
selected: '新资源已选中',
failed: '图片生成失败',
'reconciliation-required': '正在等待原任务恢复',
};
const generationPhaseProgress: Record<
ImageCanvasGenerationProgressPhase,
number
> = {
'confirmation-required': 4,
'generation-accepted': 12,
'generation-running': 40,
'remote-completed': 64,
'media-downloaded': 84,
'asset-durable-committed': 100,
'manifest-projected': 100,
'layout-ready': 100,
selected: 100,
failed: 0,
'reconciliation-required': 55,
};
function generationFailureTitle(code: string) {
if (code === 'authentication-required') return '登录已失效';
if (code === 'insufficient-mud-points') return '泥点余额不足';
if (code === 'platform-service-configuration') return '平台生成服务暂不可用';
if (code === 'reconciliation-required') return '原生成任务需要恢复';
return '图片生成未完成';
}
export type RenderAssetCanvasImage = (input: {
layers: RuntimeCanvasLayer[];
backgroundColor: string;
@@ -318,6 +361,9 @@ export function AssetCanvasSurface({
const pendingGenerationRef = useRef<PendingGenerationIdentity | null>(null);
const generationStartingRef = useRef(false);
const generationFocusEpochRef = useRef(0);
const onWalletBalanceMayHaveChanged = useWalletStore(
(state) => state.onWalletBalanceMayHaveChanged,
);
layersRef.current = layers;
viewportRef.current = viewport;
@@ -539,6 +585,7 @@ export function AssetCanvasSurface({
`已安全恢复 ${result.value.resumedGenerationIds.length} 个原生成 operation`,
);
}
void onWalletBalanceMayHaveChanged();
});
})().catch((error: unknown) => {
if (
@@ -567,6 +614,7 @@ export function AssetCanvasSurface({
host,
hydrateDraft,
onCommitted,
onWalletBalanceMayHaveChanged,
stableScope,
sessionId,
]);
@@ -1124,6 +1172,7 @@ export function AssetCanvasSurface({
referenceResourceIds: frozenReferences,
onProgress,
});
void onWalletBalanceMayHaveChanged();
if (
epoch !== epochRef.current ||
focusEpoch !== generationFocusEpochRef.current
@@ -1208,11 +1257,31 @@ export function AssetCanvasSurface({
host.projectPath,
onCommitted,
onSaveAttempt,
onWalletBalanceMayHaveChanged,
persistDraft,
sessionId,
stableScope,
]);
const reopenGenerationAfterFailure = useCallback(
(dialog: 'edit' | 'confirm') => {
if (lifecycleRef.current.kind !== 'canvas.failed') return;
pendingGenerationRef.current = {
saveAttemptId: crypto.randomUUID(),
intentId: crypto.randomUUID(),
generationId: crypto.randomUUID(),
idempotencyKey: crypto.randomUUID(),
commitId: crypto.randomUUID(),
commitIdempotencyKey: crypto.randomUUID(),
};
generationStartingRef.current = false;
setNotice('');
setLifecycle({ kind: 'canvas.editing', dirty: false });
setGenerationDialog(dialog);
},
[],
);
const stopWaitingForGeneration = useCallback(() => {
generationFocusEpochRef.current += 1;
pendingGenerationRef.current = null;
@@ -1496,6 +1565,68 @@ export function AssetCanvasSurface({
</div>
) : null}
{lifecycle.kind === 'canvas.generating' ? (
<section
className="asset-canvas-surface__operation-overlay"
aria-live="polite"
aria-label="图片生成进度"
>
<div className="asset-canvas-surface__operation-card">
<span className="asset-canvas-surface__operation-kicker">
AI
</span>
<strong>{generationPhaseLabels[lifecycle.phase]}</strong>
<progress
max={100}
value={generationPhaseProgress[lifecycle.phase]}
aria-label="图片生成进度"
/>
<p></p>
<button type="button" onClick={stopWaitingForGeneration}>
</button>
</div>
</section>
) : null}
{lifecycle.kind === 'canvas.failed' ? (
<section
className="asset-canvas-surface__operation-overlay"
role="alert"
aria-label="图片生成失败"
>
<div className="asset-canvas-surface__operation-card asset-canvas-surface__operation-card--failed">
<span className="asset-canvas-surface__operation-kicker">
AI
</span>
<strong>{generationFailureTitle(lifecycle.code)}</strong>
<p>{lifecycle.message}</p>
<div className="asset-canvas-surface__operation-actions">
<button type="button" onClick={cancelCanvas}>
</button>
{!lifecycle.reconciliationRequired ? (
<>
<button
type="button"
onClick={() => reopenGenerationAfterFailure('edit')}
>
</button>
<button
type="button"
className="asset-canvas-surface__save"
onClick={() => reopenGenerationAfterFailure('confirm')}
>
</button>
</>
) : null}
</div>
</div>
</section>
) : null}
<SharedCanvasViewport
ref={viewportElementRef}
className="asset-canvas-surface__viewport"
@@ -1688,12 +1819,22 @@ export function AssetCanvasSurface({
</aside>
<footer className="asset-canvas-surface__status" role="status">
<span>{lifecycle.kind}</span>
<span>
{lifecycle.kind === 'canvas.editing'
? '画布可编辑'
: lifecycle.kind === 'canvas.saving'
? '正在保存'
: lifecycle.kind === 'canvas.generating'
? '正在生成'
: lifecycle.kind === 'canvas.failed'
? '操作未完成'
: '正在准备画布'}
</span>
{lifecycle.kind === 'canvas.saving' ? (
<span>{lifecycle.stage}</span>
) : null}
{lifecycle.kind === 'canvas.generating' ? (
<span>{lifecycle.phase}</span>
<span>{generationPhaseLabels[lifecycle.phase]}</span>
) : null}
{lifecycle.kind === 'canvas.failed' ? (
<span>{lifecycle.message}</span>
@@ -3,8 +3,8 @@
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
width: 100%;
height: min(800px, 100vh);
min-height: 540px;
height: 100%;
min-height: 0;
overflow: hidden;
border-radius: 16px;
background: #e8e6e2;
@@ -184,6 +184,68 @@
background-size: 18px 18px;
}
.asset-canvas-surface__operation-overlay {
z-index: 12;
grid-row: 2;
grid-column: 1;
display: grid;
min-width: 0;
min-height: 0;
place-items: center;
overflow: auto;
padding: 20px;
background: rgba(45, 41, 38, 0.34);
backdrop-filter: blur(4px);
}
.asset-canvas-surface__operation-card {
display: grid;
width: min(440px, 100%);
gap: 14px;
border: 1px solid rgba(63, 54, 48, 0.14);
border-radius: 18px;
padding: 22px;
background: #fcfbf9;
box-shadow: 0 20px 64px rgba(35, 31, 28, 0.2);
}
.asset-canvas-surface__operation-card strong {
font-size: 20px;
}
.asset-canvas-surface__operation-card p {
margin: 0;
color: #6f655f;
line-height: 1.55;
}
.asset-canvas-surface__operation-card progress {
width: 100%;
height: 8px;
overflow: hidden;
border: 0;
border-radius: 999px;
accent-color: #c7653d;
}
.asset-canvas-surface__operation-kicker {
color: #9c4c2d;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
}
.asset-canvas-surface__operation-card--failed {
border-color: rgba(164, 65, 45, 0.24);
}
.asset-canvas-surface__operation-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.asset-canvas-surface__resize-handle {
position: absolute;
right: -8px;
@@ -238,16 +300,17 @@
min-height: 34px;
align-items: center;
gap: 12px;
padding: 0 12px;
padding: 8px 12px;
border-top: 1px solid rgba(63, 54, 48, 0.12);
background: rgba(252, 251, 249, 0.96);
font-size: 12px;
line-height: 1.4;
}
@media (max-width: 760px) {
.asset-canvas-surface {
height: 100dvh;
min-height: 480px;
height: 100%;
min-height: 0;
border-radius: 0;
}
@@ -263,4 +326,15 @@
.asset-canvas-surface__generation-options {
grid-template-columns: 1fr;
}
.asset-canvas-surface__operation-overlay {
place-items: end stretch;
padding: 10px;
}
.asset-canvas-surface__operation-card {
width: auto;
border-radius: 16px;
padding: 18px;
}
}
@@ -15,6 +15,7 @@ import type {
GameCreationAppAssetManifestEntry,
GameCreationAppManifest,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { getStoredAuthAccessToken } from '../../services/clientApi';
const MAX_SAFE_REVISION = Number.MAX_SAFE_INTEGER;
export const LOCAL_ASSET_COMMITTED_EVENT =
@@ -156,13 +157,22 @@ function safeRevisionString(value: string, label: string): number {
function failure(error: unknown): ImageCanvasHostResult<never> {
const message = error instanceof Error ? error.message : String(error);
const normalized = message.toLowerCase();
return {
status: 'failed',
code: message.includes('reconciliation-required')
? 'reconciliation-required'
: message.includes('project-identity-conflict')
? 'project-identity-conflict'
: 'tauri-command-failed',
code:
message.includes('reconciliation-required')
? 'reconciliation-required'
: message.includes('project-identity-conflict')
? 'project-identity-conflict'
: normalized.includes('authentication-required') ||
message.includes('登录已失效')
? 'authentication-required'
: message.includes('泥点余额不足')
? 'insufficient-mud-points'
: message.includes('平台图片生成服务暂不可用')
? 'platform-service-configuration'
: 'tauri-command-failed',
message,
};
}
@@ -257,6 +267,10 @@ export function createTauriImageCanvasHostAdapter(input: {
async generateImage(generationInput) {
let unlisten: (() => void) | undefined;
try {
const accessToken = getStoredAuthAccessToken();
if (!accessToken) {
throw new Error('authentication-required: 登录已失效,请重新登录后重试');
}
unlisten = await listenGenerationProgress(
generationInput.scope,
generationInput.generationId,
@@ -285,6 +299,7 @@ export function createTauriImageCanvasHostAdapter(input: {
assetKind: generationInput.assetKind,
assetName: generationInput.assetName,
referenceResourceIds: generationInput.referenceResourceIds,
accessToken,
},
);
return { status: 'ok', value: result };
@@ -297,6 +312,10 @@ export function createTauriImageCanvasHostAdapter(input: {
async recoverImages(recoverInput) {
let unlisten: (() => void) | undefined;
try {
const accessToken = getStoredAuthAccessToken();
if (!accessToken) {
throw new Error('authentication-required: 登录已失效,请重新登录后重试');
}
unlisten = await listenGenerationProgress(
recoverInput.scope,
null,
@@ -304,7 +323,7 @@ export function createTauriImageCanvasHostAdapter(input: {
);
const result = await invokeInput<{ resumedGenerationIds: string[] }>(
'recover_local_project_asset_canvas_generations',
baseScope(recoverInput.scope),
{ ...baseScope(recoverInput.scope), accessToken },
);
return { status: 'ok', value: result };
} catch (error) {
@@ -513,10 +513,12 @@ function normalizeRuntimeConfigDraft(
export function RuntimeConfigDialog({
projectPath,
showDeveloperEditorApi = false,
onClose,
onLog,
}: {
projectPath?: string;
showDeveloperEditorApi?: boolean;
onClose: () => void;
onLog?: (entry: string) => void;
}) {
@@ -1323,28 +1325,38 @@ export function RuntimeConfigDialog({
</Fragment>
);
})}
<label>
API Base URL
<input
aria-label="画板 API Base URL"
value={runtimeConfigDraft.editorApi.baseUrl}
onChange={(event) =>
updateRuntimeEditorConfig('baseUrl', event.currentTarget.value)
}
/>
</label>
<label>
API Key
<input
aria-label="画板 API Key"
autoComplete="off"
type="password"
value={runtimeConfigDraft.editorApi.apiKey}
onChange={(event) =>
updateRuntimeEditorConfig('apiKey', event.currentTarget.value)
}
/>
</label>
{showDeveloperEditorApi ? (
<>
<label>
External Editor Base URL
<input
aria-label="开发者 External Editor Base URL"
value={runtimeConfigDraft.editorApi.baseUrl}
onChange={(event) =>
updateRuntimeEditorConfig(
'baseUrl',
event.currentTarget.value,
)
}
/>
</label>
<label>
External Editor API Key
<input
aria-label="开发者 External Editor API Key"
autoComplete="off"
type="password"
value={runtimeConfigDraft.editorApi.apiKey}
onChange={(event) =>
updateRuntimeEditorConfig(
'apiKey',
event.currentTarget.value,
)
}
/>
</label>
</>
) : null}
<section className="runtime-mcp-section" aria-label="MCP servers">
<header className="runtime-mcp-header">
<div>
+10
View File
@@ -3966,6 +3966,16 @@ iframe.preview-frame {
grid-template-rows: auto auto minmax(0, 1fr);
}
.game-workbench-stage[data-resource-view-state^='resources.asset-canvas'] {
grid-template-rows: auto minmax(0, 1fr);
}
.game-workbench-stage[data-resource-view-state^='resources.asset-canvas']
> .asset-canvas-surface {
grid-row: 2;
min-height: 0;
}
.game-workbench-toolbar {
display: flex;
align-items: center;
@@ -27,6 +27,10 @@ import {
type LocalAssetCommittedEvent,
type TauriImageCanvasHostAdapter,
} from '../src/features/asset-canvas/tauriImageCanvasHostAdapter';
import {
clearStoredAuthAccessToken,
setStoredAuthAccessToken,
} from '../src/services/clientApi';
class TestPointerEvent extends MouseEvent {
readonly pointerId: number;
@@ -121,6 +125,7 @@ function memoryHost(input?: {
recoverGate?: Deferred<unknown>;
commitGate?: Deferred<void>;
generationGate?: Deferred<void>;
generationFailure?: { code: string; message: string };
recoveryEvent?: LocalAssetCommittedEvent;
}) {
let draft = input?.initialDraft ?? null;
@@ -246,6 +251,13 @@ function memoryHost(input?: {
errorCode: null,
});
await input?.generationGate?.promise;
if (input?.generationFailure) {
return {
status: 'failed' as const,
code: input.generationFailure.code,
message: input.generationFailure.message,
};
}
hostRevision += 1;
if (!draft) throw new Error('draft missing');
draft = { ...draft, revision: draft.revision + 1, status: 'committed' };
@@ -383,6 +395,7 @@ function renderSurface(
}
beforeEach(() => {
clearStoredAuthAccessToken();
Object.defineProperty(window, 'PointerEvent', {
configurable: true,
value: TestPointerEvent,
@@ -398,6 +411,7 @@ beforeEach(() => {
});
afterEach(() => {
clearStoredAuthAccessToken();
vi.restoreAllMocks();
});
@@ -405,7 +419,7 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
it('取消时先把草稿标记为 cancelled,再返回资源总览', async () => {
const memory = memoryHost();
const { onCancel } = renderSurface(memory.host);
expect(await screen.findByText('canvas.editing')).toBeTruthy();
expect(await screen.findByText('画布可编辑')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
await waitFor(() => expect(onCancel).toHaveBeenCalledTimes(1));
expect(memory.getDraft()?.status).toBe('cancelled');
@@ -414,7 +428,7 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
it('新建、导入、编辑、撤销重做并完成 durable commit', async () => {
const memory = memoryHost();
const { onCommitted, onSaveAttempt } = renderSurface(memory.host);
expect(await screen.findByText('canvas.editing')).toBeTruthy();
expect(await screen.findByText('画布可编辑')).toBeTruthy();
const file = new File([Uint8Array.from([1, 2, 3])], 'fixture.png', {
type: 'image/png',
@@ -567,7 +581,7 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
renderImage={renderImage}
/>,
);
expect(await screen.findByText('canvas.editing')).toBeTruthy();
expect(await screen.findByText('画布可编辑')).toBeTruthy();
await act(async () => gate.resolve(undefined));
expect(screen.queryByLabelText('图层 旧项目图层')).toBeNull();
oldMemory.emit({
@@ -632,7 +646,8 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
),
'utf8',
);
expect(css).toContain('height: min(800px, 100vh)');
expect(css).toContain('height: 100%');
expect(css).toContain('min-height: 0');
expect(css).toContain('@media (max-width: 760px)');
});
@@ -643,7 +658,7 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
generationGate: gate,
});
const { onCommitted, onSaveAttempt } = renderSurface(memory.host);
await screen.findByText('canvas.editing');
await screen.findByText('画布可编辑');
fireEvent.click(screen.getByRole('button', { name: 'AI 生成图片' }));
fireEvent.change(screen.getByLabelText('图片提示词'), {
@@ -655,7 +670,7 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
fireEvent.click(confirm);
await waitFor(() => expect(memory.generationCalls).toHaveLength(1));
expect(screen.getByText('generation-running')).toBeTruthy();
expect(screen.getAllByText('正在生成图片')).toHaveLength(2);
const identity = memory.generationCalls[0]!;
const uuidV4 =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
@@ -690,6 +705,87 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(onCommitted).toHaveBeenCalledTimes(1);
});
it('泥点不足时在中央保留失败状态和生成参数,并可返回修改', async () => {
const prompt = '保留参数的原创游戏场景';
const memory = memoryHost({
initialDraft: draftFixture(scope),
generationFailure: {
code: 'insufficient-mud-points',
message: '泥点余额不足,请充值后重试',
},
});
renderSurface(memory.host);
await screen.findByText('画布可编辑');
fireEvent.click(screen.getByRole('button', { name: 'AI 生成图片' }));
fireEvent.change(screen.getByLabelText('图片提示词'), {
target: { value: prompt },
});
fireEvent.change(screen.getByLabelText('图片比例'), {
target: { value: '9:16' },
});
fireEvent.change(screen.getByLabelText('图片尺寸'), {
target: { value: '2K' },
});
fireEvent.click(screen.getByRole('button', { name: '继续确认' }));
fireEvent.click(screen.getByRole('button', { name: '确认并生成' }));
const failure = await screen.findByRole('alert', {
name: '图片生成失败',
});
expect(failure.textContent).toContain('泥点余额不足');
expect(
screen.getByRole('button', { name: '返回修改' }),
).toBeTruthy();
expect(screen.getByRole('button', { name: '重新确认' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '返回修改' }));
expect(screen.queryByRole('alert', { name: '图片生成失败' })).toBeNull();
expect(screen.getByRole('dialog', { name: 'AI 图片生成' })).toBeTruthy();
expect((screen.getByLabelText('图片提示词') as HTMLTextAreaElement).value).toBe(
prompt,
);
expect((screen.getByLabelText('图片比例') as HTMLSelectElement).value).toBe(
'9:16',
);
expect((screen.getByLabelText('图片尺寸') as HTMLSelectElement).value).toBe(
'2K',
);
});
it('登录失效时在中央提示,并可携带原参数重新确认', async () => {
const prompt = '登录恢复后继续生成的角色立绘';
const memory = memoryHost({
initialDraft: draftFixture(scope),
generationFailure: {
code: 'authentication-required',
message: '登录已失效,请重新登录后重试',
},
});
renderSurface(memory.host);
await screen.findByText('画布可编辑');
fireEvent.click(screen.getByRole('button', { name: 'AI 生成图片' }));
fireEvent.change(screen.getByLabelText('图片提示词'), {
target: { value: prompt },
});
fireEvent.click(screen.getByRole('button', { name: '继续确认' }));
fireEvent.click(screen.getByRole('button', { name: '确认并生成' }));
const failure = await screen.findByRole('alert', {
name: '图片生成失败',
});
expect(failure.textContent).toContain('登录已失效');
fireEvent.click(screen.getByRole('button', { name: '重新确认' }));
expect(screen.queryByRole('alert', { name: '图片生成失败' })).toBeNull();
expect(screen.getByRole('dialog', { name: '确认图片生成' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '返回修改' }));
expect((screen.getByLabelText('图片提示词') as HTMLTextAreaElement).value).toBe(
prompt,
);
});
it('停止等待后迟到完成仍可后台对账,但不会抢回当前界面焦点', async () => {
const gate = deferred<void>();
const memory = memoryHost({
@@ -697,7 +793,7 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
generationGate: gate,
});
const { onCancel, onCommitted } = renderSurface(memory.host);
await screen.findByText('canvas.editing');
await screen.findByText('画布可编辑');
fireEvent.click(screen.getByRole('button', { name: 'AI 生成图片' }));
fireEvent.change(screen.getByLabelText('图片提示词'), {
@@ -706,7 +802,9 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
fireEvent.click(screen.getByRole('button', { name: '继续确认' }));
fireEvent.click(screen.getByRole('button', { name: '确认并生成' }));
await waitFor(() => expect(memory.generationCalls).toHaveLength(1));
fireEvent.click(screen.getByRole('button', { name: '停止等待并返回' }));
fireEvent.click(
screen.getAllByRole('button', { name: '停止等待并返回' }).at(-1)!,
);
expect(onCancel).toHaveBeenCalledTimes(1);
await act(async () => gate.resolve());
@@ -825,7 +923,7 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
const nextMemory = memoryHost({ initialDraft: draftFixture(nextScope) });
const onCommitted = vi.fn();
const view = renderSurface(oldMemory.host, scope, onCommitted);
await screen.findByText('canvas.editing');
await screen.findByText('画布可编辑');
fireEvent.click(screen.getByRole('button', { name: 'AI 生成图片' }));
fireEvent.change(screen.getByLabelText('图片提示词'), {
target: { value: '旧项目的生成请求' },
@@ -843,7 +941,7 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
renderImage={renderImage}
/>,
);
await screen.findByText('canvas.editing');
await screen.findByText('画布可编辑');
await act(async () => gate.resolve());
expect(onCommitted).not.toHaveBeenCalled();
expect(nextMemory.generationCalls).toHaveLength(0);
@@ -968,4 +1066,88 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
);
expect(invokeSpy).toHaveBeenCalledTimes(3);
});
it('Tauri adapter 只把登录 Token 临时传给生成命令,并在公开结果中移除', async () => {
const accessToken = 'session-token-must-stay-ephemeral';
setStoredAuthAccessToken(accessToken);
const manifest = manifestFixture(scope.projectId);
const invokeSpy = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command !== 'generate_local_project_asset_canvas_image') {
throw new Error(`unexpected command: ${command}`);
}
return {
generation: {
generationId: '22222222-2222-4222-8222-222222222222',
intentId: '11111111-1111-4111-8111-111111111111',
phase: 'asset-durable-committed',
referenceResourceIds: [],
outputAssetId: 'canvas-commit',
errorCode: null,
createdAt: 1,
updatedAt: 2,
},
images: [],
commit: {
resourceId: 'local-asset:canvas-commit',
assetId: 'canvas-commit',
projectId: scope.projectId,
commitId: '44444444-4444-4444-8444-444444444444',
committedProjectRevision: 1,
draftRevision: 1,
hostRevision: '1',
commitStatus: 'committed',
manifest,
eventId: 'event-token-test',
},
requestWasTransient: Boolean(args),
};
},
);
const adapter = createTauriImageCanvasHostAdapter({
projectPath: '/fixture/project',
expectedProjectId: scope.projectId,
expectedHostRevision: 0,
invoke: invokeSpy as unknown as <T>(
command: string,
args?: Record<string, unknown>,
) => Promise<T>,
});
const result = await adapter.generation.generateImage({
scope,
expectedHostRevision: '0',
expectedDraftRevision: 0,
intentId: '11111111-1111-4111-8111-111111111111',
generationId: '22222222-2222-4222-8222-222222222222',
idempotencyKey: '33333333-3333-4333-8333-333333333333',
commitId: '44444444-4444-4444-8444-444444444444',
commitIdempotencyKey: '55555555-5555-4555-8555-555555555555',
prompt: '登录态图片生成',
aspectRatio: '16:9',
imageSize: '1K',
assetKind: 'illustration',
assetName: '登录态素材',
referenceResourceIds: [],
});
expect(result.status).toBe('ok');
expect(invokeSpy).toHaveBeenCalledTimes(1);
const commandInput = invokeSpy.mock.calls[0]?.[1]?.input as Record<
string,
unknown
>;
expect(commandInput.accessToken).toBe(accessToken);
expect(JSON.stringify(result)).not.toContain(accessToken);
clearStoredAuthAccessToken();
const missingAuth = await adapter.generation.recoverImages({ scope });
expect(missingAuth).toEqual(
expect.objectContaining({
status: 'failed',
code: 'authentication-required',
}),
);
expect(invokeSpy).toHaveBeenCalledTimes(1);
});
});
@@ -486,7 +486,7 @@ type ProjectAgentMudPointAttribution = {
### 7.6 素材创作无限画布阶段一至五最终验收
实现状态(2026-08-05,阶段五):资源总览新增/图片精修入口、中央素材画布、取消恢复、正式 manifest/revision 实时合并、依赖图重建、dependency/type 双布局协调和三阶段自动定位已经接通。command/event 任意顺序按项目、commit、event 与 revision 去重;低 revision、旧 graph/layout 和失效 focus generation 均不能倒灌。Tauri 图片新增与精修已经通过共享 generation port 接入真实 External Editor 异步生成、稳定引用下载、阶段三正式事务和重启恢复;成功不依赖刷新、重开项目、随机等待或手工修复。
实现状态(2026-08-06):资源总览新增/图片精修入口、中央素材画布、取消恢复、正式 manifest/revision 实时合并、依赖图重建、dependency/type 双布局协调和三阶段自动定位已经接通。command/event 任意顺序按项目、commit、event 与 revision 去重;低 revision、旧 graph/layout 和失效 focus generation 均不能倒灌。普通客户 Tauri 图片新增与精修通过共享 generation port 使用平台登录态接入现有编辑器生成队列、泥点计费、稳定引用下载、阶段三正式事务和重启恢复;第三方开发者 External API 保持独立。客户无需且不能在画布填写 Base URL/API Key成功不依赖刷新、重开项目、随机等待或手工修复。
1. 网站与 Tauri 实际 import 同一份 `@genarrative/image-canvas-core``@genarrative/image-canvas-react`,客户端没有复制的主站画布目录;viewport、selection、变换、renderer 与 history 算法位于共享层,宿主只保留事件接线与 adapter 副作用。
2. “新增资源”和“精修资源”分别进入 create/refine 素材画布;精修保留原资产、创建新资产,并用规范 `referenceResourceIds` 登记直接血缘。
@@ -496,6 +496,7 @@ type ProjectAgentMudPointAttribution = {
6. 搜索/筛选隐藏新资源时保留条件,明确提示“新资源已保存,当前筛选条件下不可见”,只通过显式动作清除条件并定位。
7. 新增、精修、生成、保存、取消、失败和恢复必须覆盖权威专题 §13 的完整验收矩阵;只完成画布 UI 或只完成本地写文件都不能算正式闭环。
8. 自动定位必须分别证明资源已投影、dependency/type 两份布局都 settled 且存在目标位置、目标卡 DOM 已提交;搜索隐藏走显式清除/定位,任何 commit 最多自动聚焦一次。
9. 客户生成只使用当前登录身份;后端按 owner 预扣/退款泥点并返回可轮询 operation。登录失效、余额不足、平台生成配置故障和远端失败必须在画布内可见,保留参数并提供重试或返回修改;生成状态不得因固定高度或 `overflow` 裁剪而消失,终态后刷新钱包余额。
### 7.7 主站 UI 对齐验收
@@ -6156,11 +6156,12 @@
- CSS `url(...)` 的资产路径保持原始大小写解析,stylesheet 证据必须同时命中实际可见元素;未命中 selector、元素自身或祖先 hidden、以及匹配隐藏规则的节点均不作证。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## 2026-08-05 客户端素材画布真实图片生成复用 External Editor 与正式资产事务
## 2026-08-06 客户端素材画布客户登录态生成与开发者 External 通道隔离
- 生成入口决策:客户端共享素材画布只实现首版图片生成与精修,通过共享 `ImageCanvasGenerationPort` 调用既有 External Editor v1 与 `canvas.asset_generate`,不复制网站 `useImageCanvasGenerationWorkflow`,不建立第二套生成、扣费或发布系统。
- 确认决策:编辑 prompt、尺寸、比例、素材类型与参考图只改本地 UI;最终“确认生成”之前零 External 请求。一次已确认意图固定 `intentId + generationId + External idempotencyKey + commitId + commit idempotencyKey`,未知结果、重启和重复点击只能沿原身份恢复
- 持久化决策:prompt、精确请求快照、配置指纹、External key、operationId 和远端稳定引用只写 `.agent/workbench/asset-canvas/generations/` 私有账本;参考图上传只持久化恢复所需的稳定 bucket/objectKeydirect-upload ticket 的 host、formFields、policy、signature 和临时 Authorization 只存在内存。公开草稿、manifest 与事件只投影安全阶段和本地资产身份。External taskId 不得写入本地 manifest taskId
- 生成入口决策:客户端共享素材画布只实现首版图片生成与精修,通过共享 `ImageCanvasGenerationPort` 复用现有编辑器项目、统一生成队列、泥点预扣/退款和资产系统,不复制网站 `useImageCanvasGenerationWorkflow`,不建立第二套生成、扣费或发布系统。普通客户使用当前平台登录态调用 `/api/editor/*``/api/assets/*``/api/runtime/external-generation/jobs/*``/api/external/v1` 与开发者 API Key 继续只服务第三方 Agent、CLI 和受控内部验收。
- 安全与计费决策:客户画布不显示或要求填写 Base URL / API Key,不读取仓库根目录 `.env*`,也不把供应商 Key 打包进客户端。后端从登录身份解析 owner 并归属泥点扣费;客户 Access Token 只作为单次 IPC 输入存在内存,不写账本、草稿、manifest、事件、日志或错误
- 确认决策:编辑 prompt、尺寸、比例、素材类型与参考图只改本地 UI;最终“确认生成”之前零远端请求。一次已确认意图固定 `intentId + generationId + 远端 idempotencyKey + commitId + commit idempotencyKey`,未知结果、重启和重复点击只能沿原身份恢复
- 持久化决策:prompt、精确请求快照、不依赖凭据的调用模式/API 身份指纹、远端 key、operationId 和远端稳定引用只写 `.agent/workbench/asset-canvas/generations/` 私有账本;参考图上传只持久化恢复所需的稳定 bucket/objectKeydirect-upload ticket 的 host、formFields、policy、signature 和临时 Authorization 只存在内存。登录 Token 刷新不得触发 `configuration-changed`。公开草稿、manifest 与事件只投影安全阶段和本地资产身份。远端 taskId 不得写入本地 manifest taskId。
- 恢复决策:`accepted/running` 只 GET 原 operation,提交未知只用原请求字节和原 key 对账;远端完成后以固定 staging token 下载,再进入阶段三 journal/ledger。应用打开严格先恢复本地正式资产事务,再恢复 generation ledger,因此下载后提交中断只恢复原本地事务,不重新生成或下载。
- 显现决策:`generation-accepted -> generation-running -> remote-completed -> media-downloaded -> asset-durable-committed -> manifest-projected -> layout-ready -> selected` 不可压成 completed;停止 UI 等待只失效聚焦意图,迟到完成仍可安全投影 manifest,但不能抢焦点。
- 精修身份决策:正式本地资源通过 `referenceResourceIds` 引用原资源;External `sourceImageSrc` 只使用 objectKey、项目 resourceId 或素材 assetId,不能把 assetObjectId 当成媒体引用。`sourceResourceId` 只允许真实 External resourceId,不能使用 `local-asset:*` 或 objectKey 冒充。
@@ -2,7 +2,7 @@
## 目标
在 Genarrative 内建设独立桌面 App:普通用户通过项目开发工作台中的陶泥儿对话、资源画布、运行状态和确认操作,让平台生成保存在本地的可运行 Web 游戏原型,并通过本地 HTTP server 预览;主窗口提供运行时配置入口,用于保存发布版 AppData / Tauri 配置目录里的 LLM 与画板 API 配置。任务明细、原始文件、命令日志和专业 Agent 调试控制仍放到开发构建的独立开发窗口。v1 只做 Web 小游戏原型闭环,不扩展 Unity、Godot、云同步或插件市场。
在 Genarrative 内建设独立桌面 App:普通用户通过项目开发工作台中的陶泥儿对话、资源画布、运行状态和确认操作,让平台生成保存在本地的可运行 Web 游戏原型,并通过本地 HTTP server 预览;主窗口提供运行时配置入口,用于保存发布版 AppData / Tauri 配置目录里的 LLM 配置及受控开发者 External Editor 配置。普通客户素材画布使用平台登录态调用内部编辑器 API,不展示或要求填写画板 Base URL / API Key。任务明细、原始文件、命令日志和专业 Agent 调试控制仍放到开发构建的独立开发窗口。v1 只做 Web 小游戏原型闭环,不扩展 Unity、Godot、云同步或插件市场。
## 技术选择
@@ -952,9 +952,10 @@ game-project/
## 2026-08-05 客户端素材创作无限画布阶段五真实图片生成
- 阶段五只接图片生成与图片精修,通过阶段二共享 `ImageCanvasGenerationPort` 调用客户端 Tauri adapter,并复用 `canvas.asset_generate` 的 External Editor 会话准备、异步提交/查询、稳定引用解析和受控下载能力;不复制网站 `useImageCanvasGenerationWorkflow`,不新增角色动画、视频、音频、图集或平行生成系统。
- 阶段五只接图片生成与图片精修,通过阶段二共享 `ImageCanvasGenerationPort` 调用客户端 Tauri adapter,并复用现有编辑器项目、统一生成队列、资产上传/换签、稳定引用解析和受控下载能力;不复制网站 `useImageCanvasGenerationWorkflow`,不新增角色动画、视频、音频、图集或平行生成系统。
- 生成面板和确认摘要只操作本地状态;用户点击独立“确认生成”后才通过 `canvas.asset_generate` confirm 权限边界。本地生成意图建立时即分配稳定 `intentId/generationId/idempotencyKey`,取消确认则直接丢弃且零 External 调用;一旦提交,重复点击、重启和未知结果保持原身份,不能自动确认或换键重扣。
- External 相对 endpoint、精确请求、prompt、配置指纹、idempotency key、operationId 和稳定产物引用只进入 `.agent/workbench/asset-canvas/generations/` 私有账本。本地参考图 direct-upload ticket 的 host/formFields/policy/signature/临时 Authorization 只存在内存;恢复账本只允许保存稳定 bucket/objectKey。API Key、Authorization、Provider URL、签名 URL和绝对路径不进入项目事实;草稿/manifest/event/日志只投影安全阶段与本地身份,External taskId 不写 manifest `source.taskId`
- 普通客户模式只使用当前平台登录态调用 `/api/editor/*``/api/assets/*``/api/runtime/external-generation/jobs/*`;后端从登录身份解析 owner、按现有统一生成队列预扣/退款泥点,并只在服务端读取供应商配置。客户 UI 不显示 Base URL / API Key,也不得把根目录 `.env*`、供应商 Key 或开发者 Key 打包给客户端。`/api/external/v1` 与开发者 API Key 继续保留给第三方 Agent、CLI 和内部验收,不能用开发者 Key 替代客户身份或计费归属
- 调用模式、精确相对 endpoint、精确请求、prompt、idempotency key、operationId 和稳定产物引用只进入 `.agent/workbench/asset-canvas/generations/` 私有账本。本地参考图 direct-upload ticket 的 host/formFields/policy/signature/临时 Authorization 只存在内存;恢复账本只允许保存稳定 bucket/objectKey。登录 Access Token、API Key、Authorization、Provider URL、签名 URL和绝对路径不进入账本或项目事实;登录 Token 刷新不得触发 `configuration-changed`。草稿/manifest/event/日志只投影安全阶段与本地身份,远端 taskId 不写 manifest `source.taskId`
- 固定状态顺序为 `generation-accepted -> generation-running -> remote-completed -> media-downloaded -> asset-durable-committed -> manifest-projected -> layout-ready -> selected`。远端 completed 后必须先验证稳定引用,再经 `/assets/read-url` 下载并写入阶段三 staging;随后用账本中的固定本地 commit 身份进入既有 journal/ledger 事务。正式提交失败只恢复本地事务,不重新生成或下载。
- 应用重启时 `accepted/running` 只恢复 GET`prepared` 只以私有账本原请求字节和原键恢复同一 POST。用户停止等待只失效当前 focus generation;不可取消的远端操作继续 reconcile,迟到 completed 可以更新原项目但不能抢焦点。配置缺失、结果引用不稳定、下载/提交失败和未知结果都不得显示资源创建成功。
- 最终审计后 Tauri 撤销/重做直接使用与网站相同的共享 `useCanvasHistory`,共享 history 同时恢复 viewport、选择、图层位置和缩放边界;Tauri 自身只保留 Pointer 事件接线,平移、缩放、选择、图层变换和 renderer 算法继续来自共享 core/react。
@@ -2,7 +2,7 @@
更新时间:`2026-08-06`
状态:阶段一产品与技术合同已冻结;截至 2026-08-06,阶段一、二共享画布 chrome 与主站接入已实现,客户端中央画布换肤留待后续阶段
状态:阶段一产品与技术合同已冻结;截至 2026-08-06,客户登录态图片生成、泥点计费链路、中央进度/失败态和 Tauri 正式资产提交已落地,后续增量仍受本文合同约束
本文是网站与 AI 游戏创作 Tauri 客户端共享图片画布能力的下一阶段编码依据。若本文与资源管理阶段七的“美术编辑暂缓”口径冲突,以本文对后续素材创作切片的更新决定为准;资源总览既有布局、依赖图和只读聚焦合同继续有效。
@@ -13,7 +13,7 @@
3. 阶段一正式闭环只覆盖图片。PNG、JPEG、WebP 的导入、画布平移与缩放、单选与多选、图层移动与缩放、层序、显隐、锁定、翻转、分组、撤销与重做、裁剪/扩图等基础编辑、图片生成、导出和 Tauri 本地正式回写均属于目标,不得再列为非目标。
4. 现有一键去背景能力可以通过共享 Host Port 接入;毛发级抠图、可编辑蒙版和高级边缘修复后续分期。图集、角色动画、视频编辑和音频编辑不进入本阶段正式闭环。
5. 网站和 Tauri 必须实际 import 同一份画布 core 与 React/UI 源码。现役 `src/components/image-editor/` 是抽取来源,不得把整个目录复制到客户端,也不得形成网站版和 Tauri 版两份长期分叉的画布实现。
6. 网站继续负责账户、钱包、服务端编辑器项目和云端素材库;Tauri 继续负责本地项目、受控文件读写、manifest、项目 mutation revision 和 External Editor API。共享画布不知道这些事实来自哪个宿主。
6. 网站继续负责账户、钱包、服务端编辑器项目和云端素材库;Tauri 继续负责本地项目、受控文件读写、manifest、项目 mutation revision,并以当前平台登录态复用同一服务端编辑器与计费链路。第三方 Agent/CLI 使用的 External Editor API 是独立开发者通道,不是普通客户素材画布的鉴权方式。共享画布不知道这些事实来自哪个宿主。
7. 本阶段不修改 SpacetimeDB schema,不新增前端业务真相,不把草稿 sidecar 当成正式资产。
## 2. 中央主视窗状态与入口
@@ -58,7 +58,7 @@ apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter
- `image-canvas-core` 只含纯 TypeScript 的画布模型、几何、选择、图层命令、历史、序列化、防御校验和状态机;不得依赖 React、DOM、Tauri、HTTP、账号、钱包或浏览器存储。
- `image-canvas-react` 只含 React 视图、hooks、交互控制器和通用 UI,依赖 core 和注入的 Host Port;不得直接 import Tauri API、站点请求客户端、账户 store 或钱包 store。
- 网站 adapter 可以依赖账户、钱包、现有服务端 editor project、云端素材库、OSS/asset object 和生成 API。
- Tauri adapter 可以依赖 `invoke/listen`、本地项目上下文、受控媒体命令、manifest、项目 revision、草稿 sidecar 和 External Editor API 配置。
- Tauri adapter 可以依赖 `invoke/listen`、本地项目上下文、受控媒体命令、manifest、项目 revision、草稿 sidecar 和当前平台登录态;受控开发者模式才可依赖 External Editor API 配置。
- 依赖方向只能是“宿主 adapter -> React/UI -> core”。core/react 不得反向 import 任一宿主。
### 3.2 禁止复制的验收门
@@ -200,7 +200,7 @@ interface ImageCanvasHostPort {
- `hostRevision` 是宿主权威提交版本的字符串表示:Tauri 使用十进制项目 mutation revision,网站使用现有服务端 editor project revision。共享 UI 只透传/展示,不比较不同宿主的 revision。
- `commitId/idempotencyKey` 由共享流程在第一次正式保存前生成;响应未知时两宿主都复用原完整请求。`expectedHostRevision` 由 adapter 从已加载的权威宿主快照提供,Tauri 必须无损解析为本文的安全整数 `expectedRevision`
- Web adapter 把草稿、导入、生成、导出和提交映射到现有服务端 editor project、云端素材库及账户/钱包链路。
- Tauri adapter 把草稿、导入、导出和提交映射到本文第 7 至 11 节的本地合同;生成通过 External Editor APIAPI Key 只从 Tauri 应用配置读取,不能进入项目 sidecar、manifest、事件或错误正文。
- Tauri adapter 把草稿、导入、导出和提交映射到本文第 7 至 11 节的本地合同;普通客户生成通过平台登录态调用现有内部编辑器/资产/任务状态 API,后端按 owner 进入统一生成队列与泥点预扣/退款。客户界面不显示或要求填写 Base URL / API Key。External Editor API 只作为第三方 Agent、CLI 和受控内部验收通道;任一模式的凭据都不能进入项目 sidecar、manifest、事件或错误正文。
### 3.4 主站 UI 对齐与共享画布 chrome
@@ -911,10 +911,10 @@ cancelling
### 14.1 确认与身份
- 打开生成面板、修改 prompt / 比例 / 尺寸 / `assetKind`、选择参考资源和预览确认摘要都只能产生本地 UI 状态,确认前不得读取或创建 External Editor 项目、素材目录、上传票据或生成 operation。
- 打开生成面板、修改 prompt / 比例 / 尺寸 / `assetKind`、选择参考资源和预览确认摘要都只能产生本地 UI 状态,确认前不得读取或创建服务端编辑器项目、素材目录、上传票据或生成 operation。
- 只有用户点击独立确认面板中的“确认生成”才进入现有 `canvas.asset_generate` confirm 权限边界。项目 deny 继续优先;客户端不得调用 auto gate、模拟确认或在恢复时分配新逻辑请求。
- 一次逻辑生成在本地意图建立时固定分配规范 UUID v4 `intentId``generationId`External `idempotencyKey`、本地 `commitId` 和本地 commit idempotency key。用户取消确认可直接丢弃这组未使用身份且保持零 External 调用;一旦点击“确认生成”,重复点击、IPC 响应丢失、进程重启和结果未知都复用原值与原始请求字节。只有远端明确 terminal failed 后,用户再次明确确认才可建立新 intent。
- External taskId 是远端生成事实,不是本地 manifest task 身份。阶段五本地正式资产的 `source.taskId` 固定为 `null`,不能把 External taskId 填入并参与本地 producer/task graph。
- 一次逻辑生成在本地意图建立时固定分配规范 UUID v4 `intentId``generationId`远端 `idempotencyKey`、本地 `commitId` 和本地 commit idempotency key。用户取消确认可直接丢弃这组未使用身份且保持零远端调用;一旦点击“确认生成”,重复点击、IPC 响应丢失、进程重启和结果未知都复用原值与原始请求字节。只有远端明确 terminal failed 后,用户再次明确确认才可建立新 intent。
- 远端 taskId 是平台生成事实,不是本地 manifest task 身份。阶段五本地正式资产的 `source.taskId` 固定为 `null`,不能把远端 taskId 填入并参与本地 producer/task graph。
### 14.2 私有账本与公开投影
@@ -924,7 +924,7 @@ cancelling
.agent/workbench/asset-canvas/generations/<generationId>.json
```
账本由每个 `projectId + generationId` 的进程内 single-flight、安全相对路径和原子 sidecar 写入保护,包含精确 External 相对 endpoint / 请求 JSON / SHA-256、prompt 私有正文、配置指纹、External idempotency key、operationId、稳定 `objectKey/resourceId/assetObjectId`、本地 staging 与 commit 恢复身份。本地参考图上传恢复只允许暂存稳定 bucket/objectKeydirect-upload ticket 的 host、formFields、policy、signature 和临时 Authorization 只存在内存,任何阶段都不得序列化。阶段三正式资产事务继续由既有持久锁保护。API Key、Authorization、Provider URL、签名 URL 和绝对路径不得写入账本;API Key 只参与不可逆配置指纹
账本由每个 `projectId + generationId` 的进程内 single-flight、安全相对路径和原子 sidecar 写入保护,包含调用模式、精确相对 endpoint / 请求 JSON / SHA-256、prompt 私有正文、不依赖凭据的 API 身份指纹、远端 idempotency key、operationId、稳定 `objectKey/resourceId/assetObjectId`、本地 staging 与 commit 恢复身份。本地参考图上传恢复只允许暂存稳定 bucket/objectKeydirect-upload ticket 的 host、formFields、policy、signature 和临时 Authorization 只存在内存,任何阶段都不得序列化。阶段三正式资产事务继续由既有持久锁保护。登录 Access Token、API Key、Authorization、Provider URL、签名 URL 和绝对路径不得写入账本;登录 Token 刷新不得导致 `configuration-changed`
草稿、manifest、Tauri 事件、Agent DB、Runtime observation、trace、普通错误和日志只允许出现 `intentId/generationId`、安全阶段、引用资源 ID、本地 asset/commit/event 身份和稳定错误码。草稿 generation record 不再保存 prompt、External idempotency key 或 operationId;阶段五读取旧空记录兼容,但下一次成功写入必须移除这些私有字段。
@@ -952,8 +952,8 @@ confirmation-required
### 14.4 首版请求范围与恢复
- 普通生成使用 `POST /api/external/v1/editor/images/generations`支持 prompt、`1:1 | 2:3 | 3:2 | 9:16 | 16:9``0.5K | 1K | 2K`、合法 `assetKind` 和最多 9 个稳定参考资源
- refine 使用 `POST /api/external/v1/editor/images/edits``sourceImageSrc` 必须来自源资产的 objectKey、External 项目 resourceId 或素材 assetId`assetObjectId` 本身不是该字段接受的引用。本地独有图片在用户确认后先走现有 direct-upload ticket → OSS form → object confirm,确认后的 objectKey 才可进入生成请求。`sourceResourceId` 只在源资产确有 External resourceId 时发送,不能用本地 `local-asset:*`、objectKey 或 assetObjectId 冒充。额外参考最多 8 个。
- 普通客户生成使用 `POST /api/editor/images/generations`refine 使用 `POST /api/editor/images/edits`;两者携带当前登录 Bearer Token 和稳定 `Idempotency-Key`,由后端将 owner 与幂等身份绑定并沿统一生成队列扣费。提交响应中的 `queueState.operationId` 是轮询身份,状态使用 `GET /api/runtime/external-generation/jobs/{operationId}`completed 必须返回与 External 通道一致语义的 compact `result`,供客户端解析稳定引用
- 开发者模式才使用对应 `/api/external/v1/editor/images/*`;两种模式支持相同 prompt、`1:1 | 2:3 | 3:2 | 9:16 | 16:9``0.5K | 1K | 2K`、合法 `assetKind` 与参考资源约束。refine 的 `sourceImageSrc` 必须来自源资产的 objectKey、服务端项目 resourceId 或素材 assetId`assetObjectId` 本身不是该字段接受的引用。本地独有图片在用户确认后先走现有 direct-upload ticket → OSS form → object confirm,确认后的 objectKey 才可进入生成请求。`sourceResourceId` 只在源资产确有服务端 resourceId 时发送,不能用本地 `local-asset:*`、objectKey 或 assetObjectId 冒充。额外参考最多 8 个。
- `accepted/running` 重启只查询原 operation`prepared` 只以账本中的原 endpoint、原始字节和原键恢复同一 POST。轮询超时、临时鉴权/网关错误或结果未知保持可恢复,不自动转 failed 或换键扣费。
- 用户“停止等待”只撤销当前 session 的聚焦意图,不伪装远端取消。后台 command 可以继续安全 reconcile;应用退出后由项目恢复扫描继续原 operation。迟到完成可以更新原项目权威缓存和 manifest,但不能切回旧画布、清搜索或抢焦点。
- 配置缺失、远端 terminal failed、稳定引用缺失、下载失败、staging/正式提交失败都不得返回资源创建成功。只有 terminal failed 才允许用户通过新的独立确认建立新 intent;其它未知或对账状态必须保留原身份。
- 登录失效、余额不足、平台生成服务配置错误、远端 terminal failed、稳定引用缺失、下载失败、staging/正式提交失败都不得返回资源创建成功。客户模式不得把缺少开发者 API Key 显示为失败原因。只有 terminal failed 才允许用户通过新的独立确认建立新 intent;其它未知或对账状态必须保留原身份。
@@ -338,8 +338,8 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- 现役覆盖:worker claim 只允许 `source_module = editor-canvas`;下述逐玩法生成和写回描述均为退役前历史。历史 pending / running 行继续保留原状态,不得领取、失败收口或改写 payload。
- 用途:外部生成 worker 的内部持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写阶段、完成 / 失败。队列行继续保存 worker 执行、计费与滚动发布兼容所需字段,末尾可选 `phase` 只取 `generating / processing`claim 写 `generating`,真实进入抠图处理时由受 `job_id + worker_id + lease_token` 保护的 procedure 写 `processing`。phase procedure 以结构化结果区分 `LeaseFencingRejected``OtherRejected``LeaseFencingRejected` 立即终止,`OtherRejected` 以及 SDK 的 `Procedure` / `Runtime` 错误不重试,只有 `Build` / `ConnectDropped` / `Timeout` 在同一个 job attempt 内重试一次。该重试只重新上报 phase,不把任务写回 `pending`,也不重新调用 provider;编辑器 job 入队固定 `max_attempts=1`,第二次传输失败后任务进入 `failed`,不会回到 `pending` 或从 provider 生成起点重跑。用户可见任务列表、价格、状态、阶段、未确认终态数量和通知确认时间的正式读取事实源已经迁到 `external_generation_job_summary`;BFF 不得再为列表 / 详情 / acknowledge 读取该大表。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft``generate_puzzle_images``generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation``editor_image_edit``editor_background_removal``editor_icon_spritesheet_generation``editor_ui_design_asset_extraction``editor_character_animation_generation``editor_video_generation``editor_sound_effect_generation``editor_background_music_generation` 复用同一队列表。结构化 canvas 激活后,当前 worker completion 先以读取时 canvas revision 执行 CAS,并发冲突时拒绝覆盖并保留可诊断失败;目标是进一步收口为受 lease 栅栏保护的单事务幂等写入 `editor_project_resource`、结果 `editor_canvas_layer``editor_canvas_generation_dialog` 终态和 canvas revision。未激活 canvas 在 2 MiB 上限内继续走 legacy `editor_canvas.layers_json` 兼容写回。前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。
- 素材写回:worker 成功后仍经 `api-server` facade 写入 `editor_project_resource` / `editor_asset`;结构化 canvas 的 layer / dialog / revision 与未激活 canvas 的 legacy `layers_json` 分流按上一条执行,前端不直接发明正式完成态。
- 载荷约束:本次先对 `source_module = editor-canvas``request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用。画布 Agent 来源的任务可在 `result_payload_json.editor-agent-tool-call-result` 中保存有界的轻量结果和已登记媒体引用,供后端按已有 `externalJobId + owner_user_id` 定向懒回填;其它编辑器任务保持元数据结果,并可保存有界的 `warning.code/reason`。其它玩法在完成各自参考图资源化之前不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行、受控维护以及画布 Agent 定向结果回填读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得返回或解析这两个 payload。画布 Agent 懒回填必须经对应工具 formatter 归一为有界轻量媒体引用后写入 OSS 会话,不能把原始 payload 直接透传前端。
- 非阻断告警:角色形象、图标图集和 UI 素材提取已保存 provider 原图、但透明背景处理最终失败时,以原图唯一主图完成任务;透明图和切片不写入画布。这个 source-only 降级只包住透明背景处理的最终失败,phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。图标 / UI 透明图集成功但自动拆分降级时仍保留透明图集;通用 `warning``sliceWarning` 只在「透明背景最终失败」这一条上互斥,风格归一化或像素规整产生的通用 `warning` 可与 `sliceWarning` 并存。两类成功降级都以既有 `completed` 状态收口,不新增状态值:source-only 的 inline / external v1 响应使用结构化 `warning.code/reason`,仅拆分失败的 inline / external v1 响应继续使用既有 `sliceWarning.code/reason`,其 `reason` 保留原始诊断;queue worker 才把两者归一为有界的 `result_payload_json.warning`:只有一条时原样保留完整 `reason`,两条并存时按“通用在前、拆分在后”拼接且 `code` 收敛为 `multiple-generation-warnings`(两条 `code` 相同则沿用原 `code`),不允许任何一条被丢弃;`sliceWarning.reason` 无论是否并存都由 worker 添加“图集已生成,但自动拆分未完成:”前缀,拼接结果最后统一做长度上界收敛。除上述画布 Agent 定向回填的轻量结果外,队列结果不保存图片、切片列表媒体 URL。
- 载荷约束:本次先对 `source_module = editor-canvas``request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用。画布 Agent 来源的任务可在 `result_payload_json.editor-agent-tool-call-result` 中保存有界的轻量结果和已登记媒体引用,供后端按已有 `externalJobId + owner_user_id` 定向懒回填;External v1 以及客户端素材画布通过登录态和稳定 `Idempotency-Key` 提交的图片生成/精修任务可在 `result_payload_json.result` 保存同一套 compact 稳定引用,供各自 owner-safe 单任务查询完成本地下载,其它编辑器任务保持元数据结果,并可保存有界的 `warning.code/reason`。其它玩法在完成各自参考图资源化之前不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行、受控维护画布 Agent 定向回填以及上述两类已完成任务的精确 `job_id + owner_user_id` 结果读取;任务列表、队列概览与 acknowledge 不得返回或解析 payload,普通单任务状态也不得返回完整 payload。客户端素材画布查询只能取有界 `result` 子对象;结果不存在时保持原元数据状态响应,不能把普通已完成任务改成 `502`。画布 Agent 懒回填必须经对应工具 formatter 归一为有界轻量媒体引用后写入 OSS 会话,不能把原始 payload 直接透传前端。
- 非阻断告警:角色形象、图标图集和 UI 素材提取已保存 provider 原图、但透明背景处理最终失败时,以原图唯一主图完成任务;透明图和切片不写入画布。这个 source-only 降级只包住透明背景处理的最终失败,phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。图标 / UI 透明图集成功但自动拆分降级时仍保留透明图集;通用 `warning``sliceWarning` 只在「透明背景最终失败」这一条上互斥,风格归一化或像素规整产生的通用 `warning` 可与 `sliceWarning` 并存。两类成功降级都以既有 `completed` 状态收口,不新增状态值:source-only 的 inline / external v1 响应使用结构化 `warning.code/reason`,仅拆分失败的 inline / external v1 响应继续使用既有 `sliceWarning.code/reason`,其 `reason` 保留原始诊断;queue worker 才把两者归一为有界的 `result_payload_json.warning`:只有一条时原样保留完整 `reason`,两条并存时按“通用在前、拆分在后”拼接且 `code` 收敛为 `multiple-generation-warnings`(两条 `code` 相同则沿用原 `code`),不允许任何一条被丢弃;`sliceWarning.reason` 无论是否并存都由 worker 添加“图集已生成,但自动拆分未完成:”前缀,拼接结果最后统一做长度上界收敛。除上述画布 Agent 定向回填、External v1 和客户端素材画布幂等任务所需的 compact 稳定引用外,队列结果不保存完整图片、切片列表、临时媒体 URL 或 provider 原始响应
- 2026-07-29 收口补充:上条 source-only 的“透明背景处理最终失败”同时包含 Alpha 比例漂移超过 `5%`、provider 原图修复性回读失败、Alpha 回贴失败和透明图完整解码失败;三条链路共用 helper,只写已保存 provider 原图画布层,图标 / UI 固定 `iconImageSrcs=[]``sliceWarning=null`,不得写透明图、派生资源或切片。provider 原图本身解码失败时在首次持久化前失败,不允许 `512×512` 元数据兜底。图标自动拆分、手动拆分与 UI 提取先在受 2 路 CPU semaphore、30 秒 / 请求 deadline 保护的 blocking prepare 中完成解码、透明化、连通域和 bounds 排序;platform 对全部原始连通域设置 `4096` 硬上限、用空间网格查询邻近辅助候选,并在首片 PNG 编码前同时执行 `maxOutputSlices=64` 与全部 padding crop 总像素预算。prepare 返回共享 RGBA + bounds 计划,api-server 再以容量 `2` 的有界管线按需编码、共享 HTTP client 并发 OSS `PUT + HEAD`OSS 连接 / 单请求超时固定为 `10s / 60s`;手动入口在下载最大 `32 MiB` 来源对象前取得独立内存 admission,同一 admission 覆盖下载、prepare 到最后一片上传结束并在数据库调用前释放,CPU permit 只覆盖实际 CPU 阶段。全部对象上传验证成功后,切片的 `asset_object + editor_project_resource + editor_asset + editor_asset_group_cohort` 由单个受 editor generation runtime service identity 保护的 SpacetimeDB procedure 在一次 `try_with_tx` 中原子写入;resource / asset ID 由 owner + task + 序号稳定派生,已有同 ID 素材仅在内容完全一致时幂等复用,来源资源必须存在且与派生资源同 owner / project;上传中途失败不得写部分资源、素材或 cohort,不确定结果重放不得复制整批素材。自动超限只保留整张可信透明图并返回稳定 `sliceWarning`,不写切片;手动超限在首次持久化前返回 `422`
@@ -347,7 +347,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- Rust 结构体:`ExternalGenerationJobSummary`
- 源码:`server-rs/crates/spacetime-module/src/external_generation.rs`
- 用途:外部生成正式任务列表的轻量投影,按 `job_id` 保存 owner、来源、状态、可选 `phase`、价格、有界错误摘要、通知确认时间、各阶段时间和入队时提取的 `request_prompt`,不包含 request/result payload、worker lease 或 dedupe 内部字段。错误摘要统一拒绝内联媒体并限制为 2048 字符;列表在单次 owner 扫描中同时计数并只保留请求 limit 的固定大小 top-N,不得先收集全量历史再截断。enqueue、claim、renew、phase update、complete、fail 事务同步投影;acknowledge 只更新该轻量表并写审计事件,后续主任务同步必须保留已有确认时间,禁止为了写确认时间加载 / 重写大 payload 行。BFF 的列表、状态和确认只调用 summary procedure`running + processing` 映射为“正在处理”,其它 running(含旧行 `phase=None`)映射为“正在生成”。历史终态任务由迁移操作员的游标分批 maintenance procedure 在压缩 payload 时同步回填摘要,正式列表不得为兼容旧数据回扫完整主表。
- 用途:外部生成正式任务列表的轻量投影,按 `job_id` 保存 owner、来源、状态、可选 `phase`、价格、有界错误摘要、通知确认时间、各阶段时间和入队时提取的 `request_prompt`,不包含 request/result payload、worker lease 或 dedupe 内部字段。错误摘要统一拒绝内联媒体并限制为 2048 字符;列表在单次 owner 扫描中同时计数并只保留请求 limit 的固定大小 top-N,不得先收集全量历史再截断。enqueue、claim、renew、phase update、complete、fail 事务同步投影;acknowledge 只更新该轻量表并写审计事件,后续主任务同步必须保留已有确认时间,禁止为了写确认时间加载 / 重写大 payload 行。BFF 的列表、概览、确认和非终态单任务状态只调用 summary procedure客户端素材画布的 owner-safe 单任务查询仅在 summary 已是 `completed` 时,按同一 `job_id + owner_user_id` 额外读取 artifacts 并最多返回 compact `result`,不存在则省略。`running + processing` 映射为“正在处理”,其它 running(含旧行 `phase=None`)映射为“正在生成”。历史终态任务由迁移操作员的游标分批 maintenance procedure 在压缩 payload 时同步回填摘要,正式列表不得为兼容旧数据回扫完整主表。
- 非阻断告警:摘要字段 `warning_message` 是展示投影,由完成任务的轻量 `result_payload_json.warning.reason` 原样提取,不等同于公开 inline / external v1 的原始结构化诊断字段。complete 和历史 backfill 共用同一构建路径;历史任务按其结果载荷中已写入的 `reason` 快照投影,不为格式升级重写或补前缀。单 job 状态和任务列表 BFF 以 `warning: string` 返回该可直接展示的完整文案,不再返回结构化 code,Web 不得再次补前缀或按字符串推断告警类型。错误与告警摘要都不复制内联媒体并限制为 2048 字符。`phase``warning_message` 分别表示当前执行阶段和成功降级提示,不得混用;worker / BFF / Web 必须同版本协调发布,不保证滚动混部或旧 Web 缓存下的字符串语义兼容。
- 正式读取 procedure 为 `get_external_generation_job_summary_and_return``list_external_generation_job_summaries_and_return``acknowledge_external_generation_job_summaries_and_return`。历史维护 procedure 为 `compact_external_generation_job_payloads_and_return``backfill_external_generation_job_summaries_and_return`,仅 migration operator 可调用;运维入口统一使用 `npm run spacetime:external-generation:maintain -- ...`,默认 dry-run、单批最多 25 条。B-tree cursor 选择阶段最多反序列化 `limit + 1` 行,apply 再按主键逐条读取选中行;怀疑存在单行异常巨型 JSON 时必须先使用 `--limit 1`。payload 压缩额外固定使用 `source_module = editor-canvas` 的复合 cursor 索引,不得静默改写其它玩法历史任务。
@@ -23,6 +23,7 @@ export interface ExternalGenerationJobStatusRecord {
progress: number;
error?: string | null;
warning?: string | null;
result?: unknown;
updatedAtMicros: number;
}
@@ -9,7 +9,7 @@ use std::{
use axum::{
Json,
extract::{Extension, Path, Query, State, rejection::JsonRejection},
http::StatusCode,
http::{HeaderMap, StatusCode},
};
use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_object_upsert_input,
@@ -1591,9 +1591,11 @@ pub async fn generate_editor_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
headers: HeaderMap,
payload: Result<Json<EditorImageGenerationRequest>, JsonRejection>,
) -> Result<Json<Value>, AppError> {
let Json(payload) = parse_editor_generation_json_payload(payload)?;
let idempotency_key = optional_editor_idempotency_key(&headers)?;
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
if !state.config.external_generation_mode.is_inline() {
let queue_job = enqueue_editor_image_generation_for_owner(
@@ -1601,7 +1603,7 @@ pub async fn generate_editor_image(
&request_context,
&caller,
payload,
None,
idempotency_key,
)
.await?;
return Ok(json_success_body(
@@ -3602,13 +3604,20 @@ pub async fn edit_editor_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
headers: HeaderMap,
Json(payload): Json<EditorImageEditRequest>,
) -> Result<Json<Value>, AppError> {
let idempotency_key = optional_editor_idempotency_key(&headers)?;
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
if !state.config.external_generation_mode.is_inline() {
let queue_job =
enqueue_editor_image_edit_for_owner(&state, &request_context, &caller, payload, None)
.await?;
let queue_job = enqueue_editor_image_edit_for_owner(
&state,
&request_context,
&caller,
payload,
idempotency_key,
)
.await?;
return Ok(json_success_body(
Some(&request_context),
EditorGenerationQueuedResponse {
@@ -3619,6 +3628,25 @@ pub async fn edit_editor_image(
edit_editor_image_for_owner(&state, &request_context, caller, payload).await
}
fn optional_editor_idempotency_key(headers: &HeaderMap) -> Result<Option<&str>, AppError> {
let Some(value) = headers.get("idempotency-key") else {
return Ok(None);
};
let value = value.to_str().ok().map(str::trim).unwrap_or_default();
if value.is_empty()
|| value.len() > 128
|| !value.bytes().all(|byte| (0x21..=0x7e).contains(&byte))
{
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor_project",
"message": "Idempotency-Key 必须是 1-128 个可打印 ASCII 字符,且不能包含空格。",
})),
);
}
Ok(Some(value))
}
pub(crate) async fn enqueue_editor_image_edit_for_owner(
state: &AppState,
request_context: &RequestContext,
@@ -9490,6 +9518,28 @@ mod tests {
thread,
};
#[test]
fn authenticated_editor_generation_accepts_optional_valid_idempotency_key() {
let headers = HeaderMap::new();
assert_eq!(optional_editor_idempotency_key(&headers).unwrap(), None);
let mut headers = HeaderMap::new();
headers.insert(
"idempotency-key",
axum::http::HeaderValue::from_static("canvas-generation-1"),
);
assert_eq!(
optional_editor_idempotency_key(&headers).unwrap(),
Some("canvas-generation-1")
);
headers.insert(
"idempotency-key",
axum::http::HeaderValue::from_static("contains space"),
);
assert!(optional_editor_idempotency_key(&headers).is_err());
}
fn encode_test_png(width: u32, height: u32) -> Vec<u8> {
let image = image::DynamicImage::new_rgba8(width, height);
let mut bytes = Cursor::new(Vec::new());
@@ -137,20 +137,29 @@ pub async fn get_external_generation_job_status(
Path(job_id): Path<String>,
) -> Result<Json<serde_json::Value>, Response> {
let owner_user_id = authenticated.claims().user_id().to_string();
let input = ExternalGenerationJobGetRecordInput {
job_id,
owner_user_id,
};
let job = state
.spacetime_client()
.get_external_generation_job_summary(ExternalGenerationJobGetRecordInput {
job_id,
owner_user_id,
})
.get_external_generation_job_summary(input.clone())
.await
.map_err(|error| external_generation_error_response(&request_context, error))?;
let mut detail = map_external_generation_job_status_detail(job);
if detail.status.status == ExternalGenerationJobStatus::Completed {
let artifacts = state
.spacetime_client()
.get_external_generation_job_generated_artifacts(input)
.await
.map_err(|error| external_generation_error_response(&request_context, error))?;
detail.result =
external_generation_completed_result(artifacts.result_payload_json.as_deref());
}
Ok(json_success_body(
Some(&request_context),
ExternalGenerationJobStatusResponse {
job: map_external_generation_job_status_detail(job),
},
ExternalGenerationJobStatusResponse { job: detail },
))
}
@@ -224,9 +233,18 @@ pub(crate) fn map_external_generation_job_status_detail(
ExternalGenerationJobStatusDetailRecord {
status: map_external_generation_job_status(job),
warning,
result: None,
}
}
fn external_generation_completed_result(
result_payload_json: Option<&str>,
) -> Option<serde_json::Value> {
result_payload_json
.and_then(|payload| serde_json::from_str::<serde_json::Value>(payload).ok())
.and_then(|payload| payload.get("result").cloned())
}
fn map_external_generation_task_record(
job: ExternalGenerationJobSummaryRecord,
) -> ExternalGenerationTaskRecord {
@@ -317,6 +335,18 @@ mod tests {
);
}
#[test]
fn completed_generation_result_uses_compact_result_payload() {
let result = external_generation_completed_result(Some(
r#"{"result":{"resourceId":"resource-1","objectKey":"generated/1.png"}}"#,
))
.expect("completed result should be readable");
assert_eq!(result["resourceId"], "resource-1");
assert!(external_generation_completed_result(Some(r#"{"data":{}}"#)).is_none());
assert!(external_generation_completed_result(Some("not-json")).is_none());
}
#[test]
fn maps_task_from_payload_free_summary_projection() {
let summary = ExternalGenerationJobSummaryRecord {
@@ -44,6 +44,8 @@ pub struct ExternalGenerationJobStatusDetailRecord {
pub status: ExternalGenerationJobStatusRecord,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub warning: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -148,11 +150,13 @@ mod tests {
updated_at_micros: 1,
},
warning: Some("连通域数量不足".to_string()),
result: Some(json!({ "resourceId": "resource-1" })),
},
})
.expect("状态响应应可序列化");
assert_eq!(payload["job"]["warning"], json!("连通域数量不足"));
assert_eq!(payload["job"]["result"]["resourceId"], json!("resource-1"));
assert_eq!(payload["job"]["error"], json!(null));
assert_eq!(payload["job"]["status"], json!("completed"));
}