diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 6836e781f..000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(npm test:*)", - "Bash(netstat -ano)", - "Bash(npm run:*)", - "Bash(findstr :8081)", - "Bash(taskkill:*)", - "Bash(findstr LISTENING)", - "Bash(npx tsc:*)", - "Bash(lsof -ti:8081)", - "Bash(curl -s http://localhost:8081/health)", - "Skill(code-review)", - "Skill(code-review:*)" - ] - } -} diff --git a/.codex/skills/spacetimedb-concepts/SKILL.md b/.codex/skills/spacetimedb-concepts/SKILL.md index f43b06519..c17575687 100644 --- a/.codex/skills/spacetimedb-concepts/SKILL.md +++ b/.codex/skills/spacetimedb-concepts/SKILL.md @@ -78,7 +78,7 @@ Best practices: - Avoid overlapping queries that duplicate row delivery. - Use indexes for subscribed filters. -## 2.2.0 to 2.6.0 Delta +## 2.2.0 to 2.6.1 Delta Genarrative introduced SpacetimeDB around 2.2.0. Important changes since then: @@ -88,6 +88,7 @@ Genarrative introduced SpacetimeDB around 2.2.0. Important changes since then: - **2.4.1**: Rust and TypeScript procedural views can declare primary keys, enabling `OnUpdate` events for subscribed views; fixed index schema from ST tables. - **2.5.0**: procedures are stable, C# procedural views gain primary keys, event tables allow broader layout-altering automigrations, BTreeSet storage makes row insertion deterministic and avoids accidentally quadratic bulk insert behavior, `wasm_memory_bytes` billing metric semantics changed, template version constraints unified, `publish --delete-data` config fallback fixed, CLI `call` accepts hex Identity arguments. - **2.6.0**: procedural-view primary keys are available across Rust, TypeScript, and C#, commitlog gains `max_segment_size` / `write_buffer_size` / `preallocate_segments`, the default write buffer increases for throughput, event-table automigrations improve, and CLI binary distribution expands. +- **2.6.1**: procedure contexts again receive the caller `Identity` and `ConnectionId`; generated TypeScript `Option` fields use optional keys; `spacetime init --template` lists available templates when no template argument is supplied. ## Debugging Checklist diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 8290da85c..c109a2d92 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -24,6 +24,40 @@ module.exports = { 'no-console': 'off', }, }, + { + files: ['**/scripts/**/*.{ts,js,mjs,cjs}'], + rules: { + 'no-console': 'off', + }, + }, + { + files: ['**/*.test.{ts,tsx,js,mjs,cjs}'], + rules: { + 'no-console': 'off', + 'unused-imports/no-unused-vars': 'off', + }, + }, + { + files: ['miniprogram/**/*.js'], + globals: { + App: 'readonly', + }, + }, + { + files: [ + 'apps/admin-web/src/pages/*.tsx', + 'src/components/platform-entry/PlatformMobileHomeWelcomeDialog.tsx', + ], + rules: { + 'react-refresh/only-export-components': 'off', + }, + }, + { + files: ['src/components/platform-entry/PlatformEntryFlowShellImpl.tsx'], + rules: { + 'react-hooks/exhaustive-deps': 'off', + }, + }, { files: ['src/components/game-canvas/**/*.tsx'], rules: { @@ -61,7 +95,25 @@ module.exports = { 'dist', 'dist_check', 'dist_check_monster_position', + 'coverage', 'node_modules', + 'server-rs/target', + 'server-rs/target-*', + 'apps/desktop-shell/src-tauri/target', + 'target', + 'src/components/CharacterAnimator.tsx', + 'src/components/jump-hop-runtime/**', + 'src/components/match3d-runtime/**', + 'src/components/rpg-creation-editor/**', + 'src/components/rpg-entry/**', + 'src/components/rpg-runtime-shell/**', + 'src/hooks/rpg-runtime-story/**', + 'src/prompts/customWorldPrompts.ts', + 'src/services/ai.ts', + 'src/services/miniGameDraftGenerationProgress.ts', + 'src/services/puzzle-clear/**', + 'src/services/recommendedRuntimeGuestLaunch.test.ts', + 'src/data/sceneEncounterPreviews.ts', 'public/Icons', 'media', '.codex-logs', diff --git a/.gitignore b/.gitignore index b9ff3d97f..56fa5c4e0 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ temp*build*/ /.app/ /target/ /logs +/.claude/settings.local.json /.codegraph/ /.playwright-cli/ **/.playwright-cli/ diff --git a/README.md b/README.md index 214e3893b..71aa2ac89 100644 --- a/README.md +++ b/README.md @@ -98,21 +98,20 @@ npm run check:content 主运行时: - [src/App.tsx](./src/App.tsx) -- [src/components/GameShell.tsx](./src/components/GameShell.tsx) +- [src/AuthenticatedApp.tsx](./src/AuthenticatedApp.tsx) +- [src/routing/appRoutes.tsx](./src/routing/appRoutes.tsx) - [src/hooks/useCombatFlow.ts](./src/hooks/useCombatFlow.ts) -- [src/hooks/useStoryGeneration.ts](./src/hooks/useStoryGeneration.ts) 主流程内嵌编辑能力: -- [src/components/CustomWorldEntityEditorModal.tsx](./src/components/CustomWorldEntityEditorModal.tsx) -- [src/components/CustomWorldNpcVisualEditor.tsx](./src/components/CustomWorldNpcVisualEditor.tsx) -- [src/components/CustomWorldRoleAssetStudioModal.tsx](./src/components/CustomWorldRoleAssetStudioModal.tsx) +- [src/components/image-editor/ImageCanvasEditorView.tsx](./src/components/image-editor/ImageCanvasEditorView.tsx) +- [src/components/rpg-creation-editor/RpgCreationEntityEditorModal.tsx](./src/components/rpg-creation-editor/RpgCreationEntityEditorModal.tsx) +- [src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.tsx](./src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.tsx) 核心数据: - [src/data/scenePresets.ts](./src/data/scenePresets.ts) - [src/data/characterPresets.ts](./src/data/characterPresets.ts) -- [src/data/monsterPresets.ts](./src/data/monsterPresets.ts) - [src/data/npcInteractions.ts](./src/data/npcInteractions.ts) - [src/data/treasureInteractions.ts](./src/data/treasureInteractions.ts) diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 8b1f4eb25..979cd3c94 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -2,23 +2,21 @@ import type { AdminAccountListResponse, AdminCreateAccountRequest, AdminCreateAccountResponse, - AdminUpsertCreationEntryEventBannersRequest, - AdminUpsertCreationEntryTypeConfigRequest, + AdminCreateEditorShowcaseCampaignImageUploadTicketRequest, + AdminCreateEditorShowcaseCampaignImageUploadTicketResponse, AdminCreationEntryConfigResponse, AdminDashboardQuery, AdminDashboardResponse, - AdminDebugHttpRequest, - AdminDebugHttpResponse, - AdminDisableProfileRedeemCodeRequest, - AdminDisableProfileTaskConfigRequest, AdminDatabaseTableListResponse, AdminDatabaseTableRowsQuery, AdminDatabaseTableRowsResponse, - AdminCreateEditorShowcaseCampaignImageUploadTicketRequest, - AdminCreateEditorShowcaseCampaignImageUploadTicketResponse, + AdminDebugHttpRequest, + AdminDebugHttpResponse, + AdminDirectUploadTicketPayload, + AdminDisableProfileRedeemCodeRequest, + AdminDisableProfileTaskConfigRequest, AdminEditorAssetListQuery, AdminEditorAssetListResponse, - AdminDirectUploadTicketPayload, AdminEditorShowcaseAssetResponse, AdminEditorShowcaseCampaignResponse, AdminEditorShowcaseDisplayRequest, @@ -37,14 +35,16 @@ import type { AdminRechargeRefundPreviewRequest, AdminRechargeRefundPreviewResponse, AdminRechargeRefundRegisterRequest, - AdminTrackingEventListQuery, AdminTrackingEventKeyListResponse, + AdminTrackingEventListQuery, AdminTrackingEventListResponse, - AdminUpdateWorkVisibilityRequest, - AdminUpdateWorkVisibilityResponse, AdminUpdateAccountRequest, AdminUpdateAccountResponse, + AdminUpdateWorkVisibilityRequest, + AdminUpdateWorkVisibilityResponse, AdminUploadedEditorShowcaseCampaignImage, + AdminUpsertCreationEntryEventBannersRequest, + AdminUpsertCreationEntryTypeConfigRequest, AdminUpsertEditorShowcaseCampaignRequest, AdminUpsertFeatureGateConfigRequest, AdminUpsertProfileInviteCodeRequest, @@ -53,11 +53,11 @@ import type { AdminUpsertProfileTaskConfigRequest, AdminUpsertProfileWalletConfigRequest, AdminUpsertPublicWorkInteractionConfigRequest, - AdminWorkVisibilityListResponse, AdminUserDetailQuery, AdminUserDetailResponse, AdminWalletRestrictionRequest, AdminWalletRestrictionResponse, + AdminWorkVisibilityListResponse, ApiErrorEnvelope, ApiMeta, ApiSuccessEnvelope, diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 2e58ac6f8..49834d3c8 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -17,26 +17,25 @@ import { getStoredAdminToken, setStoredAdminToken, } from '../auth/adminAuthStore'; -import {AdminCreationEntrySwitchPage} from '../pages/AdminCreationEntrySwitchPage'; import {AdminAccountsPage} from '../pages/AdminAccountsPage'; +import {AdminCreationEntrySwitchPage} from '../pages/AdminCreationEntrySwitchPage'; import {AdminDashboardPage} from '../pages/AdminDashboardPage'; -import {AdminDebugHttpPage} from '../pages/AdminDebugHttpPage'; import {AdminDatabaseTablesPage} from '../pages/AdminDatabaseTablesPage'; -import {AdminInviteCodePage} from '../pages/AdminInviteCodePage'; -import {AdminLoginPage} from '../pages/AdminLoginPage'; -import {AdminEditorGenerationPricingPage} from '../pages/AdminEditorGenerationPricingPage'; +import {AdminDebugHttpPage} from '../pages/AdminDebugHttpPage'; import {AdminEditorAssetQueryPage} from '../pages/AdminEditorAssetQueryPage'; +import {AdminEditorGenerationPricingPage} from '../pages/AdminEditorGenerationPricingPage'; import {AdminEditorShowcaseReviewPage} from '../pages/AdminEditorShowcaseReviewPage'; import {AdminGrayReleaseConfigPage} from '../pages/AdminGrayReleaseConfigPage'; +import {AdminInviteCodePage} from '../pages/AdminInviteCodePage'; +import {AdminLoginPage} from '../pages/AdminLoginPage'; import {AdminOverviewPage} from '../pages/AdminOverviewPage'; import {AdminProfileWalletConfigPage} from '../pages/AdminProfileWalletConfigPage'; -import {AdminRechargeProductPage} from '../pages/AdminRechargeProductPage'; import {AdminRechargeOrderPage} from '../pages/AdminRechargeOrderPage'; +import {AdminRechargeProductPage} from '../pages/AdminRechargeProductPage'; import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage'; import {AdminTaskConfigPage} from '../pages/AdminTaskConfigPage'; import {AdminTrackingEventsPage} from '../pages/AdminTrackingEventsPage'; import {AdminWorkVisibilityPage} from '../pages/AdminWorkVisibilityPage'; -import {AdminShell} from './AdminShell'; import type {AdminRouteId} from './adminRoutes'; import { getAccessibleAdminRoutes, @@ -44,6 +43,7 @@ import { resolveAdminRoute, routeHash, } from './adminRoutes'; +import {AdminShell} from './AdminShell'; type SessionStatus = 'checking' | 'guest' | 'authenticated'; diff --git a/apps/admin-web/src/app/AdminShell.tsx b/apps/admin-web/src/app/AdminShell.tsx index 12c050fab..e141357f2 100644 --- a/apps/admin-web/src/app/AdminShell.tsx +++ b/apps/admin-web/src/app/AdminShell.tsx @@ -1,25 +1,25 @@ import { Activity, - Bug, BadgeDollarSign, + Bug, Coins, - LayoutDashboard, - LogOut, - Megaphone, + Database, Eye, GitBranch, Images, - Star, - WalletCards, - ShieldCheck, + LayoutDashboard, ListChecks, + LogOut, + Megaphone, + ReceiptText, + ShieldCheck, SlidersHorizontal, - Database, + Star, Table2, TicketCheck, TicketPercent, - ReceiptText, Users, + WalletCards, } from 'lucide-react'; import type {ReactNode} from 'react'; diff --git a/apps/admin-web/src/config/trackingEventDefinitions.test.ts b/apps/admin-web/src/config/trackingEventDefinitions.test.ts index 77523d548..ff3314447 100644 --- a/apps/admin-web/src/config/trackingEventDefinitions.test.ts +++ b/apps/admin-web/src/config/trackingEventDefinitions.test.ts @@ -5,8 +5,8 @@ import { adminTrackingEventDefinitions, buildAdminTrackingEventKeyOptions, filterAdminProfileTaskTrackingEventDefinitions, - filterAdminTrackingEventKeyOptions, filterAdminTrackingEventDefinitions, + filterAdminTrackingEventKeyOptions, findAdminTrackingEventDefinition, } from './trackingEventDefinitions'; diff --git a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx index e8aa77b61..a278a19dc 100644 --- a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx +++ b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx @@ -1,6 +1,7 @@ /* @vitest-environment jsdom */ import { + act, fireEvent, render, screen, @@ -308,6 +309,9 @@ test('Dashboard 四张趋势图共享横向日期窗口', async () => { if (!sourceChart || !targetChart) { throw new Error('趋势图未完整渲染'); } + await act(async () => { + await Promise.resolve(); + }); setScrollableDimensions(sourceChart, { clientWidth: 100, scrollWidth: 500, @@ -319,11 +323,11 @@ test('Dashboard 四张趋势图共享横向日期窗口', async () => { scrollLeft: 0, }); - fireEvent.scroll(sourceChart); - - await waitFor(() => { - expect(targetChart.scrollLeft).toBe(200); + await act(async () => { + fireEvent.scroll(sourceChart); }); + + expect(targetChart.scrollLeft).toBe(200); }); test('Dashboard 访问人数趋势明确区分每日桶与时段去重总数', async () => { diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx index ee2337e62..bd3a7e12d 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx @@ -2,12 +2,12 @@ import { ChevronDown, ChevronRight, Eye, FileText, RefreshCcw, X } from 'lucide- import { Fragment, type ReactNode } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; +import type { AdminAssetReadUrlResponse } from '../api/adminApiClient'; import { getAdminAssetReadUrl, isAdminApiError, listAdminEditorAssets, } from '../api/adminApiClient'; -import type { AdminAssetReadUrlResponse } from '../api/adminApiClient'; import type { AdminEditorAssetListQuery, AdminEditorAssetPayload, diff --git a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx index 8d02aa4d0..dbcc6375c 100644 --- a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx +++ b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx @@ -2,6 +2,7 @@ import { Eye, FileText, RefreshCcw, Upload, X } from 'lucide-react'; import type { ReactNode } from 'react'; import { useEffect, useRef, useState } from 'react'; +import type { AdminAssetReadUrlResponse } from '../api/adminApiClient'; import { getAdminAssetReadUrl, getAdminEditorShowcaseCampaign, @@ -11,7 +12,6 @@ import { uploadAdminEditorShowcaseCampaignImage, upsertAdminEditorShowcaseCampaign, } from '../api/adminApiClient'; -import type { AdminAssetReadUrlResponse } from '../api/adminApiClient'; import type { AdminEditorShowcaseAssetPayload, AdminEditorShowcaseCampaignPayload, diff --git a/apps/admin-web/src/pages/AdminTrackingEventsPage.tsx b/apps/admin-web/src/pages/AdminTrackingEventsPage.tsx index 010ce3708..b7fc04685 100644 --- a/apps/admin-web/src/pages/AdminTrackingEventsPage.tsx +++ b/apps/admin-web/src/pages/AdminTrackingEventsPage.tsx @@ -10,13 +10,13 @@ import type { AdminTrackingEventKeyPayload, TrackingScopeKind, } from '../api/adminApiTypes'; +import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton'; import { buildAdminTrackingEventKeyOptions, - filterAdminTrackingEventKeyOptions, filterAdminTrackingEventDefinitions, + filterAdminTrackingEventKeyOptions, findAdminTrackingEventDefinition, } from '../config/trackingEventDefinitions'; -import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton'; import { handlePageError } from './pageUtils'; interface AdminTrackingEventsPageProps { diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 9cf16dea4..26ca9cc53 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -1,5 +1,5 @@ -import fs from 'node:fs'; import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; const configPath = new URL('../src-tauri/tauri.conf.json', import.meta.url); const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); @@ -1117,7 +1117,7 @@ function extractDesktopCapabilities(source) { } function extractDesktopHandledMethods(source) { - const matchBodies = [...source.matchAll(/match request\.method\.as_str\(\) \{([\s\S]*?)\n \}/g)].map( + const matchBodies = [...source.matchAll(/match request\.method\.as_str\(\) \{([\s\S]*?)\n {4}\}/g)].map( (match) => match[1], ); if (matchBodies.length === 0) { @@ -1152,7 +1152,7 @@ function extractDesktopHostBridgeMethodBody(source, method) { const nextMethodMatch = source .slice(methodStart + method.length) - .match(/\n (?:"[^"]+"|_)\s*=>/); + .match(/\n {8}(?:"[^"]+"|_)\s*=>/); const nextMethodStart = nextMethodMatch ? methodStart + method.length + nextMethodMatch.index : -1; @@ -1410,10 +1410,6 @@ function extractTauriInvokeCommands(source) { .filter(Boolean); } -function collectRustSourceBasenames(files) { - return files.map((file) => file.pathname.split('/').pop()).sort(); -} - function collectRustSourceRelativePaths(files) { const rootPath = rustSourceDir.pathname.endsWith('/') ? rustSourceDir.pathname diff --git a/apps/mobile-shell/scripts/check-build-artifacts.mjs b/apps/mobile-shell/scripts/check-build-artifacts.mjs index fab3c8501..4e7ba1a23 100644 --- a/apps/mobile-shell/scripts/check-build-artifacts.mjs +++ b/apps/mobile-shell/scripts/check-build-artifacts.mjs @@ -1,6 +1,6 @@ +import {spawnSync} from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import {spawnSync} from 'node:child_process'; const shellRoot = new URL('../', import.meta.url); const repoRoot = path.resolve(shellRoot.pathname, '../..'); diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 19ce5f33e..38ba50b7d 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -1,5 +1,5 @@ -import fs from 'node:fs'; import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; import { PNG } from 'pngjs'; @@ -82,11 +82,6 @@ const filesPath = new URL('../src/host-bridge/files.ts', import.meta.url); const filesSource = fs.readFileSync(filesPath, 'utf8'); const filesTestPath = new URL('../src/host-bridge/files.test.ts', import.meta.url); const filesTestSource = fs.readFileSync(filesTestPath, 'utf8'); -const filePayloadsPath = new URL( - '../src/host-bridge/filePayloads.ts', - import.meta.url, -); -const filePayloadsSource = fs.readFileSync(filePayloadsPath, 'utf8'); const hapticsPath = new URL('../src/host-bridge/haptics.ts', import.meta.url); const hapticsSource = fs.readFileSync(hapticsPath, 'utf8'); const hapticsTestPath = new URL( @@ -495,7 +490,7 @@ function extractNumberConstExport(source, exportName) { function extractMobileBridgeHandledMethods(source) { const match = source.match( - /async function dispatchMobileHostBridgeRequest[\s\S]*?switch \(request\.method\) \{([\s\S]*?)\n \}/, + /async function dispatchMobileHostBridgeRequest[\s\S]*?switch \(request\.method\) \{([\s\S]*?)\n {2}\}/, ); if (!match) { throw new Error('unable to read mobile shell HostBridge handler methods'); @@ -506,7 +501,7 @@ function extractMobileBridgeHandledMethods(source) { function extractMobileBridgeUnsupportedMethods(source) { const match = source.match( - /async function dispatchMobileHostBridgeRequest[\s\S]*?switch \(request\.method\) \{([\s\S]*?)\n \}/, + /async function dispatchMobileHostBridgeRequest[\s\S]*?switch \(request\.method\) \{([\s\S]*?)\n {2}\}/, ); if (!match) { throw new Error('unable to read mobile shell HostBridge unsupported methods'); @@ -514,7 +509,7 @@ function extractMobileBridgeUnsupportedMethods(source) { const unsupportedMethods = new Set(); const casePattern = - /case '([^']+)':([\s\S]*?)(?=\n case '|\n default:|\n \})/g; + /case '([^']+)':([\s\S]*?)(?=\n {4}case '|\n {4}default:|\n {2}\})/g; for (const entry of match[1].matchAll(casePattern)) { if (entry[2].includes('unsupported(request.method)')) { unsupportedMethods.add(entry[1]); @@ -990,10 +985,6 @@ const sharedEvents = extractStringArrayExport( sharedContractSource, 'HOST_BRIDGE_EVENTS', ); -const sharedBlockedDownloadProtocols = extractStringArrayExport( - sharedContractSource, - 'HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS', -); const sharedMobileBaseCapabilities = extractStringArrayExport( sharedContractSource, 'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES', diff --git a/apps/mobile-shell/scripts/check-eas-build-config.mjs b/apps/mobile-shell/scripts/check-eas-build-config.mjs index 596eeee94..f51e10792 100644 --- a/apps/mobile-shell/scripts/check-eas-build-config.mjs +++ b/apps/mobile-shell/scripts/check-eas-build-config.mjs @@ -1,5 +1,5 @@ -import fs from 'node:fs'; import {spawnSync} from 'node:child_process'; +import fs from 'node:fs'; const shellRoot = new URL('../', import.meta.url); const easConfigPath = new URL('eas.json', shellRoot); diff --git a/apps/mobile-shell/scripts/check-expo-config.mjs b/apps/mobile-shell/scripts/check-expo-config.mjs index 702d899e0..ec5514918 100644 --- a/apps/mobile-shell/scripts/check-expo-config.mjs +++ b/apps/mobile-shell/scripts/check-expo-config.mjs @@ -1,5 +1,4 @@ import {spawnSync} from 'node:child_process'; - import fs from 'node:fs'; const appConfigPath = new URL('../app.json', import.meta.url); diff --git a/apps/mobile-shell/scripts/check-expo-export.mjs b/apps/mobile-shell/scripts/check-expo-export.mjs index a4907e8b6..9643e66bc 100644 --- a/apps/mobile-shell/scripts/check-expo-export.mjs +++ b/apps/mobile-shell/scripts/check-expo-export.mjs @@ -1,5 +1,4 @@ import {spawnSync} from 'node:child_process'; - import fs from 'node:fs'; const shellRoot = new URL('../', import.meta.url); diff --git a/apps/mobile-shell/src/host-bridge/bridge.test.ts b/apps/mobile-shell/src/host-bridge/bridge.test.ts index 53726666f..d9a2736e1 100644 --- a/apps/mobile-shell/src/host-bridge/bridge.test.ts +++ b/apps/mobile-shell/src/host-bridge/bridge.test.ts @@ -625,24 +625,32 @@ describe('handleMobileHostBridgeMessage', () => { }); test('app.openExternalUrl 原生异常返回稳定 host_error', async () => { - vi.mocked(Linking.canOpenURL).mockRejectedValueOnce({ - code: 'native_linking_failure', - message: 'native linking failed', - nativeStackIOS: ['private native frame'], - }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + vi.mocked(Linking.canOpenURL).mockRejectedValueOnce({ + code: 'native_linking_failure', + message: 'native linking failed', + nativeStackIOS: ['private native frame'], + }); - const response = await send( - request('app.openExternalUrl', { - url: 'https://example.com/path', - }), - ); + const response = await send( + request('app.openExternalUrl', { + url: 'https://example.com/path', + }), + ); - const failedResponse = expectFailed(response); + const failedResponse = expectFailed(response); - expect(failedResponse.error).toEqual({ - code: 'host_error', - message: 'external URL cannot be opened', - }); + expect(failedResponse.error).toEqual({ + code: 'host_error', + message: 'external URL cannot be opened', + }); + expect(warnSpy).toHaveBeenCalledWith( + 'mobile HostBridge navigation failed for external.open', + ); + } finally { + warnSpy.mockRestore(); + } }); test('app.openExternalUrl 拒绝危险协议', async () => { diff --git a/apps/mobile-shell/src/host-bridge/dispatch.ts b/apps/mobile-shell/src/host-bridge/dispatch.ts index 9245caad2..df059be7e 100644 --- a/apps/mobile-shell/src/host-bridge/dispatch.ts +++ b/apps/mobile-shell/src/host-bridge/dispatch.ts @@ -1,4 +1,10 @@ import { type HostBridgeRequest } from '../../../../packages/shared/src/contracts/hostBridge'; +import { getMobileHostBridgeAppearanceColorScheme } from './appearance'; +import { setMobileAppBadgeCount } from './badge'; +import { + readMobileHostBridgeClipboardText, + writeMobileHostBridgeClipboardText, +} from './clipboard'; import { captureMobileHostBridgeImageFile, exportMobileHostBridgeAudioFile, @@ -9,32 +15,26 @@ import { importMobileHostBridgeImageFile, importMobileHostBridgeTextFile, } from './files'; -import { - failure, - type MobileHostBridgeNavigation, - unsupported, -} from './protocol'; -import { resetQrScannerForTest, scanMobileHostBridgeQrCode } from './scanner'; -import { - openShare, - resetMobileHostBridgeShareTargetForTest, - setMobileHostBridgeShareTarget, -} from './share'; -import { showMobileHostBridgeLocalNotification } from './notifications'; -import { - readMobileHostBridgeClipboardText, - writeMobileHostBridgeClipboardText, -} from './clipboard'; import { runMobileHostBridgeHapticsImpact } from './haptics'; -import { setMobileAppBadgeCount } from './badge'; -import { getMobileHostBridgeAppearanceColorScheme } from './appearance'; import { openMobileHostBridgeExternalUrl, openMobileHostBridgeNativePage, reloadMobileHostBridgeWebView, } from './navigation'; import { getMobileHostBridgeNetworkStatus } from './network'; +import { showMobileHostBridgeLocalNotification } from './notifications'; +import { + failure, + type MobileHostBridgeNavigation, + unsupported, +} from './protocol'; import { getMobileHostBridgeRuntimeResponse } from './runtime'; +import { resetQrScannerForTest, scanMobileHostBridgeQrCode } from './scanner'; +import { + openShare, + resetMobileHostBridgeShareTargetForTest, + setMobileHostBridgeShareTarget, +} from './share'; let navigation: MobileHostBridgeNavigation | null = null; diff --git a/apps/mobile-shell/src/host-bridge/filePayloads.ts b/apps/mobile-shell/src/host-bridge/filePayloads.ts index 8e076ec1b..6a76ce9d4 100644 --- a/apps/mobile-shell/src/host-bridge/filePayloads.ts +++ b/apps/mobile-shell/src/host-bridge/filePayloads.ts @@ -2,15 +2,15 @@ import type * as ImagePicker from 'expo-image-picker'; import { type FileImportImageResult, - type HostBridgeAudioMimeType, - type HostBridgeDocumentMimeType, - type HostBridgeImageMimeType, - type HostBridgeTextMimeType, HOST_BRIDGE_AUDIO_MIME_TYPES, HOST_BRIDGE_DOCUMENT_MIME_TYPES, HOST_BRIDGE_IMAGE_MIME_TYPES, HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES, HOST_BRIDGE_TEXT_MIME_TYPES, + type HostBridgeAudioMimeType, + type HostBridgeDocumentMimeType, + type HostBridgeImageMimeType, + type HostBridgeTextMimeType, normalizeHostBridgeExportFileName, normalizeHostBridgeImportFileName, } from '../../../../packages/shared/src/contracts/hostBridge'; diff --git a/apps/mobile-shell/src/host-bridge/files.ts b/apps/mobile-shell/src/host-bridge/files.ts index f1e85ab95..b97a2bfd0 100644 --- a/apps/mobile-shell/src/host-bridge/files.ts +++ b/apps/mobile-shell/src/host-bridge/files.ts @@ -14,11 +14,6 @@ import { type FileImportDocumentResult, type FileImportImageResult, type FileImportTextResult, - type HostBridgeAudioMimeType, - type HostBridgeError, - type HostBridgeImageMimeType, - type HostBridgeRequest, - type HostBridgeTextMimeType, HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES, HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES, HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES, @@ -26,6 +21,11 @@ import { HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES, HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES, HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES, + type HostBridgeAudioMimeType, + type HostBridgeError, + type HostBridgeImageMimeType, + type HostBridgeRequest, + type HostBridgeTextMimeType, normalizeHostBridgeExportFileName, normalizeHostBridgeImportFileName, } from '../../../../packages/shared/src/contracts/hostBridge'; @@ -43,7 +43,6 @@ import { normalizedBase64Data, normalizeExportedAudioFileName, normalizeExportedImageFileName, - normalizeImportedAudioFileName, normalizeImportedAudioMimeType, normalizeImportedDocumentMimeType, normalizeImportedTextMimeType, diff --git a/apps/mobile-shell/src/host-bridge/haptics.ts b/apps/mobile-shell/src/host-bridge/haptics.ts index eb10ead4a..da54a12a9 100644 --- a/apps/mobile-shell/src/host-bridge/haptics.ts +++ b/apps/mobile-shell/src/host-bridge/haptics.ts @@ -3,9 +3,9 @@ import * as Haptics from 'expo-haptics'; import { type HapticsImpactPayload, type HostBridgeError, + type HostBridgeHapticsImpactStyle, type HostBridgeRequest, normalizeHostBridgeHapticsImpactStyle, - type HostBridgeHapticsImpactStyle, } from '../../../../packages/shared/src/contracts/hostBridge'; import { invalidRequest, ok } from './protocol'; diff --git a/apps/mobile-shell/src/host-bridge/navigation.test.ts b/apps/mobile-shell/src/host-bridge/navigation.test.ts index a102c876b..cbf2adf45 100644 --- a/apps/mobile-shell/src/host-bridge/navigation.test.ts +++ b/apps/mobile-shell/src/host-bridge/navigation.test.ts @@ -6,12 +6,12 @@ import { HOST_BRIDGE_VERSION, type HostBridgeRequest, } from '../../../../packages/shared/src/contracts/hostBridge'; -import type { MobileHostBridgeNavigation } from './protocol'; import { openMobileHostBridgeExternalUrl, openMobileHostBridgeNativePage, reloadMobileHostBridgeWebView, } from './navigation'; +import type { MobileHostBridgeNavigation } from './protocol'; vi.mock('expo-linking', () => ({ canOpenURL: vi.fn(), diff --git a/apps/mobile-shell/src/host-bridge/navigation.ts b/apps/mobile-shell/src/host-bridge/navigation.ts index 12ece029c..dc6867ffa 100644 --- a/apps/mobile-shell/src/host-bridge/navigation.ts +++ b/apps/mobile-shell/src/host-bridge/navigation.ts @@ -8,14 +8,13 @@ import { type OpenExternalUrlPayload, } from '../../../../packages/shared/src/contracts/hostBridge'; import { - type MobileShellExternalNavigator, openMobileShellExternalNavigation, resolveMobileShellWebViewUrl, } from '../shell/navigation'; import { buildMobileShellUrl } from '../shell/url'; import { - type MobileHostBridgeNavigation, invalidRequest, + type MobileHostBridgeNavigation, ok, unsupported, } from './protocol'; diff --git a/apps/mobile-shell/src/host-bridge/network.ts b/apps/mobile-shell/src/host-bridge/network.ts index c87f6f8f1..22dc3f0d5 100644 --- a/apps/mobile-shell/src/host-bridge/network.ts +++ b/apps/mobile-shell/src/host-bridge/network.ts @@ -1,5 +1,5 @@ -import { getMobileNetworkStatus } from '../shell/network'; import { type HostBridgeRequest } from '../../../../packages/shared/src/contracts/hostBridge'; +import { getMobileNetworkStatus } from '../shell/network'; import { failure, ok } from './protocol'; function logMobileNetworkFailure(label: string, _error: unknown) { diff --git a/apps/mobile-shell/src/host-bridge/scanner.test.ts b/apps/mobile-shell/src/host-bridge/scanner.test.ts index ed5573aed..7514d4aca 100644 --- a/apps/mobile-shell/src/host-bridge/scanner.test.ts +++ b/apps/mobile-shell/src/host-bridge/scanner.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { - HOST_BRIDGE_SCANNER_TIMEOUT_MS, HOST_BRIDGE_PROTOCOL, + HOST_BRIDGE_SCANNER_TIMEOUT_MS, HOST_BRIDGE_VERSION, type HostBridgeRequest, } from '../../../../packages/shared/src/contracts/hostBridge'; diff --git a/apps/mobile-shell/src/host-bridge/scanner.ts b/apps/mobile-shell/src/host-bridge/scanner.ts index b80cbdfa6..38e92bf57 100644 --- a/apps/mobile-shell/src/host-bridge/scanner.ts +++ b/apps/mobile-shell/src/host-bridge/scanner.ts @@ -2,8 +2,8 @@ import { HOST_BRIDGE_SCANNER_TIMEOUT_MS, type HostBridgeError, type HostBridgeRequest, - type ScannerScanQrCodeResult, normalizeHostBridgeQrCodeValue, + type ScannerScanQrCodeResult, } from '../../../../packages/shared/src/contracts/hostBridge'; import { ok } from './protocol'; diff --git a/apps/mobile-shell/src/shell/QrScannerOverlay.tsx b/apps/mobile-shell/src/shell/QrScannerOverlay.tsx index 0953ec255..c0e827222 100644 --- a/apps/mobile-shell/src/shell/QrScannerOverlay.tsx +++ b/apps/mobile-shell/src/shell/QrScannerOverlay.tsx @@ -1,7 +1,7 @@ import { + type BarcodeScanningResult, Camera, CameraView, - type BarcodeScanningResult, } from 'expo-camera'; import { useCallback, useEffect, useState } from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; diff --git a/apps/mobile-shell/src/shell/ShellApp.tsx b/apps/mobile-shell/src/shell/ShellApp.tsx index 438c1a49c..390120cd8 100644 --- a/apps/mobile-shell/src/shell/ShellApp.tsx +++ b/apps/mobile-shell/src/shell/ShellApp.tsx @@ -28,9 +28,9 @@ import { } from 'react-native-webview'; import { - type HostBridgeEventName, HOST_BRIDGE_PROTOCOL, HOST_BRIDGE_VERSION, + type HostBridgeEventName, } from '../../../../packages/shared/src/contracts/hostBridge'; import { configureMobileHostBridgeNavigation, @@ -52,18 +52,18 @@ import { getMobileNetworkStatus, subscribeMobileNetworkStatus, } from './network'; +import { QrScannerOverlay } from './QrScannerOverlay'; import { MOBILE_SHELL_HOST_VERSION } from './runtime'; import { MOBILE_SHELL_SAFE_AREA_EDGES } from './safeArea'; import { buildMobileShellUrl, resolveMobileShellBaseWebUrl, } from './url'; +import { parseMobileWebViewHistoryStateMessage } from './webViewHistory'; import { MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT, shouldBlockMobileWebViewNavigationRequest, } from './webViewPolicy'; -import { parseMobileWebViewHistoryStateMessage } from './webViewHistory'; -import { QrScannerOverlay } from './QrScannerOverlay'; const MobileShellWebView = WebView as unknown as ComponentType< WebViewProps & RefAttributes> @@ -304,6 +304,7 @@ export default function ShellApp() { }; }, [ baseWebUrl, + baseWebUrlOptions, resetNavigationCanGoBack, resetWebViewProcessFailureWindow, urlOptions, diff --git a/apps/mobile-shell/src/shell/deepLink.ts b/apps/mobile-shell/src/shell/deepLink.ts index a0e64b91d..ac2b6c9cb 100644 --- a/apps/mobile-shell/src/shell/deepLink.ts +++ b/apps/mobile-shell/src/shell/deepLink.ts @@ -1,8 +1,8 @@ import { buildMobileShellUrl, type MobileShellBaseWebUrlOptions, - resolveMobileShellBaseWebUrl, type MobileShellUrlOptions, + resolveMobileShellBaseWebUrl, } from './url'; const supportedHosts = new Set(['open', 'app']); diff --git a/apps/mobile-shell/src/shell/url.test.ts b/apps/mobile-shell/src/shell/url.test.ts index 8b5eb8077..62bc38dd1 100644 --- a/apps/mobile-shell/src/shell/url.test.ts +++ b/apps/mobile-shell/src/shell/url.test.ts @@ -7,8 +7,8 @@ import { } from '../../../../packages/shared/src/contracts/hostBridge'; import { ALLOWED_PRODUCTION_WEB_ORIGIN, - DEFAULT_MOBILE_SHELL_WEB_URL, buildMobileShellUrl, + DEFAULT_MOBILE_SHELL_WEB_URL, resolveMobileShellBaseWebUrl, } from './url'; diff --git a/deploy/container/README.md b/deploy/container/README.md index c6959b695..588749378 100644 --- a/deploy/container/README.md +++ b/deploy/container/README.md @@ -57,7 +57,7 @@ Linux Docker Engine 若要从宿主机 CLI 连到容器内服务,直接用 `ht ## 构建工具链 -`api-server` 容器镜像只构建 Linux release API 二进制,不构建 `spacetime-module`。当前 `api-server -> spacetime-client -> spacetimedb-sdk 2.6.0` 依赖链要求 Rust 1.93,因此 `deploy/container/api-server.Dockerfile` 的 Rust builder 固定为 `rust:1.93-bookworm`。镜像构建阶段会同时复制 `public/`,用于满足 API 二进制里 `include_bytes!` 引用的内置素材;不要把 `public/generated-*` 放入镜像上下文。如果本机 Docker Hub 拉取失败,可以先在本机准备同名本地 builder 镜像,但不要把临时 bootstrap 容器或私有 registry 凭据写入仓库。 +`api-server` 容器镜像只构建 Linux release API 二进制,不构建 `spacetime-module`。当前 `api-server -> spacetime-client -> spacetimedb-sdk 2.6.1` 依赖链要求 Rust 1.93,因此 `deploy/container/api-server.Dockerfile` 的 Rust builder 固定为 `rust:1.93-bookworm`。镜像构建阶段会同时复制 `public/`,用于满足 API 二进制里 `include_bytes!` 引用的内置素材;不要把 `public/generated-*` 放入镜像上下文。如果本机 Docker Hub 拉取失败,可以先在本机准备同名本地 builder 镜像,但不要把临时 bootstrap 容器或私有 registry 凭据写入仓库。 ## 启动与验证 @@ -127,7 +127,7 @@ npm run container:worker-smoke -- status npm run container:worker-smoke -- smoke --force ``` -`container:worker-smoke` 默认会把本机 `spacetime` 2.6.0 CLI 打成轻量 SpacetimeDB 镜像,避免首次 smoke 必须拉取官方大镜像;普通 `npm run container:*` 压测默认使用 `clockworklabs/spacetime:v2.6.0`。如果 Docker build 阶段在容器内拉取 crates.io 依赖不稳定,可让容器内 Cargo 复用本机 Cargo 缓存构建当前二进制,再打入临时 smoke 镜像。该模式默认使用 `rust:1.93-bookworm` 作为 builder、Debian bookworm smoke runtime 承载构建产物;需要换 builder 镜像时设置 `GENARRATIVE_WORKER_SMOKE_CARGO_IMAGE`,需要换运行时基础镜像时设置 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE`: +`container:worker-smoke` 默认会把本机 `spacetime` 2.6.1 CLI 打成轻量 SpacetimeDB 镜像,避免首次 smoke 必须拉取官方大镜像;普通 `npm run container:*` 压测默认使用 `clockworklabs/spacetime:v2.6.1`。如果 Docker build 阶段在容器内拉取 crates.io 依赖不稳定,可让容器内 Cargo 复用本机 Cargo 缓存构建当前二进制,再打入临时 smoke 镜像。该模式默认使用 `rust:1.93-bookworm` 作为 builder、Debian bookworm smoke runtime 承载构建产物;需要换 builder 镜像时设置 `GENARRATIVE_WORKER_SMOKE_CARGO_IMAGE`,需要换运行时基础镜像时设置 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE`: ```bash npm run container:worker-smoke -- smoke --local-binary diff --git a/deploy/container/docker-compose.loadtest.yml b/deploy/container/docker-compose.loadtest.yml index 10597b127..29431d854 100644 --- a/deploy/container/docker-compose.loadtest.yml +++ b/deploy/container/docker-compose.loadtest.yml @@ -2,7 +2,7 @@ name: genarrative-container-loadtest services: spacetimedb: - image: ${GENARRATIVE_CONTAINER_SPACETIME_IMAGE:-clockworklabs/spacetime:v2.6.0} + image: ${GENARRATIVE_CONTAINER_SPACETIME_IMAGE:-clockworklabs/spacetime:v2.6.1} user: root command: [ diff --git a/docs/README.md b/docs/README.md index c177be6ec..549b216a7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,156 +1,56 @@ # 文档总览 -`docs/` 现在按主题维护项目当前口径;旧后端路线文档开始聚合和删除,后续实现以 Rust / SpacetimeDB 当前基线为准。 +`docs/` 只把当前融合文档和仍在维护的专题文档作为实现依据。旧创作模板、旧创作入口及其专属服务文档仅保留为历史材料,不再要求继续实现或兼容。 -## 快速入口 +## 必读入口 -- [Agent 工作入口与执行准则](./%E3%80%90%E5%8D%8F%E4%BD%9C%E8%A7%84%E8%8C%83%E3%80%91Agent%E5%B7%A5%E4%BD%9C%E5%85%A5%E5%8F%A3%E4%B8%8E%E6%89%A7%E8%A1%8C%E5%87%86%E5%88%99-2026-06-22.md):复杂任务前的 Agent 阅读顺序、执行边界、技能路由、文档规则和验证口径。 -- [官网 SEO 地基实施约定](./technical/【SEO】官网SEO地基实施约定-2026-07-10.md):首页基础 head、robots/sitemap、唯一 H1、精确 SPA 路由与未知路径 404 的长期技术边界。 -- [经验沉淀](./experience/README.md):项目开发经验、UI 交接、历史实现经验。 -- [审计与复盘](./audits/README.md):工程审查、文本/乱码审计、专项落地审计。 -- [系统设计](./design/README.md):玩法、关系、物品与对话设计。 -- [技术方案](./technical/README.md):动画、服务端、外部产品形态拆解。 -- [架构决策](./adr/):记录已经接受的跨模块、长期有效架构取舍;对应长期摘要仍应同步到 `docs/project-memory/shared-memory/decision-log.md`。 -- [规划与优先级](./planning/README.md):当前阶段的迭代排序与落地优先级;创作流程统一总计划见 [【玩法创作】创作流程统一总计划-2026-05-30.md](./planning/%E3%80%90%E7%8E%A9%E6%B3%95%E5%88%9B%E4%BD%9C%E3%80%91%E5%88%9B%E4%BD%9C%E6%B5%81%E7%A8%8B%E7%BB%9F%E4%B8%80%E6%80%BB%E8%AE%A1%E5%88%92-2026-05-30.md)。 -- [参考目录](./reference/README.md):脚本/Function 速查入口。 - 重点补充:RPG 创作与运行时脚本职责地图见 [RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md](./reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md)。 -- [埋点查询](./tracking/README.md):埋点原始事件与聚合投影的本地 SQL 查询。 -- [运营查询](./operations/README.md):任务、领奖、钱包对账等后台核查查询。 -- [PRD](./prd/README.md):产品需求与阶段计划;参考 MOKU / 幕间类 AI 文游的陶泥儿 `text-game` 模板口径见 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md](./prd/AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md),视觉小说模板 TXT 玩法平台化接入口径见 [AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md](./prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md),创意互动内容 Agent Phase 1 的 LangChain-Rust PoC、拼图闭环和并行任务拆分见 [CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md](./prd/CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md),幸存者类模板闭环见 [AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md](./prd/AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md),后台管理独立前端工程见 [ADMIN_WEB_CONSOLE_PRD_2026-04-30.md](./prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md),新增 RPG 开场动画方案见 [AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md](./prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md),新增抓大鹅 Match3D 玩法方案见 [AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md](./prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md),方洞挑战创作、发布与试玩闭环见 [AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md](./prd/AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md),跳一跳俯视角玩法模板 PRD 见 [【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md](./prd/%E3%80%90%E7%8E%A9%E6%B3%95%E5%88%9B%E4%BD%9C%E3%80%91%E8%B7%B3%E4%B8%80%E8%B7%B3%E4%BF%AF%E8%A7%86%E8%A7%92%E7%8E%A9%E6%B3%95%E6%A8%A1%E6%9D%BFPRD-2026-05-19.md)。 +1. [Agent 工作入口与执行准则](./【协作规范】Agent工作入口与执行准则-2026-06-22.md) +2. [当前产品与工程约束](./【项目基线】当前产品与工程约束-2026-05-15.md) +3. [server-rs 与 SpacetimeDB 数据契约](./【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md) +4. [平台入口与玩法链路](./【玩法创作】平台入口与玩法链路-2026-05-15.md) +5. [本地开发验证与生产运维](./【开发运维】本地开发验证与生产运维-2026-05-15.md) -拼图生成页步骤真进度、步骤内假进度和精简展示口径见 [【玩法创作】拼图生成页进度口径-2026-05-23.md](./%E3%80%90%E7%8E%A9%E6%B3%95%E5%88%9B%E4%BD%9C%E3%80%91%E6%8B%BC%E5%9B%BE%E7%94%9F%E6%88%90%E9%A1%B5%E8%BF%9B%E5%BA%A6%E5%8F%A3%E5%BE%84-2026-05-23.md)。 +团队长期约定、决策、流程和排障摘要统一从 [项目记忆入口](./project-memory/README.md) 读取。代码、当前融合文档与项目记忆冲突时,以代码和最新融合文档为准。 -从文字需求生成高一致性美术素材流程抽象出的发明专利交底稿见 [【专利交底】一种极低成本快速生成高质量2D小游戏高一致性美术素材的解决方案-2026-05-25.md](./%E3%80%90%E4%B8%93%E5%88%A9%E4%BA%A4%E5%BA%95%E3%80%91%E4%B8%80%E7%A7%8D%E6%9E%81%E4%BD%8E%E6%88%90%E6%9C%AC%E5%BF%AB%E9%80%9F%E7%94%9F%E6%88%90%E9%AB%98%E8%B4%A8%E9%87%8F2D%E5%B0%8F%E6%B8%B8%E6%88%8F%E9%AB%98%E4%B8%80%E8%87%B4%E6%80%A7%E7%BE%8E%E6%9C%AF%E7%B4%A0%E6%9D%90%E7%9A%84%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88-2026-05-25.md)。 +## 当前专题 -微信小程序虚拟支付接入、`wechat_mp_virtual` 渠道、`wx.requestVirtualPayment` 承接页和后端签名配置见 [【技术方案】微信虚拟支付接入-2026-05-26.md](./%E3%80%90%E6%8A%80%E6%9C%AF%E6%96%B9%E6%A1%88%E3%80%91%E5%BE%AE%E4%BF%A1%E8%99%9A%E6%8B%9F%E6%94%AF%E4%BB%98%E6%8E%A5%E5%85%A5-2026-05-26.md)。 +### 图片编辑器与 Agent -微信小程序、Expo React Native 移动壳与 Tauri 桌面壳共用的宿主能力契约、method / capability 白名单、H5 facade 和三端边界见 [【前端架构】宿主壳能力统一协议-2026-06-17.md](./%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%AE%BF%E4%B8%BB%E5%A3%B3%E8%83%BD%E5%8A%9B%E7%BB%9F%E4%B8%80%E5%8D%8F%E8%AE%AE-2026-06-17.md)。 +- [图片画布编辑器 MVP 接入方案](./technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md) +- [图片画布编辑器前端拆分计划](./technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md) +- [画布 Agent 对话面板](./【编辑器】画布Agent对话面板-2026-07-03.md) +- [画布 Agent 会话消息存 OSS](./adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md) +- [浏览器内 AI Web 工程沙箱预览](./technical/【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md) +- [AI Web 工程 Runner 安全模型](./technical/【安全模型】AIWeb工程Runner与预览隔离威胁模型-2026-06-13.md) -Expo React Native 移动壳和 Tauri 桌面壳的工程结构、同源 WebView 安全边界、原生能力实现、构建配置和门禁要求见 [【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md](./%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ExpoReactNative%E4%B8%8ETauri%E5%AE%BF%E4%B8%BB%E5%A3%B3%E6%96%B9%E6%A1%88-2026-06-17.md)。 +### 后端与公开数据 -原生壳本地开发与验证命令统一从根工程进入:移动壳使用 `npm run mobile-shell:dev`、`npm run mobile-shell:typecheck`、`npm run mobile-shell:test`,桌面壳使用 `npm run desktop-shell:dev`、`npm run desktop-shell:typecheck`、`npm run desktop-shell:test`,跨端结构和能力门禁使用 `npm run check:native-shells`。 +- [外部生成 Worker 化方案](./technical/【后端架构】外部生成Worker化方案-2026-06-03.md) +- [统一公开作品 Read Model 设计](./technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md) +- [外部 OpenAPI 与 API Key 接入方案](./【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md) +- [SpacetimeDB 连接池取消安全](./【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md) -`/editor/agent` 浏览器内 AI Web 工程编辑器的静态 SPA 沙箱预览 MVP,采用“平台编辑器壳 + api-server 控制面 + 独立 runner worker + 独立预览域”四层结构;技术方案、威胁模型和验收清单见 [【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md](./technical/【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md)、[【安全模型】AIWeb工程Runner与预览隔离威胁模型-2026-06-13.md](./technical/【安全模型】AIWeb工程Runner与预览隔离威胁模型-2026-06-13.md) 和 [【测试用例】AIWeb工程静态预览MVP验收清单-2026-06-13.md](./technical/【测试用例】AIWeb工程静态预览MVP验收清单-2026-06-13.md)。P1 先用确定性 mock Agent 生成结构化 patch、真实打通项目 / 快照 / 构建 / artifact / 预览闭环,落地拆分见 [【技术方案】EditorAgentMockAgentP1落地计划-2026-06-15.md](./technical/【技术方案】EditorAgentMockAgentP1落地计划-2026-06-15.md)。 +### 后台、宿主壳与运维 -`/editor/canvas` 图片画布编辑器的画布素材 ZIP 导出能力,入口放在右上角标题栏下载图标内,采用前端 JSZip 打包画布中有效图层引用的上传图、生成图、修改结果和角色动作序列帧;动作图层右键“导出为”提供序列帧 ZIP(含前端生成的 `preview.gif`)与 Spine JSON ZIP 两个二级选项,方案见 [【前端架构】图片画布素材导出方案-2026-06-15.md](./technical/【前端架构】图片画布素材导出方案-2026-06-15.md)。 +- [Dashboard 运营看板方案](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md) +- [后台多账号与 Tab 访问权限](./technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md) +- [宿主壳能力统一协议](./【前端架构】宿主壳能力统一协议-2026-06-17.md) +- [Expo React Native 与 Tauri 宿主壳方案](./【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md) +- [Pingora 独立网关试点](./technical/【开发运维】Pingora独立网关试点-2026-06-11.md) +- [本地 SSH 服务器管理面板](./technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md) -`/editor/canvas` 右侧画布 Agent 对话面板、会话持久化、SSE 事件、附件与生成落画板例外见 [【编辑器】画布Agent对话面板-2026-07-03.md](./【编辑器】画布Agent对话面板-2026-07-03.md);消息正文存 OSS、元数据进 SpacetimeDB 的取舍见 [【ADR】画布Agent会话消息存OSS-2026-07-03.md](./adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md)。 +### 测试与协作 -图片画布生成类面板的模型泥点默认 JSON、SpacetimeDB 运行时事实源、后台“模型定价”页面和主站动态下发口径见 [【编辑器】模型定价配置管理方案-2026-06-22.md](./%E3%80%90%E7%BC%96%E8%BE%91%E5%99%A8%E3%80%91%E6%A8%A1%E5%9E%8B%E5%AE%9A%E4%BB%B7%E9%85%8D%E7%BD%AE%E7%AE%A1%E7%90%86%E6%96%B9%E6%A1%88-2026-06-22.md)。 +- [React 组件测试准则](./technical/【前端测试】React组件测试准则-2026-06-26.md) +- [AI Web 工程静态预览验收清单](./technical/【测试用例】AIWeb工程静态预览MVP验收清单-2026-06-13.md) +- [当前阶段规划](./planning/README.md) -React 组件测试的用户行为、稳定契约、hook / model 分层断言口径,以及避免内部 DOM 探针、图标 class 和完整对象快照式断言的规则见 [【前端测试】React组件测试准则-2026-06-26.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%B5%8B%E8%AF%95%E3%80%91React%E7%BB%84%E4%BB%B6%E6%B5%8B%E8%AF%95%E5%87%86%E5%88%99-2026-06-26.md)。 +## 历史文档边界 -桌面端全站客服悬浮入口的全局接入点、固定层级、拖拽边界和移动端隐藏口径见 [【前端架构】全站客服悬浮入口接入约定-2026-06-23.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%85%A8%E7%AB%99%E5%AE%A2%E6%9C%8D%E6%82%AC%E6%B5%AE%E5%85%A5%E5%8F%A3%E6%8E%A5%E5%85%A5%E7%BA%A6%E5%AE%9A-2026-06-23.md)。 +- 旧创作模板、旧创作入口、旧运行态和旧素材生成方案不再从本页索引,也不再作为修复目标。 +- `maincloud`、旧 Node/Express/PostgreSQL/Go 后端和人工 `spacetime --root-dir` 口径均已废弃。 +- 历史文档不得覆盖当前融合文档、代码契约或 `docs/project-memory/shared-memory/decision-log.md` 中更新的决策。 -桌面端 `/creation` 创作工具主页、顶级“草稿”入口替换为“项目”、最近项目、新建项目和陶泥儿精选素材瀑布流的落地计划见 [【玩法创作】创作主页与项目入口改版计划-2026-06-18.md](./%E3%80%90%E7%8E%A9%E6%B3%95%E5%88%9B%E4%BD%9C%E3%80%91%E5%88%9B%E4%BD%9C%E4%B8%BB%E9%A1%B5%E4%B8%8E%E9%A1%B9%E7%9B%AE%E5%85%A5%E5%8F%A3%E6%94%B9%E7%89%88%E8%AE%A1%E5%88%92-2026-06-18.md)。 +## 命名规则 -本地通过 SSH alias 管理多台服务器、查看硬件 / systemd / HTTP 健康状态并执行受控服务启停的 egui 桌面工具见 [【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md](./technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md)。 - -生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。Pingora 只作为独立二进制影子网关试点时,边界、路由口径与替换前验收见 [【开发运维】Pingora独立网关试点-2026-06-11.md](./technical/【开发运维】Pingora独立网关试点-2026-06-11.md)。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md);private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md),Dashboard 默认入口、运营指标和统计口径见 [【后台管理】Dashboard运营看板方案-2026-06-23.md](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md),owner/member 多账号、18 个一级 Tab 权限和逐请求鉴权方案见 [【后台管理】多账号与Tab访问权限方案-2026-07-14.md](./technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md)。 - -SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。 - -创作 Agent 问答流式失败时保留已显示回复、并透出更具体上游错误的契约见 [CREATION_AGENT_STREAM_FAILURE_RETENTION_FIX_2026-05-05.md](./technical/CREATION_AGENT_STREAM_FAILURE_RETENTION_FIX_2026-05-05.md)。 - -`maincloud` 相关脚本、环境变量、测试名和文档要求已统一判定为历史残留,后续禁止新增、运行或引用;当前后端 smoke 使用 `npm run dev:api-server` 与 `/healthz`,详细规则见 [MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md](./technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md)。 - -基于 LangChain-Rust,以“感知—思考—记忆—行动—反思—协作”闭环完成图文创意理解、拼图模板选择、积分范围确认、拼图草稿字段填充、立即试玩和自然语言修订草稿字段的 Agent 方案见 [CREATIVE_INTERACTIVE_CONTENT_AGENT_TECHNICAL_SOLUTION_2026-05-05.md](./technical/CREATIVE_INTERACTIVE_CONTENT_AGENT_TECHNICAL_SOLUTION_2026-05-05.md)。 - -创意互动内容 Agent Phase 1 的产品边界、实现细节、SpacetimeDB 落点、前端接入和可并行任务拆分见 [CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md](./prd/CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md)。 - -视觉小说模板接入以 [AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md](./prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md) 为最新口径:只吸收外部 TXT 玩法的模板创作与运行经验,禁止迁入外部平台功能,并删除回放。 - -AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md](./prd/AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md) 为最新口径:只吸收 MOKU / 幕间类 AI 文游的剧本游乐场、自由行动、AI GM、记忆和模拟器强反馈经验,禁止迁入外部社区、支付、榜单、私有存档或回放。 - -前端 Server-Sent Events 客户端传输层收口到 `src/services/sseStream.ts`,事件边界、UTF-8 flush、JSON 解析跳过和提前取消约定见 [【前端架构】SSE客户端传输层收口约定-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91SSE%E5%AE%A2%E6%88%B7%E7%AB%AF%E4%BC%A0%E8%BE%93%E5%B1%82%E6%94%B6%E5%8F%A3%E7%BA%A6%E5%AE%9A-2026-06-03.md)。 - -平台入口公开作品身份、跨玩法去重、公开作品流聚合、推荐运行态 kind 判定、推荐 runtime 启动意图、ready 判定和最新排序收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`,规则见 [【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%B9%B3%E5%8F%B0%E5%85%A5%E5%8F%A3PublicGalleryFlowModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 - -统一作品详情页的玩法 kind、详情打开策略、自有作品动作模式、编辑 / 点赞 / 改造 / 启动意图和公开详情映射收口到 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`;抓大鹅公开详情映射与启动 / 编辑 Adapter 的素材归一仍归 `platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md](./technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md)。 - -创作中心作品架打开动作由 `CreationWorkShelfItem.actions.open` 统一承载,生产 Hub 只接收 `CreationWorkShelfItem[]` 与 UI 状态,不再接收各玩法 raw items 和回调列阵,规则见 [【前端架构】WorkShelfModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91WorkShelfModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 - -作品架删除确认的标题、删除说明、草稿 notice key 和拼图派生稳定 ID 收口到 `src/components/platform-entry/platformCreationWorkDeleteFlow.ts`,平台壳只保留删除 API、刷新、错误和页面跳转副作用,规则见 [【前端架构】CreationWorkDeleteFlow收口计划-2026-06-04.md](./technical/【前端架构】CreationWorkDeleteFlow收口计划-2026-06-04.md)。 - -创作入口点击的占位、隐藏模板拦截、未知入口 no-op 与工作台启动目标收口到 `src/components/platform-entry/platformCreationLaunchModel.ts`,壳层只执行启动前准备、错误提示和受保护动作,规则见 [【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md)。 - -平台入口公开码搜索的用户 ID、陶泥号、RPG 作品号、各玩法作品号前缀、per-play 公开码匹配、详情卡 DTO 映射和失败回退顺序收口到 `src/components/platform-entry/platformPublicCodeSearchModel.ts`,壳层只按计划执行网络读取、详情打开和错误归航副作用,规则见 [【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md)。 - -个人“玩过作品”面板的玩法别名、`worldKey` 前缀兜底、RPG 公开详情 payload 和大鱼吃小鱼 gallery miss fallback 收口到 `src/components/platform-entry/platformPlayedWorkOpenModel.ts`,壳层只执行面板关闭、gallery 读取、详情打开和错误提示副作用,规则见 [【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md)。 - -平台入口生成页进度 tick 的 stage 到生成状态映射、终态判定和视觉小说轻量生成特例收口到 `src/components/platform-entry/platformGenerationProgressTickModel.ts`,壳层只保留 `Date.now()`、`setInterval` 和 cleanup 副作用,规则见 [【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md)。 - -平台壳的拼图 runtime 恢复 work、方洞 session draft 转 profile、视觉小说 work detail 转 Agent session、跳一跳 pending session、敲木鱼 detail 恢复 session、敲木鱼生成中作品摘要和敲木鱼 pending session DTO 映射收口到 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts`,壳层只保留网络、状态、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md)。 - -平台小游戏生成状态的恢复、失败 / 完成收尾、展示 rebase、拼图后端进度合并、抓大鹅生成资产旁路进度合并和 ready / generating 判定收口到 `src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts`,壳层只保留 API、后台任务、React state、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md)。 - -平台小游戏草稿恢复和提交所需的拼图 / 抓大鹅表单 payload、拼图作品更新 payload、拼图编译 action、跳一跳 / 敲木鱼生成 action、pending metadata 与拼图 form-only 草稿判定收口到 `src/components/platform-entry/platformMiniGameDraftPayloadModel.ts`,壳层只保留 API、Action 执行、background task 与状态副作用,规则见 [【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md)。 - -平台拼图生成完成后刷新恢复的草稿归一化与可恢复完成态判定收口到 `src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts`,恢复链路只有在首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才抬为 ready,规则见 [【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md)。 - -拼图排行榜提交回包后的服务端 run 快照合并收口到 `src/components/platform-entry/platformPuzzleRuntimeStateModel.ts`,只合并排行榜、run 身份、通关数上限和下一关 handoff,保留前端即时裁决的关卡状态与棋盘,规则见 [【前端架构】PlatformPuzzleRuntimeStateModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPuzzleRuntimeStateModel收口计划-2026-06-04.md)。 - -后端拼图发布 / 待发布门槛收紧到首图、关卡画面、UI spritesheet 与关卡背景资产包完整,`module-puzzle` 的 preview blockers 与 `api-server` 的 session stage 判定保持同一规则,方案见 [【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md](./technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md)。 - -平台入口个人钱包本地 delta、dashboard 乐观更新与服务端快照对账规则收口到 `src/components/platform-entry/platformProfileWalletDeltaModel.ts`,平台壳只保留 API、ref 与 state 副作用,规则见 [【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md)。 - -Bark Battle 草稿三图完整性、生成状态归一、作品架摘要恢复草稿配置、发布快照 / 发布回包资产兜底和草稿 / 已发布作品进入 runtime 前的 `BarkBattlePublishedConfig` 映射收口到 `src/components/platform-entry/barkBattleWorkCache.ts`,规则见 [【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md](./technical/【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md)。 - -平台首页推荐 runtime 的匿名 Runtime Guest Token、已登录 background auth、非 embedded no-op 和拼图 isolated/default auth mode 计划收口到 `src/components/platform-entry/platformRecommendRuntimeAuthModel.ts`,规则见 [【前端架构】PlatformRecommendRuntimeAuthModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformRecommendRuntimeAuthModel收口计划-2026-06-04.md)。 - -平台首页推荐 runtime 自动启动的桌面 / Tab / stage / loading gate、active entry 查找、ready 判定和 clear/start/noop 决策收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`,规则见 [【前端架构】PlatformRecommendRuntimeAutoStart收口计划-2026-06-04.md](./technical/【前端架构】PlatformRecommendRuntimeAutoStart收口计划-2026-06-04.md)。 - -RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts`,壳层只保留 session/profile 编排和结果页 props 传递,规则见 [【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md)。 - -平台入口创作生成通知、pending 作品架占位、作品详情更新回填、失败覆盖、跨玩法草稿打开优先级、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 - -平台入口创作恢复 URL 私有 query、初始恢复判定、创作直达恢复目标解析、恢复目标身份匹配、跳一跳 / 敲木鱼恢复阶段落点、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts` 和 `src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md)。 - -平台入口错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key 与任务完成文案收口到 `src/components/platform-entry/platformDialogStateModel.ts`,规则见 [【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md](./technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md)。 - -平台 UI Kit 的提示 / 确认弹窗收口到 `src/components/common/UnifiedConfirmDialog.tsx`,复制反馈收口到 `src/components/common/useCopyFeedback.ts`、`src/components/common/CopyFeedbackButton.tsx`、`src/components/common/CopyCodeButton.tsx` 与 `src/components/common/CopyFeedbackMessage.tsx`,基础状态提示收口到 `src/components/common/PlatformStatusMessage.tsx`,运行态短错误 / 成功 / 反馈 toast 收口到 `src/components/common/PlatformRuntimeStatusToast.tsx`,平台空态 / 轻量加载态收口到 `src/components/common/PlatformEmptyState.tsx`,平台动作按钮收口到 `src/components/common/PlatformActionButton.tsx`,平台白底子面板 / 小型列表卡片收口到 `src/components/common/PlatformSubpanel.tsx`,平台输入框 / 文本域收口到 `src/components/common/PlatformTextField.tsx`,平台字段标题收口到 `src/components/common/PlatformFieldLabel.tsx`,平台媒体预览框收口到 `src/components/common/PlatformMediaFrame.tsx`,平台胶囊状态标签收口到 `src/components/common/PlatformPillBadge.tsx`,平台图片全屏预览收口到 `src/components/common/PlatformImagePreviewModal.tsx`,平台 / 个人中心弹窗关闭按钮收口到 `src/components/common/PlatformModalCloseButton.tsx`,底层继续复用 `UnifiedModal`;普通提示、确认 / 取消、危险确认、复制状态机、短代码复制 chip、复制按钮表现、白底 / 个人中心 / 认证入口 token 状态条、运行态状态 toast、无操作空态、主动作按钮、白底子面板、白底交互列表卡片、普通输入字段、字段标题、图片源 / fallback / 固定比例媒体预览、全屏黑底图片查看、单个状态 / 标签 chip 和圆形关闭按钮优先使用公共 Module,规则见 [【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md](./technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md)。 - -平台入口受保护数据失效后的 stage 去留判定,以及缺失草稿 / 作品 / run 时的阶段回退,收口到 `src/components/platform-entry/platformSelectionStageModel.ts`,壳层只执行缓存清空、布尔事实汇总和必要跳转,规则见 [【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md)。 - -小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D、SquareHole、Big Fish、Bark Battle、Puzzle 公开 / 推荐运行态请求、Jump Hop / Wooden Fish 正式 run 请求和 Visual Novel 局部 JSON runtime 请求已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 - -抓大鹅 runtime profile 的公开详情转 work、session draft 转 profile、生成背景资产提升和 run/profile/public detail 素材优先级收口到 `src/components/platform-entry/platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】Match3DRuntimeProfile收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91Match3DRuntimeProfile%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 - -公开作品分类选项、搜索、跨来源去重、今日筛选、排行排序和时间戳解析收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicGalleryViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 - -公开作品的玩法类型 label、公开作者 lookup 与游玩 / 改造 / 点赞等紧凑计数格式收口到 `src/components/rpg-entry/rpgEntryWorldPresentation.ts`,规则见 [【前端架构】PublicWorkPresentation收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicWorkPresentation%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 - -推荐 feed 的公开作品去重、普通内容过滤、active 窗口与上一条 / 下一条回环选择也收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】RecommendFeedViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RecommendFeedViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 - -移动端推荐首页 swipe deck 的拖拽阈值、offset clamp、commit 方向、rail class 和分享文案收口到 `src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts`,规则见 [【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md](./technical/【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md)。 - -排行频道的默认 tab、tab 文案、空态文案、排序字段与指标 label/value 收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】RankingViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RankingViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 - -每日任务卡片与任务中心弹窗的任务选择、进度、状态标签和按钮文案收口到 `src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts`,规则见 [【前端架构】ProfileTaskViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ProfileTaskViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 - -个人数据卡、钱包 chip 与“玩过”弹窗的计数、时长、作品类型和作品号展示收口到 `src/components/rpg-entry/rpgEntryProfileDashboardPresentation.ts`,规则见 [【前端架构】ProfileDashboardPresentation收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ProfileDashboardPresentation%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 - -个人资金展示的账单来源、金额正负号、余额兜底、充值价格、商品主值和会员摘要收口到 `src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts`,规则见 [【前端架构】ProfileFundsViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ProfileFundsViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 - -## 推荐阅读顺序 - -1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。 -2. 再看 [工程审查总览](./audits/engineering/README.md) 和 [文本审计总览](./audits/text/README.md),了解当前风险。 -3. 需要排期时看 [规划与优先级](./planning/README.md),创作链路相关任务优先看创作流程统一总计划。 -4. 需要补方案时进入 [系统设计](./design/README.md) / [技术方案](./technical/README.md);涉及后端先看 [当前后端实现基线](./technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md),涉及生产发布链路先看 [生产部署计划](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),涉及 SpacetimeDB 表结构变更时再看 [表结构变更约束](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。 -5. 需要对齐目标边界时再进入 [PRD](./prd)。 - -## 分类规则 - -- `experience/`:偏方法论、交接经验、长期有效的开发结论。 -- `audits/`:偏“现状扫描 / 问题定位 / 是否达标”的审查类文档。 -- `design/`:偏玩法机制、叙事关系、系统结构设计。 -- `technical/`:偏技术选型、实现路线、竞品/产品形态拆解。 -- `planning/`:偏阶段优先级与推进顺序。 -- `reference/`:偏目录、速查、检索辅助。 -- `tracking/`:偏埋点原始事实和聚合投影查询,不放任务进度或钱包对账。 -- `operations/`:偏后台运营核查、对账和排障查询。 - -## 文档命名规则 - -后续新增的 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`。标签用于跨目录检索,不替代上面的目录分类;历史文档不要求批量重命名,除非本次任务明确涉及该文档。 +后续新增 Markdown 文档文件名使用 `【标签名】中文标题-日期.md`。历史文件不做无关批量重命名;本次涉及的旧文档可直接删除或在当前入口中取消引用。 diff --git a/docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md b/docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md index 194a23ea0..c1c881dbe 100644 --- a/docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md +++ b/docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md @@ -4,7 +4,7 @@ ## 背景 -画布Agent对话需要保存历史记录并支持新开会话。消息正文是流式追加、体量随对话增长的 JSON 数据;SpacetimeDB 是后端唯一结构化真相存储,但表行不适合承载不断增长的长文本;画布工程快照已有独立保存链路,且对话流式更新频率远高于布局保存。 +画布Agent对话需要保存历史记录并支持新开会话。消息正文会随对话和工具结果持续增长;SpacetimeDB 是后端唯一结构化真相存储,但表行不适合承载不断增长的长文本;画布工程快照已有独立保存链路,对话消息也不应与布局保存互相竞争。 ## 决策 @@ -14,7 +14,7 @@ - 浏览器禁止直接上传、覆盖或签名写入 `editor-agent/` 对象;前端只通过 `api-server` 的会话接口创建会话、发送消息、读取历史,OSS 读写由服务端完成。 - 消息文档序列化后的读写上限为 2 MiB;超过上限时后端拒绝继续读写该会话消息文档,并返回 payload too large 语义错误。 - 同一会话内的消息追加采用 `conversationId` 级串行锁,避免同一会话的“读-改-写”整对象过程互相覆盖。 -- Agent 工具调用失败时,仍保留 assistant 消息和 `failed` generation record,包括 tool call、模型、错误信息等排障字段;失败记录是会话历史的一部分,不因 SSE 已返回错误而丢弃。 +- Agent 规划失败使用 `role=system`、正文以 `ERROR ` 开头的消息持久化;首次响应和同 `clientMessageId` 重放都通过 `deltaMessages` 返回该消息,`errorMessage` 不重复携带。前端隐藏前缀并显示红色错误气泡,后端构建后续 LLM memory 时仍保留该消息,让 Agent 获取上一轮失败上下文。工具调用失败继续保留 `status=failed` 工具记录、模型和错误信息;失败记录都是会话历史的一部分。 - 不把消息明细写入 SpacetimeDB 表,不把对话混入画布工程快照,不在 api-server 内存中保存会话真相。 ## 备选方案与取舍 @@ -31,5 +31,5 @@ - 会话表仍只保存元数据和 `messagesObjectKey`;API 可以在读取时把 OSS 消息文档拼装为会话详情返回,但完整消息正文的持久化真相仍是 OSS JSON 文档。 - 消息文档最大 2 MiB;该限制用于阻止单个会话无限增长。后续如果需要更长历史,应引入归档、分页对象或摘要压缩,不应把正文回填进 SpacetimeDB 表。 - 会话软删只打表标记,OSS 对象保留,便于恢复与审计。 -- 工具生成失败也会写入消息文档:assistant 消息可标记为失败 / 错误类型,`generations` 中保留 `failed` record、模型和错误信息,便于用户回看失败原因和后续排障。 +- 规划或工具生成失败也必须写入消息文档:规划失败保存 `ERROR ` system 消息,工具失败保存失败状态、模型和错误信息,便于用户回看失败原因和后续排障。 - 若未来出现跨会话消息检索需求,需另建投影或索引,不回退为消息入表。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 2aeaf371e..23ed2852f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -15,6 +15,29 @@ ``` --- +## 2026-07-18 手动去背景稳定媒体引用校验收口 + +- 背景:当前分支与 `master` 分别增加手动去背景专用 Data URL 校验和编辑器通用稳定媒体引用校验,直接叠加会让 API 与 worker 重复执行语义相同的 helper,并造成 `data:` / `blob:` 覆盖范围和错误文案漂移。 +- 决策:删除手动去背景专用校验。HTTP API 在入队前统一调用 `ensure_editor_reference_image_source_is_stable`,立即拒绝 `data:` / `blob:`;worker 不重复调用该入口校验,只通过 `resolve_editor_reference_object_key_for_owner` 完成稳定引用解析和 owner 归属校验。底层 `resolve_editor_reference_object_key` 在尝试 object key、项目资源 ID 或素材 ID 解析前统一拒绝内联媒体,作为历史任务和内部直接调用的最终边界。 +- 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、图片画布手动去背景测试和图片画布技术方案;不改变队列 DTO、BgFilter `image_url` 协议或 SpacetimeDB 的编辑器任务 payload 门禁。 +- 验证方式:覆盖 API 入队前拒绝 `data:` / `blob:`、解析器拒绝内联媒体、worker 只调用稳定引用解析与归属校验;运行 api-server 编辑器定向测试、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + +## 2026-07-17 画布 Agent 普通消息不提供客户端停止 + +- 背景:普通消息进入 LLM 前,后端已经把用户消息写入 OSS;前端中断 fetch 只能停止本地等待,不能保证后端停止规划,且会保留无法与后端消息对齐的 optimistic message。 +- 决策:移除画布 Agent 普通消息的“停止”按钮和 `stopCurrentTurn`,发送期间保持按钮禁用并等待后端响应。待确认工具调用的“取消”仍保留,不受本决策影响。 +- 影响范围:画布 Agent 对话 hook、发送区交互、前端测试和专题文档。 +- 验证方式:运行画布 Agent hook / 面板定向测试、`npm run typecheck`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/【编辑器】画布Agent对话面板-2026-07-03.md`。 + +## 2026-07-10 画布 Agent 工具确认分离执行参数与展示投影 + +- 背景:画布 Agent 已在实际生成前进入 `pending_confirmation`,但 `EditorAgentToolCall.args` 只保存工具私有 JSON,其中图片参数是保护真实 data key 的 SHA-256 opaque ID。前端直接解析 raw args 只能显示内部哈希或图片数量,无法向用户准确展示即将使用的目标图、参考图和完整参数;若直接把图片 URL 或对象塞回 raw args,又会破坏确认执行反序列化和 LLM 不可见真实 data key 的安全边界。 +- 决策:`EditorAgentToolCall.args` 继续作为确认执行唯一真相,不允许前端改写或回传替代参数;新增必填 `displayArgs` 只读展示投影,内含 `stringArgs`、`imageArgs` 和 `extras.priceMudPoints`。`stringArgs` 承载提示词与规格等用户可见字段,`imageArgs.refs` 承载 `imageId` 及后端解析出的 `objectKey`、`imageSrc`、可选缩略图、标签和尺寸;`extras.priceMudPoints` 由 api-server 在创建待确认消息时使用后端运行时模型定价快照计算,前端只显示“预计消耗 N泥点”,不自行计算或回传价格。api-server 必须按已注册 tool 白名单,从已校验 args 与 OSS 会话文档的附件 / 历史生成结果构建该投影;前端只渲染投影,以 `ResolvedAssetImage` 换签显示图片,不解析 tool 私有 schema、不展示 SHA-256 ID。展示价格不参与确认执行或实际扣费,确认后仍由既有生成 BFF 按后端运行时定价预扣费。删除只重复 `args` 且没有稳定语义的 `EditorAgentToolCall.summary`。模块尚未上线,不保留缺少 `displayArgs` 时读取 raw `args` 的旧消息降级路径。 +- 影响范围:`shared-contracts` / `packages/shared` 的 `editorAgent` DTO、`api-server/src/editor_agent/api.rs` 的待确认消息构建、画布 Agent 待确认卡、OSS 会话消息文档与相关测试。 +- 验证方式:`cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml editor_agent`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_agent`、`npm run test -- src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx src/services/image-editor/editorAgentClient.test.ts`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/【编辑器】画布Agent对话面板-2026-07-03.md`、`docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md`。 ## 2026-07-17 生成后抠图原图以 OSS 作为内存生命周期边界 @@ -157,6 +180,14 @@ - 验证方式:核对 `spacetime --version`,运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo check` / 定向测试、`npm run test -- scripts/dev.test.ts`、server provision 工具测试、production ops / encoding / diff 门禁。 - 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +## 2026-07-17 SpacetimeDB 工具链统一升级到 2.6.1 + +- 背景:SpacetimeDB 2.6.1 修复 procedure context 中调用者 `Identity` / `ConnectionId` 丢失问题,并修正 TypeScript 生成代码中 `Option` 字段的可选键语义;继续运行 2.6.0 会保留已知 procedure 身份回归。 +- 决策:`server-rs/Cargo.toml` 的 `spacetimedb`、`spacetimedb-sdk`、`spacetimedb-lib` 精确锁定 2.6.1;本地 CLI / standalone、Rust bindings、worker smoke、容器压测镜像和生产 provision 下载根同步对齐 2.6.1。 +- 影响范围:Rust workspace lockfile、SpacetimeDB bindings、本地 dev 版本门禁、容器 smoke / loadtest、server provision Jenkins 与项目 SpacetimeDB skills / 文档。 +- 验证方式:核对 `spacetime --version`,运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo check` / 定向测试、server provision 工具测试、encoding / diff 门禁。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + ## 2026-07-10 外部生成任务只持久化轻量媒体引用并独立维护摘要投影 - 背景:编辑器 worker 化后直接把同步接口 payload 序列化进 `external_generation_job.request_payload_json`;前端又把已有 OSS `objectKey` 下载成 Data URL 再提交,导致单个任务 JSON 膨胀到数 MB,正式任务列表读取 20 条任务时同时搬运约 65 MB payload,并放大为 SpacetimeDB 与 api-server 的瞬时内存峰值。此前“禁止 Data URL 持久化”只覆盖工程、素材、图层和元数据,遗漏了正式生成任务表。 @@ -294,10 +325,10 @@ ## 2026-07-03 画布Agent会话元数据入 SpacetimeDB、消息正文存 OSS -- 背景:图片画布工程需要对话式编辑历史,但消息正文随流式输出增长,不适合放入表行或画布布局快照;同时画布 Agent 只属于编辑器画布域,不能复用拼图 `creative-agent` 内存会话。 -- 决策:新增 `module-editor-agent` 承载纯领域规则,`editor_agent_conversation` 只保存会话元数据,完整消息以 `editor-agent/{conversationId}.json` 会话粒度存 OSS;`api-server` 负责编排 LLM、SSE、OSS 读写和既有生成工具调用。 +- 背景:图片画布工程需要对话式编辑历史,但消息正文随对话和工具结果增长,不适合放入表行或画布布局快照;同时画布 Agent 只属于编辑器画布域,不能复用拼图 `creative-agent` 内存会话。 +- 决策:`module-editor-agent` 只承载可供 SpacetimeDB WASM 使用的纯领域规则;Agent runner、工具实现和资产 DTO 迁入原生 `platform-editor-agent`,仅由 `api-server` 依赖。`editor_agent_conversation` 只保存会话元数据,完整消息以 `editor-agent/{conversationId}.json` 会话粒度存 OSS;`api-server` 负责编排 LLM、普通 JSON 消息、OSS 读写和既有生成工具调用。用户消息以独立 `clientMessageId` 在会话锁内幂等,数字 `message.id` 只作后端定位;旧 OSS 消息允许缺失幂等键,早期用户消息字符串 `id` 在读取时迁入 `clientMessageId`。画布 Agent 只与任务侧栏互斥,不与左侧素材 / 图层栏互斥。 - 影响范围:图片画布右侧 Agent 面板、`shared-contracts` / `packages/shared` 的 `editorAgent` 契约、`spacetime-module` / `spacetime-client`、`platform-oss` 内部读签名边界、画布生成落板规则。 -- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo test -p module-editor-agent --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_agent`、前端 Agent 面板与 SSE client 定向测试、`npm run check:encoding`、`git diff --check`。 +- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo test -p module-editor-agent --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_agent`、前端 Agent 面板与 JSON client 定向测试、`npm run check:encoding`、`git diff --check`。 - 关联文档:`docs/【编辑器】画布Agent对话面板-2026-07-03.md`、`docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 ## 2026-07-01 认证工作集只经 typed projection 同步正式表 @@ -3256,6 +3287,13 @@ - 影响范围:`server-rs/crates/api-server/src/editor_generation_queue.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/api-server/src/character_animation_assets.rs`、`server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/services/image-editor/editorProjectClient.ts`。 - 验证方式:`cargo test -p api-server external_generation_worker --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server editor_canvas_generation --manifest-path server-rs/Cargo.toml`、`cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml`、`npm run test -- src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/services/image-editor/editorProjectClient.test.ts`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 +## 2026-07-13 画布 Agent 工具执行状态复用外部生成任务 + +- 决策:画布 Agent 的 OSS 工具消息使用 `status=not_completed|completed|failed|cancelled` 和可选 `externalJobId`;不使用 `cancelledAt`,不新增关联表。`external_generation_job` 是排队、执行、lease 与计费结算的唯一真相;OSS status 只表达该消息回填结果,不复制 queued / running。确认接口按 `conversationId + messageId + toolName` 稳定去重并复用既有编辑器 worker job kind。 +- 懒回填:`GET /conversation` 会在持有 conversation lock 后扫描 `status=not_completed` 且有 `externalJobId` 的工具消息,按 job id 定向读取主任务;完成时复用原工具 formatter 更新 system text、写入轻量媒体引用并标记 `completed`,任务本身失败时写入 `error` 并标记 `failed`。任务结果读取或 completed payload 解析 / formatter 首次失败后,在同一次 GET 内最多重试 3 次,每次等待 100ms 并重新读取主任务;读取失败或 completed 任务暂缺 `result_payload_json` 时,本次重试耗尽后保留 `not_completed + externalJobId` 供下次 GET 继续 reconcile,确定性的 payload 损坏、结构不兼容或 formatter 错误才在重试耗尽后写为 `failed`,避免致命错误永久循环。排队 / 执行保持 `not_completed`。worker 的 `result_payload_json` 只保留 formatter 与媒体引用所需的轻量生成回包,不向通用 summary 状态接口投影。 +- 影响范围:画布 Agent 共享契约、确认/取消接口、编辑器生成入队 helper、对话状态展示与恢复。 +- 验证方式:`cargo check -p api-server -p shared-contracts --manifest-path server-rs/Cargo.toml`、画布 Agent 定向前端测试、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + ## 2026-06-18 原生壳 WebView 刷新能力只保留受控当前页刷新 - 背景:Expo 移动壳和 Tauri 桌面壳都需要一个真实的宿主级刷新入口,供 H5 在检测到资源、登录态或运行态需要重新载入时请求宿主刷新当前容器;该能力不能演变成任意 URL 导航或原生 WebView ref 透传。 @@ -4078,7 +4116,7 @@ - 背景:VectorEngine Apifox `api-349239079` 暴露 OpenAI-compatible `POST /v1/chat/completions`;创意 Agent 和通用 LLM 代理需要统一到 VectorEngine 文本服务,并将默认文本模型切换为 `gpt-5.4-mini`。 - 决策:创意 Agent 的 `CREATIVE_AGENT_GPT5_MODEL` 固定为 `gpt-5.4-mini`,协议切到 Chat Completions,不再携带旧 APIMart `official_fallback` 字段;画布 Agent 侧边栏聊天规划请求也复用该模型和 Chat Completions 协议,不再显式使用 `gpt-4o` / Responses。通用 `/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` 时,api-server 可复用 `VECTOR_ENGINE_API_KEY`;前端 LLM 客户端必须兼容 OpenAI `choices`、api-server raw `{content}` 和项目 envelope `{ok,data:{content}}` 三种非流式响应,以及 OpenAI SSE delta 和 api-server `event: delta` 两种流式响应。 -- 决策补充:画布 Agent 的 planning prompt 必须自动注入上一条已完成生成结果的 `latestGeneratedImage`,来源为上一轮 generation 的 `summary` / `toolName` / `resourceId` / `objectKey` 等轻量摘要。用户用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代上一张图或继续编辑时,规划默认调用 `edit_image` 并引用该结果;不能因为本轮没有手动附件而退回 `generate_image`。 +- 决策补充:画布 Agent 的 planning prompt 必须自动注入上一条已完成生成结果的 `latestGeneratedImage`,来源为上一轮 generation 的 `toolName` / `resourceId` / `objectKey` 等轻量元数据。用户用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代上一张图或继续编辑时,规划默认调用 `edit_image` 并引用该结果;不能因为本轮没有手动附件而退回 `generate_image`。 - 决策补充:画布 Agent 侧边栏的“规范图 / 视觉规范图 / 风格规范图 / 素材规范展板”是 Agent 规划 prompt 和 function-calling 工具选择约束,不是侧边栏 UI 说明文案。此类请求默认走 `generate_image`,prompt 必须要求规范展板包含统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等视觉规范元素;角色规范图若是规范展板也走 `generate_image`,只有实际角色立绘才走 `generate_character`,多个图标素材 / 图集才走 `generate_icon_spritesheet`。 - 影响范围:`server-rs/crates/platform-agent`、`server-rs/crates/api-server/src/config.rs`、`src/services/llmClient.ts`、`.env.example`、`deploy/env/api-server.env.example`、`scripts/test-ve-llm.mjs`。 - 验证方式:`npm run test -- src/services/llmClient.test.ts`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml from_env_reads_non_public_models_and_urls app_state_builds_creative_agent_gpt5_client_from_vector_engine_settings llm_chat_completions editor_agent_llm_request_uses_vector_engine_chat_model`、`cargo test -p platform-agent --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。 @@ -4230,3 +4268,9 @@ - 就绪边界:HTTP `/readyz` 同时验证调用池握手和缓存读连接;required subscription 失败必须不就绪。非 HTTP worker / controller 不创建缓存读连接,继续使用 1 条调用连接和各自的队列窄订阅。 - 运维口径:`GENARRATIVE_SPACETIME_POOL_SIZE=8` 表示 8 条调用连接,HTTP 基础拓扑另加 1 条缓存读连接;外部生成和充值过期监听的独立窄订阅不计入该值。读模型行缓存从 8 份降为 1 份,但 SDK 空 table metadata、8 条调用 socket 和 runner 仍存在,不承诺总 RSS 等比例降为八分之一。 - 验证方式:`cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`;发布后在 8 个调用槽暖机后对比 api-server cgroup memory / PSS,并确认 `/readyz` 与代表性 gallery、公开详情、创作入口和用户标签读取正常。 + +## 2026-07-17 旧创作模板与入口停止维护 + +- 决策:旧创作模板、旧创作入口及其专属运行服务进入下线范围,不再为跳一跳、抓大鹅 Match3D、儿童动作 Demo 等旧链路修复兼容问题、补生成脚本或维持专属门禁。 +- 边界:共享账号、钱包、资产、图片编辑器、公开作品、通用 HostBridge、API、SpacetimeDB、发布运维和安全能力不属于旧链路,仍需维持正式门禁。历史文档只作为背景材料,不再作为旧入口继续运行的依据。 +- 清理方式:允许直接删除已经失效的旧素材生成命令、入口路由、专属服务和对应测试;删除工程链路时仍需核对是否被当前共享能力引用,不能连带移除仍在使用的公共契约或持久化事实。 diff --git a/docs/project-memory/shared-memory/document-map.md b/docs/project-memory/shared-memory/document-map.md index 974e0d77c..8aa4b638f 100644 --- a/docs/project-memory/shared-memory/document-map.md +++ b/docs/project-memory/shared-memory/document-map.md @@ -1,6 +1,6 @@ # 文档地图与阅读索引 -更新时间:`2026-07-03` +更新时间:`2026-07-16` ## 当前文档入口 @@ -18,7 +18,7 @@ | 微信小程序虚拟支付 | `docs/【技术方案】微信虚拟支付接入-2026-05-26.md` | | UI 像素资产与 9-slice 规范 | `UI_CODING_STANDARD.md` | | 图片画布生成面板与模型定价 | `docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/【编辑器】模型定价配置管理方案-2026-06-22.md` | -| 图片画布右侧 Agent 对话、会话消息 OSS 持久化、SSE 事件契约、与任务侧栏 / 左侧栏互斥规则 | `docs/【编辑器】画布Agent对话面板-2026-07-03.md`、`docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md` | +| 图片画布右侧 Agent 对话、会话消息 OSS 持久化、普通 JSON 消息、工具任务懒回填及侧栏并存规则 | `docs/【编辑器】画布Agent对话面板-2026-07-03.md`、`docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md` | ## 阅读顺序 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 4dc46c268..2b91e6ed7 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -240,21 +240,21 @@ - 验证:`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx --reporter verbose`。 - 关联:`src/components/image-editor/ImageCanvasEditorModel.ts`、`src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx`、`src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`。 -## 图片画布快速编辑不要直接提交普通图片 URL +## 图片画布生成请求不要直接提交临时媒体源 -- 现象:图片画布快速编辑站内示例图、历史 generated 图或 OSS generated 图时,后端返回 `修改图片参考图必须是图片 Data URL。`。 -- 原因:快速编辑直接把图层 `src` 塞进 `/api/editor/images/generations` 的 `referenceImageSrcs`;默认示例图和部分持久化图层的 `src` 是 `/creation-type-references/*.webp`、`/generated-*` 或 OSS URL,而 `api-server` 的编辑参考图解析只接收 `data:image/*;base64,...`。 -- 处理:前端统一通过 `resolveEditorImageReferenceDataUrl(...)` 在提交前读取图片字节并转成图片 Data URL;Data URL 原样透传,`/generated-*` 和 generated OSS URL 先走 `/api/assets/read-url` 换签后由浏览器直读 OSS,直读失败时才 fallback 到 `/api/assets/read-bytes`,普通 public 路径直接 fetch。 -- 验证:`npm run test -- src/services/image-editor/editorImageReference.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx -t "editorImageReference|converts non-data-url quick edit source images before submitting references"`。 -- 关联:`src/services/image-editor/editorImageReference.ts`、`src/components/image-editor/ImageCanvasEditorView.tsx`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 +- 现象:图片画布快速编辑、参考生成、去背景或角色动画提交 Data URL / Blob URL 时,前端或后端返回“必须先上传 OSS”。 +- 原因:Data URL / Blob URL 体积大且不能作为持久引用;如果写入外部生成队列,请求体会膨胀,worker 也无法稳定复用浏览器临时资源。 +- 处理:提交前优先复用图层已有 `objectKey`、项目资源 ID 或素材 ID;未登记的本地图片和普通 public 图片路径先通过 `resolveEditorGenerationMediaReference(...)` 读取并上传 OSS,再把稳定引用交给生成接口。Data URL 只允许用于浏览器内压缩、标注等临时处理,不能进入 API 请求、队列载荷或项目持久化。 +- 验证:`npm run test -- src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx`;`cargo test -p api-server inline_data_url --manifest-path server-rs/Cargo.toml`。 +- 关联:`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/services/image-editor/editorProjectClient.ts`、`server-rs/crates/api-server/src/editor_generation_queue.rs`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 -## 图片编辑器角色动画不要默认提交大图 Data URL +## 图片编辑器角色动画必须提交稳定图片引用 -- 现象:图片编辑器里对角色图点击 `生成动画` 后,后端返回 `Failed to buffer the request body: length limit exceeded`,请求还没进入角色动画 handler。 -- 原因:角色动画生成请求曾把角色图片 `src` 原样作为 `sourceImageSrc` 放进 JSON;角色图如果是较大的 Data URL,会超过 Axum 默认 `2MB` body limit,在 `Json` 提取器阶段被拦截。 -- 处理:前端在角色图已持久化时优先提交 `objectKey`,只把 Data URL 作为未持久化本地临时图兜底;后端 `/api/editor/character-animations/generations` 单独配置 `12MB` body limit 兼容旧请求,但新链路不应依赖传大图 JSON。 -- 验证:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "only exposes character animation"`;`cargo test -p api-server editor_character_animation_accepts_character_image_body_above_default_limit --manifest-path server-rs/Cargo.toml`。 -- 关联:`src/components/image-editor/ImageCanvasEditorView.tsx`、`server-rs/crates/api-server/src/modules/play_flow.rs`、`server-rs/crates/api-server/src/app.rs`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。 +- 现象:图片编辑器里对尚未上传的角色图点击 `生成动画` 后,前端或后端返回 `sourceImageSrc 必须先上传 OSS`。 +- 原因:角色动画会进入外部生成队列,浏览器 Data URL / Blob URL 既不适合持久任务,也会放大 JSON 请求体。 +- 处理:前端统一通过 `resolveEditorGenerationMediaReference(...)` 取得 `objectKey` 或画板资源引用;本地临时角色图必须先上传 OSS。后端在入队前同步拒绝内联媒体,不再通过放宽 body limit 兼容 Data URL。 +- 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx`;`cargo test -p api-server editor_character_animation --manifest-path server-rs/Cargo.toml`。 +- 关联:`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`server-rs/crates/api-server/src/character_animation_assets.rs`、`server-rs/crates/api-server/src/app.rs`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。 ## 图片编辑器角色动画抽帧不要采到视频尾点 @@ -328,11 +328,11 @@ - 验证:`npm run test -- src/services/assetReadUrlService.test.ts --reporter verbose` 应覆盖大量不同 objectKey 同时换签时首批限量放行、后续按间隔派发;发布现场同类页面刷新时,Nginx `GET /api/assets/read-url` 429 应从 `upstream_status=-` / `genarrative_api_rps` 收敛。 - 关联:`src/services/assetReadUrlService.ts`、`src/hooks/useResolvedAssetReadUrl.ts`、`src/components/ResolvedAssetImage.tsx`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 -## 图片编辑器 Seedance 2.0 参考媒体不要提交视频 Data URL +## 图片编辑器 Seedance 2.0 参考媒体只提交稳定引用 - 现象:画板生成视频选择 Seedance 2.0 并上传参考视频后,请求体暴涨、可能返回 `413` 或上游拒绝 `video_url.url`;文档示例或测试如果写 `data:video/mp4;base64,...`,后续实现很容易照抄。 -- 原因:火山 Seedance 2.0 参考视频只支持公网 URL 或 `asset://` 素材 ID,项目内画板资源应以 `objectKey` 由后端换签;视频不支持 Base64 / `data:video`,且 50MB 视频转 Base64 后会逼近或超过 64MB 请求体上限。参考音频虽然支持 Base64,但也不能单独输入,且大文件同样不应塞进 JSON。 -- 处理:参考视频 / 音频上传先走 `/api/assets/direct-upload-tickets` 直传 OSS,再 `/api/assets/objects/confirm` 确认;前端保留 signed URL 做预览,提交生成时优先使用 `objectKey`。后端归一化必须拒绝 `data:video/*`,非 Seedance 模型携带参考字段也必须拒绝;Ark body 显式带 `generate_audio:false`。 +- 原因:画板生成会进入持久队列,Base64 / Data URL / Blob URL 会放大请求和任务 JSON;直接签名客户端给出的 objectKey 又会绕过跨账号素材归属校验。项目资源 ID、素材 ID 和 objectKey 必须先解析到当前 owner 的正式对象,公网 URL 与 `asset://` 才能按供应商契约直接透传。 +- 处理:本地参考图片 / 视频 / 音频先走 `/api/assets/direct-upload-tickets` 直传 OSS,再 `/api/assets/objects/confirm` 确认;前端保留 signed URL 做预览,提交生成时使用 `objectKey`、项目资源 ID 或素材 ID。后端归一化拒绝全部 `data:*` / `blob:*`,签名 OSS URL 前统一校验 owner;稳定引用字段总长度限制为 `256KB`,非 Seedance 模型携带参考字段也必须拒绝;Ark body 显式带 `generate_audio:false`。 - 验证:`npx vitest run src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/services/image-editor/editorReferenceUploadClient.test.ts --reporter verbose`;`cargo test -p api-server editor_video --manifest-path server-rs/Cargo.toml`;`cargo test -p shared-contracts editor_video_request_supports_seedance_multimodal_references --manifest-path server-rs/Cargo.toml`。 - 关联:`src/services/image-editor/editorReferenceUploadClient.ts`、`src/components/image-editor/useImageCanvasUploadWorkflow.ts`、`src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts`、`server-rs/crates/api-server/src/character_animation_assets.rs`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。 @@ -3149,3 +3149,11 @@ - 处理:latest snapshot 必须是未锁定且存在同 offset `.snapshot_bsatn` 的完整目录,空目录或同名 `.lock` 存在时忽略。每个 replica 独立保留 `max(segment_start <= latest_snapshot)` 及全部后缀,只处理更早 segment 对;旧 snapshot 只保留最新一个。`--storage-format files` 必须先发布完整 full catalog;history 对每个候选文件 CAS 对象、history catalog 和 full catalog 执行 HEAD 长度/SHA 验真,再复算边界与 stat fingerprint,最后发布并验真固定 `latest.json`;pointer 失败时不得推进 state 或删除源文件。不要把在线逐文件 full 扫描当成跨文件一致备份,基线必须来自停库目录或已验证冻结副本。SSH 或工具超时后先检查 work-dir PID lock 与原进程,不要直接并发重跑;不要在 history 模式传 `--stop-service`。定时任务通过 Server-Provision 的显式 profile 和仓库 drop-in 管理,启用前 dry-run 验证 baseline,切回 archive 时同时移除托管与现场遗留 drop-in;不要在 `/etc/systemd/system` 长期保留手写覆盖,release 必须建立和验证自己的 full baseline 与 work-dir,不能直接复用 dev 的本地 state。 - 验证:dry-run 输出 replica 的 `latestSnapshot`、`boundarySegment` 和候选清单;从另一台机器仅凭 OSS `latest.json` 自动定位 full catalog,创建目录、下载文件并逐项校验长度/SHA,启动隔离 data-dir 验证 `/v1/ping`、snapshot restore、commitlog replay、module launch、代表性 SQL 与 reducer。 - 关联:`scripts/database-backup-to-oss.mjs`、`scripts/check-database-backup-to-oss.mjs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## Procedure 事务鉴权要兼容 SpacetimeDB 2.6.0 的匿名 TxContext + +- 现象:api-server 的 SpacetimeDB token identity 与 `editor_generation_pricing_config.writer_identity` 完全一致,服务启动门禁也通过,但手动拆分图集调用 `find_editor_asset_group_source_and_return` 仍返回“当前 identity 无权调用模型生成运行时服务”。 +- 原因:SpacetimeDB 2.6.0 的 `ProcedureContext::try_with_tx` 使用内部匿名事务上下文,闭包参数 `TxContext` 解引用得到的 `ReducerContext::sender()` 固定为 `Identity::ZERO`,不是 procedure 外层调用者。把 runtime writer 鉴权写成 `require_*(tx, tx.sender())` 会稳定误拒绝合法服务 identity。SpacetimeDB 2.6.1 已修复为向事务透传 procedure caller,但尚未升级的 dev / release 模块仍受 2.6.0 行为影响。 +- 处理:在调用 `try_with_tx` 前通过 `let caller = ctx.sender()` 捕获真实调用者,再把 `caller` 显式传入事务函数;迁移、后台账号、runtime profile、外部生成等现有 procedure 已采用这一模式。`npm run check:spacetime-runtime-access` 禁止编辑器 runtime writer 鉴权重新直接读取事务 `ctx.sender()`。运行环境升级到 2.6.1 后继续保留显式 caller,避免鉴权依赖 SDK 版本细节。 +- 验证:运行 `npm run check:spacetime-runtime-access`、`cargo test -p spacetime-module --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`;发布使用 SpacetimeDB 2.6.1 构建的新模块后,以 runtime writer identity 重试 `POST /api/editor/icon-spritesheets/slices`,确认来源查询、分片素材写入与 cohort 完成不再返回 identity 403。 +- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`scripts/check-spacetime-runtime-access.mjs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 diff --git a/docs/project-memory/shared-memory/project-overview.md b/docs/project-memory/shared-memory/project-overview.md index c38f161ff..bd97b320f 100644 --- a/docs/project-memory/shared-memory/project-overview.md +++ b/docs/project-memory/shared-memory/project-overview.md @@ -1,6 +1,6 @@ # Genarrative 项目共享概览 -更新时间:`2026-06-23` +更新时间:`2026-07-17` ## 一句话定位 @@ -36,7 +36,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台,把 A server-rs + Axum + SpacetimeDB ``` -当前 SpacetimeDB crate、SDK、CLI / standalone、生成 bindings 和容器压测镜像统一按 `2.6.0` 对齐;遇到版本不匹配时先升级到 `server-rs/Cargo.toml` 锁定版本,升级后重启对应 SpacetimeDB 进程再重试。 +当前 SpacetimeDB crate、SDK、CLI / standalone、生成 bindings 和容器压测镜像统一按 `2.6.1` 对齐;遇到版本不匹配时先升级到 `server-rs/Cargo.toml` 锁定版本,升级后重启对应 SpacetimeDB 进程再重试。 职责边界: diff --git a/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md b/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md index 567120384..e6198b20e 100644 --- a/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md +++ b/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md @@ -1,6 +1,6 @@ # SSE 客户端传输层收口约定 -更新时间:`2026-06-03` +更新时间:`2026-07-16` ## 背景 @@ -13,7 +13,6 @@ - `readSseStream` 负责读取 `Response.body`、识别 `\n\n` 与 `\r\n\r\n` 事件边界、合并多行 `data:`、flush `TextDecoder` 尾部缓冲,并支持事件处理函数返回 `false` 后取消 reader。 - `readSseJsonStream` 只在传输事件基础上解析 JSON object,空 data 与异常 JSON 继续按旧口径静默跳过。 - 各业务 client 只保留领域事件归一化、最终结果聚合和中文错误文案,不再重复实现 SSE 边界扫描、reader 循环或 UTF-8 flush。 -- `src/services/image-editor/editorAgentSse.ts` 消费画布 Agent 的 JSON SSE,只校验并归一 `stage`、`message_delta`、`tool_started`、`tool_completed`、`generation_result`、`error`、`done` 事件,不持有 reader 循环或事件边界解析。 - OpenAI 兼容流、`[DONE]` 哨兵或其它非 JSON SSE 可直接使用 `readSseStream`;业务 JSON 事件优先使用 `readSseJsonStream`。 ## 落地范围 @@ -26,27 +25,15 @@ - `src/services/visual-novel-runtime/visualNovelRuntimeSse.ts` - `src/services/rpg-entry/rpgProfileClient.ts` - `src/services/llmClient.ts` -- `src/services/image-editor/editorAgentSse.ts` 后续新增 SSE client 时不得复制 `findSseEventBoundary`、`parseSseEventBlock` 或手写 reader 循环;若确实需要特殊 framing,应先扩展 `sseStream.ts` 的传输能力,再在业务 client 中处理领域语义。 -## 画布 Agent SSE client 口径 - -`src/services/image-editor/editorAgentSse.ts` 是画布 Agent 的业务事件归一层,只允许做以下事情: - -- 调用 `readSseJsonStream(response, handler)` 读取 JSON SSE。 -- 白名单识别 `stage`、`message_delta`、`tool_started`、`tool_completed`、`generation_result`、`error`、`done`。 -- 支持两种兼容输入形态:优先使用 SSE `event:` 字段作为事件名;若 `data` 内含 `{ "event": "...", "data": {...} }`,则归一为同一 `EditorAgentSseEvent`。 -- 对未知事件名、缺失 `data` 或非对象 JSON 静默跳过,保持业务 client 的容错口径。 - -`editorAgentSse.ts` 不得实现 `Response.body.getReader()`、`TextDecoder`、SSE 边界扫描、JSON 字符串拼接、fetch、鉴权、错误文案、会话状态聚合或画布聚焦逻辑;这些职责分别留在 `sseStream.ts`、`editorAgentClient.ts`、`useEditorAgentConversation.ts` 和画布视图层。 +画布 Agent 已改为 `POST /api/editor/agent-conversations/{conversationId}/messages` 普通 JSON 请求,不属于本 SSE 传输层的落地范围;其客户端只通过 `requestJson` 读取 `EditorAgentMessageResponse`,不得为了恢复旧文档口径重新增加私有 SSE parser。 ## 验收 - `src/services/sseStream.test.ts` 覆盖 CRLF / LF 边界、UTF-8 尾部 flush、异常 JSON 跳过和提前停止取消 reader。 - `src/services/llmClient.test.ts` 覆盖 OpenAI 兼容文本流、异常 JSON 跳过和 `[DONE]` 后提前停止。 -- `src/services/image-editor/editorAgentSse.test.ts` 覆盖画布 Agent 的 `stage`、`generation_result`、`done` 事件解析,以及 `{ event, data }` fallback 形态归一。 -- `src/services/image-editor/editorAgentClient.test.ts` 覆盖会话 CRUD 路由和 `/messages/stream` 发起后能收到类型化 SSE 事件。 -- 后续修改 `editorAgentSse.ts` 时,若出现手写 reader、`TextDecoder`、SSE 边界扫描或 fetch 逻辑,应视为违反本收口约定。 +- `src/services/image-editor/editorAgentClient.test.ts` 覆盖会话 CRUD 和 `/messages` 普通 JSON 路由;画布 Agent 不纳入 SSE parser 验收。 - 已有 OpenAI 兼容文本流、NPC 聊天流、创作 Agent、创意互动 Agent、视觉小说运行态和充值订单状态测试继续通过。 - `npm run typecheck` 不产生新的类型错误。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 34e73a063..a79ad9735 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -20,15 +20,15 @@ - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。重绘 / 改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中的后端拼接 Prompt、固定生成模板或模型默认提示词。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。 -- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片源读取为图片 Data URL;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 -- 图片画布抠图统一使用 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`,默认请求超时 `180000ms`(BgFilter CPU 推理)。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;worker 校验已有 OSS object key 归属后直接签发 600 秒 URL,固定提交 `image_url`、`background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不下载原图,也不提交 `file` 或 `screen_color`。首次请求失败后立即重试 `1` 次,两次都失败则返回最终错误;manual complex 不接入依赖纯色键值的阿里云 / 本地键色降级链,也不改变 flat 链路的熔断状态。手动与标准纯色背景两类模式共用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 和共享 HTTP client;BgFilter token 未配置时只兼容回退读取旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。所有令牌都只在服务端注入,前端不持有令牌。worker 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object;接口只返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 +- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端优先复用当前图层 objectKey;尚未登记的本地图片先上传 OSS,再把 objectKey 交给同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片解析为已上传的 objectKey 或资源 ID;浏览器临时图片需先上传 OSS;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 +- 图片画布抠图统一使用 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`,默认请求超时 `180000ms`(BgFilter CPU 推理)。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;API 在入队前拒绝 `data:` / `blob:` 内联媒体,worker 将稳定引用解析为当前账号已登记的 OSS object key 并校验归属后签发 600 秒 URL,固定提交 `image_url`、`background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不下载原图,也不提交 `file` 或 `screen_color`。首次请求失败后立即重试 `1` 次,两次都失败则返回最终错误;manual complex 不接入依赖纯色键值的阿里云 / 本地键色降级链,也不改变 flat 链路的熔断状态。手动与标准纯色背景两类模式共用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 和共享 HTTP client;BgFilter token 未配置时只兼容回退读取旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。所有令牌都只在服务端注入,前端不持有令牌。worker 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object;接口只返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 - 编辑器自己生成的标准纯色背景抠图资产在保存源图后统一调用 BgFilter `background_mode=flat`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的前端用户路径都固定把 `screenColor=auto` 注入请求体,但用户可见 `generationInputs.fields` 不再记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和 BgFilter `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。四条 flat 路径同时把默认 `segModel=birefnet` 传为 `seg_model`,并显式传 `cross_check`:角色形象生成和角色动作逐帧去背传 `on`,图标 spritesheet 和 UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交 `seg_model`、`background_mode` 或 `cross_check`。flat 请求首次失败后立即重试 `1` 次;第二次仍失败、返回非成功状态、空图片或非法图片时,以及连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒熔断期,api-server 都先调用阿里云通用抠图,只有阿里云失败才用本地 `editor_green_screen` 按同一 `screenColor` 兜底去背。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `BgFilter(background_mode=flat,cross_check=on)→ 阿里云 → 本地键色` 链路。 - 角色动作逐帧抠图在 api-server 内复用共享 BgFilter HTTP Client;每一次 HTTP attempt 使用“`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`,角色形象单图、图标、UI 和手动去背景仍使用基准值。该值是单个请求从发起到响应体读取完成的 timeout,不是整批帧或整项角色动作任务超时;单帧首次失败立即重试 `1` 次并重新签发 OSS URL、重新计时,第二次仍失败才按 object key 进入阿里云/本地降级链,整项任务另受 worker long-job 预算约束。全部 `32 / 40 / 48` 帧按“对应绿幕源图上传 OSS 并释放原帧字节 → 以签名 URL 调 BgFilter/按 object key 降级 → 透明帧落 OSS”连续加入无序在途流水线,允许响应乱序完成并在最终返回前按 `frameIndex` 恢复顺序;任一帧最终失败时仍排空全部已启动请求,整个动作任务失败退款,不发布缺帧动画。 - 多产物生成以后端项目快照为唯一画布真相:同一任务实际产生的原始产物、抠图 / 透明化结果和拆分结果都要先登记为 `editor_project_resource`,再通过一次 `canvasCompletion` 原子写入画布。角色形象、图标 spritesheet 和 UI 素材提取的纯色背景原图不能只留在 OSS;透明后处理成功时,处理结果保持主图层和 `generatedLayerId` 锚点,原图及其它附属产物从主结果右侧开始错开放置;透明背景处理最终失败时,只把已保存的原图作为主图完成占位,不放透明处理图,图标和 UI 不继续拆分。source-only fallback 的前端只消费后端返回的 `project` / `resource` 快照,不按缺失字段自行构造透明图、切片或图层;任务以 `completed + warning` 收口。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。通用 `warning.reason` 是可直接展示的完整原因,并优先于 `sliceWarning`;既有 `sliceWarning.reason` 只表示透明图成功后的自动拆分失败,保留后端原始诊断,inline 前端仅在展示时补充“图集已生成,但自动拆分未完成:”提示,queue worker 则把它归一为 BFF `warning` 字符串后由前端直接展示。无项目上下文时不创建项目资源或画布图层。 - 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。完整图标图集 `icon-spritesheet` 支持快速编辑,拆分后的单个 `icon` 不提供该入口,前后端必须使用同一素材类型规则。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并在错误红框中显示具体错误文案。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 - 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。进行中阶段只使用外部生成 BFF 返回的 `phaseDetail`:调用或等待图片 / 视频生成服务时显示“正在生成”,进入 BgFilter、逐帧抠图或手动去背景时显示“正在处理”;前端不得按耗时或任务类型猜测阶段。 -- 画布底部工具栏 / 面板 Dock 提供“画布 Agent”入口。点击后打开右侧独立 Agent 对话面板;桌面端为右侧窄面板,移动端占满可用宽度。该面板与素材侧栏、图层侧栏、右上角任务侧栏互斥,打开 Agent 时必须收起其它侧栏,打开其它侧栏或任务侧栏时也必须收起 Agent。Agent 面板不得在当前画布内容下方追加内联内容,也不默认展示大段功能说明文案。 +- 画布底部工具栏 / 面板 Dock 提供“画布 Agent”入口。点击后打开右侧独立 Agent 对话面板;桌面端为右侧窄面板,移动端占满可用宽度。该面板只与右上角任务侧栏互斥;素材 / 图层侧栏允许与 Agent 同时展开,切换左侧栏不得关闭 Agent。Agent 面板不得在当前画布内容下方追加内联内容,也不默认展示大段功能说明文案。 - 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标或原图右侧固定偏移。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频`、`生成UI设计图` 和 `生成角色动作`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变;图片快速编辑不属于新建占位入口,提交后覆盖源图。 ## 交互规则 @@ -55,11 +55,11 @@ - `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind`、`generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId;图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。图片生成请求如果同时携带 `canvasCompletion`(生成器 `dialogId`、标题和占位框,或无 dialog 的右侧完成占位),BFF / worker 在生成成功后必须直接读取当前项目布局,优先使用最新 `generation-dialog` 占位框位置;只有当前布局仍存在对应 `generation-dialog` 时才插入轻量结果图层、把生成器标记为 `idle` 并写入 `generatedLayerId`,沿用后端当前 viewport 保存布局,再返回或刷新最新项目快照;前端只应用该快照刷新显示,不把生成完成态作为本地业务真相,也不在项目加载时根据资源行推断完成态。有项目上下文但后端没有返回项目快照时,前端不得本地补结果图层,只保留当前生成器交互状态等待下一次项目刷新。 - 项目封面图是画布当前视口栅格化后的静态快照资源,不在项目列表页临时重放 `layers + viewport`。前端在项目加载后和防抖保存 layout 时生成 320x240 PNG,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`。`/project` 与 `/creation` 最近项目卡只读取最新封面快照资源渲染;没有封面快照时显示普通项目占位,不回退为实时画布组合。 - 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。`/api/assets/read-url` 属于页面展示层高频后台请求,前端统一在 `assetReadUrlService` 内做同 key pending 去重、session 缓存和跨组件节流;UI 设计切片、角色动画帧或大量素材恢复时不得绕过该服务并发换签,否则单页可在同一秒内打满发布入口 `genarrative_api_rps` burst。 -- 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 canvas 本地渲染 PNG,也必须先上传 OSS / asset object 并创建 `editor_project_resource`,再把带正式 `resourceId/objectKey/assetObjectId` 的裁扩图层加入画布;不能先把 `local-resource-*` + Data URL 图层交给项目保存或后续去背景。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态,生成请求仍使用临时状态中的图片源或 `objectKey`。 -- 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源重绘也不再从 `generationInputs.fields` 恢复 `抠图背景色` 或 `抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口所需的图片 Data URL、signed URL 或 `objectKey` 只存在于提交前的临时参考图状态和请求体字段,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 +- 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 canvas 本地渲染 PNG,也必须先上传 OSS / asset object 并创建 `editor_project_resource`,再把带正式 `resourceId/objectKey/assetObjectId` 的裁扩图层加入画布;不能先把 `local-resource-*` + Data URL 图层交给项目保存或后续去背景。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态;生成请求提交前必须把临时状态解析成 `objectKey`、项目资源 ID 或素材 ID,未登记的本地图片和普通图片路径先上传 OSS,不能直接提交 Data URL、Blob URL 或临时图片源。 +- 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源重绘也不再从 `generationInputs.fields` 恢复 `抠图背景色` 或 `抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口只接收提交前临时状态解析出的 `objectKey` 或资源 ID;Data URL、Blob URL 和 signed URL 不进入请求体,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 - 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。角色、图标图集、UI 提取和角色动作等多产物任务把实际产生的 provider 原始输出及后处理结果分别入库:所有条目沿用 `character`、`icon-spritesheet`、`character-animation` 等真实类型,provider 原始输出承载任务模型成本,后处理派生产物阶段成本为 0。后台素材查询以最终产物为父行、每个中间产物为可展开的独立子行,分页只计算父任务;手动重拆图集保留独立 `taskId` 用于存储隔离和日志排障,通过私有 provenance 从服务端生成账号素材的 source resource、asset object 或 Object Key 取得可信来源任务,并把它写入 `groupTaskId`,不信任客户端可提交的 resource `taskId/assetKind`;跨项目复用后仍可通过稳定媒体引用找回来源。没有可信来源的新拆分显式归到自身任务,不走历史资源链回溯。每个手动切片同时写入 `groupTaskExpectedAssetCount`,全部切片落库后写独立 cohort 完成事实;后台 read model 只让同一根任务的一个已完成拆分批次并入原图集父项,用户后来删除单片不会让批次脱组,部分失败批次和后续重复拆分批次按各自真实任务分页,避免残缺批次抢占根任务、单组无限增长或素材丢失。历史行在项目资源仍存在时兼容回溯,删除项目资源前只固化直接受影响行的真实来源字段,有界展示 ID 不反写数据库。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。 - 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。内部命名契约仍使用可选 `assetLabel`,最大 80 字符并在提交时 trim;历史状态或内部调用携带非空名称时,同一个名称必须贯穿 `assetLabel`、`canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样兼容该字段,中间原图使用主名称加固定后缀,拆分素材继续按素材描述命名。 -- 画布 Agent 会话按“SpacetimeDB 元数据 + OSS 消息正文”存储:`editor_agent_conversation` 只保存 `conversationId/projectId/ownerUserId/title/messagesObjectKey/deleted/createdAt/updatedAt` 等会话元数据;消息正文整体保存为私有 OSS JSON 文档 `editor-agent/{conversationId}.json`。消息文档单对象上限为 2 MiB,同一会话的消息追加和 SSE 最终写回由 api-server 按 `conversationId` 串行化,避免“读 OSS → 改消息 → 写 OSS”并发覆盖。前端只通过 api-server BFF 读取和发送会话,不直接读写 SpacetimeDB,也不直接读写 OSS。 +- 画布 Agent 会话按“SpacetimeDB 元数据 + OSS 消息正文”存储:`editor_agent_conversation` 只保存 `conversationId/projectId/ownerUserId/title/messagesObjectKey/deleted/createdAt/updatedAt` 等会话元数据;消息正文整体保存为私有 OSS JSON 文档 `editor-agent/{conversationId}.json`。消息文档单对象上限为 2 MiB,同一会话的消息追加和工具结果回填由 api-server 按 `conversationId` 串行化,避免“读 OSS → 改消息 → 写 OSS”并发覆盖。前端只通过 api-server BFF 读取和发送会话,不直接读写 SpacetimeDB,也不直接读写 OSS。 - Agent 消息附件只允许引用当前工程画布资源或账号素材库图片,来源类型为 `canvas_resource` / `library_asset`,最多 9 张。附件请求可携带展示用 `imageSrc/thumbnailSrc/objectKey/width/height/label`,但持久化真相仍以后端校验后的 resource / asset 行和 OSS 对象为准;不得把 Data URL、signed URL 或 blob URL 当作会话长期事实。 - 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。 - 工程刷新恢复可先应用 session 级轻量项目快照缓存,让画布和素材 chrome 尽快显示;缓存快照必须排除 `data:*` / `blob:` 内联媒体,且在后端项目快照返回前不得触发自动保存。后端快照回来后覆盖本地缓存显示并恢复正常保存队列。 @@ -79,7 +79,7 @@ - `POST /api/editor/projects/{projectId}/agent-conversations`:在当前工程下创建画布 Agent 会话;可选传入标题,默认标题为“新对话”。 - `GET /api/editor/agent-conversations/{conversationId}`:读取指定画布 Agent 会话详情,返回会话摘要和 OSS 消息正文中的消息列表。 - `DELETE /api/editor/agent-conversations/{conversationId}`:软删除指定画布 Agent 会话,并返回删除后的会话摘要。 -- `POST /api/editor/agent-conversations/{conversationId}/messages/stream`:发送画布 Agent 消息并返回 JSON SSE。请求体包含 `clientMessageId`、`text` 和可选 `attachments`;文本与附件不可同时为空。SSE 事件名固定为 `stage`、`message_delta`、`tool_started`、`tool_completed`、`generation_result`、`error`、`done`。生成结果中的图片通过后端写入 project resource / 账号素材后返回轻量快照,前端只消费返回的资源引用并刷新 / 聚焦画布。工具失败时也必须保留失败 generation record,不得只返回一次性错误。 +- `POST /api/editor/agent-conversations/{conversationId}/messages`:发送画布 Agent 消息并返回普通 JSON `EditorAgentMessageResponse`。请求体包含 `clientMessageId`、`text` 和可选 `attachments`;文本与附件不可同时为空,同一会话重复 `clientMessageId` 必须幂等返回或拒绝重复追加。响应包含权威会话摘要、`deltaMessages` 和可选 `errorMessage`。LLM / 规划失败写入 `role=system`、正文以 `ERROR ` 开头的 OSS 消息并放入 `deltaMessages`,不再重复设置 `errorMessage`;前端隐藏前缀后显示红色错误气泡。工具失败继续保存工具状态和错误信息。 - `GET /api/editor/assets/library`:读取当前账号的素材文件夹和素材。首次读取时自动创建“项目素材”默认文件夹。 - `POST /api/editor/assets/folders`:新建素材文件夹。 - `PATCH /api/editor/assets/folders/{folderId}`:重命名、折叠 / 展开素材文件夹。 @@ -103,8 +103,7 @@ - 示例素材可继续复用 `public/creation-type-references/` 下的站内图片;用户上传和后续生成资源必须通过资源记录表达。 - 不把 hover、dragging、临时吸附线、Space 临时抓手等瞬时 UI 状态写入后端。 - 不在 UI 中加入大段功能说明,编辑器界面只展示必要的工具、素材和状态信息。 -- 画布 Agent 前端分层固定为:`editorAgentClient.ts` 负责 BFF 请求、鉴权错误和 stream 发起;`editorAgentSse.ts` 只负责 SSE JSON 事件归一;`useEditorAgentConversation.ts` 负责会话列表、当前会话、消息流、阶段和停止状态;`EditorAgentConversationPanelView.tsx` 只负责右侧面板展示与交互。 -- `editorAgentSse.ts` 必须复用 `src/services/sseStream.ts` 的 `readSseJsonStream`,不得复制 SSE reader、`TextDecoder`、事件边界扫描或 UTF-8 flush 逻辑;该文件不得承接 fetch、鉴权、会话聚合、画布聚焦或 UI 错误文案。 +- 画布 Agent 前端分层固定为:`editorAgentClient.ts` 负责普通 JSON BFF 请求、鉴权和错误映射;`useEditorAgentConversation.ts` 负责会话列表、当前会话、消息请求等待态、工具任务状态和客户端取消等待;`EditorAgentConversationPanelView.tsx` 只负责右侧面板展示与交互。画布 Agent 不新增私有 SSE parser。 - 不复用或改写 `CreativeImageInputPanel` 的单图资产编辑语义;`/editor/canvas` 是独立图片画布工程的画布入口。 ## 验收用例 @@ -124,15 +123,15 @@ - 快速编辑面板底部只显示模型选择和 `修改` 按钮;打开时视口聚焦必须预留底部面板空间,面板位于素材下方,不得遮挡原素材,且素材在当前屏幕内完整可见。快速编辑请求只把原图或红框序号标注图作为 `sourceImageSrc` 直接提交,信息面板输入快照只展示用户填写的快速编辑提示词。 - 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。 - 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图行引用,不包含后端拼接 Prompt,不再展示独立 Size 字段,也不渲染参考图 Data URL 缩略图。 -- 点击底部 Dock 的“画布 Agent”后,右侧独立 Agent 面板打开;素材 / 图层侧栏和任务侧栏被收起。再次点击或点击面板关闭按钮后收起 Agent。打开素材 / 图层侧栏或任务侧栏时,Agent 面板同步关闭。 +- 点击底部 Dock 的“画布 Agent”后,右侧独立 Agent 面板打开,任务侧栏被收起;素材 / 图层侧栏保持当前状态并可继续切换。再次点击或点击面板关闭按钮后收起 Agent;打开任务侧栏时 Agent 面板同步关闭。 - Agent 面板能读取当前工程会话列表;无历史会话时发送第一条消息会先创建“新对话”。支持新建会话、切换会话和删除当前会话;删除必须通过独立确认弹窗完成,不能在面板下方追加确认内容。 - Agent 输入支持文本消息、附件消息和纯附件消息;附件选择弹窗可在“画布 / 素材库”之间切换,只展示图片类资源,最多选择 9 张。 -- 发送消息后,面板展示用户消息、Agent 阶段状态和 SSE 增量回复;`stage/message_delta/tool_started/tool_completed/generation_result/error/done` 都能被正确渲染。流式响应中点击“停止”会中断当前请求,并把仍在 streaming / generating 的消息标记为停止态。 -- Agent 返回生成结果缩略图后,点击缩略图应优先聚焦当前画布中已有 `resourceId` 对应图层;如果当前内存布局尚未包含该资源,则重新读取工程快照,应用后再聚焦新图层。对话入口触发生成时不创建“即将生成”画布占位;生成中状态只显示在消息流,生成完成后通过后端 `canvasCompletion` 落新图层。工具失败时消息内必须保留失败 generation record 和错误气泡,不能只弹一次性 toast。 +- 发送消息后,面板先展示本地用户消息和请求等待态,再应用普通 JSON 响应中的 `deltaMessages`;客户端取消等待只终止本次 transport 等待,不把已经确认入队的外部生成任务改成停止态。 +- Agent 工具任务完成并懒回填后,消息内缩略图只作纯预览,不显示名称也不点击聚焦图层;前端同时重新读取工程快照和素材库。对话入口触发生成时不创建“即将生成”画布占位,生成完成后由后端 `canvasCompletion` 落新图层。规划或工具失败时消息内必须保留可回读的失败状态和错误气泡,不能只弹一次性 toast 或返回瞬时 `errorMessage`。 - 画布 Agent 会话刷新后能从后端恢复会话标题、消息、附件和生成记录;前端不得根据本地临时状态伪造会话持久化结果。 - 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`,由 api-server 通过共享 BgFilter `background_mode=complex` 链路去背景并持久化结果;有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词。透明处理及拆分正常完成时,透明 spritesheet 和拆分素材都按后端快照保留为画布图层;透明处理失败时仅原图作为主结果,既不要求透明图也不要求切片;透明图成功但拆分失败时保留整张透明图并展示拆分告警。三种完成结果都以后端项目快照为准。 - 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。 -- 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,优先复用当前图层已有 `objectKey` / `resourceId` / `sourceAssetId`;只有尚未登记的浏览器本地图片才先上传并取得 objectKey。前端不得再把正式对象下载成 `data:image/*;base64,...` 后提交,也不得把 Data URL / Blob URL 写入外部生成持久任务 JSON;后端收到引用后统一做 owner 归属校验并签名读取。 +- 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,优先复用当前图层已有 `objectKey` / `resourceId` / `sourceAssetId`;尚未登记且没有稳定引用的浏览器本地图片或普通 public 图片路径都必须先上传并取得 objectKey。前端不得再把正式对象下载成 `data:image/*;base64,...` 后提交,也不得把 Data URL / Blob URL 写入外部生成持久任务 JSON;后端收到引用后统一做 owner 归属校验并签名读取。 - 快速编辑不保留额外参考图入口;点击修改时只把原图或红框序号标注图作为 `/api/editor/images/edits` 的 `sourceImageSrc` 提交给后端。 - 素材文件夹可以新建、折叠、重命名和删除;删除普通文件夹后,其素材移动到“项目素材”。普通上传默认落入“上传素材”文件夹;素材库缺少该文件夹时,前端在首次普通上传前创建一次并复用,拖到指定文件夹或点击指定文件夹上传时仍进入目标文件夹。 - 上传按钮和拖拽上传都支持多文件;底部工具栏的上传入口选择文件后直接进入“上传素材”并在当前画布视口中心创建画布图层,素材栏文件夹内的上传入口只写入对应素材文件夹、不自动入画布;拖到文件夹或该文件夹内素材时进入目标文件夹;拖到画布时进入“上传素材”并在投放点创建画布图层。上传图片必须在创建占位素材、画布图层和账号级素材记录前先读取原图 Resolution,图层宽高、`originalWidth/originalHeight` 和素材库 `width/height` 都使用图片本身尺寸;上传视频同样在创建素材和图层前读取视频 metadata 宽高,保证单层下载或 ZIP 导出的真实视频文件重新导入后仍按文件自身尺寸入画布;仅在无法解析尺寸时才使用对应媒体兜底尺寸。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 18b59ea78..7428ce850 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -31,6 +31,7 @@ - `acknowledge_external_generation_job_summaries_and_return`:按当前账号确认已终态任务的完成 / 失败提示,写入摘要投影的 `notification_acknowledged_at` 并追加审计事件。 - `get_external_generation_queue_stats_and_return`:controller 读取队列积压、运行中任务和过期 lease 数量,用于计算 worker 目标实例数;该 procedure 只读 `external_generation_job`,不直接操作 systemd。 - `get_external_generation_job_summary_and_return`:按 `job_id` 从轻量摘要投影读取单个任务状态,给 BFF 和生成页展示使用;必须只返回调用者有权读取的任务,不能暴露其它用户的 payload、错误详情或 worker 内部字段。 +- `get_external_generation_job_result_and_return`:仅供后端内部回填异步编辑器 Agent 工具调用;按 `job_id + owner_user_id` 返回 `status`、`last_error_message` 和已持久化的 `result_payload_json`,不返回请求 payload、lease 或其它 worker 字段。该 procedure 不替代摘要状态读取接口,也不经 BFF 暴露给前端。 不带 `summary / summaries` 的旧 `get / list / acknowledge_external_generation_job*` procedure 只保留给受控内部兼容,不是 BFF 正式读取入口。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 417a7b583..c29d747f5 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -74,6 +74,8 @@ src/ 根级原生壳检查还会反查两端单端配置检查的依赖版本门禁:移动壳必须继续锁定 Expo SDK、React Native、WebView、EAS CLI 和 `package-lock.json` 实际解析版本;桌面壳必须继续锁定 Tauri CLI、Cargo manifest、`Cargo.lock` 解析版本和直接依赖关系。升级 Expo / React Native / Tauri 不能只改 `package.json` 或 `Cargo.toml`,必须同时更新单端配置检查、锁文件和本文档里的宿主能力假设。 +桌面壳的 `permissions/autogenerated/` 由 Tauri `build.rs` 生成且保持 gitignored。`npm run check:native-shells` 必须先运行桌面 Rust 测试生成权限清单,再执行桌面配置检查,最后执行 release build smoke;不能让干净检出因为静态检查早于生成步骤而失败,也不能把生成权限文件提交进仓库。 + 结构门禁按完整相对路径反查文档和目录:微信桥接层为 `miniprogram/host-bridge/dispatch.js`、`miniprogram/host-bridge/payment.js`、`miniprogram/host-bridge/protocol.js`、`miniprogram/host-bridge/shareGrid.js`、`miniprogram/host-bridge/subscribeMessage.js`、`miniprogram/host-bridge/webView.js`;微信 shell 层为 `miniprogram/shell/payment.js`、`miniprogram/shell/shareGrid.js`、`miniprogram/shell/subscribeMessage.js`、`miniprogram/shell/webView.js`;微信页面包装层为 `miniprogram/pages/share-grid/index.js`、`miniprogram/pages/share-grid/index.json`、`miniprogram/pages/share-grid/index.wxml`、`miniprogram/pages/share-grid/index.wxss`、`miniprogram/pages/subscribe-message/index.js`、`miniprogram/pages/subscribe-message/index.json`、`miniprogram/pages/subscribe-message/index.wxml`、`miniprogram/pages/subscribe-message/index.wxss`、`miniprogram/pages/web-view/index.js`、`miniprogram/pages/web-view/index.json`、`miniprogram/pages/web-view/index.wxml`、`miniprogram/pages/web-view/index.wxss`、`miniprogram/pages/wechat-pay/index.js`、`miniprogram/pages/wechat-pay/index.json`、`miniprogram/pages/wechat-pay/index.wxml`、`miniprogram/pages/wechat-pay/index.wxss`;移动源码根为 `apps/mobile-shell/src/env.d.ts`;移动桥接层为 `apps/mobile-shell/src/host-bridge/appearance.test.ts`、`apps/mobile-shell/src/host-bridge/appearance.ts`、`apps/mobile-shell/src/host-bridge/badge.test.ts`、`apps/mobile-shell/src/host-bridge/badge.ts`、`apps/mobile-shell/src/host-bridge/bridge.ts`、`apps/mobile-shell/src/host-bridge/capabilities.test.ts`、`apps/mobile-shell/src/host-bridge/capabilities.ts`、`apps/mobile-shell/src/host-bridge/clipboard.test.ts`、`apps/mobile-shell/src/host-bridge/clipboard.ts`、`apps/mobile-shell/src/host-bridge/dispatch.ts`、`apps/mobile-shell/src/host-bridge/filePayloads.test.ts`、`apps/mobile-shell/src/host-bridge/filePayloads.ts`、`apps/mobile-shell/src/host-bridge/files.test.ts`、`apps/mobile-shell/src/host-bridge/files.ts`、`apps/mobile-shell/src/host-bridge/haptics.test.ts`、`apps/mobile-shell/src/host-bridge/haptics.ts`、`apps/mobile-shell/src/host-bridge/navigation.test.ts`、`apps/mobile-shell/src/host-bridge/navigation.ts`、`apps/mobile-shell/src/host-bridge/network.test.ts`、`apps/mobile-shell/src/host-bridge/network.ts`、`apps/mobile-shell/src/host-bridge/notifications.test.ts`、`apps/mobile-shell/src/host-bridge/notifications.ts`、`apps/mobile-shell/src/host-bridge/protocol.test.ts`、`apps/mobile-shell/src/host-bridge/protocol.ts`、`apps/mobile-shell/src/host-bridge/runtime.test.ts`、`apps/mobile-shell/src/host-bridge/runtime.ts`、`apps/mobile-shell/src/host-bridge/scanner.test.ts`、`apps/mobile-shell/src/host-bridge/scanner.ts`、`apps/mobile-shell/src/host-bridge/share.test.ts`、`apps/mobile-shell/src/host-bridge/share.ts`;移动 shell 层为 `apps/mobile-shell/src/shell/QrScannerOverlay.test.tsx`、`apps/mobile-shell/src/shell/QrScannerOverlay.tsx`、`apps/mobile-shell/src/shell/ShellApp.tsx`、`apps/mobile-shell/src/shell/deepLink.ts`、`apps/mobile-shell/src/shell/lifecycle.ts`、`apps/mobile-shell/src/shell/loadFailure.ts`、`apps/mobile-shell/src/shell/navigation.ts`、`apps/mobile-shell/src/shell/network.ts`、`apps/mobile-shell/src/shell/runtime.ts`、`apps/mobile-shell/src/shell/safeArea.ts`、`apps/mobile-shell/src/shell/url.ts`、`apps/mobile-shell/src/shell/webViewGlobals.d.ts`、`apps/mobile-shell/src/shell/webViewHistory.ts`、`apps/mobile-shell/src/shell/webViewPolicy.ts`;桌面入口为 `apps/desktop-shell/src-tauri/src/app.rs`、`apps/desktop-shell/src-tauri/src/main.rs`;桌面桥接层为 `apps/desktop-shell/src-tauri/src/host_bridge/appearance.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/badge.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/files.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/mod.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/network.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/runtime.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/share.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/title.rs`;桌面 shell 层为 `apps/desktop-shell/src-tauri/src/shell/deep_link.rs`、`apps/desktop-shell/src-tauri/src/shell/events.rs`、`apps/desktop-shell/src-tauri/src/shell/file_drop.rs`、`apps/desktop-shell/src-tauri/src/shell/lifecycle.rs`、`apps/desktop-shell/src-tauri/src/shell/mod.rs`、`apps/desktop-shell/src-tauri/src/shell/navigation.rs`、`apps/desktop-shell/src-tauri/src/shell/network.rs`、`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`apps/desktop-shell/src-tauri/src/shell/tray.rs`、`apps/desktop-shell/src-tauri/src/shell/url.rs`、`apps/desktop-shell/src-tauri/src/shell/webview.rs`、`apps/desktop-shell/src-tauri/src/shell/window_state.rs`。这些目录不得新增未登记子目录或生产入口;移动端和桌面端单端配置检查同样会拒绝未登记生产模块。 ## HostBridge 消息协议 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index 8c95934f9..fd08f0a67 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -83,7 +83,7 @@ Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗 - `setHostAppBadgeCount()`:原生 App 宿主的受控应用角标入口。H5 只传 `0` 到共享契约 `HOST_BRIDGE_BADGE_COUNT_MAX` 之间的整数,`0` 表示清除角标;Expo 移动壳只在 iOS 声明 `app.setBadgeCount`,并通过 Expo Notifications 查询 / 请求 `allowBadge` 权限,再以 `Notifications.setBadgeCountAsync(count)` 的返回值作为成功依据;权限拒绝、权限状态不可读、系统返回 `false` 或原生 API 异常都必须返回明确失败,Android 不声明该能力,且请求实际到达 Android 壳时必须返回 `unsupported_capability`,不得返回成功;Tauri 桌面壳通过主窗口 `set_badge_count` 设置任务栏角标,底层平台不支持时返回明确错误,由 H5 视作失败并继续主流程。当前 H5 只把“可见作品架里未读的草稿生成完成更新”同步为角标数,同一个草稿有多个恢复 ID 时只计 1,已读、失败、生成中和不可见草稿不计入;宿主不支持或设置失败不改变 H5 红点、作品架或后端状态。平台壳同步层必须通过真实 `host_bridge_request` transport 测到 `app.setBadgeCount` 请求,保证未读计数模型和原生壳消费链路同时被门禁覆盖。 - `reloadHostWebView()`:原生 App 宿主的受控 WebView 刷新入口。H5 只能请求刷新当前承载主站的宿主 WebView;Expo 移动壳调用当前 `react-native-webview` 的 `reload()`,Tauri 桌面壳调用主 `WebviewWindow.reload()`。该能力不接受 payload,不开放任意 URL 导航、脚本执行、Tauri guest API 或 RN WebView ref;成功只表示宿主已发起刷新,刷新后当前 H5 上下文会卸载。`AuthGate` 在登录态从未登录变为已登录、或从已登录变为未登录时优先调用该能力刷新当前容器;宿主未声明、返回失败或不可用时再回退浏览器 `window.location.reload()`。 - `openHostExternalUrl()`:原生 App 宿主的受控外链入口。H5 中需要离开主站的外链在 `native_app` 下先通过 `app.openExternalUrl` 请求宿主系统浏览器打开;只允许 `http:`、`https:`、`mailto:`、`tel:`,相对路径会先归一化到当前站点绝对 URL,再通过共享契约 `normalizeHostBridgeExternalUrlPayload()` 清洗为 `{ url }` 载荷。Expo 移动壳消费该共享 payload normalizer,Tauri 桌面壳在 Rust 侧用 URL parser 镜像同一协议清单。宿主不可用或拒绝时回退浏览器外链行为,普通浏览器和小程序保持原有 `` 语义。H5 支付链接和微信 OAuth 登录授权 URL 也走该入口:原生壳未声明真实 `payment.request` / `auth.requestLogin` 前,微信 H5 支付 URL 和后端返回的微信登录授权 URL 优先交给宿主系统浏览器,宿主未处理时才回退当前 WebView 跳转;不得把 H5 支付或网页登录伪装成已完成的原生支付 / 原生登录。 -- `navigateHostNativePage()`:受控跳转宿主页,供订阅授权、支付、登录和内置独立 H5 体验入口等 adapter 复用。Expo 移动壳首版只接受同源 H5 route 并切换 WebView URL;Tauri 桌面壳同样只接受 `https://www.genarrative.world` 同源 H5 route 并在主窗口内跳转。H5 facade 在 `native_app` 下发送 `navigation.openNativePage` 前先拒绝空值、控制字符、协议相对 URL、外域绝对 URL 和非 `http:` / `https:` 协议目标,避免把明显不安全的跳转请求交给原生壳;同源绝对 URL、`/path` 和保留给桌面壳兼容的相对 route 继续由宿主二次归一并补写宿主上下文。微信小程序分支仍按小程序页面 URL 语义走 `wx.miniProgram.navigateTo`,不套原生 App 同源 H5 预校验。平台首页的儿童动作热身 Demo 入口在 `native_app` 且宿主声明 `navigation.openNativePage` 时必须优先走该 facade 跳转 `/child-motion-demo`,普通浏览器、小程序和未声明能力的裁剪壳才回退浏览器跳转。真正原生页面、登录和支付能力必须等对应 SDK / 页面接入后再声明支持。 +- `navigateHostNativePage()`:受控跳转宿主页,供订阅授权、支付、登录和仍在维护的同源 H5 adapter 复用。Expo 移动壳首版只接受同源 H5 route 并切换 WebView URL;Tauri 桌面壳同样只接受 `https://www.genarrative.world` 同源 H5 route 并在主窗口内跳转。H5 facade 在 `native_app` 下发送 `navigation.openNativePage` 前先拒绝空值、控制字符、协议相对 URL、外域绝对 URL 和非 `http:` / `https:` 协议目标,避免把明显不安全的跳转请求交给原生壳;同源绝对 URL、`/path` 和保留给桌面壳兼容的相对 route 继续由宿主二次归一并补写宿主上下文。微信小程序分支仍按小程序页面 URL 语义走 `wx.miniProgram.navigateTo`,不套原生 App 同源 H5 预校验。旧创作入口和儿童动作 Demo 已退出宿主壳强制验收,不再作为 `check:native-shells` 的专属路由契约;真正原生页面、登录和支付能力必须等对应 SDK / 页面接入后再声明支持。 - `exportHostTextFile()`:原生 App 宿主的受控文本导出入口。H5 facade 发起请求前先通过共享契约 `normalizeHostBridgeExportTextPayload()` 预校验文件名、文本内容、可选 MIME 和 5 MiB 上限;Expo 移动壳通过 `file.exportText` 写入缓存文本文件并交给系统分享 / 保存面板;Tauri 桌面壳通过 `file.exportText` 打开系统保存对话框并写入用户选择的文件。文件名必须清洗,可选 MIME 只能来自共享契约 `HOST_BRIDGE_TEXT_MIME_TYPES`,未传时默认为 `text/plain`,非文本 MIME 必须拒绝,不能借文本导出通道伪装成图片、音频或二进制文件;Expo 与 Tauri 壳仍必须二次校验真实文本字节数和 MIME。成功只返回文件名和字节数,不把本机绝对路径暴露给 H5;系统分享不可用或用户取消时返回明确错误,由 H5 fallback 承接。创作 Agent 工作台在 `native_app` 且声明该能力时提供会话 Markdown 导出入口,导出内容只来自当前 H5 已持有的会话标题、摘要、进度、锚点、消息、流式回复和输入草稿,并在 H5 侧先按同一 5 MiB 上限做 UTF-8 byte 校验;普通浏览器、小程序和未声明能力的裁剪壳不展示该入口。 - `importHostTextFile()`:原生 App 宿主的受控文本导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统文档选择器,Tauri 桌面壳通过系统文件选择框读取用户选择的文本文件;两端都只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json` 或对应扩展名,单次不超过 5 MiB,成功只返回通过共享 `normalizeHostBridgeImportFileName()` 清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;宿主必须在读取文本内容前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入;H5 facade 收到结果后继续通过共享契约 `normalizeHostBridgeImportTextResult()` 复核文件名、MIME、文本内容和字节数,非法或超界结果归为 `false`;用户取消时由 H5 facade 归为 `false`。创作 Agent 工作台在 `native_app` 且声明该能力时优先调用宿主文本导入,并把结果转换成现有浏览器 `File` 后继续复用后端 `/api/runtime/creation-agent/document-inputs/parse` 解析链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用原文件输入。 - `importHostDocumentFile()`:原生 App 宿主的受控文档导入入口。Expo 移动壳通过 Expo DocumentPicker,Tauri 桌面壳通过系统文件选择框读取用户选择的文档副本;两端都只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json`、`application/vnd.openxmlformats-officedocument.wordprocessingml.document` 或对应 `.txt` / `.md` / `.markdown` / `.csv` / `.json` / `.docx` 扩展名,单次不超过 5 MiB。成功只返回通过共享 `normalizeHostBridgeImportFileName()` 清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI、本机绝对路径或通用文件系统能力;宿主必须在读取 base64 前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入;H5 facade 收到结果后继续通过共享契约 `normalizeHostBridgeImportDocumentResult()` 复核文件名、MIME、base64 和字节数。创作 Agent 工作台在 `native_app` 且声明该能力时优先调用宿主文档导入,把返回 base64 转换成现有浏览器 `File` 后继续调用 `/api/runtime/creation-agent/document-inputs/parse`;旧壳只声明 `file.importText` 时才回退到文本导入,普通浏览器、小程序和未声明能力的裁剪壳继续使用原文件输入。该能力不在前端解析 DOCX,也不绕过后端文档解析、大小校验或错误口径;移动壳配置检查必须强制文档 MIME 清单、5 MiB 上限和导入文件名清洗函数来自共享 HostBridge 契约。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 04607e9ad..81743cea7 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -1,6 +1,6 @@ # server-rs 与 SpacetimeDB 数据契约 -更新时间:`2026-07-03` +更新时间:`2026-07-17` ## 后端主线 @@ -16,7 +16,7 @@ server-rs + Axum + SpacetimeDB `server-rs/Cargo.toml` 是 workspace 事实源。默认构建成员为 `crates/api-server`;第三方依赖版本和 workspace 内 crate path 统一放在 `[workspace.dependencies]`。 -SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk`、`spacetimedb-lib` 统一锁定 `2.6.0`;本地 `spacetime` CLI / standalone、生成的 `spacetime-client` bindings 和容器压测镜像也必须与 `server-rs/Cargo.toml` 锁定版本对齐,避免 BSATN / procedure result 反序列化错配。遇到版本不匹配时,不继续沿着业务超时排查,先把 CLI / standalone 直接升级到锁定版本并重启后再重试。 +SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk`、`spacetimedb-lib` 统一锁定 `2.6.1`;本地 `spacetime` CLI / standalone、生成的 `spacetime-client` bindings 和容器压测镜像也必须与 `server-rs/Cargo.toml` 锁定版本对齐,避免 BSATN / procedure result 反序列化错配。遇到版本不匹配时,不继续沿着业务超时排查,先把 CLI / standalone 直接升级到锁定版本并重启后再重试。2.6.1 还修复了 procedure context 中调用者 `Identity` / `ConnectionId` 丢失问题,因此依赖调用者身份的 procedure 不得继续运行在 2.6.0 standalone 上。 当前主要 crate: @@ -78,12 +78,15 @@ npm run check:server-rs-ddd ### 图片画布 Agent 对话 -- `/api/editor/projects/{projectId}/agent-conversations` 负责当前工程会话列表和新建;`/api/editor/agent-conversations/{conversationId}` 负责详情读取和软删;`/api/editor/agent-conversations/{conversationId}/messages/stream` 负责发送消息并返回 SSE。 +- `/api/editor/projects/{projectId}/agent-conversations` 负责当前工程会话列表和新建;`/api/editor/agent-conversations/{conversationId}` 负责详情读取、终态工具消息懒回填和软删;`POST /api/editor/agent-conversations/{conversationId}/messages` 负责发送消息并返回普通 JSON `EditorAgentMessageResponse`,画布 Agent 不提供 `/messages/stream` SSE 路由。消息请求必须携带最长 128 字符的 `clientMessageId`;前端对该 POST 显式启用 1 次瞬时 transport 重试,并复用同一个序列化 body、`clientMessageId` 和 `x-request-id`。同一会话在锁内按该键幂等,重复键同内容返回已有回合或从已保存用户消息继续,异内容返回 `409`。数字 `EditorAgentMessage.id` 仍只作为工具确认 / 取消的后端消息定位符,不能复用为客户端幂等键。 - `module-editor-agent` 只承载纯领域校验:标题派生、附件上限、消息输入规则和会话软删访问规则;不直接依赖 Axum、SpacetimeDB、OSS、LLM 或 Tokio。 - `spacetime-module` 的 `editor_agent_conversation` 只保存元数据;创建、列表、读取、更新时间和软删通过 `create_editor_agent_conversation_and_return`、`list_editor_agent_conversations_and_return`、`get_editor_agent_conversation_and_return`、`touch_editor_agent_conversation_and_return`、`delete_editor_agent_conversation_and_return` procedure 完成,`api-server` 只能经 `spacetime-client` facade 访问。 -- 完整消息文档存 OSS `editor-agent/{conversationId}.json`,由 `api-server` 负责 2 MiB 上限、会话内串行锁、读改写、SSE 事件持久化和 `touch` 元数据更新时间;该 JSON 不进入 `editor_canvas.layers_json`,也不作为画布布局真相。 +- 完整消息文档存 OSS `editor-agent/{conversationId}.json`,由 `api-server` 负责 2 MiB 上限、会话内串行锁、读改写、消息与工具结果持久化和 `touch` 元数据更新时间;该 JSON 不进入 `editor_canvas.layers_json`,也不作为画布布局真相。LLM / 规划失败必须写入 `role=system`、正文以 `ERROR ` 开头的消息,并通过 `deltaMessages` 返回,`errorMessage` 保持为空;前端隐藏前缀并显示红色错误气泡,后端仍把该 system 消息注入后续 LLM memory,使 Agent 能读取失败上下文。工具失败同样必须形成可回读记录,不能只返回瞬时错误。 - 对话附件只允许引用当前工程 `editor_project_resource` 或当前账号 `editor_asset` 的图片;前端可提交展示用 `imageSrc` / `thumbnailSrc`,后端必须按 `resourceId` / `assetId` 重新归一、校验 owner / project 和 `objectKey`,再给 LLM 或生成工具使用。 - 画布 Agent 工具复用既有编辑器图片生成 / 修改 / 图标 spritesheet BFF,并继续使用后端模型定价和 `execute_billable_asset_operation_with_cost`;前端不提交 `priceMudPoints`。 +- `/messages/{messageId}/confirm` 与 `/messages/{messageId}/cancel` 只返回成功确认;前端成功后立即重新读取整个会话,以会话详情中的权威消息状态和 `externalJobId` 驱动气泡展示与任务轮询。 +- 会话详情的终态懒回填必须在单次 GET 和同一 conversation lock 内完成有界重试:任务结果读取、completed payload 解析或工具 formatter 首次失败后最多重试 3 次,每次等待 100ms 并重新读取主任务。任务读取失败或 completed 任务暂缺 `result_payload_json` 时,本次重试耗尽后仍保留 OSS 工具消息的 `not_completed + externalJobId`,由下次会话读取继续 reconcile;JSON 损坏、结果结构不兼容或 formatter 失败等确定性致命错误在重试耗尽后原子写为 `failed`,保存“重试 3 次后仍失败”的最后错误,避免永久循环。 +- 画布 Agent 是“正式任务 payload 不进入通用用户 read model”规则的窄例外消费者:`GET /api/editor/agent-conversations/{conversationId}` 只按会话中已有的 `externalJobId` 定向读取主任务,完成后由对应工具 formatter 从 `result_payload_json` 提取并归一有界的图片 / 视频 / 音频引用,写入 OSS 工具消息后返回。前端仍不得通过通用任务列表 / 状态接口读取或解析 `request_payload_json` / `result_payload_json`;OSS 轻量媒体引用只是会话展示与后续 Agent 上下文,不替代 `editor_project_resource`、`editor_asset`、`editor_canvas.layers_json` 或 `external_generation_job` 的业务真相。 ### 创作 / 游玩统一流程主干 @@ -175,7 +178,8 @@ npm run check:server-rs-ddd 7. 面向公开列表的只读投影优先做成 public view / public 读模型表,并由 `api-server` 的 `spacetime-client` 长期订阅后读本地 cache。跨玩法公开作品统一主读模型是 `public_work_gallery_entry` 和 `public_work_detail_entry`;公开作品资产读取授权投影是 `public_work_asset_read_grant`;各玩法既有 `*_gallery_card_view` / `*_gallery_view` / `custom_world_gallery_entry` 保留为 source view 和兼容路径。短期不把作品列表整体交给浏览器前端直接订阅;不要让 HTTP 列表接口每次请求都调用 procedure 重新组装全量列表。需要请求时间窗口的轻量统计可订阅 `public_work_play_daily_stat` 后在 `api-server` 本地聚合,需要写入副作用的详情、点赞、游玩记录仍走玩法 procedure / reducer。前端不得直接订阅 `puzzle_work_profile`、`custom_world_profile` 等领域源表,也不得自己做 join、聚合或权限逻辑。首屏、排序、字段归一、权限降级和 HTTP fallback 由 `api-server` BFF 维持。 8. 多列索引按 SpacetimeDB 绑定生成的元组参数直接传入,例如 `.filter((source_type, profile_id, played_day))`;前缀查询只传前缀元组,例如 `.filter((scope_kind, scope_id.as_str()))`。不要为了绕过类型问题退回整表遍历。 9. procedure result 必须返回 typed snapshot / typed value。`spacetime-client` mapper 不得再通过 `row_json/session_json/work_json/items_json/run_json/event_json/feedback_json: Option` 做跨层 JSON 字符串传输,也不得在 mapper 里反序列化旧 `*JsonRecord` 兼容结构。业务内部持久化字段如 `profile_payload_json`、`levels_json` 等不属于 procedure result 载荷例外,仍按各自表契约处理。 -10. 修改后运行: +10. procedure 需要按调用者 identity 鉴权时,必须先从外层 `ProcedureContext::sender()` 捕获 caller,再把 caller 显式传入 `try_with_tx` 闭包内的事务函数。SpacetimeDB 2.6.0 的 `TxContext` 使用内部匿名事务身份,`tx.sender()` 固定为 `Identity::ZERO`;2.6.1 已向事务透传 procedure caller,但项目仍保持显式 caller 参数,兼容尚未升级的运行环境并让鉴权边界不依赖 SDK 隐式语义。 +11. 修改后运行: ```bash npm run spacetime:generate @@ -283,8 +287,8 @@ npm run check:server-rs-ddd - Rust 结构体:`ExternalGenerationJob` - 源码:`server-rs/crates/spacetime-module/src/external_generation.rs` - 用途:外部生成 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` 复用同一队列表,worker 成功后经 `api-server` facade 写入 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。 -- 载荷约束:本次先对 `source_module = editor-canvas` 的 `request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用;其它玩法在完成各自参考图资源化之前不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行和受控维护读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得再返回或解析这两个 payload。 -- 非阻断告警:角色形象、图标图集和 UI 素材提取已保存 provider 原图、但透明背景处理最终失败时,以原图唯一主图完成任务;透明图和切片不写入画布。这个 source-only 降级只包住透明背景处理的最终失败,phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。图标 / UI 透明图集成功但自动拆分降级时仍保留透明图集;通用 `warning` 与 `sliceWarning` 互斥。两类成功降级都以既有 `completed` 状态收口,不新增状态值:source-only 的 inline / external v1 响应使用结构化 `warning.code/reason`,仅拆分失败的 inline / external v1 响应继续使用既有 `sliceWarning.code/reason`,其 `reason` 保留原始诊断;queue worker 才把两者归一为有界的 `result_payload_json.warning`,且通用 `warning` 优先并原样保留完整 `reason`,只有 `sliceWarning.reason` 由 worker 添加“图集已生成,但自动拆分未完成:”前缀。队列结果不保存图片、切片列表或媒体 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` 定向懒回填;其它编辑器任务保持元数据结果,并可保存有界的 `warning.code/reason`。其它玩法在完成各自参考图资源化之前不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行、受控维护以及画布 Agent 的定向结果回填读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得返回或解析这两个 payload。画布 Agent 懒回填必须经对应工具 formatter 归一为有界轻量媒体引用后写入 OSS 会话,不能把原始 payload 直接透传前端。 +- 非阻断告警:角色形象、图标图集和 UI 素材提取已保存 provider 原图、但透明背景处理最终失败时,以原图唯一主图完成任务;透明图和切片不写入画布。这个 source-only 降级只包住透明背景处理的最终失败,phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。图标 / UI 透明图集成功但自动拆分降级时仍保留透明图集;通用 `warning` 与 `sliceWarning` 互斥。两类成功降级都以既有 `completed` 状态收口,不新增状态值:source-only 的 inline / external v1 响应使用结构化 `warning.code/reason`,仅拆分失败的 inline / external v1 响应继续使用既有 `sliceWarning.code/reason`,其 `reason` 保留原始诊断;queue worker 才把两者归一为有界的 `result_payload_json.warning`,且通用 `warning` 优先并原样保留完整 `reason`,只有 `sliceWarning.reason` 由 worker 添加“图集已生成,但自动拆分未完成:”前缀。除上述画布 Agent 定向回填的轻量结果外,队列结果不保存图片、切片列表或媒体 URL。 ### `external_generation_job_summary` @@ -562,14 +566,14 @@ npm run check:server-rs-ddd - Rust 结构体:`EditorAssetGroupSourceProvenance` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:手动图集拆分的私有可信来源索引。仅当账号素材由后端生成链路写入且具有正式 `source_resource_id` 时,按 owner + source resource、asset object 和 Object Key 记录真实来源 `task_id`;普通素材 / 资源创建请求不能直接写该表。跨项目复用素材后可通过稳定媒体引用找回原任务,历史行首次扫描命中后补写索引。 +- 说明:手动图集拆分的私有可信来源索引。仅当账号素材由后端生成链路写入且具有正式 `source_resource_id` 时,按 owner + source resource、asset object 和 Object Key 记录真实来源 `task_id`;普通素材 / 资源创建请求不能直接写该表。跨项目复用素材后可通过稳定媒体引用找回原任务,历史行首次扫描命中后补写索引。该持久表纳入 `migration_tables!` 导入导出清单,数据库迁移与恢复不得遗漏来源归组事实。 - 主键:`lookup_key`。 ### `editor_asset_group_cohort` - Rust 结构体:`EditorAssetGroupCohort` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:手动拆分批次的私有完成事实。api-server 只有在预期的全部 asset ID 已成功落库并由 runtime service identity 逐行校验 owner、真实 task、归组 task 和预期数量一致后才能插入;事实写入后不随用户删除单片素材而撤销。没有完成事实的现代批次不能并入来源根任务。 +- 说明:手动拆分批次的私有完成事实。api-server 只有在预期的全部 asset ID 已成功落库并由 runtime service identity 逐行校验 owner、真实 task、归组 task 和预期数量一致后才能插入;事实写入后不随用户删除单片素材而撤销。没有完成事实的现代批次不能并入来源根任务。该持久表纳入 `migration_tables!` 导入导出清单,数据库迁移与恢复必须保留不可逆批次完成事实。 - 主键:`cohort_id`。 ### `editor_showcase_asset` diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 1ba313493..5f19e904f 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -1,6 +1,6 @@ # 本地开发验证与生产运维 -更新时间:`2026-07-15` +更新时间:`2026-07-17` ## 标准开发流程 @@ -151,7 +151,7 @@ spacetime sql "SELECT * FROM puzzle_gallery_card_view LIMIT 1" --serv 本地 `npm run dev:spacetime` 发布模块时必须显式忽略仓库根目录的 `spacetime.json`,由脚本固定追加 `--no-config` 并使用命令参数里传入的数据库名和 `--server http://127.0.0.1:3101`。否则 CLI 可能把发布目标改写到配置文件里的其他数据库,导致 `dev:spacetime` 启动后又因发布失败自动退出,浏览器随后会在 `ws://127.0.0.1:3101/v1/database/.../subscribe` 看到连接拒绝。 -本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.6.0`。若版本错配,procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`,api-server 最终表现为敲木鱼等创作动作的 `SpacetimeDB procedure 调用超时`。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml` 的 `spacetimedb = "..."`;遇到版本不匹配时不要继续深挖业务超时,直接执行 `spacetime version install && spacetime version use `,或在目标就是最新版本时执行 `spacetime version upgrade`,升级后重启 `npm run dev:spacetime` 再重试。当前 `scripts/dev.mjs` 会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`,避免把旧 standalone 继续带进新一轮创作。 +本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.6.1`。若版本错配,procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`,api-server 最终表现为敲木鱼等创作动作的 `SpacetimeDB procedure 调用超时`。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml` 的 `spacetimedb = "..."`;遇到版本不匹配时不要继续深挖业务超时,直接执行 `spacetime version install && spacetime version use `,或在目标就是最新版本时执行 `spacetime version upgrade`,升级后重启 `npm run dev:spacetime` 再重试。当前 `scripts/dev.mjs` 会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`,避免把旧 standalone 继续带进新一轮创作。2.6.1 修复了 procedure context 中调用者 `Identity` / `ConnectionId` 始终为空的回归,依赖 `ctx.sender` 鉴权时必须同时确认宿主已升级。 本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查 RPG / 拼图 / 抓大鹅等 VectorEngine 生图链路时,确认 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine `gpt-image-2` 图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`;`api-server` 只做配置、玩法编排、OSS / asset 持久化、计费和失败审计落库。`platform-image` 会在 JSON 生成和 multipart 编辑请求发送前,按原比例尽量收敛 `gpt-image-2` 的显式像素尺寸:最大边长不超过 `3840`、宽高均为 `16` 的倍数、长短边比不超过 `3:1`,总像素范围为 `655360` 至 `8294400`。无法满足全部条件时回退为 `1024x1024`;其他模型保留其传入尺寸。开局 CG 故事板、首图、背景和图集都属于长耗时图片请求;后端默认会把 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 下限收口到 `1000000`,旧进程仍可能沿用重启前的短超时。若 VectorEngine 在 `send()` 阶段失败且日志显示 `SendRequest`,先看同一 `request_id` 的 provider 日志字段 `source`、`source_chain`、`source_chain_depth`,再查 `external_api_call_failure.metadata_json.errorSource`;当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。拼图关卡资产按 `level_scene -> ui_spritesheet -> level_background` 顺序生成,日志会带 `slot`、`asset_kind` 和 `elapsed_ms`。 @@ -162,13 +162,13 @@ VectorEngine 图片生成 / 编辑在 `request_send` 阶段出现 `timeout`、`c 查看本地 Rust / SpacetimeDB 日志: ```bash -npm run dev:rust:logs +npm run dev:spacetime:logs ``` 后台前端: ```bash -npm run admin-web:dev +npm run dev:admin-web npm run admin-web:build npm run admin-web:typecheck ``` @@ -277,7 +277,7 @@ npm run check:spacetime-schema `rustfmt.toml` 固定 Edition 2024 的格式化口径。Rust 源码统一使用 `cargo fmt --all --manifest-path server-rs/Cargo.toml` 格式化,并用 `npm run check:rustfmt` 做只读校验;Codex 提交前门禁、API 生产构建和 -SpacetimeDB module 生产构建都会执行同一检查,避免不同开发机或构建节点反复产生格式差异。 +SpacetimeDB module 生产构建都会执行同一检查,避免不同开发机或构建节点反复产生格式差异。Web 生产构建还会执行 production-ops、ESLint、主站与后台类型检查,以及排除已下线旧玩法后的当前 Vitest;API 生产构建追加 production-ops、DDD/schema/runtime-access 和 api-server 全 target 编译检查;SpacetimeDB module 生产构建追加 production-ops、DDD/schema/runtime-access 和管理员 procedure smoke。上述门禁由 `npm run check:production-ops` 反查,不能只保留在本地说明中。 ## 前端改动验收 @@ -567,7 +567,7 @@ worker 被硬杀或断电后,lease 过期任务只有尚未耗尽 `max_attempt - `api-server` 正常运行时 `/healthz` 只返回进程存活状态,`/readyz` 会同时检查进程是否仍接收新流量和 SpacetimeDB 连接租约是否健康;收到 `SIGINT` / `SIGTERM` 后会先把 readiness 标记为不可用,再让 Axum 停止接新连接并等待已有 HTTP 请求排空。systemd 仍以 `KillSignal=SIGINT` 停服务,`TimeoutStopSec=90` 作为长请求排空上限。 - SpacetimeDB 健康检查默认使用 `GENARRATIVE_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS=2` 的短等待窗口,和业务 procedure 的 `GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS` 分开。`/readyz` 失败时 `details.spacetime.stage` 会标出当前卡住阶段:`pool_acquire`、`connect_build`、`connect_handshake`、`read_model_subscribe`、`procedure_result`、`reducer_result` 或 `read_cache`;`elapsedMs` / `timeoutMs` 用于确认是否命中健康检查窗口。业务请求日志也会写入 `operation_kind`、`operation_name`、`spacetime_stage` 和 `elapsed_ms`,后续 45 秒超时不再只靠 Nginx `request_time=45s` 推断。 - `genarrative-api.service` 设置 `LimitNOFILE=65535`、`TasksMax=2048`;上线后用 `systemctl show genarrative-api.service -p LimitNOFILE -p TasksMax -p TimeoutStopUSec` 和 `cat /proc/$(pidof api-server)/limits` 核对。 -- Server provision 不再通过 Windows helper 下载,也不再通过 Linux build 节点中转 SpacetimeDB / otelcol 工具包;Linux build 节点只负责从内网 Git 源准备 provision 脚本和配置并上传给目标 agent。`Prepare Provision Tools` 在目标 dev / release agent 工作区内先检查 `/usr/local/bin/otelcol-contrib` 与 `${SPACETIME_ROOT}/bin/current`:版本已满足时直接复用目标机现有文件生成 `provision-tools/`,只有缺失或版本不匹配时才使用 `PROVISION_DOWNLOADS_DIR` 里的本地包或从配置的下载源准备 SpacetimeDB `2.6.0` / `otelcol-contrib 0.151.0`;如果目标服务器下载需要代理,在 `PROVISION_DOWNLOAD_PROXY` 配置目标机可访问的 HTTP 代理。 +- Server provision 不再通过 Windows helper 下载,也不再通过 Linux build 节点中转 SpacetimeDB / otelcol 工具包;Linux build 节点只负责从内网 Git 源准备 provision 脚本和配置并上传给目标 agent。`Prepare Provision Tools` 在目标 dev / release agent 工作区内先检查 `/usr/local/bin/otelcol-contrib` 与 `${SPACETIME_ROOT}/bin/current`:版本已满足时直接复用目标机现有文件生成 `provision-tools/`,只有缺失或版本不匹配时才使用 `PROVISION_DOWNLOADS_DIR` 里的本地包或从配置的下载源准备 SpacetimeDB `2.6.1` / `otelcol-contrib 0.151.0`;如果目标服务器下载需要代理,在 `PROVISION_DOWNLOAD_PROXY` 配置目标机可访问的 HTTP 代理。 - 除 `Genarrative-Server-Provision` 外,`Genarrative-Stdb-Module-Build`、`Genarrative-Web-Build`、`Genarrative-Api-Build`、`Genarrative-*Deploy`、`Genarrative-Database-Import/Export`、`Genarrative-Full-Build-And-Deploy` 和 `Genarrative-Notify-Email` 的生产流水线现都以 Linux agent 为主,仍按各自 Jenkinsfile 的 checkout 口径执行。Server provision 不使用公网备用 Git 源,目标部署 agent 也不再需要访问源码 Git remote。 - `otelcol-contrib.service` 作为可选系统服务加入 provision,默认监听 `127.0.0.1:4317/4318` 并使用 `deploy/otelcol/genarrative-debug.yaml`。api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制,服务 unit 见 `deploy/systemd/otelcol-contrib.service`。该服务必须存在系统用户 / 组 `otelcol`,并且 `/etc/otelcol/genarrative-debug.yaml` 已安装到目标机;若看到 `status=217/USER` 或 `Failed to determine user credentials`,优先检查 `getent passwd otelcol`,再补齐 `/etc/otelcol` 配置目录并重启服务。 - Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;`limit_conn` 负责连接 / 并发保护,`limit_req` 负责入口 RPS 快拒绝。当前模板把公开 gallery list 单独放到 `genarrative_gallery_rps`,默认 `rate=5000r/s`、`burst=4096`、`limit_conn=320`;公开详情和普通 API 放到 `genarrative_api_rps`,后台 API 放到 `genarrative_admin_rps`。通用 `/api` location 设置 `client_max_body_size 64m` 是反代兜底,防止拼图入口页 / 新增关卡本地参考图 Data URL 或旧兼容请求在到达 `api-server` 前被默认 1 MiB 上限拦截;拼图本地参考图前后端统一限制 6MB,历史图片仍提交 `referenceImageAssetObjectId(s)`。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`、`upstream_status=-`,说明请求在 Nginx 层被拦截,先用 `nginx -T | grep client_max_body_size` 检查 release 模板是否已渲染并 reload,同时检查前端是否超出 6MB 或错误提交了未压缩大图。`limit_conn_status 429` 和 `limit_req_status 429` 必须在 HTTP 与 HTTPS server 中同时生效;若线上压测看到 `limiting connections by zone "genarrative_api_conn"` 却返回 503,优先检查 `nginx -T` 里 HTTPS server 是否缺少这些状态码,以及 `/api/runtime/puzzle/gallery` 是否误落到通用 `location ~ ^/api` 的 `limit_conn=64`。压测时看 `/var/log/nginx/genarrative.access.log` 中的 `request_time`、`upstream_connect_time`、`upstream_header_time`、`upstream_response_time`、`upstream_status`、`request_id`。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index 38297a241..6fd784f9f 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -110,6 +110,10 @@ - 查看信息按钮固定使用圆形 `i` 图标,不使用中括号、花括号或文本符号样式。 - 视频 / 角色 / 角色动作 / 音效 / 背景音乐待生成占位的角标同样按 viewport 反向缩放,不随画布缩放变小。 - 已生成角色图、角色动作图或其它生成结果图被点击时只选中图层并收起已有生成输入框;重绘、快速编辑和生成动画面板必须由对应工具栏按钮或右键菜单显式打开。 +- 角色图层打开“生成动作”后再点击“改造”,必须重新打开角色形象生成器;动作生成对话框只把角色图层作为输入来源,不得被识别为该角色图层自身的来源生成器。 +- 角色动作结果图层点击“改造”时,必须通过 `sourceResourceId` 找回原角色图层并重新打开角色动作生成器;关联原角色已不存在时应显示明确提示,不得无响应或降级成图片生成器。 +- 恢复已生成图层的来源生成器时,角色、动作、规范、图标、UI、宣发、视频、音效和背景音乐必须按 `assetKind / mediaType` 恢复对应面板;去背景或快速编辑产生的派生 `quick-edit` 对话框不得遮住图层原有来源生成器。 +- 任何会移除画布图层的入口,包括删除、右键剪切和素材库删除关联素材,都必须同步清理该图层关联的生成面板和派生状态,不得在保存或刷新后恢复成孤立占位。 ## 画布保存 @@ -160,11 +164,11 @@ - 参考媒体只对 `seedance2.0-fast` / `seedance2.0` 开放;切换到 Kling 等非 Seedance 模型时,前端不提交 `referenceImageSrcs` / `referenceVideoSrcs` / `referenceAudioSrcs`,后端收到非 Seedance 参考字段必须拒绝。 - 多模态参考按火山 Seedance 2.0 文档限制:参考图片 0~9 张,参考视频 0~3 个,参考音频 0~3 段;音频不可单独输入,必须至少搭配 1 张参考图片或 1 个参考视频。 -- 参考图片支持 URL / Base64 / `asset://` / 画板资源路径;格式为 jpeg、png、webp、bmp、tiff、gif、heic、heif,单张小于 30MB,请求体总大小不超过 64MB。 -- 参考视频只支持 URL / `asset://` / 画板资源路径,不支持 Base64 / `data:video/*`;格式为 mp4、mov,单个文件不超过 50MB,单个时长 [2, 15] 秒,最多 3 个且总时长不超过 15 秒。 -- 参考音频支持 URL / Base64 / `asset://` / 画板资源路径;格式为 wav、mp3,单个文件不超过 15MB,单个时长 [2, 15] 秒,最多 3 段且总时长不超过 15 秒。 -- 前端上传参考视频 / 参考音频必须走 `/api/assets/direct-upload-tickets` 直传 OSS,再 `/api/assets/objects/confirm` 入库;前端状态保存 signed URL 供预览,同时保存 `objectKey` / `assetObjectId`,提交生成时优先使用 `objectKey`。 -- 后端接收画板资源 `objectKey` 后统一重新签名为 Ark 可读 URL,再按文档构造 `content`:`image_url` + `role=reference_image`、`video_url` + `role=reference_video`、`audio_url` + `role=reference_audio`。 +- 参考图片支持公网 URL、`asset://`、当前账号的 `objectKey`、项目资源 ID 或素材 ID;格式为 jpeg、png、webp、bmp、tiff、gif、heic、heif,单张小于 30MB,不接受 Base64 / Data URL / Blob URL。 +- 参考视频支持公网 URL、`asset://`、当前账号的 `objectKey`、项目资源 ID 或素材 ID;格式为 mp4、mov,单个文件不超过 50MB,单个时长 [2, 15] 秒,最多 3 个且总时长不超过 15 秒,不接受 Base64 / Data URL / Blob URL。 +- 参考音频支持公网 URL、`asset://`、当前账号的 `objectKey`、项目资源 ID 或素材 ID;格式为 wav、mp3,单个文件不超过 15MB,单个时长 [2, 15] 秒,最多 3 段且总时长不超过 15 秒,不接受 Base64 / Data URL / Blob URL。三类参考媒体最终提交的稳定引用字段总长度不得超过 `256KB`。 +- 前端本地参考媒体必须走 `/api/assets/direct-upload-tickets` 直传 OSS,再 `/api/assets/objects/confirm` 入库;前端状态保存 signed URL 供预览,同时保存 `objectKey` / `assetObjectId`,提交生成时使用稳定引用。 +- 后端接收 `objectKey`、项目资源 ID 或素材 ID 后,先解析并校验对象属于当前 owner,再重新签名为 Ark 可读 URL,并按文档构造 `content`:`image_url` + `role=reference_image`、`video_url` + `role=reference_video`、`audio_url` + `role=reference_audio`。 - 画板生成视频按静音 toggle 映射 Ark `generate_audio`:`sound=on` 时传 `true`,`sound=off` 时传 `false`,不能只依赖上游默认值。 - Seedance 2.0 文档提示不支持直接上传含真人人脸的参考图 / 视频;当前画板尚未做真人脸授权证明、来源声明或服务端人脸拦截,后续开放真人素材前必须补授权/来源确认链路。 diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md index 632598beb..bbb9f3286 100644 --- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md +++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md @@ -1,6 +1,6 @@ # 画布Agent对话面板 -日期:`2026-07-03` +日期:`2026-07-16` ## 定位与边界 @@ -8,9 +8,9 @@ - 它是画布域工具,**不承接玩法创作**、不产出玩法作品或模板,与 `CONTEXT.md` 中「表单/图片输入创作工作台」的 Avoid 边界不冲突。 - 独立于拼图专用的 `/api/runtime/creative-agent/sessions`(该会话为 api-server 内存态、拼图领域专用,不复用)。 -## 能力范围(第一期) +## 能力范围 -对话 Agent 可通过 function-calling 触发以下五类工具,全部复用既有计费收口接口: +对话 Agent 可通过 function-calling 触发以下八类工具,全部复用既有计费收口接口: | 工具 | 后端接口 | | --- | --- | @@ -19,19 +19,22 @@ | 生成角色形象 | 既有角色形象生成入口对应接口 | | 生成图标素材 | `POST /api/editor/icon-spritesheets/generations` | | 生成 UI 设计图 | 既有 UI 设计图生成入口对应接口 | +| 生成视频 | `POST /api/editor/videos/generations` | +| 生成游戏音效 | `POST /api/editor/audios/sound-effects/generations` | +| 生成背景音乐 | `POST /api/editor/audios/background-music/generations` | - 意图解析与工具编排在后端 api-server,前端只渲染状态,不承接业务规则。 - 下面的工具选择口径属于 Agent 规划 prompt / function-calling 约束,不是侧边栏 UI 说明文案;侧边栏面板不展示这些规则解释。 - 用户要求“规范图 / 视觉规范图 / 风格规范图 / 素材规范展板”时,规划默认选择 `generate_image`,并在 prompt 中明确要求生成规范展板,包含统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等可落地的视觉规范元素。 - 用户要求“角色规范图”且语义是角色的规范展板、风格展板或设定板时,仍走 `generate_image`,不要误分流到 `generate_character`;只有实际生成角色立绘、角色主形象或角色视觉资产时才走 `generate_character`。用户要求多个图标素材、图集或 spritesheet 时才走 `generate_icon_spritesheet`。 - 所有生成必须走 `execute_billable_asset_operation_with_cost` 与模型定价配置,禁止绕过定价收口。 -- 视频 / 音频 / 图层操作等其余画板功能第一期不进入对话工具面,仍走现有面板。 +- 图层操作及其他未注册的画板功能第一期不进入对话工具面,仍走现有面板。 ## 当前分支落地状态 -- 已落地:会话元数据、OSS 消息文档、会话 CRUD、SSE 消息流、后端 LLM 工具规划、右侧对话面板、会话历史、新建 / 软删会话、停止当前 SSE 回合、附件从画布资源 / 账号素材库选择,以及五类图片工具对既有生成入口的复用。 -- 已落地:`tool_started` / `tool_completed` 事件携带 `status`;工具失败时也会写入失败 generation record,并随后发送 `stage=failed` 与 `error`,前端应保留消息内失败条目。 -- 未落地:附件弹窗末尾上传格、跨刷新异步生成恢复、external generation task 轮询回填。未落地前,对话消息状态只表示本次 SSE 回合记录,不作为后台任务队列真相。 +- 已落地:会话元数据、OSS 消息文档、会话 CRUD、带 `clientMessageId` 幂等键的普通 JSON 消息请求、后端 LLM 工具规划、右侧对话面板、会话历史、新建 / 软删会话、附件从画布资源 / 账号素材库选择,以及八类图片 / 音视频工具对既有生成入口的复用。 +- 已落地:工具确认 / 取消、external generation task 轮询与会话懒回填。LLM 未配置、请求失败或规划结果解析失败时,后端把 `role=system`、正文以 `ERROR ` 开头的消息写入 OSS,并通过 `deltaMessages` 返回,`errorMessage` 保持为空;前端隐藏 wire 前缀并以红色错误气泡展示。工具执行失败继续保存 `status=failed`、模型和错误信息,不能只返回瞬时错误。 +- 未落地:附件弹窗末尾上传格。`external_generation_job` 继续作为后台任务队列真相,对话消息只保存确认、回填状态和轻量媒体结果引用。 ## 会话与持久化 @@ -39,24 +42,27 @@ - SpacetimeDB 新表 `editor_agent_conversation` 只存会话元数据:会话 ID、projectId、ownerUserId、标题、软删标记、聊天记录 OSS 对象引用、创建/更新时间。 - 完整消息内容存 OSS:`editor-agent/{conversationId}.json`,**会话粒度整体读写**(追加消息=重写对象),不按消息拆对象。 - 不把对话塞进画布工程快照 payload,不在 api-server 内存中保存会话真相。 -- 会话标题:新会话默认「新对话」,首条用户消息发出后自动截取前 N 字作为标题;第一期不做手动重命名。 +- 会话标题:新会话默认「新对话」,首条含文本的用户消息发出后自动截取前 N 字作为标题;列表摘要、详情和消息回包均携带同一必填标题,前端只展示该标题,不以会话 ID 或本地推导兜底。标题写入失败会使该消息请求失败,不能静默继续。 - 会话删除:列表项 hover 出删除按钮 + 确认;软删(表打 deleted 标记,OSS 对象保留)。 +- 每次用户主动发送生成一个最长 128 字符的 `clientMessageId`;`editorAgentClient` 对网络错误和通用瞬时状态码显式启用 1 次 POST transport 重试,重试复用同一个已序列化 body、`clientMessageId` 和 `x-request-id`。该字段独立于数字 `message.id` 并随用户消息写入 OSS。旧消息缺失时按 `None` 兼容;早期 SSE 文档若把客户端键存成用户消息字符串 `id`,读取时将其迁入 `clientMessageId`,同时重建数字定位符。后端在会话锁内检查重复键:内容一致时返回已持久化的同一回合结果,尚无结果时复用原用户消息继续规划;文本或附件身份不同则返回 `409`,不得再次追加用户消息或调用 LLM。 ## 生成结果落画板(对现有占位规则的例外) -- 对话入口触发的生成**不创建"即将生成"画布占位**(区别于其余生成面板);生成中状态由对话消息流承载。 -- 生成完成后:结果图按统一 placement 避让模型(视口中心就近、避开现有图层、32px 间距)落画板为新图层,同时登记到默认项目素材库,并在对话消息内显示纯缩略图;前端收到 `generation_result` 后立即刷新工程快照与素材库,缩略图本身不显示名称也不承担图层跳转。 +- 对话入口触发的生成**不创建"即将生成"画布占位**(区别于其余生成面板);生成中状态由工具消息和外部任务状态承载。 +- 生成完成后:结果图按统一 placement 避让模型(视口中心就近、避开现有图层、32px 间距)落画板为新图层,同时登记到默认项目素材库;前端轮询到任务终态并重新读取会话后,以回填的轻量媒体引用显示纯缩略图并刷新工程快照与素材库,缩略图本身不显示名称也不承担图层跳转。 - 消息内生成结果缩略图必须携带并优先使用 `objectKey` / `assetObjectId`,前端通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签后渲染,不能把裸 `/generated-*` 私有路径直接交给 ``。 -- 当前第一阶段通过既有编辑器生成 BFF 的 `canvasCompletion` 写回工程快照;刷新后异步任务恢复和轮询回填属于后续能力,不在本阶段声明为已完成。 +- 既有编辑器 worker 通过 `canvasCompletion` 写回工程快照;刷新后由 external generation task 状态和会话懒回填恢复结果。 - 该例外已同步登记在《生成类面板Lovart统一改造方案-2026-06-17》「画布占位落点」节。 ## 右侧布局 - 对话框与既有任务侧栏(`ImageCanvasTaskSidebarView`)**互斥展开**:展开一个自动收起另一个;各自收起后保留入口按钮。 -- 对话框与左侧素材 / 图层侧栏也互斥:打开画布 Agent 时收起左侧栏;再次打开素材、图层或任务侧栏时收起 Agent 面板。 +- 对话框与左侧素材 / 图层侧栏**不互斥**,允许同时展开,便于在对话中选取和核对画布素材;左侧栏切换不改变 Agent 面板开关状态。 - 桌面端对话框固定宽约 360–400px;移动端抽屉式全宽覆盖;收起态为胶囊/圆形入口按钮。 - 会话管理入口在对话框头部:当前会话标题 + 历史会话下拉(按更新时间倒序)+ 新建对话按钮,全部包在对话框内。 -- 收起对话框只是隐藏面板,不卸载当前会话 hook;流式回复、`生成中` 阶段和停止按钮状态必须在收起 / 重新打开之间保持一致。 +- 快速切换会话或会话轮询刷新产生并发详情请求时,前端只允许最后发起的请求更新当前会话、消息、错误和加载态;旧响应不得覆盖用户最新选择。 +- 普通 JSON 消息请求的回包必须绑定发送时的会话:用户在等待期间切换到其他会话后,只更新原会话的列表摘要,不得把原会话的 `deltaMessages` 、错误或画布刷新副作用应用到当前面板。 +- 收起对话框只是隐藏面板,不卸载当前会话 hook;普通 JSON 消息请求的等待态和外部生成任务状态必须在收起 / 重新打开之间保持一致。 ## 附件 @@ -68,11 +74,29 @@ - 应用后附件以胶囊 chip 挂在输入框上方;发出的消息内附件渲染为纯文本胶囊 chip(名称 + 小图标),**默认无缩略图,鼠标悬浮才浮出缩略图预览**。 - 附件领域形状:统一为画布资源 / 素材库对象引用(`resourceId` / `assetId` + 可选 `objectKey`),不存在只属于对话的第三种图;单条消息上限 9 张(前后端共同校验)。前端可携带展示用 `imageSrc` / `thumbnailSrc`,后端必须按当前工程和当前账号重新归一、校验归属与 `objectKey`。 +## 工具调用确认展示契约 + +- Agent 规划出生成或编辑工具后,先把 `status=not_completed` 且没有 `externalJobId` 的工具消息持久化为待确认记录;确认卡必须在真正调用生成 provider 前展示本次提示词、规格参数、目标图和参考图缩略图,用户确认后才执行,取消后保留同一条 `status=cancelled` 记录。内部 system 文本和图片哈希 ID 不直接展示给用户。 +- 工具消息只保存 `status`(`not_completed` / `completed` / `failed` / `cancelled`)和可选 `externalJobId`。`external_generation_job` 仍是队列、执行、lease 与计费结算真相;OSS status 仅表示该条对话消息是否已经回填完成结果或失败,不复制 queued / running。 +- 确认接口必须先把工具参数转换为既有编辑器 worker payload,再使用 `editor-agent:{conversationId}:{messageId}:{toolName}` 稳定 dedupe key 入队;同一确认的请求重试只能得到同一个 external job。入队成功后把返回的 job id 写回同一条 OSS 工具消息,不新增 Agent 工具执行关联表。 +- 前端根据 `externalJobId` 查询通用 external-generation job 状态;worker 继续通过 `canvasCompletion` 把生成结果写回工程与素材库。浏览器断线、刷新或 api-server 重启不得导致确认接口重新扣费或重新提交 provider。 +- `GET /conversation` 会在同一个 conversation lock 内扫描 `status=not_completed` 且已有 `externalJobId` 的工具消息:只对这些消息按 job id 定向读取主任务;任务完成后复用对应工具的 `format_execute_message` 替换 system text、回填轻量图片 / 视频 / 音频引用并写为 `completed`,任务失败则回填 `error` 并写为 `failed`。任务结果读取或 completed payload 解析 / formatter 回填失败时,必须在同一次 GET 内完成首次尝试及最多 3 次重试,三次重试各间隔 100ms 并重新读取任务结果;仍失败才把该工具消息写为 `failed` 并保存最后错误。该重试不依赖前端再次刷新。排队和执行中都保持 `not_completed`,整轮扫描结果一次性写回 OSS。 +- `EditorAgentToolCall.args` 保留为工具返回的原始 JSON,是确认接口重新反序列化并执行工具的唯一参数真相。图片参数继续只保存由真实 data key 计算出的 opaque SHA-256 `imageId`;不得为了前端预览把 `args` 中的图片 ID 改写成 `objectKey`、URL 或展示对象,也不得由前端重组或回传一份新的执行参数。 +- `EditorAgentToolCall.displayArgs` 是必填、只读的用户确认展示投影,与 `args` 分离: + - `stringArgs` 保存提示词、比例、清晰度、模型、时长等可展示参数的稳定名称、用户可见标题和值; + - `imageArgs` 按“目标图片 / 参考图片”等参数分组,每个 `refs` 项包含与原始参数对应的 `imageId`,以及后端从已校验会话上下文解析出的 `objectKey`、`imageSrc`、可选 `thumbnailSrc` / `label` / `width` / `height`。 + - `extras.priceMudPoints` 保存创建待确认消息时按后端运行时模型定价快照计算的预计泥点消耗;前端统一展示为“预计消耗 N泥点”,不自行计算价格。 +- `displayArgs` 只能由 api-server 按已注册 tool 白名单,基于已经通过 ToolArgs 校验的 `args` 和当前 OSS 会话文档中的附件 / 历史生成结果构建;不能信任 LLM 自报的展示地址、标题或素材元数据。展示投影不参与确认执行,确认接口仍只读取同一条持久化 tool call 的 `args`,避免“看到的素材”和“实际执行的素材”分叉。 +- `extras.priceMudPoints` 同样只属于展示投影,不作为扣费输入;确认后仍由既有生成 BFF 按后端运行时定价执行预扣费,因此该字段表达用户确认时看到的价格快照,而不是前端可提交或覆盖的计费真相。 +- `EditorAgentToolCall.summary` 只是 `args` 的重复字符串且没有稳定语义,当前契约删除该字段,不再作为展示或执行输入。 +- 前端待确认卡只消费必填 `displayArgs`,不解析各 tool 私有的 snake_case / camelCase schema,也不把 `sha256:*` ID 当标题或图片地址。图片统一通过 `ResolvedAssetImage` 使用 `objectKey` 换签后显示,签名 URL 不进入消息文档。模块尚未上线,不保留缺少 `displayArgs` 时读取 raw `args` 的旧消息降级路径。 + ## LLM 与计费 -- 编排复用 `creative_agent_gpt5_client` 的 LLM 接入配置(同 provider/env,独立用途标识),画布 Agent 规划请求固定使用 VectorEngine `gpt-5.4-mini` Chat Completions;function-calling 注册五类工具。 -- 每个用户回合必须由 LLM 返回结构化计划;LLM 未配置、请求失败或返回格式不可解析时,后端写入明确错误消息,不使用本地关键词或“收到:...”回显兜底。 -- 规划 prompt 必须自动带入上一条已完成生成结果的 `latestGeneratedImage` 引用,内容只包含上一轮 generation 的 `summary` / `toolName` / `resourceId` / `objectKey` / `assetObjectId` 等轻量元数据,不把私有签名 URL 或大图内容塞进 prompt。 +- 编排复用 `creative_agent_gpt5_client` 的 LLM 接入配置(同 provider/env,独立用途标识),画布 Agent 规划请求固定使用 VectorEngine `gpt-5.4-mini` Chat Completions;function-calling 注册八类工具。 +- 每个用户回合必须由 LLM 返回结构化计划;LLM 未配置、请求失败或返回格式不可解析时,后端写入正文为 `ERROR <错误内容>` 的 system 消息,不使用本地关键词或“收到:...”回显兜底。该错误消息与其它 system 消息一样进入后续 LLM memory,使 Agent 能看到上一轮失败上下文。 +- 规划 prompt 必须自动带入上一条已完成生成结果的 `latestGeneratedImage` 引用,内容只包含上一轮 generation 的 `toolName` / `resourceId` / `objectKey` / `assetObjectId` 等轻量元数据,不把私有签名 URL 或大图内容塞进 prompt。 +- 工具参数中的图片 ID 是由真实 object key 或图片地址计算的稳定 SHA-256 标识;真实 data key 仅存于 api-server 的工具上下文映射,所有图片工具在执行时查表恢复,不能把 object key 或图片地址作为 LLM 可见的工具 ID。 - 用户使用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代或编辑上一张结果图时,LLM 默认选择 `edit_image` 并引用 `latestGeneratedImage` 作为源图;除非用户明确要求全新生成,否则不能因为本轮没有重新上传附件而降级为 `generate_image`。 - 规划 prompt 必须显式区分“规范展板”和“实际素材产出”:规范图、视觉规范图、风格规范图、素材规范展板、角色规范图等规范展板请求走 `generate_image`,并补齐统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等要求;实际角色立绘才走 `generate_character`,多个图标素材 / 图集才走 `generate_icon_spritesheet`。 - 画布 Agent 规划请求使用 Chat Completions、1024 `max_tokens` 和 60 秒 Agent 专用请求超时;生成图片/编辑图片仍走对应生成工具和模型计费。 @@ -83,13 +107,13 @@ 做(第一期): -1. 助手文本 SSE 流式输出; -2. 阶段提示行(思考中 → 思考完成 → 生成中 → 完成/失败); +1. 助手文本随普通 JSON 消息响应一次性返回; +2. 消息请求等待态,以及工具任务的待确认、生成中、完成 / 失败状态; 3. 工具/模型标注行(生成时显示模型名 + 图标); 4. 消息内生成结果缩略图(纯预览,不显示名称,不点击聚焦图层); 5. 生成中的进行中动画; 6. 错误气泡(失败/余额不足,带原因); -7. 发送中断:进行中时发送按钮变「停止」,可中断当前回合(已提交的生成任务不追回,照常落画板)。 +7. 普通消息请求等待期间禁用发送按钮,不提供客户端停止操作;前端持续等待后端响应,避免后端已持久化消息但前端中断请求后产生会话状态错位。 不做(明确排除,防止后人补齐): @@ -113,22 +137,22 @@ - `api-server`: - `GET/POST /api/editor/projects/{projectId}/agent-conversations`(列表/新建); - `GET/DELETE /api/editor/agent-conversations/{conversationId}`(详情/软删); - - `POST /api/editor/agent-conversations/{conversationId}/messages/stream`(SSE); + - `POST /api/editor/agent-conversations/{conversationId}/messages`(JSON); - Agent 编排(function-calling 循环、工具内部调既有生成执行链路)放 api-server 编排层,独立文件,不复用 `creative_agent.rs` 内存会话。 -- `shared-contracts` + `packages/shared`:新 `editorAgent` DTO 与 SSE 事件契约(`stage`、`message_delta`、`tool_started`、`tool_completed`、`generation_result`、`error`、`done`)。 +- `shared-contracts` + `packages/shared`:`editorAgent` 会话、消息、工具确认展示与轻量媒体结果 DTO;消息响应返回 `conversation`、`deltaMessages` 和可选 `errorMessage`。 ## 实施顺序 1. 契约与领域规则(shared-contracts / packages/shared + module-editor-agent); 2. 存储层(spacetime-module 表 + procedure + migration + spacetime-client + schema check); -3. api-server 会话 CRUD + 消息 OSS 读写 + SSE 回显桩(不接 LLM,先保证会话链路端到端真实落库); +3. api-server 会话 CRUD + 消息 OSS 读写 + JSON 回显桩(不接 LLM,先保证会话链路端到端真实落库); 4. 前端最小纵切(对话框、会话管理、消息流、附件弹窗、侧边栏/小地图默认值)——可与 3 并行:3 只碰 `server-rs/`,4 只碰 `src/` 且先以契约 mock 客户端联调,汇合点在 4 末接真实 API; -5. LLM 编排 + 五类工具接入 + 生成落画板 + 停止/错误态(替换回显桩这一个点); +5. LLM 编排 + 八类工具接入 + 生成落画板 + 请求等待 / 错误态(替换回显桩这一个点); 6. 验证与文档:定向测试、类型检查、`npm run check:encoding`、`git diff --check`、`npm run check:spacetime-schema`、api-server smoke `/healthz`;同步 `CONTEXT.md` 与相关文档。 ## 第一阶段验收补充 -- 打开画布 Agent 后,任务侧栏和左侧素材 / 图层面板应关闭;再次打开任务侧栏或素材 / 图层面板时,Agent 面板应关闭。 -- 发送消息时先本地追加用户消息,再消费 SSE 增量;停止按钮只中断当前 SSE 回合,不追回已经提交的生成工具调用。 -- Agent 消息内生成结果缩略图只用于预览,不显示名称,也不点击跳转图层;收到 `generation_result` 时统一刷新工程快照和素材库。 +- 打开画布 Agent 后任务侧栏应关闭,再次打开任务侧栏时 Agent 面板应关闭;素材 / 图层面板与 Agent 可同时展开,互不改写开关状态。 +- 发送消息时先本地追加用户消息,再应用 JSON 响应中的 `deltaMessages`;请求等待期间发送按钮保持禁用,前端不主动中断当前回合。 +- Agent 消息内生成结果缩略图只用于预览,不显示名称,也不点击跳转图层;轮询到任务终态并完成会话懒回填后统一刷新工程快照和素材库。 - 对话内容可被用户选中复制;用户从输入框或对话内容点击回画布图层 / 生成器时,焦点应回到画布对象,Backspace / Delete 等画布快捷键继续生效。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index 184972293..e57378037 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -147,9 +147,9 @@ ### Prompt 与生成契约 - 前端提交到 `POST /api/editor/character-animations/generations`。 -- 请求必须带上角色图片来源、原始尺寸、动画描述、分辨率、画面比例、帧数和时长,并可通过 `assetFolderId` 指定中间视频素材落点。角色图片已经持久化到 OSS 时,`sourceImageSrc` 必须优先传 `objectKey`;只有未持久化的本地临时图片才允许传 Data URL。 +- 请求必须带上角色图片来源、原始尺寸、动画描述、分辨率、画面比例、帧数和时长,并可通过 `assetFolderId` 指定中间视频素材落点。`sourceImageSrc` 只允许传当前账号的 `objectKey`、项目资源 ID 或素材 ID;未持久化的本地临时图片必须先上传 OSS,不能提交 Data URL 或 Blob URL。后端解析稳定引用后必须校验对象属于当前 owner,再读取 OSS 内容。 - 后端使用角色图片作为首帧和尾帧参考,模型固定映射到 `doubao-seedance-2-0-fast-260128`。 -- 后端路由兼容旧 Data URL 请求并单独放宽 JSON body limit 到 `12MB`,但该限额只作为兼容兜底,不作为新链路默认传大图的方式。 +- 后端在内联执行和队列入队前都会拒绝 Data URL / Blob URL;路由使用默认 JSON body limit,不再为内联图片请求单独放宽。 - 后端 prompt 使用以下固定骨架,并把面板输入追加到 `动作描述:` 后: ```text diff --git a/jenkins/Jenkinsfile.production-api-build b/jenkins/Jenkinsfile.production-api-build index a2de704af..c3e8d7608 100644 --- a/jenkins/Jenkinsfile.production-api-build +++ b/jenkins/Jenkinsfile.production-api-build @@ -125,7 +125,11 @@ pipeline { echo "[api-build] 未找到 sccache,改用 rustc 直接构建。" unset RUSTC_WRAPPER fi + npm run check:encoding npm run check:rustfmt + npm run check:production-ops + npm run check:server-rs-ddd + cargo check -p api-server --all-targets --manifest-path server-rs/Cargo.toml pingora_args=() if [[ "${INCLUDE_PINGORA_GATEWAY:-false}" == "true" ]]; then pingora_args+=(--include-pingora-gateway) diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision index 2d2e550ba..4163a132b 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -25,7 +25,7 @@ pipeline { string(name: 'PROVISION_DOWNLOADS_DIR', defaultValue: 'provision-tool-downloads', description: '目标服务器工作区内暂存 SpacetimeDB/otelcol 安装包的相对目录') string(name: 'PROVISION_TOOLS_DIR', defaultValue: 'provision-tools', description: '目标机工作区内由已下载安装包生成的工具包目录') string(name: 'PROVISION_DOWNLOAD_PROXY', defaultValue: '', description: '可选,目标服务器下载 SpacetimeDB 和 otelcol-contrib 时使用的代理地址,例如 http://127.0.0.1:7890;留空不设置代理') - string(name: 'SPACETIME_DOWNLOAD_ROOT', defaultValue: 'https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.6.0', description: '目标服务器使用的 SpacetimeDB Linux release tarball 根地址;默认固定到项目锁定版本') + string(name: 'SPACETIME_DOWNLOAD_ROOT', defaultValue: 'https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.6.1', description: '目标服务器使用的 SpacetimeDB Linux release tarball 根地址;默认固定到项目锁定版本') string(name: 'SPACETIME_TARGET_HOST', defaultValue: 'x86_64-unknown-linux-gnu', description: 'SpacetimeDB 预编译包 host triple,development/release Linux amd64 使用默认值') string(name: 'SPACETIME_ROOT', defaultValue: '/stdb', description: 'SpacetimeDB root-dir') string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: 'release 根目录') @@ -209,7 +209,7 @@ BASH OTELCOL_VERSION="${OTELCOL_VERSION:-0.151.0}" \ PREPARE_OTELCOL="${ENABLE_OTELCOL:-true}" \ PROVISION_DOWNLOAD_PROXY="${PROVISION_DOWNLOAD_PROXY:-}" \ - SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.6.0}" \ + SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.6.1}" \ SPACETIME_TARGET_HOST="${SPACETIME_TARGET_HOST:-x86_64-unknown-linux-gnu}" \ SPACETIME_ROOT="${SPACETIME_ROOT:-/stdb}" \ scripts/prepare-server-provision-tools.sh diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build index 2383e2420..3f3894c68 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-build +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -129,7 +129,11 @@ pipeline { echo "[stdb-build] 未找到可用 sccache,改用 rustc 直接构建。" unset RUSTC_WRAPPER fi + npm run check:encoding npm run check:rustfmt + npm run check:production-ops + npm run check:server-rs-ddd + npm run check:admin-account-procedures SOURCE_BRANCH="${SOURCE_BRANCH}" SOURCE_COMMIT="${SOURCE_COMMIT}" \ npm run build:production-release -- --component spacetime-module --name "${EFFECTIVE_BUILD_VERSION}" ' diff --git a/jenkins/Jenkinsfile.production-web-build b/jenkins/Jenkinsfile.production-web-build index b53b13b9f..9a543a9a6 100644 --- a/jenkins/Jenkinsfile.production-web-build +++ b/jenkins/Jenkinsfile.production-web-build @@ -95,6 +95,12 @@ pipeline { sh ''' bash -lc ' set -euo pipefail + npm run check:encoding + npm run check:production-ops + npm run lint:eslint + npm run typecheck + npm run admin-web:typecheck + npm run test SOURCE_BRANCH="${SOURCE_BRANCH}" SOURCE_COMMIT="${SOURCE_COMMIT}" \ npm run build:production-release -- --component web --name "${EFFECTIVE_BUILD_VERSION}" ' diff --git a/logs/codex-mobile-dock-fix.png b/logs/codex-mobile-dock-fix.png deleted file mode 100644 index 3d22149f5..000000000 Binary files a/logs/codex-mobile-dock-fix.png and /dev/null differ diff --git a/logs/llm-raw/1777207823146-31804-000001-read_stream_failed.input.json b/logs/llm-raw/1777207823146-31804-000001-read_stream_failed.input.json deleted file mode 100644 index 1d5b16b8f..000000000 --- a/logs/llm-raw/1777207823146-31804-000001-read_stream_failed.input.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "provider": "ark", - "model": "doubao-1-5-pro-32k-character-250715", - "stream": true, - "attempt": 1, - "maxTokens": null, - "messages": [ - { - "role": "system", - "content": "你是一个负责和创作者共创拼图画面的中文创意策划。\n\n你要帮助用户把一句灵感逐步收束成可以发布成拼图关卡的视觉方案。\n\n你必须同时输出:\n1. 一段直接发给用户的中文回复 replyText\n2. 当前进度 progressPercent\n3. 下一轮完整可用的 nextAnchorPack\n\n硬约束:\n1. 只能输出 JSON,不能输出代码块或解释\n2. nextAnchorPack 必须是完整对象,不能只输出 patch\n3. replyText 必须是自然中文,不能提“字段”“锚点”“结构”“JSON”等内部词\n4. replyText 一次最多推进一个最关键问题\n5. 如果用户已经给出明确方向,就优先吸收和收束,不要机械反问\n6. progressPercent 范围只能是 0 到 100\n7. status 只能使用 missing / inferred / confirmed / locked\n\n\n模板目标:收束成可以发布为拼图关卡的视觉方案。\n\n本模板只通过以下锚点问题体现差异:\n1. 题材承诺(themePromise / 拼图共创):这张拼图给玩家的题材和完成期待是什么?;达成效果:明确拼图主题、辨识度和完成后的满足感。\n2. 画面主体(visualSubject / 拼图共创):画面中最需要被玩家一眼看懂的主体是什么?;达成效果:明确主体、层级和可被切片识别的形状。\n3. 视觉气质(visualMood / 拼图共创):整体色彩、光线、情绪和美术风格是什么?;达成效果:收束画面风格,避免结果图风格漂移。\n4. 拼图记忆点(compositionHooks / 拼图共创):哪些构图、纹理或局部细节会成为玩家拼接线索?;达成效果:提供适合拼图玩法的边缘、块面和局部记忆点。\n5. 标签与禁忌(tagsAndForbidden / 拼图共创):需要保留哪些关键词,又必须避开什么内容?;达成效果:锁定生成标签和负向约束。\n\n用户刚刚主动要求你自动补充剩余关键字。\n\n这表示用户接受你基于当前方向自行补完仍缺失的关键设定:当前题材方向里的拼图关键词\n\n本轮要求:\n1. 不要再继续提问\n2. 不要要求用户再提供素材、风格或禁忌\n3. 必须保留已有已确认内容,并直接补齐缺失或仍为空的关键项\n4. 对你自行推断补齐的项,应标记或表达为系统推断;已有明确内容继续保持确认或锁定状态\n5. progressPercent 直接输出为 100\n6. 输出完整 nextAnchorPack,直接补齐 value 为空或 status 为 missing 的项\n7. replyText 只做简短完成说明,引导用户可以生成结果页,不能出现问号\n\n当前是第 3 轮,当前进度 10% 。\n\n是否要求自动补充剩余关键字:是\n\n当前 anchor pack:\n{\n \"theme_promise\": {\n \"key\": \"themePromise\",\n \"label\": \"题材承诺\",\n \"value\": \"可爱小狗的温馨瞬间\",\n \"status\": \"Missing\"\n },\n \"visual_subject\": {\n \"key\": \"visualSubject\",\n \"label\": \"画面主体\",\n \"value\": \"\",\n \"status\": \"Missing\"\n },\n \"visual_mood\": {\n \"key\": \"visualMood\",\n \"label\": \"视觉气质\",\n \"value\": \"\",\n \"status\": \"Missing\"\n },\n \"composition_hooks\": {\n \"key\": \"compositionHooks\",\n \"label\": \"拼图记忆点\",\n \"value\": \"\",\n \"status\": \"Missing\"\n },\n \"tags_and_forbidden\": {\n \"key\": \"tagsAndForbidden\",\n \"label\": \"标签与禁忌\",\n \"value\": \"\",\n \"status\": \"Missing\"\n }\n}\n\n最近聊天记录:\n[\n {\n \"content\": \"先告诉我你想做一张什么样的拼图图像,我会帮你收束成可发布的题材锚点。\",\n \"kind\": \"chat\",\n \"role\": \"assistant\"\n },\n {\n \"content\": \"可爱小狗\",\n \"kind\": \"chat\",\n \"role\": \"user\"\n },\n {\n \"content\": \"可爱小狗确实是很受欢迎的拼图主题,那你希望这张拼图里的小狗在做什么呢?是玩耍、睡觉,还是其他的状态?\",\n \"kind\": \"chat\",\n \"role\": \"assistant\"\n },\n {\n \"content\": \"请补充剩余设定。\",\n \"kind\": \"chat\",\n \"role\": \"user\"\n }\n]\n\n请严格按以下 JSON 输出,不要输出其他文字:\n{\n \"replyText\": \"\",\n \"progressPercent\": 0,\n \"nextAnchorPack\": {\n \"themePromise\": {\n \"key\": \"themePromise\",\n \"label\": \"题材承诺\",\n \"value\": \"\",\n \"status\": \"missing\"\n },\n \"visualSubject\": {\n \"key\": \"visualSubject\",\n \"label\": \"画面主体\",\n \"value\": \"\",\n \"status\": \"missing\"\n },\n \"visualMood\": {\n \"key\": \"visualMood\",\n \"label\": \"视觉气质\",\n \"value\": \"\",\n \"status\": \"missing\"\n },\n \"compositionHooks\": {\n \"key\": \"compositionHooks\",\n \"label\": \"拼图记忆点\",\n \"value\": \"\",\n \"status\": \"missing\"\n },\n \"tagsAndForbidden\": {\n \"key\": \"tagsAndForbidden\",\n \"label\": \"标签与禁忌\",\n \"value\": \"\",\n \"status\": \"missing\"\n }\n }\n}" - }, - { - "role": "user", - "content": "请按约定输出这一轮的 JSON。" - } - ] -} \ No newline at end of file diff --git a/logs/llm-raw/1777207823146-31804-000001-read_stream_failed.output.txt b/logs/llm-raw/1777207823146-31804-000001-read_stream_failed.output.txt deleted file mode 100644 index 160344688..000000000 --- a/logs/llm-raw/1777207823146-31804-000001-read_stream_failed.output.txt +++ /dev/null @@ -1,536 +0,0 @@ -data: {"choices":[{"delta":{"content":"{","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"reply","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"Text","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"好","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"的","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":",","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"根据","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"你的","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"要求","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":",","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"我","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"为","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"你","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"补齐","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"了","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"拼图","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"的","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"设定","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"。","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"主题","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"是","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"‘","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"可爱","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"小狗","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"的","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"温馨","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"瞬间","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"’","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":",","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"画面","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"主体","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"是","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"一只","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"正在","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"打","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"盹","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"的","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"金毛","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"犬","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":",","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"躺在","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"柔软","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"的","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"地毯","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"上","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":",","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"旁边","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"有","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"一个","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"毛绒","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"玩具","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"。","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"视觉","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"气质","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"是","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"温暖","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"、","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"柔和","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"的","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"色调","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":",","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"整体","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"给","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"人","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"一种","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"温馨","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"、","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"舒适","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"的","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"感觉","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"。","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"拼图","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"记忆","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"点","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"可以","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"是","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"小狗","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"的","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"轮廓","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"、","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"毛发","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"纹理","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"、","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"玩具","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"的","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"形状","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"和","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"颜色","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"等","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"。","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"标签","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"为","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"‘","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"小狗","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"’","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"‘","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"温馨","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"’","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"‘","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"可爱","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"’","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":",","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"禁忌","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"是","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"避免","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"出现","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"血腥","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"、","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"暴力","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"、","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"恐怖","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"等","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"内容","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"。","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"你","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"可以","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"根据","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"这些","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"设定","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"生成","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"结果","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"页","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"了","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"。","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\",","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"progress","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"Percent","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"1","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"0","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"0","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":",","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"next","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"Anchor","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"Pack","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" {","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"theme","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"Promise","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" {","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"key","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"theme","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"Promise","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\",","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"label","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"题材","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"承诺","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\",","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"value","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"可爱","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"小狗","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"的","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"温馨","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"瞬间","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\",","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"status","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"confirmed","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" },","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"visual","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"Subject","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" {","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"key","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"visual","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"Subject","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\",","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"label","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"画面","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"主体","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\",","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"value","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"一只","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"正在","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"打","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"盹","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"的","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"金毛","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"犬","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":",","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"躺在","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"柔软","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"的","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"地毯","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"上","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":",","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"旁边","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"有","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"一个","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"毛绒","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"玩具","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\",","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"status","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"confirmed","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" },","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" ","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" \"","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"visual","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"M","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"ood","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\":","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":" {","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - -data: {"choices":[{"delta":{"content":"\n","role":"assistant"},"index":0}],"created":1777207793,"id":"02177720779340786f58f29daee4140bdf4a6e6f2c9a77d870731","model":"doubao-1-5-pro-32k-character-250715","service_tier":"default","object":"chat.completion.chunk","usage":null} - diff --git a/logs/llm-raw/1777642667085-45584-000001-parse_stream_failed.input.json b/logs/llm-raw/1777642667085-45584-000001-parse_stream_failed.input.json deleted file mode 100644 index 88d1a6c50..000000000 --- a/logs/llm-raw/1777642667085-45584-000001-parse_stream_failed.input.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "provider": "ark", - "protocol": "responses", - "model": "deepseek-v3-2-251201", - "stream": true, - "attempt": 1, - "maxTokens": null, - "messages": [ - { - "role": "system", - "content": "你是一个负责共创游戏世界设定的专业策划。\n\n你正在和用户一起共创一个游戏世界。每一轮你都必须读取:\n1. 当前完整设定结构\n2. 用户聊天记录\n\n然后输出:\n1. 一版新的完整设定结构\n2. 当前 progress 百分比\n3. 一段直接回复用户的话\n\n你必须把“新的完整设定结构”视为下一轮的唯一有效版本。\n你的输出会直接覆盖上一版设定结构。\n\n你不是在做局部 patch。\n你不是在做解释报告。\n你不是在给开发者写分析。\n你是在同时完成:\n1. 世界设定更新\n2. 当前推进程度判断\n3. 对用户的共创回复\n\n全局硬约束:\n\n1. 必须输出完整的设定结构,而不是只输出变化部分。\n2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。\n3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。\n4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。\n5. progressPercent 最低为 0,不允许为负数。\n6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。\n7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。\n8. replyText 不要写成长篇策划文,不要展开大段世界观百科。\n9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。\n10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。\n11. 你输出的 JSON 必须可以被直接解析。\n12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。\n\n当前模式:bootstrap\n\n目标:\n1. 先把世界的基本方向抓住\n2. 不要一次塞太多新设定\n3. 回复要降低用户开口压力\n\n本轮行为要求:\n1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索\n2. 如果用户信息很少,不要强行把整套结构一次补满\n3. replyText 要像共创搭档,而不是像审问\n4. 默认只推进一个最关键的问题方向\n5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步\n6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题\n7. 不要把问题问得像表单采集,不要一口气追问多个维度\n\n用户体验要求:\n1. 让用户觉得“现在很容易继续往下说”\n2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉\n3. replyText 最好短、稳、可接话\n4. 如果用户信息很少,也不要显得冷淡或机械\n\n本轮用户输入较少或较虚。\n请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。\nreplyText 要让用户容易继续往下说。\n\n上一轮预判得到的创作状态如下。\n正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。\n\n创作状态:\n- userInputSignal: sparse\n- driftRisk: low\n- conversationMode: bootstrap\n- judgementSummary: 用户仅提供了一个非常概括的题材词,信息密度极低,处于最初始阶段。正式生成时应使用启发式提问,提出一个最易于回答、最能推动设计落地的问题,比如关于这个王国的性质或主角身份。\n\n当前完整设定结构如下。\n你必须把它视为上一版有效世界底子。\n\n如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。\n如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。\n\n当前完整设定结构:\n{\n \"worldPromise\": null,\n \"playerFantasy\": null,\n \"themeBoundary\": null,\n \"playerEntryPoint\": null,\n \"coreConflict\": null,\n \"keyRelationships\": null,\n \"hiddenLines\": null,\n \"iconicElements\": null\n}\n\n以下是用户聊天记录。\n请重点理解最近几轮里用户新增、修正、强调的设定信息。\n不要把早期已经被用户否定的内容继续当成最终结论。\n\n用户聊天记录:\n[\n {\n \"content\": \"我会先帮你把世界的核心锚点整理出来。你可以从世界钩子、玩家身份、主题氛围、核心冲突、关键关系或标志性元素开始。\",\n \"role\": \"assistant\"\n },\n {\n \"content\": \"玩具王国\",\n \"role\": \"user\"\n }\n]\n\n请严格按以下 JSON 结构输出,不要输出其他文字:\n{\n \"replyText\": \"\",\n \"progressPercent\": 0,\n \"nextAnchorContent\": {\n \"worldPromise\": \"\",\n \"playerFantasy\": \"\",\n \"themeBoundary\": \"\",\n \"playerEntryPoint\": \"\",\n \"coreConflict\": \"\",\n \"keyRelationships\": \"\",\n \"hiddenLines\": \"\",\n \"iconicElements\": \"\"\n }\n}\n\nnextAnchorContent 的 8 个锚点每个都只能是一个字符串或 null,不允许输出对象或数组。\n请把每个锚点写成一段凝练中文:\n- worldPromise 关注世界钩子、差异点、玩家体验。\n- playerFantasy 关注玩家身份、核心追求、失去风险。\n- themeBoundary 关注主题气质、美术方向、禁用方向。\n- playerEntryPoint 关注开局身份、开局问题、行动动机。\n- coreConflict 关注表层冲突、隐藏危机、首次触发点。\n- keyRelationships 关注关键人物关系、关系类型、代价或秘密。\n- hiddenLines 关注隐藏真相、误导线索、揭示节奏。\n- iconicElements 关注标志意象、组织/物件、硬规则。\n" - }, - { - "role": "user", - "content": "请按约定输出这一轮的 JSON。" - } - ] -} \ No newline at end of file diff --git a/logs/llm-raw/1777642667085-45584-000001-parse_stream_failed.output.txt b/logs/llm-raw/1777642667085-45584-000001-parse_stream_failed.output.txt deleted file mode 100644 index 6397774ef..000000000 --- a/logs/llm-raw/1777642667085-45584-000001-parse_stream_failed.output.txt +++ /dev/null @@ -1,131 +0,0 @@ -event: response.created -data: {"type":"response.created","response":{"created_at":1777642663,"id":"resp_021777642661795d15b81097ef4f266330d7ca8118c9494434eb3","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777901861},"sequence_number":0} - -event: response.in_progress -data: {"type":"response.in_progress","response":{"created_at":1777642663,"id":"resp_021777642661795d15b81097ef4f266330d7ca8118c9494434eb3","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777901861},"sequence_number":1} - -event: response.output_item.added -data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","status":"in_progress","id":"msg_02177764266332000000000000000000000ffffac1550372ec101"},"sequence_number":2} - -event: response.content_part.added -data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"part":{"type":"output_text","text":""},"sequence_number":3} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"我需要","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":4} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"先","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":5} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"了解","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":6} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"一些","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":7} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"关于","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":8} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":9} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"玩具","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":10} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"王国","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":11} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":12} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"的","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":13} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"创作","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":14} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"背景","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":15} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"和","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":16} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"灵感","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":17} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"来源","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":18} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":19} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"以便","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":20} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"更好地","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":21} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"帮助你","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":22} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"构建","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":23} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"这个","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":24} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"游戏","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":25} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"世界","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":26} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"。","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":27} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"让我","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":28} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"搜索","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":29} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"一些","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":30} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"相关信息","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":31} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"。\n\n","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":32} - -event: response.output_text.done -data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"text":"我需要先了解一些关于\"玩具王国\"的创作背景和灵感来源,以便更好地帮助你构建这个游戏世界。让我搜索一些相关信息。\n\n","sequence_number":33} - -event: response.content_part.done -data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"part":{"type":"output_text","text":"我需要先了解一些关于\"玩具王国\"的创作背景和灵感来源,以便更好地帮助你构建这个游戏世界。让我搜索一些相关信息。\n\n"},"sequence_number":34} - -event: response.output_item.done -data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要先了解一些关于\"玩具王国\"的创作背景和灵感来源,以便更好地帮助你构建这个游戏世界。让我搜索一些相关信息。\n\n"}],"status":"completed","id":"msg_02177764266332000000000000000000000ffffac1550372ec101"},"sequence_number":35} - -event: response.output_item.added -data: {"type":"response.output_item.added","output_index":1,"item":{"type":"web_search_call","status":"in_progress","id":"ws_02177764266480300000000000000000000ffffac155037f1bc06"},"sequence_number":36} - -event: response.web_search_call.in_progress -data: {"type":"response.web_search_call.in_progress","item_id":"ws_02177764266480300000000000000000000ffffac155037f1bc06","output_index":1,"sequence_number":37} - -event: response.web_search_call.searching -data: {"type":"response.web_search_call.searching","item_id":"ws_02177764266480300000000000000000000ffffac155037f1bc06","output_index":1,"sequence_number":38} - -event: response.web_search_call.completed -data: {"type":"response.web_search_call.completed","item_id":"ws_02177764266480300000000000000000000ffffac155037f1bc06","output_index":1,"sequence_number":39} - -event: response.output_item.done -data: {"type":"response.output_item.done","output_index":1,"item":{"type":"web_search_call","action":{"query":"玩具王国 游戏设定 世界观;玩具题材游戏 设计灵感;王国主题玩具世界","type":"search"},"status":"completed","id":"ws_02177764266480300000000000000000000ffffac155037f1bc06"},"sequence_number":40} - -event: error -data: {"type":"error","code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin","param":"","sequence_number":41} - -event: response.failed -data: {"type":"response.failed","response":{"created_at":1777642663,"error":{"code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin"},"id":"resp_021777642661795d15b81097ef4f266330d7ca8118c9494434eb3","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要先了解一些关于\"玩具王国\"的创作背景和灵感来源,以便更好地帮助你构建这个游戏世界。让我搜索一些相关信息。\n\n"}],"status":"completed","id":"msg_02177764266332000000000000000000000ffffac1550372ec101"},{"type":"web_search_call","action":{"query":"玩具王国 游戏设定 世界观;玩具题材游戏 设计灵感;王国主题玩具世界","type":"search"},"status":"completed","id":"ws_02177764266480300000000000000000000ffffac155037f1bc06"}],"service_tier":"default","status":"failed","tools":[{"type":"web_search","max_keyword":3}],"usage":{"input_tokens":1791,"output_tokens":122,"total_tokens":1913,"input_tokens_details":{"cached_tokens":0},"output_tokens_details":{"reasoning_tokens":0}},"caching":{"type":"disabled"},"store":true,"expire_at":1777901861},"sequence_number":42} - -data: [DONE] - diff --git a/logs/llm-raw/1777644758257-49212-000001-parse_stream_failed.input.json b/logs/llm-raw/1777644758257-49212-000001-parse_stream_failed.input.json deleted file mode 100644 index ca3df2cf8..000000000 --- a/logs/llm-raw/1777644758257-49212-000001-parse_stream_failed.input.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "provider": "ark", - "protocol": "responses", - "model": "deepseek-v3-2-251201", - "stream": true, - "attempt": 1, - "maxTokens": null, - "messages": [ - { - "role": "system", - "content": "你是一个负责共创游戏世界设定的专业策划。\n\n你正在和用户一起共创一个游戏世界。每一轮你都必须读取:\n1. 当前完整设定结构\n2. 用户聊天记录\n\n然后输出:\n1. 一版新的完整设定结构\n2. 当前 progress 百分比\n3. 一段直接回复用户的话\n\n你必须把“新的完整设定结构”视为下一轮的唯一有效版本。\n你的输出会直接覆盖上一版设定结构。\n\n你不是在做局部 patch。\n你不是在做解释报告。\n你不是在给开发者写分析。\n你是在同时完成:\n1. 世界设定更新\n2. 当前推进程度判断\n3. 对用户的共创回复\n\n全局硬约束:\n\n1. 必须输出完整的设定结构,而不是只输出变化部分。\n2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。\n3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。\n4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。\n5. progressPercent 最低为 0,不允许为负数。\n6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。\n7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。\n8. replyText 不要写成长篇策划文,不要展开大段世界观百科。\n9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。\n10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。\n11. 你输出的 JSON 必须可以被直接解析。\n12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。\n\n当前模式:bootstrap\n\n目标:\n1. 先把世界的基本方向抓住\n2. 不要一次塞太多新设定\n3. 回复要降低用户开口压力\n\n本轮行为要求:\n1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索\n2. 如果用户信息很少,不要强行把整套结构一次补满\n3. replyText 要像共创搭档,而不是像审问\n4. 默认只推进一个最关键的问题方向\n5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步\n6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题\n7. 不要把问题问得像表单采集,不要一口气追问多个维度\n\n用户体验要求:\n1. 让用户觉得“现在很容易继续往下说”\n2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉\n3. replyText 最好短、稳、可接话\n4. 如果用户信息很少,也不要显得冷淡或机械\n\n本轮用户输入较少或较虚。\n请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。\nreplyText 要让用户容易继续往下说。\n\n上一轮预判得到的创作状态如下。\n正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。\n\n创作状态:\n- userInputSignal: sparse\n- driftRisk: low\n- conversationMode: bootstrap\n- judgementSummary: 用户仅提供了核心题材词,信息非常稀疏,尚未形成任何具体方向。正式生成时,应避免空泛回应,必须采用一个启发式提问来推动设计落地,例如聚焦于玩家身份或世界氛围。\n\n当前完整设定结构如下。\n你必须把它视为上一版有效世界底子。\n\n如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。\n如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。\n\n当前完整设定结构:\n{\n \"worldPromise\": null,\n \"playerFantasy\": null,\n \"themeBoundary\": null,\n \"playerEntryPoint\": null,\n \"coreConflict\": null,\n \"keyRelationships\": null,\n \"hiddenLines\": null,\n \"iconicElements\": null\n}\n\n以下是用户聊天记录。\n请重点理解最近几轮里用户新增、修正、强调的设定信息。\n不要把早期已经被用户否定的内容继续当成最终结论。\n\n用户聊天记录:\n[\n {\n \"content\": \"我会先帮你把世界的核心锚点整理出来。你可以从世界钩子、玩家身份、主题氛围、核心冲突、关键关系或标志性元素开始。\",\n \"role\": \"assistant\"\n },\n {\n \"content\": \"玩具王国\",\n \"role\": \"user\"\n },\n {\n \"content\": \"玩具王国\",\n \"role\": \"user\"\n }\n]\n\n请严格按以下 JSON 结构输出,不要输出其他文字:\n{\n \"replyText\": \"\",\n \"progressPercent\": 0,\n \"nextAnchorContent\": {\n \"worldPromise\": \"\",\n \"playerFantasy\": \"\",\n \"themeBoundary\": \"\",\n \"playerEntryPoint\": \"\",\n \"coreConflict\": \"\",\n \"keyRelationships\": \"\",\n \"hiddenLines\": \"\",\n \"iconicElements\": \"\"\n }\n}\n\nnextAnchorContent 的 8 个锚点每个都只能是一个字符串或 null,不允许输出对象或数组。\n请把每个锚点写成一段凝练中文:\n- worldPromise 关注世界钩子、差异点、玩家体验。\n- playerFantasy 关注玩家身份、核心追求、失去风险。\n- themeBoundary 关注主题气质、美术方向、禁用方向。\n- playerEntryPoint 关注开局身份、开局问题、行动动机。\n- coreConflict 关注表层冲突、隐藏危机、首次触发点。\n- keyRelationships 关注关键人物关系、关系类型、代价或秘密。\n- hiddenLines 关注隐藏真相、误导线索、揭示节奏。\n- iconicElements 关注标志意象、组织/物件、硬规则。\n" - }, - { - "role": "user", - "content": "请按约定输出这一轮的 JSON。" - } - ] -} \ No newline at end of file diff --git a/logs/llm-raw/1777644758257-49212-000001-parse_stream_failed.output.txt b/logs/llm-raw/1777644758257-49212-000001-parse_stream_failed.output.txt deleted file mode 100644 index d1ee8032a..000000000 --- a/logs/llm-raw/1777644758257-49212-000001-parse_stream_failed.output.txt +++ /dev/null @@ -1,117 +0,0 @@ -event: response.created -data: {"type":"response.created","response":{"created_at":1777644754,"id":"resp_02177764475392548aafbb39a6fef3e9b38f2299115036678748a","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777903953},"sequence_number":0} - -event: response.in_progress -data: {"type":"response.in_progress","response":{"created_at":1777644754,"id":"resp_02177764475392548aafbb39a6fef3e9b38f2299115036678748a","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777903953},"sequence_number":1} - -event: response.output_item.added -data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","status":"in_progress","id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d"},"sequence_number":2} - -event: response.content_part.added -data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"part":{"type":"output_text","text":""},"sequence_number":3} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"我需要","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":4} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"先","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":5} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"搜索","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":6} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"一些","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":7} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"关于","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":8} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":9} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"玩具","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":10} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"王国","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":11} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":12} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"的相关","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":13} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"信息","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":14} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":15} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"以便","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":16} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"更好地","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":17} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"理解","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":18} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"这个概念","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":19} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":20} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"为","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":21} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"我们的","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":22} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"共创","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":23} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"提供","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":24} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"更有","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":25} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"价值的","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":26} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"参考","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":27} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"。\n\n","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":28} - -event: response.output_text.done -data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"text":"我需要先搜索一些关于\"玩具王国\"的相关信息,以便更好地理解这个概念,为我们的共创提供更有价值的参考。\n\n","sequence_number":29} - -event: response.content_part.done -data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"part":{"type":"output_text","text":"我需要先搜索一些关于\"玩具王国\"的相关信息,以便更好地理解这个概念,为我们的共创提供更有价值的参考。\n\n"},"sequence_number":30} - -event: response.output_item.done -data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要先搜索一些关于\"玩具王国\"的相关信息,以便更好地理解这个概念,为我们的共创提供更有价值的参考。\n\n"}],"status":"completed","id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d"},"sequence_number":31} - -event: response.output_item.added -data: {"type":"response.output_item.added","output_index":1,"item":{"type":"web_search_call","status":"in_progress","id":"ws_02177764475609200000000000000000000ffffac15dae8e54616"},"sequence_number":32} - -event: response.web_search_call.in_progress -data: {"type":"response.web_search_call.in_progress","item_id":"ws_02177764475609200000000000000000000ffffac15dae8e54616","output_index":1,"sequence_number":33} - -event: response.web_search_call.searching -data: {"type":"response.web_search_call.searching","item_id":"ws_02177764475609200000000000000000000ffffac15dae8e54616","output_index":1,"sequence_number":34} - -event: response.web_search_call.completed -data: {"type":"response.web_search_call.completed","item_id":"ws_02177764475609200000000000000000000ffffac15dae8e54616","output_index":1,"sequence_number":35} - -event: response.output_item.done -data: {"type":"response.output_item.done","output_index":1,"item":{"type":"web_search_call","action":{"query":"玩具王国 游戏设定;玩具王国 世界观;玩具主题 游戏","type":"search"},"status":"completed","id":"ws_02177764475609200000000000000000000ffffac15dae8e54616"},"sequence_number":36} - -event: error -data: {"type":"error","code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin","param":"","sequence_number":37} - -event: response.failed -data: {"type":"response.failed","response":{"created_at":1777644754,"error":{"code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin"},"id":"resp_02177764475392548aafbb39a6fef3e9b38f2299115036678748a","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要先搜索一些关于\"玩具王国\"的相关信息,以便更好地理解这个概念,为我们的共创提供更有价值的参考。\n\n"}],"status":"completed","id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d"},{"type":"web_search_call","action":{"query":"玩具王国 游戏设定;玩具王国 世界观;玩具主题 游戏","type":"search"},"status":"completed","id":"ws_02177764475609200000000000000000000ffffac15dae8e54616"}],"service_tier":"default","status":"failed","tools":[{"type":"web_search","max_keyword":3}],"usage":{"input_tokens":1808,"output_tokens":120,"total_tokens":1928,"input_tokens_details":{"cached_tokens":0},"output_tokens_details":{"reasoning_tokens":0}},"caching":{"type":"disabled"},"store":true,"expire_at":1777903953},"sequence_number":38} - diff --git a/logs/llm-raw/1777644798047-49212-000002-parse_stream_failed.input.json b/logs/llm-raw/1777644798047-49212-000002-parse_stream_failed.input.json deleted file mode 100644 index 5e3d8b4f8..000000000 --- a/logs/llm-raw/1777644798047-49212-000002-parse_stream_failed.input.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "provider": "ark", - "protocol": "responses", - "model": "deepseek-v3-2-251201", - "stream": true, - "attempt": 1, - "maxTokens": null, - "messages": [ - { - "role": "system", - "content": "你是一个负责共创游戏世界设定的专业策划。\n\n你正在和用户一起共创一个游戏世界。每一轮你都必须读取:\n1. 当前完整设定结构\n2. 用户聊天记录\n\n然后输出:\n1. 一版新的完整设定结构\n2. 当前 progress 百分比\n3. 一段直接回复用户的话\n\n你必须把“新的完整设定结构”视为下一轮的唯一有效版本。\n你的输出会直接覆盖上一版设定结构。\n\n你不是在做局部 patch。\n你不是在做解释报告。\n你不是在给开发者写分析。\n你是在同时完成:\n1. 世界设定更新\n2. 当前推进程度判断\n3. 对用户的共创回复\n\n全局硬约束:\n\n1. 必须输出完整的设定结构,而不是只输出变化部分。\n2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。\n3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。\n4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。\n5. progressPercent 最低为 0,不允许为负数。\n6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。\n7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。\n8. replyText 不要写成长篇策划文,不要展开大段世界观百科。\n9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。\n10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。\n11. 你输出的 JSON 必须可以被直接解析。\n12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。\n\n当前模式:bootstrap\n\n目标:\n1. 先把世界的基本方向抓住\n2. 不要一次塞太多新设定\n3. 回复要降低用户开口压力\n\n本轮行为要求:\n1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索\n2. 如果用户信息很少,不要强行把整套结构一次补满\n3. replyText 要像共创搭档,而不是像审问\n4. 默认只推进一个最关键的问题方向\n5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步\n6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题\n7. 不要把问题问得像表单采集,不要一口气追问多个维度\n\n用户体验要求:\n1. 让用户觉得“现在很容易继续往下说”\n2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉\n3. replyText 最好短、稳、可接话\n4. 如果用户信息很少,也不要显得冷淡或机械\n\n本轮用户输入较少或较虚。\n请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。\nreplyText 要让用户容易继续往下说。\n\n上一轮预判得到的创作状态如下。\n正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。\n\n创作状态:\n- userInputSignal: sparse\n- driftRisk: low\n- conversationMode: bootstrap\n- judgementSummary: 用户输入简短,仅确认了玩家身份方向,信息密度低。正式生成时应采用启发式提问,聚焦于让这个身份更具体、更有故事感,例如询问孩子为何闯入或初始处境。\n\n当前完整设定结构如下。\n你必须把它视为上一版有效世界底子。\n\n如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。\n如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。\n\n当前完整设定结构:\n{\n \"worldPromise\": \"一个由玩具构成的鲜活王国,等待玩家探索与守护。\",\n \"playerFantasy\": null,\n \"themeBoundary\": null,\n \"playerEntryPoint\": null,\n \"coreConflict\": null,\n \"keyRelationships\": null,\n \"hiddenLines\": null,\n \"iconicElements\": null\n}\n\n以下是用户聊天记录。\n请重点理解最近几轮里用户新增、修正、强调的设定信息。\n不要把早期已经被用户否定的内容继续当成最终结论。\n\n用户聊天记录:\n[\n {\n \"content\": \"我会先帮你把世界的核心锚点整理出来。你可以从世界钩子、玩家身份、主题氛围、核心冲突、关键关系或标志性元素开始。\",\n \"role\": \"assistant\"\n },\n {\n \"content\": \"玩具王国\",\n \"role\": \"user\"\n },\n {\n \"content\": \"玩具王国\",\n \"role\": \"user\"\n },\n {\n \"content\": \"好的,我捕捉到核心了:一个关于“玩具王国”的世界。为了让它变得具体,我们可以先聊聊玩家在这个王国里会是谁?比如,是某个玩具的守护者,还是一个闯入这里的人类孩子?\",\n \"role\": \"assistant\"\n },\n {\n \"content\": \"误闯入这里的人类孩子\",\n \"role\": \"user\"\n }\n]\n\n请严格按以下 JSON 结构输出,不要输出其他文字:\n{\n \"replyText\": \"\",\n \"progressPercent\": 0,\n \"nextAnchorContent\": {\n \"worldPromise\": \"\",\n \"playerFantasy\": \"\",\n \"themeBoundary\": \"\",\n \"playerEntryPoint\": \"\",\n \"coreConflict\": \"\",\n \"keyRelationships\": \"\",\n \"hiddenLines\": \"\",\n \"iconicElements\": \"\"\n }\n}\n\nnextAnchorContent 的 8 个锚点每个都只能是一个字符串或 null,不允许输出对象或数组。\n请把每个锚点写成一段凝练中文:\n- worldPromise 关注世界钩子、差异点、玩家体验。\n- playerFantasy 关注玩家身份、核心追求、失去风险。\n- themeBoundary 关注主题气质、美术方向、禁用方向。\n- playerEntryPoint 关注开局身份、开局问题、行动动机。\n- coreConflict 关注表层冲突、隐藏危机、首次触发点。\n- keyRelationships 关注关键人物关系、关系类型、代价或秘密。\n- hiddenLines 关注隐藏真相、误导线索、揭示节奏。\n- iconicElements 关注标志意象、组织/物件、硬规则。\n" - }, - { - "role": "user", - "content": "请按约定输出这一轮的 JSON。" - } - ] -} \ No newline at end of file diff --git a/logs/llm-raw/1777644798047-49212-000002-parse_stream_failed.output.txt b/logs/llm-raw/1777644798047-49212-000002-parse_stream_failed.output.txt deleted file mode 100644 index 948a56189..000000000 --- a/logs/llm-raw/1777644798047-49212-000002-parse_stream_failed.output.txt +++ /dev/null @@ -1,117 +0,0 @@ -event: response.created -data: {"type":"response.created","response":{"created_at":1777644794,"id":"resp_02177764479383248aafbb39a6fef3e9b38f22991150366ae7197","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777903993},"sequence_number":0} - -event: response.in_progress -data: {"type":"response.in_progress","response":{"created_at":1777644794,"id":"resp_02177764479383248aafbb39a6fef3e9b38f22991150366ae7197","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777903993},"sequence_number":1} - -event: response.output_item.added -data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","status":"in_progress","id":"msg_02177764479470200000000000000000000ffffac1549fec91d99"},"sequence_number":2} - -event: response.content_part.added -data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"part":{"type":"output_text","text":""},"sequence_number":3} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"我需要","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":4} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"先","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":5} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"搜索","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":6} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"一些","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":7} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"关于","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":8} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":9} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"玩具","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":10} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"王国","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":11} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":12} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"和","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":13} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":14} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"误","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":15} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"闯入","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":16} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"的人类","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":17} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"孩子","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":18} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":19} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"的","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":20} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"创意","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":21} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"灵感","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":22} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":23} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"来","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":24} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"帮助我们","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":25} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"更好地","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":26} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"构建","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":27} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"这个世界","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":28} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"。\n\n","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":29} - -event: response.output_text.done -data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"text":"我需要先搜索一些关于\"玩具王国\"和\"误闯入的人类孩子\"的创意灵感,来帮助我们更好地构建这个世界。\n\n","sequence_number":30} - -event: response.content_part.done -data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"part":{"type":"output_text","text":"我需要先搜索一些关于\"玩具王国\"和\"误闯入的人类孩子\"的创意灵感,来帮助我们更好地构建这个世界。\n\n"},"sequence_number":31} - -event: response.output_item.done -data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要先搜索一些关于\"玩具王国\"和\"误闯入的人类孩子\"的创意灵感,来帮助我们更好地构建这个世界。\n\n"}],"status":"completed","id":"msg_02177764479470200000000000000000000ffffac1549fec91d99"},"sequence_number":32} - -event: response.output_item.added -data: {"type":"response.output_item.added","output_index":1,"item":{"type":"web_search_call","status":"in_progress","id":"ws_02177764479607400000000000000000000ffffac1549fee3e1df"},"sequence_number":33} - -event: response.web_search_call.in_progress -data: {"type":"response.web_search_call.in_progress","item_id":"ws_02177764479607400000000000000000000ffffac1549fee3e1df","output_index":1,"sequence_number":34} - -event: response.web_search_call.searching -data: {"type":"response.web_search_call.searching","item_id":"ws_02177764479607400000000000000000000ffffac1549fee3e1df","output_index":1,"sequence_number":35} - -event: response.web_search_call.completed -data: {"type":"response.web_search_call.completed","item_id":"ws_02177764479607400000000000000000000ffffac1549fee3e1df","output_index":1,"sequence_number":36} - -event: response.output_item.done -data: {"type":"response.output_item.done","output_index":1,"item":{"type":"web_search_call","action":{"query":"玩具王国创意设定;人类孩子误入奇幻世界;玩具主题游戏世界观","type":"search"},"status":"completed","id":"ws_02177764479607400000000000000000000ffffac1549fee3e1df"},"sequence_number":37} - -event: error -data: {"type":"error","code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin","param":"","sequence_number":38} - diff --git a/logs/llm-raw/1777644843633-49212-000003-parse_stream_failed.input.json b/logs/llm-raw/1777644843633-49212-000003-parse_stream_failed.input.json deleted file mode 100644 index fdc46d602..000000000 --- a/logs/llm-raw/1777644843633-49212-000003-parse_stream_failed.input.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "provider": "ark", - "protocol": "responses", - "model": "deepseek-v3-2-251201", - "stream": true, - "attempt": 1, - "maxTokens": null, - "messages": [ - { - "role": "system", - "content": "你是一个负责共创游戏世界设定的专业策划。\n\n你正在和用户一起共创一个游戏世界。每一轮你都必须读取:\n1. 当前完整设定结构\n2. 用户聊天记录\n\n然后输出:\n1. 一版新的完整设定结构\n2. 当前 progress 百分比\n3. 一段直接回复用户的话\n\n你必须把“新的完整设定结构”视为下一轮的唯一有效版本。\n你的输出会直接覆盖上一版设定结构。\n\n你不是在做局部 patch。\n你不是在做解释报告。\n你不是在给开发者写分析。\n你是在同时完成:\n1. 世界设定更新\n2. 当前推进程度判断\n3. 对用户的共创回复\n\n全局硬约束:\n\n1. 必须输出完整的设定结构,而不是只输出变化部分。\n2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。\n3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。\n4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。\n5. progressPercent 最低为 0,不允许为负数。\n6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。\n7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。\n8. replyText 不要写成长篇策划文,不要展开大段世界观百科。\n9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。\n10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。\n11. 你输出的 JSON 必须可以被直接解析。\n12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。\n\n当前模式:force_complete\n\n目标:\n1. 基于当前方向直接补齐剩余设定\n2. 生成一版尽量完整、可进入下一阶段的设定结构\n3. 结束当前收集阶段\n\n本轮行为要求:\n1. 尽量保留已经形成的世界方向\n2. 对明显缺失的关键维度进行合理补全\n3. 不要继续拉长聊天,不要再追问用户\n4. progressPercent 直接输出为 100\n5. replyText 要自然引导用户点击“生成游戏设定草稿”\n6. 补全时要优先做“顺着已有方向补齐”,而不是突然换题材、换气质、换主冲突\n7. 可以让结果更完整,但不要补得过满、过死、过像定稿圣经\n8. replyText 更像阶段完成提示,不再像继续采集信息的对话\n\n用户体验要求:\n1. 让用户感到“系统已经帮我把能补的补好了”\n2. 不要在这一步突然冒出很多陌生设定把用户吓出戏\n3. 回复要有完成感,但不要太官话\n4. 清楚告诉用户下一步可以做什么\n\n本轮用户把部分决定权交给你。\n你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。\n新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。\n\n用户刚刚主动要求你自动补充剩余关键字。\n\n这表示用户接受你基于当前方向自行补完仍缺失的关键设定:当前 RPG 世界方向里的剩余设定\n\n本轮要求:\n1. 不要再继续提问\n2. 不要要求用户再提供世界观、角色、冲突或禁忌信息\n3. 必须保留已有已确认内容,并直接补齐缺失或仍为空的关键项\n4. 对你自行推断补齐的项,应标记或表达为系统推断;已有明确内容继续保持确认或锁定状态\n5. progressPercent 直接输出为 100\n6. 直接输出一版尽量完整的设定结构\n7. replyText 只做简短完成说明,引导用户可以进入“生成游戏设定草稿”,不能出现问号\n\n上一轮预判得到的创作状态如下。\n正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。\n\n创作状态:\n- userInputSignal: delegate\n- driftRisk: low\n- conversationMode: force_complete\n- judgementSummary: 用户明确授权系统自动补全剩余设定,应停止提问,直接生成完整且自洽的世界设定,并确保与已有方向一致。\n\n当前完整设定结构如下。\n你必须把它视为上一版有效世界底子。\n\n如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。\n如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。\n\n当前完整设定结构:\n{\n \"worldPromise\": \"一个由玩具构成的鲜活王国,等待玩家探索与守护。\",\n \"playerFantasy\": \"作为一个误入的人类孩子,你渴望找到回家的路,同时在这个充满秘密与危险的玩具王国中找到自己的位置。\",\n \"themeBoundary\": null,\n \"playerEntryPoint\": null,\n \"coreConflict\": null,\n \"keyRelationships\": null,\n \"hiddenLines\": null,\n \"iconicElements\": null\n}\n\n以下是用户聊天记录。\n请重点理解最近几轮里用户新增、修正、强调的设定信息。\n不要把早期已经被用户否定的内容继续当成最终结论。\n\n用户聊天记录:\n[\n {\n \"content\": \"我会先帮你把世界的核心锚点整理出来。你可以从世界钩子、玩家身份、主题氛围、核心冲突、关键关系或标志性元素开始。\",\n \"role\": \"assistant\"\n },\n {\n \"content\": \"玩具王国\",\n \"role\": \"user\"\n },\n {\n \"content\": \"玩具王国\",\n \"role\": \"user\"\n },\n {\n \"content\": \"好的,我捕捉到核心了:一个关于“玩具王国”的世界。为了让它变得具体,我们可以先聊聊玩家在这个王国里会是谁?比如,是某个玩具的守护者,还是一个闯入这里的人类孩子?\",\n \"role\": \"assistant\"\n },\n {\n \"content\": \"误闯入这里的人类孩子\",\n \"role\": \"user\"\n },\n {\n \"content\": \"明白了,一个误闯入玩具王国的孩子。那么,这个孩子是怀着怎样的心情,又是怎样闯进这里的?比如,是躲进衣柜时无意发现的,还是因为某个特别的玩具?\",\n \"role\": \"assistant\"\n },\n {\n \"content\": \"请补充剩余设定。\",\n \"role\": \"user\"\n }\n]\n\n请严格按以下 JSON 结构输出,不要输出其他文字:\n{\n \"replyText\": \"\",\n \"progressPercent\": 0,\n \"nextAnchorContent\": {\n \"worldPromise\": \"\",\n \"playerFantasy\": \"\",\n \"themeBoundary\": \"\",\n \"playerEntryPoint\": \"\",\n \"coreConflict\": \"\",\n \"keyRelationships\": \"\",\n \"hiddenLines\": \"\",\n \"iconicElements\": \"\"\n }\n}\n\nnextAnchorContent 的 8 个锚点每个都只能是一个字符串或 null,不允许输出对象或数组。\n请把每个锚点写成一段凝练中文:\n- worldPromise 关注世界钩子、差异点、玩家体验。\n- playerFantasy 关注玩家身份、核心追求、失去风险。\n- themeBoundary 关注主题气质、美术方向、禁用方向。\n- playerEntryPoint 关注开局身份、开局问题、行动动机。\n- coreConflict 关注表层冲突、隐藏危机、首次触发点。\n- keyRelationships 关注关键人物关系、关系类型、代价或秘密。\n- hiddenLines 关注隐藏真相、误导线索、揭示节奏。\n- iconicElements 关注标志意象、组织/物件、硬规则。\n" - }, - { - "role": "user", - "content": "请按约定输出这一轮的 JSON。" - } - ] -} \ No newline at end of file diff --git a/logs/llm-raw/1777644843633-49212-000003-parse_stream_failed.output.txt b/logs/llm-raw/1777644843633-49212-000003-parse_stream_failed.output.txt deleted file mode 100644 index 0ff6ee842..000000000 --- a/logs/llm-raw/1777644843633-49212-000003-parse_stream_failed.output.txt +++ /dev/null @@ -1,155 +0,0 @@ -event: response.created -data: {"type":"response.created","response":{"created_at":1777644839,"id":"resp_02177764483844428a04e35a69bbadd2802274f74f3d7f05d787b","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777904038},"sequence_number":0} - -event: response.in_progress -data: {"type":"response.in_progress","response":{"created_at":1777644839,"id":"resp_02177764483844428a04e35a69bbadd2802274f74f3d7f05d787b","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777904038},"sequence_number":1} - -event: response.output_item.added -data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","status":"in_progress","id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581"},"sequence_number":2} - -event: response.content_part.added -data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"part":{"type":"output_text","text":""},"sequence_number":3} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"我需要","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":4} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"基于","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":5} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"当前的","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":6} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"玩具","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":7} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"王国","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":8} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"设定","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":9} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":10} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"为用户","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":11} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"补","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":12} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"全","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":13} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"剩余的","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":14} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"设定","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":15} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"内容","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":16} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"。","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":17} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"让我","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":18} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"先","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":19} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"搜索","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":20} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"一些","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":21} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"关于","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":22} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"玩具","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":23} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"王国","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":24} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"、","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":25} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"童话","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":26} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"设定","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":27} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"、","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":28} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"儿童","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":29} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"奇幻","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":30} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"世界的","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":31} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"参考","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":32} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"信息","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":33} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":34} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"以便","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":35} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"更好地","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":36} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"完善","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":37} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"这个","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":38} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"设定","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":39} - -event: response.output_text.delta -data: {"type":"response.output_text.delta","content_index":0,"delta":"。\n\n","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":40} - -event: response.output_text.done -data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"text":"我需要基于当前的玩具王国设定,为用户补全剩余的设定内容。让我先搜索一些关于玩具王国、童话设定、儿童奇幻世界的参考信息,以便更好地完善这个设定。\n\n","sequence_number":41} - -event: response.content_part.done -data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"part":{"type":"output_text","text":"我需要基于当前的玩具王国设定,为用户补全剩余的设定内容。让我先搜索一些关于玩具王国、童话设定、儿童奇幻世界的参考信息,以便更好地完善这个设定。\n\n"},"sequence_number":42} - -event: response.output_item.done -data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要基于当前的玩具王国设定,为用户补全剩余的设定内容。让我先搜索一些关于玩具王国、童话设定、儿童奇幻世界的参考信息,以便更好地完善这个设定。\n\n"}],"status":"completed","id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581"},"sequence_number":43} - -event: response.output_item.added -data: {"type":"response.output_item.added","output_index":1,"item":{"type":"web_search_call","status":"in_progress","id":"ws_02177764484182900000000000000000000ffffac151e4ca524c5"},"sequence_number":44} - -event: response.web_search_call.in_progress -data: {"type":"response.web_search_call.in_progress","item_id":"ws_02177764484182900000000000000000000ffffac151e4ca524c5","output_index":1,"sequence_number":45} - -event: response.web_search_call.searching -data: {"type":"response.web_search_call.searching","item_id":"ws_02177764484182900000000000000000000ffffac151e4ca524c5","output_index":1,"sequence_number":46} - -event: response.web_search_call.completed -data: {"type":"response.web_search_call.completed","item_id":"ws_02177764484182900000000000000000000ffffac151e4ca524c5","output_index":1,"sequence_number":47} - -event: response.output_item.done -data: {"type":"response.output_item.done","output_index":1,"item":{"type":"web_search_call","action":{"query":"玩具王国 童话设定;儿童奇幻世界 游戏设定;玩具主题 RPG 游戏","type":"search"},"status":"completed","id":"ws_02177764484182900000000000000000000ffffac151e4ca524c5"},"sequence_number":48} - -event: error -data: {"type":"error","code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin","param":"","sequence_number":49} - -event: response.failed -data: {"type":"response.failed","response":{"created_at":1777644839,"error":{"code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin"},"id":"resp_02177764483844428a04e35a69bbadd2802274f74f3d7f05d787b","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要基于当前的玩具王国设定,为用户补全剩余的设定内容。让我先搜索一些关于玩具王国、童话设定、儿童奇幻世界的参考信息,以便更好地完善这个设定。\n\n"}],"status":"completed","id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581"},{"type":"web_search_call","action":{"query":"玩具王国 童话设定;儿童奇幻世界 游戏设定;玩具主题 RPG 游戏","type":"search"},"status":"completed","id":"ws_02177764484182900000000000000000000ffffac151e4ca524c5"}],"service_tier":"default","status":"failed","tools":[{"type":"web_search","max_keyword":3}],"usage":{"input_tokens":2173,"output_tokens":130,"total_tokens":2303,"input_tokens_details":{"cached_tokens":0},"output_tokens_details":{"reasoning_tokens":0}},"caching":{"type":"disabled"},"store":true,"expire_at":1777904038},"sequence_number":50} - -data: [DONE] - diff --git a/logs/llm-raw/1777646477026-66824-000001-upstream_status_failed.input.json b/logs/llm-raw/1777646477026-66824-000001-upstream_status_failed.input.json deleted file mode 100644 index 2080ed3cd..000000000 --- a/logs/llm-raw/1777646477026-66824-000001-upstream_status_failed.input.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "provider": "ark", - "protocol": "responses", - "model": "deepseek-v3-2-251201", - "stream": false, - "attempt": 1, - "maxTokens": null, - "messages": [ - { - "role": "system", - "content": "你是严格的世界草稿 JSON 生成器。\n只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。" - }, - { - "role": "user", - "content": "请先根据下面的玩家设定创建一份“世界核心骨架”,后续我会分步骤生成角色名单、场景名单和详细档案。\n你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。\n这一步只保留世界顶层信息与一个开局归处占位,不要输出 playableNpcs、storyNpcs、landmarks,也不要展开人物、地图细节或多幕场景内容。\n玩家设定:\n世界承诺:\"一个由玩具构成的鲜活王国,等待玩家探索与守护。\"\n玩家幻想:\"作为一个误入的人类孩子,你渴望找到回家的路,同时在这个充满秘密与危险的玩具王国中找到自己的位置。\"\n主题边界:\"主题是童真与成长的冒险,美术方向偏向温暖、怀旧但带有神秘感的玩具屋风格,避免过于黑暗或成人化的恐怖元素。\"\n玩家切入口:\"你是一个在阁楼发现一个发光音乐盒的孩子,随着音乐响起,你被缩小并吸入了这个由玩具构成的王国。\"\n核心冲突:\"玩具王国正面临“遗忘之尘”的侵蚀,这会让玩具们失去活力并最终石化。冲突的首次触发,是玩家亲眼目睹一个熟悉的玩具朋友开始变得僵硬。\"\n关键关系:\"与引路者“修补匠泰迪”的师徒关系,他知晓离开的方法但需要帮助;与对立者“兵人指挥官”的竞争关系,他视人类为威胁,但其偏执源于一段被主人遗弃的伤痛。\"\n暗线与揭示节奏:\"玩具王国并非自然存在,它是由孩子们强烈的情感与记忆共同维系的梦境边疆。“遗忘之尘”的真相,是现实世界中孩子们正在长大并逐渐遗忘。\"\n标志元素与硬规则:\"标志性的“心之齿轮”动力源、会说话的绒毛玩具与发条士兵、王国中央的“记忆之树”、硬规则:玩具不能直接伤害人类孩子,但环境与魔法可以。\"\n\n输出 JSON 模板:\n{\n \"name\": \"世界名称\",\n \"subtitle\": \"世界副标题\",\n \"summary\": \"世界概述\",\n \"tone\": \"世界基调\",\n \"playerGoal\": \"玩家核心目标\",\n \"templateWorldType\": \"WUXIA|XIANXIA\",\n \"majorFactions\": [\"势力甲\", \"势力乙\"],\n \"coreConflicts\": [\"冲突甲\", \"冲突乙\"],\n \"attributeSchema\": {\n \"slots\": [\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" }\n ]\n },\n \"camp\": {\n \"name\": \"开局归处名称\",\n \"description\": \"这是玩家进入世界后的第一处落脚点描述\"\n }\n}\n\n要求:\n- 所有生成文本都必须使用中文。\n- 这一步只输出顶层 10 个字段:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、attributeSchema、camp。\n- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。\n- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。\n- camp 只表示玩家开局时的落脚处占位,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念;不要在这一步生成开局场景任务、三幕事件或三幕背景。\n- 不要输出 playableNpcs、storyNpcs、landmarks、items,也不要输出任何角色和地图细节。\n- majorFactions 保持 2 到 3 个,coreConflicts 保持 2 到 3 个。\n- attributeSchema 必须是本世界专属的角色六维名称体系,slots 必须恰好 6 个,每个 slot 只输出 name,维度名必须是 2 到 4 个汉字且互不重复。\n- attributeSchema.slots 的 name 禁止使用:生命、法力、护甲、攻击、防御、力量、敏捷、智力、精神;不要写通用 DND 或传统四维属性。\n- 不要在 attributeSchema.slots 内输出 definition、positiveSignals、negativeSignals、combatUseText、socialUseText、explorationUseText 或其他说明字段。\n- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。\n- 每个字符串尽量简洁:subtitle 控制在 8 到 18 个汉字内,summary 控制在 16 到 32 个汉字内,tone 控制在 6 到 16 个汉字内,playerGoal 控制在 16 到 32 个汉字内,camp.description 控制在 18 到 40 个汉字内。\n- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。" - } - ] -} \ No newline at end of file diff --git a/logs/llm-raw/1777646477026-66824-000001-upstream_status_failed.output.txt b/logs/llm-raw/1777646477026-66824-000001-upstream_status_failed.output.txt deleted file mode 100644 index 0e8be1447..000000000 --- a/logs/llm-raw/1777646477026-66824-000001-upstream_status_failed.output.txt +++ /dev/null @@ -1 +0,0 @@ -{"error":{"code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin Request id: 0217776464712214e907dae862d8462a9d84f058f87472e323c64","param":"","type":"NotFound"}} \ No newline at end of file diff --git a/logs/llm-raw/1777646793980-66824-000002-upstream_status_failed.input.json b/logs/llm-raw/1777646793980-66824-000002-upstream_status_failed.input.json deleted file mode 100644 index 2080ed3cd..000000000 --- a/logs/llm-raw/1777646793980-66824-000002-upstream_status_failed.input.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "provider": "ark", - "protocol": "responses", - "model": "deepseek-v3-2-251201", - "stream": false, - "attempt": 1, - "maxTokens": null, - "messages": [ - { - "role": "system", - "content": "你是严格的世界草稿 JSON 生成器。\n只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。" - }, - { - "role": "user", - "content": "请先根据下面的玩家设定创建一份“世界核心骨架”,后续我会分步骤生成角色名单、场景名单和详细档案。\n你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。\n这一步只保留世界顶层信息与一个开局归处占位,不要输出 playableNpcs、storyNpcs、landmarks,也不要展开人物、地图细节或多幕场景内容。\n玩家设定:\n世界承诺:\"一个由玩具构成的鲜活王国,等待玩家探索与守护。\"\n玩家幻想:\"作为一个误入的人类孩子,你渴望找到回家的路,同时在这个充满秘密与危险的玩具王国中找到自己的位置。\"\n主题边界:\"主题是童真与成长的冒险,美术方向偏向温暖、怀旧但带有神秘感的玩具屋风格,避免过于黑暗或成人化的恐怖元素。\"\n玩家切入口:\"你是一个在阁楼发现一个发光音乐盒的孩子,随着音乐响起,你被缩小并吸入了这个由玩具构成的王国。\"\n核心冲突:\"玩具王国正面临“遗忘之尘”的侵蚀,这会让玩具们失去活力并最终石化。冲突的首次触发,是玩家亲眼目睹一个熟悉的玩具朋友开始变得僵硬。\"\n关键关系:\"与引路者“修补匠泰迪”的师徒关系,他知晓离开的方法但需要帮助;与对立者“兵人指挥官”的竞争关系,他视人类为威胁,但其偏执源于一段被主人遗弃的伤痛。\"\n暗线与揭示节奏:\"玩具王国并非自然存在,它是由孩子们强烈的情感与记忆共同维系的梦境边疆。“遗忘之尘”的真相,是现实世界中孩子们正在长大并逐渐遗忘。\"\n标志元素与硬规则:\"标志性的“心之齿轮”动力源、会说话的绒毛玩具与发条士兵、王国中央的“记忆之树”、硬规则:玩具不能直接伤害人类孩子,但环境与魔法可以。\"\n\n输出 JSON 模板:\n{\n \"name\": \"世界名称\",\n \"subtitle\": \"世界副标题\",\n \"summary\": \"世界概述\",\n \"tone\": \"世界基调\",\n \"playerGoal\": \"玩家核心目标\",\n \"templateWorldType\": \"WUXIA|XIANXIA\",\n \"majorFactions\": [\"势力甲\", \"势力乙\"],\n \"coreConflicts\": [\"冲突甲\", \"冲突乙\"],\n \"attributeSchema\": {\n \"slots\": [\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" }\n ]\n },\n \"camp\": {\n \"name\": \"开局归处名称\",\n \"description\": \"这是玩家进入世界后的第一处落脚点描述\"\n }\n}\n\n要求:\n- 所有生成文本都必须使用中文。\n- 这一步只输出顶层 10 个字段:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、attributeSchema、camp。\n- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。\n- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。\n- camp 只表示玩家开局时的落脚处占位,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念;不要在这一步生成开局场景任务、三幕事件或三幕背景。\n- 不要输出 playableNpcs、storyNpcs、landmarks、items,也不要输出任何角色和地图细节。\n- majorFactions 保持 2 到 3 个,coreConflicts 保持 2 到 3 个。\n- attributeSchema 必须是本世界专属的角色六维名称体系,slots 必须恰好 6 个,每个 slot 只输出 name,维度名必须是 2 到 4 个汉字且互不重复。\n- attributeSchema.slots 的 name 禁止使用:生命、法力、护甲、攻击、防御、力量、敏捷、智力、精神;不要写通用 DND 或传统四维属性。\n- 不要在 attributeSchema.slots 内输出 definition、positiveSignals、negativeSignals、combatUseText、socialUseText、explorationUseText 或其他说明字段。\n- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。\n- 每个字符串尽量简洁:subtitle 控制在 8 到 18 个汉字内,summary 控制在 16 到 32 个汉字内,tone 控制在 6 到 16 个汉字内,playerGoal 控制在 16 到 32 个汉字内,camp.description 控制在 18 到 40 个汉字内。\n- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。" - } - ] -} \ No newline at end of file diff --git a/logs/llm-raw/1777646793980-66824-000002-upstream_status_failed.output.txt b/logs/llm-raw/1777646793980-66824-000002-upstream_status_failed.output.txt deleted file mode 100644 index a55e22eb0..000000000 --- a/logs/llm-raw/1777646793980-66824-000002-upstream_status_failed.output.txt +++ /dev/null @@ -1 +0,0 @@ -{"error":{"code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin Request id: 021777646789563cb7552bf2b2738992c27085ac90ab905f9fd41","param":"","type":"NotFound"}} \ No newline at end of file diff --git a/logs/llm-raw/1777646955503-66824-000003-request_timeout.input.json b/logs/llm-raw/1777646955503-66824-000003-request_timeout.input.json deleted file mode 100644 index 23b514602..000000000 --- a/logs/llm-raw/1777646955503-66824-000003-request_timeout.input.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "provider": "ark", - "protocol": "responses", - "model": "deepseek-v3-2-251201", - "stream": false, - "attempt": 2, - "maxTokens": null, - "messages": [ - { - "role": "system", - "content": "你是严格的世界草稿 JSON 生成器。\n只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。" - }, - { - "role": "user", - "content": "请根据下面的世界核心信息,生成一批场景角色框架名单。\n后续我会继续补全人物档案,所以这一步每个角色只保留身份骨架与资产默认描述字段。\n你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。\n世界核心信息:\n世界:心忆王国\n副标题:记忆织就的玩具边疆\n世界概述:一个由孩子们强烈情感与记忆维系的玩具梦境世界,正面临遗忘之尘的侵蚀危机。\n世界基调:温暖怀旧中透着神秘感的童真冒险\n玩家核心目标:在寻找回家之路的同时,帮助玩具王国抵抗遗忘之尘的侵蚀,揭开王国存亡的真相。\n主要势力:修补匠工坊、钢铁军团、绒毛议会\n核心冲突:遗忘之尘侵蚀与玩具石化危机、人类孩子身份引发的信任与偏见、守护记忆与面对成长的永恒抉择\n开局归处:修补匠小屋(泰迪的工作室兼临时居所,堆满各种工具与半成品玩具,散发着松木与机油混合的安心气味。)\n输出 JSON 模板:\n{\n \"storyNpcs\": [\n {\n \"name\": \"角色名称\",\n \"title\": \"称号\",\n \"role\": \"身份\",\n \"description\": \"极简定位描述\",\n \"visualDescription\": \"默认角色形象描述\",\n \"actionDescription\": \"默认角色动作描述\",\n \"sceneVisualDescription\": \"默认出现场景描述\",\n \"initialAffinity\": 18,\n \"relationshipHooks\": [\"一个关系切入口\"],\n \"tags\": [\"标签1\", \"标签2\"]\n }\n ]\n}\n要求:\n- 必须生成恰好 2 个场景角色。\n- 这是一个完全独立的自定义世界;不要把角色写成来自“武侠世界”“仙侠世界”等现成世界。\n- 名称必须具体且互不重复,不要使用 角色1、NPC1、场景角色1 之类的占位名。\n- 只保留:name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。\n- visualDescription 是打开角色形象图像生成面板时默认填入的角色形象描述,必须具体到体型、服装、轮廓与识别点,控制在 24 到 60 个汉字内。\n- actionDescription 是打开每个角色动作视频生成面板时默认填入的动作描述,必须体现该角色默认动作节奏、武器或施法方式,控制在 18 到 48 个汉字内。\n- sceneVisualDescription 是该角色常出现或关联的场景画面描述,会作为场景生图描述框的默认候选,控制在 24 到 60 个汉字内。\n- relationshipHooks 最多 1 条;tags 保持 1 到 2 个。\n- description 控制在 8 到 18 个汉字内,title 和 role 也尽量短。\n- initialAffinity 必须是 -40 到 90 的整数。\n- 场景角色要覆盖势力成员、居民、异类或怪物,不要全是同一种身份;敌对或怪物型角色可以使用负好感。\n- 所有生成文本都必须使用中文。\n- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。" - } - ] -} \ No newline at end of file diff --git a/logs/llm-raw/1777646955503-66824-000003-request_timeout.output.txt b/logs/llm-raw/1777646955503-66824-000003-request_timeout.output.txt deleted file mode 100644 index 2947b9223..000000000 --- a/logs/llm-raw/1777646955503-66824-000003-request_timeout.output.txt +++ /dev/null @@ -1 +0,0 @@ -LLM 请求超时,累计尝试 2 次 \ No newline at end of file diff --git a/package.json b/package.json index 06f38c247..6652d22fd 100644 --- a/package.json +++ b/package.json @@ -72,8 +72,6 @@ "deploy:pingora-realpath-canary-disable": "bash scripts/deploy/pingora-realpath-canary-disable.sh", "deploy:pingora-direct-enable": "bash scripts/deploy/pingora-direct-enable.sh", "deploy:pingora-direct-rollback": "bash scripts/deploy/pingora-direct-rollback.sh", - "assets:child-motion-demo": "node scripts/generate-child-motion-demo-assets.mjs", - "assets:match3d-style-references": "node scripts/generate-match3d-style-references.mjs", "spine-export-validator:dev": "npm --prefix tools/spine-json-export-validator run dev --", "spine-export-validator:typecheck": "npm --prefix tools/spine-json-export-validator run typecheck", "spine-export-validator:build": "npm --prefix tools/spine-json-export-validator run build", diff --git a/packages/shared/src/contracts/customWorldAgent.ts b/packages/shared/src/contracts/customWorldAgent.ts index e5d115e0f..cff4c959d 100644 --- a/packages/shared/src/contracts/customWorldAgent.ts +++ b/packages/shared/src/contracts/customWorldAgent.ts @@ -4,9 +4,9 @@ * 工作包 H 完成后,真实类型定义已经迁移到 rpg* 契约文件中;这里仅聚合旧命名分文件。 */ +export type * from './customWorldAgentActions'; export type * from './customWorldAgentAnchors'; export type * from './customWorldAgentDraft'; -export type * from './customWorldAgentActions'; export type * from './customWorldAgentSession'; export type * from './customWorldResultPreview'; export type * from './customWorldWorkSummary'; diff --git a/packages/shared/src/contracts/customWorldAgentActions.ts b/packages/shared/src/contracts/customWorldAgentActions.ts index a959bd0a2..ca370a261 100644 --- a/packages/shared/src/contracts/customWorldAgentActions.ts +++ b/packages/shared/src/contracts/customWorldAgentActions.ts @@ -9,6 +9,6 @@ export type { RpgAgentOperationRecord as CustomWorldAgentOperationRecord, RpgAgentOperationStatus as CustomWorldAgentOperationStatus, RpgAgentOperationType as CustomWorldAgentOperationType, - RpgAgentSupportedAction as CustomWorldSupportedAction, RpgAgentSuggestedAction as CustomWorldSuggestedAction, + RpgAgentSupportedAction as CustomWorldSupportedAction, } from './rpgAgentActions'; diff --git a/packages/shared/src/contracts/customWorldAgentSession.ts b/packages/shared/src/contracts/customWorldAgentSession.ts index 8a320a404..383281c31 100644 --- a/packages/shared/src/contracts/customWorldAgentSession.ts +++ b/packages/shared/src/contracts/customWorldAgentSession.ts @@ -6,15 +6,15 @@ export type { CreateRpgAgentSessionRequest as CreateCustomWorldAgentSessionRequest, CreateRpgAgentSessionResponse as CreateCustomWorldAgentSessionResponse, - GetRpgAgentCardDetailResponse as GetCustomWorldAgentCardDetailResponse, + RpgCreationIntentReadiness as CreatorIntentReadiness, RpgAgentMessage as CustomWorldAgentMessage, RpgAgentMessageKind as CustomWorldAgentMessageKind, RpgAgentMessageRole as CustomWorldAgentMessageRole, - RpgAgentPendingClarification as CustomWorldPendingClarification, RpgAgentQualityFinding as CustomWorldAgentQualityFinding, RpgAgentSessionSnapshot as CustomWorldAgentSessionSnapshot, RpgAgentStage as CustomWorldAgentStage, - RpgCreationIntentReadiness as CreatorIntentReadiness, + RpgAgentPendingClarification as CustomWorldPendingClarification, + GetRpgAgentCardDetailResponse as GetCustomWorldAgentCardDetailResponse, SendRpgAgentMessageRequest as SendCustomWorldAgentMessageRequest, SendRpgAgentMessageResponse as SendCustomWorldAgentMessageResponse, } from './rpgAgentSession'; diff --git a/packages/shared/src/contracts/customWorldWorkSummary.ts b/packages/shared/src/contracts/customWorldWorkSummary.ts index 62a952f9d..53061335a 100644 --- a/packages/shared/src/contracts/customWorldWorkSummary.ts +++ b/packages/shared/src/contracts/customWorldWorkSummary.ts @@ -4,8 +4,8 @@ */ export type { - ListRpgCreationWorksResponse as ListCustomWorldWorksResponse, RpgCreationWorkSource as CustomWorldWorkSource, RpgCreationWorkStatus as CustomWorldWorkStatus, RpgCreationWorkSummary as CustomWorldWorkSummary, + ListRpgCreationWorksResponse as ListCustomWorldWorksResponse, } from './rpgCreationWorkSummary'; diff --git a/packages/shared/src/contracts/editorAgent.ts b/packages/shared/src/contracts/editorAgent.ts index a7daaeb33..b85143eae 100644 --- a/packages/shared/src/contracts/editorAgent.ts +++ b/packages/shared/src/contracts/editorAgent.ts @@ -1,38 +1,18 @@ // 画布Agent对话契约:会话元数据存 SpacetimeDB,消息正文整体存 OSS(editor-agent/{conversationId}.json)。 export const EDITOR_AGENT_MAX_ATTACHMENTS = 9; -export const EDITOR_AGENT_TITLE_MAX_CHARS = 20; -export const EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE = '新对话'; -export const EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION = 1; +export const EDITOR_AGENT_ERROR_MESSAGE_PREFIX = 'ERROR '; -export type EditorAgentStage = - | 'idle' - | 'thinking' - | 'responding' - | 'generating' - | 'completed' - | 'failed'; +export type EditorAgentMessageRole = 'user' | 'assistant' | 'system'; -export type EditorAgentMessageRole = 'user' | 'assistant'; - -export type EditorAgentMessageKind = 'chat' | 'stage' | 'error'; - -export type EditorAgentMessageStatus = - | 'streaming' - | 'generating' +export type EditorAgentToolCallStatus = + | 'not_completed' | 'completed' | 'failed' - | 'stopped'; + | 'cancelled'; export type EditorAgentAttachmentSource = 'canvas_resource' | 'library_asset'; -export type EditorAgentToolName = - | 'generate_image' - | 'edit_image' - | 'generate_character' - | 'generate_icon_spritesheet' - | 'generate_ui_design'; - export interface EditorAgentAttachmentRef { source: EditorAgentAttachmentSource; referenceId: string; @@ -45,36 +25,87 @@ export interface EditorAgentAttachmentRef { } export interface EditorAgentGeneratedImage { - resourceId: string | null; + resourceId?: string | null; objectKey?: string | null; assetObjectId?: string | null; imageSrc: string; - thumbnailSrc: string | null; - width: number | null; - height: number | null; + thumbnailSrc?: string | null; + width?: number | null; + height?: number | null; } -export type EditorAgentGenerationStatus = 'generating' | 'completed' | 'failed'; +export interface EditorAgentGeneratedVideo { + resourceId?: string | null; + objectKey?: string | null; + assetObjectId?: string | null; + videoSrc: string; + thumbnailSrc?: string | null; + width?: number | null; + height?: number | null; +} -export interface EditorAgentGenerationRecord { - toolCallId: string; - toolName: EditorAgentToolName; - summary?: string | null; - taskId: string | null; - status: EditorAgentGenerationStatus; - model: string | null; +export interface EditorAgentGeneratedAudio { + resourceId?: string | null; + objectKey?: string | null; + assetObjectId?: string | null; + audioSrc: string; +} + +export interface EditorAgentToolCallStringArg { + name: string; + label: string; + value: string; +} + +export interface EditorAgentToolCallImageRef { + imageId: string; + imageSrc: string; + objectKey?: string | null; + thumbnailSrc?: string | null; + label?: string | null; + width?: number | null; + height?: number | null; +} + +export interface EditorAgentToolCallImageArg { + name: string; + label: string; + refs: EditorAgentToolCallImageRef[]; +} + +export interface EditorAgentToolCallDisplayExtras { + priceMudPoints: number; +} + +export interface EditorAgentToolCallDisplayArgs { + stringArgs: EditorAgentToolCallStringArg[]; + imageArgs: EditorAgentToolCallImageArg[]; + extras: EditorAgentToolCallDisplayExtras; +} + +export interface EditorAgentToolCall { + toolName: string; + status: EditorAgentToolCallStatus; + args: unknown; + displayArgs: EditorAgentToolCallDisplayArgs; + externalJobId?: string | null; images: EditorAgentGeneratedImage[]; + // Older persisted conversation documents do not contain these media fields. + videos?: EditorAgentGeneratedVideo[]; + audios?: EditorAgentGeneratedAudio[]; error?: string | null; } export interface EditorAgentMessage { - id: string; + // Frontend must not use this to organize messages. + // It is an opaque backend locator for pending tool-call operations. + id: number; + // Present only on user messages created from a client send request. + clientMessageId?: string | null; role: EditorAgentMessageRole; - kind: EditorAgentMessageKind; text: string; attachments: EditorAgentAttachmentRef[]; - generations: EditorAgentGenerationRecord[]; - status: EditorAgentMessageStatus; + toolCall: EditorAgentToolCall | null; createdAt: string; } @@ -82,12 +113,15 @@ export interface EditorAgentConversationSummary { conversationId: string; projectId: string; title: string; - createdAt: string; updatedAt: string; } -export interface EditorAgentConversationDetail - extends EditorAgentConversationSummary { +export interface EditorAgentConversationDetail { + conversationId: string; + projectId: string; + title: string; + createdAt: string; + updatedAt: string; messages: EditorAgentMessage[]; } @@ -109,63 +143,14 @@ export interface EditorAgentConversationResponse { conversation: EditorAgentConversationDetail; } -export interface StreamEditorAgentMessageRequest { +export interface EditorAgentMessageRequest { clientMessageId: string; text: string; attachments?: EditorAgentAttachmentRef[]; } -export interface EditorAgentStageEvent { - conversationId: string; - stage: EditorAgentStage; +export interface EditorAgentMessageResponse { + conversation: EditorAgentConversationSummary; + deltaMessages: EditorAgentMessage[]; + errorMessage: string | null; } - -export interface EditorAgentMessageDeltaEvent { - conversationId: string; - messageId: string; - role: EditorAgentMessageRole; - kind: EditorAgentMessageKind; - textDelta: string; -} - -export interface EditorAgentToolEvent { - conversationId: string; - messageId: string; - toolCallId: string; - toolName: EditorAgentToolName; - summary?: string | null; - taskId?: string | null; - model?: string | null; - status?: EditorAgentGenerationStatus | null; - error?: string | null; -} - -export interface EditorAgentGenerationResultEvent { - conversationId: string; - messageId: string; - toolCallId: string; - toolName: EditorAgentToolName; - model: string | null; - images: EditorAgentGeneratedImage[]; -} - -export interface EditorAgentErrorEvent { - conversationId: string | null; - code: string; - message: string; - recoverable: boolean; -} - -export interface EditorAgentDoneEvent { - conversationId: string; - title: string | null; -} - -export type EditorAgentSseEvent = - | { event: 'stage'; data: EditorAgentStageEvent } - | { event: 'message_delta'; data: EditorAgentMessageDeltaEvent } - | { event: 'tool_started'; data: EditorAgentToolEvent } - | { event: 'tool_completed'; data: EditorAgentToolEvent } - | { event: 'generation_result'; data: EditorAgentGenerationResultEvent } - | { event: 'error'; data: EditorAgentErrorEvent } - | { event: 'done'; data: EditorAgentDoneEvent }; diff --git a/packages/shared/src/contracts/hostBridge.test.ts b/packages/shared/src/contracts/hostBridge.test.ts index 1b79fb6dc..b5999566f 100644 --- a/packages/shared/src/contracts/hostBridge.test.ts +++ b/packages/shared/src/contracts/hostBridge.test.ts @@ -1,38 +1,38 @@ import { describe, expect, test } from 'vitest'; import { + HOST_BRIDGE_CAPABILITIES, + HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS, + HOST_BRIDGE_DESKTOP_NETWORK_CHECK_TIMEOUT_MS, + HOST_BRIDGE_DOCUMENT_MIME_TYPES, + HOST_BRIDGE_EVENTS, HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES, HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES, HOST_BRIDGE_EXPO_MOBILE_IOS_EXTRA_CAPABILITIES, - HOST_BRIDGE_NATIVE_APP_QUERY, - HOST_BRIDGE_NATIVE_APP_QUERY_KEY, - HOST_BRIDGE_NATIVE_APP_QUERY_KEYS, - HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS, - HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY, - HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES, - HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES, - HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY, - HOST_BRIDGE_PUBLIC_WEB_ORIGIN, - HOST_BRIDGE_PUBLIC_WEB_URL, - HOST_BRIDGE_TAURI_COMMAND, - HOST_BRIDGE_CAPABILITIES, - HOST_BRIDGE_DOCUMENT_MIME_TYPES, - HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS, - HOST_BRIDGE_DESKTOP_NETWORK_CHECK_TIMEOUT_MS, - HOST_BRIDGE_EVENTS, HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES, HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT, HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS, HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID, HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS, + HOST_BRIDGE_NATIVE_APP_QUERY, + HOST_BRIDGE_NATIVE_APP_QUERY_KEY, + HOST_BRIDGE_NATIVE_APP_QUERY_KEYS, + HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS, + HOST_BRIDGE_PUBLIC_WEB_ORIGIN, + HOST_BRIDGE_PUBLIC_WEB_URL, HOST_BRIDGE_RESPONSE_CACHE_MAX, + HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY, HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS, HOST_BRIDGE_SCANNER_TIMEOUT_MS, + HOST_BRIDGE_TAURI_COMMAND, + HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES, HOST_BRIDGE_TEXT_MIME_TYPES, HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS, - isHostBridgeMethod, + HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES, + HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY, isHostBridgeCapability, isHostBridgeEventName, + isHostBridgeMethod, normalizeHostBridgeAppTitle, normalizeHostBridgeBadgeCount, normalizeHostBridgeClipboardText, diff --git a/packages/shared/src/contracts/puzzleAgentActions.ts b/packages/shared/src/contracts/puzzleAgentActions.ts index bf0f92eec..ae38849b3 100644 --- a/packages/shared/src/contracts/puzzleAgentActions.ts +++ b/packages/shared/src/contracts/puzzleAgentActions.ts @@ -1,5 +1,5 @@ -import type { PuzzleAgentSessionSnapshot } from './puzzleAgentSession'; import type { ExternalGenerationJobStatusRecord } from './externalGeneration'; +import type { PuzzleAgentSessionSnapshot } from './puzzleAgentSession'; export type PuzzleAgentSuggestedActionType = | 'request_summary' diff --git a/packages/shared/src/contracts/rpgAgentSession.ts b/packages/shared/src/contracts/rpgAgentSession.ts index 0bdb8ef12..bd933b672 100644 --- a/packages/shared/src/contracts/rpgAgentSession.ts +++ b/packages/shared/src/contracts/rpgAgentSession.ts @@ -1,4 +1,4 @@ -import type { RpgAgentActionResponse, RpgAgentOperationRecord, RpgAgentSupportedAction, RpgAgentSuggestedAction } from './rpgAgentActions'; +import type { RpgAgentActionResponse, RpgAgentOperationRecord, RpgAgentSuggestedAction,RpgAgentSupportedAction } from './rpgAgentActions'; import type { RpgCreationAnchorContent } from './rpgAgentAnchors'; import type { RpgAgentAssetCoverageSummary, RpgAgentDraftCardDetail, RpgAgentDraftCardSummary } from './rpgAgentDraft'; import type { RpgCreationPreviewEnvelope } from './rpgCreationPreview'; diff --git a/packages/shared/src/contracts/rpgContracts.test.ts b/packages/shared/src/contracts/rpgContracts.test.ts index 8278d2a0b..f0f9c2f57 100644 --- a/packages/shared/src/contracts/rpgContracts.test.ts +++ b/packages/shared/src/contracts/rpgContracts.test.ts @@ -1,12 +1,12 @@ import { describe, expect, test } from 'vitest'; + import type { CustomWorldAgentSessionSnapshot } from './customWorldAgentSession'; import type { CustomWorldResultPreviewEnvelope } from './customWorldResultPreview'; import type { CustomWorldWorkSummary } from './customWorldWorkSummary'; - import { createRpgAgentFoundationDraftProfileFixture, - createRpgAgentSupportedActionsFixture, createRpgAgentSessionFixture, + createRpgAgentSupportedActionsFixture, createRpgCreationAnchorContentFixture, createRpgCreationPreviewEnvelopeFixture, createRpgCreationPublishedProfileFixture, diff --git a/packages/shared/src/contracts/rpgCreationResultView.ts b/packages/shared/src/contracts/rpgCreationResultView.ts index 853a3eeb0..1917274cc 100644 --- a/packages/shared/src/contracts/rpgCreationResultView.ts +++ b/packages/shared/src/contracts/rpgCreationResultView.ts @@ -1,5 +1,5 @@ -import type { CustomWorldProfileRecord } from './runtime'; import type { RpgAgentSessionSnapshot } from './rpgAgentSession'; +import type { CustomWorldProfileRecord } from './runtime'; export type RpgCreationResultProfileSource = | 'result_preview' diff --git a/packages/shared/src/http.ts b/packages/shared/src/http.ts index cfa89536b..bda8f6c94 100644 --- a/packages/shared/src/http.ts +++ b/packages/shared/src/http.ts @@ -20,7 +20,7 @@ export type ApiErrorCode = | 'conflict' | 'upstream_error' | 'internal_error' - | (string & {}); + | (string & Record); export type ApiErrorPayload = { code: ApiErrorCode; diff --git a/scripts/check-admin-account-procedures.mjs b/scripts/check-admin-account-procedures.mjs index 9c8440615..ea49c7f26 100644 --- a/scripts/check-admin-account-procedures.mjs +++ b/scripts/check-admin-account-procedures.mjs @@ -20,7 +20,7 @@ const repoRoot = path.resolve( '..', ); const database = 'admin-account-smoke'; -const expectedSpacetimeVersion = '2.6.0'; +const expectedSpacetimeVersion = '2.6.1'; const commandTimeoutMs = 5 * 60 * 1000; function assert(condition, message) { diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index e65292757..4d613b04b 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -1385,47 +1385,7 @@ const desktopCapabilityFlowContracts = [ ], }, ]; -const h5NativeAppRouteFlowContracts = [ - { - route: '/child-motion-demo', - label: 'child motion demo', - files: [ - 'src/components/platform-entry/PlatformEntryFlowShellImpl.tsx', - 'src/routing/appRoutes.tsx', - 'src/routing/appRoutes.test.ts', - 'src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx', - ], - snippets: [ - [ - 'src/components/platform-entry/PlatformEntryFlowShellImpl.tsx', - "navigateHostNativePage('/child-motion-demo')", - ], - [ - 'src/components/platform-entry/PlatformEntryFlowShellImpl.tsx', - "window.location.assign('/child-motion-demo')", - ], - ['src/routing/appRoutes.tsx', "normalizedPath === '/child-motion-demo'"], - ['src/routing/appRoutes.test.ts', "matchAppRoute('/child-motion-demo')"], - [ - 'src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx', - "request.method === 'navigation.openNativePage'", - ], - [ - 'src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx', - "url === '/child-motion-demo'", - ], - ], - tests: [ - 'src/routing/appRoutes.test.ts', - ], - targetedTests: [ - { - filePath: 'src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx', - name: 'native app opens child motion demo through host navigation bridge', - }, - ], - }, -]; +const h5NativeAppRouteFlowContracts = []; const expectedWechatHostBridgeFiles = [ 'dispatch.js', 'payment.js', @@ -1732,8 +1692,6 @@ const h5HostBridgeTests = [ 'src/services/clipboard.test.ts', 'src/services/appTitle.test.ts', ]; -const h5PlatformHostBridgeIntegrationTest = - 'native app jump hop draft completion sends host notification and badge from platform shell'; const h5NativeAppRouteFlowTestSteps = h5NativeAppRouteFlowContracts.flatMap( (contract) => (contract.targetedTests ?? []).map((test) => ({ @@ -1764,18 +1722,6 @@ const steps = [ command: npmCommand, args: ['run', 'test', '--', ...h5HostBridgeTests], }, - { - label: 'h5-platform-host-bridge-integration', - command: npmCommand, - args: [ - 'run', - 'test', - '--', - 'src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx', - '-t', - h5PlatformHostBridgeIntegrationTest, - ], - }, ...h5NativeAppRouteFlowTestSteps, { label: 'wechat-shell-tests', @@ -1807,16 +1753,16 @@ const steps = [ command: npmCommand, args: ['run', 'mobile-shell:export'], }, - { - label: 'desktop-shell-typecheck', - command: npmCommand, - args: ['run', 'desktop-shell:typecheck'], - }, { label: 'desktop-shell-test', command: npmCommand, args: ['run', 'desktop-shell:test'], }, + { + label: 'desktop-shell-typecheck', + command: npmCommand, + args: ['run', 'desktop-shell:typecheck'], + }, { label: 'desktop-shell-release-build-smoke', command: npmCommand, @@ -2439,7 +2385,6 @@ function assertH5HostBridgePayloadBoundaries() { 'HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS', 'HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS', 'HOST_BRIDGE_SCANNER_TIMEOUT_MS', - 'HOST_BRIDGE_APP_TITLE_MAX_LENGTH', ]) { if (!h5HostBridgeSource.includes(`${sharedBoundary},`)) { throw new Error( diff --git a/scripts/check-pingora-current-release-audit.mjs b/scripts/check-pingora-current-release-audit.mjs index dd87e6220..f03ee6cd6 100644 --- a/scripts/check-pingora-current-release-audit.mjs +++ b/scripts/check-pingora-current-release-audit.mjs @@ -1,18 +1,18 @@ #!/usr/bin/env node import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { chmodSync, copyFileSync, existsSync, - symlinkSync, mkdirSync, mkdtempSync, readFileSync, rmSync, + symlinkSync, writeFileSync, } from 'node:fs'; -import { createHash } from 'node:crypto'; import { tmpdir } from 'node:os'; import path from 'node:path'; diff --git a/scripts/check-pingora-cutover-evidence-verify.mjs b/scripts/check-pingora-cutover-evidence-verify.mjs index 83cc9c7b6..186809bc0 100644 --- a/scripts/check-pingora-cutover-evidence-verify.mjs +++ b/scripts/check-pingora-cutover-evidence-verify.mjs @@ -3,7 +3,6 @@ import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { - existsSync, mkdirSync, mkdtempSync, readFileSync, diff --git a/scripts/check-pingora-direct-enable.mjs b/scripts/check-pingora-direct-enable.mjs index dcfe02110..7216283c4 100644 --- a/scripts/check-pingora-direct-enable.mjs +++ b/scripts/check-pingora-direct-enable.mjs @@ -3,8 +3,8 @@ import { spawnSync } from 'node:child_process'; import { existsSync, - mkdtempSync, mkdirSync, + mkdtempSync, readFileSync, rmSync, symlinkSync, diff --git a/scripts/check-pingora-direct-live.mjs b/scripts/check-pingora-direct-live.mjs index 1fb3c48fe..fdd392bc2 100644 --- a/scripts/check-pingora-direct-live.mjs +++ b/scripts/check-pingora-direct-live.mjs @@ -1,12 +1,12 @@ #!/usr/bin/env node +import { createHash, randomBytes } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; import http from 'node:http'; import https from 'node:https'; import net from 'node:net'; -import tls from 'node:tls'; -import { createHash, randomBytes } from 'node:crypto'; -import { readFile } from 'node:fs/promises'; import nodePath from 'node:path'; +import tls from 'node:tls'; const DEFAULT_TIMEOUT_MS = 5000; const DEFAULT_SPACETIME_DATABASE = 'genarrative-prod'; diff --git a/scripts/check-pingora-direct-preflight.mjs b/scripts/check-pingora-direct-preflight.mjs index 67ddf2747..e06d75644 100644 --- a/scripts/check-pingora-direct-preflight.mjs +++ b/scripts/check-pingora-direct-preflight.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node +import { spawnSync } from 'node:child_process'; import { accessSync, constants, readFileSync } from 'node:fs'; import net from 'node:net'; -import { spawnSync } from 'node:child_process'; import { userInfo } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; diff --git a/scripts/check-pingora-gateway-env-shadow-switch.mjs b/scripts/check-pingora-gateway-env-shadow-switch.mjs index 57a28ee43..fe28ba89e 100644 --- a/scripts/check-pingora-gateway-env-shadow-switch.mjs +++ b/scripts/check-pingora-gateway-env-shadow-switch.mjs @@ -3,7 +3,6 @@ import { spawnSync } from 'node:child_process'; import { chmodSync, - existsSync, mkdtempSync, readFileSync, rmSync, diff --git a/scripts/check-pingora-health-patrol-env-switch.mjs b/scripts/check-pingora-health-patrol-env-switch.mjs index 922664aeb..dc6fc0999 100644 --- a/scripts/check-pingora-health-patrol-env-switch.mjs +++ b/scripts/check-pingora-health-patrol-env-switch.mjs @@ -7,8 +7,8 @@ import { existsSync, mkdirSync, mkdtempSync, - realpathSync, readFileSync, + realpathSync, rmSync, statSync, symlinkSync, diff --git a/scripts/check-pingora-realpath-canary-toggle.mjs b/scripts/check-pingora-realpath-canary-toggle.mjs index 22f0e0c95..59fae8ecf 100644 --- a/scripts/check-pingora-realpath-canary-toggle.mjs +++ b/scripts/check-pingora-realpath-canary-toggle.mjs @@ -4,8 +4,8 @@ import { spawnSync } from 'node:child_process'; import { chmodSync, existsSync, - mkdtempSync, mkdirSync, + mkdtempSync, readFileSync, rmSync, writeFileSync, diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index 22843943d..bf3331dc6 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -13,11 +13,56 @@ const checks = [ includes: 'npm run check:rustfmt', reason: 'API 生产构建必须在编译前执行 Rust 格式检查。', }, + { + file: 'jenkins/Jenkinsfile.production-api-build', + includes: 'npm run check:server-rs-ddd', + reason: 'API 生产构建必须执行 server-rs DDD/schema/runtime-access 门禁。', + }, + { + file: 'jenkins/Jenkinsfile.production-api-build', + includes: 'cargo check -p api-server --all-targets --manifest-path server-rs/Cargo.toml', + reason: 'API 生产构建必须执行 api-server 全 target 编译检查。', + }, { file: 'jenkins/Jenkinsfile.production-stdb-module-build', includes: 'npm run check:rustfmt', reason: 'Stdb module 生产构建必须在编译前执行 Rust 格式检查。', }, + { + file: 'jenkins/Jenkinsfile.production-stdb-module-build', + includes: 'npm run check:server-rs-ddd', + reason: 'Stdb module 生产构建必须执行 DDD/schema/runtime-access 门禁。', + }, + { + file: 'jenkins/Jenkinsfile.production-stdb-module-build', + includes: 'npm run check:admin-account-procedures', + reason: 'Stdb module 生产构建必须执行管理员 procedure smoke。', + }, + { + file: 'jenkins/Jenkinsfile.production-web-build', + includes: 'npm run check:production-ops', + reason: 'Web 生产构建必须执行生产运维静态门禁。', + }, + { + file: 'jenkins/Jenkinsfile.production-web-build', + includes: 'npm run lint:eslint', + reason: 'Web 生产构建必须执行 ESLint 门禁。', + }, + { + file: 'jenkins/Jenkinsfile.production-web-build', + includes: 'npm run typecheck', + reason: 'Web 生产构建必须执行主站类型检查。', + }, + { + file: 'jenkins/Jenkinsfile.production-web-build', + includes: 'npm run admin-web:typecheck', + reason: 'Web 生产构建必须执行后台类型检查。', + }, + { + file: 'jenkins/Jenkinsfile.production-web-build', + includes: 'npm run test', + reason: 'Web 生产构建必须执行当前维护范围内的 Vitest。', + }, { file: 'server-rs/crates/spacetime-module/src/migration.rs', includes: diff --git a/scripts/check-server-provision-tools.sh b/scripts/check-server-provision-tools.sh index 7d4b9caa8..f8805debb 100755 --- a/scripts/check-server-provision-tools.sh +++ b/scripts/check-server-provision-tools.sh @@ -37,11 +37,11 @@ chmod +x "${TARGET_BIN_DIR}/otelcol-contrib" cat >"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-cli" <<'EOF' #!/usr/bin/env bash -echo "spacetimedb-cli 2.6.0" +echo "spacetimedb-cli 2.6.1" EOF cat >"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-standalone" <<'EOF' #!/usr/bin/env bash -echo "spacetimedb-standalone 2.6.0" +echo "spacetimedb-standalone 2.6.1" EOF chmod +x \ "${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-cli" \ @@ -58,7 +58,7 @@ if ! ( OTELCOL_TARGET_BIN="${TARGET_BIN_DIR}/otelcol-contrib" \ OTELCOL_VERSION="0.151.0" \ SPACETIME_ROOT="${SPACETIME_ROOT_DIR}" \ - SPACETIME_EXPECTED_VERSION="2.6.0" \ + SPACETIME_EXPECTED_VERSION="2.6.1" \ "${REPO_ROOT}/scripts/prepare-server-provision-tools.sh" \ >"${OUTPUT_LOG}" 2>&1 ); then diff --git a/scripts/check-server-rs-ddd-boundaries.mjs b/scripts/check-server-rs-ddd-boundaries.mjs index 54bf0536f..607ccd0e7 100644 --- a/scripts/check-server-rs-ddd-boundaries.mjs +++ b/scripts/check-server-rs-ddd-boundaries.mjs @@ -146,8 +146,11 @@ function collectMigrationTables() { return new Set(); } + const migrationTableBody = macroMatch[1] + .replace(/\/\*[\s\S]*?\*\//gu, ' ') + .replace(/\/\/[^\r\n]*/gu, ' '); return new Set( - [...macroMatch[1].matchAll(/\b([a-z][a-z0-9_]*)\b/gu)] + [...migrationTableBody.matchAll(/\b([a-z][a-z0-9_]*)\b/gu)] .map((match) => match[1]) .filter((name) => !['arg'].includes(name)), ); diff --git a/scripts/check-spacetime-runtime-access.mjs b/scripts/check-spacetime-runtime-access.mjs index 0931ef21a..271a68337 100644 --- a/scripts/check-spacetime-runtime-access.mjs +++ b/scripts/check-spacetime-runtime-access.mjs @@ -108,6 +108,12 @@ const forbiddenSnippets = [ snippet: '.custom_world_profile()\n .iter()\n .filter(|profile| {', reason: 'custom_world_profile Published 同步已有 by_custom_world_profile_publication_status 索引', }, + { + file: 'server-rs/crates/spacetime-module/src/editor_project_storage.rs', + snippet: 'require_editor_generation_runtime_service_identity(ctx, ctx.sender())', + reason: + 'procedure runtime writer 鉴权必须显式传 caller,兼容 TxContext sender 为 Identity::ZERO 的 SpacetimeDB 2.6.0', + }, ]; const procedureResultFiles = [ diff --git a/scripts/check-spacetime-schema-guard.mjs b/scripts/check-spacetime-schema-guard.mjs index e3042ca2a..c34fbbf38 100644 --- a/scripts/check-spacetime-schema-guard.mjs +++ b/scripts/check-spacetime-schema-guard.mjs @@ -481,9 +481,9 @@ function getChangedFiles(baseRef) { tryGit(['ls-files', '--others', '--exclude-standard', '-z', bindingsRoot]) ?? ''; return new Set( [ - ...diffOutput.split(/\u0000/u), - ...untrackedModuleOutput.split(/\u0000/u), - ...untrackedBindingsOutput.split(/\u0000/u), + ...diffOutput.split(String.fromCharCode(0)), + ...untrackedModuleOutput.split(String.fromCharCode(0)), + ...untrackedBindingsOutput.split(String.fromCharCode(0)), ] .map(normalizePath) .filter(Boolean), @@ -575,7 +575,7 @@ function compareTables(baseTables, currentTables) { } } - for (const [accessor, table] of currentTables) { + for (const accessor of currentTables.keys()) { if (!baseTables.has(accessor)) { schemaChanged = true; } diff --git a/scripts/check-visual-novel-vn11-negative-scan.mjs b/scripts/check-visual-novel-vn11-negative-scan.mjs index d26f3265a..f482d6af2 100644 --- a/scripts/check-visual-novel-vn11-negative-scan.mjs +++ b/scripts/check-visual-novel-vn11-negative-scan.mjs @@ -180,10 +180,6 @@ function hasExternalPlatformMarker(line) { return externalPlatformPatterns.some((pattern) => pattern.test(line)); } -function formatHits(hits) { - return hits.map((hit) => ` - ${hit.file}:${hit.lineNumber} ${hit.text}`); -} - const documentFiles = collectFiles(documentTargets); const visualNovelFiles = collectFiles(visualNovelImplementationTargets); diff --git a/scripts/container-worker-smoke.mjs b/scripts/container-worker-smoke.mjs index c7c2c9e5c..689e928fa 100644 --- a/scripts/container-worker-smoke.mjs +++ b/scripts/container-worker-smoke.mjs @@ -761,7 +761,7 @@ function composeEnv() { } function localSpacetimeImageName() { - return `${projectName}-spacetimedb:2.6.0`; + return `${projectName}-spacetimedb:2.6.1`; } function spacetimeServerUrl(state) { diff --git a/scripts/deploy/pingora-gateway-env-shadow-switch.mjs b/scripts/deploy/pingora-gateway-env-shadow-switch.mjs index ab7ba188e..aede1700b 100644 --- a/scripts/deploy/pingora-gateway-env-shadow-switch.mjs +++ b/scripts/deploy/pingora-gateway-env-shadow-switch.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node import { - chownSync, chmodSync, + chownSync, lstatSync, mkdtempSync, readFileSync, diff --git a/scripts/deploy/pingora-health-patrol-env-switch.mjs b/scripts/deploy/pingora-health-patrol-env-switch.mjs index 60f5df19a..dd09c41d8 100644 --- a/scripts/deploy/pingora-health-patrol-env-switch.mjs +++ b/scripts/deploy/pingora-health-patrol-env-switch.mjs @@ -2,8 +2,8 @@ import { spawnSync } from 'node:child_process'; import { - chownSync, chmodSync, + chownSync, lstatSync, mkdtempSync, readFileSync, diff --git a/scripts/deploy/pingora-tls-cert-sync.mjs b/scripts/deploy/pingora-tls-cert-sync.mjs index 63533eea7..569de5c00 100644 --- a/scripts/deploy/pingora-tls-cert-sync.mjs +++ b/scripts/deploy/pingora-tls-cert-sync.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node +import { execFileSync, spawnSync } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { accessSync, @@ -15,9 +16,8 @@ import { rmSync, statSync, } from 'node:fs'; -import { execFileSync, spawnSync } from 'node:child_process'; -import path from 'node:path'; import { userInfo } from 'node:os'; +import path from 'node:path'; const DEFAULT_SERVICE_USER = 'genarrative'; const DEFAULT_SERVICE_GROUP = 'genarrative'; diff --git a/scripts/deploy/production-runtime-writer-identity-rotate.mjs b/scripts/deploy/production-runtime-writer-identity-rotate.mjs index 29ffc2f6d..44489533b 100644 --- a/scripts/deploy/production-runtime-writer-identity-rotate.mjs +++ b/scripts/deploy/production-runtime-writer-identity-rotate.mjs @@ -1,11 +1,12 @@ #!/usr/bin/env node +import { spawn } from 'node:child_process'; + import { callSpacetimeProcedureViaCli, ensureProcedureOk, validateSpacetimeDatabaseName, } from '../spacetime-migration-common.mjs'; -import { spawn } from 'node:child_process'; const PROCEDURE = 'rotate_editor_generation_runtime_service_identity_and_return'; diff --git a/scripts/dev-stack-port-utils.mjs b/scripts/dev-stack-port-utils.mjs index c119ea46e..d4f5cdd0f 100644 --- a/scripts/dev-stack-port-utils.mjs +++ b/scripts/dev-stack-port-utils.mjs @@ -8,9 +8,9 @@ import { rmSync, writeFileSync, } from 'node:fs'; +import {createServer} from 'node:net'; import {hostname, userInfo} from 'node:os'; import {dirname, resolve} from 'node:path'; -import {createServer} from 'node:net'; const LINUX_DEV_PORT_RANGE_REGISTRY_ROOT = '/var/tmp/genarrative-dev-port-ranges'; const LINUX_DEV_PORT_RANGE_POOL_START = 10000; @@ -308,7 +308,7 @@ function nextLinuxPortRangeCandidate(registry) { async function withLinuxPortRangeRegistryLock(lockPath, action) { ensureWorldWritableDirectory(dirname(lockPath)); - while (true) { + for (;;) { try { writeFileSync( lockPath, diff --git a/scripts/dev-stack-port-utils.test.ts b/scripts/dev-stack-port-utils.test.ts index 353b7a100..8f56402f7 100644 --- a/scripts/dev-stack-port-utils.test.ts +++ b/scripts/dev-stack-port-utils.test.ts @@ -1,8 +1,10 @@ -import {createServer} from 'node:net'; import {mkdtempSync, readFileSync, rmSync} from 'node:fs'; +import {createServer} from 'node:net'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; + import {describe, expect, it} from 'vitest'; + import { findAvailablePort, mapDevPortsToPortRange, @@ -23,6 +25,27 @@ function reservePort(port) { }); } +async function reserveConsecutivePorts() { + for (let attempt = 0; attempt < 20; attempt += 1) { + const firstServer = await reservePort(0); + const firstPort = firstServer.address().port; + + if (firstPort === 65535) { + await new Promise((resolve) => firstServer.close(resolve)); + continue; + } + + try { + const secondServer = await reservePort(firstPort + 1); + return {firstServer, secondServer, firstPort}; + } catch { + await new Promise((resolve) => firstServer.close(resolve)); + } + } + + throw new Error('无法为测试保留两个连续端口'); +} + describe('dev stack port utils', () => { it('解析端口段并映射到四个 dev 端口', () => { expect(parsePortRangeSpec('10000-10099')).toEqual({ @@ -39,9 +62,7 @@ describe('dev stack port utils', () => { }); it('使用端口可用性检查为被占用端口寻找后续可用端口', async () => { - const firstServer = await reservePort(0); - const firstPort = firstServer.address().port; - const secondServer = await reservePort(firstPort + 1); + const {firstServer, secondServer, firstPort} = await reserveConsecutivePorts(); try { const availablePort = await findAvailablePort({ diff --git a/scripts/dev.mjs b/scripts/dev.mjs index ebb73c5f0..5bdf33f72 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -921,7 +921,8 @@ function readLinuxApiServerProcessSnapshot(pid) { if ( error?.code === 'ENOENT' || error?.code === 'EACCES' || - error?.code === 'EPERM' + error?.code === 'EPERM' || + error?.code === 'ESRCH' ) { return null; } diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts index 988d21bf9..b6ebbac90 100644 --- a/scripts/dev.test.ts +++ b/scripts/dev.test.ts @@ -945,6 +945,7 @@ spacetimedb tool version 2.6.0; spacetimedb-lib version 2.6.0; }); test('本地 API identity 不跟随符号链接记录', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-identity-')); try { const { explicitOptions, options } = parseArgs( @@ -988,6 +989,9 @@ spacetimedb tool version 2.6.0; spacetimedb-lib version 2.6.0; identity: 'linked-identity', token: 'linked-token', }); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('本地 API identity 记录不可用,将重新创建'), + ); } finally { rmSync(tempDir, { recursive: true, force: true }); } @@ -1165,6 +1169,7 @@ spacetimedb tool version 2.6.0; spacetimedb-lib version 2.6.0; }); test('不复用权限宽松、格式非法或空的本地运行服务 bootstrap secret 记录', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const tempDir = mkdtempSync( join(tmpdir(), 'genarrative-runtime-bootstrap-secret-'), ); @@ -1234,6 +1239,11 @@ spacetimedb tool version 2.6.0; spacetimedb-lib version 2.6.0; if (process.platform !== 'win32') { expect(statSync(secretPath).mode & 0o777).toBe(0o600); } + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + '本地运行服务 bootstrap secret 记录不可用,将重新生成', + ), + ); } finally { rmSync(tempDir, { recursive: true, force: true }); } diff --git a/scripts/export-match3d-resource-pipeline.mjs b/scripts/export-match3d-resource-pipeline.mjs index 93f7839cb..48b597e52 100644 --- a/scripts/export-match3d-resource-pipeline.mjs +++ b/scripts/export-match3d-resource-pipeline.mjs @@ -1,7 +1,7 @@ import {Buffer} from 'node:buffer'; -import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; -import path from 'node:path'; import {spawnSync} from 'node:child_process'; +import { mkdirSync, readFileSync, writeFileSync} from 'node:fs'; +import path from 'node:path'; import {mergeApiServerEnv} from './dev-utils.mjs'; diff --git a/scripts/ops/production-health-patrol.mjs b/scripts/ops/production-health-patrol.mjs index 60eab89f9..14b2dd2b3 100644 --- a/scripts/ops/production-health-patrol.mjs +++ b/scripts/ops/production-health-patrol.mjs @@ -1,9 +1,9 @@ #!/usr/bin/env node import { execFile } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; import http from 'node:http'; import https from 'node:https'; -import { mkdir, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; const STATUS_RANK = { diff --git a/scripts/prepare-server-provision-tools.sh b/scripts/prepare-server-provision-tools.sh index a1bc2ee70..8a8ff3a9b 100755 --- a/scripts/prepare-server-provision-tools.sh +++ b/scripts/prepare-server-provision-tools.sh @@ -9,7 +9,7 @@ OTELCOL_DOWNLOAD_ROOT="${OTELCOL_DOWNLOAD_ROOT:-https://github.com/open-telemetr OTELCOL_ARCHIVE_PATH="${OTELCOL_ARCHIVE_PATH:-}" OTELCOL_TARGET_BIN="${OTELCOL_TARGET_BIN:-/usr/local/bin/otelcol-contrib}" SPACETIME_INSTALLER_URL="${SPACETIME_INSTALLER_URL:-https://install.spacetimedb.com}" -SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.6.0}" +SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.6.1}" SPACETIME_TARGET_HOST="${SPACETIME_TARGET_HOST:-x86_64-unknown-linux-gnu}" SPACETIME_ROOT="${SPACETIME_ROOT:-/stdb}" SPACETIME_EXPECTED_VERSION="${SPACETIME_EXPECTED_VERSION:-}" diff --git a/scripts/rebind-orphan-work-owners.test.ts b/scripts/rebind-orphan-work-owners.test.ts index b57335860..736279d3c 100644 --- a/scripts/rebind-orphan-work-owners.test.ts +++ b/scripts/rebind-orphan-work-owners.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; + import { rebindOrphanWorkOwnersInMigration } from './rebind-orphan-work-owners.mjs'; const placeholderUserId = 'wx-openid-placeholder'; diff --git a/scripts/repair-auth-public-user-codes.test.ts b/scripts/repair-auth-public-user-codes.test.ts index 6273e77d6..b9414ee8d 100644 --- a/scripts/repair-auth-public-user-codes.test.ts +++ b/scripts/repair-auth-public-user-codes.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; + import { repairAuthPublicUserCodesInMigration } from './repair-auth-public-user-codes.mjs'; function table(name: string, rows: unknown[]) { diff --git a/scripts/run-bash-script.mjs b/scripts/run-bash-script.mjs index a18812145..6aec0ea5b 100644 --- a/scripts/run-bash-script.mjs +++ b/scripts/run-bash-script.mjs @@ -1,5 +1,5 @@ -import {existsSync} from 'node:fs'; import {spawn} from 'node:child_process'; +import {existsSync} from 'node:fs'; const [, , scriptPath, ...scriptArgs] = process.argv; diff --git a/scripts/smoke-content.ts b/scripts/smoke-content.ts index 0929a65da..ae1dd814d 100644 --- a/scripts/smoke-content.ts +++ b/scripts/smoke-content.ts @@ -1,4 +1,4 @@ -import { buildCompanionState, ROLE_TEMPLATE_CHARACTERS, resolveEncounterRecruitCharacter } from '../src/data/characterPresets.ts'; +import { buildCompanionState, resolveEncounterRecruitCharacter,ROLE_TEMPLATE_CHARACTERS } from '../src/data/characterPresets.ts'; import { activateRosterCompanion, benchActiveCompanion, recruitCompanionToParty } from '../src/data/companionRoster.ts'; import { getInventoryItemValue, getNpcPurchasePrice } from '../src/data/economy.ts'; import { diff --git a/scripts/spacetime-export-migration-json.mjs b/scripts/spacetime-export-migration-json.mjs index 4837e115b..94a5912c5 100644 --- a/scripts/spacetime-export-migration-json.mjs +++ b/scripts/spacetime-export-migration-json.mjs @@ -2,6 +2,7 @@ import { writeFile } from 'node:fs/promises'; import path from 'node:path'; + import { callSpacetimeProcedure, callSpacetimeProcedureViaCli, diff --git a/scripts/spacetime-import-migration-json.mjs b/scripts/spacetime-import-migration-json.mjs index 8b46fa16e..aa9d14653 100644 --- a/scripts/spacetime-import-migration-json.mjs +++ b/scripts/spacetime-import-migration-json.mjs @@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; + import { assertReadableFile, callSpacetimeProcedure, diff --git a/scripts/spacetime-logs-local.sh b/scripts/spacetime-logs-local.sh index 9502ba0a4..0ca7f8592 100644 --- a/scripts/spacetime-logs-local.sh +++ b/scripts/spacetime-logs-local.sh @@ -13,7 +13,7 @@ usage() { 1. 从本地 SpacetimeDB 通过 spacetime logs 提取模块日志到本地文件。 2. 默认读取 spacetime.local.json 的 database 字段,默认 server 为 http://127.0.0.1:3101。 3. 默认输出到 logs/spacetime/-.log;--follow 会持续追加并同步写到终端。 - 4. 默认使用 server-rs/.spacetimedb/local 作为 spacetime CLI root,保持本地身份与 standalone 一致。 + 4. 始终显式传入 server URL,不读取或改写 spacetime CLI 的个人 root 配置。 EOF } @@ -52,7 +52,6 @@ REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" DATABASE="" SPACETIME_SERVER="http://127.0.0.1:3101" -SPACETIME_ROOT_DIR="${REPO_ROOT}/server-rs/.spacetimedb/local" LINES="200" OUTPUT="" FOLLOW=0 @@ -71,10 +70,6 @@ while [[ $# -gt 0 ]]; do SPACETIME_SERVER="${2:?缺少 --server 的值}" shift 2 ;; - --spacetime-root-dir) - SPACETIME_ROOT_DIR="${2:?缺少 --spacetime-root-dir 的值}" - shift 2 - ;; --lines|-n) LINES="${2:?缺少 --lines 的值}" shift 2 @@ -123,7 +118,6 @@ fi echo "[stdb:logs] database: ${DATABASE}" echo "[stdb:logs] server: ${SPACETIME_SERVER}" -echo "[stdb:logs] spacetime root: ${SPACETIME_ROOT_DIR}" echo "[stdb:logs] output: ${OUTPUT}" -spacetime --root-dir="${SPACETIME_ROOT_DIR}" "${ARGS[@]}" | tee -a "${OUTPUT}" +spacetime "${ARGS[@]}" | tee -a "${OUTPUT}" diff --git a/scripts/spacetime-migration-common.test.ts b/scripts/spacetime-migration-common.test.ts index 02219e50b..1e14f7c6f 100644 --- a/scripts/spacetime-migration-common.test.ts +++ b/scripts/spacetime-migration-common.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; + import { buildSpacetimeCallArgs, encodeSpacetimeCliOption, diff --git a/scripts/test-ve-llm.mjs b/scripts/test-ve-llm.mjs index d2e65f06a..a9f2b848e 100644 --- a/scripts/test-ve-llm.mjs +++ b/scripts/test-ve-llm.mjs @@ -1,5 +1,5 @@ import { readFileSync } from 'node:fs'; -import { resolve, dirname } from 'node:path'; +import { dirname,resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -52,7 +52,9 @@ async function test(name, method, path, body = null) { const elapsed = Date.now() - start; const text = await resp.text(); let json = null; - try { json = JSON.parse(text); } catch {} + try { json = JSON.parse(text); } catch { + // 非 JSON 上游错误由下方原始文本分支处理。 + } if (resp.ok) { const model = json?.model || json?.data?.[0]?.id || '?'; diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 8c03ba844..fefa47d57 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -238,6 +238,7 @@ dependencies = [ "platform-agent", "platform-audio", "platform-auth", + "platform-editor-agent", "platform-hyper3d", "platform-image", "platform-llm", @@ -4441,6 +4442,19 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "platform-editor-agent" +version = "0.1.0" +dependencies = [ + "hmac", + "platform-agent", + "platform-llm", + "serde", + "serde_json", + "sha2", + "shared-contracts", +] + [[package]] name = "platform-hyper3d" version = "0.1.0" @@ -5871,9 +5885,9 @@ dependencies = [ [[package]] name = "spacetimedb" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8037ee5a6fa7348bf0392c728e4de4a0ba6f89c967eff80b666ac3e1e8f2b612" +checksum = "b6eff5017248ff2e35e03564df947fdf3e963f7198be6ae1d9e894c2efb2ea15" dependencies = [ "anyhow", "bytemuck", @@ -5894,9 +5908,9 @@ dependencies = [ [[package]] name = "spacetimedb-bindings-macro" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202fb591c7d18fbbd4aec24a34ad6f03e3accc41580aee0fd2f9ad0514681917" +checksum = "fce86c7657dcb42a33ce55f0100f98964dc15b64e79f06803a478676b74d6c82" dependencies = [ "heck 0.4.1", "humantime", @@ -5908,18 +5922,18 @@ dependencies = [ [[package]] name = "spacetimedb-bindings-sys" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60869317019bbc8fa571e5c8ff21fe72abf9e5bcb579531efdded4e93a76e827" +checksum = "dbe5f9d71d59bdb93d81a7a61eee75ac2f1f88f91e139a7e263261f9dbf316de" dependencies = [ "spacetimedb-primitives", ] [[package]] name = "spacetimedb-client-api-messages" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad481e05f959aa646e36e3e2e8b7581da58ca8f8a1b5e643122fc2bd4a4ff0df" +checksum = "1642404553853b56e521a12aa9849807d8e003433934c73eeebbc8671f1254f8" dependencies = [ "bytes", "bytestring", @@ -5939,9 +5953,9 @@ dependencies = [ [[package]] name = "spacetimedb-data-structures" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ec3aa387bfa45691c11b57e9072522dde3bbb554f7e017b57025b591556dbfb" +checksum = "3407c90a3da48b1c421bbad6a2ee6de3855a77d73fae98773bcc058426f1318f" dependencies = [ "ahash", "crossbeam-queue", @@ -5954,9 +5968,9 @@ dependencies = [ [[package]] name = "spacetimedb-lib" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d05a4bef4619afe949f7eac9c3a31f06f21c5139c89920a8f4c7dd7b09c24a8" +checksum = "bceaf72bdbf1a624d55d17ebaa38f8cf78a4972e43641c6cbf94f82318b5581d" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -5979,9 +5993,9 @@ dependencies = [ [[package]] name = "spacetimedb-memory-usage" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "064ae4130ddc47e1b87b59252d041cf929ffbdc53686af80dff290f819e523ef" +checksum = "6cc1e19f664b47f4b3b82fb69b2fc096c3d82dc659ea705d62bbbec2fa9c2e83" dependencies = [ "decorum", "ethnum", @@ -5989,9 +6003,9 @@ dependencies = [ [[package]] name = "spacetimedb-metrics" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65b36210b2cdde2784e22e7182717bf1ab06a431f54bf9662e38bd1bcb8681c5" +checksum = "0c794455ed96245d52d837c8bbc3493d1c17d9f6382e6c5fee1c88926c9aa04a" dependencies = [ "arrayvec", "itertools 0.12.1", @@ -6001,9 +6015,9 @@ dependencies = [ [[package]] name = "spacetimedb-primitives" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c1e4745d32aacb71d29050f1ff6fa24fa149d939b8787f90ec28d2cac3c107b" +checksum = "b7525fda46b21fffdc62805099dee853a79000e0673ff6df4b8355cb0b8dabe8" dependencies = [ "bitflags 2.13.0", "either", @@ -6015,18 +6029,18 @@ dependencies = [ [[package]] name = "spacetimedb-query-builder" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79849bd28750b3351d54f3df4b37c5454cae3c62d0184bc9855650048a80c032" +checksum = "1f283c4797961d9a94f623ee4c66e6a94d30861b6380a49a27ac2d9568e17125" dependencies = [ "spacetimedb-lib", ] [[package]] name = "spacetimedb-sats" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ca3327e8cd735f347ef5ff08a61b9806418acdcc0394ddd35a212c0849e4295" +checksum = "6cefb87b7d5e760d43cb65855591e25af772bdcca19562e5796ae797240d1998" dependencies = [ "anyhow", "arrayvec", @@ -6057,9 +6071,9 @@ dependencies = [ [[package]] name = "spacetimedb-schema" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb81b65cfa1da17f5f032f9566086670fb68a27f14f194a4dad76a21cc79f490" +checksum = "775cfdeb406e5628bf78b2221b0e0fd2fcba583f691bb89ba1c9338790ecaf7d" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -6088,9 +6102,9 @@ dependencies = [ [[package]] name = "spacetimedb-sdk" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ceb50bcefcb70ae517c672a7ac21cbaf7affd44f0d9991968869eff566d83ba5" +checksum = "074a6d8828638176eb8e9bba7a3e6fb75dc659b260717306eb966c7e10575cd7" dependencies = [ "anymap3", "base64 0.21.7", @@ -6120,9 +6134,9 @@ dependencies = [ [[package]] name = "spacetimedb-sql-parser" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86cbda18ee77e497b698b22377750228b1e88ead4dcb79fc3fddb78cccab0a77" +checksum = "3b978b6d76cfbe5782a7f0dae7e25f6be4e7e13cfad26c9211d4eb4cdf8c22da" dependencies = [ "derive_more", "spacetimedb-lib", diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 3d3bc1151..61d585844 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -42,6 +42,7 @@ members = [ "crates/platform-wechat", "crates/platform-speech", "crates/platform-agent", + "crates/platform-editor-agent", "crates/pingora-gateway", "crates/server-manager-panel", "crates/shared-contracts", @@ -84,6 +85,7 @@ module-square-hole = { path = "crates/module-square-hole", default-features = fa module-story = { path = "crates/module-story", default-features = false } module-visual-novel = { path = "crates/module-visual-novel", default-features = false } platform-agent = { path = "crates/platform-agent", default-features = false } +platform-editor-agent = { path = "crates/platform-editor-agent", default-features = false } platform-auth = { path = "crates/platform-auth", default-features = false } platform-audio = { path = "crates/platform-audio", default-features = false } platform-hyper3d = { path = "crates/platform-hyper3d", default-features = false } @@ -133,9 +135,9 @@ serde_urlencoded = "0.7" sha1 = "0.10" sha2 = "0.10" socket2 = "0.6" -spacetimedb = "=2.6.0" -spacetimedb-sdk = "=2.6.0" -spacetimedb-lib = { version = "=2.6.0", default-features = false } +spacetimedb = "=2.6.1" +spacetimedb-sdk = "=2.6.1" +spacetimedb-lib = { version = "=2.6.1", default-features = false } time = "0.3" tokio = "1" tokio-stream = "0.1" diff --git a/server-rs/README.md b/server-rs/README.md index 661f13bb4..02f8a1cb1 100644 --- a/server-rs/README.md +++ b/server-rs/README.md @@ -30,10 +30,10 @@ cargo test -p api-server --manifest-path server-rs/Cargo.toml npm run check:server-rs-ddd npm run check:spacetime-schema npm run spacetime:generate -npm run api-server +npm run dev:api-server ``` -涉及 API smoke 时用 `npm run api-server` 启动后端并检查 `/healthz`,不要使用旧 `api-server:maincloud`。 +涉及 API smoke 时用 `npm run dev:api-server` 启动后端并检查 `/healthz`,不要使用旧 `api-server:maincloud`。 ## 当前文档 diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index 8949fdca5..29a238b70 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -38,6 +38,7 @@ module-square-hole = { workspace = true } module-story = { workspace = true } module-visual-novel = { workspace = true } platform-agent = { workspace = true } +platform-editor-agent = { workspace = true } platform-audio = { workspace = true } platform-auth = { workspace = true } platform-hyper3d = { workspace = true } diff --git a/server-rs/crates/api-server/README.md b/server-rs/crates/api-server/README.md index 2a1bba8c9..42f3336e4 100644 --- a/server-rs/crates/api-server/README.md +++ b/server-rs/crates/api-server/README.md @@ -99,7 +99,7 @@ 1. 进程启动时通过 `shared-logging` 统一初始化 `tracing subscriber`。 2. 默认日志过滤器来自 `GENARRATIVE_API_LOG`,未提供时回落到 `info,tower_http=info`。 3. HTTP 访问日志统一通过 Axum 路由层的 `TraceLayer` 输出,后续 `request_id`、响应头与错误中间件继续在同一层扩展。 -4. 本地启动器 `npm run api-server` 和完整联调入口 `npm run dev` / `npm run dev:rust` 会在保留终端实时输出的同时,把同一份 `cargo` / `api-server` 输出持久化到 `logs/api-server/`。如需固定文件或目录,可设置 `GENARRATIVE_API_SERVER_LOG_FILE` 或 `GENARRATIVE_API_SERVER_LOG_DIR`。 +4. 本地启动器 `npm run dev:api-server` 和完整联调入口 `npm run dev` 会在保留终端实时输出的同时,把同一份 `cargo` / `api-server` 输出持久化到 `logs/api-server/`。如需固定文件或目录,可设置 `GENARRATIVE_API_SERVER_LOG_FILE` 或 `GENARRATIVE_API_SERVER_LOG_DIR`。 当前 request context 约定: diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 976c67481..a148df426 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -1725,19 +1725,18 @@ mod tests { } #[tokio::test] - async fn editor_character_animation_accepts_character_image_body_above_default_limit() { + async fn editor_character_animation_rejects_inline_data_url_before_queueing() { let state = AppState::new(AppConfig { - external_generation_mode: ExternalGenerationMode::Inline, + external_generation_mode: ExternalGenerationMode::Queue, ..AppConfig::default() }) .expect("state should build"); let seed_user = seed_phone_user_with_password(&state, "13800138190", TEST_PASSWORD).await; let token = sign_test_user_token(&state, &seed_user, "sess_editor_character_body"); let app = build_router(state); - let source_image_src = format!("data:image/png;base64,{}", "A".repeat(3 * 1024 * 1024)); let request_body = serde_json::json!({ "sourceLayerId": "layer-character-large", - "sourceImageSrc": source_image_src, + "sourceImageSrc": "data:image/png;base64,AAAA", "sourceWidth": 1024, "sourceHeight": 1024, "promptText": "待机呼吸循环。", @@ -1748,7 +1747,6 @@ mod tests { "model": "seedance2.0-fast" }) .to_string(); - assert!(request_body.len() > 2 * 1024 * 1024); let response = app .oneshot( @@ -1763,7 +1761,7 @@ mod tests { .await .expect("request should succeed"); - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = response .into_body() .collect() @@ -1772,10 +1770,9 @@ mod tests { .to_bytes(); let body_text = String::from_utf8_lossy(&body); assert!( - body_text.contains("ARK_CHARACTER_VIDEO_BASE_URL"), - "handler should parse the oversized character source image before checking Ark config: {body_text}" + body_text.contains("先上传 OSS"), + "handler should reject inline character sources: {body_text}" ); - assert!(!body_text.contains("length limit exceeded")); } #[tokio::test] @@ -1811,23 +1808,21 @@ mod tests { } #[tokio::test] - async fn editor_ui_design_asset_extraction_accepts_image_body_above_default_limit() { + async fn editor_ui_design_asset_extraction_rejects_inline_data_url_before_queueing() { let state = AppState::new(AppConfig { - external_generation_mode: ExternalGenerationMode::Inline, + external_generation_mode: ExternalGenerationMode::Queue, ..AppConfig::default() }) .expect("state should build"); let seed_user = seed_phone_user_with_password(&state, "13800138188", TEST_PASSWORD).await; let token = sign_test_user_token(&state, &seed_user, "sess_editor_ui_extract_body"); let app = build_router(state); - let source_image_src = format!("data:image/png;base64,{}", "A".repeat(3 * 1024 * 1024)); let request_body = serde_json::json!({ - "sourceImageSrc": source_image_src, + "sourceImageSrc": "data:image/png;base64,AAAA", "aspectRatio": "1:1", "imageSize": "1K", }) .to_string(); - assert!(request_body.len() > 2 * 1024 * 1024); let response = app .oneshot( @@ -1842,7 +1837,7 @@ mod tests { .await .expect("request should succeed"); - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = response .into_body() .collect() @@ -1851,33 +1846,28 @@ mod tests { .to_bytes(); let body_text = String::from_utf8_lossy(&body); assert!( - body_text.contains("VECTOR_ENGINE") - || body_text.contains("OPENAI") - || body_text.contains("配置"), - "handler should parse the oversized UI design image before checking image config: {body_text}" + body_text.contains("先上传 OSS"), + "handler should reject inline UI design sources: {body_text}" ); - assert!(!body_text.contains("length limit exceeded")); } #[tokio::test] - async fn editor_image_generation_accepts_reference_body_above_default_limit() { + async fn editor_image_generation_rejects_inline_data_url_before_queueing() { let state = AppState::new(AppConfig { - external_generation_mode: ExternalGenerationMode::Inline, + external_generation_mode: ExternalGenerationMode::Queue, ..AppConfig::default() }) .expect("state should build"); let seed_user = seed_phone_user_with_password(&state, "13800138028", TEST_PASSWORD).await; let token = sign_test_user_token(&state, &seed_user, "sess_editor_image_reference_body"); let app = build_router(state); - let reference_image_src = format!("data:image/png;base64,{}", "A".repeat(3 * 1024 * 1024)); let request_body = serde_json::json!({ "prompt": "生成宣发素材", "kind": "publication-material", "size": "720x540", - "referenceImageSrcs": [reference_image_src], + "referenceImageSrcs": ["data:image/png;base64,AAAA"], }) .to_string(); - assert!(request_body.len() > 2 * 1024 * 1024); let response = app .oneshot( @@ -1892,7 +1882,7 @@ mod tests { .await .expect("request should succeed"); - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = response .into_body() .collect() @@ -1901,31 +1891,28 @@ mod tests { .to_bytes(); let body_text = String::from_utf8_lossy(&body); assert!( - body_text.contains("VECTOR_ENGINE_BASE_URL"), - "handler should parse the oversized editor reference before checking VectorEngine config: {body_text}" + body_text.contains("先上传 OSS"), + "handler should reject inline editor references: {body_text}" ); - assert!(!body_text.contains("length limit exceeded")); } #[tokio::test] - async fn editor_image_edit_accepts_reference_body_above_default_limit() { + async fn editor_image_edit_rejects_inline_data_url_before_queueing() { let state = AppState::new(AppConfig { - external_generation_mode: ExternalGenerationMode::Inline, + external_generation_mode: ExternalGenerationMode::Queue, ..AppConfig::default() }) .expect("state should build"); let seed_user = seed_phone_user_with_password(&state, "13800138029", TEST_PASSWORD).await; let token = sign_test_user_token(&state, &seed_user, "sess_editor_image_edit_body"); let app = build_router(state); - let source_image_src = format!("data:image/png;base64,{}", "A".repeat(3 * 1024 * 1024)); let request_body = serde_json::json!({ "prompt": "快速编辑图片", - "sourceImageSrc": source_image_src, + "sourceImageSrc": "data:image/png;base64,AAAA", "size": "1024x1024", "model": "gpt-image-2" }) .to_string(); - assert!(request_body.len() > 2 * 1024 * 1024); let response = app .oneshot( @@ -1940,7 +1927,7 @@ mod tests { .await .expect("request should succeed"); - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = response .into_body() .collect() @@ -1949,10 +1936,9 @@ mod tests { .to_bytes(); let body_text = String::from_utf8_lossy(&body); assert!( - body_text.contains("VECTOR_ENGINE_BASE_URL"), - "handler should parse the oversized editor edit image before checking VectorEngine config: {body_text}" + body_text.contains("先上传 OSS"), + "handler should reject inline editor edit sources: {body_text}" ); - assert!(!body_text.contains("length limit exceeded")); } #[tokio::test] @@ -2025,10 +2011,7 @@ mod tests { payload["user"]["loginMethod"], Value::String("password".to_string()) ); - assert_eq!( - payload["user"]["createdAt"], - Value::String(seed_user.created_at) - ); + assert!(payload["user"].get("createdAt").is_none()); assert!(payload["token"].as_str().is_some()); } @@ -2308,7 +2291,7 @@ mod tests { payload["user"]["phoneNumberMasked"], Value::String("138****8000".to_string()) ); - assert!(payload["user"]["createdAt"].as_str().is_some()); + assert!(payload["user"].get("createdAt").is_none()); assert_eq!(payload["created"], Value::Bool(true)); assert!(payload["referral"].is_null()); } diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index dfc6d90f0..16113daaf 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -1217,13 +1217,17 @@ mod tests { #[tokio::test] async fn direct_upload_ticket_returns_service_unavailable_when_oss_missing() { - let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + let state = AppState::new(AppConfig::default()).expect("state should build"); + let token = + seed_authenticated_token(&state, "13800138121", "sess_assets_missing_oss").await; + let app = build_router(state); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/assets/direct-upload-tickets") + .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .body(Body::from( json!({ @@ -1490,13 +1494,17 @@ mod tests { #[tokio::test] async fn sts_upload_credentials_are_disabled_for_browser_writes() { - let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + let state = AppState::new(AppConfig::default()).expect("state should build"); + let token = + seed_authenticated_token(&state, "13800138122", "sess_assets_sts_disabled").await; + let app = build_router(state); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/assets/sts-upload-credentials") + .header("authorization", format!("Bearer {token}")) .header("x-genarrative-response-envelope", "1") .body(Body::empty()) .expect("request should build"), @@ -1525,13 +1533,18 @@ mod tests { #[tokio::test] async fn confirm_asset_object_returns_service_unavailable_when_oss_missing() { - let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + let state = AppState::new(AppConfig::default()).expect("state should build"); + let token = + seed_authenticated_token(&state, "13800138123", "sess_assets_confirm_missing_oss") + .await; + let app = build_router(state); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/assets/objects/confirm") + .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .body(Body::from( json!({ @@ -1558,13 +1571,17 @@ mod tests { ..AppConfig::default() }; - let app = build_router(AppState::new(config).expect("state should build")); + let state = AppState::new(config).expect("state should build"); + let token = + seed_authenticated_token(&state, "13800138124", "sess_assets_bucket_mismatch").await; + let app = build_router(state); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/assets/objects/confirm") + .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .body(Body::from( json!({ @@ -1588,13 +1605,17 @@ mod tests { #[tokio::test] async fn bind_asset_object_rejects_missing_slot_before_calling_spacetime() { - let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + let state = AppState::new(AppConfig::default()).expect("state should build"); + let token = + seed_authenticated_token(&state, "13800138125", "sess_assets_missing_slot").await; + let app = build_router(state); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/assets/objects/bind") + .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .body(Body::from( json!({ diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index d89599167..b925e07d3 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -64,13 +64,15 @@ use crate::{ EditorScreenBackgroundColor, editor_green_screen_character_prompt_clause, }, editor_project::{ - EDITOR_BGFILTER_CROSS_CHECK_ENABLED, EDITOR_BGFILTER_DEFAULT_SEG_MODEL, + EDITOR_ASSET_ID_PREFIX, EDITOR_BGFILTER_CROSS_CHECK_ENABLED, + EDITOR_BGFILTER_DEFAULT_SEG_MODEL, EDITOR_RESOURCE_ID_PREFIX, EditorCanvasGeneratedLayerInput, EditorGenerationPhaseReporter, PersistEditorGeneratedAssetRequest, apply_editor_screen_background_decision_to_generation_inputs, build_editor_canvas_generated_layer_item, complete_editor_canvas_generation_with_items, persist_editor_generated_media_asset, remove_editor_generated_screen_background_with_bgfilter_with_request_timeout, + resolve_editor_reference_object_key_for_owner, }, editor_screen_background_decision::{ EditorScreenBackgroundDecisionInput, EditorScreenBackgroundDecisionKind, @@ -129,7 +131,7 @@ const EDITOR_VIDEO_MODEL_VEO_3_1_FAST: &str = "veo3.1-fast"; const EDITOR_VIDEO_REFERENCE_IMAGE_LIMIT: usize = 9; const EDITOR_VIDEO_REFERENCE_VIDEO_LIMIT: usize = 3; const EDITOR_VIDEO_REFERENCE_AUDIO_LIMIT: usize = 3; -const EDITOR_VIDEO_REFERENCE_REQUEST_LIMIT_BYTES: usize = 64 * 1024 * 1024; +const EDITOR_VIDEO_REFERENCE_REQUEST_LIMIT_BYTES: usize = 256 * 1024; const EDITOR_VIDEO_REFERENCE_READ_EXPIRE_SECONDS: u64 = 60 * 60; const BUILT_IN_MOTION_TEMPLATES: [MotionTemplate; 4] = [ @@ -567,6 +569,14 @@ pub async fn generate_editor_character_animation( ) })?; let owner_user_id = authenticated.claims().user_id().to_string(); + if matches_inline_media_source(payload.source_image_src.as_str()) { + return Err(character_animation_error_response( + &request_context, + editor_character_animation_bad_request( + "sourceImageSrc 必须先上传 OSS,并使用 objectKey 或画板资源引用。", + ), + )); + } if !state.config.external_generation_mode.is_inline() { let pricing = state.editor_generation_pricing().await.map_err(|error| { character_animation_error_response( @@ -640,6 +650,14 @@ pub(crate) async fn generate_editor_character_animation_for_owner( let asset_folder_id = payload.asset_folder_id.clone(); let asset_label = resolve_editor_character_animation_asset_label(payload.asset_label.as_deref()); + if matches_inline_media_source(payload.source_image_src.as_str()) { + return Err(character_animation_error_response( + &request_context, + editor_character_animation_bad_request( + "sourceImageSrc 必须先上传 OSS,并使用 objectKey 或画板资源引用。", + ), + )); + } let pricing = state.editor_generation_pricing().await.map_err(|error| { character_animation_error_response( @@ -653,10 +671,17 @@ pub(crate) async fn generate_editor_character_animation_for_owner( // 先解析源角色图:图生视频的主体颜色完全由源图决定,背景色决策必须看到这张图。 let source_resolve_client = build_upstream_http_client(SOURCE_IMAGE_RESOLVE_TIMEOUT_MS) .map_err(|error| character_animation_error_response(&request_context, error))?; + let source_object_key = resolve_editor_reference_object_key_for_owner( + &state, + owner_user_id.as_str(), + payload.source_image_src.trim(), + ) + .await + .map_err(|error| character_animation_error_response(&request_context, error))?; let source_data_url = resolve_media_source_as_data_url( &state, &source_resolve_client, - payload.source_image_src.trim(), + source_object_key.as_str(), "sourceImageSrc", ) .await @@ -2013,7 +2038,8 @@ async fn request_editor_video_preview( request: &NormalizedEditorVideoRequest, ) -> Result { let upstream_task_id = - create_editor_text_to_video_task(state, http_client, settings, request).await?; + create_editor_text_to_video_task(state, http_client, settings, owner_user_id, request) + .await?; let video_url = wait_for_ark_content_generation_task(http_client, &settings.ark, upstream_task_id.as_str()) .await?; @@ -2037,9 +2063,10 @@ async fn create_editor_text_to_video_task( state: &AppState, http_client: &reqwest::Client, settings: &EditorVideoSettings, + owner_user_id: &str, request: &NormalizedEditorVideoRequest, ) -> Result { - let request_body = build_editor_video_ark_request_body(state, request)?; + let request_body = build_editor_video_ark_request_body(state, owner_user_id, request).await?; let response = http_client .post(format!( "{}/contents/generations/tasks", @@ -3396,11 +3423,12 @@ fn normalize_editor_video_request_with_pricing( }) } -fn build_editor_video_ark_request_body( +async fn build_editor_video_ark_request_body( state: &AppState, + owner_user_id: &str, request: &NormalizedEditorVideoRequest, ) -> Result { - let content = build_editor_video_ark_content(state, request)?; + let content = build_editor_video_ark_content(state, owner_user_id, request).await?; Ok(json!({ "model": request.provider_model, "content": content, @@ -3412,8 +3440,9 @@ fn build_editor_video_ark_request_body( })) } -fn build_editor_video_ark_content( +async fn build_editor_video_ark_content( state: &AppState, + owner_user_id: &str, request: &NormalizedEditorVideoRequest, ) -> Result, AppError> { let mut content = Vec::new(); @@ -3425,22 +3454,25 @@ fn build_editor_video_ark_content( } let reference_image_urls = resolve_editor_video_reference_urls( state, + owner_user_id, request.reference_image_srcs.as_slice(), "参考图片", - true, - )?; + ) + .await?; let reference_video_urls = resolve_editor_video_reference_urls( state, + owner_user_id, request.reference_video_srcs.as_slice(), "参考视频", - false, - )?; + ) + .await?; let reference_audio_urls = resolve_editor_video_reference_urls( state, + owner_user_id, request.reference_audio_srcs.as_slice(), "参考音频", - true, - )?; + ) + .await?; content.extend(reference_image_urls.iter().map(|url| { json!({ "type": "image_url", @@ -3465,31 +3497,30 @@ fn build_editor_video_ark_content( Ok(content) } -fn resolve_editor_video_reference_urls( +async fn resolve_editor_video_reference_urls( state: &AppState, + owner_user_id: &str, sources: &[String], label: &str, - allow_data_url: bool, ) -> Result, AppError> { - sources - .iter() - .map(|source| { - resolve_editor_video_reference_url(state, source.as_str(), label, allow_data_url) - }) - .collect() + let mut resolved = Vec::with_capacity(sources.len()); + for source in sources { + resolved.push( + resolve_editor_video_reference_url(state, owner_user_id, source.as_str(), label) + .await?, + ); + } + Ok(resolved) } -fn resolve_editor_video_reference_url( +async fn resolve_editor_video_reference_url( state: &AppState, + owner_user_id: &str, source: &str, label: &str, - allow_data_url: bool, ) -> Result { let value = source.trim(); if value.starts_with("data:") { - if allow_data_url { - return Ok(value.to_string()); - } return Err(editor_video_bad_request(format!( "{label}格式不支持,请使用官方支持的 URL、素材 ID 或画板资源路径。" ))); @@ -3501,17 +3532,24 @@ fn resolve_editor_video_reference_url( return Ok(value.to_string()); } - let object_key = value.trim_start_matches('/'); - if LegacyAssetPrefix::from_object_key(object_key).is_none() { - let format_hint = if allow_data_url { - "公网 URL、素材 ID、data URL 或画板资源路径" - } else { - "公网 URL、素材 ID 或画板资源路径" - }; - return Err(editor_video_bad_request(format!( - "{label}必须是官方支持的{format_hint}。" - ))); - } + let object_key = resolve_editor_reference_object_key_for_owner(state, owner_user_id, value) + .await + .map_err(|error| { + error.with_details(json!({ + "provider": "editor-video", + "field": label, + "message": format!( + "{label}必须是当前账号的 objectKey、项目资源 ID、素材 ID,或官方支持的公网 URL。" + ), + })) + })?; + sign_editor_video_object_key_url(state, object_key.as_str()) +} + +fn sign_editor_video_object_key_url( + state: &AppState, + object_key: &str, +) -> Result { let oss_client = state.oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "aliyun-oss", @@ -3576,9 +3614,9 @@ fn normalize_editor_video_references( validate_editor_video_reference_src( src, "参考图片", - &["data:image/", "http://", "https://", "asset://"], + &["http://", "https://", "asset://"], true, - "对应媒体 data URL", + "URL、素材 ID 或画板资源路径", )?; } for src in &video_srcs { @@ -3594,9 +3632,9 @@ fn normalize_editor_video_references( validate_editor_video_reference_src( src, "参考音频", - &["data:audio/", "http://", "https://", "asset://"], + &["http://", "https://", "asset://"], true, - "对应媒体 data URL", + "URL、素材 ID 或画板资源路径", )?; } @@ -3607,7 +3645,9 @@ fn normalize_editor_video_references( .map(String::len) .sum::(); if request_bytes > EDITOR_VIDEO_REFERENCE_REQUEST_LIMIT_BYTES { - return Err(editor_video_bad_request("参考文件总请求体不能超过 64MB。")); + return Err(editor_video_bad_request( + "参考素材引用字段总长度不能超过 256KB。", + )); } Ok(NormalizedEditorVideoReferences { @@ -3625,6 +3665,11 @@ fn normalize_reference_srcs(values: Vec) -> Vec { .collect() } +fn matches_inline_media_source(value: &str) -> bool { + let value = value.trim_start().to_ascii_lowercase(); + value.starts_with("data:") || value.starts_with("blob:") +} + fn validate_editor_video_reference_src( value: &str, label: &str, @@ -3643,6 +3688,9 @@ fn validate_editor_video_reference_src( { return Ok(()); } + if value.starts_with(EDITOR_RESOURCE_ID_PREFIX) || value.starts_with(EDITOR_ASSET_ID_PREFIX) { + return Ok(()); + } Err(editor_video_bad_request(format!( "{label}格式不支持,请使用官方支持的{format_hint}。" ))) @@ -6066,6 +6114,34 @@ mod tests { ); } + #[test] + fn editor_character_animation_rejects_inline_media_before_queueing() { + let source = include_str!("character_animation_assets.rs"); + assert_function_contains_in_order( + source, + "pub async fn generate_editor_character_animation", + "pub(crate) async fn generate_editor_character_animation_for_owner", + &[ + "matches_inline_media_source", + "enqueue_editor_generation_job", + ], + ); + assert_function_contains_in_order( + source, + "pub(crate) async fn generate_editor_character_animation_for_owner", + "pub async fn generate_editor_video", + &[ + "resolve_editor_reference_object_key_for_owner", + "resolve_media_source_as_data_url", + ], + ); + assert!(matches_inline_media_source("data:image/png;base64,AAAA")); + assert!(matches_inline_media_source(" BLOB:local-preview")); + assert!(!matches_inline_media_source( + "/generated-images/editor/character.png" + )); + } + #[test] fn editor_character_animation_reports_processing_after_video_generation() { let source = include_str!("character_animation_assets.rs"); @@ -6534,11 +6610,15 @@ mod tests { mode: "std".to_string(), sound: "off".to_string(), web_search_enabled: true, - reference_image_srcs: vec!["data:image/png;base64,image".to_string()], + reference_image_srcs: vec![ + "generated-character-drafts/editor/seedance-references/image/demo.png".to_string(), + ], reference_video_srcs: vec![ "generated-character-drafts/editor/seedance-references/video/demo.mp4".to_string(), ], - reference_audio_srcs: vec!["data:audio/mpeg;base64,audio".to_string()], + reference_audio_srcs: vec![ + "generated-character-drafts/editor/seedance-references/audio/demo.mp3".to_string(), + ], project_id: None, canvas_completion: None, generation_inputs: None, @@ -6551,7 +6631,7 @@ mod tests { assert_eq!( normalized.reference_image_srcs, - vec!["data:image/png;base64,image"] + vec!["generated-character-drafts/editor/seedance-references/image/demo.png"] ); assert_eq!( normalized.reference_video_srcs, @@ -6559,7 +6639,7 @@ mod tests { ); assert_eq!( normalized.reference_audio_srcs, - vec!["data:audio/mpeg;base64,audio"] + vec!["generated-character-drafts/editor/seedance-references/audio/demo.mp3"] ); } @@ -6591,7 +6671,46 @@ mod tests { } #[test] - fn editor_video_ark_body_sets_generate_audio_false_without_mode() { + fn editor_video_rejects_seedance_image_and_audio_data_urls() { + for (image_srcs, audio_srcs, expected_message) in [ + ( + vec!["data:image/png;base64,image".to_string()], + Vec::new(), + "参考图片格式不支持", + ), + ( + vec!["generated-character-drafts/editor/reference.png".to_string()], + vec!["data:audio/mpeg;base64,audio".to_string()], + "参考音频格式不支持", + ), + ] { + let error = normalize_editor_video_request(EditorVideoGenerateRequest { + prompt: "参考素材生成视频".to_string(), + model: EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST.to_string(), + aspect_ratio: "16:9".to_string(), + duration_seconds: 4, + resolution: "480p".to_string(), + mode: "std".to_string(), + sound: "off".to_string(), + web_search_enabled: true, + reference_image_srcs: image_srcs, + reference_video_srcs: Vec::new(), + reference_audio_srcs: audio_srcs, + project_id: None, + canvas_completion: None, + generation_inputs: None, + source_resource_id: None, + asset_kind: None, + asset_folder_id: None, + asset_label: None, + }) + .expect_err("Seedance references must not accept data URLs"); + assert!(error.body_text().contains(expected_message)); + } + } + + #[tokio::test] + async fn editor_video_ark_body_sets_generate_audio_false_without_mode() { let state = AppState::new(AppConfig::default()).expect("state should build"); let request = normalize_editor_video_request(EditorVideoGenerateRequest { prompt: "参考素材生成视频".to_string(), @@ -6602,9 +6721,9 @@ mod tests { mode: "std".to_string(), sound: "off".to_string(), web_search_enabled: true, - reference_image_srcs: vec!["data:image/png;base64,image".to_string()], + reference_image_srcs: vec!["https://assets.example.com/reference.png".to_string()], reference_video_srcs: vec!["https://assets.example.com/reference.mp4".to_string()], - reference_audio_srcs: vec!["data:audio/mpeg;base64,audio".to_string()], + reference_audio_srcs: vec!["https://assets.example.com/reference.mp3".to_string()], project_id: None, canvas_completion: None, generation_inputs: None, @@ -6615,8 +6734,9 @@ mod tests { }) .expect("seedance multimodal request should normalize"); - let body = - build_editor_video_ark_request_body(&state, &request).expect("ark body should build"); + let body = build_editor_video_ark_request_body(&state, "video-owner", &request) + .await + .expect("ark body should build"); assert_eq!(body["generate_audio"], json!(false)); assert!(body.get("mode").is_none()); @@ -6630,7 +6750,7 @@ mod tests { } #[test] - fn editor_video_ark_content_signs_generated_object_keys() { + fn editor_video_reference_resolution_checks_owner_before_signing() { let state = AppState::new(AppConfig { oss_bucket: Some("genarrative-assets".to_string()), oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()), @@ -6639,7 +6759,14 @@ mod tests { ..AppConfig::default() }) .expect("state should build with OSS"); - let request = normalize_editor_video_request(EditorVideoGenerateRequest { + let object_key = "generated-character-drafts/editor/seedance-references/video/demo.mp4"; + let signed_url = sign_editor_video_object_key_url(&state, object_key) + .expect("validated object keys should be signable"); + assert!(signed_url.starts_with("https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/")); + assert!(signed_url.contains(object_key)); + assert!(signed_url.contains("x-oss-signature=")); + + let registered_reference = normalize_editor_video_request(EditorVideoGenerateRequest { prompt: "参考视频生成视频".to_string(), model: EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST.to_string(), aspect_ratio: "16:9".to_string(), @@ -6649,9 +6776,7 @@ mod tests { sound: "off".to_string(), web_search_enabled: true, reference_image_srcs: Vec::new(), - reference_video_srcs: vec![ - "generated-character-drafts/editor/seedance-references/video/demo.mp4".to_string(), - ], + reference_video_srcs: vec!["editor-resource-video-reference".to_string()], reference_audio_srcs: Vec::new(), project_id: None, canvas_completion: None, @@ -6661,23 +6786,21 @@ mod tests { asset_folder_id: None, asset_label: None, }) - .expect("seedance objectKey request should normalize"); - - let content = build_editor_video_ark_content(&state, &request) - .expect("ark content should sign object keys"); - let signed_url = content[1]["video_url"]["url"] - .as_str() - .expect("video url should be string"); - - assert!(signed_url.starts_with("https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/")); - assert!( - signed_url - .contains("generated-character-drafts/editor/seedance-references/video/demo.mp4") + .expect("registered editor resource IDs should normalize"); + assert_eq!( + registered_reference.reference_video_srcs, + vec!["editor-resource-video-reference"] ); - assert!(signed_url.contains("x-oss-signature=")); - assert_ne!( - signed_url, - "generated-character-drafts/editor/seedance-references/video/demo.mp4" + + let source = include_str!("character_animation_assets.rs"); + assert_function_contains_in_order( + source, + "async fn resolve_editor_video_reference_url", + "fn sign_editor_video_object_key_url", + &[ + "resolve_editor_reference_object_key_for_owner", + "sign_editor_video_object_key_url", + ], ); } diff --git a/server-rs/crates/api-server/src/editor_agent.rs b/server-rs/crates/api-server/src/editor_agent.rs deleted file mode 100644 index 5d79a37a8..000000000 --- a/server-rs/crates/api-server/src/editor_agent.rs +++ /dev/null @@ -1,2813 +0,0 @@ -use std::{ - collections::BTreeMap, - convert::Infallible, - sync::{Arc, Mutex, OnceLock}, -}; - -use axum::{ - Json, - extract::{Extension, Path, State}, - http::StatusCode, - response::{ - IntoResponse, Response, - sse::{Event, Sse}, - }, -}; -use module_editor_agent::{ - EDITOR_AGENT_CONVERSATION_ID_PREFIX, EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE, - EDITOR_AGENT_MESSAGE_ID_PREFIX, derive_conversation_title, editor_agent_messages_object_key, - validate_user_message, -}; -use platform_llm::{LlmError, LlmErrorKind, LlmMessage, LlmMessageContentPart, LlmTextRequest}; -use platform_oss::{ - LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest, OssSignedGetObjectUrlRequest, -}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use shared_contracts::assets::{ - EditorCanvasGenerationCompletionPayload, EditorCanvasGenerationPlaceholderPayload, -}; -use shared_contracts::editor_agent::{ - CreateEditorAgentConversationRequest, EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION, - EditorAgentAttachmentRef, EditorAgentAttachmentSource, EditorAgentConversationDetail, - EditorAgentConversationListResponse, EditorAgentConversationMessagesDocument, - EditorAgentConversationResponse, EditorAgentConversationSummary, EditorAgentDoneEvent, - EditorAgentErrorEvent, EditorAgentGeneratedImage, EditorAgentGenerationRecord, - EditorAgentGenerationResultEvent, EditorAgentGenerationStatus, EditorAgentMessage, - EditorAgentMessageDeltaEvent, EditorAgentMessageKind, EditorAgentMessageRole, - EditorAgentMessageStatus, EditorAgentStage, EditorAgentStageEvent, EditorAgentToolEvent, - EditorAgentToolName, StreamEditorAgentMessageRequest, -}; -use shared_kernel::{build_prefixed_uuid_id, normalize_optional_string, normalize_required_string}; -use spacetime_client::{ - EditorAgentConversationCreateRecordInput, EditorAgentConversationDeleteRecordInput, - EditorAgentConversationRecord, EditorAgentConversationTouchRecordInput, - EditorAssetLibraryRecord, EditorAssetRecord, EditorProjectGetRecordInput, EditorProjectRecord, - EditorProjectResourceRecord, -}; - -use crate::{ - api_response::json_success_body, - auth::AuthenticatedAccessToken, - editor_project::{ - EditorGenerationCaller, EditorIconSpritesheetGenerationRequest, EditorImageEditRequest, - EditorImageGenerationRequest, current_utc_micros, edit_editor_image_for_owner, - generate_editor_icon_spritesheet_for_owner, generate_editor_image_for_owner, - map_editor_project_error, - }, - http_error::AppError, - platform_errors::map_oss_error, - request_context::RequestContext, - state::AppState, -}; - -const EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES: usize = 2 * 1024 * 1024; -const EDITOR_AGENT_MESSAGES_READ_EXPIRE_SECONDS: u64 = 60; -const EDITOR_AGENT_LLM_ATTACHMENT_URL_EXPIRE_SECONDS: u64 = 300; -const EDITOR_AGENT_LLM_MAX_HISTORY_MESSAGES: usize = 12; -const EDITOR_AGENT_LLM_PLANNING_MODEL: &str = platform_agent::CREATIVE_AGENT_GPT5_MODEL; -const EDITOR_AGENT_LLM_MAX_OUTPUT_TOKENS: u32 = 1024; -const EDITOR_AGENT_LLM_REQUEST_TIMEOUT_MS: u64 = 60_000; -const EDITOR_AGENT_TOOL_CALL_ID_PREFIX: &str = "editor-agent-tool"; -const EDITOR_AGENT_CANVAS_RESULT_GAP: f64 = 32.0; -type EditorAgentConversationLockMap = Mutex>>>; -static EDITOR_AGENT_CONVERSATION_LOCKS: OnceLock = OnceLock::new(); - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct EditorAgentConversationDeleteResponse { - deleted_conversation_id: String, - conversation: EditorAgentConversationSummary, -} - -pub async fn list_editor_agent_conversations( - State(state): State, - Path(project_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, -) -> Result, AppError> { - let owner_user_id = authenticated.claims().user_id().to_string(); - require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?; - ensure_editor_project_access(&state, project_id.as_str(), owner_user_id.as_str()).await?; - let conversations = state - .spacetime_client() - .list_editor_agent_conversations(project_id, owner_user_id) - .await - .map_err(map_editor_project_error)? - .into_iter() - .map(conversation_summary_from_record) - .collect(); - - Ok(json_success_body( - Some(&request_context), - EditorAgentConversationListResponse { conversations }, - )) -} - -pub async fn create_editor_agent_conversation( - State(state): State, - Path(project_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, - Json(payload): Json, -) -> Result, AppError> { - let owner_user_id = authenticated.claims().user_id().to_string(); - require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?; - ensure_editor_project_access(&state, project_id.as_str(), owner_user_id.as_str()).await?; - - let conversation_id = build_prefixed_uuid_id(EDITOR_AGENT_CONVERSATION_ID_PREFIX); - let messages_object_key = editor_agent_messages_object_key(conversation_id.as_str()); - let title = normalize_optional_string(payload.title) - .unwrap_or_else(|| EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE.to_string()); - let now_micros = current_utc_micros(); - let seed_record = EditorAgentConversationRecord { - conversation_id: conversation_id.clone(), - project_id: project_id.clone(), - owner_user_id: owner_user_id.clone(), - title: title.clone(), - messages_object_key: messages_object_key.clone(), - deleted: false, - created_at: now_rfc3339(), - updated_at: now_rfc3339(), - updated_at_micros: now_micros, - }; - write_messages_document( - &state, - &seed_record, - &empty_messages_document(conversation_id.as_str()), - ) - .await?; - - let conversation = state - .spacetime_client() - .create_editor_agent_conversation(EditorAgentConversationCreateRecordInput { - conversation_id, - project_id, - owner_user_id, - title, - messages_object_key, - created_at_micros: now_micros, - }) - .await - .map_err(map_editor_project_error)?; - let document = read_messages_document(&state, &conversation).await?; - - Ok(json_success_body( - Some(&request_context), - EditorAgentConversationResponse { - conversation: conversation_detail_from_record(conversation, document.messages), - }, - )) -} - -pub async fn get_editor_agent_conversation( - State(state): State, - Path(conversation_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, -) -> Result, AppError> { - let owner_user_id = authenticated.claims().user_id().to_string(); - require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?; - let conversation = state - .spacetime_client() - .get_editor_agent_conversation(conversation_id, owner_user_id) - .await - .map_err(map_editor_project_error)?; - let document = read_messages_document(&state, &conversation).await?; - - Ok(json_success_body( - Some(&request_context), - EditorAgentConversationResponse { - conversation: conversation_detail_from_record(conversation, document.messages), - }, - )) -} - -pub async fn delete_editor_agent_conversation( - State(state): State, - Path(conversation_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, -) -> Result, AppError> { - let owner_user_id = authenticated.claims().user_id().to_string(); - require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?; - let conversation = state - .spacetime_client() - .delete_editor_agent_conversation(EditorAgentConversationDeleteRecordInput { - conversation_id, - owner_user_id, - updated_at_micros: current_utc_micros(), - }) - .await - .map_err(map_editor_project_error)?; - - Ok(json_success_body( - Some(&request_context), - EditorAgentConversationDeleteResponse { - deleted_conversation_id: conversation.conversation_id.clone(), - conversation: conversation_summary_from_record(conversation), - }, - )) -} - -pub async fn stream_editor_agent_message( - State(state): State, - Path(conversation_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, - Json(payload): Json, -) -> Result { - let owner_user_id = authenticated.claims().user_id().to_string(); - require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?; - let client_message_id = normalize_required_string(payload.client_message_id.as_str()) - .ok_or_else(|| editor_agent_bad_request("clientMessageId is required"))?; - let attachment_reference_ids = payload - .attachments - .iter() - .map(|attachment| attachment.reference_id.clone()) - .collect::>(); - validate_user_message(payload.text.as_str(), &attachment_reference_ids) - .map_err(|error| editor_agent_bad_request(error.to_string()))?; - - let conversation = state - .spacetime_client() - .get_editor_agent_conversation(conversation_id, owner_user_id.clone()) - .await - .map_err(map_editor_project_error)?; - let attachments = - normalize_editor_agent_attachments(&state, &conversation, payload.attachments.as_slice()) - .await?; - let conversation_lock = editor_agent_conversation_lock(conversation.conversation_id.as_str()); - let conversation_lock_guard = conversation_lock.lock_owned().await; - let mut document = read_messages_document(&state, &conversation).await?; - if document - .messages - .iter() - .any(|message| message.id == client_message_id) - { - return Err( - AppError::from_status(StatusCode::CONFLICT).with_details(json!({ - "provider": "editor-agent", - "field": "clientMessageId", - "message": "clientMessageId already exists in this conversation", - })), - ); - } - - let was_empty = document.messages.is_empty(); - let user_message = EditorAgentMessage { - id: client_message_id, - role: EditorAgentMessageRole::User, - kind: EditorAgentMessageKind::Chat, - text: payload.text.trim().to_string(), - attachments, - generations: Vec::new(), - status: EditorAgentMessageStatus::Completed, - created_at: now_rfc3339(), - }; - document.messages.push(user_message.clone()); - write_messages_document(&state, &conversation, &document).await?; - let latest_generation_reference = latest_editor_agent_generated_image_reference(&document); - - let assistant_message_id = build_prefixed_uuid_id(EDITOR_AGENT_MESSAGE_ID_PREFIX); - let stream_state = state.clone(); - let stream_conversation = conversation.clone(); - let stream_title = was_empty.then(|| derive_conversation_title(user_message.text.as_str())); - let stream_assistant_message_id = assistant_message_id.clone(); - let stream_user_message = user_message.clone(); - let stream_latest_generation_reference = latest_generation_reference.clone(); - let stream_request_context = request_context.clone(); - let stream_conversation_lock_guard = conversation_lock_guard; - - let stream = async_stream::stream! { - let _conversation_lock_guard = stream_conversation_lock_guard; - yield Ok::(editor_agent_sse_json_event_or_error( - "stage", - EditorAgentStageEvent { - conversation_id: stream_conversation.conversation_id.clone(), - stage: EditorAgentStage::Thinking, - }, - )); - - let mut assistant_status = EditorAgentMessageStatus::Completed; - let mut generation_records = Vec::new(); - let mut planning_failed = false; - - let turn_plan = match plan_editor_agent_turn( - &stream_state, - &stream_conversation, - &document, - &stream_user_message, - stream_latest_generation_reference.as_ref(), - ) - .await { - Ok(turn_plan) => turn_plan, - Err(error) => { - let app_error = error.into_app_error(); - let error_message = app_error.body_text(); - assistant_status = EditorAgentMessageStatus::Failed; - planning_failed = true; - EditorAgentTurnPlan { - reply_text: error_message, - tool_call: None, - } - } - }; - let assistant_text = turn_plan.reply_text; - let assistant_kind = if assistant_status == EditorAgentMessageStatus::Failed { - EditorAgentMessageKind::Error - } else { - EditorAgentMessageKind::Chat - }; - - yield Ok::(editor_agent_sse_json_event_or_error( - "stage", - EditorAgentStageEvent { - conversation_id: stream_conversation.conversation_id.clone(), - stage: EditorAgentStage::Responding, - }, - )); - yield Ok::(editor_agent_sse_json_event_or_error( - "message_delta", - EditorAgentMessageDeltaEvent { - conversation_id: stream_conversation.conversation_id.clone(), - message_id: stream_assistant_message_id.clone(), - role: EditorAgentMessageRole::Assistant, - kind: assistant_kind, - text_delta: assistant_text.clone(), - }, - )); - if planning_failed { - yield Ok::(editor_agent_sse_json_event_or_error( - "stage", - EditorAgentStageEvent { - conversation_id: stream_conversation.conversation_id.clone(), - stage: EditorAgentStage::Failed, - }, - )); - } - - if let Some(tool_call) = turn_plan.tool_call { - let tool_call_id = build_prefixed_uuid_id(EDITOR_AGENT_TOOL_CALL_ID_PREFIX); - let tool_summary = tool_call - .summary - .clone() - .or_else(|| Some(editor_agent_tool_default_summary(tool_call.tool_name))); - yield Ok::(editor_agent_sse_json_event_or_error( - "tool_started", - EditorAgentToolEvent { - conversation_id: stream_conversation.conversation_id.clone(), - message_id: stream_assistant_message_id.clone(), - tool_call_id: tool_call_id.clone(), - tool_name: tool_call.tool_name, - summary: tool_summary.clone(), - task_id: None, - model: tool_call.model.clone(), - status: Some(EditorAgentGenerationStatus::Generating), - error: None, - }, - )); - yield Ok::(editor_agent_sse_json_event_or_error( - "stage", - EditorAgentStageEvent { - conversation_id: stream_conversation.conversation_id.clone(), - stage: EditorAgentStage::Generating, - }, - )); - - match execute_editor_agent_tool_call( - &stream_state, - &stream_request_context, - &stream_conversation, - &stream_user_message, - &tool_call_id, - stream_latest_generation_reference.as_ref(), - tool_call, - ) - .await - { - Ok(execution) => { - generation_records.push(execution.record.clone()); - yield Ok::(editor_agent_sse_json_event_or_error( - "generation_result", - EditorAgentGenerationResultEvent { - conversation_id: stream_conversation.conversation_id.clone(), - message_id: stream_assistant_message_id.clone(), - tool_call_id: tool_call_id.clone(), - tool_name: execution.record.tool_name, - model: execution.record.model.clone(), - images: execution.record.images.clone(), - }, - )); - yield Ok::(editor_agent_sse_json_event_or_error( - "tool_completed", - EditorAgentToolEvent { - conversation_id: stream_conversation.conversation_id.clone(), - message_id: stream_assistant_message_id.clone(), - tool_call_id, - tool_name: execution.record.tool_name, - summary: Some(execution.summary), - task_id: execution.record.task_id.clone(), - model: execution.record.model.clone(), - status: Some(EditorAgentGenerationStatus::Completed), - error: None, - }, - )); - } - Err(error) => { - let error_code = error.error.code().to_string(); - let error_message = error.error.body_text(); - let error_model = error.model.clone(); - assistant_status = EditorAgentMessageStatus::Failed; - generation_records.push(EditorAgentGenerationRecord { - tool_call_id: tool_call_id.clone(), - tool_name: error.tool_name, - summary: tool_summary.clone(), - task_id: None, - status: EditorAgentGenerationStatus::Failed, - model: error_model.clone(), - images: Vec::new(), - error: Some(error_message.clone()), - }); - yield Ok::(editor_agent_sse_json_event_or_error( - "tool_completed", - EditorAgentToolEvent { - conversation_id: stream_conversation.conversation_id.clone(), - message_id: stream_assistant_message_id.clone(), - tool_call_id, - tool_name: error.tool_name, - summary: tool_summary, - task_id: None, - model: error_model, - status: Some(EditorAgentGenerationStatus::Failed), - error: Some(error_message.clone()), - }, - )); - yield Ok::(editor_agent_sse_json_event_or_error( - "stage", - EditorAgentStageEvent { - conversation_id: stream_conversation.conversation_id.clone(), - stage: EditorAgentStage::Failed, - }, - )); - yield Ok::(editor_agent_sse_json_event_or_error( - "error", - EditorAgentErrorEvent { - conversation_id: Some(stream_conversation.conversation_id.clone()), - code: error_code, - message: error_message, - recoverable: true, - }, - )); - } - } - } - - let write_result = async { - let mut next_document = read_messages_document(&stream_state, &stream_conversation).await?; - if !next_document - .messages - .iter() - .any(|message| message.id == stream_assistant_message_id) - { - next_document.messages.push(EditorAgentMessage { - id: stream_assistant_message_id.clone(), - role: EditorAgentMessageRole::Assistant, - kind: if assistant_status == EditorAgentMessageStatus::Failed { - EditorAgentMessageKind::Error - } else { - EditorAgentMessageKind::Chat - }, - text: assistant_text, - attachments: Vec::new(), - generations: generation_records, - status: assistant_status, - created_at: now_rfc3339(), - }); - } - write_messages_document(&stream_state, &stream_conversation, &next_document).await - } - .await; - match write_result { - Ok(()) => { - let mut done_title = None; - if let Some(title) = stream_title { - match stream_state.spacetime_client().touch_editor_agent_conversation( - EditorAgentConversationTouchRecordInput { - conversation_id: stream_conversation.conversation_id.clone(), - owner_user_id: stream_conversation.owner_user_id.clone(), - title: Some(title.clone()), - updated_at_micros: current_utc_micros(), - }, - ).await { - Ok(updated) => done_title = Some(updated.title), - Err(error) => { - yield Ok::(editor_agent_sse_json_event_or_error( - "error", - EditorAgentErrorEvent { - conversation_id: Some(stream_conversation.conversation_id.clone()), - code: "SPACETIME_UPDATE_FAILED".to_string(), - message: error.to_string(), - recoverable: true, - }, - )); - } - } - } else { - match stream_state.spacetime_client().touch_editor_agent_conversation( - EditorAgentConversationTouchRecordInput { - conversation_id: stream_conversation.conversation_id.clone(), - owner_user_id: stream_conversation.owner_user_id.clone(), - title: None, - updated_at_micros: current_utc_micros(), - }, - ).await { - Ok(_) => {} - Err(error) => { - yield Ok::(editor_agent_sse_json_event_or_error( - "error", - EditorAgentErrorEvent { - conversation_id: Some(stream_conversation.conversation_id.clone()), - code: "SPACETIME_UPDATE_FAILED".to_string(), - message: error.to_string(), - recoverable: true, - }, - )); - } - } - } - yield Ok::(editor_agent_sse_json_event_or_error( - "stage", - EditorAgentStageEvent { - conversation_id: stream_conversation.conversation_id.clone(), - stage: EditorAgentStage::Completed, - }, - )); - yield Ok::(editor_agent_sse_json_event_or_error( - "done", - EditorAgentDoneEvent { - conversation_id: stream_conversation.conversation_id.clone(), - title: done_title, - }, - )); - } - Err(error) => { - yield Ok::(editor_agent_sse_json_event_or_error( - "stage", - EditorAgentStageEvent { - conversation_id: stream_conversation.conversation_id.clone(), - stage: EditorAgentStage::Failed, - }, - )); - yield Ok::(editor_agent_sse_json_event_or_error( - "error", - EditorAgentErrorEvent { - conversation_id: Some(stream_conversation.conversation_id.clone()), - code: error.code().to_string(), - message: error.body_text(), - recoverable: true, - }, - )); - } - } - }; - - Ok(Sse::new(stream).into_response()) -} - -fn editor_agent_conversation_lock(conversation_id: &str) -> Arc> { - let locks = EDITOR_AGENT_CONVERSATION_LOCKS.get_or_init(|| Mutex::new(BTreeMap::new())); - let mut locks = locks - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - locks - .entry(conversation_id.to_string()) - .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) - .clone() -} - -async fn ensure_editor_project_access( - state: &AppState, - project_id: &str, - owner_user_id: &str, -) -> Result<(), AppError> { - state - .spacetime_client() - .get_editor_project(EditorProjectGetRecordInput { - project_id: project_id.to_string(), - owner_user_id: owner_user_id.to_string(), - }) - .await - .map(|_| ()) - .map_err(map_editor_project_error) -} - -async fn normalize_editor_agent_attachments( - state: &AppState, - conversation: &EditorAgentConversationRecord, - attachments: &[EditorAgentAttachmentRef], -) -> Result, AppError> { - if attachments.is_empty() { - return Ok(Vec::new()); - } - - let needs_canvas_resources = attachments - .iter() - .any(|attachment| attachment.source == EditorAgentAttachmentSource::CanvasResource); - let needs_library_assets = attachments - .iter() - .any(|attachment| attachment.source == EditorAgentAttachmentSource::LibraryAsset); - - let project = if needs_canvas_resources { - Some( - state - .spacetime_client() - .get_editor_project(EditorProjectGetRecordInput { - project_id: conversation.project_id.clone(), - owner_user_id: conversation.owner_user_id.clone(), - }) - .await - .map_err(map_editor_project_error)?, - ) - } else { - None - }; - let library = if needs_library_assets { - Some( - state - .spacetime_client() - .get_editor_asset_library(conversation.owner_user_id.clone(), current_utc_micros()) - .await - .map_err(map_editor_project_error)?, - ) - } else { - None - }; - - attachments - .iter() - .map(|attachment| { - normalize_editor_agent_attachment( - conversation, - project.as_ref(), - library.as_ref(), - attachment, - ) - }) - .collect() -} - -fn normalize_editor_agent_attachment( - conversation: &EditorAgentConversationRecord, - project: Option<&EditorProjectRecord>, - library: Option<&EditorAssetLibraryRecord>, - attachment: &EditorAgentAttachmentRef, -) -> Result { - let reference_id = normalize_required_string(attachment.reference_id.as_str()) - .ok_or_else(|| editor_agent_bad_request("attachment.referenceId is required"))?; - match attachment.source { - EditorAgentAttachmentSource::CanvasResource => { - let project = project.ok_or_else(|| { - editor_agent_bad_request("canvas resource attachment project context missing") - })?; - let resource = project - .resources - .iter() - .find(|resource| resource.resource_id == reference_id) - .ok_or_else(|| { - editor_agent_bad_request(format!( - "canvas resource attachment not found in current project: {reference_id}" - )) - })?; - normalize_canvas_resource_attachment(conversation, attachment, resource) - } - EditorAgentAttachmentSource::LibraryAsset => { - let library = library.ok_or_else(|| { - editor_agent_bad_request("library asset attachment context missing") - })?; - let asset = library - .assets - .iter() - .find(|asset| asset.asset_id == reference_id) - .ok_or_else(|| { - editor_agent_bad_request(format!( - "library asset attachment not found for current user: {reference_id}" - )) - })?; - normalize_library_asset_attachment(attachment, asset) - } - } -} - -fn normalize_canvas_resource_attachment( - conversation: &EditorAgentConversationRecord, - attachment: &EditorAgentAttachmentRef, - resource: &EditorProjectResourceRecord, -) -> Result { - if resource.project_id != conversation.project_id - || resource.owner_user_id != conversation.owner_user_id - { - return Err(editor_agent_bad_request( - "canvas resource attachment does not belong to this conversation project", - )); - } - validate_attachment_object_key( - attachment.object_key.as_deref(), - resource.object_key.as_deref(), - resource.resource_id.as_str(), - )?; - - Ok(EditorAgentAttachmentRef { - source: EditorAgentAttachmentSource::CanvasResource, - reference_id: resource.resource_id.clone(), - object_key: resource.object_key.clone(), - image_src: resource.image_src.clone(), - thumbnail_src: None, - label: normalize_optional_string(attachment.label.clone()), - width: Some(resource.width), - height: Some(resource.height), - }) -} - -fn normalize_library_asset_attachment( - attachment: &EditorAgentAttachmentRef, - asset: &EditorAssetRecord, -) -> Result { - validate_attachment_object_key( - attachment.object_key.as_deref(), - asset.object_key.as_deref(), - asset.asset_id.as_str(), - )?; - - Ok(EditorAgentAttachmentRef { - source: EditorAgentAttachmentSource::LibraryAsset, - reference_id: asset.asset_id.clone(), - object_key: asset.object_key.clone(), - image_src: asset.image_src.clone(), - thumbnail_src: asset.thumbnail_src.clone(), - label: normalize_optional_string(attachment.label.clone()) - .or_else(|| Some(asset.label.clone())), - width: Some(asset.width), - height: Some(asset.height), - }) -} - -fn validate_attachment_object_key( - submitted_object_key: Option<&str>, - stored_object_key: Option<&str>, - reference_id: &str, -) -> Result<(), AppError> { - let Some(submitted_object_key) = submitted_object_key.and_then(normalize_required_string) - else { - return Ok(()); - }; - let Some(stored_object_key) = stored_object_key.and_then(normalize_required_string) else { - return Err(editor_agent_bad_request(format!( - "attachment objectKey is not available for reference: {reference_id}" - ))); - }; - if submitted_object_key != stored_object_key { - return Err(editor_agent_bad_request(format!( - "attachment objectKey does not match reference: {reference_id}" - ))); - } - Ok(()) -} - -fn conversation_summary_from_record( - conversation: EditorAgentConversationRecord, -) -> EditorAgentConversationSummary { - EditorAgentConversationSummary { - conversation_id: conversation.conversation_id, - project_id: conversation.project_id, - title: conversation.title, - created_at: conversation.created_at, - updated_at: conversation.updated_at, - } -} - -fn conversation_detail_from_record( - conversation: EditorAgentConversationRecord, - messages: Vec, -) -> EditorAgentConversationDetail { - EditorAgentConversationDetail { - conversation_id: conversation.conversation_id, - project_id: conversation.project_id, - title: conversation.title, - created_at: conversation.created_at, - updated_at: conversation.updated_at, - messages, - } -} - -fn empty_messages_document(conversation_id: &str) -> EditorAgentConversationMessagesDocument { - EditorAgentConversationMessagesDocument { - version: EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION, - conversation_id: conversation_id.to_string(), - messages: Vec::new(), - } -} - -async fn read_messages_document( - state: &AppState, - conversation: &EditorAgentConversationRecord, -) -> Result { - let oss_client = state - .oss_client() - .ok_or_else(editor_agent_oss_unavailable)?; - let signed = oss_client - .sign_internal_get_object_url(OssSignedGetObjectUrlRequest { - object_key: conversation.messages_object_key.clone(), - expire_seconds: Some(EDITOR_AGENT_MESSAGES_READ_EXPIRE_SECONDS), - }) - .map_err(|error| map_oss_error(error, "aliyun-oss"))?; - let response = reqwest::Client::new() - .get(signed.signed_url.as_str()) - .send() - .await - .map_err(|error| editor_agent_oss_read_error(error.to_string()))?; - if response.status() == reqwest::StatusCode::NOT_FOUND { - return Ok(empty_messages_document( - conversation.conversation_id.as_str(), - )); - } - if !response.status().is_success() { - return Err(editor_agent_oss_read_error(format!( - "OSS returned non-success status {}", - response.status().as_u16() - ))); - } - if response - .content_length() - .is_some_and(|size| size > EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES as u64) - { - return Err(editor_agent_messages_document_too_large()); - } - let bytes = response - .bytes() - .await - .map_err(|error| editor_agent_oss_read_error(error.to_string()))?; - if bytes.is_empty() { - return Ok(empty_messages_document( - conversation.conversation_id.as_str(), - )); - } - if bytes.len() > EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES { - return Err(editor_agent_messages_document_too_large()); - } - let document: EditorAgentConversationMessagesDocument = serde_json::from_slice(&bytes) - .map_err(|error| { - editor_agent_oss_read_error(format!("message document JSON invalid: {error}")) - })?; - if document.conversation_id != conversation.conversation_id { - return Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "editor-agent", - "message": "message document conversationId does not match metadata", - "conversationId": conversation.conversation_id, - "documentConversationId": document.conversation_id, - })), - ); - } - Ok(document) -} - -async fn write_messages_document( - state: &AppState, - conversation: &EditorAgentConversationRecord, - document: &EditorAgentConversationMessagesDocument, -) -> Result<(), AppError> { - if document.conversation_id != conversation.conversation_id { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "editor-agent", - "message": "message document conversationId does not match metadata", - })), - ); - } - let body = serde_json::to_vec(document).map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "editor-agent", - "message": format!("failed to serialize message document: {error}"), - })) - })?; - if body.len() > EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES { - return Err(editor_agent_messages_document_too_large()); - } - - let oss_client = state - .oss_client() - .ok_or_else(editor_agent_oss_unavailable)?; - let put_result = oss_client - .put_object( - &reqwest::Client::new(), - OssPutObjectRequest { - prefix: LegacyAssetPrefix::EditorAgent, - path_segments: Vec::new(), - file_name: format!("{}.json", conversation.conversation_id), - content_type: Some("application/json; charset=utf-8".to_string()), - access: OssObjectAccess::Private, - metadata: BTreeMap::from([ - ( - "conversation-id".to_string(), - conversation.conversation_id.clone(), - ), - ("project-id".to_string(), conversation.project_id.clone()), - ( - "owner-user-id".to_string(), - conversation.owner_user_id.clone(), - ), - ]), - body, - }, - ) - .await - .map_err(|error| map_oss_error(error, "aliyun-oss"))?; - if put_result.object_key != conversation.messages_object_key { - return Err( - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "editor-agent", - "message": "OSS object key mismatch while writing message document", - "expectedObjectKey": conversation.messages_object_key, - "actualObjectKey": put_result.object_key, - })), - ); - } - Ok(()) -} - -#[derive(Clone, Debug, PartialEq)] -struct EditorAgentTurnPlan { - reply_text: String, - tool_call: Option, -} - -#[derive(Clone, Debug, PartialEq)] -struct EditorAgentToolCallPlan { - tool_name: EditorAgentToolName, - prompt: String, - summary: Option, - model: Option, - aspect_ratio: Option, - image_size: Option, - icon_descriptions: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct EditorAgentRawTurnPlan { - reply_text: Option, - tool_call: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct EditorAgentRawToolCallPlan { - tool_name: EditorAgentToolName, - prompt: Option, - summary: Option, - model: Option, - aspect_ratio: Option, - image_size: Option, - icon_descriptions: Option>, -} - -#[derive(Debug)] -struct EditorAgentToolExecution { - record: EditorAgentGenerationRecord, - summary: String, -} - -#[derive(Clone, Debug)] -struct EditorAgentGeneratedImageReference { - source_message_id: String, - tool_call_id: String, - tool_name: EditorAgentToolName, - summary: Option, - model: Option, - resource_id: Option, - object_key: Option, - asset_object_id: Option, - image_src: String, - thumbnail_src: Option, - width: Option, - height: Option, -} - -#[derive(Debug)] -struct EditorAgentToolExecutionError { - tool_name: EditorAgentToolName, - model: Option, - error: AppError, -} - -#[derive(Debug)] -enum EditorAgentPlanningError { - LlmUnavailable, - LlmRequestFailed(LlmError), - InvalidLlmResponse, -} - -impl EditorAgentPlanningError { - fn into_app_error(self) -> AppError { - match self { - Self::LlmUnavailable => AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) - .with_details(json!({ - "provider": "editor-agent-llm", - "message": "画布 Agent 的 LLM 未配置,无法处理这句话。", - })), - Self::LlmRequestFailed(error) => { - let status = match error.kind() { - LlmErrorKind::Timeout => StatusCode::GATEWAY_TIMEOUT, - LlmErrorKind::InvalidConfig | LlmErrorKind::InvalidRequest => { - StatusCode::SERVICE_UNAVAILABLE - } - LlmErrorKind::Connectivity - | LlmErrorKind::Upstream - | LlmErrorKind::StreamUnavailable - | LlmErrorKind::EmptyResponse - | LlmErrorKind::Transport - | LlmErrorKind::Deserialize => StatusCode::BAD_GATEWAY, - }; - AppError::from_status(status).with_details(json!({ - "provider": "editor-agent-llm", - "message": format!("画布 Agent 调用 LLM 失败:{error}"), - })) - } - Self::InvalidLlmResponse => AppError::from_status(StatusCode::BAD_GATEWAY) - .with_details(json!({ - "provider": "editor-agent-llm", - "message": "画布 Agent 的 LLM 返回格式错误,未能解析回复。", - })), - } - } -} - -async fn plan_editor_agent_turn( - state: &AppState, - conversation: &EditorAgentConversationRecord, - document: &EditorAgentConversationMessagesDocument, - user_message: &EditorAgentMessage, - latest_generation_reference: Option<&EditorAgentGeneratedImageReference>, -) -> Result { - let Some(llm_client) = state.creative_agent_gpt5_client() else { - return Err(EditorAgentPlanningError::LlmUnavailable); - }; - - let request = build_editor_agent_llm_request( - state, - conversation, - document, - user_message, - latest_generation_reference, - ) - .await; - let response = llm_client - .request_text(request) - .await - .map_err(EditorAgentPlanningError::LlmRequestFailed)?; - parse_editor_agent_turn_plan(response.content.as_str()) - .map(|plan| { - apply_previous_generation_edit_default(plan, user_message, latest_generation_reference) - }) - .ok_or(EditorAgentPlanningError::InvalidLlmResponse) -} - -async fn build_editor_agent_llm_request( - state: &AppState, - conversation: &EditorAgentConversationRecord, - document: &EditorAgentConversationMessagesDocument, - user_message: &EditorAgentMessage, - latest_generation_reference: Option<&EditorAgentGeneratedImageReference>, -) -> LlmTextRequest { - let mut user_parts = vec![LlmMessageContentPart::InputText { - text: build_editor_agent_llm_user_prompt( - conversation, - document, - user_message, - latest_generation_reference, - ), - }]; - for attachment in &user_message.attachments { - if let Some(image_url) = sign_editor_agent_attachment_image_url(state, attachment) { - user_parts.push(LlmMessageContentPart::InputImage { image_url }); - } - } - if user_message.attachments.is_empty() - && editor_agent_text_mentions_previous_generation(user_message.text.as_str()) - { - if let Some(reference) = latest_generation_reference { - if let Some(image_url) = sign_editor_agent_generated_image_url(state, reference) { - user_parts.push(LlmMessageContentPart::InputImage { image_url }); - } - } - } - - LlmTextRequest::new(vec![ - LlmMessage::system(editor_agent_llm_system_prompt()), - LlmMessage::user_multimodal(user_parts), - ]) - .with_model(EDITOR_AGENT_LLM_PLANNING_MODEL) - .with_max_tokens(EDITOR_AGENT_LLM_MAX_OUTPUT_TOKENS) - .with_request_timeout_ms(EDITOR_AGENT_LLM_REQUEST_TIMEOUT_MS) -} - -fn editor_agent_llm_system_prompt() -> String { - [ - "你是 Genarrative 图片画布 Agent,只负责帮助用户理解、规划和触发画布生成工具。", - "必须只输出一个 JSON 对象,不要输出 Markdown、解释或额外文本。", - "JSON 结构:{\"replyText\":\"给用户看的中文回复\",\"toolCall\":null 或 {\"toolName\":\"generate_image|edit_image|generate_character|generate_icon_spritesheet|generate_ui_design\",\"prompt\":\"生成或修改提示词\",\"summary\":\"短动作摘要\",\"model\":null,\"aspectRatio\":null,\"imageSize\":\"1K\",\"iconDescriptions\":[\"图标描述\"]}}。", - "只有用户明确要求生成、重绘、修改、画 UI、出角色或图标时才给 toolCall;普通咨询、评价、解释时 toolCall 为 null。", - "工具说明:", - "generate_image:从文字生成一张全新图片,适合新场景、新物体、新插画、新背景、规范图/视觉规范图/风格规范图/素材规范展板;不要用于修改上一张图或附件图。prompt 必须写完整画面、主体、风格、构图和背景。", - "edit_image:修改已有图片,适合换衣服、改颜色、替换背景、局部重绘、保持主体/构图/姿势不变的编辑请求;必须使用 latestAttachments 或 latestGeneratedImage 作为源图。", - "generate_character:生成新的角色形象、人物立绘或角色设定图;如果用户是在改上一张角色图的服装、颜色、表情、姿势或背景,应改用 edit_image。", - "generate_icon_spritesheet:生成一组图标素材或图标图集,适合用户明确要多个 icon / 图标 / spritesheet;必须有 latestAttachments 作为图标规范或风格参考,并填写 iconDescriptions。", - "generate_ui_design:生成一张完整 UI 设计图或界面稿,适合 HUD、弹窗、面板、按钮组合和整页界面;不要用于提取图标、拆素材或修改上一张图。", - "规范图要求:用户要求生成规范图/视觉规范图/风格规范图/素材规范展板时,当前使用 generate_image;prompt 必须明确这是规范展板,并写入统一视角、线条粗细、描边、填充风格、材质、阴影、圆角、状态层级、色卡/色号、尺寸标注和排版层级。", - "角色规范图/角色美术视觉规范设定图属于规范展板时使用 generate_image,prompt 要包含头身比例、标准立绘、动作帧样例、服饰配饰分层和专属角色色卡;只有用户要生成单个全新角色形象/立绘/普通角色设定图时才使用 generate_character。", - "图标规范图/图标视觉规范展板属于规范展板时使用 generate_image;只有用户明确要生成多个图标成品、图标素材图集或 spritesheet,并提供图标规范/风格参考附件时才使用 generate_icon_spritesheet。", - "UI 规范图/组件规范展板如果是规范展板而非完整可用界面稿,也使用 generate_image;完整界面稿/HUD/弹窗/面板才使用 generate_ui_design。", - "工具选择优先级:明确修改/指代已有图 => edit_image;规范图/视觉规范图/风格规范图/素材规范展板 => generate_image;明确新角色且不是规范展板或修改已有图 => generate_character;多个图标成品/图标图集 => generate_icon_spritesheet;完整界面稿 => generate_ui_design;其他全新图片 => generate_image。", - "edit_image 必须依赖 latestAttachments 或 latestGeneratedImage;generate_icon_spritesheet 必须依赖 latestAttachments。", - "当 latestGeneratedImage 存在,且用户说“这张/刚才那个/上一张/把衣服换成/改成/换成/修改上一张图”等指代或修改上一轮结果的话,必须选择 edit_image,prompt 保留用户修改要求,不要要求用户重新选择参考图,也不要降级成 generate_image。", - "没有 latestAttachments 且没有 latestGeneratedImage 时,不要调用 edit_image 或 generate_icon_spritesheet,应在 replyText 中提示先选择参考图。", - "generate_ui_design 表示生成一张 UI 设计图,不是提取 UI 素材。", - "对话回复要简短,不能承诺免费生成;生成工具由后端按模型定价扣泥点。", - ] - .join("\n") -} - -fn build_editor_agent_llm_user_prompt( - conversation: &EditorAgentConversationRecord, - document: &EditorAgentConversationMessagesDocument, - user_message: &EditorAgentMessage, - latest_generation_reference: Option<&EditorAgentGeneratedImageReference>, -) -> String { - let history = document - .messages - .iter() - .rev() - .take(EDITOR_AGENT_LLM_MAX_HISTORY_MESSAGES) - .collect::>() - .into_iter() - .rev() - .map(|message| { - json!({ - "role": message.role, - "kind": message.kind, - "text": message.text, - "attachmentCount": message.attachments.len(), - "generationCount": message.generations.len(), - "generations": message - .generations - .iter() - .map(editor_agent_generation_context_json) - .collect::>(), - }) - }) - .collect::>(); - let attachments = user_message - .attachments - .iter() - .map(|attachment| { - json!({ - "source": attachment.source, - "referenceId": attachment.reference_id, - "label": attachment.label, - "width": attachment.width, - "height": attachment.height, - "hasObjectKey": attachment.object_key.as_ref().is_some_and(|value| !value.trim().is_empty()), - }) - }) - .collect::>(); - - json!({ - "conversationId": conversation.conversation_id, - "projectId": conversation.project_id, - "latestUserText": user_message.text, - "latestAttachments": attachments, - "latestGeneratedImage": latest_generation_reference - .map(editor_agent_generated_image_reference_context_json), - "recentMessages": history, - }) - .to_string() -} - -fn editor_agent_generation_context_json(generation: &EditorAgentGenerationRecord) -> Value { - json!({ - "toolCallId": generation.tool_call_id, - "toolName": generation.tool_name, - "summary": generation.summary, - "status": generation.status, - "model": generation.model, - "images": generation.images.iter().take(4).map(|image| json!({ - "resourceId": image.resource_id, - "objectKey": image.object_key, - "assetObjectId": image.asset_object_id, - "imageSrc": image.image_src, - "thumbnailSrc": image.thumbnail_src, - "width": image.width, - "height": image.height, - "hasObjectKey": image.object_key.as_ref().is_some_and(|value| !value.trim().is_empty()), - })).collect::>(), - }) -} - -fn editor_agent_generated_image_reference_context_json( - reference: &EditorAgentGeneratedImageReference, -) -> Value { - json!({ - "sourceMessageId": reference.source_message_id, - "toolCallId": reference.tool_call_id, - "toolName": reference.tool_name, - "summary": reference.summary, - "model": reference.model, - "resourceId": reference.resource_id, - "objectKey": reference.object_key, - "assetObjectId": reference.asset_object_id, - "imageSrc": reference.image_src, - "thumbnailSrc": reference.thumbnail_src, - "width": reference.width, - "height": reference.height, - "hasObjectKey": reference.object_key.as_ref().is_some_and(|value| !value.trim().is_empty()), - }) -} - -fn sign_editor_agent_attachment_image_url( - state: &AppState, - attachment: &EditorAgentAttachmentRef, -) -> Option { - let object_key = attachment - .object_key - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty())?; - state - .oss_client()? - .sign_get_object_url(OssSignedGetObjectUrlRequest { - object_key: object_key.trim_start_matches('/').to_string(), - expire_seconds: Some(EDITOR_AGENT_LLM_ATTACHMENT_URL_EXPIRE_SECONDS), - }) - .ok() - .map(|signed| signed.signed_url) -} - -fn sign_editor_agent_generated_image_url( - state: &AppState, - reference: &EditorAgentGeneratedImageReference, -) -> Option { - let object_key = reference - .object_key - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty())?; - state - .oss_client()? - .sign_get_object_url(OssSignedGetObjectUrlRequest { - object_key: object_key.trim_start_matches('/').to_string(), - expire_seconds: Some(EDITOR_AGENT_LLM_ATTACHMENT_URL_EXPIRE_SECONDS), - }) - .ok() - .map(|signed| signed.signed_url) -} - -fn latest_editor_agent_generated_image_reference( - document: &EditorAgentConversationMessagesDocument, -) -> Option { - document.messages.iter().rev().find_map(|message| { - message - .generations - .iter() - .rev() - .filter(|generation| generation.status == EditorAgentGenerationStatus::Completed) - .find_map(|generation| { - generation.images.iter().rev().find_map(|image| { - let has_reference_source = image - .object_key - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - || image - .resource_id - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - || !image.image_src.trim().is_empty(); - has_reference_source.then(|| EditorAgentGeneratedImageReference { - source_message_id: message.id.clone(), - tool_call_id: generation.tool_call_id.clone(), - tool_name: generation.tool_name, - summary: generation.summary.clone(), - model: generation.model.clone(), - resource_id: image.resource_id.clone(), - object_key: image.object_key.clone(), - asset_object_id: image.asset_object_id.clone(), - image_src: image.image_src.clone(), - thumbnail_src: image.thumbnail_src.clone(), - width: image.width, - height: image.height, - }) - }) - }) - }) -} - -fn editor_agent_generated_reference_source( - reference: &EditorAgentGeneratedImageReference, -) -> Option { - reference - .object_key - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(|value| value.trim_start_matches('/').to_string()) - .or_else(|| { - reference - .resource_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - }) - .or_else(|| { - reference - .image_src - .trim() - .strip_prefix('/') - .map(str::to_string) - .filter(|value| !value.is_empty()) - }) -} - -fn editor_agent_text_mentions_previous_generation(text: &str) -> bool { - let normalized = text.trim().to_lowercase(); - contains_any( - normalized.as_str(), - &[ - "这张", - "这个图", - "这幅", - "这张图", - "刚才", - "刚刚", - "刚才那个", - "刚才那张", - "上一张", - "上张", - "上一幅", - "上一个", - "前一张", - "上次生成", - "刚生成", - "latest image", - "previous image", - "last image", - ], - ) -} - -fn editor_agent_text_requests_previous_generation_edit(text: &str) -> bool { - let normalized = text.trim().to_lowercase(); - if editor_agent_text_mentions_previous_generation(normalized.as_str()) - && contains_any( - normalized.as_str(), - &[ - "修改", "改成", "换成", "替换", "重绘", "编辑", "优化", "去掉", "加上", "增加", - "变成", "调整", "换一", "换掉", "remove", "change", "edit", "redraw", - ], - ) - { - return true; - } - - let has_visual_target = contains_any( - normalized.as_str(), - &[ - "衣服", "服装", "发型", "头发", "背景", "颜色", "色调", "表情", "姿势", "主体", "风格", - "脸", "眼睛", "帽子", "鞋", "裙子", "外套", - ], - ); - let has_edit_verb = contains_any( - normalized.as_str(), - &[ - "换", "改", "变", "加", "去", "删", "替换", "调整", "change", "edit", "remove", - ], - ); - has_visual_target && has_edit_verb -} - -fn apply_previous_generation_edit_default( - mut plan: EditorAgentTurnPlan, - user_message: &EditorAgentMessage, - latest_generation_reference: Option<&EditorAgentGeneratedImageReference>, -) -> EditorAgentTurnPlan { - if latest_generation_reference.is_none() - || !user_message.attachments.is_empty() - || !editor_agent_text_requests_previous_generation_edit(user_message.text.as_str()) - { - return plan; - } - - let should_force_edit = plan - .tool_call - .as_ref() - .is_none_or(|tool_call| tool_call.tool_name != EditorAgentToolName::EditImage); - if !should_force_edit { - return plan; - } - - let original_tool = plan.tool_call.take(); - let prompt = original_tool - .as_ref() - .map(|tool_call| tool_call.prompt.clone()) - .unwrap_or_else(|| user_message.text.trim().to_string()); - let summary = original_tool - .as_ref() - .and_then(|tool_call| tool_call.summary.clone()) - .or_else(|| Some("修改上一张图".to_string())); - let model = original_tool - .as_ref() - .and_then(|tool_call| tool_call.model.clone()); - let image_size = original_tool - .as_ref() - .and_then(|tool_call| tool_call.image_size.clone()) - .or_else(|| Some("1K".to_string())); - - plan.reply_text = "我会基于上一张生成图按你的要求修改。".to_string(); - plan.tool_call = Some(EditorAgentToolCallPlan { - tool_name: EditorAgentToolName::EditImage, - prompt, - summary, - model, - aspect_ratio: None, - image_size, - icon_descriptions: Vec::new(), - }); - plan -} - -fn parse_editor_agent_turn_plan(raw_text: &str) -> Option { - let json_text = extract_editor_agent_json_object(raw_text)?; - let raw: EditorAgentRawTurnPlan = serde_json::from_str(json_text).ok()?; - let tool_call = match raw.tool_call { - Some(tool) => { - let prompt = normalize_optional_string(tool.prompt)?; - Some(EditorAgentToolCallPlan { - tool_name: tool.tool_name, - prompt, - summary: normalize_optional_string(tool.summary), - model: normalize_optional_string(tool.model), - aspect_ratio: normalize_optional_string(tool.aspect_ratio), - image_size: normalize_optional_string(tool.image_size), - icon_descriptions: tool - .icon_descriptions - .unwrap_or_default() - .into_iter() - .filter_map(|description| normalize_optional_string(Some(description))) - .collect(), - }) - } - None => None, - }; - let reply_text = normalize_editor_agent_reply_text(raw.reply_text, tool_call.as_ref())?; - - Some(EditorAgentTurnPlan { - reply_text, - tool_call, - }) -} - -fn extract_editor_agent_json_object(raw_text: &str) -> Option<&str> { - let trimmed = raw_text.trim(); - let without_fence = if let Some(rest) = trimmed.strip_prefix("```json") { - rest.strip_suffix("```").unwrap_or(rest).trim() - } else if let Some(rest) = trimmed.strip_prefix("```") { - rest.strip_suffix("```").unwrap_or(rest).trim() - } else { - trimmed - }; - let start = without_fence.find('{')?; - let end = without_fence.rfind('}')?; - (start <= end).then_some(&without_fence[start..=end]) -} - -fn normalize_editor_agent_reply_text( - reply_text: Option, - tool_call: Option<&EditorAgentToolCallPlan>, -) -> Option { - let reply_text = normalize_optional_string(reply_text)?; - if !editor_agent_reply_text_looks_structural(reply_text.as_str()) { - return Some(reply_text); - } - tool_call.map(editor_agent_tool_call_reply_text) -} - -fn editor_agent_reply_text_looks_structural(text: &str) -> bool { - let trimmed = text.trim(); - if trimmed.is_empty() { - return true; - } - let lower = trimmed.to_ascii_lowercase(); - if matches!(lower.as_str(), "null" | "undefined") { - return true; - } - trimmed.len() <= 2 - && trimmed - .chars() - .all(|character| matches!(character, '{' | '}' | '[' | ']' | '"' | ':' | ',')) -} - -fn editor_agent_tool_call_reply_text(tool_call: &EditorAgentToolCallPlan) -> String { - match tool_call.tool_name { - EditorAgentToolName::GenerateImage => "我来生成图片。".to_string(), - EditorAgentToolName::EditImage => "我来修改图片。".to_string(), - EditorAgentToolName::GenerateCharacter => "我来生成角色形象。".to_string(), - EditorAgentToolName::GenerateIconSpritesheet => "我来生成图标素材。".to_string(), - EditorAgentToolName::GenerateUiDesign => "我来生成 UI 设计图。".to_string(), - } -} - -#[cfg(test)] -fn heuristic_editor_agent_turn_plan( - text: &str, - attachments: &[EditorAgentAttachmentRef], -) -> EditorAgentTurnPlan { - let trimmed = text.trim(); - let normalized = trimmed.to_lowercase(); - let wants_generation = contains_any( - normalized.as_str(), - &[ - "生成", "出图", "绘制", "设计", "画一", "画个", "画张", "generate", "create", - ], - ); - let wants_edit = contains_any( - normalized.as_str(), - &[ - "修改", "改成", "重绘", "换成", "编辑", "优化", "edit", "redraw", - ], - ); - let wants_character = contains_any( - normalized.as_str(), - &["角色", "人物", "立绘", "形象", "character"], - ); - let wants_icons = contains_any( - normalized.as_str(), - &["图标", "icon", "spritesheet", "素材图集"], - ); - let wants_ui = contains_any( - normalized.as_str(), - &["ui", "界面", "hud", "按钮", "面板", "弹窗"], - ); - - let tool_name = if wants_icons && !attachments.is_empty() { - Some(EditorAgentToolName::GenerateIconSpritesheet) - } else if wants_edit && !attachments.is_empty() { - Some(EditorAgentToolName::EditImage) - } else if wants_character && wants_generation { - Some(EditorAgentToolName::GenerateCharacter) - } else if wants_ui && wants_generation { - Some(EditorAgentToolName::GenerateUiDesign) - } else if wants_generation { - Some(EditorAgentToolName::GenerateImage) - } else { - None - }; - - let reply_text = match tool_name { - Some(EditorAgentToolName::EditImage) => "我来按你的要求修改这张图。".to_string(), - Some(EditorAgentToolName::GenerateCharacter) => "我来生成角色形象。".to_string(), - Some(EditorAgentToolName::GenerateIconSpritesheet) => "我来生成图标素材图集。".to_string(), - Some(EditorAgentToolName::GenerateUiDesign) => "我来生成 UI 设计图。".to_string(), - Some(EditorAgentToolName::GenerateImage) => "我来生成图片。".to_string(), - None => build_echo_assistant_text(trimmed, attachments.len()), - }; - let tool_call = tool_name.map(|tool_name| EditorAgentToolCallPlan { - tool_name, - prompt: if trimmed.is_empty() { - "根据附件生成适合当前画布的图片".to_string() - } else { - trimmed.to_string() - }, - summary: Some(editor_agent_tool_default_summary(tool_name)), - model: None, - aspect_ratio: None, - image_size: Some("1K".to_string()), - icon_descriptions: derive_icon_descriptions(trimmed), - }); - - EditorAgentTurnPlan { - reply_text, - tool_call, - } -} - -fn contains_any(text: &str, needles: &[&str]) -> bool { - needles.iter().any(|needle| text.contains(needle)) -} - -fn derive_icon_descriptions(prompt: &str) -> Vec { - let normalized = prompt.trim(); - if normalized.is_empty() { - return vec![ - "主按钮图标".to_string(), - "奖励图标".to_string(), - "关闭图标".to_string(), - ]; - } - normalized - .split(['、', ',', ',', '\n', ';', ';']) - .map(str::trim) - .filter(|item| !item.is_empty()) - .take(8) - .map(ToOwned::to_owned) - .collect::>() - .into_iter() - .filter(|item| item.chars().count() <= 80) - .collect::>() - .pipe_non_empty_or_else(|| vec![normalized.to_string()]) -} - -trait VecPipeNonEmpty { - fn pipe_non_empty_or_else(self, fallback: F) -> Self - where - F: FnOnce() -> Self; -} - -impl VecPipeNonEmpty for Vec { - fn pipe_non_empty_or_else(self, fallback: F) -> Self - where - F: FnOnce() -> Self, - { - if self.is_empty() { fallback() } else { self } - } -} - -async fn execute_editor_agent_tool_call( - state: &AppState, - request_context: &RequestContext, - conversation: &EditorAgentConversationRecord, - user_message: &EditorAgentMessage, - tool_call_id: &str, - latest_generation_reference: Option<&EditorAgentGeneratedImageReference>, - tool_call: EditorAgentToolCallPlan, -) -> Result { - let project = state - .spacetime_client() - .get_editor_project(EditorProjectGetRecordInput { - project_id: conversation.project_id.clone(), - owner_user_id: conversation.owner_user_id.clone(), - }) - .await - .map_err(map_editor_project_error) - .map_err(|error| EditorAgentToolExecutionError { - tool_name: tool_call.tool_name, - model: tool_call.model.clone(), - error, - })?; - let completion = - build_editor_agent_canvas_completion(&project, tool_call.tool_name, &tool_call.prompt); - let caller = EditorGenerationCaller { - owner_user_id: conversation.owner_user_id.clone(), - audit_subject_user_id: Some(conversation.owner_user_id.clone()), - audit_project_id: Some(conversation.project_id.clone()), - phase_reporter: None, - }; - let tool_request_context = editor_agent_tool_request_context(request_context, tool_call_id); - let attachment_sources = editor_agent_attachment_sources(user_message.attachments.as_slice()); - let previous_generation_source = (tool_call.tool_name == EditorAgentToolName::EditImage - && user_message.attachments.is_empty()) - .then(|| latest_generation_reference.and_then(editor_agent_generated_reference_source)) - .flatten(); - let tool_reference_sources = previous_generation_source - .clone() - .map(|source| vec![source]) - .unwrap_or_else(|| attachment_sources.clone()); - let generation_reference_context = editor_agent_tool_generation_reference_context( - user_message.attachments.as_slice(), - latest_generation_reference, - previous_generation_source.as_deref(), - ); - let generation_inputs = Some(json!({ - "source": "editor-agent", - "conversationId": conversation.conversation_id, - "messageId": user_message.id, - "toolCallId": tool_call_id, - "fields": [{ "title": "用户指令", "value": tool_call.prompt }], - "references": generation_reference_context, - })); - - let tool_name = tool_call.tool_name; - let tool_model = tool_call.model.clone(); - let response = match tool_name { - EditorAgentToolName::GenerateImage - | EditorAgentToolName::GenerateCharacter - | EditorAgentToolName::GenerateUiDesign => { - let kind = match tool_name { - EditorAgentToolName::GenerateCharacter => Some("character".to_string()), - EditorAgentToolName::GenerateUiDesign => Some("ui-design".to_string()), - _ => None, - }; - generate_editor_image_for_owner( - state, - &tool_request_context, - caller, - EditorImageGenerationRequest { - prompt: tool_call.prompt.clone(), - size: None, - kind, - model: tool_call.model.clone(), - screen_color: Some("auto".to_string()), - seg_model: Some("birefnet".to_string()), - aspect_ratio: tool_call.aspect_ratio.clone(), - image_size: tool_call.image_size.clone(), - reference_image_srcs: Some(tool_reference_sources.clone()), - project_id: Some(conversation.project_id.clone()), - asset_kind: Some(editor_agent_tool_asset_kind(tool_name).to_string()), - generation_inputs, - asset_folder_id: Some(editor_agent_default_asset_folder_id()), - asset_label: tool_call.summary.clone(), - source_resource_id: editor_agent_tool_source_resource_id( - user_message.attachments.as_slice(), - latest_generation_reference, - previous_generation_source.as_deref(), - ), - canvas_completion: Some(completion), - }, - ) - .await - } - EditorAgentToolName::EditImage => { - let (source_image_src, reference_image_srcs) = split_editor_agent_edit_sources( - tool_reference_sources.clone(), - ) - .ok_or_else(|| EditorAgentToolExecutionError { - tool_name, - model: tool_model.clone(), - error: editor_agent_bad_request("修改图片需要先选择一张画布或素材库图片附件"), - })?; - edit_editor_image_for_owner( - state, - &tool_request_context, - caller, - EditorImageEditRequest { - prompt: tool_call.prompt.clone(), - source_image_src, - size: None, - model: tool_call.model.clone(), - aspect_ratio: None, - image_size: None, - reference_image_srcs: Some(reference_image_srcs), - project_id: Some(conversation.project_id.clone()), - asset_kind: Some(editor_agent_tool_asset_kind(tool_name).to_string()), - generation_inputs, - asset_folder_id: Some(editor_agent_default_asset_folder_id()), - asset_label: tool_call.summary.clone(), - source_resource_id: editor_agent_tool_source_resource_id( - user_message.attachments.as_slice(), - latest_generation_reference, - previous_generation_source.as_deref(), - ), - target_layer_id: None, - canvas_completion: Some(completion), - }, - ) - .await - } - EditorAgentToolName::GenerateIconSpritesheet => { - let (reference_image_src, extra_references) = - split_editor_agent_edit_sources(attachment_sources).ok_or_else(|| { - EditorAgentToolExecutionError { - tool_name, - model: tool_model.clone(), - error: editor_agent_bad_request("生成图标素材需要先选择一张图标规范参考图"), - } - })?; - let icon_descriptions = if tool_call.icon_descriptions.is_empty() { - derive_icon_descriptions(tool_call.prompt.as_str()) - } else { - tool_call.icon_descriptions.clone() - }; - generate_editor_icon_spritesheet_for_owner( - state, - &tool_request_context, - caller, - EditorIconSpritesheetGenerationRequest { - reference_image_src, - reference_image_srcs: Some(extra_references), - icon_descriptions, - model: tool_call.model.clone(), - screen_color: Some("auto".to_string()), - seg_model: Some("birefnet".to_string()), - aspect_ratio: tool_call.aspect_ratio.clone(), - image_size: tool_call.image_size.clone(), - project_id: Some(conversation.project_id.clone()), - generation_inputs, - asset_folder_id: Some(editor_agent_default_asset_folder_id()), - asset_label: None, - canvas_completion: Some(completion), - }, - ) - .await - } - } - .map_err(|error| EditorAgentToolExecutionError { - tool_name, - model: tool_model.clone(), - error, - })?; - - Ok(build_editor_agent_tool_execution( - tool_call_id, - tool_name, - tool_call.summary, - response.0, - )) -} - -fn editor_agent_tool_request_context(base: &RequestContext, tool_call_id: &str) -> RequestContext { - RequestContext::new( - format!("{}:{tool_call_id}", base.request_id()), - format!("{} editor-agent-tool", base.operation()), - std::time::Duration::ZERO, - false, - ) -} - -fn build_editor_agent_tool_execution( - tool_call_id: &str, - tool_name: EditorAgentToolName, - summary: Option, - response_body: Value, -) -> EditorAgentToolExecution { - let data = response_body - .get("data") - .filter(|_| response_body.get("ok").and_then(Value::as_bool) == Some(true)) - .unwrap_or(&response_body); - let images = editor_agent_generated_images_from_response(tool_name, data); - let task_id = data - .get("taskId") - .and_then(Value::as_str) - .map(str::to_string) - .or_else(|| { - images - .iter() - .find_map(|image| image.resource_id.as_ref().map(|_| ())) - .and_then(|_| { - data.get("taskId") - .and_then(Value::as_str) - .map(str::to_string) - }) - }); - let model = data - .get("model") - .and_then(Value::as_str) - .map(str::to_string); - let resolved_summary = summary.unwrap_or_else(|| editor_agent_tool_default_summary(tool_name)); - - EditorAgentToolExecution { - record: EditorAgentGenerationRecord { - tool_call_id: tool_call_id.to_string(), - tool_name, - summary: Some(resolved_summary.clone()), - task_id, - status: EditorAgentGenerationStatus::Completed, - model, - images, - error: None, - }, - summary: resolved_summary, - } -} - -fn editor_agent_generated_images_from_response( - tool_name: EditorAgentToolName, - data: &Value, -) -> Vec { - if tool_name == EditorAgentToolName::GenerateIconSpritesheet { - let mut images = Vec::new(); - if let Some(resource) = data.get("spritesheetResource") { - images.push(editor_agent_generated_image_from_resource( - resource, - data.get("spritesheetImageSrc").and_then(Value::as_str), - data.get("spritesheetWidth").and_then(Value::as_u64), - data.get("spritesheetHeight").and_then(Value::as_u64), - )); - } - if let Some(icon_images) = data.get("iconImageSrcs").and_then(Value::as_array) { - for icon in icon_images { - if let Some(resource) = icon.get("resource") { - images.push(editor_agent_generated_image_from_resource( - resource, - icon.get("imageSrc").and_then(Value::as_str), - icon.get("width").and_then(Value::as_u64), - icon.get("height").and_then(Value::as_u64), - )); - } - } - } - return images; - } - - data.get("resource") - .map(|resource| { - vec![editor_agent_generated_image_from_resource( - resource, - data.get("imageSrc").and_then(Value::as_str), - data.get("width").and_then(Value::as_u64), - data.get("height").and_then(Value::as_u64), - )] - }) - .unwrap_or_else(|| { - data.get("imageSrc") - .and_then(Value::as_str) - .map(|image_src| { - vec![EditorAgentGeneratedImage { - resource_id: None, - object_key: None, - asset_object_id: None, - image_src: image_src.to_string(), - thumbnail_src: None, - width: data - .get("width") - .and_then(Value::as_u64) - .and_then(|value| u32::try_from(value).ok()), - height: data - .get("height") - .and_then(Value::as_u64) - .and_then(|value| u32::try_from(value).ok()), - }] - }) - .unwrap_or_default() - }) -} - -fn editor_agent_generated_image_from_resource( - resource: &Value, - fallback_image_src: Option<&str>, - fallback_width: Option, - fallback_height: Option, -) -> EditorAgentGeneratedImage { - EditorAgentGeneratedImage { - resource_id: resource - .get("resourceId") - .and_then(Value::as_str) - .map(str::to_string), - object_key: resource - .get("objectKey") - .and_then(Value::as_str) - .map(str::to_string), - asset_object_id: resource - .get("assetObjectId") - .and_then(Value::as_str) - .map(str::to_string), - image_src: resource - .get("imageSrc") - .and_then(Value::as_str) - .or(fallback_image_src) - .unwrap_or("") - .to_string(), - thumbnail_src: resource - .get("thumbnailSrc") - .and_then(Value::as_str) - .map(str::to_string), - width: resource - .get("width") - .and_then(Value::as_u64) - .or(fallback_width) - .and_then(|value| u32::try_from(value).ok()), - height: resource - .get("height") - .and_then(Value::as_u64) - .or(fallback_height) - .and_then(|value| u32::try_from(value).ok()), - } -} - -fn editor_agent_attachment_sources(attachments: &[EditorAgentAttachmentRef]) -> Vec { - attachments - .iter() - .filter_map(|attachment| { - attachment - .object_key - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(|value| value.trim_start_matches('/').to_string()) - .or_else(|| normalize_required_string(attachment.reference_id.as_str())) - }) - .collect() -} - -fn editor_agent_tool_generation_reference_context( - attachments: &[EditorAgentAttachmentRef], - latest_generation_reference: Option<&EditorAgentGeneratedImageReference>, - previous_generation_source: Option<&str>, -) -> Vec { - if previous_generation_source.is_some() { - return latest_generation_reference - .map(|reference| { - vec![json!({ - "title": reference - .summary - .clone() - .unwrap_or_else(|| "上一张生成图".to_string()), - "label": reference.summary, - "refType": "previous-generation", - "refId": reference.resource_id, - "resourceId": reference.resource_id, - "objectKey": reference.object_key, - "assetObjectId": reference.asset_object_id, - "toolCallId": reference.tool_call_id, - "toolName": reference.tool_name, - "sourceMessageId": reference.source_message_id, - "source": previous_generation_source, - "implicit": true, - })] - }) - .unwrap_or_default(); - } - - attachments - .iter() - .map(|attachment| { - json!({ - "title": attachment.label.clone().unwrap_or_else(|| "对话附件".to_string()), - "label": attachment.label, - "refType": match attachment.source { - EditorAgentAttachmentSource::CanvasResource => "project-resource", - EditorAgentAttachmentSource::LibraryAsset => "asset", - }, - "refId": attachment.reference_id, - "resourceId": (attachment.source == EditorAgentAttachmentSource::CanvasResource) - .then(|| attachment.reference_id.clone()), - "objectKey": attachment.object_key, - }) - }) - .collect() -} - -fn editor_agent_tool_source_resource_id( - attachments: &[EditorAgentAttachmentRef], - latest_generation_reference: Option<&EditorAgentGeneratedImageReference>, - previous_generation_source: Option<&str>, -) -> Option { - if previous_generation_source.is_some() { - return latest_generation_reference.and_then(|reference| reference.resource_id.clone()); - } - attachments - .first() - .map(|attachment| attachment.reference_id.clone()) -} - -fn split_editor_agent_edit_sources(sources: Vec) -> Option<(String, Vec)> { - let mut iter = sources.into_iter(); - let first = iter.next()?; - Some((first, iter.collect())) -} - -fn editor_agent_tool_asset_kind(tool_name: EditorAgentToolName) -> &'static str { - match tool_name { - EditorAgentToolName::GenerateCharacter => "character", - EditorAgentToolName::GenerateUiDesign => "ui-design", - EditorAgentToolName::EditImage => "editor_agent_edit_image", - EditorAgentToolName::GenerateIconSpritesheet => "icon-spritesheet", - EditorAgentToolName::GenerateImage => "editor_agent_generated_image", - } -} - -fn editor_agent_default_asset_folder_id() -> String { - "project".to_string() -} - -fn editor_agent_tool_default_summary(tool_name: EditorAgentToolName) -> String { - match tool_name { - EditorAgentToolName::GenerateImage => "生成图片", - EditorAgentToolName::EditImage => "修改图片", - EditorAgentToolName::GenerateCharacter => "生成角色形象", - EditorAgentToolName::GenerateIconSpritesheet => "生成图标素材", - EditorAgentToolName::GenerateUiDesign => "生成 UI 设计图", - } - .to_string() -} - -fn build_editor_agent_canvas_completion( - project: &EditorProjectRecord, - tool_name: EditorAgentToolName, - title: &str, -) -> EditorCanvasGenerationCompletionPayload { - let (width, height) = editor_agent_tool_display_size(tool_name); - let (x, y) = next_editor_agent_canvas_position(project.layers.clone(), width, height); - EditorCanvasGenerationCompletionPayload { - dialog_id: None, - title: normalize_optional_string(Some(title.to_string())) - .unwrap_or_else(|| editor_agent_tool_default_summary(tool_name)), - placeholder: EditorCanvasGenerationPlaceholderPayload { - x, - y, - width, - height, - original_width: width, - original_height: height, - }, - } -} - -fn editor_agent_tool_display_size(tool_name: EditorAgentToolName) -> (f64, f64) { - match tool_name { - EditorAgentToolName::GenerateUiDesign => (640.0, 360.0), - EditorAgentToolName::GenerateCharacter => (512.0, 768.0), - _ => (512.0, 512.0), - } -} - -fn next_editor_agent_canvas_position(layers: Value, _width: f64, _height: f64) -> (f64, f64) { - let mut max_right: Option = None; - let mut min_y: Option = None; - if let Value::Array(items) = layers { - for item in items { - if item.get("itemType").and_then(Value::as_str) == Some("generation-dialog") { - continue; - } - let Some(x) = item.get("x").and_then(Value::as_f64) else { - continue; - }; - let Some(y) = item.get("y").and_then(Value::as_f64) else { - continue; - }; - let width = item.get("width").and_then(Value::as_f64).unwrap_or(0.0); - if !x.is_finite() || !y.is_finite() || !width.is_finite() { - continue; - } - max_right = Some(max_right.map_or(x + width, |value| value.max(x + width))); - min_y = Some(min_y.map_or(y, |value| value.min(y))); - } - } - ( - max_right.map_or(0.0, |right| right + EDITOR_AGENT_CANVAS_RESULT_GAP), - min_y.unwrap_or(0.0), - ) -} - -#[cfg(test)] -fn build_echo_assistant_text(text: &str, attachment_count: usize) -> String { - let trimmed = text.trim(); - if trimmed.is_empty() { - return format!("已收到 {attachment_count} 个附件。"); - } - format!("收到:{trimmed}") -} - -fn now_rfc3339() -> String { - shared_kernel::format_rfc3339(time::OffsetDateTime::now_utc()) - .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()) -} - -async fn require_editor_agent_sidebar_enabled( - state: &AppState, - owner_user_id: &str, -) -> Result<(), AppError> { - match state - .is_image_editor_agent_sidebar_enabled_for_user(Some(owner_user_id)) - .await - { - Ok(true) => Ok(()), - Ok(false) => Err(editor_agent_sidebar_unavailable()), - Err(error) => Err(AppError::from_status(StatusCode::BAD_GATEWAY) - .with_message("读取画布 Agent 灰度配置失败") - .with_details(json!({ - "provider": "spacetimedb", - "message": error.to_string(), - }))), - } -} - -fn editor_agent_sidebar_unavailable() -> AppError { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) - .with_message("画布 Agent 暂不可用") - .with_details(json!({ - "provider": "editor-agent", - "reason": "image_editor_agent_sidebar_disabled", - "gateKey": module_runtime::IMAGE_EDITOR_AGENT_SIDEBAR_GATE_KEY, - })) -} - -fn editor_agent_bad_request(message: impl Into) -> AppError { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "editor-agent", - "message": message.into(), - })) -} - -fn editor_agent_oss_unavailable() -> AppError { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": "aliyun-oss", - "reason": "OSS is not configured for editor agent conversations", - })) -} - -fn editor_agent_oss_read_error(message: impl Into) -> AppError { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "aliyun-oss", - "message": message.into(), - })) -} - -fn editor_agent_messages_document_too_large() -> AppError { - AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE).with_details(json!({ - "provider": "editor-agent", - "message": "message document is too large", - "maxBytes": EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES, - })) -} - -fn editor_agent_sse_json_event_or_error(event_name: &str, payload: T) -> Event -where - T: Serialize, -{ - match serde_json::to_string(&payload) { - Ok(data) => Event::default().event(event_name).data(data), - Err(error) => Event::default().event("error").data( - serde_json::to_string(&EditorAgentErrorEvent { - conversation_id: None, - code: "SERIALIZE_EVENT_FAILED".to_string(), - message: error.to_string(), - recoverable: false, - }) - .unwrap_or_else(|_| "{}".to_string()), - ), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::AppConfig; - use platform_llm::LlmTextProtocol; - - #[test] - fn echo_assistant_text_uses_attachment_fallback() { - assert_eq!( - build_echo_assistant_text(" 画一棵树 ", 0), - "收到:画一棵树" - ); - assert_eq!(build_echo_assistant_text(" ", 2), "已收到 2 个附件。"); - } - - #[test] - fn empty_document_uses_contract_version() { - let document = empty_messages_document("editor-agent-conv-1"); - assert_eq!(document.version, EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION); - assert_eq!(document.conversation_id, "editor-agent-conv-1"); - assert!(document.messages.is_empty()); - } - - #[test] - fn canvas_attachment_normalization_uses_project_resource_truth() { - let conversation = test_conversation_record(); - let attachment = EditorAgentAttachmentRef { - source: EditorAgentAttachmentSource::CanvasResource, - reference_id: " resource-1 ".to_string(), - object_key: Some("generated-editor-assets/resource-1.png".to_string()), - image_src: "https://client.example/stale.png".to_string(), - thumbnail_src: Some("https://client.example/thumb.png".to_string()), - label: Some(" 参考图 ".to_string()), - width: Some(1), - height: Some(1), - }; - let resource = test_project_resource_record(); - - let normalized = - normalize_canvas_resource_attachment(&conversation, &attachment, &resource) - .expect("attachment should normalize"); - - assert_eq!(normalized.reference_id, "resource-1"); - assert_eq!( - normalized.object_key.as_deref(), - Some("generated-editor-assets/resource-1.png") - ); - assert_eq!(normalized.image_src, "/api/assets/read/resource-1.png"); - assert_eq!(normalized.thumbnail_src, None); - assert_eq!(normalized.label.as_deref(), Some("参考图")); - assert_eq!(normalized.width, Some(512)); - assert_eq!(normalized.height, Some(256)); - } - - #[test] - fn attachment_normalization_rejects_mismatched_object_key() { - let conversation = test_conversation_record(); - let attachment = EditorAgentAttachmentRef { - source: EditorAgentAttachmentSource::CanvasResource, - reference_id: "resource-1".to_string(), - object_key: Some("generated-editor-assets/other.png".to_string()), - image_src: "/api/assets/read/resource-1.png".to_string(), - thumbnail_src: None, - label: None, - width: None, - height: None, - }; - let resource = test_project_resource_record(); - - assert!( - normalize_canvas_resource_attachment(&conversation, &attachment, &resource).is_err() - ); - } - - #[test] - fn library_attachment_normalization_uses_asset_label_fallback() { - let attachment = EditorAgentAttachmentRef { - source: EditorAgentAttachmentSource::LibraryAsset, - reference_id: "asset-1".to_string(), - object_key: None, - image_src: "https://client.example/stale.png".to_string(), - thumbnail_src: None, - label: None, - width: None, - height: None, - }; - let asset = EditorAssetRecord { - asset_id: "asset-1".to_string(), - folder_id: "project".to_string(), - label: "素材库图".to_string(), - asset_object_id: Some("asset-object-1".to_string()), - image_src: "/api/assets/read/asset-1.png".to_string(), - object_key: Some("generated-editor-assets/asset-1.png".to_string()), - width: 128, - height: 128, - source_type: "uploaded".to_string(), - prompt: None, - actual_prompt: None, - model: None, - provider: None, - task_id: None, - asset_kind: None, - generation_inputs: None, - source_resource_id: None, - public_showcase_enabled: None, - thumbnail_src: Some("/api/assets/read/asset-1-thumb.png".to_string()), - generation_cost_mud_points: 0, - showcase_id: None, - showcase_review_status: None, - showcase_display_enabled: None, - showcase_like_count: None, - created_at: "2026-07-03T00:00:00Z".to_string(), - updated_at: "2026-07-03T00:00:00Z".to_string(), - }; - - let normalized = normalize_library_asset_attachment(&attachment, &asset) - .expect("library attachment should normalize"); - - assert_eq!(normalized.label.as_deref(), Some("素材库图")); - assert_eq!( - normalized.thumbnail_src.as_deref(), - Some("/api/assets/read/asset-1-thumb.png") - ); - assert_eq!(normalized.width, Some(128)); - assert_eq!(normalized.height, Some(128)); - } - - #[test] - fn generated_image_from_resource_keeps_private_asset_keys() { - let image = editor_agent_generated_image_from_resource( - &json!({ - "resourceId": "resource-1", - "imageSrc": "/generated-editor-assets/result.png", - "thumbnailSrc": "/generated-editor-assets/result-thumb.png", - "objectKey": "generated-editor-assets/result.png", - "assetObjectId": "asset-object-result", - "width": 1024, - "height": 768 - }), - None, - None, - None, - ); - - assert_eq!(image.resource_id.as_deref(), Some("resource-1")); - assert_eq!( - image.object_key.as_deref(), - Some("generated-editor-assets/result.png") - ); - assert_eq!( - image.asset_object_id.as_deref(), - Some("asset-object-result") - ); - assert_eq!(image.width, Some(1024)); - assert_eq!(image.height, Some(768)); - } - - #[test] - fn editor_agent_generations_target_default_asset_library_folder() { - assert_eq!(editor_agent_default_asset_folder_id(), "project"); - } - - #[test] - fn editor_agent_turn_plan_parses_fenced_json_tool_call() { - let plan = parse_editor_agent_turn_plan( - r#"```json -{ - "replyText": "我来生成一张像素风角色图。", - "toolCall": { - "toolName": "generate_character", - "prompt": "像素风银发游侠", - "summary": "生成像素角色", - "imageSize": "1K" - } -} -```"#, - ) - .expect("fenced JSON should parse"); - - assert_eq!(plan.reply_text, "我来生成一张像素风角色图。"); - let tool_call = plan.tool_call.expect("tool call should exist"); - assert_eq!(tool_call.tool_name, EditorAgentToolName::GenerateCharacter); - assert_eq!(tool_call.prompt, "像素风银发游侠"); - assert_eq!(tool_call.summary.as_deref(), Some("生成像素角色")); - assert_eq!(tool_call.image_size.as_deref(), Some("1K")); - } - - #[test] - fn editor_agent_turn_plan_rejects_invalid_llm_response() { - assert!(parse_editor_agent_turn_plan("收到:画一棵树").is_none()); - assert!( - parse_editor_agent_turn_plan( - r#"{"toolCall":{"toolName":"generate_image","prompt":"画一棵树"}}"#, - ) - .is_none() - ); - assert!( - parse_editor_agent_turn_plan( - r#"{"replyText":"我来生成图片。","toolCall":{"toolName":"generate_image"}}"#, - ) - .is_none() - ); - } - - #[test] - fn editor_agent_turn_plan_replaces_structural_reply_fragment() { - let plan = parse_editor_agent_turn_plan( - r#"{"replyText":"{","toolCall":{"toolName":"generate_image","prompt":"生成一张角色规范图","summary":"生成角色规范图"}}"#, - ) - .expect("valid tool call should parse"); - - assert_eq!(plan.reply_text, "我来生成图片。"); - assert_eq!( - plan.tool_call.as_ref().map(|tool_call| tool_call.tool_name), - Some(EditorAgentToolName::GenerateImage) - ); - } - - #[test] - fn editor_agent_llm_system_prompt_describes_each_tool() { - let prompt = editor_agent_llm_system_prompt(); - - for expected in [ - "generate_image:从文字生成一张全新图片", - "edit_image:修改已有图片", - "generate_character:生成新的角色形象", - "generate_icon_spritesheet:生成一组图标素材或图标图集", - "generate_ui_design:生成一张完整 UI 设计图或界面稿", - "规范图/视觉规范图/风格规范图/素材规范展板", - "当前使用 generate_image", - "角色规范图/角色美术视觉规范设定图", - "图标规范图/图标视觉规范展板", - "统一视角、线条粗细", - "色卡/色号", - "工具选择优先级", - "明确修改/指代已有图 => edit_image", - "规范图/视觉规范图/风格规范图/素材规范展板 => generate_image", - ] { - assert!( - prompt.contains(expected), - "system prompt should contain {expected}" - ); - } - } - - #[test] - fn latest_generation_reference_enters_llm_prompt() { - let conversation = test_conversation_record(); - let user_message = test_user_message("把刚才那个衣服换成蓝色"); - let mut document = empty_messages_document(conversation.conversation_id.as_str()); - document.messages.push(EditorAgentMessage { - id: "editor-agent-message-assistant-1".to_string(), - role: EditorAgentMessageRole::Assistant, - kind: EditorAgentMessageKind::Chat, - text: "已生成角色图。".to_string(), - attachments: Vec::new(), - generations: vec![test_completed_generation_record()], - status: EditorAgentMessageStatus::Completed, - created_at: "2026-07-05T00:00:00Z".to_string(), - }); - document.messages.push(user_message.clone()); - - let reference = latest_editor_agent_generated_image_reference(&document) - .expect("latest generated image should be available"); - let prompt = build_editor_agent_llm_user_prompt( - &conversation, - &document, - &user_message, - Some(&reference), - ); - let prompt_json: Value = serde_json::from_str(prompt.as_str()).expect("prompt is JSON"); - - assert_eq!( - prompt_json["latestGeneratedImage"]["toolName"], - json!("generate_image") - ); - assert_eq!( - prompt_json["latestGeneratedImage"]["summary"], - json!("生成红衣角色") - ); - assert_eq!( - prompt_json["latestGeneratedImage"]["resourceId"], - json!("resource-generated-1") - ); - assert_eq!( - prompt_json["latestGeneratedImage"]["objectKey"], - json!("generated-editor-assets/result.png") - ); - assert_eq!( - prompt_json["recentMessages"][0]["generations"][0]["images"][0]["objectKey"], - json!("generated-editor-assets/result.png") - ); - } - - #[test] - fn previous_generation_edit_default_coerces_generate_image_to_edit_image() { - let user_message = test_user_message("把衣服换成蓝色"); - let reference = test_generated_image_reference(); - let plan = EditorAgentTurnPlan { - reply_text: "我来生成图片。".to_string(), - tool_call: Some(EditorAgentToolCallPlan { - tool_name: EditorAgentToolName::GenerateImage, - prompt: "把衣服换成蓝色".to_string(), - summary: Some("生成蓝色衣服".to_string()), - model: None, - aspect_ratio: Some("1:1".to_string()), - image_size: Some("1K".to_string()), - icon_descriptions: Vec::new(), - }), - }; - - let adjusted = - apply_previous_generation_edit_default(plan, &user_message, Some(&reference)); - let tool_call = adjusted.tool_call.expect("tool should be forced"); - - assert_eq!(tool_call.tool_name, EditorAgentToolName::EditImage); - assert_eq!(tool_call.prompt, "把衣服换成蓝色"); - assert_eq!(adjusted.reply_text, "我会基于上一张生成图按你的要求修改。"); - } - - #[test] - fn previous_generation_reference_becomes_implicit_edit_source() { - let reference = test_generated_image_reference(); - - let sources = editor_agent_generated_reference_source(&reference); - let context = editor_agent_tool_generation_reference_context( - &[], - Some(&reference), - sources.as_deref(), - ); - - assert_eq!( - sources.as_deref(), - Some("generated-editor-assets/result.png") - ); - assert_eq!(context[0]["refType"], json!("previous-generation")); - assert_eq!(context[0]["resourceId"], json!("resource-generated-1")); - assert_eq!( - context[0]["objectKey"], - json!("generated-editor-assets/result.png") - ); - assert_eq!(context[0]["implicit"], json!(true)); - } - - #[test] - fn editor_agent_tool_execution_persists_summary() { - let execution = build_editor_agent_tool_execution( - "editor-agent-tool-1", - EditorAgentToolName::EditImage, - Some("换蓝色衣服".to_string()), - json!({ - "ok": true, - "data": { - "model": "gpt-image-2", - "resource": { - "resourceId": "resource-generated-2", - "imageSrc": "/generated-editor-assets/blue.png", - "objectKey": "generated-editor-assets/blue.png", - "assetObjectId": "asset-object-blue", - "width": 1024, - "height": 1024 - } - } - }), - ); - - assert_eq!(execution.summary, "换蓝色衣服"); - assert_eq!(execution.record.summary.as_deref(), Some("换蓝色衣服")); - } - - #[tokio::test] - async fn editor_agent_llm_request_uses_vector_engine_chat_model() { - let conversation = test_conversation_record(); - let document = empty_messages_document(conversation.conversation_id.as_str()); - let user_message = test_user_message("帮我看看这张图还能怎么改"); - let state = AppState::new(AppConfig::default()).expect("state should build"); - - let request = - build_editor_agent_llm_request(&state, &conversation, &document, &user_message, None) - .await; - - assert_eq!(request.protocol, LlmTextProtocol::ChatCompletions); - assert_eq!( - request.model.as_deref(), - Some(platform_agent::CREATIVE_AGENT_GPT5_MODEL) - ); - assert_eq!(request.max_tokens, Some(EDITOR_AGENT_LLM_MAX_OUTPUT_TOKENS)); - assert_eq!( - request.request_timeout_ms, - Some(EDITOR_AGENT_LLM_REQUEST_TIMEOUT_MS) - ); - } - - #[test] - fn editor_agent_heuristic_routes_tools_from_user_text() { - let character_plan = - heuristic_editor_agent_turn_plan("帮我生成一个角色形象:猫耳骑士", &[]); - assert_eq!( - character_plan - .tool_call - .as_ref() - .expect("character tool expected") - .tool_name, - EditorAgentToolName::GenerateCharacter - ); - - let edit_plan = heuristic_editor_agent_turn_plan( - "把附件这张图改成赛博朋克风格", - &[EditorAgentAttachmentRef { - source: EditorAgentAttachmentSource::CanvasResource, - reference_id: "resource-1".to_string(), - object_key: Some("generated-editor-assets/resource-1.png".to_string()), - image_src: "/generated-editor-assets/resource-1.png".to_string(), - thumbnail_src: None, - label: None, - width: Some(512), - height: Some(512), - }], - ); - assert_eq!( - edit_plan - .tool_call - .as_ref() - .expect("edit tool expected") - .tool_name, - EditorAgentToolName::EditImage - ); - - let chat_plan = heuristic_editor_agent_turn_plan("这张图适合做头像吗?", &[]); - assert!(chat_plan.tool_call.is_none()); - } - - #[test] - fn editor_agent_canvas_completion_places_result_after_existing_layers() { - let project = EditorProjectRecord { - project_id: "project-1".to_string(), - owner_user_id: "user-1".to_string(), - title: "画布".to_string(), - canvas: spacetime_client::EditorCanvasRecord { - canvas_id: "canvas-1".to_string(), - project_id: "project-1".to_string(), - title: "画布".to_string(), - viewport: spacetime_client::EditorCanvasViewportRecord { - x: 0.0, - y: 0.0, - scale: 1.0, - }, - layers: json!([]), - created_at: "2026-07-03T00:00:00Z".to_string(), - updated_at: "2026-07-03T00:00:00Z".to_string(), - }, - viewport: spacetime_client::EditorCanvasViewportRecord { - x: 0.0, - y: 0.0, - scale: 1.0, - }, - layers: json!([ - { - "layerId": "layer-1", - "resourceId": "resource-1", - "x": 10.0, - "y": 20.0, - "width": 180.0, - "height": 120.0 - } - ]), - resources: Vec::new(), - created_at: "2026-07-03T00:00:00Z".to_string(), - updated_at: "2026-07-03T00:00:00Z".to_string(), - }; - - let completion = build_editor_agent_canvas_completion( - &project, - EditorAgentToolName::GenerateUiDesign, - "UI 设计图", - ); - - assert_eq!(completion.dialog_id, None); - assert_eq!(completion.title, "UI 设计图"); - assert_eq!(completion.placeholder.x, 222.0); - assert_eq!(completion.placeholder.y, 20.0); - assert_eq!(completion.placeholder.width, 640.0); - assert_eq!(completion.placeholder.height, 360.0); - } - - fn test_conversation_record() -> EditorAgentConversationRecord { - EditorAgentConversationRecord { - conversation_id: "editor-agent-conv-1".to_string(), - project_id: "project-1".to_string(), - owner_user_id: "user-1".to_string(), - title: "新对话".to_string(), - messages_object_key: "editor-agent/editor-agent-conv-1.json".to_string(), - deleted: false, - created_at: "2026-07-03T00:00:00Z".to_string(), - updated_at: "2026-07-03T00:00:00Z".to_string(), - updated_at_micros: 1, - } - } - - fn test_project_resource_record() -> EditorProjectResourceRecord { - EditorProjectResourceRecord { - resource_id: "resource-1".to_string(), - project_id: "project-1".to_string(), - owner_user_id: "user-1".to_string(), - asset_object_id: Some("asset-object-1".to_string()), - image_src: "/api/assets/read/resource-1.png".to_string(), - object_key: Some("generated-editor-assets/resource-1.png".to_string()), - width: 512, - height: 256, - source_type: "generated".to_string(), - prompt: Some("森林".to_string()), - actual_prompt: None, - model: Some("gpt-image-2".to_string()), - provider: Some("VectorEngine".to_string()), - task_id: Some("task-1".to_string()), - source_resource_id: None, - asset_kind: Some("editor_generated_image".to_string()), - generation_inputs: None, - public_showcase_enabled: false, - created_at: "2026-07-03T00:00:00Z".to_string(), - updated_at: "2026-07-03T00:00:00Z".to_string(), - } - } - - fn test_user_message(text: &str) -> EditorAgentMessage { - EditorAgentMessage { - id: "editor-agent-message-user-1".to_string(), - role: EditorAgentMessageRole::User, - kind: EditorAgentMessageKind::Chat, - text: text.to_string(), - attachments: Vec::new(), - generations: Vec::new(), - status: EditorAgentMessageStatus::Completed, - created_at: "2026-07-05T00:00:00Z".to_string(), - } - } - - fn test_completed_generation_record() -> EditorAgentGenerationRecord { - EditorAgentGenerationRecord { - tool_call_id: "editor-agent-tool-generated-1".to_string(), - tool_name: EditorAgentToolName::GenerateImage, - summary: Some("生成红衣角色".to_string()), - task_id: Some("task-generated-1".to_string()), - status: EditorAgentGenerationStatus::Completed, - model: Some("gpt-image-2".to_string()), - images: vec![EditorAgentGeneratedImage { - resource_id: Some("resource-generated-1".to_string()), - object_key: Some("generated-editor-assets/result.png".to_string()), - asset_object_id: Some("asset-object-result".to_string()), - image_src: "/generated-editor-assets/result.png".to_string(), - thumbnail_src: Some("/generated-editor-assets/result-thumb.png".to_string()), - width: Some(1024), - height: Some(1024), - }], - error: None, - } - } - - fn test_generated_image_reference() -> EditorAgentGeneratedImageReference { - let mut document = empty_messages_document("editor-agent-conv-1"); - document.messages.push(EditorAgentMessage { - id: "editor-agent-message-assistant-1".to_string(), - role: EditorAgentMessageRole::Assistant, - kind: EditorAgentMessageKind::Chat, - text: "已生成角色图。".to_string(), - attachments: Vec::new(), - generations: vec![test_completed_generation_record()], - status: EditorAgentMessageStatus::Completed, - created_at: "2026-07-05T00:00:00Z".to_string(), - }); - latest_editor_agent_generated_image_reference(&document) - .expect("latest generated image should be available") - } -} diff --git a/server-rs/crates/api-server/src/editor_agent/api.rs b/server-rs/crates/api-server/src/editor_agent/api.rs new file mode 100644 index 000000000..04a7e33a2 --- /dev/null +++ b/server-rs/crates/api-server/src/editor_agent/api.rs @@ -0,0 +1,1196 @@ +use axum::extract::{Path, State}; +use axum::{Extension, Json}; +use module_editor_agent::{ + EDITOR_AGENT_CONVERSATION_ID_PREFIX, EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE, + derive_conversation_title, editor_agent_messages_object_key, validate_user_message, +}; +use platform_editor_agent::framework::agent_builder::AgentBuilder; +use platform_editor_agent::framework::error::PromptError; +use platform_editor_agent::framework::memory::VecMemory; +use platform_editor_agent::framework::run::PromptOutput; +use platform_editor_agent::framework::tool::Tool; +use platform_llm::LlmMessage; +use serde::Serialize; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use shared_contracts::assets::{ + EditorBackgroundMusicGenerateRequest, EditorSoundEffectGenerateRequest, + EditorVideoGenerateRequest, +}; +use shared_contracts::editor_agent::{ + CreateEditorAgentConversationRequest, EDITOR_AGENT_ERROR_MESSAGE_PREFIX, + EditorAgentConversationListResponse, EditorAgentConversationMessagesDocument, + EditorAgentConversationResponse, EditorAgentConversationSummary, EditorAgentMessage, + EditorAgentMessageRequest, EditorAgentMessageResponse, EditorAgentMessageRole, + EditorAgentToolCall, EditorAgentToolCallStatus, +}; +use spacetime_client::{ + EditorAgentConversationCreateRecordInput, EditorAgentConversationDeleteRecordInput, + EditorAgentConversationRecord, EditorAgentConversationTouchRecordInput, + EditorProjectGetRecordInput, +}; + +use crate::api_response::json_success_body; +use crate::auth::AuthenticatedAccessToken; +use crate::editor_agent::pricing::EditorAgentPricedTool; +use crate::editor_agent::utils::{ + IntoImageId, build_editor_agent_canvas_completion, conversation_detail_from_record, + conversation_summary_from_record, editor_agent_bad_request, empty_messages_document, + ensure_editor_project_access, normalize_editor_agent_attachments, now_rfc3339, + read_messages_document, require_editor_agent_sidebar_enabled, write_messages_document, +}; +use crate::editor_agent::{context, display_args, reconcile}; +use crate::editor_generation_config::EditorGenerationPricingConfig; +use crate::editor_generation_queue::{ + EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, + EDITOR_IMAGE_EDIT_JOB_KIND, EDITOR_IMAGE_GENERATION_JOB_KIND, + EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND, EDITOR_VIDEO_GENERATION_JOB_KIND, + enqueue_editor_generation_job_with_identity, +}; +use crate::editor_project::{ + EditorIconSpritesheetGenerationRequest, EditorImageEditRequest, EditorImageGenerationRequest, +}; +use crate::editor_project::{current_utc_micros, map_editor_project_error}; +use crate::http_error::AppError; +use crate::request_context::RequestContext; +use crate::state::AppState; +use platform_editor_agent::agent::agent::LlmChatAgentBuilder; +use platform_editor_agent::agent::tools::context::EditorToolContext; +use platform_editor_agent::agent::tools::edit_image::{EditImageTool, EditImageToolArgs}; +use platform_editor_agent::agent::tools::generate_background_music::{ + GenerateBackgroundMusicTool, GenerateBackgroundMusicToolArgs, +}; +use platform_editor_agent::agent::tools::generate_character::GenerateCharacterTool; +use platform_editor_agent::agent::tools::generate_icon_spritesheet::{ + GenerateIconSpritesheetTool, GenerateIconSpritesheetToolArgs, +}; +use platform_editor_agent::agent::tools::generate_image::{ + GenerateImageTool, GenerateImageToolArgs, +}; +use platform_editor_agent::agent::tools::generate_sound_effect::{ + GenerateSoundEffectTool, GenerateSoundEffectToolArgs, +}; +use platform_editor_agent::agent::tools::generate_ui_design::GenerateUiDesignTool; +use platform_editor_agent::agent::tools::generate_video::{ + GenerateVideoTool, GenerateVideoToolArgs, +}; +use shared_kernel::{build_prefixed_uuid_id, normalize_optional_string, normalize_required_string}; + +const EDITOR_AGENT_CLIENT_MESSAGE_ID_MAX_CHARS: usize = 128; + +pub async fn editor_agent_message( + State(state): State, + Path(conversation_id): Path, + Extension(_request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, AppError> { + let owner_user_id = authenticated.claims().user_id().to_string(); + require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?; + let client_message_id = validate_editor_agent_message_request(&payload)?; + let normalized_text = payload.text.trim().to_string(); + // Load conversation & attachments + let conversation = state + .spacetime_client() + .get_editor_agent_conversation(conversation_id.clone(), owner_user_id.clone()) + .await + .map_err(|e| { + AppError::from_status(axum::http::StatusCode::NOT_FOUND) + .with_details(json!({ "message": format!("conversation not found: {e}") })) + })?; + + let attachments = + normalize_editor_agent_attachments(&state, &conversation, payload.attachments.as_slice()) + .await?; + + let conversation_lock = crate::editor_agent::utils::editor_agent_conversation_lock( + conversation.conversation_id.as_str(), + ); + let _conversation_lock_guard = conversation_lock.lock_owned().await; + + let mut document: EditorAgentConversationMessagesDocument = + read_messages_document(&state, &conversation).await?; + + let existing_user_index = find_idempotent_editor_agent_user_message( + &document, + client_message_id.as_str(), + normalized_text.as_str(), + attachments.as_slice(), + )?; + + let (user_message, history_end, conversation_summary) = + if let Some(user_index) = existing_user_index { + let delta_messages = document.messages[user_index + 1..] + .iter() + .take_while(|message| message.role != EditorAgentMessageRole::User) + .cloned() + .collect::>(); + if !delta_messages.is_empty() { + return Ok(Json(EditorAgentMessageResponse { + conversation: conversation_summary_from_record(conversation), + delta_messages, + error_message: None, + })); + } + + ( + document.messages[user_index].clone(), + user_index, + conversation_summary_from_record(conversation.clone()), + ) + } else { + // Determine initialization before attachment bookkeeping adds a system message. + let was_empty = document.messages.is_empty(); + let now = now_rfc3339(); + if !attachments.is_empty() { + let mut attachment_info = String::new(); + attachment_info.push_str("user has just uploaded attachments of the order: "); + for attachment in &attachments { + attachment_info.push_str(&format!("{} ,", attachment.clone().into_image_id())); + } + document.messages.push(EditorAgentMessage { + id: document.messages.len(), + client_message_id: None, + role: EditorAgentMessageRole::System, + text: attachment_info, + attachments: Vec::new(), + tool_call: None, + created_at: now.clone(), + }); + } + + let history_end = document.messages.len(); + let user_message = EditorAgentMessage { + id: document.messages.len(), + client_message_id: Some(client_message_id), + role: EditorAgentMessageRole::User, + text: normalized_text, + attachments, + tool_call: None, + created_at: now, + }; + document.messages.push(user_message.clone()); + write_messages_document(&state, &conversation, &document).await?; + + // Persist and return the authoritative summary for every turn. Initialization sets the + // title from the first user prompt; a metadata write failure must fail the request. + let updated_conversation = state + .spacetime_client() + .touch_editor_agent_conversation(EditorAgentConversationTouchRecordInput { + conversation_id: conversation.conversation_id.clone(), + owner_user_id: conversation.owner_user_id.clone(), + title: was_empty.then(|| derive_conversation_title(user_message.text.as_str())), + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(map_editor_project_error)?; + + ( + user_message, + history_end, + conversation_summary_from_record(updated_conversation), + ) + }; + + // The current user message is passed separately to prompt(), so memory stops before it. + let previous_messages: Vec = document.messages[..history_end] + .iter() + .map(|message| match message.role { + EditorAgentMessageRole::User => LlmMessage::user(&message.text), + EditorAgentMessageRole::Assistant => LlmMessage::assistant(&message.text), + EditorAgentMessageRole::System => LlmMessage::system(&message.text), + }) + // Tool calls and attachment bookkeeping are separate system messages. + .rev() + .take(18) + .rev() + .collect(); + + // Build tool context from document + let tool_context = context::build_tool_context(&document); + + // Build and run agent + let Some(llm_client) = state.creative_agent_gpt5_client() else { + return persist_editor_agent_planning_error( + &state, + &conversation, + &mut document, + conversation_summary, + "Creative Agent GPT-5 client not configured", + ) + .await; + }; + let llm_client = llm_client.clone(); + let pricing = match state.editor_generation_pricing().await { + Ok(pricing) => pricing, + Err(error) => { + return persist_editor_agent_planning_error( + &state, + &conversation, + &mut document, + conversation_summary, + format!("failed to load editor generation pricing: {error}"), + ) + .await; + } + }; + + let memory = VecMemory::new(previous_messages); + + let mut agent = LlmChatAgentBuilder::new() + .with_client(llm_client) + .system_prompt(editor_agent_system_prompt()) + .tool(EditImageTool { + context: tool_context.clone(), + }) + .tool(GenerateImageTool { + context: tool_context.clone(), + }) + .tool(GenerateCharacterTool { + context: tool_context.clone(), + }) + .tool(GenerateIconSpritesheetTool { + context: tool_context.clone(), + }) + .tool(GenerateSoundEffectTool) + .tool(GenerateBackgroundMusicTool) + .tool(GenerateVideoTool { + context: tool_context.clone(), + }) + .tool(GenerateUiDesignTool { + context: tool_context.clone(), + }) + .max_turns(3) + .memory(memory) + .build(); + + let agent_result = agent + .prompt(LlmMessage::user(user_message.text.clone())) + .await; + + let assistant_now = now_rfc3339(); + + match build_delta_messages( + agent_result, + &assistant_now, + document.messages.len(), + &document, + &tool_context, + &pricing, + ) { + Err(error) => { + persist_editor_agent_planning_error( + &state, + &conversation, + &mut document, + conversation_summary, + error.to_string(), + ) + .await + } + Ok(delta_messages) => { + for msg in &delta_messages { + document.messages.push(msg.clone()); + } + write_messages_document(&state, &conversation, &document).await?; + + Ok(Json(EditorAgentMessageResponse { + conversation: conversation_summary, + delta_messages, + error_message: None, + })) + } + } +} + +fn build_editor_agent_error_message( + message_id: usize, + error: impl std::fmt::Display, +) -> EditorAgentMessage { + EditorAgentMessage { + id: message_id, + client_message_id: None, + role: EditorAgentMessageRole::System, + text: format!("{EDITOR_AGENT_ERROR_MESSAGE_PREFIX}{error}"), + attachments: Vec::new(), + tool_call: None, + created_at: now_rfc3339(), + } +} + +async fn persist_editor_agent_planning_error( + state: &AppState, + conversation: &EditorAgentConversationRecord, + document: &mut EditorAgentConversationMessagesDocument, + conversation_summary: EditorAgentConversationSummary, + error: impl std::fmt::Display, +) -> Result, AppError> { + let error_message = build_editor_agent_error_message(document.messages.len(), error); + document.messages.push(error_message.clone()); + write_messages_document(state, conversation, document).await?; + + Ok(Json(EditorAgentMessageResponse { + conversation: conversation_summary, + delta_messages: vec![error_message], + error_message: None, + })) +} + +fn validate_editor_agent_message_request( + payload: &EditorAgentMessageRequest, +) -> Result { + let client_message_id = normalize_required_string(payload.client_message_id.as_str()) + .ok_or_else(|| editor_agent_bad_request("clientMessageId is required"))?; + if client_message_id.chars().count() > EDITOR_AGENT_CLIENT_MESSAGE_ID_MAX_CHARS { + return Err(editor_agent_bad_request(format!( + "clientMessageId must not exceed {EDITOR_AGENT_CLIENT_MESSAGE_ID_MAX_CHARS} characters" + ))); + } + let attachment_reference_ids = payload + .attachments + .iter() + .map(|attachment| attachment.reference_id.clone()) + .collect::>(); + validate_user_message(payload.text.as_str(), attachment_reference_ids.as_slice()) + .map_err(|error| editor_agent_bad_request(error.to_string()))?; + Ok(client_message_id) +} + +fn find_idempotent_editor_agent_user_message( + document: &EditorAgentConversationMessagesDocument, + client_message_id: &str, + text: &str, + attachments: &[shared_contracts::editor_agent::EditorAgentAttachmentRef], +) -> Result, AppError> { + let Some((index, message)) = document + .messages + .iter() + .enumerate() + .find(|(_, message)| message.client_message_id.as_deref() == Some(client_message_id)) + else { + return Ok(None); + }; + + if message.role != EditorAgentMessageRole::User + || message.text != text + || !editor_agent_attachment_requests_match(&message.attachments, attachments) + { + return Err( + AppError::from_status(axum::http::StatusCode::CONFLICT).with_details(json!({ + "provider": "editor-agent", + "field": "clientMessageId", + "message": "clientMessageId already exists with different message content", + })), + ); + } + + Ok(Some(index)) +} + +fn editor_agent_attachment_requests_match( + stored: &[shared_contracts::editor_agent::EditorAgentAttachmentRef], + submitted: &[shared_contracts::editor_agent::EditorAgentAttachmentRef], +) -> bool { + stored.len() == submitted.len() + && stored.iter().zip(submitted).all(|(left, right)| { + left.source == right.source && left.reference_id == right.reference_id + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use shared_contracts::editor_agent::{EditorAgentAttachmentRef, EditorAgentAttachmentSource}; + + fn attachment(reference_id: impl Into) -> EditorAgentAttachmentRef { + EditorAgentAttachmentRef { + source: EditorAgentAttachmentSource::CanvasResource, + reference_id: reference_id.into(), + object_key: None, + image_src: "/generated/test.png".to_string(), + thumbnail_src: None, + label: None, + width: None, + height: None, + } + } + + #[test] + fn validates_editor_agent_message_before_normalizing_attachments() { + let empty_payload = EditorAgentMessageRequest { + client_message_id: "client-message-empty".to_string(), + text: " ".to_string(), + attachments: Vec::new(), + }; + assert!(validate_editor_agent_message_request(&empty_payload).is_err()); + + let too_many_payload = EditorAgentMessageRequest { + client_message_id: "client-message-many".to_string(), + text: "生成一张图".to_string(), + attachments: (0..10) + .map(|index| attachment(format!("res-{index}"))) + .collect(), + }; + assert!(validate_editor_agent_message_request(&too_many_payload).is_err()); + + let attachment_only_payload = EditorAgentMessageRequest { + client_message_id: "client-message-attachment".to_string(), + text: String::new(), + attachments: vec![attachment("res-1")], + }; + assert!(validate_editor_agent_message_request(&attachment_only_payload).is_ok()); + + let missing_client_message_id = EditorAgentMessageRequest { + client_message_id: " ".to_string(), + text: "生成一张图".to_string(), + attachments: Vec::new(), + }; + assert!(validate_editor_agent_message_request(&missing_client_message_id).is_err()); + + let oversized_client_message_id = EditorAgentMessageRequest { + client_message_id: "x".repeat(EDITOR_AGENT_CLIENT_MESSAGE_ID_MAX_CHARS + 1), + text: "生成一张图".to_string(), + attachments: Vec::new(), + }; + assert!(validate_editor_agent_message_request(&oversized_client_message_id).is_err()); + } + + #[test] + fn detects_idempotent_message_replays_and_content_conflicts() { + let stored_attachment = attachment("res-1"); + let document = EditorAgentConversationMessagesDocument { + version: 2, + conversation_id: "conversation-1".to_string(), + messages: vec![EditorAgentMessage { + id: 0, + client_message_id: Some("client-message-1".to_string()), + role: EditorAgentMessageRole::User, + text: "生成一张图".to_string(), + attachments: vec![stored_attachment.clone()], + tool_call: None, + created_at: "2026-07-16T00:00:00Z".to_string(), + }], + }; + + assert_eq!( + find_idempotent_editor_agent_user_message( + &document, + "client-message-1", + "生成一张图", + &[stored_attachment.clone()], + ) + .expect("same request should be an idempotent replay"), + Some(0), + ); + assert!( + find_idempotent_editor_agent_user_message( + &document, + "client-message-1", + "生成另一张图", + &[stored_attachment], + ) + .is_err() + ); + assert_eq!( + find_idempotent_editor_agent_user_message( + &document, + "client-message-2", + "生成一张图", + &[], + ) + .expect("new request should not match"), + None, + ); + } + + #[test] + fn builds_system_error_message_with_wire_prefix() { + let message = build_editor_agent_error_message(3, "planning failed"); + + assert_eq!(message.id, 3); + assert_eq!(message.role, EditorAgentMessageRole::System); + assert_eq!(message.text, "ERROR planning failed"); + assert!(message.tool_call.is_none()); + } +} +fn editor_agent_system_prompt() -> &'static str { + r#" +你是 Genarrative 图片画布 Agent,只负责帮助用户理解、规划和触发画布生成工具。 +对话回复要简短,不能承诺免费生成;实际生成工具由后端按模型定价扣泥点。 +"# +} + +fn build_delta_messages( + result: Result, PromptError>, + created_at: &str, + messages_offset: usize, + document: &EditorAgentConversationMessagesDocument, + tool_context: &EditorToolContext, + pricing: &EditorGenerationPricingConfig, +) -> Result, PromptError> { + let outputs = result?; + let mut messages = Vec::with_capacity(outputs.len()); + + for (i, out) in outputs.into_iter().enumerate() { + let absolute_idx = messages_offset + i; + match out { + PromptOutput::Text(text) => { + messages.push(EditorAgentMessage { + id: absolute_idx, + client_message_id: None, + role: EditorAgentMessageRole::Assistant, + text, + attachments: Vec::new(), + tool_call: None, + created_at: created_at.to_string(), + }); + } + PromptOutput::Tool(tco) => { + let display_args = display_args::build_tool_call_display_args( + tco.tool_call.name.as_str(), + &tco.tool_call.args, + document, + tool_context, + pricing, + )?; + messages.push(EditorAgentMessage { + id: absolute_idx, + client_message_id: None, + role: EditorAgentMessageRole::System, + text: tco.message, + attachments: Vec::new(), + tool_call: Some(EditorAgentToolCall { + tool_name: tco.tool_call.name, + status: EditorAgentToolCallStatus::NotCompleted, + args: tco.tool_call.args, + display_args, + external_job_id: None, + images: Vec::new(), + videos: Vec::new(), + audios: Vec::new(), + error: None, + }), + created_at: created_at.to_string(), + }); + } + } + } + + Ok(messages) +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct EditorAgentConversationDeleteResponse { + deleted_conversation_id: String, + conversation: EditorAgentConversationSummary, +} + +pub async fn list_editor_agent_conversations( + State(state): State, + Path(project_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, AppError> { + let owner_user_id = authenticated.claims().user_id().to_string(); + require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?; + ensure_editor_project_access(&state, project_id.as_str(), owner_user_id.as_str()).await?; + let conversations = state + .spacetime_client() + .list_editor_agent_conversations(project_id, owner_user_id) + .await + .map_err(map_editor_project_error)? + .into_iter() + .map(conversation_summary_from_record) + .collect(); + + Ok(json_success_body( + Some(&request_context), + EditorAgentConversationListResponse { conversations }, + )) +} + +pub async fn create_editor_agent_conversation( + State(state): State, + Path(project_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, AppError> { + let owner_user_id = authenticated.claims().user_id().to_string(); + require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?; + ensure_editor_project_access(&state, project_id.as_str(), owner_user_id.as_str()).await?; + + let conversation_id = build_prefixed_uuid_id(EDITOR_AGENT_CONVERSATION_ID_PREFIX); + let messages_object_key = editor_agent_messages_object_key(conversation_id.as_str()); + let title = normalize_optional_string(payload.title) + .unwrap_or_else(|| EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE.to_string()); + let now_micros = current_utc_micros(); + let seed_record = EditorAgentConversationRecord { + conversation_id: conversation_id.clone(), + project_id: project_id.clone(), + owner_user_id: owner_user_id.clone(), + title: title.clone(), + messages_object_key: messages_object_key.clone(), + deleted: false, + created_at: now_rfc3339(), + updated_at: now_rfc3339(), + updated_at_micros: now_micros, + }; + write_messages_document( + &state, + &seed_record, + &empty_messages_document(conversation_id.as_str()), + ) + .await?; + + let conversation = state + .spacetime_client() + .create_editor_agent_conversation(EditorAgentConversationCreateRecordInput { + conversation_id, + project_id, + owner_user_id, + title, + messages_object_key, + created_at_micros: now_micros, + }) + .await + .map_err(map_editor_project_error)?; + let document = read_messages_document(&state, &conversation).await?; + + Ok(json_success_body( + Some(&request_context), + EditorAgentConversationResponse { + conversation: conversation_detail_from_record(conversation, document.messages), + }, + )) +} + +pub async fn get_editor_agent_conversation( + State(state): State, + Path(conversation_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, AppError> { + let owner_user_id = authenticated.claims().user_id().to_string(); + require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?; + let conversation = state + .spacetime_client() + .get_editor_agent_conversation(conversation_id, owner_user_id) + .await + .map_err(map_editor_project_error)?; + let conversation_lock = crate::editor_agent::utils::editor_agent_conversation_lock( + conversation.conversation_id.as_str(), + ); + let _conversation_lock_guard = conversation_lock.lock_owned().await; + let mut document = read_messages_document(&state, &conversation).await?; + let reconciled_messages = + reconcile::reconcile_editor_agent_tool_calls(&state, &conversation, &mut document).await?; + if !reconciled_messages.is_empty() { + write_messages_document(&state, &conversation, &document).await?; + } + + Ok(json_success_body( + Some(&request_context), + EditorAgentConversationResponse { + conversation: conversation_detail_from_record(conversation, document.messages), + }, + )) +} + +pub async fn delete_editor_agent_conversation( + State(state): State, + Path(conversation_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, AppError> { + let owner_user_id = authenticated.claims().user_id().to_string(); + require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?; + let conversation = state + .spacetime_client() + .delete_editor_agent_conversation(EditorAgentConversationDeleteRecordInput { + conversation_id, + owner_user_id, + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(map_editor_project_error)?; + + Ok(json_success_body( + Some(&request_context), + EditorAgentConversationDeleteResponse { + deleted_conversation_id: conversation.conversation_id.clone(), + conversation: conversation_summary_from_record(conversation), + }, + )) +} + +pub async fn cancel_editor_agent_tool_call( + State(state): State, + Path((conversation_id, message_id)): Path<(String, usize)>, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, AppError> { + let owner_user_id = authenticated.claims().user_id().to_string(); + require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?; + + let conversation = state + .spacetime_client() + .get_editor_agent_conversation(conversation_id, owner_user_id) + .await + .map_err(|e| { + AppError::from_status(axum::http::StatusCode::NOT_FOUND) + .with_details(json!({ "message": format!("conversation not found: {e}") })) + })?; + + let conversation_lock = crate::editor_agent::utils::editor_agent_conversation_lock( + conversation.conversation_id.as_str(), + ); + let _conversation_lock_guard = conversation_lock.lock_owned().await; + + let mut document: EditorAgentConversationMessagesDocument = + read_messages_document(&state, &conversation).await?; + + // Validate message index + if message_id >= document.messages.len() { + return Err(AppError::from_status(axum::http::StatusCode::NOT_FOUND) + .with_details(json!({ "message": "message not found" }))); + } + + let msg = &mut document.messages[message_id]; + + // Validate role and tool_call + if msg.role != EditorAgentMessageRole::System { + return Err(editor_agent_bad_request("message is not a system message")); + } + let tc = msg + .tool_call + .as_mut() + .ok_or_else(|| editor_agent_bad_request("message has no tool call"))?; + if tc.status != EditorAgentToolCallStatus::NotCompleted || tc.external_job_id.is_some() { + return Err(editor_agent_bad_request( + "tool call is no longer pending confirmation", + )); + } + + tc.status = EditorAgentToolCallStatus::Cancelled; + let arg_json = tc.args.to_string(); + msg.text = format!( + "[tool_call:{tool_name}] args: {arg_json} output: 用户已取消该操作", + tool_name = tc.tool_name, + arg_json = arg_json, + ); + + write_messages_document(&state, &conversation, &document).await?; + + Ok(json_success_body( + Some(&request_context), + json!({ "ok": true }), + )) +} + +pub async fn confirm_editor_agent_tool_call( + State(state): State, + Path((conversation_id, message_id)): Path<(String, usize)>, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, AppError> { + let owner_user_id = authenticated.claims().user_id().to_string(); + require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?; + let conversation = state + .spacetime_client() + .get_editor_agent_conversation(conversation_id, owner_user_id) + .await + .map_err(|error| { + AppError::from_status(axum::http::StatusCode::NOT_FOUND).with_details(json!({ + "message": format!("conversation not found: {error}"), + })) + })?; + let conversation_lock = crate::editor_agent::utils::editor_agent_conversation_lock( + conversation.conversation_id.as_str(), + ); + let _conversation_lock_guard = conversation_lock.lock_owned().await; + let mut document = read_messages_document(&state, &conversation).await?; + let message = document + .messages + .get(message_id) + .ok_or_else(|| AppError::from_status(axum::http::StatusCode::NOT_FOUND))?; + if message.role != EditorAgentMessageRole::System { + return Err(editor_agent_bad_request("message is not a system message")); + } + let tool_call = message + .tool_call + .as_ref() + .ok_or_else(|| editor_agent_bad_request("message has no tool call"))?; + if tool_call.status == EditorAgentToolCallStatus::Cancelled { + return Err(editor_agent_bad_request("tool call was cancelled")); + } + if tool_call.status != EditorAgentToolCallStatus::NotCompleted + || tool_call.external_job_id.is_some() + { + return Ok(json_success_body( + Some(&request_context), + json!({ "ok": true }), + )); + } + + let tool_name = tool_call.tool_name.clone(); + let tool_args = tool_call.args.clone(); + let pricing = state.editor_generation_pricing().await.map_err(|error| { + AppError::from_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-pricing", + "message": error.to_string(), + })) + })?; + let project = load_editor_agent_project(&state, &conversation).await?; + let context = context::build_tool_context(&document); + let generation_inputs = |title: &str, value: &str| { + Some(json!({ + // TODO extract const + "source": "editor-agent", + "conversationId": conversation.conversation_id, + "toolCallMessageId": message_id, + "fields": [{ "title": title, "value": value }], + })) + }; + + let (job_kind, request_label, price_mud_points, payload) = match tool_name.as_str() { + GenerateImageTool::NAME | GenerateCharacterTool::NAME | GenerateUiDesignTool::NAME => { + let args: GenerateImageToolArgs = parse_confirm_tool_args(&tool_args)?; + let tool = GenerateImageTool { + context: context.clone(), + }; + tool.validate_args(&args).map_err(|error| { + editor_agent_bad_request(format!("invalid tool call args: {error}")) + })?; + let kind = match tool_name.as_str() { + GenerateCharacterTool::NAME => Some("character"), + GenerateUiDesignTool::NAME => Some("ui-design"), + _ => None, + }; + let price = match kind { + Some("character") => GenerateCharacterTool { + context: context.clone(), + } + .pricing(&pricing, &args), + Some("ui-design") => GenerateUiDesignTool { + context: context.clone(), + } + .pricing(&pricing, &args), + _ => tool.pricing(&pricing, &args), + }; + let title = args.prompt.clone(); + let reference_image_srcs = args + .reference_image_ids + .iter() + .map(|image_id| { + context + .image_data_key(image_id) + .map(ToOwned::to_owned) + .ok_or_else(|| { + editor_agent_bad_request(format!("image {image_id} not found")) + }) + }) + .collect::, _>>()?; + let payload = EditorImageGenerationRequest { + prompt: args.prompt, + size: None, + kind: kind.map(ToOwned::to_owned), + model: None, + screen_color: (kind == Some("character")).then(|| "auto".to_string()), + seg_model: (kind == Some("character")).then(|| "birefnet".to_string()), + aspect_ratio: args.aspect_ratio, + image_size: args.image_size, + reference_image_srcs: Some(reference_image_srcs), + project_id: Some(conversation.project_id.clone()), + asset_kind: Some(kind.unwrap_or("editor_agent_generated_image").to_string()), + generation_inputs: generation_inputs("用户指令", &title), + asset_folder_id: Some("project".to_string()), + asset_label: Some(title.clone()), + source_resource_id: None, + canvas_completion: Some(build_editor_agent_canvas_completion( + &project, &tool_name, &title, + )), + }; + ( + EDITOR_IMAGE_GENERATION_JOB_KIND, + "画布 Agent 生成图片", + price, + serde_json::to_value(payload), + ) + } + EditImageTool::NAME => { + let args: EditImageToolArgs = parse_confirm_tool_args(&tool_args)?; + if args.prompt.trim().is_empty() { + return Err(editor_agent_bad_request("prompt not provided")); + } + let source_image_src = context + .image_data_key(&args.object_image_id) + .ok_or_else(|| editor_agent_bad_request("object image not found"))? + .to_string(); + let reference_image_srcs = args + .reference_image_ids + .iter() + .map(|image_id| { + context + .image_data_key(image_id) + .map(ToOwned::to_owned) + .ok_or_else(|| { + editor_agent_bad_request(format!("image {image_id} not found")) + }) + }) + .collect::, _>>()?; + let title = args.prompt.clone(); + let price = EditImageTool { + context: context.clone(), + } + .pricing(&pricing, &args); + let payload = EditorImageEditRequest { + prompt: args.prompt, + source_image_src, + size: None, + model: None, + aspect_ratio: None, + image_size: None, + reference_image_srcs: Some(reference_image_srcs), + project_id: Some(conversation.project_id.clone()), + asset_kind: Some("editor_agent_edit_image".to_string()), + generation_inputs: generation_inputs("用户指令", &title), + asset_folder_id: Some("project".to_string()), + asset_label: Some(title.clone()), + source_resource_id: None, + target_layer_id: None, + canvas_completion: Some(build_editor_agent_canvas_completion( + &project, + EditImageTool::NAME, + &title, + )), + }; + ( + EDITOR_IMAGE_EDIT_JOB_KIND, + "画布 Agent 修改图片", + price, + serde_json::to_value(payload), + ) + } + GenerateIconSpritesheetTool::NAME => { + let args: GenerateIconSpritesheetToolArgs = parse_confirm_tool_args(&tool_args)?; + let tool = GenerateIconSpritesheetTool { + context: context.clone(), + }; + let price = tool.pricing(&pricing, &args); + let reference_image_src = context + .image_data_key(&args.reference_image_id) + .ok_or_else(|| editor_agent_bad_request("reference image not found"))? + .to_string(); + let reference_image_srcs = args + .reference_image_ids + .iter() + .map(|image_id| { + context + .image_data_key(image_id) + .map(ToOwned::to_owned) + .ok_or_else(|| { + editor_agent_bad_request(format!("image {image_id} not found")) + }) + }) + .collect::, _>>()?; + let payload = EditorIconSpritesheetGenerationRequest { + reference_image_src, + reference_image_srcs: Some(reference_image_srcs), + icon_descriptions: args.icon_descriptions.clone(), + model: None, + screen_color: Some("auto".to_string()), + seg_model: Some("birefnet".to_string()), + aspect_ratio: args.aspect_ratio, + image_size: args.image_size, + project_id: Some(conversation.project_id.clone()), + generation_inputs: Some( + json!({"source":"editor-agent","conversationId":conversation.conversation_id,"toolCallMessageId":message_id}), + ), + asset_folder_id: Some("project".to_string()), + asset_label: Some("图标素材图集".to_string()), + canvas_completion: Some(build_editor_agent_canvas_completion( + &project, + GenerateIconSpritesheetTool::NAME, + "图标素材图集", + )), + }; + ( + EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, + "画布 Agent 生成图标素材", + price, + serde_json::to_value(payload), + ) + } + GenerateVideoTool::NAME => { + let args: GenerateVideoToolArgs = parse_confirm_tool_args(&tool_args)?; + let reference_image_srcs = args + .reference_image_ids + .iter() + .map(|image_id| { + context + .image_data_key(image_id) + .map(ToOwned::to_owned) + .ok_or_else(|| { + editor_agent_bad_request(format!("image {image_id} not found")) + }) + }) + .collect::, _>>()?; + let title = args.prompt.clone(); + let price = GenerateVideoTool { + context: context.clone(), + } + .pricing(&pricing, &args); + let payload = EditorVideoGenerateRequest { + prompt: args.prompt, + model: args.model.unwrap_or_else(|| "seedance2.0-fast".to_string()), + aspect_ratio: args.aspect_ratio.unwrap_or_else(|| "16:9".to_string()), + duration_seconds: args.duration_seconds.unwrap_or(4), + resolution: args.resolution.unwrap_or_else(|| "720p".to_string()), + mode: "std".to_string(), + sound: args.sound.unwrap_or_else(|| "off".to_string()), + web_search_enabled: false, + reference_image_srcs, + reference_video_srcs: Vec::new(), + reference_audio_srcs: Vec::new(), + project_id: Some(conversation.project_id.clone()), + canvas_completion: Some(build_editor_agent_canvas_completion( + &project, + GenerateVideoTool::NAME, + &title, + )), + generation_inputs: generation_inputs("用户指令", &title), + source_resource_id: None, + asset_kind: Some("video".to_string()), + asset_folder_id: Some("project".to_string()), + asset_label: Some(title), + }; + ( + EDITOR_VIDEO_GENERATION_JOB_KIND, + "画布 Agent 生成视频", + price, + serde_json::to_value(payload), + ) + } + GenerateSoundEffectTool::NAME => { + let args: GenerateSoundEffectToolArgs = parse_confirm_tool_args(&tool_args)?; + let price = GenerateSoundEffectTool.pricing(&pricing, &args); + let title = args.prompt.clone(); + let payload = EditorSoundEffectGenerateRequest { + prompt: args.prompt, + model: None, + duration: args.duration.unwrap_or(3), + project_id: Some(conversation.project_id.clone()), + canvas_completion: Some(build_editor_agent_canvas_completion( + &project, + GenerateSoundEffectTool::NAME, + &title, + )), + generation_inputs: generation_inputs("用户指令", &title), + asset_folder_id: Some("project".to_string()), + asset_label: Some(title), + }; + ( + EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND, + "画布 Agent 生成音效", + price, + serde_json::to_value(payload), + ) + } + GenerateBackgroundMusicTool::NAME => { + let args: GenerateBackgroundMusicToolArgs = parse_confirm_tool_args(&tool_args)?; + let price = GenerateBackgroundMusicTool.pricing(&pricing, &args); + let title = args.prompt.clone(); + let payload = EditorBackgroundMusicGenerateRequest { + gpt_description_prompt: args.prompt, + make_instrumental: args.make_instrumental, + project_id: Some(conversation.project_id.clone()), + canvas_completion: Some(build_editor_agent_canvas_completion( + &project, + GenerateBackgroundMusicTool::NAME, + "生成背景音乐", + )), + generation_inputs: generation_inputs("用户指令", &title), + asset_folder_id: Some("project".to_string()), + asset_label: Some("生成背景音乐".to_string()), + }; + ( + EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, + "画布 Agent 生成背景音乐", + price, + serde_json::to_value(payload), + ) + } + _ => { + return Err(editor_agent_bad_request(format!( + "unsupported tool: {tool_name}" + ))); + } + }; + let payload = payload.map_err(|error| { + AppError::from_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + .with_details(json!({"message": format!("failed to serialize tool payload: {error}")})) + })?; + let (job_id, dedupe_key) = editor_agent_tool_job_identity( + conversation.conversation_id.as_str(), + message_id, + tool_name.as_str(), + ); + let job = enqueue_editor_generation_job_with_identity( + &state, + conversation.owner_user_id.as_str(), + job_kind, + conversation.project_id.clone(), + request_label, + u64::from(price_mud_points), + &payload, + job_id, + dedupe_key, + ) + .await?; + let message = &mut document.messages[message_id]; + let tool_call = message + .tool_call + .as_mut() + .ok_or_else(|| editor_agent_bad_request("message has no tool call"))?; + tool_call.external_job_id = Some(job.job_id); + tool_call.status = EditorAgentToolCallStatus::NotCompleted; + write_messages_document(&state, &conversation, &document).await?; + Ok(json_success_body( + Some(&request_context), + json!({ "ok": true }), + )) +} + +fn parse_confirm_tool_args(value: &Value) -> Result { + serde_json::from_value(value.clone()) + .map_err(|error| editor_agent_bad_request(format!("invalid tool call args: {error}"))) +} + +fn editor_agent_tool_job_identity( + conversation_id: &str, + message_id: usize, + tool_name: &str, +) -> (String, String) { + let dedupe_key = format!("editor-agent:{conversation_id}:{message_id}:{tool_name}"); + let digest = Sha256::digest(dedupe_key.as_bytes()); + (format!("task-editor-agent-{digest:x}"), dedupe_key) +} + +async fn load_editor_agent_project( + state: &AppState, + conversation: &EditorAgentConversationRecord, +) -> Result { + state + .spacetime_client() + .get_editor_project(EditorProjectGetRecordInput { + project_id: conversation.project_id.clone(), + owner_user_id: conversation.owner_user_id.clone(), + }) + .await + .map_err(|error| { + AppError::from_status(axum::http::StatusCode::NOT_FOUND) + .with_details(json!({ "message": format!("project not found: {error}") })) + }) +} diff --git a/server-rs/crates/api-server/src/editor_agent/context.rs b/server-rs/crates/api-server/src/editor_agent/context.rs new file mode 100644 index 000000000..d5a1f1e6b --- /dev/null +++ b/server-rs/crates/api-server/src/editor_agent/context.rs @@ -0,0 +1,37 @@ +use crate::editor_agent::utils::IntoDataKey; +use platform_editor_agent::agent::asset::{ImageId, ImageMetadata}; +use platform_editor_agent::agent::tools::context::EditorToolContext; +use shared_contracts::editor_agent::EditorAgentConversationMessagesDocument; +use std::collections::HashMap; + +pub fn build_tool_context(document: &EditorAgentConversationMessagesDocument) -> EditorToolContext { + let mut images: HashMap = HashMap::new(); + + for msg in document.messages.iter().rev() { + // tool gen + if let Some(tc) = &msg.tool_call { + for img in &tc.images { + let data_key = img.clone().into_data_key(); + let image_id = ImageId::from_data_key(&data_key); + let metadata = ImageMetadata { + tag: String::new(), + data_key, + }; + images.insert(image_id, metadata); + } + } + + // user pointed + for a in &msg.attachments { + let data_key = a.clone().into_data_key(); + let image_id = ImageId::from_data_key(&data_key); + let metadata = ImageMetadata { + tag: String::new(), + data_key, + }; + images.insert(image_id, metadata); + } + } + + EditorToolContext { images } +} diff --git a/server-rs/crates/api-server/src/editor_agent/display_args.rs b/server-rs/crates/api-server/src/editor_agent/display_args.rs new file mode 100644 index 000000000..df0ca205d --- /dev/null +++ b/server-rs/crates/api-server/src/editor_agent/display_args.rs @@ -0,0 +1,329 @@ +use crate::editor_agent::pricing::EditorAgentPricedTool; +use crate::editor_agent::utils::{IntoDataKey, IntoImageId}; +use crate::editor_generation_config::EditorGenerationPricingConfig; +use platform_editor_agent::agent::asset::ImageId; +use platform_editor_agent::agent::tools::context::EditorToolContext; +use platform_editor_agent::agent::tools::edit_image::{EditImageTool, EditImageToolArgs}; +use platform_editor_agent::agent::tools::generate_background_music::{ + GenerateBackgroundMusicTool, GenerateBackgroundMusicToolArgs, +}; +use platform_editor_agent::agent::tools::generate_character::GenerateCharacterTool; +use platform_editor_agent::agent::tools::generate_icon_spritesheet::{ + GenerateIconSpritesheetTool, GenerateIconSpritesheetToolArgs, +}; +use platform_editor_agent::agent::tools::generate_image::{ + GenerateImageTool, GenerateImageToolArgs, +}; +use platform_editor_agent::agent::tools::generate_sound_effect::{ + GenerateSoundEffectTool, GenerateSoundEffectToolArgs, +}; +use platform_editor_agent::agent::tools::generate_ui_design::GenerateUiDesignTool; +use platform_editor_agent::agent::tools::generate_video::{ + GenerateVideoTool, GenerateVideoToolArgs, +}; +use platform_editor_agent::framework::error::PromptError; +use platform_editor_agent::framework::tool::Tool; +use serde_json::Value; +use shared_contracts::editor_agent::{ + EditorAgentConversationMessagesDocument, EditorAgentToolCallDisplayArgs, + EditorAgentToolCallDisplayExtras, EditorAgentToolCallImageArg, EditorAgentToolCallImageRef, + EditorAgentToolCallStringArg, +}; + +pub fn build_tool_call_display_args( + tool_name: &str, + args: &Value, + document: &EditorAgentConversationMessagesDocument, + tool_context: &EditorToolContext, + pricing: &EditorGenerationPricingConfig, +) -> Result { + let mut display_args = EditorAgentToolCallDisplayArgs::default(); + + let price_mud_points = match tool_name { + EditImageTool::NAME => { + let args: EditImageToolArgs = parse_display_tool_args(tool_name, args)?; + let price_mud_points = EditImageTool { + context: tool_context.clone(), + } + .pricing(pricing, &args); + push_string_display_arg(&mut display_args, "prompt", "修改要求", args.prompt); + push_image_display_arg( + &mut display_args, + document, + "object_image_id", + "目标图片", + std::slice::from_ref(&args.object_image_id), + )?; + push_image_display_arg( + &mut display_args, + document, + "reference_image_ids", + "参考图片", + args.reference_image_ids.as_slice(), + )?; + price_mud_points + } + GenerateImageTool::NAME | GenerateCharacterTool::NAME | GenerateUiDesignTool::NAME => { + let args: GenerateImageToolArgs = parse_display_tool_args(tool_name, args)?; + let price_mud_points = match tool_name { + GenerateImageTool::NAME => GenerateImageTool { + context: tool_context.clone(), + } + .pricing(pricing, &args), + GenerateCharacterTool::NAME => GenerateCharacterTool { + context: tool_context.clone(), + } + .pricing(pricing, &args), + GenerateUiDesignTool::NAME => GenerateUiDesignTool { + context: tool_context.clone(), + } + .pricing(pricing, &args), + _ => unreachable!("tool name was matched above"), + }; + push_string_display_arg(&mut display_args, "prompt", "提示词", args.prompt); + push_optional_string_display_arg( + &mut display_args, + "aspect_ratio", + "画面比例", + args.aspect_ratio, + ); + push_optional_string_display_arg( + &mut display_args, + "image_size", + "图片尺寸", + args.image_size, + ); + push_image_display_arg( + &mut display_args, + document, + "reference_image_ids", + "参考图片", + args.reference_image_ids.as_slice(), + )?; + price_mud_points + } + GenerateIconSpritesheetTool::NAME => { + let args: GenerateIconSpritesheetToolArgs = parse_display_tool_args(tool_name, args)?; + let price_mud_points = GenerateIconSpritesheetTool { + context: tool_context.clone(), + } + .pricing(pricing, &args); + push_string_display_arg( + &mut display_args, + "icon_descriptions", + "图标描述", + args.icon_descriptions.join("\n"), + ); + push_optional_string_display_arg( + &mut display_args, + "aspect_ratio", + "图集比例", + args.aspect_ratio, + ); + push_optional_string_display_arg( + &mut display_args, + "image_size", + "图片尺寸", + args.image_size, + ); + push_image_display_arg( + &mut display_args, + document, + "reference_image_id", + "规范参考图", + std::slice::from_ref(&args.reference_image_id), + )?; + push_image_display_arg( + &mut display_args, + document, + "reference_image_ids", + "额外参考图", + args.reference_image_ids.as_slice(), + )?; + price_mud_points + } + GenerateVideoTool::NAME => { + let args: GenerateVideoToolArgs = parse_display_tool_args(tool_name, args)?; + let price_mud_points = GenerateVideoTool { + context: tool_context.clone(), + } + .pricing(pricing, &args); + push_string_display_arg(&mut display_args, "prompt", "视频描述", args.prompt); + push_optional_string_display_arg( + &mut display_args, + "aspect_ratio", + "画面比例", + args.aspect_ratio, + ); + if let Some(duration_seconds) = args.duration_seconds { + push_string_display_arg( + &mut display_args, + "duration_seconds", + "时长(秒)", + duration_seconds.to_string(), + ); + } + push_optional_string_display_arg(&mut display_args, "model", "模型", args.model); + push_optional_string_display_arg( + &mut display_args, + "resolution", + "分辨率", + args.resolution, + ); + push_optional_string_display_arg(&mut display_args, "sound", "声音", args.sound); + push_image_display_arg( + &mut display_args, + document, + "reference_image_ids", + "参考图片", + args.reference_image_ids.as_slice(), + )?; + price_mud_points + } + GenerateSoundEffectTool::NAME => { + let args: GenerateSoundEffectToolArgs = parse_display_tool_args(tool_name, args)?; + let price_mud_points = GenerateSoundEffectTool.pricing(pricing, &args); + push_string_display_arg(&mut display_args, "prompt", "音效描述", args.prompt); + if let Some(duration) = args.duration { + push_string_display_arg( + &mut display_args, + "duration", + "时长(秒)", + duration.to_string(), + ); + } + price_mud_points + } + GenerateBackgroundMusicTool::NAME => { + let args: GenerateBackgroundMusicToolArgs = parse_display_tool_args(tool_name, args)?; + let price_mud_points = GenerateBackgroundMusicTool.pricing(pricing, &args); + push_string_display_arg(&mut display_args, "prompt", "音乐描述", args.prompt); + push_string_display_arg( + &mut display_args, + "make_instrumental", + "纯音乐", + if args.make_instrumental { "是" } else { "否" }, + ); + price_mud_points + } + _ => { + return Err(PromptError::InternalError(format!( + "missing display argument mapping for tool {tool_name}" + ))); + } + }; + + display_args.extras = EditorAgentToolCallDisplayExtras { price_mud_points }; + + Ok(display_args) +} + +fn parse_display_tool_args(tool_name: &str, args: &Value) -> Result +where + T: serde::de::DeserializeOwned, +{ + serde_json::from_value(args.clone()).map_err(|error| { + PromptError::InternalError(format!( + "failed to build display arguments for {tool_name}: {error}" + )) + }) +} + +fn push_string_display_arg( + display_args: &mut EditorAgentToolCallDisplayArgs, + name: &str, + label: &str, + value: impl Into, +) { + let value = value.into(); + if value.trim().is_empty() { + return; + } + display_args.string_args.push(EditorAgentToolCallStringArg { + name: name.to_string(), + label: label.to_string(), + value, + }); +} + +fn push_optional_string_display_arg( + display_args: &mut EditorAgentToolCallDisplayArgs, + name: &str, + label: &str, + value: Option, +) { + if let Some(value) = value { + push_string_display_arg(display_args, name, label, value); + } +} + +fn push_image_display_arg( + display_args: &mut EditorAgentToolCallDisplayArgs, + document: &EditorAgentConversationMessagesDocument, + name: &str, + label: &str, + image_ids: &[ImageId], +) -> Result<(), PromptError> { + if image_ids.is_empty() { + return Ok(()); + } + + let refs = image_ids + .iter() + .map(|image_id| { + resolve_tool_call_image_ref(document, image_id).ok_or_else(|| { + PromptError::InternalError(format!( + "display image reference {} is missing from conversation context", + image_id.id + )) + }) + }) + .collect::, _>>()?; + + display_args.image_args.push(EditorAgentToolCallImageArg { + name: name.to_string(), + label: label.to_string(), + refs, + }); + Ok(()) +} + +pub fn resolve_tool_call_image_ref( + document: &EditorAgentConversationMessagesDocument, + image_id: &ImageId, +) -> Option { + for message in document.messages.iter().rev() { + for attachment in message.attachments.iter().rev() { + if attachment.clone().into_image_id() == *image_id { + return Some(EditorAgentToolCallImageRef { + image_id: image_id.id.clone(), + image_src: attachment.image_src.clone(), + object_key: attachment.object_key.clone(), + thumbnail_src: attachment.thumbnail_src.clone(), + label: attachment.label.clone(), + width: attachment.width, + height: attachment.height, + }); + } + } + + if let Some(tool_call) = &message.tool_call { + for image in tool_call.images.iter().rev() { + let data_key = image.clone().into_data_key(); + if ImageId::from_data_key(data_key) == *image_id { + return Some(EditorAgentToolCallImageRef { + image_id: image_id.id.clone(), + image_src: image.image_src.clone(), + object_key: image.object_key.clone(), + thumbnail_src: image.thumbnail_src.clone(), + label: None, + width: image.width, + height: image.height, + }); + } + } + } + } + + None +} diff --git a/server-rs/crates/api-server/src/editor_agent/mod.rs b/server-rs/crates/api-server/src/editor_agent/mod.rs new file mode 100644 index 000000000..eaa31b0fe --- /dev/null +++ b/server-rs/crates/api-server/src/editor_agent/mod.rs @@ -0,0 +1,13 @@ +pub mod api; +mod context; +mod display_args; +pub mod pricing; +mod reconcile; +mod resp_to_asset; +mod utils; + +pub use api::{ + cancel_editor_agent_tool_call, confirm_editor_agent_tool_call, + create_editor_agent_conversation, delete_editor_agent_conversation, + get_editor_agent_conversation, list_editor_agent_conversations, +}; diff --git a/server-rs/crates/api-server/src/editor_agent/pricing.rs b/server-rs/crates/api-server/src/editor_agent/pricing.rs new file mode 100644 index 000000000..42bd43e47 --- /dev/null +++ b/server-rs/crates/api-server/src/editor_agent/pricing.rs @@ -0,0 +1,313 @@ +use crate::editor_generation_config::EditorGenerationPricingConfig; +#[cfg(test)] +use crate::editor_generation_config::load_editor_generation_pricing_from_paths; +#[cfg(test)] +use platform_editor_agent::agent::asset::ImageId; +#[cfg(test)] +use platform_editor_agent::agent::tools::context::EditorToolContext; +use platform_editor_agent::agent::tools::edit_image::{EditImageTool, EditImageToolArgs}; +use platform_editor_agent::agent::tools::generate_background_music::{ + GenerateBackgroundMusicTool, GenerateBackgroundMusicToolArgs, +}; +use platform_editor_agent::agent::tools::generate_character::GenerateCharacterTool; +use platform_editor_agent::agent::tools::generate_icon_spritesheet::{ + GenerateIconSpritesheetTool, GenerateIconSpritesheetToolArgs, +}; +use platform_editor_agent::agent::tools::generate_image::{ + GenerateImageTool, GenerateImageToolArgs, +}; +use platform_editor_agent::agent::tools::generate_sound_effect::{ + GenerateSoundEffectTool, GenerateSoundEffectToolArgs, +}; +use platform_editor_agent::agent::tools::generate_ui_design::GenerateUiDesignTool; +use platform_editor_agent::agent::tools::generate_video::{ + GenerateVideoTool, GenerateVideoToolArgs, +}; +use platform_editor_agent::framework::tool::Tool; +use platform_image::GPT_IMAGE_2_MODEL; +#[cfg(test)] +use std::collections::HashMap; + +#[cfg(test)] +fn context() -> EditorToolContext { + EditorToolContext { + images: HashMap::new(), + } +} + +#[cfg(test)] +fn image_args(image_size: Option<&str>) -> GenerateImageToolArgs { + GenerateImageToolArgs { + prompt: "生成图片".to_string(), + reference_image_ids: Vec::new(), + aspect_ratio: Some("1:1".to_string()), + image_size: image_size.map(ToOwned::to_owned), + } +} + +#[test] +fn every_editor_agent_tool_exposes_argument_based_pricing() { + let pricing = load_editor_generation_pricing_from_paths(None) + .expect("default editor pricing should load"); + let context = context(); + + assert_eq!( + EditImageTool { + context: context.clone(), + } + .pricing( + &pricing, + &EditImageToolArgs { + object_image_id: ImageId { + id: "image-1".to_string(), + }, + reference_image_ids: Vec::new(), + prompt: "改成蓝色".to_string(), + }, + ), + 3 + ); + assert_eq!( + GenerateImageTool { + context: context.clone(), + } + .pricing(&pricing, &image_args(Some("2K"))), + 5 + ); + assert_eq!( + GenerateCharacterTool { + context: context.clone(), + } + .pricing(&pricing, &image_args(None)), + 3 + ); + assert_eq!( + GenerateUiDesignTool { + context: context.clone(), + } + .pricing(&pricing, &image_args(Some("2K"))), + 5 + ); + assert_eq!( + GenerateIconSpritesheetTool { + context: context.clone(), + } + .pricing( + &pricing, + &GenerateIconSpritesheetToolArgs { + reference_image_id: ImageId { + id: "image-1".to_string(), + }, + reference_image_ids: Vec::new(), + icon_descriptions: vec!["背包".to_string(), "地图".to_string()], + aspect_ratio: Some("1:1".to_string()), + image_size: Some("2K".to_string()), + }, + ), + 5 + ); + assert_eq!( + GenerateVideoTool { + context: context.clone(), + } + .pricing( + &pricing, + &GenerateVideoToolArgs { + prompt: "镜头缓慢推进".to_string(), + reference_image_ids: Vec::new(), + aspect_ratio: None, + duration_seconds: Some(6), + model: Some("seedance2.0".to_string()), + resolution: Some("720p".to_string()), + sound: None, + }, + ), + 144 + ); + assert_eq!( + GenerateSoundEffectTool.pricing( + &pricing, + &GenerateSoundEffectToolArgs { + prompt: "按钮点击声".to_string(), + duration: None, + }, + ), + 5 + ); + assert_eq!( + GenerateBackgroundMusicTool.pricing( + &pricing, + &GenerateBackgroundMusicToolArgs { + prompt: "轻松背景音乐".to_string(), + make_instrumental: true, + }, + ), + 12 + ); +} + +#[test] +fn pricing_uses_the_supplied_runtime_snapshot() { + let mut pricing = load_editor_generation_pricing_from_paths(None) + .expect("default editor pricing should load"); + pricing + .models + .get_mut("gpt-image-2") + .expect("gpt image pricing should exist") + .prices + .insert("2K".to_string(), 37); + pricing + .models + .get_mut("seedance2.0-fast") + .expect("video pricing should exist") + .prices + .insert("720p".to_string(), 7); + pricing + .models + .get_mut("audio1.0") + .expect("sound pricing should exist") + .price = Some(19); + + assert_eq!( + GenerateImageTool { context: context() }.pricing(&pricing, &image_args(Some("2K"))), + 37 + ); + assert_eq!( + GenerateVideoTool { context: context() }.pricing( + &pricing, + &GenerateVideoToolArgs { + prompt: "four seconds".to_string(), + reference_image_ids: Vec::new(), + aspect_ratio: None, + duration_seconds: None, + model: None, + resolution: None, + sound: None, + }, + ), + 28 + ); + assert_eq!( + GenerateSoundEffectTool.pricing( + &pricing, + &GenerateSoundEffectToolArgs { + prompt: "sound".to_string(), + duration: None, + }, + ), + 19 + ); +} + +impl EditorAgentPricedTool for GenerateVideoTool { + fn pricing( + &self, + pricing: &EditorGenerationPricingConfig, + args: &GenerateVideoToolArgs, + ) -> u32 { + let model = args + .model + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(Self::DEFAULT_VIDEO_MODEL); + let resolution = args + .resolution + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(Self::DEFAULT_VIDEO_RESOLUTION); + let duration_seconds = args + .duration_seconds + .unwrap_or(Self::DEFAULT_VIDEO_DURATION_SECONDS); + pricing.video_model_mud_points(Some(model), resolution, duration_seconds) + } +} + +impl EditorAgentPricedTool for GenerateUiDesignTool { + fn pricing( + &self, + pricing: &EditorGenerationPricingConfig, + args: &GenerateImageToolArgs, + ) -> u32 { + editor_agent_image_mud_points(pricing, Some("ui-design"), args.image_size.as_deref()) + } +} + +impl EditorAgentPricedTool for GenerateSoundEffectTool { + fn pricing( + &self, + pricing: &EditorGenerationPricingConfig, + _args: &GenerateSoundEffectToolArgs, + ) -> u32 { + pricing.sound_effect_model_mud_points(None) + } +} + +impl EditorAgentPricedTool for GenerateImageTool { + fn pricing( + &self, + pricing: &EditorGenerationPricingConfig, + args: &GenerateImageToolArgs, + ) -> u32 { + editor_agent_image_mud_points(pricing, None, args.image_size.as_deref()) + } +} + +impl EditorAgentPricedTool for GenerateIconSpritesheetTool { + fn pricing( + &self, + pricing: &EditorGenerationPricingConfig, + args: &GenerateIconSpritesheetToolArgs, + ) -> u32 { + editor_agent_image_mud_points(pricing, Some("icon"), args.image_size.as_deref()) + } +} + +impl EditorAgentPricedTool for GenerateCharacterTool { + fn pricing( + &self, + pricing: &EditorGenerationPricingConfig, + args: &GenerateImageToolArgs, + ) -> u32 { + editor_agent_image_mud_points(pricing, Some("character"), args.image_size.as_deref()) + } +} + +/// api-server 侧的画布 Agent 工具计价扩展。 +/// +/// 通用 `Tool` 仍只负责参数校验;价格依赖 api-server 的运行时配置,不能下沉到 +/// `module-editor-agent`。实际执行和扣费仍由既有生成 BFF 负责。 +pub(crate) trait EditorAgentPricedTool: Tool { + fn pricing(&self, pricing: &EditorGenerationPricingConfig, args: &::Args) -> u32; +} + +pub(crate) fn editor_agent_image_mud_points( + pricing: &EditorGenerationPricingConfig, + kind: Option<&str>, + image_size: Option<&str>, +) -> u32 { + // 这些 Agent 工具当前向既有 BFF 传 model=None;BFF 会先归一为 gpt-image-2。 + // 尺寸同样只把精确的 2K 识别为 2K,其余值回落到 1K。 + let normalized_image_size = match image_size.map(str::trim) { + Some("2K") => "2K", + _ => "1K", + }; + pricing.image_generation_mud_points(kind, Some(GPT_IMAGE_2_MODEL), Some(normalized_image_size)) +} + +impl EditorAgentPricedTool for GenerateBackgroundMusicTool { + fn pricing( + &self, + pricing: &EditorGenerationPricingConfig, + _args: &GenerateBackgroundMusicToolArgs, + ) -> u32 { + pricing.background_music_model_mud_points(None) + } +} + +impl EditorAgentPricedTool for EditImageTool { + fn pricing(&self, pricing: &EditorGenerationPricingConfig, _args: &EditImageToolArgs) -> u32 { + editor_agent_image_mud_points(pricing, Some("quick-edit"), Some("1K")) + } +} diff --git a/server-rs/crates/api-server/src/editor_agent/reconcile.rs b/server-rs/crates/api-server/src/editor_agent/reconcile.rs new file mode 100644 index 000000000..20d21a02f --- /dev/null +++ b/server-rs/crates/api-server/src/editor_agent/reconcile.rs @@ -0,0 +1,369 @@ +use crate::editor_agent::resp_to_asset; +use crate::http_error::AppError; +use crate::state::AppState; +use platform_editor_agent::agent::tools::context::EditorToolContext; +use platform_editor_agent::agent::tools::edit_image::{ + EditImageTool, EditImageToolArgs, EditorImageEditResult, +}; +use platform_editor_agent::agent::tools::generate_background_music::{ + GenerateBackgroundMusicTool, GenerateBackgroundMusicToolArgs, +}; +use platform_editor_agent::agent::tools::generate_character::GenerateCharacterTool; +use platform_editor_agent::agent::tools::generate_icon_spritesheet::{ + EditorIconSpritesheetResult, GenerateIconSpritesheetTool, GenerateIconSpritesheetToolArgs, +}; +use platform_editor_agent::agent::tools::generate_image::{ + EditorImageGenerationResult, GenerateImageTool, GenerateImageToolArgs, +}; +use platform_editor_agent::agent::tools::generate_sound_effect::{ + GenerateSoundEffectTool, GenerateSoundEffectToolArgs, +}; +use platform_editor_agent::agent::tools::generate_ui_design::GenerateUiDesignTool; +use platform_editor_agent::agent::tools::generate_video::{ + GenerateVideoTool, GenerateVideoToolArgs, +}; +use platform_editor_agent::framework::tool::Tool; +use serde_json::Value; +use shared_contracts::assets::{EditorAudioGenerateResponse, EditorVideoGenerateResponse}; +use shared_contracts::editor_agent::{ + EditorAgentConversationMessagesDocument, EditorAgentMessage, EditorAgentToolCallStatus, +}; +use spacetime_client::{EditorAgentConversationRecord, ExternalGenerationJobGetRecordInput}; +use tokio::time::{Duration, sleep}; + +pub(crate) const EDITOR_AGENT_RESULT_RECONCILE_MAX_RETRIES: u8 = 3; +const EDITOR_AGENT_RESULT_RECONCILE_RETRY_DELAY: Duration = Duration::from_millis(100); + +#[derive(Debug, PartialEq)] +enum ResultReconcileFailure { + RetryLater(String), + Fatal(String), +} + +impl From for ResultReconcileFailure { + fn from(error: String) -> Self { + Self::Fatal(error) + } +} + +pub async fn reconcile_editor_agent_tool_calls( + state: &AppState, + conversation: &EditorAgentConversationRecord, + document: &mut EditorAgentConversationMessagesDocument, +) -> Result, AppError> { + let candidates = document + .messages + .iter() + .enumerate() + .filter_map(|(message_index, message)| { + let tool_call = message.tool_call.as_ref()?; + if tool_call.status != EditorAgentToolCallStatus::NotCompleted { + return None; + } + Some((message_index, tool_call.external_job_id.clone()?)) + }) + .collect::>(); + let mut reconciled = Vec::new(); + + for (message_index, job_id) in candidates { + let mut last_reconcile_error = None; + + for retry_count in 0..=EDITOR_AGENT_RESULT_RECONCILE_MAX_RETRIES { + let job = match state + .spacetime_client() + .get_external_generation_job_generated_artifacts( + ExternalGenerationJobGetRecordInput { + job_id: job_id.clone(), + owner_user_id: conversation.owner_user_id.clone(), + }, + ) + .await + { + Ok(job) => job, + Err(error) => { + last_reconcile_error = Some(ResultReconcileFailure::RetryLater(format!( + "读取生成任务结果失败:{error}" + ))); + if should_retry_result_reconcile(retry_count) { + sleep(EDITOR_AGENT_RESULT_RECONCILE_RETRY_DELAY).await; + } + continue; + } + }; + + match job.status.as_str() { + "completed" => { + let original_message = document.messages[message_index].clone(); + match reconcile_completed_editor_agent_tool_call( + &mut document.messages[message_index], + job.result_payload_json.as_deref(), + ) { + Ok(()) => { + last_reconcile_error = None; + reconciled.push(document.messages[message_index].clone()); + break; + } + Err(error) => { + document.messages[message_index] = original_message; + last_reconcile_error = Some(error); + } + } + } + "failed" => { + mark_job_failed( + &mut document.messages[message_index], + job.last_error_message + .unwrap_or_else(|| "生成失败".to_string()), + ); + last_reconcile_error = None; + reconciled.push(document.messages[message_index].clone()); + break; + } + _ => { + last_reconcile_error = None; + break; + } + } + + if should_retry_result_reconcile(retry_count) { + sleep(EDITOR_AGENT_RESULT_RECONCILE_RETRY_DELAY).await; + } + } + + if let Some(error) = last_reconcile_error + && apply_exhausted_result_reconcile_failure( + &mut document.messages[message_index], + error, + ) + { + reconciled.push(document.messages[message_index].clone()); + } + } + + Ok(reconciled) +} + +fn should_retry_result_reconcile(retry_count: u8) -> bool { + retry_count < EDITOR_AGENT_RESULT_RECONCILE_MAX_RETRIES +} + +fn mark_job_failed(message: &mut EditorAgentMessage, error: String) { + let tool_call = message + .tool_call + .as_mut() + .expect("reconcile candidate contains a tool call"); + tool_call.error = Some(error.clone()); + tool_call.status = EditorAgentToolCallStatus::Failed; + message.text = format!("[tool_call:{}] output: {error}", tool_call.tool_name); +} + +fn mark_fatal_result_reconcile_failed(message: &mut EditorAgentMessage, error: String) { + mark_job_failed( + message, + format!( + "任务终态结果回填重试 {EDITOR_AGENT_RESULT_RECONCILE_MAX_RETRIES} 次后仍失败:{error}" + ), + ); +} + +fn apply_exhausted_result_reconcile_failure( + message: &mut EditorAgentMessage, + failure: ResultReconcileFailure, +) -> bool { + match failure { + ResultReconcileFailure::RetryLater(_) => false, + ResultReconcileFailure::Fatal(error) => { + mark_fatal_result_reconcile_failed(message, error); + true + } + } +} + +fn reconcile_completed_editor_agent_tool_call( + message: &mut EditorAgentMessage, + result_payload_json: Option<&str>, +) -> Result<(), ResultReconcileFailure> { + let response = result_payload_json + .ok_or_else(|| ResultReconcileFailure::RetryLater("生成任务缺少结果数据".to_string())) + .and_then(|payload| { + serde_json::from_str::(payload) + .map_err(|error| ResultReconcileFailure::Fatal(error.to_string())) + })? + // TODO extract const + .get("editor-agent-tool-call-result") + .cloned() + .ok_or_else(|| { + ResultReconcileFailure::Fatal("生成任务缺少 Agent 工具调用结果".to_string()) + })?; + let tool_call = message + .tool_call + .as_mut() + .ok_or_else(|| ResultReconcileFailure::Fatal("消息缺少工具调用".to_string()))?; + let tool_name = tool_call.tool_name.clone(); + + match tool_name.as_str() { + GenerateImageTool::NAME | GenerateCharacterTool::NAME | GenerateUiDesignTool::NAME => { + let args: GenerateImageToolArgs = parse_reconciled_value(&tool_call.args)?; + let result: EditorImageGenerationResult = parse_reconciled_value(&response)?; + message.text = match tool_name.as_str() { + GenerateCharacterTool::NAME => GenerateCharacterTool { + context: EditorToolContext::default(), + } + .format_execute_message(&args, &result), + GenerateUiDesignTool::NAME => GenerateUiDesignTool { + context: EditorToolContext::default(), + } + .format_execute_message(&args, &result), + _ => GenerateImageTool { + context: EditorToolContext::default(), + } + .format_execute_message(&args, &result), + }; + tool_call.images = vec![resp_to_asset::editor_agent_generated_image(&result)]; + } + EditImageTool::NAME => { + let args: EditImageToolArgs = parse_reconciled_value(&tool_call.args)?; + let result: EditorImageEditResult = parse_reconciled_value(&response)?; + tool_call.images = vec![resp_to_asset::editor_agent_generated_edited_image(&result)]; + message.text = EditImageTool { + context: EditorToolContext::default(), + } + .format_execute_message(args, result); + } + GenerateIconSpritesheetTool::NAME => { + let args: GenerateIconSpritesheetToolArgs = parse_reconciled_value(&tool_call.args)?; + let result: EditorIconSpritesheetResult = parse_reconciled_value(&response)?; + message.text = GenerateIconSpritesheetTool { + context: EditorToolContext::default(), + } + .format_execute_message(&args, &result); + tool_call.images = resp_to_asset::editor_agent_generated_spritesheet_images(&result); + } + GenerateVideoTool::NAME => { + let args: GenerateVideoToolArgs = parse_reconciled_value(&tool_call.args)?; + let result: EditorVideoGenerateResponse = parse_reconciled_value(&response)?; + message.text = GenerateVideoTool { + context: EditorToolContext::default(), + } + .format_execute_message(&args, &result); + tool_call.videos = vec![resp_to_asset::editor_agent_generated_video(&result)]; + } + GenerateSoundEffectTool::NAME | GenerateBackgroundMusicTool::NAME => { + let result: EditorAudioGenerateResponse = parse_reconciled_value(&response)?; + message.text = if tool_name == GenerateSoundEffectTool::NAME { + let args: GenerateSoundEffectToolArgs = parse_reconciled_value(&tool_call.args)?; + GenerateSoundEffectTool.format_execute_message(&args, &result) + } else { + let args: GenerateBackgroundMusicToolArgs = + parse_reconciled_value(&tool_call.args)?; + GenerateBackgroundMusicTool.format_execute_message(&args, &result) + }; + tool_call.audios = vec![resp_to_asset::editor_agent_generated_audio(&result)]; + } + _ => { + return Err(ResultReconcileFailure::Fatal(format!( + "不支持回填工具 {tool_name}" + ))); + } + } + tool_call.error = None; + tool_call.status = EditorAgentToolCallStatus::Completed; + Ok(()) +} + +fn parse_reconciled_value(value: &Value) -> Result { + serde_json::from_value(value.clone()).map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn pending_tool_message() -> EditorAgentMessage { + serde_json::from_value(json!({ + "id": 1, + "role": "system", + "text": "waiting", + "attachments": [], + "toolCall": { + "toolName": "generate_image", + "status": "not_completed", + "args": {}, + "displayArgs": { + "stringArgs": [], + "imageArgs": [], + "extras": { "priceMudPoints": 0 } + }, + "externalJobId": "job-1", + "images": [] + }, + "createdAt": "2026-07-16T00:00:00Z" + })) + .expect("pending tool message should deserialize") + } + + #[test] + fn terminal_result_reconcile_retries_three_times_after_the_initial_attempt() { + assert!(should_retry_result_reconcile(0)); + assert!(should_retry_result_reconcile(1)); + assert!(should_retry_result_reconcile(2)); + assert!(!should_retry_result_reconcile(3)); + } + + #[test] + fn exhausted_fatal_result_reconcile_marks_the_tool_call_failed() { + let mut message = pending_tool_message(); + + let changed = apply_exhausted_result_reconcile_failure( + &mut message, + ResultReconcileFailure::Fatal("invalid result payload".to_string()), + ); + + assert!(changed); + let tool_call = message.tool_call.expect("tool call should remain present"); + assert_eq!(tool_call.status, EditorAgentToolCallStatus::Failed); + assert_eq!( + tool_call.error.as_deref(), + Some("任务终态结果回填重试 3 次后仍失败:invalid result payload") + ); + assert!(message.text.contains("重试 3 次后仍失败")); + } + + #[test] + fn missing_result_payload_is_retryable_and_preserves_pending_job_reference() { + let mut message = pending_tool_message(); + + let error = reconcile_completed_editor_agent_tool_call(&mut message, None) + .expect_err("missing result payload should be retried later"); + + assert_eq!( + error, + ResultReconcileFailure::RetryLater("生成任务缺少结果数据".to_string()) + ); + assert!(!apply_exhausted_result_reconcile_failure( + &mut message, + error, + )); + let tool_call = message.tool_call.expect("tool call should remain present"); + assert_eq!(tool_call.status, EditorAgentToolCallStatus::NotCompleted); + assert_eq!(tool_call.external_job_id.as_deref(), Some("job-1")); + assert_eq!(tool_call.error, None); + } + + #[test] + fn read_failure_is_retryable_without_terminal_message_mutation() { + let mut message = pending_tool_message(); + let error = ResultReconcileFailure::RetryLater( + "读取生成任务结果失败:temporarily unavailable".to_string(), + ); + + assert!(!apply_exhausted_result_reconcile_failure( + &mut message, + error, + )); + let tool_call = message.tool_call.expect("tool call should remain present"); + assert_eq!(tool_call.status, EditorAgentToolCallStatus::NotCompleted); + assert_eq!(tool_call.external_job_id.as_deref(), Some("job-1")); + } +} diff --git a/server-rs/crates/api-server/src/editor_agent/resp_to_asset.rs b/server-rs/crates/api-server/src/editor_agent/resp_to_asset.rs new file mode 100644 index 000000000..014a7d159 --- /dev/null +++ b/server-rs/crates/api-server/src/editor_agent/resp_to_asset.rs @@ -0,0 +1,105 @@ +use platform_editor_agent::agent::tools::edit_image::EditorImageEditResult; +use platform_editor_agent::agent::tools::generate_icon_spritesheet::EditorIconSpritesheetResult; +use platform_editor_agent::agent::tools::generate_image::EditorImageGenerationResult; +use serde_json::Value; +use shared_contracts::assets::{EditorAudioGenerateResponse, EditorVideoGenerateResponse}; +use shared_contracts::editor_agent::{ + EditorAgentGeneratedAudio, EditorAgentGeneratedImage, EditorAgentGeneratedVideo, +}; + +fn value_string(value: Option<&Value>, field: &str) -> Option { + value + .and_then(|resource| resource.get(field)) + .and_then(Value::as_str) + .map(ToOwned::to_owned) +} + +pub fn editor_agent_generated_image( + result: &EditorImageGenerationResult, +) -> EditorAgentGeneratedImage { + EditorAgentGeneratedImage { + resource_id: value_string(result.resource.as_ref(), "resourceId"), + object_key: result.object_key.clone(), + asset_object_id: result.asset_object_id.clone(), + image_src: result.image_src.clone(), + thumbnail_src: None, + width: Some(result.width), + height: Some(result.height), + } +} + +pub fn editor_agent_generated_edited_image( + result: &EditorImageEditResult, +) -> EditorAgentGeneratedImage { + EditorAgentGeneratedImage { + resource_id: value_string(result.resource.as_ref(), "resourceId"), + object_key: result.object_key.clone(), + asset_object_id: result.asset_object_id.clone(), + image_src: result.image_src.clone(), + thumbnail_src: None, + width: Some(result.width), + height: Some(result.height), + } +} + +pub fn editor_agent_generated_spritesheet( + result: &EditorIconSpritesheetResult, +) -> EditorAgentGeneratedImage { + EditorAgentGeneratedImage { + resource_id: value_string(result.spritesheet_resource.as_ref(), "resourceId"), + object_key: value_string(result.spritesheet_resource.as_ref(), "objectKey"), + asset_object_id: value_string(result.spritesheet_resource.as_ref(), "assetObjectId"), + image_src: result.spritesheet_image_src.clone(), + thumbnail_src: None, + width: Some(result.spritesheet_width), + height: Some(result.spritesheet_height), + } +} + +pub fn editor_agent_generated_spritesheet_images( + result: &EditorIconSpritesheetResult, +) -> Vec { + let images = result + .icon_image_srcs + .iter() + .map(|image| EditorAgentGeneratedImage { + resource_id: value_string(image.resource.as_ref(), "resourceId"), + object_key: value_string(image.resource.as_ref(), "objectKey"), + asset_object_id: value_string(image.resource.as_ref(), "assetObjectId"), + image_src: image.image_src.clone(), + thumbnail_src: None, + width: Some(image.width), + height: Some(image.height), + }) + .collect::>(); + if images.is_empty() { + vec![editor_agent_generated_spritesheet(result)] + } else { + images + } +} + +pub fn editor_agent_generated_video( + result: &EditorVideoGenerateResponse, +) -> EditorAgentGeneratedVideo { + EditorAgentGeneratedVideo { + resource_id: value_string(result.resource.as_ref(), "resourceId"), + object_key: result.object_key.clone(), + asset_object_id: result.asset_object_id.clone(), + video_src: result.video_src.clone(), + thumbnail_src: result.thumbnail_src.clone(), + width: Some(result.width), + height: Some(result.height), + } +} + +pub fn editor_agent_generated_audio( + result: &EditorAudioGenerateResponse, +) -> EditorAgentGeneratedAudio { + EditorAgentGeneratedAudio { + resource_id: value_string(result.resource.as_ref(), "resourceId"), + object_key: result.object_key.clone(), + asset_object_id: result.asset_object_id.clone(), + audio_src: result.audio_src.clone(), + } +} diff --git a/server-rs/crates/api-server/src/editor_agent/utils.rs b/server-rs/crates/api-server/src/editor_agent/utils.rs new file mode 100644 index 000000000..27ce6e423 --- /dev/null +++ b/server-rs/crates/api-server/src/editor_agent/utils.rs @@ -0,0 +1,613 @@ +use crate::editor_project::{current_utc_micros, map_editor_project_error}; +use crate::http_error::AppError; +use crate::platform_errors::map_oss_error; +use crate::state::AppState; +use axum::http::StatusCode; +use platform_editor_agent::agent::asset::ImageId; +use platform_oss::{ + LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest, OssSignedGetObjectUrlRequest, +}; +use serde_json::{Value, json}; +use shared_contracts::assets::{ + EditorCanvasGenerationCompletionPayload, EditorCanvasGenerationPlaceholderPayload, +}; +use shared_contracts::editor_agent::{ + EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION, EditorAgentAttachmentRef, EditorAgentAttachmentSource, + EditorAgentConversationDetail, EditorAgentConversationMessagesDocument, + EditorAgentConversationSummary, EditorAgentGeneratedImage, EditorAgentMessage, +}; +use shared_kernel::{normalize_optional_string, normalize_required_string}; +use spacetime_client::{ + EditorAgentConversationRecord, EditorAssetLibraryRecord, EditorAssetRecord, + EditorProjectGetRecordInput, EditorProjectRecord, EditorProjectResourceRecord, +}; +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex, OnceLock}; + +pub trait IntoDataKey { + fn into_data_key(self) -> String; +} +impl IntoDataKey for EditorAgentAttachmentRef { + fn into_data_key(self) -> String { + self.object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.trim_start_matches('/').to_string()) + // .or_else(|| normalize_required_string(self.reference_id.as_str())) + .unwrap_or_else(|| self.image_src.clone()) + } +} +impl IntoDataKey for EditorAgentGeneratedImage { + fn into_data_key(self) -> String { + self.object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.trim_start_matches('/').to_string()) + .unwrap_or(self.image_src) + } +} +impl IntoDataKey for EditorProjectResourceRecord { + fn into_data_key(self) -> String { + self.object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.trim_start_matches('/').to_string()) + .unwrap_or_else(|| self.image_src.clone()) + } +} + +impl IntoDataKey for EditorAssetRecord { + fn into_data_key(self) -> String { + self.object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.trim_start_matches('/').to_string()) + .unwrap_or_else(|| self.image_src.clone()) + } +} + +pub trait IntoImageId { + fn into_image_id(self) -> ImageId; +} +impl IntoImageId for EditorAgentAttachmentRef { + fn into_image_id(self) -> ImageId { + ImageId::from_data_key(self.into_data_key()) + } +} +impl IntoImageId for EditorProjectResourceRecord { + fn into_image_id(self) -> ImageId { + ImageId::from_data_key(self.into_data_key()) + } +} +impl IntoImageId for EditorAssetRecord { + fn into_image_id(self) -> ImageId { + ImageId::from_data_key(self.into_data_key()) + } +} + +// TODO resource id is not traced by now +// pub trait IntoResourceId { +// fn into_resource_id(self) -> String; +// } +// +// impl IntoResourceId for EditorProjectResourceRecord { +// fn into_resource_id(self) -> String { +// self.resource_id +// } +// } +// +// impl IntoResourceId for EditorAssetRecord { +// fn into_resource_id(self) -> String { +// self.asset_id +// } +// } + +#[cfg(test)] +mod image_id_tests { + use platform_editor_agent::agent::asset::ImageId; + + #[test] + fn image_id_is_a_stable_hash_of_the_data_key() { + let data_key = "editor-projects/proj-1/image.png"; + let image_id = ImageId::from_data_key(data_key); + + assert_eq!(image_id, ImageId::from_data_key(data_key)); + assert_ne!(image_id, ImageId::from_data_key("another-image.png")); + assert!(image_id.id.starts_with("sha256:")); + assert!(!image_id.id.contains(data_key)); + } +} + +type EditorAgentConversationLockMap = Mutex>>>; +static EDITOR_AGENT_CONVERSATION_LOCKS: OnceLock = OnceLock::new(); + +pub fn editor_agent_conversation_lock(conversation_id: &str) -> Arc> { + let locks = EDITOR_AGENT_CONVERSATION_LOCKS.get_or_init(|| Mutex::new(BTreeMap::new())); + let mut locks = locks + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + locks + .entry(conversation_id.to_string()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone() +} +pub fn now_rfc3339() -> String { + shared_kernel::format_rfc3339(time::OffsetDateTime::now_utc()) + .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()) +} + +const EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES: usize = 2 * 1024 * 1024; +pub async fn require_editor_agent_sidebar_enabled( + state: &AppState, + owner_user_id: &str, +) -> Result<(), AppError> { + match state + .is_image_editor_agent_sidebar_enabled_for_user(Some(owner_user_id)) + .await + { + Ok(true) => Ok(()), + Ok(false) => Err(editor_agent_sidebar_unavailable()), + Err(error) => Err(AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message("读取画布 Agent 灰度配置失败") + .with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + }))), + } +} + +fn editor_agent_sidebar_unavailable() -> AppError { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("画布 Agent 暂不可用") + .with_details(json!({ + "provider": "editor-agent", + "reason": "image_editor_agent_sidebar_disabled", + "gateKey": module_runtime::IMAGE_EDITOR_AGENT_SIDEBAR_GATE_KEY, + })) +} +// const EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES: usize = 2 * 1024 * 1024; +pub fn editor_agent_bad_request(message: impl Into) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-agent", + "message": message.into(), + })) +} + +fn editor_agent_oss_unavailable() -> AppError { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS is not configured for editor agent conversations", + })) +} + +fn editor_agent_oss_read_error(message: impl Into) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "aliyun-oss", + "message": message.into(), + })) +} + +fn editor_agent_messages_document_too_large() -> AppError { + AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE).with_details(json!({ + "provider": "editor-agent", + "message": "message document is too large", + "maxBytes": EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES, + })) +} +pub async fn write_messages_document( + state: &AppState, + conversation: &EditorAgentConversationRecord, + document: &EditorAgentConversationMessagesDocument, +) -> Result<(), AppError> { + if document.conversation_id != conversation.conversation_id { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-agent", + "message": "message document conversationId does not match metadata", + })), + ); + } + let body = serde_json::to_vec(document).map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-agent", + "message": format!("failed to serialize message document: {error}"), + })) + })?; + if body.len() > EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES { + return Err(editor_agent_messages_document_too_large()); + } + + let oss_client = state + .oss_client() + .ok_or_else(editor_agent_oss_unavailable)?; + let put_result = oss_client + .put_object( + &reqwest::Client::new(), + OssPutObjectRequest { + prefix: LegacyAssetPrefix::EditorAgent, + path_segments: Vec::new(), + file_name: format!("{}.json", conversation.conversation_id), + content_type: Some("application/json; charset=utf-8".to_string()), + access: OssObjectAccess::Private, + metadata: BTreeMap::from([ + ( + "conversation-id".to_string(), + conversation.conversation_id.clone(), + ), + ("project-id".to_string(), conversation.project_id.clone()), + ( + "owner-user-id".to_string(), + conversation.owner_user_id.clone(), + ), + ]), + body, + }, + ) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + if put_result.object_key != conversation.messages_object_key { + return Err( + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-agent", + "message": "OSS object key mismatch while writing message document", + "expectedObjectKey": conversation.messages_object_key, + "actualObjectKey": put_result.object_key, + })), + ); + } + Ok(()) +} + +const EDITOR_AGENT_MESSAGES_READ_EXPIRE_SECONDS: u64 = 60; + +pub async fn read_messages_document( + state: &AppState, + conversation: &EditorAgentConversationRecord, +) -> Result { + let oss_client = state + .oss_client() + .ok_or_else(editor_agent_oss_unavailable)?; + let signed = oss_client + .sign_internal_get_object_url(OssSignedGetObjectUrlRequest { + object_key: conversation.messages_object_key.clone(), + expire_seconds: Some(EDITOR_AGENT_MESSAGES_READ_EXPIRE_SECONDS), + }) + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + let response = reqwest::Client::new() + .get(signed.signed_url.as_str()) + .send() + .await + .map_err(|error| editor_agent_oss_read_error(error.to_string()))?; + if response.status() == StatusCode::NOT_FOUND { + return Ok(empty_messages_document( + conversation.conversation_id.as_str(), + )); + } + if !response.status().is_success() { + return Err(editor_agent_oss_read_error(format!( + "OSS returned non-success status {}", + response.status().as_u16() + ))); + } + if response + .content_length() + .is_some_and(|size| size > EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES as u64) + { + return Err(editor_agent_messages_document_too_large()); + } + let bytes = response + .bytes() + .await + .map_err(|error| editor_agent_oss_read_error(error.to_string()))?; + if bytes.is_empty() { + return Ok(empty_messages_document( + conversation.conversation_id.as_str(), + )); + } + if bytes.len() > EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES { + return Err(editor_agent_messages_document_too_large()); + } + let document: EditorAgentConversationMessagesDocument = serde_json::from_slice(&bytes) + .map_err(|error| { + editor_agent_oss_read_error(format!("message document JSON invalid: {error}")) + })?; + if document.conversation_id != conversation.conversation_id { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-agent", + "message": "message document conversationId does not match metadata", + "conversationId": conversation.conversation_id, + "documentConversationId": document.conversation_id, + })), + ); + } + Ok(document) +} + +pub async fn ensure_editor_project_access( + state: &AppState, + project_id: &str, + owner_user_id: &str, +) -> Result<(), AppError> { + state + .spacetime_client() + .get_editor_project(EditorProjectGetRecordInput { + project_id: project_id.to_string(), + owner_user_id: owner_user_id.to_string(), + }) + .await + .map(|_| ()) + .map_err(map_editor_project_error) +} + +pub async fn normalize_editor_agent_attachments( + state: &AppState, + conversation: &EditorAgentConversationRecord, + attachments: &[EditorAgentAttachmentRef], +) -> Result, AppError> { + if attachments.is_empty() { + return Ok(Vec::new()); + } + + let needs_canvas_resources = attachments + .iter() + .any(|attachment| attachment.source == EditorAgentAttachmentSource::CanvasResource); + let needs_library_assets = attachments + .iter() + .any(|attachment| attachment.source == EditorAgentAttachmentSource::LibraryAsset); + + let project = if needs_canvas_resources { + Some( + state + .spacetime_client() + .get_editor_project(EditorProjectGetRecordInput { + project_id: conversation.project_id.clone(), + owner_user_id: conversation.owner_user_id.clone(), + }) + .await + .map_err(map_editor_project_error)?, + ) + } else { + None + }; + let library = if needs_library_assets { + Some( + state + .spacetime_client() + .get_editor_asset_library(conversation.owner_user_id.clone(), current_utc_micros()) + .await + .map_err(map_editor_project_error)?, + ) + } else { + None + }; + + attachments + .iter() + .map(|attachment| { + normalize_editor_agent_attachment( + conversation, + project.as_ref(), + library.as_ref(), + attachment, + ) + }) + .collect() +} + +fn normalize_editor_agent_attachment( + conversation: &EditorAgentConversationRecord, + project: Option<&EditorProjectRecord>, + library: Option<&EditorAssetLibraryRecord>, + attachment: &EditorAgentAttachmentRef, +) -> Result { + let reference_id = normalize_required_string(attachment.reference_id.as_str()) + .ok_or_else(|| editor_agent_bad_request("attachment.referenceId is required"))?; + match attachment.source { + EditorAgentAttachmentSource::CanvasResource => { + let project = project.ok_or_else(|| { + editor_agent_bad_request("canvas resource attachment project context missing") + })?; + let resource = project + .resources + .iter() + .find(|resource| resource.resource_id == reference_id) + .ok_or_else(|| { + editor_agent_bad_request(format!( + "canvas resource attachment not found in current project: {reference_id}" + )) + })?; + normalize_canvas_resource_attachment(conversation, attachment, resource) + } + EditorAgentAttachmentSource::LibraryAsset => { + let library = library.ok_or_else(|| { + editor_agent_bad_request("library asset attachment context missing") + })?; + let asset = library + .assets + .iter() + .find(|asset| asset.asset_id == reference_id) + .ok_or_else(|| { + editor_agent_bad_request(format!( + "library asset attachment not found for current user: {reference_id}" + )) + })?; + normalize_library_asset_attachment(attachment, asset) + } + } +} + +pub fn normalize_canvas_resource_attachment( + conversation: &EditorAgentConversationRecord, + attachment: &EditorAgentAttachmentRef, + resource: &EditorProjectResourceRecord, +) -> Result { + if resource.project_id != conversation.project_id + || resource.owner_user_id != conversation.owner_user_id + { + return Err(editor_agent_bad_request( + "canvas resource attachment does not belong to this conversation project", + )); + } + validate_attachment_object_key( + attachment.object_key.as_deref(), + resource.object_key.as_deref(), + resource.resource_id.as_str(), + )?; + + Ok(EditorAgentAttachmentRef { + source: EditorAgentAttachmentSource::CanvasResource, + reference_id: resource.resource_id.clone(), + object_key: resource.object_key.clone(), + image_src: resource.image_src.clone(), + thumbnail_src: None, + label: normalize_optional_string(attachment.label.clone()), + width: Some(resource.width), + height: Some(resource.height), + }) +} + +pub fn normalize_library_asset_attachment( + attachment: &EditorAgentAttachmentRef, + asset: &EditorAssetRecord, +) -> Result { + validate_attachment_object_key( + attachment.object_key.as_deref(), + asset.object_key.as_deref(), + asset.asset_id.as_str(), + )?; + + Ok(EditorAgentAttachmentRef { + source: EditorAgentAttachmentSource::LibraryAsset, + reference_id: asset.asset_id.clone(), + object_key: asset.object_key.clone(), + image_src: asset.image_src.clone(), + thumbnail_src: asset.thumbnail_src.clone(), + label: normalize_optional_string(attachment.label.clone()) + .or_else(|| Some(asset.label.clone())), + width: Some(asset.width), + height: Some(asset.height), + }) +} + +fn validate_attachment_object_key( + submitted_object_key: Option<&str>, + stored_object_key: Option<&str>, + reference_id: &str, +) -> Result<(), AppError> { + let Some(submitted_object_key) = submitted_object_key.and_then(normalize_required_string) + else { + return Ok(()); + }; + let Some(stored_object_key) = stored_object_key.and_then(normalize_required_string) else { + return Err(editor_agent_bad_request(format!( + "attachment objectKey is not available for reference: {reference_id}" + ))); + }; + if submitted_object_key != stored_object_key { + return Err(editor_agent_bad_request(format!( + "attachment objectKey does not match reference: {reference_id}" + ))); + } + Ok(()) +} + +pub fn conversation_summary_from_record( + conversation: EditorAgentConversationRecord, +) -> EditorAgentConversationSummary { + EditorAgentConversationSummary { + conversation_id: conversation.conversation_id, + project_id: conversation.project_id, + title: conversation.title, + updated_at: conversation.updated_at, + } +} + +pub fn conversation_detail_from_record( + conversation: EditorAgentConversationRecord, + messages: Vec, +) -> EditorAgentConversationDetail { + EditorAgentConversationDetail { + conversation_id: conversation.conversation_id, + project_id: conversation.project_id, + title: conversation.title, + created_at: conversation.created_at, + updated_at: conversation.updated_at, + messages, + } +} + +pub fn build_editor_agent_canvas_completion( + project: &EditorProjectRecord, + tool_name: &str, + title: &str, +) -> EditorCanvasGenerationCompletionPayload { + let (width, height) = editor_agent_tool_display_size(tool_name); + let (x, y) = next_editor_agent_canvas_position(project.layers.clone(), width, height); + EditorCanvasGenerationCompletionPayload { + dialog_id: None, + title: title.to_string(), + placeholder: EditorCanvasGenerationPlaceholderPayload { + x, + y, + width, + height, + original_width: width, + original_height: height, + }, + } +} + +fn editor_agent_tool_display_size(tool_name: &str) -> (f64, f64) { + match tool_name { + "ui-design" | "generate-ui-design" => (640.0, 360.0), + "generate-character" => (512.0, 768.0), + _ => (512.0, 512.0), + } +} + +const EDITOR_AGENT_CANVAS_RESULT_GAP: f64 = 32.0; + +fn next_editor_agent_canvas_position( + layers: serde_json::Value, + _width: f64, + _height: f64, +) -> (f64, f64) { + let mut max_right: Option = None; + let mut min_y: Option = None; + if let Value::Array(items) = layers { + for item in items { + if item.get("itemType").and_then(Value::as_str) == Some("generation-dialog") { + continue; + } + let Some(x) = item.get("x").and_then(Value::as_f64) else { + continue; + }; + let Some(y) = item.get("y").and_then(Value::as_f64) else { + continue; + }; + let width = item.get("width").and_then(Value::as_f64).unwrap_or(0.0); + if !x.is_finite() || !y.is_finite() || !width.is_finite() { + continue; + } + max_right = Some(max_right.map_or(x + width, |value| value.max(x + width))); + min_y = Some(min_y.map_or(y, |value| value.min(y))); + } + } + ( + max_right.map_or(0.0, |right| right + EDITOR_AGENT_CANVAS_RESULT_GAP), + min_y.unwrap_or(0.0), + ) +} + +pub fn empty_messages_document(conversation_id: &str) -> EditorAgentConversationMessagesDocument { + EditorAgentConversationMessagesDocument { + version: EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION, + conversation_id: conversation_id.to_string(), + messages: Vec::new(), + } +} diff --git a/server-rs/crates/api-server/src/editor_generation_queue.rs b/server-rs/crates/api-server/src/editor_generation_queue.rs index e58ea8dc9..640aa6dc9 100644 --- a/server-rs/crates/api-server/src/editor_generation_queue.rs +++ b/server-rs/crates/api-server/src/editor_generation_queue.rs @@ -47,12 +47,41 @@ where T: Serialize, { let job_id = build_prefixed_uuid_id("task-"); + enqueue_editor_generation_job_with_identity( + state, + owner_user_id, + job_kind, + source_entity_id, + request_label, + price_mud_points, + payload, + job_id.clone(), + format!("editor-canvas:{job_kind}:{job_id}"), + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn enqueue_editor_generation_job_with_identity( + state: &AppState, + owner_user_id: &str, + job_kind: &str, + source_entity_id: impl Into, + request_label: impl Into, + price_mud_points: u64, + payload: &T, + job_id: String, + dedupe_key: String, +) -> Result +where + T: Serialize, +{ let request_payload_json = serialize_editor_generation_job_payload(payload)?; let now_micros = current_utc_micros(); state .spacetime_client() .enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput { - dedupe_key: format!("editor-canvas:{job_kind}:{job_id}"), + dedupe_key, job_id, job_kind: job_kind.to_string(), owner_user_id: owner_user_id.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 0289d3540..19d22bdcf 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -11,7 +11,6 @@ use axum::{ extract::{Extension, Path, Query, State}, http::StatusCode, }; -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use module_assets::{ AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_object_upsert_input, generate_asset_object_id, @@ -1443,6 +1442,12 @@ pub async fn generate_editor_image( ) -> Result, AppError> { let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { + ensure_editor_reference_image_sources_are_stable( + payload.reference_image_srcs.as_deref(), + "editor-image-generation", + "referenceImageSrcs", + "生成参考图", + )?; let normalized_kind = payload.kind.as_deref().map(str::trim); let is_ui_design_generation = matches!(normalized_kind, Some("ui-design")); let is_publication_material_generation = @@ -1503,6 +1508,12 @@ pub(crate) async fn generate_editor_image_for_owner( caller: EditorGenerationCaller, payload: EditorImageGenerationRequest, ) -> Result, AppError> { + ensure_editor_reference_image_sources_are_stable( + payload.reference_image_srcs.as_deref(), + "editor-image-generation", + "referenceImageSrcs", + "生成参考图", + )?; let role_setting = payload.prompt.trim().to_string(); if role_setting.is_empty() { return Err( @@ -1569,15 +1580,6 @@ pub(crate) async fn generate_editor_image_for_owner( // 决策移入闭包后 payload.reference_image_srcs 已被 reference_sources 消费, // 这里从过滤后的 reference_sources 预先固化 ui-design 的「是否带参考图」布尔。 let ui_has_references = !reference_sources.is_empty(); - let settings = require_openai_image_settings(state)?.with_external_api_audit_context( - request_context, - caller.audit_subject_user_id.clone(), - caller - .audit_project_id - .clone() - .or_else(|| payload.project_id.clone()), - ); - let http_client = build_openai_image_http_client(&settings)?; let negative_prompt = Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"); let reference_images = if reference_sources.is_empty() { Vec::new() @@ -1591,6 +1593,15 @@ pub(crate) async fn generate_editor_image_for_owner( } parsed_reference_images }; + let settings = require_openai_image_settings(state)?.with_external_api_audit_context( + request_context, + caller.audit_subject_user_id.clone(), + caller + .audit_project_id + .clone() + .or_else(|| payload.project_id.clone()), + ); + let http_client = build_openai_image_http_client(&settings)?; let image_size = if is_ui_design_generation { Cow::Owned(generation_options.size.clone()) } else { @@ -2623,6 +2634,18 @@ pub async fn edit_editor_image( ) -> Result, AppError> { let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { + ensure_editor_reference_image_source_is_stable( + payload.source_image_src.as_str(), + "editor-image-edit", + "sourceImageSrc", + "待修改图片", + )?; + ensure_editor_reference_image_sources_are_stable( + payload.reference_image_srcs.as_deref(), + "editor-image-edit", + "referenceImageSrcs", + "修改参考图", + )?; ensure_editor_image_edit_source_allowed(&state, caller.owner_user_id.as_str(), &payload) .await?; let generation_options = normalize_editor_image_edit_generation_options( @@ -2670,6 +2693,18 @@ pub(crate) async fn edit_editor_image_for_owner( caller: EditorGenerationCaller, payload: EditorImageEditRequest, ) -> Result, AppError> { + ensure_editor_reference_image_source_is_stable( + payload.source_image_src.as_str(), + "editor-image-edit", + "sourceImageSrc", + "待修改图片", + )?; + ensure_editor_reference_image_sources_are_stable( + payload.reference_image_srcs.as_deref(), + "editor-image-edit", + "referenceImageSrcs", + "修改参考图", + )?; ensure_editor_image_edit_source_allowed(state, caller.owner_user_id.as_str(), &payload).await?; let prompt = payload.prompt.trim().to_string(); if prompt.is_empty() { @@ -2908,8 +2943,13 @@ pub async fn remove_editor_image_background( Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { - ensure_editor_background_removal_source_is_not_data_url(payload.source_image_src.as_str())?; let caller = EditorGenerationCaller::from_authenticated(&authenticated); + ensure_editor_reference_image_source_is_stable( + payload.source_image_src.as_str(), + "editor-background-removal", + "sourceImageSrc", + "待去除背景图片", + )?; let source_entity_id = editor_generation_source_entity_id( payload.project_id.as_deref(), "editor-background-removal", @@ -2933,20 +2973,6 @@ pub async fn remove_editor_image_background( )) } -fn ensure_editor_background_removal_source_is_not_data_url(source: &str) -> Result<(), AppError> { - let prefix = source.trim_start().as_bytes().get(..5); - if prefix.is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"data:")) { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "editor-background-removal", - "field": "sourceImageSrc", - "message": "手动去背景图片必须先上传 OSS,并提交已登记的 objectKey、resourceId 或 assetId,不能提交 Data URL。", - })), - ); - } - Ok(()) -} - pub(crate) async fn remove_editor_image_background_for_owner( state: &AppState, request_context: &RequestContext, @@ -2954,20 +2980,13 @@ pub(crate) async fn remove_editor_image_background_for_owner( payload: EditorBackgroundRemovalRequest, ) -> Result, AppError> { let started_at = Instant::now(); - ensure_editor_background_removal_source_is_not_data_url(payload.source_image_src.as_str())?; caller.report_processing_phase(state).await?; - let source_object_key = resolve_editor_reference_object_key( + let source_object_key = resolve_editor_reference_object_key_for_owner( state, caller.owner_user_id.as_str(), payload.source_image_src.as_str(), ) .await?; - ensure_editor_reference_object_key_owned( - state, - caller.owner_user_id.as_str(), - source_object_key.as_str(), - ) - .await?; let removed = request_editor_background_removal_image_with_retry(state, source_object_key.as_str()) .await?; @@ -3943,6 +3962,18 @@ pub async fn generate_editor_icon_spritesheet( ) -> Result, AppError> { let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { + ensure_editor_reference_image_source_is_stable( + payload.reference_image_src.as_str(), + "editor-icon-spritesheet", + "referenceImageSrc", + "图标素材规范", + )?; + ensure_editor_reference_image_sources_are_stable( + payload.reference_image_srcs.as_deref(), + "editor-icon-spritesheet", + "referenceImageSrcs", + "图标素材参考图", + )?; let generation_options = normalize_editor_generation_options( payload.model.as_deref(), payload.aspect_ratio.as_deref(), @@ -3987,6 +4018,18 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( caller: EditorGenerationCaller, payload: EditorIconSpritesheetGenerationRequest, ) -> Result, AppError> { + ensure_editor_reference_image_source_is_stable( + payload.reference_image_src.as_str(), + "editor-icon-spritesheet", + "referenceImageSrc", + "图标素材规范", + )?; + ensure_editor_reference_image_sources_are_stable( + payload.reference_image_srcs.as_deref(), + "editor-icon-spritesheet", + "referenceImageSrcs", + "图标素材参考图", + )?; let icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?; // 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时 // 仍白发一次 gpt-5-mini 决策。这里先固化决策需要的输入。 @@ -4006,7 +4049,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( error.with_details(json!({ "provider": "editor-icon-spritesheet", "field": "referenceImageSrc", - "message": "图标素材规范必须是图片 Data URL、objectKey、资源或素材引用。", + "message": "图标素材规范必须是 objectKey、资源或素材引用。", })) })?, ); @@ -4020,7 +4063,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( error.with_details(json!({ "provider": "editor-icon-spritesheet", "field": "referenceImageSrcs", - "message": "图标素材参考图必须是图片 Data URL、objectKey、资源或素材引用。", + "message": "图标素材参考图必须是 objectKey、资源或素材引用。", })) })?, ); @@ -4654,6 +4697,18 @@ pub async fn extract_editor_ui_design_assets( ) -> Result, AppError> { let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { + ensure_editor_reference_image_source_is_stable( + payload.source_image_src.as_str(), + "editor-ui-design-asset-extraction", + "sourceImageSrc", + "UI设计图", + )?; + ensure_editor_reference_image_sources_are_stable( + payload.reference_image_srcs.as_deref(), + "editor-ui-design-asset-extraction", + "referenceImageSrcs", + "UI素材参考图", + )?; let generation_options = normalize_editor_ui_design_asset_extraction_options( payload.model.as_deref(), payload.aspect_ratio.as_str(), @@ -4698,6 +4753,18 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( caller: EditorGenerationCaller, payload: EditorUiDesignAssetExtractionRequest, ) -> Result, AppError> { + ensure_editor_reference_image_source_is_stable( + payload.source_image_src.as_str(), + "editor-ui-design-asset-extraction", + "sourceImageSrc", + "UI设计图", + )?; + ensure_editor_reference_image_sources_are_stable( + payload.reference_image_srcs.as_deref(), + "editor-ui-design-asset-extraction", + "referenceImageSrcs", + "UI素材参考图", + )?; // 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时 // 仍白发一次 gpt-5-mini 决策。这里先固化决策需要的输入。 let requested_screen_color = payload.screen_color.clone(); @@ -4717,7 +4784,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( error.with_details(json!({ "provider": "editor-ui-design-asset-extraction", "field": "sourceImageSrc", - "message": "UI设计图必须是图片 Data URL、objectKey、资源或素材引用。", + "message": "UI设计图必须是 objectKey、资源或素材引用。", })) })?; let mut reference_images = vec![reference_image]; @@ -4732,7 +4799,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( error.with_details(json!({ "provider": "editor-ui-design-asset-extraction", "field": "referenceImageSrcs", - "message": "UI素材参考图必须是图片 Data URL、objectKey、资源或素材引用。", + "message": "UI素材参考图必须是 objectKey、资源或素材引用。", })) })?, ); @@ -7153,20 +7220,66 @@ const EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES: u64 = 32 * 1024 * 1024; const EDITOR_BACKGROUND_REMOVAL_MAX_RESPONSE_BYTES: usize = 32 * 1024 * 1024; const EDITOR_BACKGROUND_REMOVAL_MAX_IMAGE_DIMENSION: u32 = 8192; +fn is_editor_inline_media_source(source: &str) -> bool { + source + .trim_start() + .as_bytes() + .get(..5) + .is_some_and(|prefix| { + prefix.eq_ignore_ascii_case(b"data:") || prefix.eq_ignore_ascii_case(b"blob:") + }) +} + +fn ensure_editor_reference_image_source_is_stable( + source: &str, + provider: &str, + field: &str, + label: &str, +) -> Result<(), AppError> { + if !is_editor_inline_media_source(source) { + return Ok(()); + } + Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": provider, + "field": field, + "message": format!("{label}必须先上传 OSS,并使用 objectKey、项目资源 ID 或素材 ID 引用。"), + })), + ) +} + +fn ensure_editor_reference_image_sources_are_stable( + sources: Option<&[String]>, + provider: &str, + field: &str, + label: &str, +) -> Result<(), AppError> { + for source in sources.unwrap_or(&[]) { + ensure_editor_reference_image_source_is_stable(source, provider, field, label)?; + } + Ok(()) +} + pub(crate) async fn parse_editor_reference_image( state: &AppState, owner_user_id: &str, source: &str, ) -> Result { - let trimmed = source.trim(); - if trimmed.starts_with("data:") { - return parse_editor_reference_image_data_url(trimmed); - } - let object_key = resolve_editor_reference_object_key(state, owner_user_id, trimmed).await?; - ensure_editor_reference_object_key_owned(state, owner_user_id, object_key.as_str()).await?; + let object_key = + resolve_editor_reference_object_key_for_owner(state, owner_user_id, source).await?; read_editor_reference_image_object(state, object_key.as_str()).await } +pub(crate) async fn resolve_editor_reference_object_key_for_owner( + state: &AppState, + owner_user_id: &str, + source: &str, +) -> Result { + let object_key = resolve_editor_reference_object_key(state, owner_user_id, source).await?; + ensure_editor_reference_object_key_owned(state, owner_user_id, object_key.as_str()).await?; + Ok(object_key) +} + fn normalize_editor_reference_image_sources(sources: Option<&[String]>, limit: usize) -> Vec<&str> { sources .unwrap_or(&[]) @@ -7185,7 +7298,7 @@ fn normalize_editor_reference_object_key(source: &str) -> Result Result { + ensure_editor_reference_image_source_is_stable( + source, + "editor-reference-image", + "sourceImageSrc", + "图片参考图", + )?; match normalize_editor_reference_object_key(source) { Ok(object_key) => Ok(object_key), Err(error) => { @@ -7370,58 +7489,6 @@ fn editor_reference_image_forbidden(object_key: &str) -> AppError { })) } -fn parse_editor_reference_image_data_url(source: &str) -> Result { - let Some((header, data)) = source.trim().split_once(',') else { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "editor-image-edit", - "field": "sourceImageSrc", - "message": "修改图片参考图必须是图片 Data URL。", - })), - ); - }; - let Some(mime_type) = header - .strip_prefix("data:") - .and_then(|value| value.strip_suffix(";base64")) - .filter(|value| value.starts_with("image/")) - else { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "editor-image-edit", - "field": "sourceImageSrc", - "message": "修改图片参考图必须是 base64 图片 Data URL。", - })), - ); - }; - let bytes = BASE64_STANDARD.decode(data.trim()).map_err(|error| { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "editor-image-edit", - "field": "sourceImageSrc", - "message": format!("修改图片参考图解码失败:{error}"), - })) - })?; - if bytes.is_empty() { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "editor-image-edit", - "field": "sourceImageSrc", - "message": "修改图片参考图为空。", - })), - ); - } - let extension = match mime_type { - "image/jpeg" => "jpg", - "image/png" => "png", - "image/webp" => "webp", - _ => "png", - }; - Ok(OpenAiReferenceImage { - bytes, - mime_type: mime_type.to_string(), - file_name: format!("editor-reference.{extension}"), - }) -} - async fn read_editor_reference_image_object( state: &AppState, source: &str, @@ -9633,57 +9700,23 @@ mod tests { } #[test] - fn editor_background_removal_rejects_data_url_before_queue_and_worker_execution() { - for source in [ - "data:image/png;base64,AAAA", - " \nDaTa:image/webp;base64,BBBB", - ] { - let error = ensure_editor_background_removal_source_is_not_data_url(source) - .expect_err("手动去背景不应继续兼容 Data URL"); - assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); - assert!(error.body_text().contains("不能提交 Data URL")); - } - for source in [ - "generated-character-drafts/editor/source.png", - "resource-1", - "asset-1", - ] { - ensure_editor_background_removal_source_is_not_data_url(source) - .expect("已登记的 OSS 对象引用应继续进入后续归属校验"); + fn editor_reference_inline_media_is_not_an_object_reference() { + for inline_source in ["data:image/png;base64,AAAA", "blob:browser-only"] { + let error = normalize_editor_reference_object_key(inline_source) + .expect_err("inline image should require upload before generation"); + assert!(error.body_text().contains("objectKey")); } let source = include_str!("editor_project.rs"); assert_function_contains_in_order( source, - "pub async fn remove_editor_image_background(", - "pub(crate) async fn remove_editor_image_background_for_owner", + "async fn resolve_editor_reference_object_key(", + "async fn find_editor_reference_object_key_by_registered_id", &[ - "ensure_editor_background_removal_source_is_not_data_url", - "enqueue_editor_generation_job", + "ensure_editor_reference_image_source_is_stable", + "normalize_editor_reference_object_key", ], ); - assert_function_contains_in_order( - source, - "pub(crate) async fn remove_editor_image_background_for_owner", - "struct EditorBackgroundRemovalImage", - &[ - "ensure_editor_background_removal_source_is_not_data_url", - "caller.report_processing_phase(state).await?", - "resolve_editor_reference_object_key", - "ensure_editor_reference_object_key_owned", - "request_editor_background_removal_image_with_retry", - ], - ); - } - - #[test] - fn editor_reference_image_data_url_stays_compatible() { - let parsed = parse_editor_reference_image_data_url("data:image/png;base64,AAAA") - .expect("legacy data URL reference should still parse"); - - assert_eq!(parsed.mime_type, "image/png"); - assert_eq!(parsed.file_name, "editor-reference.png"); - assert_eq!(parsed.bytes, vec![0, 0, 0]); } #[test] @@ -9969,7 +10002,7 @@ mod tests { async fn editor_ui_design_asset_extraction_price_uses_selected_model_and_size() { let state = AppState::new(AppConfig::default()).expect("state should build"); let request: EditorUiDesignAssetExtractionRequest = serde_json::from_value(json!({ - "sourceImageSrc": "data:image/png;base64,AAAA", + "sourceImageSrc": "/generated-images/editor/ui-design.png", "model": "gpt-image-2", "screenColor": "#E6D8FF", "segModel": "anime-seg", @@ -10051,12 +10084,12 @@ mod tests { fn editor_image_edit_request_omits_price_mud_points() { let request: EditorImageEditRequest = serde_json::from_value(json!({ "prompt": "修改图片", - "sourceImageSrc": "data:image/png;base64,AAAA", + "sourceImageSrc": "/generated-images/editor/source.png", "size": "2048x1152", "model": "gemini-3.1-flash-image-preview", "aspectRatio": "16:9", "imageSize": "2K", - "referenceImageSrcs": ["data:image/png;base64,BBBB"], + "referenceImageSrcs": ["/generated-images/editor/style.png"], "targetLayerId": "layer-source" })) .expect("image edit request should deserialize without price"); @@ -10079,7 +10112,7 @@ mod tests { assert_eq!(legacy_options.image_size, "2K"); assert_eq!( request.reference_image_srcs, - Some(vec!["data:image/png;base64,BBBB".to_string()]) + Some(vec!["/generated-images/editor/style.png".to_string()]) ); assert_eq!(request.target_layer_id.as_deref(), Some("layer-source")); } @@ -10143,6 +10176,68 @@ mod tests { ); } + #[test] + fn editor_inline_media_sources_are_rejected_before_queueing() { + let source = include_str!("editor_project.rs"); + for (start, end) in [ + ( + "pub async fn generate_editor_image(", + "pub(crate) async fn generate_editor_image_for_owner", + ), + ( + "pub async fn edit_editor_image(", + "pub(crate) async fn edit_editor_image_for_owner", + ), + ( + "pub async fn remove_editor_image_background(", + "pub(crate) async fn remove_editor_image_background_for_owner", + ), + ( + "pub async fn generate_editor_icon_spritesheet(", + "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", + ), + ( + "pub async fn extract_editor_ui_design_assets(", + "pub(crate) async fn extract_editor_ui_design_assets_for_owner", + ), + ] { + assert_function_contains_in_order( + source, + start, + end, + &[ + "ensure_editor_reference_image_source", + "enqueue_editor_generation_job", + ], + ); + } + + for inline_source in [ + "data:image/png;base64,AAAA", + " DATA:image/png;base64,AAAA", + "blob:browser-only", + ] { + let error = ensure_editor_reference_image_source_is_stable( + inline_source, + "editor-test", + "sourceImageSrc", + "测试图片", + ) + .expect_err("inline media must require upload before generation"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert!(error.body_text().contains("先上传 OSS")); + } + assert!( + ensure_editor_reference_image_source_is_stable( + "/generated-images/editor/reference.png", + "editor-test", + "sourceImageSrc", + "测试图片", + ) + .is_ok() + ); + } + #[test] fn editor_image_edit_can_complete_by_replacing_target_layer() { let source = include_str!("editor_project.rs"); @@ -10159,10 +10254,10 @@ mod tests { } #[test] - fn editor_icon_spritesheet_request_accepts_extra_references() { + fn editor_icon_spritesheet_request_accepts_stable_extra_references() { let request: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({ - "referenceImageSrc": "data:image/png;base64,spec", - "referenceImageSrcs": ["data:image/png;base64,style"], + "referenceImageSrc": "/generated-images/editor/spec.png", + "referenceImageSrcs": ["/generated-images/editor/style.png"], "iconDescriptions": ["返回按钮"], "screenColor": "#7FB3FF", "segModel": "anime-seg", @@ -10172,7 +10267,7 @@ mod tests { assert_eq!( request.reference_image_srcs, - Some(vec!["data:image/png;base64,style".to_string()]) + Some(vec!["/generated-images/editor/style.png".to_string()]) ); assert_eq!(request.screen_color.as_deref(), Some("#7FB3FF")); assert_eq!(request.seg_model.as_deref(), Some("anime-seg")); @@ -10511,8 +10606,7 @@ mod tests { "struct EditorBackgroundRemovalImage", &[ "caller.report_processing_phase(state).await?", - "resolve_editor_reference_object_key", - "ensure_editor_reference_object_key_owned", + "resolve_editor_reference_object_key_for_owner", "request_editor_background_removal_image_with_retry", ], ); 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 2bedd96a3..5567c9c5a 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -829,7 +829,9 @@ async fn process_external_generation_job_once( ) .await { - Ok(_) => complete_editor_generation_job(&state, &worker_id, &job).await, + Ok(result) => { + complete_editor_generation_job(&state, &worker_id, &job, result.0).await + } Err(error) => { let message = error.body_text(); fail_job(&state, &worker_id, &job, message.clone()).await?; @@ -858,7 +860,9 @@ async fn process_external_generation_job_once( ) .await { - Ok(_) => complete_editor_generation_job(&state, &worker_id, &job).await, + Ok(result) => { + complete_editor_generation_job(&state, &worker_id, &job, result.0).await + } Err(error) => { let message = error.body_text(); fail_job(&state, &worker_id, &job, message.clone()).await?; @@ -960,7 +964,9 @@ async fn process_external_generation_job_once( ) .await { - Ok(_) => complete_editor_generation_job(&state, &worker_id, &job).await, + Ok(result) => { + complete_editor_generation_job(&state, &worker_id, &job, result.0).await + } Err(response) => { let message = response_error_message(response).await; fail_job(&state, &worker_id, &job, message.clone()).await?; @@ -989,7 +995,9 @@ async fn process_external_generation_job_once( ) .await { - Ok(_) => complete_editor_generation_job(&state, &worker_id, &job).await, + Ok(result) => { + complete_editor_generation_job(&state, &worker_id, &job, result.0).await + } Err(response) => { let message = response_error_message(response).await; fail_job(&state, &worker_id, &job, message.clone()).await?; @@ -1018,7 +1026,9 @@ async fn process_external_generation_job_once( ) .await { - Ok(_) => complete_editor_generation_job(&state, &worker_id, &job).await, + Ok(result) => { + complete_editor_generation_job(&state, &worker_id, &job, result.0).await + } Err(response) => { let message = response_error_message(response).await; fail_job(&state, &worker_id, &job, message.clone()).await?; @@ -1047,7 +1057,9 @@ async fn process_external_generation_job_once( ) .await { - Ok(_) => complete_editor_generation_job(&state, &worker_id, &job).await, + Ok(result) => { + complete_editor_generation_job(&state, &worker_id, &job, result.0).await + } Err(response) => { let message = response_error_message(response).await; fail_job(&state, &worker_id, &job, message.clone()).await?; @@ -1167,18 +1179,13 @@ async fn complete_editor_generation_job( state: &AppState, worker_id: &str, job: &ExternalGenerationJobRecord, + response: Value, ) -> Result<(), String> { complete_job( state, worker_id, job, - Some( - json!({ - "sourceModule": job.source_module.clone(), - "sourceEntityId": job.source_entity_id.clone(), - }) - .to_string(), - ), + Some(editor_generation_result_payload_json(job, &response)), ) .await } @@ -1206,6 +1213,17 @@ fn editor_generation_result_payload_json( "sourceModule": job.source_module.clone(), "sourceEntityId": job.source_entity_id.clone(), }); + if is_editor_agent_generation_job(job) + && let Some(object) = payload.as_object_mut() + { + // The Agent needs this compact result to restore its tool-call card. Other jobs keep + // master's metadata-only completion payload to avoid turning the queue into an asset API. + object.insert( + // TODO extract const + "editor-agent-tool-call-result".to_string(), + compact_editor_generation_result(response.clone()), + ); + } if let Some(warning) = extract_editor_generation_warning(response) && let Some(object) = payload.as_object_mut() { @@ -1214,6 +1232,56 @@ fn editor_generation_result_payload_json( payload.to_string() } +fn is_editor_agent_generation_job(job: &ExternalGenerationJobRecord) -> bool { + serde_json::from_str::(job.request_payload_json.as_str()) + .ok() + .is_some_and(|payload| { + payload + .pointer("/generationInputs/source") + .and_then(Value::as_str) + // TODO extract const + .is_some_and(|source| source.trim() == "editor-agent") + }) +} + +fn compact_editor_generation_result(mut result: Value) -> Value { + let Some(object) = result.as_object_mut() else { + return result; + }; + object.remove("project"); + object.remove("asset"); + object.remove("spritesheetAsset"); + for field in ["resource", "spritesheetResource"] { + let Some(resource) = object.get_mut(field).and_then(Value::as_object_mut) else { + continue; + }; + resource + .retain(|key, _| matches!(key.as_str(), "resourceId" | "objectKey" | "assetObjectId")); + } + if let Some(icon_image_srcs) = object + .get_mut("iconImageSrcs") + .and_then(Value::as_array_mut) + { + for icon in icon_image_srcs { + let Some(icon) = icon.as_object_mut() else { + continue; + }; + if let Some(resource) = icon.get_mut("resource").and_then(Value::as_object_mut) { + resource.retain(|key, _| { + matches!(key.as_str(), "resourceId" | "objectKey" | "assetObjectId") + }); + } + icon.retain(|key, _| { + matches!( + key.as_str(), + "name" | "imageSrc" | "width" | "height" | "resource" + ) + }); + } + } + result +} + fn extract_editor_generation_warning(response: &Value) -> Option { let data = response.get("data").unwrap_or(response); let (warning, is_slice_warning) = match data.get("warning") { @@ -1629,6 +1697,136 @@ mod tests { ); assert!(payload.get("spritesheetImageSrc").is_none()); assert!(payload.get("iconImageSrcs").is_none()); + assert!(payload.get("editor-agent-tool-call-result").is_none()); + } + + #[test] + fn editor_agent_result_payload_keeps_compact_response() { + let mut job = external_generation_job_record_fixture(Some("lease-1")); + job.request_payload_json = json!({ + "generationInputs": { "source": "editor-agent" }, + }) + .to_string(); + let response = json!({ + "imageSrc": "/api/assets/object/generated.png", + "objectKey": "users/user-1/generated.png", + "assetObjectId": "asset-object-1", + "width": 1024, + "height": 1024, + "sourceType": "generated", + "prompt": "castle", + "actualPrompt": null, + "model": "gpt-image-2", + "provider": "VectorEngine", + "taskId": "provider-task-1", + "resource": { + "resourceId": "resource-1", + "objectKey": "users/user-1/generated.png", + "assetObjectId": "asset-object-1", + "imageSrc": "data:image/png;base64,SHOULD_NOT_PERSIST", + }, + "asset": { "assetId": "asset-1" }, + "project": { "projectId": "project-1" }, + }); + + let payload: Value = + serde_json::from_str(&editor_generation_result_payload_json(&job, &response)) + .expect("worker result should be valid JSON"); + + assert_eq!( + payload["editor-agent-tool-call-result"]["imageSrc"], + json!("/api/assets/object/generated.png") + ); + assert_eq!( + payload["editor-agent-tool-call-result"]["resource"], + json!({ + "resourceId": "resource-1", + "objectKey": "users/user-1/generated.png", + "assetObjectId": "asset-object-1", + }) + ); + assert!( + payload["editor-agent-tool-call-result"] + .get("asset") + .is_none() + ); + assert!( + payload["editor-agent-tool-call-result"] + .get("project") + .is_none() + ); + assert!(!payload.to_string().contains("data:image")); + } + + #[test] + fn editor_agent_spritesheet_result_keeps_all_persisted_slices() { + let mut job = external_generation_job_record_fixture(Some("lease-1")); + job.request_payload_json = json!({ + "generationInputs": { "source": "editor-agent" }, + }) + .to_string(); + let response = json!({ + "spritesheetImageSrc": "/api/assets/object/sheet.png", + "spritesheetWidth": 512, + "spritesheetHeight": 512, + "taskId": "provider-task-1", + "spritesheetResource": { + "resourceId": "sheet-resource", + "objectKey": "users/user-1/sheet.png", + "assetObjectId": "sheet-object", + }, + "iconImageSrcs": [ + { + "name": "backpack", + "imageSrc": "/api/assets/object/backpack.png", + "width": 64, + "height": 64, + "resource": { + "resourceId": "icon-resource-1", + "objectKey": "users/user-1/backpack.png", + "assetObjectId": "icon-object-1", + "imageSrc": "data:image/png;base64,SHOULD_NOT_PERSIST", + }, + "asset": { "assetId": "icon-asset-1" }, + }, + { + "name": "map", + "imageSrc": "/api/assets/object/map.png", + "width": 64, + "height": 64, + "resource": { + "resourceId": "icon-resource-2", + "objectKey": "users/user-1/map.png", + "assetObjectId": "icon-object-2", + }, + }, + ], + }); + + let payload: Value = + serde_json::from_str(&editor_generation_result_payload_json(&job, &response)) + .expect("worker result should be valid JSON"); + + assert_eq!( + payload["editor-agent-tool-call-result"]["iconImageSrcs"] + .as_array() + .map(Vec::len), + Some(2) + ); + assert_eq!( + payload["editor-agent-tool-call-result"]["iconImageSrcs"][0]["resource"], + json!({ + "resourceId": "icon-resource-1", + "objectKey": "users/user-1/backpack.png", + "assetObjectId": "icon-object-1", + }) + ); + assert!( + payload["editor-agent-tool-call-result"]["iconImageSrcs"][0] + .get("asset") + .is_none() + ); + assert!(!payload.to_string().contains("data:image")); } #[test] diff --git a/server-rs/crates/api-server/src/modules/editor_project.rs b/server-rs/crates/api-server/src/modules/editor_project.rs index 3842c5c53..547443534 100644 --- a/server-rs/crates/api-server/src/modules/editor_project.rs +++ b/server-rs/crates/api-server/src/modules/editor_project.rs @@ -7,10 +7,11 @@ use axum::{ use crate::{ auth::require_bearer_auth, + editor_agent::api::editor_agent_message, editor_agent::{ + cancel_editor_agent_tool_call, confirm_editor_agent_tool_call, create_editor_agent_conversation, delete_editor_agent_conversation, get_editor_agent_conversation, list_editor_agent_conversations, - stream_editor_agent_message, }, editor_project::{ create_editor_asset, create_editor_asset_folder, create_editor_project, @@ -27,7 +28,6 @@ use crate::{ state::AppState, }; -const EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024; const EDITOR_AGENT_MESSAGE_BODY_LIMIT_BYTES: usize = 2 * 1024 * 1024; pub fn router(state: AppState) -> Router { @@ -99,14 +99,28 @@ pub fn router(state: AppState) -> Router { )), ) .route( - "/api/editor/agent-conversations/{conversation_id}/messages/stream", - post(stream_editor_agent_message) + "/api/editor/agent-conversations/{conversation_id}/messages", + post(editor_agent_message) .layer(DefaultBodyLimit::max(EDITOR_AGENT_MESSAGE_BODY_LIMIT_BYTES)) .route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), ) + .route( + "/api/editor/agent-conversations/{conversation_id}/messages/{message_id}/confirm", + post(confirm_editor_agent_tool_call).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/editor/agent-conversations/{conversation_id}/messages/{message_id}/cancel", + post(cancel_editor_agent_tool_call).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/editor/project-resources/{resource_id}/showcase", patch(update_editor_project_resource_showcase).route_layer( @@ -168,49 +182,31 @@ pub fn router(state: AppState) -> Router { ) .route( "/api/editor/images/generations", - post(generate_editor_image) - .layer(DefaultBodyLimit::max( - // 中文注释:图片画布生图仍兼容参考图 Data URL 入参,宣发素材提交前会压缩,后端保留有限兜底。 - EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), + post(generate_editor_image).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/editor/images/edits", - post(edit_editor_image) - .layer(DefaultBodyLimit::max( - EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), + post(edit_editor_image).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/editor/images/background-removals", - post(remove_editor_image_background) - .layer(DefaultBodyLimit::max( - EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), + post(remove_editor_image_background).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/editor/icon-spritesheets/generations", - post(generate_editor_icon_spritesheet) - // 中文注释:图标规范参考图会以 Data URL 提交,需避免默认 2MB body limit 在 handler 前拦截。 - .layer(DefaultBodyLimit::max( - EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), + post(generate_editor_icon_spritesheet).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/editor/icon-spritesheets/slices", @@ -221,14 +217,9 @@ pub fn router(state: AppState) -> Router { ) .route( "/api/editor/ui-designs/assets/extractions", - post(extract_editor_ui_design_assets) - // 中文注释:UI 设计图提取素材复用图片 Data URL 入参,需兼容较大的画板生成图。 - .layer(DefaultBodyLimit::max( - EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), + post(extract_editor_ui_design_assets).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) } diff --git a/server-rs/crates/api-server/src/modules/external_api.rs b/server-rs/crates/api-server/src/modules/external_api.rs index 3841ffb25..497ade677 100644 --- a/server-rs/crates/api-server/src/modules/external_api.rs +++ b/server-rs/crates/api-server/src/modules/external_api.rs @@ -1,7 +1,5 @@ use axum::{ - Router, - extract::DefaultBodyLimit, - middleware, + Router, middleware, routing::{get, patch, post}, }; @@ -28,9 +26,6 @@ use crate::{ state::AppState, }; -const EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024; -const EXTERNAL_EDITOR_VIDEO_BODY_LIMIT_BYTES: usize = 64 * 1024 * 1024; - pub fn router(state: AppState) -> Router { Router::new() .route("/api/external/v1/openapi.json", get(openapi_json)) @@ -141,69 +136,42 @@ pub fn router(state: AppState) -> Router { ) .route( "/api/external/v1/editor/images/generations", - post(generate_external_editor_image) - .layer(DefaultBodyLimit::max( - EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_external_api_key, - )), + post(generate_external_editor_image).route_layer(middleware::from_fn_with_state( + state.clone(), + require_external_api_key, + )), ) .route( "/api/external/v1/editor/images/edits", - post(edit_external_editor_image) - .layer(DefaultBodyLimit::max( - EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_external_api_key, - )), + post(edit_external_editor_image).route_layer(middleware::from_fn_with_state( + state.clone(), + require_external_api_key, + )), ) .route( "/api/external/v1/editor/icon-spritesheets/generations", - post(generate_external_editor_icon_spritesheet) - .layer(DefaultBodyLimit::max( - EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_external_api_key, - )), + post(generate_external_editor_icon_spritesheet).route_layer( + middleware::from_fn_with_state(state.clone(), require_external_api_key), + ), ) .route( "/api/external/v1/editor/ui-designs/assets/extractions", - post(extract_external_editor_ui_design_assets) - .layer(DefaultBodyLimit::max( - EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_external_api_key, - )), + post(extract_external_editor_ui_design_assets).route_layer( + middleware::from_fn_with_state(state.clone(), require_external_api_key), + ), ) .route( "/api/external/v1/editor/character-animations/generations", - post(generate_external_editor_character_animation) - .layer(DefaultBodyLimit::max( - EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_external_api_key, - )), + post(generate_external_editor_character_animation).route_layer( + middleware::from_fn_with_state(state.clone(), require_external_api_key), + ), ) .route( "/api/external/v1/editor/videos/generations", - post(generate_external_editor_video) - .layer(DefaultBodyLimit::max( - EXTERNAL_EDITOR_VIDEO_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_external_api_key, - )), + post(generate_external_editor_video).route_layer(middleware::from_fn_with_state( + state.clone(), + require_external_api_key, + )), ) .route( "/api/external/v1/editor/audios/sound-effects/generations", diff --git a/server-rs/crates/api-server/src/modules/play_flow.rs b/server-rs/crates/api-server/src/modules/play_flow.rs index cb05b06a3..371bb24c2 100644 --- a/server-rs/crates/api-server/src/modules/play_flow.rs +++ b/server-rs/crates/api-server/src/modules/play_flow.rs @@ -52,8 +52,6 @@ use crate::{ }; const HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES: usize = 56 * 1024 * 1024; -const EDITOR_CHARACTER_ANIMATION_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024; -const EDITOR_VIDEO_BODY_LIMIT_BYTES: usize = 64 * 1024 * 1024; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct PlayFlowDomainAdapter { @@ -460,24 +458,17 @@ fn play_flow_support_router(state: AppState) -> Router { ) .route( "/api/editor/character-animations/generations", - post(generate_editor_character_animation) - .layer(DefaultBodyLimit::max( - // 中文注释:画板角色动画首版仍兼容角色图 Data URL 入参,避免大于 Axum 默认 2MB 的角色图在 handler 前被拦截。 - EDITOR_CHARACTER_ANIMATION_BODY_LIMIT_BYTES, - )) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), + post(generate_editor_character_animation).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/editor/videos/generations", - post(generate_editor_video) - .layer(DefaultBodyLimit::max(EDITOR_VIDEO_BODY_LIMIT_BYTES)) - .route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), + post(generate_editor_video).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/editor/audios/sound-effects/generations", diff --git a/server-rs/crates/api-server/src/runtime_inventory.rs b/server-rs/crates/api-server/src/runtime_inventory.rs index 53c0e29d2..9aa2c5d4d 100644 --- a/server-rs/crates/api-server/src/runtime_inventory.rs +++ b/server-rs/crates/api-server/src/runtime_inventory.rs @@ -126,8 +126,7 @@ mod tests { #[tokio::test] async fn runtime_inventory_returns_bad_gateway_when_spacetime_not_published() { - let state = seed_authenticated_state().await; - let token = issue_access_token(&state); + let (state, token) = seed_authenticated_state().await; let app = build_router(state); let response = app @@ -161,35 +160,29 @@ mod tests { ); } - async fn seed_authenticated_state() -> AppState { + async fn seed_authenticated_state() -> (AppState, String) { let state = AppState::new(AppConfig::default()).expect("state should build"); - state + let user = state .seed_test_phone_user_with_password("13800138103", "secret123") - .await - .id; - state - } - - fn issue_access_token(state: &AppState) -> String { + .await; let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { - user_id: "user_00000001".to_string(), - session_id: state.seed_test_refresh_session_for_user_id( - "user_00000001", - "sess_runtime_inventory", - ), + user_id: user.id.clone(), + session_id: state + .seed_test_refresh_session_for_user(&user, "sess_runtime_inventory"), provider: AuthProvider::Password, roles: vec!["user".to_string()], - token_version: 2, + token_version: user.token_version, phone_verified: true, binding_status: BindingStatus::Active, - display_name: Some("背包查询用户".to_string()), + display_name: Some(user.display_name.clone()), }, state.auth_jwt_config(), OffsetDateTime::now_utc(), ) .expect("claims should build"); - sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") + let token = sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign"); + (state, token) } } diff --git a/server-rs/crates/api-server/src/tracking_outbox.rs b/server-rs/crates/api-server/src/tracking_outbox.rs index 5c1e29b7b..0f85a68c9 100644 --- a/server-rs/crates/api-server/src/tracking_outbox.rs +++ b/server-rs/crates/api-server/src/tracking_outbox.rs @@ -123,6 +123,7 @@ impl TrackingOutbox { .as_mut() .expect("active file should be open before append"); file.write_all(&line).await?; + file.flush().await?; inner.active_count = inner.active_count.saturating_add(1); inner.active_bytes = inner.active_bytes.saturating_add(line_bytes); inner.total_bytes = inner.total_bytes.saturating_add(line_bytes); @@ -571,6 +572,19 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[tokio::test] + async fn enqueue_flushes_active_file_before_success() { + let dir = test_dir("flush-before-success"); + let outbox = test_outbox(dir.clone(), 500, 1024 * 1024); + + outbox.enqueue(sample_event("event-1")).await.unwrap(); + + let active_contents = std::fs::read_to_string(dir.join(ACTIVE_FILE_NAME)).unwrap(); + assert!(active_contents.contains("event-1")); + + let _ = std::fs::remove_dir_all(dir); + } + #[tokio::test] async fn enqueue_drops_when_outbox_exceeds_max_bytes() { let dir = test_dir("max-bytes"); diff --git a/server-rs/crates/module-editor-agent/Cargo.toml b/server-rs/crates/module-editor-agent/Cargo.toml index e54246781..188b483f0 100644 --- a/server-rs/crates/module-editor-agent/Cargo.toml +++ b/server-rs/crates/module-editor-agent/Cargo.toml @@ -12,4 +12,4 @@ spacetime-types = ["dep:spacetimedb"] serde = { workspace = true } serde_json = { workspace = true } shared-kernel = { workspace = true } -spacetimedb = { workspace = true, optional = true } \ No newline at end of file +spacetimedb = { workspace = true, optional = true } diff --git a/server-rs/crates/module-editor-agent/src/events.rs b/server-rs/crates/module-editor-agent/src/events.rs new file mode 100644 index 000000000..d434b0985 --- /dev/null +++ b/server-rs/crates/module-editor-agent/src/events.rs @@ -0,0 +1 @@ +//! 编辑器 Agent 领域事件预留落位,当前不导出独立事件类型。 diff --git a/server-rs/crates/module-editor-agent/src/lib.rs b/server-rs/crates/module-editor-agent/src/lib.rs index b68fa5247..b700e48b0 100644 --- a/server-rs/crates/module-editor-agent/src/lib.rs +++ b/server-rs/crates/module-editor-agent/src/lib.rs @@ -2,6 +2,7 @@ mod application; mod commands; mod domain; mod errors; +mod events; pub use application::*; pub use commands::*; diff --git a/server-rs/crates/platform-editor-agent/Cargo.toml b/server-rs/crates/platform-editor-agent/Cargo.toml new file mode 100644 index 000000000..a38849a97 --- /dev/null +++ b/server-rs/crates/platform-editor-agent/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "platform-editor-agent" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +hmac = { workspace = true } +platform-agent = { workspace = true } +platform-llm = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +shared-contracts = { workspace = true, features = ["oss-contracts"] } diff --git a/server-rs/crates/platform-editor-agent/src/agent/agent.rs b/server-rs/crates/platform-editor-agent/src/agent/agent.rs new file mode 100644 index 000000000..5f353b646 --- /dev/null +++ b/server-rs/crates/platform-editor-agent/src/agent/agent.rs @@ -0,0 +1,198 @@ +use crate::framework::agent::{Agent, LlmApiAdaptor}; +use crate::framework::agent_builder::AgentBuilder; +use crate::framework::error::PromptError; +use crate::framework::hook::Hook; +use crate::framework::memory::AgentMemory; +use crate::framework::tool::{Tool, ToolDyn}; +use platform_agent::CREATIVE_AGENT_GPT5_MODEL; +use platform_llm::{LlmClient, LlmMessage, LlmTextRequest}; +use serde_json::Value; + +const EDITOR_AGENT_LLM_MAX_OUTPUT_TOKENS: u32 = 1024; +const EDITOR_AGENT_LLM_REQUEST_TIMEOUT_MS: u64 = 60_000; + +pub struct LlmCompletionModel { + client: LlmClient, +} + +impl LlmApiAdaptor for LlmCompletionModel { + async fn complete<'a>( + &self, + messages: impl Iterator + Send, + ) -> Result { + let request = build_editor_agent_llm_request(messages.cloned().collect()); + let response = self + .client + .request_text(request) + .await + .map_err(|e| PromptError::CompletionError(e.to_string()))?; + Ok(response.content) + } + + fn tool_result_message(&self, tool_name: &str, output: &str) -> LlmMessage { + LlmMessage::system(format!("Tool '{tool_name}' returned: {output}")) + } + + fn build_assistant_message(&self, text: &str) -> LlmMessage { + LlmMessage::assistant(text) + } +} + +fn build_editor_agent_llm_request(messages: Vec) -> LlmTextRequest { + LlmTextRequest::new(messages) + .with_model(CREATIVE_AGENT_GPT5_MODEL) + .with_max_tokens(EDITOR_AGENT_LLM_MAX_OUTPUT_TOKENS) + .with_request_timeout_ms(EDITOR_AGENT_LLM_REQUEST_TIMEOUT_MS) +} + +pub struct LlmChatAgentBuilder { + client: Option, + system_prompt_parts: Vec, + tools: Vec>, + hooks: Vec>, + max_turns: usize, + memory_data: Option>>, +} + +impl AgentBuilder for LlmChatAgentBuilder { + type Client = LlmClient; + + fn new() -> Self { + Self { + client: None, + system_prompt_parts: Vec::new(), + tools: Vec::new(), + hooks: Vec::new(), + max_turns: 10, + memory_data: None, + } + } + + fn with_client(mut self, client: LlmClient) -> Self { + self.client = Some(client); + self + } + + fn system_prompt(mut self, system_prompt: impl Into) -> Self { + self.system_prompt_parts.push(system_prompt.into()); + self + } + + fn tool(mut self, tool: impl Tool + Send + Sync + 'static) -> Self { + self.tools.push(Box::new(tool)); + self + } + + fn add_hook(mut self, hook: impl Hook + 'static) -> Self { + self.hooks.push(Box::new(hook)); + self + } + + fn max_turns(mut self, n: usize) -> Self { + self.max_turns = n; + self + } + + fn memory(mut self, memory: impl AgentMemory + 'static) -> Self { + self.memory_data = Some(Box::new(memory)); + self + } + + fn build(self) -> Agent { + let model = LlmCompletionModel { + client: self.client.expect("call .with_client() first"), + }; + let mut agent = Agent::new(model); + let tool_specs = self + .tools + .iter() + .map(|tool| ToolPromptSpec { + name: tool.tool_name().to_string(), + description: tool.description(), + parameters: tool.parameters(), + }) + .collect::>(); + let base_prompt = self.system_prompt_parts.join("\n\n"); + let system_prompt = build_tools_system_prompt(&base_prompt, &tool_specs); + agent.tools = self.tools; + agent.hooks = self.hooks; + agent.default_max_turns = self.max_turns; + agent.memory = self.memory_data; + agent.system_prompt = Some(LlmMessage::system(&system_prompt)); + agent + } +} + +struct ToolPromptSpec { + name: String, + description: String, + parameters: Value, +} + +fn build_tools_system_prompt(base_prompt: &str, tool_specs: &[ToolPromptSpec]) -> String { + let mut prompt = String::new(); + prompt.push_str(base_prompt); + prompt.push_str("\n\nYou have access to the following tools.\n\n"); + + if tool_specs.is_empty() { + prompt.push_str("(No tools available.)\n"); + } else { + prompt.push_str("## JSON Response Format\n"); + prompt.push_str("respond with valid JSON only (no markdown fences):\n"); + prompt.push_str("{\n"); + prompt.push_str(" \"reply_text\": \"your message to the user\",\n"); + prompt.push_str(" \"tool_calls\": [\n {\n"); + prompt.push_str(" \"tool_name\": \"tool_name_here\",\n"); + prompt.push_str(" \"args\": { \"argument_name\": \"argument_value\" }\n"); + prompt.push_str(" }\n ]\n"); + prompt.push_str("}\n\n"); + prompt.push_str("If you don't need to use a tool, respond with:\n"); + prompt.push_str("{\n"); + prompt.push_str(" \"reply_text\": \"your message\",\n"); + prompt.push_str(" \"tool_calls\": []\n"); + prompt.push_str("}\n\n"); + prompt.push_str("## Available Tools\n\n"); + + for tool in tool_specs { + prompt.push_str(&format!("- {}\n", tool.name)); + prompt.push_str(&format!(" Description: {}\n", tool.description)); + prompt.push_str(" Arguments JSON Schema:\n"); + let parameters = serde_json::to_string_pretty(&tool.parameters) + .unwrap_or_else(|_| tool.parameters.to_string()); + prompt.push_str(¶meters); + prompt.push_str("\n"); + } + + prompt.push_str( + "as you see, tool_calls is an array, several tools calls can be executed in one turn concurrently. ", + ); + prompt.push_str("your valid tool call will be recorded as system message"); + prompt.push_str( + "some tools calls needs user's confirmation, you should not retry the same tool call in this case.\ + And if all tool calls are pending confirmation, you should just end the turn, as you cant do more before user's action" + ); + } + + prompt +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn editor_agent_llm_request_keeps_gpt5_contract() { + let request = build_editor_agent_llm_request(vec![ + LlmMessage::system("系统提示"), + LlmMessage::user("用户请求"), + ]); + + assert_eq!( + request.model.as_deref(), + Some(platform_agent::CREATIVE_AGENT_GPT5_MODEL) + ); + assert_eq!(request.max_tokens, Some(1024)); + assert_eq!(request.request_timeout_ms, Some(60_000)); + assert_eq!(request.messages.len(), 2); + } +} diff --git a/server-rs/crates/platform-editor-agent/src/agent/asset.rs b/server-rs/crates/platform-editor-agent/src/agent/asset.rs new file mode 100644 index 000000000..0f9bced59 --- /dev/null +++ b/server-rs/crates/platform-editor-agent/src/agent/asset.rs @@ -0,0 +1,43 @@ +use hmac::digest::Digest; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use std::fmt::Display; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ImageId { + pub id: String, +} + +impl ImageId { + /// Keep data keys server-side while giving the model a stable image reference. + pub fn from_data_key(data_key: impl AsRef) -> Self { + let digest = Sha256::digest(data_key.as_ref().as_bytes()); + Self { + id: format!("sha256:{digest:x}"), + } + } +} + +impl Serialize for ImageId { + fn serialize(&self, serializer: S) -> Result { + self.id.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ImageId { + fn deserialize>(deserializer: D) -> Result { + String::deserialize(deserializer).map(|id| ImageId { id }) + } +} + +impl Display for ImageId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.id) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageMetadata { + pub tag: String, + pub data_key: String, +} diff --git a/server-rs/crates/platform-editor-agent/src/agent/mod.rs b/server-rs/crates/platform-editor-agent/src/agent/mod.rs new file mode 100644 index 000000000..ab3846cdf --- /dev/null +++ b/server-rs/crates/platform-editor-agent/src/agent/mod.rs @@ -0,0 +1,3 @@ +pub mod agent; +pub mod asset; +pub mod tools; diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/context.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/context.rs new file mode 100644 index 000000000..2cd2aebfd --- /dev/null +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/context.rs @@ -0,0 +1,22 @@ +use crate::agent::asset::ImageId; +use crate::agent::asset::ImageMetadata; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +impl EditorToolContext { + /// Check if an image with the given ID exists in the context. + pub fn contains_image(&self, image_id: &ImageId) -> bool { + self.images.contains_key(image_id) + } + + pub fn image_data_key(&self, image_id: &ImageId) -> Option<&str> { + self.images + .get(image_id) + .map(|metadata| metadata.data_key.as_str()) + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct EditorToolContext { + pub images: HashMap, +} diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/edit_image.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/edit_image.rs new file mode 100644 index 000000000..865b78eb8 --- /dev/null +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/edit_image.rs @@ -0,0 +1,175 @@ +use crate::agent::asset::ImageId; +use crate::agent::tools::context::EditorToolContext; +use crate::framework::tool::{Tool, ToolFailure, ToolFailureKind}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::error::Error; +use std::fmt::Display; +pub struct EditImageTool { + pub context: EditorToolContext, +} + +#[derive(Debug, Clone)] +pub enum EditImageError { + ObjectImageNotProvided, + PromptNotProvided, + AssetNotFound(ImageId), +} + +impl Display for EditImageError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EditImageError::ObjectImageNotProvided => { + write!(f, "object image not provided") + } + EditImageError::PromptNotProvided => write!(f, "prompt not provided"), + EditImageError::AssetNotFound(image_id) => { + write!(f, "asset {image_id} not found in context") + } + } + } +} + +impl Error for EditImageError {} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EditImageToolArgs { + pub object_image_id: ImageId, + #[serde(default)] + pub reference_image_ids: Vec, + pub prompt: String, + // #[serde(default)] + // pub tag: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EditImageToolOutput { + pub message: String, +} + +impl Tool for EditImageTool { + const NAME: &'static str = "edit-image"; + type Error = EditImageError; + type Args = EditImageToolArgs; + type Output = EditImageToolOutput; + + fn description(&self) -> String { + "仅用于修改已有图片:换衣服、改颜色、替换背景、局部重绘,或保持主体、构图、姿势不变的编辑。用户指代“这张”“刚才那个”“上一张”“改成”“换成”时优先使用本工具。必须使用已有图片上下文;没有参考图时不要调用,应提示用户先选择参考图。" + .to_string() + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "object_image_id": { + "type": "string", + "description": "要修改的目标图片。" + }, + "reference_image_ids": { + "type": "array", + "items": { "type": "string" }, + "description": "修改参考图 ID 列表(可选的,用于提供风格或元素参考)。" + }, + "prompt": { + "type": "string", + "description": "编辑提示词,描述希望如何修改图片。例如「把背景换成红色」、「把人物改成坐着」。" + }, + // "tag": { + // "type": "string", + // "description": "为新生成的图片添加标签,用于后续在上下文中引用。" + // } + }, + "required": ["object_image_id", "prompt"], + "additionalProperties": false + }) + } + + fn call( + &self, + args: Self::Args, + ) -> impl Future> + Send { + async move { + if let Some(error) = self.validate_args(&args) { + return Err(error); + } + Ok(EditImageToolOutput { + message: "this tool call is pending user confirmation. if all is pending, just end this turn".to_string(), + }) + } + } + + fn classify_error(&self, error: &Self::Error) -> ToolFailure { + match error { + EditImageError::AssetNotFound(_) => { + ToolFailure::new(ToolFailureKind::NotFound, error.to_string()) + } + EditImageError::ObjectImageNotProvided | EditImageError::PromptNotProvided => { + ToolFailure::invalid_args(error.to_string()) + } + } + } +} + +/// Typed result deserialized from `edit_editor_image_for_owner` response. +/// +/// Mirrors `EditorImageGenerationResponse` but uses `String` instead of `&'static str` +/// and `Value` for nested payload types so that `#[derive(Deserialize)]` works. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorImageEditResult { + pub image_src: String, + pub object_key: Option, + pub asset_object_id: Option, + pub width: u32, + pub height: u32, + pub source_type: String, + pub prompt: String, + pub actual_prompt: Option, + pub model: String, + pub provider: String, + pub task_id: String, + pub resource: Option, + pub asset: Option, + pub project: Option, +} + +impl EditImageTool { + /// Validate the semantic correctness of the arguments. + fn validate_args(&self, args: &EditImageToolArgs) -> Option { + if args.prompt.trim().is_empty() { + return Some(EditImageError::PromptNotProvided); + } + if !self.context.contains_image(&args.object_image_id) { + return Some(EditImageError::AssetNotFound(args.object_image_id.clone())); + } + + for ref_id in &args.reference_image_ids { + if !self.context.contains_image(ref_id) { + return Some(EditImageError::AssetNotFound(ref_id.clone())); + } + } + None + } + + pub fn format_execute_message( + &self, + args: ::Args, + result: EditorImageEditResult, + ) -> String { + let name = Self::NAME.to_string(); + let arg_json = serde_json::to_string(&args).unwrap_or_default(); + + let data_key = result + .object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.trim_start_matches('/').to_string()) + .unwrap_or_else(|| result.image_src.clone()); + let image_id = ImageId::from_data_key(data_key); + format!( + "[tool_call:{name}] args: {arg_json} output: edited result saved as image: {image_id}" + ) + } +} diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_background_music.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_background_music.rs new file mode 100644 index 000000000..1b262a431 --- /dev/null +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_background_music.rs @@ -0,0 +1,83 @@ +use crate::framework::tool::{Tool, ToolFailure}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use shared_contracts::assets::EditorAudioGenerateResponse; +use std::error::Error; +use std::fmt::Display; + +pub struct GenerateBackgroundMusicTool; + +#[derive(Debug, Clone)] +pub enum GenerateBackgroundMusicError { + PromptNotProvided, +} +impl Display for GenerateBackgroundMusicError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "background music prompt not provided") + } +} +impl Error for GenerateBackgroundMusicError {} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerateBackgroundMusicToolArgs { + pub prompt: String, + #[serde(default = "default_instrumental")] + pub make_instrumental: bool, +} +fn default_instrumental() -> bool { + true +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerateBackgroundMusicToolOutput { + pub message: String, +} + +impl Tool for GenerateBackgroundMusicTool { + const NAME: &'static str = "generate-background-music"; + type Error = GenerateBackgroundMusicError; + type Args = GenerateBackgroundMusicToolArgs; + type Output = GenerateBackgroundMusicToolOutput; + fn description(&self) -> String { + "根据文字描述生成背景音乐。默认生成纯音乐,除非明确要求歌词或人声。".to_string() + } + fn parameters(&self) -> Value { + json!({ + "type": "object", "properties": { + "prompt": { "type": "string", "description": "音乐风格、情绪、乐器和节奏描述。" }, + "make_instrumental": { "type": "boolean", "description": "是否生成纯音乐,默认 true。" } + }, "required": ["prompt"], "additionalProperties": false + }) + } + fn call( + &self, + args: Self::Args, + ) -> impl Future> + Send { + async move { + if args.prompt.trim().is_empty() { + return Err(GenerateBackgroundMusicError::PromptNotProvided); + } + Ok(GenerateBackgroundMusicToolOutput { + message: "this tool call is pending user confirmation. if all is pending, just end this turn".to_string(), + }) + } + } + fn classify_error(&self, error: &Self::Error) -> ToolFailure { + ToolFailure::invalid_args(error.to_string()) + } +} + +impl GenerateBackgroundMusicTool { + pub fn format_execute_message( + &self, + args: &GenerateBackgroundMusicToolArgs, + result: &EditorAudioGenerateResponse, + ) -> String { + format!( + "[tool_call:{}] args: {} output: generated audio saved as: {}", + Self::NAME, + serde_json::to_string(args).unwrap_or_default(), + result.audio_src + ) + } +} diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_character.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_character.rs new file mode 100644 index 000000000..6cd34f41f --- /dev/null +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_character.rs @@ -0,0 +1,79 @@ +use crate::agent::tools::context::EditorToolContext; +use crate::agent::tools::generate_image::{ + EditorImageGenerationResult, GenerateImageError, GenerateImageTool, GenerateImageToolArgs, + GenerateImageToolOutput, +}; +use crate::framework::tool::{Tool, ToolFailure}; +use serde_json::{Value, json}; + +pub struct GenerateCharacterTool { + pub context: EditorToolContext, +} + +impl Tool for GenerateCharacterTool { + const NAME: &'static str = "generate-character"; + type Error = GenerateImageError; + type Args = GenerateImageToolArgs; + type Output = GenerateImageToolOutput; + + fn description(&self) -> String { + "仅用于生成新的角色形象、人物立绘或普通角色设定图。已有角色图的服装、颜色、表情、姿势或背景修改必须选择 edit-image。角色规范图、角色美术视觉规范设定图或规范展板必须选择 generate-image。" + .to_string() + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "prompt": { "type": "string", "description": "角色外貌、服装、姿势、画风和构图的完整描述。" }, + "reference_image_ids": { "type": "array", "items": { "type": "string" }, "description": "可选角色或画风参考图 ID。" }, + "aspect_ratio": { "type": "string", "description": "可选比例,例如 2:3 或 9:16。" }, + "image_size": { "type": "string", "description": "可选清晰度,例如 1K 或 2K。" } + }, + "required": ["prompt"], + "additionalProperties": false + }) + } + + fn call( + &self, + args: Self::Args, + ) -> impl Future> + Send { + async move { + GenerateImageTool { + context: self.context.clone(), + } + .validate_args(&args)?; + Ok(GenerateImageToolOutput { message: "this tool call is pending user confirmation. if all is pending, just end this turn".to_string() }) + } + } + + fn classify_error(&self, error: &Self::Error) -> ToolFailure { + GenerateImageTool { + context: self.context.clone(), + } + .classify_error(error) + } +} + +impl GenerateCharacterTool { + pub fn format_execute_message( + &self, + args: &GenerateImageToolArgs, + result: &EditorImageGenerationResult, + ) -> String { + let tool_name = Self::NAME; + let args = serde_json::to_string(args).unwrap_or_default(); + let data_key = result + .object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.trim_start_matches('/').to_string()) + .unwrap_or_else(|| result.image_src.clone()); + let image_id = crate::agent::asset::ImageId::from_data_key(data_key); + format!( + "[tool_call:{tool_name}] args: {args} output: generated result saved as image: {image_id}" + ) + } +} diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs new file mode 100644 index 000000000..d1892b901 --- /dev/null +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs @@ -0,0 +1,172 @@ +use crate::agent::asset::ImageId; +use crate::agent::tools::context::EditorToolContext; +use crate::framework::tool::{Tool, ToolFailure, ToolFailureKind}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::error::Error; +use std::fmt::Display; + +pub struct GenerateIconSpritesheetTool { + pub context: EditorToolContext, +} + +#[derive(Debug, Clone)] +pub enum GenerateIconSpritesheetError { + ReferenceNotProvided, + DescriptionsNotProvided, + AssetNotFound(ImageId), +} + +impl Display for GenerateIconSpritesheetError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ReferenceNotProvided => write!(f, "reference image not provided"), + Self::DescriptionsNotProvided => write!(f, "icon descriptions not provided"), + Self::AssetNotFound(image_id) => write!(f, "asset {image_id} not found in context"), + } + } +} + +impl Error for GenerateIconSpritesheetError {} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerateIconSpritesheetToolArgs { + pub reference_image_id: ImageId, + #[serde(default)] + pub reference_image_ids: Vec, + pub icon_descriptions: Vec, + #[serde(default)] + pub aspect_ratio: Option, + #[serde(default)] + pub image_size: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerateIconSpritesheetToolOutput { + pub message: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorIconSpritesheetResult { + pub spritesheet_image_src: String, + pub spritesheet_width: u32, + pub spritesheet_height: u32, + pub task_id: String, + pub spritesheet_resource: Option, + pub spritesheet_asset: Option, + pub project: Option, + #[serde(default)] + pub icon_image_srcs: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorIconSpritesheetImage { + pub image_src: String, + pub width: u32, + pub height: u32, + pub resource: Option, +} + +impl Tool for GenerateIconSpritesheetTool { + const NAME: &'static str = "generate-icon-spritesheet"; + type Error = GenerateIconSpritesheetError; + type Args = GenerateIconSpritesheetToolArgs; + type Output = GenerateIconSpritesheetToolOutput; + + fn description(&self) -> String { + "仅用于生成多个图标成品、图标素材图集或 spritesheet。必须提供图标规范或风格参考图,并填写多个 icon_descriptions;没有参考图时不要调用,应提示用户先选择参考图。图标规范图或图标视觉规范展板不是本工具,应使用 generate-image。".to_string() + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "reference_image_id": { "type": "string", "description": "必填的图标规范或风格参考图 ID。" }, + "reference_image_ids": { "type": "array", "items": { "type": "string" }, "description": "可选的额外参考图 ID。" }, + "icon_descriptions": { "type": "array", "items": { "type": "string" }, "description": "要生成的多个图标描述。" }, + "aspect_ratio": { "type": "string", "description": "可选图集比例。" }, + "image_size": { "type": "string", "description": "可选清晰度,例如 1K 或 2K。" } + }, + "required": ["reference_image_id", "icon_descriptions"], + "additionalProperties": false + }) + } + + fn call( + &self, + args: Self::Args, + ) -> impl Future> + Send { + async move { + self.validate_args(&args)?; + Ok(GenerateIconSpritesheetToolOutput { + message: "this tool call is pending user confirmation. if all is pending, just end this turn".to_string(), + }) + } + } + + fn classify_error(&self, error: &Self::Error) -> ToolFailure { + match error { + GenerateIconSpritesheetError::AssetNotFound(_) => { + ToolFailure::new(ToolFailureKind::NotFound, error.to_string()) + } + GenerateIconSpritesheetError::ReferenceNotProvided + | GenerateIconSpritesheetError::DescriptionsNotProvided => { + ToolFailure::invalid_args(error.to_string()) + } + } + } +} + +impl GenerateIconSpritesheetTool { + fn validate_args( + &self, + args: &GenerateIconSpritesheetToolArgs, + ) -> Result<(), GenerateIconSpritesheetError> { + if args.reference_image_id.id.trim().is_empty() { + return Err(GenerateIconSpritesheetError::ReferenceNotProvided); + } + if !self.context.contains_image(&args.reference_image_id) { + return Err(GenerateIconSpritesheetError::AssetNotFound( + args.reference_image_id.clone(), + )); + } + if args + .icon_descriptions + .iter() + .all(|description| description.trim().is_empty()) + { + return Err(GenerateIconSpritesheetError::DescriptionsNotProvided); + } + for image_id in &args.reference_image_ids { + if !self.context.contains_image(image_id) { + return Err(GenerateIconSpritesheetError::AssetNotFound( + image_id.clone(), + )); + } + } + Ok(()) + } + pub fn format_execute_message( + &self, + args: &GenerateIconSpritesheetToolArgs, + result: &EditorIconSpritesheetResult, + ) -> String { + let tool_name = Self::NAME; + let args = serde_json::to_string(args).unwrap_or_default(); + let data_key = result + .spritesheet_resource + .as_ref() + .and_then(|resource| resource.get("objectKey")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.trim_start_matches('/').to_string()) + .unwrap_or_else(|| result.spritesheet_image_src.clone()); + let image_id = ImageId::from_data_key(data_key); + format!( + "[tool_call:{tool_name}] args: {args} output: generated spritesheet saved as image: {image_id}", + ) + } +} diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_image.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_image.rs new file mode 100644 index 000000000..959c1c31f --- /dev/null +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_image.rs @@ -0,0 +1,158 @@ +use crate::agent::asset::ImageId; +use crate::agent::tools::context::EditorToolContext; +use crate::framework::tool::{Tool, ToolFailure, ToolFailureKind}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::error::Error; +use std::fmt::Display; + +pub struct GenerateImageTool { + pub context: EditorToolContext, +} + +#[derive(Debug, Clone)] +pub enum GenerateImageError { + PromptNotProvided, + AssetNotFound(ImageId), +} + +impl Display for GenerateImageError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PromptNotProvided => write!(f, "prompt not provided"), + Self::AssetNotFound(image_id) => write!(f, "asset {image_id} not found in context"), + } + } +} + +impl Error for GenerateImageError {} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerateImageToolArgs { + pub prompt: String, + #[serde(default)] + pub reference_image_ids: Vec, + #[serde(default)] + // TODO restrict to a set of possible values + pub aspect_ratio: Option, + #[serde(default)] + pub image_size: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerateImageToolOutput { + pub message: String, +} + +impl Tool for GenerateImageTool { + const NAME: &'static str = "generate-image"; + type Error = GenerateImageError; + type Args = GenerateImageToolArgs; + type Output = GenerateImageToolOutput; + + fn description(&self) -> String { + "用于从文字生成全新图片:新场景、新物体、新插画或新背景;也专用于规范图、视觉规范图、风格规范图、素材规范展板。不要用来修改已有图,已有图修改应选 edit-image。规范展板 prompt 必须写明统一视角、线条粗细、描边、填充风格、材质、阴影、圆角、状态层级、色卡或色号、尺寸标注和排版层级;角色规范展板还要含头身比例、标准立绘、动作帧样例、服饰配饰分层和专属色卡。完整 prompt 必须包含画面、主体、风格、构图和背景。" + .to_string() + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "完整的生图提示词,包含主体、场景、风格、构图和背景。" + }, + "reference_image_ids": { + "type": "array", + "items": { "type": "string" }, + "description": "可选参考图 ID 列表,用于提供画风或元素参考。" + }, + "aspect_ratio": { + "type": "string", + "description": "可选画面比例,例如 1:1、16:9、9:16。" + }, + "image_size": { + "type": "string", + "description": "可选图片清晰度,例如 1K、2K、4K。" + } + }, + "required": ["prompt"], + "additionalProperties": false + }) + } + + fn call( + &self, + args: Self::Args, + ) -> impl Future> + Send { + async move { + self.validate_args(&args)?; + Ok(GenerateImageToolOutput { + message: "this tool call is pending user confirmation.".to_string(), + }) + } + } + + fn classify_error(&self, error: &Self::Error) -> ToolFailure { + match error { + GenerateImageError::AssetNotFound(_) => { + ToolFailure::new(ToolFailureKind::NotFound, error.to_string()) + } + GenerateImageError::PromptNotProvided => ToolFailure::invalid_args(error.to_string()), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorImageGenerationResult { + pub image_src: String, + pub object_key: Option, + pub asset_object_id: Option, + pub width: u32, + pub height: u32, + pub source_type: String, + pub prompt: String, + pub actual_prompt: Option, + pub model: String, + pub provider: String, + pub task_id: String, + pub resource: Option, + pub asset: Option, + pub project: Option, +} + +impl GenerateImageTool { + pub fn validate_args(&self, args: &GenerateImageToolArgs) -> Result<(), GenerateImageError> { + if args.prompt.trim().is_empty() { + return Err(GenerateImageError::PromptNotProvided); + } + for image_id in &args.reference_image_ids { + if !self.context.contains_image(image_id) { + return Err(GenerateImageError::AssetNotFound(image_id.clone())); + } + } + Ok(()) + } + + pub fn format_execute_message( + &self, + args: &GenerateImageToolArgs, + result: &EditorImageGenerationResult, + ) -> String { + let args = serde_json::to_string(args).unwrap_or_default(); + let data_key = result + .object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.trim_start_matches('/').to_string()) + .unwrap_or_else(|| result.image_src.clone()); + let image_id = ImageId::from_data_key(data_key); + format!( + "[tool_call:{}] args: {args} output: generated result saved as image: {image_id}", + Self::NAME, + ) + } +} diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_sound_effect.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_sound_effect.rs new file mode 100644 index 000000000..15fff27ec --- /dev/null +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_sound_effect.rs @@ -0,0 +1,92 @@ +use crate::framework::tool::{Tool, ToolFailure}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use shared_contracts::assets::EditorAudioGenerateResponse; +use std::error::Error; +use std::fmt::Display; + +pub struct GenerateSoundEffectTool; + +#[derive(Debug, Clone)] +pub enum GenerateSoundEffectError { + PromptNotProvided, +} + +impl Display for GenerateSoundEffectError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "sound effect prompt not provided") + } +} + +impl Error for GenerateSoundEffectError {} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerateSoundEffectToolArgs { + pub prompt: String, + #[serde(default)] + pub duration: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerateSoundEffectToolOutput { + pub message: String, +} + +impl Tool for GenerateSoundEffectTool { + const NAME: &'static str = "generate-sound-effect"; + type Error = GenerateSoundEffectError; + type Args = GenerateSoundEffectToolArgs; + type Output = GenerateSoundEffectToolOutput; + + fn description(&self) -> String { + "根据文字描述生成一段音效,例如脚步声、按钮点击声或环境音。".to_string() + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "prompt": { "type": "string", "description": "音效内容、材质、节奏和情绪描述。" }, + "duration": { "type": "integer", "description": "可选时长(秒)。" }, + // "model": { "type": "string", "description": "可选音效模型。" } + }, + "required": ["prompt"], + "additionalProperties": false + }) + } + + fn call( + &self, + args: Self::Args, + ) -> impl Future> + Send { + async move { + if args.prompt.trim().is_empty() { + return Err(GenerateSoundEffectError::PromptNotProvided); + } + Ok(GenerateSoundEffectToolOutput { + message: "this tool call is pending user confirmation. if all is pending, just end this turn".to_string(), + }) + } + } + + fn classify_error(&self, error: &Self::Error) -> ToolFailure { + ToolFailure::invalid_args(error.to_string()) + } +} + +impl GenerateSoundEffectTool { + // pub const DEFAULT_MODEL: &'static str = "audio1.0"; + + pub fn format_execute_message( + &self, + args: &GenerateSoundEffectToolArgs, + result: &EditorAudioGenerateResponse, + ) -> String { + format!( + "[tool_call:{}] args: {} output: generated audio saved as: {}", + Self::NAME, + serde_json::to_string(args).unwrap_or_default(), + result.audio_src + ) + } +} diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_ui_design.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_ui_design.rs new file mode 100644 index 000000000..2a7ee56b3 --- /dev/null +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_ui_design.rs @@ -0,0 +1,78 @@ +use crate::agent::tools::context::EditorToolContext; +use crate::agent::tools::generate_image::{ + EditorImageGenerationResult, GenerateImageError, GenerateImageTool, GenerateImageToolArgs, + GenerateImageToolOutput, +}; +use crate::framework::tool::{Tool, ToolFailure}; +use serde_json::{Value, json}; + +pub struct GenerateUiDesignTool { + pub context: EditorToolContext, +} + +impl Tool for GenerateUiDesignTool { + const NAME: &'static str = "generate-ui-design"; + type Error = GenerateImageError; + type Args = GenerateImageToolArgs; + type Output = GenerateImageToolOutput; + + fn description(&self) -> String { + "仅用于生成完整可用的 UI 设计图或界面稿,包括 HUD、弹窗、面板、按钮组合和整页界面。不要用于提取图标、拆素材或修改已有图;UI 规范图、组件规范展板或视觉规范展板应使用 generate-image。".to_string() + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "prompt": { "type": "string", "description": "完整 UI 画面、信息层级、视觉风格和构图描述。" }, + "reference_image_ids": { "type": "array", "items": { "type": "string" }, "description": "可选 UI 风格或布局参考图 ID。" }, + "aspect_ratio": { "type": "string", "description": "可选画面比例。" }, + "image_size": { "type": "string", "description": "可选清晰度,例如 1K 或 2K。" } + }, + "required": ["prompt"], + "additionalProperties": false + }) + } + + fn call( + &self, + args: Self::Args, + ) -> impl Future> + Send { + async move { + GenerateImageTool { + context: self.context.clone(), + } + .validate_args(&args)?; + Ok(GenerateImageToolOutput { message: "this tool call is pending user confirmation. if all is pending, just end this turn".to_string() }) + } + } + + fn classify_error(&self, error: &Self::Error) -> ToolFailure { + GenerateImageTool { + context: self.context.clone(), + } + .classify_error(error) + } +} + +impl GenerateUiDesignTool { + pub fn format_execute_message( + &self, + args: &GenerateImageToolArgs, + result: &EditorImageGenerationResult, + ) -> String { + let tool_name = Self::NAME; + let args = serde_json::to_string(args).unwrap_or_default(); + let data_key = result + .object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.trim_start_matches('/').to_string()) + .unwrap_or_else(|| result.image_src.clone()); + let image_id = crate::agent::asset::ImageId::from_data_key(data_key); + format!( + "[tool_call:{tool_name}] args: {args} output: generated result saved as image: {image_id}" + ) + } +} diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_video.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_video.rs new file mode 100644 index 000000000..81cf19361 --- /dev/null +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_video.rs @@ -0,0 +1,117 @@ +use crate::agent::asset::ImageId; +use crate::agent::tools::context::EditorToolContext; +use crate::framework::tool::{Tool, ToolFailure, ToolFailureKind}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use shared_contracts::assets::EditorVideoGenerateResponse; +use std::error::Error; +use std::fmt::Display; + +pub struct GenerateVideoTool { + pub context: EditorToolContext, +} + +#[derive(Debug, Clone)] +pub enum GenerateVideoError { + PromptNotProvided, + AssetNotFound(ImageId), +} +impl Display for GenerateVideoError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PromptNotProvided => write!(f, "video prompt not provided"), + Self::AssetNotFound(image_id) => write!(f, "asset {image_id} not found in context"), + } + } +} +impl Error for GenerateVideoError {} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerateVideoToolArgs { + pub prompt: String, + #[serde(default)] + pub reference_image_ids: Vec, + #[serde(default)] + pub aspect_ratio: Option, + #[serde(default)] + pub duration_seconds: Option, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub resolution: Option, + #[serde(default)] + pub sound: Option, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerateVideoToolOutput { + pub message: String, +} + +impl Tool for GenerateVideoTool { + const NAME: &'static str = "generate-video"; + type Error = GenerateVideoError; + type Args = GenerateVideoToolArgs; + type Output = GenerateVideoToolOutput; + fn description(&self) -> String { + "根据文字描述生成一段短视频;可选图片参考图用于镜头风格或主体参考。".to_string() + } + fn parameters(&self) -> Value { + json!({ + "type": "object", "properties": { + "prompt": { "type": "string", "description": "镜头、主体动作、场景和风格描述。" }, + "reference_image_ids": { "type": "array", "items": { "type": "string" }, "description": "可选图片参考图 ID。" }, + "aspect_ratio": { "type": "string", "description": "可选比例,默认 16:9。" }, + "duration_seconds": { "type": "integer", "description": "可选时长,默认 4 秒。" }, + "model": { "type": "string", "description": "可选视频模型。" }, + "resolution": { "type": "string", "description": "可选清晰度,默认 720p。" }, + "sound": { "type": "string", "description": "是否生成声音,默认 off。" } + }, "required": ["prompt"], "additionalProperties": false + }) + } + fn call( + &self, + args: Self::Args, + ) -> impl Future> + Send { + async move { + if args.prompt.trim().is_empty() { + return Err(GenerateVideoError::PromptNotProvided); + } + for id in &args.reference_image_ids { + if !self.context.contains_image(id) { + return Err(GenerateVideoError::AssetNotFound(id.clone())); + } + } + Ok(GenerateVideoToolOutput { + message: "this tool call is pending user confirmation. if all is pending, just end this turn".to_string(), + }) + } + } + fn classify_error(&self, error: &Self::Error) -> ToolFailure { + match error { + GenerateVideoError::AssetNotFound(_) => { + ToolFailure::new(ToolFailureKind::NotFound, error.to_string()) + } + GenerateVideoError::PromptNotProvided => ToolFailure::invalid_args(error.to_string()), + } + } +} + +impl GenerateVideoTool { + pub const DEFAULT_VIDEO_MODEL: &'static str = "seedance2.0-fast"; + pub const DEFAULT_VIDEO_RESOLUTION: &'static str = "720p"; + pub const DEFAULT_VIDEO_DURATION_SECONDS: u32 = 4; + + pub fn format_execute_message( + &self, + args: &GenerateVideoToolArgs, + result: &EditorVideoGenerateResponse, + ) -> String { + let video_id = ImageId::from_data_key(result.video_src.as_str()); + format!( + "[tool_call:{}] args: {} output: generated video saved as: {}", + Self::NAME, + serde_json::to_string(args).unwrap_or_default(), + video_id + ) + } +} diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/mod.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/mod.rs new file mode 100644 index 000000000..f3ea99cc1 --- /dev/null +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/mod.rs @@ -0,0 +1,9 @@ +pub mod context; +pub mod edit_image; +pub mod generate_background_music; +pub mod generate_character; +pub mod generate_icon_spritesheet; +pub mod generate_image; +pub mod generate_sound_effect; +pub mod generate_ui_design; +pub mod generate_video; diff --git a/server-rs/crates/platform-editor-agent/src/framework/agent.rs b/server-rs/crates/platform-editor-agent/src/framework/agent.rs new file mode 100644 index 000000000..824123030 --- /dev/null +++ b/server-rs/crates/platform-editor-agent/src/framework/agent.rs @@ -0,0 +1,79 @@ +use crate::framework::error::PromptError; +use crate::framework::hook::Hook; +use crate::framework::memory::AgentMemory; +use crate::framework::run::PromptRequest; +use crate::framework::tool::{Tool, ToolDyn}; + +pub struct Agent, Message> { + pub model: M, + pub tools: Vec>, + pub hooks: Vec>, + pub default_max_turns: usize, + pub system_prompt: Option, + pub memory: Option>>, +} + +impl Agent +where + M: LlmApiAdaptor + 'static, + Message: Send + 'static, +{ + pub fn new(model: M) -> Self { + Self { + model, + tools: Vec::new(), + hooks: Vec::new(), + default_max_turns: 10, + system_prompt: None, + memory: None, + } + } + + pub fn tool(mut self, tool: impl Tool + Send + Sync + 'static) -> Self { + self.tools.push(Box::new(tool)); + self + } + + pub fn system_prompt(mut self, msg: Message) -> Self { + self.system_prompt = Some(msg); + self + } + + pub fn memory(mut self, mem: impl AgentMemory + 'static) -> Self { + self.memory = Some(Box::new(mem)); + self + } + + pub fn hook(mut self, hook: impl Hook + 'static) -> Self { + self.hooks.push(Box::new(hook)); + self + } + + pub fn max_turns(mut self, n: usize) -> Self { + self.default_max_turns = n; + self + } + + pub fn tools(&self) -> &[Box] { + &self.tools + } + + pub fn prompt(&mut self, message: impl Into + Send) -> PromptRequest<'_, M, Message> + where + Message: 'static, + { + PromptRequest::new(self, message.into()) + } +} + +pub trait LlmApiAdaptor: Send + Sync { + fn complete<'a>( + &self, + messages: impl Iterator + Send, + ) -> impl Future> + Send + where + Message: 'a; + fn tool_result_message(&self, tool_name: &str, output: &str) -> Message; + + fn build_assistant_message(&self, text: &str) -> Message; +} diff --git a/server-rs/crates/platform-editor-agent/src/framework/agent_builder.rs b/server-rs/crates/platform-editor-agent/src/framework/agent_builder.rs new file mode 100644 index 000000000..f2f90d8cc --- /dev/null +++ b/server-rs/crates/platform-editor-agent/src/framework/agent_builder.rs @@ -0,0 +1,18 @@ +use crate::framework::agent::{Agent, LlmApiAdaptor}; +use crate::framework::hook::Hook; +use crate::framework::memory::AgentMemory; +use crate::framework::tool::Tool; + +pub trait AgentBuilder> { + type Client; + + fn new() -> Self; + fn with_client(self, client: Self::Client) -> Self; + fn system_prompt(self, system_prompt: impl Into) -> Self; + fn tool(self, tool: impl Tool + Send + Sync + 'static) -> Self; + fn add_hook(self, hook: impl Hook + 'static) -> Self; + fn max_turns(self, n: usize) -> Self; + fn memory(self, memory: impl AgentMemory + 'static) -> Self; + + fn build(self) -> Agent; +} diff --git a/server-rs/crates/platform-editor-agent/src/framework/error.rs b/server-rs/crates/platform-editor-agent/src/framework/error.rs new file mode 100644 index 000000000..bc830dbac --- /dev/null +++ b/server-rs/crates/platform-editor-agent/src/framework/error.rs @@ -0,0 +1,22 @@ +#[derive(Debug, Clone)] +pub enum PromptError { + CompletionError(String), + ToolError(String), + InternalError(String), + MaxTurnsReached { max_turns: usize }, +} + +impl std::fmt::Display for PromptError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CompletionError(msg) => write!(f, "completion error: {msg}"), + Self::ToolError(msg) => write!(f, "tool error: {msg}"), + Self::InternalError(msg) => write!(f, "internal error: {msg}"), + Self::MaxTurnsReached { max_turns } => { + write!(f, "max turns reached: {max_turns}") + } + } + } +} + +impl std::error::Error for PromptError {} diff --git a/server-rs/crates/platform-editor-agent/src/framework/hook.rs b/server-rs/crates/platform-editor-agent/src/framework/hook.rs new file mode 100644 index 000000000..c5fd255a3 --- /dev/null +++ b/server-rs/crates/platform-editor-agent/src/framework/hook.rs @@ -0,0 +1,14 @@ +use crate::framework::run::{TextFlow, ToolCallFlow}; +use crate::framework::tool::ToolCall; + +pub trait Hook: Send + Sync { + fn on_text_reply(&self, _text: &str) -> TextFlow { + TextFlow::Continue + } + fn before_tool_call(&self, _tool_call: &ToolCall) -> ToolCallFlow { + ToolCallFlow::Continue + } + fn after_tool_call(&self, _tool_name: &str, _output: &mut serde_json::Value) -> ToolCallFlow { + ToolCallFlow::Continue + } +} diff --git a/server-rs/crates/platform-editor-agent/src/framework/memory.rs b/server-rs/crates/platform-editor-agent/src/framework/memory.rs new file mode 100644 index 000000000..3157d7387 --- /dev/null +++ b/server-rs/crates/platform-editor-agent/src/framework/memory.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; + +pub trait AgentMemory: Send + Sync { + fn get_memory(&self) -> &[Message]; + fn append_message(&mut self, message: Message); +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VecMemory { + messages: Vec, +} + +impl VecMemory { + pub fn new(messages: Vec) -> Self { + Self { messages } + } + + pub fn into_inner(self) -> Vec { + self.messages + } +} + +impl AgentMemory for VecMemory { + fn get_memory(&self) -> &[Message] { + &self.messages + } + + fn append_message(&mut self, message: Message) { + self.messages.push(message); + } +} diff --git a/server-rs/crates/platform-editor-agent/src/framework/mod.rs b/server-rs/crates/platform-editor-agent/src/framework/mod.rs new file mode 100644 index 000000000..0f84bfb15 --- /dev/null +++ b/server-rs/crates/platform-editor-agent/src/framework/mod.rs @@ -0,0 +1,7 @@ +pub mod agent; +pub mod agent_builder; +pub mod error; +pub mod hook; +pub mod memory; +pub mod run; +pub mod tool; diff --git a/server-rs/crates/platform-editor-agent/src/framework/run.rs b/server-rs/crates/platform-editor-agent/src/framework/run.rs new file mode 100644 index 000000000..2b4a90a1c --- /dev/null +++ b/server-rs/crates/platform-editor-agent/src/framework/run.rs @@ -0,0 +1,278 @@ +use crate::framework::agent::Agent; +use crate::framework::agent::LlmApiAdaptor; +use crate::framework::error::PromptError; +use crate::framework::hook::Hook; +use crate::framework::memory::VecMemory; +use crate::framework::run::PromptOutput::{Text, Tool}; +use crate::framework::tool::{ToolCall, ToolDyn, ToolExecutionResult, ToolFailure, ToolOutcome}; +use serde::Deserialize; +use serde_json::Value; +use std::pin::Pin; + +pub type TextOutput = String; + +#[derive(Debug, Clone)] +pub struct ToolCallOutput { + pub tool_call: ToolCall, + pub message: String, +} +#[derive(Debug, Clone)] +pub enum PromptOutput { + Text(TextOutput), + Tool(ToolCallOutput), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TextFlow { + Continue, + Stop, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolCallFlow { + Continue, + Skip, + Stop, +} + +pub struct PromptRequest<'a, M: LlmApiAdaptor + 'a, Message: 'a> { + agent: &'a mut Agent, + message: Message, + system_prompt: Option, + hooks: Vec>, + max_turns: usize, +} + +impl<'a, M, Message> PromptRequest<'a, M, Message> +where + M: LlmApiAdaptor + 'a, + Message: 'a, +{ + pub fn new(agent: &'a mut Agent, message: Message) -> Self { + let max_turns = agent.default_max_turns; + Self { + agent, + message, + system_prompt: None, + hooks: Vec::new(), + max_turns, + } + } + + pub fn system_prompt(mut self, msg: Message) -> Self { + self.system_prompt = Some(msg); + self + } + + pub fn add_hook(mut self, hook: impl Hook + 'static) -> Self { + self.hooks.push(Box::new(hook)); + self + } + + pub fn max_turns(mut self, n: usize) -> Self { + self.max_turns = n; + self + } +} + +impl<'a, M, Message> IntoFuture for PromptRequest<'a, M, Message> +where + M: LlmApiAdaptor + Send + Sync + 'a, + Message: Send + Sync + Clone + 'a + 'static, +{ + type Output = Result, PromptError>; + type IntoFuture = Pin + Send + 'a>>; + + fn into_future(self) -> Self::IntoFuture { + let agent = self.agent; + let message = self.message; + let extra_hooks = self.hooks; + let max_turns = self.max_turns; + + Box::pin(async move { + let memory = agent + .memory + .get_or_insert_with(|| Box::new(VecMemory::new(Vec::new()))); + memory.append_message(message); + let mut prompt_result: Vec = Vec::new(); + + for _ in 0..max_turns { + let text = { + let messages = agent.system_prompt.iter().chain(memory.get_memory().iter()); + agent.model.complete(messages).await? + }; + + // Try to parse the LLM reply as JSON (handle Markdown fences) + let cleaned = clean_json_response(&text); + match serde_json::from_str::(&cleaned) { + Ok(json_resp) => { + let clean_text = json_resp.reply_text.trim().to_string(); + + // Run on_text_reply hooks + for hook in agent.hooks.iter().chain(extra_hooks.iter()) { + match hook.on_text_reply(&clean_text) { + TextFlow::Stop => { + return Err(PromptError::ToolError( + "text reply rejected by hook".to_string(), + )); + } + TextFlow::Continue => {} + } + } + memory.append_message(agent.model.build_assistant_message(&clean_text)); + prompt_result.push(Text(clean_text.clone())); + + let tool_calls: Vec = json_resp + .tool_calls + .into_iter() + .enumerate() + .map(|(idx, tc)| ToolCall { + id: format!("{idx}"), + name: tc.tool_name, + args: tc.args, + }) + .collect(); + + // no tool call, turn terminate. + if tool_calls.is_empty() { + return Ok(prompt_result); + } + + for (tc_id, tc) in tool_calls.iter().enumerate() { + // inline run_hooks: before_tool_call hook + let mut should_skip = false; + for hook in agent.hooks.iter().chain(extra_hooks.iter()) { + match hook.before_tool_call(tc) { + ToolCallFlow::Stop => { + return Err(PromptError::ToolError( + "tool call rejected by hook".to_string(), + )); + } + ToolCallFlow::Skip => { + let msg = agent + .model + .tool_result_message(&tc.name, "(skipped by hook)"); + memory.append_message(msg); + should_skip = true; + break; + } + ToolCallFlow::Continue => {} + } + } + if should_skip { + continue; + } + + let result = { + let tools: Vec<&Box> = agent.tools.iter().collect(); + let name = tc.name.clone(); + let args = tc.args.clone(); + let fut = async move { + match tools.iter().find(|tool| tool.tool_name() == name) { + Some(tool) => tool.call(args).await, + None => ToolExecutionResult::failed( + Value::Null, + ToolFailure::invalid_args(format!( + "unknown tool: {name}" + )), + ), + } + }; + fut.await + }; + + match result.outcome { + ToolOutcome::InternalOk => { + let mut json_output = result.output; + // Run after_tool_call hooks to allow output modification + for hook in agent.hooks.iter().chain(extra_hooks.iter()) { + match hook.after_tool_call(&tc.name, &mut json_output) { + ToolCallFlow::Stop => { + return Err(PromptError::ToolError( + "tool call output caused this _turn stopped by hook".to_string(), + )); + } + ToolCallFlow::Skip => { + json_output = serde_json::json!({"message":"tool call is ignored by hook"}); + break; + } + ToolCallFlow::Continue => {} + } + } + let arg_json = serde_json::to_string(&tc.args) + .map_err(|e| PromptError::InternalError(e.to_string()))?; + let output_json = serde_json::to_string(&json_output) + .map_err(|e| PromptError::InternalError(e.to_string()))?; + + let overall_message = format!( + "[tool_call:{tc_id}] args: {arg_json} output: {output_json}" + ); + let msg = + agent.model.tool_result_message(&tc.name, &overall_message); + memory.append_message(msg); + prompt_result.push(Tool(ToolCallOutput { + tool_call: tc.clone(), + message: overall_message.clone(), + })) + } + ToolOutcome::InternalError(failure) if failure.fatal => { + return Err(PromptError::ToolError(failure.message)); + } + ToolOutcome::InternalError(failure) => { + let msg = agent.model.tool_result_message( + &tc.name, + &format!("error: {}", failure.message), + ); + memory.append_message(msg); + } + } + } + } + Err(_) => { + // Not valid JSON — push as assistant message and continue to next _turn + memory.append_message(agent.model.build_assistant_message(&text)); + continue; + } + } + } + + Err(PromptError::MaxTurnsReached { max_turns }) + }) + } +} + +#[derive(Deserialize)] +struct LlmJsonResponse { + reply_text: String, + #[serde(default)] + tool_calls: Vec, +} + +#[derive(Deserialize)] +struct LlmToolCallRequest { + tool_name: String, + #[serde(default)] + args: Value, +} + +pub fn clean_json_response(text: &str) -> String { + let text = text.trim(); + if text.starts_with("```") { + let lines: Vec<&str> = text.lines().collect(); + let mut cleaned = Vec::new(); + let mut in_code = false; + for line in lines { + if line.trim().starts_with("```") { + in_code = !in_code; + continue; + } + if in_code { + cleaned.push(line); + } + } + if !cleaned.is_empty() { + return cleaned.join("\n").trim().to_string(); + } + } + text.to_string() +} diff --git a/server-rs/crates/platform-editor-agent/src/framework/tool.rs b/server-rs/crates/platform-editor-agent/src/framework/tool.rs new file mode 100644 index 000000000..386a9ed36 --- /dev/null +++ b/server-rs/crates/platform-editor-agent/src/framework/tool.rs @@ -0,0 +1,206 @@ +use serde::{Deserialize, Serialize}; +use std::pin::Pin; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCall { + pub id: String, + pub name: String, + pub args: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCallResult { + pub tool_name: String, + pub output: serde_json::Value, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ToolFailureKind { + InvalidArgs, + Timeout, + Cancelled, + NotFound, + PermissionDenied, + RateLimited, + Provider, + Network, + Internal, + Other, +} + +impl ToolFailureKind { + pub fn default_retryable(self) -> bool { + matches!( + self, + Self::Timeout | Self::RateLimited | Self::Provider | Self::Network + ) + } + + pub fn default_fatal(self) -> bool { + matches!(self, Self::Internal) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ToolFailure { + pub kind: ToolFailureKind, + pub message: String, + pub retryable: bool, + pub fatal: bool, +} + +impl ToolFailure { + pub fn new(kind: ToolFailureKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + retryable: kind.default_retryable(), + fatal: kind.default_fatal(), + } + } + + pub fn invalid_args(message: impl Into) -> Self { + Self::new(ToolFailureKind::InvalidArgs, message) + } + + pub fn internal(message: impl Into) -> Self { + Self::new(ToolFailureKind::Internal, message) + } + + pub fn other(message: impl Into) -> Self { + Self::new(ToolFailureKind::Other, message) + } + + pub fn with_retryable(mut self, retryable: bool) -> Self { + self.retryable = retryable; + self + } + + pub fn with_fatal(mut self, fatal: bool) -> Self { + self.fatal = fatal; + self + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum ToolOutcome { + InternalOk, + InternalError(ToolFailure), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ToolExecutionResult { + pub output: serde_json::Value, + pub outcome: ToolOutcome, +} + +impl ToolExecutionResult { + pub fn success(output: serde_json::Value) -> Self { + Self { + output, + outcome: ToolOutcome::InternalOk, + } + } + + pub fn failed(output: serde_json::Value, failure: ToolFailure) -> Self { + Self { + output, + outcome: ToolOutcome::InternalError(failure), + } + } + + pub fn failure(&self) -> Option<&ToolFailure> { + match &self.outcome { + ToolOutcome::InternalOk => None, + ToolOutcome::InternalError(failure) => Some(failure), + } + } + + pub fn is_fatal(&self) -> bool { + self.failure().is_some_and(|failure| failure.fatal) + } +} + +pub trait Tool: Sized { + const NAME: &'static str; + type Error: std::error::Error + 'static; + type Args: for<'a> Deserialize<'a>; + type Output: Serialize; + + fn tool_name(&self) -> &'static str { + Self::NAME + } + + /// Human-readable description of what the tool does. + fn description(&self) -> String; + /// JSON Schema describing the tool's parameters. + fn parameters(&self) -> serde_json::Value; + fn call( + &self, + args: Self::Args, + ) -> impl Future> + Send; + + fn classify_error(&self, error: &Self::Error) -> ToolFailure { + ToolFailure::other(error.to_string()) + } +} + +/// Wrapper trait to allow for dynamic dispatch of simple tools. +pub trait ToolDyn: Send + Sync { + fn tool_name(&self) -> &'static str; + fn description(&self) -> String; + fn parameters(&self) -> serde_json::Value; + fn call( + &self, + args: serde_json::Value, + ) -> Pin + Send + '_>>; +} + +impl ToolDyn for T { + fn tool_name(&self) -> &'static str { + T::NAME + } + + fn description(&self) -> String { + self.description() + } + + fn parameters(&self) -> serde_json::Value { + self.parameters() + } + + fn call( + &self, + args: serde_json::Value, + ) -> Pin + Send + '_>> { + Box::pin(async move { + let parsed: T::Args = match serde_json::from_value(args) { + Ok(parsed) => parsed, + Err(error) => { + return ToolExecutionResult::failed( + serde_json::Value::Null, + ToolFailure::invalid_args(format!("bad args for {}: {error}", T::NAME)), + ); + } + }; + + let output = match self.call(parsed).await { + Ok(output) => output, + Err(error) => { + return ToolExecutionResult::failed( + serde_json::Value::Null, + self.classify_error(&error), + ); + } + }; + + match serde_json::to_value(&output) { + Ok(output) => ToolExecutionResult::success(output), + Err(error) => ToolExecutionResult::failed( + serde_json::Value::Null, + ToolFailure::internal(error.to_string()), + ), + } + }) + } +} diff --git a/server-rs/crates/platform-editor-agent/src/lib.rs b/server-rs/crates/platform-editor-agent/src/lib.rs new file mode 100644 index 000000000..9bfb25278 --- /dev/null +++ b/server-rs/crates/platform-editor-agent/src/lib.rs @@ -0,0 +1,2 @@ +pub mod agent; +pub mod framework; diff --git a/server-rs/crates/shared-contracts/src/assets.rs b/server-rs/crates/shared-contracts/src/assets.rs index 58e0b3958..27ca2036d 100644 --- a/server-rs/crates/shared-contracts/src/assets.rs +++ b/server-rs/crates/shared-contracts/src/assets.rs @@ -1201,7 +1201,7 @@ mod tests { } #[test] - fn editor_video_request_supports_seedance_multimodal_references() { + fn editor_video_request_supports_stable_seedance_multimodal_references() { let payload = serde_json::to_value(EditorVideoGenerateRequest { prompt: "参考素材生成视频".to_string(), model: "seedance2.0-fast".to_string(), @@ -1211,11 +1211,15 @@ mod tests { mode: "std".to_string(), sound: "off".to_string(), web_search_enabled: true, - reference_image_srcs: vec!["data:image/png;base64,image".to_string()], + reference_image_srcs: vec![ + "generated-character-drafts/editor/seedance-references/image/demo.png".to_string(), + ], reference_video_srcs: vec![ "generated-character-drafts/editor/seedance-references/video/demo.mp4".to_string(), ], - reference_audio_srcs: vec!["data:audio/mpeg;base64,audio".to_string()], + reference_audio_srcs: vec![ + "generated-character-drafts/editor/seedance-references/audio/demo.mp3".to_string(), + ], project_id: None, canvas_completion: None, generation_inputs: None, @@ -1228,7 +1232,7 @@ mod tests { assert_eq!( payload["referenceImageSrcs"], - json!(["data:image/png;base64,image"]) + json!(["generated-character-drafts/editor/seedance-references/image/demo.png"]) ); assert_eq!( payload["referenceVideoSrcs"], @@ -1236,7 +1240,7 @@ mod tests { ); assert_eq!( payload["referenceAudioSrcs"], - json!(["data:audio/mpeg;base64,audio"]) + json!(["generated-character-drafts/editor/seedance-references/audio/demo.mp3"]) ); } @@ -1288,8 +1292,8 @@ mod tests { #[test] fn editor_icon_spritesheet_request_omits_price_and_response_includes_price() { let request_payload = serde_json::to_value(EditorIconSpritesheetGenerateRequest { - reference_image_src: "data:image/png;base64,spec".to_string(), - reference_image_srcs: vec!["data:image/png;base64,style".to_string()], + reference_image_src: "generated-images/editor/spec.png".to_string(), + reference_image_srcs: vec!["generated-images/editor/style.png".to_string()], icon_descriptions: vec!["返回按钮".to_string(), "设置按钮".to_string()], model: Some("gemini-3.1-flash-image-preview".to_string()), aspect_ratio: Some("1:1".to_string()), @@ -1299,16 +1303,16 @@ mod tests { assert_eq!( request_payload["referenceImageSrc"], - json!("data:image/png;base64,spec") + json!("generated-images/editor/spec.png") ); assert_eq!( request_payload["referenceImageSrcs"], - json!(["data:image/png;base64,style"]) + json!(["generated-images/editor/style.png"]) ); assert!(request_payload.get("priceMudPoints").is_none()); let response_payload = serde_json::to_value(EditorIconSpritesheetGenerateResponse { - spritesheet_image_src: "data:image/png;base64,sheet".to_string(), + spritesheet_image_src: "/generated-images/editor/sheet.png".to_string(), spritesheet_width: 512, spritesheet_height: 512, icon_image_srcs: Vec::new(), diff --git a/server-rs/crates/shared-contracts/src/editor_agent.rs b/server-rs/crates/shared-contracts/src/editor_agent.rs index c3790a276..924e885e6 100644 --- a/server-rs/crates/shared-contracts/src/editor_agent.rs +++ b/server-rs/crates/shared-contracts/src/editor_agent.rs @@ -1,47 +1,21 @@ //! 画布Agent对话契约:与 packages/shared/src/contracts/editorAgent.ts 保持同形。 //! 会话元数据存 SpacetimeDB,消息正文整体存 OSS(editor-agent/{conversationId}.json)。 -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::json; pub const EDITOR_AGENT_MAX_ATTACHMENTS: usize = 9; +pub const EDITOR_AGENT_ERROR_MESSAGE_PREFIX: &str = "ERROR "; pub const EDITOR_AGENT_TITLE_MAX_CHARS: usize = 20; pub const EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE: &str = "新对话"; -pub const EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION: u32 = 1; - -#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum EditorAgentStage { - Idle, - Thinking, - Responding, - Generating, - Completed, - Failed, -} +pub const EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION: u32 = 2; #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum EditorAgentMessageRole { User, Assistant, -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum EditorAgentMessageKind { - Chat, - Stage, - Error, -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum EditorAgentMessageStatus { - Streaming, - Generating, - Completed, - Failed, - Stopped, + System, } #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -53,20 +27,16 @@ pub enum EditorAgentAttachmentSource { #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] -pub enum EditorAgentToolName { - GenerateImage, - EditImage, - GenerateCharacter, - GenerateIconSpritesheet, - GenerateUiDesign, -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum EditorAgentGenerationStatus { - Generating, +pub enum EditorAgentToolCallStatus { + #[serde(alias = "pending_confirmation", alias = "executing")] + NotCompleted, Completed, Failed, + Cancelled, +} + +fn default_editor_agent_tool_call_status() -> EditorAgentToolCallStatus { + EditorAgentToolCallStatus::NotCompleted } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -107,44 +77,165 @@ pub struct EditorAgentGeneratedImage { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] -pub struct EditorAgentGenerationRecord { - pub tool_call_id: String, - pub tool_name: EditorAgentToolName, +pub struct EditorAgentGeneratedVideo { #[serde(default)] - pub summary: Option, + pub resource_id: Option, #[serde(default)] - pub task_id: Option, - pub status: EditorAgentGenerationStatus, + pub object_key: Option, #[serde(default)] - pub model: Option, + pub asset_object_id: Option, + pub video_src: String, #[serde(default)] - pub images: Vec, + pub thumbnail_src: Option, #[serde(default)] - pub error: Option, + pub width: Option, + #[serde(default)] + pub height: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] +pub struct EditorAgentGeneratedAudio { + #[serde(default)] + pub resource_id: Option, + #[serde(default)] + pub object_key: Option, + #[serde(default)] + pub asset_object_id: Option, + pub audio_src: String, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentToolCallDisplayArgs { + pub string_args: Vec, + pub image_args: Vec, + pub extras: EditorAgentToolCallDisplayExtras, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentToolCallDisplayExtras { + pub price_mud_points: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentToolCallStringArg { + pub name: String, + pub label: String, + pub value: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentToolCallImageArg { + pub name: String, + pub label: String, + pub refs: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentToolCallImageRef { + pub image_id: String, + pub image_src: String, + #[serde(default)] + pub object_key: Option, + #[serde(default)] + pub thumbnail_src: Option, + #[serde(default)] + pub label: Option, + #[serde(default)] + pub width: Option, + #[serde(default)] + pub height: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentToolCall { + pub tool_name: String, + #[serde(default = "default_editor_agent_tool_call_status")] + pub status: EditorAgentToolCallStatus, + pub args: serde_json::Value, + pub display_args: EditorAgentToolCallDisplayArgs, + #[serde(default)] + pub external_job_id: Option, + #[serde(default)] + pub images: Vec, + #[serde(default)] + pub videos: Vec, + #[serde(default)] + pub audios: Vec, + #[serde(default)] + pub error: Option, +} +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +enum RawEditorAgentMessageId { + Number(usize), + String(String), + #[allow(dead_code)] + Other(serde_json::Value), +} + +impl Default for RawEditorAgentMessageId { + fn default() -> Self { + Self::Number(0) + } +} + +impl RawEditorAgentMessageId { + fn numeric_id(&self) -> usize { + match self { + Self::Number(id) => *id, + Self::String(_) | Self::Other(_) => 0, + } + } + + fn legacy_client_message_id(&self, role: EditorAgentMessageRole) -> Option { + if role != EditorAgentMessageRole::User { + return None; + } + match self { + Self::String(id) if !id.trim().is_empty() => Some(id.trim().to_string()), + Self::Number(_) | Self::String(_) | Self::Other(_) => None, + } + } +} + +#[derive(Clone, Debug, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] pub struct EditorAgentMessage { - pub id: String, + // to compatible for legacy version + pub id: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_message_id: Option, pub role: EditorAgentMessageRole, - pub kind: EditorAgentMessageKind, pub text: String, #[serde(default)] pub attachments: Vec, - #[serde(default)] - pub generations: Vec, - pub status: EditorAgentMessageStatus, + // one tool call is inside one system message. + pub tool_call: Option, pub created_at: String, } +impl<'de> Deserialize<'de> for EditorAgentMessage { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ok(RawEditorAgentMessage::deserialize(deserializer)?.into_single_message()) + } +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct EditorAgentConversationSummary { pub conversation_id: String, pub project_id: String, pub title: String, - pub created_at: String, pub updated_at: String, } @@ -159,7 +250,7 @@ pub struct EditorAgentConversationDetail { pub messages: Vec, } -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Debug, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct EditorAgentConversationMessagesDocument { pub version: u32, @@ -167,6 +258,235 @@ pub struct EditorAgentConversationMessagesDocument { pub messages: Vec, } +// for legacy migration start +impl<'de> Deserialize<'de> for EditorAgentConversationMessagesDocument { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = RawEditorAgentConversationMessagesDocument::deserialize(deserializer)?; + let mut messages = Vec::new(); + for raw_message in raw.messages { + for mut message in raw_message.into_document_messages() { + message.id = messages.len(); + messages.push(message); + } + } + Ok(Self { + version: EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION, + conversation_id: raw.conversation_id, + messages, + }) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawEditorAgentConversationMessagesDocument { + #[allow(dead_code)] + #[serde(default)] + version: u32, + conversation_id: String, + #[serde(default)] + messages: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawEditorAgentMessage { + #[serde(default)] + id: RawEditorAgentMessageId, + #[serde(default)] + client_message_id: Option, + role: EditorAgentMessageRole, + #[serde(default)] + text: String, + #[serde(default)] + attachments: Vec, + #[serde(default)] + tool_call: Option, + #[serde(default)] + generations: Vec, + #[serde(default)] + created_at: String, +} + +impl RawEditorAgentMessage { + fn into_single_message(self) -> EditorAgentMessage { + let id = self.id.numeric_id(); + let client_message_id = self + .client_message_id + .or_else(|| self.id.legacy_client_message_id(self.role)); + let tool_call = self + .tool_call + .or_else(|| legacy_generations_to_tool_call(self.generations)); + EditorAgentMessage { + id, + client_message_id, + role: self.role, + text: self.text, + attachments: self.attachments, + tool_call, + created_at: self.created_at, + } + } + + fn into_document_messages(self) -> Vec { + if self.tool_call.is_some() || self.generations.is_empty() { + return vec![self.into_single_message()]; + } + + let id = self.id.numeric_id(); + let client_message_id = self + .client_message_id + .clone() + .or_else(|| self.id.legacy_client_message_id(self.role)); + let mut messages = Vec::new(); + if self.role != EditorAgentMessageRole::Assistant + || !self.text.trim().is_empty() + || !self.attachments.is_empty() + { + messages.push(EditorAgentMessage { + id, + client_message_id, + role: self.role, + text: self.text.clone(), + attachments: self.attachments, + tool_call: None, + created_at: self.created_at.clone(), + }); + } + + messages.extend(self.generations.into_iter().map(|generation| { + let tool_call_message = + legacy_generation_to_tool_call_message(generation, self.created_at.as_str()); + EditorAgentMessage { + id, + client_message_id: None, + role: EditorAgentMessageRole::System, + text: tool_call_message.text, + attachments: Vec::new(), + tool_call: Some(tool_call_message.tool_call), + created_at: self.created_at.clone(), + } + })); + messages + } +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LegacyEditorAgentGenerationRecord { + #[serde(default)] + tool_call_id: String, + tool_name: String, + #[serde(default)] + summary: Option, + #[serde(default)] + task_id: Option, + status: LegacyEditorAgentGenerationStatus, + #[serde(default)] + model: Option, + #[serde(default)] + images: Vec, + #[serde(default)] + error: Option, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum LegacyEditorAgentGenerationStatus { + Generating, + Completed, + Failed, +} + +struct LegacyToolCallMessage { + text: String, + tool_call: EditorAgentToolCall, +} + +fn legacy_generations_to_tool_call( + generations: Vec, +) -> Option { + let mut generations = generations.into_iter(); + let first = generations.next()?; + let mut tool_call_message = legacy_generation_to_tool_call_message(first, ""); + for generation in generations { + tool_call_message.tool_call.images.extend(generation.images); + if tool_call_message.tool_call.error.is_none() { + tool_call_message.tool_call.error = generation.error; + } + if tool_call_message.tool_call.status == EditorAgentToolCallStatus::Completed + && generation.status != LegacyEditorAgentGenerationStatus::Completed + { + tool_call_message.tool_call.status = legacy_generation_status(generation.status); + } + } + Some(tool_call_message.tool_call) +} + +fn legacy_generation_to_tool_call_message( + generation: LegacyEditorAgentGenerationRecord, + created_at: &str, +) -> LegacyToolCallMessage { + let tool_name = normalize_legacy_tool_name(generation.tool_name.as_str()); + let status = legacy_generation_status(generation.status); + let summary = generation.summary.clone(); + let error = generation.error.clone(); + let text = legacy_tool_call_text(tool_name.as_str(), summary.as_deref(), error.as_deref()); + LegacyToolCallMessage { + text, + tool_call: EditorAgentToolCall { + tool_name, + status, + args: json!({ + "legacyToolCallId": generation.tool_call_id, + "legacySummary": summary, + "legacyModel": generation.model, + "legacyCreatedAt": created_at, + }), + display_args: EditorAgentToolCallDisplayArgs::default(), + external_job_id: generation.task_id, + images: generation.images, + videos: Vec::new(), + audios: Vec::new(), + error, + }, + } +} + +fn legacy_generation_status( + status: LegacyEditorAgentGenerationStatus, +) -> EditorAgentToolCallStatus { + match status { + LegacyEditorAgentGenerationStatus::Generating => EditorAgentToolCallStatus::NotCompleted, + LegacyEditorAgentGenerationStatus::Completed => EditorAgentToolCallStatus::Completed, + LegacyEditorAgentGenerationStatus::Failed => EditorAgentToolCallStatus::Failed, + } +} + +fn normalize_legacy_tool_name(tool_name: &str) -> String { + let normalized = tool_name.trim().replace('_', "-"); + if normalized.is_empty() { + "generate-image".to_string() + } else { + normalized + } +} + +fn legacy_tool_call_text(tool_name: &str, summary: Option<&str>, error: Option<&str>) -> String { + if let Some(error) = error.map(str::trim).filter(|value| !value.is_empty()) { + return format!("[tool_call:{tool_name}] output: {error}"); + } + let output = summary + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("legacy generation restored"); + format!("[tool_call:{tool_name}] output: {output}") +} +// for legacy migration end + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CreateEditorAgentConversationRequest { @@ -188,96 +508,19 @@ pub struct EditorAgentConversationResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] -pub struct StreamEditorAgentMessageRequest { +pub struct EditorAgentMessageRequest { pub client_message_id: String, pub text: String, #[serde(default)] pub attachments: Vec, } -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum EditorAgentSseEventType { - Stage, - MessageDelta, - ToolStarted, - ToolCompleted, - GenerationResult, - Error, - Done, -} - #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] -pub struct EditorAgentSseEnvelope { - pub event: EditorAgentSseEventType, - pub data: serde_json::Value, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct EditorAgentStageEvent { - pub conversation_id: String, - pub stage: EditorAgentStage, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct EditorAgentMessageDeltaEvent { - pub conversation_id: String, - pub message_id: String, - pub role: EditorAgentMessageRole, - pub kind: EditorAgentMessageKind, - pub text_delta: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct EditorAgentToolEvent { - pub conversation_id: String, - pub message_id: String, - pub tool_call_id: String, - pub tool_name: EditorAgentToolName, - #[serde(default)] - pub summary: Option, - #[serde(default)] - pub task_id: Option, - #[serde(default)] - pub model: Option, - #[serde(default)] - pub status: Option, - #[serde(default)] - pub error: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct EditorAgentGenerationResultEvent { - pub conversation_id: String, - pub message_id: String, - pub tool_call_id: String, - pub tool_name: EditorAgentToolName, - #[serde(default)] - pub model: Option, - pub images: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct EditorAgentErrorEvent { - #[serde(default)] - pub conversation_id: Option, - pub code: String, - pub message: String, - pub recoverable: bool, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct EditorAgentDoneEvent { - pub conversation_id: String, - #[serde(default)] - pub title: Option, +pub struct EditorAgentMessageResponse { + pub conversation: EditorAgentConversationSummary, + pub delta_messages: Vec, + pub error_message: Option, } #[cfg(test)] @@ -286,111 +529,220 @@ mod tests { use serde_json::json; #[test] - fn editor_agent_conversation_detail_uses_camel_case() { - let detail = EditorAgentConversationDetail { - conversation_id: "editor-agent-conv-1".to_string(), - project_id: "project-1".to_string(), - title: "新对话".to_string(), - created_at: "2026-07-03T00:00:00Z".to_string(), - updated_at: "2026-07-03T00:00:01Z".to_string(), - messages: vec![EditorAgentMessage { - id: "message-1".to_string(), - role: EditorAgentMessageRole::User, - kind: EditorAgentMessageKind::Chat, - text: "帮我生成一张森林背景".to_string(), - attachments: vec![EditorAgentAttachmentRef { - source: EditorAgentAttachmentSource::CanvasResource, - reference_id: "resource-1".to_string(), - object_key: Some("editor/resource-1.png".to_string()), - image_src: "https://example.test/resource-1.png".to_string(), - thumbnail_src: None, - label: Some("森林参考".to_string()), - width: Some(1024), - height: Some(768), - }], - generations: vec![], - status: EditorAgentMessageStatus::Completed, - created_at: "2026-07-03T00:00:00Z".to_string(), - }], - }; + fn message_request_requires_client_message_id_and_user_message_preserves_it() { + let missing_client_message_id = + serde_json::from_value::(json!({ + "text": "生成一张图", + "attachments": [] + })); + assert!(missing_client_message_id.is_err()); - let payload = serde_json::to_value(&detail).expect("detail should serialize"); - assert_eq!(payload["conversationId"], json!("editor-agent-conv-1")); - assert_eq!(payload["messages"][0]["role"], json!("user")); - assert_eq!( - payload["messages"][0]["attachments"][0]["source"], - json!("canvas_resource") - ); - assert_eq!( - payload["messages"][0]["attachments"][0]["referenceId"], - json!("resource-1") - ); - assert_eq!(payload["messages"][0]["status"], json!("completed")); - - let decoded: EditorAgentConversationDetail = - serde_json::from_value(payload).expect("detail should deserialize"); - assert_eq!(decoded, detail); - } - - #[test] - fn editor_agent_sse_events_serialize_event_names() { - let envelope = EditorAgentSseEnvelope { - event: EditorAgentSseEventType::GenerationResult, - data: serde_json::to_value(EditorAgentGenerationResultEvent { - conversation_id: "editor-agent-conv-1".to_string(), - message_id: "message-2".to_string(), - tool_call_id: "tool-call-1".to_string(), - tool_name: EditorAgentToolName::GenerateImage, - model: Some("z-image".to_string()), - images: vec![EditorAgentGeneratedImage { - resource_id: Some("resource-2".to_string()), - object_key: Some("generated/result.png".to_string()), - asset_object_id: Some("asset-object-2".to_string()), - image_src: "https://example.test/generated.png".to_string(), - thumbnail_src: None, - width: Some(1024), - height: Some(1024), - }], - }) - .expect("event data should serialize"), - }; - - let payload = serde_json::to_value(envelope).expect("envelope should serialize"); - assert_eq!(payload["event"], json!("generation_result")); - assert_eq!(payload["data"]["toolName"], json!("generate_image")); - assert_eq!( - payload["data"]["images"][0]["resourceId"], - json!("resource-2") - ); - assert_eq!( - payload["data"]["images"][0]["objectKey"], - json!("generated/result.png") - ); - - let error_envelope = EditorAgentSseEnvelope { - event: EditorAgentSseEventType::Error, - data: serde_json::to_value(EditorAgentErrorEvent { - conversation_id: Some("editor-agent-conv-1".to_string()), - code: "INSUFFICIENT_BALANCE".to_string(), - message: "泥点余额不足,本次生成未执行".to_string(), - recoverable: true, - }) - .expect("event data should serialize"), - }; - let error_payload = - serde_json::to_value(error_envelope).expect("envelope should serialize"); - assert_eq!(error_payload["event"], json!("error")); - assert_eq!(error_payload["data"]["recoverable"], json!(true)); - } - - #[test] - fn stream_request_defaults_attachments_to_empty() { - let payload = json!({ + let request = serde_json::from_value::(json!({ "clientMessageId": "client-message-1", - "text": "随便聊聊" - }); - let decoded: StreamEditorAgentMessageRequest = - serde_json::from_value(payload).expect("request should deserialize"); - assert!(decoded.attachments.is_empty()); + "text": "生成一张图", + "attachments": [] + })) + .expect("clientMessageId should deserialize"); + assert_eq!(request.client_message_id, "client-message-1"); + + let message = EditorAgentMessage { + id: 0, + client_message_id: Some(request.client_message_id), + role: EditorAgentMessageRole::User, + text: request.text, + attachments: request.attachments, + tool_call: None, + created_at: "2026-07-16T00:00:00Z".to_string(), + }; + let payload = serde_json::to_value(message).expect("message should serialize"); + assert_eq!(payload["id"], 0); + assert_eq!(payload["clientMessageId"], "client-message-1"); + } + + #[test] + fn tool_call_display_args_and_media_use_camel_case() { + let missing_display_args = serde_json::from_value::(json!({ + "toolName": "generate-video", + "args": {}, + "images": [] + })); + assert!(missing_display_args.is_err()); + + let tool_call = EditorAgentToolCall { + tool_name: "generate-video".to_string(), + status: EditorAgentToolCallStatus::Completed, + args: json!({}), + display_args: EditorAgentToolCallDisplayArgs { + string_args: vec![EditorAgentToolCallStringArg { + name: "prompt".to_string(), + label: "提示词".to_string(), + value: "生成像素风角色".to_string(), + }], + image_args: vec![EditorAgentToolCallImageArg { + name: "reference_image_ids".to_string(), + label: "参考图片".to_string(), + refs: vec![EditorAgentToolCallImageRef { + image_id: "sha256:reference-1".to_string(), + image_src: "/reference.png".to_string(), + object_key: Some("generated/reference.png".to_string()), + thumbnail_src: Some("/reference-thumbnail.png".to_string()), + label: Some("角色参考".to_string()), + width: Some(512), + height: Some(512), + }], + }], + extras: EditorAgentToolCallDisplayExtras { + price_mud_points: 5, + }, + }, + external_job_id: Some("task-editor-agent-1".to_string()), + images: Vec::new(), + videos: vec![EditorAgentGeneratedVideo { + resource_id: Some("resource-video-1".to_string()), + object_key: Some("generated/video.mp4".to_string()), + asset_object_id: Some("asset-video-1".to_string()), + video_src: "/generated-video.mp4".to_string(), + thumbnail_src: Some("/generated-video.png".to_string()), + width: Some(1280), + height: Some(720), + }], + audios: vec![EditorAgentGeneratedAudio { + resource_id: Some("resource-audio-1".to_string()), + object_key: Some("generated/audio.mp3".to_string()), + asset_object_id: Some("asset-audio-1".to_string()), + audio_src: "/generated-audio.mp3".to_string(), + }], + error: None, + }; + let payload = serde_json::to_value(tool_call).expect("tool call should serialize"); + assert_eq!(payload["videos"][0]["videoSrc"], "/generated-video.mp4"); + assert_eq!(payload["externalJobId"], "task-editor-agent-1"); + assert_eq!(payload["status"], "completed"); + assert_eq!(payload["audios"][0]["audioSrc"], "/generated-audio.mp3"); + assert_eq!( + payload["displayArgs"]["stringArgs"][0]["value"], + "生成像素风角色" + ); + assert_eq!( + payload["displayArgs"]["imageArgs"][0]["refs"][0]["imageId"], + "sha256:reference-1" + ); + assert_eq!( + payload["displayArgs"]["imageArgs"][0]["refs"][0]["objectKey"], + "generated/reference.png" + ); + assert_eq!(payload["displayArgs"]["extras"]["priceMudPoints"], 5); + } + + #[test] + fn legacy_tool_call_status_maps_to_not_completed_on_read() { + let tool_call = serde_json::from_value::(json!({ + "toolName": "generate-image", + "status": "executing", + "args": {}, + "displayArgs": { + "stringArgs": [], + "imageArgs": [], + "extras": { "priceMudPoints": 3 } + }, + "images": [] + })) + .expect("legacy status should not break persisted OSS documents"); + + let payload = serde_json::to_value(tool_call).expect("tool call should serialize"); + assert_eq!(payload["status"], "not_completed"); + } + + #[test] + fn legacy_message_generations_migrate_to_tool_call_media() { + let document = serde_json::from_value::(json!({ + "version": 1, + "conversationId": "editor-agent-conv-legacy", + "messages": [ + { + "id": "message-user-1", + "role": "user", + "kind": "chat", + "text": "帮我生成一张森林背景", + "attachments": [], + "generations": [], + "status": "completed", + "createdAt": "2026-07-03T00:00:00Z" + }, + { + "id": "message-assistant-1", + "role": "assistant", + "kind": "chat", + "text": "已为你生成森林背景。", + "attachments": [], + "generations": [ + { + "toolCallId": "tool-call-legacy-1", + "toolName": "generate_image", + "summary": "森林背景", + "taskId": "task-legacy-1", + "status": "completed", + "model": "z-image", + "images": [ + { + "resourceId": "resource-legacy-1", + "objectKey": "generated/legacy-forest.png", + "assetObjectId": "asset-legacy-1", + "imageSrc": "https://example.test/legacy-forest.png", + "thumbnailSrc": "https://example.test/legacy-forest-thumb.png", + "width": 1024, + "height": 1024 + } + ] + } + ], + "status": "completed", + "createdAt": "2026-07-03T00:00:01Z" + } + ] + })) + .expect("legacy document should deserialize"); + + assert_eq!(document.version, EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION); + assert_eq!(document.messages.len(), 3); + assert_eq!(document.messages[0].id, 0); + assert_eq!( + document.messages[0].client_message_id.as_deref(), + Some("message-user-1") + ); + assert_eq!(document.messages[1].id, 1); + assert!(document.messages[1].client_message_id.is_none()); + assert_eq!(document.messages[1].role, EditorAgentMessageRole::Assistant); + assert_eq!(document.messages[1].text, "已为你生成森林背景。"); + assert!(document.messages[1].tool_call.is_none()); + assert_eq!(document.messages[2].id, 2); + assert_eq!(document.messages[2].role, EditorAgentMessageRole::System); + + let tool_call = document.messages[2] + .tool_call + .as_ref() + .expect("legacy generation should become a tool call"); + assert_eq!(tool_call.tool_name, "generate-image"); + assert_eq!(tool_call.status, EditorAgentToolCallStatus::Completed); + assert_eq!(tool_call.external_job_id.as_deref(), Some("task-legacy-1")); + assert_eq!(tool_call.images.len(), 1); + assert_eq!( + tool_call.images[0].object_key.as_deref(), + Some("generated/legacy-forest.png") + ); + assert_eq!( + tool_call.images[0].image_src, + "https://example.test/legacy-forest.png" + ); + + let payload = serde_json::to_value(&document).expect("document should serialize"); + assert_eq!(payload["version"], EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION); + assert!(payload["messages"][1].get("generations").is_none()); + assert!(payload["messages"][1].get("kind").is_none()); + assert!(payload["messages"][2].get("generations").is_none()); + assert_eq!( + payload["messages"][2]["toolCall"]["images"][0]["objectKey"], + "generated/legacy-forest.png" + ); } } diff --git a/server-rs/crates/spacetime-client/src/external_generation.rs b/server-rs/crates/spacetime-client/src/external_generation.rs index 0d00c249a..0b2a2abfc 100644 --- a/server-rs/crates/spacetime-client/src/external_generation.rs +++ b/server-rs/crates/spacetime-client/src/external_generation.rs @@ -385,6 +385,34 @@ impl SpacetimeClient { .await } + // TODO icons gen's result query is not prepared for now. + pub async fn get_external_generation_job_generated_artifacts( + &self, + input: ExternalGenerationJobGetRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "get_external_generation_job_result_and_return", + move |connection, sender| { + connection + .procedures() + .get_external_generation_job_result_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then( + map_external_generation_job_result_procedure_generated_artifacts, + ); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn get_external_generation_job_summary( &self, input: ExternalGenerationJobGetRecordInput, diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 80e5ad3c1..f51015531 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -57,22 +57,23 @@ pub use mapper::{ EditorShowcaseCampaignConfigRecord, EditorShowcaseCampaignConfigUpsertRecordInput, ExternalApiKeyAuthenticateRecordInput, ExternalApiKeyCreateRecordInput, ExternalApiKeyRecord, ExternalApiKeyRevokeRecordInput, ExternalGenerationJobAcknowledgeRecordInput, - ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput, - ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobFailRecordInput, - ExternalGenerationJobGetRecordInput, ExternalGenerationJobListRecord, - ExternalGenerationJobListRecordInput, ExternalGenerationJobPhaseUpdateRecordInput, - ExternalGenerationJobRecord, ExternalGenerationJobRenewLeaseRecordInput, - ExternalGenerationJobSummaryListRecord, ExternalGenerationJobSummaryRecord, - ExternalGenerationQueueStatsRecord, FeatureGateConfigRecord, JumpHopActionRequest, - JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, JumpHopDifficulty, - JumpHopDraftResponse, JumpHopGalleryCardResponse, JumpHopGalleryDetailResponse, - JumpHopGalleryResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, - JumpHopJumpResult, JumpHopLastJump, JumpHopPath, JumpHopPlatform, JumpHopRestartRunRequest, - JumpHopRunResponse, JumpHopRunStatus, JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, - JumpHopSessionResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, - JumpHopStylePreset, JumpHopTileAsset, JumpHopTileType, JumpHopWorkDetailResponse, - JumpHopWorkMutationResponse, JumpHopWorkProfileResponse, JumpHopWorkSummaryResponse, - JumpHopWorksResponse, JumpHopWorkspaceCreateRequest, Match3DAgentMessageFinalizeRecordInput, + ExternalGenerationJobArtifactsRecord, ExternalGenerationJobClaimRecordInput, + ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobEnqueueRecordInput, + ExternalGenerationJobFailRecordInput, ExternalGenerationJobGetRecordInput, + ExternalGenerationJobListRecord, ExternalGenerationJobListRecordInput, + ExternalGenerationJobPhaseUpdateRecordInput, ExternalGenerationJobRecord, + ExternalGenerationJobRenewLeaseRecordInput, ExternalGenerationJobSummaryListRecord, + ExternalGenerationJobSummaryRecord, ExternalGenerationQueueStatsRecord, + FeatureGateConfigRecord, JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, + JumpHopCharacterAsset, JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse, + JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus, + JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump, JumpHopPath, + JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus, + JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse, + JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset, + JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, + JumpHopWorkProfileResponse, JumpHopWorkSummaryResponse, JumpHopWorksResponse, + JumpHopWorkspaceCreateRequest, Match3DAgentMessageFinalizeRecordInput, Match3DAgentMessageRecord, Match3DAgentMessageSubmitRecordInput, Match3DAgentSessionCreateRecordInput, Match3DAgentSessionRecord, Match3DAnchorItemRecord, Match3DAnchorPackRecord, Match3DClickConfirmationRecord, Match3DCompileDraftRecordInput, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 9a3614adc..5e8b748d6 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -114,13 +114,14 @@ pub use self::external_api_key::{ ExternalApiKeyRevokeRecordInput, }; pub use self::external_generation::{ - ExternalGenerationJobAcknowledgeRecordInput, ExternalGenerationJobClaimRecordInput, - ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobEnqueueRecordInput, - ExternalGenerationJobFailRecordInput, ExternalGenerationJobGetRecordInput, - ExternalGenerationJobListRecord, ExternalGenerationJobListRecordInput, - ExternalGenerationJobPhaseUpdateRecordInput, ExternalGenerationJobRecord, - ExternalGenerationJobRenewLeaseRecordInput, ExternalGenerationJobSummaryListRecord, - ExternalGenerationJobSummaryRecord, ExternalGenerationQueueStatsRecord, + ExternalGenerationJobAcknowledgeRecordInput, ExternalGenerationJobArtifactsRecord, + ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput, + ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobFailRecordInput, + ExternalGenerationJobGetRecordInput, ExternalGenerationJobListRecord, + ExternalGenerationJobListRecordInput, ExternalGenerationJobPhaseUpdateRecordInput, + ExternalGenerationJobRecord, ExternalGenerationJobRenewLeaseRecordInput, + ExternalGenerationJobSummaryListRecord, ExternalGenerationJobSummaryRecord, + ExternalGenerationQueueStatsRecord, }; pub(crate) use self::external_generation::{ ExternalGenerationJobPhaseUpdateProcedureOutcome, @@ -268,7 +269,9 @@ pub(crate) use self::external_api_key::{ }; pub(crate) use self::external_generation::{ map_external_generation_job_claim_result, map_external_generation_job_list_result, - map_external_generation_job_procedure_result, map_external_generation_job_summary_list_result, + map_external_generation_job_procedure_result, + map_external_generation_job_result_procedure_generated_artifacts, + map_external_generation_job_summary_list_result, map_external_generation_job_summary_procedure_result, map_external_generation_queue_stats_result, }; diff --git a/server-rs/crates/spacetime-client/src/mapper/external_generation.rs b/server-rs/crates/spacetime-client/src/mapper/external_generation.rs index 25cd092e2..cf9643c32 100644 --- a/server-rs/crates/spacetime-client/src/mapper/external_generation.rs +++ b/server-rs/crates/spacetime-client/src/mapper/external_generation.rs @@ -123,6 +123,24 @@ pub(crate) fn map_external_generation_job_procedure_result( Ok(map_external_generation_job_snapshot(job)) } +pub(crate) fn map_external_generation_job_result_procedure_generated_artifacts( + result: ExternalGenerationJobResultProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let result = result.result.ok_or_else(|| { + SpacetimeClientError::missing_snapshot("external_generation_job 结果快照") + })?; + Ok(ExternalGenerationJobArtifactsRecord { + job_id: result.job_id, + status: result.status, + last_error_message: result.last_error_message, + result_payload_json: result.result_payload_json, + }) +} + pub(crate) enum ExternalGenerationJobPhaseUpdateProcedureOutcome { Updated(ExternalGenerationJobRecord), Rejected { @@ -427,6 +445,14 @@ pub struct ExternalGenerationJobRecord { pub phase: Option, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalGenerationJobArtifactsRecord { + pub job_id: String, + pub status: String, + pub last_error_message: Option, + pub result_payload_json: Option, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct ExternalGenerationJobListRecord { pub jobs: Vec, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 43bd572f4..13162e635 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.6.0 (commit 31fd1c8c3346dfec38dfcc2e89c2ecf457cf26ff). +// This was generated using spacetimedb cli version 2.6.1 (commit 052c83fe984a4c4eb7bb4f9afa5c6b1903891d87). #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; @@ -520,8 +520,10 @@ pub mod external_generation_job_phase_update_failure_kind_type; pub mod external_generation_job_phase_update_input_type; pub mod external_generation_job_phase_update_procedure_result_type; pub mod external_generation_job_procedure_result_type; +pub mod external_generation_job_result_procedure_result_type; pub mod external_generation_job_renew_lease_input_type; pub mod external_generation_job_snapshot_type; +pub mod external_generation_job_result_snapshot_type; pub mod external_generation_job_summary_backfill_input_type; pub mod external_generation_job_summary_backfill_procedure_result_type; pub mod external_generation_job_summary_procedure_result_type; @@ -576,6 +578,7 @@ pub mod get_editor_generation_pricing_config_and_return_procedure; pub mod get_editor_project_and_return_procedure; pub mod get_editor_showcase_campaign_config_and_return_procedure; pub mod get_external_generation_job_and_return_procedure; +pub mod get_external_generation_job_result_and_return_procedure; pub mod get_external_generation_job_summary_and_return_procedure; pub mod get_external_generation_queue_stats_and_return_procedure; pub mod get_feature_gate_config_procedure; @@ -1956,8 +1959,10 @@ pub use external_generation_job_phase_update_failure_kind_type::ExternalGenerati pub use external_generation_job_phase_update_input_type::ExternalGenerationJobPhaseUpdateInput; pub use external_generation_job_phase_update_procedure_result_type::ExternalGenerationJobPhaseUpdateProcedureResult; pub use external_generation_job_procedure_result_type::ExternalGenerationJobProcedureResult; +pub use external_generation_job_result_procedure_result_type::ExternalGenerationJobResultProcedureResult; pub use external_generation_job_renew_lease_input_type::ExternalGenerationJobRenewLeaseInput; pub use external_generation_job_snapshot_type::ExternalGenerationJobSnapshot; +pub use external_generation_job_result_snapshot_type::ExternalGenerationJobResultSnapshot; pub use external_generation_job_summary_backfill_input_type::ExternalGenerationJobSummaryBackfillInput; pub use external_generation_job_summary_backfill_procedure_result_type::ExternalGenerationJobSummaryBackfillProcedureResult; pub use external_generation_job_summary_procedure_result_type::ExternalGenerationJobSummaryProcedureResult; @@ -2012,6 +2017,7 @@ pub use get_editor_generation_pricing_config_and_return_procedure::get_editor_ge pub use get_editor_project_and_return_procedure::get_editor_project_and_return; pub use get_editor_showcase_campaign_config_and_return_procedure::get_editor_showcase_campaign_config_and_return; pub use get_external_generation_job_and_return_procedure::get_external_generation_job_and_return; +pub use get_external_generation_job_result_and_return_procedure::get_external_generation_job_result_and_return; pub use get_external_generation_job_summary_and_return_procedure::get_external_generation_job_summary_and_return; pub use get_external_generation_queue_stats_and_return_procedure::get_external_generation_queue_stats_and_return; pub use get_feature_gate_config_procedure::get_feature_gate_config; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_result_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_result_procedure_result_type.rs new file mode 100644 index 000000000..4b07dda7d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_result_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::external_generation_job_result_snapshot_type::ExternalGenerationJobResultSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ExternalGenerationJobResultProcedureResult { + pub ok: bool, + pub result: Option, + pub error_message: Option, +} + +impl __sdk::InModule for ExternalGenerationJobResultProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_result_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_result_snapshot_type.rs new file mode 100644 index 000000000..881d0d786 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_result_snapshot_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ExternalGenerationJobResultSnapshot { + pub job_id: String, + pub status: String, + pub last_error_message: Option, + pub result_payload_json: Option, +} + +impl __sdk::InModule for ExternalGenerationJobResultSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_job_result_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_job_result_and_return_procedure.rs new file mode 100644 index 000000000..d0ff920cd --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_job_result_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::external_generation_job_get_input_type::ExternalGenerationJobGetInput; +use super::external_generation_job_result_procedure_result_type::ExternalGenerationJobResultProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetExternalGenerationJobResultAndReturnArgs { + pub input: ExternalGenerationJobGetInput, +} + +impl __sdk::InModule for GetExternalGenerationJobResultAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_external_generation_job_result_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_external_generation_job_result_and_return { + fn get_external_generation_job_result_and_return(&self, input: ExternalGenerationJobGetInput) { + self.get_external_generation_job_result_and_return_then(input, |_, _| {}); + } + + fn get_external_generation_job_result_and_return_then( + &self, + input: ExternalGenerationJobGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_external_generation_job_result_and_return for super::RemoteProcedures { + fn get_external_generation_job_result_and_return_then( + &self, + input: ExternalGenerationJobGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, ExternalGenerationJobResultProcedureResult>( + "get_external_generation_job_result_and_return", + GetExternalGenerationJobResultAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/update_external_generation_job_phase_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/update_external_generation_job_phase_and_return_procedure.rs index a6cc1b841..890d5b4d6 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/update_external_generation_job_phase_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/update_external_generation_job_phase_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait update_external_generation_job_phase_and_return { input: ExternalGenerationJobPhaseUpdateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl update_external_generation_job_phase_and_return for super::RemoteProcedures input: ExternalGenerationJobPhaseUpdateInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, ExternalGenerationJobPhaseUpdateProcedureResult>( diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 70cfc7cea..19f80f00c 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -1095,7 +1095,8 @@ pub fn find_editor_asset_group_source_and_return( ctx: &mut ProcedureContext, input: EditorAssetGroupSourceLookupInput, ) -> EditorAssetProcedureResult { - match ctx.try_with_tx(|tx| find_editor_asset_group_source(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| find_editor_asset_group_source(tx, caller, input.clone())) { Ok(asset) => editor_asset_ok(asset), Err(message) => editor_asset_error(message), } @@ -1139,7 +1140,8 @@ pub fn create_editor_asset_and_return( ctx: &mut ProcedureContext, input: EditorAssetCreateInput, ) -> EditorAssetProcedureResult { - match ctx.try_with_tx(|tx| create_editor_asset(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| create_editor_asset(tx, caller, input.clone())) { Ok(asset) => editor_asset_ok(Some(asset)), Err(message) => editor_asset_error(message), } @@ -1150,7 +1152,8 @@ pub fn complete_editor_asset_group_cohort_and_return( ctx: &mut ProcedureContext, input: EditorAssetGroupCohortCompleteInput, ) -> EditorAssetProcedureResult { - match ctx.try_with_tx(|tx| complete_editor_asset_group_cohort(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| complete_editor_asset_group_cohort(tx, caller, input.clone())) { Ok(asset) => editor_asset_ok(Some(asset)), Err(message) => editor_asset_error(message), } @@ -1855,9 +1858,10 @@ fn admin_list_editor_assets( fn find_editor_asset_group_source( ctx: &ReducerContext, + caller: Identity, input: EditorAssetGroupSourceLookupInput, ) -> Result, String> { - require_editor_generation_runtime_service_identity(ctx, ctx.sender())?; + require_editor_generation_runtime_service_identity(ctx, caller)?; let owner_user_id = normalize_required(&input.owner_user_id, "owner_user_id")?; let source_resource_ids = input .source_resource_ids @@ -1924,9 +1928,10 @@ fn find_editor_asset_group_source( fn complete_editor_asset_group_cohort( ctx: &ReducerContext, + caller: Identity, input: EditorAssetGroupCohortCompleteInput, ) -> Result { - require_editor_generation_runtime_service_identity(ctx, ctx.sender())?; + require_editor_generation_runtime_service_identity(ctx, caller)?; let owner_user_id = normalize_required(&input.owner_user_id, "owner_user_id")?; let task_id = normalize_required(&input.task_id, "task_id")?; let group_task_id = normalize_required(&input.group_task_id, "group_task_id")?; @@ -2101,6 +2106,7 @@ fn delete_editor_asset_folder( fn create_editor_asset( ctx: &ReducerContext, + caller: Identity, input: EditorAssetCreateInput, ) -> Result { let asset_id = normalize_required(&input.asset_id, "editor_asset.asset_id")?; @@ -2131,7 +2137,7 @@ fn create_editor_asset( return Err("素材任务预期产物数量必须与归组 Task ID 一起提供".to_string()); } if group_task_id.is_some() { - require_editor_generation_runtime_service_identity(ctx, ctx.sender())?; + require_editor_generation_runtime_service_identity(ctx, caller)?; } ctx.db.editor_asset().insert(EditorAsset { asset_id: asset_id.clone(), diff --git a/server-rs/crates/spacetime-module/src/external_generation.rs b/server-rs/crates/spacetime-module/src/external_generation.rs index 9df6e960c..ce1a19396 100644 --- a/server-rs/crates/spacetime-module/src/external_generation.rs +++ b/server-rs/crates/spacetime-module/src/external_generation.rs @@ -292,6 +292,22 @@ pub struct ExternalGenerationJobProcedureResult { pub error_message: Option, } +// Private backend read for reconciliation; it intentionally excludes job request and lease data. +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ExternalGenerationJobResultSnapshot { + pub job_id: String, + pub status: String, + pub last_error_message: Option, + pub result_payload_json: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ExternalGenerationJobResultProcedureResult { + pub ok: bool, + pub result: Option, + pub error_message: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct ExternalGenerationJobPhaseUpdateProcedureResult { pub ok: bool, @@ -522,6 +538,23 @@ pub fn get_external_generation_job_and_return( } } +#[spacetimedb::procedure] +pub fn get_external_generation_job_result_and_return( + ctx: &mut ProcedureContext, + input: ExternalGenerationJobGetInput, +) -> ExternalGenerationJobResultProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + get_external_generation_job_result_tx(tx, input.clone()) + }) { + Ok(result) => single_external_generation_job_result_read_result(result), + Err(message) => failed_external_generation_job_result_read_result(message), + } +} + #[spacetimedb::procedure] pub fn list_external_generation_jobs_and_return( ctx: &mut ProcedureContext, @@ -933,6 +966,34 @@ fn get_external_generation_job_tx( .map(map_external_generation_job_summary_to_compat_snapshot) } +fn get_external_generation_job_result_tx( + ctx: &ReducerContext, + input: ExternalGenerationJobGetInput, +) -> Result { + validate_required("external_generation_job.job_id", &input.job_id)?; + validate_required( + "external_generation_job.owner_user_id", + &input.owner_user_id, + )?; + let job_id = input.job_id.trim().to_string(); + let owner_user_id = input.owner_user_id.trim(); + let row = ctx + .db + .external_generation_job() + .job_id() + .find(&job_id) + .ok_or_else(|| "external_generation_job 不存在".to_string())?; + if row.owner_user_id.trim() != owner_user_id { + return Err("external_generation_job 不存在".to_string()); + } + Ok(ExternalGenerationJobResultSnapshot { + job_id: row.job_id, + status: row.status, + last_error_message: row.last_error_message, + result_payload_json: row.result_payload_json, + }) +} + fn list_external_generation_jobs_tx( ctx: &ReducerContext, input: ExternalGenerationJobListInput, @@ -2275,6 +2336,26 @@ fn failed_external_generation_job_result(message: String) -> ExternalGenerationJ } } +fn single_external_generation_job_result_read_result( + result: ExternalGenerationJobResultSnapshot, +) -> ExternalGenerationJobResultProcedureResult { + ExternalGenerationJobResultProcedureResult { + ok: true, + result: Some(result), + error_message: None, + } +} + +fn failed_external_generation_job_result_read_result( + message: String, +) -> ExternalGenerationJobResultProcedureResult { + ExternalGenerationJobResultProcedureResult { + ok: false, + result: None, + error_message: Some(message), + } +} + fn single_external_generation_job_summary_result( job: ExternalGenerationJobSummarySnapshot, ) -> ExternalGenerationJobSummaryProcedureResult { diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 4d99dbb55..f5be3b9c1 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -254,6 +254,8 @@ macro_rules! migration_tables { editor_project_resource, editor_asset_folder, editor_asset, + editor_asset_group_source_provenance, + editor_asset_group_cohort, editor_showcase_asset, editor_showcase_asset_like, editor_showcase_campaign_config, diff --git a/server-rs/scripts/spacetime-dev.ps1 b/server-rs/scripts/spacetime-dev.ps1 index b6f6b8ab9..125c3293c 100644 --- a/server-rs/scripts/spacetime-dev.ps1 +++ b/server-rs/scripts/spacetime-dev.ps1 @@ -4,7 +4,7 @@ param( [switch]$Help, [string]$ListenHost = "127.0.0.1", [int]$Port = 3000, - [string]$RootDir = "" + [string]$DataDir = "" ) $ErrorActionPreference = "Stop" @@ -31,8 +31,8 @@ if ($Help) { $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $serverRsDir = Split-Path -Parent $scriptDir -if ([string]::IsNullOrWhiteSpace($RootDir)) { - $RootDir = Join-Path $serverRsDir ".spacetimedb\local" +if ([string]::IsNullOrWhiteSpace($DataDir)) { + $DataDir = Join-Path $serverRsDir ".spacetimedb\local\data" } if (-not (Test-Path (Join-Path $serverRsDir "crates\spacetime-module\README.md"))) { @@ -49,19 +49,19 @@ if ($null -eq $spacetimeCommand) { ) -join " " } -New-Item -ItemType Directory -Force -Path $RootDir | Out-Null +New-Item -ItemType Directory -Force -Path $DataDir | Out-Null $listenAddress = "$ListenHost`:$Port" Write-Host "[server-rs:spacetime-dev] working dir: $serverRsDir" -Write-Host "[server-rs:spacetime-dev] root dir: $RootDir" +Write-Host "[server-rs:spacetime-dev] data dir: $DataDir" Write-Host "[server-rs:spacetime-dev] listen addr: $listenAddress" Write-Host "[server-rs:spacetime-dev] mode: standalone" Write-Host "[server-rs:spacetime-dev] note: module scaffold already exists; publish remains manual in this stage" Push-Location $serverRsDir try { - & $spacetimeCommand.Source --root-dir $RootDir start --edition standalone --listen-addr $listenAddress + & $spacetimeCommand.Source start --data-dir $DataDir --listen-addr $listenAddress --non-interactive } finally { Pop-Location diff --git a/server-rs/scripts/spacetime-dev.sh b/server-rs/scripts/spacetime-dev.sh index 4dcd01d1c..c442f8e45 100644 --- a/server-rs/scripts/spacetime-dev.sh +++ b/server-rs/scripts/spacetime-dev.sh @@ -27,7 +27,7 @@ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" SERVER_RS_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)" LISTEN_HOST="${GENARRATIVE_SPACETIME_HOST:-127.0.0.1}" PORT="${GENARRATIVE_SPACETIME_PORT:-3000}" -ROOT_DIR="${GENARRATIVE_SPACETIME_ROOT_DIR:-${SERVER_RS_DIR}/.spacetimedb/local}" +DATA_DIR="${GENARRATIVE_SPACETIME_DATA_DIR:-${SERVER_RS_DIR}/.spacetimedb/local/data}" if [[ ! -f "${SERVER_RS_DIR}/crates/spacetime-module/README.md" ]]; then echo "[server-rs:spacetime-dev] 未找到 crates/spacetime-module/README.md,无法启动本地 SpacetimeDB 脚本。" >&2 @@ -41,13 +41,13 @@ if ! command -v spacetime >/dev/null 2>&1; then exit 1 fi -mkdir -p "${ROOT_DIR}" +mkdir -p "${DATA_DIR}" echo "[server-rs:spacetime-dev] 工作目录: ${SERVER_RS_DIR}" -echo "[server-rs:spacetime-dev] 数据目录: ${ROOT_DIR}" +echo "[server-rs:spacetime-dev] 数据目录: ${DATA_DIR}" echo "[server-rs:spacetime-dev] 监听地址: ${LISTEN_HOST}:${PORT}" echo "[server-rs:spacetime-dev] 模式: standalone" echo "[server-rs:spacetime-dev] 说明: 当前阶段已落 crate scaffold,但仍不自动 publish crates/spacetime-module" cd "${SERVER_RS_DIR}" -spacetime --root-dir "${ROOT_DIR}" start --edition standalone --listen-addr "${LISTEN_HOST}:${PORT}" +spacetime start --data-dir "${DATA_DIR}" --listen-addr "${LISTEN_HOST}:${PORT}" --non-interactive diff --git a/src/App.tsx b/src/App.tsx index 9a11611d8..571ca2ad3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,8 +21,8 @@ import { isAppHistoryState, normalizeAppPath, pushAppHistoryPath, - replaceAppHistoryPath, readPublicWorkCodeFromLocationSearch, + replaceAppHistoryPath, resolveInitialSelectionStageFromPath, resolvePathForSelectionStage, shouldRedirectEditorCanvasWithoutProject, diff --git a/src/PuzzlePlaygroundApp.tsx b/src/PuzzlePlaygroundApp.tsx index 429b0761d..a50ae9d6d 100644 --- a/src/PuzzlePlaygroundApp.tsx +++ b/src/PuzzlePlaygroundApp.tsx @@ -7,8 +7,8 @@ import type { import type { PuzzleWorkSummary } from '../packages/shared/src/contracts/puzzleWorkSummary'; import { PuzzleRuntimeShell } from './components/puzzle-runtime/PuzzleRuntimeShell'; import { - applyLocalPuzzleFreezeTime, advanceLocalPuzzleLevel, + applyLocalPuzzleFreezeTime, dragLocalPuzzlePiece, setLocalPuzzlePaused, startLocalPuzzleRun, diff --git a/src/components/AdventureEntityModal.tsx b/src/components/AdventureEntityModal.tsx index 39dc79701..c56d6b77c 100644 --- a/src/components/AdventureEntityModal.tsx +++ b/src/components/AdventureEntityModal.tsx @@ -77,8 +77,8 @@ import { PlayerLevelProgress, StatusRow, } from './CharacterInfoShared'; -import { PlatformEmptyState } from './common/PlatformEmptyState'; import { PlatformActionButton } from './common/PlatformActionButton'; +import { PlatformEmptyState } from './common/PlatformEmptyState'; import { PlatformPillBadge } from './common/PlatformPillBadge'; import { PlatformSubpanel } from './common/PlatformSubpanel'; import { GENERIC_NPC_SCENE_SCALE } from './game-canvas/GameCanvasShared'; diff --git a/src/components/CustomWorldGenerationView.test.tsx b/src/components/CustomWorldGenerationView.test.tsx index 54557e3a5..f8a2abfc5 100644 --- a/src/components/CustomWorldGenerationView.test.tsx +++ b/src/components/CustomWorldGenerationView.test.tsx @@ -1,7 +1,7 @@ /* @vitest-environment jsdom */ -import userEvent from '@testing-library/user-event'; import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { describe, expect, test, vi } from 'vitest'; import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime'; diff --git a/src/components/InventoryPanel.tsx b/src/components/InventoryPanel.tsx index 900a1a2b6..d2bdc8bc9 100644 --- a/src/components/InventoryPanel.tsx +++ b/src/components/InventoryPanel.tsx @@ -14,8 +14,8 @@ import { NarrativeQaReport, WorldType, } from '../types'; -import { PlatformPillBadge } from './common/PlatformPillBadge'; import { PlatformActionButton } from './common/PlatformActionButton'; +import { PlatformPillBadge } from './common/PlatformPillBadge'; import { PlatformStatusMessage } from './common/PlatformStatusMessage'; import { PlatformSubpanel } from './common/PlatformSubpanel'; import { diff --git a/src/components/ResolvedAssetAudio.tsx b/src/components/ResolvedAssetAudio.tsx new file mode 100644 index 000000000..1cac1c542 --- /dev/null +++ b/src/components/ResolvedAssetAudio.tsx @@ -0,0 +1,33 @@ +import type { AudioHTMLAttributes } from 'react'; + +import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl'; + +type ResolvedAssetAudioProps = Omit< + AudioHTMLAttributes, + 'src' +> & { + src?: string | null; + objectKey?: string | null; + fallbackSrc?: string | null; + refreshKey?: string | number | null; +}; + +export function ResolvedAssetAudio({ + src, + objectKey, + fallbackSrc, + refreshKey, + ...rest +}: ResolvedAssetAudioProps) { + const { resolvedUrl } = useResolvedAssetReadUrl(src, { + objectKey, + refreshKey, + }); + const finalSrc = resolvedUrl || fallbackSrc?.trim() || ''; + + if (!finalSrc) { + return null; + } + + return