@musnows/scriverse 0.8.4 → 0.8.6

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 (50) hide show
  1. package/README.md +1 -0
  2. package/dist/ai-model-pricing.js +295 -0
  3. package/dist/ai-model-pricing.js.map +1 -0
  4. package/dist/ai-protocol.js +335 -15
  5. package/dist/ai-protocol.js.map +1 -1
  6. package/dist/ai-retry.js +1 -1
  7. package/dist/ai-retry.js.map +1 -1
  8. package/dist/ai-stream-timeout.js +9 -6
  9. package/dist/ai-stream-timeout.js.map +1 -1
  10. package/dist/ai.js +1103 -142
  11. package/dist/ai.js.map +1 -1
  12. package/dist/app.js +230 -28
  13. package/dist/app.js.map +1 -1
  14. package/dist/attachment-download.js +26 -0
  15. package/dist/attachment-download.js.map +1 -0
  16. package/dist/attachment-storage.js +8 -6
  17. package/dist/attachment-storage.js.map +1 -1
  18. package/dist/cli-contract.js +6 -4
  19. package/dist/cli-contract.js.map +1 -1
  20. package/dist/database.js +352 -3
  21. package/dist/database.js.map +1 -1
  22. package/dist/public/ai-image-attachments.d.ts +21 -0
  23. package/dist/public/ai-image-attachments.js +42 -0
  24. package/dist/public/ai-usage.d.ts +1 -0
  25. package/dist/public/ai-usage.js +11 -0
  26. package/dist/public/app.js +1125 -150
  27. package/dist/public/display-labels.d.ts +2 -1
  28. package/dist/public/display-labels.js +6 -6
  29. package/dist/public/index.html +42 -7
  30. package/dist/public/model-config.d.ts +3 -1
  31. package/dist/public/model-config.js +9 -2
  32. package/dist/public/styles.css +120 -6
  33. package/dist/s3-backup.js +21 -0
  34. package/dist/s3-backup.js.map +1 -1
  35. package/dist/security.js +2 -1
  36. package/dist/security.js.map +1 -1
  37. package/dist/server-runtime.js +10 -2
  38. package/dist/server-runtime.js.map +1 -1
  39. package/dist/store.js +562 -47
  40. package/dist/store.js.map +1 -1
  41. package/dist/upload-limits.js +10 -4
  42. package/dist/upload-limits.js.map +1 -1
  43. package/dist/user-auth.js +3 -0
  44. package/dist/user-auth.js.map +1 -1
  45. package/dist/utils.js +1 -1
  46. package/dist/utils.js.map +1 -1
  47. package/dist/version.js +1 -1
  48. package/dist/writing-progress-time.js +15 -0
  49. package/dist/writing-progress-time.js.map +1 -1
  50. package/package.json +1 -1
package/dist/app.js CHANGED
@@ -9,12 +9,14 @@ 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, MAX_TOKENS_PARAMETERS } from "./ai-protocol.js";
12
+ import { AI_PROVIDER_PROTOCOL_OPTIONS, AI_PROVIDER_PROTOCOLS, AI_THINKING_TYPES, MAX_TOKENS_PARAMETERS } from "./ai-protocol.js";
13
13
  import { aiConversationExportContentDisposition, exportAiConversationMarkdown } from "./ai-conversation-export.js";
14
14
  import { DEFAULT_AI_CHAT_TAB_LIMIT } from "./ai-chat-tab-limit.js";
15
- import { DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS } from "./ai-stream-timeout.js";
15
+ import { MAX_AI_STREAM_IDLE_TIMEOUT_SECONDS, MIN_AI_STREAM_IDLE_TIMEOUT_SECONDS, normalizeAiStreamIdleTimeoutSeconds } from "./ai-stream-timeout.js";
16
16
  import { AttachmentStorage } from "./attachment-storage.js";
17
+ import { attachmentDownloadFileName, inlineContentDisposition } from "./attachment-download.js";
17
18
  import { AiManager } from "./ai.js";
19
+ import { LiteLlmPriceCache } from "./ai-model-pricing.js";
18
20
  import { resolveMaxAgentToolCallLimit } from "./ai-tool-results.js";
19
21
  import { CHARACTER_EXTRACTION_MAX_ALIASES, CHARACTER_EXTRACTION_MAX_CANDIDATES, CHARACTER_EXTRACTION_MAX_IDENTITY_LENGTH, CHARACTER_EXTRACTION_MAX_NAME_LENGTH, CHARACTER_EXTRACTION_MAX_SPECIES_LENGTH } from "./character-extraction.js";
20
22
  import { CredentialVault } from "./credential-vault.js";
@@ -39,7 +41,7 @@ import { currentRequestActor, runWithRequestActor } from "./request-context.js";
39
41
  import { S3BackupManager } from "./s3-backup.js";
40
42
  import { APP_VERSION } from "./version.js";
41
43
  import { ReleaseUpdateChecker } from "./release-update.js";
42
- import { DEFAULT_IMAGE_UPLOAD_LIMITS, formatUploadLimit } from "./upload-limits.js";
44
+ import { CHARACTER_AVATAR_IMAGE_MAX_BYTES, DEFAULT_IMAGE_UPLOAD_LIMITS, formatUploadLimit } from "./upload-limits.js";
43
45
  import { canReadWorkModule, canWriteWorkModule, chapterAnnotationPermissionModule, fullWorkModulePermissions, proseReplacementPermissionModules } from "./work-permissions.js";
44
46
  import { CollaborationPresence, editorPageKey, entityEditorPageKey, modulePageKey, presencePageKinds } from "./collaboration-presence.js";
45
47
  import { PresenceStore } from "./presence-store.js";
@@ -60,6 +62,15 @@ const versionedEntityTypeSchema = z.enum(versionedEntityTypes);
60
62
  const attachmentPermissionModuleSchema = z.enum(attachmentPermissionModules);
