@musnows/scriverse 0.9.5 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/app.js CHANGED
@@ -17,9 +17,10 @@ import { DEFAULT_AI_CHAT_TAB_LIMIT } from "./ai-chat-tab-limit.js";
17
17
  import { MAX_AI_STREAM_IDLE_TIMEOUT_SECONDS, MIN_AI_STREAM_IDLE_TIMEOUT_SECONDS, normalizeAiStreamIdleTimeoutSeconds } from "./ai-stream-timeout.js";
18
18
  import { AttachmentStorage } from "./attachment-storage.js";
19
19
  import { attachmentDownloadFileName, inlineContentDisposition } from "./attachment-download.js";
20
- import { AiManager } from "./ai.js";
20
+ import { AI_MODEL_KINDS, AiManager } from "./ai.js";
21
21
  import { LiteLlmPriceCache } from "./ai-model-pricing.js";
22
22
  import { resolveMaxAgentToolCallLimit } from "./ai-tool-results.js";
23
+ import { SEMANTIC_SOURCE_TYPES } from "./semantic-search.js";
23
24
  import { CHARACTER_EXTRACTION_MAX_ALIASES, CHARACTER_EXTRACTION_MAX_CANDIDATES, CHARACTER_EXTRACTION_MAX_IDENTITY_LENGTH, CHARACTER_EXTRACTION_MAX_NAME_LENGTH, CHARACTER_EXTRACTION_MAX_SPECIES_LENGTH } from "./character-extraction.js";
24
25
  import { AiWritePlanManager, AI_USER_QUESTION_STATUSES, AI_WRITE_PLAN_STATUSES, aiWriteToolDescriptions, aiWriteToolLabels, aiWriteToolsUpdateSchema, answerAiUserQuestionSchema, resolveAiWritePlanMaxOperations } from "./ai-write-plans.js";
25
26
  import { CredentialVault } from "./credential-vault.js";
@@ -32,6 +33,7 @@ import { isOfficialGoogleVertexBaseUrl, parseGoogleServiceAccount } from "./goog
32
33
  import { HYBRID_SEARCH_TYPES, MAXIMUM_WORK_SEARCH_QUERY_LENGTH, readableHybridSearchTypes } from "./hybrid-search.js";
33
34
  import { applyImportFileHints, parseNovelText } from "./parser.js";
34
35
  import { MAX_CHAPTER_LINE_IDS } from "./chapter-annotation-anchor.js";
36
+ import { isChapterNumberTemplate } from "./chapter-title-numbering.js";
35
37
  import { aiConversationTaskTypes, attachmentPermissionModules, RECYCLE_BIN_RETENTION_DAYS, Store, versionedEntityTypes, WORK_AGENT_TOOL_IDS } from "./store.js";
36
38
  import { composeRoleplayStoredUserContent } from "./roleplay-turn.js";
37
39
  import { ROLEPLAY_MEMORY_CATEGORIES, ROLEPLAY_MEMORY_CERTAINTY, ROLEPLAY_MEMORY_IMPORTANCE, ROLEPLAY_MEMORY_STATUSES } from "./roleplay-memory.js";
