diff --git a/muse-studio/.gitignore b/muse-studio/.gitignore index a547bf36..11cc5a06 100644 --- a/muse-studio/.gitignore +++ b/muse-studio/.gitignore @@ -10,6 +10,9 @@ lerna-debug.log* node_modules dist dist-ssr +coverage +test-results +playwright-report *.local # Editor directories and files diff --git a/muse-studio/e2e/workspace.spec.ts b/muse-studio/e2e/workspace.spec.ts new file mode 100644 index 00000000..d84f2e5f --- /dev/null +++ b/muse-studio/e2e/workspace.spec.ts @@ -0,0 +1,8 @@ +import { expect, test } from '@playwright/test'; + +test('loads the studio workspace with mocked works', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByRole('heading', { name: '我的创作台' })).toBeVisible(); + await expect(page.getByText('星海迷途')).toBeVisible(); +}); diff --git a/muse-studio/package.json b/muse-studio/package.json index 29c2e07a..ab6f7d94 100644 --- a/muse-studio/package.json +++ b/muse-studio/package.json @@ -7,6 +7,10 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", + "test": "vitest run", + "test:unit": "vitest run", + "coverage": "vitest run --coverage", + "test:e2e": "playwright test", "preview": "vite preview" }, "dependencies": { @@ -33,6 +37,7 @@ "@types/react-dom": "^18.3.7", "@typescript-eslint/parser": "^8.59.4", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "2.0.0", "autoprefixer": "^10.5.0", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.1.1", @@ -56,4 +61,4 @@ "public" ] } -} \ No newline at end of file +} diff --git a/muse-studio/playwright.config.ts b/muse-studio/playwright.config.ts new file mode 100644 index 00000000..0e85e0c5 --- /dev/null +++ b/muse-studio/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + retries: process.env.CI ? 2 : 0, + reporter: 'list', + use: { + baseURL: 'http://127.0.0.1:5173', + trace: 'on-first-retry', + }, + webServer: { + command: 'pnpm dev --host 127.0.0.1', + url: 'http://127.0.0.1:5173', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/muse-studio/pnpm-lock.yaml b/muse-studio/pnpm-lock.yaml index 0a14e8fc..7dcb7336 100644 --- a/muse-studio/pnpm-lock.yaml +++ b/muse-studio/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: '@vitejs/plugin-react': specifier: ^6.0.1 version: 6.0.2(vite@8.0.14(@types/node@24.12.4)(jiti@1.21.7)) + '@vitest/coverage-v8': + specifier: 2.0.0 + version: 2.0.0(vitest@2.0.0(@types/node@24.12.4)(jsdom@29.1.1)(lightningcss@1.32.0)) autoprefixer: specifier: ^10.5.0 version: 10.5.0(postcss@8.5.15) @@ -223,6 +226,9 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true @@ -553,6 +559,10 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + '@jest/schemas@29.6.3': resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1196,6 +1206,11 @@ packages: babel-plugin-react-compiler: optional: true + '@vitest/coverage-v8@2.0.0': + resolution: {integrity: sha512-k2OgMH8e4AyUVsmLk2P3vT++VVaUQbvVaP5NJQDgXgv1q4lG56Z4nb26QNcxgk4ZLypmOrfultAShMo+okIOYw==} + peerDependencies: + vitest: 2.0.0 + '@vitest/expect@2.0.0': resolution: {integrity: sha512-5BSfZ0+dAVmC6uPF7s+TcKx0i7oyYHb1WQQL5gg6G2c+Qkaa5BNrdRM74sxDfUIZUgYCr6bfCqmJp+X5bfcNxQ==} @@ -1757,6 +1772,9 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -1830,6 +1848,22 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jackspeak@3.1.2: resolution: {integrity: sha512-kWmLKn2tRtfYMF/BakihVVRzBKOxz4gJMiL2Rj91WnAB5TPZumSH99R/Yf1qE1u4uRimvCSJfm6hnxohXeEXjQ==} engines: {node: '>=14'} @@ -1848,6 +1882,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -2010,6 +2047,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.3.4: + resolution: {integrity: sha512-TyDF/Pn36bBji9rWKHlZe+PZb6Mx5V8IHCSxk7X4aljM4e/vyDvZZYwHewdVaqiA0nb3ghfHU/6AUpDxWoER2Q==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2515,6 +2559,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@2.1.1: + resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -2540,6 +2587,10 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -2979,6 +3030,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@0.2.3': {} + '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 @@ -3227,6 +3280,8 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@istanbuljs/schema@0.1.6': {} + '@jest/schemas@29.6.3': dependencies: '@sinclair/typebox': 0.27.10 @@ -3812,6 +3867,25 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.0.14(@types/node@24.12.4)(jiti@1.21.7) + '@vitest/coverage-v8@2.0.0(vitest@2.0.0(@types/node@24.12.4)(jsdom@29.1.1)(lightningcss@1.32.0))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.4 + picocolors: 1.1.1 + std-env: 3.7.0 + strip-literal: 2.1.1 + test-exclude: 7.0.2 + vitest: 2.0.0(@types/node@24.12.4)(jsdom@29.1.1)(lightningcss@1.32.0) + transitivePeerDependencies: + - supports-color + '@vitest/expect@2.0.0': dependencies: '@vitest/spy': 2.0.0 @@ -4403,6 +4477,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-escaper@2.0.2: {} + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.0 @@ -4455,6 +4531,27 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jackspeak@3.1.2: dependencies: '@isaacs/cliui': 8.0.2 @@ -4477,6 +4574,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -4611,6 +4710,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.4: + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.1 + math-intrinsics@1.1.0: {} mdn-data@2.27.1: {} @@ -5125,6 +5234,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@2.1.1: + dependencies: + js-tokens: 9.0.1 + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.5 @@ -5173,6 +5286,12 @@ snapshots: - tsx - yaml + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 10.5.0 + minimatch: 10.2.5 + thenify-all@1.6.0: dependencies: thenify: 3.3.1 diff --git a/muse-studio/src/api/client.test.ts b/muse-studio/src/api/client.test.ts new file mode 100644 index 00000000..e969f364 --- /dev/null +++ b/muse-studio/src/api/client.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { api } from './client'; + +describe('api client contract behavior', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('treats successful empty 200 responses as success instead of network errors', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(null, { + status: 200, + headers: {}, + }) + ); + + await expect(api.delete('/works/work-1')).resolves.toBeNull(); + }); + + it('sends JSON body for DELETE requests when operation requires command data', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + Response.json({ + code: 0, + msg: 'success', + data: true, + }) + ); + + await api.delete('/works/work-1/chapters/chapter-1', { + commandId: 'cmd-delete', + expectedRevision: 7, + }); + + const [, options] = fetchSpy.mock.calls[0] ?? []; + expect(options?.method).toBe('DELETE'); + expect(options?.body).toBe(JSON.stringify({ + commandId: 'cmd-delete', + expectedRevision: 7, + })); + }); +}); diff --git a/muse-studio/src/api/client.ts b/muse-studio/src/api/client.ts index f0eaf1dc..062ed45b 100644 --- a/muse-studio/src/api/client.ts +++ b/muse-studio/src/api/client.ts @@ -60,11 +60,11 @@ export async function request( } const response = await fetch(`${BASE_URL}${path}`, { + ...options, headers: { ...headers, ...options.headers, }, - ...options, }); const contentType = response.headers.get('content-type'); @@ -78,8 +78,8 @@ export async function request( throw new ApiError('JSON_PARSE_ERROR', '反序列化服务器 JSON 数据失败', response.status); } } else { - // 204 No Content 或 205 Reset Content 兜底 - if (response.status === 204 || response.status === 205) { + // 部分 OpenAPI 写命令以 200 + 空响应体表示成功,不能误判为网络异常 + if (response.ok || response.status === 204 || response.status === 205) { return null as unknown as T; } throw new ApiError( @@ -146,5 +146,15 @@ export const api = { }, /** DELETE 请求 */ - delete: (path: string) => request(path, { method: 'DELETE' }), + delete: (path: string, data?: unknown) => { + const options: RequestInit = { method: 'DELETE' }; + if (data !== undefined) { + if (isFormData(data)) { + options.body = data; + } else { + options.body = JSON.stringify(data); + } + } + return request(path, options); + }, }; diff --git a/muse-studio/src/api/mocks/handlers/ai.ts b/muse-studio/src/api/mocks/handlers/ai.ts index a9738869..dfae5aa4 100644 --- a/muse-studio/src/api/mocks/handlers/ai.ts +++ b/muse-studio/src/api/mocks/handlers/ai.ts @@ -16,6 +16,13 @@ type AgentTestRequestBody = { commandId?: string; }; +type CreateAiTaskRequestBody = { + intent?: { + userInstruction?: string; + }; + commandId?: string; +}; + type SlotPrecheckRequest = { commandId?: string; sourceAgentId?: string; @@ -105,12 +112,14 @@ const INITIAL_SLOTS: AgentSlotSummary[] = [ let mockAgents = structuredClone(INITIAL_AGENTS); let mockSlots = structuredClone(INITIAL_SLOTS); const mockPrechecks = new Map(); +const mockAiTasks = new Map(); /** 重置 AI Mock 内存库,供后续单测隔离使用。 */ export function resetMockDb(): void { mockAgents = structuredClone(INITIAL_AGENTS); mockSlots = structuredClone(INITIAL_SLOTS); mockPrechecks.clear(); + mockAiTasks.clear(); } /** @@ -270,15 +279,41 @@ export const aiHandlers = [ }); }), - // Mock AI SSE 流式生成接口 - http.get('/app-api/muse/ai/stream', () => { + // POST /app-api/muse/ai/tasks — 创建 AI 生成任务 + http.post('/app-api/muse/ai/tasks', async ({ request }) => { + const body = (await request.json()) as CreateAiTaskRequestBody; + if (!body.commandId) { + return HttpResponse.json({ code: 40010, msg: '缺少 commandId', data: null }, { status: 400 }); + } + + const taskId = crypto.randomUUID(); + mockAiTasks.set(taskId, { + prompt: body.intent?.userInstruction?.trim() || '请继续创作', + }); + + return HttpResponse.json({ + code: 0, + msg: 'success', + data: { + taskId, + pollUrl: `/app-api/muse/ai/tasks/${taskId}`, + }, + }, { status: 202 }); + }), + + // GET /app-api/muse/ai/tasks/:taskId/stream — Mock AI SSE 流式生成接口 + http.get('/app-api/muse/ai/tasks/:taskId/stream', ({ params }) => { + const task = mockAiTasks.get(String(params.taskId)); + if (!task) { + return HttpResponse.json({ code: 40420, msg: 'AI 任务不存在', data: null }, { status: 404 }); + } const encoder = new TextEncoder(); // 手动构造支持流式传输的 ReadableStream const stream = new ReadableStream({ async start(controller) { // 模拟多批次的流式文本块推送 - const chunks = ['宇宙', '深处', '闪烁着', '未知的', '微光,', '那是', '文明的', '遗迹。']; + const chunks = ['围绕', '“', task.prompt.slice(0, 12), '”', '展开', '新的', '情节', '转折。']; for (const chunk of chunks) { // 模拟 Token 渲染的轻微延时效果 diff --git a/muse-studio/src/api/mocks/handlers/content.ts b/muse-studio/src/api/mocks/handlers/content.ts index 432e8cea..0c53bc13 100644 --- a/muse-studio/src/api/mocks/handlers/content.ts +++ b/muse-studio/src/api/mocks/handlers/content.ts @@ -20,6 +20,9 @@ export interface MockChapter { wordCount: number; sortOrder: number; workId: string; + blockId: string; + blockVersion: number; + content: string; } interface WorkCreateRequest { @@ -35,6 +38,12 @@ interface ChapterCreateRequest { interface BlockSaveRequest { content?: string; expectedRevision?: number; + commandId?: string; + sourceSnapshot?: { + contentRevision?: number; + sourceType?: string; + sourceId?: string; + }; } interface TiptapTextNode { @@ -55,6 +64,44 @@ function collectTiptapText(node: unknown, textNodes: string[]): void { } } +/** 为章节构造默认 Tiptap JSON 正文,保持 mock 数据与真实 Block 契约一致。 */ +function createChapterContent(title: string, wordCount: number): string { + return JSON.stringify({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: `这是章节《${title}》的正文内容。当前已保存字数为 ${wordCount} 字。`, + }, + ], + }, + ], + }); +} + +/** 构造章节和首个正文 Block 的一体化 mock 数据。 */ +function createMockChapter( + id: string, + title: string, + wordCount: number, + sortOrder: number, + workId: string +): MockChapter { + return { + id, + title, + wordCount, + sortOrder, + workId, + blockId: `block-${id}-main`, + blockVersion: 1, + content: createChapterContent(title, wordCount), + }; +} + // 初始作品数据快照 const INITIAL_WORKS: MockWork[] = [ { @@ -95,17 +142,17 @@ const INITIAL_WORKS: MockWork[] = [ // 初始章节数据快照 const INITIAL_CHAPTERS: MockChapter[] = [ // 星海迷途章节 - { id: '1-1', title: '第一章 启程', wordCount: 15000, sortOrder: 1, workId: '1' }, - { id: '1-2', title: '第二章 信号', wordCount: 18000, sortOrder: 2, workId: '1' }, - { id: '1-3', title: '第三章 降落', wordCount: 19300, sortOrder: 3, workId: '1' }, + createMockChapter('1-1', '第一章 启程', 15000, 1, '1'), + createMockChapter('1-2', '第二章 信号', 18000, 2, '1'), + createMockChapter('1-3', '第三章 降落', 19300, 3, '1'), // 长安旧事章节 - { id: '2-1', title: '第一章 惊蛰', wordCount: 6000, sortOrder: 1, workId: '2' }, - { id: '2-2', title: '第二章 春分', wordCount: 6800, sortOrder: 2, workId: '2' }, + createMockChapter('2-1', '第一章 惊蛰', 6000, 1, '2'), + createMockChapter('2-2', '第二章 春分', 6800, 2, '2'), // 深渊笔记章节 - { id: '3-1', title: '第一章 迷雾', wordCount: 20000, sortOrder: 1, workId: '3' }, - { id: '3-2', title: '第二章 深潜', wordCount: 22000, sortOrder: 2, workId: '3' }, - { id: '3-3', title: '第三章 遗迹', wordCount: 24000, sortOrder: 3, workId: '3' }, - { id: '3-4', title: '第四章 归途', wordCount: 23500, sortOrder: 4, workId: '3' }, + createMockChapter('3-1', '第一章 迷雾', 20000, 1, '3'), + createMockChapter('3-2', '第二章 深潜', 22000, 2, '3'), + createMockChapter('3-3', '第三章 遗迹', 24000, 3, '3'), + createMockChapter('3-4', '第四章 归途', 23500, 4, '3'), ]; // 运行时的可变内存数据库状态 @@ -216,7 +263,7 @@ export const contentHandlers = [ }); }), - // GET /app-api/muse/works/:workId/chapters/:chapterId — 获取章节详情及正文 + // GET /app-api/muse/works/:workId/chapters/:chapterId — 获取章节详情及 Block 正文 http.get('/app-api/muse/works/:workId/chapters/:chapterId', ({ params }) => { const { workId, chapterId } = params; const chapter = mockChapters.find((ch) => ch.id === chapterId && ch.workId === workId); @@ -225,30 +272,34 @@ export const contentHandlers = [ return HttpResponse.json({ code: 40402, msg: '章节不存在', data: null }, { status: 404 }); } - // 提供默认的富文本编辑器 Tiptap JSON 格式,字数按 mock 库同步 - const dummyContent = { - type: 'doc', - content: [ - { - type: 'paragraph', - content: [ - { - type: 'text', - text: `这是章节《${chapter.title}》的正文内容。当前已保存字数为 ${chapter.wordCount} 字。`, - }, - ], - }, - ], - }; - return HttpResponse.json({ code: 0, msg: 'success', data: { id: chapter.id, + workId: chapter.workId, title: chapter.title, - content: JSON.stringify(dummyContent), - revision: 1, + sortOrder: chapter.sortOrder, + status: 'draft', + wordCount: chapter.wordCount, + blockCount: 1, + createdAt: '2026-05-20T00:00:00Z', + updatedAt: new Date().toISOString(), + blocks: [ + { + id: chapter.blockId, + workId: chapter.workId, + chapterId: chapter.id, + blockType: 'scene', + title: chapter.title, + content: chapter.content, + sortOrder: 1, + version: chapter.blockVersion, + wordCount: chapter.wordCount, + createdAt: '2026-05-20T00:00:00Z', + updatedAt: new Date().toISOString(), + }, + ], }, }); }), @@ -266,6 +317,9 @@ export const contentHandlers = [ wordCount: 0, sortOrder: list.length + 1, workId: workId as string, + blockId: crypto.randomUUID(), + blockVersion: 1, + content: createChapterContent(body.title || `第 ${list.length + 1} 章 未命名章节`, 0), }; mockChapters.push(newChapter); @@ -314,10 +368,21 @@ export const contentHandlers = [ }); }), - // PUT /app-api/muse/works/blocks/:chapterId — 编辑器保存章节内容接口拦截 - http.put('/app-api/muse/works/blocks/:chapterId', async ({ params, request }) => { - const { chapterId } = params; + // PUT /app-api/muse/works/:workId/blocks/:blockId — 编辑器保存 Block 正文接口拦截 + http.put('/app-api/muse/works/:workId/blocks/:blockId', async ({ params, request }) => { + const { workId, blockId } = params; const body = (await request.json()) as BlockSaveRequest; + const chapter = mockChapters.find((ch) => ch.blockId === blockId && ch.workId === workId); + + if (!chapter) { + return HttpResponse.json({ code: 40403, msg: 'Block 不存在', data: null }, { status: 404 }); + } + if (!body.commandId || !body.sourceSnapshot) { + return HttpResponse.json({ code: 40001, msg: '缺少保存命令字段', data: null }, { status: 400 }); + } + if (body.expectedRevision !== chapter.blockVersion) { + return HttpResponse.json({ code: 40901, msg: 'Block 版本已变化,请刷新后重试', data: null }, { status: 409 }); + } // 获取提交的正文 JSON 以计算字数进行 Mock 存储更新 let newWordCount = 0; @@ -333,27 +398,24 @@ export const contentHandlers = [ newWordCount = 100; // 降级默认字数 } - // 查找到该章节并更新其字数 - const chapter = mockChapters.find((ch) => ch.id === chapterId); - if (chapter) { - const oldWords = chapter.wordCount; - chapter.wordCount = newWordCount; + // 查找到该章节并更新其字数、正文和乐观锁版本 + const oldWords = chapter.wordCount; + chapter.wordCount = newWordCount; + chapter.content = body.content || chapter.content; + chapter.blockVersion += 1; - // 联动更新关联作品的总字数统计 - const work = mockWorks.find((w) => w.id === chapter.workId); - if (work) { - work.wordCount = Math.max(0, work.wordCount - oldWords + newWordCount); - work.updatedAt = new Date().toISOString(); - } + // 联动更新关联作品的总字数统计 + const work = mockWorks.find((w) => w.id === chapter.workId); + if (work) { + work.wordCount = Math.max(0, work.wordCount - oldWords + newWordCount); + work.updatedAt = new Date().toISOString(); } - const expectedRevision = typeof body.expectedRevision === 'number' ? body.expectedRevision : 1; - return HttpResponse.json({ code: 0, msg: 'success', data: { - newRevision: expectedRevision + 1, + newVersion: chapter.blockVersion, }, }); }), diff --git a/muse-studio/src/api/mocks/handlers/knowledge.ts b/muse-studio/src/api/mocks/handlers/knowledge.ts index c13c9efe..cf583cf3 100644 --- a/muse-studio/src/api/mocks/handlers/knowledge.ts +++ b/muse-studio/src/api/mocks/handlers/knowledge.ts @@ -2,6 +2,7 @@ import { http, HttpResponse } from 'msw'; /** 知识库 Mock 实体定义 */ export interface MockKnowledgeBase { + installId?: string; kbId: string; name: string; description: string; @@ -35,7 +36,22 @@ export interface MockDocument { interface KnowledgeBaseCreateRequest { name?: string; description?: string; - category?: string; + templateId?: string; + commandId?: string; +} + +interface CommandRequest { + commandId?: string; + impactConfirmed?: boolean; + retentionConfirmed?: boolean; +} + +/** 校验写命令必须携带幂等键,模拟真实 OpenAPI 的命令式写入契约。 */ +function assertCommandId(body: CommandRequest | KnowledgeBaseCreateRequest): Response | null { + if (!body.commandId) { + return HttpResponse.json({ code: 40001, msg: '缺少 commandId', data: null }, { status: 400 }); + } + return null; } // 初始只读数据快照模板 @@ -67,6 +83,7 @@ const INITIAL_KNOWLEDGE_BASES: MockKnowledgeBase[] = [ updatedAt: '2026-05-24T11:00:00Z', }, { + installId: 'kb-installed-1', kbId: 'kb-installed-1', name: '中古奇幻种族大百科', description: '精灵、矮人、半身人以及各种地下城生物的生态与战斗特征。', @@ -172,14 +189,12 @@ export const knowledgeHandlers = [ // GET /app-api/muse/knowledge-bases — 获取用户及系统知识库列表 http.get('/app-api/muse/knowledge-bases', ({ request }) => { const url = new URL(request.url); - const scope = url.searchParams.get('scope'); // 用户筛选:user(自建)、installed(已安装)、global(公开) + const source = url.searchParams.get('source'); // 用户筛选:user(自建)、account_available/global(可用公开库) let list = mockKnowledgeBases; - if (scope === 'user') { + if (source === 'user') { list = mockKnowledgeBases.filter((kb) => kb.source === 'user'); - } else if (scope === 'installed') { - list = mockKnowledgeBases.filter((kb) => kb.source === 'installed'); - } else if (scope === 'global') { + } else if (source === 'global' || source === 'account_available') { list = mockKnowledgeBases.filter((kb) => kb.source === 'global'); } @@ -195,17 +210,50 @@ export const knowledgeHandlers = [ }); }), + // GET /app-api/muse/installed-knowledge-bases — 获取当前账号已安装知识库列表 + http.get('/app-api/muse/installed-knowledge-bases', () => { + const list = mockKnowledgeBases + .filter((kb) => kb.source === 'installed') + .map((kb) => ({ + installId: kb.installId ?? kb.kbId, + kbId: kb.kbId, + name: kb.name, + publisher: 'Muse 市场', + currentVersion: kb.currentVersion, + latestVersion: kb.currentVersion, + upgradeAvailable: false, + isVersionPinned: false, + status: kb.status === 'disabled' ? 'disabled' : 'installed', + canSearch: true, + canGenerate: true, + updatedAt: kb.updatedAt, + })); + + return HttpResponse.json({ + code: 0, + msg: 'success', + data: { + total: list.length, + pageNo: 1, + pageSize: 20, + list, + }, + }); + }), + // POST /app-api/muse/knowledge-bases — 创建知识库 http.post('/app-api/muse/knowledge-bases', async ({ request }) => { const body = (await request.json()) as KnowledgeBaseCreateRequest; + const commandError = assertCommandId(body); + if (commandError) return commandError; const newKb: MockKnowledgeBase = { kbId: crypto.randomUUID(), name: body.name || '未命名知识库', description: body.description || '', - category: body.category || '默认', + category: body.templateId ? '模板创建' : '默认', currentVersion: 1, - status: 'active', + status: 'processing', source: 'user', documentCount: 0, bindingCount: 0, @@ -218,7 +266,10 @@ export const knowledgeHandlers = [ return HttpResponse.json({ code: 0, msg: 'success', - data: newKb, + data: { + kbId: newKb.kbId, + status: 'draft', + }, }, { status: 201 }); }), @@ -239,7 +290,14 @@ export const knowledgeHandlers = [ }), // DELETE /app-api/muse/knowledge-bases/:kbId — 删除自建知识库 (仅能删除用户自建的,物理删除并级联删除所属资料) - http.delete('/app-api/muse/knowledge-bases/:kbId', ({ params }) => { + http.delete('/app-api/muse/knowledge-bases/:kbId', async ({ params, request }) => { + const body = (await request.json()) as CommandRequest; + const commandError = assertCommandId(body); + if (commandError) return commandError; + if (body.impactConfirmed !== true || body.retentionConfirmed !== true) { + return HttpResponse.json({ code: 40002, msg: '缺少删除影响确认', data: null }, { status: 400 }); + } + const { kbId } = params; const index = mockKnowledgeBases.findIndex((kb) => kb.kbId === kbId && kb.source === 'user'); @@ -253,14 +311,26 @@ export const knowledgeHandlers = [ return HttpResponse.json({ code: 0, msg: 'success', - data: true, + data: { + status: 'deleting', + affectedBindings: 0, + }, }); }), // DELETE /app-api/muse/installed-knowledge-bases/:installId — 卸载已安装知识库 (不删除云端资产,仅移除本地绑定) - http.delete('/app-api/muse/installed-knowledge-bases/:installId', ({ params }) => { + http.delete('/app-api/muse/installed-knowledge-bases/:installId', async ({ params, request }) => { + const body = (await request.json()) as CommandRequest; + const commandError = assertCommandId(body); + if (commandError) return commandError; + if (body.impactConfirmed !== true) { + return HttpResponse.json({ code: 40002, msg: '缺少卸载影响确认', data: null }, { status: 400 }); + } + const { installId } = params; - const index = mockKnowledgeBases.findIndex((kb) => kb.kbId === installId && kb.source === 'installed'); + const index = mockKnowledgeBases.findIndex( + (kb) => (kb.installId === installId || kb.kbId === installId) && kb.source === 'installed' + ); if (index === -1) { return HttpResponse.json({ code: 40403, msg: '已安装知识库未找到或不具备卸载权限', data: null }, { status: 404 }); @@ -270,7 +340,9 @@ export const knowledgeHandlers = [ return HttpResponse.json({ code: 0, msg: 'success', - data: true, + data: { + affectedWorkBindings: 0, + }, }); }), @@ -407,7 +479,14 @@ export const knowledgeHandlers = [ }), // DELETE /app-api/muse/knowledge-bases/:kbId/documents/:documentId — 删除资料 - http.delete('/app-api/muse/knowledge-bases/:kbId/documents/:documentId', ({ params }) => { + http.delete('/app-api/muse/knowledge-bases/:kbId/documents/:documentId', async ({ params, request }) => { + const body = (await request.json()) as CommandRequest; + const commandError = assertCommandId(body); + if (commandError) return commandError; + if (body.impactConfirmed !== true) { + return HttpResponse.json({ code: 40002, msg: '缺少资料删除影响确认', data: null }, { status: 400 }); + } + const { kbId, documentId } = params; const index = mockDocuments.findIndex((doc) => doc.documentId === documentId && doc.kbId === kbId); @@ -427,7 +506,10 @@ export const knowledgeHandlers = [ return HttpResponse.json({ code: 0, msg: 'success', - data: true, + data: { + sourceEventId: crypto.randomUUID(), + affectedBindings: 0, + }, }); }), diff --git a/muse-studio/src/features/editor/components/AIPanel.contract.test.tsx b/muse-studio/src/features/editor/components/AIPanel.contract.test.tsx new file mode 100644 index 00000000..90f0aa0d --- /dev/null +++ b/muse-studio/src/features/editor/components/AIPanel.contract.test.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { http, HttpResponse } from 'msw'; +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import AIPanel from './AIPanel'; +import { server } from '@/test/setup'; + +describe('AIPanel task stream contract', () => { + it('creates an AI task with JSON body and streams by taskId without leaking prompt into URL', async () => { + const createRequests: Array<{ url: string; body: unknown }> = []; + const streamRequests: string[] = []; + + server.use( + http.post('/app-api/muse/ai/tasks', async ({ request }) => { + createRequests.push({ + url: request.url, + body: await request.json(), + }); + return HttpResponse.json({ + code: 0, + msg: 'success', + data: { + taskId: 'task-123', + }, + }, { status: 202 }); + }), + http.get('/app-api/muse/ai/tasks/:taskId/stream', ({ request }) => { + streamRequests.push(request.url); + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"type":"chunk","content":"星海","sequenceNo":1}\n\n')); + controller.enqueue(encoder.encode('data: {"type":"done","generationId":"g-1","candidateId":"c-1"}\n\n')); + controller.close(); + }, + }); + + return new HttpResponse(stream, { + headers: { 'Content-Type': 'text/event-stream' }, + }); + }) + ); + + const onCandidateGenerated = vi.fn(); + render(React.createElement(AIPanel, { onCandidateGenerated })); + + fireEvent.change(screen.getByPlaceholderText(/在此处输入创作描述/), { + target: { value: '秘密提示词' }, + }); + fireEvent.click(screen.getByRole('button', { name: /发送指令/ })); + + await waitFor(() => expect(onCandidateGenerated).toHaveBeenCalledWith('星海')); + expect(createRequests).toHaveLength(1); + expect(createRequests[0]?.url).not.toContain('秘密提示词'); + expect(createRequests[0]?.body).toMatchObject({ + intent: { + kind: 'continuation', + userInstruction: '秘密提示词', + outputTarget: 'suggestion', + }, + contextScope: 'work', + }); + expect(streamRequests).toEqual(['http://localhost:3000/app-api/muse/ai/tasks/task-123/stream']); + }); +}); diff --git a/muse-studio/src/features/editor/components/AIPanel.tsx b/muse-studio/src/features/editor/components/AIPanel.tsx index a5bc9278..7164c41d 100644 --- a/muse-studio/src/features/editor/components/AIPanel.tsx +++ b/muse-studio/src/features/editor/components/AIPanel.tsx @@ -1,5 +1,7 @@ -import { useState, useEffect, useRef } from 'react'; +import React from 'react'; import { connectAIStream } from '@/lib/sse'; +import { api } from '@/api/client'; +import type { CreateAiTaskRequest, CreateAiTaskResult } from '@/types/openapi'; /** AI 面板 Props 定义 */ interface AIPanelProps { @@ -12,16 +14,16 @@ interface AIPanelProps { * 发起流式续写或优化指令,展示 Token 级增量打字机视口,支持安全中止和网络重试 */ export default function AIPanel({ onCandidateGenerated }: AIPanelProps) { - const [prompt, setPrompt] = useState(''); - const [isGenerating, setIsGenerating] = useState(false); - const [streamContent, setStreamContent] = useState(''); - const [errorMsg, setErrorMsg] = useState(''); + const [prompt, setPrompt] = React.useState(''); + const [isGenerating, setIsGenerating] = React.useState(false); + const [streamContent, setStreamContent] = React.useState(''); + const [errorMsg, setErrorMsg] = React.useState(''); // 维护中止控制器的引用以管理流生命周期 - const abortControllerRef = useRef(null); + const abortControllerRef = React.useRef(null); // 1. 发起 AI 生成请求 - const handleGenerate = () => { + const handleGenerate = async () => { if (!prompt.trim()) return; // 幂等处理:如果有正在生成中的流,先主动打断 @@ -33,30 +35,48 @@ export default function AIPanel({ onCandidateGenerated }: AIPanelProps) { setStreamContent(''); setErrorMsg(''); - // 构建流式连接 URL 路径 - const targetUrl = `/app-api/muse/ai/stream?prompt=${encodeURIComponent(prompt.trim())}`; + try { + const taskRequest: CreateAiTaskRequest = { + commandId: crypto.randomUUID(), + intent: { + kind: 'continuation', + userInstruction: prompt.trim(), + outputTarget: 'suggestion', + }, + contextScope: 'work', + }; + const task = await api.post('/ai/tasks', taskRequest); - const controller = connectAIStream(targetUrl, { - onChunk: (data) => { - setStreamContent((prev) => prev + data.content); - }, - onDone: () => { - setIsGenerating(false); - // 通过回调将生成正文提交给上层,以渲染候选 Diff 视图 - setStreamContent((current) => { - onCandidateGenerated(current); - return current; - }); - abortControllerRef.current = null; - }, - onError: (err) => { - setIsGenerating(false); - setErrorMsg(err.message || '流式传输中断'); - abortControllerRef.current = null; - }, - }); + if (!task.taskId) { + throw new Error('AI 任务创建成功但缺少 taskId'); + } - abortControllerRef.current = controller; + const controller = connectAIStream(`/app-api/muse/ai/tasks/${task.taskId}/stream`, { + onChunk: (data) => { + setStreamContent((prev) => prev + data.content); + }, + onDone: () => { + setIsGenerating(false); + // 通过回调将生成正文提交给上层,以渲染候选 Diff 视图 + setStreamContent((current) => { + onCandidateGenerated(current); + return current; + }); + abortControllerRef.current = null; + }, + onError: (err) => { + setIsGenerating(false); + setErrorMsg(err.message || '流式传输中断'); + abortControllerRef.current = null; + }, + }); + + abortControllerRef.current = controller; + } catch (err) { + setIsGenerating(false); + setErrorMsg(err instanceof Error ? err.message : 'AI 任务创建失败'); + abortControllerRef.current = null; + } }; // 2. 主动中止生成 @@ -69,7 +89,7 @@ export default function AIPanel({ onCandidateGenerated }: AIPanelProps) { }; // 3. 生命周期清理:当用户切换路由或关闭侧栏导致组件卸载时,强制打断 fetch stream 连接以免泄漏 - useEffect(() => { + React.useEffect(() => { return () => { if (abortControllerRef.current) { abortControllerRef.current.abort(); diff --git a/muse-studio/src/features/editor/components/MuseEditor.tsx b/muse-studio/src/features/editor/components/MuseEditor.tsx index 9b8446f9..487d5843 100644 --- a/muse-studio/src/features/editor/components/MuseEditor.tsx +++ b/muse-studio/src/features/editor/components/MuseEditor.tsx @@ -1,12 +1,13 @@ import { useEditor, EditorContent } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Placeholder from '@tiptap/extension-placeholder'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useEditorStore } from '@/stores/editorStore'; import { saveBlockDraft, getBlockDraftRecord, removeBlockDraft } from '@/lib/indexed-db'; import { debounce } from '@/lib/debounce'; import { api } from '@/api/client'; import { useQueryClient } from '@tanstack/react-query'; +import type { SaveBlockRequest, SaveBlockResult } from '@/types/openapi'; export type MuseEditorInstance = NonNullable>; @@ -58,33 +59,55 @@ export default function MuseEditor({ blockId, workId, initialContent, revision, const markSaved = useEditorStore((state) => state.markSaved); const queryClient = useQueryClient(); + const revisionRef = useRef(revision); const [hasSyncFailedDraft, setHasSyncFailedDraft] = useState(false); const [pendingDraftContent, setPendingDraftContent] = useState | null>(null); + useEffect(() => { + revisionRef.current = revision; + }, [blockId, revision]); + // 2. 本地 IndexedDB 去抖保存 (500ms 降低高频打字时密集的磁盘事务开销) - const debouncedIndexedDBSave = useMemo( - () => debounce(async (id: string, json: Record, status: 'synced' | 'sync_failed' = 'synced') => { + const saveLocalDraft = useCallback( + async (id: string, json: Record, status: 'pending_remote' | 'synced' | 'sync_failed' = 'pending_remote') => { try { await saveBlockDraft(id, json, status); } catch (err) { console.error('缓存本地临时草稿失败:', err); } - }, 500), + }, [] ); + const debouncedIndexedDBSave = useMemo( + () => debounce(saveLocalDraft, 500), + [saveLocalDraft] + ); // 3. 远程同步 API 去抖保存 (2000ms 限制网络请求频次) - const debouncedAPISave = useMemo( - () => debounce(async (id: string, json: Record) => { + const saveRemoteDraft = useCallback( + async (id: string, json: Record) => { try { - // 先乐观设定本地缓存为 synced,若后续 API 报错,会在 catch 中重新覆盖为 sync_failed - await debouncedIndexedDBSave(id, json, 'synced'); + await debouncedIndexedDBSave(id, json, 'pending_remote'); - await api.put<{ newRevision: number }>(`/works/blocks/${id}`, { + if (!workId) { + throw new Error('缺少作品 ID,无法保存 Block'); + } + + const expectedRevision = revisionRef.current; + const payload: SaveBlockRequest = { + commandId: crypto.randomUUID(), content: JSON.stringify(json), - expectedRevision: revision, - }); + expectedRevision, + sourceSnapshot: { + sourceType: 'user_original', + sourceId: id, + contentRevision: expectedRevision, + }, + }; + + const result = await api.put(`/works/${workId}/blocks/${id}`, payload); + revisionRef.current = result?.newVersion ?? expectedRevision + 1; // 提交成功后移除本地临时影子层备份,释放 IndexedDB 空间 await removeBlockDraft(id); @@ -117,8 +140,14 @@ export default function MuseEditor({ blockId, workId, initialContent, revision, // 保存失败,标记为 sync_failed 并在后续重载时提供用户恢复入口 await debouncedIndexedDBSave(id, json, 'sync_failed'); } - }, 2000), - [markSaved, debouncedIndexedDBSave, queryClient, revision, workId] + }, + [markSaved, debouncedIndexedDBSave, queryClient, workId] + ); + const debouncedAPISave = useMemo( + // debounce 只保存函数引用,不会在 render 阶段执行该回调,因此这里允许回调内部读取 revisionRef。 + // eslint-disable-next-line react-hooks/refs + () => debounce(saveRemoteDraft, 2000), + [saveRemoteDraft] ); // 4. 初始化 Tiptap 编辑器实例 @@ -135,7 +164,7 @@ export default function MuseEditor({ blockId, workId, initialContent, revision, const json = editor.getJSON(); // 触发防抖本地缓存 - debouncedIndexedDBSave(blockId, json, 'synced'); + debouncedIndexedDBSave(blockId, json, 'pending_remote'); // 触发防抖远程网络保存 debouncedAPISave(blockId, json); }, @@ -165,7 +194,11 @@ export default function MuseEditor({ blockId, workId, initialContent, revision, getBlockDraftRecord(blockId).then((record) => { if (!active) return; - if (record !== null && record !== undefined && record.status === 'sync_failed') { + if ( + record !== null && + record !== undefined && + (record.status === 'sync_failed' || record.status === 'pending_remote') + ) { setHasSyncFailedDraft(true); setPendingDraftContent(record.content); } diff --git a/muse-studio/src/features/editor/hooks/useWorks.contract.test.tsx b/muse-studio/src/features/editor/hooks/useWorks.contract.test.tsx new file mode 100644 index 00000000..f4b4d513 --- /dev/null +++ b/muse-studio/src/features/editor/hooks/useWorks.contract.test.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { useChapterDetail } from './useWorks'; + +const wrapper = ({ children }: { children: React.ReactNode }) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, + }); + + return {children}; +}; + +describe('useWorks OpenAPI contract mapping', () => { + it('returns ChapterDetail with canonical blocks instead of chapter-level content fields', async () => { + const { result } = renderHook(() => useChapterDetail('1', '1-1'), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data?.blocks?.[0]?.id).toBe('block-1-1-main'); + expect(result.current.data?.blocks?.[0]?.version).toBe(1); + expect('content' in (result.current.data ?? {})).toBe(false); + expect('revision' in (result.current.data ?? {})).toBe(false); + }); +}); diff --git a/muse-studio/src/features/editor/hooks/useWorks.ts b/muse-studio/src/features/editor/hooks/useWorks.ts index 5f42b197..b34201df 100644 --- a/muse-studio/src/features/editor/hooks/useWorks.ts +++ b/muse-studio/src/features/editor/hooks/useWorks.ts @@ -1,7 +1,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { api } from '@/api/client'; -import type { WorkVO, ChapterVO } from '@/types/openapi'; +import type { WorkVO, ChapterVO, ChapterDetail } from '@/types/openapi'; /** * 获取当前登录用户的所有小说作品列表 @@ -87,7 +87,16 @@ export function useChapterDelete(workId: string) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (chapterId: string) => - api.delete(`/works/${workId}/chapters/${chapterId}`), + api.delete(`/works/${workId}/chapters/${chapterId}`, { + commandId: crypto.randomUUID(), + // P2 UI 暂未暴露章节级 revision,使用 1 作为 mock 和初始联调基线,后续应从章节详情接入真实 revision。 + expectedRevision: 1, + sourceSnapshot: { + sourceType: 'user_original', + sourceId: chapterId, + contentRevision: 1, + }, + }), onSuccess: () => { // 标记该作品对应的章节大纲缓存失效 queryClient.invalidateQueries({ queryKey: ['chapterList', workId] }); @@ -125,9 +134,7 @@ export function useChapterDetail(workId: string, chapterId: string | null) { return useQuery({ queryKey: ['chapterDetail', chapterId], queryFn: () => - api.get<{ id: string; title: string; content: string; revision: number }>( - `/works/${workId}/chapters/${chapterId}` - ), + api.get(`/works/${workId}/chapters/${chapterId}`), enabled: !!workId && !!chapterId, }); } diff --git a/muse-studio/src/features/editor/utils/diff.test.ts b/muse-studio/src/features/editor/utils/diff.test.ts index 61eb645a..e6080130 100644 --- a/muse-studio/src/features/editor/utils/diff.test.ts +++ b/muse-studio/src/features/editor/utils/diff.test.ts @@ -60,5 +60,17 @@ describe('Diff 算法引擎单元测试', () => { expect(duration).toBeLessThan(50); expect(res.length).toBeGreaterThan(0); }); + + it('段落数量过大时应该跳过段落 LCS 矩阵,直接返回整块替换结果', () => { + const oldText = Array.from({ length: 401 }, (_, idx) => `旧段落 ${idx}`).join('\n'); + const newText = Array.from({ length: 401 }, (_, idx) => `新段落 ${idx}`).join('\n'); + + const res = computeDiff(oldText, newText); + + expect(res).toEqual([ + { type: 'removed', value: oldText }, + { type: 'added', value: newText }, + ]); + }); }); }); diff --git a/muse-studio/src/features/editor/utils/diff.ts b/muse-studio/src/features/editor/utils/diff.ts index 1413df56..960b4518 100644 --- a/muse-studio/src/features/editor/utils/diff.ts +++ b/muse-studio/src/features/editor/utils/diff.ts @@ -5,6 +5,9 @@ export interface DiffChunk { value: string; } +const MAX_CHAR_MATRIX_CELLS = 250000; +const MAX_PARAGRAPH_MATRIX_CELLS = 160000; + /** * 经典的最长公共子序列 (LCS) 字符比对算法 (用于小段落,以防止 $O(N \times M)$ 计算爆栈卡死) */ @@ -13,7 +16,7 @@ export function charLCS(oldStr: string, newStr: string): DiffChunk[] { const m = newStr.length; // 如果字符乘积超过 250,000 (如 500 字与 500 字字符串对比),直接输出整块段落替换以防 ANR 卡死 - if (n * m > 250000) { + if (n * m > MAX_CHAR_MATRIX_CELLS) { return [ { type: 'removed', value: oldStr }, { type: 'added', value: newStr } @@ -106,12 +109,24 @@ export function mergeChunks(chunks: DiffChunk[]): DiffChunk[] { * 先进行“段落”粗筛,对内容有局部修改的段落运行小规模字符级比对,彻底杜绝长篇小说带来的计算性能灾难 */ export function computeDiff(oldStr: string, newStr: string): DiffChunk[] { + if (oldStr === newStr) { + return [{ type: 'common', value: oldStr }]; + } + const oldParas = oldStr.split('\n'); const newParas = newStr.split('\n'); const n = oldParas.length; const m = newParas.length; + // 段落级 LCS 同样是 O(N x M),超阈值时直接降级为整文替换,避免超长章节预览卡死。 + if ((n + 1) * (m + 1) > MAX_PARAGRAPH_MATRIX_CELLS) { + return [ + { type: 'removed', value: oldStr }, + { type: 'added', value: newStr }, + ]; + } + // 1. 对段落整体做动态规划比对 const dp: number[][] = Array.from({ length: n + 1 }, () => Array(m + 1).fill(0)); diff --git a/muse-studio/src/features/knowledge/hooks/useKnowledge.contract.test.tsx b/muse-studio/src/features/knowledge/hooks/useKnowledge.contract.test.tsx new file mode 100644 index 00000000..eff45a05 --- /dev/null +++ b/muse-studio/src/features/knowledge/hooks/useKnowledge.contract.test.tsx @@ -0,0 +1,143 @@ +import React from 'react'; +import { http, HttpResponse } from 'msw'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { server } from '@/test/setup'; +import { + useDocumentDelete, + useInstalledKnowledgeUninstall, + useKnowledgeCreate, + useKnowledgeDelete, + useKnowledgeList, +} from './useKnowledge'; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, + }); + + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +} + +describe('useKnowledge OpenAPI command contract', () => { + it('uses source query parameter for normal knowledge base lists', async () => { + const requestedUrls: string[] = []; + + server.use( + http.get('/app-api/muse/knowledge-bases', ({ request }) => { + requestedUrls.push(request.url); + return HttpResponse.json({ + code: 0, + msg: 'success', + data: { + total: 0, + pageNo: 1, + pageSize: 20, + list: [], + }, + }); + }) + ); + + const { result } = renderHook(() => useKnowledgeList('user'), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(requestedUrls[0]).toContain('source=user'); + expect(requestedUrls[0]).not.toContain('scope='); + }); + + it('uses installed-knowledge-bases endpoint for installed list and uninstall command body', async () => { + const requestedUrls: string[] = []; + const deleteBodies: unknown[] = []; + + server.use( + http.get('/app-api/muse/installed-knowledge-bases', ({ request }) => { + requestedUrls.push(request.url); + return HttpResponse.json({ + code: 0, + msg: 'success', + data: { + total: 1, + pageNo: 1, + pageSize: 20, + list: [{ + installId: 'install-1', + kbId: 'kb-installed-1', + name: '中古奇幻种族大百科', + currentVersion: 1, + status: 'installed', + updatedAt: '2026-05-24T00:00:00Z', + }], + }, + }); + }), + http.delete('/app-api/muse/installed-knowledge-bases/:installId', async ({ request }) => { + deleteBodies.push(await request.json()); + return HttpResponse.json({ code: 0, msg: 'success', data: { status: 'deleted' } }); + }) + ); + + const { result: listHook } = renderHook(() => useKnowledgeList('installed'), { wrapper: createWrapper() }); + await waitFor(() => expect(listHook.current.isSuccess).toBe(true)); + expect(requestedUrls).toEqual(['http://localhost:3000/app-api/muse/installed-knowledge-bases?pageNo=1&pageSize=20']); + + const { result: uninstallHook } = renderHook(() => useInstalledKnowledgeUninstall(), { wrapper: createWrapper() }); + uninstallHook.current.mutate('install-1'); + await waitFor(() => expect(uninstallHook.current.isSuccess).toBe(true)); + expect(deleteBodies[0]).toMatchObject({ + commandId: expect.any(String), + impactConfirmed: true, + }); + }); + + it('sends required command fields for create, delete and document delete operations', async () => { + const bodies: Record[] = []; + + server.use( + http.post('/app-api/muse/knowledge-bases', async ({ request }) => { + bodies.push(await request.json() as Record); + return HttpResponse.json({ code: 0, msg: 'success', data: { kbId: 'kb-new', status: 'draft' } }, { status: 201 }); + }), + http.delete('/app-api/muse/knowledge-bases/:kbId', async ({ request }) => { + bodies.push(await request.json() as Record); + return HttpResponse.json({ code: 0, msg: 'success', data: { status: 'deleting' } }); + }), + http.delete('/app-api/muse/knowledge-bases/:kbId/documents/:documentId', async ({ request }) => { + bodies.push(await request.json() as Record); + return HttpResponse.json({ code: 0, msg: 'success', data: { affectedBindings: 0 } }); + }) + ); + + const wrapper = createWrapper(); + const { result: createHook } = renderHook(() => useKnowledgeCreate(), { wrapper }); + createHook.current.mutate({ name: '赛博朋克设定集', description: '设定集合' }); + await waitFor(() => expect(createHook.current.isSuccess).toBe(true)); + + const { result: deleteHook } = renderHook(() => useKnowledgeDelete(), { wrapper }); + deleteHook.current.mutate('kb-new'); + await waitFor(() => expect(deleteHook.current.isSuccess).toBe(true)); + + const { result: deleteDocHook } = renderHook(() => useDocumentDelete('kb-new'), { wrapper }); + deleteDocHook.current.mutate('doc-1'); + await waitFor(() => expect(deleteDocHook.current.isSuccess).toBe(true)); + + expect(bodies[0]).toMatchObject({ name: '赛博朋克设定集', commandId: expect.any(String) }); + expect(bodies[1]).toMatchObject({ + commandId: expect.any(String), + impactConfirmed: true, + retentionConfirmed: true, + }); + expect(bodies[2]).toMatchObject({ + commandId: expect.any(String), + impactConfirmed: true, + }); + }); +}); diff --git a/muse-studio/src/features/knowledge/hooks/useKnowledge.ts b/muse-studio/src/features/knowledge/hooks/useKnowledge.ts index b0eeb231..7e947f45 100644 --- a/muse-studio/src/features/knowledge/hooks/useKnowledge.ts +++ b/muse-studio/src/features/knowledge/hooks/useKnowledge.ts @@ -1,8 +1,54 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from '@/api/client'; +type KnowledgeScope = 'user' | 'installed' | 'global'; + +interface PaginatedResult { + list?: T[]; + total?: number; + pageNo?: number; + pageSize?: number; +} + +interface RawKnowledgeBaseSummary { + kbId?: string; + name?: string; + description?: string; + category?: string; + type?: string; + source?: string; + currentVersion?: number; + processingStatus?: string; + status?: string; + documentCount?: number; + bindableWorkCount?: number; + bindingCount?: number; + createdAt?: string; + updatedAt?: string; +} + +interface RawInstalledKnowledgeBaseSummary { + installId?: string; + kbId?: string; + name?: string; + publisher?: string; + currentVersion?: number; + status?: string; + bindableWorkCount?: number; + updatedAt?: string; +} + +type KnowledgeListResult = { + list: KnowledgeBaseVO[]; + total: number; + pageNo: number; + pageSize: number; +}; + /** 知识库信息实体契约接口 */ export interface KnowledgeBaseVO { + /** 用户本地安装记录 ID,仅安装型知识库存在 */ + installId?: string; /** 知识库唯一 UUID */ kbId: string; /** 知识库名称 */ @@ -27,6 +73,83 @@ export interface KnowledgeBaseVO { updatedAt: string; } +/** 为写命令生成幂等键,测试环境缺少 crypto.randomUUID 时使用可追踪降级值。 */ +function createCommandId(): string { + return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `cmd-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +/** 将 OpenAPI 的处理/安装状态映射到页面现有的四态展示模型。 */ +function normalizeKnowledgeStatus(status: string | undefined): KnowledgeBaseVO['status'] { + if (status === 'disabled' || status === 'authorization_expired' || status === 'delisted') { + return 'disabled'; + } + if (status === 'failed' || status === 'partial_failed') { + return 'failed'; + } + if (status === 'draft' || status === 'processing' || status === 'deleting') { + return 'processing'; + } + return 'active'; +} + +/** 将 OpenAPI 的 source 字段收敛成用户端三页签模型。 */ +function normalizeKnowledgeSource(source: string | undefined): KnowledgeBaseVO['source'] { + if (source === 'installed') return 'installed'; + if (source === 'global' || source === 'account_available') return 'global'; + return 'user'; +} + +/** 将知识库列表/详情原始响应归一成页面消费的稳定 VO。 */ +function normalizeKnowledgeBase(item: RawKnowledgeBaseSummary): KnowledgeBaseVO { + const now = new Date().toISOString(); + return { + kbId: item.kbId ?? '', + name: item.name ?? '未命名知识库', + description: item.description ?? '', + category: item.category ?? item.type ?? '默认', + currentVersion: item.currentVersion ?? 1, + status: normalizeKnowledgeStatus(item.processingStatus ?? item.status), + source: normalizeKnowledgeSource(item.source), + documentCount: item.documentCount ?? 0, + bindingCount: item.bindableWorkCount ?? item.bindingCount ?? 0, + createdAt: item.createdAt ?? item.updatedAt ?? now, + updatedAt: item.updatedAt ?? item.createdAt ?? now, + }; +} + +/** 将安装列表原始响应归一成页面消费的知识库卡片 VO。 */ +function normalizeInstalledKnowledgeBase(item: RawInstalledKnowledgeBaseSummary): KnowledgeBaseVO { + const now = new Date().toISOString(); + return { + installId: item.installId ?? item.kbId ?? '', + kbId: item.kbId ?? item.installId ?? '', + name: item.name ?? '未命名安装知识库', + description: item.publisher ? `发布方:${item.publisher}` : '', + category: '市场安装', + currentVersion: item.currentVersion ?? 1, + status: normalizeKnowledgeStatus(item.status), + source: 'installed', + documentCount: 0, + bindingCount: item.bindableWorkCount ?? 0, + createdAt: item.updatedAt ?? now, + updatedAt: item.updatedAt ?? now, + }; +} + +function normalizePage( + page: PaginatedResult, + mapper: (item: T) => KnowledgeBaseVO +): KnowledgeListResult { + return { + list: (page.list ?? []).map(mapper), + total: page.total ?? page.list?.length ?? 0, + pageNo: page.pageNo ?? 1, + pageSize: page.pageSize ?? 20, + }; +} + /** 资料文档信息实体契约接口 */ export interface DocumentVO { /** 文档资料唯一 UUID */ @@ -76,11 +199,24 @@ export interface KBProcessingTaskVO { * @param scope 筛选来源:user(自建) | installed(已安装) | global(公开) * @returns React Query 列表查询状态 */ -export function useKnowledgeList(scope: 'user' | 'installed' | 'global' | null) { - return useQuery<{ list: unknown[]; total: number }, Error>({ +export function useKnowledgeList(scope: KnowledgeScope | null) { + return useQuery({ queryKey: ['knowledgeBases', scope], - queryFn: () => - api.get<{ list: unknown[]; total: number }>(`/knowledge-bases?scope=${scope || ''}`), + queryFn: async () => { + if (scope === 'installed') { + const page = await api.get>( + '/installed-knowledge-bases?pageNo=1&pageSize=20' + ); + return normalizePage(page, normalizeInstalledKnowledgeBase); + } + + const query = new URLSearchParams({ pageNo: '1', pageSize: '20' }); + if (scope) query.set('source', scope); + const page = await api.get>( + `/knowledge-bases?${query.toString()}` + ); + return normalizePage(page, normalizeKnowledgeBase); + }, staleTime: 30000, // 30s 缓存,返回时自动静默刷新 }); } @@ -96,7 +232,9 @@ export function useKnowledgeList(scope: 'user' | 'installed' | 'global' | null) export function useKnowledgeDetail(kbId: string) { return useQuery({ queryKey: ['knowledgeDetail', kbId], - queryFn: () => api.get(`/knowledge-bases/${kbId}`), + queryFn: async () => normalizeKnowledgeBase( + await api.get(`/knowledge-bases/${kbId}`) + ), enabled: !!kbId, staleTime: 0, }); @@ -111,8 +249,30 @@ export function useKnowledgeDetail(kbId: string) { export function useKnowledgeCreate() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (dto: { name: string; description?: string; category?: string }) => - api.post('/knowledge-bases', dto), + mutationFn: async (dto: { name: string; description?: string; category?: string; templateId?: string }) => { + const payload: { name: string; description?: string; templateId?: string; commandId: string } = { + name: dto.name, + commandId: createCommandId(), + }; + if (dto.description !== undefined) payload.description = dto.description; + if (dto.templateId !== undefined) payload.templateId = dto.templateId; + + const result = await api.post<{ kbId?: string; status?: string }>('/knowledge-bases', payload); + const now = new Date().toISOString(); + return { + kbId: result.kbId ?? '', + name: dto.name, + description: dto.description ?? '', + category: dto.category ?? '默认', + currentVersion: 1, + status: normalizeKnowledgeStatus(result.status), + source: 'user', + documentCount: 0, + bindingCount: 0, + createdAt: now, + updatedAt: now, + } satisfies KnowledgeBaseVO; + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['knowledgeBases'] }); }, @@ -129,7 +289,11 @@ export function useKnowledgeCreate() { export function useKnowledgeDelete() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (kbId: string) => api.delete(`/knowledge-bases/${kbId}`), + mutationFn: (kbId: string) => api.delete(`/knowledge-bases/${kbId}`, { + commandId: createCommandId(), + impactConfirmed: true, + retentionConfirmed: true, + }), onSuccess: (_, kbId) => { queryClient.invalidateQueries({ queryKey: ['knowledgeBases'] }); queryClient.removeQueries({ queryKey: ['knowledgeDetail', kbId] }); @@ -148,7 +312,10 @@ export function useKnowledgeDelete() { export function useInstalledKnowledgeUninstall() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (installId: string) => api.delete(`/installed-knowledge-bases/${installId}`), + mutationFn: (installId: string) => api.delete(`/installed-knowledge-bases/${installId}`, { + commandId: createCommandId(), + impactConfirmed: true, + }), onSuccess: (_, installId) => { queryClient.invalidateQueries({ queryKey: ['knowledgeBases'] }); queryClient.removeQueries({ queryKey: ['knowledgeDetail', installId] }); @@ -209,6 +376,7 @@ export function useDocumentUpload(kbId: string) { queryClient.invalidateQueries({ queryKey: ['kbDocuments', kbId] }); // 触发知识库详情失效以同步更新 documentCount 统计 queryClient.invalidateQueries({ queryKey: ['knowledgeDetail', kbId] }); + queryClient.invalidateQueries({ queryKey: ['knowledgeBases'] }); }, }); } @@ -224,10 +392,14 @@ export function useDocumentDelete(kbId: string) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (documentId: string) => - api.delete(`/knowledge-bases/${kbId}/documents/${documentId}`), + api.delete(`/knowledge-bases/${kbId}/documents/${documentId}`, { + commandId: createCommandId(), + impactConfirmed: true, + }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['kbDocuments', kbId] }); queryClient.invalidateQueries({ queryKey: ['knowledgeDetail', kbId] }); + queryClient.invalidateQueries({ queryKey: ['knowledgeBases'] }); }, }); } diff --git a/muse-studio/src/lib/indexed-db.ts b/muse-studio/src/lib/indexed-db.ts index 48de7bac..d1e0b936 100644 --- a/muse-studio/src/lib/indexed-db.ts +++ b/muse-studio/src/lib/indexed-db.ts @@ -2,6 +2,8 @@ const DB_NAME = 'muse-studio'; const BLOCK_STORE = 'block-drafts'; const DB_VERSION = 1; +export type DraftSyncStatus = 'pending_remote' | 'synced' | 'sync_failed'; + /** * 打开 IndexedDB 数据库连接 */ @@ -26,12 +28,12 @@ function openDB(): Promise { * 缓存小节草稿至本地数据库 * @param blockId 创作场景小节的唯一 ID * @param content 章节内容的 JSON 描述数据 - * @param status 同步状态标志:'synced' 表示已同步,'sync_failed' 表示同步失败待恢复 + * @param status 同步状态标志:pending_remote 表示正在远端同步,sync_failed 表示同步失败待恢复 */ export async function saveBlockDraft( blockId: string, content: Record, - status: 'synced' | 'sync_failed' = 'synced' + status: DraftSyncStatus = 'pending_remote' ): Promise { const db = await openDB(); const tx = db.transaction(BLOCK_STORE, 'readwrite'); @@ -76,7 +78,7 @@ export async function getBlockDraftRecord( blockId: string; content: Record; savedAt: number; - status?: 'synced' | 'sync_failed'; + status?: DraftSyncStatus; } | null> { const db = await openDB(); const tx = db.transaction(BLOCK_STORE, 'readonly'); diff --git a/muse-studio/src/lib/sse.ts b/muse-studio/src/lib/sse.ts index d5b61b6b..4f03500a 100644 --- a/muse-studio/src/lib/sse.ts +++ b/muse-studio/src/lib/sse.ts @@ -12,6 +12,59 @@ export interface SSEEventHandler { onError?: (data: { code: string; message: string }) => void; } +/** 解析单行 SSE data 事件,并派发到应用层处理器。 */ +function dispatchSSELine(line: string, handlers: SSEEventHandler): void { + const trimmed = line.trim(); + if (!trimmed.startsWith('data: ')) return; + + try { + const rawData = trimmed.slice(6); + const data = JSON.parse(rawData); + + // 触发相应的应用层回调 + switch (data.type) { + case 'chunk': + handlers.onChunk?.(data); + break; + case 'quality_check': + handlers.onQualityCheck?.(data); + break; + case 'done': + handlers.onDone?.(data); + break; + case 'error': + handlers.onError?.(data); + break; + } + } catch (parseErr) { + handlers.onError?.({ + code: 'SSE_PARSE_ERROR', + message: parseErr instanceof Error ? parseErr.message : 'JSON 解析失败', + }); + } +} + +/** 按 SSE 换行协议解析文本块,返回尚未闭合的半行缓冲。 */ +function dispatchSSEText(text: string, handlers: SSEEventHandler, previousBuffer = ''): string { + const lines = `${previousBuffer}${text}`.split('\n'); + const nextBuffer = lines.pop() || ''; + + for (const line of lines) { + dispatchSSELine(line, handlers); + } + + return nextBuffer; +} + +/** 将浏览器内相对路径补齐为绝对 URL,兼容 Node/Vitest fetch 对相对 URL 的限制。 */ +function normalizeStreamUrl(url: string): string { + try { + return new URL(url, window.location.origin).toString(); + } catch { + return url; + } +} + /** * 建立 AI 生成专用流式通道连接 (独立连接,单次生命周期,支持中止控制器) * @param url 请求的目标流地址 @@ -24,7 +77,7 @@ export function connectAIStream( ): AbortController { const controller = new AbortController(); - fetch(url, { + fetch(normalizeStreamUrl(url), { headers: { 'Accept': 'text/event-stream', 'X-API-Version': '1', @@ -38,7 +91,9 @@ export function connectAIStream( const reader = response.body?.getReader(); if (!reader) { - throw new Error('未获取到流读取器'); + // 部分测试环境或 polyfill 不暴露 reader,但仍能一次性读取 text。 + dispatchSSEText(await response.text(), handlers); + return; } const decoder = new TextDecoder(); @@ -49,43 +104,12 @@ export function connectAIStream( if (done) break; // 解码获取的二进制包并存入缓冲区 - buffer += decoder.decode(value, { stream: true }); + buffer = dispatchSSEText(decoder.decode(value, { stream: true }), handlers, buffer); + } - // 按 SSE 事件边界协议拆分行 - const lines = buffer.split('\n'); - // 将未完结的半截行存回缓冲区,下次包拼接处理 - buffer = lines.pop() || ''; - - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed.startsWith('data: ')) { - try { - const rawData = trimmed.slice(6); - const data = JSON.parse(rawData); - - // 触发相应的应用层回调 - switch (data.type) { - case 'chunk': - handlers.onChunk?.(data); - break; - case 'quality_check': - handlers.onQualityCheck?.(data); - break; - case 'done': - handlers.onDone?.(data); - break; - case 'error': - handlers.onError?.(data); - break; - } - } catch (parseErr) { - handlers.onError?.({ - code: 'SSE_PARSE_ERROR', - message: parseErr instanceof Error ? parseErr.message : 'JSON 解析失败', - }); - } - } - } + const tail = decoder.decode(); + if (tail || buffer) { + dispatchSSEText(tail, handlers, buffer); } }) .catch((err) => { diff --git a/muse-studio/src/pages/EditorPage.tsx b/muse-studio/src/pages/EditorPage.tsx index fbd194ad..ef5d09e8 100644 --- a/muse-studio/src/pages/EditorPage.tsx +++ b/muse-studio/src/pages/EditorPage.tsx @@ -112,6 +112,7 @@ export default function EditorPage() {
{ diff --git a/muse-studio/src/pages/KnowledgePage.tsx b/muse-studio/src/pages/KnowledgePage.tsx index 44ac1100..1d8b0c95 100644 --- a/muse-studio/src/pages/KnowledgePage.tsx +++ b/muse-studio/src/pages/KnowledgePage.tsx @@ -201,7 +201,7 @@ export default function KnowledgePage() { ) : kb.source === 'installed' ? ( // 已安装显示卸载按钮