61
63
  const maximumImportedTextLength = 20_000_000;
62
64
  const maximumKnowledgeSectionsLength = 4_000_000;
65
+ const aiChatAttachmentIngestOptions = {
66
+ allowedFormats: new Set(["png", "jpeg"]),
67
+ preserveFormat: true,
68
+ unsupportedMessage: "AI 对话图片附件仅支持 PNG、JPG、JPEG 图片"
69
+ };
70
+ const characterAvatarIngestOptions = {
71
+ allowedFormats: new Set(["png", "jpeg", "webp"]),
72
+ unsupportedMessage: "角色头像仅支持 PNG、JPEG 和 WebP 图片"
73
+ };
63
74
  function stableJson(value) {
64
75
  if (Array.isArray(value))
65
76
  return `[${value.map((item) => stableJson(item)).join(",")}]`;
@@ -77,14 +88,20 @@ function assertImageUploadSize(byteLength, maximumBytes, message) {
77
88
  return;
78
89
  throw new AppError(413, "IMAGE_TOO_LARGE", message);
79
90
  }
80
- function uploadSizeError(pathname, limits) {
91
+ function uploadSizeError(pathname, limits, module = "") {
81
92
  if (pathname === "/api/auth/avatar")
82
93
  return { code: "IMAGE_TOO_LARGE", message: `头像图片不能超过 ${formatUploadLimit(limits.avatarBytes)}` };
94
+ if (/^\/api\/characters\/[^/]+\/avatar$/u.test(pathname)) {
95
+ return { code: "CHARACTER_AVATAR_TOO_LARGE", message: `角色头像不能超过 ${formatUploadLimit(CHARACTER_AVATAR_IMAGE_MAX_BYTES)}` };
96
+ }
83
97
  if (/^\/api\/works\/[^/]+\/cover$/u.test(pathname)) {
84
98
  return { code: "IMAGE_TOO_LARGE", message: `封面图片不能超过 ${formatUploadLimit(limits.coverBytes)}` };
85
99
  }
86
100
  if (/^\/api\/works\/[^/]+\/attachments$/u.test(pathname)) {
87
- return { code: "ATTACHMENT_TOO_LARGE", message: `图片附件不能超过 ${formatUploadLimit(limits.attachmentBytes)}` };
101
+ const maximumBytes = module === "ai-chat"
102
+ ? limits.chatImageBytes
103
+ : limits.attachmentBytes;
104
+ return { code: "ATTACHMENT_TOO_LARGE", message: `图片附件不能超过 ${formatUploadLimit(maximumBytes)}` };
88
105
  }
89
106
  return null;
90
107
  }
@@ -355,11 +372,14 @@ const providerBaseSchema = z.object({
355
372
  baseUrl: z.string().url().refine((value) => value.startsWith("http://") || value.startsWith("https://"), "接口地址必须使用 HTTP 或 HTTPS"),
356
373
  apiKey: z.string().trim().min(1).max(50_000),
357
374
  protocol: z.enum(AI_PROVIDER_PROTOCOLS).optional(),
375
+ thinkingType: z.enum(AI_THINKING_TYPES).optional(),
358
376
  maxTokensParameter: z.enum(MAX_TOKENS_PARAMETERS).optional(),
359
377
  status: z.enum(["enabled", "disabled"]).optional(),
360
378
  note: z.string().max(10_000).optional(),
361
379
  concurrencyLimit: z.number().int().min(1).max(100).optional(),
362
- rpmLimit: z.number().int().min(1).max(10_000).optional()
380
+ rpmLimit: z.number().int().min(1).max(10_000).optional(),
381
+ dailyTokenQuota: z.number().int().min(1, "Token 额度必须设置大于 0").max(2_000_000_000).nullable().optional(),
382
+ monthlyTokenQuota: z.number().int().min(1, "Token 额度必须设置大于 0").max(2_000_000_000).nullable().optional()
363
383
  });
364
384
  function refineProviderApiKey(value, ctx) {
365
385
  if (value.protocol === "google-vertex" && value.baseUrl && !isOfficialGoogleVertexBaseUrl(value.baseUrl)) {
@@ -416,11 +436,19 @@ const modelSchema = z.object({
416
436
  });
417
437
  const aiPromptSchema = z.object({
418
438
  systemPrompt: z.string().max(100_000).optional(),
419
- imageToolModelId: identifier.nullable().optional()
439
+ imageToolModelId: identifier.nullable().optional(),
440
+ streamIdleTimeoutSeconds: z.number().int().min(MIN_AI_STREAM_IDLE_TIMEOUT_SECONDS).max(MAX_AI_STREAM_IDLE_TIMEOUT_SECONDS).optional()
420
441
  });
421
442
  const aiUsageQuerySchema = z.object({
422
443
  timezoneOffset: z.coerce.number().int().min(-840).max(840).default(0)
423
444
  }).strict();
445
+ const adminAiConversationQuerySchema = z.object({
446
+ page: z.string().optional(),
447
+ limit: z.string().optional(),
448
+ q: z.string().trim().max(200).optional(),
449
+ workId: identifier.optional(),
450
+ userId: identifier.optional()
451
+ }).strict();
424
452
  const platformPageSizesSchema = z.object({
425
453
  drafts: z.number().int().min(10).max(100).optional(),
426
454
  settings: z.number().int().min(10).max(100).optional(),
@@ -543,7 +571,8 @@ const aiProcessStepSchema = z.discriminatedUnion("type", [
543
571
  ]);
544
572
  const workAiSettingsSchema = z.object({
545
573
  systemPrompt: z.string().max(100_000).optional(),
546
- dailyTokenQuota: z.number().int().min(10_000).max(2_000_000_000).nullable().optional(),
574
+ dailyTokenQuota: z.number().int().min(1, "Token 额度必须设置大于 0").max(2_000_000_000).nullable().optional(),
575
+ monthlyTokenQuota: z.number().int().min(1, "Token 额度必须设置大于 0").max(2_000_000_000).nullable().optional(),
547
576
  autoRunEnabled: z.boolean().optional(),
548
577
  autoRunConcurrency: z.number().int().min(1).max(8).optional(),
549
578
  autoRunBatchLimit: z.number().int().min(1).max(200).optional(),
@@ -975,6 +1004,7 @@ function redactAiConversation(record, permissions) {
975
1004
  result.agentTools = [
976
1005
  ...(permissions.characters !== "none" ? ["recall_self"] : []),
977
1006
  ...(permissions.characters !== "none" && permissions.relationships !== "none" ? ["recall_relationship"] : []),
1007
+ ...(permissions.prose !== "none" ? ["recall_story"] : []),
978
1008
  "calculate_time"
979
1009
  ];
980
1010
  }
@@ -1002,10 +1032,30 @@ export function publicAiStreamError(error) {
1002
1032
  const details = error.details && typeof error.details === "object" && !Array.isArray(error.details)
1003
1033
  ? error.details
1004
1034
  : null;
1035
+ const publicQuotaDetails = details?.platformLimited === true
1036
+ ? Object.fromEntries([
1037
+ "platformLimited",
1038
+ "limitScope",
1039
+ "limitPeriod",
1040
+ "workId",
1041
+ "providerId",
1042
+ "providerName",
1043
+ "dailyTokenQuota",
1044
+ "monthlyTokenQuota",
1045
+ "usedTokens",
1046
+ "remainingTokens",
1047
+ "estimatedInputTokens",
1048
+ "resetsAt",
1049
+ "timezone",
1050
+ "dayStartedAt",
1051
+ "monthStartedAt"
1052
+ ].filter((key) => details[key] !== undefined).map((key) => [key, details[key]]))
1053
+ : undefined;
1005
1054
  return {
1006
1055
  code: error.code,
1007
1056
  message: error.message,
1008
1057
  status: error.status,
1058
+ ...(publicQuotaDetails ? { details: publicQuotaDetails } : {}),
1009
1059
  ...((error.status < 500 || error.code === "AI_CALL_FAILED") && typeof details?.failure === "string" ? { failure: details.failure } : {}),
1010
1060
  ...(typeof details?.callId === "string" ? { callId: details.callId } : {}),
1011
1061
  ...(typeof details?.providerName === "string" ? { providerName: details.providerName } : {}),
@@ -1047,11 +1097,14 @@ export function createRuntime(options) {
1047
1097
  });
1048
1098
  const database = new Database(options.databasePath);
1049
1099
  const bootId = randomUUID();
1050
- const temporaryAttachmentRoot = options.databasePath === ":memory:" && !options.attachmentDirectory
1051
- ? mkdtempSync(join(tmpdir(), "scriverse-attachments-"))
1100
+ const temporaryStorageRoot = options.databasePath === ":memory:" && (!options.attachmentDirectory || !options.characterAvatarDirectory)
1101
+ ? mkdtempSync(join(tmpdir(), "scriverse-storage-"))
1052
1102
  : null;
1053
- const attachmentStorage = new AttachmentStorage(options.attachmentDirectory ?? temporaryAttachmentRoot ?? join(dirname(options.databasePath), "attachments"), uploadLimits.attachmentBytes);
1103
+ const attachmentStorage = new AttachmentStorage(options.attachmentDirectory ?? temporaryStorageRoot ?? join(dirname(options.databasePath), "attachments"), uploadLimits.attachmentBytes);
1054
1104
  mkdirSync(attachmentStorage.temporaryDirectory, { recursive: true, mode: 0o700 });
1105
+ const characterAvatarStorage = new AttachmentStorage(options.characterAvatarDirectory
1106
+ ?? (temporaryStorageRoot ? join(temporaryStorageRoot, "character-avatars") : join(dirname(options.databasePath), "character-avatars")), CHARACTER_AVATAR_IMAGE_MAX_BYTES);
1107
+ mkdirSync(characterAvatarStorage.temporaryDirectory, { recursive: true, mode: 0o700 });
1055
1108
  const auth = new UserAuthService(database);
1056
1109
  const collaborationPresence = new CollaborationPresence(45_000, Date.now, 120_000, 50, { store: new PresenceStore(database) });
1057
1110
  const publishCollaborativeChange = (workId, pageKey, options = {}) => {
@@ -1083,6 +1136,8 @@ export function createRuntime(options) {
1083
1136
  ? auth.listUsers().find((user) => user.status === "active") ?? null
1084
1137
  : null;
1085
1138
  const store = new Store(database);
1139
+ const platformAiSettings = store.getPlatformAiSettings();
1140
+ const platformAiStreamIdleTimeoutMs = normalizeAiStreamIdleTimeoutSeconds(Number(platformAiSettings.streamIdleTimeoutSeconds)) * 1_000;
1086
1141
  let attachmentCleanupChain = Promise.resolve();
1087
1142
  const cleanupAttachments = () => {
1088
1143
  const cleanup = attachmentCleanupChain.then(async () => {
@@ -1109,6 +1164,13 @@ export function createRuntime(options) {
1109
1164
  attachmentCleanupChain = cleanup.catch(() => undefined);
1110
1165
  return cleanup;
1111
1166
  };
1167
+ const cleanupCharacterAvatarFiles = async (storageKeys) => {
1168
+ for (const storageKey of new Set(storageKeys)) {
1169
+ if (store.characterAvatarStorageKeyInUse(storageKey))
1170
+ continue;
1171
+ await characterAvatarStorage.remove(storageKey);
1172
+ }
1173
+ };
1112
1174
  const requestPermissions = (request, workId) => {
1113
1175
  if (!request.authUser)
1114
1176
  return fullWorkModulePermissions();
@@ -1117,19 +1179,28 @@ export function createRuntime(options) {
1117
1179
  return fullWorkModulePermissions();
1118
1180
  return auth.workModulePermissions(request.authUser, resolvedWorkId, request.authMethod !== "api-key") ?? fullWorkModulePermissions();
1119
1181
  };
1182
+ const assertRequestAiConversationOwner = (request, conversationId) => {
1183
+ if (request.authUser)
1184
+ store.assertAiConversationOwner(conversationId, request.authUser.userId);
1185
+ };
1120
1186
  const resolveConversationModelId = (workId, conversationId, requestedModelId) => {
1121
1187
  if (!conversationId)
1122
1188
  return requestedModelId;
1123
1189
  const lockedModelId = store.getAiConversationLockedModelId(conversationId, workId);
1190
+ const hasImageAttachments = store.getAiConversationHasImageAttachments(conversationId, workId);
1124
1191
  if (lockedModelId && requestedModelId && lockedModelId !== requestedModelId) {
1125
1192
  throw new AppError(409, "AI_CONVERSATION_MODEL_LOCKED", "当前对话已经锁定模型,请新建对话后再切换模型");
1126
1193
  }
1194
+ if (hasImageAttachments && !lockedModelId && requestedModelId) {
1195
+ throw new AppError(409, "AI_CONVERSATION_IMAGE_MODEL_LOCKED", "当前对话链路包含图片但没有可继承的模型,请新建对话后再选择模型");
1196
+ }
1127
1197
  return lockedModelId ?? requestedModelId;
1128
1198
  };
1129
1199
  const captcha = new ImageCaptchaService({ revealAnswer: options.revealCaptchaAnswer === true });
1130
1200
  const credentialVault = new CredentialVault(options.masterSecret);
1131
1201
  const backups = new S3BackupManager(database, credentialVault, store, attachmentStorage, {
1132
1202
  ...options.backupOptions,
1203
+ characterAvatarStorage,
1133
1204
  masterKey: options.masterSecret,
1134
1205
  validateEndpoint: options.backupOptions?.validateEndpoint
1135
1206
  ?? (options.security ? (url) => assertSafeS3Endpoint(url, options.security?.allowPrivateAiEndpoints) : undefined)
@@ -1139,6 +1210,11 @@ export function createRuntime(options) {
1139
1210
  timeoutMs: options.releaseCheckTimeoutMs,
1140
1211
  retries: options.releaseCheckRetries
1141
1212
  });
1213
+ const liteLlmPriceCache = options.liteLlmPriceCache ?? new LiteLlmPriceCache({
1214
+ ...(options.liteLlmPriceCachePath ? { cachePath: options.liteLlmPriceCachePath } : {})
1215
+ });
1216
+ if (!options.liteLlmPriceCache && options.liteLlmPriceCachePath)
1217
+ liteLlmPriceCache.start();
1142
1218
  const ai = new AiManager(store, credentialVault, options.fetchImpl ?? fetch, options.developmentServer === true
1143
1219
  ? undefined
1144
1220
  : options.security ? (url) => assertSafeAiEndpoint(url, options.security?.allowPrivateAiEndpoints) : undefined, (task, actor) => {
@@ -1155,9 +1231,11 @@ export function createRuntime(options) {
1155
1231
  write: ["ai-analysis"]
1156
1232
  }, false, actor?.allowAdminAccess ?? false);
1157
1233
  }, attachmentStorage, {
1158
- interactiveStreamIdleTimeoutMs: options.aiStreamIdleTimeoutMs ?? DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS,
1234
+ interactiveStreamIdleTimeoutMs: options.aiStreamIdleTimeoutMs ?? platformAiStreamIdleTimeoutMs,
1159
1235
  retryPolicy: options.aiRetryPolicy,
1160
- retrySleep: options.aiRetrySleep
1236
+ retrySleep: options.aiRetrySleep,
1237
+ aiChatImageMaxBytes: uploadLimits.chatImageBytes,
1238
+ liteLlmPriceCache
1161
1239
  });
1162
1240
  const app = express();
1163
1241
  enforceCaseInsensitiveRouting(app);
@@ -1173,12 +1251,19 @@ export function createRuntime(options) {
1173
1251
  storage: multer.memoryStorage(),
1174
1252
  limits: { fileSize: uploadLimits.avatarBytes + 1, files: 1, fields: 1, fieldSize: 1024, parts: 2, headerPairs: 50 }
1175
1253
  });
1254
+ const characterAvatarUpload = multer({
1255
+ storage: multer.diskStorage({
1256
+ destination: characterAvatarStorage.temporaryDirectory,
1257
+ filename: (_request, _file, callback) => callback(null, randomUUID())
1258
+ }),
1259
+ limits: { fileSize: CHARACTER_AVATAR_IMAGE_MAX_BYTES + 1, files: 1, fields: 0, fieldSize: 1024, parts: 2, headerPairs: 50 }
1260
+ });
1176
1261
  const attachmentUpload = multer({
1177
1262
  storage: multer.diskStorage({
1178
1263
  destination: attachmentStorage.temporaryDirectory,
1179
1264
  filename: (_request, _file, callback) => callback(null, randomUUID())
1180
1265
  }),
1181
- limits: { fileSize: uploadLimits.attachmentBytes + 1, files: 1, fields: 4, fieldSize: 16 * 1024, parts: 5, headerPairs: 100 }
1266
+ limits: { fileSize: Math.max(uploadLimits.attachmentBytes, uploadLimits.chatImageBytes) + 1, files: 1, fields: 4, fieldSize: 16 * 1024, parts: 5, headerPairs: 100 }
1182
1267
  });
1183
1268
  app.disable("x-powered-by");
1184
1269
  const trustProxy = resolveTrustProxySetting(options.security?.trustProxy);
@@ -1392,7 +1477,9 @@ export function createRuntime(options) {
1392
1477
  if (request.authUser)
1393
1478
  auth.assertDeletedWorkAccess(request.authUser, request.params.workId, request.authMethod !== "api-key");
1394
1479
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1480
+ const characterAvatarStorageKeys = store.listCharacterAvatarStorageKeysForWork(request.params.workId);
1395
1481
  store.permanentlyDeleteWork(request.params.workId, input.expectedVersionNo);
1482
+ await cleanupCharacterAvatarFiles(characterAvatarStorageKeys);
1396
1483
  await cleanupAttachments();
1397
1484
  noContent(response);
1398
1485
  });
@@ -1544,11 +1631,11 @@ export function createRuntime(options) {
1544
1631
  data(response, store.replaceWorkText(request.params.workId, parse(globalReplaceSchema, request.body)));
1545
1632
  });
1546
1633
  app.post("/api/works/:workId/volumes", (request, response) => {
1547
- const input = parse(z.object({ title: nonEmpty.max(200), kind: z.enum(["main", "prequel", "extra", "epilogue", "appendix"]).optional(), description: z.string().max(5_000).optional(), keywords: z.array(nonEmpty.max(100)).max(100).optional() }), request.body);
1634
+ const input = parse(z.object({ title: nonEmpty.max(200), kind: z.enum(["main", "prequel", "extra", "epilogue", "appendix"]).optional(), description: z.string().max(5_000).optional(), keywords: z.array(nonEmpty.max(100)).max(100).optional(), storyOrder: z.number().int().min(0).max(1_000_000).optional() }).strict(), request.body);
1548
1635
  data(response, store.createVolume(request.params.workId, input), 201);
1549
1636
  });
1550
1637
  app.patch("/api/volumes/:volumeId", (request, response) => {
1551
- const input = parse(z.object({ title: nonEmpty.max(200).optional(), kind: z.enum(["main", "prequel", "extra", "epilogue", "appendix"]).optional(), description: z.string().max(5_000).optional(), keywords: z.array(nonEmpty.max(100)).max(100).optional(), sortOrder: z.number().int().min(0).optional(), expectedVersionNo: expectedVersionNoSchema, changeNote: changeNoteSchema }).strict(), request.body);
1638
+ const input = parse(z.object({ title: nonEmpty.max(200).optional(), kind: z.enum(["main", "prequel", "extra", "epilogue", "appendix"]).optional(), description: z.string().max(5_000).optional(), keywords: z.array(nonEmpty.max(100)).max(100).optional(), sortOrder: z.number().int().min(0).optional(), storyOrder: z.number().int().min(0).max(1_000_000).optional(), expectedVersionNo: expectedVersionNoSchema, changeNote: changeNoteSchema }).strict(), request.body);
1552
1639
  const { expectedVersionNo, changeNote, ...volumeInput } = input;
1553
1640
  data(response, store.updateVolume(request.params.volumeId, volumeInput, expectedVersionNo, "manual", null, changeNote));
1554
1641
  });
@@ -1835,6 +1922,60 @@ export function createRuntime(options) {
1835
1922
  app.get("/api/characters/:characterId", (request, response) => {
1836
1923
  data(response, redactCharacterLinks(store.getCharacter(request.params.characterId), requestPermissions(request)));
1837
1924
  });
1925
+ app.put("/api/characters/:characterId/avatar", characterAvatarUpload.single("file"), async (request, response) => {
1926
+ if (!request.file)
1927
+ throw new AppError(400, "FILE_REQUIRED", "请选择 PNG、JPEG 或 WebP 角色头像");
1928
+ const characterId = String(request.params.characterId);
1929
+ let stored = null;
1930
+ try {
1931
+ stored = await characterAvatarStorage.ingest(request.file.path, characterAvatarIngestOptions);
1932
+ const result = store.setCharacterAvatar(characterId, {
1933
+ mimeType: stored.storedMimeType,
1934
+ byteLength: stored.storedByteLength,
1935
+ sha256: stored.storedSha256,
1936
+ storageKey: stored.storageKey,
1937
+ width: stored.width,
1938
+ height: stored.height
1939
+ });
1940
+ if (result.previousStorageKey
1941
+ && result.previousStorageKey !== stored.storageKey
1942
+ && !store.characterAvatarStorageKeyInUse(result.previousStorageKey)) {
1943
+ await characterAvatarStorage.remove(result.previousStorageKey);
1944
+ }
1945
+ data(response, redactCharacterLinks(result.character, requestPermissions(request)));
1946
+ }
1947
+ catch (error) {
1948
+ if (error instanceof AppError && error.code === "ATTACHMENT_TOO_LARGE") {
1949
+ throw new AppError(413, "CHARACTER_AVATAR_TOO_LARGE", `角色头像不能超过 ${formatUploadLimit(CHARACTER_AVATAR_IMAGE_MAX_BYTES)}`);
1950
+ }
1951
+ if (stored && !store.characterAvatarStorageKeyInUse(stored.storageKey)) {
1952
+ await characterAvatarStorage.remove(stored.storageKey);
1953
+ }
1954
+ throw error;
1955
+ }
1956
+ finally {
1957
+ await rm(request.file.path, { force: true });
1958
+ }
1959
+ });
1960
+ app.get("/api/characters/:characterId/avatar", async (request, response) => {
1961
+ const avatar = store.getCharacterAvatar(request.params.characterId);
1962
+ if (!avatar)
1963
+ throw new AppError(404, "CHARACTER_AVATAR_NOT_FOUND", "角色头像不存在");
1964
+ const content = await characterAvatarStorage.read(avatar.storageKey);
1965
+ response.setHeader("Content-Type", avatar.mimeType);
1966
+ response.setHeader("Content-Length", String(content.byteLength));
1967
+ response.setHeader("ETag", `\"${avatar.sha256}\"`);
1968
+ response.setHeader("Cache-Control", "private, max-age=31536000, immutable");
1969
+ response.setHeader("X-Content-Type-Options", "nosniff");
1970
+ response.send(content);
1971
+ });
1972
+ app.delete("/api/characters/:characterId/avatar", async (request, response) => {
1973
+ const result = store.deleteCharacterAvatar(request.params.characterId);
1974
+ if (result.storageKey && !store.characterAvatarStorageKeyInUse(result.storageKey)) {
1975
+ await characterAvatarStorage.remove(result.storageKey);
1976
+ }
1977
+ data(response, redactCharacterLinks(result.character, requestPermissions(request)));
1978
+ });
1838
1979
  app.patch("/api/characters/:characterId", (request, response) => {
1839
1980
  const { changeNote, expectedVersionNo, ...input } = parse(characterUpdateSchema.extend({ expectedVersionNo: expectedVersionNoSchema }), request.body);
1840
1981
  const character = store.updateCharacter(request.params.characterId, input, "manual", null, changeNote, expectedVersionNo);
@@ -1852,10 +1993,14 @@ export function createRuntime(options) {
1852
1993
  const character = store.restoreCharacter(request.params.characterId, input.versionNo, input.expectedVersionNo);
1853
1994
  data(response, redactCharacterLinks(character, requestPermissions(request)));
1854
1995
  });
1855
- app.delete("/api/characters/:characterId", (request, response) => {
1996
+ app.delete("/api/characters/:characterId", async (request, response) => {
1856
1997
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1857
1998
  const character = store.getCharacter(request.params.characterId);
1999
+ const avatar = store.getCharacterAvatar(request.params.characterId);
1858
2000
  store.deleteCharacter(request.params.characterId, input.expectedVersionNo);
2001
+ if (avatar && !store.characterAvatarStorageKeyInUse(avatar.storageKey)) {
2002
+ await characterAvatarStorage.remove(avatar.storageKey);
2003
+ }
1859
2004
  publishEntityChange(String(character.workId), "character", String(character.id), deletedPageChange);
1860
2005
  noContent(response);
1861
2006
  });
@@ -1925,9 +2070,18 @@ export function createRuntime(options) {
1925
2070
  if (!request.file)
1926
2071
  throw new AppError(400, "FILE_REQUIRED", "请选择要上传的图片附件");
1927
2072
  const accessModule = parse(attachmentPermissionModuleSchema, request.query.module ?? "settings");
2073
+ if (!canWriteWorkModule(requestPermissions(request, String(request.params.workId)), accessModule)) {
2074
+ throw new AppError(403, "WORK_MODULE_WRITE_DENIED", "你没有编辑该资料模块的权限");
2075
+ }
2076
+ const maximumUploadBytes = accessModule === "ai-chat" ? uploadLimits.chatImageBytes : uploadLimits.attachmentBytes;
1928
2077
  let storageKey = null;
1929
2078
  try {
1930
- const stored = await attachmentStorage.ingest(request.file.path);
2079
+ if (request.file.size > maximumUploadBytes) {
2080
+ throw new AppError(413, "ATTACHMENT_TOO_LARGE", `图片附件不能超过 ${formatUploadLimit(maximumUploadBytes)}`);
2081
+ }
2082
+ const stored = await attachmentStorage.ingest(request.file.path, accessModule === "ai-chat"
2083
+ ? { ...aiChatAttachmentIngestOptions, maximumUploadBytes }
2084
+ : undefined);
1931
2085
  storageKey = stored.storageKey;
1932
2086
  const result = store.createAttachment(String(request.params.workId), {
1933
2087
  originalName: normalizeUploadFileName(request.file.originalname),
@@ -1954,8 +2108,10 @@ export function createRuntime(options) {
1954
2108
  throw new AppError(403, "WORK_MODULE_READ_DENIED", "你没有读取该附件所属资料模块的权限");
1955
2109
  }
1956
2110
  const content = await attachmentStorage.read(String(attachment.storageKey));
2111
+ const fileName = attachmentDownloadFileName(store.getAttachmentDownloadContextName(String(attachment.id)), String(attachment.originalName));
1957
2112
  response.setHeader("Content-Type", String(attachment.storedMimeType));
1958
2113
  response.setHeader("Content-Length", String(attachment.storedByteLength));
2114
+ response.setHeader("Content-Disposition", inlineContentDisposition(fileName));
1959
2115
  response.setHeader("ETag", `"${String(attachment.storedSha256)}"`);
1960
2116
  response.setHeader("Cache-Control", "private, max-age=31536000, immutable");
1961
2117
  response.setHeader("X-Content-Type-Options", "nosniff");
@@ -2311,6 +2467,7 @@ export function createRuntime(options) {
2311
2467
  const pagination = parsePagination(request.query);
2312
2468
  data(response, pagination ? ai.listProvidersPage(pagination) : ai.listProviders());
2313
2469
  });
2470
+ app.get("/api/platform/ai/protocols", (_request, response) => data(response, AI_PROVIDER_PROTOCOL_OPTIONS));
2314
2471
  app.post("/api/platform/ai/providers", (request, response) => data(response, ai.createProvider(parse(providerSchema, request.body)), 201));
2315
2472
  app.get("/api/platform/ai/models", (request, response) => {
2316
2473
  const pagination = parsePagination(request.query);
@@ -2321,12 +2478,39 @@ export function createRuntime(options) {
2321
2478
  const input = parse(aiPromptSchema, request.body);
2322
2479
  if (input.imageToolModelId)
2323
2480
  ai.assertImageToolModelAvailable(input.imageToolModelId);
2324
- data(response, store.updatePlatformAiSettings(input));
2481
+ const settings = store.updatePlatformAiSettings(input);
2482
+ ai.setInteractiveStreamIdleTimeoutSeconds(Number(settings.streamIdleTimeoutSeconds));
2483
+ data(response, settings);
2325
2484
  });
2326
2485
  app.get("/api/platform/ai/usage", (request, response) => {
2327
2486
  const query = parse(aiUsageQuerySchema, request.query);
2328
2487
  data(response, ai.getPlatformTokenUsage(query.timezoneOffset));
2329
2488
  });
2489
+ app.post("/api/platform/ai/usage/pricing/refresh", async (request, response) => {
2490
+ if (!request.authUser)
2491
+ throw new AppError(401, "AUTH_REQUIRED", "请先登录");
2492
+ if (request.authUser.role !== "admin")
2493
+ throw new AppError(403, "ADMIN_REQUIRED", "该操作仅限系统管理员");
2494
+ parse(z.object({}).strict(), request.body ?? {});
2495
+ const refreshed = await liteLlmPriceCache.refresh();
2496
+ if (!refreshed) {
2497
+ throw new AppError(502, "LITELLM_PRICE_REFRESH_FAILED", "LiteLLM 模型价格刷新失败,历史缓存未改变");
2498
+ }
2499
+ data(response, {
2500
+ refreshed: true,
2501
+ pricingAvailable: liteLlmPriceCache.hasData(),
2502
+ modelCount: liteLlmPriceCache.getPriceTable().size
2503
+ });
2504
+ });
2505
+ app.get("/api/platform/ai-conversations", (request, response) => {
2506
+ const query = parse(adminAiConversationQuerySchema, request.query);
2507
+ const pagination = parsePagination(request.query) ?? { page: 1, limit: 30, offset: 0 };
2508
+ data(response, store.listAdminAiConversationsPage(pagination, {
2509
+ query: query.q,
2510
+ workId: query.workId,
2511
+ userId: query.userId
2512
+ }));
2513
+ });
2330
2514
  app.get("/api/ui-settings", (_request, response) => data(response, store.getPlatformUiSettings()));
2331
2515
  app.get("/api/platform/ui-settings", (_request, response) => data(response, store.getPlatformUiSettings()));
2332
2516
  app.patch("/api/platform/ui-settings", (request, response) => {
@@ -2417,7 +2601,7 @@ export function createRuntime(options) {
2417
2601
  limit: request.query.limit ?? "20"
2418
2602
  }) ?? { page: 1, limit: 20, offset: 0 };
2419
2603
  const permissions = requestPermissions(request, request.params.workId);
2420
- data(response, mapRecords(store.listAiConversationsPage(request.params.workId, pagination), (conversation) => (redactAiConversation(conversation, permissions))));
2604
+ data(response, mapRecords(store.listAiConversationsPage(request.params.workId, pagination, request.authUser?.userId), (conversation) => (redactAiConversation(conversation, permissions))));
2421
2605
  });
2422
2606
  app.post("/api/works/:workId/ai-conversations", (request, response) => {
2423
2607
  const input = parse(z.object({
@@ -2426,6 +2610,10 @@ export function createRuntime(options) {
2426
2610
  }).strict(), request.body ?? {});
2427
2611
  data(response, store.createAiConversation(request.params.workId, input.title, input.taskType), 201);
2428
2612
  });
2613
+ app.use("/api/ai-conversations/:conversationId", (request, _response, next) => {
2614
+ assertRequestAiConversationOwner(request, request.params.conversationId);
2615
+ next();
2616
+ });
2429
2617
  app.get("/api/ai-conversations/:conversationId", (request, response) => {
2430
2618
  const pagination = parsePagination(request.query);
2431
2619
  const focusMessageId = request.query.messageId === undefined
@@ -2574,6 +2762,10 @@ export function createRuntime(options) {
2574
2762
  const pagination = parsePagination(request.query);
2575
2763
  data(response, pagination ? ai.listModelsPage(request.params.providerId, pagination) : ai.listModels(request.params.providerId));
2576
2764
  });
2765
+ app.post("/api/providers/:providerId/models/import", async (request, response) => {
2766
+ parse(z.object({}).strict(), request.body ?? {});
2767
+ data(response, await ai.importProviderModels(request.params.providerId));
2768
+ });
2577
2769
  app.post("/api/providers/:providerId/models", (request, response) => data(response, ai.createModel(request.params.providerId, parse(modelSchema, request.body)), 201));
2578
2770
  app.post("/api/models/:modelId/test", async (request, response) => {
2579
2771
  parse(z.object({}).strict(), request.body ?? {});
@@ -2621,6 +2813,8 @@ export function createRuntime(options) {
2621
2813
  if (store.getChapter(citation.chapterId).workId !== request.params.workId)
2622
2814
  throw new AppError(400, "CITATION_WORK_MISMATCH", "引用章节不属于当前作品");
2623
2815
  }
2816
+ if (input.conversationId)
2817
+ assertRequestAiConversationOwner(request, input.conversationId);
2624
2818
  const modelId = resolveConversationModelId(request.params.workId, input.conversationId, input.modelId);
2625
2819
  data(response, redactSuggestion(await ai.createSuggestion({
2626
2820
  workId: request.params.workId,
@@ -2639,6 +2833,7 @@ export function createRuntime(options) {
2639
2833
  modelId: identifier.optional(),
2640
2834
  parameters: jsonObject.optional(),
2641
2835
  citations: aiCitationsSchema.optional(),
2836
+ imageAttachmentIds: z.array(identifier).max(4).optional(),
2642
2837
  conversationId: identifier.optional(),
2643
2838
  currentMessageId: identifier.optional(),
2644
2839
  ignoreContextWarning: z.boolean().optional()
@@ -2664,6 +2859,7 @@ export function createRuntime(options) {
2664
2859
  : input.conversationId
2665
2860
  ? store.getAiConversationSummary(input.conversationId)
2666
2861
  : store.createAiConversation(request.params.workId);
2862
+ assertRequestAiConversationOwner(request, String(conversation.id));
2667
2863
  if (String(conversation.workId) !== request.params.workId) {
2668
2864
  throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
2669
2865
  }
@@ -2680,6 +2876,7 @@ export function createRuntime(options) {
2680
2876
  let lastStreamLeaseTouchAt = Date.now();
2681
2877
  let preparedContext = null;
2682
2878
  let preparedConversation = null;
2879
+ let preparedChatImageAttachments = [];
2683
2880
  const startStream = () => {
2684
2881
  if (response.headersSent)
2685
2882
  return;
@@ -2700,6 +2897,7 @@ export function createRuntime(options) {
2700
2897
  };
2701
2898
  try {
2702
2899
  if (!existingRequest) {
2900
+ preparedChatImageAttachments = await ai.prepareChatImageAttachments(request.params.workId, modelId, input.imageAttachmentIds ?? [], permissions);
2703
2901
  store.assertAiConversationStreamAvailable(conversationId);
2704
2902
  preparedContext = await ai.prepareConversationContext({
2705
2903
  conversationId,
@@ -2751,11 +2949,12 @@ export function createRuntime(options) {
2751
2949
  content: input.instruction,
2752
2950
  citations,
2753
2951
  ...(input.currentMessageId ? { existingMessageId: input.currentMessageId } : {}),
2754
- ...((modelId || mentionCharacterIds.length || mentionRaceIds.length || mentionOrganizationIds.length) ? { metadata: {
2952
+ ...((modelId || mentionCharacterIds.length || mentionRaceIds.length || mentionOrganizationIds.length || input.imageAttachmentIds?.length) ? { metadata: {
2755
2953
  ...(modelId ? { modelId } : {}),
2756
2954
  ...(mentionCharacterIds.length ? { mentionCharacterIds } : {}),
2757
2955
  ...(mentionRaceIds.length ? { mentionRaceIds } : {}),
2758
- ...(mentionOrganizationIds.length ? { mentionOrganizationIds } : {})
2956
+ ...(mentionOrganizationIds.length ? { mentionOrganizationIds } : {}),
2957
+ ...(input.imageAttachmentIds?.length ? { chatImageAttachmentIds: [...new Set(input.imageAttachmentIds)] } : {})
2759
2958
  } } : {})
2760
2959
  }
2761
2960
  });
@@ -2815,7 +3014,8 @@ export function createRuntime(options) {
2815
3014
  excludeConversationMessageId: currentMessageId,
2816
3015
  ...(currentMessageId ? { assistantMessageRequestId: `assistant:${currentMessageId}` } : {}),
2817
3016
  ...(modelId ? { modelId } : {}),
2818
- ...(input.parameters ? { parameters: input.parameters } : {})
3017
+ ...(input.parameters ? { parameters: input.parameters } : {}),
3018
+ ...(preparedChatImageAttachments.length ? { imageAttachments: preparedChatImageAttachments } : {})
2819
3019
  }, (delta) => sendEvent("delta", { delta }));
2820
3020
  const assistantMessageId = typeof suggestion.conversationMessage === "object" && suggestion.conversationMessage !== null
2821
3021
  ? String(suggestion.conversationMessage.id ?? "")
@@ -2923,7 +3123,8 @@ export function createRuntime(options) {
2923
3123
  data(response, await ai.searchWork(request.params.workId, query.q, {
2924
3124
  type: query.type,
2925
3125
  limit: query.limit,
2926
- allowedTypes: readableHybridSearchTypes(permissions)
3126
+ allowedTypes: readableHybridSearchTypes(permissions),
3127
+ conversationOwnerUserId: request.authUser?.userId
2927
3128
  }));
2928
3129
  });
2929
3130
  app.head("/api/works/:workId/export", (request, response) => {
@@ -3050,7 +3251,7 @@ export function createRuntime(options) {
3050
3251
  if (error instanceof multer.MulterError) {
3051
3252
  logger.warn("http.request.upload_rejected", { ...commonFields, uploadCode: error.code });
3052
3253
  if (error.code === "LIMIT_FILE_SIZE") {
3053
- const sizeError = uploadSizeError(request.path, uploadLimits);
3254
+ const sizeError = uploadSizeError(request.path, uploadLimits, String(request.query.module ?? ""));
3054
3255
  if (sizeError) {
3055
3256
  response.status(413).json({ error: sizeError });
3056
3257
  return;
@@ -3106,6 +3307,7 @@ export function createRuntime(options) {
3106
3307
  stopping = true;
3107
3308
  logger.info("runtime.closing");
3108
3309
  backups.dispose();
3310
+ liteLlmPriceCache.dispose();
3109
3311
  ai.dispose();
3110
3312
  const cancelledStreamRequests = store.cancelActiveAiConversationStreamRequests();
3111
3313
  if (cancelledStreamRequests > 0)
@@ -3116,8 +3318,8 @@ export function createRuntime(options) {
3116
3318
  await backups.waitForIdle(RUNTIME_BACKUP_IDLE_TIMEOUT_MS);
3117
3319
  collaborationPresence.close();
3118
3320
  database.close();
3119
- if (temporaryAttachmentRoot)
3120
- rmSync(temporaryAttachmentRoot, { recursive: true, force: true });
3321
+ if (temporaryStorageRoot)
3322
+ rmSync(temporaryStorageRoot, { recursive: true, force: true });
3121
3323
  closed = true;
3122
3324
  logger.info("runtime.closed");
3123
3325
  }
@@ -3132,6 +3334,6 @@ export function createRuntime(options) {
3132
3334
  })();
3133
3335
  return closePromise;
3134
3336
  };
3135
- return { app, database, store, ai, backups, auth, attachmentStorage, cleanupAttachments, close };
3337
+ return { app, database, store, ai, liteLlmPriceCache, backups, auth, attachmentStorage, characterAvatarStorage, cleanupAttachments, close };
3136
3338
  }
3137
3339
  //# sourceMappingURL=app.js.map