@musnows/scriverse 0.2.0 → 0.3.1

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