@musnows/scriverse 0.7.2 → 0.7.3

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 (48) hide show
  1. package/README.md +5 -0
  2. package/dist/ai.js +248 -250
  3. package/dist/ai.js.map +1 -1
  4. package/dist/app.js +184 -39
  5. package/dist/app.js.map +1 -1
  6. package/dist/attachment-storage.js +28 -12
  7. package/dist/attachment-storage.js.map +1 -1
  8. package/dist/backup-encryption.js +129 -0
  9. package/dist/backup-encryption.js.map +1 -0
  10. package/dist/collaboration-presence.js +200 -18
  11. package/dist/collaboration-presence.js.map +1 -1
  12. package/dist/database.js +162 -3
  13. package/dist/database.js.map +1 -1
  14. package/dist/docx-export.js +1 -1
  15. package/dist/docx-export.js.map +1 -1
  16. package/dist/domain.js +18 -0
  17. package/dist/domain.js.map +1 -1
  18. package/dist/image-metadata.js +17 -2
  19. package/dist/image-metadata.js.map +1 -1
  20. package/dist/presence-store.js +160 -0
  21. package/dist/presence-store.js.map +1 -0
  22. package/dist/public/ai-context-meter.js +27 -0
  23. package/dist/public/ai-mentions.js +14 -0
  24. package/dist/public/app.js +1017 -291
  25. package/dist/public/background-task-center.js +1 -1
  26. package/dist/public/chapter-editor-virtualization.js +52 -0
  27. package/dist/public/index.html +43 -21
  28. package/dist/public/presence-client-id.d.ts +10 -0
  29. package/dist/public/presence-client-id.js +31 -0
  30. package/dist/public/s3-backup-ui.d.ts +10 -0
  31. package/dist/public/s3-backup-ui.js +29 -0
  32. package/dist/public/setting-filters.d.ts +16 -0
  33. package/dist/public/setting-filters.js +19 -0
  34. package/dist/public/styles.css +37 -7
  35. package/dist/s3-backup.js +197 -12
  36. package/dist/s3-backup.js.map +1 -1
  37. package/dist/security.js +82 -20
  38. package/dist/security.js.map +1 -1
  39. package/dist/server-runtime.js +36 -18
  40. package/dist/server-runtime.js.map +1 -1
  41. package/dist/store.js +75 -17
  42. package/dist/store.js.map +1 -1
  43. package/dist/upload-limits.js +35 -0
  44. package/dist/upload-limits.js.map +1 -0
  45. package/dist/user-auth.js +11 -1
  46. package/dist/user-auth.js.map +1 -1
  47. package/dist/version.js +1 -1
  48. package/package.json +3 -2
package/dist/app.js CHANGED
@@ -16,7 +16,7 @@ import { resolveMaxAgentToolCallLimit } from "./ai-tool-results.js";
16
16
  import { CredentialVault } from "./credential-vault.js";
17
17
  import { Database } from "./database.js";
18
18
  import { assertSafeDocxArchive } from "./docx-security.js";
19
- import { DRAFT_SETTING_MODULES, TASK_TYPES } from "./domain.js";
19
+ import { CREATABLE_ANALYSIS_TASK_TYPES, DRAFT_SETTING_MODULES, TASK_TYPES } from "./domain.js";
20
20
  import { AppError } from "./errors.js";
21
21
  import { isOfficialGoogleVertexBaseUrl, parseGoogleServiceAccount } from "./google-vertex-auth.js";
22
22
  import { HYBRID_SEARCH_TYPES } from "./hybrid-search.js";
@@ -34,8 +34,10 @@ import { currentRequestActor, runWithRequestActor } from "./request-context.js";
34
34
  import { S3BackupManager } from "./s3-backup.js";
35
35
  import { APP_VERSION } from "./version.js";
36
36
  import { ReleaseUpdateChecker } from "./release-update.js";
37
+ import { DEFAULT_IMAGE_UPLOAD_LIMITS, formatUploadLimit } from "./upload-limits.js";
37
38
  import { canReadWorkModule, canWriteWorkModule, fullWorkModulePermissions, proseReplacementPermissionModules } from "./work-permissions.js";
38
- import { CollaborationPresence, entityEditorPageKey, presencePageKinds } from "./collaboration-presence.js";
39
+ import { CollaborationPresence, editorPageKey, entityEditorPageKey, modulePageKey, presencePageKinds } from "./collaboration-presence.js";
40
+ import { PresenceStore } from "./presence-store.js";
39
41
  import { analysisTaskReadModules, clearSessionCookie, createCliApiScopeMiddleware, createUserSessionMiddleware, createWorkAuthorizationMiddleware, relationshipAnalysisReadModules, setSessionCookie, UserAuthService } from "./user-auth.js";
