@musnows/scriverse 0.9.4 → 0.9.5

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
@@ -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";
@@ -20,6 +21,7 @@ import { AiManager } from "./ai.js";
20
21
  import { LiteLlmPriceCache } from "./ai-model-pricing.js";
21
22
  import { resolveMaxAgentToolCallLimit } from "./ai-tool-results.js";
22
23
  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
+ import { AiWritePlanManager, AI_USER_QUESTION_STATUSES, AI_WRITE_PLAN_STATUSES, aiWriteToolDescriptions, aiWriteToolLabels, aiWriteToolsUpdateSchema, answerAiUserQuestionSchema, resolveAiWritePlanMaxOperations } from "./ai-write-plans.js";
23
25
  import { CredentialVault } from "./credential-vault.js";
24
26
  import { Database } from "./database.js";
25
27
  import { assertSafeDocxArchive } from "./docx-security.js";
@@ -29,8 +31,10 @@ import { AppError } from "./errors.js";
29
31
  import { isOfficialGoogleVertexBaseUrl, parseGoogleServiceAccount } from "./google-vertex-auth.js";
30
32
  import { HYBRID_SEARCH_TYPES, MAXIMUM_WORK_SEARCH_QUERY_LENGTH, readableHybridSearchTypes } from "./hybrid-search.js";
31
33
  import { applyImportFileHints, parseNovelText } from "./parser.js";
34
+ import { MAX_CHAPTER_LINE_IDS } from "./chapter-annotation-anchor.js";
32
35
  import { aiConversationTaskTypes, attachmentPermissionModules, RECYCLE_BIN_RETENTION_DAYS, Store, versionedEntityTypes, WORK_AGENT_TOOL_IDS } from "./store.js";
33
36
  import { composeRoleplayStoredUserContent } from "./roleplay-turn.js";
37
+ import { ROLEPLAY_MEMORY_CATEGORIES, ROLEPLAY_MEMORY_CERTAINTY, ROLEPLAY_MEMORY_IMPORTANCE, ROLEPLAY_MEMORY_STATUSES } from "./roleplay-memory.js";
34
38
  import { paginated, parsePagination } from "./pagination.js";
35
39
  import { normalizeUploadFileName } from "./utils.js";
36
40
  import { assertSafeAiEndpoint, assertSafeS3Endpoint, createApiRateLimitMiddleware, createAuthenticationRateLimitMiddleware, createBasicAuthMiddleware, createCaptchaRateLimitMiddleware, createExpensiveApiRateLimitMiddleware, createSameOriginMiddleware, createSecurityHeadersMiddleware, createUploadRateLimitMiddleware, enforceCaseInsensitiveRouting, normalizeApiPath, resolveTrustProxySetting, verifySetupToken } from "./security.js";
@@ -50,6 +54,7 @@ import { canReadWorkModule, canWriteWorkModule, chapterAnnotationPermissionModul
50
54
  import { CollaborationPresence, editorPageKey, entityEditorPageKey, modulePageKey, presencePageKinds } from "./collaboration-presence.js";
51
55
  import { PresenceStore } from "./presence-store.js";
52
56
  import { analysisTaskReadModules, clearSessionCookie, createCliApiScopeMiddleware, createUserSessionMiddleware, createWorkAuthorizationMiddleware, relationshipAnalysisReadModules, setSessionCookie, UserAuthService } from "./user-auth.js";
57
+ import { extractStaticModuleImports, injectModulePreloads } from "./ui-module-preload.js";
53
58
  const chapterAnnotationKinds = ["note", "todo"];
54
59
  function readableChapterAnnotationKinds(permissions) {
55
60
  return chapterAnnotationKinds.filter((kind) => canReadWorkModule(permissions, chapterAnnotationPermissionModule(kind)));
@@ -62,6 +67,23 @@ const optionalStrings = z.array(z.string()).optional();
62
67
  const jsonObject = z.record(z.string(), z.unknown());
63
68
  const chapterTypeSchema = z.enum(["正文", "设定", "作者的话", "其他"]);
64
69
  const aiConversationTaskTypeSchema = z.enum(aiConversationTaskTypes);
70
+ const roleplayMemoryInputSchema = z.object({
71
+ category: z.enum(ROLEPLAY_MEMORY_CATEGORIES),
72
+ content: z.string().trim().min(1).max(2_000),
73
+ importance: z.enum(ROLEPLAY_MEMORY_IMPORTANCE).optional(),
74
+ certainty: z.enum(ROLEPLAY_MEMORY_CERTAINTY).optional(),
75
+ isPinned: z.boolean().optional()
76
+ }).strict();
77
+ const roleplayMemoryListQuerySchema = z.object({
78
+ q: z.string().trim().max(200).optional(),
79
+ 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()),
80
+ 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()),
81
+ cursor: z.coerce.number().int().min(0).max(100_000).optional(),
82
+ limit: z.coerce.number().int().min(1).max(100).optional()
83
+ }).strict();
84
+ const roleplayMemoryUpdateSchema = roleplayMemoryInputSchema.partial().extend({
85
+ expectedVersion: z.number().int().min(1)
86
+ }).strict().refine((value) => Object.keys(value).some((key) => key !== "expectedVersion"), "至少提供一个要修改的字段");
65
87
  const versionedEntityTypeSchema = z.enum(versionedEntityTypes);
