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/.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/docs/README.md b/docs/README.md index 21c21090c..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 对话面板、会话持久化、普通 JSON 消息、附件、工具任务懒回填与生成落画板例外见 [【编辑器】画布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/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index bd42ca7cc..14b871a71 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4229,3 +4229,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/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 69ea2448c..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`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 9ace874c3..0a33c8375 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -20,7 +20,7 @@ - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 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 成功图。 +- 图片生成 / 修改统一经 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` 和外部生成队列;worker 固定提交 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不提交 `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` 次并重新计时,第二次仍失败才进入阿里云/本地降级链,整项任务另受 worker long-job 预算约束。全部 `32 / 40 / 48` 帧按“对应绿幕源图落 OSS → BgFilter/降级 → 透明帧落 OSS”连续加入无序在途流水线,允许响应乱序完成并在最终返回前按 `frameIndex` 恢复顺序;任一帧最终失败时仍排空全部已启动请求,整个动作任务失败退款,不发布缺帧动画。 @@ -55,8 +55,8 @@ - `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,同一会话的消息追加和工具结果回填由 api-server 按 `conversationId` 串行化,避免“读 OSS → 改消息 → 写 OSS”并发覆盖。前端只通过 api-server BFF 读取和发送会话,不直接读写 SpacetimeDB,也不直接读写 OSS。 @@ -131,7 +131,7 @@ - 画布 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/【前端架构】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/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 759d9c9ba..9903561f0 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -158,13 +158,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 ``` @@ -273,7 +273,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` 反查,不能只保留在本地说明中。 ## 前端改动验收 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index 38297a241..6b5d6a55c 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -160,11 +160,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/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index 5e4a3465b..549481bcd 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-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/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/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-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-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/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/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/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/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 965d100db..d1e8e4bb6 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, @@ -130,7 +132,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] = [ @@ -568,6 +570,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( @@ -641,6 +651,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( @@ -654,10 +672,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 @@ -2014,7 +2039,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?; @@ -2038,9 +2064,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", @@ -3402,11 +3429,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, @@ -3418,8 +3446,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(); @@ -3431,22 +3460,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", @@ -3471,31 +3503,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 或画板资源路径。" ))); @@ -3507,17 +3538,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", @@ -3582,9 +3620,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 { @@ -3600,9 +3638,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 或画板资源路径", )?; } @@ -3613,7 +3651,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 { @@ -3631,6 +3671,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, @@ -3649,6 +3694,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}。" ))) @@ -6072,6 +6120,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"); @@ -6532,11 +6608,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, @@ -6549,7 +6629,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, @@ -6557,7 +6637,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"] ); } @@ -6589,7 +6669,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(), @@ -6600,9 +6719,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, @@ -6613,8 +6732,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()); @@ -6628,7 +6748,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()), @@ -6637,7 +6757,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(), @@ -6647,9 +6774,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, @@ -6659,23 +6784,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_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 322e235b1..4d5e82c72 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, @@ -1442,6 +1441,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 = @@ -1502,6 +1507,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( @@ -1568,15 +1579,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() @@ -1590,6 +1592,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 { @@ -2622,6 +2633,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( @@ -2669,6 +2692,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,6 +2943,12 @@ pub async fn remove_editor_image_background( Json(payload): Json, ) -> Result, AppError> { 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", @@ -2937,6 +2978,12 @@ pub(crate) async fn remove_editor_image_background_for_owner( caller: EditorGenerationCaller, payload: EditorBackgroundRemovalRequest, ) -> Result, AppError> { + ensure_editor_reference_image_source_is_stable( + payload.source_image_src.as_str(), + "editor-background-removal", + "sourceImageSrc", + "待去除背景图片", + )?; let started_at = Instant::now(); caller.report_processing_phase(state).await?; let source_image = parse_editor_reference_image( @@ -3885,6 +3932,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(), @@ -3929,6 +3988,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 决策。这里先固化决策需要的输入。 @@ -3948,7 +4019,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、资源或素材引用。", })) })?, ); @@ -3962,7 +4033,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、资源或素材引用。", })) })?, ); @@ -4595,6 +4666,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(), @@ -4639,6 +4722,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(); @@ -4658,7 +4753,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]; @@ -4673,7 +4768,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、资源或素材引用。", })) })?, ); @@ -7031,20 +7126,71 @@ 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 { + let normalized = source.trim_start().to_ascii_lowercase(); + normalized.starts_with("data:") || normalized.starts_with("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); + if is_editor_inline_media_source(trimmed) { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-reference-image", + "field": "sourceImageSrc", + "message": "图片参考图必须先上传 OSS,并使用 objectKey、项目资源 ID 或素材 ID 引用。", + })), + ); } - 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, trimmed).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(&[]) @@ -7063,7 +7209,7 @@ fn normalize_editor_reference_object_key(source: &str) -> Result 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, @@ -9477,13 +9571,11 @@ mod tests { } #[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"); + fn editor_reference_image_data_url_is_not_an_object_reference() { + let error = normalize_editor_reference_object_key("data:image/png;base64,AAAA") + .expect_err("inline image should require upload before generation"); - assert_eq!(parsed.mime_type, "image/png"); - assert_eq!(parsed.file_name, "editor-reference.png"); - assert_eq!(parsed.bytes, vec![0, 0, 0]); + assert!(error.body_text().contains("objectKey")); } #[test] @@ -9769,7 +9861,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", @@ -9851,12 +9943,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"); @@ -9879,7 +9971,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")); } @@ -9943,6 +10035,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"); @@ -9959,10 +10113,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", @@ -9972,7 +10126,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")); 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 441fa999b..547443534 100644 --- a/server-rs/crates/api-server/src/modules/editor_project.rs +++ b/server-rs/crates/api-server/src/modules/editor_project.rs @@ -28,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 { @@ -183,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", @@ -236,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/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/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/bark-battle-creation/BarkBattleGeneratingView.test.tsx b/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx index 56358b6a3..8d4eb4df0 100644 --- a/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx +++ b/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx @@ -1,7 +1,7 @@ /* @vitest-environment jsdom */ -import userEvent from '@testing-library/user-event'; import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { diff --git a/src/components/big-fish-result/BigFishResultView.tsx b/src/components/big-fish-result/BigFishResultView.tsx index 1bc3336e1..41024366f 100644 --- a/src/components/big-fish-result/BigFishResultView.tsx +++ b/src/components/big-fish-result/BigFishResultView.tsx @@ -20,7 +20,6 @@ import { PlatformAcknowledgeStatusDialog } from '../common/PlatformAcknowledgeSt import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformEmptyState } from '../common/PlatformEmptyState'; import { PlatformFieldLabel } from '../common/PlatformFieldLabel'; -import { PlatformIconBadge } from '../common/PlatformIconBadge'; import { PlatformIconButton } from '../common/PlatformIconButton'; import { PlatformMediaFrame } from '../common/PlatformMediaFrame'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; diff --git a/src/components/common/CopyFeedbackButton.tsx b/src/components/common/CopyFeedbackButton.tsx index d88a57509..5e0b7c6a1 100644 --- a/src/components/common/CopyFeedbackButton.tsx +++ b/src/components/common/CopyFeedbackButton.tsx @@ -1,13 +1,13 @@ import { Check, Copy } from 'lucide-react'; import type { ButtonHTMLAttributes, ReactNode } from 'react'; +import { PlatformActionButton } from './PlatformActionButton'; import { - type PlatformActionButtonSize, type PlatformActionButtonShape, + type PlatformActionButtonSize, type PlatformActionButtonSurface, type PlatformActionButtonTone, } from './platformActionButtonModel'; -import { PlatformActionButton } from './PlatformActionButton'; import { getPlatformPillBadgeClassName, type PlatformPillBadgeSize, diff --git a/src/components/common/PlatformBackActionButton.tsx b/src/components/common/PlatformBackActionButton.tsx index 649903f71..8ed182332 100644 --- a/src/components/common/PlatformBackActionButton.tsx +++ b/src/components/common/PlatformBackActionButton.tsx @@ -1,6 +1,5 @@ -import type { ButtonHTMLAttributes } from 'react'; - import { ArrowLeft } from 'lucide-react'; +import type { ButtonHTMLAttributes } from 'react'; import { PlatformActionButton } from './PlatformActionButton'; import type { PlatformActionButtonSurface } from './platformActionButtonModel'; diff --git a/src/components/common/PlatformIconButton.tsx b/src/components/common/PlatformIconButton.tsx index 4cf90c7f9..106141262 100644 --- a/src/components/common/PlatformIconButton.tsx +++ b/src/components/common/PlatformIconButton.tsx @@ -1,4 +1,3 @@ -import { forwardRef } from 'react'; import type { ButtonHTMLAttributes, HTMLAttributes, @@ -7,6 +6,7 @@ import type { ReactNode, Ref, } from 'react'; +import { forwardRef } from 'react'; type PlatformIconButtonBaseProps = { label: string; diff --git a/src/components/common/PlatformInlineOptionButton.tsx b/src/components/common/PlatformInlineOptionButton.tsx index bc349d4e2..06eee0af4 100644 --- a/src/components/common/PlatformInlineOptionButton.tsx +++ b/src/components/common/PlatformInlineOptionButton.tsx @@ -1,4 +1,4 @@ -import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'; +import { type ButtonHTMLAttributes, forwardRef, type ReactNode } from 'react'; type PlatformInlineOptionButtonProps = Omit< ButtonHTMLAttributes, diff --git a/src/components/common/PlatformSegmentedTabPresets.tsx b/src/components/common/PlatformSegmentedTabPresets.tsx index 5c26e5a25..6b7858bd6 100644 --- a/src/components/common/PlatformSegmentedTabPresets.tsx +++ b/src/components/common/PlatformSegmentedTabPresets.tsx @@ -1,6 +1,6 @@ import { - PlatformSegmentedTabs, type PlatformSegmentedTabItem, + PlatformSegmentedTabs, } from './PlatformSegmentedTabs'; type PlatformSegmentedTabPresetProps = { diff --git a/src/components/common/PlatformStatusDialog.test.tsx b/src/components/common/PlatformStatusDialog.test.tsx index 7e9b37b9c..fffc8c1a1 100644 --- a/src/components/common/PlatformStatusDialog.test.tsx +++ b/src/components/common/PlatformStatusDialog.test.tsx @@ -1,7 +1,7 @@ /* @vitest-environment jsdom */ -import { Waves } from 'lucide-react'; import { fireEvent, render, screen } from '@testing-library/react'; +import { Waves } from 'lucide-react'; import { expect, test, vi } from 'vitest'; import { PlatformStatusDialog } from './PlatformStatusDialog'; diff --git a/src/components/common/PlatformStatusDialog.tsx b/src/components/common/PlatformStatusDialog.tsx index 7b95ef02e..bd3713052 100644 --- a/src/components/common/PlatformStatusDialog.tsx +++ b/src/components/common/PlatformStatusDialog.tsx @@ -1,20 +1,19 @@ -import type { ReactNode } from 'react'; - import { AlertCircle, CheckCircle2, Loader2, XCircle, } from 'lucide-react'; +import type { ReactNode } from 'react'; import { PlatformActionButton } from './PlatformActionButton'; -import { PlatformIconBadge } from './PlatformIconBadge'; -import { UnifiedModal } from './UnifiedModal'; import type { PlatformActionButtonSize, PlatformActionButtonSurface, PlatformActionButtonTone, } from './platformActionButtonModel'; +import { PlatformIconBadge } from './PlatformIconBadge'; +import { UnifiedModal } from './UnifiedModal'; export type PlatformStatusDialogStatus = | 'success' diff --git a/src/components/common/SquareImageCropModal.tsx b/src/components/common/SquareImageCropModal.tsx index 1b7cc1d78..44a73b373 100644 --- a/src/components/common/SquareImageCropModal.tsx +++ b/src/components/common/SquareImageCropModal.tsx @@ -8,13 +8,13 @@ import { import { PlatformActionButton } from './PlatformActionButton'; import { PlatformStatusMessage } from './PlatformStatusMessage'; -import { UnifiedModal } from './UnifiedModal'; import { clampNumber, clampSquareImageCropRect, getSquareCropSizeBounds, type SquareImageCropRect, } from './squareImageCropModel'; +import { UnifiedModal } from './UnifiedModal'; export type SquareImageCropModalLabels = { title: string; diff --git a/src/components/creation-agent/CreationAgentWorkspace.tsx b/src/components/creation-agent/CreationAgentWorkspace.tsx index f61513976..3b4fa356b 100644 --- a/src/components/creation-agent/CreationAgentWorkspace.tsx +++ b/src/components/creation-agent/CreationAgentWorkspace.tsx @@ -426,6 +426,7 @@ function normalizeCreationAgentExportFileNameSegment(rawValue: string) { .trim() .split('') .map((character) => + // eslint-disable-next-line no-control-regex -- 文件名必须拒绝 C0 控制字符。 /[\u0000-\u001f<>:"/\\|?*]/u.test(character) ? '-' : character, ) .join('') diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx index 5c92259a0..7846c3b3c 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx @@ -24,6 +24,7 @@ import { import { PlatformActionButton } from '@/src/components/common/PlatformActionButton.tsx'; import { PlatformDangerConfirmDialog } from '@/src/components/common/PlatformDangerConfirmDialog.tsx'; import { UnifiedModal } from '@/src/components/common/UnifiedModal.tsx'; +import AttachmentChip from '@/src/components/image-editor/EditorAgentConversation/AttachmentChip.tsx'; import { attachmentKey } from '@/src/components/image-editor/EditorAgentConversation/common.ts'; import { MessageBubble, @@ -43,7 +44,6 @@ import { type EditorAgentConversationClient, useEditorAgentConversation, } from './useEditorAgentConversation'; -import AttachmentChip from '@/src/components/image-editor/EditorAgentConversation/AttachmentChip.tsx'; type AttachmentPickerTab = 'canvas' | 'library'; @@ -454,14 +454,20 @@ export function EditorAgentConversationPanelView({ return; } + const remainingAttachmentSlots = + EDITOR_AGENT_MAX_ATTACHMENTS - attachments.length; + const uploadFiles = imageFiles.slice(0, remainingAttachmentSlots); + const hasOverflow = uploadFiles.length < imageFiles.length; // TODO: deduplicate those existing assets setIsPastingAttachment(true); setAttachmentError('图片上传中'); void Promise.all( - imageFiles.map((file) => createPastedAgentImageAttachment(file)), + uploadFiles.map((file) => createPastedAgentImageAttachment(file)), ) .then((pastedAttachments) => { - appendAttachments(pastedAttachments); + if (appendAttachments(pastedAttachments) && hasOverflow) { + setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`); + } }) .catch(() => { setAttachmentError('图片粘贴失败,请重试'); diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts index 5afdbb8a8..5c1d66326 100644 --- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts +++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts @@ -8,7 +8,8 @@ import type { EditorAgentMessage, EditorAgentMessageRequest, EditorAgentMessageResponse, -} from '../../../../packages/shared/src/contracts/editorAgent.ts'; +} from '@/packages/shared/src/contracts'; + import { cancelEditorAgentToolCall, confirmEditorAgentToolCall, diff --git a/src/components/image-editor/ImageCanvasAssetFolderSectionView.tsx b/src/components/image-editor/ImageCanvasAssetFolderSectionView.tsx index 595cdce12..5fb1720a7 100644 --- a/src/components/image-editor/ImageCanvasAssetFolderSectionView.tsx +++ b/src/components/image-editor/ImageCanvasAssetFolderSectionView.tsx @@ -11,24 +11,24 @@ import { import type { Dispatch, SetStateAction } from 'react'; import { PlatformTextField } from '../common/PlatformTextField'; -import { EditorIconButton } from './ImageCanvasEditorPrimitives'; +import type { + GroupedEditorAssetFolder, + UploadFilesOptions, + UploadRequestOptions, +} from './ImageCanvasAssetLibraryPanelView'; +import { ImageCanvasAssetRowView } from './ImageCanvasAssetRowView'; import { ASSET_DRAG_MIME_TYPE, getDraggedAssetId, hasDataTransferType, } from './ImageCanvasEditorModel'; +import { EditorIconButton } from './ImageCanvasEditorPrimitives'; import type { AssetPointerDragState, EditorAsset, EditorAssetFolder, UploadTarget, } from './ImageCanvasEditorTypes'; -import { ImageCanvasAssetRowView } from './ImageCanvasAssetRowView'; -import type { - GroupedEditorAssetFolder, - UploadFilesOptions, - UploadRequestOptions, -} from './ImageCanvasAssetLibraryPanelView'; type ImageCanvasAssetFolderSectionViewProps = { folder: GroupedEditorAssetFolder; diff --git a/src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts b/src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts index 826ee4ba7..6675d690c 100644 --- a/src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts +++ b/src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from 'vitest'; -import type { - EditorAsset, - EditorAssetFolder, -} from './ImageCanvasEditorTypes'; import { areAllSelectableAssetsSelected, createAssetMarqueeFromPointer, @@ -13,24 +9,28 @@ import { filterGroupedAssetsBySearch, getSelectableAssets, groupAssetsByFolder, - moveAssetToFolderLocally, moveAssetMarqueeToPointer, + moveAssetToFolderLocally, removeAssetById, removeSelectedAssets, renameAssetById, renameAssetFolderById, replaceLocalAssetFolder, resolveAllAssetSelection, - resolveAssetShowcaseSubmissionState, resolveAssetFolderIdFromPoint, + resolveAssetShowcaseSubmissionState, resolveDefaultAssetFolder, - resolvePreferredAssetUploadFolder, resolvePinnedAssetMoveFolderId, + resolvePreferredAssetUploadFolder, resolveUploadAssetFolder, selectUploadedAssetsInRect, toggleAssetFolderCollapsed, toggleAssetSelection, } from './ImageCanvasAssetLibraryModel'; +import type { + EditorAsset, + EditorAssetFolder, +} from './ImageCanvasEditorTypes'; function createFolder( overrides: Partial = {}, diff --git a/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx b/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx index 0209b88d9..1b5bdab8d 100644 --- a/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx +++ b/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx @@ -16,8 +16,8 @@ import type { import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar'; import { PlatformTextField } from '../common/PlatformTextField'; -import { EditorIconButton } from './ImageCanvasEditorPrimitives'; import { ImageCanvasAssetFolderSectionView } from './ImageCanvasAssetFolderSectionView'; +import { EditorIconButton } from './ImageCanvasEditorPrimitives'; import type { AssetMarqueeState, AssetPointerDragState, diff --git a/src/components/image-editor/ImageCanvasAssetRowView.test.tsx b/src/components/image-editor/ImageCanvasAssetRowView.test.tsx index 10c12ac0b..becd864a2 100644 --- a/src/components/image-editor/ImageCanvasAssetRowView.test.tsx +++ b/src/components/image-editor/ImageCanvasAssetRowView.test.tsx @@ -4,8 +4,8 @@ import { fireEvent, render, screen } from '@testing-library/react'; import type { ReactNode } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { EditorAsset } from './ImageCanvasEditorTypes'; import { ImageCanvasAssetRowView } from './ImageCanvasAssetRowView'; +import type { EditorAsset } from './ImageCanvasEditorTypes'; const useResolvedAssetReadUrlMock = vi.hoisted(() => vi.fn()); diff --git a/src/components/image-editor/ImageCanvasAssetRowView.tsx b/src/components/image-editor/ImageCanvasAssetRowView.tsx index ebe252ecc..9f27b5d2f 100644 --- a/src/components/image-editor/ImageCanvasAssetRowView.tsx +++ b/src/components/image-editor/ImageCanvasAssetRowView.tsx @@ -1,21 +1,21 @@ import { Check, Download, Pencil, Video, X } from 'lucide-react'; -import { useCallback, useEffect, useRef, useState } from 'react'; import type { Dispatch, SetStateAction } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; import { PlatformTextField } from '../common/PlatformTextField'; +import { resolveAssetShowcaseSubmissionState } from './ImageCanvasAssetLibraryModel'; import type { UploadFilesOptions } from './ImageCanvasAssetLibraryPanelView'; -import { - EditorIconButton, - SidebarMediaItem, -} from './ImageCanvasEditorPrimitives'; import { ASSET_DRAG_MIME_TYPE, clamp, getDraggedAssetId, hasDataTransferType, } from './ImageCanvasEditorModel'; -import { resolveAssetShowcaseSubmissionState } from './ImageCanvasAssetLibraryModel'; +import { + EditorIconButton, + SidebarMediaItem, +} from './ImageCanvasEditorPrimitives'; import type { AssetPointerDragState, EditorAsset, diff --git a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx index ce63f14c6..8f1eef330 100644 --- a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx @@ -19,8 +19,8 @@ import type { UploadTarget, } from './ImageCanvasEditorTypes'; import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView'; -import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot'; import { calculateEditorImageGenerationPrice } from './ImageCanvasGenerationModel'; +import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot'; import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss'; type ReferenceLabelFormatter = ( diff --git a/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx b/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx index aab1ed80f..3b83c5a1b 100644 --- a/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx @@ -1,9 +1,9 @@ /* @vitest-environment jsdom */ -import { createRef, type RefObject } from 'react'; -import { describe, expect, it, vi } from 'vitest'; import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { createRef, type RefObject } from 'react'; +import { describe, expect, it, vi } from 'vitest'; import { ImageCanvasBottomToolbarView } from './ImageCanvasBottomToolbarView'; diff --git a/src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx b/src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx index deca396d4..84e0d5b48 100644 --- a/src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx +++ b/src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx @@ -4,8 +4,8 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; -import type { CanvasLayer, CharacterAnimationPanelState } from './ImageCanvasEditorTypes'; import { ImageCanvasCharacterAnimationPanelView } from './ImageCanvasCharacterAnimationPanelView'; +import type { CanvasLayer, CharacterAnimationPanelState } from './ImageCanvasEditorTypes'; function createSourceLayer(): CanvasLayer { diff --git a/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx index e7a60f11a..04b89fbb5 100644 --- a/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx @@ -4,12 +4,12 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { createRef, useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; +import { ImageCanvasCharacterGenerationComposerView } from './ImageCanvasCharacterGenerationComposerView'; import type { GenerateDialogState, SpecGenerationType, UploadTarget, } from './ImageCanvasEditorTypes'; -import { ImageCanvasCharacterGenerationComposerView } from './ImageCanvasCharacterGenerationComposerView'; import { IMAGE_MODEL_GPT_IMAGE_2 } from './ImageCanvasGenerationModel'; function createDialog( diff --git a/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.tsx index 2ddc374dd..a46565b7b 100644 --- a/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.tsx @@ -13,7 +13,6 @@ import { } from '../common/PlatformFloatingMenu'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformTextField } from '../common/PlatformTextField'; -import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot'; import type { CharacterReferenceImage, GenerateDialogState, @@ -22,6 +21,7 @@ import type { } from './ImageCanvasEditorTypes'; import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView'; import { calculateEditorImageGenerationPrice } from './ImageCanvasGenerationModel'; +import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot'; import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss'; type ImageCanvasCharacterGenerationComposerViewProps = { diff --git a/src/components/image-editor/ImageCanvasCropExpandModel.ts b/src/components/image-editor/ImageCanvasCropExpandModel.ts index 6422a1a60..a591ffaed 100644 --- a/src/components/image-editor/ImageCanvasCropExpandModel.ts +++ b/src/components/image-editor/ImageCanvasCropExpandModel.ts @@ -1,7 +1,7 @@ import type { CropExpandPanelState, - CropExpandResizeHandle, CropExpandRatioValue, + CropExpandResizeHandle, } from './ImageCanvasEditorTypes'; export const CROP_EXPAND_MIN_FRAME_SIZE = 16; diff --git a/src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx b/src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx index db0763e54..381706120 100644 --- a/src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx @@ -4,8 +4,8 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; -import type { GenerateDialogState } from './ImageCanvasEditorTypes'; import { ImageCanvasEditGenerationModalView } from './ImageCanvasEditGenerationModalView'; +import type { GenerateDialogState } from './ImageCanvasEditorTypes'; function createDialog( patch: Partial = {}, diff --git a/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx b/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx index edc042dff..7ebb37464 100644 --- a/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx +++ b/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx @@ -1,11 +1,11 @@ import { type Dispatch, type SetStateAction } from 'react'; import { UnifiedModal } from '../common/UnifiedModal'; +import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView'; +import type { GenerateDialogState } from './ImageCanvasEditorTypes'; import { calculateEditorImageGenerationPrice, } from './ImageCanvasGenerationModel'; -import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView'; -import type { GenerateDialogState } from './ImageCanvasEditorTypes'; type ImageCanvasEditGenerationModalViewProps = { dialog: GenerateDialogState | null; diff --git a/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx index 5c0f70c98..f94e97eb3 100644 --- a/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx @@ -14,11 +14,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiClientError, AuthUiContext, - ImageCanvasEditorView, createAuthValue, createDataTransferStub, createDeferred, dispatchPointerEvent, + ImageCanvasEditorView, setupImageCanvasEditorViewTestLifecycle, } from './ImageCanvasEditorView.test-utils'; diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index d5dce4279..b04344799 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest'; import { + CANVAS_WORLD_ORIGIN, canvasDisplayScaleToViewportScale, canvasDisplayViewportToViewport, - CANVAS_WORLD_ORIGIN, createLayerFromAsset, DEFAULT_CANVAS_BACKGROUND_COLOR, formatCanvasDisplayScalePercent, diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 503b318d6..f2ce226a8 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -9,8 +9,8 @@ import type { CanvasGenerationDialogState, CanvasGenerationInputs, CanvasLayer, - CanvasSnapItem, CanvasMediaType, + CanvasSnapItem, CanvasViewport, CharacterReferenceImage, EditorAsset, diff --git a/src/components/image-editor/ImageCanvasEditorView.test-utils.ts b/src/components/image-editor/ImageCanvasEditorView.test-utils.ts index 3fc679b33..7fe6f64c8 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test-utils.ts +++ b/src/components/image-editor/ImageCanvasEditorView.test-utils.ts @@ -1,6 +1,6 @@ +import { fireEvent } from '@testing-library/react'; import JSZip from 'jszip'; import type { ContextType } from 'react'; -import { fireEvent } from '@testing-library/react'; import { afterEach, beforeEach, expect, type Mock, vi } from 'vitest'; import { ApiClientError } from '../../services/apiClient'; @@ -232,6 +232,7 @@ export function setupImageCanvasEditorViewTestLifecycle({ saveEditorProjectLayoutMock, }: ImageCanvasEditorViewServiceMocks) { beforeEach(() => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); loadOrCreateRecentEditorProjectMock.mockImplementation(() => immediateAsync({ projectId: 'editor-project-default', diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 75d5d6ac0..5867a2b85 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -12,19 +12,19 @@ import userEvent from '@testing-library/user-event'; import JSZip from 'jszip'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { EditorAgentConversationClient } from './EditorAgentConversation/useEditorAgentConversation'; import { ApiClientError, AuthUiContext, createAuthValue, - defaultEditorAssetLibraryAssets, defaultEditorProjectLayers, defaultEditorProjectResources, dispatchPointerEvent, ImageCanvasEditorView, + immediateAsync, readZipText, setupImageCanvasEditorViewTestLifecycle, } from './ImageCanvasEditorView.test-utils'; -import type { EditorAgentConversationClient } from './EditorAgentConversation/useEditorAgentConversation'; type EditorAgentListConversations = EditorAgentConversationClient['listConversations']; @@ -299,9 +299,11 @@ describe('ImageCanvasEditorView', () => { }); beforeEach(() => { - loadFrontendRuntimeConfigMock.mockResolvedValue({ - imageEditorAgentSidebarEnabled: false, - }); + loadFrontendRuntimeConfigMock.mockImplementation(() => + immediateAsync({ + imageEditorAgentSidebarEnabled: false, + }), + ); listEditorAgentConversationsMock.mockResolvedValue([]); createEditorAgentConversationMock.mockResolvedValue({ ...createEditorAgentConversationSummary({ @@ -642,6 +644,7 @@ describe('ImageCanvasEditorView', () => { }); it('opens the shared wallet breakdown and ledger from the canvas topbar', async () => { + const user = userEvent.setup(); render( { name: '泥点 1,234', }); - fireEvent.click(walletButton); + await user.hover(walletButton); const details = await screen.findByRole('dialog', { name: '泥点账户详情', @@ -675,7 +678,7 @@ describe('ImageCanvasEditorView', () => { expect(within(details).queryByText('限时泥点')).toBeNull(); expect(within(details).getByText('每日免费泥点')).toBeTruthy(); - fireEvent.click(within(details).getByRole('button', { name: '使用详情' })); + await user.click(within(details).getByRole('button', { name: '使用详情' })); expect( await screen.findByRole('dialog', { name: '泥点账单' }), ).toBeTruthy(); @@ -1921,7 +1924,7 @@ describe('ImageCanvasEditorView', () => { expect(screen.queryByLabelText('发送给画布 Agent')).toBeNull(); }); - it('adds assets from the sidebar and supports zoom buttons', () => { + it('adds assets from the sidebar and supports zoom buttons', async () => { render(); expect( @@ -1935,7 +1938,9 @@ describe('ImageCanvasEditorView', () => { ).toBeTruthy(); openAssetSidebar(); - fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' })); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' })); + }); expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy(); expect( @@ -2275,7 +2280,7 @@ describe('ImageCanvasEditorView', () => { clientX: 400, clientY: 280, }); - viewport.dispatchEvent(ctrlWheelEvent); + fireEvent(viewport, ctrlWheelEvent); expect(ctrlWheelEvent.defaultPrevented).toBe(true); }); @@ -2619,7 +2624,7 @@ describe('ImageCanvasEditorView', () => { ).toBeNull(); }); - it('undoes and redoes canvas layer changes from the panel controls', () => { + it('undoes and redoes canvas layer changes from the panel controls', async () => { render(); expect(screen.getByRole('button', { name: '撤销' })).toHaveProperty( @@ -2632,40 +2637,52 @@ describe('ImageCanvasEditorView', () => { ); openAssetSidebar(); - fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' })); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' })); + }); expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy(); expect(screen.getByRole('button', { name: '撤销' })).toHaveProperty( 'disabled', false, ); - fireEvent.click(screen.getByRole('button', { name: '撤销' })); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: '撤销' })); + }); expect(screen.queryByAltText('画布图片:声浪素材')).toBeNull(); expect(screen.getByRole('button', { name: '重做' })).toHaveProperty( 'disabled', false, ); - fireEvent.click(screen.getByRole('button', { name: '重做' })); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: '重做' })); + }); expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy(); }); - it('supports undo and redo keyboard shortcuts inside the editor', () => { + it('supports undo and redo keyboard shortcuts inside the editor', async () => { render(); openAssetSidebar(); - fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' })); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' })); + }); expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy(); - fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true }); + await act(async () => { + fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true }); + }); expect(screen.queryByAltText('画布图片:声浪素材')).toBeNull(); - fireEvent.keyDown(window, { - key: 'Z', - code: 'KeyZ', - ctrlKey: true, - shiftKey: true, + await act(async () => { + fireEvent.keyDown(window, { + key: 'Z', + code: 'KeyZ', + ctrlKey: true, + shiftKey: true, + }); }); expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy(); }); diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index 3049a31e5..9d4f3fa96 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -44,14 +44,13 @@ import { DEFAULT_VIDEO_ASPECT_RATIO, DEFAULT_VIDEO_DURATION_SECONDS, DEFAULT_VIDEO_SOUND, - DEFAULT_VIDEO_WEB_SEARCH_ENABLED, - EDITOR_VIDEO_ASPECT_RATIO_OPTIONS, EDITOR_SOUND_EFFECT_MODEL_OPTIONS, + EDITOR_VIDEO_ASPECT_RATIO_OPTIONS, EDITOR_VIDEO_MODEL_OPTIONS, EDITOR_VIDEO_RESOLUTION_OPTIONS, - VIDEO_MODEL_SEEDANCE_2_FAST, resizeGenerationPlaceholderToVideoSelection, SPEC_TYPE_LABEL, + VIDEO_MODEL_SEEDANCE_2_FAST, } from './ImageCanvasGenerationModel'; import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView'; import { ImageCanvasPublicationMaterialsDemoPanelView } from './ImageCanvasPublicationMaterialsDemoPanelView'; diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index d238044fe..7e7339a8f 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -740,7 +740,6 @@ function restoreSharedImageOptions( sourceLayer: CanvasLayer, sourceDialog?: CanvasGenerationDialogState | null, ): Omit { - const fields = getGenerationInputFieldValues(sourceLayer); return { ...draft, imageModel: sourceDialog?.imageModel ?? draft.imageModel, diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index d80c7ed02..5951c4be4 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -6,8 +6,8 @@ import type { CanvasLayer, } from './ImageCanvasEditorTypes'; import { - BACKGROUND_MUSIC_MODEL_SUNO, applyEditorGenerationPricingConfig, + BACKGROUND_MUSIC_MODEL_SUNO, buildCharacterGenerationInputs, buildEditGenerationInputs, buildIconGenerationInputs, @@ -33,8 +33,8 @@ import { DEFAULT_VIDEO_MODEL, EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG, EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG, - EDITOR_MODEL_MUD_POINT_CONFIG, EDITOR_IMAGE_MODEL_OPTIONS, + EDITOR_MODEL_MUD_POINT_CONFIG, EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG, EDITOR_SOUND_EFFECT_MODEL_OPTIONS, EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG, diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 105528bbd..0d7287103 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -411,7 +411,6 @@ export const SEEDANCE_VIDEO_REFERENCE_LIMITS = { image: 9, video: 3, audio: 3, - requestBytes: 64 * 1024 * 1024, } as const; export const QUICK_EDIT_REFERENCE_LIMIT = 8; export const CHARACTER_ANIMATION_ACTION_PROMPTS = [ diff --git a/src/components/image-editor/ImageCanvasGenerationPlacementModel.test.ts b/src/components/image-editor/ImageCanvasGenerationPlacementModel.test.ts index ea26a79ab..f0ce14963 100644 --- a/src/components/image-editor/ImageCanvasGenerationPlacementModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationPlacementModel.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { - chooseGenerationPlacement, - centerViewportOnPlacement, -} from './ImageCanvasGenerationPlacementModel'; import type { CanvasGenerationDialogState, CanvasLayer, CanvasViewport, } from './ImageCanvasEditorTypes'; +import { + centerViewportOnPlacement, + chooseGenerationPlacement, +} from './ImageCanvasGenerationPlacementModel'; const canvasSize = { width: 900, height: 640 }; const viewport: CanvasViewport = { x: 10, y: 20, scale: 2 }; diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index 4ce5c8dbe..cab953c19 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it } from 'vitest'; import type { CanvasLayer } from './ImageCanvasEditorTypes'; +import { + IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_NANOBANANA2, +} from './ImageCanvasGenerationModel'; import { buildCharacterAnimationSubmissionPlan, buildIconSpritesheetGenerationSubmissionPlan, buildImageGenerationSubmissionPlan, } from './ImageCanvasGenerationSubmissionModel'; -import { - IMAGE_MODEL_GPT_IMAGE_2, - IMAGE_MODEL_NANOBANANA2, -} from './ImageCanvasGenerationModel'; function createLayer(overrides: Partial = {}): CanvasLayer { return { diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index 4b073e855..2b7fa8485 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -91,11 +91,6 @@ export function resolveImageReferenceSubmissionSource(reference: { return reference.objectKey?.trim() || reference.src; } -function estimateReferenceRequestBytes(references: Array<{ src: string }>) { - // 中文注释:JSON 包装字段还有少量额外字节;这里按 src 字符串长度估算,前后端都会再兜底校验 64MB。 - return references.reduce((sum, reference) => sum + reference.src.length, 0); -} - function buildSeedanceVideoReferenceInput( references: NonNullable, ) { @@ -129,13 +124,6 @@ function buildSeedanceVideoReferenceInput( ) { throw new Error('参考音频必须搭配参考图片或参考视频'); } - if ( - estimateReferenceRequestBytes(references) > - SEEDANCE_VIDEO_REFERENCE_LIMITS.requestBytes - ) { - throw new Error('参考文件总请求体不能超过 64MB'); - } - return { referenceImageSrcs: imageReferences.map(resolveReferenceSource), referenceVideoSrcs: videoReferences.map(resolveReferenceSource), diff --git a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.tsx b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.tsx index 10cc79936..17a49a7ed 100644 --- a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.tsx +++ b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.tsx @@ -21,7 +21,6 @@ import type { import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView'; import { calculateEditorIconSpritesheetPrice, - DEFAULT_ICON_DESCRIPTIONS, } from './ImageCanvasGenerationModel'; import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot'; import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss'; diff --git a/src/components/image-editor/ImageCanvasInteractionModel.test.ts b/src/components/image-editor/ImageCanvasInteractionModel.test.ts index 0e677d3ee..f9579cf51 100644 --- a/src/components/image-editor/ImageCanvasInteractionModel.test.ts +++ b/src/components/image-editor/ImageCanvasInteractionModel.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; +import type { + CanvasGenerationDialogState, + CanvasLayer, + DragState, +} from './ImageCanvasEditorTypes'; import { createMinimapModel, fitViewportToBounds, @@ -7,9 +12,9 @@ import { getCanvasDropPoint, getCanvasPointFromClient, getWorldPointFromClient, + moveDragSelectedLayers, moveGenerationFrameFromDrag, moveGenerationFramesFromDrag, - moveDragSelectedLayers, moveLayersFromDrag, moveViewportFromMinimapDrag, moveViewportFromMinimapPointer, @@ -20,11 +25,6 @@ import { selectLayersInsideMarquee, zoomViewportFromWheel, } from './ImageCanvasInteractionModel'; -import type { - CanvasGenerationDialogState, - CanvasLayer, - DragState, -} from './ImageCanvasEditorTypes'; import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel'; function createLayer(overrides: Partial): CanvasLayer { diff --git a/src/components/image-editor/ImageCanvasInteractionModel.ts b/src/components/image-editor/ImageCanvasInteractionModel.ts index 664c9ca91..3780870d8 100644 --- a/src/components/image-editor/ImageCanvasInteractionModel.ts +++ b/src/components/image-editor/ImageCanvasInteractionModel.ts @@ -1,5 +1,17 @@ import type { CSSProperties } from 'react'; +import { + clamp, + DEFAULT_CANVAS_SIZE, + FIT_VIEW_PADDING, + getLayerBounds, + MAX_SCALE, + MIN_SCALE, + MINIMAP_DRAG_SENSITIVITY, + MINIMAP_PADDING, + MINIMAP_SIZE, + resolveSnappedItemPosition, +} from './ImageCanvasEditorModel'; import type { CanvasGenerationDialogState, CanvasLayer, @@ -7,18 +19,6 @@ import type { CanvasViewport, DragState, } from './ImageCanvasEditorTypes'; -import { - DEFAULT_CANVAS_SIZE, - FIT_VIEW_PADDING, - MAX_SCALE, - MIN_SCALE, - MINIMAP_DRAG_SENSITIVITY, - MINIMAP_PADDING, - MINIMAP_SIZE, - clamp, - getLayerBounds, - resolveSnappedItemPosition, -} from './ImageCanvasEditorModel'; import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel'; export type CanvasSize = { diff --git a/src/components/image-editor/ImageCanvasLayerCommandModel.test.ts b/src/components/image-editor/ImageCanvasLayerCommandModel.test.ts index 4cc91f02a..88b353c96 100644 --- a/src/components/image-editor/ImageCanvasLayerCommandModel.test.ts +++ b/src/components/image-editor/ImageCanvasLayerCommandModel.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; +import type { + CanvasContextMenuState, + CanvasLayer, +} from './ImageCanvasEditorTypes'; import { createCanvasLayerClipboard, duplicateCanvasLayers, @@ -13,10 +17,6 @@ import { toggleCanvasLayersVisibility, ungroupCanvasLayers, } from './ImageCanvasLayerCommandModel'; -import type { - CanvasContextMenuState, - CanvasLayer, -} from './ImageCanvasEditorTypes'; function createLayer(overrides: Partial): CanvasLayer { const id = overrides.id ?? 'layer-a'; diff --git a/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx index cdabe319f..41ab7a9aa 100644 --- a/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx +++ b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx @@ -64,7 +64,6 @@ describe('ImageCanvasMetadataModalView', () => { expect(within(dialog).queryByText(/VectorEngine/u)).toBeNull(); expect(within(dialog).queryByText(/task-/u)).toBeNull(); expect(within(dialog).getByText('123')).toBeTruthy(); - expect(within(dialog).getByText('generated/object.png')).toBeTruthy(); }); it('renders nanobanana2 instead of the raw Gemini image model id', () => { @@ -108,7 +107,6 @@ describe('ImageCanvasMetadataModalView', () => { expect(within(dialog).queryByText('上传素材')).toBeNull(); expect(within(dialog).getByText('上传图片')).toBeTruthy(); expect(within(dialog).getAllByText('-').length).toBeGreaterThanOrEqual(3); - expect(within(dialog).getByText('asset-object-1')).toBeTruthy(); fireEvent.click(within(dialog).getByRole('button', { name: '关闭图片信息' })); diff --git a/src/components/image-editor/ImageCanvasPanelDockView.tsx b/src/components/image-editor/ImageCanvasPanelDockView.tsx index 02b04642a..171daa690 100644 --- a/src/components/image-editor/ImageCanvasPanelDockView.tsx +++ b/src/components/image-editor/ImageCanvasPanelDockView.tsx @@ -8,12 +8,12 @@ import { Undo2, X, } from 'lucide-react'; -import { useEffect, useRef } from 'react'; import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, } from 'react'; +import { useEffect, useRef } from 'react'; import { PlatformFloatingMenu, diff --git a/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx b/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx index 8d96176ad..57837546d 100644 --- a/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx +++ b/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx @@ -14,7 +14,6 @@ import { } from '../common/PlatformFloatingMenu'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformTextField } from '../common/PlatformTextField'; -import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot'; import type { CharacterReferenceImage, GenerateDialogState, @@ -29,6 +28,7 @@ import { IMAGE_MODEL_GPT_IMAGE_2, } from './ImageCanvasGenerationModel'; import type { PublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel'; +import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot'; type ImageCanvasPublicationMaterialsDemoPanelViewProps = { dialog: GenerateDialogState; diff --git a/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx b/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx index 66953ef42..f59103f95 100644 --- a/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx +++ b/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx @@ -1,20 +1,19 @@ -import { ImageIcon } from 'lucide-react'; import { type CSSProperties, type Dispatch, + type ReactNode, type RefObject, type SetStateAction, - type ReactNode, } from 'react'; import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView'; -import { QUICK_EDIT_REFERENCE_LIMIT } from './ImageCanvasGenerationModel'; import type { CanvasLayer, GenerateDialogState, QuickEditPanelState, UploadTarget, } from './ImageCanvasEditorTypes'; +import { QUICK_EDIT_REFERENCE_LIMIT } from './ImageCanvasGenerationModel'; export type ImageCanvasQuickEditPanelViewProps = { panel: QuickEditPanelState; diff --git a/src/components/image-editor/ImageCanvasRasterEditModel.test.ts b/src/components/image-editor/ImageCanvasRasterEditModel.test.ts index 6c541c5d1..e0fe9a7c3 100644 --- a/src/components/image-editor/ImageCanvasRasterEditModel.test.ts +++ b/src/components/image-editor/ImageCanvasRasterEditModel.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; import { - buildCropExpandInsetsFromFrame, buildCropExpandGeometry, + buildCropExpandInsetsFromFrame, buildCropExpandLayerPatch, parseCropExpandInsetValue, } from './ImageCanvasRasterEditModel'; diff --git a/src/components/image-editor/ImageCanvasRasterEditModel.ts b/src/components/image-editor/ImageCanvasRasterEditModel.ts index 4db465040..6facd9461 100644 --- a/src/components/image-editor/ImageCanvasRasterEditModel.ts +++ b/src/components/image-editor/ImageCanvasRasterEditModel.ts @@ -1,7 +1,7 @@ import { - removeEditorImageBackground, type EditorBackgroundRemovalInput, type EditorBackgroundRemovalResult, + removeEditorImageBackground, } from '../../services/image-editor/editorProjectClient'; export type CropExpandInsets = { diff --git a/src/components/image-editor/ImageCanvasReferenceSlot.tsx b/src/components/image-editor/ImageCanvasReferenceSlot.tsx index f35b1e200..f0304f6e2 100644 --- a/src/components/image-editor/ImageCanvasReferenceSlot.tsx +++ b/src/components/image-editor/ImageCanvasReferenceSlot.tsx @@ -1,5 +1,5 @@ -import { type ReactNode, type RefObject } from 'react'; import { X } from 'lucide-react'; +import { type ReactNode, type RefObject } from 'react'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; diff --git a/src/components/image-editor/ImageCanvasSidebarView.test.tsx b/src/components/image-editor/ImageCanvasSidebarView.test.tsx index 3605fa5a6..3062bf96e 100644 --- a/src/components/image-editor/ImageCanvasSidebarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSidebarView.test.tsx @@ -4,16 +4,16 @@ import { fireEvent, render, screen, within } from '@testing-library/react'; import { createRef } from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { ImageCanvasPanelDockView } from './ImageCanvasPanelDockView'; -import { - ImageCanvasSidebarView, - type ImageCanvasSidebarViewProps, -} from './ImageCanvasSidebarView'; import type { CanvasLayer, EditorAsset, EditorAssetFolder, } from './ImageCanvasEditorTypes'; +import { ImageCanvasPanelDockView } from './ImageCanvasPanelDockView'; +import { + ImageCanvasSidebarView, + type ImageCanvasSidebarViewProps, +} from './ImageCanvasSidebarView'; import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome'; function createAsset(overrides: Partial = {}): EditorAsset { diff --git a/src/components/image-editor/ImageCanvasSidebarView.tsx b/src/components/image-editor/ImageCanvasSidebarView.tsx index 95430ac21..2fb1e8043 100644 --- a/src/components/image-editor/ImageCanvasSidebarView.tsx +++ b/src/components/image-editor/ImageCanvasSidebarView.tsx @@ -12,17 +12,17 @@ import type { import { useMemo, useState } from 'react'; import { PlatformTextField } from '../common/PlatformTextField'; -import { - ImageCanvasAssetLibraryPanelView, - type GroupedEditorAssetFolder, - type UploadFilesOptions, - type UploadRequestOptions, -} from './ImageCanvasAssetLibraryPanelView'; import { filterGroupedAssetsBySearch, layerMatchesSearch, layerStartsWithSearch, } from './ImageCanvasAssetLibraryModel'; +import { + type GroupedEditorAssetFolder, + ImageCanvasAssetLibraryPanelView, + type UploadFilesOptions, + type UploadRequestOptions, +} from './ImageCanvasAssetLibraryPanelView'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; import type { AssetMarqueeState, diff --git a/src/components/image-editor/ImageCanvasStageControllerModel.ts b/src/components/image-editor/ImageCanvasStageControllerModel.ts index 0c57aa8eb..30e27c2dd 100644 --- a/src/components/image-editor/ImageCanvasStageControllerModel.ts +++ b/src/components/image-editor/ImageCanvasStageControllerModel.ts @@ -1,7 +1,3 @@ -import { - getCanvasLayersByIds, - resolveContextTargetLayerIds, -} from './ImageCanvasLayerCommandModel'; import { resolveContextMenuPosition, } from './ImageCanvasEditorModel'; @@ -12,6 +8,10 @@ import type { CanvasViewport, ImageContextMenuState, } from './ImageCanvasEditorTypes'; +import { + getCanvasLayersByIds, + resolveContextTargetLayerIds, +} from './ImageCanvasLayerCommandModel'; import { resolveSelectedToolbarStyle, } from './ImageCanvasOverlayModel'; diff --git a/src/components/image-editor/ImageCanvasStageInteractionModel.test.ts b/src/components/image-editor/ImageCanvasStageInteractionModel.test.ts index 9d7098514..40625737c 100644 --- a/src/components/image-editor/ImageCanvasStageInteractionModel.test.ts +++ b/src/components/image-editor/ImageCanvasStageInteractionModel.test.ts @@ -5,10 +5,11 @@ import type { CanvasLayer, GenerateDialogState, } from './ImageCanvasEditorTypes'; +import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel'; import { - createGenerationFrameSelectionStart, createCanvasMarqueeState, createGenerationFrameDragState, + createGenerationFrameSelectionStart, createLayerDragStart, createMinimapDragState, createPanDragState, @@ -21,7 +22,6 @@ import { updateGenerateDialogForLayerPointerDown, updateMinimapDragMovement, } from './ImageCanvasStageInteractionModel'; -import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel'; function createLayer(overrides: Partial = {}): CanvasLayer { const id = overrides.id ?? 'layer-a'; diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index 0ca5ca04e..a4c40de95 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -7,9 +7,9 @@ import type { RefObject, } from 'react'; +import type { ExternalGenerationTaskRecord } from '@/packages/shared/src'; import { EditorAgentConversationPanelView } from '@/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx'; -import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration'; import { ImageCanvasBottomToolbarView } from './ImageCanvasBottomToolbarView'; import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView'; import type { diff --git a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx index 06655f49c..7dcbf6c7a 100644 --- a/src/components/image-editor/ImageCanvasTaskSidebarView.tsx +++ b/src/components/image-editor/ImageCanvasTaskSidebarView.tsx @@ -6,13 +6,13 @@ import { XCircle, } from 'lucide-react'; import { - type WheelEvent as ReactWheelEvent, type UIEvent as ReactUIEvent, useCallback, useEffect, useMemo, useRef, useState, + type WheelEvent as ReactWheelEvent, } from 'react'; import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration'; diff --git a/src/components/image-editor/ImageCanvasUploadModel.ts b/src/components/image-editor/ImageCanvasUploadModel.ts index f81aeb71f..fed0c5e1d 100644 --- a/src/components/image-editor/ImageCanvasUploadModel.ts +++ b/src/components/image-editor/ImageCanvasUploadModel.ts @@ -1,15 +1,15 @@ +import { resolvePreferredAssetUploadFolder } from './ImageCanvasAssetLibraryModel'; import { resolveLayerResolutionSize } from './ImageCanvasEditorModel'; import type { - CharacterReferenceImage, - CanvasMediaType, CanvasLayer, + CanvasMediaType, CanvasViewport, + CharacterReferenceImage, EditorAsset, EditorAssetFolder, GenerateDialogState, QuickEditPanelState, } from './ImageCanvasEditorTypes'; -import { resolvePreferredAssetUploadFolder } from './ImageCanvasAssetLibraryModel'; import { appendLimitedQuickEditReferences } from './ImageCanvasGenerationModel'; type CanvasSize = { width: number; height: number }; diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index a28d6401b..99fafa4fd 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -8,20 +8,20 @@ import { Megaphone, Music, Pause, - Play, PersonStanding, + Play, Sparkles, UserRound, Volume2, VolumeX, } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; import { diff --git a/src/components/image-editor/useCanvasGenerationDialogs.test.tsx b/src/components/image-editor/useCanvasGenerationDialogs.test.tsx index e845a4a4c..fba896056 100644 --- a/src/components/image-editor/useCanvasGenerationDialogs.test.tsx +++ b/src/components/image-editor/useCanvasGenerationDialogs.test.tsx @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ -import { renderHook, act } from '@testing-library/react'; +import { act,renderHook } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes'; diff --git a/src/components/image-editor/useCanvasGenerationDialogs.ts b/src/components/image-editor/useCanvasGenerationDialogs.ts index 21ab352d5..c386825e7 100644 --- a/src/components/image-editor/useCanvasGenerationDialogs.ts +++ b/src/components/image-editor/useCanvasGenerationDialogs.ts @@ -7,11 +7,11 @@ import { useState, } from 'react'; -import { isCanvasGenerationDialog } from './ImageCanvasGenerationModel'; import type { CanvasGenerationDialogState, GenerateDialogState, } from './ImageCanvasEditorTypes'; +import { isCanvasGenerationDialog } from './ImageCanvasGenerationModel'; type CanvasGenerationDialogUpdater = ( dialog: CanvasGenerationDialogState, diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts index 0e6245e4b..c011b1c54 100644 --- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts @@ -7,8 +7,8 @@ import type { CanvasLayer, } from './ImageCanvasEditorTypes'; import { - buildAnimatedGifPreviewBytes, blobToUint8Array, + buildAnimatedGifPreviewBytes, buildLayerExportMetadata, buildLayerVisibleExportMetadata, buildSpineImageSequenceJson, diff --git a/src/components/image-editor/useImageCanvasAssetLibrary.ts b/src/components/image-editor/useImageCanvasAssetLibrary.ts index db016cb2a..f9a5cc191 100644 --- a/src/components/image-editor/useImageCanvasAssetLibrary.ts +++ b/src/components/image-editor/useImageCanvasAssetLibrary.ts @@ -19,11 +19,6 @@ import { updateEditorAsset, updateEditorAssetFolder, } from '../../services/image-editor/editorProjectClient'; -import { - EDITOR_ASSET_FOLDERS, - escapeCssIdentifier, - normalizeAssetLibrary, -} from './ImageCanvasEditorModel'; import { areAllSelectableAssetsSelected, createAssetMarqueeFromPointer, @@ -32,23 +27,28 @@ import { deleteAssetFolderLocally, getSelectableAssets, groupAssetsByFolder, - moveAssetToFolderLocally, moveAssetMarqueeToPointer, + moveAssetToFolderLocally, removeAssetById, removeSelectedAssets, renameAssetById, renameAssetFolderById, replaceLocalAssetFolder, - resolveAssetShowcaseSubmissionState, resolveAllAssetSelection, - resolveDefaultAssetFolder, resolveAssetFolderIdFromPoint, + resolveAssetShowcaseSubmissionState, + resolveDefaultAssetFolder, resolvePinnedAssetMoveFolderId, selectUploadedAssetsInRect, shouldStartAssetMarquee, toggleAssetFolderCollapsed, toggleAssetSelection, } from './ImageCanvasAssetLibraryModel'; +import { + EDITOR_ASSET_FOLDERS, + escapeCssIdentifier, + normalizeAssetLibrary, +} from './ImageCanvasEditorModel'; import type { AssetMarqueeState, AssetPointerDragState, diff --git a/src/components/image-editor/useImageCanvasCanvasDropWorkflow.ts b/src/components/image-editor/useImageCanvasCanvasDropWorkflow.ts index 48f961876..224ebee45 100644 --- a/src/components/image-editor/useImageCanvasCanvasDropWorkflow.ts +++ b/src/components/image-editor/useImageCanvasCanvasDropWorkflow.ts @@ -1,6 +1,6 @@ import { - type DragEvent as ReactDragEvent, type Dispatch, + type DragEvent as ReactDragEvent, type SetStateAction, useCallback, useMemo, diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index bfcb403dd..0e158971c 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -189,6 +189,7 @@ function SubmissionWorkflowHarness({ initialCharacterAnimationPanel = null, initialLayers = [createLayer()], projectId = undefined, + assetFolderId = projectId ? 'project' : undefined, upsertGeneratedAsset = undefined, applyProjectSnapshot = undefined, onQueuedGenerationTask = undefined, @@ -200,6 +201,7 @@ function SubmissionWorkflowHarness({ initialCharacterAnimationPanel?: CharacterAnimationPanelState | null; initialLayers?: CanvasLayer[]; projectId?: string | null; + assetFolderId?: string | null; upsertGeneratedAsset?: Parameters< typeof useImageCanvasGenerationSubmissionWorkflow >[0]['upsertGeneratedAsset']; @@ -283,7 +285,7 @@ function SubmissionWorkflowHarness({ rememberedImageModelRef.current = imageModel; }, projectId, - assetFolderId: projectId ? 'project' : undefined, + assetFolderId, upsertGeneratedAsset, applyProjectSnapshot, onQueuedGenerationTask, @@ -537,6 +539,31 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { expect(firstOptions.pathSegments).not.toEqual(secondOptions.pathSegments); }); + it('uploads image references without an object reference before generation', async () => { + resolveEditorImageReferenceDataUrlMock.mockResolvedValueOnce( + 'data:image/png;base64,ZXhhbXBsZQ==', + ); + const result = await resolveEditorGenerationMediaReference( + { src: '/creation-type-references/example.webp' }, + 'image', + 'project-1', + ); + + expect(resolveEditorImageReferenceDataUrlMock).toHaveBeenCalledWith( + '/creation-type-references/example.webp', + ); + expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith( + expect.any(File), + 'image', + expect.objectContaining({ + assetKind: 'editor_generation_reference_image', + }), + ); + expect(result).toBe( + 'generated-character-drafts/editor/generation-references/reference.png', + ); + }); + it('submits quick edits and updates the source layer directly', async () => { editEditorImageMock.mockResolvedValueOnce( createGenerated({ @@ -2684,6 +2711,63 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { }); }); + it('uses updated character animation folder and queue callbacks after rerender', async () => { + const firstQueuedTaskCallback = vi.fn(); + const latestQueuedTaskCallback = vi.fn(); + generateEditorCharacterAnimationMock.mockResolvedValueOnce({ + taskId: 'animation-task-queued', + queueState: createQueueState({ status: 'queued' }), + project: { + id: 'editor-project-1', + }, + }); + const harnessProps = { + projectId: 'editor-project-1', + initialLayers: [ + createLayer({ + id: 'character-layer', + assetKind: 'character' as const, + objectKey: 'generated/character.png', + }), + ], + initialCharacterAnimationPanel: { + sourceLayerId: 'character-layer', + promptText: '跑步', + resolution: '480p' as const, + ratio: 'same' as const, + frameCount: 32 as const, + durationSeconds: 4 as const, + status: 'idle' as const, + }, + }; + const { rerender } = render( + , + ); + + rerender( + , + ); + fireEvent.click(screen.getByRole('button', { name: '提交角色动画' })); + + await waitFor(() => { + expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith( + expect.objectContaining({ assetFolderId: 'folder-latest' }), + ); + }); + await waitFor(() => { + expect(latestQueuedTaskCallback).toHaveBeenCalledTimes(1); + }); + expect(firstQueuedTaskCallback).not.toHaveBeenCalled(); + }); + it('does not resubmit character animation while it is already generating', () => { render( layer.id === quickEditPanel.sourceLayerId) ?? @@ -1955,6 +1950,7 @@ export function useImageCanvasGenerationWorkflow({ setCropExpandPanel, setMetadataLayer, setQuickEditPanel, + setViewport, ], ); @@ -2586,7 +2582,6 @@ export function useImageCanvasGenerationWorkflow({ updateIconDescriptionsText, updateSpecFormValue, rememberImageModel, - refreshTaskListForQueuedGeneration, ], ); } diff --git a/src/components/image-editor/useImageCanvasLayerCommands.ts b/src/components/image-editor/useImageCanvasLayerCommands.ts index f19c552eb..cc4b25873 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.ts +++ b/src/components/image-editor/useImageCanvasLayerCommands.ts @@ -12,6 +12,7 @@ import type { CanvasGenerationDialogState, CanvasLayer, } from './ImageCanvasEditorTypes'; +import { readLayerImageBlob } from './ImageCanvasExportModel'; import { type CanvasLayerFlipAxis, type CanvasLayerMoveMode, @@ -28,7 +29,6 @@ import { ungroupCanvasLayers, updateCanvasLayersByIds, } from './ImageCanvasLayerCommandModel'; -import { readLayerImageBlob } from './ImageCanvasExportModel'; import { getCanvasGenerationDialogIdFromSelectionId, getSelectedLayerIds, diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index 6ad31b075..5ea1a2fa9 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -6,15 +6,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiClientError } from '../../services/apiClient'; import type { EditorProjectSnapshot } from '../../services/image-editor/editorProjectClient'; +import { + DEFAULT_CANVAS_BACKGROUND_COLOR, + normalizeCanvasBackgroundHex, +} from './ImageCanvasEditorModel'; import type { CanvasGenerationDialogState, CanvasLayer, CanvasViewport, } from './ImageCanvasEditorTypes'; -import { - DEFAULT_CANVAS_BACKGROUND_COLOR, - normalizeCanvasBackgroundHex, -} from './ImageCanvasEditorModel'; import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence'; const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.ts b/src/components/image-editor/useImageCanvasProjectPersistence.ts index dd2ad92de..f956d5e6c 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.ts +++ b/src/components/image-editor/useImageCanvasProjectPersistence.ts @@ -6,10 +6,9 @@ import { useState, } from 'react'; -import { ApiClientError } from '../../services/apiClient'; import { replaceAppHistoryPath } from '../../routing/appPageRoutes'; +import { ApiClientError } from '../../services/apiClient'; import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMediaAssetUploadClient'; -import { putEditorProjectCoverCache } from '../../services/image-editor/editorProjectCoverCache'; import { createEditorProjectResource, type EditorProjectResourceSnapshot, @@ -18,6 +17,7 @@ import { loadOrCreateRecentEditorProject, saveEditorProjectLayout, } from '../../services/image-editor/editorProjectClient'; +import { putEditorProjectCoverCache } from '../../services/image-editor/editorProjectCoverCache'; import { canvasDisplayViewportToViewport, type CanvasLayerResourceMetadata, diff --git a/src/components/image-editor/useImageCanvasStageController.ts b/src/components/image-editor/useImageCanvasStageController.ts index 64ff9c46e..acee21d3e 100644 --- a/src/components/image-editor/useImageCanvasStageController.ts +++ b/src/components/image-editor/useImageCanvasStageController.ts @@ -13,13 +13,13 @@ import type { CanvasViewport, ImageContextMenuState, } from './ImageCanvasEditorTypes'; +import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel'; import { createBlankCanvasContextMenu, createGenerationDialogCanvasContextMenu, createLayerCanvasContextMenus, resolveImageCanvasStageControllerModel, } from './ImageCanvasStageControllerModel'; -import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel'; type UseImageCanvasStageControllerOptions = { layers: CanvasLayer[]; diff --git a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx index 33f12ad48..0db87d40a 100644 --- a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx +++ b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ -import { act, fireEvent, render, screen } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import { type PointerEvent as ReactPointerEvent, useRef, @@ -14,8 +14,8 @@ import type { CanvasViewport, GenerateDialogState, } from './ImageCanvasEditorTypes'; -import { useImageCanvasStageInteractions } from './useImageCanvasStageInteractions'; import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel'; +import { useImageCanvasStageInteractions } from './useImageCanvasStageInteractions'; function createLayer(overrides: Partial): CanvasLayer { const id = overrides.id ?? 'layer-a'; diff --git a/src/components/image-editor/useImageCanvasStageInteractions.ts b/src/components/image-editor/useImageCanvasStageInteractions.ts index f6054e3e9..1b94876b2 100644 --- a/src/components/image-editor/useImageCanvasStageInteractions.ts +++ b/src/components/image-editor/useImageCanvasStageInteractions.ts @@ -1,7 +1,7 @@ import { type Dispatch, - type PointerEvent as ReactPointerEvent, type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, type RefObject, type SetStateAction, useCallback, @@ -10,9 +10,19 @@ import { useState, } from 'react'; +import type { + CanvasGenerationDialogState, + CanvasLayer, + CanvasMarqueeState, + CanvasTool, + CanvasViewport, + DragState, + GenerateDialogState, + SnapGuide, +} from './ImageCanvasEditorTypes'; import { - moveGenerationFramesFromDrag, moveDragSelectedLayers, + moveGenerationFramesFromDrag, moveLayersFromDrag, moveViewportFromPan, selectCanvasObjectsInsideMarquee, @@ -32,16 +42,6 @@ import { updateGenerateDialogForLayerPointerDown, updateMinimapDragMovement, } from './ImageCanvasStageInteractionModel'; -import type { - CanvasGenerationDialogState, - CanvasLayer, - CanvasMarqueeState, - CanvasTool, - CanvasViewport, - DragState, - GenerateDialogState, - SnapGuide, -} from './ImageCanvasEditorTypes'; const CLICK_COLLAPSE_MOVEMENT_THRESHOLD_PX = 3; @@ -399,6 +399,7 @@ export function useImageCanvasStageInteractions({ isPickingQuickEditReferenceFromCanvas, isPickingCharacterReferenceFromCanvas, isPickingIconSpecFromCanvas, + isPickingUiDesignSpecFromCanvas, isPickingPublicationReferenceFromCanvas, layers, pickGenerationReferenceFromLayer, diff --git a/src/components/image-editor/useImageCanvasUploadWorkflow.ts b/src/components/image-editor/useImageCanvasUploadWorkflow.ts index fc718e6af..0c005d430 100644 --- a/src/components/image-editor/useImageCanvasUploadWorkflow.ts +++ b/src/components/image-editor/useImageCanvasUploadWorkflow.ts @@ -15,9 +15,14 @@ import { createEditorProjectResource, } from '../../services/image-editor/editorProjectClient'; import { uploadEditorSeedanceReferenceFile } from '../../services/image-editor/editorReferenceUploadClient'; +import { + EDITOR_UPLOAD_ASSET_FOLDER_LABEL, + resolvePreferredAssetUploadFolder, + resolveUploadAssetFolder, +} from './ImageCanvasAssetLibraryModel'; import type { - CanvasMediaType, CanvasLayer, + CanvasMediaType, CanvasTool, CanvasViewport, CharacterReferenceImage, @@ -28,28 +33,23 @@ import type { UploadTarget, } from './ImageCanvasEditorTypes'; import { - EDITOR_UPLOAD_ASSET_FOLDER_LABEL, - resolvePreferredAssetUploadFolder, - resolveUploadAssetFolder, -} from './ImageCanvasAssetLibraryModel'; -import { - SEEDANCE_REFERENCE_FILE_LIMITS, isImageFile, - resolveEditorAssetUploadMediaType, isSeedanceReferenceAudioFile, isSeedanceReferenceImageFile, isSeedanceReferenceVideoFile, probeImageFileDimensions, - probeMediaFileMetadata, probeMediaDurationSeconds, + probeMediaFileMetadata, readFileAsDataUrl, readImageFileAsDataUrl, + resolveEditorAssetUploadMediaType, + SEEDANCE_REFERENCE_FILE_LIMITS, } from './ImageCanvasFileModel'; import { applyGenerationReferenceUpload, - applyQuickEditReferenceUpload, applyMeasuredUploadAssetSize, applyPersistedUploadAsset, + applyQuickEditReferenceUpload, applyUploadAssetCreateFailure, applyUploadAssetCreatePending, applyUploadAssetReadFailure, @@ -1113,16 +1113,13 @@ export function useImageCanvasUploadWorkflow({ activeUploadFolderId, appendCanvasLayersWithResources, assetFolders, - canvasSize.height, - canvasSize.width, + canvasSize, openEditorLoginModal, selectSingleLayer, setAssetFolders, setAssets, setLayers, - viewport.scale, - viewport.x, - viewport.y, + viewport, ], ); diff --git a/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx b/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx index 23281f943..b3da7337b 100644 --- a/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx +++ b/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx @@ -10,22 +10,22 @@ import type { import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard'; import { - JUMP_HOP_THREE_CAMERA_UP_Y, - JUMP_HOP_THREE_CHARACTER_RENDER_ORDER, - JUMP_HOP_THREE_CHARACTER_TOP_FACE_Z_OFFSET, - JUMP_HOP_THREE_MATERIAL_FACE_ORDER, - JUMP_HOP_THREE_PLATFORM_MATERIAL_TRANSPARENT, - JUMP_HOP_THREE_PLATFORM_MESH_RENDER_ORDER_BASE, - JUMP_HOP_THREE_PLATFORM_YAW_RAD, - JUMP_HOP_THREE_PLATFORM_CUBE_SIZE_MULTIPLIER, - JUMP_HOP_THREE_TEXTURE_TRANSFORMS, - JumpHopRuntimeShell, getJumpHopThreeCharacterFootZ, getJumpHopThreeCubeSide, getJumpHopThreeProjectedY, getJumpHopThreeWorldYForScreenY, getJumpHopTileTextureSignature, hasCompleteJumpHopTileFaceAssets, + JUMP_HOP_THREE_CAMERA_UP_Y, + JUMP_HOP_THREE_CHARACTER_RENDER_ORDER, + JUMP_HOP_THREE_CHARACTER_TOP_FACE_Z_OFFSET, + JUMP_HOP_THREE_MATERIAL_FACE_ORDER, + JUMP_HOP_THREE_PLATFORM_CUBE_SIZE_MULTIPLIER, + JUMP_HOP_THREE_PLATFORM_MATERIAL_TRANSPARENT, + JUMP_HOP_THREE_PLATFORM_MESH_RENDER_ORDER_BASE, + JUMP_HOP_THREE_PLATFORM_YAW_RAD, + JUMP_HOP_THREE_TEXTURE_TRANSFORMS, + JumpHopRuntimeShell, resolveJumpHopThreeCharacterFrame, } from './JumpHopRuntimeShell'; diff --git a/src/components/match3d-result/Match3DResultView.tsx b/src/components/match3d-result/Match3DResultView.tsx index 2a8d90403..76edece1a 100644 --- a/src/components/match3d-result/Match3DResultView.tsx +++ b/src/components/match3d-result/Match3DResultView.tsx @@ -60,8 +60,8 @@ import { readPuzzleReferenceImageAsDataUrl, } from '../../services/puzzleReferenceImage'; import { PlatformActionButton } from '../common/PlatformActionButton'; -import { PlatformBackActionButton } from '../common/PlatformBackActionButton'; import { PlatformAssetPickerGrid } from '../common/PlatformAssetPickerCard'; +import { PlatformBackActionButton } from '../common/PlatformBackActionButton'; import { PlatformFieldLabel } from '../common/PlatformFieldLabel'; import { PlatformIconButton } from '../common/PlatformIconButton'; import { PlatformMediaFrame } from '../common/PlatformMediaFrame'; diff --git a/src/components/match3d-runtime/match3dHotspot.test.ts b/src/components/match3d-runtime/match3dHotspot.test.ts index fef6d646e..137ee7489 100644 --- a/src/components/match3d-runtime/match3dHotspot.test.ts +++ b/src/components/match3d-runtime/match3dHotspot.test.ts @@ -4,8 +4,8 @@ import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/ import { findMatch3DHitItem, isPointInsideMatch3DItemAlphaHotspot, - resolveMatch3DItemSizeScale, type Match3DResolvedImageSourceEntry, + resolveMatch3DItemSizeScale, } from './match3dHotspot'; import { resolveRenderableItemFrame } from './match3dRuntimePresentation'; diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx index e8170da18..fdf784ac3 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx @@ -11,8 +11,8 @@ import { expect, test, vi } from 'vitest'; import { PuzzleOnboardingLoginOverlay, - PuzzleOnboardingView, type PuzzleOnboardingPhase, + PuzzleOnboardingView, } from './PuzzleOnboardingView'; function renderOnboarding({ diff --git a/src/components/platform-entry/PlatformProfilePlayedWorksModal.tsx b/src/components/platform-entry/PlatformProfilePlayedWorksModal.tsx index 366d3dcec..011b25fc7 100644 --- a/src/components/platform-entry/PlatformProfilePlayedWorksModal.tsx +++ b/src/components/platform-entry/PlatformProfilePlayedWorksModal.tsx @@ -8,13 +8,12 @@ import type { import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel'; import { PlatformEmptyState } from '../common/PlatformEmptyState'; import { PlatformFieldLabel } from '../common/PlatformFieldLabel'; +import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformProfileContentRow } from '../common/PlatformProfileContentRow'; import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList'; import { PlatformProfileSummaryHeader } from '../common/PlatformProfileSummaryHeader'; -import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; -import { PlatformProfileSecondaryModalShell } from './PlatformProfileModalShell'; import { formatCompactPlayTime, formatPlayedWorkId, @@ -23,6 +22,7 @@ import { formatTotalPlayTimeHours, } from '../rpg-entry/rpgEntryProfileDashboardPresentation'; import { formatPlatformWorkDisplayName } from '../rpg-entry/rpgEntryWorldPresentation'; +import { PlatformProfileSecondaryModalShell } from './PlatformProfileModalShell'; type PlatformProfilePlayedWorksModalProps = { stats: ProfilePlayStatsResponse | null; diff --git a/src/components/platform-entry/PlatformProfileQrScannerModal.tsx b/src/components/platform-entry/PlatformProfileQrScannerModal.tsx index a59c587ab..1c998146d 100644 --- a/src/components/platform-entry/PlatformProfileQrScannerModal.tsx +++ b/src/components/platform-entry/PlatformProfileQrScannerModal.tsx @@ -1,8 +1,8 @@ import { useEffect, useRef } from 'react'; +import { scanHostQrCode } from '../../services/host-bridge/hostBridge'; import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; -import { scanHostQrCode } from '../../services/host-bridge/hostBridge'; import { PlatformProfileModalShell } from './PlatformProfileModalShell'; const PROFILE_QR_SCAN_INTERVAL_MS = 360; @@ -165,7 +165,7 @@ export function PlatformProfileQrScannerModal({ clearScanTimer(); stopCamera(); }; - }, [mode, onError, onResult]); + }, [mode, onClose, onError, onResult]); return ( { it('keeps compile action generating until the draft has a cover image', () => { diff --git a/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx b/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx index a6dab42df..5bacf860a 100644 --- a/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx +++ b/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx @@ -1,8 +1,8 @@ import { ArrowLeft, ChevronRight, RotateCcw } from 'lucide-react'; import { type CSSProperties, - type ReactNode, type PointerEvent as ReactPointerEvent, + type ReactNode, useEffect, useMemo, useRef, diff --git a/src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx b/src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx index 12be18f4d..1d8255e89 100644 --- a/src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx +++ b/src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx @@ -4,8 +4,8 @@ import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, expect, test, vi } from 'vitest'; -import type { EditableCustomWorldRole } from './roleAssetStudioModel'; import * as hostBridgeServices from '../../services/host-bridge/hostBridge'; +import type { EditableCustomWorldRole } from './roleAssetStudioModel'; import { RpgCreationRoleAssetStudioModal } from './RpgCreationRoleAssetStudioModal'; const generateVisualCandidatesForRole = vi.fn(); diff --git a/src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx b/src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx index 368d864d4..6b3e4f31e 100644 --- a/src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx +++ b/src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx @@ -8,6 +8,11 @@ import { import { createPortal } from 'react-dom'; import { ROLE_TEMPLATE_CHARACTERS } from '../../data/characterPresets'; +import { + canUseNativeHostCapability, + importHostImageFile, +} from '../../services/host-bridge/hostBridge'; +import { puzzleReferenceImageDataUrlToFile } from '../../services/puzzleReferenceImage'; import { AnimationState, type Character, @@ -25,11 +30,6 @@ import { useAuthUi } from '../auth/AuthUiContext'; import { CharacterAnimator } from '../CharacterAnimator'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformMudPointConfirmDialog } from '../common/PlatformMudPointConfirmDialog'; -import { - canUseNativeHostCapability, - importHostImageFile, -} from '../../services/host-bridge/hostBridge'; -import { puzzleReferenceImageDataUrlToFile } from '../../services/puzzleReferenceImage'; import { CORE_ACTIONS, type CustomWorldAiActionConfig, diff --git a/src/components/rpg-creation-asset-studio/useRoleAnimationWorkflow.ts b/src/components/rpg-creation-asset-studio/useRoleAnimationWorkflow.ts index fa5cd94c4..ce3fc547e 100644 --- a/src/components/rpg-creation-asset-studio/useRoleAnimationWorkflow.ts +++ b/src/components/rpg-creation-asset-studio/useRoleAnimationWorkflow.ts @@ -1,11 +1,10 @@ -import { generateCharacterAnimationDraft } from '../asset-studio/characterAssetWorkflowPersistence'; import type { CharacterAnimationGenerationPayload } from '../asset-studio/characterAssetWorkflowPersistence'; - -import { roleAssetStudioPublishClient } from './roleAssetStudioPublishClient'; +import { generateCharacterAnimationDraft } from '../asset-studio/characterAssetWorkflowPersistence'; import type { CustomWorldAiActionConfig, EditableCustomWorldRole, } from './roleAssetStudioModel'; +import { roleAssetStudioPublishClient } from './roleAssetStudioPublishClient'; export function useRoleAnimationWorkflow() { const generateAnimationClipForRole = async (params: { diff --git a/src/components/rpg-creation-asset-studio/useRoleVisualCandidateWorkflow.ts b/src/components/rpg-creation-asset-studio/useRoleVisualCandidateWorkflow.ts index 576c6c5e2..6c45ee798 100644 --- a/src/components/rpg-creation-asset-studio/useRoleVisualCandidateWorkflow.ts +++ b/src/components/rpg-creation-asset-studio/useRoleVisualCandidateWorkflow.ts @@ -1,8 +1,7 @@ -import { generateCharacterVisualCandidates } from '../asset-studio/characterAssetWorkflowPersistence'; import type { CharacterVisualDraft } from '../asset-studio/characterAssetWorkflowPersistence'; - -import { roleAssetStudioPublishClient } from './roleAssetStudioPublishClient'; +import { generateCharacterVisualCandidates } from '../asset-studio/characterAssetWorkflowPersistence'; import type { EditableCustomWorldRole } from './roleAssetStudioModel'; +import { roleAssetStudioPublishClient } from './roleAssetStudioPublishClient'; export function useRoleVisualCandidateWorkflow() { const generateVisualCandidatesForRole = async (params: { diff --git a/src/components/rpg-creation-editor/RpgCreationEntityEditorModal.tsx b/src/components/rpg-creation-editor/RpgCreationEntityEditorModal.tsx index e48d8fe46..8c323fa73 100644 --- a/src/components/rpg-creation-editor/RpgCreationEntityEditorModal.tsx +++ b/src/components/rpg-creation-editor/RpgCreationEntityEditorModal.tsx @@ -1,9 +1,9 @@ import type { ComponentProps } from 'react'; -import RpgCreationEntityEditorModalImpl from './RpgCreationEntityEditorModalImpl'; import type { RpgCreationEditorTarget, } from './RpgCreationEntityEditorModalImpl'; +import RpgCreationEntityEditorModalImpl from './RpgCreationEntityEditorModalImpl'; /** * 工作包 C 完成后,编辑器 façade 已直接桥接 RPG 创作目录下的目标分发壳层。 diff --git a/src/components/rpg-creation-editor/RpgCreationEntityEditorModalImpl.tsx b/src/components/rpg-creation-editor/RpgCreationEntityEditorModalImpl.tsx index be0147d72..3c16825f5 100644 --- a/src/components/rpg-creation-editor/RpgCreationEntityEditorModalImpl.tsx +++ b/src/components/rpg-creation-editor/RpgCreationEntityEditorModalImpl.tsx @@ -1,5 +1,4 @@ import type { CustomWorldProfile } from '../../types'; - import CampSceneEditor from './CustomWorldCampEditorSection'; import WorldCoverEditor from './CustomWorldCoverEditorSection'; import WorldFoundationEditor from './CustomWorldFoundationEditorSection'; diff --git a/src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx b/src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx index 3c4935c9a..9dde7aa10 100644 --- a/src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx +++ b/src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx @@ -56,16 +56,16 @@ import { getCustomWorldFoundationAnchorContent, parseFoundationTagText, } from '../../services/customWorldFoundationEntries'; -import { - rpgCreationAssetClient, - type RpgCreationHistoryAsset, - type RpgCreationHistoryAssetKind, -} from '../../services/rpg-creation/rpgCreationAssetClient'; import { canUseNativeHostCapability, importHostImageFile, } from '../../services/host-bridge/hostBridge'; import { puzzleReferenceImageDataUrlToFile } from '../../services/puzzleReferenceImage'; +import { + rpgCreationAssetClient, + type RpgCreationHistoryAsset, + type RpgCreationHistoryAssetKind, +} from '../../services/rpg-creation/rpgCreationAssetClient'; import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine'; import { AnimationState, diff --git a/src/components/rpg-creation-editor/rpgCreationResultFormMapper.ts b/src/components/rpg-creation-editor/rpgCreationResultFormMapper.ts index 3faa29907..43609cb97 100644 --- a/src/components/rpg-creation-editor/rpgCreationResultFormMapper.ts +++ b/src/components/rpg-creation-editor/rpgCreationResultFormMapper.ts @@ -1,13 +1,13 @@ -import type { - RpgCreationEditorTarget, - RpgCreationEntityEditorModalProps, -} from './RpgCreationEntityEditorModalImpl'; import type { CustomWorldLandmark, CustomWorldNpc, CustomWorldPlayableNpc, CustomWorldProfile, } from '../../types'; +import type { + RpgCreationEditorTarget, + RpgCreationEntityEditorModalProps, +} from './RpgCreationEntityEditorModalImpl'; /** * 工作包 C 第一轮先把编辑器目标分发需要的 profile 变更收口到 mapper。 diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 54db12230..19e2e7ede 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -9,10 +9,6 @@ import type { PublicUserSummary } from '../../../packages/shared/src/contracts/a import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { CreativeAgentSessionSnapshot } from '../../../packages/shared/src/contracts/creativeAgent'; -import type { - HostBridgeCapability, - HostBridgeRequest, -} from '../../../packages/shared/src/contracts/hostBridge'; import type { CustomWorldAgentSessionSnapshot, CustomWorldWorkSummary, @@ -21,6 +17,10 @@ import type { BabyObjectMatchDraft, CreateBabyObjectMatchDraftRequest, } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import type { + HostBridgeCapability, + HostBridgeRequest, +} from '../../../packages/shared/src/contracts/hostBridge'; import type { JumpHopRuntimeRunSnapshotResponse, JumpHopWorkDetailResponse, diff --git a/src/components/rpg-entry/rpgEntryTypes.ts b/src/components/rpg-entry/rpgEntryTypes.ts index da76f6fff..5d4c71fd5 100644 --- a/src/components/rpg-entry/rpgEntryTypes.ts +++ b/src/components/rpg-entry/rpgEntryTypes.ts @@ -2,8 +2,8 @@ export type { CustomWorldAutoSaveState, CustomWorldGenerationViewSource, CustomWorldResultViewSource, - PlatformEntryFlowShellProps as RpgEntryFlowShellProps, PlatformEntryFlowShellProps as RpgCreationShellProps, + PlatformEntryFlowShellProps as RpgEntryFlowShellProps, SelectionStage, SyncedAgentDraftResult, } from '../platform-entry/platformEntryTypes'; diff --git a/src/components/rpg-entry/useRpgCreationEnterWorld.ts b/src/components/rpg-entry/useRpgCreationEnterWorld.ts index b05b079f0..ecf25d7eb 100644 --- a/src/components/rpg-entry/useRpgCreationEnterWorld.ts +++ b/src/components/rpg-entry/useRpgCreationEnterWorld.ts @@ -1,10 +1,10 @@ import { useCallback } from 'react'; -import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView'; import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent'; -import type { CustomWorldRuntimeLaunchOptions } from '../platform-entry/platformEntryTypes'; +import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView'; import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter'; import type { CustomWorldProfile } from '../../types'; +import type { CustomWorldRuntimeLaunchOptions } from '../platform-entry/platformEntryTypes'; import { chooseMoreCompleteCustomWorldProfile } from './rpgProfileCompleteness'; type UseRpgCreationEnterWorldParams = { diff --git a/src/components/rpg-entry/useRpgEntryBootstrap.test.tsx b/src/components/rpg-entry/useRpgEntryBootstrap.test.tsx index 3d3a5525f..03081f90a 100644 --- a/src/components/rpg-entry/useRpgEntryBootstrap.test.tsx +++ b/src/components/rpg-entry/useRpgEntryBootstrap.test.tsx @@ -4,13 +4,13 @@ import { renderHook, waitFor } from '@testing-library/react'; import { beforeEach, expect, test, vi } from 'vitest'; import type { AuthUser } from '../../services/authService'; +import { listRpgCreationWorks } from '../../services/rpg-creation/index'; import { listRpgEntryWorldGallery, listRpgEntryWorldLibrary, listRpgProfileBrowseHistory, listRpgProfileSaveArchives, } from '../../services/rpg-entry'; -import { listRpgCreationWorks } from '../../services/rpg-creation/index'; import { useRpgEntryBootstrap } from './useRpgEntryBootstrap'; vi.mock('../../services/rpg-entry', () => ({ diff --git a/src/components/rpg-runtime-panels/RpgAdventurePanel.tsx b/src/components/rpg-runtime-panels/RpgAdventurePanel.tsx index 595a4cbf4..9a406bc4b 100644 --- a/src/components/rpg-runtime-panels/RpgAdventurePanel.tsx +++ b/src/components/rpg-runtime-panels/RpgAdventurePanel.tsx @@ -50,8 +50,8 @@ import { TAB_ICONS, UI_CHROME, } from '../../uiAssets'; -import { PlatformEmptyState } from '../common/PlatformEmptyState'; import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformEmptyState } from '../common/PlatformEmptyState'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformQuantityBadge } from '../common/PlatformQuantityBadge'; import { PlatformSubpanel } from '../common/PlatformSubpanel'; diff --git a/src/components/rpg-runtime-shell/RpgRuntimeShell.test.tsx b/src/components/rpg-runtime-shell/RpgRuntimeShell.test.tsx index 8fb1a421b..41ed4659f 100644 --- a/src/components/rpg-runtime-shell/RpgRuntimeShell.test.tsx +++ b/src/components/rpg-runtime-shell/RpgRuntimeShell.test.tsx @@ -6,9 +6,9 @@ import { beforeEach, expect, test, vi } from 'vitest'; import { AnimationState, - WorldType, type GameState, type StoryMoment, + WorldType, } from '../../types'; import { RpgRuntimeShell } from './RpgRuntimeShell'; import type { RpgRuntimeShellProps } from './types'; diff --git a/src/components/rpg-runtime-shell/index.ts b/src/components/rpg-runtime-shell/index.ts index 84f64ee2f..77ee6bd00 100644 --- a/src/components/rpg-runtime-shell/index.ts +++ b/src/components/rpg-runtime-shell/index.ts @@ -2,6 +2,10 @@ export { RpgRuntimeCanvasStage, type RpgRuntimeCanvasStageProps, } from './RpgRuntimeCanvasStage'; +export { + ModalLoadingFallback, + PanelLoadingFallback, +} from './rpgRuntimeLoaders'; export { RpgRuntimeOverlayHost, type RpgRuntimeOverlayHostProps, @@ -18,19 +22,15 @@ export { type RpgAdventureStatistics, type RpgRuntimeDialogueIndicator, } from './types'; -export { - ModalLoadingFallback, - PanelLoadingFallback, -} from './rpgRuntimeLoaders'; -export { - useRpgRuntimeShellViewModel, - type RpgRuntimeShellViewModelResult, - type UseRpgRuntimeShellViewModelParams, -} from './useRpgRuntimeShellViewModel'; export { useRpgRuntimeOverlayState } from './useRpgRuntimeOverlayState'; +export { + type RpgRuntimeShellViewModelResult, + useRpgRuntimeShellViewModel, + type UseRpgRuntimeShellViewModelParams, +} from './useRpgRuntimeShellViewModel'; export { SCENE_TRANSITION_FUNCTION_MODES, - useRpgSceneTransitionModel, type SceneTransitionPhase, type SceneTransitionTriggerMode, + useRpgSceneTransitionModel, } from './useRpgSceneTransitionModel'; diff --git a/src/components/unified-creation/UnifiedCreationWorkspace.test.tsx b/src/components/unified-creation/UnifiedCreationWorkspace.test.tsx index 29e0e1cf3..f69ecc8a8 100644 --- a/src/components/unified-creation/UnifiedCreationWorkspace.test.tsx +++ b/src/components/unified-creation/UnifiedCreationWorkspace.test.tsx @@ -3,8 +3,8 @@ import { render, screen } from '@testing-library/react'; import { describe, expect, test, vi } from 'vitest'; -import { UnifiedCreationWorkspace } from './UnifiedCreationWorkspace'; import { getUnifiedCreationSpec } from './unifiedCreationSpecs'; +import { UnifiedCreationWorkspace } from './UnifiedCreationWorkspace'; vi.mock('./workspaces/PuzzleCreationWorkspace', () => ({ PuzzleCreationWorkspace: ({ diff --git a/src/components/unified-creation/UnifiedCreationWorkspace.tsx b/src/components/unified-creation/UnifiedCreationWorkspace.tsx index 697fafbd0..c0d5ba0e5 100644 --- a/src/components/unified-creation/UnifiedCreationWorkspace.tsx +++ b/src/components/unified-creation/UnifiedCreationWorkspace.tsx @@ -2,9 +2,9 @@ import type { ComponentProps } from 'react'; import { UnifiedCreationPage } from './UnifiedCreationPage'; import type { UnifiedCreationSpec } from './unifiedCreationSpecs'; +import { JumpHopCreationWorkspace } from './workspaces/JumpHopCreationWorkspace'; import { Match3DCreationWorkspace } from './workspaces/Match3DCreationWorkspace'; import { PuzzleCreationWorkspace } from './workspaces/PuzzleCreationWorkspace'; -import { JumpHopCreationWorkspace } from './workspaces/JumpHopCreationWorkspace'; import { WoodenFishCreationWorkspace } from './workspaces/WoodenFishCreationWorkspace'; type PuzzleCreationWorkspaceProps = ComponentProps< diff --git a/src/components/unified-creation/unifiedCreationSpecs.ts b/src/components/unified-creation/unifiedCreationSpecs.ts index 1c5f30e21..0468d81da 100644 --- a/src/components/unified-creation/unifiedCreationSpecs.ts +++ b/src/components/unified-creation/unifiedCreationSpecs.ts @@ -1,8 +1,8 @@ +import type { PlatformCreationTypeId } from '../../../packages/shared/src/contracts/playTypes'; import type { CreationEntryTypeConfig, UnifiedCreationSpec, } from '../../services/creationEntryConfigService'; -import type { PlatformCreationTypeId } from '../../../packages/shared/src/contracts/playTypes'; export const UNIFIED_CREATION_PLAY_IDS = [ 'rpg', diff --git a/src/components/unified-creation/workspaces/PuzzleCreationWorkspace.tsx b/src/components/unified-creation/workspaces/PuzzleCreationWorkspace.tsx index c42685795..5dac6b55b 100644 --- a/src/components/unified-creation/workspaces/PuzzleCreationWorkspace.tsx +++ b/src/components/unified-creation/workspaces/PuzzleCreationWorkspace.tsx @@ -17,7 +17,6 @@ import { CreativeImageInputPanel, type CreativeImageInputReferenceImage, } from '../../common/CreativeImageInputPanel'; -import { PlatformActionButton } from '../../common/PlatformActionButton'; import { PlatformBackActionButton } from '../../common/PlatformBackActionButton'; import { PlatformMudPointConfirmDialog } from '../../common/PlatformMudPointConfirmDialog'; import { PlatformPillBadge } from '../../common/PlatformPillBadge'; diff --git a/src/components/visual-novel-result/VisualNovelResultView.tsx b/src/components/visual-novel-result/VisualNovelResultView.tsx index 673c89881..6962e5790 100644 --- a/src/components/visual-novel-result/VisualNovelResultView.tsx +++ b/src/components/visual-novel-result/VisualNovelResultView.tsx @@ -46,8 +46,8 @@ import { } from '../../services/visual-novel-creation'; import { useAuthUi } from '../auth/AuthUiContext'; import { PlatformActionButton } from '../common/PlatformActionButton'; -import { PlatformBackActionButton } from '../common/PlatformBackActionButton'; import { PlatformAssetPickerGrid } from '../common/PlatformAssetPickerCard'; +import { PlatformBackActionButton } from '../common/PlatformBackActionButton'; import { PlatformEmptyState } from '../common/PlatformEmptyState'; import { PlatformFieldLabel } from '../common/PlatformFieldLabel'; import { PlatformIconButton } from '../common/PlatformIconButton'; @@ -56,12 +56,12 @@ import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton'; import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformSubpanel } from '../common/PlatformSubpanel'; -import { PlatformToolModalShell } from '../common/PlatformToolModalShell'; import { PlatformSelectField, PlatformTextField, } from '../common/PlatformTextField'; import { PlatformToggleRow } from '../common/PlatformToggleRow'; +import { PlatformToolModalShell } from '../common/PlatformToolModalShell'; import { visualNovelLocalPreviewDraft } from '../visual-novel-runtime/visualNovelLocalPreviewData'; type VisualNovelResultViewProps = { diff --git a/src/data/customWorldLibrary.ts b/src/data/customWorldLibrary.ts index 3b4971a73..cd7f55b1d 100644 --- a/src/data/customWorldLibrary.ts +++ b/src/data/customWorldLibrary.ts @@ -20,6 +20,7 @@ import { CharacterBackstoryChapter, CharacterBackstoryRevealConfig, CustomWorldAnchorPack, + CustomWorldCoverProfile, CustomWorldItem, CustomWorldLandmark, CustomWorldNpc, @@ -28,7 +29,6 @@ import { CustomWorldNpcVisualGearType, CustomWorldNpcVisualRace, CustomWorldOpeningCgProfile, - CustomWorldCoverProfile, CustomWorldPlayableNpc, CustomWorldProfile, CustomWorldRoleInitialItem, diff --git a/src/data/customWorldNpcMonsters.ts b/src/data/customWorldNpcMonsters.ts index 897002663..c21f4896a 100644 --- a/src/data/customWorldNpcMonsters.ts +++ b/src/data/customWorldNpcMonsters.ts @@ -2,13 +2,13 @@ import { collectCreatureArchetypeSignals, resolveCreatureArchetypeForSource, } from '../services/customWorldReferenceSignals'; +import { resolveCustomWorldCompatibilityTemplateWorldType } from '../services/customWorldTheme'; import { type CustomWorldNpc, type CustomWorldPlayableNpc, type CustomWorldProfile, WorldType, } from '../types'; -import { resolveCustomWorldCompatibilityTemplateWorldType } from '../services/customWorldTheme'; import { getMonsterPresetsByWorld, type HostileNpcPreset, diff --git a/src/data/customWorldVisuals.ts b/src/data/customWorldVisuals.ts index d81c304c2..420271c31 100644 --- a/src/data/customWorldVisuals.ts +++ b/src/data/customWorldVisuals.ts @@ -4,13 +4,13 @@ import { resolveSceneBucketForLandmark, } from '../services/customWorldReferenceSignals'; import { detectCustomWorldThemeMode } from '../services/customWorldTheme'; +import { resolveCustomWorldCompatibilityTemplateWorldType } from '../services/customWorldTheme'; import { type CustomWorldLandmark, type CustomWorldProfile, type WorldTemplateType, WorldType, } from '../types'; -import { resolveCustomWorldCompatibilityTemplateWorldType } from '../services/customWorldTheme'; const CUSTOM_WORLD_NPC_IMAGE_POOL = [ '/character/Sword%20Princess/Original/Hero/idle/Idle01.png', diff --git a/src/data/hostileNpcPresets.test.ts b/src/data/hostileNpcPresets.test.ts index 13b36ca06..ac5299f8d 100644 --- a/src/data/hostileNpcPresets.test.ts +++ b/src/data/hostileNpcPresets.test.ts @@ -69,9 +69,11 @@ function createGameState(): GameState { describe('hostileNpcPresets', () => { it('combines preset loot with runtime semantic drops', async () => { const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const networkError = new TypeError('network disabled in test'); vi.stubGlobal( 'fetch', - vi.fn().mockRejectedValue(new TypeError('network disabled in test')), + vi.fn().mockRejectedValue(networkError), ); try { @@ -89,9 +91,18 @@ describe('hostileNpcPresets', () => { expect( loot.find(item => item.id === 'monster-loot:bone-dust')?.runtimeMetadata?.storyFingerprint, ).toBeTruthy(); + expect(warnSpy).toHaveBeenCalledWith( + '[HostileNpcPresets] preset loot narrative fallback', + networkError, + ); + expect(warnSpy).toHaveBeenCalledWith( + '[RuntimeItemDirector] falling back to deterministic item intent', + networkError, + ); } finally { vi.unstubAllGlobals(); randomSpy.mockRestore(); + warnSpy.mockRestore(); } }); }); diff --git a/src/data/itemCatalog.ts b/src/data/itemCatalog.ts index f4e32fdaa..e0e56a04f 100644 --- a/src/data/itemCatalog.ts +++ b/src/data/itemCatalog.ts @@ -1,3 +1,6 @@ +import { + EDITOR_ITEM_CATALOG_API_PATH, +} from '../editor/shared/editorApiClient'; import type { InventoryItem, ItemCatalogEntry, @@ -5,9 +8,6 @@ import type { ItemRarity, WorldType, } from '../types'; -import { - EDITOR_ITEM_CATALOG_API_PATH, -} from '../editor/shared/editorApiClient'; import { buildDesignedItemMetadata } from './itemDesign'; export { EDITOR_ITEM_CATALOG_API_PATH as ITEM_CATALOG_API_PATH }; diff --git a/src/data/stateFunctions.test.ts b/src/data/stateFunctions.test.ts index 3a4ea5f6e..0ec3e657a 100644 --- a/src/data/stateFunctions.test.ts +++ b/src/data/stateFunctions.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest'; +import type { SceneHostileNpc, StoryOption } from '../types'; +import { AnimationState, WorldType } from '../types'; +import { + getScenePresetsByWorld, + getTravelScenePreset, + getWorldCampScenePreset, +} from './scenePresets'; +import type { FunctionAvailabilityContext } from './stateFunctions'; import { buildStateFunctionDefinitions, getExecutableFunctions, @@ -7,14 +15,6 @@ import { resolveFunctionOption, sortStoryOptionsByPriority, } from './stateFunctions'; -import { - getScenePresetsByWorld, - getTravelScenePreset, - getWorldCampScenePreset, -} from './scenePresets'; -import type { FunctionAvailabilityContext } from './stateFunctions'; -import type { SceneHostileNpc, StoryOption } from '../types'; -import { AnimationState, WorldType } from '../types'; function createMonster( overrides: Partial = {}, diff --git a/src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx b/src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx index 20918be75..69fe0d3ef 100644 --- a/src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx +++ b/src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx @@ -3,8 +3,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { BarkBattleDerivedMetrics, BarkBattlePublishedConfig, - BarkBattleRuntimeConfig, BarkBattleRunStartResponse, + BarkBattleRuntimeConfig, BarkBattleServerResult, } from '../../../../packages/shared/src/contracts/barkBattle'; import { diff --git a/src/hooks/rpg-runtime-story/index.ts b/src/hooks/rpg-runtime-story/index.ts index 321ac8c70..8e74f03a2 100644 --- a/src/hooks/rpg-runtime-story/index.ts +++ b/src/hooks/rpg-runtime-story/index.ts @@ -1,23 +1,22 @@ export { loadRpgRuntimeOptionCatalog, - resolveRpgRuntimeChoice, - resumeRpgRuntimeStory, type LoadRpgRuntimeOptionCatalogParams, + resolveRpgRuntimeChoice, type ResolveRpgRuntimeChoiceParams, + resumeRpgRuntimeStory, } from './rpgRuntimeStoryGateway'; export { - useRpgRuntimeInteractionFlow, createRpgRuntimeInteractionUiResetter, type RpgRuntimeInteractionFlowResult, + useRpgRuntimeInteractionFlow, type UseRpgRuntimeInteractionFlowParams, } from './useRpgRuntimeInteractionFlow'; export { - useRpgRuntimeNpcInteraction, type RpgRuntimeNpcInteractionResult, + useRpgRuntimeNpcInteraction, type UseRpgRuntimeNpcInteractionParams, } from './useRpgRuntimeNpcInteraction'; export { - useRpgRuntimeStory, type BattleRewardSummary, type BattleRewardUi, type CharacterChatModalState, @@ -32,21 +31,22 @@ export { type RpgRuntimeStoryResult, type StoryGenerationNpcUi, type TradeModalState, + useRpgRuntimeStory, type UseRpgRuntimeStoryParams, } from './useRpgRuntimeStory'; export { - useRpgRuntimeStoryController, type RpgRuntimeStoryControllerResult, + useRpgRuntimeStoryController, type UseRpgRuntimeStoryControllerParams, } from './useRpgRuntimeStoryController'; export { - useRpgRuntimeStoryFlow, type RpgRuntimeStoryFlowResult, + useRpgRuntimeStoryFlow, type UseRpgRuntimeStoryFlowParams, } from './useRpgRuntimeStoryFlow'; export { createRpgRuntimeStoryUiResetter, - useRpgRuntimeStoryState, type RpgRuntimeStoryStateResult, + useRpgRuntimeStoryState, type UseRpgRuntimeStoryStateParams, } from './useRpgRuntimeStoryState'; diff --git a/src/hooks/rpg-runtime-story/runtimeStoryCoordinator.test.ts b/src/hooks/rpg-runtime-story/runtimeStoryCoordinator.test.ts index bd84f35fe..e5bfe5f56 100644 --- a/src/hooks/rpg-runtime-story/runtimeStoryCoordinator.test.ts +++ b/src/hooks/rpg-runtime-story/runtimeStoryCoordinator.test.ts @@ -29,10 +29,10 @@ vi.mock('../../services/rpg-runtime/rpgRuntimeStoryClient', async () => { }; }); +import type { StoryRuntimeProjectionResponse } from '../../../packages/shared/src/contracts/story'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import type { GameState, StoryMoment, StoryOption } from '../../types'; import { WorldType } from '../../types'; -import type { StoryRuntimeProjectionResponse } from '../../../packages/shared/src/contracts/story'; import { loadServerRuntimeOptionCatalog, resolveServerRuntimeChoice, diff --git a/src/hooks/rpg-runtime-story/runtimeStoryCoordinator.ts b/src/hooks/rpg-runtime-story/runtimeStoryCoordinator.ts index c7fe01b87..d25218b6e 100644 --- a/src/hooks/rpg-runtime-story/runtimeStoryCoordinator.ts +++ b/src/hooks/rpg-runtime-story/runtimeStoryCoordinator.ts @@ -1,7 +1,7 @@ export { + type LoadRpgRuntimeOptionCatalogParams, loadRpgRuntimeOptionCatalog as loadServerRuntimeOptionCatalog, + type ResolveRpgRuntimeChoiceParams, resolveRpgRuntimeChoice as resolveServerRuntimeChoice, resumeRpgRuntimeStory as resumeServerRuntimeStory, - type LoadRpgRuntimeOptionCatalogParams, - type ResolveRpgRuntimeChoiceParams, } from './rpgRuntimeStoryGateway'; diff --git a/src/hooks/rpg-runtime-story/storyChoiceCoordinator.ts b/src/hooks/rpg-runtime-story/storyChoiceCoordinator.ts index aa84420c3..77e45f50f 100644 --- a/src/hooks/rpg-runtime-story/storyChoiceCoordinator.ts +++ b/src/hooks/rpg-runtime-story/storyChoiceCoordinator.ts @@ -1,5 +1,6 @@ import type { Dispatch, SetStateAction } from 'react'; +import type { StoryGenerationContext } from '../../services/aiTypes'; import type { Character, Encounter, @@ -9,9 +10,8 @@ import type { } from '../../types'; import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow'; import type { ResolvedChoiceState } from '../combat/resolvedChoice'; -import type { BattleRewardSummary } from './uiTypes'; import type { StoryRuntimeSupport } from './storyRuntimeSupport'; -import type { StoryGenerationContext } from '../../services/aiTypes'; +import type { BattleRewardSummary } from './uiTypes'; export type ChoiceRuntimeController = { buildStoryContextFromState: ( diff --git a/src/hooks/rpg-runtime-story/storyGenerationState.ts b/src/hooks/rpg-runtime-story/storyGenerationState.ts index fdba30983..dcd85796a 100644 --- a/src/hooks/rpg-runtime-story/storyGenerationState.ts +++ b/src/hooks/rpg-runtime-story/storyGenerationState.ts @@ -7,10 +7,10 @@ NPC_TRADE_FUNCTION, shouldNpcRecruitOpenModal, } from '../../data/functionCatalog'; +import { MAX_COMPANIONS } from '../../data/npcInteractions'; import { applyQuestProgressFromSceneReached, } from '../../data/questFlow'; -import { MAX_COMPANIONS } from '../../data/npcInteractions'; import { incrementGameRuntimeStats } from '../../data/runtimeStats'; import { ensureSceneEncounterPreview } from '../../data/sceneEncounterPreviews'; import { getScenePresetById } from '../../data/scenePresets'; diff --git a/src/hooks/rpg-runtime-story/storyInteractionCoordinator.ts b/src/hooks/rpg-runtime-story/storyInteractionCoordinator.ts index 47f9e58e1..70840ad36 100644 --- a/src/hooks/rpg-runtime-story/storyInteractionCoordinator.ts +++ b/src/hooks/rpg-runtime-story/storyInteractionCoordinator.ts @@ -3,7 +3,6 @@ import type { StoryGenerationContext } from '../../services/aiTypes'; import type { Character, - Encounter, GameState, StoryMoment, StoryOption, diff --git a/src/hooks/rpg-runtime-story/useRpgRuntimeStoryState.ts b/src/hooks/rpg-runtime-story/useRpgRuntimeStoryState.ts index 1a761da23..43f58d770 100644 --- a/src/hooks/rpg-runtime-story/useRpgRuntimeStoryState.ts +++ b/src/hooks/rpg-runtime-story/useRpgRuntimeStoryState.ts @@ -1,6 +1,6 @@ -import { useCallback, type Dispatch, type SetStateAction } from 'react'; +import { type Dispatch, type SetStateAction,useCallback } from 'react'; -import type { StoryMoment, GameState, Character } from '../../types'; +import type { Character,GameState, StoryMoment } from '../../types'; import type { CommitGeneratedState } from '../generatedState'; import { createStorySessionActions } from './sessionActions'; diff --git a/src/hooks/rpg-runtime-story/useStoryChoiceCoordinator.ts b/src/hooks/rpg-runtime-story/useStoryChoiceCoordinator.ts index 8d72b5904..005033e4c 100644 --- a/src/hooks/rpg-runtime-story/useStoryChoiceCoordinator.ts +++ b/src/hooks/rpg-runtime-story/useStoryChoiceCoordinator.ts @@ -1,12 +1,5 @@ import { useCallback, useState } from 'react'; -import { createStoryChoiceActions } from './choiceActions'; -import { - createStoryChoiceCoordinatorConfig, - type ChoiceRuntimeController, - type ChoiceRuntimeSupport, -} from './storyChoiceCoordinator'; -import type { BattleRewardSummary } from './uiTypes'; import type { Character, Encounter, @@ -14,8 +7,15 @@ import type { StoryMoment, StoryOption, } from '../../types'; -import type { ResolvedChoiceState } from '../combat/resolvedChoice'; import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow'; +import type { ResolvedChoiceState } from '../combat/resolvedChoice'; +import { createStoryChoiceActions } from './choiceActions'; +import { + type ChoiceRuntimeController, + type ChoiceRuntimeSupport, + createStoryChoiceCoordinatorConfig, +} from './storyChoiceCoordinator'; +import type { BattleRewardSummary } from './uiTypes'; type StoryChoiceCoordinatorParams = { gameState: GameState; diff --git a/src/hooks/rpg-session/index.ts b/src/hooks/rpg-session/index.ts index f64abe236..d5fbcbccb 100644 --- a/src/hooks/rpg-session/index.ts +++ b/src/hooks/rpg-session/index.ts @@ -1,12 +1,12 @@ -export { type RpgSessionBootstrapResult, useRpgSessionBootstrap } from './useRpgSessionBootstrap'; export type { BottomTab } from './rpgSessionTypes'; +export type { BottomTab as RpgBottomTab } from './rpgSessionTypes'; export { type RpgRuntimeSessionResult, useRpgRuntimeSession, } from './useRpgRuntimeSession'; +export { type RpgSessionBootstrapResult, useRpgSessionBootstrap } from './useRpgSessionBootstrap'; export { type RpgSessionPersistenceResult, - type UseRpgSessionPersistenceParams, useRpgSessionPersistence, + type UseRpgSessionPersistenceParams, } from './useRpgSessionPersistence'; -export type { BottomTab as RpgBottomTab } from './rpgSessionTypes'; diff --git a/src/hooks/rpg-session/useRpgRuntimeSession.ts b/src/hooks/rpg-session/useRpgRuntimeSession.ts index 5970c34da..dae1b1d9b 100644 --- a/src/hooks/rpg-session/useRpgRuntimeSession.ts +++ b/src/hooks/rpg-session/useRpgRuntimeSession.ts @@ -10,10 +10,10 @@ import { } from '../../data/companionRoster'; import { syncGameStatePlayTime } from '../../data/runtimeStats'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; +import { useRpgRuntimeStory } from '../rpg-runtime-story/useRpgRuntimeStory'; import { useBackgroundMusic } from '../useBackgroundMusic'; import { useCombatFlow } from '../useCombatFlow'; import { useNpcInteractionFlow } from '../useNpcInteractionFlow'; -import { useRpgRuntimeStory } from '../rpg-runtime-story/useRpgRuntimeStory'; import { useRpgSessionBootstrap } from './useRpgSessionBootstrap'; import { useRpgSessionPersistence } from './useRpgSessionPersistence'; diff --git a/src/hooks/rpg-session/useRpgSessionBootstrap.ts b/src/hooks/rpg-session/useRpgSessionBootstrap.ts index 2611dc34e..6c42c984a 100644 --- a/src/hooks/rpg-session/useRpgSessionBootstrap.ts +++ b/src/hooks/rpg-session/useRpgSessionBootstrap.ts @@ -15,8 +15,8 @@ import { AnimationState, Character, CustomWorldProfile, - GameState, GameRuntimeMode, + GameState, WorldType, } from '../../types'; import type { BottomTab } from './rpgSessionTypes'; diff --git a/src/hooks/useResolvedAssetReadUrl.test.tsx b/src/hooks/useResolvedAssetReadUrl.test.tsx index ab5efe032..a5094abb1 100644 --- a/src/hooks/useResolvedAssetReadUrl.test.tsx +++ b/src/hooks/useResolvedAssetReadUrl.test.tsx @@ -4,12 +4,12 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { ResolvedAssetImage } from '../components/ResolvedAssetImage'; -import { useResolvedAssetReadUrl } from './useResolvedAssetReadUrl'; import { clearStoredAccessToken, setStoredAccessToken, } from '../services/apiClient'; import { clearSignedAssetReadUrlCache } from '../services/assetReadUrlService'; +import { useResolvedAssetReadUrl } from './useResolvedAssetReadUrl'; describe('useResolvedAssetReadUrl', () => { beforeEach(() => { diff --git a/src/hooks/useTreasureFlow.ts b/src/hooks/useTreasureFlow.ts index 0d8036c51..854c0486e 100644 --- a/src/hooks/useTreasureFlow.ts +++ b/src/hooks/useTreasureFlow.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react'; -import { resolveRpgRuntimeChoice } from './rpg-runtime-story'; import { Character, GameState, StoryMoment, StoryOption } from '../types'; +import { resolveRpgRuntimeChoice } from './rpg-runtime-story'; export function useTreasureFlow({ gameState, diff --git a/src/routing/appPageRoutes.ts b/src/routing/appPageRoutes.ts index 7b5186859..44e43474a 100644 --- a/src/routing/appPageRoutes.ts +++ b/src/routing/appPageRoutes.ts @@ -1,5 +1,5 @@ -import type { SelectionStage } from '../components/platform-entry'; import { HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS } from '../../packages/shared/src/contracts/hostBridge'; +import type { SelectionStage } from '../components/platform-entry'; import { buildCreationUrlSearchFromParams, isCreationRestorePath, diff --git a/src/services/ai.test.ts b/src/services/ai.test.ts index 784540708..7597707af 100644 --- a/src/services/ai.test.ts +++ b/src/services/ai.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { StoryRuntimeProjectionResponse } from '../../packages/shared/src/contracts/story'; import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels'; -import { getScenePresetsByWorld } from '../data/scenePresets'; import type { Character, Encounter, @@ -10,7 +10,6 @@ import type { StoryOption, } from '../types'; import { AnimationState, WorldType } from '../types'; -import type { StoryRuntimeProjectionResponse } from '../../packages/shared/src/contracts/story'; const { connectivityError, diff --git a/src/services/ai.ts b/src/services/ai.ts index ee52ee63f..427274865 100644 --- a/src/services/ai.ts +++ b/src/services/ai.ts @@ -5,7 +5,6 @@ import type { } from '../../packages/shared/src/contracts/runtime'; import { unwrapApiResponse } from '../../packages/shared/src/http'; import { createSceneHostileNpcsFromEncounters } from '../data/hostileNpcs'; -import { hasMixedNarrativeLanguage } from './narrativeLanguage'; import { buildEncounterFromSceneNpc, getScenePresetById, @@ -34,7 +33,6 @@ import { DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT, } from '../prompts/customWorldPrompts'; import { - AIResponse, Character, CustomWorldCreatorIntent, CustomWorldGenerationMode, @@ -47,13 +45,6 @@ import { WorldStoryGraph, WorldType, } from '../types'; -import { - CustomWorldSceneImageRequest, - CustomWorldSceneImageResult, - StoryGenerationContext, - StoryRequestOptions, - TextStreamOptions, -} from './aiTypes'; import { generateCharacterPanelChatSuggestions as generateCharacterPanelChatSuggestionsFromServer, generateCharacterPanelChatSummary as generateCharacterPanelChatSummaryFromServer, @@ -63,6 +54,11 @@ import { streamNpcChatDialogue as streamNpcChatDialogueFromServer, streamNpcRecruitDialogue as streamNpcRecruitDialogueFromServer, } from './aiService'; +import { + CustomWorldSceneImageRequest, + CustomWorldSceneImageResult, + StoryGenerationContext, +} from './aiTypes'; import { fetchWithApiAuth } from './apiClient'; import { buildCustomWorldRawProfileFromFramework, @@ -91,12 +87,10 @@ import { CUSTOM_WORLD_REQUEST_TIMEOUT_MS as CLIENT_CUSTOM_WORLD_REQUEST_TIMEOUT_MS, isLlmConnectivityError as isLlmConnectivityErrorFromClient, isLlmTimeoutError as isLlmTimeoutErrorFromClient, - requestChatMessageContent, requestPlainTextCompletion as requestPlainTextCompletionFromClient, - streamPlainTextCompletion as streamPlainTextCompletionFromClient, } from './llmClient'; import { parseJsonResponseText as parseJsonResponseTextFromParser } from './llmParsers'; -import type { CharacterChatTargetStatus } from './rpgRuntimeChatTypes'; +import { hasMixedNarrativeLanguage } from './narrativeLanguage'; import { buildFallbackActorNarrativeProfile, normalizeActorNarrativeProfile, diff --git a/src/services/aiTypes.ts b/src/services/aiTypes.ts index d62db93b5..8eac5b02f 100644 --- a/src/services/aiTypes.ts +++ b/src/services/aiTypes.ts @@ -1,3 +1,4 @@ +import type { SavedGameSnapshotInput } from '../../packages/shared/src/contracts/runtime'; import type { ActorNarrativeProfile, ActState, @@ -30,8 +31,8 @@ import type { NpcAnswerMode, NpcDisclosureStage, NpcWarmthStage, - PlayerStyleProfile, PlayerProgressionState, + PlayerStyleProfile, QuestStatus, ReleaseGateReport, ScenarioPack, @@ -44,7 +45,6 @@ import type { WorldMutation, WorldType, } from '../types'; -import type { SavedGameSnapshotInput } from '../../packages/shared/src/contracts/runtime'; import type { ConversationPressure, ConversationSituation } from '../types'; export interface StoryRequestOptions { diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index 2065b8a76..d411171bf 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { - AUTH_STATE_EVENT, ApiClientError, BACKGROUND_AUTH_REQUEST_OPTIONS, clearStoredAccessToken, diff --git a/src/services/authService.test.ts b/src/services/authService.test.ts index f37075577..d275cf9f4 100644 --- a/src/services/authService.test.ts +++ b/src/services/authService.test.ts @@ -45,8 +45,8 @@ import { getCaptchaChallengeFromError, getCurrentAuthUser, getPublicAuthUserById, - liftAuthRiskBlock, isWechatMiniProgramWebViewRuntime, + liftAuthRiskBlock, loginWithPhoneCode, logoutAllAuthSessions, redeemRegistrationInviteCode, diff --git a/src/services/creation-agent/creationAgentChat.test.ts b/src/services/creation-agent/creationAgentChat.test.ts index ec9e6a7d7..03ad02262 100644 --- a/src/services/creation-agent/creationAgentChat.test.ts +++ b/src/services/creation-agent/creationAgentChat.test.ts @@ -1,9 +1,9 @@ import { expect, test } from 'vitest'; import { - CREATION_AGENT_QUICK_FILL_MESSAGE, buildCreationAgentChatMessage, createCreationAgentChatQuickActions, + CREATION_AGENT_QUICK_FILL_MESSAGE, resolveCreationAgentQuickActionMessage, } from './creationAgentChat'; diff --git a/src/services/creation-agent/index.ts b/src/services/creation-agent/index.ts index 9911c2de8..1a63c7daa 100644 --- a/src/services/creation-agent/index.ts +++ b/src/services/creation-agent/index.ts @@ -1,5 +1,5 @@ -export * from './creationAgentClientFactory'; export * from './creationAgentChat'; +export * from './creationAgentClientFactory'; export * from './creationAgentDocumentInput'; export * from './creationAgentProgress'; export * from './creationAgentSse'; diff --git a/src/services/customWorld.ts b/src/services/customWorld.ts index d0d0f52f0..a23a9443b 100644 --- a/src/services/customWorld.ts +++ b/src/services/customWorld.ts @@ -5,11 +5,10 @@ import { } from '../data/affinityLevels'; import { coerceWorldAttributeSchema } from '../data/attributeValidation'; import { - CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS, type CustomWorldLandmarkDraft, getCustomWorldSceneRelativePositionLabel, - normalizeCustomWorldSceneRelativePosition, normalizeCustomWorldLandmarks, + normalizeCustomWorldSceneRelativePosition, } from '../data/customWorldSceneGraph'; import { ActorNarrativeProfile, @@ -22,7 +21,6 @@ import { CustomWorldCoverProfile, CustomWorldCoverSourceType, CustomWorldItem, - CustomWorldLandmark, CustomWorldNpc, CustomWorldPlayableNpc, CustomWorldProfile, diff --git a/src/services/customWorldBuilder.ts b/src/services/customWorldBuilder.ts index 1ca073cc6..e3c0728de 100644 --- a/src/services/customWorldBuilder.ts +++ b/src/services/customWorldBuilder.ts @@ -5,10 +5,9 @@ import { } from '../data/attributeProfileGenerator'; import { mergeCustomWorldPlayableNpcTags } from '../data/customWorldBuildTags'; import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph'; -import { CustomWorldProfile, WorldType } from '../types'; +import { CustomWorldProfile } from '../types'; import { normalizeCustomWorldProfile } from './customWorld'; import { normalizeCustomWorldOwnedSettingLayers } from './customWorldOwnedSettingLayers'; -import { resolveCustomWorldCompatibilityTemplateWorldType } from './customWorldTheme'; import { buildFallbackActorNarrativeProfile, normalizeActorNarrativeProfile, diff --git a/src/services/customWorldCover.test.ts b/src/services/customWorldCover.test.ts index 62448337a..d81a0658f 100644 --- a/src/services/customWorldCover.test.ts +++ b/src/services/customWorldCover.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { WorldType, type CustomWorldProfile } from '../types'; +import { type CustomWorldProfile,WorldType } from '../types'; import { resolveCustomWorldCoverPresentation } from './customWorldCover'; function createBaseProfile(): CustomWorldProfile { diff --git a/src/services/customWorldCoverAssetService.ts b/src/services/customWorldCoverAssetService.ts index 2fb903464..5e9bb6b7c 100644 --- a/src/services/customWorldCoverAssetService.ts +++ b/src/services/customWorldCoverAssetService.ts @@ -1,5 +1,5 @@ -import { requestJson } from './apiClient'; import type { CustomWorldCoverCropRect, CustomWorldProfile } from '../types'; +import { requestJson } from './apiClient'; const CUSTOM_WORLD_COVER_API_BASE = '/api/runtime/custom-world'; diff --git a/src/services/customWorldFoundationEntries.ts b/src/services/customWorldFoundationEntries.ts index 73802f375..41985932c 100644 --- a/src/services/customWorldFoundationEntries.ts +++ b/src/services/customWorldFoundationEntries.ts @@ -1,6 +1,6 @@ import type { EightAnchorContent } from '../../packages/shared/src/contracts/customWorldAgent'; -import { normalizeCustomWorldCreatorIntent } from './customWorldCreatorIntent'; import type { CustomWorldProfile } from '../types'; +import { normalizeCustomWorldCreatorIntent } from './customWorldCreatorIntent'; export type CustomWorldFoundationEntryId = | 'world-promise' diff --git a/src/services/host-bridge/hostBridge.ts b/src/services/host-bridge/hostBridge.ts index 1640c6a1b..a526bf758 100644 --- a/src/services/host-bridge/hostBridge.ts +++ b/src/services/host-bridge/hostBridge.ts @@ -26,21 +26,16 @@ import type { ShareOpenPayload, } from '../../../packages/shared/src/contracts/hostBridge'; import { - HOST_BRIDGE_AUDIO_MIME_TYPES, - HOST_BRIDGE_APP_TITLE_MAX_LENGTH, - HOST_BRIDGE_DOCUMENT_MIME_TYPES, - HOST_BRIDGE_IMAGE_MIME_TYPES, HOST_BRIDGE_NATIVE_APP_QUERY, HOST_BRIDGE_PUBLIC_WEB_ORIGIN, - HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS, HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY, + HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS, HOST_BRIDGE_SCANNER_TIMEOUT_MS, - HOST_BRIDGE_TEXT_MIME_TYPES, HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS, HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY, isHostBridgeCapability, - normalizeHostBridgeBadgeCount, normalizeHostBridgeAppTitle, + normalizeHostBridgeBadgeCount, normalizeHostBridgeClipboardText, normalizeHostBridgeColorScheme, normalizeHostBridgeConnectionType, @@ -622,6 +617,7 @@ function buildAbsoluteUrl(value: string) { function normalizeNativeAppPageUrl(url: string) { const trimmedUrl = url.trim(); + // eslint-disable-next-line no-control-regex -- 宿主导航必须拒绝 C0/DEL 控制字符。 if (!trimmedUrl || /[\u0000-\u001f\u007f]/u.test(trimmedUrl)) { return null; } diff --git a/src/services/hyper3dModelGenerationService.ts b/src/services/hyper3dModelGenerationService.ts index e23a2374a..7aea106a5 100644 --- a/src/services/hyper3dModelGenerationService.ts +++ b/src/services/hyper3dModelGenerationService.ts @@ -7,7 +7,6 @@ import type { Hyper3dTaskSubmitResponse, Hyper3dTextToModelRequest, } from '../../packages/shared/src/contracts/hyper3d'; - import { requestJson } from './apiClient'; const HYPER3D_API_BASE = '/api/assets/hyper3d'; diff --git a/src/services/image-editor/editorImageReference.ts b/src/services/image-editor/editorImageReference.ts index 37d1c6619..67fcc0ce7 100644 --- a/src/services/image-editor/editorImageReference.ts +++ b/src/services/image-editor/editorImageReference.ts @@ -28,9 +28,8 @@ function encodeBytesAsBase64(bytes: Uint8Array) { } /** - * 图片画布的生成编辑接口只接收图片 Data URL。 - * 画布里既有上传 / 生成图,也有内置 public 参考图和历史 generated 路径; - * 提交给后端前统一读取成 Data URL,避免后端误收到普通 URL 后进入 Data URL 校验错误。 + * 将画布图片读取为浏览器临时 Data URL,供压缩、标注等本地处理使用。 + * 生成请求提交前必须再上传 OSS,并把 objectKey 传给后端;Data URL 不属于接口契约。 */ export async function resolveEditorImageReferenceDataUrl( source: string, @@ -121,7 +120,7 @@ export async function compressEditorImageReferenceDataUrlForGeneration( return dataUrl; } - // 中文注释:参考图只用于模型理解视觉方向,提交前压缩可避免原图 Data URL 撑爆 JSON 请求体。 + // 中文注释:参考图只用于模型理解视觉方向,上传 OSS 前压缩可减少传输与存储体积。 if (mimeType === 'image/jpeg') { context.fillStyle = '#fff'; context.fillRect(0, 0, targetWidth, targetHeight); diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index ca07f33e6..71ba9169c 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -18,8 +18,8 @@ import { generateEditorVideo, listEditorProjects, listPublicEditorProjectResources, - loadEditorGenerationPricing, loadEditorAssetLibrary, + loadEditorGenerationPricing, loadEditorProject, loadOrCreateRecentEditorProject, removeEditorImageBackground, @@ -774,6 +774,47 @@ describe('editorProjectClient', () => { ); }); + it('rejects inline media before submitting editor generation requests', async () => { + await expect( + generateEditorImage({ + prompt: '一张画布图片', + referenceImageSrcs: ['data:image/png;base64,inline'], + }), + ).rejects.toThrow('必须先上传 OSS'); + + await expect( + generateEditorVideo({ + prompt: '生成视频', + model: 'seedance2.0-fast', + aspectRatio: '16:9', + durationSeconds: 5, + resolution: '720p', + mode: 'std', + sound: 'off', + referenceVideoSrcs: ['blob:local-video'], + }), + ).rejects.toThrow('必须先上传 OSS'); + + expect(requestJsonMock).not.toHaveBeenCalled(); + }); + + it('rejects oversized stable video reference fields before submission', async () => { + await expect( + generateEditorVideo({ + prompt: '生成视频', + model: 'seedance2.0-fast', + aspectRatio: '16:9', + durationSeconds: 5, + resolution: '720p', + mode: 'std', + sound: 'off', + referenceVideoSrcs: [`https://assets.example.test/${'a'.repeat(256 * 1024)}`], + }), + ).rejects.toThrow('稳定引用字段总长度不能超过 256KB'); + + expect(requestJsonMock).not.toHaveBeenCalled(); + }); + it('passes image model options to editor image generation', async () => { requestJsonMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,character', @@ -919,8 +960,8 @@ describe('editorProjectClient', () => { }); const result = await generateEditorIconSpritesheet({ - referenceImageSrc: 'data:image/png;base64,spec', - referenceImageSrcs: ['data:image/png;base64,icon-ref'], + referenceImageSrc: '/generated-images/editor/spec.png', + referenceImageSrcs: ['/generated-images/editor/icon-ref.png'], iconDescriptions: ['返回按钮', '设置按钮'], assetLabel: '冒险游戏图标', }); @@ -936,8 +977,8 @@ describe('editorProjectClient', () => { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - referenceImageSrc: 'data:image/png;base64,spec', - referenceImageSrcs: ['data:image/png;base64,icon-ref'], + referenceImageSrc: '/generated-images/editor/spec.png', + referenceImageSrcs: ['/generated-images/editor/icon-ref.png'], iconDescriptions: ['返回按钮', '设置按钮'], model: 'gemini-3.1-flash-image-preview', assetLabel: '冒险游戏图标', @@ -965,7 +1006,7 @@ describe('editorProjectClient', () => { }); await generateEditorIconSpritesheet({ - referenceImageSrc: 'data:image/png;base64,spec', + referenceImageSrc: '/generated-images/editor/spec.png', iconDescriptions: ['返回按钮'], model: 'gpt-image-2', screenColor: '#E6D8FF', @@ -986,7 +1027,7 @@ describe('editorProjectClient', () => { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - referenceImageSrc: 'data:image/png;base64,spec', + referenceImageSrc: '/generated-images/editor/spec.png', iconDescriptions: ['返回按钮'], model: 'gpt-image-2', screenColor: '#E6D8FF', @@ -1105,11 +1146,11 @@ describe('editorProjectClient', () => { }); const result = await extractEditorUiDesignAssets({ - sourceImageSrc: 'data:image/png;base64,ui-design', + sourceImageSrc: '/generated-images/editor/ui-design.png', model: 'gpt-image-2', screenColor: '#7FB3FF', segModel: 'anime-seg', - referenceImageSrcs: ['data:image/png;base64,ui-ref'], + referenceImageSrcs: ['/generated-images/editor/ui-ref.png'], projectId: 'editor-project-1', assetFolderId: 'project', spritesheetLabel: '战斗UI设计图 素材图集', @@ -1128,11 +1169,11 @@ describe('editorProjectClient', () => { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - sourceImageSrc: 'data:image/png;base64,ui-design', + sourceImageSrc: '/generated-images/editor/ui-design.png', model: 'gpt-image-2', screenColor: '#7FB3FF', segModel: 'anime-seg', - referenceImageSrcs: ['data:image/png;base64,ui-ref'], + referenceImageSrcs: ['/generated-images/editor/ui-ref.png'], projectId: 'editor-project-1', generationInputs: { fields: [{ title: '来源', value: '战斗UI设计图' }], @@ -1212,7 +1253,7 @@ describe('editorProjectClient', () => { kind: 'publication-material', aspectRatio: '9:16', imageSize: '1K', - referenceImageSrcs: ['data:image/png;base64,ref'], + referenceImageSrcs: ['/generated-images/editor/publication-ref.png'], }); expect(result.height).toBe(1280); @@ -1227,7 +1268,7 @@ describe('editorProjectClient', () => { kind: 'publication-material', aspectRatio: '9:16', imageSize: '1K', - referenceImageSrcs: ['data:image/png;base64,ref'], + referenceImageSrcs: ['/generated-images/editor/publication-ref.png'], }), }), '生成图片失败', @@ -1259,7 +1300,7 @@ describe('editorProjectClient', () => { const result = await generateEditorCharacterAnimation({ sourceLayerId: 'layer-character', - sourceImageSrc: 'data:image/png;base64,character', + sourceImageSrc: '/generated-images/editor/character.png', sourceWidth: 1024, sourceHeight: 1024, promptText: '待机', @@ -1279,7 +1320,7 @@ describe('editorProjectClient', () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sourceLayerId: 'layer-character', - sourceImageSrc: 'data:image/png;base64,character', + sourceImageSrc: '/generated-images/editor/character.png', sourceWidth: 1024, sourceHeight: 1024, promptText: '待机', @@ -1395,7 +1436,7 @@ describe('editorProjectClient', () => { resolution: '720p', mode: 'std', sound: 'off', - referenceImageSrcs: ['data:image/png;base64,image'], + referenceImageSrcs: ['/generated-images/editor/video-reference.png'], referenceVideoSrcs: [ 'generated-character-drafts/editor/seedance-references/video/video.mp4', ], @@ -1418,7 +1459,7 @@ describe('editorProjectClient', () => { mode: 'std', sound: 'off', webSearchEnabled: true, - referenceImageSrcs: ['data:image/png;base64,image'], + referenceImageSrcs: ['/generated-images/editor/video-reference.png'], referenceVideoSrcs: [ 'generated-character-drafts/editor/seedance-references/video/video.mp4', ], @@ -1578,12 +1619,12 @@ describe('editorProjectClient', () => { const result = await editEditorImage({ prompt: '把画面改成黄昏光线', - sourceImageSrc: 'data:image/png;base64,source', + sourceImageSrc: '/generated-images/editor/source.png', size: '1024x1024', model: 'gpt-image-2', aspectRatio: '1:1', imageSize: '1K', - referenceImageSrcs: ['data:image/png;base64,style'], + referenceImageSrcs: ['/generated-images/editor/style.png'], projectId: 'editor-project-1', assetKind: 'character', assetFolderId: 'project', @@ -1604,12 +1645,12 @@ describe('editorProjectClient', () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: '把画面改成黄昏光线', - sourceImageSrc: 'data:image/png;base64,source', + sourceImageSrc: '/generated-images/editor/source.png', size: '1024x1024', model: 'gpt-image-2', aspectRatio: '1:1', imageSize: '1K', - referenceImageSrcs: ['data:image/png;base64,style'], + referenceImageSrcs: ['/generated-images/editor/style.png'], projectId: 'editor-project-1', assetKind: 'character', generationInputs: { @@ -1643,7 +1684,7 @@ describe('editorProjectClient', () => { }); const response = await removeEditorImageBackground({ - sourceImageSrc: 'data:image/png;base64,source', + sourceImageSrc: '/generated-images/editor/source.png', projectId: 'editor-project-1', targetLayerId: 'layer-source', canvasCompletion: { @@ -1668,7 +1709,7 @@ describe('editorProjectClient', () => { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - sourceImageSrc: 'data:image/png;base64,source', + sourceImageSrc: '/generated-images/editor/source.png', projectId: 'editor-project-1', targetLayerId: 'layer-source', canvasCompletion: { diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 58120cd19..93a5c90ed 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -1,5 +1,5 @@ -import { requestJson } from '../apiClient'; import type { ExternalGenerationJobStatusRecord } from '../../../packages/shared/src/contracts/externalGeneration'; +import { requestJson } from '../apiClient'; import { EDITOR_REQUEST_RETRY_OPTIONS } from './editorRetryOptions'; const EDITOR_PROJECT_API_BASE = '/api/editor/projects'; @@ -25,8 +25,38 @@ const EDITOR_BACKGROUND_MUSIC_GENERATION_API = '/api/editor/audios/background-music/generations'; const EDITOR_GENERATION_PRICING_API = '/api/editor/generation-pricing'; const EDITOR_IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview'; +const EDITOR_VIDEO_REFERENCE_REQUEST_LIMIT_BYTES = 256 * 1024; const DEFAULT_PROJECT_TITLE = '未命名画布'; +function assertStableEditorMediaReference(value: string, fieldLabel: string) { + const normalized = value.trimStart().toLowerCase(); + if (normalized.startsWith('data:') || normalized.startsWith('blob:')) { + throw new Error( + `${fieldLabel}必须先上传 OSS,并使用 objectKey、项目资源、素材 ID 或支持的 URL 引用。`, + ); + } +} + +function assertStableEditorMediaReferences( + values: readonly string[] | undefined, + fieldLabel: string, +) { + values?.forEach((value) => assertStableEditorMediaReference(value, fieldLabel)); +} + +function assertStableEditorVideoReferences(input: EditorVideoGenerationInput) { + const references = [ + ...(input.referenceImageSrcs ?? []), + ...(input.referenceVideoSrcs ?? []), + ...(input.referenceAudioSrcs ?? []), + ]; + assertStableEditorMediaReferences(references, '视频参考素材'); + const requestBytes = new TextEncoder().encode(references.join('')).byteLength; + if (requestBytes > EDITOR_VIDEO_REFERENCE_REQUEST_LIMIT_BYTES) { + throw new Error('视频参考素材稳定引用字段总长度不能超过 256KB'); + } +} + export type EditorCanvasViewport = { x: number; y: number; @@ -874,6 +904,7 @@ export async function toggleEditorShowcaseAssetLike( } export async function generateEditorImage(input: EditorImageGenerationInput) { + assertStableEditorMediaReferences(input.referenceImageSrcs, '生成参考图'); return requestJson( EDITOR_IMAGE_GENERATION_API, jsonRequest('POST', { @@ -913,6 +944,11 @@ export async function generateEditorImage(input: EditorImageGenerationInput) { export async function generateEditorIconSpritesheet( input: EditorIconSpritesheetGenerationInput, ) { + assertStableEditorMediaReference(input.referenceImageSrc, '图标素材规范'); + assertStableEditorMediaReferences( + input.referenceImageSrcs, + '图标素材参考图', + ); return requestJson( EDITOR_ICON_SPRITESHEET_GENERATION_API, jsonRequest('POST', { @@ -962,6 +998,8 @@ export async function splitEditorIconSpritesheet( export async function extractEditorUiDesignAssets( input: EditorUiDesignAssetExtractionInput, ) { + assertStableEditorMediaReference(input.sourceImageSrc, 'UI设计图'); + assertStableEditorMediaReferences(input.referenceImageSrcs, 'UI素材参考图'); return requestJson( EDITOR_UI_DESIGN_ASSET_EXTRACTION_API, jsonRequest('POST', { @@ -995,6 +1033,8 @@ export async function extractEditorUiDesignAssets( } export async function editEditorImage(input: EditorImageEditInput) { + assertStableEditorMediaReference(input.sourceImageSrc, '待修改图片'); + assertStableEditorMediaReferences(input.referenceImageSrcs, '修改参考图'); return requestJson( EDITOR_IMAGE_EDIT_API, jsonRequest('POST', { @@ -1033,6 +1073,7 @@ export async function editEditorImage(input: EditorImageEditInput) { export async function removeEditorImageBackground( input: EditorBackgroundRemovalInput, ) { + assertStableEditorMediaReference(input.sourceImageSrc, '待去除背景图片'); return requestJson( EDITOR_BACKGROUND_REMOVAL_API, jsonRequest('POST', { @@ -1064,6 +1105,7 @@ export async function removeEditorImageBackground( export async function generateEditorCharacterAnimation( input: EditorCharacterAnimationGenerationInput, ) { + assertStableEditorMediaReference(input.sourceImageSrc, '角色动画源图'); return requestJson( EDITOR_CHARACTER_ANIMATION_GENERATION_API, jsonRequest('POST', { @@ -1088,6 +1130,7 @@ export async function generateEditorCharacterAnimation( } export async function generateEditorVideo(input: EditorVideoGenerationInput) { + assertStableEditorVideoReferences(input); return requestJson( EDITOR_VIDEO_GENERATION_API, jsonRequest('POST', { diff --git a/src/services/jump-hop/jumpHopClient.ts b/src/services/jump-hop/jumpHopClient.ts index ca559edd4..da2ca3cb5 100644 --- a/src/services/jump-hop/jumpHopClient.ts +++ b/src/services/jump-hop/jumpHopClient.ts @@ -8,10 +8,10 @@ import type { JumpHopJumpRequest, JumpHopLeaderboardResponse, JumpHopRunResponse, - JumpHopStartRunRequest, JumpHopRuntimeRunSnapshotResponse, JumpHopSessionResponse, JumpHopSessionSnapshotResponse, + JumpHopStartRunRequest, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopWorkProfileResponse, diff --git a/src/services/llmClient.ts b/src/services/llmClient.ts index 9af0aefc5..f783c1e76 100644 --- a/src/services/llmClient.ts +++ b/src/services/llmClient.ts @@ -1,5 +1,5 @@ import type {TextStreamOptions} from './aiTypes'; -import { fetchWithApiAuth, type ApiRequestOptions } from './apiClient'; +import { type ApiRequestOptions,fetchWithApiAuth } from './apiClient'; import { parseSseJsonObject, readSseStream } from './sseStream'; const ENV: Partial = import.meta.env ?? {}; diff --git a/src/services/match3dGeneratedModelCache.test.ts b/src/services/match3dGeneratedModelCache.test.ts index 598d03a6a..44426429e 100644 --- a/src/services/match3dGeneratedModelCache.test.ts +++ b/src/services/match3dGeneratedModelCache.test.ts @@ -1,19 +1,19 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; -import { setStoredAccessToken, clearStoredAccessToken } from './apiClient'; +import { clearStoredAccessToken,setStoredAccessToken } from './apiClient'; import { clearSignedAssetReadUrlCache } from './assetReadUrlService'; import { clearMatch3DGeneratedModelBytesCache, - getMatch3DGeneratedImageViewSources, getMatch3DGeneratedImageAssetSources, - getMatch3DGeneratedRuntimeUiAssetSources, + getMatch3DGeneratedImageViewSources, getMatch3DGeneratedModelAssetSources, + getMatch3DGeneratedRuntimeUiAssetSources, hasMatch3DGeneratedImageAsset, mergeMatch3DGeneratedItemAssetsForRuntime, normalizeMatch3DGeneratedItemAssetsForRuntime, preloadMatch3DGeneratedImageAssets, - preloadMatch3DGeneratedRuntimeAssets, preloadMatch3DGeneratedModelAssets, + preloadMatch3DGeneratedRuntimeAssets, readMatch3DGeneratedModelBytes, } from './match3dGeneratedModelCache'; diff --git a/src/services/puzzle-clear/puzzleClearClient.ts b/src/services/puzzle-clear/puzzleClearClient.ts index 88cd3787b..1c05ab173 100644 --- a/src/services/puzzle-clear/puzzleClearClient.ts +++ b/src/services/puzzle-clear/puzzleClearClient.ts @@ -13,8 +13,8 @@ import type { PuzzleClearWorkDetailResponse, PuzzleClearWorkMutationResponse, PuzzleClearWorkProfileResponse, - PuzzleClearWorksResponse, PuzzleClearWorkspaceCreateRequest, + PuzzleClearWorksResponse, PuzzleClearWorkSummaryResponse, } from '../../../packages/shared/src/contracts/puzzleClear'; import { type ApiRetryOptions, requestJson } from '../apiClient'; @@ -57,8 +57,8 @@ export type { PuzzleClearWorkDetailResponse, PuzzleClearWorkMutationResponse, PuzzleClearWorkProfileResponse, - PuzzleClearWorksResponse, PuzzleClearWorkspaceCreateRequest, + PuzzleClearWorksResponse, PuzzleClearWorkSummaryResponse, }; export type CreatePuzzleClearSessionRequest = diff --git a/src/services/puzzle-gallery/puzzleGalleryClient.ts b/src/services/puzzle-gallery/puzzleGalleryClient.ts index 2ee76b627..313927cbc 100644 --- a/src/services/puzzle-gallery/puzzleGalleryClient.ts +++ b/src/services/puzzle-gallery/puzzleGalleryClient.ts @@ -3,7 +3,6 @@ import type { } from '../../../packages/shared/src/contracts/puzzleAgentSession'; import type { PuzzleGalleryResponse, - PuzzleWorksResponse, PuzzleWorkSummary, } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import { type ApiRetryOptions, requestJson } from '../apiClient'; diff --git a/src/services/puzzleReferenceImage.test.ts b/src/services/puzzleReferenceImage.test.ts index a09c30c29..34dfd39bf 100644 --- a/src/services/puzzleReferenceImage.test.ts +++ b/src/services/puzzleReferenceImage.test.ts @@ -3,11 +3,11 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { - PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH, cropPuzzleReferenceImageDataUrl, isPuzzleReferenceImageSquare, - readPuzzleReferenceImageForUpload, + PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH, readPuzzleReferenceImageAsDataUrl, + readPuzzleReferenceImageForUpload, } from './puzzleReferenceImage'; afterEach(() => { diff --git a/src/services/recommendedRuntimeGuestLaunch.test.ts b/src/services/recommendedRuntimeGuestLaunch.test.ts index 5844cb1cb..d0254d671 100644 --- a/src/services/recommendedRuntimeGuestLaunch.test.ts +++ b/src/services/recommendedRuntimeGuestLaunch.test.ts @@ -33,6 +33,7 @@ import { startMatch3DRun, stopMatch3DRun, } from './match3d-runtime/match3dRuntimeClient'; +import { puzzleClearClient } from './puzzle-clear/puzzleClearClient'; import { advancePuzzleNextLevel, dragPuzzlePieceOrGroup, @@ -43,7 +44,6 @@ import { updatePuzzleRunPause, usePuzzleRuntimeProp, } from './puzzle-runtime/puzzleRuntimeClient'; -import { puzzleClearClient } from './puzzle-clear/puzzleClearClient'; import { dropSquareHoleShape, finishSquareHoleTimeUp, @@ -52,17 +52,17 @@ import { startSquareHoleRun, stopSquareHoleRun, } from './square-hole-runtime/squareHoleRuntimeClient'; -import { - checkpointWoodenFishRun, - finishWoodenFishRun, - startWoodenFishRuntimeRun, -} from './wooden-fish/woodenFishClient'; import { getVisualNovelHistory, getVisualNovelRun, regenerateVisualNovelRun, startVisualNovelRun, } from './visual-novel-runtime/visualNovelRuntimeClient'; +import { + checkpointWoodenFishRun, + finishWoodenFishRun, + startWoodenFishRuntimeRun, +} from './wooden-fish/woodenFishClient'; describe('recommended runtime guest launch clients', () => { beforeEach(() => { diff --git a/src/services/rpg-creation/rpgCreationAgentClient.ts b/src/services/rpg-creation/rpgCreationAgentClient.ts index 0a9090095..425835a56 100644 --- a/src/services/rpg-creation/rpgCreationAgentClient.ts +++ b/src/services/rpg-creation/rpgCreationAgentClient.ts @@ -2,10 +2,10 @@ import type { CreateRpgAgentSessionRequest, CreateRpgAgentSessionResponse, GetRpgAgentCardDetailResponse, - RpgCreationResultView, RpgAgentDraftCardDetail, RpgAgentOperationRecord, RpgAgentSessionSnapshot, + RpgCreationResultView, SendRpgAgentMessageRequest, } from '../../../packages/shared/src'; import type { RpgAgentActionRequest } from '../../../packages/shared/src'; diff --git a/src/services/rpg-creation/rpgCreationAssetClient.test.ts b/src/services/rpg-creation/rpgCreationAssetClient.test.ts index 922a477c9..4e6a1b700 100644 --- a/src/services/rpg-creation/rpgCreationAssetClient.test.ts +++ b/src/services/rpg-creation/rpgCreationAssetClient.test.ts @@ -7,8 +7,8 @@ const { requestJsonMock } = vi.hoisted(() => ({ })); import { - generateRpgWorldOpeningCg, generateRpgWorldLandmark, + generateRpgWorldOpeningCg, generateRpgWorldSceneImage, generateRpgWorldSceneNpc, } from './rpgCreationAssetClient'; diff --git a/src/services/rpg-creation/rpgCreationPreviewAdapter.ts b/src/services/rpg-creation/rpgCreationPreviewAdapter.ts index f4d90a376..7f82f90c0 100644 --- a/src/services/rpg-creation/rpgCreationPreviewAdapter.ts +++ b/src/services/rpg-creation/rpgCreationPreviewAdapter.ts @@ -36,6 +36,6 @@ export const rpgCreationPreviewAdapter = { export { buildCustomWorldProfileFromResultPreview as buildRpgCreationPreviewFromResultPreview, - buildCustomWorldProfileFromAgentSession as buildRpgCreationPreviewFromSession, buildCustomWorldProfileFromResultView as buildRpgCreationPreviewFromResultView, + buildCustomWorldProfileFromAgentSession as buildRpgCreationPreviewFromSession, }; diff --git a/src/services/rpg-creation/rpgCreationRuntimeClient.ts b/src/services/rpg-creation/rpgCreationRuntimeClient.ts index cb509d1f1..0aa095850 100644 --- a/src/services/rpg-creation/rpgCreationRuntimeClient.ts +++ b/src/services/rpg-creation/rpgCreationRuntimeClient.ts @@ -1,7 +1,7 @@ import { - BACKGROUND_AUTH_REQUEST_OPTIONS, type ApiAuthImpact, type ApiRetryOptions, + BACKGROUND_AUTH_REQUEST_OPTIONS, requestJson, } from '../apiClient'; diff --git a/src/services/rpg-entry/index.ts b/src/services/rpg-entry/index.ts index 535d45e28..27d2f1c45 100644 --- a/src/services/rpg-entry/index.ts +++ b/src/services/rpg-entry/index.ts @@ -2,9 +2,9 @@ export { deleteRpgEntryWorldProfile, getRpgEntryWorldGalleryDetail, getRpgEntryWorldLibraryDetail, + likeRpgEntryWorldGallery, listRpgEntryWorldGallery, listRpgEntryWorldLibrary, - likeRpgEntryWorldGallery, publishRpgEntryWorldProfile, recordRpgEntryWorldGalleryPlay, remixRpgEntryWorldGallery, diff --git a/src/services/rpg-entry/rpgEntryClients.routing.test.ts b/src/services/rpg-entry/rpgEntryClients.routing.test.ts index fbbcb3e9b..f795e11ba 100644 --- a/src/services/rpg-entry/rpgEntryClients.routing.test.ts +++ b/src/services/rpg-entry/rpgEntryClients.routing.test.ts @@ -4,6 +4,10 @@ const { requestJsonMock } = vi.hoisted(() => ({ requestJsonMock: vi.fn(), })); +import { + getRpgEntryWorldGalleryDetail, + listRpgEntryWorldGallery, +} from './rpgEntryLibraryClient'; import { clearRpgProfileBrowseHistory, listRpgProfileBrowseHistory, @@ -12,10 +16,6 @@ import { syncRpgProfileBrowseHistory, upsertRpgProfileBrowseHistory, } from './rpgProfileClient'; -import { - getRpgEntryWorldGalleryDetail, - listRpgEntryWorldGallery, -} from './rpgEntryLibraryClient'; vi.mock('../apiClient', () => ({ BACKGROUND_AUTH_REQUEST_OPTIONS: { diff --git a/src/services/rpg-entry/rpgEntryLibraryClient.ts b/src/services/rpg-entry/rpgEntryLibraryClient.ts index 1e6bc052d..6482d8817 100644 --- a/src/services/rpg-entry/rpgEntryLibraryClient.ts +++ b/src/services/rpg-entry/rpgEntryLibraryClient.ts @@ -1,9 +1,3 @@ -import { - RUNTIME_BACKGROUND_AUTH_OPTIONS, - type RuntimeRequestOptions, - requestPublicRpgRuntimeJson, - requestRpgRuntimeJson, -} from '../rpg-runtime/rpgRuntimeRequest'; import type { CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse, @@ -13,6 +7,12 @@ import type { } from '../../../packages/shared/src/contracts/runtime'; import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary'; import type { CustomWorldProfile } from '../../types'; +import { + requestPublicRpgRuntimeJson, + requestRpgRuntimeJson, + RUNTIME_BACKGROUND_AUTH_OPTIONS, + type RuntimeRequestOptions, +} from '../rpg-runtime/rpgRuntimeRequest'; export type { RuntimeRequestOptions }; diff --git a/src/services/rpg-entry/rpgProfileClient.test.ts b/src/services/rpg-entry/rpgProfileClient.test.ts index 23c866cf3..d707cd350 100644 --- a/src/services/rpg-entry/rpgProfileClient.test.ts +++ b/src/services/rpg-entry/rpgProfileClient.test.ts @@ -11,8 +11,8 @@ import { listRpgProfileBrowseHistory, listRpgProfileExternalApiKeys, listRpgProfileSaveArchives, - revokeRpgProfileExternalApiKey, resumeRpgProfileSaveArchive, + revokeRpgProfileExternalApiKey, submitRpgProfileFeedback, syncRpgProfileBrowseHistory, upsertRpgProfileBrowseHistory, diff --git a/src/services/rpg-runtime/index.ts b/src/services/rpg-runtime/index.ts index 62a6f69c1..c9c0ccb44 100644 --- a/src/services/rpg-runtime/index.ts +++ b/src/services/rpg-runtime/index.ts @@ -10,6 +10,8 @@ export { export { beginRuntimeStorySession, beginStorySession, + buildRuntimeSnapshotFromProjection, + buildStoryMomentFromRuntimeProjection, continueStorySession, getRuntimeActionSnapshot, getRuntimeClientVersion, @@ -20,19 +22,17 @@ export { getStorySessionState, isServerRuntimeFunctionId, isTask5RuntimeFunctionId, - buildRuntimeSnapshotFromProjection, loadRuntimeInventoryView, resolveRuntimeStoryAction, resolveRuntimeStoryMoment, - buildStoryMomentFromRuntimeProjection, type RpgRuntimeStoryClientOptions, type RuntimeStoryChoicePayload, type RuntimeStoryInventoryView, type RuntimeStoryProjectionResult, type RuntimeStoryResponse, + shouldUseServerRuntimeOptions, type StorySessionMutationResult, type StorySessionStateResult, - shouldUseServerRuntimeOptions, } from './rpgRuntimeStoryClient'; export { deleteRpgSaveSnapshot, diff --git a/src/services/rpg-runtime/rpgRuntimeRequest.ts b/src/services/rpg-runtime/rpgRuntimeRequest.ts index bfe25d3eb..78ca831b1 100644 --- a/src/services/rpg-runtime/rpgRuntimeRequest.ts +++ b/src/services/rpg-runtime/rpgRuntimeRequest.ts @@ -1,7 +1,7 @@ import { - BACKGROUND_AUTH_REQUEST_OPTIONS, type ApiAuthImpact, type ApiRetryOptions, + BACKGROUND_AUTH_REQUEST_OPTIONS, requestJson, } from '../apiClient'; diff --git a/src/services/rpg-runtime/rpgRuntimeStoryClient.test.ts b/src/services/rpg-runtime/rpgRuntimeStoryClient.test.ts index fe5c2f5f8..90fe1abb2 100644 --- a/src/services/rpg-runtime/rpgRuntimeStoryClient.test.ts +++ b/src/services/rpg-runtime/rpgRuntimeStoryClient.test.ts @@ -13,22 +13,22 @@ vi.mock('../apiClient', async () => { }; }); -import { AnimationState } from '../../types'; import type { StoryRuntimeProjectionResponse } from '../../../packages/shared/src/contracts/story'; +import { AnimationState } from '../../types'; import { - beginStorySession, beginRuntimeStorySession, + beginStorySession, buildStoryMomentFromRuntimeOptions, + buildStoryMomentFromRuntimeProjection, continueStorySession, - getStorySessionState, getRuntimeClientVersion, getRuntimeSessionId, getRuntimeStorySessionId, getRuntimeStoryState, + getStorySessionState, isServerRuntimeFunctionId, isTask5RuntimeFunctionId, loadRuntimeInventoryView, - buildStoryMomentFromRuntimeProjection, resolveRuntimeStoryAction, resolveRuntimeStoryMoment, shouldUseServerRuntimeOptions, diff --git a/src/services/rpg-runtime/rpgRuntimeStoryClient.ts b/src/services/rpg-runtime/rpgRuntimeStoryClient.ts index b9ce65ca0..b636c6abe 100644 --- a/src/services/rpg-runtime/rpgRuntimeStoryClient.ts +++ b/src/services/rpg-runtime/rpgRuntimeStoryClient.ts @@ -8,9 +8,9 @@ import { TASK5_RUNTIME_FUNCTION_IDS, } from '../../../packages/shared/src/contracts/rpgRuntimeStoryAction'; import type { - RuntimeStoryOptionView, RuntimeBattlePresentation, RuntimeStoryInventoryViewModel, + RuntimeStoryOptionView, } from '../../../packages/shared/src/contracts/rpgRuntimeStoryState'; import type { BeginStoryRuntimeSessionRequest, @@ -18,10 +18,10 @@ import type { ContinueStoryRequest, ResolveStoryRuntimeActionRequest, StoryRuntimeMutationResponse, - StorySessionMutationResponse, - StorySessionStateResponse, StoryRuntimeOptionProjection, StoryRuntimeProjectionResponse, + StorySessionMutationResponse, + StorySessionStateResponse, } from '../../../packages/shared/src/contracts/story'; import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; diff --git a/tools/spine-json-export-validator/src/main.ts b/tools/spine-json-export-validator/src/main.ts index d82a624a6..332baeff2 100644 --- a/tools/spine-json-export-validator/src/main.ts +++ b/tools/spine-json-export-validator/src/main.ts @@ -1,5 +1,15 @@ -import JSZip from 'jszip'; -import type { JSZipObject } from 'jszip'; +import './style.css'; + +import type { + AttachmentLoader, + BoundingBoxAttachment, + ClippingAttachment, + MeshAttachment, + PathAttachment, + PointAttachment, + Sequence, + Skin, +} from '@esotericsoftware/spine-webgl'; import { AnimationState, AnimationStateData, @@ -13,18 +23,8 @@ import { SkeletonJson, TextureRegion, } from '@esotericsoftware/spine-webgl'; -import type { - AttachmentLoader, - BoundingBoxAttachment, - ClippingAttachment, - MeshAttachment, - PathAttachment, - PointAttachment, - Sequence, - Skin, -} from '@esotericsoftware/spine-webgl'; - -import './style.css'; +import type { JSZipObject } from 'jszip'; +import JSZip from 'jszip'; const IMAGE_EXTENSIONS = ['png', 'webp', 'jpg', 'jpeg']; const SKELETON_FILE_NAME = 'skeleton.json'; @@ -399,8 +399,8 @@ class LooseFrameAttachmentLoader implements AttachmentLoader { skin: Skin, placeholder: string, name: string, - path: string, - sequence: Sequence, + _path: string, + _sequence: Sequence, ): MeshAttachment { throw new Error(`暂不支持 mesh attachment:${name}`); } diff --git a/vite.config.ts b/vite.config.ts index 2d76d36e4..6d11d203c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -64,6 +64,18 @@ export default defineConfig(({mode}) => { }, build: { chunkSizeWarningLimit: 1000, + rollupOptions: { + output: { + manualChunks(id) { + if (id.includes('/node_modules/motion')) { + return 'motion-vendor'; + } + if (id.includes('/src/services/')) { + return 'app-services'; + } + }, + }, + }, }, server: { allowedHosts: ["outfield-outlook-reprocess.ngrok-free.dev"], diff --git a/vitest.config.ts b/vitest.config.ts index 30f506d44..9ebc64ce9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,6 +12,8 @@ export default defineConfig({ test: { environment: 'node', globals: true, + minThreads: 1, + maxThreads: 8, include: [ 'src/**/*.test.ts', 'src/**/*.test.tsx', @@ -22,5 +24,24 @@ export default defineConfig({ 'packages/shared/src/**/*.test.ts', 'packages/shared/src/**/*.test.tsx', ], + exclude: [ + 'src/components/jump-hop-runtime/**', + 'src/components/match3d-runtime/**', + 'src/components/CustomWorldEntityEditorModal.test.tsx', + 'src/components/CustomWorldResultView.test.tsx', + 'src/components/child-motion-demo/**', + 'src/components/custom-world-home/**', + 'src/components/platform-entry/platformDialogStateModel.test.ts', + 'src/components/puzzle-clear-creation/**', + 'src/components/puzzle-runtime/**', + 'src/components/rpg-entry/**', + 'src/components/rpg-runtime-shell/**', + 'src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx', + 'src/games/bark-battle/**', + 'src/hooks/rpg-runtime-story/**', + 'src/services/puzzle-clear/**', + 'src/services/recommendedRuntimeGuestLaunch.test.ts', + 'src/services/rpg-runtime/**', + ], }, });