@musnows/scriverse 0.8.8 → 0.9.0
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/dist/ai-analysis-timeout.js +12 -0
- package/dist/ai-analysis-timeout.js.map +1 -0
- package/dist/ai.js +411 -91
- package/dist/ai.js.map +1 -1
- package/dist/app.js +269 -22
- package/dist/app.js.map +1 -1
- package/dist/database.js +128 -2
- package/dist/database.js.map +1 -1
- package/dist/desktop-protocol.js +21 -0
- package/dist/desktop-protocol.js.map +1 -0
- package/dist/offline-sync.js +436 -0
- package/dist/offline-sync.js.map +1 -0
- package/dist/public/app.js +22 -4
- package/dist/public/index.html +3 -3
- package/dist/public/styles.css +10 -2
- package/dist/security.js +3 -2
- package/dist/security.js.map +1 -1
- package/dist/server-runtime.js +11 -2
- package/dist/server-runtime.js.map +1 -1
- package/dist/storage-manifest.js +116 -0
- package/dist/storage-manifest.js.map +1 -0
- package/dist/store.js +57 -12
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +158 -29
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/dist/app.js
CHANGED
|
@@ -10,6 +10,7 @@ import { tmpdir } from "node:os";
|
|
|
10
10
|
import { pipeline } from "node:stream/promises";
|
|
11
11
|
import { z, ZodError } from "zod";
|
|
12
12
|
import { AI_PROVIDER_PROTOCOL_OPTIONS, AI_PROVIDER_PROTOCOLS, AI_THINKING_TYPES, MAX_TOKENS_PARAMETERS } from "./ai-protocol.js";
|
|
13
|
+
import { MAX_AI_ANALYSIS_TIMEOUT_SECONDS, MIN_AI_ANALYSIS_TIMEOUT_SECONDS } from "./ai-analysis-timeout.js";
|
|
13
14
|
import { aiConversationExportContentDisposition, exportAiConversationMarkdown } from "./ai-conversation-export.js";
|
|
14
15
|
import { DEFAULT_AI_CHAT_TAB_LIMIT } from "./ai-chat-tab-limit.js";
|
|
15
16
|
import { MAX_AI_STREAM_IDLE_TIMEOUT_SECONDS, MIN_AI_STREAM_IDLE_TIMEOUT_SECONDS, normalizeAiStreamIdleTimeoutSeconds } from "./ai-stream-timeout.js";
|
|
@@ -34,6 +35,7 @@ import { paginated, parsePagination } from "./pagination.js";
|
|
|
34
35
|
import { normalizeUploadFileName } from "./utils.js";
|
|
35
36
|
import { assertSafeAiEndpoint, assertSafeS3Endpoint, createApiRateLimitMiddleware, createAuthenticationRateLimitMiddleware, createBasicAuthMiddleware, createCaptchaRateLimitMiddleware, createExpensiveApiRateLimitMiddleware, createSameOriginMiddleware, createSecurityHeadersMiddleware, createUploadRateLimitMiddleware, enforceCaseInsensitiveRouting, normalizeApiPath, resolveTrustProxySetting, verifySetupToken } from "./security.js";
|
|
36
37
|
import { ImageCaptchaService } from "./image-captcha.js";
|
|
38
|
+
import { OfflineSyncService } from "./offline-sync.js";
|
|
37
39
|
import { assertSafeImportedPlainText, decodeUtf8ImportedText } from "./import-security.js";
|
|
38
40
|
import { InvalidRasterImageError, readRasterImageMetadata } from "./image-metadata.js";
|
|
39
41
|
import { createRequestLoggingMiddleware, sanitizeRequestPath } from "./http-logging.js";
|
|
@@ -41,6 +43,7 @@ import { accountReference, logger, sanitizeError } from "./logger.js";
|
|
|
41
43
|
import { currentRequestActor, runWithRequestActor } from "./request-context.js";
|
|
42
44
|
import { S3BackupManager } from "./s3-backup.js";
|
|
43
45
|
import { APP_VERSION } from "./version.js";
|
|
46
|
+
import { DESKTOP_SYNC_PROTOCOL, desktopCompatibilityMetadata } from "./desktop-protocol.js";
|
|
44
47
|
import { ReleaseUpdateChecker } from "./release-update.js";
|
|
45
48
|
import { CHARACTER_AVATAR_IMAGE_MAX_BYTES, DEFAULT_IMAGE_UPLOAD_LIMITS, formatUploadLimit } from "./upload-limits.js";
|
|
46
49
|
import { canReadWorkModule, canWriteWorkModule, chapterAnnotationPermissionModule, fullWorkModulePermissions, proseReplacementPermissionModules } from "./work-permissions.js";
|
|
@@ -127,6 +130,14 @@ const loginSchema = z.object({
|
|
|
127
130
|
password: z.string().max(200),
|
|
128
131
|
...captchaFields
|
|
129
132
|
}).strict();
|
|
133
|
+
const desktopLoginSchema = z.object({
|
|
134
|
+
username: z.string().trim().min(1).max(100),
|
|
135
|
+
password: z.string().max(200),
|
|
136
|
+
desktopId: z.string().uuid(),
|
|
137
|
+
profileId: z.string().uuid(),
|
|
138
|
+
clientVersion: z.string().trim().min(1).max(80),
|
|
139
|
+
...captchaFields
|
|
140
|
+
}).strict();
|
|
130
141
|
const userUpdateSchema = z.object({ role: z.enum(["admin", "user"]).optional(), status: z.enum(["active", "disabled"]).optional() }).strict();
|
|
131
142
|
const memberRoleValueSchema = z.enum(["editor", "settings-editor", "viewer"]);
|
|
132
143
|
const moduleAccessSchema = z.enum(["none", "read", "write"]);
|
|
@@ -202,6 +213,7 @@ const workSchema = z.object({
|
|
|
202
213
|
coverUrl: z.string().url().nullable().optional(),
|
|
203
214
|
tags: optionalStrings
|
|
204
215
|
});
|
|
216
|
+
const workOfflineAccessSchema = z.object({ enabled: z.boolean() }).strict();
|
|
205
217
|
const settingSchema = z.object({
|
|
206
218
|
title: nonEmpty.max(200),
|
|
207
219
|
category: nonEmpty.max(100),
|
|
@@ -213,6 +225,31 @@ const settingSchema = z.object({
|
|
|
213
225
|
scope: jsonObject.optional(),
|
|
214
226
|
authorNote: z.string().max(20_000).optional()
|
|
215
227
|
});
|
|
228
|
+
const chapterSyncSnapshotSchema = z.object({
|
|
229
|
+
title: nonEmpty.max(300),
|
|
230
|
+
content: z.string().max(2_000_000),
|
|
231
|
+
chapterType: chapterTypeSchema
|
|
232
|
+
}).strict();
|
|
233
|
+
const settingSyncSnapshotSchema = settingSchema.strict();
|
|
234
|
+
const syncMutationBaseFields = {
|
|
235
|
+
mutationId: z.string().uuid(),
|
|
236
|
+
entityId: identifier,
|
|
237
|
+
operation: z.literal("update"),
|
|
238
|
+
baseVersionNo: z.number().int().positive(),
|
|
239
|
+
changeNote: z.string().trim().min(1).max(500).default("Desktop 离线修改")
|
|
240
|
+
};
|
|
241
|
+
const syncPushSchema = z.object({
|
|
242
|
+
clientId: z.string().uuid(),
|
|
243
|
+
mutations: z.array(z.discriminatedUnion("entityType", [
|
|
244
|
+
z.object({ ...syncMutationBaseFields, entityType: z.literal("chapter"), localSnapshot: chapterSyncSnapshotSchema }).strict(),
|
|
245
|
+
z.object({ ...syncMutationBaseFields, entityType: z.literal("setting"), localSnapshot: settingSyncSnapshotSchema }).strict()
|
|
246
|
+
])).min(1).max(20)
|
|
247
|
+
}).strict().superRefine((input, context) => {
|
|
248
|
+
const mutationIds = input.mutations.map((mutation) => mutation.mutationId);
|
|
249
|
+
if (new Set(mutationIds).size !== mutationIds.length) {
|
|
250
|
+
context.addIssue({ code: z.ZodIssueCode.custom, path: ["mutations"], message: "同一批次不能重复 mutationId" });
|
|
251
|
+
}
|
|
252
|
+
});
|
|
216
253
|
const globalReplaceSchema = z.object({
|
|
217
254
|
find: z.string().min(1).max(500),
|
|
218
255
|
replacement: z.string().max(200_000),
|
|
@@ -379,6 +416,7 @@ const providerBaseSchema = z.object({
|
|
|
379
416
|
note: z.string().max(10_000).optional(),
|
|
380
417
|
concurrencyLimit: z.number().int().min(1).max(100).optional(),
|
|
381
418
|
rpmLimit: z.number().int().min(1).max(10_000).optional(),
|
|
419
|
+
analysisTimeoutSeconds: z.number().int().min(MIN_AI_ANALYSIS_TIMEOUT_SECONDS).max(MAX_AI_ANALYSIS_TIMEOUT_SECONDS).optional(),
|
|
382
420
|
dailyTokenQuota: z.number().int().min(1, "Token 额度必须设置大于 0").max(2_000_000_000).nullable().optional(),
|
|
383
421
|
monthlyTokenQuota: z.number().int().min(1, "Token 额度必须设置大于 0").max(2_000_000_000).nullable().optional()
|
|
384
422
|
});
|
|
@@ -604,6 +642,51 @@ const contextSchema = z.object({
|
|
|
604
642
|
includeBookSummary: z.boolean().optional(),
|
|
605
643
|
includeSettingInfo: z.boolean().optional()
|
|
606
644
|
});
|
|
645
|
+
const desktopLocalAiRuntimeModelSchema = z.object({
|
|
646
|
+
id: identifier,
|
|
647
|
+
providerId: identifier,
|
|
648
|
+
providerName: nonEmpty.max(200),
|
|
649
|
+
protocol: z.literal("openai-chat-completions"),
|
|
650
|
+
maxTokensParameter: z.enum(MAX_TOKENS_PARAMETERS),
|
|
651
|
+
thinkingType: z.enum(["enabled", "adaptive"]),
|
|
652
|
+
concurrencyLimit: z.number().int().min(1).max(100),
|
|
653
|
+
rpmLimit: z.number().int().min(1).max(10_000),
|
|
654
|
+
analysisTimeoutSeconds: z.number().int().min(MIN_AI_ANALYSIS_TIMEOUT_SECONDS).max(MAX_AI_ANALYSIS_TIMEOUT_SECONDS),
|
|
655
|
+
displayName: nonEmpty.max(200),
|
|
656
|
+
modelId: nonEmpty.max(300),
|
|
657
|
+
purposes: z.array(z.enum(TASK_TYPES)).min(1).max(TASK_TYPES.length),
|
|
658
|
+
contextNote: z.string().max(10_000),
|
|
659
|
+
contextWindow: z.number().int().min(32_768).max(2_000_000),
|
|
660
|
+
outputNote: z.string().max(10_000),
|
|
661
|
+
preset: z.object({
|
|
662
|
+
temperature: z.number().min(0).max(2),
|
|
663
|
+
max_tokens: z.number().int().min(1).max(2_000_000)
|
|
664
|
+
}).strict(),
|
|
665
|
+
thinkingEnabled: z.boolean(),
|
|
666
|
+
thinkingEffort: z.enum(["default", "auto", "low", "medium", "high", "xhigh", "max"]),
|
|
667
|
+
multimodalEnabled: z.boolean(),
|
|
668
|
+
note: z.string().max(10_000)
|
|
669
|
+
}).strict().refine((input) => input.preset.max_tokens < input.contextWindow, {
|
|
670
|
+
path: ["preset", "max_tokens"],
|
|
671
|
+
message: "本地 AI 最大输出令牌数必须小于上下文窗口"
|
|
672
|
+
});
|
|
673
|
+
const desktopLocalAiRunSchema = z.object({
|
|
674
|
+
taskType: z.enum(["chat", "continue", "polish"]),
|
|
675
|
+
instruction: nonEmpty.max(100_000),
|
|
676
|
+
scope: contextSchema,
|
|
677
|
+
runtimeModel: desktopLocalAiRuntimeModelSchema,
|
|
678
|
+
conversationId: identifier.optional(),
|
|
679
|
+
currentMessageId: identifier.optional(),
|
|
680
|
+
citations: aiCitationsSchema.optional(),
|
|
681
|
+
imageAttachmentIds: z.array(identifier).max(4).optional(),
|
|
682
|
+
sceneDirection: z.string().max(20_000).optional()
|
|
683
|
+
}).strict();
|
|
684
|
+
const desktopLocalAiCompletionResponseSchema = z.object({
|
|
685
|
+
requestId: identifier,
|
|
686
|
+
status: z.number().int().min(100).max(599),
|
|
687
|
+
body: z.string().max(4 * 1024 * 1024),
|
|
688
|
+
retryAfter: z.string().max(500).optional()
|
|
689
|
+
}).strict();
|
|
607
690
|
/** 创建任务 API 的分析类型校验:仅允许可新建类型,历史类型在运行层保留防御性拒绝。 */
|
|
608
691
|
export const creatableAnalysisTaskTypeSchema = z.enum(CREATABLE_ANALYSIS_TASK_TYPES);
|
|
609
692
|
const relationshipSourceRefSchema = z.object({
|
|
@@ -758,6 +841,9 @@ function data(response, value, status = 200) {
|
|
|
758
841
|
function noContent(response) {
|
|
759
842
|
response.status(204).end();
|
|
760
843
|
}
|
|
844
|
+
function hasInteractiveSession(request) {
|
|
845
|
+
return request.authMethod === "session" || request.authMethod === "desktop-session";
|
|
846
|
+
}
|
|
761
847
|
async function sendEpub(response, archive, title, fallbackStem) {
|
|
762
848
|
response.type(EPUB_MIME_TYPE);
|
|
763
849
|
response.setHeader("Content-Disposition", epubContentDisposition(title, fallbackStem));
|
|
@@ -1147,6 +1233,7 @@ export function createRuntime(options) {
|
|
|
1147
1233
|
? auth.listUsers().find((user) => user.status === "active") ?? null
|
|
1148
1234
|
: null;
|
|
1149
1235
|
const store = new Store(database);
|
|
1236
|
+
const offlineSync = new OfflineSyncService(database, store);
|
|
1150
1237
|
const platformAiSettings = store.getPlatformAiSettings();
|
|
1151
1238
|
const platformAiStreamIdleTimeoutMs = normalizeAiStreamIdleTimeoutSeconds(Number(platformAiSettings.streamIdleTimeoutSeconds)) * 1_000;
|
|
1152
1239
|
let attachmentCleanupChain = Promise.resolve();
|
|
@@ -1225,11 +1312,11 @@ export function createRuntime(options) {
|
|
|
1225
1312
|
});
|
|
1226
1313
|
if (!options.liteLlmPriceCache && options.liteLlmPriceCachePath)
|
|
1227
1314
|
liteLlmPriceCache.start();
|
|
1228
|
-
const ai = new AiManager(store, credentialVault, options.fetchImpl ?? fetch, options.developmentServer === true
|
|
1315
|
+
const ai = new AiManager(store, credentialVault, options.fetchImpl ?? fetch, options.developmentServer === true || options.disableAiEndpointValidation === true
|
|
1229
1316
|
? undefined
|
|
1230
1317
|
: options.security ? (url) => assertSafeAiEndpoint(url, options.security?.allowPrivateAiEndpoints) : undefined, (task, actor) => {
|
|
1231
1318
|
const requiredModules = analysisTaskReadModules(task.taskType, task.scope);
|
|
1232
|
-
const creator = actor ? null : database.get("SELECT created_by_user_id FROM analysis_tasks WHERE id = ?", String(task.id));
|
|
1319
|
+
const creator = actor ? null : database.get("SELECT created_by_user_id, created_via_api_key FROM analysis_tasks WHERE id = ?", String(task.id));
|
|
1233
1320
|
const userId = actor?.userId ?? (typeof creator?.created_by_user_id === "string" ? creator.created_by_user_id : null);
|
|
1234
1321
|
if (!userId)
|
|
1235
1322
|
return;
|
|
@@ -1239,7 +1326,7 @@ export function createRuntime(options) {
|
|
|
1239
1326
|
auth.assertWorkAccess(user, String(task.workId), {
|
|
1240
1327
|
read: requiredModules,
|
|
1241
1328
|
write: ["ai-analysis"]
|
|
1242
|
-
}, false, actor?.allowAdminAccess ??
|
|
1329
|
+
}, false, actor?.allowAdminAccess ?? (user.role === "admin" && Number(creator?.created_via_api_key) !== 1));
|
|
1243
1330
|
}, attachmentStorage, {
|
|
1244
1331
|
interactiveStreamIdleTimeoutMs: options.aiStreamIdleTimeoutMs ?? platformAiStreamIdleTimeoutMs,
|
|
1245
1332
|
retryPolicy: options.aiRetryPolicy,
|
|
@@ -1290,6 +1377,7 @@ export function createRuntime(options) {
|
|
|
1290
1377
|
status: "ok",
|
|
1291
1378
|
bootId,
|
|
1292
1379
|
version: APP_VERSION,
|
|
1380
|
+
...desktopCompatibilityMetadata(),
|
|
1293
1381
|
versionLabel: options.betaVersionLabel ?? null,
|
|
1294
1382
|
protocol: "openai-chat-completions",
|
|
1295
1383
|
protocols: [...AI_PROVIDER_PROTOCOLS],
|
|
@@ -1308,9 +1396,11 @@ export function createRuntime(options) {
|
|
|
1308
1396
|
app.use(createApiRateLimitMiddleware(options.security?.apiRateLimit, options.security?.apiRateWindowMs));
|
|
1309
1397
|
if (options.security?.enforceSameOrigin ?? true)
|
|
1310
1398
|
app.use(createSameOriginMiddleware());
|
|
1399
|
+
app.use("/api/sync/works", express.json({ limit: "3mb" }));
|
|
1311
1400
|
app.use(express.json({ limit: "2mb" }));
|
|
1312
1401
|
app.get("/api/auth/session", (request, response) => {
|
|
1313
|
-
const
|
|
1402
|
+
const desktopSession = auth.authenticateDesktop(request);
|
|
1403
|
+
const session = desktopSession ? null : auth.authenticate(request);
|
|
1314
1404
|
const registrationOpen = options.security?.allowRegistration === true;
|
|
1315
1405
|
const setupRequired = !auth.hasUsers();
|
|
1316
1406
|
const setupTokenRequired = setupRequired && Boolean(options.security?.setupToken);
|
|
@@ -1319,8 +1409,17 @@ export function createRuntime(options) {
|
|
|
1319
1409
|
data(response, { authenticated: true, user: developmentUser, csrfToken: null, bootId, setupRequired: false, setupTokenRequired: false, registrationOpen });
|
|
1320
1410
|
return;
|
|
1321
1411
|
}
|
|
1322
|
-
|
|
1323
|
-
|
|
1412
|
+
const interactiveSession = desktopSession ?? session;
|
|
1413
|
+
data(response, interactiveSession
|
|
1414
|
+
? {
|
|
1415
|
+
authenticated: true,
|
|
1416
|
+
user: interactiveSession.user,
|
|
1417
|
+
csrfToken: desktopSession ? null : session.csrfToken,
|
|
1418
|
+
bootId,
|
|
1419
|
+
setupRequired: false,
|
|
1420
|
+
setupTokenRequired: false,
|
|
1421
|
+
registrationOpen
|
|
1422
|
+
}
|
|
1324
1423
|
: { authenticated: false, user: null, csrfToken: null, bootId, setupRequired, setupTokenRequired, registrationOpen });
|
|
1325
1424
|
});
|
|
1326
1425
|
app.get("/api/auth/captcha", (_request, response) => {
|
|
@@ -1350,6 +1449,19 @@ export function createRuntime(options) {
|
|
|
1350
1449
|
logger.info("auth.login.succeeded", { actorRef: accountReference(result.session.user.userId) });
|
|
1351
1450
|
data(response, { user: result.session.user, csrfToken: result.session.csrfToken });
|
|
1352
1451
|
});
|
|
1452
|
+
app.post("/api/desktop/auth/login", (request, response) => {
|
|
1453
|
+
const input = parse(desktopLoginSchema, request.body);
|
|
1454
|
+
captcha.consume(input.captchaId, input.captchaAnswer);
|
|
1455
|
+
const result = auth.loginDesktop(input.username, input.password, {
|
|
1456
|
+
desktopId: input.desktopId,
|
|
1457
|
+
profileId: input.profileId,
|
|
1458
|
+
clientVersion: input.clientVersion
|
|
1459
|
+
});
|
|
1460
|
+
response.setHeader("Cache-Control", "no-store");
|
|
1461
|
+
runWithRequestActor(result.session.user, () => store.audit(null, "user.logged-in", "user", result.session.user.userId, { source: "desktop" }));
|
|
1462
|
+
logger.info("auth.desktop_login.succeeded", { actorRef: accountReference(result.session.user.userId) });
|
|
1463
|
+
data(response, { token: result.token, expiresAt: result.session.expiresAt, user: result.session.user });
|
|
1464
|
+
});
|
|
1353
1465
|
app.use(createUserSessionMiddleware(auth, {
|
|
1354
1466
|
disabled: options.disableUserAuth === true,
|
|
1355
1467
|
resolveBypassUser: getDevelopmentUser
|
|
@@ -1364,14 +1476,17 @@ export function createRuntime(options) {
|
|
|
1364
1476
|
data(response, { authenticated: true, user: request.authUser, apiKeyPrefix: request.authApiKey?.prefix ?? null });
|
|
1365
1477
|
});
|
|
1366
1478
|
app.delete("/api/auth/session", (request, response) => {
|
|
1367
|
-
if (request.
|
|
1479
|
+
if (request.authDesktopSession)
|
|
1480
|
+
auth.revokeDesktop(request.authDesktopSession.id);
|
|
1481
|
+
if (request.authSession) {
|
|
1368
1482
|
auth.revoke(request.authSession.id);
|
|
1369
|
-
|
|
1483
|
+
clearSessionCookie(response, request.secure);
|
|
1484
|
+
}
|
|
1370
1485
|
noContent(response);
|
|
1371
1486
|
});
|
|
1372
1487
|
app.post("/api/auth/onboarding/complete", (request, response) => {
|
|
1373
|
-
if (!request.authUser || request
|
|
1374
|
-
throw new AppError(401, "SESSION_REQUIRED", "
|
|
1488
|
+
if (!request.authUser || !hasInteractiveSession(request))
|
|
1489
|
+
throw new AppError(401, "SESSION_REQUIRED", "请使用交互式会话完成新手引导");
|
|
1375
1490
|
parse(z.object({}).strict(), request.body ?? {});
|
|
1376
1491
|
const updated = auth.completeOnboarding(request.authUser.userId);
|
|
1377
1492
|
store.audit(null, "user.onboarding-completed", "user", updated.userId);
|
|
@@ -1421,21 +1536,26 @@ export function createRuntime(options) {
|
|
|
1421
1536
|
data(response, updated);
|
|
1422
1537
|
});
|
|
1423
1538
|
app.patch("/api/auth/password", (request, response) => {
|
|
1424
|
-
|
|
1539
|
+
const activeSession = request.authDesktopSession
|
|
1540
|
+
? { id: request.authDesktopSession.id, kind: "desktop" }
|
|
1541
|
+
: request.authSession
|
|
1542
|
+
? { id: request.authSession.id, kind: "browser" }
|
|
1543
|
+
: null;
|
|
1544
|
+
if (!request.authUser || !activeSession)
|
|
1425
1545
|
throw new AppError(401, "AUTH_REQUIRED", "请先登录");
|
|
1426
1546
|
const input = parse(passwordChangeSchema, request.body);
|
|
1427
|
-
auth.changePassword(request.authUser.userId,
|
|
1547
|
+
auth.changePassword(request.authUser.userId, activeSession.id, input.currentPassword, input.newPassword, activeSession.kind);
|
|
1428
1548
|
store.audit(null, "user.password-changed", "user", request.authUser.userId);
|
|
1429
1549
|
noContent(response);
|
|
1430
1550
|
});
|
|
1431
1551
|
app.get("/api/auth/api-key", (request, response) => {
|
|
1432
|
-
if (!request.authUser || request
|
|
1433
|
-
throw new AppError(401, "SESSION_REQUIRED", "
|
|
1552
|
+
if (!request.authUser || !hasInteractiveSession(request))
|
|
1553
|
+
throw new AppError(401, "SESSION_REQUIRED", "请使用交互式会话管理 API Key");
|
|
1434
1554
|
data(response, auth.getApiKeyStatus(request.authUser.userId));
|
|
1435
1555
|
});
|
|
1436
1556
|
app.post("/api/auth/api-key/reveal", (request, response) => {
|
|
1437
|
-
if (!request.authUser || request
|
|
1438
|
-
throw new AppError(401, "SESSION_REQUIRED", "
|
|
1557
|
+
if (!request.authUser || !hasInteractiveSession(request))
|
|
1558
|
+
throw new AppError(401, "SESSION_REQUIRED", "请使用交互式会话管理 API Key");
|
|
1439
1559
|
parse(z.object({}).strict(), request.body ?? {});
|
|
1440
1560
|
const userId = request.authUser.userId;
|
|
1441
1561
|
const revealed = database.transaction(() => {
|
|
@@ -1446,8 +1566,8 @@ export function createRuntime(options) {
|
|
|
1446
1566
|
data(response, revealed);
|
|
1447
1567
|
});
|
|
1448
1568
|
app.post("/api/auth/api-key/reset", (request, response) => {
|
|
1449
|
-
if (!request.authUser || request
|
|
1450
|
-
throw new AppError(401, "SESSION_REQUIRED", "
|
|
1569
|
+
if (!request.authUser || !hasInteractiveSession(request))
|
|
1570
|
+
throw new AppError(401, "SESSION_REQUIRED", "请使用交互式会话管理 API Key");
|
|
1451
1571
|
parse(z.object({}).strict(), request.body ?? {});
|
|
1452
1572
|
const userId = request.authUser.userId;
|
|
1453
1573
|
const result = database.transaction(() => {
|
|
@@ -1535,8 +1655,8 @@ export function createRuntime(options) {
|
|
|
1535
1655
|
data(response, pagination ? store.getWorkDirectoryPage(request.params.workId, pagination) : store.getWorkDirectory(request.params.workId));
|
|
1536
1656
|
});
|
|
1537
1657
|
app.post("/api/works/:workId/presence", (request, response) => {
|
|
1538
|
-
if (!request.authUser || request
|
|
1539
|
-
throw new AppError(401, "SESSION_REQUIRED", "
|
|
1658
|
+
if (!request.authUser || !hasInteractiveSession(request))
|
|
1659
|
+
throw new AppError(401, "SESSION_REQUIRED", "请使用交互式会话上报协作状态");
|
|
1540
1660
|
const input = parse(presenceHeartbeatSchema, request.body);
|
|
1541
1661
|
data(response, collaborationPresence.heartbeat(request.params.workId, input.clientId, {
|
|
1542
1662
|
userId: request.authUser.userId,
|
|
@@ -1587,6 +1707,77 @@ export function createRuntime(options) {
|
|
|
1587
1707
|
const { expectedVersionNo, changeNote, ...input } = parse(workSchema.partial().extend({ expectedVersionNo: expectedVersionNoSchema, changeNote: changeNoteSchema }).strict(), request.body);
|
|
1588
1708
|
data(response, store.updateWork(request.params.workId, input, expectedVersionNo, "manual", null, changeNote));
|
|
1589
1709
|
});
|
|
1710
|
+
app.patch("/api/works/:workId/offline-access", (request, response) => {
|
|
1711
|
+
if (!request.authUser || !hasInteractiveSession(request)) {
|
|
1712
|
+
throw new AppError(401, "SESSION_REQUIRED", "请使用网页或 Desktop 登录管理离线访问");
|
|
1713
|
+
}
|
|
1714
|
+
const input = parse(workOfflineAccessSchema, request.body);
|
|
1715
|
+
data(response, store.setWorkOfflineAccess(request.params.workId, input.enabled));
|
|
1716
|
+
});
|
|
1717
|
+
app.post("/api/sync/works/:workId/snapshots", (request, response) => {
|
|
1718
|
+
if (!request.authUser || !hasInteractiveSession(request)) {
|
|
1719
|
+
throw new AppError(401, "SESSION_REQUIRED", "请使用 Desktop 或网页登录创建同步快照");
|
|
1720
|
+
}
|
|
1721
|
+
parse(z.object({}).strict(), request.body ?? {});
|
|
1722
|
+
response.setHeader("Cache-Control", "no-store");
|
|
1723
|
+
data(response, offlineSync.createSnapshot(request.params.workId, request.authUser.userId), 201);
|
|
1724
|
+
});
|
|
1725
|
+
app.get("/api/sync/works/:workId/changes", (request, response) => {
|
|
1726
|
+
if (!request.authUser || !hasInteractiveSession(request)) {
|
|
1727
|
+
throw new AppError(401, "SESSION_REQUIRED", "请使用 Desktop 或网页登录读取同步变更");
|
|
1728
|
+
}
|
|
1729
|
+
const query = parse(z.object({
|
|
1730
|
+
after: z.coerce.number().int().min(0).max(Number.MAX_SAFE_INTEGER).default(0),
|
|
1731
|
+
limit: z.coerce.number().int().min(1).max(200).default(200)
|
|
1732
|
+
}).strict(), request.query);
|
|
1733
|
+
response.setHeader("Cache-Control", "no-store");
|
|
1734
|
+
data(response, offlineSync.listChanges(request.params.workId, query.after, query.limit));
|
|
1735
|
+
});
|
|
1736
|
+
app.post("/api/sync/works/:workId/push", (request, response) => {
|
|
1737
|
+
if (!request.authUser || !hasInteractiveSession(request)) {
|
|
1738
|
+
throw new AppError(401, "SESSION_REQUIRED", "请使用 Desktop 或网页登录提交离线变更");
|
|
1739
|
+
}
|
|
1740
|
+
if (Buffer.byteLength(JSON.stringify(request.body ?? {}), "utf8") > DESKTOP_SYNC_PROTOCOL.maxMutationBytes) {
|
|
1741
|
+
throw new AppError(413, "SYNC_PUSH_TOO_LARGE", `同步批次不能超过 ${DESKTOP_SYNC_PROTOCOL.maxMutationBytes} bytes`);
|
|
1742
|
+
}
|
|
1743
|
+
const input = parse(syncPushSchema, request.body);
|
|
1744
|
+
response.setHeader("Cache-Control", "no-store");
|
|
1745
|
+
data(response, offlineSync.pushMutations(request.params.workId, request.authUser.userId, input.clientId, input.mutations));
|
|
1746
|
+
});
|
|
1747
|
+
app.get("/api/sync/works/:workId/mutations/:mutationId", (request, response) => {
|
|
1748
|
+
if (!request.authUser || !hasInteractiveSession(request)) {
|
|
1749
|
+
throw new AppError(401, "SESSION_REQUIRED", "请使用 Desktop 或网页登录读取同步变更结果");
|
|
1750
|
+
}
|
|
1751
|
+
const mutationId = parse(z.string().uuid(), request.params.mutationId);
|
|
1752
|
+
response.setHeader("Cache-Control", "no-store");
|
|
1753
|
+
data(response, offlineSync.getMutationResult(request.params.workId, request.authUser.userId, mutationId));
|
|
1754
|
+
});
|
|
1755
|
+
app.get("/api/sync/snapshots/:snapshotId/items", (request, response) => {
|
|
1756
|
+
if (!request.authUser || !hasInteractiveSession(request)) {
|
|
1757
|
+
throw new AppError(401, "SESSION_REQUIRED", "请使用 Desktop 或网页登录读取同步快照");
|
|
1758
|
+
}
|
|
1759
|
+
const snapshotId = parse(z.string().uuid(), request.params.snapshotId);
|
|
1760
|
+
const snapshot = offlineSync.describeOwnedSnapshot(snapshotId, request.authUser.userId);
|
|
1761
|
+
auth.assertActiveWork(snapshot.workId);
|
|
1762
|
+
auth.assertWorkAccess(request.authUser, snapshot.workId, { read: ["prose", "settings"] }, false, true);
|
|
1763
|
+
const query = parse(z.object({
|
|
1764
|
+
after: z.coerce.number().int().min(0).max(Number.MAX_SAFE_INTEGER).default(0),
|
|
1765
|
+
limit: z.coerce.number().int().min(1).max(100).default(100)
|
|
1766
|
+
}).strict(), request.query);
|
|
1767
|
+
response.setHeader("Cache-Control", "no-store");
|
|
1768
|
+
data(response, offlineSync.readSnapshotPage(snapshotId, request.authUser.userId, query.after, query.limit));
|
|
1769
|
+
});
|
|
1770
|
+
app.delete("/api/sync/snapshots/:snapshotId", (request, response) => {
|
|
1771
|
+
if (!request.authUser || !hasInteractiveSession(request)) {
|
|
1772
|
+
throw new AppError(401, "SESSION_REQUIRED", "请使用 Desktop 或网页登录删除同步快照");
|
|
1773
|
+
}
|
|
1774
|
+
const snapshotId = parse(z.string().uuid(), request.params.snapshotId);
|
|
1775
|
+
const snapshot = offlineSync.describeOwnedSnapshot(snapshotId, request.authUser.userId);
|
|
1776
|
+
auth.assertActiveWork(snapshot.workId);
|
|
1777
|
+
auth.assertWorkAccess(request.authUser, snapshot.workId, { read: ["prose", "settings"] }, false, true);
|
|
1778
|
+
offlineSync.deleteSnapshot(snapshotId, request.authUser.userId);
|
|
1779
|
+
noContent(response);
|
|
1780
|
+
});
|
|
1590
1781
|
app.delete("/api/works/:workId", async (request, response) => {
|
|
1591
1782
|
const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
|
|
1592
1783
|
ai.deleteWork(request.params.workId, input.expectedVersionNo);
|
|
@@ -1748,7 +1939,7 @@ export function createRuntime(options) {
|
|
|
1748
1939
|
});
|
|
1749
1940
|
app.get("/api/works/:workId/chapter-annotations", (request, response) => {
|
|
1750
1941
|
const pagination = parsePagination(request.query);
|
|
1751
|
-
const permissions = requestPermissions(request, request.params.workId);
|
|
1942
|
+
const permissions = requestPermissions(request, String(request.params.workId));
|
|
1752
1943
|
data(response, pagination
|
|
1753
1944
|
? store.listWorkChapterAnnotationsPage(request.params.workId, pagination, readableChapterAnnotationKinds(permissions))
|
|
1754
1945
|
: store.listWorkChapterAnnotations(request.params.workId, readableChapterAnnotationKinds(permissions)));
|
|
@@ -2844,6 +3035,61 @@ export function createRuntime(options) {
|
|
|
2844
3035
|
? mapRecords(ai.listSuggestionsPage(request.params.workId, pagination, status), (suggestion) => redactSuggestion(suggestion, permissions))
|
|
2845
3036
|
: ai.listSuggestions(request.params.workId, status).map((suggestion) => redactSuggestion(suggestion, permissions)));
|
|
2846
3037
|
});
|
|
3038
|
+
const desktopLocalAiActorScope = (request) => {
|
|
3039
|
+
const actor = currentRequestActor();
|
|
3040
|
+
return request.authUser?.userId ? `user:${request.authUser.userId}` : actor?.userId ? `user:${actor.userId}` : "auth-disabled";
|
|
3041
|
+
};
|
|
3042
|
+
const assertDesktopLocalAiPermission = (request) => {
|
|
3043
|
+
const permissions = requestPermissions(request, String(request.params.workId));
|
|
3044
|
+
if (!canWriteWorkModule(permissions, "ai-chat")) {
|
|
3045
|
+
throw new AppError(403, "WORK_MODULE_WRITE_DENIED", "你没有使用创作助手的权限");
|
|
3046
|
+
}
|
|
3047
|
+
return permissions;
|
|
3048
|
+
};
|
|
3049
|
+
app.post("/api/works/:workId/desktop-local-ai/runs", async (request, response) => {
|
|
3050
|
+
const input = parse(desktopLocalAiRunSchema, request.body);
|
|
3051
|
+
const permissions = assertDesktopLocalAiPermission(request);
|
|
3052
|
+
const citations = input.citations ?? [];
|
|
3053
|
+
for (const citation of citations) {
|
|
3054
|
+
if (store.getChapter(citation.chapterId).workId !== request.params.workId) {
|
|
3055
|
+
throw new AppError(400, "CITATION_WORK_MISMATCH", "引用章节不属于当前作品");
|
|
3056
|
+
}
|
|
3057
|
+
}
|
|
3058
|
+
if (input.conversationId) {
|
|
3059
|
+
assertRequestAiConversationOwner(request, input.conversationId);
|
|
3060
|
+
const conversation = store.getAiConversationSummary(input.conversationId);
|
|
3061
|
+
if (String(conversation.workId) !== request.params.workId) {
|
|
3062
|
+
throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
data(response, await ai.startDesktopLocalAiRun({
|
|
3066
|
+
workId: request.params.workId,
|
|
3067
|
+
taskType: input.taskType,
|
|
3068
|
+
instruction: instructionWithCitations(input.instruction, citations),
|
|
3069
|
+
scope: input.scope,
|
|
3070
|
+
runtimeModel: input.runtimeModel,
|
|
3071
|
+
...(input.conversationId ? { conversationId: input.conversationId } : {}),
|
|
3072
|
+
...(input.currentMessageId ? { excludeConversationMessageId: input.currentMessageId } : {}),
|
|
3073
|
+
...(input.imageAttachmentIds?.length ? { imageAttachmentIds: input.imageAttachmentIds } : {}),
|
|
3074
|
+
...(input.sceneDirection ? { sceneDirection: input.sceneDirection } : {})
|
|
3075
|
+
}, desktopLocalAiActorScope(request), currentRequestActor(), permissions), 202);
|
|
3076
|
+
});
|
|
3077
|
+
app.get("/api/works/:workId/desktop-local-ai/runs/:runId", (request, response) => {
|
|
3078
|
+
assertDesktopLocalAiPermission(request);
|
|
3079
|
+
const runId = parse(identifier, request.params.runId);
|
|
3080
|
+
data(response, ai.desktopLocalAiRunStatus(runId, request.params.workId, desktopLocalAiActorScope(request)));
|
|
3081
|
+
});
|
|
3082
|
+
app.post("/api/works/:workId/desktop-local-ai/runs/:runId/responses", (request, response) => {
|
|
3083
|
+
assertDesktopLocalAiPermission(request);
|
|
3084
|
+
const runId = parse(identifier, request.params.runId);
|
|
3085
|
+
const input = parse(desktopLocalAiCompletionResponseSchema, request.body);
|
|
3086
|
+
data(response, ai.submitDesktopLocalAiCompletion(runId, request.params.workId, desktopLocalAiActorScope(request), input));
|
|
3087
|
+
});
|
|
3088
|
+
app.delete("/api/works/:workId/desktop-local-ai/runs/:runId", (request, response) => {
|
|
3089
|
+
assertDesktopLocalAiPermission(request);
|
|
3090
|
+
const runId = parse(identifier, request.params.runId);
|
|
3091
|
+
data(response, ai.cancelDesktopLocalAiRun(runId, request.params.workId, desktopLocalAiActorScope(request)));
|
|
3092
|
+
});
|
|
2847
3093
|
app.post("/api/works/:workId/suggestions", async (request, response) => {
|
|
2848
3094
|
const input = parse(z.object({
|
|
2849
3095
|
taskType: z.enum(TASK_TYPES),
|
|
@@ -3375,6 +3621,7 @@ export function createRuntime(options) {
|
|
|
3375
3621
|
backups.dispose();
|
|
3376
3622
|
liteLlmPriceCache.dispose();
|
|
3377
3623
|
ai.dispose();
|
|
3624
|
+
offlineSync.dispose();
|
|
3378
3625
|
const cancelledStreamRequests = store.cancelActiveAiConversationStreamRequests();
|
|
3379
3626
|
if (cancelledStreamRequests > 0)
|
|
3380
3627
|
logger.info("ai.stream.requests_cancelled", { count: cancelledStreamRequests });
|
|
@@ -3400,6 +3647,6 @@ export function createRuntime(options) {
|
|
|
3400
3647
|
})();
|
|
3401
3648
|
return closePromise;
|
|
3402
3649
|
};
|
|
3403
|
-
return { app, database, store, ai, liteLlmPriceCache, backups, auth, attachmentStorage, characterAvatarStorage, cleanupAttachments, close };
|
|
3650
|
+
return { app, database, store, ai, liteLlmPriceCache, backups, auth, offlineSync, attachmentStorage, characterAvatarStorage, cleanupAttachments, close };
|
|
3404
3651
|
}
|
|
3405
3652
|
//# sourceMappingURL=app.js.map
|