@musnows/scriverse 0.8.7 → 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";
@@ -29,10 +30,12 @@ import { isOfficialGoogleVertexBaseUrl, parseGoogleServiceAccount } from "./goog
29
30
  import { HYBRID_SEARCH_TYPES, MAXIMUM_WORK_SEARCH_QUERY_LENGTH, readableHybridSearchTypes } from "./hybrid-search.js";
30
31
  import { applyImportFileHints, parseNovelText } from "./parser.js";
31
32
  import { aiConversationTaskTypes, attachmentPermissionModules, RECYCLE_BIN_RETENTION_DAYS, Store, versionedEntityTypes, WORK_AGENT_TOOL_IDS } from "./store.js";
33
+ import { composeRoleplayStoredUserContent } from "./roleplay-turn.js";
32
34
  import { paginated, parsePagination } from "./pagination.js";
33
35
  import { normalizeUploadFileName } from "./utils.js";
34
36
  import { assertSafeAiEndpoint, assertSafeS3Endpoint, createApiRateLimitMiddleware, createAuthenticationRateLimitMiddleware, createBasicAuthMiddleware, createCaptchaRateLimitMiddleware, createExpensiveApiRateLimitMiddleware, createSameOriginMiddleware, createSecurityHeadersMiddleware, createUploadRateLimitMiddleware, enforceCaseInsensitiveRouting, normalizeApiPath, resolveTrustProxySetting, verifySetupToken } from "./security.js";
35
37
  import { ImageCaptchaService } from "./image-captcha.js";
38
+ import { OfflineSyncService } from "./offline-sync.js";
36
39
  import { assertSafeImportedPlainText, decodeUtf8ImportedText } from "./import-security.js";
37
40
  import { InvalidRasterImageError, readRasterImageMetadata } from "./image-metadata.js";
38
41
  import { createRequestLoggingMiddleware, sanitizeRequestPath } from "./http-logging.js";
@@ -40,6 +43,7 @@ import { accountReference, logger, sanitizeError } from "./logger.js";
40
43
  import { currentRequestActor, runWithRequestActor } from "./request-context.js";
41
44
  import { S3BackupManager } from "./s3-backup.js";
42
45
  import { APP_VERSION } from "./version.js";
46
+ import { DESKTOP_SYNC_PROTOCOL, desktopCompatibilityMetadata } from "./desktop-protocol.js";
43
47
  import { ReleaseUpdateChecker } from "./release-update.js";
44
48
  import { CHARACTER_AVATAR_IMAGE_MAX_BYTES, DEFAULT_IMAGE_UPLOAD_LIMITS, formatUploadLimit } from "./upload-limits.js";
45
49
  import { canReadWorkModule, canWriteWorkModule, chapterAnnotationPermissionModule, fullWorkModulePermissions, proseReplacementPermissionModules } from "./work-permissions.js";
