@musnows/scriverse 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.en.md +13 -4
  2. package/README.md +13 -4
  3. package/dist/ai.js +3374 -0
  4. package/dist/ai.js.map +1 -0
  5. package/dist/app.js +1046 -0
  6. package/dist/app.js.map +1 -0
  7. package/dist/cli-core.js +147 -20
  8. package/dist/cli-core.js.map +1 -1
  9. package/dist/credential-vault.js +40 -0
  10. package/dist/credential-vault.js.map +1 -0
  11. package/dist/database.js +1122 -0
  12. package/dist/database.js.map +1 -0
  13. package/dist/docx-security.js +184 -0
  14. package/dist/docx-security.js.map +1 -0
  15. package/dist/domain.js +21 -0
  16. package/dist/domain.js.map +1 -0
  17. package/dist/errors.js +16 -0
  18. package/dist/errors.js.map +1 -0
  19. package/dist/image-captcha.js +173 -0
  20. package/dist/image-captcha.js.map +1 -0
  21. package/dist/image-metadata.js +128 -0
  22. package/dist/image-metadata.js.map +1 -0
  23. package/dist/import-security.js +46 -0
  24. package/dist/import-security.js.map +1 -0
  25. package/dist/parser.js +223 -0
  26. package/dist/parser.js.map +1 -0
  27. package/dist/public/ai-context-meter.js +10 -0
  28. package/dist/public/ai-conversation.js +3 -0
  29. package/dist/public/ai-mentions.js +55 -0
  30. package/dist/public/ai-message-actions.js +27 -0
  31. package/dist/public/ai-message-meta.js +15 -0
  32. package/dist/public/ai-message-time.js +23 -0
  33. package/dist/public/ai-prompt-keyboard.js +3 -0
  34. package/dist/public/app.js +4255 -0
  35. package/dist/public/character-profile.d.ts +9 -0
  36. package/dist/public/character-profile.js +65 -0
  37. package/dist/public/character-version.d.ts +2 -0
  38. package/dist/public/character-version.js +31 -0
  39. package/dist/public/entity-version.js +34 -0
  40. package/dist/public/icon.svg +10 -0
  41. package/dist/public/index.html +547 -0
  42. package/dist/public/line-number-layout.js +15 -0
  43. package/dist/public/markdown.js +199 -0
  44. package/dist/public/model-config.d.ts +17 -0
  45. package/dist/public/model-config.js +57 -0
  46. package/dist/public/page-route.d.ts +11 -0
  47. package/dist/public/page-route.js +81 -0
  48. package/dist/public/relationship-graph.js +2017 -0
  49. package/dist/public/site.webmanifest +17 -0
  50. package/dist/public/styles.css +1399 -0
  51. package/dist/public/text-formatting.d.ts +2 -0
  52. package/dist/public/text-formatting.js +20 -0
  53. package/dist/public/theme-init.js +8 -0
  54. package/dist/public/theme.js +13 -0
  55. package/dist/public/whitespace-visualization.js +20 -0
  56. package/dist/request-context.js +9 -0
  57. package/dist/request-context.js.map +1 -0
  58. package/dist/security.js +235 -0
  59. package/dist/security.js.map +1 -0
  60. package/dist/server-runtime.js +77 -0
  61. package/dist/server-runtime.js.map +1 -0
  62. package/dist/server.js +15 -0
  63. package/dist/server.js.map +1 -0
  64. package/dist/store.js +2492 -0
  65. package/dist/store.js.map +1 -0
  66. package/dist/user-auth.js +489 -0
  67. package/dist/user-auth.js.map +1 -0
  68. package/dist/utils.js +50 -0
  69. package/dist/utils.js.map +1 -0
  70. package/package.json +4 -9
