@musnows/scriverse 0.7.3 → 0.7.4

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 (54) hide show
  1. package/README.en.md +3 -0
  2. package/README.md +3 -0
  3. package/dist/ai-connectivity-test.js +109 -0
  4. package/dist/ai-connectivity-test.js.map +1 -0
  5. package/dist/ai-conversation-export.js +70 -0
  6. package/dist/ai-conversation-export.js.map +1 -0
  7. package/dist/ai-stream-timeout.js +18 -0
  8. package/dist/ai-stream-timeout.js.map +1 -0
  9. package/dist/ai.js +681 -107
  10. package/dist/ai.js.map +1 -1
  11. package/dist/app.js +326 -53
  12. package/dist/app.js.map +1 -1
  13. package/dist/character-extraction.js +133 -0
  14. package/dist/character-extraction.js.map +1 -0
  15. package/dist/cli-core.js +7 -6
  16. package/dist/cli-core.js.map +1 -1
  17. package/dist/database.js +175 -2
  18. package/dist/database.js.map +1 -1
  19. package/dist/epub-export.js +319 -0
  20. package/dist/epub-export.js.map +1 -0
  21. package/dist/hybrid-search.js +8 -0
  22. package/dist/hybrid-search.js.map +1 -1
  23. package/dist/public/ai-connectivity-test.d.ts +7 -0
  24. package/dist/public/ai-connectivity-test.js +82 -0
  25. package/dist/public/ai-request-manager.js +99 -0
  26. package/dist/public/ai-stream-protocol.js +51 -0
  27. package/dist/public/app.js +2567 -265
  28. package/dist/public/chapter-version-diff.d.ts +20 -0
  29. package/dist/public/chapter-version-diff.js +116 -0
  30. package/dist/public/foreshadow-reminder.d.ts +32 -0
  31. package/dist/public/foreshadow-reminder.js +73 -0
  32. package/dist/public/global-replace-refresh.js +60 -0
  33. package/dist/public/index.html +120 -12
  34. package/dist/public/outline-board.d.ts +61 -0
  35. package/dist/public/outline-board.js +137 -0
  36. package/dist/public/page-route.d.ts +1 -0
  37. package/dist/public/page-route.js +8 -0
  38. package/dist/public/reading-preview.d.ts +32 -0
  39. package/dist/public/reading-preview.js +136 -0
  40. package/dist/public/styles.css +485 -4
  41. package/dist/public/upload-progress.d.ts +2 -0
  42. package/dist/public/upload-progress.js +10 -0
  43. package/dist/security.js +5 -2
  44. package/dist/security.js.map +1 -1
  45. package/dist/server-runtime.js +2 -0
  46. package/dist/server-runtime.js.map +1 -1
  47. package/dist/store.js +825 -88
  48. package/dist/store.js.map +1 -1
  49. package/dist/user-auth.js +45 -9
  50. package/dist/user-auth.js.map +1 -1
  51. package/dist/utils.js +3 -0
  52. package/dist/utils.js.map +1 -1
  53. package/dist/version.js +1 -1
  54. package/package.json +2 -1
package/dist/app.js CHANGED
@@ -10,18 +10,22 @@ import { tmpdir } from "node:os";
10
10
  import { pipeline } from "node:stream/promises";
11
11
  import { z, ZodError } from "zod";
12
12
  import { AI_PROVIDER_PROTOCOLS } from "./ai-protocol.js";
13
+ import { aiConversationExportContentDisposition, exportAiConversationMarkdown } from "./ai-conversation-export.js";
14
+ import { DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS } from "./ai-stream-timeout.js";
13
15
  import { AttachmentStorage } from "./attachment-storage.js";
14
16
  import { AiManager } from "./ai.js";
15
17
  import { resolveMaxAgentToolCallLimit } from "./ai-tool-results.js";
18
+ import { CHARACTER_EXTRACTION_MAX_ALIASES, CHARACTER_EXTRACTION_MAX_CANDIDATES, CHARACTER_EXTRACTION_MAX_IDENTITY_LENGTH, CHARACTER_EXTRACTION_MAX_NAME_LENGTH, CHARACTER_EXTRACTION_MAX_SPECIES_LENGTH } from "./character-extraction.js";
16
19
  import { CredentialVault } from "./credential-vault.js";
17
20
  import { Database } from "./database.js";
18
21
  import { assertSafeDocxArchive } from "./docx-security.js";
22
+ import { EPUB_MIME_TYPE, epubContentDisposition } from "./epub-export.js";
19
23
  import { CREATABLE_ANALYSIS_TASK_TYPES, DRAFT_SETTING_MODULES, TASK_TYPES } from "./domain.js";
20
24
  import { AppError } from "./errors.js";
21
25
  import { isOfficialGoogleVertexBaseUrl, parseGoogleServiceAccount } from "./google-vertex-auth.js";
22
- import { HYBRID_SEARCH_TYPES } from "./hybrid-search.js";
26
+ import { HYBRID_SEARCH_TYPES, MAXIMUM_WORK_SEARCH_QUERY_LENGTH } from "./hybrid-search.js";
23
27
  import { applyImportFileHints, parseNovelText } from "./parser.js";