@@ -485,6 +487,7 @@ const providerUpdateSchema = providerBaseSchema.partial().superRefine((value, ct
485
487
  const modelSchema = z.object({
486
488
  displayName: nonEmpty.max(200),
487
489
  modelId: nonEmpty.max(300),
490
+ modelKind: z.enum(AI_MODEL_KINDS).optional(),
488
491
  purposes: optionalStrings,
489
492
  contextNote: z.string().max(10_000).optional(),
490
493
  contextWindow: z.number().int().min(32_768, "模型上下文不能低于 32768 Token").max(2_000_000).optional(),
@@ -651,11 +654,38 @@ const workAiSettingsSchema = z.object({
651
654
  titleGenerationModelId: z.string().trim().max(200).optional(),
652
655
  imageToolModelId: identifier.nullable().optional()
653
656
  }).strict();
657
+ const semanticSearchSettingsSchema = z.object({
658
+ enabled: z.boolean().optional(),
659
+ embeddingModelId: identifier.nullable().optional(),
660
+ rerankModelId: identifier.nullable().optional(),
661
+ vectorDimension: z.number().int().min(1).max(65_536).optional(),
662
+ recallLimit: z.number().int().min(1).max(200).optional(),
663
+ resultLimit: z.number().int().min(1).max(100).optional(),
664
+ budgetTokens: z.number().int().min(256).max(100_000).optional(),
665
+ channelWeight: z.number().min(0.1).max(5).optional()
666
+ }).strict();
667
+ const semanticSearchRequestSchema = z.object({
668
+ query: z.string().trim().min(1).max(2_000),
669
+ types: z.array(z.enum(SEMANTIC_SOURCE_TYPES)).max(SEMANTIC_SOURCE_TYPES.length).optional(),
670
+ limit: z.number().int().min(1).max(100).optional(),
671
+ includeKeyword: z.boolean().optional(),
672
+ currentChapterId: identifier.optional(),
673
+ selection: z.string().max(4_000).optional()
674
+ }).strict();
675
+ const semanticSnapshotSchema = z.object({
676
+ query: z.string().trim().min(1).max(2_000),
677
+ entryIds: z.array(identifier).min(1).max(30),
678
+ scope: jsonObject.optional(),
679
+ conversationId: identifier.optional()
680
+ }).strict();
654
681
  const contextSchema = z.object({
655
682
  type: z.enum(["none", "selection", "chapter", "volume", "book", "settings-catalog", "entities"]),
656
683
  chapterId: identifier.optional(),
657
684
  volumeId: identifier.optional(),
658
685
  selection: z.string().max(200_000).optional(),
686
+ selectionStart: z.number().int().min(0).max(10_000_000).optional(),
687
+ selectionEnd: z.number().int().min(0).max(10_000_000).optional(),
688
+ writingChapterVersion: z.number().int().min(1).max(2_000_000_000).optional(),
659
689
  chapterIds: z.array(identifier).max(20).optional(),
660
690
  volumeIds: z.array(identifier).max(20).optional(),
661
691
  characterIds: optionalStrings,
@@ -663,6 +693,7 @@ const contextSchema = z.object({
663
693
  settingIds: optionalStrings,
664
694
  raceIds: optionalStrings,
665
695
  organizationIds: optionalStrings,
696
+ semanticSnapshotId: identifier.optional(),
666
697
  includeBookSummary: z.boolean().optional(),
667
698
  includeSettingInfo: z.boolean().optional()
668
699
  });
@@ -1021,7 +1052,7 @@ function redactAiCallContext(record, permissions) {
1021
1052
  const redactedScope = { ...scope };
1022
1053
  let restricted = false;
1023
1054
  if (permissions.prose === "none") {
1024
- for (const field of ["selection", "chapterId", "volumeId", "chapterIds", "includeBookSummary"]) {
1055
+ for (const field of ["selection", "selectionStart", "selectionEnd", "writingChapterVersion", "chapterId", "volumeId", "chapterIds", "includeBookSummary"]) {
1025
1056
  if (field in redactedScope) {
1026
1057
  delete redactedScope[field];
1027
1058
  restricted = true;
@@ -1977,10 +2008,13 @@ export function createRuntime(options) {
1977
2008
  });
1978
2009
  app.get("/api/works/:workId/chapter-annotations", (request, response) => {
1979
2010
  const pagination = parsePagination(request.query);
2011
+ const chapterId = request.query.chapterId === undefined ? undefined : parse(identifier, request.query.chapterId);
2012
+ const query = request.query.q === undefined ? undefined : parse(z.string().trim().max(100), request.query.q);
2013
+ const filters = { chapterId, query };
1980
2014
  const permissions = requestPermissions(request, String(request.params.workId));
1981
2015
  data(response, pagination
1982
- ? store.listWorkChapterAnnotationsPage(request.params.workId, pagination, readableChapterAnnotationKinds(permissions))
1983
- : store.listWorkChapterAnnotations(request.params.workId, readableChapterAnnotationKinds(permissions)));
2016
+ ? store.listWorkChapterAnnotationsPage(request.params.workId, pagination, readableChapterAnnotationKinds(permissions), filters)
2017
+ : store.listWorkChapterAnnotations(request.params.workId, readableChapterAnnotationKinds(permissions), filters));
1984
2018
  });
1985
2019
  app.post("/api/chapters/:chapterId/annotations", (request, response) => {
1986
2020
  const input = parse(z.object({
@@ -2017,6 +2051,12 @@ export function createRuntime(options) {
2017
2051
  z.object({ type: z.literal("move"), volumeId: identifier }).strict(),
2018
2052
  z.object({ type: z.literal("setType"), chapterType: chapterTypeSchema }).strict(),
2019
2053
  z.object({ type: z.literal("setAnalysisExclusion"), excludedFromAnalysis: z.boolean() }).strict(),
2054
+ z.object({
2055
+ type: z.literal("renumberTitles"),
2056
+ template: z.string().min(1).max(50).refine(isChapterNumberTemplate, "标题格式必须且只能包含一个 {n} 占位符,且不能包含换行或控制字符"),
2057
+ numberStyle: z.enum(["arabic", "chinese"]),
2058
+ startAt: z.number().int().min(1).max(999_999)
2059
+ }).strict(),
2020
2060
  z.object({ type: z.literal("delete") }).strict()
2021
2061
  ]);
2022
2062
  const input = parse(z.object({ chapters: selectedChapters, action }).strict(), request.body);
@@ -2846,6 +2886,12 @@ export function createRuntime(options) {
2846
2886
  data(response, { ...queued, queuedAt: new Date().toISOString() }, 202);
2847
2887
  });
2848
2888
  app.get("/api/works/:workId/ai-settings", (request, response) => data(response, store.getWorkAiSettings(request.params.workId)));
2889
+ app.get("/api/works/:workId/ai-settings/mcp-servers", (request, response) => {
2890
+ data(response, ai.getRemoteMcpSettings(request.params.workId));
2891
+ });
2892
+ app.put("/api/works/:workId/ai-settings/mcp-servers", async (request, response) => {
2893
+ data(response, await ai.updateRemoteMcpSettings(request.params.workId, request.body));
2894
+ });
2849
2895
  app.get("/api/works/:workId/ai-settings/usage", (request, response) => {
2850
2896
  const query = parse(aiUsageQuerySchema, request.query);
2851
2897
  data(response, ai.getWorkTokenUsage(request.params.workId, query.timezoneOffset));
@@ -2853,6 +2899,20 @@ export function createRuntime(options) {
2853
2899
  app.get("/api/works/:workId/ai-settings/relationship-search-index", (request, response) => {
2854
2900
  data(response, ai.getRelationshipSearchIndexStatus(request.params.workId));
2855
2901
  });
2902
+ app.get("/api/works/:workId/ai-settings/semantic-search-index", (request, response) => {
2903
+ data(response, ai.getSemanticSearchIndexStatus(request.params.workId));
2904
+ });
2905
+ app.patch("/api/works/:workId/ai-settings/semantic-search", async (request, response) => {
2906
+ data(response, await ai.updateSemanticSearchSettings(request.params.workId, parse(semanticSearchSettingsSchema, request.body)));
2907
+ });
2908
+ app.post("/api/works/:workId/ai-settings/semantic-search-index/sync", (request, response) => {
2909
+ parse(z.object({}).strict(), request.body ?? {});
2910
+ data(response, ai.syncSemanticSearchIndex(request.params.workId), 202);
2911
+ });
2912
+ app.post("/api/works/:workId/ai-settings/semantic-search-index/rebuild", (request, response) => {
2913
+ parse(z.object({}).strict(), request.body ?? {});
2914
+ data(response, ai.rebuildSemanticSearchIndex(request.params.workId), 202);
2915
+ });
2856
2916
  app.post("/api/works/:workId/ai-settings/relationship-search-index/sync", (request, response) => {
2857
2917
  const workId = request.params.workId;
2858
2918
  const result = ai.syncRelationshipSearchIndex(workId);
@@ -3032,12 +3092,24 @@ export function createRuntime(options) {
3032
3092
  ignoreContextWarning: z.boolean().optional()
3033
3093
  }).strict(), request.body ?? {});
3034
3094
  const conversation = store.getAiConversation(request.params.conversationId);
3095
+ const permissions = requestPermissions(request, String(conversation.workId));
3096
+ const writingSkillRequest = conversation.roleplayCharacter
3097
+ ? { skillName: null, instruction: input.instruction }
3098
+ : ai.resolveWritingSkillInstruction(input.instruction);
3099
+ if (writingSkillRequest.skillName && !canReadWorkModule(permissions, "prose")) {
3100
+ throw new AppError(403, "WORK_MODULE_READ_DENIED", "你没有读取当前章节正文以使用写作 Skill 的权限");
3101
+ }
3102
+ const citedInstruction = instructionWithCitations(writingSkillRequest.instruction, input.citations ?? []);
3103
+ const preparedScope = conversation.roleplayCharacter
3104
+ ? input.scope
3105
+ : ai.resolveWritingSkillScope(String(conversation.workId), "chat", input.instruction, input.scope);
3035
3106
  data(response, await ai.prepareConversationContext({
3036
3107
  conversationId: request.params.conversationId,
3037
3108
  workId: String(conversation.workId),
3038
3109
  modelId: input.modelId,
3039
- scope: input.scope,
3040
- instruction: instructionWithCitations(input.instruction, input.citations ?? [])
3110
+ scope: preparedScope,
3111
+ instruction: citedInstruction,
3112
+ skillInstruction: input.instruction
3041
3113
  }, { ignoreWarning: input.ignoreContextWarning === true }));
3042
3114
  });
3043
3115
  app.post("/api/ai-conversations/:conversationId/compact", async (request, response) => {
@@ -3226,6 +3298,9 @@ export function createRuntime(options) {
3226
3298
  const pagination = parsePagination(request.query);
3227
3299
  data(response, pagination ? ai.listWorkModelsPage(request.params.workId, pagination) : ai.listWorkModels(request.params.workId));
3228
3300
  });
3301
+ app.get("/api/works/:workId/semantic-models", (request, response) => {
3302
+ data(response, ai.listWorkSemanticModels(request.params.workId));
3303
+ });
3229
3304
  app.get("/api/works/:workId/task-defaults", (request, response) => {
3230
3305
  const pagination = parsePagination(request.query);
3231
3306
  data(response, pagination ? ai.listTaskDefaultsPage(request.params.workId, pagination) : ai.listTaskDefaults(request.params.workId));
@@ -3381,10 +3456,19 @@ export function createRuntime(options) {
3381
3456
  const storedUserContent = isRoleplay
3382
3457
  ? composeRoleplayStoredUserContent(sceneDirection, instructionText)
3383
3458
  : instructionText;
3384
- const resolvedInstruction = instructionWithCitations(instructionText, citations);
3459
+ const permissions = requestPermissions(request, request.params.workId);
3460
+ const writingSkillRequest = isRoleplay
3461
+ ? { skillName: null, instruction: instructionText }
3462
+ : ai.resolveWritingSkillInstruction(instructionText);
3463
+ const resolvedInstruction = instructionWithCitations(writingSkillRequest.instruction, citations);
3464
+ if (writingSkillRequest.skillName && !canReadWorkModule(permissions, "prose")) {
3465
+ throw new AppError(403, "WORK_MODULE_READ_DENIED", "你没有读取当前章节正文以使用写作 Skill 的权限");
3466
+ }
3467
+ const requestScope = isRoleplay
3468
+ ? input.scope
3469
+ : ai.resolveWritingSkillScope(request.params.workId, "chat", instructionText, input.scope);
3385
3470
  const mentionSource = [sceneDirection, resolvedInstruction].filter(Boolean).join("\n");
3386
3471
  const modelId = resolveConversationModelId(request.params.workId, conversationId, input.modelId);
3387
- const permissions = requestPermissions(request, request.params.workId);
3388
3472
  const controller = new AbortController();
3389
3473
  let streamRequestId = null;
3390
3474
  let streamRequestFinished = false;
@@ -3439,8 +3523,9 @@ export function createRuntime(options) {
3439
3523
  conversationId,
3440
3524
  workId: request.params.workId,
3441
3525
  modelId,
3442
- scope: input.scope,
3526
+ scope: requestScope,
3443
3527
  instruction: mentionSource,
3528
+ skillInstruction: instructionText,
3444
3529
  excludeConversationMessageId: input.currentMessageId
3445
3530
  }, { ignoreWarning: input.ignoreContextWarning === true });
3446
3531
  preparedConversation = redactAiConversation(store.getAiConversationSummary(conversationId), permissions);
@@ -3461,12 +3546,12 @@ export function createRuntime(options) {
3461
3546
  }
3462
3547
  }
3463
3548
  const resolvedScope = input.currentMessageId
3464
- ? input.scope
3549
+ ? requestScope
3465
3550
  : ai.resolveInstructionMentions({
3466
3551
  workId: request.params.workId,
3467
3552
  taskType: "chat",
3468
3553
  instruction: mentionSource,
3469
- scope: input.scope,
3554
+ scope: requestScope,
3470
3555
  conversationId
3471
3556
  });
3472
3557
  const mentionCharacterIds = [...new Set([
@@ -3485,11 +3570,12 @@ export function createRuntime(options) {
3485
3570
  content: storedUserContent,
3486
3571
  citations,
3487
3572
  ...(input.currentMessageId ? { existingMessageId: input.currentMessageId } : {}),
3488
- ...((modelId || mentionCharacterIds.length || mentionRaceIds.length || mentionOrganizationIds.length || input.imageAttachmentIds?.length) ? { metadata: {
3573
+ ...((modelId || mentionCharacterIds.length || mentionRaceIds.length || mentionOrganizationIds.length || input.imageAttachmentIds?.length || input.scope.semanticSnapshotId) ? { metadata: {
3489
3574
  ...(modelId ? { modelId } : {}),
3490
3575
  ...(mentionCharacterIds.length ? { mentionCharacterIds } : {}),
3491
3576
  ...(mentionRaceIds.length ? { mentionRaceIds } : {}),
3492
3577
  ...(mentionOrganizationIds.length ? { mentionOrganizationIds } : {}),
3578
+ ...(input.scope.semanticSnapshotId ? { semanticSnapshotId: input.scope.semanticSnapshotId } : {}),
3493
3579
  ...(input.imageAttachmentIds?.length ? { chatImageAttachmentIds: [...new Set(input.imageAttachmentIds)] } : {})
3494
3580
  } } : {})
3495
3581
  }
@@ -3504,6 +3590,15 @@ export function createRuntime(options) {
3504
3590
  }
3505
3591
  if (begun.request.status === "completed" && begun.assistantMessage) {
3506
3592
  const content = String(begun.assistantMessage.content ?? "");
3593
+ const assistantMetadata = begun.assistantMessage.metadata && typeof begun.assistantMessage.metadata === "object"
3594
+ ? begun.assistantMessage.metadata
3595
+ : {};
3596
+ const writingSuggestionId = typeof assistantMetadata.writingSuggestionId === "string"
3597
+ ? assistantMetadata.writingSuggestionId
3598
+ : "";
3599
+ const writingSuggestion = writingSuggestionId
3600
+ ? redactSuggestion(ai.getSuggestion(writingSuggestionId), permissions)
3601
+ : null;
3507
3602
  if (content)
3508
3603
  sendEvent("delta", { delta: content, replayed: true });
3509
3604
  sendEvent("complete", {
@@ -3511,7 +3606,8 @@ export function createRuntime(options) {
3511
3606
  conversationId,
3512
3607
  conversationTitle: store.getAiConversationSummary(conversationId).title,
3513
3608
  messageId: begun.assistantMessage.id,
3514
- messageCreatedAt: begun.assistantMessage.createdAt
3609
+ messageCreatedAt: begun.assistantMessage.createdAt,
3610
+ ...(writingSuggestion ? { writingSuggestion } : {})
3515
3611
  });
3516
3612
  }
3517
3613
  else {
@@ -3543,9 +3639,10 @@ export function createRuntime(options) {
3543
3639
  const suggestion = await ai.createStreamingChat({
3544
3640
  workId: request.params.workId,
3545
3641
  instruction: resolvedInstruction,
3642
+ skillInstruction: instructionText,
3546
3643
  ...(sceneDirection ? { sceneDirection } : {}),
3547
3644
  // 仍由生成路径基于原始范围持久化累计注入,保证预解析不会吞掉本轮自动命中。
3548
- scope: input.scope,
3645
+ scope: requestScope,
3549
3646
  signal: controller.signal,
3550
3647
  onToolCall: (toolCall, round) => sendEvent("tool_call", { ...toolCall, round }),
3551
3648
  onProcessStep: (step) => sendEvent("process_step", step),
@@ -3578,6 +3675,7 @@ export function createRuntime(options) {
3578
3675
  contextUsage: suggestion.contextUsage,
3579
3676
  conversationId,
3580
3677
  conversationTitle: suggestion.conversationTitle,
3678
+ ...(suggestion.action !== "note" ? { writingSuggestion: redactSuggestion(suggestion, permissions) } : {}),
3581
3679
  messageId: typeof suggestion.conversationMessage === "object" && suggestion.conversationMessage !== null
3582
3680
  ? suggestion.conversationMessage.id
3583
3681
  : undefined,
@@ -3640,6 +3738,11 @@ export function createRuntime(options) {
3640
3738
  });
3641
3739
  app.post("/api/suggestions/:suggestionId/accept", (request, response) => {
3642
3740
  const input = parse(z.object({ content: z.string().max(2_000_000).optional() }), request.body ?? {});
3741
+ const suggestion = ai.getSuggestion(request.params.suggestionId);
3742
+ const permissions = requestPermissions(request, String(suggestion.workId));
3743
+ if (!canWriteWorkModule(permissions, "prose")) {
3744
+ throw new AppError(403, "WORK_MODULE_WRITE_DENIED", "你没有将 AI 建议写入正文的权限");
3745
+ }
3643
3746
  data(response, ai.acceptSuggestion(request.params.suggestionId, input.content));
3644
3747
  });
3645
3748
  app.post("/api/suggestions/:suggestionId/reject", (request, response) => {
@@ -3668,6 +3771,27 @@ export function createRuntime(options) {
3668
3771
  conversationOwnerUserId: request.authUser?.userId
3669
3772
  }));
3670
3773
  });
3774
+ app.post("/api/works/:workId/semantic-search", async (request, response) => {
3775
+ const input = parse(semanticSearchRequestSchema, request.body);
3776
+ const permissions = requestPermissions(request, request.params.workId);
3777
+ const allowedTypes = readableHybridSearchTypes(permissions)
3778
+ .filter((type) => SEMANTIC_SOURCE_TYPES.includes(type));
3779
+ data(response, await ai.semanticSearchStory(request.params.workId, input.query, {
3780
+ allowedTypes,
3781
+ types: input.types,
3782
+ limit: input.limit,
3783
+ includeKeyword: input.includeKeyword,
3784
+ conversationOwnerUserId: request.authUser?.userId,
3785
+ currentChapterId: input.currentChapterId,
3786
+ selection: input.selection
3787
+ }));
3788
+ });
3789
+ app.post("/api/works/:workId/semantic-search/snapshots", (request, response) => {
3790
+ const input = parse(semanticSnapshotSchema, request.body);
3791
+ if (input.conversationId)
3792
+ assertRequestAiConversationOwner(request, input.conversationId);
3793
+ data(response, ai.createSemanticContextSnapshot(request.params.workId, input), 201);
3794
+ });
3671
3795
  app.head("/api/works/:workId/export", (request, response) => {
3672
3796
  parse(z.enum(["epub"]), request.query.format ?? "epub");
3673
3797
  store.getWork(request.params.workId);