@musnows/scriverse 0.9.4 → 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.
Files changed (45) hide show
  1. package/dist/ai-protocol.js +6 -0
  2. package/dist/ai-protocol.js.map +1 -1
  3. package/dist/ai-skills.js +134 -0
  4. package/dist/ai-skills.js.map +1 -0
  5. package/dist/ai-write-plans.js +2279 -0
  6. package/dist/ai-write-plans.js.map +1 -0
  7. package/dist/ai.js +2781 -154
  8. package/dist/ai.js.map +1 -1
  9. package/dist/app.js +328 -17
  10. package/dist/app.js.map +1 -1
  11. package/dist/chapter-annotation-anchor.js +327 -0
  12. package/dist/chapter-annotation-anchor.js.map +1 -0
  13. package/dist/chapter-title-numbering.js +56 -0
  14. package/dist/chapter-title-numbering.js.map +1 -0
  15. package/dist/database.js +478 -3
  16. package/dist/database.js.map +1 -1
  17. package/dist/public/ai-context-meter.js +3 -3
  18. package/dist/public/ai-interactive.js +483 -0
  19. package/dist/public/app.js +1991 -268
  20. package/dist/public/chapter-editor-behavior.js +40 -0
  21. package/dist/public/chapter-line-id-tracker.d.ts +25 -0
  22. package/dist/public/chapter-line-id-tracker.js +151 -0
  23. package/dist/public/index.html +79 -19
  24. package/dist/public/model-config.d.ts +1 -0
  25. package/dist/public/model-config.js +9 -4
  26. package/dist/public/styles.css +337 -57
  27. package/dist/public/theme-init.js +25 -1
  28. package/dist/remote-mcp.js +496 -0
  29. package/dist/remote-mcp.js.map +1 -0
  30. package/dist/roleplay-memory.js +53 -0
  31. package/dist/roleplay-memory.js.map +1 -0
  32. package/dist/security.js +4 -0
  33. package/dist/security.js.map +1 -1
  34. package/dist/semantic-search.js +225 -0
  35. package/dist/semantic-search.js.map +1 -0
  36. package/dist/skills/continue-writing/SKILL.md +23 -0
  37. package/dist/skills/polish-writing/SKILL.md +23 -0
  38. package/dist/store.js +695 -51
  39. package/dist/store.js.map +1 -1
  40. package/dist/ui-module-preload.js +27 -0
  41. package/dist/ui-module-preload.js.map +1 -0
  42. package/dist/user-auth.js +33 -1
  43. package/dist/user-auth.js.map +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +6 -1
package/dist/app.js CHANGED
@@ -1,3 +1,4 @@
1
+ import compression from "compression";
1
2
  import express from "express";
2
3
  import JSZip from "jszip";
3
4
  import multer from "multer";
@@ -16,10 +17,12 @@ import { DEFAULT_AI_CHAT_TAB_LIMIT } from "./ai-chat-tab-limit.js";
16
17
  import { MAX_AI_STREAM_IDLE_TIMEOUT_SECONDS, MIN_AI_STREAM_IDLE_TIMEOUT_SECONDS, normalizeAiStreamIdleTimeoutSeconds } from "./ai-stream-timeout.js";
17
18
  import { AttachmentStorage } from "./attachment-storage.js";
18
19
  import { attachmentDownloadFileName, inlineContentDisposition } from "./attachment-download.js";
19
- import { AiManager } from "./ai.js";
20
+ import { AI_MODEL_KINDS, AiManager } from "./ai.js";
20
21
  import { LiteLlmPriceCache } from "./ai-model-pricing.js";
21
22
  import { resolveMaxAgentToolCallLimit } from "./ai-tool-results.js";
23
+ import { SEMANTIC_SOURCE_TYPES } from "./semantic-search.js";
22
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";
25
+ import { AiWritePlanManager, AI_USER_QUESTION_STATUSES, AI_WRITE_PLAN_STATUSES, aiWriteToolDescriptions, aiWriteToolLabels, aiWriteToolsUpdateSchema, answerAiUserQuestionSchema, resolveAiWritePlanMaxOperations } from "./ai-write-plans.js";
23
26
  import { CredentialVault } from "./credential-vault.js";
24
27
  import { Database } from "./database.js";
25
28
  import { assertSafeDocxArchive } from "./docx-security.js";
@@ -29,8 +32,11 @@ import { AppError } from "./errors.js";
29
32
  import { isOfficialGoogleVertexBaseUrl, parseGoogleServiceAccount } from "./google-vertex-auth.js";
30
33
  import { HYBRID_SEARCH_TYPES, MAXIMUM_WORK_SEARCH_QUERY_LENGTH, readableHybridSearchTypes } from "./hybrid-search.js";