24
- import { aiConversationTaskTypes, attachmentPermissionModules, Store, versionedEntityTypes } from "./store.js";
28
+ import { aiConversationTaskTypes, attachmentPermissionModules, RECYCLE_BIN_RETENTION_DAYS, Store, versionedEntityTypes } from "./store.js";
25
29
  import { paginated, parsePagination } from "./pagination.js";
26
30
  import { normalizeUploadFileName } from "./utils.js";
27
31
  import { assertSafeAiEndpoint, assertSafeS3Endpoint, createApiRateLimitMiddleware, createAuthenticationRateLimitMiddleware, createBasicAuthMiddleware, createCaptchaRateLimitMiddleware, createExpensiveApiRateLimitMiddleware, createSameOriginMiddleware, createSecurityHeadersMiddleware, createUploadRateLimitMiddleware, enforceCaseInsensitiveRouting, normalizeApiPath, resolveTrustProxySetting, verifySetupToken } from "./security.js";
@@ -41,6 +45,8 @@ import { PresenceStore } from "./presence-store.js";
41
45
  import { analysisTaskReadModules, clearSessionCookie, createCliApiScopeMiddleware, createUserSessionMiddleware, createWorkAuthorizationMiddleware, relationshipAnalysisReadModules, setSessionCookie, UserAuthService } from "./user-auth.js";
42
46
  const nonEmpty = z.string().trim().min(1);
43
47
  const identifier = z.string().trim().min(1).max(200);
48
+ const idempotencyKeySchema = z.string().trim().min(16).max(128)
49
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/u, "幂等键只能包含英文字母、数字、点、下划线、冒号和短横线");
44
50
  const optionalStrings = z.array(z.string()).optional();
45
51
  const jsonObject = z.record(z.string(), z.unknown());
46
52
  const chapterTypeSchema = z.enum(["正文", "设定", "作者的话", "其他"]);
@@ -49,6 +55,18 @@ const versionedEntityTypeSchema = z.enum(versionedEntityTypes);
49
55
  const attachmentPermissionModuleSchema = z.enum(attachmentPermissionModules);
50
56
  const maximumImportedTextLength = 20_000_000;
51
57
  const maximumKnowledgeSectionsLength = 4_000_000;