66
88
  const attachmentPermissionModuleSchema = z.enum(attachmentPermissionModules);
67
89
  const maximumImportedTextLength = 20_000_000;
@@ -211,7 +233,9 @@ const workSchema = z.object({
211
233
  description: z.string().max(10_000).optional(),
212
234
  language: z.string().max(30).optional(),
213
235
  coverUrl: z.string().url().nullable().optional(),
214
- tags: optionalStrings
236
+ tags: optionalStrings,
237
+ editorAutoIndentEnabled: z.boolean().optional(),
238
+ editorTypewriterModeEnabled: z.boolean().optional()
215
239
  });
216
240
  const workOfflineAccessSchema = z.object({ enabled: z.boolean() }).strict();
217
241
  const settingSchema = z.object({
@@ -1340,6 +1364,15 @@ export function createRuntime(options) {
1340
1364
  liteLlmPriceCache,
1341
1365
  allowPrivateAiEndpoints: options.security?.allowPrivateAiEndpoints === true
1342
1366
  });
1367
+ // AI 可写工具与审批工作流:计划创建只能由侧边栏 AI 发起,确认入口只接收审批 ID。
1368
+ const aiWritePlanManager = new AiWritePlanManager({
1369
+ database,
1370
+ store,
1371
+ auth,
1372
+ resolveAnalysisTask: (workId, input) => ai.resolveTaskInput(workId, input),
1373
+ startAnalysisTask: (workId, input) => store.createTask(workId, input)
1374
+ });
1375
+ ai.attachWritePlanManager(aiWritePlanManager);
1343
1376
  const app = express();
1344
1377
  enforceCaseInsensitiveRouting(app);
1345
1378
  const upload = multer({
@@ -1905,7 +1938,7 @@ export function createRuntime(options) {
1905
1938
  });
1906
1939
  app.get("/api/chapters/:chapterId", (request, response) => data(response, store.getChapter(request.params.chapterId)));
1907
1940
  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);
1941
+ 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
1942
  const { source, changeNote, expectedVersionNo, ...chapterInput } = input;
1910
1943
  const chapter = store.saveChapter(request.params.chapterId, chapterInput, source ?? "manual", null, changeNote, expectedVersionNo);
1911
1944
  publishEditorChange(String(chapter.workId), String(chapter.id));
@@ -2875,6 +2908,16 @@ export function createRuntime(options) {
2875
2908
  data(response, store.createAiConversation(request.params.workId, input.title, input.taskType), 201);
2876
2909
  });
2877
2910
  app.use("/api/ai-conversations/:conversationId", (request, _response, next) => {
2911
+ const sourceId = typeof request.query.roleplayMemorySourceId === "string"
2912
+ ? request.query.roleplayMemorySourceId
2913
+ : null;
2914
+ const messageId = typeof request.query.messageId === "string" ? request.query.messageId : null;
2915
+ if (["GET", "HEAD"].includes(request.method)
2916
+ && request.authUser?.role === "admin"
2917
+ && sourceId !== null
2918
+ && messageId !== null
2919
+ && store.isRoleplayMemorySourceTarget(sourceId, request.params.conversationId, messageId))
2920
+ return next();
2878
2921
  assertRequestAiConversationOwner(request, request.params.conversationId);
2879
2922
  next();
2880
2923
  });
