@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/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 ?? false);
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 session = auth.authenticate(request);
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
- data(response, session
1323
- ? { authenticated: true, user: session.user, csrfToken: session.csrfToken, bootId, setupRequired: false, setupTokenRequired: false, registrationOpen }
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.authSession)
1479
+ if (request.authDesktopSession)
1480
+ auth.revokeDesktop(request.authDesktopSession.id);
1481
+ if (request.authSession) {
1368
1482
  auth.revoke(request.authSession.id);
1369
- clearSessionCookie(response, request.secure);
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.authMethod !== "session")
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
- if (!request.authUser || !request.authSession)
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, request.authSession.id, input.currentPassword, input.newPassword);
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.authMethod !== "session")
1433
- throw new AppError(401, "SESSION_REQUIRED", "请使用网页会话管理 API Key");
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.authMethod !== "session")
1438
- throw new AppError(401, "SESSION_REQUIRED", "请使用网页会话管理 API Key");
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.authMethod !== "session")
1450
- throw new AppError(401, "SESSION_REQUIRED", "请使用网页会话管理 API Key");
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.authMethod !== "session")
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