@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.
- package/README.en.md +13 -4
- package/README.md +13 -4
- package/dist/ai.js +3548 -0
- package/dist/ai.js.map +1 -0
- package/dist/app.js +1079 -0
- package/dist/app.js.map +1 -0
- package/dist/cli-core.js +147 -20
- package/dist/cli-core.js.map +1 -1
- package/dist/credential-vault.js +40 -0
- package/dist/credential-vault.js.map +1 -0
- package/dist/database.js +1220 -0
- package/dist/database.js.map +1 -0
- package/dist/docx-security.js +184 -0
- package/dist/docx-security.js.map +1 -0
- package/dist/domain.js +21 -0
- package/dist/domain.js.map +1 -0
- package/dist/errors.js +16 -0
- package/dist/errors.js.map +1 -0
- package/dist/http-logging.js +76 -0
- package/dist/http-logging.js.map +1 -0
- package/dist/image-captcha.js +173 -0
- package/dist/image-captcha.js.map +1 -0
- package/dist/image-metadata.js +128 -0
- package/dist/image-metadata.js.map +1 -0
- package/dist/import-security.js +46 -0
- package/dist/import-security.js.map +1 -0
- package/dist/logger.js +110 -0
- package/dist/logger.js.map +1 -0
- package/dist/parser.js +223 -0
- package/dist/parser.js.map +1 -0
- package/dist/public/ai-context-meter.js +10 -0
- package/dist/public/ai-conversation.js +3 -0
- package/dist/public/ai-mentions.js +55 -0
- package/dist/public/ai-message-actions.js +27 -0
- package/dist/public/ai-message-meta.js +15 -0
- package/dist/public/ai-message-time.js +23 -0
- package/dist/public/ai-prompt-keyboard.js +3 -0
- package/dist/public/app.js +4435 -0
- package/dist/public/character-profile.d.ts +9 -0
- package/dist/public/character-profile.js +65 -0
- package/dist/public/character-version.d.ts +2 -0
- package/dist/public/character-version.js +31 -0
- package/dist/public/entity-version.js +34 -0
- package/dist/public/icon.svg +10 -0
- package/dist/public/index.html +547 -0
- package/dist/public/line-number-layout.js +15 -0
- package/dist/public/markdown.js +199 -0
- package/dist/public/model-config.d.ts +17 -0
- package/dist/public/model-config.js +57 -0
- package/dist/public/page-route.d.ts +11 -0
- package/dist/public/page-route.js +81 -0
- package/dist/public/relationship-graph.js +2017 -0
- package/dist/public/site.webmanifest +17 -0
- package/dist/public/styles.css +1441 -0
- package/dist/public/text-formatting.d.ts +2 -0
- package/dist/public/text-formatting.js +20 -0
- package/dist/public/theme-init.js +8 -0
- package/dist/public/theme.js +13 -0
- package/dist/public/whitespace-visualization.js +20 -0
- package/dist/request-context.js +16 -0
- package/dist/request-context.js.map +1 -0
- package/dist/security.js +247 -0
- package/dist/security.js.map +1 -0
- package/dist/server-runtime.js +90 -0
- package/dist/server-runtime.js.map +1 -0
- package/dist/server.js +26 -0
- package/dist/server.js.map +1 -0
- package/dist/store.js +2568 -0
- package/dist/store.js.map +1 -0
- package/dist/user-auth.js +515 -0
- package/dist/user-auth.js.map +1 -0
- package/dist/utils.js +67 -0
- package/dist/utils.js.map +1 -0
- 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
|