package/dist/app.js ADDED
@@ -0,0 +1,1046 @@
1
+ import express from "express";
2
+ import multer from "multer";
3
+ import mammoth from "mammoth";
4
+ import { extname, join } from "node:path";
5
+ import { z, ZodError } from "zod";
6
+ import { AiManager } from "./ai.js";
7
+ import { CredentialVault } from "./credential-vault.js";
8
+ import { Database } from "./database.js";
9
+ import { assertSafeDocxArchive } from "./docx-security.js";
10
+ import { TASK_TYPES } from "./domain.js";
11
+ import { AppError } from "./errors.js";
12
+ import { applyImportFileHints, parseNovelText } from "./parser.js";
13
+ import { Store, versionedEntityTypes } from "./store.js";
14
+ import { normalizeUploadFileName } from "./utils.js";
15
+ import { assertSafeAiEndpoint, createApiRateLimitMiddleware, createAuthenticationRateLimitMiddleware, createBasicAuthMiddleware, createSameOriginMiddleware, createSecurityHeadersMiddleware } from "./security.js";
16
+ import { ImageCaptchaService } from "./image-captcha.js";
17
+ import { assertSafeImportedPlainText, decodeUtf8ImportedText } from "./import-security.js";
18
+ import { InvalidRasterImageError, readRasterImageMetadata } from "./image-metadata.js";
19
+ import { runWithRequestActor } from "./request-context.js";
20
+ import { clearSessionCookie, createCliApiScopeMiddleware, createUserSessionMiddleware, createWorkAuthorizationMiddleware, setSessionCookie, UserAuthService } from "./user-auth.js";
21
+ const nonEmpty = z.string().trim().min(1);
22
+ const identifier = z.string().trim().min(1).max(200);
23
+ const optionalStrings = z.array(z.string()).optional();
24
+ const jsonObject = z.record(z.string(), z.unknown());
25
+ const chapterTypeSchema = z.enum(["正文", "设定", "作者的话", "其他"]);
26
+ const versionedEntityTypeSchema = z.enum(versionedEntityTypes);
27
+ const maximumImportedTextLength = 20_000_000;
28
+ const captchaFields = {
29
+ captchaId: z.string().trim().min(1).max(200),
30
+ captchaAnswer: z.string().trim().min(1).max(16)
31
+ };
32
+ const usernameSchema = z.string().trim().min(3).max(40).regex(/^[\p{L}\p{N}_.-]+$/u, "用户名只能包含文字、数字、点、下划线和短横线");
33
+ const passwordSchema = z.string().min(10).max(200);
34
+ const registrationSchema = z.object({
35
+ username: usernameSchema,
36
+ password: passwordSchema,
37
+ passwordConfirmation: passwordSchema,
38
+ ...captchaFields
39
+ }).strict().refine((input) => input.password === input.passwordConfirmation, {
40
+ path: ["passwordConfirmation"],
41
+ message: "两次输入的密码不一致"
42
+ });
43
+ const loginSchema = z.object({
44
+ username: z.string().trim().min(1).max(100),
45
+ password: z.string().max(200),
46
+ ...captchaFields
47
+ }).strict();
48
+ const userUpdateSchema = z.object({ role: z.enum(["admin", "user"]).optional(), status: z.enum(["active", "disabled"]).optional() }).strict();
49
+ const memberSchema = z.object({ userId: identifier }).strict();
50
+ const profileSchema = z.object({ displayName: z.string().trim().min(1).max(80) }).strict();
51
+ const passwordChangeSchema = z.object({ currentPassword: z.string().max(200), newPassword: passwordSchema }).strict();
52
+ const changeNoteSchema = z.string().trim().max(500).optional();
53
+ function validateImportedText(text) {
54
+ if (text.length > maximumImportedTextLength)
55
+ throw new AppError(413, "IMPORT_TEXT_TOO_LARGE", "导入文件解压后的文本超过 2000 万字符限制");
56
+ assertSafeImportedPlainText(text);
57
+ return text;
58
+ }
59
+ async function extractDocxText(buffer) {
60
+ assertSafeDocxArchive(buffer);
61
+ try {
62
+ return (await mammoth.extractRawText({ buffer })).value;
63
+ }
64
+ catch {
65
+ throw new AppError(415, "INVALID_DOCX_FILE", "文件内容不是有效的 DOCX 文档");
66
+ }
67
+ }
68
+ const workSchema = z.object({
69
+ title: nonEmpty.max(200),
70
+ author: z.string().max(200).optional(),
71
+ description: z.string().max(10_000).optional(),
72
+ language: z.string().max(30).optional(),
73
+ coverUrl: z.string().url().nullable().optional(),
74
+ tags: optionalStrings
75
+ });
76
+ const settingSchema = z.object({
77
+ title: nonEmpty.max(200),
78
+ category: nonEmpty.max(100),
79
+ content: nonEmpty.max(200_000),
80
+ tags: optionalStrings,
81
+ status: z.enum(["draft", "pending", "confirmed", "deprecated"]).optional(),
82
+ locked: z.boolean().optional(),
83
+ evidence: z.array(z.unknown()).optional(),
84
+ scope: jsonObject.optional(),
85
+ authorNote: z.string().max(20_000).optional()
86
+ });
87
+ const characterSchema = z.object({
88
+ name: nonEmpty.max(200),
89
+ aliases: z.array(z.string().trim().min(1).max(200)).max(100).optional(),
90
+ raceId: identifier.nullable().optional(),
91
+ organizationIds: z.array(identifier).max(100).optional(),
92
+ attributes: jsonObject.optional(),
93
+ profile: jsonObject.optional(),
94
+ currentState: jsonObject.optional(),
95
+ lockedFields: optionalStrings,
96
+ visibility: z.enum(["public", "author", "collaborators"]).optional(),
97
+ firstChapterId: identifier.nullable().optional()
98
+ }).strict();
99
+ const characterUpdateSchema = characterSchema.partial().extend({
100
+ changeNote: z.string().trim().max(500).optional()
101
+ });
102
+ const timelineSchema = z.object({
103
+ name: nonEmpty.max(300),
104
+ trackId: identifier.nullable().optional(),
105
+ description: z.string().max(100_000).optional(),
106
+ eventType: z.string().max(100).optional(),
107
+ timeLabel: z.string().max(300).optional(),
108
+ timeSort: z.number().finite().nullable().optional(),
109
+ chapterIds: optionalStrings,
110
+ participantIds: optionalStrings,
111
+ location: z.string().max(500).optional(),
112
+ causes: optionalStrings,
113
+ impactScope: z.enum(["personal", "organization", "regional", "world", "galaxy"]).optional(),
114
+ evidence: z.array(z.unknown()).optional(),
115
+ status: z.enum(["candidate", "pending", "confirmed", "deprecated"]).optional()
116
+ });
117
+ const timelineTrackSchema = z.object({
118
+ name: nonEmpty.max(200),
119
+ description: z.string().max(20_000).optional(),
120
+ sortOrder: z.number().int().min(0).optional()
121
+ });
122
+ const aiCitationSchema = z.object({
123
+ chapterId: identifier,
124
+ chapterTitle: nonEmpty.max(300),
125
+ startLine: z.number().int().positive(),
126
+ endLine: z.number().int().positive(),
127
+ text: z.string().max(20_000)
128
+ }).refine((citation) => citation.endLine >= citation.startLine, "引用结束行不能早于开始行");
129
+ const aiCitationsSchema = z.array(aiCitationSchema).max(20).refine((citations) => citations.reduce((total, citation) => total + citation.text.length, 0) <= 100_000, "引用正文总长度不能超过 100000 字符");
130
+ function instructionWithCitations(instruction, citations) {
131
+ if (!citations.length)
132
+ return instruction;
133
+ const references = citations.map((citation) => {
134
+ const lines = citation.startLine === citation.endLine ? `L${citation.startLine}` : `L${citation.startLine}-L${citation.endLine}`;
135
+ return `[${citation.chapterTitle} ${lines}]\n${citation.text}`;
136
+ }).join("\n\n");
137
+ return `${instruction}\n\n作者显式添加了以下正文引用。请优先依据这些引用回答,并在引用相关结论中注明章节与行号:\n\n${references}`;
138
+ }
139
+ const relationshipSchema = z.object({
140
+ fromCharacterId: identifier,
141
+ toCharacterId: identifier,
142
+ category: z.enum(["family", "social", "emotional", "conflict", "uncertain"]),
143
+ subtype: z.string().max(100).optional(),
144
+ keywords: z.array(z.string().trim().min(1).max(100)).max(30).optional(),
145
+ directed: z.boolean().optional(),
146
+ currentStatus: z.string().max(100).optional(),
147
+ timeRange: jsonObject.optional(),
148
+ confidence: z.number().min(0).max(1).optional(),
149
+ evidence: z.array(z.unknown()).optional(),
150
+ confirmationStatus: z.enum(["pending", "confirmed", "rejected"]).optional(),
151
+ locked: z.boolean().optional()
152
+ });
153
+ const organizationSchema = z.object({
154
+ name: nonEmpty.max(200),
155
+ description: z.string().max(100_000).optional(),
156
+ settings: z.array(z.string().trim().min(1).max(20_000)).max(200).optional(),
157
+ memberIds: z.array(identifier).max(1000).optional()
158
+ });
159
+ const raceSchema = z.object({
160
+ name: nonEmpty.max(200),
161
+ description: z.string().max(100_000).optional(),
162
+ settings: z.array(z.string().trim().min(1).max(20_000)).max(200).optional(),
163
+ memberIds: z.array(identifier).max(1000).optional()
164
+ });
165
+ const chapterOutlineSchema = z.object({
166
+ goal: z.string().max(100_000).optional(),
167
+ conflict: z.string().max(100_000).optional(),
168
+ turningPoint: z.string().max(100_000).optional(),
169
+ notes: z.string().max(100_000).optional(),
170
+ status: z.enum(["draft", "ready", "completed"]).optional()
171
+ });
172
+ const foreshadowOccurrenceSchema = z.object({
173
+ chapterId: identifier,
174
+ role: z.enum(["setup", "reminder", "payoff"]),
175
+ note: z.string().max(100_000).optional(),
176
+ evidence: z.array(z.unknown()).optional()
177
+ });
178
+ const foreshadowSchema = z.object({
179
+ title: nonEmpty.max(300),
180
+ description: z.string().max(100_000).optional(),
181
+ status: z.enum(["planned", "planted", "resolved", "abandoned"]).optional(),
182
+ importance: z.enum(["low", "medium", "high"]).optional(),
183
+ plannedPayoffChapterId: identifier.nullable().optional(),
184
+ resolutionNote: z.string().max(100_000).optional(),
185
+ occurrences: z.array(foreshadowOccurrenceSchema).max(500).optional()
186
+ });
187
+ const reviewSchema = z.object({
188
+ itemType: nonEmpty.max(100),
189
+ severity: z.enum(["low", "medium", "high"]).optional(),
190
+ title: nonEmpty.max(300),
191
+ description: z.string().max(100_000).optional(),
192
+ entityRefs: z.array(z.unknown()).optional(),
193
+ evidence: z.array(z.unknown()).optional(),
194
+ suggestion: z.string().max(100_000).optional(),
195
+ status: z.enum(["pending", "ignored", "fixing", "fixed", "exception"]).optional(),
196
+ resolutionNote: z.string().max(20_000).optional()
197
+ });
198
+ const providerSchema = z.object({
199
+ name: nonEmpty.max(200),
200
+ baseUrl: z.string().url().refine((value) => value.startsWith("http://") || value.startsWith("https://"), "接口地址必须使用 HTTP 或 HTTPS"),
201
+ apiKey: nonEmpty.max(10_000),
202
+ status: z.enum(["enabled", "disabled"]).optional(),
203
+ note: z.string().max(10_000).optional(),
204
+ concurrencyLimit: z.number().int().min(1).max(100).optional(),
205
+ rpmLimit: z.number().int().min(1).max(10_000).optional(),
206
+ maxTokens: z.number().int().min(1).max(32_768).optional()
207
+ });
208
+ const modelSchema = z.object({
209
+ displayName: nonEmpty.max(200),
210
+ modelId: nonEmpty.max(300),
211
+ purposes: optionalStrings,
212
+ contextNote: z.string().max(10_000).optional(),
213
+ contextWindow: z.number().int().min(1_024).max(2_000_000).optional(),
214
+ outputNote: z.string().max(10_000).optional(),
215
+ preset: jsonObject.optional(),
216
+ thinkingEnabled: z.boolean().optional(),
217
+ enabled: z.boolean().optional(),
218
+ note: z.string().max(10_000).optional()
219
+ });
220
+ const aiPromptSchema = z.object({
221
+ systemPrompt: z.string().max(100_000).optional()
222
+ });
223
+ const platformUiSettingsSchema = z.object({
224
+ toastPosition: z.enum(["bottom-right", "top-right"])
225
+ }).strict();
226
+ const aiToolCallResultSchema = z.object({
227
+ id: z.string().min(1).max(300),
228
+ name: z.string().min(1).max(200),
229
+ calledAt: z.string().datetime({ offset: true }).optional(),
230
+ arguments: z.record(z.string(), z.unknown()).nullable(),
231
+ status: z.enum(["completed", "failed"]),
232
+ result: z.record(z.string(), z.unknown())
233
+ }).strict();
234
+ const aiProcessStepSchema = z.discriminatedUnion("type", [
235
+ z.object({
236
+ id: z.string().min(1).max(300),
237
+ type: z.literal("thinking"),
238
+ round: z.number().int().min(1).max(20),
239
+ content: z.string().max(500_000),
240
+ createdAt: z.string().datetime({ offset: true })
241
+ }).strict(),
242
+ z.object({
243
+ id: z.string().min(1).max(300),
244
+ type: z.literal("intermediate"),
245
+ round: z.number().int().min(1).max(20),
246
+ content: z.string().max(500_000),
247
+ createdAt: z.string().datetime({ offset: true })
248
+ }).strict(),
249
+ z.object({
250
+ id: z.string().min(1).max(300),
251
+ type: z.literal("tool"),
252
+ round: z.number().int().min(1).max(20),
253
+ toolCall: aiToolCallResultSchema,
254
+ createdAt: z.string().datetime({ offset: true })
255
+ }).strict()
256
+ ]);
257
+ const workAiSettingsSchema = z.object({
258
+ systemPrompt: z.string().max(100_000).optional(),
259
+ autoRunEnabled: z.boolean().optional(),
260
+ autoRunConcurrency: z.number().int().min(1).max(8).optional(),
261
+ autoRunBatchLimit: z.number().int().min(1).max(200).optional(),
262
+ bookSummaryContextPercent: z.number().int().min(1).max(90).optional(),
263
+ contextCompactThreshold: z.number().int().min(50).max(90).optional(),
264
+ agentTools: z.array(z.enum(["story_index", "read_chapters", "query_story_knowledge"])).max(3).optional()
265
+ }).strict();
266
+ const contextSchema = z.object({
267
+ type: z.enum(["none", "selection", "chapter", "volume", "book", "entities"]),
268
+ chapterId: identifier.optional(),
269
+ volumeId: identifier.optional(),
270
+ selection: z.string().max(200_000).optional(),
271
+ chapterIds: z.array(identifier).max(20).optional(),
272
+ characterIds: optionalStrings,
273
+ settingIds: optionalStrings,
274
+ includeBookSummary: z.boolean().optional()
275
+ });
276
+ function data(response, value, status = 200) {
277
+ response.status(status).json({ data: value });
278
+ }
279
+ function noContent(response) {
280
+ response.status(204).end();
281
+ }
282
+ function parse(schema, value) {
283
+ return schema.parse(value);
284
+ }
285
+ export function createRuntime(options) {
286
+ const database = new Database(options.databasePath);
287
+ const auth = new UserAuthService(database);
288
+ const store = new Store(database);
289
+ const captcha = new ImageCaptchaService({ revealAnswer: options.revealCaptchaAnswer === true });
290
+ const ai = new AiManager(store, new CredentialVault(options.masterSecret), options.fetchImpl ?? fetch, options.security ? (url) => assertSafeAiEndpoint(url, options.security?.allowPrivateAiEndpoints) : undefined);
291
+ const app = express();
292
+ const upload = multer({
293
+ storage: multer.memoryStorage(),
294
+ limits: { fileSize: 30 * 1024 * 1024, files: 1, fields: 10, fieldSize: 64 * 1024, parts: 11, headerPairs: 100 }
295
+ });
296
+ const coverUpload = multer({
297
+ storage: multer.memoryStorage(),
298
+ limits: { fileSize: 5 * 1024 * 1024, files: 1, fields: 4, fieldSize: 16 * 1024, parts: 5, headerPairs: 100 }
299
+ });
300
+ const avatarUpload = multer({
301
+ storage: multer.memoryStorage(),
302
+ limits: { fileSize: 5 * 1024 * 1024, files: 1, fields: 1, fieldSize: 1024, parts: 2, headerPairs: 50 }
303
+ });
304
+ app.disable("x-powered-by");
305
+ if (options.security?.trustProxy !== undefined)
306
+ app.set("trust proxy", options.security.trustProxy);
307
+ app.use(createSecurityHeadersMiddleware());
308
+ app.get("/api/health", (_request, response) => {
309
+ data(response, { status: "ok", version: "0.3.0", protocol: "openai-chat-completions" });
310
+ });
311
+ if (options.security?.auth)
312
+ app.use(createBasicAuthMiddleware(options.security.auth));
313
+ app.use(createAuthenticationRateLimitMiddleware());
314
+ app.use(createApiRateLimitMiddleware(options.security?.apiRateLimit, options.security?.apiRateWindowMs));
315
+ if (options.security?.enforceSameOrigin ?? true)
316
+ app.use(createSameOriginMiddleware());
317
+ app.use(express.json({ limit: "2mb" }));
318
+ app.get("/api/auth/session", (request, response) => {
319
+ const session = auth.authenticate(request);
320
+ const registrationOpen = !auth.hasUsers() || options.security?.allowRegistration !== false;
321
+ data(response, session
322
+ ? { authenticated: true, user: session.user, csrfToken: session.csrfToken, setupRequired: false, registrationOpen }
323
+ : { authenticated: false, user: null, csrfToken: null, setupRequired: !auth.hasUsers(), registrationOpen });
324
+ });
325
+ app.get("/api/auth/captcha", (_request, response) => {
326
+ data(response, captcha.create());
327
+ });
328
+ app.post("/api/auth/register", (request, response) => {
329
+ if (auth.hasUsers() && options.security?.allowRegistration === false) {
330
+ throw new AppError(403, "REGISTRATION_DISABLED", "当前部署已关闭新用户注册");
331
+ }
332
+ const input = parse(registrationSchema, request.body);
333
+ captcha.consume(input.captchaId, input.captchaAnswer);
334
+ const result = auth.register({ username: input.username, password: input.password });
335
+ setSessionCookie(response, result.token, request.secure);
336
+ runWithRequestActor(result.session.user, () => store.audit(null, "user.registered", "user", result.session.user.userId, { role: result.session.user.role }));
337
+ data(response, { user: result.session.user, csrfToken: result.session.csrfToken }, 201);
338
+ });
339
+ app.post("/api/auth/login", (request, response) => {
340
+ const input = parse(loginSchema, request.body);
341
+ captcha.consume(input.captchaId, input.captchaAnswer);
342
+ const result = auth.login(input.username, input.password);
343
+ setSessionCookie(response, result.token, request.secure);
344
+ runWithRequestActor(result.session.user, () => store.audit(null, "user.logged-in", "user", result.session.user.userId));
345
+ data(response, { user: result.session.user, csrfToken: result.session.csrfToken });
346
+ });
347
+ app.use(createUserSessionMiddleware(auth, options.disableUserAuth));
348
+ app.use(createCliApiScopeMiddleware(options.disableUserAuth));
349
+ app.use(createWorkAuthorizationMiddleware(auth, options.disableUserAuth));
350
+ app.get("/api/cli/session", (request, response) => {
351
+ if (!request.authUser || request.authMethod !== "api-key")
352
+ throw new AppError(401, "API_KEY_REQUIRED", "请使用 API Key 登录");
353
+ data(response, { authenticated: true, user: request.authUser, apiKeyPrefix: request.authApiKey?.prefix ?? null });
354
+ });
355
+ app.delete("/api/auth/session", (request, response) => {
356
+ if (request.authSession)
357
+ auth.revoke(request.authSession.id);
358
+ clearSessionCookie(response, request.secure);
359
+ noContent(response);
360
+ });
361
+ app.patch("/api/auth/profile", (request, response) => {
362
+ if (!request.authUser)
363
+ throw new AppError(401, "AUTH_REQUIRED", "请先登录");
364
+ const updated = auth.updateProfile(request.authUser.userId, parse(profileSchema, request.body).displayName);
365
+ store.audit(null, "user.profile-updated", "user", updated.userId);
366
+ data(response, updated);
367
+ });
368
+ app.put("/api/auth/avatar", avatarUpload.single("file"), (request, response) => {
369
+ if (!request.authUser)
370
+ throw new AppError(401, "AUTH_REQUIRED", "请先登录");
371
+ if (!request.file)
372
+ throw new AppError(400, "FILE_REQUIRED", "请选择 PNG、JPEG 或 WebP 头像");
373
+ try {
374
+ const metadata = readRasterImageMetadata(request.file.buffer);
375
+ const updated = database.transaction(() => {
376
+ const user = auth.setAvatar(request.authUser.userId, { ...metadata, content: request.file.buffer });
377
+ store.audit(null, "user.avatar-updated", "user", user.userId, {
378
+ mimeType: metadata.mimeType,
379
+ byteLength: request.file.buffer.byteLength,
380
+ width: metadata.width,
381
+ height: metadata.height
382
+ });
383
+ return user;
384
+ });
385
+ data(response, updated);
386
+ }
387
+ catch (error) {
388
+ if (error instanceof InvalidRasterImageError)
389
+ throw new AppError(415, "INVALID_AVATAR", error.message);
390
+ throw error;
391
+ }
392
+ });
393
+ app.delete("/api/auth/avatar", (request, response) => {
394
+ if (!request.authUser)
395
+ throw new AppError(401, "AUTH_REQUIRED", "请先登录");
396
+ const updated = database.transaction(() => {
397
+ const user = auth.deleteAvatar(request.authUser.userId);
398
+ store.audit(null, "user.avatar-deleted", "user", user.userId);
399
+ return user;
400
+ });
401
+ data(response, updated);
402
+ });
403
+ app.patch("/api/auth/password", (request, response) => {
404
+ if (!request.authUser || !request.authSession)
405
+ throw new AppError(401, "AUTH_REQUIRED", "请先登录");
406
+ const input = parse(passwordChangeSchema, request.body);
407
+ auth.changePassword(request.authUser.userId, request.authSession.id, input.currentPassword, input.newPassword);
408
+ store.audit(null, "user.password-changed", "user", request.authUser.userId);
409
+ noContent(response);
410
+ });
411
+ app.get("/api/auth/api-key", (request, response) => {
412
+ if (!request.authUser || request.authMethod !== "session")
413
+ throw new AppError(401, "SESSION_REQUIRED", "请使用网页会话管理 API Key");
414
+ data(response, auth.getApiKeyStatus(request.authUser.userId));
415
+ });
416
+ app.post("/api/auth/api-key/reset", (request, response) => {
417
+ if (!request.authUser || request.authMethod !== "session")
418
+ throw new AppError(401, "SESSION_REQUIRED", "请使用网页会话管理 API Key");
419
+ parse(z.object({}).strict(), request.body ?? {});
420
+ const userId = request.authUser.userId;
421
+ const result = database.transaction(() => {
422
+ const reset = auth.resetApiKey(userId);
423
+ store.audit(null, "user.api-key-reset", "user", userId, { prefix: reset.prefix });
424
+ return reset;
425
+ });
426
+ data(response, result);
427
+ });
428
+ app.get("/api/users", (_request, response) => data(response, auth.listUsers()));
429
+ app.get("/api/users/directory", (request, response) => data(response, auth.directory(String(request.query.q ?? ""))));
430
+ app.get("/api/user-avatars/:userId", (request, response) => {
431
+ const avatar = auth.getAvatar(request.params.userId);
432
+ response.setHeader("Content-Type", avatar.mimeType);
433
+ response.setHeader("Content-Length", String(avatar.byteLength));
434
+ response.setHeader("ETag", `\"${avatar.sha256}\"`);
435
+ response.setHeader("Cache-Control", "private, max-age=31536000, immutable");
436
+ response.send(avatar.content);
437
+ });
438
+ app.patch("/api/users/:userId", (request, response) => {
439
+ if (!request.authUser)
440
+ throw new AppError(401, "AUTH_REQUIRED", "请先登录");
441
+ const updated = auth.updateUser(request.authUser, request.params.userId, parse(userUpdateSchema, request.body));
442
+ store.audit(null, "user.updated", "user", updated.userId, { role: updated.role, status: updated.status });
443
+ data(response, updated);
444
+ });
445
+ app.get("/api/works", (_request, response) => data(response, store.listWorks()));
446
+ app.post("/api/works", (request, response) => data(response, store.createWork(parse(workSchema, request.body)), 201));
447
+ app.post("/api/works/import", upload.single("file"), async (request, response) => {
448
+ if (!request.file)
449
+ throw new AppError(400, "FILE_REQUIRED", "请选择要导入的 TXT 或 DOCX 文件");
450
+ const originalFileName = normalizeUploadFileName(request.file.originalname);
451
+ const extension = extname(originalFileName).toLocaleLowerCase();
452
+ if (extension !== ".txt" && extension !== ".docx")
453
+ throw new AppError(415, "UNSUPPORTED_FILE", "仅支持 TXT 和 DOCX 导入");
454
+ const text = validateImportedText(extension === ".docx"
455
+ ? await extractDocxText(request.file.buffer)
456
+ : decodeUtf8ImportedText(request.file.buffer));
457
+ const parsedNovel = applyImportFileHints(parseNovelText(text), originalFileName);
458
+ const inferredTitle = originalFileName.replace(/\.(txt|docx)$/iu, "").trim() || "未命名作品";
459
+ const input = parse(workSchema, {
460
+ title: typeof request.body.title === "string" && request.body.title.trim() ? request.body.title : inferredTitle,
461
+ author: typeof request.body.author === "string" ? request.body.author : "",
462
+ description: typeof request.body.description === "string" ? request.body.description : ""
463
+ });
464
+ data(response, store.createImportedWork(input, originalFileName, extension.slice(1), parsedNovel), 201);
465
+ });
466
+ app.get("/api/works/:workId", (request, response) => data(response, store.getWorkTree(request.params.workId)));
467
+ app.get("/api/works/:workId/members", (request, response) => data(response, auth.listMembers(request.params.workId)));
468
+ app.post("/api/works/:workId/members", (request, response) => {
469
+ if (!request.authUser)
470
+ throw new AppError(401, "AUTH_REQUIRED", "请先登录");
471
+ const input = parse(memberSchema, request.body);
472
+ const members = auth.addMember(request.params.workId, input.userId, request.authUser.userId);
473
+ store.audit(request.params.workId, "work.member-added", "user", input.userId);
474
+ data(response, members, 201);
475
+ });
476
+ app.delete("/api/works/:workId/members/:userId", (request, response) => {
477
+ const members = auth.removeMember(request.params.workId, request.params.userId);
478
+ store.audit(request.params.workId, "work.member-removed", "user", request.params.userId);
479
+ data(response, members);
480
+ });
481
+ app.patch("/api/works/:workId", (request, response) => data(response, store.updateWork(request.params.workId, parse(workSchema.partial(), request.body))));
482
+ app.delete("/api/works/:workId", (request, response) => {
483
+ store.deleteWork(request.params.workId);
484
+ noContent(response);
485
+ });
486
+ app.get("/api/works/:workId/cover", (request, response) => {
487
+ const cover = store.getWorkCover(request.params.workId);
488
+ response.setHeader("Content-Type", cover.mimeType);
489
+ response.setHeader("Content-Length", String(cover.byteLength));
490
+ response.setHeader("ETag", `\"${cover.sha256}\"`);
491
+ response.setHeader("Cache-Control", "private, max-age=31536000, immutable");
492
+ response.send(cover.content);
493
+ });
494
+ app.put("/api/works/:workId/cover", coverUpload.single("file"), (request, response) => {
495
+ if (!request.file)
496
+ throw new AppError(400, "FILE_REQUIRED", "请选择 PNG、JPEG 或 WebP 封面");
497
+ const bytes = request.file.buffer;
498
+ const isPng = bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
499
+ const isJpeg = bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
500
+ const isWebp = bytes.length >= 12 && bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP";
501
+ const mimeType = isPng ? "image/png" : isJpeg ? "image/jpeg" : isWebp ? "image/webp" : null;
502
+ if (!mimeType)
503
+ throw new AppError(415, "INVALID_COVER", "封面文件内容不是有效的 PNG、JPEG 或 WebP 图片");
504
+ data(response, store.setWorkCover(String(request.params.workId), mimeType, bytes));
505
+ });
506
+ app.delete("/api/works/:workId/cover", (request, response) => {
507
+ store.deleteWorkCover(request.params.workId);
508
+ noContent(response);
509
+ });
510
+ app.get("/api/works/:workId/file-versions", (request, response) => data(response, store.listFileVersions(request.params.workId)));
511
+ app.post("/api/works/:workId/file-versions/:fileVersionId/restore", (request, response) => {
512
+ data(response, store.restoreFileVersion(request.params.workId, request.params.fileVersionId));
513
+ });
514
+ app.post("/api/works/:workId/import", upload.single("file"), async (request, response) => {
515
+ if (!request.file)
516
+ throw new AppError(400, "FILE_REQUIRED", "请选择要导入的 TXT 或 DOCX 文件");
517
+ const originalFileName = normalizeUploadFileName(request.file.originalname);
518
+ const extension = extname(originalFileName).toLocaleLowerCase();
519
+ if (extension !== ".txt" && extension !== ".docx") {
520
+ throw new AppError(415, "UNSUPPORTED_FILE", "MVP 仅支持 TXT 和 DOCX 导入");
521
+ }
522
+ const text = validateImportedText(extension === ".docx"
523
+ ? await extractDocxText(request.file.buffer)
524
+ : decodeUtf8ImportedText(request.file.buffer));
525
+ const parsed = applyImportFileHints(parseNovelText(text), originalFileName);
526
+ data(response, store.importNovel(String(request.params.workId), originalFileName, extension.slice(1), parsed), 201);
527
+ });
528
+ app.post("/api/works/:workId/volumes", (request, response) => {
529
+ const input = parse(z.object({ title: nonEmpty.max(200), kind: z.enum(["main", "prequel", "extra", "epilogue", "appendix"]).optional(), description: z.string().max(5_000).optional(), keywords: z.array(nonEmpty.max(100)).max(100).optional() }), request.body);
530
+ data(response, store.createVolume(request.params.workId, input), 201);
531
+ });
532
+ app.patch("/api/volumes/:volumeId", (request, response) => {
533
+ const input = parse(z.object({ title: nonEmpty.max(200).optional(), kind: z.enum(["main", "prequel", "extra", "epilogue", "appendix"]).optional(), description: z.string().max(5_000).optional(), keywords: z.array(nonEmpty.max(100)).max(100).optional(), sortOrder: z.number().int().min(0).optional() }), request.body);
534
+ data(response, store.updateVolume(request.params.volumeId, input));
535
+ });
536
+ app.get("/api/volumes/:volumeId", (request, response) => data(response, store.getVolume(request.params.volumeId)));
537
+ app.delete("/api/volumes/:volumeId", (request, response) => {
538
+ store.deleteVolume(request.params.volumeId);
539
+ noContent(response);
540
+ });
541
+ app.post("/api/works/:workId/chapters", (request, response) => {
542
+ const input = parse(z.object({ volumeId: identifier, title: nonEmpty.max(300), content: z.string().max(2_000_000).optional(), chapterType: chapterTypeSchema.optional() }), request.body);
543
+ data(response, store.createChapter(request.params.workId, input), 201);
544
+ });
545
+ app.get("/api/chapters/:chapterId", (request, response) => data(response, store.getChapter(request.params.chapterId)));
546
+ app.patch("/api/chapters/:chapterId", (request, response) => {
547
+ const input = parse(z.object({ title: nonEmpty.max(300).optional(), content: z.string().max(2_000_000).optional(), excludedFromAnalysis: z.boolean().optional(), chapterType: chapterTypeSchema.optional(), source: z.enum(["manual", "auto"]).optional(), changeNote: changeNoteSchema }).strict(), request.body);
548
+ const { source, changeNote, ...chapterInput } = input;
549
+ data(response, store.saveChapter(request.params.chapterId, chapterInput, source ?? "manual", null, changeNote));
550
+ });
551
+ app.delete("/api/chapters/:chapterId", (request, response) => {
552
+ store.deleteChapter(request.params.chapterId);
553
+ noContent(response);
554
+ });
555
+ app.get("/api/chapters/:chapterId/versions", (request, response) => data(response, store.listChapterVersions(request.params.chapterId)));
556
+ app.get("/api/chapters/:chapterId/insights", (request, response) => data(response, store.listChapterInsights(request.params.chapterId)));
557
+ app.post("/api/chapters/:chapterId/restore", (request, response) => {
558
+ const input = parse(z.object({ versionNo: z.number().int().positive() }), request.body);
559
+ data(response, store.restoreChapter(request.params.chapterId, input.versionNo));
560
+ });
561
+ app.post("/api/chapters/:chapterId/move", (request, response) => {
562
+ const input = parse(z.object({ volumeId: identifier, sortOrder: z.number().int().min(0) }), request.body);
563
+ data(response, store.moveChapter(request.params.chapterId, input));
564
+ });
565
+ app.get("/api/works/:workId/outlines", (request, response) => data(response, store.listChapterOutlines(request.params.workId)));
566
+ app.get("/api/chapters/:chapterId/outline", (request, response) => data(response, store.getChapterOutline(request.params.chapterId)));
567
+ app.put("/api/chapters/:chapterId/outline", (request, response) => {
568
+ const { changeNote, ...input } = parse(chapterOutlineSchema.extend({ changeNote: changeNoteSchema }), request.body);
569
+ data(response, store.upsertChapterOutline(request.params.chapterId, input, "manual", null, changeNote));
570
+ });
571
+ app.delete("/api/chapters/:chapterId/outline", (request, response) => {
572
+ store.deleteChapterOutline(request.params.chapterId);
573
+ noContent(response);
574
+ });
575
+ app.get("/api/works/:workId/foreshadows", (request, response) => {
576
+ const query = parse(z.object({
577
+ status: z.enum(["all", "unresolved", "resolved"]).default("all"),
578
+ currentChapterId: identifier.optional()
579
+ }), request.query);
580
+ data(response, store.listForeshadows(request.params.workId, query.status, query.currentChapterId));
581
+ });
582
+ app.post("/api/works/:workId/foreshadows", (request, response) => {
583
+ data(response, store.createForeshadow(request.params.workId, parse(foreshadowSchema, request.body)), 201);
584
+ });
585
+ app.get("/api/foreshadows/:foreshadowId", (request, response) => data(response, store.getForeshadow(request.params.foreshadowId)));
586
+ app.patch("/api/foreshadows/:foreshadowId", (request, response) => {
587
+ const { changeNote, ...input } = parse(foreshadowSchema.partial().extend({ changeNote: changeNoteSchema }), request.body);
588
+ data(response, store.updateForeshadow(request.params.foreshadowId, input, "manual", null, changeNote));
589
+ });
590
+ app.delete("/api/foreshadows/:foreshadowId", (request, response) => {
591
+ store.deleteForeshadow(request.params.foreshadowId);
592
+ noContent(response);
593
+ });
594
+ app.post("/api/foreshadows/:foreshadowId/occurrences", (request, response) => {
595
+ data(response, store.createForeshadowOccurrence(request.params.foreshadowId, parse(foreshadowOccurrenceSchema, request.body)), 201);
596
+ });
597
+ app.patch("/api/foreshadow-occurrences/:occurrenceId", (request, response) => {
598
+ data(response, store.updateForeshadowOccurrence(request.params.occurrenceId, parse(foreshadowOccurrenceSchema.partial(), request.body)));
599
+ });
600
+ app.delete("/api/foreshadow-occurrences/:occurrenceId", (request, response) => {
601
+ store.deleteForeshadowOccurrence(request.params.occurrenceId);
602
+ noContent(response);
603
+ });
604
+ app.get("/api/works/:workId/settings", (request, response) => data(response, store.listSettings(request.params.workId)));
605
+ app.post("/api/works/:workId/settings", (request, response) => data(response, store.createSetting(request.params.workId, parse(settingSchema, request.body)), 201));
606
+ app.get("/api/settings/:settingId", (request, response) => data(response, store.getSetting(request.params.settingId)));
607
+ app.patch("/api/settings/:settingId", (request, response) => {
608
+ const { changeNote, ...input } = parse(settingSchema.partial().extend({ changeNote: changeNoteSchema }), request.body);
609
+ data(response, store.updateSetting(request.params.settingId, input, "manual", null, changeNote));
610
+ });
611
+ app.delete("/api/settings/:settingId", (request, response) => {
612
+ store.deleteSetting(request.params.settingId);
613
+ noContent(response);
614
+ });
615
+ app.get("/api/works/:workId/characters", (request, response) => data(response, store.listCharacters(request.params.workId)));
616
+ app.post("/api/works/:workId/characters", (request, response) => data(response, store.createCharacter(request.params.workId, parse(characterSchema, request.body)), 201));
617
+ app.get("/api/characters/:characterId", (request, response) => data(response, store.getCharacter(request.params.characterId)));
618
+ app.patch("/api/characters/:characterId", (request, response) => {
619
+ const { changeNote, ...input } = parse(characterUpdateSchema, request.body);
620
+ data(response, store.updateCharacter(request.params.characterId, input, "manual", null, changeNote));
621
+ });
622
+ app.get("/api/characters/:characterId/versions", (request, response) => data(response, store.listCharacterVersions(request.params.characterId)));
623
+ app.post("/api/characters/:characterId/restore", (request, response) => {
624
+ const input = parse(z.object({ versionNo: z.number().int().positive() }), request.body);
625
+ data(response, store.restoreCharacter(request.params.characterId, input.versionNo));
626
+ });
627
+ app.delete("/api/characters/:characterId", (request, response) => {
628
+ store.deleteCharacter(request.params.characterId);
629
+ noContent(response);
630
+ });
631
+ app.get("/api/works/:workId/races", (request, response) => data(response, store.listRaces(request.params.workId)));
632
+ app.post("/api/works/:workId/races", (request, response) => {
633
+ data(response, store.createRace(request.params.workId, parse(raceSchema, request.body)), 201);
634
+ });
635
+ app.get("/api/races/:raceId", (request, response) => data(response, store.getRace(request.params.raceId)));
636
+ app.patch("/api/races/:raceId", (request, response) => {
637
+ const { changeNote, ...input } = parse(raceSchema.partial().extend({ changeNote: changeNoteSchema }), request.body);
638
+ data(response, store.updateRace(request.params.raceId, input, "manual", null, changeNote));
639
+ });
640
+ app.delete("/api/races/:raceId", (request, response) => {
641
+ store.deleteRace(request.params.raceId);
642
+ noContent(response);
643
+ });
644
+ app.get("/api/works/:workId/organizations", (request, response) => data(response, store.listOrganizations(request.params.workId)));
645
+ app.post("/api/works/:workId/organizations", (request, response) => {
646
+ data(response, store.createOrganization(request.params.workId, parse(organizationSchema, request.body)), 201);
647
+ });
648
+ app.get("/api/organizations/:organizationId", (request, response) => data(response, store.getOrganization(request.params.organizationId)));
649
+ app.patch("/api/organizations/:organizationId", (request, response) => {
650
+ const { changeNote, ...input } = parse(organizationSchema.partial().extend({ changeNote: changeNoteSchema }), request.body);
651
+ data(response, store.updateOrganization(request.params.organizationId, input, "manual", null, changeNote));
652
+ });
653
+ app.delete("/api/organizations/:organizationId", (request, response) => {
654
+ store.deleteOrganization(request.params.organizationId);
655
+ noContent(response);
656
+ });
657
+ app.get("/api/works/:workId/timeline-tracks", (request, response) => data(response, store.listTimelineTracks(request.params.workId)));
658
+ app.post("/api/works/:workId/timeline-tracks", (request, response) => data(response, store.createTimelineTrack(request.params.workId, parse(timelineTrackSchema, request.body)), 201));
659
+ app.get("/api/timeline-tracks/:trackId", (request, response) => data(response, store.getTimelineTrack(request.params.trackId)));
660
+ app.patch("/api/timeline-tracks/:trackId", (request, response) => {
661
+ const { changeNote, ...input } = parse(timelineTrackSchema.partial().extend({ changeNote: changeNoteSchema }), request.body);
662
+ data(response, store.updateTimelineTrack(request.params.trackId, input, "manual", null, changeNote));
663
+ });
664
+ app.delete("/api/timeline-tracks/:trackId", (request, response) => {
665
+ store.deleteTimelineTrack(request.params.trackId);
666
+ noContent(response);
667
+ });
668
+ app.get("/api/works/:workId/timeline", (request, response) => data(response, store.listTimelineEvents(request.params.workId)));
669
+ app.post("/api/works/:workId/timeline", (request, response) => data(response, store.createTimelineEvent(request.params.workId, parse(timelineSchema, request.body)), 201));
670
+ app.post("/api/works/:workId/timeline/merge", (request, response) => {
671
+ const input = parse(z.object({
672
+ eventIds: z.array(identifier).min(2),
673
+ name: nonEmpty.max(300),
674
+ description: z.string().max(100_000).optional(),
675
+ timeLabel: z.string().max(300).optional(),
676
+ timeSort: z.number().finite().nullable().optional()
677
+ }), request.body);
678
+ data(response, store.mergeTimelineEvents(request.params.workId, input.eventIds, input), 201);
679
+ });
680
+ app.get("/api/timeline/:eventId", (request, response) => data(response, store.getTimelineEvent(request.params.eventId)));
681
+ app.patch("/api/timeline/:eventId", (request, response) => {
682
+ const { changeNote, ...input } = parse(timelineSchema.partial().extend({ changeNote: changeNoteSchema }), request.body);
683
+ data(response, store.updateTimelineEvent(request.params.eventId, input, "manual", null, changeNote));
684
+ });
685
+ app.post("/api/timeline/:eventId/split", (request, response) => {
686
+ const input = parse(z.object({
687
+ parts: z.array(z.object({
688
+ name: nonEmpty.max(300),
689
+ description: z.string().max(100_000).optional(),
690
+ timeLabel: z.string().max(300).optional(),
691
+ timeSort: z.number().finite().nullable().optional()
692
+ })).min(2)
693
+ }), request.body);
694
+ data(response, store.splitTimelineEvent(request.params.eventId, input.parts), 201);
695
+ });
696
+ app.delete("/api/timeline/:eventId", (request, response) => {
697
+ store.deleteTimelineEvent(request.params.eventId);
698
+ noContent(response);
699
+ });
700
+ app.get("/api/works/:workId/relationships", (request, response) => {
701
+ const confidence = request.query.minimumConfidence ? Number(request.query.minimumConfidence) : 0;
702
+ if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1)
703
+ throw new AppError(400, "INVALID_CONFIDENCE", "置信度必须在 0 到 1 之间");
704
+ data(response, store.listRelationships(request.params.workId, confidence));
705
+ });
706
+ app.post("/api/works/:workId/relationships", (request, response) => data(response, store.createRelationship(request.params.workId, parse(relationshipSchema, request.body)), 201));
707
+ app.get("/api/relationships/:relationshipId", (request, response) => data(response, store.getRelationship(request.params.relationshipId)));
708
+ app.patch("/api/relationships/:relationshipId", (request, response) => {
709
+ const { changeNote, ...input } = parse(relationshipSchema.partial().extend({ changeNote: changeNoteSchema }), request.body);
710
+ data(response, store.updateRelationship(request.params.relationshipId, input, "manual", null, changeNote));
711
+ });
712
+ app.delete("/api/relationships/:relationshipId", (request, response) => {
713
+ store.deleteRelationship(request.params.relationshipId);
714
+ noContent(response);
715
+ });
716
+ app.get("/api/entity-versions/:entityType/:entityId", (request, response) => {
717
+ const input = parse(z.object({ entityType: versionedEntityTypeSchema, entityId: identifier }), request.params);
718
+ data(response, store.listEntityVersions(input.entityType, input.entityId));
719
+ });
720
+ app.post("/api/entity-versions/:entityType/:entityId/restore", (request, response) => {
721
+ const params = parse(z.object({ entityType: versionedEntityTypeSchema, entityId: identifier }), request.params);
722
+ const input = parse(z.object({ versionNo: z.number().int().positive() }), request.body);
723
+ data(response, store.restoreEntityVersion(params.entityType, params.entityId, input.versionNo));
724
+ });
725
+ app.get("/api/works/:workId/reviews", (request, response) => {
726
+ const status = typeof request.query.status === "string" ? request.query.status : undefined;
727
+ data(response, store.listReviewItems(request.params.workId, status));
728
+ });
729
+ app.post("/api/works/:workId/reviews", (request, response) => data(response, store.createReviewItem(request.params.workId, parse(reviewSchema, request.body)), 201));
730
+ app.patch("/api/reviews/:reviewId", (request, response) => data(response, store.updateReviewItem(request.params.reviewId, parse(reviewSchema.partial(), request.body))));
731
+ app.get("/api/works/:workId/tasks", (request, response) => data(response, store.listTasks(request.params.workId)));
732
+ app.post("/api/works/:workId/tasks", (request, response) => {
733
+ const input = parse(z.object({ taskType: z.enum(["structure", "chapter-analysis", "character-extraction", "character-summary", "timeline-analysis", "relationship-analysis", "worldview-analysis", "setting-extraction", "consistency-check", "report-update", "book-analysis"]), scope: jsonObject.optional() }), request.body);
734
+ data(response, store.createTask(request.params.workId, input), 201);
735
+ });
736
+ app.post("/api/works/:workId/tasks/auto-run", (request, response) => {
737
+ data(response, ai.startAutoRunBatch(request.params.workId));
738
+ });
739
+ app.get("/api/tasks/:taskId", (request, response) => data(response, store.getTask(request.params.taskId)));
740
+ app.post("/api/tasks/:taskId/run", async (request, response) => {
741
+ const input = parse(z.object({ modelId: identifier.optional() }), request.body ?? {});
742
+ data(response, await ai.runTask(request.params.taskId, input.modelId));
743
+ });
744
+ app.post("/api/tasks/:taskId/cancel", (request, response) => data(response, ai.cancelTask(request.params.taskId)));
745
+ app.get("/api/platform/ai/providers", (_request, response) => data(response, ai.listProviders()));
746
+ app.post("/api/platform/ai/providers", (request, response) => data(response, ai.createProvider(parse(providerSchema, request.body)), 201));
747
+ app.get("/api/platform/ai/models", (_request, response) => data(response, ai.listPlatformModels()));
748
+ app.get("/api/platform/ai/settings", (_request, response) => data(response, store.getPlatformAiSettings()));
749
+ app.patch("/api/platform/ai/settings", (request, response) => data(response, store.updatePlatformAiSettings(parse(aiPromptSchema, request.body))));
750
+ app.get("/api/ui-settings", (_request, response) => data(response, store.getPlatformUiSettings()));
751
+ app.get("/api/platform/ui-settings", (_request, response) => data(response, store.getPlatformUiSettings()));
752
+ app.patch("/api/platform/ui-settings", (request, response) => {
753
+ data(response, store.updatePlatformUiSettings(parse(platformUiSettingsSchema, request.body)));
754
+ });
755
+ app.get("/api/works/:workId/ai-settings", (request, response) => data(response, store.getWorkAiSettings(request.params.workId)));
756
+ app.patch("/api/works/:workId/ai-settings", (request, response) => {
757
+ const workId = request.params.workId;
758
+ const before = store.getWorkAiSettings(workId);
759
+ const updated = store.updateWorkAiSettings(workId, parse(workAiSettingsSchema, request.body));
760
+ if (updated.autoRunEnabled) {
761
+ if (!before.autoRunEnabled)
762
+ ai.resetAutoRunBatch(workId);
763
+ ai.scheduleAutoRun(workId);
764
+ }
765
+ data(response, updated);
766
+ });
767
+ app.get("/api/works/:workId/ai-conversations", (request, response) => data(response, store.listAiConversations(request.params.workId)));
768
+ app.post("/api/works/:workId/ai-conversations", (request, response) => {
769
+ const input = parse(z.object({ title: z.string().max(200).optional() }), request.body ?? {});
770
+ data(response, store.createAiConversation(request.params.workId, input.title), 201);
771
+ });
772
+ app.get("/api/ai-conversations/:conversationId", (request, response) => data(response, store.getAiConversation(request.params.conversationId)));
773
+ app.post("/api/ai-conversations/:conversationId/fork", (request, response) => {
774
+ const input = parse(z.object({ messageId: identifier, title: z.string().max(200).optional() }), request.body);
775
+ data(response, store.forkAiConversation(request.params.conversationId, input.messageId, input.title), 201);
776
+ });
777
+ app.post("/api/ai-conversations/:conversationId/messages", (request, response) => {
778
+ const input = parse(z.object({
779
+ role: z.enum(["user", "assistant"]),
780
+ content: nonEmpty.max(200_000),
781
+ citations: z.array(z.unknown()).max(100).optional(),
782
+ metadata: z.object({
783
+ modelDisplayName: z.string().max(200).optional(),
784
+ outputTokens: z.number().int().min(0).max(10_000_000).optional(),
785
+ processDurationMs: z.number().int().min(0).max(86_400_000).optional(),
786
+ toolCalls: z.array(aiToolCallResultSchema).max(12).optional(),
787
+ processSteps: z.array(aiProcessStepSchema).max(50).optional()
788
+ }).optional()
789
+ }), request.body);
790
+ data(response, store.addAiConversationMessage(request.params.conversationId, input), 201);
791
+ });
792
+ app.post("/api/ai-conversations/:conversationId/context/prepare", async (request, response) => {
793
+ const input = parse(z.object({
794
+ modelId: identifier.optional(),
795
+ scope: contextSchema,
796
+ instruction: z.string().max(100_000).default(""),
797
+ citations: aiCitationsSchema.optional()
798
+ }), request.body ?? {});
799
+ const conversation = store.getAiConversation(request.params.conversationId);
800
+ data(response, await ai.prepareConversationContext({
801
+ conversationId: request.params.conversationId,
802
+ workId: String(conversation.workId),
803
+ modelId: input.modelId,
804
+ scope: input.scope,
805
+ instruction: instructionWithCitations(input.instruction, input.citations ?? [])
806
+ }));
807
+ });
808
+ app.post("/api/ai-conversations/:conversationId/compact", async (request, response) => {
809
+ const input = parse(z.object({ modelId: identifier.optional(), scope: contextSchema }), request.body);
810
+ const conversation = store.getAiConversation(request.params.conversationId);
811
+ data(response, await ai.compactConversation({
812
+ conversationId: request.params.conversationId,
813
+ workId: String(conversation.workId),
814
+ modelId: input.modelId,
815
+ scope: input.scope
816
+ }));
817
+ });
818
+ app.post("/api/works/:workId/ai-context-usage", (request, response) => {
819
+ const input = parse(z.object({
820
+ modelId: identifier.optional(),
821
+ taskType: z.enum(TASK_TYPES).default("chat"),
822
+ scope: contextSchema,
823
+ instruction: z.string().max(100_000).default(""),
824
+ citations: aiCitationsSchema.optional(),
825
+ conversationId: identifier.optional(),
826
+ currentMessageId: identifier.optional()
827
+ }), request.body ?? {});
828
+ data(response, ai.getContextUsage({
829
+ workId: request.params.workId,
830
+ modelId: input.modelId,
831
+ taskType: input.taskType,
832
+ scope: input.scope,
833
+ instruction: instructionWithCitations(input.instruction, input.citations ?? []),
834
+ conversationId: input.conversationId,
835
+ excludeConversationMessageId: input.currentMessageId
836
+ }));
837
+ });
838
+ app.get("/api/works/:workId/providers", (request, response) => {
839
+ store.getWork(request.params.workId);
840
+ data(response, ai.listProviders());
841
+ });
842
+ app.post("/api/works/:workId/providers", (request, response) => {
843
+ store.getWork(request.params.workId);
844
+ data(response, ai.createProvider(parse(providerSchema, request.body)), 201);
845
+ });
846
+ app.get("/api/providers/:providerId", (request, response) => data(response, ai.getProvider(request.params.providerId)));
847
+ app.patch("/api/providers/:providerId", (request, response) => data(response, ai.updateProvider(request.params.providerId, parse(providerSchema.partial(), request.body))));
848
+ app.delete("/api/providers/:providerId", (request, response) => {
849
+ ai.deleteProvider(request.params.providerId);
850
+ noContent(response);
851
+ });
852
+ app.post("/api/providers/:providerId/test", async (request, response) => data(response, await ai.testProvider(request.params.providerId)));
853
+ app.get("/api/providers/:providerId/models", (request, response) => data(response, ai.listModels(request.params.providerId)));
854
+ app.post("/api/providers/:providerId/models", (request, response) => data(response, ai.createModel(request.params.providerId, parse(modelSchema, request.body)), 201));
855
+ app.get("/api/models/:modelId", (request, response) => data(response, ai.getModel(request.params.modelId)));
856
+ app.patch("/api/models/:modelId", (request, response) => data(response, ai.updateModel(request.params.modelId, parse(modelSchema.partial(), request.body))));
857
+ app.delete("/api/models/:modelId", (request, response) => {
858
+ ai.deleteModel(request.params.modelId);
859
+ noContent(response);
860
+ });
861
+ app.get("/api/works/:workId/models", (request, response) => data(response, ai.listWorkModels(request.params.workId)));
862
+ app.get("/api/works/:workId/task-defaults", (request, response) => data(response, ai.listTaskDefaults(request.params.workId)));
863
+ app.put("/api/works/:workId/task-defaults/:taskType", (request, response) => {
864
+ const taskType = parse(z.enum(TASK_TYPES), request.params.taskType);
865
+ const input = parse(z.object({ modelId: identifier }), request.body);
866
+ data(response, ai.setTaskDefault(request.params.workId, taskType, input.modelId));
867
+ });
868
+ app.get("/api/works/:workId/suggestions", (request, response) => {
869
+ const status = typeof request.query.status === "string" ? request.query.status : undefined;
870
+ data(response, ai.listSuggestions(request.params.workId, status));
871
+ });
872
+ app.post("/api/works/:workId/suggestions", async (request, response) => {
873
+ const input = parse(z.object({
874
+ taskType: z.enum(TASK_TYPES),
875
+ instruction: nonEmpty.max(100_000),
876
+ scope: contextSchema,
877
+ modelId: identifier.optional(),
878
+ parameters: jsonObject.optional(),
879
+ citations: aiCitationsSchema.optional()
880
+ }), request.body);
881
+ const citations = input.citations ?? [];
882
+ for (const citation of citations) {
883
+ if (store.getChapter(citation.chapterId).workId !== request.params.workId)
884
+ throw new AppError(400, "CITATION_WORK_MISMATCH", "引用章节不属于当前作品");
885
+ }
886
+ data(response, await ai.createSuggestion({
887
+ workId: request.params.workId,
888
+ taskType: input.taskType,
889
+ instruction: instructionWithCitations(input.instruction, citations),
890
+ scope: input.scope,
891
+ ...(input.modelId ? { modelId: input.modelId } : {}),
892
+ ...(input.parameters ? { parameters: input.parameters } : {})
893
+ }), 201);
894
+ });
895
+ app.post("/api/works/:workId/chat/stream", async (request, response) => {
896
+ const input = parse(z.object({
897
+ instruction: nonEmpty.max(100_000),
898
+ scope: contextSchema,
899
+ modelId: identifier.optional(),
900
+ parameters: jsonObject.optional(),
901
+ citations: aiCitationsSchema.optional(),
902
+ conversationId: identifier.optional(),
903
+ currentMessageId: identifier.optional()
904
+ }), request.body);
905
+ const citations = input.citations ?? [];
906
+ for (const citation of citations) {
907
+ if (store.getChapter(citation.chapterId).workId !== request.params.workId)
908
+ throw new AppError(400, "CITATION_WORK_MISMATCH", "引用章节不属于当前作品");
909
+ }
910
+ const controller = new AbortController();
911
+ response.on("close", () => {
912
+ if (!response.writableEnded)
913
+ controller.abort(new Error("浏览器已中断流式请求"));
914
+ });
915
+ response.status(200);
916
+ response.setHeader("Content-Type", "text/event-stream; charset=utf-8");
917
+ response.setHeader("Cache-Control", "no-cache, no-transform");
918
+ response.setHeader("Connection", "keep-alive");
919
+ response.setHeader("X-Accel-Buffering", "no");
920
+ response.flushHeaders();
921
+ const sendEvent = (event, payload) => {
922
+ if (!response.writableEnded && !response.destroyed)
923
+ response.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
924
+ };
925
+ sendEvent("ready", { streaming: true });
926
+ try {
927
+ const suggestion = await ai.createStreamingChat({
928
+ workId: request.params.workId,
929
+ instruction: instructionWithCitations(input.instruction, citations),
930
+ scope: input.scope,
931
+ signal: controller.signal,
932
+ onToolCall: (toolCall, round) => sendEvent("tool_call", { ...toolCall, round }),
933
+ onProcessStep: (step) => sendEvent("process_step", step),
934
+ conversationId: input.conversationId,
935
+ excludeConversationMessageId: input.currentMessageId,
936
+ ...(input.modelId ? { modelId: input.modelId } : {}),
937
+ ...(input.parameters ? { parameters: input.parameters } : {})
938
+ }, (delta) => sendEvent("delta", { delta }));
939
+ sendEvent("complete", {
940
+ suggestionId: suggestion.id,
941
+ callId: suggestion.callId,
942
+ provider: suggestion.provider,
943
+ model: suggestion.model,
944
+ outputTokens: suggestion.outputTokens,
945
+ chapterVersion: suggestion.chapterVersion,
946
+ toolCalls: suggestion.toolCalls,
947
+ processSteps: suggestion.processSteps
948
+ });
949
+ }
950
+ catch (error) {
951
+ if (!controller.signal.aborted) {
952
+ sendEvent("error", {
953
+ code: error instanceof AppError ? error.code : "AI_STREAM_FAILED",
954
+ message: error instanceof Error ? error.message : "AI 流式调用失败"
955
+ });
956
+ }
957
+ }
958
+ finally {
959
+ if (!response.writableEnded && !response.destroyed)
960
+ response.end();
961
+ }
962
+ });
963
+ app.get("/api/suggestions/:suggestionId", (request, response) => data(response, ai.getSuggestion(request.params.suggestionId)));
964
+ app.get("/api/suggestions/:suggestionId/guards", (request, response) => {
965
+ data(response, store.listContinuationGuards(request.params.suggestionId));
966
+ });
967
+ app.post("/api/suggestions/:suggestionId/guard", async (request, response) => {
968
+ const input = parse(z.object({ content: z.string().max(2_000_000).optional() }), request.body ?? {});
969
+ data(response, await ai.runSuggestionGuard(request.params.suggestionId, input.content), 201);
970
+ });
971
+ app.post("/api/suggestions/:suggestionId/accept", (request, response) => {
972
+ const input = parse(z.object({ content: z.string().max(2_000_000).optional() }), request.body ?? {});
973
+ data(response, ai.acceptSuggestion(request.params.suggestionId, input.content));
974
+ });
975
+ app.post("/api/suggestions/:suggestionId/reject", (request, response) => data(response, ai.rejectSuggestion(request.params.suggestionId)));
976
+ app.get("/api/works/:workId/ai-calls", (request, response) => data(response, ai.listCalls(request.params.workId)));
977
+ app.get("/api/works/:workId/search", (request, response) => {
978
+ const query = parse(z.string().trim().min(1).max(500), request.query.q);
979
+ data(response, store.search(request.params.workId, query));
980
+ });
981
+ app.get("/api/works/:workId/export", (request, response) => {
982
+ const format = parse(z.enum(["json", "txt", "markdown"]), request.query.format ?? "json");
983
+ if (format === "json") {
984
+ response.setHeader("Content-Disposition", `attachment; filename=novel-${request.params.workId}.json`);
985
+ data(response, store.exportWork(request.params.workId));
986
+ return;
987
+ }
988
+ response.type(format === "txt" ? "text/plain" : "text/markdown");
989
+ response.setHeader("Content-Disposition", `attachment; filename=novel-${request.params.workId}.${format === "markdown" ? "md" : "txt"}`);
990
+ response.send(store.exportText(request.params.workId, format));
991
+ });
992
+ app.get("/api/works/:workId/audit-logs", (request, response) => data(response, store.listAuditLogs(request.params.workId)));
993
+ if (options.serveUi ?? true) {
994
+ const publicPath = options.publicPath ?? join(process.cwd(), "src", "public");
995
+ app.use(express.static(publicPath, {
996
+ index: "index.html",
997
+ maxAge: 0,
998
+ setHeaders: (response) => response.setHeader("Cache-Control", "no-store")
999
+ }));
1000
+ app.get("/{*path}", (request, response, next) => {
1001
+ if (request.path.startsWith("/api/"))
1002
+ return next();
1003
+ response.sendFile(join(publicPath, "index.html"));
1004
+ });
1005
+ }
1006
+ app.use((_request, _response, next) => next(new AppError(404, "ROUTE_NOT_FOUND", "请求的接口不存在")));
1007
+ app.use((error, _request, response, _next) => {
1008
+ if (error instanceof ZodError) {
1009
+ response.status(400).json({
1010
+ error: {
1011
+ code: "VALIDATION_ERROR",
1012
+ message: "请求参数不符合要求",
1013
+ details: error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message }))
1014
+ }
1015
+ });
1016
+ return;
1017
+ }
1018
+ if (error instanceof multer.MulterError) {
1019
+ response.status(400).json({ error: { code: "UPLOAD_ERROR", message: error.message } });
1020
+ return;
1021
+ }
1022
+ if (error instanceof SyntaxError && "status" in error && error.status === 400) {
1023
+ response.status(400).json({ error: { code: "INVALID_JSON", message: "请求体不是有效的 JSON" } });
1024
+ return;
1025
+ }
1026
+ if (error && typeof error === "object" && "type" in error && error.type === "entity.too.large") {
1027
+ response.status(413).json({ error: { code: "REQUEST_TOO_LARGE", message: "请求体超过大小限制" } });
1028
+ return;
1029
+ }
1030
+ if (error instanceof AppError) {
1031
+ response.status(error.status).json({ error: { code: error.code, message: error.message, ...(error.details === undefined ? {} : { details: error.details }) } });
1032
+ return;
1033
+ }
1034
+ if (error instanceof Error && error.message.includes("UNIQUE constraint failed")) {
1035
+ response.status(409).json({ error: { code: "DUPLICATE_RECORD", message: "记录已存在" } });
1036
+ return;
1037
+ }
1038
+ console.error("Unhandled request error", error);
1039
+ response.status(500).json({ error: { code: "INTERNAL_ERROR", message: "服务器内部错误" } });
1040
+ });
1041
+ return { app, database, store, ai, auth, close: () => {
1042
+ ai.dispose();
1043
+ database.close();
1044
+ } };
1045
+ }
1046
+ //# sourceMappingURL=app.js.map