@@ -126,6 +130,14 @@ const loginSchema = z.object({
126
130
  password: z.string().max(200),
127
131
  ...captchaFields
128
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();
129
141
  const userUpdateSchema = z.object({ role: z.enum(["admin", "user"]).optional(), status: z.enum(["active", "disabled"]).optional() }).strict();
130
142
  const memberRoleValueSchema = z.enum(["editor", "settings-editor", "viewer"]);
131
143
  const moduleAccessSchema = z.enum(["none", "read", "write"]);
@@ -201,6 +213,7 @@ const workSchema = z.object({
201
213
  coverUrl: z.string().url().nullable().optional(),
202
214
  tags: optionalStrings
203
215
  });
216
+ const workOfflineAccessSchema = z.object({ enabled: z.boolean() }).strict();
204
217
  const settingSchema = z.object({
205
218
  title: nonEmpty.max(200),
206
219
  category: nonEmpty.max(100),
@@ -212,6 +225,31 @@ const settingSchema = z.object({
212
225
  scope: jsonObject.optional(),
213
226
  authorNote: z.string().max(20_000).optional()
214
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
+ });
215
253
  const globalReplaceSchema = z.object({
216
254
  find: z.string().min(1).max(500),
217
255
  replacement: z.string().max(200_000),
@@ -378,6 +416,7 @@ const providerBaseSchema = z.object({
378
416
  note: z.string().max(10_000).optional(),
379
417
  concurrencyLimit: z.number().int().min(1).max(100).optional(),
380
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(),
381
420
  dailyTokenQuota: z.number().int().min(1, "Token 额度必须设置大于 0").max(2_000_000_000).nullable().optional(),
382
421
  monthlyTokenQuota: z.number().int().min(1, "Token 额度必须设置大于 0").max(2_000_000_000).nullable().optional()
383
422
  });
@@ -603,6 +642,51 @@ const contextSchema = z.object({
603
642
  includeBookSummary: z.boolean().optional(),
604
643
  includeSettingInfo: z.boolean().optional()
605
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();
606
690
  /** 创建任务 API 的分析类型校验:仅允许可新建类型,历史类型在运行层保留防御性拒绝。 */
607
691
  export const creatableAnalysisTaskTypeSchema = z.enum(CREATABLE_ANALYSIS_TASK_TYPES);
608
692
  const relationshipSourceRefSchema = z.object({
@@ -757,6 +841,9 @@ function data(response, value, status = 200) {
757
841
  function noContent(response) {
758
842
  response.status(204).end();
759
843
  }
844
+ function hasInteractiveSession(request) {
845
+ return request.authMethod === "session" || request.authMethod === "desktop-session";
846
+ }
760
847
  async function sendEpub(response, archive, title, fallbackStem) {
761
848
  response.type(EPUB_MIME_TYPE);
762
849
  response.setHeader("Content-Disposition", epubContentDisposition(title, fallbackStem));
@@ -1004,7 +1091,16 @@ function redactAiConversation(record, permissions) {
1004
1091
  result.agentTools = [
1005
1092
  ...(permissions.characters !== "none" ? ["recall_self"] : []),
1006
1093
  ...(permissions.characters !== "none" && permissions.relationships !== "none" ? ["recall_relationship"] : []),
1094
+ ...(permissions.characters !== "none"
1095
+ && (permissions.relationships !== "none" || permissions.organizations !== "none" || permissions.timeline !== "none")
1096
+ ? ["recall_other"]
1097
+ : []),
1098
+ ...((permissions.races !== "none" || permissions.organizations !== "none" || permissions.settings !== "none") ? ["recall_known"] : []),
1007
1099
  ...(permissions.prose !== "none" ? ["recall_story"] : []),
1100
+ ...["settings", "characters", "races", "organizations", "timeline", "relationships", "outlines"]
1101
+ .some((module) => permissions[module] !== "none")
1102
+ ? ["image"]
1103
+ : [],
1008
1104
  "calculate_time"
1009
1105
  ];
1010
1106
  }
@@ -1105,7 +1201,8 @@ export function createRuntime(options) {
1105
1201
  const characterAvatarStorage = new AttachmentStorage(options.characterAvatarDirectory
1106
1202
  ?? (temporaryStorageRoot ? join(temporaryStorageRoot, "character-avatars") : join(dirname(options.databasePath), "character-avatars")), CHARACTER_AVATAR_IMAGE_MAX_BYTES);
1107
1203
  mkdirSync(characterAvatarStorage.temporaryDirectory, { recursive: true, mode: 0o700 });
1108
- const auth = new UserAuthService(database);
1204
+ const credentialVault = new CredentialVault(options.masterSecret);
1205
+ const auth = new UserAuthService(database, credentialVault);
1109
1206
  const collaborationPresence = new CollaborationPresence(45_000, Date.now, 120_000, 50, { store: new PresenceStore(database) });
1110
1207
  const publishCollaborativeChange = (workId, pageKey, options = {}) => {
1111
1208
  const actor = currentRequestActor();
@@ -1136,6 +1233,7 @@ export function createRuntime(options) {
1136
1233
  ? auth.listUsers().find((user) => user.status === "active") ?? null
1137
1234
  : null;
1138
1235
  const store = new Store(database);
1236
+ const offlineSync = new OfflineSyncService(database, store);
1139
1237
  const platformAiSettings = store.getPlatformAiSettings();
1140
1238
  const platformAiStreamIdleTimeoutMs = normalizeAiStreamIdleTimeoutSeconds(Number(platformAiSettings.streamIdleTimeoutSeconds)) * 1_000;
1141
1239
  let attachmentCleanupChain = Promise.resolve();
@@ -1197,7 +1295,6 @@ export function createRuntime(options) {
1197
1295
  return lockedModelId ?? requestedModelId;
1198
1296
  };
1199
1297
  const captcha = new ImageCaptchaService({ revealAnswer: options.revealCaptchaAnswer === true });
1200
- const credentialVault = new CredentialVault(options.masterSecret);
1201
1298
  const backups = new S3BackupManager(database, credentialVault, store, attachmentStorage, {
1202
1299
  ...options.backupOptions,
1203
1300
  characterAvatarStorage,
@@ -1215,11 +1312,11 @@ export function createRuntime(options) {
1215
1312
  });
1216
1313
  if (!options.liteLlmPriceCache && options.liteLlmPriceCachePath)
1217
1314
  liteLlmPriceCache.start();
1218
- 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
1219
1316
  ? undefined
1220
1317
  : options.security ? (url) => assertSafeAiEndpoint(url, options.security?.allowPrivateAiEndpoints) : undefined, (task, actor) => {
1221
1318
  const requiredModules = analysisTaskReadModules(task.taskType, task.scope);
1222
- 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));
1223
1320
  const userId = actor?.userId ?? (typeof creator?.created_by_user_id === "string" ? creator.created_by_user_id : null);
1224
1321
  if (!userId)
1225
1322
  return;
@@ -1229,13 +1326,14 @@ export function createRuntime(options) {
1229
1326
  auth.assertWorkAccess(user, String(task.workId), {
1230
1327
  read: requiredModules,
1231
1328
  write: ["ai-analysis"]
1232
- }, false, actor?.allowAdminAccess ?? false);
1329
+ }, false, actor?.allowAdminAccess ?? (user.role === "admin" && Number(creator?.created_via_api_key) !== 1));
1233
1330
  }, attachmentStorage, {
1234
1331
  interactiveStreamIdleTimeoutMs: options.aiStreamIdleTimeoutMs ?? platformAiStreamIdleTimeoutMs,
1235
1332
  retryPolicy: options.aiRetryPolicy,
1236
1333
  retrySleep: options.aiRetrySleep,
1237
1334
  aiChatImageMaxBytes: uploadLimits.chatImageBytes,
1238
- liteLlmPriceCache
1335
+ liteLlmPriceCache,
1336
+ allowPrivateAiEndpoints: options.security?.allowPrivateAiEndpoints === true
1239
1337
  });
1240
1338
  const app = express();
1241
1339
  enforceCaseInsensitiveRouting(app);
@@ -1279,6 +1377,7 @@ export function createRuntime(options) {
1279
1377
  status: "ok",
1280
1378
  bootId,
1281
1379
  version: APP_VERSION,
1380
+ ...desktopCompatibilityMetadata(),
1282
1381
  versionLabel: options.betaVersionLabel ?? null,
1283
1382
  protocol: "openai-chat-completions",
1284
1383
  protocols: [...AI_PROVIDER_PROTOCOLS],
@@ -1297,9 +1396,11 @@ export function createRuntime(options) {
1297
1396
  app.use(createApiRateLimitMiddleware(options.security?.apiRateLimit, options.security?.apiRateWindowMs));
1298
1397
  if (options.security?.enforceSameOrigin ?? true)
1299
1398
  app.use(createSameOriginMiddleware());
1399
+ app.use("/api/sync/works", express.json({ limit: "3mb" }));
1300
1400
  app.use(express.json({ limit: "2mb" }));
1301
1401
  app.get("/api/auth/session", (request, response) => {
1302
- const session = auth.authenticate(request);
1402
+ const desktopSession = auth.authenticateDesktop(request);
1403
+ const session = desktopSession ? null : auth.authenticate(request);
1303
1404
  const registrationOpen = options.security?.allowRegistration === true;
1304
1405
  const setupRequired = !auth.hasUsers();
1305
1406
  const setupTokenRequired = setupRequired && Boolean(options.security?.setupToken);
@@ -1308,8 +1409,17 @@ export function createRuntime(options) {
1308
1409
  data(response, { authenticated: true, user: developmentUser, csrfToken: null, bootId, setupRequired: false, setupTokenRequired: false, registrationOpen });
1309
1410
  return;
1310
1411
  }
1311
- data(response, session
1312
- ? { 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
+ }
1313
1423
  : { authenticated: false, user: null, csrfToken: null, bootId, setupRequired, setupTokenRequired, registrationOpen });
1314
1424
  });
1315
1425
  app.get("/api/auth/captcha", (_request, response) => {
@@ -1339,6 +1449,19 @@ export function createRuntime(options) {
1339
1449
  logger.info("auth.login.succeeded", { actorRef: accountReference(result.session.user.userId) });
1340
1450
  data(response, { user: result.session.user, csrfToken: result.session.csrfToken });
1341
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
+ });
1342
1465
  app.use(createUserSessionMiddleware(auth, {
1343
1466
  disabled: options.disableUserAuth === true,
1344
1467
  resolveBypassUser: getDevelopmentUser
@@ -1353,14 +1476,17 @@ export function createRuntime(options) {
1353
1476
  data(response, { authenticated: true, user: request.authUser, apiKeyPrefix: request.authApiKey?.prefix ?? null });
1354
1477
  });
1355
1478
  app.delete("/api/auth/session", (request, response) => {
1356
- if (request.authSession)
1479
+ if (request.authDesktopSession)
1480
+ auth.revokeDesktop(request.authDesktopSession.id);
1481
+ if (request.authSession) {
1357
1482
  auth.revoke(request.authSession.id);
1358
- clearSessionCookie(response, request.secure);
1483
+ clearSessionCookie(response, request.secure);
1484
+ }
1359
1485
  noContent(response);
1360
1486
  });
1361
1487
  app.post("/api/auth/onboarding/complete", (request, response) => {
1362
- if (!request.authUser || request.authMethod !== "session")
1363
- throw new AppError(401, "SESSION_REQUIRED", "请使用网页会话完成新手引导");
1488
+ if (!request.authUser || !hasInteractiveSession(request))
1489
+ throw new AppError(401, "SESSION_REQUIRED", "请使用交互式会话完成新手引导");
1364
1490
  parse(z.object({}).strict(), request.body ?? {});
1365
1491
  const updated = auth.completeOnboarding(request.authUser.userId);
1366
1492
  store.audit(null, "user.onboarding-completed", "user", updated.userId);
@@ -1410,21 +1536,38 @@ export function createRuntime(options) {
1410
1536
  data(response, updated);
1411
1537
  });
1412
1538
  app.patch("/api/auth/password", (request, response) => {
1413
- 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)
1414
1545
  throw new AppError(401, "AUTH_REQUIRED", "请先登录");
1415
1546
  const input = parse(passwordChangeSchema, request.body);
1416
- 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);
1417
1548
  store.audit(null, "user.password-changed", "user", request.authUser.userId);
1418
1549
  noContent(response);
1419
1550
  });
1420
1551
  app.get("/api/auth/api-key", (request, response) => {
1421
- if (!request.authUser || request.authMethod !== "session")
1422
- throw new AppError(401, "SESSION_REQUIRED", "请使用网页会话管理 API Key");
1552
+ if (!request.authUser || !hasInteractiveSession(request))
1553
+ throw new AppError(401, "SESSION_REQUIRED", "请使用交互式会话管理 API Key");
1423
1554
  data(response, auth.getApiKeyStatus(request.authUser.userId));
1424
1555
  });
1556
+ app.post("/api/auth/api-key/reveal", (request, response) => {
1557
+ if (!request.authUser || !hasInteractiveSession(request))
1558
+ throw new AppError(401, "SESSION_REQUIRED", "请使用交互式会话管理 API Key");
1559
+ parse(z.object({}).strict(), request.body ?? {});
1560
+ const userId = request.authUser.userId;
1561
+ const revealed = database.transaction(() => {
1562
+ const secret = auth.revealApiKey(userId);
1563
+ store.audit(null, "user.api-key-copied", "user", userId, { prefix: secret.prefix });
1564
+ return { apiKey: secret.apiKey };
1565
+ });
1566
+ data(response, revealed);
1567
+ });
1425
1568
  app.post("/api/auth/api-key/reset", (request, response) => {
1426
- if (!request.authUser || request.authMethod !== "session")
1427
- throw new AppError(401, "SESSION_REQUIRED", "请使用网页会话管理 API Key");
1569
+ if (!request.authUser || !hasInteractiveSession(request))
1570
+ throw new AppError(401, "SESSION_REQUIRED", "请使用交互式会话管理 API Key");
1428
1571
  parse(z.object({}).strict(), request.body ?? {});
1429
1572
  const userId = request.authUser.userId;
1430
1573
  const result = database.transaction(() => {
@@ -1512,8 +1655,8 @@ export function createRuntime(options) {
1512
1655
  data(response, pagination ? store.getWorkDirectoryPage(request.params.workId, pagination) : store.getWorkDirectory(request.params.workId));
1513
1656
  });
1514
1657
  app.post("/api/works/:workId/presence", (request, response) => {
1515
- if (!request.authUser || request.authMethod !== "session")
1516
- throw new AppError(401, "SESSION_REQUIRED", "请使用网页会话上报协作状态");
1658
+ if (!request.authUser || !hasInteractiveSession(request))
1659
+ throw new AppError(401, "SESSION_REQUIRED", "请使用交互式会话上报协作状态");
1517
1660
  const input = parse(presenceHeartbeatSchema, request.body);
1518
1661
  data(response, collaborationPresence.heartbeat(request.params.workId, input.clientId, {
1519
1662
  userId: request.authUser.userId,
@@ -1564,6 +1707,77 @@ export function createRuntime(options) {
1564
1707
  const { expectedVersionNo, changeNote, ...input } = parse(workSchema.partial().extend({ expectedVersionNo: expectedVersionNoSchema, changeNote: changeNoteSchema }).strict(), request.body);
1565
1708
  data(response, store.updateWork(request.params.workId, input, expectedVersionNo, "manual", null, changeNote));
1566
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
+ });
1567
1781
  app.delete("/api/works/:workId", async (request, response) => {
1568
1782
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1569
1783
  ai.deleteWork(request.params.workId, input.expectedVersionNo);
@@ -1725,7 +1939,7 @@ export function createRuntime(options) {
1725
1939
  });
1726
1940
  app.get("/api/works/:workId/chapter-annotations", (request, response) => {
1727
1941
  const pagination = parsePagination(request.query);
1728
- const permissions = requestPermissions(request, request.params.workId);
1942
+ const permissions = requestPermissions(request, String(request.params.workId));
1729
1943
  data(response, pagination
1730
1944
  ? store.listWorkChapterAnnotationsPage(request.params.workId, pagination, readableChapterAnnotationKinds(permissions))
1731
1945
  : store.listWorkChapterAnnotations(request.params.workId, readableChapterAnnotationKinds(permissions)));
@@ -1866,6 +2080,10 @@ export function createRuntime(options) {
1866
2080
  data(response, store.createDraft(request.params.workId, parse(draftSchema, request.body)), 201);
1867
2081
  });
1868
2082
  app.get("/api/drafts/:draftId", (request, response) => data(response, store.getDraft(request.params.draftId)));
2083
+ app.patch("/api/drafts/:draftId/favorite", (request, response) => {
2084
+ const input = parse(z.object({ isFavorite: z.boolean() }).strict(), request.body);
2085
+ data(response, store.setDraftFavorite(request.params.draftId, input.isFavorite));
2086
+ });
1869
2087
  app.patch("/api/drafts/:draftId", (request, response) => {
1870
2088
  const { changeNote, expectedVersionNo, ...input } = parse(draftSchema.partial().extend({
1871
2089
  changeNote: changeNoteSchema,
@@ -1889,6 +2107,12 @@ export function createRuntime(options) {
1889
2107
  });
1890
2108
  app.get("/api/works/:workId/settings/context", (request, response) => data(response, store.listSettings(request.params.workId, true)));
1891
2109
  app.get("/api/settings/:settingId", (request, response) => data(response, store.getSetting(request.params.settingId)));
2110
+ app.patch("/api/settings/:settingId/favorite", (request, response) => {
2111
+ const input = parse(z.object({ isFavorite: z.boolean() }).strict(), request.body);
2112
+ const setting = store.setSettingFavorite(request.params.settingId, input.isFavorite);
2113
+ publishEntityChange(String(setting.workId), "setting", String(setting.id));
2114
+ data(response, setting);
2115
+ });
1892
2116
  app.patch("/api/settings/:settingId", (request, response) => {
1893
2117
  const { changeNote, expectedVersionNo, ...input } = parse(settingSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1894
2118
  const setting = store.updateSetting(request.params.settingId, input, "manual", null, changeNote, expectedVersionNo);
@@ -1976,6 +2200,12 @@ export function createRuntime(options) {
1976
2200
  }
1977
2201
  data(response, redactCharacterLinks(result.character, requestPermissions(request)));
1978
2202
  });
2203
+ app.patch("/api/characters/:characterId/favorite", (request, response) => {
2204
+ const input = parse(z.object({ isFavorite: z.boolean() }).strict(), request.body);
2205
+ const character = store.setCharacterFavorite(request.params.characterId, input.isFavorite);
2206
+ publishEntityChange(String(character.workId), "character", String(character.id));
2207
+ data(response, redactCharacterLinks(character, requestPermissions(request)));
2208
+ });
1979
2209
  app.patch("/api/characters/:characterId", (request, response) => {
1980
2210
  const { changeNote, expectedVersionNo, ...input } = parse(characterUpdateSchema.extend({ expectedVersionNo: expectedVersionNoSchema }), request.body);
1981
2211
  const character = store.updateCharacter(request.params.characterId, input, "manual", null, changeNote, expectedVersionNo);
@@ -2184,6 +2414,12 @@ export function createRuntime(options) {
2184
2414
  app.get("/api/organizations/:organizationId", (request, response) => {
2185
2415
  data(response, redactOrganizationMembers(store.getOrganization(request.params.organizationId), requestPermissions(request)));
2186
2416
  });
2417
+ app.patch("/api/organizations/:organizationId/favorite", (request, response) => {
2418
+ const input = parse(z.object({ isFavorite: z.boolean() }).strict(), request.body);
2419
+ const organization = store.setOrganizationFavorite(request.params.organizationId, input.isFavorite);
2420
+ publishEntityChange(String(organization.workId), "organization", String(organization.id));
2421
+ data(response, redactOrganizationMembers(organization, requestPermissions(request)));
2422
+ });
2187
2423
  app.patch("/api/organizations/:organizationId", (request, response) => {
2188
2424
  const { changeNote, expectedVersionNo, ...input } = parse(organizationSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
2189
2425
  const organization = store.updateOrganization(request.params.organizationId, input, "manual", null, changeNote, expectedVersionNo);
@@ -2799,6 +3035,61 @@ export function createRuntime(options) {
2799
3035
  ? mapRecords(ai.listSuggestionsPage(request.params.workId, pagination, status), (suggestion) => redactSuggestion(suggestion, permissions))
2800
3036
  : ai.listSuggestions(request.params.workId, status).map((suggestion) => redactSuggestion(suggestion, permissions)));
2801
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
+ });
2802
3093
  app.post("/api/works/:workId/suggestions", async (request, response) => {
2803
3094
  const input = parse(z.object({
2804
3095
  taskType: z.enum(TASK_TYPES),
@@ -2829,7 +3120,13 @@ export function createRuntime(options) {
2829
3120
  });
2830
3121
  app.post("/api/works/:workId/chat/stream", async (request, response) => {
2831
3122
  const input = parse(z.object({
2832
- instruction: nonEmpty.max(100_000),
3123
+ instruction: z.string().max(100_000).default(""),
3124
+ sceneDirection: z.string().max(20_000).optional(),
3125
+ scenePin: z.object({
3126
+ location: z.string().max(200).optional(),
3127
+ present: z.string().max(500).optional(),
3128
+ timeLabel: z.string().max(200).optional()
3129
+ }).strict().optional(),
2833
3130
  scope: contextSchema,
2834
3131
  modelId: identifier.optional(),
2835
3132
  parameters: jsonObject.optional(),
@@ -2850,7 +3147,6 @@ export function createRuntime(options) {
2850
3147
  if (store.getChapter(citation.chapterId).workId !== request.params.workId)
2851
3148
  throw new AppError(400, "CITATION_WORK_MISMATCH", "引用章节不属于当前作品");
2852
3149
  }
2853
- const resolvedInstruction = instructionWithCitations(input.instruction, citations);
2854
3150
  const existingRequest = store.findAiConversationStreamRequest(actorScope, request.params.workId, idempotencyKey);
2855
3151
  if (existingRequest && input.conversationId && existingRequest.conversationId !== input.conversationId) {
2856
3152
  throw new AppError(409, "IDEMPOTENCY_KEY_REUSED", "该请求标识已用于另一项 AI 对话请求");
@@ -2865,6 +3161,20 @@ export function createRuntime(options) {
2865
3161
  throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
2866
3162
  }
2867
3163
  const conversationId = String(conversation.id);
3164
+ const isRoleplay = Boolean(conversation.roleplayCharacter);
3165
+ const instructionText = input.instruction.trim();
3166
+ const sceneDirection = isRoleplay ? (input.sceneDirection ?? "").trim() : "";
3167
+ if (!instructionText && !sceneDirection) {
3168
+ throw new AppError(400, "INSTRUCTION_REQUIRED", isRoleplay ? "请输入角色台词或场景旁白" : "请输入指令");
3169
+ }
3170
+ if (!existingRequest && isRoleplay && input.scenePin !== undefined) {
3171
+ store.setAiConversationScenePin(conversationId, request.params.workId, input.scenePin);
3172
+ }
3173
+ const storedUserContent = isRoleplay
3174
+ ? composeRoleplayStoredUserContent(sceneDirection, instructionText)
3175
+ : instructionText;
3176
+ const resolvedInstruction = instructionWithCitations(instructionText, citations);
3177
+ const mentionSource = [sceneDirection, resolvedInstruction].filter(Boolean).join("\n");
2868
3178
  const modelId = resolveConversationModelId(request.params.workId, conversationId, input.modelId);
2869
3179
  const permissions = requestPermissions(request, request.params.workId);
2870
3180
  const controller = new AbortController();
@@ -2905,7 +3215,7 @@ export function createRuntime(options) {
2905
3215
  workId: request.params.workId,
2906
3216
  modelId,
2907
3217
  scope: input.scope,
2908
- instruction: resolvedInstruction,
3218
+ instruction: mentionSource,
2909
3219
  excludeConversationMessageId: input.currentMessageId
2910
3220
  }, { ignoreWarning: input.ignoreContextWarning === true });
2911
3221
  preparedConversation = redactAiConversation(store.getAiConversationSummary(conversationId), permissions);
@@ -2930,7 +3240,7 @@ export function createRuntime(options) {
2930
3240
  : ai.resolveInstructionMentions({
2931
3241
  workId: request.params.workId,
2932
3242
  taskType: "chat",
2933
- instruction: resolvedInstruction,
3243
+ instruction: mentionSource,
2934
3244
  scope: input.scope,
2935
3245
  conversationId
2936
3246
  });
@@ -2947,7 +3257,7 @@ export function createRuntime(options) {
2947
3257
  idempotencyKey,
2948
3258
  requestHash,
2949
3259
  userMessage: {
2950
- content: input.instruction,
3260
+ content: storedUserContent,
2951
3261
  citations,
2952
3262
  ...(input.currentMessageId ? { existingMessageId: input.currentMessageId } : {}),
2953
3263
  ...((modelId || mentionCharacterIds.length || mentionRaceIds.length || mentionOrganizationIds.length || input.imageAttachmentIds?.length) ? { metadata: {
@@ -3005,6 +3315,7 @@ export function createRuntime(options) {
3005
3315
  const suggestion = await ai.createStreamingChat({
3006
3316
  workId: request.params.workId,
3007
3317
  instruction: resolvedInstruction,
3318
+ ...(sceneDirection ? { sceneDirection } : {}),
3008
3319
  // 仍由生成路径基于原始范围持久化累计注入,保证预解析不会吞掉本轮自动命中。
3009
3320
  scope: input.scope,
3010
3321
  signal: controller.signal,
@@ -3310,6 +3621,7 @@ export function createRuntime(options) {
3310
3621
  backups.dispose();
3311
3622
  liteLlmPriceCache.dispose();
3312
3623
  ai.dispose();
3624
+ offlineSync.dispose();
3313
3625
  const cancelledStreamRequests = store.cancelActiveAiConversationStreamRequests();
3314
3626
  if (cancelledStreamRequests > 0)
3315
3627
  logger.info("ai.stream.requests_cancelled", { count: cancelledStreamRequests });
@@ -3335,6 +3647,6 @@ export function createRuntime(options) {
3335
3647
  })();
3336
3648
  return closePromise;
3337
3649
  };
3338
- 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 };
3339
3651
  }
3340
3652
  //# sourceMappingURL=app.js.map