@musnows/scriverse 0.6.1 → 0.6.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 (47) hide show
  1. package/README.en.md +3 -2
  2. package/README.md +3 -2
  3. package/dist/ai-tool-results.js +71 -0
  4. package/dist/ai-tool-results.js.map +1 -1
  5. package/dist/ai.js +267 -54
  6. package/dist/ai.js.map +1 -1
  7. package/dist/app.js +256 -57
  8. package/dist/app.js.map +1 -1
  9. package/dist/attachment-storage.js +9 -1
  10. package/dist/attachment-storage.js.map +1 -1
  11. package/dist/cli-contract.js +4 -0
  12. package/dist/cli-contract.js.map +1 -1
  13. package/dist/cli-core.js +23 -7
  14. package/dist/cli-core.js.map +1 -1
  15. package/dist/credential-vault.js +7 -5
  16. package/dist/credential-vault.js.map +1 -1
  17. package/dist/database.js +243 -2
  18. package/dist/database.js.map +1 -1
  19. package/dist/docx-export.js +89 -0
  20. package/dist/docx-export.js.map +1 -0
  21. package/dist/domain.js +9 -0
  22. package/dist/domain.js.map +1 -1
  23. package/dist/image-captcha.js +8 -5
  24. package/dist/image-captcha.js.map +1 -1
  25. package/dist/public/ai-context-meter.js +4 -0
  26. package/dist/public/ai-message-time.js +3 -9
  27. package/dist/public/ai-tool-call.js +4 -0
  28. package/dist/public/app.js +765 -111
  29. package/dist/public/index.html +49 -17
  30. package/dist/public/markdown.js +1 -2
  31. package/dist/public/page-route.js +2 -2
  32. package/dist/public/styles.css +87 -29
  33. package/dist/public/system-status.d.ts +12 -0
  34. package/dist/public/system-status.js +16 -0
  35. package/dist/public/theme-init.js +2 -2
  36. package/dist/security.js +143 -16
  37. package/dist/security.js.map +1 -1
  38. package/dist/server-runtime.js +56 -1
  39. package/dist/server-runtime.js.map +1 -1
  40. package/dist/store.js +289 -39
  41. package/dist/store.js.map +1 -1
  42. package/dist/user-auth.js +56 -88
  43. package/dist/user-auth.js.map +1 -1
  44. package/dist/version.js +1 -1
  45. package/dist/writing-progress-time.js +15 -0
  46. package/dist/writing-progress-time.js.map +1 -1
  47. package/package.json +2 -1
package/dist/app.js CHANGED
@@ -14,14 +14,14 @@ import { AiManager } from "./ai.js";
14
14
  import { CredentialVault } from "./credential-vault.js";
15
15
  import { Database } from "./database.js";
16
16
  import { assertSafeDocxArchive } from "./docx-security.js";
17
- import { TASK_TYPES } from "./domain.js";
17
+ import { DRAFT_SETTING_MODULES, TASK_TYPES } from "./domain.js";
18
18
  import { AppError } from "./errors.js";
19
19
  import { HYBRID_SEARCH_TYPES } from "./hybrid-search.js";
20
20
  import { applyImportFileHints, parseNovelText } from "./parser.js";
21
- import { Store, versionedEntityTypes } from "./store.js";
22
- import { parsePagination } from "./pagination.js";
21
+ import { attachmentPermissionModules, Store, versionedEntityTypes } from "./store.js";
22
+ import { paginated, parsePagination } from "./pagination.js";
23
23
  import { normalizeUploadFileName } from "./utils.js";
24
- import { assertSafeAiEndpoint, createApiRateLimitMiddleware, createAuthenticationRateLimitMiddleware, createBasicAuthMiddleware, createSameOriginMiddleware, createSecurityHeadersMiddleware } from "./security.js";
24
+ import { assertSafeAiEndpoint, createApiRateLimitMiddleware, createAuthenticationRateLimitMiddleware, createBasicAuthMiddleware, createCaptchaRateLimitMiddleware, createExpensiveApiRateLimitMiddleware, createSameOriginMiddleware, createSecurityHeadersMiddleware, createUploadRateLimitMiddleware, enforceCaseInsensitiveRouting, normalizeApiPath, resolveTrustProxySetting, verifySetupToken } from "./security.js";
25
25
  import { ImageCaptchaService } from "./image-captcha.js";
26
26
  import { assertSafeImportedPlainText, decodeUtf8ImportedText } from "./import-security.js";
27
27
  import { InvalidRasterImageError, readRasterImageMetadata } from "./image-metadata.js";
@@ -29,7 +29,7 @@ import { createRequestLoggingMiddleware, sanitizeRequestPath } from "./http-logg
29
29
  import { accountReference, logger, sanitizeError } from "./logger.js";
30
30
  import { currentRequestActor, runWithRequestActor } from "./request-context.js";
31
31
  import { APP_VERSION } from "./version.js";
32
- import { fullWorkModulePermissions, proseReplacementPermissionModules } from "./work-permissions.js";
32
+ import { canReadWorkModule, canWriteWorkModule, fullWorkModulePermissions, proseReplacementPermissionModules } from "./work-permissions.js";
33
33
  import { CollaborationPresence, entityEditorPageKey, presencePageKinds } from "./collaboration-presence.js";
