@musnows/scriverse 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/ai-protocol.js +22 -9
  2. package/dist/ai-protocol.js.map +1 -1
  3. package/dist/ai-tool-results.js +71 -0
  4. package/dist/ai-tool-results.js.map +1 -1
  5. package/dist/ai.js +913 -195
  6. package/dist/ai.js.map +1 -1
  7. package/dist/app.js +245 -46
  8. package/dist/app.js.map +1 -1
  9. package/dist/cli-core.js +23 -13
  10. package/dist/cli-core.js.map +1 -1
  11. package/dist/database.js +251 -2
  12. package/dist/database.js.map +1 -1
  13. package/dist/docx-export.js +89 -0
  14. package/dist/docx-export.js.map +1 -0
  15. package/dist/google-vertex-auth.js +155 -0
  16. package/dist/google-vertex-auth.js.map +1 -0
  17. package/dist/image-captcha.js +8 -5
  18. package/dist/image-captcha.js.map +1 -1
  19. package/dist/public/ai-context-meter.js +4 -0
  20. package/dist/public/ai-mentions.js +10 -0
  21. package/dist/public/ai-message-time.js +3 -9
  22. package/dist/public/ai-tool-call.js +4 -0
  23. package/dist/public/app.js +1004 -131
  24. package/dist/public/display-labels.js +2 -1
  25. package/dist/public/index.html +75 -16
  26. package/dist/public/page-route.js +2 -2
  27. package/dist/public/styles.css +155 -19
  28. package/dist/public/system-status.d.ts +12 -0
  29. package/dist/public/system-status.js +16 -0
  30. package/dist/public/theme-init.js +2 -2
  31. package/dist/security.js +101 -9
  32. package/dist/security.js.map +1 -1
  33. package/dist/store.js +290 -21
  34. package/dist/store.js.map +1 -1
  35. package/dist/user-auth.js +62 -24
  36. package/dist/user-auth.js.map +1 -1
  37. package/dist/version.js +1 -1
  38. package/dist/writing-progress-time.js +23 -0
  39. package/dist/writing-progress-time.js.map +1 -1
  40. package/package.json +2 -1
package/dist/app.js CHANGED
@@ -9,6 +9,7 @@ import { rm } from "node:fs/promises";
9
9
  import { tmpdir } from "node:os";
10
10
  import { pipeline } from "node:stream/promises";
11
11
  import { z, ZodError } from "zod";
12
+ import { AI_PROVIDER_PROTOCOLS } from "./ai-protocol.js";
12
13
  import { AttachmentStorage } from "./attachment-storage.js";
13
14
  import { AiManager } from "./ai.js";
14
15
  import { CredentialVault } from "./credential-vault.js";
@@ -16,12 +17,13 @@ import { Database } from "./database.js";
16
17
  import { assertSafeDocxArchive } from "./docx-security.js";
17
18
  import { DRAFT_SETTING_MODULES, TASK_TYPES } from "./domain.js";
18
19
  import { AppError } from "./errors.js";
20
+ import { isOfficialGoogleVertexBaseUrl, parseGoogleServiceAccount } from "./google-vertex-auth.js";
19
21
  import { HYBRID_SEARCH_TYPES } from "./hybrid-search.js";
20
22
  import { applyImportFileHints, parseNovelText } from "./parser.js";
21
- import { attachmentPermissionModules, Store, versionedEntityTypes } from "./store.js";
23
+ import { aiConversationTaskTypes, attachmentPermissionModules, Store, versionedEntityTypes } from "./store.js";
22
24
  import { paginated, parsePagination } from "./pagination.js";
23
25
  import { normalizeUploadFileName } from "./utils.js";
24
- import { assertSafeAiEndpoint, createApiRateLimitMiddleware, createAuthenticationRateLimitMiddleware, createBasicAuthMiddleware, createSameOriginMiddleware, createSecurityHeadersMiddleware, createUploadRateLimitMiddleware, verifySetupToken } from "./security.js";
26
+ import { assertSafeAiEndpoint, createApiRateLimitMiddleware, createAuthenticationRateLimitMiddleware, createBasicAuthMiddleware, createCaptchaRateLimitMiddleware, createExpensiveApiRateLimitMiddleware, createSameOriginMiddleware, createSecurityHeadersMiddleware, createUploadRateLimitMiddleware, enforceCaseInsensitiveRouting, normalizeApiPath, resolveTrustProxySetting, verifySetupToken } from "./security.js";
25
27
  import { ImageCaptchaService } from "./image-captcha.js";
26
28
  import { assertSafeImportedPlainText, decodeUtf8ImportedText } from "./import-security.js";