@@ -2942,6 +2985,20 @@ export function createRuntime(options) {
2942
2985
  const permissions = requestPermissions(request, String(updated.workId));
2943
2986
  data(response, redactAiConversation(updated, permissions));
2944
2987
  });
2988
+ app.get("/api/characters/:characterId/roleplay-memories", (request, response) => {
2989
+ const query = parse(roleplayMemoryListQuerySchema, request.query);
2990
+ data(response, store.listRoleplayMemories(request.params.characterId, {
2991
+ query: query.q,
2992
+ categories: query.categories,
2993
+ statuses: query.statuses,
2994
+ cursor: query.cursor,
2995
+ limit: query.limit
2996
+ }));
2997
+ });
2998
+ app.post("/api/characters/:characterId/roleplay-memories", (request, response) => {
2999
+ const input = parse(roleplayMemoryInputSchema, request.body);
3000
+ data(response, store.createRoleplayMemory(request.params.characterId, input), 201);
3001
+ });
2945
3002
  app.post("/api/ai-conversations/:conversationId/messages", (request, response) => {
2946
3003
  const input = parse(z.object({
2947
3004
  role: z.enum(["user", "assistant"]),
@@ -3004,6 +3061,130 @@ export function createRuntime(options) {
3004
3061
  })
3005
3062
  });
3006
3063
  });