34
34
  import { analysisTaskReadModules, clearSessionCookie, createCliApiScopeMiddleware, createUserSessionMiddleware, createWorkAuthorizationMiddleware, relationshipAnalysisReadModules, setSessionCookie, UserAuthService } from "./user-auth.js";
35
35
  const nonEmpty = z.string().trim().min(1);
@@ -38,6 +38,7 @@ const optionalStrings = z.array(z.string()).optional();
38
38
  const jsonObject = z.record(z.string(), z.unknown());
39
39
  const chapterTypeSchema = z.enum(["正文", "设定", "作者的话", "其他"]);
40
40
  const versionedEntityTypeSchema = z.enum(versionedEntityTypes);
41
+ const attachmentPermissionModuleSchema = z.enum(attachmentPermissionModules);
41
42
  const maximumImportedTextLength = 20_000_000;
42
43
  const maximumKnowledgeSectionsLength = 4_000_000;
43
44
  const captchaFields = {
@@ -50,6 +51,7 @@ const registrationSchema = z.object({
50
51
  username: usernameSchema,
51
52
  password: passwordSchema,
52
53
  passwordConfirmation: passwordSchema,
54
+ setupToken: z.string().max(500).optional(),
53
55
  ...captchaFields
54
56
  }).strict().refine((input) => input.password === input.passwordConfirmation, {
55
57
  path: ["passwordConfirmation"],
@@ -144,6 +146,8 @@ const settingSchema = z.object({
144
146
  });
145
147
  const draftSchema = z.object({
146
148
  draftType: z.enum(["prose", "setting"]),
149
+ volumeId: identifier.nullable().optional(),
150
+ settingModule: z.enum(DRAFT_SETTING_MODULES).nullable().optional(),
147
151
  title: nonEmpty.max(200),
148
152
  content: z.string().max(200_000)
149
153
  }).strict();
@@ -375,6 +379,7 @@ const aiProcessStepSchema = z.discriminatedUnion("type", [
375
379
  ]);
376
380
  const workAiSettingsSchema = z.object({
377
381
  systemPrompt: z.string().max(100_000).optional(),
382
+ dailyTokenQuota: z.number().int().min(10_000).max(2_000_000_000).nullable().optional(),
378
383
  autoRunEnabled: z.boolean().optional(),
379
384
  autoRunConcurrency: z.number().int().min(1).max(8).optional(),
380
385
  autoRunBatchLimit: z.number().int().min(1).max(200).optional(),
@@ -382,6 +387,8 @@ const workAiSettingsSchema = z.object({
382
387
  autoRunFailureThreshold: z.number().int().min(1).max(10).optional(),
383
388
  bookSummaryContextPercent: z.number().int().min(1).max(90).optional(),
384
389
  contextCompactThreshold: z.number().int().min(50).max(90).optional(),
390
+ agentToolCallLimit: z.number().int().min(5).max(48).optional(),
391
+ agentToolCallGlobalMultiplier: z.number().int().min(1).max(6).optional(),
385
392
  agentTools: z.array(z.enum(["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts"])).max(6).optional(),
386
393
  titleGenerationModelId: z.string().trim().max(200).optional()
387
394
  }).strict();
@@ -596,6 +603,114 @@ function redactTaskCharacterNames(record, permissions) {
596
603
  }
597
604
  return result;
598
605
  }
606
+ function redactAiCallContext(record, permissions) {
607
+ const result = { ...record };
608
+ const scope = recordValue(result.contextScope);
609
+ if (!scope)
610
+ return result;
611
+ const redactedScope = { ...scope };
612
+ let restricted = false;
613
+ if (permissions.prose === "none") {
614
+ for (const field of ["selection", "chapterId", "volumeId", "chapterIds", "includeBookSummary"]) {
615
+ if (field in redactedScope) {
616
+ delete redactedScope[field];
617
+ restricted = true;
618
+ }
619
+ }
620
+ }
621
+ if (permissions.characters === "none" && "characterIds" in redactedScope) {
622
+ delete redactedScope.characterIds;
623
+ restricted = true;
624
+ }
625
+ if (permissions.settings === "none" && "settingIds" in redactedScope) {
626
+ delete redactedScope.settingIds;
627
+ restricted = true;
628
+ }
629
+ result.contextScope = restricted ? { ...redactedScope, restricted: true } : redactedScope;
630
+ return result;
631
+ }
632
+ const proseRestrictedPlaceholder = "(正文读取权限受限)";
633
+ function redactContinuationGuard(record, permissions) {
634
+ if (permissions.prose !== "none")
635
+ return record;
636
+ return {
637
+ ...record,
638
+ issues: [],
639
+ contextRefs: {},
640
+ failure: null,
641
+ restricted: true
642
+ };
643
+ }
644
+ /** 无正文读取权限时移除建议中的原文、指令和检查证据,避免通过 AI 接口绕过 prose=none。 */
645
+ function redactSuggestion(record, permissions) {
646
+ if (permissions.prose !== "none")
647
+ return record;
648
+ const guard = recordValue(record.guard);
649
+ return {
650
+ ...record,
651
+ instruction: proseRestrictedPlaceholder,
652
+ sourceText: "",
653
+ ...(guard ? { guard: redactContinuationGuard(guard, permissions) } : {}),
654
+ restricted: true
655
+ };
656
+ }
657
+ function redactAiConversationMessage(item, permissions) {
658
+ if (permissions.prose !== "none")
659
+ return item;
660
+ const message = recordValue(item);
661
+ if (!message)
662
+ return item;
663
+ return {
664
+ ...message,
665
+ content: proseRestrictedPlaceholder,
666
+ citations: [],
667
+ metadata: { restricted: true },
668
+ restricted: true
669
+ };
670
+ }
671
+ /** 无正文读取权限时隐藏对话预览与消息正文,避免历史对话泄露章节原文。 */
672
+ function redactAiConversation(record, permissions) {
673
+ if (permissions.prose !== "none")
674
+ return record;
675
+ const result = {
676
+ ...record,
677
+ title: proseRestrictedPlaceholder
678
+ };
679
+ if (typeof result.preview === "string" && result.preview.length > 0) {
680
+ result.preview = proseRestrictedPlaceholder;
681
+ }
682
+ if (Array.isArray(result.messages)) {
683
+ result.messages = result.messages.map((item) => redactAiConversationMessage(item, permissions));
684
+ }
685
+ const messagesPage = recordValue(result.messagesPage);
686
+ if (messagesPage && Array.isArray(messagesPage.items)) {
687
+ result.messagesPage = {
688
+ ...messagesPage,
689
+ items: messagesPage.items.map((item) => redactAiConversationMessage(item, permissions))
690
+ };
691
+ }
692
+ return { ...result, restricted: true };
693
+ }
694
+ /** SSE 错误事件只暴露 AppError 的公开信息,避免透传内部异常 message。 */
695
+ export function publicAiStreamError(error) {
696
+ if (error instanceof AppError) {
697
+ const details = error.details && typeof error.details === "object" && !Array.isArray(error.details)
698
+ ? error.details
699
+ : null;
700
+ return {
701
+ code: error.code,
702
+ message: error.message,
703
+ status: error.status,
704
+ ...(error.status < 500 && typeof details?.failure === "string" ? { failure: details.failure } : {}),
705
+ ...(typeof details?.callId === "string" ? { callId: details.callId } : {}),
706
+ ...(typeof details?.providerName === "string" ? { providerName: details.providerName } : {}),
707
+ ...(typeof details?.providerId === "string" ? { providerId: details.providerId } : {}),
708
+ ...(typeof details?.modelId === "string" ? { modelId: details.modelId } : {}),
709
+ ...(typeof details?.modelRecordId === "string" ? { modelRecordId: details.modelRecordId } : {})
710
+ };
711
+ }
712
+ return { code: "AI_STREAM_FAILED", message: "AI 流式调用失败" };
713
+ }
599
714
  function redactMergeRecords(value, mapper) {
600
715
  const record = recordValue(value);
601
716
  if (!record)
@@ -622,6 +737,7 @@ export function createRuntime(options) {
622
737
  sameOriginEnforced: options.security?.enforceSameOrigin ?? true
623
738
  });
624
739
  const database = new Database(options.databasePath);
740
+ const bootId = randomUUID();
625
741
  const temporaryAttachmentRoot = options.databasePath === ":memory:" && !options.attachmentDirectory
626
742
  ? mkdtempSync(join(tmpdir(), "scriverse-attachments-"))
627
743
  : null;
@@ -642,6 +758,32 @@ export function createRuntime(options) {
642
758
  ? auth.listUsers().find((user) => user.status === "active") ?? null
643
759
  : null;
644
760
  const store = new Store(database);
761
+ let attachmentCleanupChain = Promise.resolve();
762
+ const cleanupAttachments = () => {
763
+ const cleanup = attachmentCleanupChain.then(async () => {
764
+ store.queueUnreferencedAttachments();
765
+ for (const queued of store.listAttachmentCleanupQueue()) {
766
+ if (!store.attachmentCleanupStillRequired(queued.storageKey)) {
767
+ store.completeAttachmentCleanup(queued.storageKey);
768
+ continue;
769
+ }
770
+ try {
771
+ await attachmentStorage.remove(queued.storageKey);
772
+ store.completeAttachmentCleanup(queued.storageKey);
773
+ }
774
+ catch (error) {
775
+ store.failAttachmentCleanup(queued.storageKey, error instanceof Error ? error.message : "Attachment cleanup failed");
776
+ logger.warn("attachment.cleanup.failed", {
777
+ storageKey: queued.storageKey,
778
+ attempts: queued.attempts + 1,
779
+ error: sanitizeError(error)
780
+ });
781
+ }
782
+ }
783
+ });
784
+ attachmentCleanupChain = cleanup.catch(() => undefined);
785
+ return cleanup;
786
+ };
645
787
  const requestPermissions = (request, workId) => {
646
788
  if (!request.authUser)
647
789
  return fullWorkModulePermissions();
@@ -666,6 +808,7 @@ export function createRuntime(options) {
666
808
  }, false, actor?.allowAdminAccess ?? false);
667
809
  });
668
810
  const app = express();
811
+ enforceCaseInsensitiveRouting(app);
669
812
  const upload = multer({
670
813
  storage: multer.memoryStorage(),
671
814
  limits: { fileSize: 30 * 1024 * 1024, files: 1, fields: 10, fieldSize: 64 * 1024, parts: 11, headerPairs: 100 }
@@ -686,13 +829,18 @@ export function createRuntime(options) {
686
829
  limits: { fileSize: 30 * 1024 * 1024, files: 1, fields: 4, fieldSize: 16 * 1024, parts: 5, headerPairs: 100 }
687
830
  });
688
831
  app.disable("x-powered-by");
689
- if (options.security?.trustProxy !== undefined)
690
- app.set("trust proxy", options.security.trustProxy);
832
+ const trustProxy = resolveTrustProxySetting(options.security?.trustProxy);
833
+ if (options.security?.trustProxy === true) {
834
+ logger.warn("security.trust_proxy.coerced", { from: true, to: 1 });
835
+ }
836
+ if (trustProxy !== undefined)
837
+ app.set("trust proxy", trustProxy);
691
838
  app.use(createRequestLoggingMiddleware());
692
839
  app.use(createSecurityHeadersMiddleware());
693
840
  app.get("/api/health", (_request, response) => {
694
841
  data(response, {
695
842
  status: "ok",
843
+ bootId,
696
844
  version: APP_VERSION,
697
845
  protocol: "openai-chat-completions",
698
846
  protocols: ["openai-chat-completions", "anthropic-messages"],
@@ -702,6 +850,7 @@ export function createRuntime(options) {
702
850
  if (options.security?.auth)
703
851
  app.use(createBasicAuthMiddleware(options.security.auth));
704
852
  app.use(createAuthenticationRateLimitMiddleware());
853
+ app.use(createCaptchaRateLimitMiddleware());
705
854
  app.use(createApiRateLimitMiddleware(options.security?.apiRateLimit, options.security?.apiRateWindowMs));
706
855
  if (options.security?.enforceSameOrigin ?? true)
707
856
  app.use(createSameOriginMiddleware());
@@ -709,14 +858,16 @@ export function createRuntime(options) {
709
858
  app.get("/api/auth/session", (request, response) => {
710
859
  const session = auth.authenticate(request);
711
860
  const registrationOpen = options.security?.allowRegistration === true;
861
+ const setupRequired = !auth.hasUsers();
862
+ const setupTokenRequired = setupRequired && Boolean(options.security?.setupToken);
712
863
  const developmentUser = getDevelopmentUser();
713
864
  if (!session && developmentUser) {
714
- data(response, { authenticated: true, user: developmentUser, csrfToken: null, setupRequired: false, registrationOpen });
865
+ data(response, { authenticated: true, user: developmentUser, csrfToken: null, bootId, setupRequired: false, setupTokenRequired: false, registrationOpen });
715
866
  return;
716
867
  }
717
868
  data(response, session
718
- ? { authenticated: true, user: session.user, csrfToken: session.csrfToken, setupRequired: false, registrationOpen }
719
- : { authenticated: false, user: null, csrfToken: null, setupRequired: !auth.hasUsers(), registrationOpen });
869
+ ? { authenticated: true, user: session.user, csrfToken: session.csrfToken, bootId, setupRequired: false, setupTokenRequired: false, registrationOpen }
870
+ : { authenticated: false, user: null, csrfToken: null, bootId, setupRequired, setupTokenRequired, registrationOpen });
720
871
  });
721
872
  app.get("/api/auth/captcha", (_request, response) => {
722
873
  data(response, captcha.create());
@@ -727,6 +878,9 @@ export function createRuntime(options) {
727
878
  }
728
879
  const input = parse(registrationSchema, request.body);
729
880
  captcha.consume(input.captchaId, input.captchaAnswer);
881
+ if (!auth.hasUsers() && !verifySetupToken(options.security?.setupToken, input.setupToken)) {
882
+ throw new AppError(403, "SETUP_TOKEN_INVALID", "初始化令牌无效或未配置");
883
+ }
730
884
  const result = auth.register({ username: input.username, password: input.password });
731
885
  setSessionCookie(response, result.token, request.secure);
732
886
  runWithRequestActor(result.session.user, () => store.audit(null, "user.registered", "user", result.session.user.userId, { role: result.session.user.role }));
@@ -746,6 +900,8 @@ export function createRuntime(options) {
746
900
  disabled: options.disableUserAuth === true,
747
901
  resolveBypassUser: getDevelopmentUser
748
902
  }));
903
+ app.use(createUploadRateLimitMiddleware());
904
+ app.use(createExpensiveApiRateLimitMiddleware());
749
905
  app.use(createCliApiScopeMiddleware(options.disableUserAuth));
750
906
  app.use(createWorkAuthorizationMiddleware(auth, options.disableUserAuth));
751
907
  app.get("/api/cli/session", (request, response) => {
@@ -854,6 +1010,8 @@ export function createRuntime(options) {
854
1010
  app.patch("/api/users/:userId", (request, response) => {
855
1011
  if (!request.authUser)
856
1012
  throw new AppError(401, "AUTH_REQUIRED", "请先登录");
1013
+ if (request.authUser.role !== "admin")
1014
+ throw new AppError(403, "ADMIN_REQUIRED", "该操作仅限系统管理员");
857
1015
  const updated = auth.updateUser(request.authUser, request.params.userId, parse(userUpdateSchema, request.body));
858
1016
  store.audit(null, "user.updated", "user", updated.userId, { role: updated.role, status: updated.status });
859
1017
  data(response, updated);
@@ -945,8 +1103,8 @@ export function createRuntime(options) {
945
1103
  });
946
1104
  app.delete("/api/works/:workId", async (request, response) => {
947
1105
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
948
- const removableStorageKeys = store.deleteWork(request.params.workId, input.expectedVersionNo);
949
- await Promise.all(removableStorageKeys.map((storageKey) => attachmentStorage.remove(storageKey)));
1106
+ store.deleteWork(request.params.workId, input.expectedVersionNo);
1107
+ await cleanupAttachments();
950
1108
  noContent(response);
951
1109
  });
952
1110
  app.get("/api/works/:workId/cover", (request, response) => {
@@ -961,14 +1119,16 @@ export function createRuntime(options) {
961
1119
  if (!request.file)
962
1120
  throw new AppError(400, "FILE_REQUIRED", "请选择 PNG、JPEG 或 WebP 封面");
963
1121
  const bytes = request.file.buffer;
964
- const isPng = bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
965
- const isJpeg = bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
966
- const isWebp = bytes.length >= 12 && bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP";
967
- const mimeType = isPng ? "image/png" : isJpeg ? "image/jpeg" : isWebp ? "image/webp" : null;
968
- if (!mimeType)
969
- throw new AppError(415, "INVALID_COVER", "封面文件内容不是有效的 PNG、JPEG 或 WebP 图片");
970
- const expectedVersionNo = parse(expectedVersionNoSchema, request.body.expectedVersionNo);
971
- data(response, store.setWorkCover(String(request.params.workId), mimeType, bytes, expectedVersionNo));
1122
+ try {
1123
+ const metadata = readRasterImageMetadata(bytes);
1124
+ const expectedVersionNo = parse(expectedVersionNoSchema, request.body.expectedVersionNo);
1125
+ data(response, store.setWorkCover(String(request.params.workId), metadata.mimeType, bytes, expectedVersionNo));
1126
+ }
1127
+ catch (error) {
1128
+ if (error instanceof InvalidRasterImageError)
1129
+ throw new AppError(415, "INVALID_COVER", error.message);
1130
+ throw error;
1131
+ }
972
1132
  });
973
1133
  app.delete("/api/works/:workId/cover", (request, response) => {
974
1134
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
@@ -1301,11 +1461,16 @@ export function createRuntime(options) {
1301
1461
  });
1302
1462
  app.get("/api/works/:workId/attachments", (request, response) => {
1303
1463
  const pagination = parsePagination(request.query);
1304
- data(response, pagination ? store.listAttachmentsPage(request.params.workId, pagination) : store.listAttachments(request.params.workId));
1464
+ const permissions = requestPermissions(request, request.params.workId);
1465
+ const readable = store.listAttachments(request.params.workId).filter((attachment) => (store.attachmentModules(String(attachment.id)).some((module) => canReadWorkModule(permissions, module))));
1466
+ data(response, pagination
1467
+ ? paginated(readable.slice(pagination.offset, pagination.offset + pagination.limit + 1), pagination, readable.length)
1468
+ : readable);
1305
1469
  });
1306
1470
  app.post("/api/works/:workId/attachments", attachmentUpload.single("file"), async (request, response) => {
1307
1471
  if (!request.file)
1308
1472
  throw new AppError(400, "FILE_REQUIRED", "请选择要上传的图片附件");
1473
+ const accessModule = parse(attachmentPermissionModuleSchema, request.query.module ?? "settings");
1309
1474
  let storageKey = null;
1310
1475
  try {
1311
1476
  const stored = await attachmentStorage.ingest(request.file.path);
@@ -1313,7 +1478,7 @@ export function createRuntime(options) {
1313
1478
  const result = store.createAttachment(String(request.params.workId), {
1314
1479
  originalName: normalizeUploadFileName(request.file.originalname),
1315
1480
  ...stored
1316
- });
1481
+ }, accessModule);
1317
1482
  data(response, { ...result.attachment, deduplicated: !result.created }, result.created ? 201 : 200);
1318
1483
  }
1319
1484
  catch (error) {
@@ -1330,6 +1495,10 @@ export function createRuntime(options) {
1330
1495
  });
1331
1496
  app.get("/api/attachments/:attachmentId/content", async (request, response) => {
1332
1497
  const attachment = store.getAttachment(request.params.attachmentId);
1498
+ const permissions = requestPermissions(request, String(attachment.workId));
1499
+ if (!store.attachmentModules(request.params.attachmentId).some((module) => canReadWorkModule(permissions, module))) {
1500
+ throw new AppError(403, "WORK_MODULE_READ_DENIED", "你没有读取该附件所属资料模块的权限");
1501
+ }
1333
1502
  const content = await attachmentStorage.read(String(attachment.storageKey));
1334
1503
  response.setHeader("Content-Type", String(attachment.storedMimeType));
1335
1504
  response.setHeader("Content-Length", String(attachment.storedByteLength));
@@ -1339,9 +1508,13 @@ export function createRuntime(options) {
1339
1508
  response.send(content);
1340
1509
  });
1341
1510
  app.delete("/api/attachments/:attachmentId", async (request, response) => {
1342
- const deleted = store.deleteAttachment(request.params.attachmentId);
1343
- if (deleted.removeStoredFile)
1344
- await attachmentStorage.remove(deleted.storageKey);
1511
+ const attachment = store.getAttachment(request.params.attachmentId);
1512
+ const permissions = requestPermissions(request, String(attachment.workId));
1513
+ if (!store.attachmentModules(request.params.attachmentId).some((module) => canWriteWorkModule(permissions, module))) {
1514
+ throw new AppError(403, "WORK_MODULE_WRITE_DENIED", "你没有编辑该附件所属资料模块的权限");
1515
+ }
1516
+ store.deleteAttachment(request.params.attachmentId);
1517
+ await cleanupAttachments();
1345
1518
  noContent(response);
1346
1519
  });
1347
1520
  app.get("/api/works/:workId/races", (request, response) => {
@@ -1703,7 +1876,8 @@ export function createRuntime(options) {
1703
1876
  page: request.query.page ?? "1",
1704
1877
  limit: request.query.limit ?? "20"
1705
1878
  }) ?? { page: 1, limit: 20, offset: 0 };
1706
- data(response, store.listAiConversationsPage(request.params.workId, pagination));
1879
+ const permissions = requestPermissions(request, request.params.workId);
1880
+ data(response, mapRecords(store.listAiConversationsPage(request.params.workId, pagination), (conversation) => (redactAiConversation(conversation, permissions))));
1707
1881
  });
1708
1882
  app.post("/api/works/:workId/ai-conversations", (request, response) => {
1709
1883
  const input = parse(z.object({ title: z.string().max(200).optional() }), request.body ?? {});
@@ -1711,11 +1885,17 @@ export function createRuntime(options) {
1711
1885
  });
1712
1886
  app.get("/api/ai-conversations/:conversationId", (request, response) => {
1713
1887
  const pagination = parsePagination(request.query);
1714
- data(response, pagination ? store.getAiConversationPage(request.params.conversationId, pagination) : store.getAiConversation(request.params.conversationId));
1888
+ const conversation = pagination
1889
+ ? store.getAiConversationPage(request.params.conversationId, pagination)
1890
+ : store.getAiConversation(request.params.conversationId);
1891
+ const permissions = requestPermissions(request, String(conversation.workId));
1892
+ data(response, redactAiConversation(conversation, permissions));
1715
1893
  });
1716
1894
  app.post("/api/ai-conversations/:conversationId/fork", (request, response) => {
1717
1895
  const input = parse(z.object({ messageId: identifier, title: z.string().max(200).optional() }), request.body);
1718
- data(response, store.forkAiConversation(request.params.conversationId, input.messageId, input.title), 201);
1896
+ const forked = store.forkAiConversation(request.params.conversationId, input.messageId, input.title);
1897
+ const permissions = requestPermissions(request, String(forked.workId));
1898
+ data(response, redactAiConversation(forked, permissions), 201);
1719
1899
  });
1720
1900
  app.post("/api/ai-conversations/:conversationId/messages", (request, response) => {
1721
1901
  const input = parse(z.object({
@@ -1732,7 +1912,10 @@ export function createRuntime(options) {
1732
1912
  processSteps: z.array(aiProcessStepSchema).max(50).optional()
1733
1913
  }).optional()
1734
1914
  }), request.body);
1735
- data(response, store.addAiConversationMessage(request.params.conversationId, input), 201);
1915
+ const message = store.addAiConversationMessage(request.params.conversationId, input);
1916
+ const conversation = store.getAiConversationSummary(request.params.conversationId);
1917
+ const permissions = requestPermissions(request, String(conversation.workId));
1918
+ data(response, redactAiConversationMessage(message, permissions), 201);
1736
1919
  });
1737
1920
  app.post("/api/ai-conversations/:conversationId/context/prepare", async (request, response) => {
1738
1921
  const input = parse(z.object({
@@ -1814,7 +1997,10 @@ export function createRuntime(options) {
1814
1997
  app.get("/api/works/:workId/suggestions", (request, response) => {
1815
1998
  const status = typeof request.query.status === "string" ? request.query.status : undefined;
1816
1999
  const pagination = parsePagination(request.query);
1817
- data(response, pagination ? ai.listSuggestionsPage(request.params.workId, pagination, status) : ai.listSuggestions(request.params.workId, status));
2000
+ const permissions = requestPermissions(request, request.params.workId);
2001
+ data(response, pagination
2002
+ ? mapRecords(ai.listSuggestionsPage(request.params.workId, pagination, status), (suggestion) => redactSuggestion(suggestion, permissions))
2003
+ : ai.listSuggestions(request.params.workId, status).map((suggestion) => redactSuggestion(suggestion, permissions)));
1818
2004
  });
1819
2005
  app.post("/api/works/:workId/suggestions", async (request, response) => {
1820
2006
  const input = parse(z.object({
@@ -1830,14 +2016,14 @@ export function createRuntime(options) {
1830
2016
  if (store.getChapter(citation.chapterId).workId !== request.params.workId)
1831
2017
  throw new AppError(400, "CITATION_WORK_MISMATCH", "引用章节不属于当前作品");
1832
2018
  }
1833
- data(response, await ai.createSuggestion({
2019
+ data(response, redactSuggestion(await ai.createSuggestion({
1834
2020
  workId: request.params.workId,
1835
2021
  taskType: input.taskType,
1836
2022
  instruction: instructionWithCitations(input.instruction, citations),
1837
2023
  scope: input.scope,
1838
2024
  ...(input.modelId ? { modelId: input.modelId } : {}),
1839
2025
  ...(input.parameters ? { parameters: input.parameters } : {})
1840
- }), 201);
2026
+ }), requestPermissions(request, request.params.workId)), 201);
1841
2027
  });
1842
2028
  app.post("/api/works/:workId/chat/stream", async (request, response) => {
1843
2029
  const input = parse(z.object({
@@ -1878,6 +2064,7 @@ export function createRuntime(options) {
1878
2064
  throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
1879
2065
  }
1880
2066
  const conversationId = String(conversation.id);
2067
+ const permissions = requestPermissions(request, request.params.workId);
1881
2068
  const prepared = await ai.prepareConversationContext({
1882
2069
  conversationId,
1883
2070
  workId: request.params.workId,
@@ -1888,10 +2075,10 @@ export function createRuntime(options) {
1888
2075
  });
1889
2076
  sendEvent("context", {
1890
2077
  ...prepared,
1891
- conversation: {
2078
+ conversation: redactAiConversation({
1892
2079
  ...store.getAiConversationSummary(conversationId),
1893
2080
  contextWarningPending: prepared.action === "warn"
1894
- }
2081
+ }, permissions)
1895
2082
  });
1896
2083
  if (prepared.action === "warn")
1897
2084
  return;
@@ -1904,7 +2091,7 @@ export function createRuntime(options) {
1904
2091
  });
1905
2092
  const currentMessageId = input.currentMessageId ?? String(userMessage?.id ?? "");
1906
2093
  if (userMessage)
1907
- sendEvent("user_message", { message: userMessage });
2094
+ sendEvent("user_message", { message: redactAiConversationMessage(userMessage, permissions) });
1908
2095
  const suggestion = await ai.createStreamingChat({
1909
2096
  workId: request.params.workId,
1910
2097
  instruction: instructionWithCitations(input.instruction, citations),
@@ -1942,20 +2129,11 @@ export function createRuntime(options) {
1942
2129
  }
1943
2130
  catch (error) {
1944
2131
  if (!controller.signal.aborted) {
1945
- const details = error instanceof AppError && error.details && typeof error.details === "object" && !Array.isArray(error.details)
1946
- ? error.details
1947
- : null;
1948
- sendEvent("error", {
1949
- code: error instanceof AppError ? error.code : "AI_STREAM_FAILED",
1950
- message: error instanceof Error ? error.message : "AI 流式调用失败",
1951
- ...(error instanceof AppError ? { status: error.status } : {}),
1952
- ...(typeof details?.failure === "string" ? { failure: details.failure } : {}),
1953
- ...(typeof details?.callId === "string" ? { callId: details.callId } : {}),
1954
- ...(typeof details?.providerName === "string" ? { providerName: details.providerName } : {}),
1955
- ...(typeof details?.providerId === "string" ? { providerId: details.providerId } : {}),
1956
- ...(typeof details?.modelId === "string" ? { modelId: details.modelId } : {}),
1957
- ...(typeof details?.modelRecordId === "string" ? { modelRecordId: details.modelRecordId } : {})
2132
+ logger.error("ai.stream.failed", {
2133
+ workId: request.params.workId,
2134
+ error: sanitizeError(error)
1958
2135
  });
2136
+ sendEvent("error", publicAiStreamError(error));
1959
2137
  }
1960
2138
  }
1961
2139
  finally {
@@ -1963,25 +2141,40 @@ export function createRuntime(options) {
1963
2141
  response.end();
1964
2142
  }
1965
2143
  });
1966
- app.get("/api/suggestions/:suggestionId", (request, response) => data(response, ai.getSuggestion(request.params.suggestionId)));
2144
+ app.get("/api/suggestions/:suggestionId", (request, response) => {
2145
+ const suggestion = ai.getSuggestion(request.params.suggestionId);
2146
+ const permissions = requestPermissions(request, String(suggestion.workId));
2147
+ data(response, redactSuggestion(suggestion, permissions));
2148
+ });
1967
2149
  app.get("/api/suggestions/:suggestionId/guards", (request, response) => {
1968
2150
  const pagination = parsePagination(request.query);
1969
- data(response, pagination
2151
+ const suggestion = ai.getSuggestion(request.params.suggestionId);
2152
+ const permissions = requestPermissions(request, String(suggestion.workId));
2153
+ data(response, mapRecords(pagination
1970
2154
  ? store.listContinuationGuardsPage(request.params.suggestionId, pagination)
1971
- : store.listContinuationGuards(request.params.suggestionId));
2155
+ : store.listContinuationGuards(request.params.suggestionId), (guard) => redactContinuationGuard(guard, permissions)));
1972
2156
  });
1973
2157
  app.post("/api/suggestions/:suggestionId/guard", async (request, response) => {
1974
2158
  const input = parse(z.object({ content: z.string().max(2_000_000).optional() }), request.body ?? {});
1975
- data(response, await ai.runSuggestionGuard(request.params.suggestionId, input.content), 201);
2159
+ const suggestion = ai.getSuggestion(request.params.suggestionId);
2160
+ const permissions = requestPermissions(request, String(suggestion.workId));
2161
+ data(response, redactContinuationGuard(await ai.runSuggestionGuard(request.params.suggestionId, input.content), permissions), 201);
1976
2162
  });
1977
2163
  app.post("/api/suggestions/:suggestionId/accept", (request, response) => {
1978
2164
  const input = parse(z.object({ content: z.string().max(2_000_000).optional() }), request.body ?? {});
1979
2165
  data(response, ai.acceptSuggestion(request.params.suggestionId, input.content));
1980
2166
  });
1981
- app.post("/api/suggestions/:suggestionId/reject", (request, response) => data(response, ai.rejectSuggestion(request.params.suggestionId)));
2167
+ app.post("/api/suggestions/:suggestionId/reject", (request, response) => {
2168
+ const suggestion = ai.rejectSuggestion(request.params.suggestionId);
2169
+ const permissions = requestPermissions(request, String(suggestion.workId));
2170
+ data(response, redactSuggestion(suggestion, permissions));
2171
+ });
1982
2172
  app.get("/api/works/:workId/ai-calls", (request, response) => {
1983
2173
  const pagination = parsePagination(request.query);
1984
- data(response, pagination ? ai.listCallsPage(request.params.workId, pagination) : ai.listCalls(request.params.workId));
2174
+ const permissions = requestPermissions(request, request.params.workId);
2175
+ data(response, pagination
2176
+ ? mapRecords(ai.listCallsPage(request.params.workId, pagination), (call) => redactAiCallContext(call, permissions))
2177
+ : ai.listCalls(request.params.workId).map((call) => redactAiCallContext(call, permissions)));
1985
2178
  });
1986
2179
  app.get("/api/works/:workId/search", async (request, response) => {
1987
2180
  const query = parse(z.object({
@@ -1992,7 +2185,7 @@ export function createRuntime(options) {
1992
2185
  data(response, await ai.searchWork(request.params.workId, query.q, { type: query.type, limit: query.limit }));
1993
2186
  });
1994
2187
  app.get("/api/works/:workId/export", async (request, response) => {
1995
- const format = parse(z.enum(["json", "txt", "markdown"]), request.query.format ?? "json");
2188
+ const format = parse(z.enum(["json", "txt", "markdown", "docx"]), request.query.format ?? "json");
1996
2189
  if (format === "json") {
1997
2190
  response.setHeader("Content-Disposition", `attachment; filename=novel-${request.params.workId}.json`);
1998
2191
  data(response, store.exportWork(request.params.workId));
@@ -2012,6 +2205,12 @@ export function createRuntime(options) {
2012
2205
  }), response);
2013
2206
  return;
2014
2207
  }
2208
+ if (format === "docx") {
2209
+ response.type("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
2210
+ response.setHeader("Content-Disposition", `attachment; filename=novel-${request.params.workId}.docx`);
2211
+ response.send(await store.exportDocx(request.params.workId));
2212
+ return;
2213
+ }
2015
2214
  response.type("text/plain");
2016
2215
  response.setHeader("Content-Disposition", `attachment; filename=novel-${request.params.workId}.txt`);
2017
2216
  response.send(store.exportText(request.params.workId, format));
@@ -2071,7 +2270,7 @@ export function createRuntime(options) {
2071
2270
  setHeaders: setStaticCacheControl
2072
2271
  }));
2073
2272
  app.get("/{*path}", (request, response, next) => {
2074
- if (request.path.startsWith("/api/"))
2273
+ if (normalizeApiPath(request.path).startsWith("/api/"))
2075
2274
  return next();
2076
2275
  sendIndexHtml(request, response);
2077
2276
  });
@@ -2135,7 +2334,7 @@ export function createRuntime(options) {
2135
2334
  response.status(500).json({ error: { code: "INTERNAL_ERROR", message: "服务器内部错误" } });
2136
2335
  });
2137
2336
  logger.info("runtime.ready", { serveUi: options.serveUi ?? true });
2138
- return { app, database, store, ai, auth, attachmentStorage, close: () => {
2337
+ return { app, database, store, ai, auth, attachmentStorage, cleanupAttachments, close: () => {
2139
2338
  logger.info("runtime.closing");
2140
2339
  ai.dispose();
2141
2340
  database.close();