27
29
  import { InvalidRasterImageError, readRasterImageMetadata } from "./image-metadata.js";
@@ -37,6 +39,7 @@ const identifier = z.string().trim().min(1).max(200);
37
39
  const optionalStrings = z.array(z.string()).optional();
38
40
  const jsonObject = z.record(z.string(), z.unknown());
39
41
  const chapterTypeSchema = z.enum(["正文", "设定", "作者的话", "其他"]);
42
+ const aiConversationTaskTypeSchema = z.enum(aiConversationTaskTypes);
40
43
  const versionedEntityTypeSchema = z.enum(versionedEntityTypes);
41
44
  const attachmentPermissionModuleSchema = z.enum(attachmentPermissionModules);
42
45
  const maximumImportedTextLength = 20_000_000;
@@ -289,16 +292,54 @@ const reviewSchema = z.object({
289
292
  status: z.enum(["pending", "ignored", "fixing", "fixed", "exception"]).optional(),
290
293
  resolutionNote: z.string().max(20_000).optional()
291
294
  });
292
- const providerSchema = z.object({
295
+ const providerBaseSchema = z.object({
293
296
  name: nonEmpty.max(200),
294
297
  baseUrl: z.string().url().refine((value) => value.startsWith("http://") || value.startsWith("https://"), "接口地址必须使用 HTTP 或 HTTPS"),
295
- apiKey: nonEmpty.max(10_000),
296
- protocol: z.enum(["openai-chat-completions", "anthropic-messages"]).optional(),
298
+ apiKey: z.string().trim().min(1).max(50_000),
299
+ protocol: z.enum(AI_PROVIDER_PROTOCOLS).optional(),
297
300
  status: z.enum(["enabled", "disabled"]).optional(),
298
301
  note: z.string().max(10_000).optional(),
299
302
  concurrencyLimit: z.number().int().min(1).max(100).optional(),
300
303
  rpmLimit: z.number().int().min(1).max(10_000).optional()
301
304
  });
305
+ function refineProviderApiKey(value, ctx) {
306
+ if (value.protocol === "google-vertex" && value.baseUrl && !isOfficialGoogleVertexBaseUrl(value.baseUrl)) {
307
+ ctx.addIssue({
308
+ code: z.ZodIssueCode.custom,
309
+ path: ["baseUrl"],
310
+ message: "Google Vertex 接口地址必须使用官方 aiplatform.googleapis.com 或 *-aiplatform.googleapis.com 域名"
311
+ });
312
+ }
313
+ if (!value.apiKey)
314
+ return;
315
+ const protocol = value.protocol ?? "openai-chat-completions";
316
+ if (protocol === "google-vertex") {
317
+ try {
318
+ parseGoogleServiceAccount(value.apiKey);
319
+ }
320
+ catch (error) {
321
+ ctx.addIssue({
322
+ code: z.ZodIssueCode.custom,
323
+ path: ["apiKey"],
324
+ message: error instanceof AppError ? error.message : "服务账号 JSON 无效"
325
+ });
326
+ }
327
+ return;
328
+ }
329
+ if (value.apiKey.length > 10_000) {
330
+ ctx.addIssue({
331
+ code: z.ZodIssueCode.custom,
332
+ path: ["apiKey"],
333
+ message: "API 密钥过长"
334
+ });
335
+ }
336
+ }
337
+ const providerSchema = providerBaseSchema.superRefine((value, ctx) => {
338
+ refineProviderApiKey(value, ctx);
339
+ });
340
+ const providerUpdateSchema = providerBaseSchema.partial().superRefine((value, ctx) => {
341
+ refineProviderApiKey(value, ctx);
342
+ });
302
343
  const modelSchema = z.object({
303
344
  displayName: nonEmpty.max(200),
304
345
  modelId: nonEmpty.max(300),
@@ -379,6 +420,7 @@ const aiProcessStepSchema = z.discriminatedUnion("type", [
379
420
  ]);
380
421
  const workAiSettingsSchema = z.object({
381
422
  systemPrompt: z.string().max(100_000).optional(),
423
+ dailyTokenQuota: z.number().int().min(10_000).max(2_000_000_000).nullable().optional(),
382
424
  autoRunEnabled: z.boolean().optional(),
383
425
  autoRunConcurrency: z.number().int().min(1).max(8).optional(),
384
426
  autoRunBatchLimit: z.number().int().min(1).max(200).optional(),
@@ -386,18 +428,24 @@ const workAiSettingsSchema = z.object({
386
428
  autoRunFailureThreshold: z.number().int().min(1).max(10).optional(),
387
429
  bookSummaryContextPercent: z.number().int().min(1).max(90).optional(),
388
430
  contextCompactThreshold: z.number().int().min(50).max(90).optional(),
431
+ agentToolCallLimit: z.number().int().min(5).max(48).optional(),
432
+ agentToolCallGlobalMultiplier: z.number().int().min(1).max(6).optional(),
389
433
  agentTools: z.array(z.enum(["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts"])).max(6).optional(),
390
434
  titleGenerationModelId: z.string().trim().max(200).optional()
391
435
  }).strict();
392
436
  const contextSchema = z.object({
393
- type: z.enum(["none", "selection", "chapter", "volume", "book", "entities"]),
437
+ type: z.enum(["none", "selection", "chapter", "volume", "book", "settings-catalog", "entities"]),
394
438
  chapterId: identifier.optional(),
395
439
  volumeId: identifier.optional(),
396
440
  selection: z.string().max(200_000).optional(),
397
441
  chapterIds: z.array(identifier).max(20).optional(),
398
442
  characterIds: optionalStrings,
443
+ mentionCharacterIds: optionalStrings,
399
444
  settingIds: optionalStrings,
400
- includeBookSummary: z.boolean().optional()
445
+ raceIds: optionalStrings,
446
+ organizationIds: optionalStrings,
447
+ includeBookSummary: z.boolean().optional(),
448
+ includeSettingInfo: z.boolean().optional()
401
449
  });
402
450
  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"]);
403
451
  const relationshipSourceRefSchema = z.object({
@@ -619,6 +667,18 @@ function redactAiCallContext(record, permissions) {
619
667
  delete redactedScope.characterIds;
620
668
  restricted = true;
621
669
  }
670
+ if (permissions.characters === "none" && "mentionCharacterIds" in redactedScope) {
671
+ delete redactedScope.mentionCharacterIds;
672
+ restricted = true;
673
+ }
674
+ if (permissions.races === "none" && "raceIds" in redactedScope) {
675
+ delete redactedScope.raceIds;
676
+ restricted = true;
677
+ }
678
+ if (permissions.organizations === "none" && "organizationIds" in redactedScope) {
679
+ delete redactedScope.organizationIds;
680
+ restricted = true;
681
+ }
622
682
  if (permissions.settings === "none" && "settingIds" in redactedScope) {
623
683
  delete redactedScope.settingIds;
624
684
  restricted = true;
@@ -626,6 +686,90 @@ function redactAiCallContext(record, permissions) {
626
686
  result.contextScope = restricted ? { ...redactedScope, restricted: true } : redactedScope;
627
687
  return result;
628
688
  }
689
+ const proseRestrictedPlaceholder = "(正文读取权限受限)";
690
+ function redactContinuationGuard(record, permissions) {
691
+ if (permissions.prose !== "none")
692
+ return record;
693
+ return {
694
+ ...record,
695
+ issues: [],
696
+ contextRefs: {},
697
+ failure: null,
698
+ restricted: true
699
+ };
700
+ }
701
+ /** 无正文读取权限时移除建议中的原文、指令和检查证据,避免通过 AI 接口绕过 prose=none。 */
702
+ function redactSuggestion(record, permissions) {
703
+ if (permissions.prose !== "none")
704
+ return record;
705
+ const guard = recordValue(record.guard);
706
+ return {
707
+ ...record,
708
+ instruction: proseRestrictedPlaceholder,
709
+ sourceText: "",
710
+ ...(guard ? { guard: redactContinuationGuard(guard, permissions) } : {}),
711
+ restricted: true
712
+ };
713
+ }
714
+ function redactAiConversationMessage(item, permissions) {
715
+ if (permissions.prose !== "none")
716
+ return item;
717
+ const message = recordValue(item);
718
+ if (!message)
719
+ return item;
720
+ return {
721
+ ...message,
722
+ content: proseRestrictedPlaceholder,
723
+ citations: [],
724
+ metadata: { restricted: true },
725
+ restricted: true
726
+ };
727
+ }
728
+ /** 无正文读取权限时隐藏对话预览与消息正文,避免历史对话泄露章节原文。 */
729
+ function redactAiConversation(record, permissions) {
730
+ const readableRecord = permissions.characters === "none" ? { ...record, roleplayCharacter: null } : record;
731
+ const scopedRecord = redactAiCallContext(readableRecord, permissions);
732
+ if (permissions.prose !== "none")
733
+ return scopedRecord;
734
+ const result = {
735
+ ...scopedRecord,
736
+ title: proseRestrictedPlaceholder
737
+ };
738
+ if (typeof result.preview === "string" && result.preview.length > 0) {
739
+ result.preview = proseRestrictedPlaceholder;
740
+ }
741
+ if (Array.isArray(result.messages)) {
742
+ result.messages = result.messages.map((item) => redactAiConversationMessage(item, permissions));
743
+ }
744
+ const messagesPage = recordValue(result.messagesPage);
745
+ if (messagesPage && Array.isArray(messagesPage.items)) {
746
+ result.messagesPage = {
747
+ ...messagesPage,
748
+ items: messagesPage.items.map((item) => redactAiConversationMessage(item, permissions))
749
+ };
750
+ }
751
+ return { ...result, restricted: true };
752
+ }
753
+ /** SSE 错误事件只暴露 AppError 的公开信息;AI_CALL_FAILED 的 failure 已在 AI 层完成密钥脱敏。 */
754
+ export function publicAiStreamError(error) {
755
+ if (error instanceof AppError) {
756
+ const details = error.details && typeof error.details === "object" && !Array.isArray(error.details)
757
+ ? error.details
758
+ : null;
759
+ return {
760
+ code: error.code,
761
+ message: error.message,
762
+ status: error.status,
763
+ ...((error.status < 500 || error.code === "AI_CALL_FAILED") && typeof details?.failure === "string" ? { failure: details.failure } : {}),
764
+ ...(typeof details?.callId === "string" ? { callId: details.callId } : {}),
765
+ ...(typeof details?.providerName === "string" ? { providerName: details.providerName } : {}),
766
+ ...(typeof details?.providerId === "string" ? { providerId: details.providerId } : {}),
767
+ ...(typeof details?.modelId === "string" ? { modelId: details.modelId } : {}),
768
+ ...(typeof details?.modelRecordId === "string" ? { modelRecordId: details.modelRecordId } : {})
769
+ };
770
+ }
771
+ return { code: "AI_STREAM_FAILED", message: "AI 流式调用失败" };
772
+ }
629
773
  function redactMergeRecords(value, mapper) {
630
774
  const record = recordValue(value);
631
775
  if (!record)
@@ -652,6 +796,7 @@ export function createRuntime(options) {
652
796
  sameOriginEnforced: options.security?.enforceSameOrigin ?? true
653
797
  });
654
798
  const database = new Database(options.databasePath);
799
+ const bootId = randomUUID();
655
800
  const temporaryAttachmentRoot = options.databasePath === ":memory:" && !options.attachmentDirectory
656
801
  ? mkdtempSync(join(tmpdir(), "scriverse-attachments-"))
657
802
  : null;
@@ -722,6 +867,7 @@ export function createRuntime(options) {
722
867
  }, false, actor?.allowAdminAccess ?? false);
723
868
  });
724
869
  const app = express();
870
+ enforceCaseInsensitiveRouting(app);
725
871
  const upload = multer({
726
872
  storage: multer.memoryStorage(),
727
873
  limits: { fileSize: 30 * 1024 * 1024, files: 1, fields: 10, fieldSize: 64 * 1024, parts: 11, headerPairs: 100 }
@@ -742,22 +888,28 @@ export function createRuntime(options) {
742
888
  limits: { fileSize: 30 * 1024 * 1024, files: 1, fields: 4, fieldSize: 16 * 1024, parts: 5, headerPairs: 100 }
743
889
  });
744
890
  app.disable("x-powered-by");
745
- if (options.security?.trustProxy !== undefined)
746
- app.set("trust proxy", options.security.trustProxy);
891
+ const trustProxy = resolveTrustProxySetting(options.security?.trustProxy);
892
+ if (options.security?.trustProxy === true) {
893
+ logger.warn("security.trust_proxy.coerced", { from: true, to: 1 });
894
+ }
895
+ if (trustProxy !== undefined)
896
+ app.set("trust proxy", trustProxy);
747
897
  app.use(createRequestLoggingMiddleware());
748
898
  app.use(createSecurityHeadersMiddleware());
749
899
  app.get("/api/health", (_request, response) => {
750
900
  data(response, {
751
901
  status: "ok",
902
+ bootId,
752
903
  version: APP_VERSION,
753
904
  protocol: "openai-chat-completions",
754
- protocols: ["openai-chat-completions", "anthropic-messages"],
905
+ protocols: [...AI_PROVIDER_PROTOCOLS],
755
906
  development: options.developmentServer === true
756
907
  });
757
908
  });
758
909
  if (options.security?.auth)
759
910
  app.use(createBasicAuthMiddleware(options.security.auth));
760
911
  app.use(createAuthenticationRateLimitMiddleware());
912
+ app.use(createCaptchaRateLimitMiddleware());
761
913
  app.use(createApiRateLimitMiddleware(options.security?.apiRateLimit, options.security?.apiRateWindowMs));
762
914
  if (options.security?.enforceSameOrigin ?? true)
763
915
  app.use(createSameOriginMiddleware());
@@ -769,12 +921,12 @@ export function createRuntime(options) {
769
921
  const setupTokenRequired = setupRequired && Boolean(options.security?.setupToken);
770
922
  const developmentUser = getDevelopmentUser();
771
923
  if (!session && developmentUser) {
772
- data(response, { authenticated: true, user: developmentUser, csrfToken: null, setupRequired: false, setupTokenRequired: false, registrationOpen });
924
+ data(response, { authenticated: true, user: developmentUser, csrfToken: null, bootId, setupRequired: false, setupTokenRequired: false, registrationOpen });
773
925
  return;
774
926
  }
775
927
  data(response, session
776
- ? { authenticated: true, user: session.user, csrfToken: session.csrfToken, setupRequired: false, setupTokenRequired: false, registrationOpen }
777
- : { authenticated: false, user: null, csrfToken: null, setupRequired, setupTokenRequired, registrationOpen });
928
+ ? { authenticated: true, user: session.user, csrfToken: session.csrfToken, bootId, setupRequired: false, setupTokenRequired: false, registrationOpen }
929
+ : { authenticated: false, user: null, csrfToken: null, bootId, setupRequired, setupTokenRequired, registrationOpen });
778
930
  });
779
931
  app.get("/api/auth/captcha", (_request, response) => {
780
932
  data(response, captcha.create());
@@ -808,6 +960,7 @@ export function createRuntime(options) {
808
960
  resolveBypassUser: getDevelopmentUser
809
961
  }));
810
962
  app.use(createUploadRateLimitMiddleware());
963
+ app.use(createExpensiveApiRateLimitMiddleware());
811
964
  app.use(createCliApiScopeMiddleware(options.disableUserAuth));
812
965
  app.use(createWorkAuthorizationMiddleware(auth, options.disableUserAuth));
813
966
  app.get("/api/cli/session", (request, response) => {
@@ -916,6 +1069,8 @@ export function createRuntime(options) {
916
1069
  app.patch("/api/users/:userId", (request, response) => {
917
1070
  if (!request.authUser)
918
1071
  throw new AppError(401, "AUTH_REQUIRED", "请先登录");
1072
+ if (request.authUser.role !== "admin")
1073
+ throw new AppError(403, "ADMIN_REQUIRED", "该操作仅限系统管理员");
919
1074
  const updated = auth.updateUser(request.authUser, request.params.userId, parse(userUpdateSchema, request.body));
920
1075
  store.audit(null, "user.updated", "user", updated.userId, { role: updated.role, status: updated.status });
921
1076
  data(response, updated);
@@ -1780,19 +1935,47 @@ export function createRuntime(options) {
1780
1935
  page: request.query.page ?? "1",
1781
1936
  limit: request.query.limit ?? "20"
1782
1937
  }) ?? { page: 1, limit: 20, offset: 0 };
1783
- data(response, store.listAiConversationsPage(request.params.workId, pagination));
1938
+ const permissions = requestPermissions(request, request.params.workId);
1939
+ data(response, mapRecords(store.listAiConversationsPage(request.params.workId, pagination), (conversation) => (redactAiConversation(conversation, permissions))));
1784
1940
  });
1785
1941
  app.post("/api/works/:workId/ai-conversations", (request, response) => {
1786
- const input = parse(z.object({ title: z.string().max(200).optional() }), request.body ?? {});
1787
- data(response, store.createAiConversation(request.params.workId, input.title), 201);
1942
+ const input = parse(z.object({
1943
+ title: z.string().max(200).optional(),
1944
+ taskType: aiConversationTaskTypeSchema.optional()
1945
+ }).strict(), request.body ?? {});
1946
+ data(response, store.createAiConversation(request.params.workId, input.title, input.taskType), 201);
1788
1947
  });
1789
1948
  app.get("/api/ai-conversations/:conversationId", (request, response) => {
1790
1949
  const pagination = parsePagination(request.query);
1791
- data(response, pagination ? store.getAiConversationPage(request.params.conversationId, pagination) : store.getAiConversation(request.params.conversationId));
1950
+ const conversation = pagination
1951
+ ? store.getAiConversationPage(request.params.conversationId, pagination)
1952
+ : store.getAiConversation(request.params.conversationId);
1953
+ const permissions = requestPermissions(request, String(conversation.workId));
1954
+ data(response, redactAiConversation(conversation, permissions));
1792
1955
  });
1793
1956
  app.post("/api/ai-conversations/:conversationId/fork", (request, response) => {
1794
1957
  const input = parse(z.object({ messageId: identifier, title: z.string().max(200).optional() }), request.body);
1795
- data(response, store.forkAiConversation(request.params.conversationId, input.messageId, input.title), 201);
1958
+ const forked = store.forkAiConversation(request.params.conversationId, input.messageId, input.title);
1959
+ const permissions = requestPermissions(request, String(forked.workId));
1960
+ data(response, redactAiConversation(forked, permissions), 201);
1961
+ });
1962
+ app.patch("/api/ai-conversations/:conversationId/task-type", (request, response) => {
1963
+ const input = parse(z.object({ taskType: aiConversationTaskTypeSchema }).strict(), request.body);
1964
+ const updated = store.setAiConversationTaskType(request.params.conversationId, input.taskType);
1965
+ const permissions = requestPermissions(request, String(updated.workId));
1966
+ data(response, redactAiConversation(updated, permissions));
1967
+ });
1968
+ app.patch("/api/ai-conversations/:conversationId/context-scope", (request, response) => {
1969
+ const input = parse(z.object({ scope: contextSchema }).strict(), request.body);
1970
+ const updated = store.setAiConversationContextScope(request.params.conversationId, input.scope);
1971
+ const permissions = requestPermissions(request, String(updated.workId));
1972
+ data(response, redactAiConversation(updated, permissions));
1973
+ });
1974
+ app.patch("/api/ai-conversations/:conversationId/roleplay", (request, response) => {
1975
+ const input = parse(z.object({ characterId: identifier.nullable() }).strict(), request.body);
1976
+ const updated = store.setAiConversationRoleplayCharacter(request.params.conversationId, input.characterId);
1977
+ const permissions = requestPermissions(request, String(updated.workId));
1978
+ data(response, redactAiConversation(updated, permissions));
1796
1979
  });
1797
1980
  app.post("/api/ai-conversations/:conversationId/messages", (request, response) => {
1798
1981
  const input = parse(z.object({
@@ -1809,7 +1992,10 @@ export function createRuntime(options) {
1809
1992
  processSteps: z.array(aiProcessStepSchema).max(50).optional()
1810
1993
  }).optional()
1811
1994
  }), request.body);
1812
- data(response, store.addAiConversationMessage(request.params.conversationId, input), 201);
1995
+ const message = store.addAiConversationMessage(request.params.conversationId, input);
1996
+ const conversation = store.getAiConversationSummary(request.params.conversationId);
1997
+ const permissions = requestPermissions(request, String(conversation.workId));
1998
+ data(response, redactAiConversationMessage(message, permissions), 201);
1813
1999
  });
1814
2000
  app.post("/api/ai-conversations/:conversationId/context/prepare", async (request, response) => {
1815
2001
  const input = parse(z.object({
@@ -1857,7 +2043,7 @@ export function createRuntime(options) {
1857
2043
  data(response, ai.createProvider(parse(providerSchema, request.body)), 201);
1858
2044
  });
1859
2045
  app.get("/api/providers/:providerId", (request, response) => data(response, ai.getProvider(request.params.providerId)));
1860
- app.patch("/api/providers/:providerId", (request, response) => data(response, ai.updateProvider(request.params.providerId, parse(providerSchema.partial(), request.body))));
2046
+ app.patch("/api/providers/:providerId", (request, response) => data(response, ai.updateProvider(request.params.providerId, parse(providerUpdateSchema, request.body))));
1861
2047
  app.delete("/api/providers/:providerId", (request, response) => {
1862
2048
  ai.deleteProvider(request.params.providerId);
1863
2049
  noContent(response);
@@ -1891,7 +2077,10 @@ export function createRuntime(options) {
1891
2077
  app.get("/api/works/:workId/suggestions", (request, response) => {
1892
2078
  const status = typeof request.query.status === "string" ? request.query.status : undefined;
1893
2079
  const pagination = parsePagination(request.query);
1894
- data(response, pagination ? ai.listSuggestionsPage(request.params.workId, pagination, status) : ai.listSuggestions(request.params.workId, status));
2080
+ const permissions = requestPermissions(request, request.params.workId);
2081
+ data(response, pagination
2082
+ ? mapRecords(ai.listSuggestionsPage(request.params.workId, pagination, status), (suggestion) => redactSuggestion(suggestion, permissions))
2083
+ : ai.listSuggestions(request.params.workId, status).map((suggestion) => redactSuggestion(suggestion, permissions)));
1895
2084
  });
1896
2085
  app.post("/api/works/:workId/suggestions", async (request, response) => {
1897
2086
  const input = parse(z.object({
@@ -1907,14 +2096,14 @@ export function createRuntime(options) {
1907
2096
  if (store.getChapter(citation.chapterId).workId !== request.params.workId)
1908
2097
  throw new AppError(400, "CITATION_WORK_MISMATCH", "引用章节不属于当前作品");
1909
2098
  }
1910
- data(response, await ai.createSuggestion({
2099
+ data(response, redactSuggestion(await ai.createSuggestion({
1911
2100
  workId: request.params.workId,
1912
2101
  taskType: input.taskType,
1913
2102
  instruction: instructionWithCitations(input.instruction, citations),
1914
2103
  scope: input.scope,
1915
2104
  ...(input.modelId ? { modelId: input.modelId } : {}),
1916
2105
  ...(input.parameters ? { parameters: input.parameters } : {})
1917
- }), 201);
2106
+ }), requestPermissions(request, request.params.workId)), 201);
1918
2107
  });
1919
2108
  app.post("/api/works/:workId/chat/stream", async (request, response) => {
1920
2109
  const input = parse(z.object({
@@ -1955,6 +2144,7 @@ export function createRuntime(options) {
1955
2144
  throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
1956
2145
  }
1957
2146
  const conversationId = String(conversation.id);
2147
+ const permissions = requestPermissions(request, request.params.workId);
1958
2148
  const prepared = await ai.prepareConversationContext({
1959
2149
  conversationId,
1960
2150
  workId: request.params.workId,
@@ -1965,10 +2155,10 @@ export function createRuntime(options) {
1965
2155
  });
1966
2156
  sendEvent("context", {
1967
2157
  ...prepared,
1968
- conversation: {
2158
+ conversation: redactAiConversation({
1969
2159
  ...store.getAiConversationSummary(conversationId),
1970
2160
  contextWarningPending: prepared.action === "warn"
1971
- }
2161
+ }, permissions)
1972
2162
  });
1973
2163
  if (prepared.action === "warn")
1974
2164
  return;
@@ -1981,7 +2171,7 @@ export function createRuntime(options) {
1981
2171
  });
1982
2172
  const currentMessageId = input.currentMessageId ?? String(userMessage?.id ?? "");
1983
2173
  if (userMessage)
1984
- sendEvent("user_message", { message: userMessage });
2174
+ sendEvent("user_message", { message: redactAiConversationMessage(userMessage, permissions) });
1985
2175
  const suggestion = await ai.createStreamingChat({
1986
2176
  workId: request.params.workId,
1987
2177
  instruction: instructionWithCitations(input.instruction, citations),
@@ -2019,20 +2209,11 @@ export function createRuntime(options) {
2019
2209
  }
2020
2210
  catch (error) {
2021
2211
  if (!controller.signal.aborted) {
2022
- const details = error instanceof AppError && error.details && typeof error.details === "object" && !Array.isArray(error.details)
2023
- ? error.details
2024
- : null;
2025
- sendEvent("error", {
2026
- code: error instanceof AppError ? error.code : "AI_STREAM_FAILED",
2027
- message: error instanceof Error ? error.message : "AI 流式调用失败",
2028
- ...(error instanceof AppError ? { status: error.status } : {}),
2029
- ...(typeof details?.failure === "string" ? { failure: details.failure } : {}),
2030
- ...(typeof details?.callId === "string" ? { callId: details.callId } : {}),
2031
- ...(typeof details?.providerName === "string" ? { providerName: details.providerName } : {}),
2032
- ...(typeof details?.providerId === "string" ? { providerId: details.providerId } : {}),
2033
- ...(typeof details?.modelId === "string" ? { modelId: details.modelId } : {}),
2034
- ...(typeof details?.modelRecordId === "string" ? { modelRecordId: details.modelRecordId } : {})
2212
+ logger.error("ai.stream.failed", {
2213
+ workId: request.params.workId,
2214
+ error: sanitizeError(error)
2035
2215
  });
2216
+ sendEvent("error", publicAiStreamError(error));
2036
2217
  }
2037
2218
  }
2038
2219
  finally {
@@ -2040,22 +2221,34 @@ export function createRuntime(options) {
2040
2221
  response.end();
2041
2222
  }
2042
2223
  });
2043
- app.get("/api/suggestions/:suggestionId", (request, response) => data(response, ai.getSuggestion(request.params.suggestionId)));
2224
+ app.get("/api/suggestions/:suggestionId", (request, response) => {
2225
+ const suggestion = ai.getSuggestion(request.params.suggestionId);
2226
+ const permissions = requestPermissions(request, String(suggestion.workId));
2227
+ data(response, redactSuggestion(suggestion, permissions));
2228
+ });
2044
2229
  app.get("/api/suggestions/:suggestionId/guards", (request, response) => {
2045
2230
  const pagination = parsePagination(request.query);
2046
- data(response, pagination
2231
+ const suggestion = ai.getSuggestion(request.params.suggestionId);
2232
+ const permissions = requestPermissions(request, String(suggestion.workId));
2233
+ data(response, mapRecords(pagination
2047
2234
  ? store.listContinuationGuardsPage(request.params.suggestionId, pagination)
2048
- : store.listContinuationGuards(request.params.suggestionId));
2235
+ : store.listContinuationGuards(request.params.suggestionId), (guard) => redactContinuationGuard(guard, permissions)));
2049
2236
  });
2050
2237
  app.post("/api/suggestions/:suggestionId/guard", async (request, response) => {
2051
2238
  const input = parse(z.object({ content: z.string().max(2_000_000).optional() }), request.body ?? {});
2052
- data(response, await ai.runSuggestionGuard(request.params.suggestionId, input.content), 201);
2239
+ const suggestion = ai.getSuggestion(request.params.suggestionId);
2240
+ const permissions = requestPermissions(request, String(suggestion.workId));
2241
+ data(response, redactContinuationGuard(await ai.runSuggestionGuard(request.params.suggestionId, input.content), permissions), 201);
2053
2242
  });
2054
2243
  app.post("/api/suggestions/:suggestionId/accept", (request, response) => {
2055
2244
  const input = parse(z.object({ content: z.string().max(2_000_000).optional() }), request.body ?? {});
2056
2245
  data(response, ai.acceptSuggestion(request.params.suggestionId, input.content));
2057
2246
  });
2058
- app.post("/api/suggestions/:suggestionId/reject", (request, response) => data(response, ai.rejectSuggestion(request.params.suggestionId)));
2247
+ app.post("/api/suggestions/:suggestionId/reject", (request, response) => {
2248
+ const suggestion = ai.rejectSuggestion(request.params.suggestionId);
2249
+ const permissions = requestPermissions(request, String(suggestion.workId));
2250
+ data(response, redactSuggestion(suggestion, permissions));
2251
+ });
2059
2252
  app.get("/api/works/:workId/ai-calls", (request, response) => {
2060
2253
  const pagination = parsePagination(request.query);
2061
2254
  const permissions = requestPermissions(request, request.params.workId);
@@ -2072,7 +2265,7 @@ export function createRuntime(options) {
2072
2265
  data(response, await ai.searchWork(request.params.workId, query.q, { type: query.type, limit: query.limit }));
2073
2266
  });
2074
2267
  app.get("/api/works/:workId/export", async (request, response) => {
2075
- const format = parse(z.enum(["json", "txt", "markdown"]), request.query.format ?? "json");
2268
+ const format = parse(z.enum(["json", "txt", "markdown", "docx"]), request.query.format ?? "json");
2076
2269
  if (format === "json") {
2077
2270
  response.setHeader("Content-Disposition", `attachment; filename=novel-${request.params.workId}.json`);
2078
2271
  data(response, store.exportWork(request.params.workId));
@@ -2092,6 +2285,12 @@ export function createRuntime(options) {
2092
2285
  }), response);
2093
2286
  return;
2094
2287
  }
2288
+ if (format === "docx") {
2289
+ response.type("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
2290
+ response.setHeader("Content-Disposition", `attachment; filename=novel-${request.params.workId}.docx`);
2291
+ response.send(await store.exportDocx(request.params.workId));
2292
+ return;
2293
+ }
2095
2294
  response.type("text/plain");
2096
2295
  response.setHeader("Content-Disposition", `attachment; filename=novel-${request.params.workId}.txt`);
2097
2296
  response.send(store.exportText(request.params.workId, format));
@@ -2151,7 +2350,7 @@ export function createRuntime(options) {
2151
2350
  setHeaders: setStaticCacheControl
2152
2351
  }));
2153
2352
  app.get("/{*path}", (request, response, next) => {
2154
- if (request.path.startsWith("/api/"))
2353
+ if (normalizeApiPath(request.path).startsWith("/api/"))
2155
2354
  return next();
2156
2355
  sendIndexHtml(request, response);
2157
2356
  });