@musnows/scriverse 0.8.5 → 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 (48) 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.js +1073 -138
  9. package/dist/ai.js.map +1 -1
  10. package/dist/app.js +220 -24
  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 +306 -3
  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 +875 -102
  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 +3 -1
  29. package/dist/public/model-config.js +9 -2
  30. package/dist/public/styles.css +111 -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)) {
@@ -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,31 @@ 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, "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
+ });
2336
2514
  app.get("/api/ui-settings", (_request, response) => data(response, store.getPlatformUiSettings()));
2337
2515
  app.get("/api/platform/ui-settings", (_request, response) => data(response, store.getPlatformUiSettings()));
2338
2516
  app.patch("/api/platform/ui-settings", (request, response) => {
@@ -2423,7 +2601,7 @@ export function createRuntime(options) {
2423
2601
  limit: request.query.limit ?? "20"
2424
2602
  }) ?? { page: 1, limit: 20, offset: 0 };
2425
2603
  const permissions = requestPermissions(request, request.params.workId);
2426
- 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))));
2427
2605
  });
2428
2606
  app.post("/api/works/:workId/ai-conversations", (request, response) => {
2429
2607
  const input = parse(z.object({
@@ -2432,6 +2610,10 @@ export function createRuntime(options) {
2432
2610
  }).strict(), request.body ?? {});
2433
2611
  data(response, store.createAiConversation(request.params.workId, input.title, input.taskType), 201);
2434
2612
  });
2613
+ app.use("/api/ai-conversations/:conversationId", (request, _response, next) => {
2614
+ assertRequestAiConversationOwner(request, request.params.conversationId);
2615
+ next();
2616
+ });
2435
2617
  app.get("/api/ai-conversations/:conversationId", (request, response) => {
2436
2618
  const pagination = parsePagination(request.query);
2437
2619
  const focusMessageId = request.query.messageId === undefined
@@ -2580,6 +2762,10 @@ export function createRuntime(options) {
2580
2762
  const pagination = parsePagination(request.query);
2581
2763
  data(response, pagination ? ai.listModelsPage(request.params.providerId, pagination) : ai.listModels(request.params.providerId));
2582
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
+ });
2583
2769
  app.post("/api/providers/:providerId/models", (request, response) => data(response, ai.createModel(request.params.providerId, parse(modelSchema, request.body)), 201));
2584
2770
  app.post("/api/models/:modelId/test", async (request, response) => {
2585
2771
  parse(z.object({}).strict(), request.body ?? {});
@@ -2627,6 +2813,8 @@ export function createRuntime(options) {
2627
2813
  if (store.getChapter(citation.chapterId).workId !== request.params.workId)
2628
2814
  throw new AppError(400, "CITATION_WORK_MISMATCH", "引用章节不属于当前作品");
2629
2815
  }
2816
+ if (input.conversationId)
2817
+ assertRequestAiConversationOwner(request, input.conversationId);
2630
2818
  const modelId = resolveConversationModelId(request.params.workId, input.conversationId, input.modelId);
2631
2819
  data(response, redactSuggestion(await ai.createSuggestion({
2632
2820
  workId: request.params.workId,
@@ -2645,6 +2833,7 @@ export function createRuntime(options) {
2645
2833
  modelId: identifier.optional(),
2646
2834
  parameters: jsonObject.optional(),
2647
2835
  citations: aiCitationsSchema.optional(),
2836
+ imageAttachmentIds: z.array(identifier).max(4).optional(),
2648
2837
  conversationId: identifier.optional(),
2649
2838
  currentMessageId: identifier.optional(),
2650
2839
  ignoreContextWarning: z.boolean().optional()
@@ -2670,6 +2859,7 @@ export function createRuntime(options) {
2670
2859
  : input.conversationId
2671
2860
  ? store.getAiConversationSummary(input.conversationId)
2672
2861
  : store.createAiConversation(request.params.workId);
2862
+ assertRequestAiConversationOwner(request, String(conversation.id));
2673
2863
  if (String(conversation.workId) !== request.params.workId) {
2674
2864
  throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
2675
2865
  }
@@ -2686,6 +2876,7 @@ export function createRuntime(options) {
2686
2876
  let lastStreamLeaseTouchAt = Date.now();
2687
2877
  let preparedContext = null;
2688
2878
  let preparedConversation = null;
2879
+ let preparedChatImageAttachments = [];
2689
2880
  const startStream = () => {
2690
2881
  if (response.headersSent)
2691
2882
  return;
@@ -2706,6 +2897,7 @@ export function createRuntime(options) {
2706
2897
  };
2707
2898
  try {
2708
2899
  if (!existingRequest) {
2900
+ preparedChatImageAttachments = await ai.prepareChatImageAttachments(request.params.workId, modelId, input.imageAttachmentIds ?? [], permissions);
2709
2901
  store.assertAiConversationStreamAvailable(conversationId);
2710
2902
  preparedContext = await ai.prepareConversationContext({
2711
2903
  conversationId,
@@ -2757,11 +2949,12 @@ export function createRuntime(options) {
2757
2949
  content: input.instruction,
2758
2950
  citations,
2759
2951
  ...(input.currentMessageId ? { existingMessageId: input.currentMessageId } : {}),
2760
- ...((modelId || mentionCharacterIds.length || mentionRaceIds.length || mentionOrganizationIds.length) ? { metadata: {
2952
+ ...((modelId || mentionCharacterIds.length || mentionRaceIds.length || mentionOrganizationIds.length || input.imageAttachmentIds?.length) ? { metadata: {
2761
2953
  ...(modelId ? { modelId } : {}),
2762
2954
  ...(mentionCharacterIds.length ? { mentionCharacterIds } : {}),
2763
2955
  ...(mentionRaceIds.length ? { mentionRaceIds } : {}),
2764
- ...(mentionOrganizationIds.length ? { mentionOrganizationIds } : {})
2956
+ ...(mentionOrganizationIds.length ? { mentionOrganizationIds } : {}),
2957
+ ...(input.imageAttachmentIds?.length ? { chatImageAttachmentIds: [...new Set(input.imageAttachmentIds)] } : {})
2765
2958
  } } : {})
2766
2959
  }
2767
2960
  });
@@ -2821,7 +3014,8 @@ export function createRuntime(options) {
2821
3014
  excludeConversationMessageId: currentMessageId,
2822
3015
  ...(currentMessageId ? { assistantMessageRequestId: `assistant:${currentMessageId}` } : {}),
2823
3016
  ...(modelId ? { modelId } : {}),
2824
- ...(input.parameters ? { parameters: input.parameters } : {})
3017
+ ...(input.parameters ? { parameters: input.parameters } : {}),
3018
+ ...(preparedChatImageAttachments.length ? { imageAttachments: preparedChatImageAttachments } : {})
2825
3019
  }, (delta) => sendEvent("delta", { delta }));
2826
3020
  const assistantMessageId = typeof suggestion.conversationMessage === "object" && suggestion.conversationMessage !== null
2827
3021
  ? String(suggestion.conversationMessage.id ?? "")
@@ -2929,7 +3123,8 @@ export function createRuntime(options) {
2929
3123
  data(response, await ai.searchWork(request.params.workId, query.q, {
2930
3124
  type: query.type,
2931
3125
  limit: query.limit,
2932
- allowedTypes: readableHybridSearchTypes(permissions)
3126
+ allowedTypes: readableHybridSearchTypes(permissions),
3127
+ conversationOwnerUserId: request.authUser?.userId
2933
3128
  }));
2934
3129
  });
2935
3130
  app.head("/api/works/:workId/export", (request, response) => {
@@ -3056,7 +3251,7 @@ export function createRuntime(options) {
3056
3251
  if (error instanceof multer.MulterError) {
3057
3252
  logger.warn("http.request.upload_rejected", { ...commonFields, uploadCode: error.code });
3058
3253
  if (error.code === "LIMIT_FILE_SIZE") {
3059
- const sizeError = uploadSizeError(request.path, uploadLimits);
3254
+ const sizeError = uploadSizeError(request.path, uploadLimits, String(request.query.module ?? ""));
3060
3255
  if (sizeError) {
3061
3256
  response.status(413).json({ error: sizeError });
3062
3257
  return;
@@ -3112,6 +3307,7 @@ export function createRuntime(options) {
3112
3307
  stopping = true;
3113
3308
  logger.info("runtime.closing");
3114
3309
  backups.dispose();
3310
+ liteLlmPriceCache.dispose();
3115
3311
  ai.dispose();
3116
3312
  const cancelledStreamRequests = store.cancelActiveAiConversationStreamRequests();
3117
3313
  if (cancelledStreamRequests > 0)
@@ -3122,8 +3318,8 @@ export function createRuntime(options) {
3122
3318
  await backups.waitForIdle(RUNTIME_BACKUP_IDLE_TIMEOUT_MS);
3123
3319
  collaborationPresence.close();
3124
3320
  database.close();
3125
- if (temporaryAttachmentRoot)
3126
- rmSync(temporaryAttachmentRoot, { recursive: true, force: true });
3321
+ if (temporaryStorageRoot)
3322
+ rmSync(temporaryStorageRoot, { recursive: true, force: true });
3127
3323
  closed = true;
3128
3324
  logger.info("runtime.closed");
3129
3325
  }
@@ -3138,6 +3334,6 @@ export function createRuntime(options) {
3138
3334
  })();
3139
3335
  return closePromise;
3140
3336
  };
3141
- return { app, database, store, ai, backups, auth, attachmentStorage, cleanupAttachments, close };
3337
+ return { app, database, store, ai, liteLlmPriceCache, backups, auth, attachmentStorage, characterAvatarStorage, cleanupAttachments, close };
3142
3338
  }
3143
3339
  //# sourceMappingURL=app.js.map