31
34
  import { applyImportFileHints, parseNovelText } from "./parser.js";
35
+ import { MAX_CHAPTER_LINE_IDS } from "./chapter-annotation-anchor.js";
36
+ import { isChapterNumberTemplate } from "./chapter-title-numbering.js";
32
37
  import { aiConversationTaskTypes, attachmentPermissionModules, RECYCLE_BIN_RETENTION_DAYS, Store, versionedEntityTypes, WORK_AGENT_TOOL_IDS } from "./store.js";
33
38
  import { composeRoleplayStoredUserContent } from "./roleplay-turn.js";
39
+ import { ROLEPLAY_MEMORY_CATEGORIES, ROLEPLAY_MEMORY_CERTAINTY, ROLEPLAY_MEMORY_IMPORTANCE, ROLEPLAY_MEMORY_STATUSES } from "./roleplay-memory.js";
34
40
  import { paginated, parsePagination } from "./pagination.js";
35
41
  import { normalizeUploadFileName } from "./utils.js";
36
42
  import { assertSafeAiEndpoint, assertSafeS3Endpoint, createApiRateLimitMiddleware, createAuthenticationRateLimitMiddleware, createBasicAuthMiddleware, createCaptchaRateLimitMiddleware, createExpensiveApiRateLimitMiddleware, createSameOriginMiddleware, createSecurityHeadersMiddleware, createUploadRateLimitMiddleware, enforceCaseInsensitiveRouting, normalizeApiPath, resolveTrustProxySetting, verifySetupToken } from "./security.js";
