fix(studio): 对齐 P2 用户端工作台契约
修复 P2 review 发现的编辑器 Block 保存、AI task stream、知识库命令写入、草稿同步状态和 diff 性能边界问题。补充契约测试、Vitest coverage 和 Playwright e2e 冒烟入口。
This commit is contained in:
parent
e3e330317f
commit
79c0633c3a
3
muse-studio/.gitignore
vendored
3
muse-studio/.gitignore
vendored
@ -10,6 +10,9 @@ lerna-debug.log*
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
test-results
|
||||
playwright-report
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
8
muse-studio/e2e/workspace.spec.ts
Normal file
8
muse-studio/e2e/workspace.spec.ts
Normal 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();
|
||||
});
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
24
muse-studio/playwright.config.ts
Normal file
24
muse-studio/playwright.config.ts
Normal 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'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
119
muse-studio/pnpm-lock.yaml
generated
119
muse-studio/pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
41
muse-studio/src/api/client.test.ts
Normal file
41
muse-studio/src/api/client.test.ts
Normal 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,
|
||||
}));
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@ -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 渲染的轻微延时效果
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
|
||||
@ -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']);
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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));
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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'];
|
||||
|
||||
@ -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/**'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user