@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.
- package/README.md +1 -0
- package/dist/ai-model-pricing.js +295 -0
- package/dist/ai-model-pricing.js.map +1 -0
- package/dist/ai-protocol.js +335 -15
- package/dist/ai-protocol.js.map +1 -1
- package/dist/ai-retry.js +1 -1
- package/dist/ai-retry.js.map +1 -1
- package/dist/ai.js +1073 -138
- package/dist/ai.js.map +1 -1
- package/dist/app.js +220 -24
- package/dist/app.js.map +1 -1
- package/dist/attachment-download.js +26 -0
- package/dist/attachment-download.js.map +1 -0
- package/dist/attachment-storage.js +8 -6
- package/dist/attachment-storage.js.map +1 -1
- package/dist/cli-contract.js +6 -4
- package/dist/cli-contract.js.map +1 -1
- package/dist/database.js +306 -3
- package/dist/database.js.map +1 -1
- package/dist/public/ai-image-attachments.d.ts +21 -0
- package/dist/public/ai-image-attachments.js +42 -0
- package/dist/public/ai-usage.d.ts +1 -0
- package/dist/public/ai-usage.js +11 -0
- package/dist/public/app.js +875 -102
- package/dist/public/display-labels.d.ts +2 -1
- package/dist/public/display-labels.js +6 -6
- package/dist/public/index.html +42 -7
- package/dist/public/model-config.d.ts +3 -1
- package/dist/public/model-config.js +9 -2
- package/dist/public/styles.css +111 -6
- package/dist/s3-backup.js +21 -0
- package/dist/s3-backup.js.map +1 -1
- package/dist/security.js +2 -1
- package/dist/security.js.map +1 -1
- package/dist/server-runtime.js +6 -0
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +550 -44
- package/dist/store.js.map +1 -1
- package/dist/upload-limits.js +10 -4
- package/dist/upload-limits.js.map +1 -1
- package/dist/user-auth.js +3 -0
- package/dist/user-auth.js.map +1 -1
- package/dist/utils.js +1 -1
- package/dist/utils.js.map +1 -1
- package/dist/version.js +1 -1
- package/dist/writing-progress-time.js +15 -0
- package/dist/writing-progress-time.js.map +1 -1
- 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
|
-
|
|
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(
|
|
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
|
|
1053
|
-
? mkdtempSync(join(tmpdir(), "scriverse-
|
|
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 ??
|
|
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
|
-
|
|
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 (
|
|
3126
|
-
rmSync(
|
|
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
|