@@ -50,6 +56,7 @@ import { canReadWorkModule, canWriteWorkModule, chapterAnnotationPermissionModul
50
56
  import { CollaborationPresence, editorPageKey, entityEditorPageKey, modulePageKey, presencePageKinds } from "./collaboration-presence.js";
51
57
  import { PresenceStore } from "./presence-store.js";
52
58
  import { analysisTaskReadModules, clearSessionCookie, createCliApiScopeMiddleware, createUserSessionMiddleware, createWorkAuthorizationMiddleware, relationshipAnalysisReadModules, setSessionCookie, UserAuthService } from "./user-auth.js";
59
+ import { extractStaticModuleImports, injectModulePreloads } from "./ui-module-preload.js";
53
60
  const chapterAnnotationKinds = ["note", "todo"];
54
61
  function readableChapterAnnotationKinds(permissions) {
55
62
  return chapterAnnotationKinds.filter((kind) => canReadWorkModule(permissions, chapterAnnotationPermissionModule(kind)));
@@ -62,6 +69,23 @@ const optionalStrings = z.array(z.string()).optional();
62
69
  const jsonObject = z.record(z.string(), z.unknown());
63
70
  const chapterTypeSchema = z.enum(["正文", "设定", "作者的话", "其他"]);
64
71
  const aiConversationTaskTypeSchema = z.enum(aiConversationTaskTypes);
72
+ const roleplayMemoryInputSchema = z.object({
73
+ category: z.enum(ROLEPLAY_MEMORY_CATEGORIES),
74
+ content: z.string().trim().min(1).max(2_000),
75
+ importance: z.enum(ROLEPLAY_MEMORY_IMPORTANCE).optional(),
76
+ certainty: z.enum(ROLEPLAY_MEMORY_CERTAINTY).optional(),
77
+ isPinned: z.boolean().optional()
78
+ }).strict();
79
+ const roleplayMemoryListQuerySchema = z.object({
80
+ q: z.string().trim().max(200).optional(),
81
+ categories: z.preprocess((value) => typeof value === "string" ? value.split(",").filter(Boolean) : value, z.array(z.enum(ROLEPLAY_MEMORY_CATEGORIES)).max(ROLEPLAY_MEMORY_CATEGORIES.length).optional()),
82
+ statuses: z.preprocess((value) => typeof value === "string" ? value.split(",").filter(Boolean) : value, z.array(z.enum(ROLEPLAY_MEMORY_STATUSES)).max(ROLEPLAY_MEMORY_STATUSES.length).optional()),
83
+ cursor: z.coerce.number().int().min(0).max(100_000).optional(),
84
+ limit: z.coerce.number().int().min(1).max(100).optional()
85
+ }).strict();
86
+ const roleplayMemoryUpdateSchema = roleplayMemoryInputSchema.partial().extend({
87
+ expectedVersion: z.number().int().min(1)
88
+ }).strict().refine((value) => Object.keys(value).some((key) => key !== "expectedVersion"), "至少提供一个要修改的字段");
65
89
  const versionedEntityTypeSchema = z.enum(versionedEntityTypes);
66
90
  const attachmentPermissionModuleSchema = z.enum(attachmentPermissionModules);
67
91
  const maximumImportedTextLength = 20_000_000;
@@ -211,7 +235,9 @@ const workSchema = z.object({
211
235
  description: z.string().max(10_000).optional(),
212
236
  language: z.string().max(30).optional(),
213
237
  coverUrl: z.string().url().nullable().optional(),
214
- tags: optionalStrings
238
+ tags: optionalStrings,
239
+ editorAutoIndentEnabled: z.boolean().optional(),
240
+ editorTypewriterModeEnabled: z.boolean().optional()
215
241
  });
216
242
  const workOfflineAccessSchema = z.object({ enabled: z.boolean() }).strict();
217
243
  const settingSchema = z.object({
@@ -461,6 +487,7 @@ const providerUpdateSchema = providerBaseSchema.partial().superRefine((value, ct
461
487
  const modelSchema = z.object({
462
488
  displayName: nonEmpty.max(200),
463
489
  modelId: nonEmpty.max(300),
490
+ modelKind: z.enum(AI_MODEL_KINDS).optional(),
464
491
  purposes: optionalStrings,
465
492
  contextNote: z.string().max(10_000).optional(),
466
493
  contextWindow: z.number().int().min(32_768, "模型上下文不能低于 32768 Token").max(2_000_000).optional(),
@@ -627,11 +654,38 @@ const workAiSettingsSchema = z.object({
627
654
  titleGenerationModelId: z.string().trim().max(200).optional(),
628
655
  imageToolModelId: identifier.nullable().optional()
629
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();
630
681
  const contextSchema = z.object({
631
682
  type: z.enum(["none", "selection", "chapter", "volume", "book", "settings-catalog", "entities"]),
632
683
  chapterId: identifier.optional(),
633
684
  volumeId: identifier.optional(),
634
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(),
635
689
  chapterIds: z.array(identifier).max(20).optional(),
636
690
  volumeIds: z.array(identifier).max(20).optional(),
637
691
  characterIds: optionalStrings,
@@ -639,6 +693,7 @@ const contextSchema = z.object({
639
693
  settingIds: optionalStrings,
640
694
  raceIds: optionalStrings,
641
695
  organizationIds: optionalStrings,
696
+ semanticSnapshotId: identifier.optional(),
642
697
  includeBookSummary: z.boolean().optional(),
643
698
  includeSettingInfo: z.boolean().optional()
644
699
  });
@@ -997,7 +1052,7 @@ function redactAiCallContext(record, permissions) {
997
1052
  const redactedScope = { ...scope };
998
1053
  let restricted = false;
999
1054
  if (permissions.prose === "none") {
1000
- for (const field of ["selection", "chapterId", "volumeId", "chapterIds", "includeBookSummary"]) {
1055
+ for (const field of ["selection", "selectionStart", "selectionEnd", "writingChapterVersion", "chapterId", "volumeId", "chapterIds", "includeBookSummary"]) {
1001
1056
  if (field in redactedScope) {
1002
1057
  delete redactedScope[field];
1003
1058
  restricted = true;
@@ -1340,6 +1395,15 @@ export function createRuntime(options) {
1340
1395
  liteLlmPriceCache,
1341
1396
  allowPrivateAiEndpoints: options.security?.allowPrivateAiEndpoints === true
1342
1397
  });
1398
+ // AI 可写工具与审批工作流:计划创建只能由侧边栏 AI 发起,确认入口只接收审批 ID。
1399
+ const aiWritePlanManager = new AiWritePlanManager({
1400
+ database,
1401
+ store,
1402
+ auth,
1403
+ resolveAnalysisTask: (workId, input) => ai.resolveTaskInput(workId, input),
1404
+ startAnalysisTask: (workId, input) => store.createTask(workId, input)
1405
+ });
1406
+ ai.attachWritePlanManager(aiWritePlanManager);
1343
1407
  const app = express();
1344
1408
  enforceCaseInsensitiveRouting(app);
1345
1409
  const upload = multer({
@@ -1905,7 +1969,7 @@ export function createRuntime(options) {
1905
1969
  });
1906
1970
  app.get("/api/chapters/:chapterId", (request, response) => data(response, store.getChapter(request.params.chapterId)));
1907
1971
  app.patch("/api/chapters/:chapterId", (request, response) => {
1908
- const input = parse(z.object({ title: nonEmpty.max(300).optional(), content: z.string().max(2_000_000).optional(), excludedFromAnalysis: z.boolean().optional(), chapterType: chapterTypeSchema.optional(), source: z.enum(["manual", "auto"]).optional(), changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1972
+ const input = parse(z.object({ title: nonEmpty.max(300).optional(), content: z.string().max(2_000_000).optional(), lineIds: z.array(z.union([identifier, z.null()])).max(MAX_CHAPTER_LINE_IDS).optional(), excludedFromAnalysis: z.boolean().optional(), chapterType: chapterTypeSchema.optional(), source: z.enum(["manual", "auto"]).optional(), changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1909
1973
  const { source, changeNote, expectedVersionNo, ...chapterInput } = input;
1910
1974
  const chapter = store.saveChapter(request.params.chapterId, chapterInput, source ?? "manual", null, changeNote, expectedVersionNo);
1911
1975
  publishEditorChange(String(chapter.workId), String(chapter.id));
@@ -1944,10 +2008,13 @@ export function createRuntime(options) {
1944
2008
  });
1945
2009
  app.get("/api/works/:workId/chapter-annotations", (request, response) => {
1946
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 };
1947
2014
  const permissions = requestPermissions(request, String(request.params.workId));
1948
2015
  data(response, pagination
1949
- ? store.listWorkChapterAnnotationsPage(request.params.workId, pagination, readableChapterAnnotationKinds(permissions))
1950
- : 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));
1951
2018
  });
1952
2019
  app.post("/api/chapters/:chapterId/annotations", (request, response) => {
1953
2020
  const input = parse(z.object({
@@ -1984,6 +2051,12 @@ export function createRuntime(options) {
1984
2051
  z.object({ type: z.literal("move"), volumeId: identifier }).strict(),
1985
2052
  z.object({ type: z.literal("setType"), chapterType: chapterTypeSchema }).strict(),
1986
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(),
1987
2060
  z.object({ type: z.literal("delete") }).strict()
1988
2061
  ]);
1989
2062
  const input = parse(z.object({ chapters: selectedChapters, action }).strict(), request.body);
@@ -2813,6 +2886,12 @@ export function createRuntime(options) {
2813
2886
  data(response, { ...queued, queuedAt: new Date().toISOString() }, 202);
2814
2887
  });
2815
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
+ });
2816
2895
  app.get("/api/works/:workId/ai-settings/usage", (request, response) => {
2817
2896
  const query = parse(aiUsageQuerySchema, request.query);
2818
2897
  data(response, ai.getWorkTokenUsage(request.params.workId, query.timezoneOffset));
@@ -2820,6 +2899,20 @@ export function createRuntime(options) {
2820
2899
  app.get("/api/works/:workId/ai-settings/relationship-search-index", (request, response) => {
2821
2900
  data(response, ai.getRelationshipSearchIndexStatus(request.params.workId));
2822
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
+ });
2823
2916
  app.post("/api/works/:workId/ai-settings/relationship-search-index/sync", (request, response) => {
2824
2917
  const workId = request.params.workId;
2825
2918
  const result = ai.syncRelationshipSearchIndex(workId);
@@ -2875,6 +2968,16 @@ export function createRuntime(options) {
2875
2968
  data(response, store.createAiConversation(request.params.workId, input.title, input.taskType), 201);
2876
2969
  });
2877
2970
  app.use("/api/ai-conversations/:conversationId", (request, _response, next) => {
2971
+ const sourceId = typeof request.query.roleplayMemorySourceId === "string"
2972
+ ? request.query.roleplayMemorySourceId
2973
+ : null;
2974
+ const messageId = typeof request.query.messageId === "string" ? request.query.messageId : null;
2975
+ if (["GET", "HEAD"].includes(request.method)
2976
+ && request.authUser?.role === "admin"
2977
+ && sourceId !== null
2978
+ && messageId !== null
2979
+ && store.isRoleplayMemorySourceTarget(sourceId, request.params.conversationId, messageId))
2980
+ return next();
2878
2981
  assertRequestAiConversationOwner(request, request.params.conversationId);
2879
2982
  next();
2880
2983
  });
@@ -2942,6 +3045,20 @@ export function createRuntime(options) {
2942
3045
  const permissions = requestPermissions(request, String(updated.workId));
2943
3046
  data(response, redactAiConversation(updated, permissions));
2944
3047
  });
3048
+ app.get("/api/characters/:characterId/roleplay-memories", (request, response) => {
3049
+ const query = parse(roleplayMemoryListQuerySchema, request.query);
3050
+ data(response, store.listRoleplayMemories(request.params.characterId, {
3051
+ query: query.q,
3052
+ categories: query.categories,
3053
+ statuses: query.statuses,
3054
+ cursor: query.cursor,
3055
+ limit: query.limit
3056
+ }));
3057
+ });
3058
+ app.post("/api/characters/:characterId/roleplay-memories", (request, response) => {
3059
+ const input = parse(roleplayMemoryInputSchema, request.body);
3060
+ data(response, store.createRoleplayMemory(request.params.characterId, input), 201);
3061
+ });
2945
3062
  app.post("/api/ai-conversations/:conversationId/messages", (request, response) => {
2946
3063
  const input = parse(z.object({
2947
3064
  role: z.enum(["user", "assistant"]),
@@ -2975,12 +3092,24 @@ export function createRuntime(options) {
2975
3092
  ignoreContextWarning: z.boolean().optional()
2976
3093
  }).strict(), request.body ?? {});
2977
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);
2978
3106
  data(response, await ai.prepareConversationContext({
2979
3107
  conversationId: request.params.conversationId,
2980
3108
  workId: String(conversation.workId),
2981
3109
  modelId: input.modelId,
2982
- scope: input.scope,
2983
- instruction: instructionWithCitations(input.instruction, input.citations ?? [])
3110
+ scope: preparedScope,
3111
+ instruction: citedInstruction,
3112
+ skillInstruction: input.instruction
2984
3113
  }, { ignoreWarning: input.ignoreContextWarning === true }));
2985
3114
  });
2986
3115
  app.post("/api/ai-conversations/:conversationId/compact", async (request, response) => {
@@ -3004,6 +3133,130 @@ export function createRuntime(options) {
3004
3133
  })
3005
3134
  });
3006
3135
  });
3136
+ app.patch("/api/roleplay-memories/:memoryId", (request, response) => {
3137
+ const input = parse(roleplayMemoryUpdateSchema, request.body);
3138
+ data(response, store.updateRoleplayMemory(request.params.memoryId, input));
3139
+ });
3140
+ app.delete("/api/roleplay-memories/:memoryId", (request, response) => {
3141
+ const input = parse(z.object({ expectedVersion: z.number().int().min(1) }).strict(), request.body);
3142
+ data(response, store.setRoleplayMemoryArchived(request.params.memoryId, true, input.expectedVersion));
3143
+ });
3144
+ app.post("/api/roleplay-memories/:memoryId/restore", (request, response) => {
3145
+ const input = parse(z.object({ expectedVersion: z.number().int().min(1) }).strict(), request.body);
3146
+ data(response, store.setRoleplayMemoryArchived(request.params.memoryId, false, input.expectedVersion));
3147
+ });
3148
+ // ---------------------------------------------------------------- AI 可写工具与审批中心
3149
+ const planViewer = () => {
3150
+ const actor = currentRequestActor();
3151
+ return actor ? { userId: actor.userId, role: actor.role } : null;
3152
+ };
3153
+ app.get("/api/works/:workId/ai/tools", (request, response) => {
3154
+ store.getWork(request.params.workId);
3155
+ data(response, {
3156
+ tools: aiWritePlanManager.getEnabledTools(request.params.workId),
3157
+ labels: aiWriteToolLabels,
3158
+ descriptions: aiWriteToolDescriptions,
3159
+ maxOperations: resolveAiWritePlanMaxOperations(process.env.AI_WRITE_PLAN_MAX_OPERATIONS)
3160
+ });
3161
+ });
3162
+ app.put("/api/works/:workId/ai/tools", (request, response) => {
3163
+ const input = parse(aiWriteToolsUpdateSchema, request.body ?? {});
3164
+ data(response, {
3165
+ tools: aiWritePlanManager.updateToolSettings(request.params.workId, input.tools, currentRequestActor()?.userId ?? null)
3166
+ });
3167
+ });
3168
+ app.get("/api/works/:workId/ai/write-plans", (request, response) => {
3169
+ const status = typeof request.query.status === "string"
3170
+ ? parse(z.enum(AI_WRITE_PLAN_STATUSES), request.query.status)
3171
+ : undefined;
3172
+ const limit = Number.parseInt(typeof request.query.limit === "string" ? request.query.limit : "", 10);
3173
+ data(response, {
3174
+ plans: aiWritePlanManager.listPlansForWork(request.params.workId, planViewer(), {
3175
+ status,
3176
+ ...(Number.isFinite(limit) ? { limit } : {})
3177
+ })
3178
+ });
3179
+ });
3180
+ app.get("/api/works/:workId/ai/write-plans/:planId", (request, response) => {
3181
+ data(response, aiWritePlanManager.getPlanDetail(request.params.planId, request.params.workId, planViewer()));
3182
+ });
3183
+ app.post("/api/works/:workId/ai/write-plans/:planId/confirm", async (request, response) => {
3184
+ data(response, await aiWritePlanManager.confirmPlan(request.params.planId, request.params.workId, planViewer()));
3185
+ });
3186
+ app.post("/api/works/:workId/ai/write-plans/:planId/reject", (request, response) => {
3187
+ data(response, aiWritePlanManager.rejectPlan(request.params.planId, request.params.workId, planViewer()));
3188
+ });
3189
+ app.post("/api/works/:workId/ai/write-plans/:planId/undo", (request, response) => {
3190
+ data(response, aiWritePlanManager.createUndoPlan(request.params.planId, request.params.workId, planViewer()), 201);
3191
+ });
3192
+ app.get("/api/works/:workId/ai/questions", (request, response) => {
3193
+ const conversationId = typeof request.query.conversationId === "string"
3194
+ ? parse(identifier, request.query.conversationId)
3195
+ : undefined;
3196
+ const status = typeof request.query.status === "string"
3197
+ ? parse(z.enum(AI_USER_QUESTION_STATUSES), request.query.status)
3198
+ : undefined;
3199
+ const limit = Number.parseInt(typeof request.query.limit === "string" ? request.query.limit : "", 10);
3200
+ data(response, {
3201
+ questions: aiWritePlanManager.listQuestions(request.params.workId, planViewer(), {
3202
+ ...(conversationId !== undefined ? { conversationId } : {}),
3203
+ ...(status !== undefined ? { status } : {}),
3204
+ ...(Number.isFinite(limit) ? { limit } : {})
3205
+ })
3206
+ });
3207
+ });
3208
+ app.get("/api/works/:workId/ai/questions/:questionId", (request, response) => {
3209
+ data(response, aiWritePlanManager.getQuestion(request.params.questionId, request.params.workId, planViewer()));
3210
+ });
3211
+ const resumeQuestionWorkflow = async (questionId, workId, viewer) => {
3212
+ const continuation = aiWritePlanManager.claimQuestionContinuation(questionId, workId, viewer);
3213
+ if (!continuation)
3214
+ return;
3215
+ try {
3216
+ const conversationId = typeof continuation.conversationId === "string" ? continuation.conversationId : "";
3217
+ if (!conversationId)
3218
+ throw new AppError(409, "AI_QUESTION_CONTINUATION_MISSING", "提问缺少可恢复的对话状态");
3219
+ const scope = parse(contextSchema, continuation.scope ?? { type: "none" });
3220
+ const resumed = await ai.resumeUserQuestion({
3221
+ questionId,
3222
+ workId,
3223
+ conversationId,
3224
+ scope,
3225
+ status: String(continuation.status ?? "rejected"),
3226
+ answerText: String(continuation.answerText ?? ""),
3227
+ selectedOptionLabel: typeof continuation.selectedOptionLabel === "string" ? continuation.selectedOptionLabel : null,
3228
+ supplementalAnswer: typeof continuation.customAnswer === "string" ? continuation.customAnswer : "",
3229
+ ...(typeof continuation.modelId === "string" && continuation.modelId ? { modelId: continuation.modelId } : {}),
3230
+ ...(typeof continuation.toolCallId === "string" && continuation.toolCallId ? { toolCallId: continuation.toolCallId } : {}),
3231
+ ...(typeof continuation.assistantMessageRequestId === "string" && continuation.assistantMessageRequestId
3232
+ ? { assistantMessageRequestId: continuation.assistantMessageRequestId }
3233
+ : {}),
3234
+ ...(continuation.questionView && typeof continuation.questionView === "object" && !Array.isArray(continuation.questionView)
3235
+ ? { questionView: continuation.questionView }
3236
+ : {}),
3237
+ ...(typeof continuation.round === "number" ? { round: continuation.round } : {}),
3238
+ ...(Array.isArray(continuation.toolMessages) ? { toolMessages: continuation.toolMessages } : {})
3239
+ });
3240
+ aiWritePlanManager.finishQuestionContinuation(questionId, { callId: resumed.callId ?? null, completed: true });
3241
+ }
3242
+ catch (error) {
3243
+ aiWritePlanManager.finishQuestionContinuation(questionId, { message: error instanceof Error ? error.message : "恢复失败" }, true);
3244
+ throw error;
3245
+ }
3246
+ };
3247
+ app.post("/api/works/:workId/ai/questions/:questionId/answer", async (request, response) => {
3248
+ const input = parse(answerAiUserQuestionSchema, request.body ?? {});
3249
+ const viewer = planViewer();
3250
+ aiWritePlanManager.answerQuestion(request.params.questionId, request.params.workId, viewer, { ...(input.selectedOption !== undefined ? { selectedOption: input.selectedOption } : {}), ...(input.customAnswer !== undefined ? { customAnswer: input.customAnswer } : {}) });
3251
+ await resumeQuestionWorkflow(request.params.questionId, request.params.workId, viewer);
3252
+ data(response, aiWritePlanManager.getQuestion(request.params.questionId, request.params.workId, viewer));
3253
+ });
3254
+ app.post("/api/works/:workId/ai/questions/:questionId/reject", async (request, response) => {
3255
+ const viewer = planViewer();
3256
+ aiWritePlanManager.rejectQuestion(request.params.questionId, request.params.workId, viewer);
3257
+ await resumeQuestionWorkflow(request.params.questionId, request.params.workId, viewer);
3258
+ data(response, aiWritePlanManager.getQuestion(request.params.questionId, request.params.workId, viewer));
3259
+ });
3007
3260
  app.get("/api/works/:workId/providers", (request, response) => {
3008
3261
  store.getWork(request.params.workId);
3009
3262
  data(response, ai.listProviders());
@@ -3045,6 +3298,9 @@ export function createRuntime(options) {
3045
3298
  const pagination = parsePagination(request.query);
3046
3299
  data(response, pagination ? ai.listWorkModelsPage(request.params.workId, pagination) : ai.listWorkModels(request.params.workId));
3047
3300
  });
3301
+ app.get("/api/works/:workId/semantic-models", (request, response) => {
3302
+ data(response, ai.listWorkSemanticModels(request.params.workId));
3303
+ });
3048
3304
  app.get("/api/works/:workId/task-defaults", (request, response) => {
3049
3305
  const pagination = parsePagination(request.query);
3050
3306
  data(response, pagination ? ai.listTaskDefaultsPage(request.params.workId, pagination) : ai.listTaskDefaults(request.params.workId));
@@ -3200,10 +3456,19 @@ export function createRuntime(options) {
3200
3456
  const storedUserContent = isRoleplay
3201
3457
  ? composeRoleplayStoredUserContent(sceneDirection, instructionText)
3202
3458
  : instructionText;
3203
- 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);
3204
3470
  const mentionSource = [sceneDirection, resolvedInstruction].filter(Boolean).join("\n");
3205
3471
  const modelId = resolveConversationModelId(request.params.workId, conversationId, input.modelId);
3206
- const permissions = requestPermissions(request, request.params.workId);
3207
3472
  const controller = new AbortController();
3208
3473
  let streamRequestId = null;
3209
3474
  let streamRequestFinished = false;
@@ -3258,8 +3523,9 @@ export function createRuntime(options) {
3258
3523
  conversationId,
3259
3524
  workId: request.params.workId,
3260
3525
  modelId,
3261
- scope: input.scope,
3526
+ scope: requestScope,
3262
3527
  instruction: mentionSource,
3528
+ skillInstruction: instructionText,
3263
3529
  excludeConversationMessageId: input.currentMessageId
3264
3530
  }, { ignoreWarning: input.ignoreContextWarning === true });
3265
3531
  preparedConversation = redactAiConversation(store.getAiConversationSummary(conversationId), permissions);
@@ -3280,12 +3546,12 @@ export function createRuntime(options) {
3280
3546
  }
3281
3547
  }
3282
3548
  const resolvedScope = input.currentMessageId
3283
- ? input.scope
3549
+ ? requestScope
3284
3550
  : ai.resolveInstructionMentions({
3285
3551
  workId: request.params.workId,
3286
3552
  taskType: "chat",
3287
3553
  instruction: mentionSource,
3288
- scope: input.scope,
3554
+ scope: requestScope,
3289
3555
  conversationId
3290
3556
  });
3291
3557
  const mentionCharacterIds = [...new Set([
@@ -3304,11 +3570,12 @@ export function createRuntime(options) {
3304
3570
  content: storedUserContent,
3305
3571
  citations,
3306
3572
  ...(input.currentMessageId ? { existingMessageId: input.currentMessageId } : {}),
3307
- ...((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: {
3308
3574
  ...(modelId ? { modelId } : {}),
3309
3575
  ...(mentionCharacterIds.length ? { mentionCharacterIds } : {}),
3310
3576
  ...(mentionRaceIds.length ? { mentionRaceIds } : {}),
3311
3577
  ...(mentionOrganizationIds.length ? { mentionOrganizationIds } : {}),
3578
+ ...(input.scope.semanticSnapshotId ? { semanticSnapshotId: input.scope.semanticSnapshotId } : {}),
3312
3579
  ...(input.imageAttachmentIds?.length ? { chatImageAttachmentIds: [...new Set(input.imageAttachmentIds)] } : {})
3313
3580
  } } : {})
3314
3581
  }
@@ -3323,6 +3590,15 @@ export function createRuntime(options) {
3323
3590
  }
3324
3591
  if (begun.request.status === "completed" && begun.assistantMessage) {
3325
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;
3326
3602
  if (content)
3327
3603
  sendEvent("delta", { delta: content, replayed: true });
3328
3604
  sendEvent("complete", {
@@ -3330,7 +3606,8 @@ export function createRuntime(options) {
3330
3606
  conversationId,
3331
3607
  conversationTitle: store.getAiConversationSummary(conversationId).title,
3332
3608
  messageId: begun.assistantMessage.id,
3333
- messageCreatedAt: begun.assistantMessage.createdAt
3609
+ messageCreatedAt: begun.assistantMessage.createdAt,
3610
+ ...(writingSuggestion ? { writingSuggestion } : {})
3334
3611
  });
3335
3612
  }
3336
3613
  else {
@@ -3356,12 +3633,16 @@ export function createRuntime(options) {
3356
3633
  const currentMessageId = String(begun.userMessage?.id ?? input.currentMessageId ?? "");
3357
3634
  if (begun.userMessage)
3358
3635
  sendEvent("user_message", { message: redactAiConversationMessage(begun.userMessage, permissions) });
3636
+ if (aiWritePlanManager.latestPendingQuestion(conversationId)) {
3637
+ throw new AppError(409, "AI_QUESTION_PENDING", "当前对话仍有待回答问题,请先回答或拒绝后再继续");
3638
+ }
3359
3639
  const suggestion = await ai.createStreamingChat({
3360
3640
  workId: request.params.workId,
3361
3641
  instruction: resolvedInstruction,
3642
+ skillInstruction: instructionText,
3362
3643
  ...(sceneDirection ? { sceneDirection } : {}),
3363
3644
  // 仍由生成路径基于原始范围持久化累计注入,保证预解析不会吞掉本轮自动命中。
3364
- scope: input.scope,
3645
+ scope: requestScope,
3365
3646
  signal: controller.signal,
3366
3647
  onToolCall: (toolCall, round) => sendEvent("tool_call", { ...toolCall, round }),
3367
3648
  onProcessStep: (step) => sendEvent("process_step", step),
@@ -3394,6 +3675,7 @@ export function createRuntime(options) {
3394
3675
  contextUsage: suggestion.contextUsage,
3395
3676
  conversationId,
3396
3677
  conversationTitle: suggestion.conversationTitle,
3678
+ ...(suggestion.action !== "note" ? { writingSuggestion: redactSuggestion(suggestion, permissions) } : {}),
3397
3679
  messageId: typeof suggestion.conversationMessage === "object" && suggestion.conversationMessage !== null
3398
3680
  ? suggestion.conversationMessage.id
3399
3681
  : undefined,
@@ -3456,6 +3738,11 @@ export function createRuntime(options) {
3456
3738
  });
3457
3739
  app.post("/api/suggestions/:suggestionId/accept", (request, response) => {
3458
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
+ }
3459
3746
  data(response, ai.acceptSuggestion(request.params.suggestionId, input.content));
3460
3747
  });
3461
3748
  app.post("/api/suggestions/:suggestionId/reject", (request, response) => {
@@ -3484,6 +3771,27 @@ export function createRuntime(options) {
3484
3771
  conversationOwnerUserId: request.authUser?.userId
3485
3772
  }));
3486
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
+ });
3487
3795
  app.head("/api/works/:workId/export", (request, response) => {
3488
3796
  parse(z.enum(["epub"]), request.query.format ?? "epub");
3489
3797
  store.getWork(request.params.workId);
@@ -3540,11 +3848,14 @@ export function createRuntime(options) {
3540
3848
  });
3541
3849
  if (options.serveUi ?? true) {
3542
3850
  const publicPath = options.publicPath ?? join(process.cwd(), "src", "public");
3851
+ const publicApplicationModuleImports = extractStaticModuleImports(readFileSync(join(publicPath, "app.js"), "utf8"));
3852
+ // API 与 SSE 路由已经在此前注册;压缩只作用于页面和静态资源,避免缓冲流式响应。
3853
+ app.use(compression());
3543
3854
  // index.html 按登录态动态下发:未登录时注入 login-route 类,首帧直接渲染登录页;
3544
3855
  // 已登录时保持骨架屏,由前端恢复会话后进入工作台,避免两种闪烁。
3545
3856
  const sendIndexHtml = (request, response) => {
3546
3857
  const authenticated = options.disableUserAuth === true || auth.authenticate(request) !== null;
3547
- let html = readFileSync(join(publicPath, "index.html"), "utf8");
3858
+ let html = injectModulePreloads(readFileSync(join(publicPath, "index.html"), "utf8"), publicApplicationModuleImports);
3548
3859
  if (!authenticated)
3549
3860
  html = html.replace('<html lang="zh-CN">', '<html lang="zh-CN" class="login-route">');
3550
3861
  if (options.disableUserAuth === true) {