@musnows/scriverse 0.6.8 → 0.6.10

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
@@ -12,6 +12,7 @@ import { z, ZodError } from "zod";
12
12
  import { AI_PROVIDER_PROTOCOLS } from "./ai-protocol.js";
13
13
  import { AttachmentStorage } from "./attachment-storage.js";
14
14
  import { AiManager } from "./ai.js";
15
+ import { resolveMaxAgentToolCallLimit } from "./ai-tool-results.js";
15
16
  import { CredentialVault } from "./credential-vault.js";
16
17
  import { Database } from "./database.js";
17
18
  import { assertSafeDocxArchive } from "./docx-security.js";
@@ -23,13 +24,14 @@ import { applyImportFileHints, parseNovelText } from "./parser.js";
23
24
  import { aiConversationTaskTypes, attachmentPermissionModules, Store, versionedEntityTypes } from "./store.js";
24
25
  import { paginated, parsePagination } from "./pagination.js";
25
26
  import { normalizeUploadFileName } from "./utils.js";
26
- import { assertSafeAiEndpoint, createApiRateLimitMiddleware, createAuthenticationRateLimitMiddleware, createBasicAuthMiddleware, createCaptchaRateLimitMiddleware, createExpensiveApiRateLimitMiddleware, createSameOriginMiddleware, createSecurityHeadersMiddleware, createUploadRateLimitMiddleware, enforceCaseInsensitiveRouting, normalizeApiPath, resolveTrustProxySetting, verifySetupToken } from "./security.js";
27
+ import { assertSafeAiEndpoint, assertSafeS3Endpoint, createApiRateLimitMiddleware, createAuthenticationRateLimitMiddleware, createBasicAuthMiddleware, createCaptchaRateLimitMiddleware, createExpensiveApiRateLimitMiddleware, createSameOriginMiddleware, createSecurityHeadersMiddleware, createUploadRateLimitMiddleware, enforceCaseInsensitiveRouting, normalizeApiPath, resolveTrustProxySetting, verifySetupToken } from "./security.js";
27
28
  import { ImageCaptchaService } from "./image-captcha.js";
28
29
  import { assertSafeImportedPlainText, decodeUtf8ImportedText } from "./import-security.js";
29
30
  import { InvalidRasterImageError, readRasterImageMetadata } from "./image-metadata.js";
30
31
  import { createRequestLoggingMiddleware, sanitizeRequestPath } from "./http-logging.js";
31
32
  import { accountReference, logger, sanitizeError } from "./logger.js";
32
33
  import { currentRequestActor, runWithRequestActor } from "./request-context.js";
34
+ import { S3BackupManager } from "./s3-backup.js";
33
35
  import { APP_VERSION } from "./version.js";
34
36
  import { ReleaseUpdateChecker } from "./release-update.js";
35
37
  import { canReadWorkModule, canWriteWorkModule, fullWorkModulePermissions, proseReplacementPermissionModules } from "./work-permissions.js";
@@ -385,6 +387,50 @@ const platformUiSettingsSchema = z.object({
385
387
  }).strict().refine((input) => input.toastPosition !== undefined || input.pageSizes !== undefined, {
386
388
  message: "至少需要提供一项界面设置"
387
389
  });
