fix(studio): 对齐 P2 用户端工作台契约

修复 P2 review 发现的编辑器 Block 保存、AI task stream、知识库命令写入、草稿同步状态和 diff 性能边界问题。补充契约测试、Vitest coverage 和 Playwright e2e 冒烟入口。
This commit is contained in:
zizi 2026-05-26 07:50:07 +08:00
parent e3e330317f
commit 79c0633c3a
26 changed files with 1120 additions and 179 deletions

View File

@ -10,6 +10,9 @@ lerna-debug.log*
node_modules
dist
dist-ssr
coverage
test-results
playwright-report
*.local
# Editor directories and files

View File

@ -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();
});

View File

@ -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"
]
}
}
}

View File

@ -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'] },
},
],
});

View File

@ -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

View File

@ -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<void>('/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<boolean>('/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,
}));
});
});

View File

@ -60,11 +60,11 @@ export async function request<T>(
}
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<T>(
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: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
delete: <T>(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<T>(path, options);
},
};

View File

@ -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<string, SlotPrecheckRecord>();
const mockAiTasks = new Map<string, { prompt: string }>();
/** 重置 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 渲染的轻微延时效果

View File

@ -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,
},
});
}),

View File

@ -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,
},
});
}),

View File

@ -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']);
});
});

View File

@ -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<AbortController | null>(null);
const abortControllerRef = React.useRef<AbortController | null>(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<CreateAiTaskResult>('/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();

View File

@ -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<ReturnType<typeof useEditor>>;
@ -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<Record<string, unknown> | null>(null);
useEffect(() => {
revisionRef.current = revision;
}, [blockId, revision]);
// 2. 本地 IndexedDB 去抖保存 (500ms 降低高频打字时密集的磁盘事务开销)
const debouncedIndexedDBSave = useMemo(
() => debounce(async (id: string, json: Record<string, unknown>, status: 'synced' | 'sync_failed' = 'synced') => {
const saveLocalDraft = useCallback(
async (id: string, json: Record<string, unknown>, 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<string, unknown>) => {
const saveRemoteDraft = useCallback(
async (id: string, json: Record<string, unknown>) => {
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<SaveBlockResult>(`/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);
}

View File

@ -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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
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);
});
});

View File

@ -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<boolean>(`/works/${workId}/chapters/${chapterId}`),
api.delete<void>(`/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<ChapterDetail>(`/works/${workId}/chapters/${chapterId}`),
enabled: !!workId && !!chapterId,
});
}

View File

@ -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 },
]);
});
});
});

View File

@ -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));

View File

@ -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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
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<string, unknown>[] = [];
server.use(
http.post('/app-api/muse/knowledge-bases', async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>);
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<string, unknown>);
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<string, unknown>);
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,
});
});
});

View File

@ -1,8 +1,54 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/api/client';
type KnowledgeScope = 'user' | 'installed' | 'global';
interface PaginatedResult<T> {
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<T>(
page: PaginatedResult<T>,
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<KnowledgeListResult, Error>({
queryKey: ['knowledgeBases', scope],
queryFn: () =>
api.get<{ list: unknown[]; total: number }>(`/knowledge-bases?scope=${scope || ''}`),
queryFn: async () => {
if (scope === 'installed') {
const page = await api.get<PaginatedResult<RawInstalledKnowledgeBaseSummary>>(
'/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<PaginatedResult<RawKnowledgeBaseSummary>>(
`/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<KnowledgeBaseVO, Error>({
queryKey: ['knowledgeDetail', kbId],
queryFn: () => api.get<KnowledgeBaseVO>(`/knowledge-bases/${kbId}`),
queryFn: async () => normalizeKnowledgeBase(
await api.get<RawKnowledgeBaseSummary>(`/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<KnowledgeBaseVO>('/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<boolean>(`/knowledge-bases/${kbId}`),
mutationFn: (kbId: string) => api.delete<unknown>(`/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<boolean>(`/installed-knowledge-bases/${installId}`),
mutationFn: (installId: string) => api.delete<unknown>(`/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<boolean>(`/knowledge-bases/${kbId}/documents/${documentId}`),
api.delete<unknown>(`/knowledge-bases/${kbId}/documents/${documentId}`, {
commandId: createCommandId(),
impactConfirmed: true,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['kbDocuments', kbId] });
queryClient.invalidateQueries({ queryKey: ['knowledgeDetail', kbId] });
queryClient.invalidateQueries({ queryKey: ['knowledgeBases'] });
},
});
}

View File

@ -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<IDBDatabase> {
* 缓存小节草稿至本地数据库
* @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<string, unknown>,
status: 'synced' | 'sync_failed' = 'synced'
status: DraftSyncStatus = 'pending_remote'
): Promise<void> {
const db = await openDB();
const tx = db.transaction(BLOCK_STORE, 'readwrite');
@ -76,7 +78,7 @@ export async function getBlockDraftRecord(
blockId: string;
content: Record<string, unknown>;
savedAt: number;
status?: 'synced' | 'sync_failed';
status?: DraftSyncStatus;
} | null> {
const db = await openDB();
const tx = db.transaction(BLOCK_STORE, 'readonly');

View File

@ -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) => {

View File

@ -112,6 +112,7 @@ export default function EditorPage() {
<div className="flex-1 overflow-hidden">
<MuseEditor
blockId={`b-${selectedChapterId}`}
workId="demo-work"
revision={1}
initialContent={null}
onEditorReady={(editor) => {

View File

@ -201,7 +201,7 @@ export default function KnowledgePage() {
) : kb.source === 'installed' ? (
// 已安装显示卸载按钮
<button
onClick={(e) => handleUninstallKB(e, kb.kbId, kb.name)}
onClick={(e) => handleUninstallKB(e, kb.installId ?? kb.kbId, kb.name)}
type="button"
title="卸载知识库"
className="opacity-0 group-hover:opacity-100 text-slate-400 hover:text-rose-600 p-1.5 hover:bg-rose-50 rounded-lg transition-all duration-200"

View File

@ -33,6 +33,16 @@ export default function WorkspacePage() {
workId || '',
activeChapterId
);
const activeBlock = chapterDetail?.blocks?.[0];
let initialBlockContent: Record<string, unknown> | null = null;
if (activeBlock?.content) {
try {
initialBlockContent = JSON.parse(activeBlock.content) as Record<string, unknown>;
} catch {
initialBlockContent = null;
}
}
// 4. 首屏刷新与空值防死锁激活逻辑
useEffect(() => {
@ -113,16 +123,14 @@ export default function WorkspacePage() {
<div className="h-6 bg-slate-50/80 rounded-xl w-5/6" />
<div className="h-6 bg-slate-50/80 rounded-xl w-4/5" />
</div>
) : chapterDetail ? (
) : chapterDetail && activeBlock ? (
// 强制绑定 key={activeChapterId}!在切换章节时强制进行 React 销毁卸载重构,完美断绝 ProseMirror 的状态污染残留
<MuseEditor
key={activeChapterId}
blockId={activeChapterId}
key={activeBlock.id}
blockId={activeBlock.id}
workId={workId}
initialContent={
chapterDetail.content ? JSON.parse(chapterDetail.content) : null
}
revision={chapterDetail.revision}
initialContent={initialBlockContent}
revision={activeBlock.version}
/>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-slate-400 bg-white">

View File

@ -1,8 +1,10 @@
// src/types/openapi.ts
import type { components } from './base';
import type { paths as ContentPaths } from './content';
import type { components as ContentComponents } from './content';
import type { components as AIComponents } from './ai';
import type { paths as AIPaths } from './ai';
import type { components as MarketComponents } from './market';
import type { components as AccountComponents } from './account';
@ -16,7 +18,17 @@ export type * as Account from './account';
export type * as Meta from './meta';
export type WorkVO = ContentComponents['schemas']['Work'];
export type ChapterVO = ContentComponents['schemas']['Chapter'];
export type ChapterDetail = ContentComponents['schemas']['ChapterDetail'];
export type BlockVO = ContentComponents['schemas']['Block'];
export type SaveBlockRequest = ContentComponents['schemas']['SaveBlockRequest'];
export type SaveBlockResult = NonNullable<
ContentPaths['/app-api/muse/works/{workId}/blocks/{blockId}']['put']['responses'][200]['content']['application/json']['data']
>;
export type AgentSummary = AIComponents['schemas']['AgentSummary'];
export type CreateAiTaskRequest = AIComponents['schemas']['CreateAiTaskRequest'];
export type CreateAiTaskResult = NonNullable<
AIPaths['/app-api/muse/ai/tasks']['post']['responses'][202]['content']['application/json']['data']
>;
export type AgentSlotSummary = AIComponents['schemas']['AgentSlotSummary'];
export type AgentTestRequest = AIComponents['schemas']['AgentTestRequest'];
export type AgentTestResult = AIComponents['schemas']['AgentTestResult'];

View File

@ -25,5 +25,12 @@ export default defineConfig({
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
css: true,
include: ['src/**/*.test.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/**/*.test.{ts,tsx}', 'src/types/**', 'src/test/**'],
},
},
})