@musnows/scriverse 0.7.2 → 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 (82) hide show
  1. package/README.en.md +3 -0
  2. package/README.md +8 -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 +926 -354
  10. package/dist/ai.js.map +1 -1
  11. package/dist/app.js +498 -80
  12. package/dist/app.js.map +1 -1
  13. package/dist/attachment-storage.js +28 -12
  14. package/dist/attachment-storage.js.map +1 -1
  15. package/dist/backup-encryption.js +129 -0
  16. package/dist/backup-encryption.js.map +1 -0
  17. package/dist/character-extraction.js +133 -0
  18. package/dist/character-extraction.js.map +1 -0
  19. package/dist/cli-core.js +7 -6
  20. package/dist/cli-core.js.map +1 -1
  21. package/dist/collaboration-presence.js +200 -18
  22. package/dist/collaboration-presence.js.map +1 -1
  23. package/dist/database.js +335 -3
  24. package/dist/database.js.map +1 -1
  25. package/dist/docx-export.js +1 -1
  26. package/dist/docx-export.js.map +1 -1
  27. package/dist/domain.js +18 -0
  28. package/dist/domain.js.map +1 -1
  29. package/dist/epub-export.js +319 -0
  30. package/dist/epub-export.js.map +1 -0
  31. package/dist/hybrid-search.js +8 -0
  32. package/dist/hybrid-search.js.map +1 -1
  33. package/dist/image-metadata.js +17 -2
  34. package/dist/image-metadata.js.map +1 -1
  35. package/dist/presence-store.js +160 -0
  36. package/dist/presence-store.js.map +1 -0
  37. package/dist/public/ai-connectivity-test.d.ts +7 -0
  38. package/dist/public/ai-connectivity-test.js +82 -0
  39. package/dist/public/ai-context-meter.js +27 -0
  40. package/dist/public/ai-mentions.js +14 -0
  41. package/dist/public/ai-request-manager.js +99 -0
  42. package/dist/public/ai-stream-protocol.js +51 -0
  43. package/dist/public/app.js +3587 -559
  44. package/dist/public/background-task-center.js +1 -1
  45. package/dist/public/chapter-editor-virtualization.js +52 -0
  46. package/dist/public/chapter-version-diff.d.ts +20 -0
  47. package/dist/public/chapter-version-diff.js +116 -0
  48. package/dist/public/foreshadow-reminder.d.ts +32 -0
  49. package/dist/public/foreshadow-reminder.js +73 -0
  50. package/dist/public/global-replace-refresh.js +60 -0
  51. package/dist/public/index.html +161 -31
  52. package/dist/public/outline-board.d.ts +61 -0
  53. package/dist/public/outline-board.js +137 -0
  54. package/dist/public/page-route.d.ts +1 -0
  55. package/dist/public/page-route.js +8 -0
  56. package/dist/public/presence-client-id.d.ts +10 -0
  57. package/dist/public/presence-client-id.js +31 -0
  58. package/dist/public/reading-preview.d.ts +32 -0
  59. package/dist/public/reading-preview.js +136 -0
  60. package/dist/public/s3-backup-ui.d.ts +10 -0
  61. package/dist/public/s3-backup-ui.js +29 -0
  62. package/dist/public/setting-filters.d.ts +16 -0
  63. package/dist/public/setting-filters.js +19 -0
  64. package/dist/public/styles.css +522 -11
  65. package/dist/public/upload-progress.d.ts +2 -0
  66. package/dist/public/upload-progress.js +10 -0
  67. package/dist/s3-backup.js +197 -12
  68. package/dist/s3-backup.js.map +1 -1
  69. package/dist/security.js +86 -21
  70. package/dist/security.js.map +1 -1
  71. package/dist/server-runtime.js +38 -18
  72. package/dist/server-runtime.js.map +1 -1
  73. package/dist/store.js +899 -104
  74. package/dist/store.js.map +1 -1
  75. package/dist/upload-limits.js +35 -0
  76. package/dist/upload-limits.js.map +1 -0
  77. package/dist/user-auth.js +56 -10
  78. package/dist/user-auth.js.map +1 -1
  79. package/dist/utils.js +3 -0
  80. package/dist/utils.js.map +1 -1
  81. package/dist/version.js +1 -1
  82. package/package.json +4 -2
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";
19
- import { DRAFT_SETTING_MODULES, TASK_TYPES } from "./domain.js";
22
+ import { EPUB_MIME_TYPE, epubContentDisposition } from "./epub-export.js";
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";
@@ -34,11 +38,15 @@ import { currentRequestActor, runWithRequestActor } from "./request-context.js";
34
38
  import { S3BackupManager } from "./s3-backup.js";
35
39
  import { APP_VERSION } from "./version.js";
36
40
  import { ReleaseUpdateChecker } from "./release-update.js";
41
+ import { DEFAULT_IMAGE_UPLOAD_LIMITS, formatUploadLimit } from "./upload-limits.js";
37
42
  import { canReadWorkModule, canWriteWorkModule, fullWorkModulePermissions, proseReplacementPermissionModules } from "./work-permissions.js";
38
- import { CollaborationPresence, entityEditorPageKey, presencePageKinds } from "./collaboration-presence.js";
43
+ import { CollaborationPresence, editorPageKey, entityEditorPageKey, modulePageKey, presencePageKinds } from "./collaboration-presence.js";
44
+ import { PresenceStore } from "./presence-store.js";
39
45
  import { analysisTaskReadModules, clearSessionCookie, createCliApiScopeMiddleware, createUserSessionMiddleware, createWorkAuthorizationMiddleware, relationshipAnalysisReadModules, setSessionCookie, UserAuthService } from "./user-auth.js";
40
46
  const nonEmpty = z.string().trim().min(1);
41
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, "幂等键只能包含英文字母、数字、点、下划线、冒号和短横线");
42
50
  const optionalStrings = z.array(z.string()).optional();
43
51
  const jsonObject = z.record(z.string(), z.unknown());
44
52
  const chapterTypeSchema = z.enum(["正文", "设定", "作者的话", "其他"]);
@@ -47,6 +55,34 @@ const versionedEntityTypeSchema = z.enum(versionedEntityTypes);
47
55
  const attachmentPermissionModuleSchema = z.enum(attachmentPermissionModules);
48
56
  const maximumImportedTextLength = 20_000_000;