3064
+ app.patch("/api/roleplay-memories/:memoryId", (request, response) => {
3065
+ const input = parse(roleplayMemoryUpdateSchema, request.body);
3066
+ data(response, store.updateRoleplayMemory(request.params.memoryId, input));
3067
+ });
3068
+ app.delete("/api/roleplay-memories/:memoryId", (request, response) => {
3069
+ const input = parse(z.object({ expectedVersion: z.number().int().min(1) }).strict(), request.body);
3070
+ data(response, store.setRoleplayMemoryArchived(request.params.memoryId, true, input.expectedVersion));
3071
+ });
3072
+ app.post("/api/roleplay-memories/:memoryId/restore", (request, response) => {
3073
+ const input = parse(z.object({ expectedVersion: z.number().int().min(1) }).strict(), request.body);
3074
+ data(response, store.setRoleplayMemoryArchived(request.params.memoryId, false, input.expectedVersion));
3075
+ });
3076
+ // ---------------------------------------------------------------- AI 可写工具与审批中心
3077
+ const planViewer = () => {
3078
+ const actor = currentRequestActor();
3079
+ return actor ? { userId: actor.userId, role: actor.role } : null;
3080
+ };
3081
+ app.get("/api/works/:workId/ai/tools", (request, response) => {
3082
+ store.getWork(request.params.workId);
3083
+ data(response, {
3084
+ tools: aiWritePlanManager.getEnabledTools(request.params.workId),
3085
+ labels: aiWriteToolLabels,
3086
+ descriptions: aiWriteToolDescriptions,
3087
+ maxOperations: resolveAiWritePlanMaxOperations(process.env.AI_WRITE_PLAN_MAX_OPERATIONS)
3088
+ });
3089
+ });
3090
+ app.put("/api/works/:workId/ai/tools", (request, response) => {
3091
+ const input = parse(aiWriteToolsUpdateSchema, request.body ?? {});
3092
+ data(response, {
3093
+ tools: aiWritePlanManager.updateToolSettings(request.params.workId, input.tools, currentRequestActor()?.userId ?? null)
3094
+ });
3095
+ });
3096
+ app.get("/api/works/:workId/ai/write-plans", (request, response) => {
3097
+ const status = typeof request.query.status === "string"
3098
+ ? parse(z.enum(AI_WRITE_PLAN_STATUSES), request.query.status)
3099
+ : undefined;
3100
+ const limit = Number.parseInt(typeof request.query.limit === "string" ? request.query.limit : "", 10);
3101
+ data(response, {
3102
+ plans: aiWritePlanManager.listPlansForWork(request.params.workId, planViewer(), {
3103
+ status,
3104
+ ...(Number.isFinite(limit) ? { limit } : {})
3105
+ })
3106
+ });
3107
+ });
3108
+ app.get("/api/works/:workId/ai/write-plans/:planId", (request, response) => {
3109
+ data(response, aiWritePlanManager.getPlanDetail(request.params.planId, request.params.workId, planViewer()));
3110
+ });
3111
+ app.post("/api/works/:workId/ai/write-plans/:planId/confirm", async (request, response) => {
3112
+ data(response, await aiWritePlanManager.confirmPlan(request.params.planId, request.params.workId, planViewer()));
3113
+ });
3114
+ app.post("/api/works/:workId/ai/write-plans/:planId/reject", (request, response) => {
3115
+ data(response, aiWritePlanManager.rejectPlan(request.params.planId, request.params.workId, planViewer()));
3116
+ });
3117
+ app.post("/api/works/:workId/ai/write-plans/:planId/undo", (request, response) => {
3118
+ data(response, aiWritePlanManager.createUndoPlan(request.params.planId, request.params.workId, planViewer()), 201);
3119
+ });
3120
+ app.get("/api/works/:workId/ai/questions", (request, response) => {
3121
+ const conversationId = typeof request.query.conversationId === "string"
3122
+ ? parse(identifier, request.query.conversationId)
3123
+ : undefined;
3124
+ const status = typeof request.query.status === "string"
3125
+ ? parse(z.enum(AI_USER_QUESTION_STATUSES), request.query.status)
3126
+ : undefined;
3127
+ const limit = Number.parseInt(typeof request.query.limit === "string" ? request.query.limit : "", 10);
3128
+ data(response, {
3129
+ questions: aiWritePlanManager.listQuestions(request.params.workId, planViewer(), {
3130
+ ...(conversationId !== undefined ? { conversationId } : {}),
3131
+ ...(status !== undefined ? { status } : {}),
3132
+ ...(Number.isFinite(limit) ? { limit } : {})
3133
+ })
3134
+ });
3135
+ });
3136
+ app.get("/api/works/:workId/ai/questions/:questionId", (request, response) => {
3137
+ data(response, aiWritePlanManager.getQuestion(request.params.questionId, request.params.workId, planViewer()));
3138
+ });
3139
+ const resumeQuestionWorkflow = async (questionId, workId, viewer) => {
3140
+ const continuation = aiWritePlanManager.claimQuestionContinuation(questionId, workId, viewer);
3141
+ if (!continuation)
3142
+ return;
3143
+ try {
3144
+ const conversationId = typeof continuation.conversationId === "string" ? continuation.conversationId : "";
3145
+ if (!conversationId)
3146
+ throw new AppError(409, "AI_QUESTION_CONTINUATION_MISSING", "提问缺少可恢复的对话状态");
3147
+ const scope = parse(contextSchema, continuation.scope ?? { type: "none" });
3148
+ const resumed = await ai.resumeUserQuestion({
3149
+ questionId,
3150
+ workId,
3151
+ conversationId,
3152
+ scope,
3153
+ status: String(continuation.status ?? "rejected"),
3154
+ answerText: String(continuation.answerText ?? ""),
3155
+ selectedOptionLabel: typeof continuation.selectedOptionLabel === "string" ? continuation.selectedOptionLabel : null,
3156
+ supplementalAnswer: typeof continuation.customAnswer === "string" ? continuation.customAnswer : "",
3157
+ ...(typeof continuation.modelId === "string" && continuation.modelId ? { modelId: continuation.modelId } : {}),
3158
+ ...(typeof continuation.toolCallId === "string" && continuation.toolCallId ? { toolCallId: continuation.toolCallId } : {}),
3159
+ ...(typeof continuation.assistantMessageRequestId === "string" && continuation.assistantMessageRequestId
3160
+ ? { assistantMessageRequestId: continuation.assistantMessageRequestId }
3161
+ : {}),
3162
+ ...(continuation.questionView && typeof continuation.questionView === "object" && !Array.isArray(continuation.questionView)
3163
+ ? { questionView: continuation.questionView }
3164
+ : {}),
3165
+ ...(typeof continuation.round === "number" ? { round: continuation.round } : {}),
3166
+ ...(Array.isArray(continuation.toolMessages) ? { toolMessages: continuation.toolMessages } : {})
3167
+ });
3168
+ aiWritePlanManager.finishQuestionContinuation(questionId, { callId: resumed.callId ?? null, completed: true });
3169
+ }
3170
+ catch (error) {
3171
+ aiWritePlanManager.finishQuestionContinuation(questionId, { message: error instanceof Error ? error.message : "恢复失败" }, true);
3172
+ throw error;
3173
+ }
3174
+ };
3175
+ app.post("/api/works/:workId/ai/questions/:questionId/answer", async (request, response) => {
3176
+ const input = parse(answerAiUserQuestionSchema, request.body ?? {});
3177
+ const viewer = planViewer();
3178
+ aiWritePlanManager.answerQuestion(request.params.questionId, request.params.workId, viewer, { ...(input.selectedOption !== undefined ? { selectedOption: input.selectedOption } : {}), ...(input.customAnswer !== undefined ? { customAnswer: input.customAnswer } : {}) });
3179
+ await resumeQuestionWorkflow(request.params.questionId, request.params.workId, viewer);
3180
+ data(response, aiWritePlanManager.getQuestion(request.params.questionId, request.params.workId, viewer));
3181
+ });
3182
+ app.post("/api/works/:workId/ai/questions/:questionId/reject", async (request, response) => {
3183
+ const viewer = planViewer();
3184
+ aiWritePlanManager.rejectQuestion(request.params.questionId, request.params.workId, viewer);
3185
+ await resumeQuestionWorkflow(request.params.questionId, request.params.workId, viewer);
3186
+ data(response, aiWritePlanManager.getQuestion(request.params.questionId, request.params.workId, viewer));
3187
+ });
3007
3188
  app.get("/api/works/:workId/providers", (request, response) => {
3008
3189
  store.getWork(request.params.workId);
3009
3190
  data(response, ai.listProviders());
@@ -3356,6 +3537,9 @@ export function createRuntime(options) {
3356
3537
  const currentMessageId = String(begun.userMessage?.id ?? input.currentMessageId ?? "");
3357
3538
  if (begun.userMessage)
3358
3539
  sendEvent("user_message", { message: redactAiConversationMessage(begun.userMessage, permissions) });
3540
+ if (aiWritePlanManager.latestPendingQuestion(conversationId)) {
3541
+ throw new AppError(409, "AI_QUESTION_PENDING", "当前对话仍有待回答问题,请先回答或拒绝后再继续");
3542
+ }
3359
3543
  const suggestion = await ai.createStreamingChat({
3360
3544
  workId: request.params.workId,
3361
3545
  instruction: resolvedInstruction,
@@ -3540,11 +3724,14 @@ export function createRuntime(options) {
3540
3724
  });
3541
3725
  if (options.serveUi ?? true) {
3542
3726
  const publicPath = options.publicPath ?? join(process.cwd(), "src", "public");
3727
+ const publicApplicationModuleImports = extractStaticModuleImports(readFileSync(join(publicPath, "app.js"), "utf8"));
3728
+ // API 与 SSE 路由已经在此前注册;压缩只作用于页面和静态资源,避免缓冲流式响应。
3729
+ app.use(compression());
3543
3730
  // index.html 按登录态动态下发:未登录时注入 login-route 类,首帧直接渲染登录页;
3544
3731
  // 已登录时保持骨架屏,由前端恢复会话后进入工作台,避免两种闪烁。
3545
3732
  const sendIndexHtml = (request, response) => {
3546
3733
  const authenticated = options.disableUserAuth === true || auth.authenticate(request) !== null;
3547
- let html = readFileSync(join(publicPath, "index.html"), "utf8");
3734
+ let html = injectModulePreloads(readFileSync(join(publicPath, "index.html"), "utf8"), publicApplicationModuleImports);
3548
3735
  if (!authenticated)
3549
3736
  html = html.replace('<html lang="zh-CN">', '<html lang="zh-CN" class="login-route">');
3550
3737
  if (options.disableUserAuth === true) {