58
+ function stableJson(value) {
59
+ if (Array.isArray(value))
60
+ return `[${value.map((item) => stableJson(item)).join(",")}]`;
61
+ if (value && typeof value === "object") {
62
+ return `{${Object.entries(value)
63
+ .filter(([, item]) => item !== undefined)
64
+ .sort(([left], [right]) => left.localeCompare(right))
65
+ .map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`)
66
+ .join(",")}}`;
67
+ }
68
+ return JSON.stringify(value) ?? "null";
69
+ }
52
70
  function assertImageUploadSize(byteLength, maximumBytes, message) {
53
71
  if (byteLength <= maximumBytes)
54
72
  return;
@@ -607,6 +625,34 @@ const analysisTaskSchema = z.union([
607
625
  }
608
626
  })
609
627
  ]);
628
+ const characterExtractionSelectionSchema = z.object({
629
+ candidateId: z.string().trim().min(1).max(100).regex(/^[A-Za-z0-9_-]+$/u),
630
+ action: z.enum(["create", "merge", "skip"]),
631
+ targetCharacterId: identifier.optional(),
632
+ name: z.string().trim().min(1).max(CHARACTER_EXTRACTION_MAX_NAME_LENGTH).optional(),
633
+ aliases: z.array(z.string().trim().min(1).max(CHARACTER_EXTRACTION_MAX_NAME_LENGTH))
634
+ .max(CHARACTER_EXTRACTION_MAX_ALIASES).optional(),
635
+ species: z.string().trim().max(CHARACTER_EXTRACTION_MAX_SPECIES_LENGTH).optional(),
636
+ attributes: z.object({
637
+ identity: z.string().trim().max(CHARACTER_EXTRACTION_MAX_IDENTITY_LENGTH).optional()
638
+ }).strict().optional()
639
+ }).strict().superRefine((selection, context) => {
640
+ if (selection.action === "merge" && !selection.targetCharacterId) {
641
+ context.addIssue({ code: z.ZodIssueCode.custom, path: ["targetCharacterId"], message: "合并角色候选必须选择目标角色" });
642
+ }
643
+ if (selection.action !== "merge" && selection.targetCharacterId !== undefined) {
644
+ context.addIssue({ code: z.ZodIssueCode.custom, path: ["targetCharacterId"], message: "只有合并操作可以指定目标角色" });
645
+ }
646
+ });
647
+ const characterExtractionApplySchema = z.object({
648
+ previewToken: z.string().regex(/^[a-f0-9]{64}$/u),
649
+ selections: z.array(characterExtractionSelectionSchema).min(1).max(CHARACTER_EXTRACTION_MAX_CANDIDATES)
650
+ }).strict().superRefine((input, context) => {
651
+ const candidateIds = input.selections.map((selection) => selection.candidateId);
652
+ if (new Set(candidateIds).size !== candidateIds.length) {
653
+ context.addIssue({ code: z.ZodIssueCode.custom, path: ["selections"], message: "角色候选不能重复" });
654
+ }
655
+ });
610
656
  export const RUNTIME_BACKUP_IDLE_TIMEOUT_MS = 9_000;
611
657
  function data(response, value, status = 200) {
612
658
  response.status(status).json({ data: value });
@@ -614,6 +660,18 @@ function data(response, value, status = 200) {
614
660
  function noContent(response) {
615
661
  response.status(204).end();
616
662
  }
663
+ async function sendEpub(response, archive, title, fallbackStem) {
664
+ response.type(EPUB_MIME_TYPE);
665
+ response.setHeader("Content-Disposition", epubContentDisposition(title, fallbackStem));
666
+ response.setHeader("Cache-Control", "private, no-store");
667
+ await pipeline(archive.generateNodeStream({
668
+ type: "nodebuffer",
669
+ // 逐条目压缩后再写入,确保首个 mimetype 本地头包含确定长度且没有额外字段。
670
+ streamFiles: false,
671
+ compression: "DEFLATE",
672
+ compressionOptions: { level: 6 }
673
+ }), response);
674
+ }
617
675
  function parse(schema, value) {
618
676
  return schema.parse(value);
619
677
  }
@@ -876,7 +934,9 @@ export function publicAiStreamError(error) {
876
934
  ...(typeof details?.providerName === "string" ? { providerName: details.providerName } : {}),
877
935
  ...(typeof details?.providerId === "string" ? { providerId: details.providerId } : {}),
878
936
  ...(typeof details?.modelId === "string" ? { modelId: details.modelId } : {}),
879
- ...(typeof details?.modelRecordId === "string" ? { modelRecordId: details.modelRecordId } : {})
937
+ ...(typeof details?.modelRecordId === "string" ? { modelRecordId: details.modelRecordId } : {}),
938
+ ...(typeof details?.phase === "string" ? { phase: details.phase } : {}),
939
+ ...(typeof details?.idleTimeoutSeconds === "number" ? { idleTimeoutSeconds: details.idleTimeoutSeconds } : {})
880
940
  };
881
941
  }
882
942
  return { code: "AI_STREAM_FAILED", message: "AI 流式调用失败" };
@@ -1007,7 +1067,7 @@ export function createRuntime(options) {
1007
1067
  read: requiredModules,
1008
1068
  write: ["ai-analysis"]
1009
1069
  }, false, actor?.allowAdminAccess ?? false);
1010
- }, attachmentStorage);
1070
+ }, attachmentStorage, { interactiveStreamIdleTimeoutMs: options.aiStreamIdleTimeoutMs ?? DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS });
1011
1071
  const app = express();
1012
1072
  enforceCaseInsensitiveRouting(app);
1013
1073
  const upload = multer({
@@ -1226,6 +1286,23 @@ export function createRuntime(options) {
1226
1286
  const pagination = parsePagination(request.query);
1227
1287
  data(response, pagination ? store.listWorksPage(pagination) : store.listWorks());
1228
1288
  });
1289
+ app.get("/api/recycle-bin/works", (_request, response) => {
1290
+ data(response, { retentionDays: RECYCLE_BIN_RETENTION_DAYS, works: store.listDeletedWorks() });
1291
+ });
1292
+ app.post("/api/recycle-bin/works/:workId/restore", (request, response) => {
1293
+ if (request.authUser)
1294
+ auth.assertDeletedWorkAccess(request.authUser, request.params.workId, request.authMethod !== "api-key");
1295
+ const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1296
+ data(response, store.restoreWork(request.params.workId, input.expectedVersionNo));
1297
+ });
1298
+ app.delete("/api/recycle-bin/works/:workId/permanent", async (request, response) => {
1299
+ if (request.authUser)
1300
+ auth.assertDeletedWorkAccess(request.authUser, request.params.workId, request.authMethod !== "api-key");
1301
+ const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1302
+ store.permanentlyDeleteWork(request.params.workId, input.expectedVersionNo);
1303
+ await cleanupAttachments();
1304
+ noContent(response);
1305
+ });
1229
1306
  app.post("/api/works", (request, response) => data(response, store.createWork(parse(workSchema, request.body)), 201));
1230
1307
  app.post("/api/works/import", upload.single("file"), async (request, response) => {
1231
1308
  if (!request.file)
@@ -1310,7 +1387,6 @@ export function createRuntime(options) {
1310
1387
  app.delete("/api/works/:workId", async (request, response) => {
1311
1388
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1312
1389
  store.deleteWork(request.params.workId, input.expectedVersionNo);
1313
- await cleanupAttachments();
1314
1390
  noContent(response);
1315
1391
  });
1316
1392
  app.get("/api/works/:workId/cover", (request, response) => {
@@ -1390,11 +1466,31 @@ export function createRuntime(options) {
1390
1466
  ? store.listVolumeChaptersPage(request.params.volumeId, pagination)
1391
1467
  : store.listVolumeChapters(request.params.volumeId));
1392
1468
  });
1469
+ app.head("/api/volumes/:volumeId/export", (request, response) => {
1470
+ parse(z.enum(["epub"]), request.query.format ?? "epub");
1471
+ store.getVolume(request.params.volumeId);
1472
+ noContent(response);
1473
+ });
1474
+ app.get("/api/volumes/:volumeId/export", async (request, response) => {
1475
+ parse(z.enum(["epub"]), request.query.format ?? "epub");
1476
+ const exported = await store.exportVolumeEpub(request.params.volumeId);
1477
+ await sendEpub(response, exported.archive, exported.title, `volume-${request.params.volumeId}`);
1478
+ });
1393
1479
  app.delete("/api/volumes/:volumeId", (request, response) => {
1394
1480
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1395
1481
  store.deleteVolume(request.params.volumeId, input.expectedVersionNo);
1396
1482
  noContent(response);
1397
1483
  });
1484
+ app.post("/api/volumes/:volumeId/restore", (request, response) => {
1485
+ const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1486
+ data(response, store.restoreVolume(request.params.volumeId, input.expectedVersionNo));
1487
+ });
1488
+ app.delete("/api/volumes/:volumeId/permanent", async (request, response) => {
1489
+ const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1490
+ store.permanentlyDeleteVolume(request.params.volumeId, input.expectedVersionNo);
1491
+ await cleanupAttachments();
1492
+ noContent(response);
1493
+ });
1398
1494
  app.post("/api/works/:workId/chapters", (request, response) => {
1399
1495
  const input = parse(z.object({ volumeId: identifier, title: nonEmpty.max(300), content: z.string().max(2_000_000).optional(), chapterType: chapterTypeSchema.optional() }), request.body);
1400
1496
  data(response, store.createChapter(request.params.workId, input), 201);
@@ -1405,6 +1501,9 @@ export function createRuntime(options) {
1405
1501
  ? store.listDeletedChaptersPage(request.params.workId, pagination)
1406
1502
  : store.listDeletedChapters(request.params.workId));
1407
1503
  });
1504
+ app.get("/api/works/:workId/recycle-bin", (request, response) => {
1505
+ data(response, store.getRecycleBin(request.params.workId));
1506
+ });
1408
1507
  app.get("/api/chapters/:chapterId", (request, response) => data(response, store.getChapter(request.params.chapterId)));
1409
1508
  app.patch("/api/chapters/:chapterId", (request, response) => {
1410
1509
  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, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
@@ -1484,6 +1583,9 @@ export function createRuntime(options) {
1484
1583
  const pagination = parsePagination(request.query);
1485
1584
  data(response, pagination ? store.listChapterOutlinesPage(request.params.workId, pagination) : store.listChapterOutlines(request.params.workId));
1486
1585
  });
1586
+ app.get("/api/works/:workId/outline-board", (request, response) => {
1587
+ data(response, store.getChapterOutlineBoard(request.params.workId));
1588
+ });
1487
1589
  app.get("/api/chapters/:chapterId/outline", (request, response) => data(response, store.getChapterOutline(request.params.chapterId)));
1488
1590
  app.put("/api/chapters/:chapterId/outline", (request, response) => {
1489
1591
  const { changeNote, expectedVersionNo, ...input } = parse(chapterOutlineSchema.extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
@@ -1495,6 +1597,13 @@ export function createRuntime(options) {
1495
1597
  store.deleteChapterOutline(request.params.chapterId, input.expectedVersionNo);
1496
1598
  noContent(response);
1497
1599
  });
1600
+ app.get("/api/works/:workId/chapters/:chapterId/foreshadow-reminders", (request, response) => {
1601
+ data(response, store.listChapterForeshadowReminders(request.params.workId, request.params.chapterId));
1602
+ });
1603
+ app.post("/api/works/:workId/chapters/:chapterId/foreshadow-reminders/:foreshadowId/resolve", (request, response) => {
1604
+ const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1605
+ data(response, store.resolveChapterForeshadowReminder(request.params.workId, request.params.chapterId, request.params.foreshadowId, input.expectedVersionNo));
1606
+ });
1498
1607
  app.get("/api/works/:workId/foreshadows", (request, response) => {
1499
1608
  const query = parse(z.object({
1500
1609
  status: z.enum(["all", "unresolved", "resolved"]).default("all"),
@@ -2001,6 +2110,9 @@ export function createRuntime(options) {
2001
2110
  data(response, ai.resumeAutoRun(request.params.workId));
2002
2111
  });
2003
2112
  app.get("/api/tasks/:taskId/detail", (request, response) => data(response, redactTaskCharacterNames(store.getTaskDetail(request.params.taskId), requestPermissions(request))));
2113
+ app.get("/api/tasks/:taskId/character-extraction/preview", (request, response) => {
2114
+ data(response, ai.getCharacterExtractionPreview(request.params.taskId));
2115
+ });
2004
2116
  app.get("/api/tasks/:taskId/result", (request, response) => {
2005
2117
  const task = store.getTaskResultPayload(request.params.taskId);
2006
2118
  const permissions = requestPermissions(request);
@@ -2042,6 +2154,14 @@ export function createRuntime(options) {
2042
2154
  data(response, redactTaskCharacterNames(ai.rerunTask(request.params.taskId, input.modelId), requestPermissions(request)), 201);
2043
2155
  });
2044
2156
  app.post("/api/tasks/:taskId/cancel", (request, response) => data(response, redactTaskCharacterNames(ai.cancelTask(request.params.taskId), requestPermissions(request))));
2157
+ app.post("/api/tasks/:taskId/character-extraction/apply", (request, response) => {
2158
+ const input = parse(characterExtractionApplySchema, request.body);
2159
+ const applied = ai.applyCharacterExtractionPreview(request.params.taskId, input.previewToken, input.selections);
2160
+ const characterIds = Array.isArray(applied.characterIds) ? applied.characterIds.filter((value) => typeof value === "string") : [];
2161
+ for (const characterId of characterIds)
2162
+ publishEntityChange(String(store.getTask(request.params.taskId).workId), "character", characterId);
2163
+ data(response, applied);
2164
+ });
2045
2165
  app.post("/api/tasks/:taskId/relationship-changes/apply", (request, response) => {
2046
2166
  parse(z.object({}).strict(), request.body ?? {});
2047
2167
  const applied = ai.applyRelationshipChangePreview(request.params.taskId);
@@ -2179,10 +2299,26 @@ export function createRuntime(options) {
2179
2299
  const permissions = requestPermissions(request, String(conversation.workId));
2180
2300
  data(response, redactAiConversation(conversation, permissions));
2181
2301
  });
2302
+ app.get("/api/ai-conversations/:conversationId/export", (request, response) => {
2303
+ const conversation = store.getAiConversation(request.params.conversationId);
2304
+ const permissions = requestPermissions(request, String(conversation.workId));
2305
+ const readableConversation = redactAiConversation(conversation, permissions);
2306
+ response.type("text/markdown; charset=utf-8");
2307
+ response.setHeader("Content-Disposition", aiConversationExportContentDisposition(readableConversation));
2308
+ response.send(exportAiConversationMarkdown(readableConversation));
2309
+ });
2182
2310
  app.post("/api/ai-conversations/:conversationId/fork", (request, response) => {
2183
- const input = parse(z.object({ messageId: identifier, title: z.string().max(200).optional() }), request.body);
2184
- const forked = store.forkAiConversation(request.params.conversationId, input.messageId, input.title);
2185
- const permissions = requestPermissions(request, String(forked.workId));
2311
+ const input = parse(z.object({
2312
+ messageId: identifier,
2313
+ title: z.string().max(200).optional(),
2314
+ requestId: identifier.optional()
2315
+ }).strict(), request.body);
2316
+ const sourceConversation = store.getAiConversationSummary(request.params.conversationId);
2317
+ const permissions = requestPermissions(request, String(sourceConversation.workId));
2318
+ if (sourceConversation.roleplayCharacter && !canReadWorkModule(permissions, "characters")) {
2319
+ throw new AppError(403, "WORK_MODULE_READ_DENIED", "你没有读取“角色”模块的权限");
2320
+ }
2321
+ const forked = store.forkAiConversation(request.params.conversationId, input.messageId, input.title, input.requestId);
2186
2322
  data(response, redactAiConversation(forked, permissions), 201);
2187
2323
  });
2188
2324
  app.patch("/api/ai-conversations/:conversationId/task-type", (request, response) => {
@@ -2214,6 +2350,9 @@ export function createRuntime(options) {
2214
2350
  outputTokens: z.number().int().min(0).max(10_000_000).optional(),
2215
2351
  cacheHitPercent: z.number().min(0).max(100).optional(),
2216
2352
  processDurationMs: z.number().int().min(0).max(86_400_000).optional(),
2353
+ interrupted: z.boolean().optional(),
2354
+ interruptionCode: z.string().max(100).optional(),
2355
+ interruptionMessage: z.string().max(500).optional(),
2217
2356
  toolCalls: z.array(aiToolCallResultSchema).max(12).optional(),
2218
2357
  processSteps: z.array(aiProcessStepSchema).max(50).optional()
2219
2358
  }).optional()
@@ -2274,13 +2413,19 @@ export function createRuntime(options) {
2274
2413
  ai.deleteProvider(request.params.providerId);
2275
2414
  noContent(response);
2276
2415
  });
2277
- app.post("/api/providers/:providerId/test", async (request, response) => data(response, await ai.testProvider(request.params.providerId)));
2416
+ app.post("/api/providers/:providerId/test", async (request, response) => {
2417
+ parse(z.object({}).strict(), request.body ?? {});
2418
+ data(response, await ai.testProvider(request.params.providerId));
2419
+ });
2278
2420
  app.get("/api/providers/:providerId/models", (request, response) => {
2279
2421
  const pagination = parsePagination(request.query);
2280
2422
  data(response, pagination ? ai.listModelsPage(request.params.providerId, pagination) : ai.listModels(request.params.providerId));
2281
2423
  });
2282
2424
  app.post("/api/providers/:providerId/models", (request, response) => data(response, ai.createModel(request.params.providerId, parse(modelSchema, request.body)), 201));
2283
- app.post("/api/models/:modelId/test", async (request, response) => data(response, await ai.testModel(request.params.modelId)));
2425
+ app.post("/api/models/:modelId/test", async (request, response) => {
2426
+ parse(z.object({}).strict(), request.body ?? {});
2427
+ data(response, await ai.testModel(request.params.modelId));
2428
+ });
2284
2429
  app.get("/api/models/:modelId", (request, response) => data(response, ai.getModel(request.params.modelId)));
2285
2430
  app.patch("/api/models/:modelId", (request, response) => data(response, ai.updateModel(request.params.modelId, parse(modelSchema.partial(), request.body))));
2286
2431
  app.delete("/api/models/:modelId", (request, response) => {
@@ -2341,54 +2486,90 @@ export function createRuntime(options) {
2341
2486
  conversationId: identifier.optional(),
2342
2487
  currentMessageId: identifier.optional()
2343
2488
  }), request.body);
2489
+ const providedIdempotencyKey = request.get("Idempotency-Key");
2490
+ const idempotencyKey = providedIdempotencyKey
2491
+ ? parse(idempotencyKeySchema, providedIdempotencyKey)
2492
+ : randomUUID();
2493
+ const actorScope = request.authUser ? `user:${request.authUser.userId}` : "auth-disabled";
2494
+ const requestHash = store.hashContent(stableJson({ workId: request.params.workId, ...input }));
2344
2495
  const citations = input.citations ?? [];
2345
2496
  for (const citation of citations) {
2346
2497
  if (store.getChapter(citation.chapterId).workId !== request.params.workId)
2347
2498
  throw new AppError(400, "CITATION_WORK_MISMATCH", "引用章节不属于当前作品");
2348
2499
  }
2349
2500
  const resolvedInstruction = instructionWithCitations(input.instruction, citations);
2501
+ const existingRequest = store.findAiConversationStreamRequest(actorScope, request.params.workId, idempotencyKey);
2502
+ if (existingRequest && input.conversationId && existingRequest.conversationId !== input.conversationId) {
2503
+ throw new AppError(409, "IDEMPOTENCY_KEY_REUSED", "该请求标识已用于另一项 AI 对话请求");
2504
+ }
2505
+ const conversation = existingRequest
2506
+ ? store.getAiConversationSummary(existingRequest.conversationId)
2507
+ : input.conversationId
2508
+ ? store.getAiConversationSummary(input.conversationId)
2509
+ : store.createAiConversation(request.params.workId);
2510
+ if (String(conversation.workId) !== request.params.workId) {
2511
+ throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
2512
+ }
2513
+ const conversationId = String(conversation.id);
2514
+ const permissions = requestPermissions(request, request.params.workId);
2350
2515
  const controller = new AbortController();
2351
2516
  response.on("close", () => {
2352
2517
  if (!response.writableEnded)
2353
2518
  controller.abort(new Error("浏览器已中断流式请求"));
2354
2519
  });
2355
- response.status(200);
2356
- response.setHeader("Content-Type", "text/event-stream; charset=utf-8");
2357
- response.setHeader("Cache-Control", "no-cache, no-transform");
2358
- response.setHeader("Connection", "keep-alive");
2359
- response.setHeader("X-Accel-Buffering", "no");
2360
- response.flushHeaders();
2520
+ let streamRequestId = null;
2521
+ let streamRequestFinished = false;
2522
+ let lastStreamLeaseTouchAt = Date.now();
2523
+ const startStream = () => {
2524
+ if (response.headersSent)
2525
+ return;
2526
+ response.status(200);
2527
+ response.setHeader("Content-Type", "text/event-stream; charset=utf-8");
2528
+ response.setHeader("Cache-Control", "no-cache, no-transform");
2529
+ response.setHeader("Connection", "keep-alive");
2530
+ response.setHeader("X-Accel-Buffering", "no");
2531
+ response.flushHeaders();
2532
+ };
2361
2533
  const sendEvent = (event, payload) => {
2534
+ if (streamRequestId && Date.now() - lastStreamLeaseTouchAt >= 30_000) {
2535
+ store.touchAiConversationStreamRequest(streamRequestId);
2536
+ lastStreamLeaseTouchAt = Date.now();
2537
+ }
2362
2538
  if (!response.writableEnded && !response.destroyed)
2363
2539
  response.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
2364
2540
  };
2365
- sendEvent("ready", { streaming: true });
2366
2541
  try {
2367
- const conversation = input.conversationId
2368
- ? store.getAiConversationSummary(input.conversationId)
2369
- : store.createAiConversation(request.params.workId);
2370
- if (String(conversation.workId) !== request.params.workId) {
2371
- throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
2542
+ if (!existingRequest) {
2543
+ store.assertAiConversationStreamAvailable(conversationId);
2544
+ const inspection = ai.inspectConversationContext({
2545
+ conversationId,
2546
+ workId: request.params.workId,
2547
+ modelId: input.modelId,
2548
+ scope: input.scope,
2549
+ instruction: resolvedInstruction,
2550
+ excludeConversationMessageId: input.currentMessageId
2551
+ });
2552
+ if (inspection.action === "warn") {
2553
+ const prepared = await ai.prepareConversationContext({
2554
+ conversationId,
2555
+ workId: request.params.workId,
2556
+ modelId: input.modelId,
2557
+ scope: input.scope,
2558
+ instruction: resolvedInstruction,
2559
+ excludeConversationMessageId: input.currentMessageId
2560
+ });
2561
+ startStream();
2562
+ sendEvent("ready", { streaming: true, idempotencyKey });
2563
+ sendEvent("context", {
2564
+ ...prepared,
2565
+ conversation: redactAiConversation({
2566
+ ...store.getAiConversationSummary(conversationId),
2567
+ contextWarningPending: true
2568
+ }, permissions)
2569
+ });
2570
+ return;
2571
+ }
2372
2572
  }
2373
- const conversationId = String(conversation.id);
2374
- const permissions = requestPermissions(request, request.params.workId);
2375
- const prepared = await ai.prepareConversationContext({
2376
- conversationId,
2377
- workId: request.params.workId,
2378
- modelId: input.modelId,
2379
- scope: input.scope,
2380
- instruction: resolvedInstruction,
2381
- excludeConversationMessageId: input.currentMessageId
2382
- });
2383
- sendEvent("context", {
2384
- ...prepared,
2385
- conversation: redactAiConversation({
2386
- ...store.getAiConversationSummary(conversationId),
2387
- contextWarningPending: prepared.action === "warn"
2388
- }, permissions)
2389
- });
2390
- if (prepared.action === "warn")
2391
- return;
2392
2573
  const resolvedScope = input.currentMessageId
2393
2574
  ? input.scope
2394
2575
  : ai.resolveInstructionMentions({
@@ -2402,17 +2583,71 @@ export function createRuntime(options) {
2402
2583
  ...(resolvedScope.characterIds ?? []),
2403
2584
  ...(resolvedScope.mentionCharacterIds ?? [])
2404
2585
  ])];
2405
- const userMessage = input.currentMessageId
2406
- ? null
2407
- : store.addAiConversationMessage(conversationId, {
2408
- role: "user",
2586
+ const begun = store.beginAiConversationStreamRequest({
2587
+ workId: request.params.workId,
2588
+ conversationId,
2589
+ actorScope,
2590
+ idempotencyKey,
2591
+ requestHash,
2592
+ userMessage: {
2409
2593
  content: input.instruction,
2410
2594
  citations,
2595
+ ...(input.currentMessageId ? { existingMessageId: input.currentMessageId } : {}),
2411
2596
  ...(mentionCharacterIds.length ? { metadata: { mentionCharacterIds } } : {})
2412
- });
2413
- const currentMessageId = input.currentMessageId ?? String(userMessage?.id ?? "");
2414
- if (userMessage)
2415
- sendEvent("user_message", { message: redactAiConversationMessage(userMessage, permissions) });
2597
+ }
2598
+ });
2599
+ streamRequestId = begun.request.id;
2600
+ startStream();
2601
+ sendEvent("ready", { streaming: begun.disposition === "started", idempotencyKey });
2602
+ if (begun.disposition !== "started") {
2603
+ streamRequestFinished = true;
2604
+ if (begun.userMessage) {
2605
+ sendEvent("user_message", { message: redactAiConversationMessage(begun.userMessage, permissions), replayed: true });
2606
+ }
2607
+ if (begun.request.status === "completed" && begun.assistantMessage) {
2608
+ const content = String(begun.assistantMessage.content ?? "");
2609
+ if (content)
2610
+ sendEvent("delta", { delta: content, replayed: true });
2611
+ sendEvent("complete", {
2612
+ replayed: true,
2613
+ conversationId,
2614
+ conversationTitle: store.getAiConversationSummary(conversationId).title,
2615
+ messageId: begun.assistantMessage.id,
2616
+ messageCreatedAt: begun.assistantMessage.createdAt
2617
+ });
2618
+ }
2619
+ else {
2620
+ sendEvent("request_status", {
2621
+ code: begun.request.status === "in_progress"
2622
+ ? "AI_IDEMPOTENT_REQUEST_IN_PROGRESS"
2623
+ : "AI_IDEMPOTENT_REQUEST_TERMINAL",
2624
+ message: begun.request.status === "in_progress"
2625
+ ? "相同请求正在处理中,请等待当前响应结束"
2626
+ : "相同请求已经结束,不会再次调用 AI",
2627
+ status: begun.request.status,
2628
+ terminalReason: begun.request.terminalReason
2629
+ });
2630
+ }
2631
+ return;
2632
+ }
2633
+ const currentMessageId = String(begun.userMessage?.id ?? input.currentMessageId ?? "");
2634
+ if (begun.userMessage)
2635
+ sendEvent("user_message", { message: redactAiConversationMessage(begun.userMessage, permissions) });
2636
+ const prepared = await ai.prepareConversationContext({
2637
+ conversationId,
2638
+ workId: request.params.workId,
2639
+ modelId: input.modelId,
2640
+ scope: input.scope,
2641
+ instruction: resolvedInstruction,
2642
+ excludeConversationMessageId: currentMessageId
2643
+ }, { skipWarning: true });
2644
+ sendEvent("context", {
2645
+ ...prepared,
2646
+ conversation: redactAiConversation({
2647
+ ...store.getAiConversationSummary(conversationId),
2648
+ contextWarningPending: prepared.action === "warn"
2649
+ }, permissions)
2650
+ });
2416
2651
  const suggestion = await ai.createStreamingChat({
2417
2652
  workId: request.params.workId,
2418
2653
  instruction: resolvedInstruction,
@@ -2428,6 +2663,13 @@ export function createRuntime(options) {
2428
2663
  ...(input.modelId ? { modelId: input.modelId } : {}),
2429
2664
  ...(input.parameters ? { parameters: input.parameters } : {})
2430
2665
  }, (delta) => sendEvent("delta", { delta }));
2666
+ const assistantMessageId = typeof suggestion.conversationMessage === "object" && suggestion.conversationMessage !== null
2667
+ ? String(suggestion.conversationMessage.id ?? "")
2668
+ : "";
2669
+ if (!stopping) {
2670
+ store.finishAiConversationStreamRequest(streamRequestId, "completed", "completed", assistantMessageId || undefined);
2671
+ }
2672
+ streamRequestFinished = true;
2431
2673
  sendEvent("complete", {
2432
2674
  suggestionId: suggestion.id,
2433
2675
  callId: suggestion.callId,
@@ -2450,6 +2692,18 @@ export function createRuntime(options) {
2450
2692
  });
2451
2693
  }
2452
2694
  catch (error) {
2695
+ if (streamRequestId && !streamRequestFinished && !stopping) {
2696
+ const code = error instanceof AppError ? error.code : "AI_STREAM_FAILED";
2697
+ const status = code === "AI_STREAM_IDLE_TIMEOUT"
2698
+ ? "timed_out"
2699
+ : code === "AI_STREAM_REQUEST_CANCELLED" || controller.signal.aborted
2700
+ ? "cancelled"
2701
+ : "failed";
2702
+ store.finishAiConversationStreamRequest(streamRequestId, status, code);
2703
+ streamRequestFinished = true;
2704
+ }
2705
+ if (!response.headersSent)
2706
+ throw error;
2453
2707
  if (!controller.signal.aborted) {
2454
2708
  logger.error("ai.stream.failed", {
2455
2709
  workId: request.params.workId,
@@ -2459,7 +2713,13 @@ export function createRuntime(options) {
2459
2713
  }
2460
2714
  }
2461
2715
  finally {
2462
- if (!response.writableEnded && !response.destroyed)
2716
+ if (streamRequestId && !streamRequestFinished && !stopping) {
2717
+ store.finishAiConversationStreamRequest(streamRequestId, "cancelled", "stream_closed");
2718
+ streamRequestFinished = true;
2719
+ }
2720
+ if (stopping)
2721
+ streamRequestFinished = true;
2722
+ if (response.headersSent && !response.writableEnded && !response.destroyed)
2463
2723
  response.end();
2464
2724
  }
2465
2725
  });
@@ -2500,7 +2760,7 @@ export function createRuntime(options) {
2500
2760
  });
2501
2761
  app.get("/api/works/:workId/search", async (request, response) => {
2502
2762
  const query = parse(z.object({
2503
- q: z.string().trim().min(1).max(500),
2763
+ q: z.string().trim().min(1).max(MAXIMUM_WORK_SEARCH_QUERY_LENGTH),
2504
2764
  type: z.enum(HYBRID_SEARCH_TYPES).optional(),
2505
2765
  limit: z.coerce.number().int().min(1).max(100).optional()
2506
2766
  }).strict(), request.query);
@@ -2511,8 +2771,13 @@ export function createRuntime(options) {
2511
2771
  includeAgentHistory: permissions["ai-chat"] !== "none"
2512
2772
  }));
2513
2773
  });
2774
+ app.head("/api/works/:workId/export", (request, response) => {
2775
+ parse(z.enum(["epub"]), request.query.format ?? "epub");
2776
+ store.getWork(request.params.workId);
2777
+ noContent(response);
2778
+ });
2514
2779
  app.get("/api/works/:workId/export", async (request, response) => {
2515
- const format = parse(z.enum(["json", "txt", "markdown", "docx"]), request.query.format ?? "json");
2780
+ const format = parse(z.enum(["json", "txt", "markdown", "docx", "epub"]), request.query.format ?? "json");
2516
2781
  if (format === "json") {
2517
2782
  response.setHeader("Content-Disposition", `attachment; filename=novel-${request.params.workId}.json`);
2518
2783
  data(response, store.exportWork(request.params.workId));
@@ -2538,6 +2803,11 @@ export function createRuntime(options) {
2538
2803
  response.send(await store.exportDocx(request.params.workId));
2539
2804
  return;
2540
2805
  }
2806
+ if (format === "epub") {
2807
+ const exported = await store.exportEpub(request.params.workId);
2808
+ await sendEpub(response, exported.archive, exported.title, `novel-${request.params.workId}`);
2809
+ return;
2810
+ }
2541
2811
  response.type("text/plain");
2542
2812
  response.setHeader("Content-Disposition", `attachment; filename=novel-${request.params.workId}.txt`);
2543
2813
  response.send(store.exportText(request.params.workId, format));
@@ -2650,7 +2920,7 @@ export function createRuntime(options) {
2650
2920
  logger.error("http.request.application_error", logFields);
2651
2921
  else
2652
2922
  logger.warn("http.request.application_error", logFields);
2653
- if (error.code === "LOGIN_LOCKED" && error.details && typeof error.details === "object") {
2923
+ if ((error.code === "LOGIN_LOCKED" || error.status === 429) && error.details && typeof error.details === "object") {
2654
2924
  const retryAfterSeconds = Number(error.details.retryAfterSeconds);
2655
2925
  if (Number.isInteger(retryAfterSeconds) && retryAfterSeconds > 0) {
2656
2926
  response.setHeader("Retry-After", String(retryAfterSeconds));
@@ -2682,6 +2952,9 @@ export function createRuntime(options) {
2682
2952
  logger.info("runtime.closing");
2683
2953
  backups.dispose();
2684
2954
  ai.dispose();
2955
+ const cancelledStreamRequests = store.cancelActiveAiConversationStreamRequests();
2956
+ if (cancelledStreamRequests > 0)
2957
+ logger.info("ai.stream.requests_cancelled", { count: cancelledStreamRequests });
2685
2958
  }
2686
2959
  closePromise = (async () => {
2687
2960
  try {