390
+ const s3EndpointSchema = z.string().trim().url().max(2_000).superRefine((value, context) => {
391
+ let url;
392
+ try {
393
+ url = new URL(value);
394
+ }
395
+ catch {
396
+ return;
397
+ }
398
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
399
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "S3 服务地址必须使用 HTTP 或 HTTPS" });
400
+ }
401
+ if (url.username || url.password || url.search || url.hash) {
402
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "S3 服务地址不能包含凭据、查询参数或片段" });
403
+ }
404
+ });
405
+ const s3BasePathSchema = z.string().trim().max(512).refine((value) => {
406
+ if (/[\u0000-\u001f\u007f]/u.test(value))
407
+ return false;
408
+ return value.split("/").filter(Boolean).every((segment) => segment !== "." && segment !== "..");
409
+ }, "S3 子目录不能包含控制字符或相对路径段");
410
+ const s3BucketSchema = z.string().trim().min(1).max(255).refine((value) => !/[\/\\\s\u0000-\u001f\u007f]/u.test(value), "S3 桶名称不能包含空白、斜杠或控制字符");
411
+ const s3ScheduleTimeSchema = z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/u, "备份触发时间必须使用 HH:mm 格式");
412
+ const s3BackupTargetBaseSchema = z.object({
413
+ name: nonEmpty.max(100),
414
+ endpoint: s3EndpointSchema,
415
+ region: z.string().trim().min(1).max(64).regex(/^[A-Za-z0-9][A-Za-z0-9-]*$/u, "S3 区域格式无效").optional(),
416
+ bucket: s3BucketSchema,
417
+ basePath: s3BasePathSchema.optional(),
418
+ accessKeyId: z.string().trim().min(1).max(512),
419
+ secretAccessKey: z.string().min(1).max(2_048),
420
+ forcePathStyle: z.boolean().optional(),
421
+ enabled: z.boolean().optional(),
422
+ backupImages: z.boolean().optional(),
423
+ scheduleTime: s3ScheduleTimeSchema.optional(),
424
+ retentionCount: z.number().int().min(1).max(365).optional()
425
+ }).strict();
426
+ const s3BackupTargetUpdateSchema = s3BackupTargetBaseSchema.partial().refine((input) => Object.keys(input).length > 0, "至少需要提供一项 S3 备份配置");
427
+ const s3BackupRunSchema = z.object({
428
+ targetIds: z.array(identifier).min(1).max(100).refine((items) => new Set(items).size === items.length, "S3 备份目标不能重复").optional()
429
+ }).strict();
430
+ const s3BackupRunQuerySchema = z.object({
431
+ afterSequence: z.coerce.number().int().min(0).optional(),
432
+ limit: z.coerce.number().int().min(1).max(100).optional()
433
+ }).strict();
388
434
  const aiToolCallResultSchema = z.object({
389
435
  id: z.string().min(1).max(300),
390
436
  name: z.string().min(1).max(200),
@@ -435,7 +481,7 @@ const workAiSettingsSchema = z.object({
435
481
  autoRunFailureThreshold: z.number().int().min(1).max(10).optional(),
436
482
  bookSummaryContextPercent: z.number().int().min(1).max(90).optional(),
437
483
  contextCompactThreshold: z.number().int().min(50).max(90).optional(),
438
- agentToolCallLimit: z.number().int().min(5).max(48).optional(),
484
+ agentToolCallLimit: z.number().int().min(5).optional(),
439
485
  agentToolCallGlobalMultiplier: z.number().int().min(1).max(6).optional(),
440
486
  agentTools: z.array(z.enum(["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts", "image"])).max(7).optional(),
441
487
  alwaysIncludeSettingInfo: z.boolean().optional(),
@@ -861,12 +907,19 @@ export function createRuntime(options) {
861
907
  return auth.workModulePermissions(request.authUser, resolvedWorkId, request.authMethod !== "api-key") ?? fullWorkModulePermissions();
862
908
  };
863
909
  const captcha = new ImageCaptchaService({ revealAnswer: options.revealCaptchaAnswer === true });
910
+ const credentialVault = new CredentialVault(options.masterSecret);
911
+ const backups = new S3BackupManager(database, credentialVault, store, attachmentStorage, {
912
+ ...options.backupOptions,
913
+ masterKey: options.masterSecret,
914
+ validateEndpoint: options.backupOptions?.validateEndpoint
915
+ ?? (options.security ? (url) => assertSafeS3Endpoint(url, options.security?.allowPrivateAiEndpoints) : undefined)
916
+ });
864
917
  const releaseUpdateChecker = new ReleaseUpdateChecker(APP_VERSION, options.releaseFetchImpl ?? fetch, {
865
918
  intervalMs: options.releaseCheckIntervalMs,
866
919
  timeoutMs: options.releaseCheckTimeoutMs,
867
920
  retries: options.releaseCheckRetries
868
921
  });
869
- const ai = new AiManager(store, new CredentialVault(options.masterSecret), options.fetchImpl ?? fetch, options.security ? (url) => assertSafeAiEndpoint(url, options.security?.allowPrivateAiEndpoints) : undefined, (task, actor) => {
922
+ const ai = new AiManager(store, credentialVault, options.fetchImpl ?? fetch, options.security ? (url) => assertSafeAiEndpoint(url, options.security?.allowPrivateAiEndpoints) : undefined, (task, actor) => {
870
923
  const requiredModules = analysisTaskReadModules(task.taskType, task.scope);
871
924
  const creator = actor ? null : database.get("SELECT created_by_user_id FROM analysis_tasks WHERE id = ?", String(task.id));
872
925
  const userId = actor?.userId ?? (typeof creator?.created_by_user_id === "string" ? creator.created_by_user_id : null);
@@ -1913,6 +1966,27 @@ export function createRuntime(options) {
1913
1966
  app.patch("/api/platform/ui-settings", (request, response) => {
1914
1967
  data(response, store.updatePlatformUiSettings(parse(platformUiSettingsSchema, request.body)));
1915
1968
  });
1969
+ app.get("/api/platform/backups/targets", (_request, response) => data(response, backups.listTargets()));
1970
+ app.post("/api/platform/backups/targets", (request, response) => {
1971
+ data(response, backups.createTarget(parse(s3BackupTargetBaseSchema, request.body)), 201);
1972
+ });
1973
+ app.patch("/api/platform/backups/targets/:targetId", (request, response) => {
1974
+ data(response, backups.updateTarget(request.params.targetId, parse(s3BackupTargetUpdateSchema, request.body)));
1975
+ });
1976
+ app.delete("/api/platform/backups/targets/:targetId", (request, response) => {
1977
+ backups.deleteTarget(request.params.targetId);
1978
+ noContent(response);
1979
+ });
1980
+ app.get("/api/platform/backups/runs", (request, response) => {
1981
+ data(response, backups.listRuns(parse(s3BackupRunQuerySchema, request.query)));
1982
+ });
1983
+ app.post("/api/platform/backups/run", (request, response) => {
1984
+ const input = parse(s3BackupRunSchema, request.body ?? {});
1985
+ const queued = input.targetIds
1986
+ ? backups.enqueueTargets(input.targetIds, "manual")
1987
+ : backups.enqueueEnabledTargets("manual");
1988
+ data(response, { ...queued, queuedAt: new Date().toISOString() }, 202);
1989
+ });
1916
1990
  app.get("/api/works/:workId/ai-settings", (request, response) => data(response, store.getWorkAiSettings(request.params.workId)));
1917
1991
  app.get("/api/works/:workId/ai-settings/usage", (request, response) => {
1918
1992
  const query = parse(aiUsageQuerySchema, request.query);
@@ -1940,6 +2014,10 @@ export function createRuntime(options) {
1940
2014
  app.patch("/api/works/:workId/ai-settings", (request, response) => {
1941
2015
  const workId = request.params.workId;
1942
2016
  const input = parse(workAiSettingsSchema, request.body);
2017
+ const maximumAgentToolCallLimit = resolveMaxAgentToolCallLimit();
2018
+ if (input.agentToolCallLimit !== undefined && input.agentToolCallLimit > maximumAgentToolCallLimit) {
2019
+ throw new AppError(400, "AGENT_TOOL_CALL_LIMIT_TOO_HIGH", `Agent 工具调用上限不能超过 ${maximumAgentToolCallLimit} 次`);
2020
+ }
1943
2021
  if (input.titleGenerationModelId)
1944
2022
  ai.assertModelAvailable(input.titleGenerationModelId);
1945
2023
  if (input.imageToolModelId)
@@ -1971,8 +2049,11 @@ export function createRuntime(options) {
1971
2049
  });
1972
2050
  app.get("/api/ai-conversations/:conversationId", (request, response) => {
1973
2051
  const pagination = parsePagination(request.query);
2052
+ const focusMessageId = request.query.messageId === undefined
2053
+ ? undefined
2054
+ : parse(identifier, request.query.messageId);
1974
2055
  const conversation = pagination
1975
- ? store.getAiConversationPage(request.params.conversationId, pagination)
2056
+ ? store.getAiConversationPage(request.params.conversationId, pagination, focusMessageId)
1976
2057
  : store.getAiConversation(request.params.conversationId);
1977
2058
  const permissions = requestPermissions(request, String(conversation.workId));
1978
2059
  data(response, redactAiConversation(conversation, permissions));
@@ -2286,7 +2367,12 @@ export function createRuntime(options) {
2286
2367
  type: z.enum(HYBRID_SEARCH_TYPES).optional(),
2287
2368
  limit: z.coerce.number().int().min(1).max(100).optional()
2288
2369
  }).strict(), request.query);
2289
- data(response, await ai.searchWork(request.params.workId, query.q, { type: query.type, limit: query.limit }));
2370
+ const permissions = requestPermissions(request, request.params.workId);
2371
+ data(response, await ai.searchWork(request.params.workId, query.q, {
2372
+ type: query.type,
2373
+ limit: query.limit,
2374
+ includeAgentHistory: permissions["ai-chat"] !== "none"
2375
+ }));
2290
2376
  });
2291
2377
  app.get("/api/works/:workId/export", async (request, response) => {
2292
2378
  const format = parse(z.enum(["json", "txt", "markdown", "docx"]), request.query.format ?? "json");
@@ -2437,9 +2523,11 @@ export function createRuntime(options) {
2437
2523
  logger.error("http.request.unhandled_error", commonFields);
2438
2524
  response.status(500).json({ error: { code: "INTERNAL_ERROR", message: "服务器内部错误" } });
2439
2525
  });
2526
+ backups.startScheduler();
2440
2527
  logger.info("runtime.ready", { serveUi: options.serveUi ?? true });
2441
- return { app, database, store, ai, auth, attachmentStorage, cleanupAttachments, close: () => {
2528
+ return { app, database, store, ai, backups, auth, attachmentStorage, cleanupAttachments, close: () => {
2442
2529
  logger.info("runtime.closing");
2530
+ backups.dispose();
2443
2531
  ai.dispose();
2444
2532
  database.close();
2445
2533
  if (temporaryAttachmentRoot)