@musnows/scriverse 0.7.1 → 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 (49) 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 +194 -40
  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 +215 -4
  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 +1018 -292
  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 +44 -22
  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/relationship-graph.js +24 -4
  31. package/dist/public/s3-backup-ui.d.ts +10 -0
  32. package/dist/public/s3-backup-ui.js +29 -0
  33. package/dist/public/setting-filters.d.ts +16 -0
  34. package/dist/public/setting-filters.js +19 -0
  35. package/dist/public/styles.css +39 -9
  36. package/dist/s3-backup.js +197 -12
  37. package/dist/s3-backup.js.map +1 -1
  38. package/dist/security.js +82 -20
  39. package/dist/security.js.map +1 -1
  40. package/dist/server-runtime.js +36 -18
  41. package/dist/server-runtime.js.map +1 -1
  42. package/dist/store.js +77 -18
  43. package/dist/store.js.map +1 -1
  44. package/dist/upload-limits.js +35 -0
  45. package/dist/upload-limits.js.map +1 -0
  46. package/dist/user-auth.js +11 -1
  47. package/dist/user-auth.js.map +1 -1
  48. package/dist/version.js +1 -1
  49. 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"]),
@@ -389,7 +407,16 @@ const platformPageSizesSchema = z.object({
389
407
  const platformUiSettingsSchema = z.object({
390
408
  toastPosition: z.enum(["bottom-right", "top-right"]).optional(),
391
409
  pageSizes: platformPageSizesSchema.optional(),
392
- galaxyFrameRate: z.union([z.literal(24), z.literal(30), z.literal(60)]).optional()
410
+ galaxyFrameRate: z.union([
411
+ z.literal(24),
412
+ z.literal(30),
413
+ z.literal(60),
414
+ z.literal(90),
415
+ z.literal(120),
416
+ z.literal(144),
417
+ z.literal(165),
418
+ z.literal(240)
419
+ ]).optional()
393
420
  }).strict().refine((input) => input.toastPosition !== undefined || input.pageSizes !== undefined || input.galaxyFrameRate !== undefined, {
394
421
  message: "至少需要提供一项界面设置"
395
422
  });
@@ -437,6 +464,12 @@ const s3BackupRunQuerySchema = z.object({
437
464
  afterSequence: z.coerce.number().int().min(0).optional(),
438
465
  limit: z.coerce.number().int().min(1).max(100).optional()
439
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();
440
473
  const aiToolCallResultSchema = z.object({
441
474
  id: z.string().min(1).max(300),
442
475
  name: z.string().min(1).max(200),
@@ -508,7 +541,8 @@ const contextSchema = z.object({
508
541
  includeBookSummary: z.boolean().optional(),
509
542
  includeSettingInfo: z.boolean().optional()
510
543
  });
511
- 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);
512
546
  const relationshipSourceRefSchema = z.object({
513
547
  sourceType: z.string().trim().min(1).max(50).regex(/^[a-z][a-z-]*$/u),
514
548
  sourceId: identifier,
@@ -552,7 +586,7 @@ const relationshipAnalysisScopeSchema = z.object({
552
586
  });
553
587
  const analysisTaskSchema = z.union([
554
588
  z.object({ taskType: z.literal("relationship-analysis"), scope: relationshipAnalysisScopeSchema.optional(), modelId: identifier.optional() }).strict(),
555
- 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) => {
556
590
  if (input.scope?.includeAllSettings !== undefined) {
557
591
  context.addIssue({ code: z.ZodIssueCode.custom, path: ["scope", "includeAllSettings"], message: "包含所有设定仅支持人物关系分析" });
558
592
  }
@@ -573,6 +607,7 @@ const analysisTaskSchema = z.union([
573
607
  }
574
608
  })
575
609
  ]);
610
+ export const RUNTIME_BACKUP_IDLE_TIMEOUT_MS = 9_000;
576
611
  function data(response, value, status = 200) {
577
612
  response.status(status).json({ data: value });
578
613
  }
@@ -781,11 +816,18 @@ function redactSuggestion(record, permissions) {
781
816
  };
782
817
  }
783
818
  function redactAiConversationMessage(item, permissions) {
784
- if (permissions.prose !== "none")
785
- return item;
786
819
  const message = recordValue(item);
787
820
  if (!message)
788
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
+ }
789
831
  return {
790
832
  ...message,
791
833
  content: proseRestrictedPlaceholder,
@@ -798,25 +840,25 @@ function redactAiConversationMessage(item, permissions) {
798
840
  function redactAiConversation(record, permissions) {
799
841
  const readableRecord = permissions.characters === "none" ? { ...record, roleplayCharacter: null } : record;
800
842
  const scopedRecord = redactAiCallContext(readableRecord, permissions);
801
- if (permissions.prose !== "none")
802
- return scopedRecord;
803
843
  const result = {
804
- ...scopedRecord,
805
- title: proseRestrictedPlaceholder
844
+ ...scopedRecord
806
845
  };
807
- if (typeof result.preview === "string" && result.preview.length > 0) {
808
- result.preview = proseRestrictedPlaceholder;
809
- }
810
- if (Array.isArray(result.messages)) {
846
+ if ((permissions.prose === "none" || permissions.characters === "none") && Array.isArray(result.messages)) {
811
847
  result.messages = result.messages.map((item) => redactAiConversationMessage(item, permissions));
812
848
  }
813
849
  const messagesPage = recordValue(result.messagesPage);
814
- if (messagesPage && Array.isArray(messagesPage.items)) {
850
+ if ((permissions.prose === "none" || permissions.characters === "none") && messagesPage && Array.isArray(messagesPage.items)) {
815
851
  result.messagesPage = {
816
852
  ...messagesPage,
817
853
  items: messagesPage.items.map((item) => redactAiConversationMessage(item, permissions))
818
854
  };
819
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
+ }
820
862
  return { ...result, restricted: true };
821
863
  }
822
864
  /** SSE 错误事件只暴露 AppError 的公开信息;AI_CALL_FAILED 的 failure 已在 AI 层完成密钥脱敏。 */
@@ -856,6 +898,7 @@ function redactVersionSnapshots(value, mapper) {
856
898
  });
857
899
  }
858
900
  export function createRuntime(options) {
901
+ const uploadLimits = options.uploadLimits ?? DEFAULT_IMAGE_UPLOAD_LIMITS;
859
902
  logger.info("runtime.initializing", {
860
903
  databasePath: options.databasePath,
861
904
  serveUi: options.serveUi ?? true,
@@ -869,19 +912,35 @@ export function createRuntime(options) {
869
912
  const temporaryAttachmentRoot = options.databasePath === ":memory:" && !options.attachmentDirectory
870
913
  ? mkdtempSync(join(tmpdir(), "scriverse-attachments-"))
871
914
  : null;
872
- 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);
873
916
  mkdirSync(attachmentStorage.temporaryDirectory, { recursive: true, mode: 0o700 });
874
917
  const auth = new UserAuthService(database);
875
- const collaborationPresence = new CollaborationPresence();
876
- 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 = {}) => {
877
920
  const actor = currentRequestActor();
878
- if (!actor || !workId || !relationshipId)
921
+ if (!actor || !workId || !pageKey)
879
922
  return;
880
- collaborationPresence.publishChange(workId, entityEditorPageKey("relationship", relationshipId), {
923
+ collaborationPresence.publishChange(workId, pageKey, {
881
924
  userId: actor.userId,
882
925
  displayName: actor.displayName
883
- });
926
+ }, options);
884
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 };
885
944
  const getDevelopmentUser = () => options.devAuthBypass
886
945
  ? auth.listUsers().find((user) => user.status === "active") ?? null
887
946
  : null;
@@ -933,7 +992,9 @@ export function createRuntime(options) {
933
992
  timeoutMs: options.releaseCheckTimeoutMs,
934
993
  retries: options.releaseCheckRetries
935
994
  });
936
- 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) => {
937
998
  const requiredModules = analysisTaskReadModules(task.taskType, task.scope);
938
999
  const creator = actor ? null : database.get("SELECT created_by_user_id FROM analysis_tasks WHERE id = ?", String(task.id));
939
1000
  const userId = actor?.userId ?? (typeof creator?.created_by_user_id === "string" ? creator.created_by_user_id : null);
@@ -955,18 +1016,18 @@ export function createRuntime(options) {
955
1016
  });
956
1017
  const coverUpload = multer({
957
1018
  storage: multer.memoryStorage(),
958
- 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 }
959
1020
  });
960
1021
  const avatarUpload = multer({
961
1022
  storage: multer.memoryStorage(),
962
- 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 }
963
1024
  });
964
1025
  const attachmentUpload = multer({
965
1026
  storage: multer.diskStorage({
966
1027
  destination: attachmentStorage.temporaryDirectory,
967
1028
  filename: (_request, _file, callback) => callback(null, randomUUID())
968
1029
  }),
969
- 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 }
970
1031
  });
971
1032
  app.disable("x-powered-by");
972
1033
  const trustProxy = resolveTrustProxySetting(options.security?.trustProxy);
@@ -984,7 +1045,8 @@ export function createRuntime(options) {
984
1045
  version: APP_VERSION,
985
1046
  protocol: "openai-chat-completions",
986
1047
  protocols: [...AI_PROVIDER_PROTOCOLS],
987
- development: options.developmentServer === true
1048
+ development: options.developmentServer === true,
1049
+ uploadLimits
988
1050
  });
989
1051
  });
990
1052
  app.get("/api/update-check", async (_request, response) => {
@@ -1077,7 +1139,8 @@ export function createRuntime(options) {
1077
1139
  if (!request.authUser)
1078
1140
  throw new AppError(401, "AUTH_REQUIRED", "请先登录");
1079
1141
  if (!request.file)
1080
- 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)}`);
1081
1144
  try {
1082
1145
  const metadata = readRasterImageMetadata(request.file.buffer);
1083
1146
  const updated = database.transaction(() => {
@@ -1262,8 +1325,11 @@ export function createRuntime(options) {
1262
1325
  if (!request.file)
1263
1326
  throw new AppError(400, "FILE_REQUIRED", "请选择 PNG、JPEG 或 WebP 封面");
1264
1327
  const bytes = request.file.buffer;
1328
+ assertImageUploadSize(bytes.byteLength, uploadLimits.coverBytes, `封面图片不能超过 ${formatUploadLimit(uploadLimits.coverBytes)}`);
1265
1329
  try {
1266
1330
  const metadata = readRasterImageMetadata(bytes);
1331
+ if (metadata.mimeType === "image/gif")
1332
+ throw new AppError(415, "UNSUPPORTED_COVER_FORMAT", "封面不支持 GIF 图片");
1267
1333
  const expectedVersionNo = parse(expectedVersionNoSchema, request.body.expectedVersionNo);
1268
1334
  data(response, store.setWorkCover(String(request.params.workId), metadata.mimeType, bytes, expectedVersionNo));
1269
1335
  }
@@ -1344,11 +1410,14 @@ export function createRuntime(options) {
1344
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);
1345
1411
  const { source, changeNote, expectedVersionNo, ...chapterInput } = input;
1346
1412
  const chapter = store.saveChapter(request.params.chapterId, chapterInput, source ?? "manual", null, changeNote, expectedVersionNo);
1413
+ publishEditorChange(String(chapter.workId), String(chapter.id));
1347
1414
  data(response, chapter);
1348
1415
  });
1349
1416
  app.delete("/api/chapters/:chapterId", (request, response) => {
1350
1417
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1418
+ const chapter = store.getChapter(request.params.chapterId);
1351
1419
  store.deleteChapter(request.params.chapterId, input.expectedVersionNo);
1420
+ publishEditorChange(String(chapter.workId), String(chapter.id), deletedPageChange);
1352
1421
  noContent(response);
1353
1422
  });
1354
1423
  app.delete("/api/chapters/:chapterId/permanent", (request, response) => {
@@ -1509,11 +1578,14 @@ export function createRuntime(options) {
1509
1578
  app.patch("/api/settings/:settingId", (request, response) => {
1510
1579
  const { changeNote, expectedVersionNo, ...input } = parse(settingSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1511
1580
  const setting = store.updateSetting(request.params.settingId, input, "manual", null, changeNote, expectedVersionNo);
1581
+ publishEntityChange(String(setting.workId), "setting", String(setting.id));
1512
1582
  data(response, setting);
1513
1583
  });
1514
1584
  app.delete("/api/settings/:settingId", (request, response) => {
1515
1585
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1586
+ const setting = store.getSetting(request.params.settingId);
1516
1587
  store.deleteSetting(request.params.settingId, input.expectedVersionNo);
1588
+ publishEntityChange(String(setting.workId), "setting", String(setting.id), deletedPageChange);
1517
1589
  noContent(response);
1518
1590
  });
1519
1591
  app.get("/api/works/:workId/characters", (request, response) => {
@@ -1539,6 +1611,7 @@ export function createRuntime(options) {
1539
1611
  app.patch("/api/characters/:characterId", (request, response) => {
1540
1612
  const { changeNote, expectedVersionNo, ...input } = parse(characterUpdateSchema.extend({ expectedVersionNo: expectedVersionNoSchema }), request.body);
1541
1613
  const character = store.updateCharacter(request.params.characterId, input, "manual", null, changeNote, expectedVersionNo);
1614
+ publishEntityChange(String(character.workId), "character", String(character.id));
1542
1615
  data(response, redactCharacterLinks(character, requestPermissions(request)));
1543
1616
  });
1544
1617
  app.get("/api/characters/:characterId/versions", (request, response) => {
@@ -1554,7 +1627,9 @@ export function createRuntime(options) {
1554
1627
  });
1555
1628
  app.delete("/api/characters/:characterId", (request, response) => {
1556
1629
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1630
+ const character = store.getCharacter(request.params.characterId);
1557
1631
  store.deleteCharacter(request.params.characterId, input.expectedVersionNo);
1632
+ publishEntityChange(String(character.workId), "character", String(character.id), deletedPageChange);
1558
1633
  noContent(response);
1559
1634
  });
1560
1635
  app.post("/api/characters/:characterId/merge", (request, response) => {
@@ -1587,11 +1662,17 @@ export function createRuntime(options) {
1587
1662
  app.patch("/api/character-sections/:sectionId", (request, response) => {
1588
1663
  const { changeNote, expectedVersionNo, ...input } = parse(characterProfileSectionSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1589
1664
  const section = store.updateCharacterProfileSection(request.params.sectionId, input, "manual", null, changeNote, expectedVersionNo);
1665
+ publishEntityChange(String(section.workId), "character", String(section.characterId));
1590
1666
  data(response, section);
1591
1667
  });
1592
1668
  app.delete("/api/character-sections/:sectionId", (request, response) => {
1593
1669
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1670
+ const section = store.getCharacterProfileSection(request.params.sectionId);
1594
1671
  store.deleteCharacterProfileSection(request.params.sectionId, input.expectedVersionNo);
1672
+ publishEntityChange(String(section.workId), "character", String(section.characterId), {
1673
+ action: "delete",
1674
+ label: "角色档案章节"
1675
+ });
1595
1676
  noContent(response);
1596
1677
  });
1597
1678
  app.get("/api/character-sections/:sectionId/versions", (request, response) => {
@@ -1690,11 +1771,14 @@ export function createRuntime(options) {
1690
1771
  app.patch("/api/races/:raceId", (request, response) => {
1691
1772
  const { changeNote, expectedVersionNo, ...input } = parse(raceSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1692
1773
  const race = store.updateRace(request.params.raceId, input, "manual", null, changeNote, expectedVersionNo);
1774
+ publishEntityChange(String(race.workId), "race", String(race.id));
1693
1775
  data(response, redactRaceMembers(race, requestPermissions(request)));
1694
1776
  });
1695
1777
  app.delete("/api/races/:raceId", (request, response) => {
1696
1778
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1779
+ const race = store.getRace(request.params.raceId);
1697
1780
  store.deleteRace(request.params.raceId, input.expectedVersionNo);
1781
+ publishEntityChange(String(race.workId), "race", String(race.id), deletedPageChange);
1698
1782
  noContent(response);
1699
1783
  });
1700
1784
  app.post("/api/races/:raceId/merge", (request, response) => {
@@ -1720,11 +1804,14 @@ export function createRuntime(options) {
1720
1804
  app.patch("/api/organizations/:organizationId", (request, response) => {
1721
1805
  const { changeNote, expectedVersionNo, ...input } = parse(organizationSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1722
1806
  const organization = store.updateOrganization(request.params.organizationId, input, "manual", null, changeNote, expectedVersionNo);
1807
+ publishEntityChange(String(organization.workId), "organization", String(organization.id));
1723
1808
  data(response, redactOrganizationMembers(organization, requestPermissions(request)));
1724
1809
  });
1725
1810
  app.delete("/api/organizations/:organizationId", (request, response) => {
1726
1811
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1812
+ const organization = store.getOrganization(request.params.organizationId);
1727
1813
  store.deleteOrganization(request.params.organizationId, input.expectedVersionNo);
1814
+ publishEntityChange(String(organization.workId), "organization", String(organization.id), deletedPageChange);
1728
1815
  noContent(response);
1729
1816
  });
1730
1817
  app.post("/api/organizations/:organizationId/merge", (request, response) => {
@@ -1745,11 +1832,14 @@ export function createRuntime(options) {
1745
1832
  app.patch("/api/timeline-tracks/:trackId", (request, response) => {
1746
1833
  const { changeNote, expectedVersionNo, ...input } = parse(timelineTrackSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1747
1834
  const track = store.updateTimelineTrack(request.params.trackId, input, "manual", null, changeNote, expectedVersionNo);
1835
+ publishModuleChange(String(track.workId), "timeline");
1748
1836
  data(response, track);
1749
1837
  });
1750
1838
  app.delete("/api/timeline-tracks/:trackId", (request, response) => {
1751
1839
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1840
+ const track = store.getTimelineTrack(request.params.trackId);
1752
1841
  store.deleteTimelineTrack(request.params.trackId, input.expectedVersionNo);
1842
+ publishModuleChange(String(track.workId), "timeline", { action: "delete", label: "时间轴轨道" });
1753
1843
  noContent(response);
1754
1844
  });
1755
1845
  app.get("/api/works/:workId/timeline", (request, response) => {
@@ -1777,6 +1867,7 @@ export function createRuntime(options) {
1777
1867
  app.patch("/api/timeline/:eventId", (request, response) => {
1778
1868
  const { changeNote, expectedVersionNo, ...input } = parse(timelineSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1779
1869
  const event = store.updateTimelineEvent(request.params.eventId, input, "manual", null, changeNote, expectedVersionNo);
1870
+ publishModuleChange(String(event.workId), "timeline");
1780
1871
  data(response, event);
1781
1872
  });
1782
1873
  app.post("/api/timeline/:eventId/split", (request, response) => {
@@ -1794,7 +1885,9 @@ export function createRuntime(options) {
1794
1885
  });
1795
1886
  app.delete("/api/timeline/:eventId", (request, response) => {
1796
1887
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1888
+ const event = store.getTimelineEvent(request.params.eventId);
1797
1889
  store.deleteTimelineEvent(request.params.eventId, input.expectedVersionNo);
1890
+ publishModuleChange(String(event.workId), "timeline", { action: "delete", label: "时间轴事件" });
1798
1891
  noContent(response);
1799
1892
  });
1800
1893
  app.get("/api/works/:workId/relationships", (request, response) => {
@@ -1814,14 +1907,14 @@ export function createRuntime(options) {
1814
1907
  app.patch("/api/relationships/:relationshipId", (request, response) => {
1815
1908
  const { changeNote, expectedVersionNo, ...input } = parse(relationshipSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1816
1909
  const relationship = store.updateRelationship(request.params.relationshipId, input, "manual", null, changeNote, expectedVersionNo);
1817
- publishRelationshipChange(String(relationship.workId), String(relationship.id));
1910
+ publishEntityChange(String(relationship.workId), "relationship", String(relationship.id));
1818
1911
  data(response, relationship);
1819
1912
  });
1820
1913
  app.delete("/api/relationships/:relationshipId", (request, response) => {
1821
1914
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1822
1915
  const relationship = store.getRelationship(request.params.relationshipId);
1823
1916
  store.deleteRelationship(request.params.relationshipId, input.expectedVersionNo);
1824
- publishRelationshipChange(String(relationship.workId), String(relationship.id));
1917
+ publishEntityChange(String(relationship.workId), "relationship", String(relationship.id), deletedPageChange);
1825
1918
  noContent(response);
1826
1919
  });
1827
1920
  app.get("/api/entity-versions/:entityType/:entityId", (request, response) => {
@@ -1983,6 +2076,17 @@ export function createRuntime(options) {
1983
2076
  app.patch("/api/platform/ui-settings", (request, response) => {
1984
2077
  data(response, store.updatePlatformUiSettings(parse(platformUiSettingsSchema, request.body)));
1985
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
+ });
1986
2090
  app.get("/api/platform/backups/targets", (_request, response) => data(response, backups.listTargets()));
1987
2091
  app.post("/api/platform/backups/targets", (request, response) => {
1988
2092
  data(response, backups.createTarget(parse(s3BackupTargetBaseSchema, request.body)), 201);
@@ -2242,6 +2346,7 @@ export function createRuntime(options) {
2242
2346
  if (store.getChapter(citation.chapterId).workId !== request.params.workId)
2243
2347
  throw new AppError(400, "CITATION_WORK_MISMATCH", "引用章节不属于当前作品");
2244
2348
  }
2349
+ const resolvedInstruction = instructionWithCitations(input.instruction, citations);
2245
2350
  const controller = new AbortController();
2246
2351
  response.on("close", () => {
2247
2352
  if (!response.writableEnded)
@@ -2272,7 +2377,7 @@ export function createRuntime(options) {
2272
2377
  workId: request.params.workId,
2273
2378
  modelId: input.modelId,
2274
2379
  scope: input.scope,
2275
- instruction: instructionWithCitations(input.instruction, citations),
2380
+ instruction: resolvedInstruction,
2276
2381
  excludeConversationMessageId: input.currentMessageId
2277
2382
  });
2278
2383
  sendEvent("context", {
@@ -2284,19 +2389,34 @@ export function createRuntime(options) {
2284
2389
  });
2285
2390
  if (prepared.action === "warn")
2286
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
+ ])];
2287
2405
  const userMessage = input.currentMessageId
2288
2406
  ? null
2289
2407
  : store.addAiConversationMessage(conversationId, {
2290
2408
  role: "user",
2291
2409
  content: input.instruction,
2292
- citations
2410
+ citations,
2411
+ ...(mentionCharacterIds.length ? { metadata: { mentionCharacterIds } } : {})
2293
2412
  });
2294
2413
  const currentMessageId = input.currentMessageId ?? String(userMessage?.id ?? "");
2295
2414
  if (userMessage)
2296
2415
  sendEvent("user_message", { message: redactAiConversationMessage(userMessage, permissions) });
2297
2416
  const suggestion = await ai.createStreamingChat({
2298
2417
  workId: request.params.workId,
2299
- instruction: instructionWithCitations(input.instruction, citations),
2418
+ instruction: resolvedInstruction,
2419
+ // 仍由生成路径基于原始范围持久化累计注入,保证预解析不会吞掉本轮自动命中。
2300
2420
  scope: input.scope,
2301
2421
  signal: controller.signal,
2302
2422
  onToolCall: (toolCall, round) => sendEvent("tool_call", { ...toolCall, round }),
@@ -2504,6 +2624,13 @@ export function createRuntime(options) {
2504
2624
  }
2505
2625
  if (error instanceof multer.MulterError) {
2506
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
+ }
2507
2634
  response.status(400).json({ error: { code: "UPLOAD_ERROR", message: error.message } });
2508
2635
  return;
2509
2636
  }
@@ -2542,14 +2669,41 @@ export function createRuntime(options) {
2542
2669
  });
2543
2670
  backups.startScheduler();
2544
2671
  logger.info("runtime.ready", { serveUi: options.serveUi ?? true });
2545
- 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;
2546
2682
  logger.info("runtime.closing");
2547
2683
  backups.dispose();
2548
2684
  ai.dispose();
2549
- database.close();
2550
- if (temporaryAttachmentRoot)
2551
- rmSync(temporaryAttachmentRoot, { recursive: true, force: true });
2552
- logger.info("runtime.closed");
2553
- } };
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 };
2554
2708
  }
2555
2709
  //# sourceMappingURL=app.js.map