40
42
  const nonEmpty = z.string().trim().min(1);
41
43
  const identifier = z.string().trim().min(1).max(200);
@@ -47,6 +49,22 @@ const versionedEntityTypeSchema = z.enum(versionedEntityTypes);
47
49
  const attachmentPermissionModuleSchema = z.enum(attachmentPermissionModules);
48
50
  const maximumImportedTextLength = 20_000_000;
49
51
  const maximumKnowledgeSectionsLength = 4_000_000;
52
+ function assertImageUploadSize(byteLength, maximumBytes, message) {
53
+ if (byteLength <= maximumBytes)
54
+ return;
55
+ throw new AppError(413, "IMAGE_TOO_LARGE", message);
56
+ }
57
+ function uploadSizeError(pathname, limits) {
58
+ if (pathname === "/api/auth/avatar")
59
+ return { code: "IMAGE_TOO_LARGE", message: `头像图片不能超过 ${formatUploadLimit(limits.avatarBytes)}` };
60
+ if (/^\/api\/works\/[^/]+\/cover$/u.test(pathname)) {
61
+ return { code: "IMAGE_TOO_LARGE", message: `封面图片不能超过 ${formatUploadLimit(limits.coverBytes)}` };
62
+ }
63
+ if (/^\/api\/works\/[^/]+\/attachments$/u.test(pathname)) {
64
+ return { code: "ATTACHMENT_TOO_LARGE", message: `图片附件不能超过 ${formatUploadLimit(limits.attachmentBytes)}` };
65
+ }
66
+ return null;
67
+ }
50
68
  const captchaFields = {
51
69
  captchaId: z.string().trim().min(1).max(200),
52
70
  captchaAnswer: z.string().trim().min(1).max(16)
@@ -153,7 +171,7 @@ const settingSchema = z.object({
153
171
  const globalReplaceSchema = z.object({
154
172
  find: z.string().min(1).max(500),
155
173
  replacement: z.string().max(200_000),
156
- scope: z.enum(["prose", "settings", "prose-and-settings"]).default("prose")
174
+ scope: z.enum(["prose", "settings", "prose-and-settings"])
157
175
  }).strict();
158
176
  const draftSchema = z.object({
159
177
  draftType: z.enum(["prose", "setting"]),
@@ -446,6 +464,12 @@ const s3BackupRunQuerySchema = z.object({
446
464
  afterSequence: z.coerce.number().int().min(0).optional(),
447
465
  limit: z.coerce.number().int().min(1).max(100).optional()
448
466
  }).strict();
467
+ const s3BackupEncryptionSchema = z.object({
468
+ enabled: z.boolean()
469
+ }).strict();
470
+ const s3BackupEncryptionConfirmationSchema = z.object({
471
+ confirmationToken: z.string().regex(/^[A-Za-z0-9_-]{43}$/u, "备份加密确认令牌格式无效")
472
+ }).strict();
449
473
  const aiToolCallResultSchema = z.object({
450
474
  id: z.string().min(1).max(300),
451
475
  name: z.string().min(1).max(200),
@@ -517,7 +541,8 @@ const contextSchema = z.object({
517
541
  includeBookSummary: z.boolean().optional(),
518
542
  includeSettingInfo: z.boolean().optional()
519
543
  });
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"]);
544
+ /** 创建任务 API 的分析类型校验:仅允许可新建类型,历史类型在运行层保留防御性拒绝。 */
545
+ export const creatableAnalysisTaskTypeSchema = z.enum(CREATABLE_ANALYSIS_TASK_TYPES);
521
546
  const relationshipSourceRefSchema = z.object({
522
547
  sourceType: z.string().trim().min(1).max(50).regex(/^[a-z][a-z-]*$/u),
523
548
  sourceId: identifier,
@@ -561,7 +586,7 @@ const relationshipAnalysisScopeSchema = z.object({
561
586
  });
562
587
  const analysisTaskSchema = z.union([
563
588
  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) => {
589
+ z.object({ taskType: creatableAnalysisTaskTypeSchema, scope: jsonObject.optional(), modelId: identifier.optional() }).strict().superRefine((input, context) => {
565
590
  if (input.scope?.includeAllSettings !== undefined) {
566
591
  context.addIssue({ code: z.ZodIssueCode.custom, path: ["scope", "includeAllSettings"], message: "包含所有设定仅支持人物关系分析" });
567
592
  }
@@ -582,6 +607,7 @@ const analysisTaskSchema = z.union([
582
607
  }
583
608
  })
584
609
  ]);
610
+ export const RUNTIME_BACKUP_IDLE_TIMEOUT_MS = 9_000;
585
611
  function data(response, value, status = 200) {
586
612
  response.status(status).json({ data: value });
587
613
  }
@@ -790,11 +816,18 @@ function redactSuggestion(record, permissions) {
790
816
  };
791
817
  }
792
818
  function redactAiConversationMessage(item, permissions) {
793
- if (permissions.prose !== "none")
794
- return item;
795
819
  const message = recordValue(item);
796
820
  if (!message)
797
821
  return item;
822
+ if (permissions.prose !== "none") {
823
+ if (permissions.characters !== "none")
824
+ return item;
825
+ const metadata = recordValue(message.metadata);
826
+ if (!metadata || !("mentionCharacterIds" in metadata))
827
+ return item;
828
+ const { mentionCharacterIds: _mentionCharacterIds, ...readableMetadata } = metadata;
829
+ return { ...message, metadata: readableMetadata };
830
+ }
798
831
  return {
799
832
  ...message,
800
833
  content: proseRestrictedPlaceholder,
@@ -807,25 +840,25 @@ function redactAiConversationMessage(item, permissions) {
807
840
  function redactAiConversation(record, permissions) {
808
841
  const readableRecord = permissions.characters === "none" ? { ...record, roleplayCharacter: null } : record;
809
842
  const scopedRecord = redactAiCallContext(readableRecord, permissions);
810
- if (permissions.prose !== "none")
811
- return scopedRecord;
812
843
  const result = {
813
- ...scopedRecord,
814
- title: proseRestrictedPlaceholder
844
+ ...scopedRecord
815
845
  };
816
- if (typeof result.preview === "string" && result.preview.length > 0) {
817
- result.preview = proseRestrictedPlaceholder;
818
- }
819
- if (Array.isArray(result.messages)) {
846
+ if ((permissions.prose === "none" || permissions.characters === "none") && Array.isArray(result.messages)) {
820
847
  result.messages = result.messages.map((item) => redactAiConversationMessage(item, permissions));
821
848
  }
822
849
  const messagesPage = recordValue(result.messagesPage);
823
- if (messagesPage && Array.isArray(messagesPage.items)) {
850
+ if ((permissions.prose === "none" || permissions.characters === "none") && messagesPage && Array.isArray(messagesPage.items)) {
824
851
  result.messagesPage = {
825
852
  ...messagesPage,
826
853
  items: messagesPage.items.map((item) => redactAiConversationMessage(item, permissions))
827
854
  };
828
855
  }
856
+ if (permissions.prose !== "none")
857
+ return result;
858
+ result.title = proseRestrictedPlaceholder;
859
+ if (typeof result.preview === "string" && result.preview.length > 0) {
860
+ result.preview = proseRestrictedPlaceholder;
861
+ }
829
862
  return { ...result, restricted: true };
830
863
  }
831
864
  /** SSE 错误事件只暴露 AppError 的公开信息;AI_CALL_FAILED 的 failure 已在 AI 层完成密钥脱敏。 */
@@ -865,6 +898,7 @@ function redactVersionSnapshots(value, mapper) {
865
898
  });
866
899
  }
867
900
  export function createRuntime(options) {
901
+ const uploadLimits = options.uploadLimits ?? DEFAULT_IMAGE_UPLOAD_LIMITS;
868
902
  logger.info("runtime.initializing", {
869
903
  databasePath: options.databasePath,
870
904
  serveUi: options.serveUi ?? true,
@@ -878,19 +912,35 @@ export function createRuntime(options) {
878
912
  const temporaryAttachmentRoot = options.databasePath === ":memory:" && !options.attachmentDirectory
879
913
  ? mkdtempSync(join(tmpdir(), "scriverse-attachments-"))
880
914
  : null;
881
- const attachmentStorage = new AttachmentStorage(options.attachmentDirectory ?? temporaryAttachmentRoot ?? join(dirname(options.databasePath), "attachments"));
915
+ const attachmentStorage = new AttachmentStorage(options.attachmentDirectory ?? temporaryAttachmentRoot ?? join(dirname(options.databasePath), "attachments"), uploadLimits.attachmentBytes);
882
916
  mkdirSync(attachmentStorage.temporaryDirectory, { recursive: true, mode: 0o700 });
883
917
  const auth = new UserAuthService(database);
884
- const collaborationPresence = new CollaborationPresence();
885
- const publishRelationshipChange = (workId, relationshipId) => {
918
+ const collaborationPresence = new CollaborationPresence(45_000, Date.now, 120_000, 50, { store: new PresenceStore(database) });
919
+ const publishCollaborativeChange = (workId, pageKey, options = {}) => {
886
920
  const actor = currentRequestActor();
887
- if (!actor || !workId || !relationshipId)
921
+ if (!actor || !workId || !pageKey)
888
922
  return;
889
- collaborationPresence.publishChange(workId, entityEditorPageKey("relationship", relationshipId), {
923
+ collaborationPresence.publishChange(workId, pageKey, {
890
924
  userId: actor.userId,
891
925
  displayName: actor.displayName
892
- });
926
+ }, options);
893
927
  };
928
+ const publishEditorChange = (workId, chapterId, options = {}) => {
929
+ if (!chapterId)
930
+ return;
931
+ publishCollaborativeChange(workId, editorPageKey(chapterId), options);
932
+ };
933
+ const publishEntityChange = (workId, module, resourceId, options = {}) => {
934
+ if (!resourceId)
935
+ return;
936
+ publishCollaborativeChange(workId, entityEditorPageKey(module, resourceId), options);
937
+ };
938
+ const publishModuleChange = (workId, module, options = {}) => {
939
+ if (!module)
940
+ return;
941
+ publishCollaborativeChange(workId, modulePageKey(module), options);
942
+ };
943
+ const deletedPageChange = { action: "delete", pageDeleted: true };
894
944
  const getDevelopmentUser = () => options.devAuthBypass
895
945
  ? auth.listUsers().find((user) => user.status === "active") ?? null
896
946
  : null;
@@ -942,7 +992,9 @@ export function createRuntime(options) {
942
992
  timeoutMs: options.releaseCheckTimeoutMs,
943
993
  retries: options.releaseCheckRetries
944
994
  });
945
- const ai = new AiManager(store, credentialVault, options.fetchImpl ?? fetch, options.security ? (url) => assertSafeAiEndpoint(url, options.security?.allowPrivateAiEndpoints) : undefined, (task, actor) => {
995
+ const ai = new AiManager(store, credentialVault, options.fetchImpl ?? fetch, options.developmentServer === true
996
+ ? undefined
997
+ : options.security ? (url) => assertSafeAiEndpoint(url, options.security?.allowPrivateAiEndpoints) : undefined, (task, actor) => {
946
998
  const requiredModules = analysisTaskReadModules(task.taskType, task.scope);
947
999
  const creator = actor ? null : database.get("SELECT created_by_user_id FROM analysis_tasks WHERE id = ?", String(task.id));
948
1000
  const userId = actor?.userId ?? (typeof creator?.created_by_user_id === "string" ? creator.created_by_user_id : null);
@@ -964,18 +1016,18 @@ export function createRuntime(options) {
964
1016
  });
965
1017
  const coverUpload = multer({
966
1018
  storage: multer.memoryStorage(),
967
- limits: { fileSize: 5 * 1024 * 1024, files: 1, fields: 4, fieldSize: 16 * 1024, parts: 5, headerPairs: 100 }
1019
+ limits: { fileSize: uploadLimits.coverBytes + 1, files: 1, fields: 4, fieldSize: 16 * 1024, parts: 5, headerPairs: 100 }
968
1020
  });
969
1021
  const avatarUpload = multer({
970
1022
  storage: multer.memoryStorage(),
971
- limits: { fileSize: 5 * 1024 * 1024, files: 1, fields: 1, fieldSize: 1024, parts: 2, headerPairs: 50 }
1023
+ limits: { fileSize: uploadLimits.avatarBytes + 1, files: 1, fields: 1, fieldSize: 1024, parts: 2, headerPairs: 50 }
972
1024
  });
973
1025
  const attachmentUpload = multer({
974
1026
  storage: multer.diskStorage({
975
1027
  destination: attachmentStorage.temporaryDirectory,
976
1028
  filename: (_request, _file, callback) => callback(null, randomUUID())
977
1029
  }),
978
- limits: { fileSize: 30 * 1024 * 1024, files: 1, fields: 4, fieldSize: 16 * 1024, parts: 5, headerPairs: 100 }
1030
+ limits: { fileSize: uploadLimits.attachmentBytes + 1, files: 1, fields: 4, fieldSize: 16 * 1024, parts: 5, headerPairs: 100 }
979
1031
  });
980
1032
  app.disable("x-powered-by");
981
1033
  const trustProxy = resolveTrustProxySetting(options.security?.trustProxy);
@@ -993,7 +1045,8 @@ export function createRuntime(options) {
993
1045
  version: APP_VERSION,
994
1046
  protocol: "openai-chat-completions",
995
1047
  protocols: [...AI_PROVIDER_PROTOCOLS],
996
- development: options.developmentServer === true
1048
+ development: options.developmentServer === true,
1049
+ uploadLimits
997
1050
  });
998
1051
  });
999
1052
  app.get("/api/update-check", async (_request, response) => {
@@ -1086,7 +1139,8 @@ export function createRuntime(options) {
1086
1139
  if (!request.authUser)
1087
1140
  throw new AppError(401, "AUTH_REQUIRED", "请先登录");
1088
1141
  if (!request.file)
1089
- throw new AppError(400, "FILE_REQUIRED", "请选择 PNG、JPEG 或 WebP 头像");
1142
+ throw new AppError(400, "FILE_REQUIRED", "请选择 PNG、JPEG、WebPGIF 头像");
1143
+ assertImageUploadSize(request.file.buffer.byteLength, uploadLimits.avatarBytes, `头像图片不能超过 ${formatUploadLimit(uploadLimits.avatarBytes)}`);
1090
1144
  try {
1091
1145
  const metadata = readRasterImageMetadata(request.file.buffer);
1092
1146
  const updated = database.transaction(() => {
@@ -1271,8 +1325,11 @@ export function createRuntime(options) {
1271
1325
  if (!request.file)
1272
1326
  throw new AppError(400, "FILE_REQUIRED", "请选择 PNG、JPEG 或 WebP 封面");
1273
1327
  const bytes = request.file.buffer;
1328
+ assertImageUploadSize(bytes.byteLength, uploadLimits.coverBytes, `封面图片不能超过 ${formatUploadLimit(uploadLimits.coverBytes)}`);
1274
1329
  try {
1275
1330
  const metadata = readRasterImageMetadata(bytes);
1331
+ if (metadata.mimeType === "image/gif")
1332
+ throw new AppError(415, "UNSUPPORTED_COVER_FORMAT", "封面不支持 GIF 图片");
1276
1333
  const expectedVersionNo = parse(expectedVersionNoSchema, request.body.expectedVersionNo);
1277
1334
  data(response, store.setWorkCover(String(request.params.workId), metadata.mimeType, bytes, expectedVersionNo));
1278
1335
  }
@@ -1353,11 +1410,14 @@ export function createRuntime(options) {
1353
1410
  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
1411
  const { source, changeNote, expectedVersionNo, ...chapterInput } = input;
1355
1412
  const chapter = store.saveChapter(request.params.chapterId, chapterInput, source ?? "manual", null, changeNote, expectedVersionNo);
1413
+ publishEditorChange(String(chapter.workId), String(chapter.id));
1356
1414
  data(response, chapter);
1357
1415
  });
1358
1416
  app.delete("/api/chapters/:chapterId", (request, response) => {
1359
1417
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1418
+ const chapter = store.getChapter(request.params.chapterId);
1360
1419
  store.deleteChapter(request.params.chapterId, input.expectedVersionNo);
1420
+ publishEditorChange(String(chapter.workId), String(chapter.id), deletedPageChange);
1361
1421
  noContent(response);
1362
1422
  });
1363
1423
  app.delete("/api/chapters/:chapterId/permanent", (request, response) => {
@@ -1518,11 +1578,14 @@ export function createRuntime(options) {
1518
1578
  app.patch("/api/settings/:settingId", (request, response) => {
1519
1579
  const { changeNote, expectedVersionNo, ...input } = parse(settingSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1520
1580
  const setting = store.updateSetting(request.params.settingId, input, "manual", null, changeNote, expectedVersionNo);
1581
+ publishEntityChange(String(setting.workId), "setting", String(setting.id));
1521
1582
  data(response, setting);
1522
1583
  });
1523
1584
  app.delete("/api/settings/:settingId", (request, response) => {
1524
1585
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1586
+ const setting = store.getSetting(request.params.settingId);
1525
1587
  store.deleteSetting(request.params.settingId, input.expectedVersionNo);
1588
+ publishEntityChange(String(setting.workId), "setting", String(setting.id), deletedPageChange);
1526
1589
  noContent(response);
1527
1590
  });
1528
1591
  app.get("/api/works/:workId/characters", (request, response) => {
@@ -1548,6 +1611,7 @@ export function createRuntime(options) {
1548
1611
  app.patch("/api/characters/:characterId", (request, response) => {
1549
1612
  const { changeNote, expectedVersionNo, ...input } = parse(characterUpdateSchema.extend({ expectedVersionNo: expectedVersionNoSchema }), request.body);
1550
1613
  const character = store.updateCharacter(request.params.characterId, input, "manual", null, changeNote, expectedVersionNo);
1614
+ publishEntityChange(String(character.workId), "character", String(character.id));
1551
1615
  data(response, redactCharacterLinks(character, requestPermissions(request)));
1552
1616
  });
1553
1617
  app.get("/api/characters/:characterId/versions", (request, response) => {
@@ -1563,7 +1627,9 @@ export function createRuntime(options) {
1563
1627
  });
1564
1628
  app.delete("/api/characters/:characterId", (request, response) => {
1565
1629
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1630
+ const character = store.getCharacter(request.params.characterId);
1566
1631
  store.deleteCharacter(request.params.characterId, input.expectedVersionNo);
1632
+ publishEntityChange(String(character.workId), "character", String(character.id), deletedPageChange);
1567
1633
  noContent(response);
1568
1634
  });
1569
1635
  app.post("/api/characters/:characterId/merge", (request, response) => {
@@ -1596,11 +1662,17 @@ export function createRuntime(options) {
1596
1662
  app.patch("/api/character-sections/:sectionId", (request, response) => {
1597
1663
  const { changeNote, expectedVersionNo, ...input } = parse(characterProfileSectionSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1598
1664
  const section = store.updateCharacterProfileSection(request.params.sectionId, input, "manual", null, changeNote, expectedVersionNo);
1665
+ publishEntityChange(String(section.workId), "character", String(section.characterId));
1599
1666
  data(response, section);
1600
1667
  });
1601
1668
  app.delete("/api/character-sections/:sectionId", (request, response) => {
1602
1669
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1670
+ const section = store.getCharacterProfileSection(request.params.sectionId);
1603
1671
  store.deleteCharacterProfileSection(request.params.sectionId, input.expectedVersionNo);
1672
+ publishEntityChange(String(section.workId), "character", String(section.characterId), {
1673
+ action: "delete",
1674
+ label: "角色档案章节"
1675
+ });
1604
1676
  noContent(response);
1605
1677
  });
1606
1678
  app.get("/api/character-sections/:sectionId/versions", (request, response) => {
@@ -1699,11 +1771,14 @@ export function createRuntime(options) {
1699
1771
  app.patch("/api/races/:raceId", (request, response) => {
1700
1772
  const { changeNote, expectedVersionNo, ...input } = parse(raceSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1701
1773
  const race = store.updateRace(request.params.raceId, input, "manual", null, changeNote, expectedVersionNo);
1774
+ publishEntityChange(String(race.workId), "race", String(race.id));
1702
1775
  data(response, redactRaceMembers(race, requestPermissions(request)));
1703
1776
  });
1704
1777
  app.delete("/api/races/:raceId", (request, response) => {
1705
1778
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1779
+ const race = store.getRace(request.params.raceId);
1706
1780
  store.deleteRace(request.params.raceId, input.expectedVersionNo);
1781
+ publishEntityChange(String(race.workId), "race", String(race.id), deletedPageChange);
1707
1782
  noContent(response);
1708
1783
  });
1709
1784
  app.post("/api/races/:raceId/merge", (request, response) => {
@@ -1729,11 +1804,14 @@ export function createRuntime(options) {
1729
1804
  app.patch("/api/organizations/:organizationId", (request, response) => {
1730
1805
  const { changeNote, expectedVersionNo, ...input } = parse(organizationSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1731
1806
  const organization = store.updateOrganization(request.params.organizationId, input, "manual", null, changeNote, expectedVersionNo);
1807
+ publishEntityChange(String(organization.workId), "organization", String(organization.id));
1732
1808
  data(response, redactOrganizationMembers(organization, requestPermissions(request)));
1733
1809
  });
1734
1810
  app.delete("/api/organizations/:organizationId", (request, response) => {
1735
1811
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1812
+ const organization = store.getOrganization(request.params.organizationId);
1736
1813
  store.deleteOrganization(request.params.organizationId, input.expectedVersionNo);
1814
+ publishEntityChange(String(organization.workId), "organization", String(organization.id), deletedPageChange);
1737
1815
  noContent(response);
1738
1816
  });
1739
1817
  app.post("/api/organizations/:organizationId/merge", (request, response) => {
@@ -1754,11 +1832,14 @@ export function createRuntime(options) {
1754
1832
  app.patch("/api/timeline-tracks/:trackId", (request, response) => {
1755
1833
  const { changeNote, expectedVersionNo, ...input } = parse(timelineTrackSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1756
1834
  const track = store.updateTimelineTrack(request.params.trackId, input, "manual", null, changeNote, expectedVersionNo);
1835
+ publishModuleChange(String(track.workId), "timeline");
1757
1836
  data(response, track);
1758
1837
  });
1759
1838
  app.delete("/api/timeline-tracks/:trackId", (request, response) => {
1760
1839
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1840
+ const track = store.getTimelineTrack(request.params.trackId);
1761
1841
  store.deleteTimelineTrack(request.params.trackId, input.expectedVersionNo);
1842
+ publishModuleChange(String(track.workId), "timeline", { action: "delete", label: "时间轴轨道" });
1762
1843
  noContent(response);
1763
1844
  });
1764
1845
  app.get("/api/works/:workId/timeline", (request, response) => {
@@ -1786,6 +1867,7 @@ export function createRuntime(options) {
1786
1867
  app.patch("/api/timeline/:eventId", (request, response) => {
1787
1868
  const { changeNote, expectedVersionNo, ...input } = parse(timelineSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1788
1869
  const event = store.updateTimelineEvent(request.params.eventId, input, "manual", null, changeNote, expectedVersionNo);
1870
+ publishModuleChange(String(event.workId), "timeline");
1789
1871
  data(response, event);
1790
1872
  });
1791
1873
  app.post("/api/timeline/:eventId/split", (request, response) => {
@@ -1803,7 +1885,9 @@ export function createRuntime(options) {
1803
1885
  });
1804
1886
  app.delete("/api/timeline/:eventId", (request, response) => {
1805
1887
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1888
+ const event = store.getTimelineEvent(request.params.eventId);
1806
1889
  store.deleteTimelineEvent(request.params.eventId, input.expectedVersionNo);
1890
+ publishModuleChange(String(event.workId), "timeline", { action: "delete", label: "时间轴事件" });
1807
1891
  noContent(response);
1808
1892
  });
1809
1893
  app.get("/api/works/:workId/relationships", (request, response) => {
@@ -1823,14 +1907,14 @@ export function createRuntime(options) {
1823
1907
  app.patch("/api/relationships/:relationshipId", (request, response) => {
1824
1908
  const { changeNote, expectedVersionNo, ...input } = parse(relationshipSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1825
1909
  const relationship = store.updateRelationship(request.params.relationshipId, input, "manual", null, changeNote, expectedVersionNo);
1826
- publishRelationshipChange(String(relationship.workId), String(relationship.id));
1910
+ publishEntityChange(String(relationship.workId), "relationship", String(relationship.id));
1827
1911
  data(response, relationship);
1828
1912
  });
1829
1913
  app.delete("/api/relationships/:relationshipId", (request, response) => {
1830
1914
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1831
1915
  const relationship = store.getRelationship(request.params.relationshipId);
1832
1916
  store.deleteRelationship(request.params.relationshipId, input.expectedVersionNo);
1833
- publishRelationshipChange(String(relationship.workId), String(relationship.id));
1917
+ publishEntityChange(String(relationship.workId), "relationship", String(relationship.id), deletedPageChange);
1834
1918
  noContent(response);
1835
1919
  });
1836
1920
  app.get("/api/entity-versions/:entityType/:entityId", (request, response) => {
@@ -1992,6 +2076,17 @@ export function createRuntime(options) {
1992
2076
  app.patch("/api/platform/ui-settings", (request, response) => {
1993
2077
  data(response, store.updatePlatformUiSettings(parse(platformUiSettingsSchema, request.body)));
1994
2078
  });
2079
+ app.get("/api/platform/backups/encryption", (_request, response) => {
2080
+ data(response, backups.getEncryptionState());
2081
+ });
2082
+ app.post("/api/platform/backups/encryption", (request, response) => {
2083
+ const input = parse(s3BackupEncryptionSchema, request.body);
2084
+ data(response, backups.setEncryptionEnabled(input.enabled));
2085
+ });
2086
+ app.post("/api/platform/backups/encryption/confirm", (request, response) => {
2087
+ const input = parse(s3BackupEncryptionConfirmationSchema, request.body);
2088
+ data(response, backups.confirmEncryptionEnabled(input.confirmationToken));
2089
+ });
1995
2090
  app.get("/api/platform/backups/targets", (_request, response) => data(response, backups.listTargets()));
1996
2091
  app.post("/api/platform/backups/targets", (request, response) => {
1997
2092
  data(response, backups.createTarget(parse(s3BackupTargetBaseSchema, request.body)), 201);
@@ -2251,6 +2346,7 @@ export function createRuntime(options) {
2251
2346
  if (store.getChapter(citation.chapterId).workId !== request.params.workId)
2252
2347
  throw new AppError(400, "CITATION_WORK_MISMATCH", "引用章节不属于当前作品");
2253
2348
  }
2349
+ const resolvedInstruction = instructionWithCitations(input.instruction, citations);
2254
2350
  const controller = new AbortController();
2255
2351
  response.on("close", () => {
2256
2352
  if (!response.writableEnded)
@@ -2281,7 +2377,7 @@ export function createRuntime(options) {
2281
2377
  workId: request.params.workId,
2282
2378
  modelId: input.modelId,
2283
2379
  scope: input.scope,
2284
- instruction: instructionWithCitations(input.instruction, citations),
2380
+ instruction: resolvedInstruction,
2285
2381
  excludeConversationMessageId: input.currentMessageId
2286
2382
  });
2287
2383
  sendEvent("context", {
@@ -2293,19 +2389,34 @@ export function createRuntime(options) {
2293
2389
  });
2294
2390
  if (prepared.action === "warn")
2295
2391
  return;
2392
+ const resolvedScope = input.currentMessageId
2393
+ ? input.scope
2394
+ : ai.resolveInstructionMentions({
2395
+ workId: request.params.workId,
2396
+ taskType: "chat",
2397
+ instruction: resolvedInstruction,
2398
+ scope: input.scope,
2399
+ conversationId
2400
+ });
2401
+ const mentionCharacterIds = [...new Set([
2402
+ ...(resolvedScope.characterIds ?? []),
2403
+ ...(resolvedScope.mentionCharacterIds ?? [])
2404
+ ])];
2296
2405
  const userMessage = input.currentMessageId
2297
2406
  ? null
2298
2407
  : store.addAiConversationMessage(conversationId, {
2299
2408
  role: "user",
2300
2409
  content: input.instruction,
2301
- citations
2410
+ citations,
2411
+ ...(mentionCharacterIds.length ? { metadata: { mentionCharacterIds } } : {})
2302
2412
  });
2303
2413
  const currentMessageId = input.currentMessageId ?? String(userMessage?.id ?? "");
2304
2414
  if (userMessage)
2305
2415
  sendEvent("user_message", { message: redactAiConversationMessage(userMessage, permissions) });
2306
2416
  const suggestion = await ai.createStreamingChat({
2307
2417
  workId: request.params.workId,
2308
- instruction: instructionWithCitations(input.instruction, citations),
2418
+ instruction: resolvedInstruction,
2419
+ // 仍由生成路径基于原始范围持久化累计注入,保证预解析不会吞掉本轮自动命中。
2309
2420
  scope: input.scope,
2310
2421
  signal: controller.signal,
2311
2422
  onToolCall: (toolCall, round) => sendEvent("tool_call", { ...toolCall, round }),
@@ -2513,6 +2624,13 @@ export function createRuntime(options) {
2513
2624
  }
2514
2625
  if (error instanceof multer.MulterError) {
2515
2626
  logger.warn("http.request.upload_rejected", { ...commonFields, uploadCode: error.code });
2627
+ if (error.code === "LIMIT_FILE_SIZE") {
2628
+ const sizeError = uploadSizeError(request.path, uploadLimits);
2629
+ if (sizeError) {
2630
+ response.status(413).json({ error: sizeError });
2631
+ return;
2632
+ }
2633
+ }
2516
2634
  response.status(400).json({ error: { code: "UPLOAD_ERROR", message: error.message } });
2517
2635
  return;
2518
2636
  }
@@ -2551,14 +2669,41 @@ export function createRuntime(options) {
2551
2669
  });
2552
2670
  backups.startScheduler();
2553
2671
  logger.info("runtime.ready", { serveUi: options.serveUi ?? true });
2554
- return { app, database, store, ai, backups, auth, attachmentStorage, cleanupAttachments, close: () => {
2672
+ let closePromise = null;
2673
+ let stopping = false;
2674
+ let closed = false;
2675
+ const close = () => {
2676
+ if (closed)
2677
+ return Promise.resolve();
2678
+ if (closePromise)
2679
+ return closePromise;
2680
+ if (!stopping) {
2681
+ stopping = true;
2555
2682
  logger.info("runtime.closing");
2556
2683
  backups.dispose();
2557
2684
  ai.dispose();
2558
- database.close();
2559
- if (temporaryAttachmentRoot)
2560
- rmSync(temporaryAttachmentRoot, { recursive: true, force: true });
2561
- logger.info("runtime.closed");
2562
- } };
2685
+ }
2686
+ closePromise = (async () => {
2687
+ try {
2688
+ await backups.waitForIdle(RUNTIME_BACKUP_IDLE_TIMEOUT_MS);
2689
+ collaborationPresence.close();
2690
+ database.close();
2691
+ if (temporaryAttachmentRoot)
2692
+ rmSync(temporaryAttachmentRoot, { recursive: true, force: true });
2693
+ closed = true;
2694
+ logger.info("runtime.closed");
2695
+ }
2696
+ catch (error) {
2697
+ logger.error("runtime.close_failed", { error: sanitizeError(error) });
2698
+ throw error;
2699
+ }
2700
+ finally {
2701
+ if (!closed)
2702
+ closePromise = null;
2703
+ }
2704
+ })();
2705
+ return closePromise;
2706
+ };
2707
+ return { app, database, store, ai, backups, auth, attachmentStorage, cleanupAttachments, close };
2563
2708
  }
2564
2709
  //# sourceMappingURL=app.js.map