@musnows/scriverse 0.8.5 → 0.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +1 -0
  2. package/dist/ai-model-pricing.js +530 -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.js +1083 -139
  9. package/dist/ai.js.map +1 -1
  10. package/dist/app.js +222 -25
  11. package/dist/app.js.map +1 -1
  12. package/dist/attachment-download.js +26 -0
  13. package/dist/attachment-download.js.map +1 -0
  14. package/dist/attachment-storage.js +8 -6
  15. package/dist/attachment-storage.js.map +1 -1
  16. package/dist/cli-contract.js +6 -4
  17. package/dist/cli-contract.js.map +1 -1
  18. package/dist/database.js +363 -4
  19. package/dist/database.js.map +1 -1
  20. package/dist/public/ai-image-attachments.d.ts +21 -0
  21. package/dist/public/ai-image-attachments.js +42 -0
  22. package/dist/public/ai-usage.d.ts +1 -0
  23. package/dist/public/ai-usage.js +11 -0
  24. package/dist/public/app.js +1030 -106
  25. package/dist/public/display-labels.d.ts +2 -1
  26. package/dist/public/display-labels.js +6 -6
  27. package/dist/public/index.html +42 -7
  28. package/dist/public/model-config.d.ts +6 -4
  29. package/dist/public/model-config.js +10 -2
  30. package/dist/public/styles.css +157 -6
  31. package/dist/s3-backup.js +21 -0
  32. package/dist/s3-backup.js.map +1 -1
  33. package/dist/security.js +2 -1
  34. package/dist/security.js.map +1 -1
  35. package/dist/server-runtime.js +6 -0
  36. package/dist/server-runtime.js.map +1 -1
  37. package/dist/store.js +550 -44
  38. package/dist/store.js.map +1 -1
  39. package/dist/upload-limits.js +10 -4
  40. package/dist/upload-limits.js.map +1 -1
  41. package/dist/user-auth.js +3 -0
  42. package/dist/user-auth.js.map +1 -1
  43. package/dist/utils.js +1 -1
  44. package/dist/utils.js.map +1 -1
  45. package/dist/version.js +1 -1
  46. package/dist/writing-progress-time.js +15 -0
  47. package/dist/writing-progress-time.js.map +1 -1
  48. 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
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)) {
@@ -408,7 +428,7 @@ const modelSchema = z.object({
408
428
  outputNote: z.string().max(10_000).optional(),
409
429
  preset: jsonObject.optional(),
410
430
  thinkingEnabled: z.boolean().optional(),
411
- thinkingEffort: z.enum(["default", "low", "medium", "high", "xhigh", "max"]).optional(),
431
+ thinkingEffort: z.enum(["default", "auto", "low", "medium", "high", "xhigh", "max"]).optional(),
412
432
  multimodalEnabled: z.boolean().optional(),
413
433
  imageToolDefault: z.boolean().optional(),
414
434
  enabled: z.boolean().optional(),
@@ -422,6 +442,13 @@ const aiPromptSchema = z.object({
422
442
  const aiUsageQuerySchema = z.object({
423
443
  timezoneOffset: z.coerce.number().int().min(-840).max(840).default(0)
424
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();
425
452
  const platformPageSizesSchema = z.object({
426
453
  drafts: z.number().int().min(10).max(100).optional(),
427
454
  settings: z.number().int().min(10).max(100).optional(),
@@ -544,7 +571,8 @@ const aiProcessStepSchema = z.discriminatedUnion("type", [
544
571
  ]);
545
572
  const workAiSettingsSchema = z.object({
546
573
  systemPrompt: z.string().max(100_000).optional(),
547
- 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(),
548
576
  autoRunEnabled: z.boolean().optional(),
549
577
  autoRunConcurrency: z.number().int().min(1).max(8).optional(),
550
578
  autoRunBatchLimit: z.number().int().min(1).max(200).optional(),
@@ -1004,10 +1032,30 @@ export function publicAiStreamError(error) {
1004
1032
  const details = error.details && typeof error.details === "object" && !Array.isArray(error.details)
1005
1033
  ? error.details
1006
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;
1007
1054
  return {
1008
1055
  code: error.code,
1009
1056
  message: error.message,
1010
1057
  status: error.status,
1058
+ ...(publicQuotaDetails ? { details: publicQuotaDetails } : {}),
1011
1059
  ...((error.status < 500 || error.code === "AI_CALL_FAILED") && typeof details?.failure === "string" ? { failure: details.failure } : {}),
1012
1060
  ...(typeof details?.callId === "string" ? { callId: details.callId } : {}),
1013
1061
  ...(typeof details?.providerName === "string" ? { providerName: details.providerName } : {}),
@@ -1049,11 +1097,14 @@ export function createRuntime(options) {
1049
1097
  });
1050
1098
  const database = new Database(options.databasePath);
1051
1099
  const bootId = randomUUID();
1052
- const temporaryAttachmentRoot = options.databasePath === ":memory:" && !options.attachmentDirectory
1053
- ? mkdtempSync(join(tmpdir(), "scriverse-attachments-"))
1100
+ const temporaryStorageRoot = options.databasePath === ":memory:" && (!options.attachmentDirectory || !options.characterAvatarDirectory)
1101
+ ? mkdtempSync(join(tmpdir(), "scriverse-storage-"))
1054
1102
  : null;
1055
- 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);
1056
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 });
1057
1108
  const auth = new UserAuthService(database);
1058
1109
  const collaborationPresence = new CollaborationPresence(45_000, Date.now, 120_000, 50, { store: new PresenceStore(database) });
1059
1110
  const publishCollaborativeChange = (workId, pageKey, options = {}) => {
@@ -1113,6 +1164,13 @@ export function createRuntime(options) {
1113
1164
  attachmentCleanupChain = cleanup.catch(() => undefined);
1114
1165
  return cleanup;
1115
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
+ };
1116
1174
  const requestPermissions = (request, workId) => {
1117
1175
  if (!request.authUser)
1118
1176
  return fullWorkModulePermissions();
@@ -1121,19 +1179,28 @@ export function createRuntime(options) {
1121
1179
  return fullWorkModulePermissions();
1122
1180
  return auth.workModulePermissions(request.authUser, resolvedWorkId, request.authMethod !== "api-key") ?? fullWorkModulePermissions();
1123
1181
  };
1182
+ const assertRequestAiConversationOwner = (request, conversationId) => {
1183
+ if (request.authUser)
1184
+ store.assertAiConversationOwner(conversationId, request.authUser.userId);
1185
+ };
1124
1186
  const resolveConversationModelId = (workId, conversationId, requestedModelId) => {
1125
1187
  if (!conversationId)
1126
1188
  return requestedModelId;
1127
1189
  const lockedModelId = store.getAiConversationLockedModelId(conversationId, workId);
1190
+ const hasImageAttachments = store.getAiConversationHasImageAttachments(conversationId, workId);
1128
1191
  if (lockedModelId && requestedModelId && lockedModelId !== requestedModelId) {
1129
1192
  throw new AppError(409, "AI_CONVERSATION_MODEL_LOCKED", "当前对话已经锁定模型,请新建对话后再切换模型");
1130
1193
  }
1194
+ if (hasImageAttachments && !lockedModelId && requestedModelId) {
1195
+ throw new AppError(409, "AI_CONVERSATION_IMAGE_MODEL_LOCKED", "当前对话链路包含图片但没有可继承的模型,请新建对话后再选择模型");
1196
+ }
1131
1197
  return lockedModelId ?? requestedModelId;
1132
1198
  };
1133
1199
  const captcha = new ImageCaptchaService({ revealAnswer: options.revealCaptchaAnswer === true });
1134
1200
  const credentialVault = new CredentialVault(options.masterSecret);
1135
1201
  const backups = new S3BackupManager(database, credentialVault, store, attachmentStorage, {
1136
1202
  ...options.backupOptions,
1203
+ characterAvatarStorage,
1137
1204
  masterKey: options.masterSecret,
1138
1205
  validateEndpoint: options.backupOptions?.validateEndpoint
1139
1206
  ?? (options.security ? (url) => assertSafeS3Endpoint(url, options.security?.allowPrivateAiEndpoints) : undefined)
@@ -1143,6 +1210,11 @@ export function createRuntime(options) {
1143
1210
  timeoutMs: options.releaseCheckTimeoutMs,
1144
1211
  retries: options.releaseCheckRetries
1145
1212
  });
1213
+ const liteLlmPriceCache = options.liteLlmPriceCache ?? new LiteLlmPriceCache({
1214
+ ...(options.liteLlmPriceCachePath ? { cachePath: options.liteLlmPriceCachePath } : {})
1215
+ });
1216
+ if (!options.liteLlmPriceCache && options.liteLlmPriceCachePath)
1217
+ liteLlmPriceCache.start();
1146
1218
  const ai = new AiManager(store, credentialVault, options.fetchImpl ?? fetch, options.developmentServer === true
1147
1219
  ? undefined
1148
1220
  : options.security ? (url) => assertSafeAiEndpoint(url, options.security?.allowPrivateAiEndpoints) : undefined, (task, actor) => {
@@ -1161,7 +1233,9 @@ export function createRuntime(options) {
1161
1233
  }, attachmentStorage, {
1162
1234
  interactiveStreamIdleTimeoutMs: options.aiStreamIdleTimeoutMs ?? platformAiStreamIdleTimeoutMs,
1163
1235
  retryPolicy: options.aiRetryPolicy,
1164
- retrySleep: options.aiRetrySleep
1236
+ retrySleep: options.aiRetrySleep,
1237
+ aiChatImageMaxBytes: uploadLimits.chatImageBytes,
1238
+ liteLlmPriceCache
1165
1239
  });
1166
1240
  const app = express();
1167
1241
  enforceCaseInsensitiveRouting(app);
@@ -1177,12 +1251,19 @@ export function createRuntime(options) {
1177
1251
  storage: multer.memoryStorage(),
1178
1252
  limits: { fileSize: uploadLimits.avatarBytes + 1, files: 1, fields: 1, fieldSize: 1024, parts: 2, headerPairs: 50 }
1179
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
+ });
1180
1261
  const attachmentUpload = multer({
1181
1262
  storage: multer.diskStorage({
1182
1263
  destination: attachmentStorage.temporaryDirectory,
1183
1264
  filename: (_request, _file, callback) => callback(null, randomUUID())
1184
1265
  }),
1185
- 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 }
1186
1267
  });
1187
1268
  app.disable("x-powered-by");
1188
1269
  const trustProxy = resolveTrustProxySetting(options.security?.trustProxy);
@@ -1396,7 +1477,9 @@ export function createRuntime(options) {
1396
1477
  if (request.authUser)
1397
1478
  auth.assertDeletedWorkAccess(request.authUser, request.params.workId, request.authMethod !== "api-key");
1398
1479
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1480
+ const characterAvatarStorageKeys = store.listCharacterAvatarStorageKeysForWork(request.params.workId);
1399
1481
  store.permanentlyDeleteWork(request.params.workId, input.expectedVersionNo);
1482
+ await cleanupCharacterAvatarFiles(characterAvatarStorageKeys);
1400
1483
  await cleanupAttachments();
1401
1484
  noContent(response);
1402
1485
  });
@@ -1548,11 +1631,11 @@ export function createRuntime(options) {
1548
1631
  data(response, store.replaceWorkText(request.params.workId, parse(globalReplaceSchema, request.body)));
1549
1632
  });
1550
1633
  app.post("/api/works/:workId/volumes", (request, response) => {
1551
- 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);
1552
1635
  data(response, store.createVolume(request.params.workId, input), 201);
1553
1636
  });
1554
1637
  app.patch("/api/volumes/:volumeId", (request, response) => {
1555
- 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);
1556
1639
  const { expectedVersionNo, changeNote, ...volumeInput } = input;
1557
1640
  data(response, store.updateVolume(request.params.volumeId, volumeInput, expectedVersionNo, "manual", null, changeNote));
1558
1641
  });
@@ -1839,6 +1922,60 @@ export function createRuntime(options) {
1839
1922
  app.get("/api/characters/:characterId", (request, response) => {
1840
1923
  data(response, redactCharacterLinks(store.getCharacter(request.params.characterId), requestPermissions(request)));
1841
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
+ });
1842
1979
  app.patch("/api/characters/:characterId", (request, response) => {
1843
1980
  const { changeNote, expectedVersionNo, ...input } = parse(characterUpdateSchema.extend({ expectedVersionNo: expectedVersionNoSchema }), request.body);
1844
1981
  const character = store.updateCharacter(request.params.characterId, input, "manual", null, changeNote, expectedVersionNo);
@@ -1856,10 +1993,14 @@ export function createRuntime(options) {
1856
1993
  const character = store.restoreCharacter(request.params.characterId, input.versionNo, input.expectedVersionNo);
1857
1994
  data(response, redactCharacterLinks(character, requestPermissions(request)));
1858
1995
  });
1859
- app.delete("/api/characters/:characterId", (request, response) => {
1996
+ app.delete("/api/characters/:characterId", async (request, response) => {
1860
1997
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1861
1998
  const character = store.getCharacter(request.params.characterId);
1999
+ const avatar = store.getCharacterAvatar(request.params.characterId);
1862
2000
  store.deleteCharacter(request.params.characterId, input.expectedVersionNo);
2001
+ if (avatar && !store.characterAvatarStorageKeyInUse(avatar.storageKey)) {
2002
+ await characterAvatarStorage.remove(avatar.storageKey);
2003
+ }
1863
2004
  publishEntityChange(String(character.workId), "character", String(character.id), deletedPageChange);
1864
2005
  noContent(response);
1865
2006
  });
@@ -1929,9 +2070,18 @@ export function createRuntime(options) {
1929
2070
  if (!request.file)
1930
2071
  throw new AppError(400, "FILE_REQUIRED", "请选择要上传的图片附件");
1931
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;
1932
2077
  let storageKey = null;
1933
2078
  try {
1934
- 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);
1935
2085
  storageKey = stored.storageKey;
1936
2086
  const result = store.createAttachment(String(request.params.workId), {
1937
2087
  originalName: normalizeUploadFileName(request.file.originalname),
@@ -1958,8 +2108,10 @@ export function createRuntime(options) {
1958
2108
  throw new AppError(403, "WORK_MODULE_READ_DENIED", "你没有读取该附件所属资料模块的权限");
1959
2109
  }
1960
2110
  const content = await attachmentStorage.read(String(attachment.storageKey));
2111
+ const fileName = attachmentDownloadFileName(store.getAttachmentDownloadContextName(String(attachment.id)), String(attachment.originalName));
1961
2112
  response.setHeader("Content-Type", String(attachment.storedMimeType));
1962
2113
  response.setHeader("Content-Length", String(attachment.storedByteLength));
2114
+ response.setHeader("Content-Disposition", inlineContentDisposition(fileName));
1963
2115
  response.setHeader("ETag", `"${String(attachment.storedSha256)}"`);
1964
2116
  response.setHeader("Cache-Control", "private, max-age=31536000, immutable");
1965
2117
  response.setHeader("X-Content-Type-Options", "nosniff");
@@ -2315,6 +2467,7 @@ export function createRuntime(options) {
2315
2467
  const pagination = parsePagination(request.query);
2316
2468
  data(response, pagination ? ai.listProvidersPage(pagination) : ai.listProviders());
2317
2469
  });
2470
+ app.get("/api/platform/ai/protocols", (_request, response) => data(response, AI_PROVIDER_PROTOCOL_OPTIONS));
2318
2471
  app.post("/api/platform/ai/providers", (request, response) => data(response, ai.createProvider(parse(providerSchema, request.body)), 201));
2319
2472
  app.get("/api/platform/ai/models", (request, response) => {
2320
2473
  const pagination = parsePagination(request.query);
@@ -2333,6 +2486,32 @@ export function createRuntime(options) {
2333
2486
  const query = parse(aiUsageQuerySchema, request.query);
2334
2487
  data(response, ai.getPlatformTokenUsage(query.timezoneOffset));
2335
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, "MODEL_PRICE_REFRESH_FAILED", "模型价格刷新失败,历史缓存未改变");
2498
+ }
2499
+ data(response, {
2500
+ refreshed: true,
2501
+ pricingAvailable: liteLlmPriceCache.hasData(),
2502
+ modelCount: liteLlmPriceCache.getPriceTable().size,
2503
+ sources: liteLlmPriceCache.getSourceStatuses()
2504
+ });
2505
+ });
2506
+ app.get("/api/platform/ai-conversations", (request, response) => {
2507
+ const query = parse(adminAiConversationQuerySchema, request.query);
2508
+ const pagination = parsePagination(request.query) ?? { page: 1, limit: 30, offset: 0 };
2509
+ data(response, store.listAdminAiConversationsPage(pagination, {
2510
+ query: query.q,
2511
+ workId: query.workId,
2512
+ userId: query.userId
2513
+ }));
2514
+ });
2336
2515
  app.get("/api/ui-settings", (_request, response) => data(response, store.getPlatformUiSettings()));
2337
2516
  app.get("/api/platform/ui-settings", (_request, response) => data(response, store.getPlatformUiSettings()));
2338
2517
  app.patch("/api/platform/ui-settings", (request, response) => {
@@ -2423,7 +2602,7 @@ export function createRuntime(options) {
2423
2602
  limit: request.query.limit ?? "20"
2424
2603
  }) ?? { page: 1, limit: 20, offset: 0 };
2425
2604
  const permissions = requestPermissions(request, request.params.workId);
2426
- data(response, mapRecords(store.listAiConversationsPage(request.params.workId, pagination), (conversation) => (redactAiConversation(conversation, permissions))));
2605
+ data(response, mapRecords(store.listAiConversationsPage(request.params.workId, pagination, request.authUser?.userId), (conversation) => (redactAiConversation(conversation, permissions))));
2427
2606
  });
2428
2607
  app.post("/api/works/:workId/ai-conversations", (request, response) => {
2429
2608
  const input = parse(z.object({
@@ -2432,6 +2611,10 @@ export function createRuntime(options) {
2432
2611
  }).strict(), request.body ?? {});
2433
2612
  data(response, store.createAiConversation(request.params.workId, input.title, input.taskType), 201);
2434
2613
  });
2614
+ app.use("/api/ai-conversations/:conversationId", (request, _response, next) => {
2615
+ assertRequestAiConversationOwner(request, request.params.conversationId);
2616
+ next();
2617
+ });
2435
2618
  app.get("/api/ai-conversations/:conversationId", (request, response) => {
2436
2619
  const pagination = parsePagination(request.query);
2437
2620
  const focusMessageId = request.query.messageId === undefined
@@ -2580,6 +2763,10 @@ export function createRuntime(options) {
2580
2763
  const pagination = parsePagination(request.query);
2581
2764
  data(response, pagination ? ai.listModelsPage(request.params.providerId, pagination) : ai.listModels(request.params.providerId));
2582
2765
  });
2766
+ app.post("/api/providers/:providerId/models/import", async (request, response) => {
2767
+ parse(z.object({}).strict(), request.body ?? {});
2768
+ data(response, await ai.importProviderModels(request.params.providerId));
2769
+ });
2583
2770
  app.post("/api/providers/:providerId/models", (request, response) => data(response, ai.createModel(request.params.providerId, parse(modelSchema, request.body)), 201));
2584
2771
  app.post("/api/models/:modelId/test", async (request, response) => {
2585
2772
  parse(z.object({}).strict(), request.body ?? {});
@@ -2627,6 +2814,8 @@ export function createRuntime(options) {
2627
2814
  if (store.getChapter(citation.chapterId).workId !== request.params.workId)
2628
2815
  throw new AppError(400, "CITATION_WORK_MISMATCH", "引用章节不属于当前作品");
2629
2816
  }
2817
+ if (input.conversationId)
2818
+ assertRequestAiConversationOwner(request, input.conversationId);
2630
2819
  const modelId = resolveConversationModelId(request.params.workId, input.conversationId, input.modelId);
2631
2820
  data(response, redactSuggestion(await ai.createSuggestion({
2632
2821
  workId: request.params.workId,
@@ -2645,6 +2834,7 @@ export function createRuntime(options) {
2645
2834
  modelId: identifier.optional(),
2646
2835
  parameters: jsonObject.optional(),
2647
2836
  citations: aiCitationsSchema.optional(),
2837
+ imageAttachmentIds: z.array(identifier).max(4).optional(),
2648
2838
  conversationId: identifier.optional(),
2649
2839
  currentMessageId: identifier.optional(),
2650
2840
  ignoreContextWarning: z.boolean().optional()
@@ -2670,6 +2860,7 @@ export function createRuntime(options) {
2670
2860
  : input.conversationId
2671
2861
  ? store.getAiConversationSummary(input.conversationId)
2672
2862
  : store.createAiConversation(request.params.workId);
2863
+ assertRequestAiConversationOwner(request, String(conversation.id));
2673
2864
  if (String(conversation.workId) !== request.params.workId) {
2674
2865
  throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
2675
2866
  }
@@ -2686,6 +2877,7 @@ export function createRuntime(options) {
2686
2877
  let lastStreamLeaseTouchAt = Date.now();
2687
2878
  let preparedContext = null;
2688
2879
  let preparedConversation = null;
2880
+ let preparedChatImageAttachments = [];
2689
2881
  const startStream = () => {
2690
2882
  if (response.headersSent)
2691
2883
  return;
@@ -2706,6 +2898,7 @@ export function createRuntime(options) {
2706
2898
  };
2707
2899
  try {
2708
2900
  if (!existingRequest) {
2901
+ preparedChatImageAttachments = await ai.prepareChatImageAttachments(request.params.workId, modelId, input.imageAttachmentIds ?? [], permissions);
2709
2902
  store.assertAiConversationStreamAvailable(conversationId);
2710
2903
  preparedContext = await ai.prepareConversationContext({
2711
2904
  conversationId,
@@ -2757,11 +2950,12 @@ export function createRuntime(options) {
2757
2950
  content: input.instruction,
2758
2951
  citations,
2759
2952
  ...(input.currentMessageId ? { existingMessageId: input.currentMessageId } : {}),
2760
- ...((modelId || mentionCharacterIds.length || mentionRaceIds.length || mentionOrganizationIds.length) ? { metadata: {
2953
+ ...((modelId || mentionCharacterIds.length || mentionRaceIds.length || mentionOrganizationIds.length || input.imageAttachmentIds?.length) ? { metadata: {
2761
2954
  ...(modelId ? { modelId } : {}),
2762
2955
  ...(mentionCharacterIds.length ? { mentionCharacterIds } : {}),
2763
2956
  ...(mentionRaceIds.length ? { mentionRaceIds } : {}),
2764
- ...(mentionOrganizationIds.length ? { mentionOrganizationIds } : {})
2957
+ ...(mentionOrganizationIds.length ? { mentionOrganizationIds } : {}),
2958
+ ...(input.imageAttachmentIds?.length ? { chatImageAttachmentIds: [...new Set(input.imageAttachmentIds)] } : {})
2765
2959
  } } : {})
2766
2960
  }
2767
2961
  });
@@ -2821,7 +3015,8 @@ export function createRuntime(options) {
2821
3015
  excludeConversationMessageId: currentMessageId,
2822
3016
  ...(currentMessageId ? { assistantMessageRequestId: `assistant:${currentMessageId}` } : {}),
2823
3017
  ...(modelId ? { modelId } : {}),
2824
- ...(input.parameters ? { parameters: input.parameters } : {})
3018
+ ...(input.parameters ? { parameters: input.parameters } : {}),
3019
+ ...(preparedChatImageAttachments.length ? { imageAttachments: preparedChatImageAttachments } : {})
2825
3020
  }, (delta) => sendEvent("delta", { delta }));
2826
3021
  const assistantMessageId = typeof suggestion.conversationMessage === "object" && suggestion.conversationMessage !== null
2827
3022
  ? String(suggestion.conversationMessage.id ?? "")
@@ -2929,7 +3124,8 @@ export function createRuntime(options) {
2929
3124
  data(response, await ai.searchWork(request.params.workId, query.q, {
2930
3125
  type: query.type,
2931
3126
  limit: query.limit,
2932
- allowedTypes: readableHybridSearchTypes(permissions)
3127
+ allowedTypes: readableHybridSearchTypes(permissions),
3128
+ conversationOwnerUserId: request.authUser?.userId
2933
3129
  }));
2934
3130
  });
2935
3131
  app.head("/api/works/:workId/export", (request, response) => {
@@ -3056,7 +3252,7 @@ export function createRuntime(options) {
3056
3252
  if (error instanceof multer.MulterError) {
3057
3253
  logger.warn("http.request.upload_rejected", { ...commonFields, uploadCode: error.code });
3058
3254
  if (error.code === "LIMIT_FILE_SIZE") {
3059
- const sizeError = uploadSizeError(request.path, uploadLimits);
3255
+ const sizeError = uploadSizeError(request.path, uploadLimits, String(request.query.module ?? ""));
3060
3256
  if (sizeError) {
3061
3257
  response.status(413).json({ error: sizeError });
3062
3258
  return;
@@ -3112,6 +3308,7 @@ export function createRuntime(options) {
3112
3308
  stopping = true;
3113
3309
  logger.info("runtime.closing");
3114
3310
  backups.dispose();
3311
+ liteLlmPriceCache.dispose();
3115
3312
  ai.dispose();
3116
3313
  const cancelledStreamRequests = store.cancelActiveAiConversationStreamRequests();
3117
3314
  if (cancelledStreamRequests > 0)
@@ -3122,8 +3319,8 @@ export function createRuntime(options) {
3122
3319
  await backups.waitForIdle(RUNTIME_BACKUP_IDLE_TIMEOUT_MS);
3123
3320
  collaborationPresence.close();
3124
3321
  database.close();
3125
- if (temporaryAttachmentRoot)
3126
- rmSync(temporaryAttachmentRoot, { recursive: true, force: true });
3322
+ if (temporaryStorageRoot)
3323
+ rmSync(temporaryStorageRoot, { recursive: true, force: true });
3127
3324
  closed = true;
3128
3325
  logger.info("runtime.closed");
3129
3326
  }
@@ -3138,6 +3335,6 @@ export function createRuntime(options) {
3138
3335
  })();
3139
3336
  return closePromise;
3140
3337
  };
3141
- return { app, database, store, ai, backups, auth, attachmentStorage, cleanupAttachments, close };
3338
+ return { app, database, store, ai, liteLlmPriceCache, backups, auth, attachmentStorage, characterAvatarStorage, cleanupAttachments, close };
3142
3339
  }
3143
3340
  //# sourceMappingURL=app.js.map