49
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
+ }
70
+ function assertImageUploadSize(byteLength, maximumBytes, message) {
71
+ if (byteLength <= maximumBytes)
72
+ return;
73
+ throw new AppError(413, "IMAGE_TOO_LARGE", message);
74
+ }
75
+ function uploadSizeError(pathname, limits) {
76
+ if (pathname === "/api/auth/avatar")
77
+ return { code: "IMAGE_TOO_LARGE", message: `头像图片不能超过 ${formatUploadLimit(limits.avatarBytes)}` };
78
+ if (/^\/api\/works\/[^/]+\/cover$/u.test(pathname)) {
79
+ return { code: "IMAGE_TOO_LARGE", message: `封面图片不能超过 ${formatUploadLimit(limits.coverBytes)}` };
80
+ }
81
+ if (/^\/api\/works\/[^/]+\/attachments$/u.test(pathname)) {
82
+ return { code: "ATTACHMENT_TOO_LARGE", message: `图片附件不能超过 ${formatUploadLimit(limits.attachmentBytes)}` };
83
+ }
84
+ return null;
85
+ }
50
86
  const captchaFields = {
51
87
  captchaId: z.string().trim().min(1).max(200),
52
88
  captchaAnswer: z.string().trim().min(1).max(16)
@@ -153,7 +189,7 @@ const settingSchema = z.object({
153
189
  const globalReplaceSchema = z.object({
154
190
  find: z.string().min(1).max(500),
155
191
  replacement: z.string().max(200_000),
156
- scope: z.enum(["prose", "settings", "prose-and-settings"]).default("prose")
192
+ scope: z.enum(["prose", "settings", "prose-and-settings"])
157
193
  }).strict();
158
194
  const draftSchema = z.object({
159
195
  draftType: z.enum(["prose", "setting"]),
@@ -446,6 +482,12 @@ const s3BackupRunQuerySchema = z.object({
446
482
  afterSequence: z.coerce.number().int().min(0).optional(),
447
483
  limit: z.coerce.number().int().min(1).max(100).optional()
448
484
  }).strict();
485
+ const s3BackupEncryptionSchema = z.object({
486
+ enabled: z.boolean()
487
+ }).strict();
488
+ const s3BackupEncryptionConfirmationSchema = z.object({
489
+ confirmationToken: z.string().regex(/^[A-Za-z0-9_-]{43}$/u, "备份加密确认令牌格式无效")
490
+ }).strict();
449
491
  const aiToolCallResultSchema = z.object({
450
492
  id: z.string().min(1).max(300),
451
493
  name: z.string().min(1).max(200),
@@ -517,7 +559,8 @@ const contextSchema = z.object({
517
559
  includeBookSummary: z.boolean().optional(),
518
560
  includeSettingInfo: z.boolean().optional()
519
561
  });
520
- const analysisTaskTypeSchema = z.enum(["structure", "chapter-analysis", "character-extraction", "character-summary", "character-identity-audit", "timeline-analysis", "worldview-analysis", "setting-extraction", "consistency-check", "report-update", "book-analysis"]);
562
+ /** 创建任务 API 的分析类型校验:仅允许可新建类型,历史类型在运行层保留防御性拒绝。 */
563
+ export const creatableAnalysisTaskTypeSchema = z.enum(CREATABLE_ANALYSIS_TASK_TYPES);
521
564
  const relationshipSourceRefSchema = z.object({
522
565
  sourceType: z.string().trim().min(1).max(50).regex(/^[a-z][a-z-]*$/u),
523
566
  sourceId: identifier,
@@ -561,7 +604,7 @@ const relationshipAnalysisScopeSchema = z.object({
561
604
  });
562
605
  const analysisTaskSchema = z.union([
563
606
  z.object({ taskType: z.literal("relationship-analysis"), scope: relationshipAnalysisScopeSchema.optional(), modelId: identifier.optional() }).strict(),
564
- z.object({ taskType: analysisTaskTypeSchema, scope: jsonObject.optional(), modelId: identifier.optional() }).strict().superRefine((input, context) => {
607
+ z.object({ taskType: creatableAnalysisTaskTypeSchema, scope: jsonObject.optional(), modelId: identifier.optional() }).strict().superRefine((input, context) => {
565
608
  if (input.scope?.includeAllSettings !== undefined) {
566
609
  context.addIssue({ code: z.ZodIssueCode.custom, path: ["scope", "includeAllSettings"], message: "包含所有设定仅支持人物关系分析" });
567
610
  }
@@ -582,12 +625,53 @@ const analysisTaskSchema = z.union([
582
625
  }
583
626
  })
584
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
+ });
656
+ export const RUNTIME_BACKUP_IDLE_TIMEOUT_MS = 9_000;
585
657
  function data(response, value, status = 200) {
586
658
  response.status(status).json({ data: value });
587
659
  }
588
660
  function noContent(response) {
589
661
  response.status(204).end();
590
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
+ }
591
675
  function parse(schema, value) {
592
676
  return schema.parse(value);
593
677
  }
@@ -790,11 +874,18 @@ function redactSuggestion(record, permissions) {
790
874
  };
791
875
  }
792
876
  function redactAiConversationMessage(item, permissions) {
793
- if (permissions.prose !== "none")
794
- return item;
795
877
  const message = recordValue(item);
796
878
  if (!message)
797
879
  return item;
880
+ if (permissions.prose !== "none") {
881
+ if (permissions.characters !== "none")
882
+ return item;
883
+ const metadata = recordValue(message.metadata);
884
+ if (!metadata || !("mentionCharacterIds" in metadata))
885
+ return item;
886
+ const { mentionCharacterIds: _mentionCharacterIds, ...readableMetadata } = metadata;
887
+ return { ...message, metadata: readableMetadata };
888
+ }
798
889
  return {
799
890
  ...message,
800
891
  content: proseRestrictedPlaceholder,
@@ -807,25 +898,25 @@ function redactAiConversationMessage(item, permissions) {
807
898
  function redactAiConversation(record, permissions) {
808
899
  const readableRecord = permissions.characters === "none" ? { ...record, roleplayCharacter: null } : record;
809
900
  const scopedRecord = redactAiCallContext(readableRecord, permissions);
810
- if (permissions.prose !== "none")
811
- return scopedRecord;
812
901
  const result = {
813
- ...scopedRecord,
814
- title: proseRestrictedPlaceholder
902
+ ...scopedRecord
815
903
  };
816
- if (typeof result.preview === "string" && result.preview.length > 0) {
817
- result.preview = proseRestrictedPlaceholder;
818
- }
819
- if (Array.isArray(result.messages)) {
904
+ if ((permissions.prose === "none" || permissions.characters === "none") && Array.isArray(result.messages)) {
820
905
  result.messages = result.messages.map((item) => redactAiConversationMessage(item, permissions));
821
906
  }
822
907
  const messagesPage = recordValue(result.messagesPage);
823
- if (messagesPage && Array.isArray(messagesPage.items)) {
908
+ if ((permissions.prose === "none" || permissions.characters === "none") && messagesPage && Array.isArray(messagesPage.items)) {
824
909
  result.messagesPage = {
825
910
  ...messagesPage,
826
911
  items: messagesPage.items.map((item) => redactAiConversationMessage(item, permissions))
827
912
  };
828
913
  }
914
+ if (permissions.prose !== "none")
915
+ return result;
916
+ result.title = proseRestrictedPlaceholder;
917
+ if (typeof result.preview === "string" && result.preview.length > 0) {
918
+ result.preview = proseRestrictedPlaceholder;
919
+ }
829
920
  return { ...result, restricted: true };
830
921
  }
831
922
  /** SSE 错误事件只暴露 AppError 的公开信息;AI_CALL_FAILED 的 failure 已在 AI 层完成密钥脱敏。 */
@@ -843,7 +934,9 @@ export function publicAiStreamError(error) {
843
934
  ...(typeof details?.providerName === "string" ? { providerName: details.providerName } : {}),
844
935
  ...(typeof details?.providerId === "string" ? { providerId: details.providerId } : {}),
845
936
  ...(typeof details?.modelId === "string" ? { modelId: details.modelId } : {}),
846
- ...(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 } : {})
847
940
  };
848
941
  }
849
942
  return { code: "AI_STREAM_FAILED", message: "AI 流式调用失败" };
@@ -865,6 +958,7 @@ function redactVersionSnapshots(value, mapper) {
865
958
  });
866
959
  }
867
960
  export function createRuntime(options) {
961
+ const uploadLimits = options.uploadLimits ?? DEFAULT_IMAGE_UPLOAD_LIMITS;
868
962
  logger.info("runtime.initializing", {
869
963
  databasePath: options.databasePath,
870
964
  serveUi: options.serveUi ?? true,
@@ -878,19 +972,35 @@ export function createRuntime(options) {
878
972
  const temporaryAttachmentRoot = options.databasePath === ":memory:" && !options.attachmentDirectory
879
973
  ? mkdtempSync(join(tmpdir(), "scriverse-attachments-"))
880
974
  : null;
881
- const attachmentStorage = new AttachmentStorage(options.attachmentDirectory ?? temporaryAttachmentRoot ?? join(dirname(options.databasePath), "attachments"));
975
+ const attachmentStorage = new AttachmentStorage(options.attachmentDirectory ?? temporaryAttachmentRoot ?? join(dirname(options.databasePath), "attachments"), uploadLimits.attachmentBytes);
882
976
  mkdirSync(attachmentStorage.temporaryDirectory, { recursive: true, mode: 0o700 });
883
977
  const auth = new UserAuthService(database);
884
- const collaborationPresence = new CollaborationPresence();
885
- const publishRelationshipChange = (workId, relationshipId) => {
978
+ const collaborationPresence = new CollaborationPresence(45_000, Date.now, 120_000, 50, { store: new PresenceStore(database) });
979
+ const publishCollaborativeChange = (workId, pageKey, options = {}) => {
886
980
  const actor = currentRequestActor();
887
- if (!actor || !workId || !relationshipId)
981
+ if (!actor || !workId || !pageKey)
888
982
  return;
889
- collaborationPresence.publishChange(workId, entityEditorPageKey("relationship", relationshipId), {
983
+ collaborationPresence.publishChange(workId, pageKey, {
890
984
  userId: actor.userId,
891
985
  displayName: actor.displayName
892
- });
986
+ }, options);
987
+ };
988
+ const publishEditorChange = (workId, chapterId, options = {}) => {
989
+ if (!chapterId)
990
+ return;
991
+ publishCollaborativeChange(workId, editorPageKey(chapterId), options);
893
992
  };
993
+ const publishEntityChange = (workId, module, resourceId, options = {}) => {
994
+ if (!resourceId)
995
+ return;
996
+ publishCollaborativeChange(workId, entityEditorPageKey(module, resourceId), options);
997
+ };
998
+ const publishModuleChange = (workId, module, options = {}) => {
999
+ if (!module)
1000
+ return;
1001
+ publishCollaborativeChange(workId, modulePageKey(module), options);
1002
+ };
1003
+ const deletedPageChange = { action: "delete", pageDeleted: true };
894
1004
  const getDevelopmentUser = () => options.devAuthBypass
895
1005
  ? auth.listUsers().find((user) => user.status === "active") ?? null
896
1006
  : null;
@@ -942,7 +1052,9 @@ export function createRuntime(options) {
942
1052
  timeoutMs: options.releaseCheckTimeoutMs,
943
1053
  retries: options.releaseCheckRetries
944
1054
  });
945
- const ai = new AiManager(store, credentialVault, options.fetchImpl ?? fetch, options.security ? (url) => assertSafeAiEndpoint(url, options.security?.allowPrivateAiEndpoints) : undefined, (task, actor) => {
1055
+ const ai = new AiManager(store, credentialVault, options.fetchImpl ?? fetch, options.developmentServer === true
1056
+ ? undefined
1057
+ : options.security ? (url) => assertSafeAiEndpoint(url, options.security?.allowPrivateAiEndpoints) : undefined, (task, actor) => {
946
1058
  const requiredModules = analysisTaskReadModules(task.taskType, task.scope);
947
1059
  const creator = actor ? null : database.get("SELECT created_by_user_id FROM analysis_tasks WHERE id = ?", String(task.id));
948
1060
  const userId = actor?.userId ?? (typeof creator?.created_by_user_id === "string" ? creator.created_by_user_id : null);
@@ -955,7 +1067,7 @@ export function createRuntime(options) {
955
1067
  read: requiredModules,
956
1068
  write: ["ai-analysis"]
957
1069
  }, false, actor?.allowAdminAccess ?? false);
958
- }, attachmentStorage);
1070
+ }, attachmentStorage, { interactiveStreamIdleTimeoutMs: options.aiStreamIdleTimeoutMs ?? DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS });
959
1071
  const app = express();
960
1072
  enforceCaseInsensitiveRouting(app);
961
1073
  const upload = multer({
@@ -964,18 +1076,18 @@ export function createRuntime(options) {
964
1076
  });
965
1077
  const coverUpload = multer({
966
1078
  storage: multer.memoryStorage(),
967
- limits: { fileSize: 5 * 1024 * 1024, files: 1, fields: 4, fieldSize: 16 * 1024, parts: 5, headerPairs: 100 }
1079
+ limits: { fileSize: uploadLimits.coverBytes + 1, files: 1, fields: 4, fieldSize: 16 * 1024, parts: 5, headerPairs: 100 }
968
1080
  });
969
1081
  const avatarUpload = multer({
970
1082
  storage: multer.memoryStorage(),
971
- limits: { fileSize: 5 * 1024 * 1024, files: 1, fields: 1, fieldSize: 1024, parts: 2, headerPairs: 50 }
1083
+ limits: { fileSize: uploadLimits.avatarBytes + 1, files: 1, fields: 1, fieldSize: 1024, parts: 2, headerPairs: 50 }
972
1084
  });
973
1085
  const attachmentUpload = multer({
974
1086
  storage: multer.diskStorage({
975
1087
  destination: attachmentStorage.temporaryDirectory,
976
1088
  filename: (_request, _file, callback) => callback(null, randomUUID())
977
1089
  }),
978
- limits: { fileSize: 30 * 1024 * 1024, files: 1, fields: 4, fieldSize: 16 * 1024, parts: 5, headerPairs: 100 }
1090
+ limits: { fileSize: uploadLimits.attachmentBytes + 1, files: 1, fields: 4, fieldSize: 16 * 1024, parts: 5, headerPairs: 100 }
979
1091
  });
980
1092
  app.disable("x-powered-by");
981
1093
  const trustProxy = resolveTrustProxySetting(options.security?.trustProxy);
@@ -993,7 +1105,8 @@ export function createRuntime(options) {
993
1105
  version: APP_VERSION,
994
1106
  protocol: "openai-chat-completions",
995
1107
  protocols: [...AI_PROVIDER_PROTOCOLS],
996
- development: options.developmentServer === true
1108
+ development: options.developmentServer === true,
1109
+ uploadLimits
997
1110
  });
998
1111
  });
999
1112
  app.get("/api/update-check", async (_request, response) => {
@@ -1086,7 +1199,8 @@ export function createRuntime(options) {
1086
1199
  if (!request.authUser)
1087
1200
  throw new AppError(401, "AUTH_REQUIRED", "请先登录");
1088
1201
  if (!request.file)
1089
- throw new AppError(400, "FILE_REQUIRED", "请选择 PNG、JPEG 或 WebP 头像");
1202
+ throw new AppError(400, "FILE_REQUIRED", "请选择 PNG、JPEG、WebPGIF 头像");
1203
+ assertImageUploadSize(request.file.buffer.byteLength, uploadLimits.avatarBytes, `头像图片不能超过 ${formatUploadLimit(uploadLimits.avatarBytes)}`);
1090
1204
  try {
1091
1205
  const metadata = readRasterImageMetadata(request.file.buffer);
1092
1206
  const updated = database.transaction(() => {
@@ -1172,6 +1286,23 @@ export function createRuntime(options) {
1172
1286
  const pagination = parsePagination(request.query);
1173
1287
  data(response, pagination ? store.listWorksPage(pagination) : store.listWorks());
1174
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
+ });
1175
1306
  app.post("/api/works", (request, response) => data(response, store.createWork(parse(workSchema, request.body)), 201));
1176
1307
  app.post("/api/works/import", upload.single("file"), async (request, response) => {
1177
1308
  if (!request.file)
@@ -1256,7 +1387,6 @@ export function createRuntime(options) {
1256
1387
  app.delete("/api/works/:workId", async (request, response) => {
1257
1388
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1258
1389
  store.deleteWork(request.params.workId, input.expectedVersionNo);
1259
- await cleanupAttachments();
1260
1390
  noContent(response);
1261
1391
  });
1262
1392
  app.get("/api/works/:workId/cover", (request, response) => {
@@ -1271,8 +1401,11 @@ export function createRuntime(options) {
1271
1401
  if (!request.file)
1272
1402
  throw new AppError(400, "FILE_REQUIRED", "请选择 PNG、JPEG 或 WebP 封面");
1273
1403
  const bytes = request.file.buffer;
1404
+ assertImageUploadSize(bytes.byteLength, uploadLimits.coverBytes, `封面图片不能超过 ${formatUploadLimit(uploadLimits.coverBytes)}`);
1274
1405
  try {
1275
1406
  const metadata = readRasterImageMetadata(bytes);
1407
+ if (metadata.mimeType === "image/gif")
1408
+ throw new AppError(415, "UNSUPPORTED_COVER_FORMAT", "封面不支持 GIF 图片");
1276
1409
  const expectedVersionNo = parse(expectedVersionNoSchema, request.body.expectedVersionNo);
1277
1410
  data(response, store.setWorkCover(String(request.params.workId), metadata.mimeType, bytes, expectedVersionNo));
1278
1411
  }
@@ -1333,11 +1466,31 @@ export function createRuntime(options) {
1333
1466
  ? store.listVolumeChaptersPage(request.params.volumeId, pagination)
1334
1467
  : store.listVolumeChapters(request.params.volumeId));
1335
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
+ });
1336
1479
  app.delete("/api/volumes/:volumeId", (request, response) => {
1337
1480
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1338
1481
  store.deleteVolume(request.params.volumeId, input.expectedVersionNo);
1339
1482
  noContent(response);
1340
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
+ });
1341
1494
  app.post("/api/works/:workId/chapters", (request, response) => {
1342
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);
1343
1496
  data(response, store.createChapter(request.params.workId, input), 201);
@@ -1348,16 +1501,22 @@ export function createRuntime(options) {
1348
1501
  ? store.listDeletedChaptersPage(request.params.workId, pagination)
1349
1502
  : store.listDeletedChapters(request.params.workId));
1350
1503
  });
1504
+ app.get("/api/works/:workId/recycle-bin", (request, response) => {
1505
+ data(response, store.getRecycleBin(request.params.workId));
1506
+ });
1351
1507
  app.get("/api/chapters/:chapterId", (request, response) => data(response, store.getChapter(request.params.chapterId)));
1352
1508
  app.patch("/api/chapters/:chapterId", (request, response) => {
1353
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);
1354
1510
  const { source, changeNote, expectedVersionNo, ...chapterInput } = input;
1355
1511
  const chapter = store.saveChapter(request.params.chapterId, chapterInput, source ?? "manual", null, changeNote, expectedVersionNo);
1512
+ publishEditorChange(String(chapter.workId), String(chapter.id));
1356
1513
  data(response, chapter);
1357
1514
  });
1358
1515
  app.delete("/api/chapters/:chapterId", (request, response) => {
1359
1516
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1517
+ const chapter = store.getChapter(request.params.chapterId);
1360
1518
  store.deleteChapter(request.params.chapterId, input.expectedVersionNo);
1519
+ publishEditorChange(String(chapter.workId), String(chapter.id), deletedPageChange);
1361
1520
  noContent(response);
1362
1521
  });
1363
1522
  app.delete("/api/chapters/:chapterId/permanent", (request, response) => {
@@ -1424,6 +1583,9 @@ export function createRuntime(options) {
1424
1583
  const pagination = parsePagination(request.query);
1425
1584
  data(response, pagination ? store.listChapterOutlinesPage(request.params.workId, pagination) : store.listChapterOutlines(request.params.workId));
1426
1585
  });
1586
+ app.get("/api/works/:workId/outline-board", (request, response) => {
1587
+ data(response, store.getChapterOutlineBoard(request.params.workId));
1588
+ });
1427
1589
  app.get("/api/chapters/:chapterId/outline", (request, response) => data(response, store.getChapterOutline(request.params.chapterId)));
1428
1590
  app.put("/api/chapters/:chapterId/outline", (request, response) => {
1429
1591
  const { changeNote, expectedVersionNo, ...input } = parse(chapterOutlineSchema.extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
@@ -1435,6 +1597,13 @@ export function createRuntime(options) {
1435
1597
  store.deleteChapterOutline(request.params.chapterId, input.expectedVersionNo);
1436
1598
  noContent(response);
1437
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
+ });
1438
1607
  app.get("/api/works/:workId/foreshadows", (request, response) => {
1439
1608
  const query = parse(z.object({
1440
1609
  status: z.enum(["all", "unresolved", "resolved"]).default("all"),
@@ -1518,11 +1687,14 @@ export function createRuntime(options) {
1518
1687
  app.patch("/api/settings/:settingId", (request, response) => {
1519
1688
  const { changeNote, expectedVersionNo, ...input } = parse(settingSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1520
1689
  const setting = store.updateSetting(request.params.settingId, input, "manual", null, changeNote, expectedVersionNo);
1690
+ publishEntityChange(String(setting.workId), "setting", String(setting.id));
1521
1691
  data(response, setting);
1522
1692
  });
1523
1693
  app.delete("/api/settings/:settingId", (request, response) => {
1524
1694
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1695
+ const setting = store.getSetting(request.params.settingId);
1525
1696
  store.deleteSetting(request.params.settingId, input.expectedVersionNo);
1697
+ publishEntityChange(String(setting.workId), "setting", String(setting.id), deletedPageChange);
1526
1698
  noContent(response);
1527
1699
  });
1528
1700
  app.get("/api/works/:workId/characters", (request, response) => {
@@ -1548,6 +1720,7 @@ export function createRuntime(options) {
1548
1720
  app.patch("/api/characters/:characterId", (request, response) => {
1549
1721
  const { changeNote, expectedVersionNo, ...input } = parse(characterUpdateSchema.extend({ expectedVersionNo: expectedVersionNoSchema }), request.body);
1550
1722
  const character = store.updateCharacter(request.params.characterId, input, "manual", null, changeNote, expectedVersionNo);
1723
+ publishEntityChange(String(character.workId), "character", String(character.id));
1551
1724
  data(response, redactCharacterLinks(character, requestPermissions(request)));
1552
1725
  });
1553
1726
  app.get("/api/characters/:characterId/versions", (request, response) => {
@@ -1563,7 +1736,9 @@ export function createRuntime(options) {
1563
1736
  });
1564
1737
  app.delete("/api/characters/:characterId", (request, response) => {
1565
1738
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1739
+ const character = store.getCharacter(request.params.characterId);
1566
1740
  store.deleteCharacter(request.params.characterId, input.expectedVersionNo);
1741
+ publishEntityChange(String(character.workId), "character", String(character.id), deletedPageChange);
1567
1742
  noContent(response);
1568
1743
  });
1569
1744
  app.post("/api/characters/:characterId/merge", (request, response) => {
@@ -1596,11 +1771,17 @@ export function createRuntime(options) {
1596
1771
  app.patch("/api/character-sections/:sectionId", (request, response) => {
1597
1772
  const { changeNote, expectedVersionNo, ...input } = parse(characterProfileSectionSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1598
1773
  const section = store.updateCharacterProfileSection(request.params.sectionId, input, "manual", null, changeNote, expectedVersionNo);
1774
+ publishEntityChange(String(section.workId), "character", String(section.characterId));
1599
1775
  data(response, section);
1600
1776
  });
1601
1777
  app.delete("/api/character-sections/:sectionId", (request, response) => {
1602
1778
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1779
+ const section = store.getCharacterProfileSection(request.params.sectionId);
1603
1780
  store.deleteCharacterProfileSection(request.params.sectionId, input.expectedVersionNo);
1781
+ publishEntityChange(String(section.workId), "character", String(section.characterId), {
1782
+ action: "delete",
1783
+ label: "角色档案章节"
1784
+ });
1604
1785
  noContent(response);
1605
1786
  });
1606
1787
  app.get("/api/character-sections/:sectionId/versions", (request, response) => {
@@ -1699,11 +1880,14 @@ export function createRuntime(options) {
1699
1880
  app.patch("/api/races/:raceId", (request, response) => {
1700
1881
  const { changeNote, expectedVersionNo, ...input } = parse(raceSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1701
1882
  const race = store.updateRace(request.params.raceId, input, "manual", null, changeNote, expectedVersionNo);
1883
+ publishEntityChange(String(race.workId), "race", String(race.id));
1702
1884
  data(response, redactRaceMembers(race, requestPermissions(request)));
1703
1885
  });
1704
1886
  app.delete("/api/races/:raceId", (request, response) => {
1705
1887
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1888
+ const race = store.getRace(request.params.raceId);
1706
1889
  store.deleteRace(request.params.raceId, input.expectedVersionNo);
1890
+ publishEntityChange(String(race.workId), "race", String(race.id), deletedPageChange);
1707
1891
  noContent(response);
1708
1892
  });
1709
1893
  app.post("/api/races/:raceId/merge", (request, response) => {
@@ -1729,11 +1913,14 @@ export function createRuntime(options) {
1729
1913
  app.patch("/api/organizations/:organizationId", (request, response) => {
1730
1914
  const { changeNote, expectedVersionNo, ...input } = parse(organizationSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1731
1915
  const organization = store.updateOrganization(request.params.organizationId, input, "manual", null, changeNote, expectedVersionNo);
1916
+ publishEntityChange(String(organization.workId), "organization", String(organization.id));
1732
1917
  data(response, redactOrganizationMembers(organization, requestPermissions(request)));
1733
1918
  });
1734
1919
  app.delete("/api/organizations/:organizationId", (request, response) => {
1735
1920
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1921
+ const organization = store.getOrganization(request.params.organizationId);
1736
1922
  store.deleteOrganization(request.params.organizationId, input.expectedVersionNo);
1923
+ publishEntityChange(String(organization.workId), "organization", String(organization.id), deletedPageChange);
1737
1924
  noContent(response);
1738
1925
  });
1739
1926
  app.post("/api/organizations/:organizationId/merge", (request, response) => {
@@ -1754,11 +1941,14 @@ export function createRuntime(options) {
1754
1941
  app.patch("/api/timeline-tracks/:trackId", (request, response) => {
1755
1942
  const { changeNote, expectedVersionNo, ...input } = parse(timelineTrackSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1756
1943
  const track = store.updateTimelineTrack(request.params.trackId, input, "manual", null, changeNote, expectedVersionNo);
1944
+ publishModuleChange(String(track.workId), "timeline");
1757
1945
  data(response, track);
1758
1946
  });
1759
1947
  app.delete("/api/timeline-tracks/:trackId", (request, response) => {
1760
1948
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1949
+ const track = store.getTimelineTrack(request.params.trackId);
1761
1950
  store.deleteTimelineTrack(request.params.trackId, input.expectedVersionNo);
1951
+ publishModuleChange(String(track.workId), "timeline", { action: "delete", label: "时间轴轨道" });
1762
1952
  noContent(response);
1763
1953
  });
1764
1954
  app.get("/api/works/:workId/timeline", (request, response) => {
@@ -1786,6 +1976,7 @@ export function createRuntime(options) {
1786
1976
  app.patch("/api/timeline/:eventId", (request, response) => {
1787
1977
  const { changeNote, expectedVersionNo, ...input } = parse(timelineSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1788
1978
  const event = store.updateTimelineEvent(request.params.eventId, input, "manual", null, changeNote, expectedVersionNo);
1979
+ publishModuleChange(String(event.workId), "timeline");
1789
1980
  data(response, event);
1790
1981
  });
1791
1982
  app.post("/api/timeline/:eventId/split", (request, response) => {
@@ -1803,7 +1994,9 @@ export function createRuntime(options) {
1803
1994
  });
1804
1995
  app.delete("/api/timeline/:eventId", (request, response) => {
1805
1996
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1997
+ const event = store.getTimelineEvent(request.params.eventId);
1806
1998
  store.deleteTimelineEvent(request.params.eventId, input.expectedVersionNo);
1999
+ publishModuleChange(String(event.workId), "timeline", { action: "delete", label: "时间轴事件" });
1807
2000
  noContent(response);
1808
2001
  });
1809
2002
  app.get("/api/works/:workId/relationships", (request, response) => {
@@ -1823,14 +2016,14 @@ export function createRuntime(options) {
1823
2016
  app.patch("/api/relationships/:relationshipId", (request, response) => {
1824
2017
  const { changeNote, expectedVersionNo, ...input } = parse(relationshipSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1825
2018
  const relationship = store.updateRelationship(request.params.relationshipId, input, "manual", null, changeNote, expectedVersionNo);
1826
- publishRelationshipChange(String(relationship.workId), String(relationship.id));
2019
+ publishEntityChange(String(relationship.workId), "relationship", String(relationship.id));
1827
2020
  data(response, relationship);
1828
2021
  });
1829
2022
  app.delete("/api/relationships/:relationshipId", (request, response) => {
1830
2023
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1831
2024
  const relationship = store.getRelationship(request.params.relationshipId);
1832
2025
  store.deleteRelationship(request.params.relationshipId, input.expectedVersionNo);
1833
- publishRelationshipChange(String(relationship.workId), String(relationship.id));
2026
+ publishEntityChange(String(relationship.workId), "relationship", String(relationship.id), deletedPageChange);
1834
2027
  noContent(response);
1835
2028
  });
1836
2029
  app.get("/api/entity-versions/:entityType/:entityId", (request, response) => {
@@ -1917,6 +2110,9 @@ export function createRuntime(options) {
1917
2110
  data(response, ai.resumeAutoRun(request.params.workId));
1918
2111
  });
1919
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
+ });
1920
2116
  app.get("/api/tasks/:taskId/result", (request, response) => {
1921
2117
  const task = store.getTaskResultPayload(request.params.taskId);
1922
2118
  const permissions = requestPermissions(request);
@@ -1958,6 +2154,14 @@ export function createRuntime(options) {
1958
2154
  data(response, redactTaskCharacterNames(ai.rerunTask(request.params.taskId, input.modelId), requestPermissions(request)), 201);
1959
2155
  });
1960
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
+ });
1961
2165
  app.post("/api/tasks/:taskId/relationship-changes/apply", (request, response) => {
1962
2166
  parse(z.object({}).strict(), request.body ?? {});
1963
2167
  const applied = ai.applyRelationshipChangePreview(request.params.taskId);
@@ -1992,6 +2196,17 @@ export function createRuntime(options) {
1992
2196
  app.patch("/api/platform/ui-settings", (request, response) => {
1993
2197
  data(response, store.updatePlatformUiSettings(parse(platformUiSettingsSchema, request.body)));
1994
2198
  });
2199
+ app.get("/api/platform/backups/encryption", (_request, response) => {
2200
+ data(response, backups.getEncryptionState());
2201
+ });
2202
+ app.post("/api/platform/backups/encryption", (request, response) => {
2203
+ const input = parse(s3BackupEncryptionSchema, request.body);
2204
+ data(response, backups.setEncryptionEnabled(input.enabled));
2205
+ });
2206
+ app.post("/api/platform/backups/encryption/confirm", (request, response) => {
2207
+ const input = parse(s3BackupEncryptionConfirmationSchema, request.body);
2208
+ data(response, backups.confirmEncryptionEnabled(input.confirmationToken));
2209
+ });
1995
2210
  app.get("/api/platform/backups/targets", (_request, response) => data(response, backups.listTargets()));
1996
2211
  app.post("/api/platform/backups/targets", (request, response) => {
1997
2212
  data(response, backups.createTarget(parse(s3BackupTargetBaseSchema, request.body)), 201);
@@ -2084,10 +2299,26 @@ export function createRuntime(options) {
2084
2299
  const permissions = requestPermissions(request, String(conversation.workId));
2085
2300
  data(response, redactAiConversation(conversation, permissions));
2086
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
+ });
2087
2310
  app.post("/api/ai-conversations/:conversationId/fork", (request, response) => {
2088
- const input = parse(z.object({ messageId: identifier, title: z.string().max(200).optional() }), request.body);
2089
- const forked = store.forkAiConversation(request.params.conversationId, input.messageId, input.title);
2090
- 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);
2091
2322
  data(response, redactAiConversation(forked, permissions), 201);
2092
2323
  });
2093
2324
  app.patch("/api/ai-conversations/:conversationId/task-type", (request, response) => {
@@ -2119,6 +2350,9 @@ export function createRuntime(options) {
2119
2350
  outputTokens: z.number().int().min(0).max(10_000_000).optional(),
2120
2351
  cacheHitPercent: z.number().min(0).max(100).optional(),
2121
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(),
2122
2356
  toolCalls: z.array(aiToolCallResultSchema).max(12).optional(),
2123
2357
  processSteps: z.array(aiProcessStepSchema).max(50).optional()
2124
2358
  }).optional()
@@ -2179,13 +2413,19 @@ export function createRuntime(options) {
2179
2413
  ai.deleteProvider(request.params.providerId);
2180
2414
  noContent(response);
2181
2415
  });
2182
- 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
+ });
2183
2420
  app.get("/api/providers/:providerId/models", (request, response) => {
2184
2421
  const pagination = parsePagination(request.query);
2185
2422
  data(response, pagination ? ai.listModelsPage(request.params.providerId, pagination) : ai.listModels(request.params.providerId));
2186
2423
  });
2187
2424
  app.post("/api/providers/:providerId/models", (request, response) => data(response, ai.createModel(request.params.providerId, parse(modelSchema, request.body)), 201));
2188
- 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
+ });
2189
2429
  app.get("/api/models/:modelId", (request, response) => data(response, ai.getModel(request.params.modelId)));
2190
2430
  app.patch("/api/models/:modelId", (request, response) => data(response, ai.updateModel(request.params.modelId, parse(modelSchema.partial(), request.body))));
2191
2431
  app.delete("/api/models/:modelId", (request, response) => {
@@ -2246,44 +2486,161 @@ export function createRuntime(options) {
2246
2486
  conversationId: identifier.optional(),
2247
2487
  currentMessageId: identifier.optional()
2248
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 }));
2249
2495
  const citations = input.citations ?? [];
2250
2496
  for (const citation of citations) {
2251
2497
  if (store.getChapter(citation.chapterId).workId !== request.params.workId)
2252
2498
  throw new AppError(400, "CITATION_WORK_MISMATCH", "引用章节不属于当前作品");
2253
2499
  }
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);
2254
2515
  const controller = new AbortController();
2255
2516
  response.on("close", () => {
2256
2517
  if (!response.writableEnded)
2257
2518
  controller.abort(new Error("浏览器已中断流式请求"));
2258
2519
  });
2259
- response.status(200);
2260
- response.setHeader("Content-Type", "text/event-stream; charset=utf-8");
2261
- response.setHeader("Cache-Control", "no-cache, no-transform");
2262
- response.setHeader("Connection", "keep-alive");
2263
- response.setHeader("X-Accel-Buffering", "no");
2264
- 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
+ };
2265
2533
  const sendEvent = (event, payload) => {
2534
+ if (streamRequestId && Date.now() - lastStreamLeaseTouchAt >= 30_000) {
2535
+ store.touchAiConversationStreamRequest(streamRequestId);
2536
+ lastStreamLeaseTouchAt = Date.now();
2537
+ }
2266
2538
  if (!response.writableEnded && !response.destroyed)
2267
2539
  response.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
2268
2540
  };
2269
- sendEvent("ready", { streaming: true });
2270
2541
  try {
2271
- const conversation = input.conversationId
2272
- ? store.getAiConversationSummary(input.conversationId)
2273
- : store.createAiConversation(request.params.workId);
2274
- if (String(conversation.workId) !== request.params.workId) {
2275
- 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
+ }
2276
2572
  }
2277
- const conversationId = String(conversation.id);
2278
- const permissions = requestPermissions(request, request.params.workId);
2573
+ const resolvedScope = input.currentMessageId
2574
+ ? input.scope
2575
+ : ai.resolveInstructionMentions({
2576
+ workId: request.params.workId,
2577
+ taskType: "chat",
2578
+ instruction: resolvedInstruction,
2579
+ scope: input.scope,
2580
+ conversationId
2581
+ });
2582
+ const mentionCharacterIds = [...new Set([
2583
+ ...(resolvedScope.characterIds ?? []),
2584
+ ...(resolvedScope.mentionCharacterIds ?? [])
2585
+ ])];
2586
+ const begun = store.beginAiConversationStreamRequest({
2587
+ workId: request.params.workId,
2588
+ conversationId,
2589
+ actorScope,
2590
+ idempotencyKey,
2591
+ requestHash,
2592
+ userMessage: {
2593
+ content: input.instruction,
2594
+ citations,
2595
+ ...(input.currentMessageId ? { existingMessageId: input.currentMessageId } : {}),
2596
+ ...(mentionCharacterIds.length ? { metadata: { mentionCharacterIds } } : {})
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) });
2279
2636
  const prepared = await ai.prepareConversationContext({
2280
2637
  conversationId,
2281
2638
  workId: request.params.workId,
2282
2639
  modelId: input.modelId,
2283
2640
  scope: input.scope,
2284
- instruction: instructionWithCitations(input.instruction, citations),
2285
- excludeConversationMessageId: input.currentMessageId
2286
- });
2641
+ instruction: resolvedInstruction,
2642
+ excludeConversationMessageId: currentMessageId
2643
+ }, { skipWarning: true });
2287
2644
  sendEvent("context", {
2288
2645
  ...prepared,
2289
2646
  conversation: redactAiConversation({
@@ -2291,21 +2648,10 @@ export function createRuntime(options) {
2291
2648
  contextWarningPending: prepared.action === "warn"
2292
2649
  }, permissions)
2293
2650
  });
2294
- if (prepared.action === "warn")
2295
- return;
2296
- const userMessage = input.currentMessageId
2297
- ? null
2298
- : store.addAiConversationMessage(conversationId, {
2299
- role: "user",
2300
- content: input.instruction,
2301
- citations
2302
- });
2303
- const currentMessageId = input.currentMessageId ?? String(userMessage?.id ?? "");
2304
- if (userMessage)
2305
- sendEvent("user_message", { message: redactAiConversationMessage(userMessage, permissions) });
2306
2651
  const suggestion = await ai.createStreamingChat({
2307
2652
  workId: request.params.workId,
2308
- instruction: instructionWithCitations(input.instruction, citations),
2653
+ instruction: resolvedInstruction,
2654
+ // 仍由生成路径基于原始范围持久化累计注入,保证预解析不会吞掉本轮自动命中。
2309
2655
  scope: input.scope,
2310
2656
  signal: controller.signal,
2311
2657
  onToolCall: (toolCall, round) => sendEvent("tool_call", { ...toolCall, round }),
@@ -2317,6 +2663,13 @@ export function createRuntime(options) {
2317
2663
  ...(input.modelId ? { modelId: input.modelId } : {}),
2318
2664
  ...(input.parameters ? { parameters: input.parameters } : {})
2319
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;
2320
2673
  sendEvent("complete", {
2321
2674
  suggestionId: suggestion.id,
2322
2675
  callId: suggestion.callId,
@@ -2339,6 +2692,18 @@ export function createRuntime(options) {
2339
2692
  });
2340
2693
  }
2341
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;
2342
2707
  if (!controller.signal.aborted) {
2343
2708
  logger.error("ai.stream.failed", {
2344
2709
  workId: request.params.workId,
@@ -2348,7 +2713,13 @@ export function createRuntime(options) {
2348
2713
  }
2349
2714
  }
2350
2715
  finally {
2351
- 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)
2352
2723
  response.end();
2353
2724
  }
2354
2725
  });
@@ -2389,7 +2760,7 @@ export function createRuntime(options) {
2389
2760
  });
2390
2761
  app.get("/api/works/:workId/search", async (request, response) => {
2391
2762
  const query = parse(z.object({
2392
- q: z.string().trim().min(1).max(500),
2763
+ q: z.string().trim().min(1).max(MAXIMUM_WORK_SEARCH_QUERY_LENGTH),
2393
2764
  type: z.enum(HYBRID_SEARCH_TYPES).optional(),
2394
2765
  limit: z.coerce.number().int().min(1).max(100).optional()
2395
2766
  }).strict(), request.query);
@@ -2400,8 +2771,13 @@ export function createRuntime(options) {
2400
2771
  includeAgentHistory: permissions["ai-chat"] !== "none"
2401
2772
  }));
2402
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
+ });
2403
2779
  app.get("/api/works/:workId/export", async (request, response) => {
2404
- 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");
2405
2781
  if (format === "json") {
2406
2782
  response.setHeader("Content-Disposition", `attachment; filename=novel-${request.params.workId}.json`);
2407
2783
  data(response, store.exportWork(request.params.workId));
@@ -2427,6 +2803,11 @@ export function createRuntime(options) {
2427
2803
  response.send(await store.exportDocx(request.params.workId));
2428
2804
  return;
2429
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
+ }
2430
2811
  response.type("text/plain");
2431
2812
  response.setHeader("Content-Disposition", `attachment; filename=novel-${request.params.workId}.txt`);
2432
2813
  response.send(store.exportText(request.params.workId, format));
@@ -2513,6 +2894,13 @@ export function createRuntime(options) {
2513
2894
  }
2514
2895
  if (error instanceof multer.MulterError) {
2515
2896
  logger.warn("http.request.upload_rejected", { ...commonFields, uploadCode: error.code });
2897
+ if (error.code === "LIMIT_FILE_SIZE") {
2898
+ const sizeError = uploadSizeError(request.path, uploadLimits);
2899
+ if (sizeError) {
2900
+ response.status(413).json({ error: sizeError });
2901
+ return;
2902
+ }
2903
+ }
2516
2904
  response.status(400).json({ error: { code: "UPLOAD_ERROR", message: error.message } });
2517
2905
  return;
2518
2906
  }
@@ -2532,7 +2920,7 @@ export function createRuntime(options) {
2532
2920
  logger.error("http.request.application_error", logFields);
2533
2921
  else
2534
2922
  logger.warn("http.request.application_error", logFields);
2535
- 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") {
2536
2924
  const retryAfterSeconds = Number(error.details.retryAfterSeconds);
2537
2925
  if (Number.isInteger(retryAfterSeconds) && retryAfterSeconds > 0) {
2538
2926
  response.setHeader("Retry-After", String(retryAfterSeconds));
@@ -2551,14 +2939,44 @@ export function createRuntime(options) {
2551
2939
  });
2552
2940
  backups.startScheduler();
2553
2941
  logger.info("runtime.ready", { serveUi: options.serveUi ?? true });
2554
- return { app, database, store, ai, backups, auth, attachmentStorage, cleanupAttachments, close: () => {
2942
+ let closePromise = null;
2943
+ let stopping = false;
2944
+ let closed = false;
2945
+ const close = () => {
2946
+ if (closed)
2947
+ return Promise.resolve();
2948
+ if (closePromise)
2949
+ return closePromise;
2950
+ if (!stopping) {
2951
+ stopping = true;
2555
2952
  logger.info("runtime.closing");
2556
2953
  backups.dispose();
2557
2954
  ai.dispose();
2558
- database.close();
2559
- if (temporaryAttachmentRoot)
2560
- rmSync(temporaryAttachmentRoot, { recursive: true, force: true });
2561
- logger.info("runtime.closed");
2562
- } };
2955
+ const cancelledStreamRequests = store.cancelActiveAiConversationStreamRequests();
2956
+ if (cancelledStreamRequests > 0)
2957
+ logger.info("ai.stream.requests_cancelled", { count: cancelledStreamRequests });
2958
+ }
2959
+ closePromise = (async () => {
2960
+ try {
2961
+ await backups.waitForIdle(RUNTIME_BACKUP_IDLE_TIMEOUT_MS);
2962
+ collaborationPresence.close();
2963
+ database.close();
2964
+ if (temporaryAttachmentRoot)
2965
+ rmSync(temporaryAttachmentRoot, { recursive: true, force: true });
2966
+ closed = true;
2967
+ logger.info("runtime.closed");
2968
+ }
2969
+ catch (error) {
2970
+ logger.error("runtime.close_failed", { error: sanitizeError(error) });
2971
+ throw error;
2972
+ }
2973
+ finally {
2974
+ if (!closed)
2975
+ closePromise = null;
2976
+ }
2977
+ })();
2978
+ return closePromise;
2979
+ };
2980
+ return { app, database, store, ai, backups, auth, attachmentStorage, cleanupAttachments, close };
2563
2981
  }
2564
2982
  //# sourceMappingURL=app.js.map