@musnows/scriverse 1.0.11 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.en.md +6 -3
  2. package/README.md +6 -3
  3. package/dist/ai-stream-steer.js +96 -0
  4. package/dist/ai-stream-steer.js.map +1 -0
  5. package/dist/ai-write-plans.js +44 -38
  6. package/dist/ai-write-plans.js.map +1 -1
  7. package/dist/ai.js +241 -128
  8. package/dist/ai.js.map +1 -1
  9. package/dist/app.js +134 -14
  10. package/dist/app.js.map +1 -1
  11. package/dist/attachment-storage.js +3 -0
  12. package/dist/attachment-storage.js.map +1 -1
  13. package/dist/chapter-annotation-note.js +14 -0
  14. package/dist/chapter-annotation-note.js.map +1 -0
  15. package/dist/character-structured-fields.js +7 -0
  16. package/dist/character-structured-fields.js.map +1 -0
  17. package/dist/cli-contract.js +1 -1
  18. package/dist/cli-contract.js.map +1 -1
  19. package/dist/database.js +29 -2
  20. package/dist/database.js.map +1 -1
  21. package/dist/im.js +2 -1
  22. package/dist/im.js.map +1 -1
  23. package/dist/public/ai-prompt-keyboard.js +5 -1
  24. package/dist/public/ai-prompt-queue.js +226 -0
  25. package/dist/public/ai-send-mode.js +31 -0
  26. package/dist/public/app.js +689 -74
  27. package/dist/public/chapter-editor-scroll.d.ts +37 -0
  28. package/dist/public/chapter-editor-scroll.js +52 -0
  29. package/dist/public/character-profile.d.ts +7 -0
  30. package/dist/public/character-profile.js +123 -5
  31. package/dist/public/index.html +31 -10
  32. package/dist/public/styles.css +75 -8
  33. package/dist/security.js +22 -3
  34. package/dist/security.js.map +1 -1
  35. package/dist/server-runtime.js +2 -0
  36. package/dist/server-runtime.js.map +1 -1
  37. package/dist/store.js +7 -6
  38. package/dist/store.js.map +1 -1
  39. package/dist/user-auth.js +125 -15
  40. package/dist/user-auth.js.map +1 -1
  41. package/dist/version.js +1 -1
  42. package/dist/version.js.map +1 -1
  43. package/package.json +1 -1
package/README.en.md CHANGED
@@ -109,17 +109,20 @@ Run `scriverse --help` for all local server, default server, authentication, wor
109
109
  | `SCRIVERSE_AI_RETRY_COUNT` | `3` | Retry count for AI upstream HTTP errors other than `403`, `429`, and `502`; valid integers are clamped to `1`–`20` |
110
110
  | `SCRIVERSE_AI_BACKOFF_RETRY_COUNT` | `10` | Backoff retry count when an AI upstream returns `429` or `502`; valid integers are clamped to `1`–`20` |
111
111
  | `SCRIVERSE_AI_STREAM_IDLE_TIMEOUT_SECONDS` | `30` | Maximum idle time while an interactive AI stream waits for its first or next valid event; valid integers are clamped to `10`–`120`, and invalid values fall back to `30` |
112
+ | `SCRIVERSE_CHAPTER_ANNOTATION_NOTE_MAX_LENGTH` | `6000` | Maximum character length for chapter comments and todos; valid integers are clamped to `2000`–`20000`, values below `2000` are treated as `2000`, and invalid values fall back to `6000` |
112
113
  | `APP_AUTH_USERNAME` | Empty | Optional deployment gateway username; the in-app user system is always enabled |
113
114
  | `APP_AUTH_PASSWORD` | Empty | Optional deployment gateway password, at least 12 characters; must be transported over HTTPS |
114
115
  | `APP_TRUST_PROXY` | `false` | Set to the trusted proxy hop count (usually `1`) or `true` behind a trusted reverse proxy |
115
116
  | `APP_ALLOW_PRIVATE_AI_ENDPOINTS` | `true` in development, `false` in production | In production, AI provider URLs are validated and loopback, private-network, link-local, and reserved ranges are blocked by default. Setting `true`/`1` disables that validation for a trusted transparent-proxy or fake-IP deployment and writes a startup warning |
116
- | `APP_ALLOW_REGISTRATION` | `false` | Registration is enabled only when explicitly set to `true`; unset and all other values stay closed, including first-admin setup |
117
- | `APP_SETUP_TOKEN` | Empty | Required when registration is enabled and must contain at least 32 characters; only the first administrator must enter it |
117
+ | `APP_ALLOW_REGISTRATION` | `false` | Three mutually exclusive levels: unset, `false`, `0`, or any other value disables registration; `invite` requires a one-time invite code; `true` or `1` opens registration. Invite-only and open registration both require a setup token |
118
+ | `APP_SETUP_TOKEN` | Empty | Required when registration is open or invite-only and must contain at least 32 characters; only the first administrator must enter it |
118
119
 
119
120
  `APP_ALLOW_PRIVATE_AI_ENDPOINTS` disables AI provider URL SSRF validation and should be enabled only for a trusted deployment that must reach a provider through a transparent proxy or fake-IP mapping. Unset production deployments keep validating and blocking loopback, private-network, link-local, and reserved ranges; when enabled, those addresses no longer fail provider connection tests and startup writes a warning log. Remote MCP server URLs retain independent SSRF validation.
120
121
 
121
122
  `SCRIVERSE_AI_STREAM_IDLE_TIMEOUT_SECONDS` is read when the service starts and only controls how long an interactive AI stream may remain without a new event. Every valid stream event restarts the timer, so generation may continue beyond 60 seconds; this setting does not impose a total-duration limit or change timeout behavior for analysis tasks and other AI requests. Restart the service after changing it.
122
123
 
124
+ `SCRIVERSE_CHAPTER_ANNOTATION_NOTE_MAX_LENGTH` is read when the service starts and applies to API validation, the comment input, and AI write plans. Restart the service after changing it.
125
+
123
126
  The AI upstream HTTP retry settings are read when the service starts. `403` is never retried. `429` and `502` use exponential backoff starting at 500 milliseconds and capped at 5 seconds, honoring a numeric `Retry-After` within the same cap; other HTTP errors use a linear delay. Invalid values fall back to their defaults. Restart the service after changing either setting.
124
127
 
125
128
  Custom configuration example:
@@ -138,7 +141,7 @@ APP_AUTH_PASSWORD='replace-with-a-long-random-password' \
138
141
  npm start
139
142
  ```
140
143
 
141
- Production deployments must use HTTPS at a trusted reverse proxy. For first-time setup, set `APP_ALLOW_REGISTRATION=true` and configure `APP_SETUP_TOKEN` with at least 32 random characters. The first registered user must enter that token and becomes the system administrator. Afterwards, remove both variables or disable registration and restart the service. Later ordinary registrations do not require the setup token. Optional HTTP Basic Auth is only an additional deployment gateway, and its credentials are merely Base64 encoded. `/api/health` remains public for health checks, while business APIs require an in-app login.
144
+ Production deployments must use HTTPS at a trusted reverse proxy. For first-time setup, set `APP_ALLOW_REGISTRATION` to `true` or `invite` and configure `APP_SETUP_TOKEN` with at least 32 random characters. The first registered user must enter that token and becomes the system administrator. Afterwards, set the switch to `false` to close registration, or to `invite` so later users must present a one-time invite code generated by a system administrator. Fully open registration no longer requires the setup token. Optional HTTP Basic Auth is only an additional deployment gateway, and its credentials are merely Base64 encoded. `/api/health` remains public for health checks, while business APIs require an in-app login.
142
145
 
143
146
  ## AI Provider Setup
144
147
 
package/README.md CHANGED
@@ -118,12 +118,13 @@ CLI 会按服务器保存登录凭据。所有连接服务的数据命令都可
118
118
  | `SCRIVERSE_AI_BACKOFF_RETRY_COUNT` | `10` | AI 上游返回 `429` 或 `502` 时的退避重试次数;有效整数按 `1`–`20` 钳制 |
119
119
  | `SCRIVERSE_AI_STREAM_IDLE_TIMEOUT_SECONDS` | `30` | 交互式 AI 流等待首个或下一个有效事件的最长空闲秒数;有效整数按 `10`–`120` 钳制,非法值回退为 `30` |
120
120
  | `APP_AI_CHAT_TAB_LIMIT` | `5` | 浏览器中可同时打开的 Agent 对话数;有效整数按 `1`–`20` 钳制,设为 `1` 时关闭多会话切换和工作台 |
121
+ | `SCRIVERSE_CHAPTER_ANNOTATION_NOTE_MAX_LENGTH` | `6000` | 正文评论和待办内容的最大字符数;有效整数按 `2000`–`20000` 钳制,低于 `2000` 按 `2000` 处理,非法值回退为 `6000` |
121
122
  | `APP_AUTH_USERNAME` | 空 | 可选的部署网关账号;应用内用户系统始终启用 |
122
123
  | `APP_AUTH_PASSWORD` | 空 | 可选的部署网关密码,至少 12 个字符;必须通过 HTTPS 传输 |
123
124
  | `APP_TRUST_PROXY` | `false` | 位于可信反向代理后时设为代理跳数(通常为 `1`)或 `true` |
124
125
  | `APP_ALLOW_PRIVATE_AI_ENDPOINTS` | 开发环境 `true`,生产环境 `false` | 生产环境默认校验 AI 供应商地址并拦截本机、内网、链路本地和保留网段。显式设为 `true`/`1` 后关闭该校验,适用于受信任的透明代理或 fake-ip 部署;启动时会打印警告 |
125
- | `APP_ALLOW_REGISTRATION` | `false` | 仅明确设为 `true` 或 `1` 时开放注册;未设置或其他值均关闭,首次初始化创建管理员也必须显式开启 |
126
- | `APP_SETUP_TOKEN` | 空 | 开放注册时必填且至少 32 个字符;仅首位管理员注册需要在页面输入 |
126
+ | `APP_ALLOW_REGISTRATION` | `false` | 三个互斥等级:未设置、`false`、`0` 或其他值表示彻底禁止注册;`invite` 表示需要一次性邀请码才能注册;`true` 或 `1` 表示完全开放注册。`invite` 与开放注册时都必须配置初始化令牌 |
127
+ | `APP_SETUP_TOKEN` | 空 | 开放或邀请码注册时必填且至少 32 个字符;仅首位管理员注册需要在页面输入 |
127
128
  | `SCRIVERSE_AVATAR_IMAGE_MAX_BYTES` | `2097152` | 头像图片上传大小上限,单位为字节 |
128
129
  | `SCRIVERSE_COVER_IMAGE_MAX_BYTES` | `5242880` | 作品封面图片上传大小上限,单位为字节;封面不支持 GIF |
129
130
  | `SCRIVERSE_ATTACHMENT_IMAGE_MAX_BYTES` | `31457280` | 设定库等其他图片附件上传大小上限,单位为字节;包括 GIF |
@@ -137,6 +138,8 @@ CLI 会按服务器保存登录凭据。所有连接服务的数据命令都可
137
138
 
138
139
  `SCRIVERSE_AI_STREAM_IDLE_TIMEOUT_SECONDS` 在服务启动时读取,只控制交互式 AI 流连续没有新事件的等待时间。每收到一个有效流事件都会重新计时,持续生成超过 60 秒不会因此中断;该配置不设置总时长上限,也不改变分析任务等其他 AI 请求的超时策略。修改后需重启服务生效。
139
140
 
141
+ `SCRIVERSE_CHAPTER_ANNOTATION_NOTE_MAX_LENGTH` 在服务启动时读取,同时约束 API、界面输入和 AI 写计划中的正文评论与待办。修改后需重启服务生效。
142
+
140
143
  AI 上游 HTTP 重试配置在服务启动时读取。`403` 始终不重试;`429` 和 `502` 使用指数退避,等待从 500 毫秒开始并在 5 秒封顶,同时遵循秒数格式的 `Retry-After`(同样最多等待 5 秒);其余 HTTP 错误使用线性等待。非法配置回退到对应默认值,修改后需重启服务生效。
141
144
 
142
145
  自定义示例:
@@ -155,7 +158,7 @@ APP_AUTH_PASSWORD='请替换为足够长的随机密码' \
155
158
  npm start
156
159
  ```
157
160
 
158
- 生产环境必须在可信反向代理后启用 HTTPS。首次初始化时,将 `APP_ALLOW_REGISTRATION` 设为 `true`,并为 `APP_SETUP_TOKEN` 配置至少 32 个字符的随机值;创建的第一个用户会自动成为系统管理员,且必须在页面输入该令牌。完成后应删除这两个环境变量或关闭注册并重启服务。后续添加普通用户只需临时开放注册,不再要求初始化令牌。可选的 HTTP Basic Auth 仅作为额外部署网关,其凭据只是 Base64 编码,未使用 HTTPS 时不能防止链路窃听。`/api/health` 保持免认证以供探活,业务 API 需要应用内登录。
161
+ 生产环境必须在可信反向代理后启用 HTTPS。首次初始化时,将 `APP_ALLOW_REGISTRATION` 设为 `true` 或 `invite`,并为 `APP_SETUP_TOKEN` 配置至少 32 个字符的随机值;创建的第一个用户会自动成为系统管理员,且必须在页面输入该令牌。完成后可将注册改为 `false` 彻底关闭,或改为 `invite` 仅允许持有一次性邀请码的用户注册。后续完全开放注册时不再要求初始化令牌;邀请码模式下普通注册必须填写系统管理员生成的一次性邀请码。可选的 HTTP Basic Auth 仅作为额外部署网关,其凭据只是 Base64 编码,未使用 HTTPS 时不能防止链路窃听。`/api/health` 保持免认证以供探活,业务 API 需要应用内登录。
159
162
 
160
163
  ## AI 供应商配置
161
164
 
@@ -0,0 +1,96 @@
1
+ import { AppError } from "./errors.js";
2
+ import { id, now } from "./utils.js";
3
+ export const AI_STEER_MAX_CHARS = 100_000;
4
+ export const AI_STEER_INSTRUCTION_HEADING = "【执行流引导】";
5
+ export function createAiSteerRefreshError() {
6
+ return new AppError(409, "AI_STEER_REFRESH", "收到执行流引导,正在把当前回复交给引导继续");
7
+ }
8
+ export function isAiSteerRefreshError(error) {
9
+ return error instanceof AppError && error.code === "AI_STEER_REFRESH";
10
+ }
11
+ export function normalizeAiSteerContent(value) {
12
+ return String(value ?? "").trim();
13
+ }
14
+ export function formatAiSteerInstruction(content) {
15
+ const normalized = normalizeAiSteerContent(content);
16
+ return `${AI_STEER_INSTRUCTION_HEADING}\n作者在当前回答尚未结束时补充了以下引导,请立即按此调整后续执行,不要重新开始整个任务:\n\n${normalized}`;
17
+ }
18
+ export class AiStreamSteerMailbox {
19
+ active = new Set();
20
+ pending = new Map();
21
+ waiters = new Map();
22
+ begin(conversationId) {
23
+ const key = String(conversationId ?? "").trim();
24
+ if (!key)
25
+ throw new AppError(400, "AI_STEER_CONVERSATION_REQUIRED", "执行流引导必须绑定对话");
26
+ this.active.add(key);
27
+ if (!this.pending.has(key))
28
+ this.pending.set(key, []);
29
+ return () => this.end(key);
30
+ }
31
+ isActive(conversationId) {
32
+ return this.active.has(String(conversationId ?? "").trim());
33
+ }
34
+ enqueue(conversationId, content) {
35
+ const key = String(conversationId ?? "").trim();
36
+ const normalized = normalizeAiSteerContent(content);
37
+ if (!key)
38
+ throw new AppError(400, "AI_STEER_CONVERSATION_REQUIRED", "执行流引导必须绑定对话");
39
+ if (!normalized)
40
+ throw new AppError(400, "AI_STEER_EMPTY", "请输入引导内容");
41
+ if (normalized.length > AI_STEER_MAX_CHARS) {
42
+ throw new AppError(400, "AI_STEER_TOO_LONG", `引导内容不能超过 ${AI_STEER_MAX_CHARS} 个字符`);
43
+ }
44
+ if (!this.active.has(key)) {
45
+ throw new AppError(409, "AI_STEER_NO_ACTIVE_STREAM", "当前对话没有正在执行的回复,无法发送引导");
46
+ }
47
+ const entry = {
48
+ id: id("steer"),
49
+ conversationId: key,
50
+ content: normalized,
51
+ createdAt: now()
52
+ };
53
+ const queue = this.pending.get(key) ?? [];
54
+ queue.push(entry);
55
+ this.pending.set(key, queue);
56
+ this.signal(key);
57
+ return entry;
58
+ }
59
+ drain(conversationId) {
60
+ const key = String(conversationId ?? "").trim();
61
+ const items = this.pending.get(key) ?? [];
62
+ this.pending.set(key, []);
63
+ return items;
64
+ }
65
+ subscribe(conversationId, controller) {
66
+ const key = String(conversationId ?? "").trim();
67
+ if (!key)
68
+ return () => undefined;
69
+ let waiters = this.waiters.get(key);
70
+ if (!waiters) {
71
+ waiters = new Set();
72
+ this.waiters.set(key, waiters);
73
+ }
74
+ waiters.add(controller);
75
+ return () => {
76
+ waiters?.delete(controller);
77
+ if (waiters && waiters.size === 0)
78
+ this.waiters.delete(key);
79
+ };
80
+ }
81
+ signal(conversationId) {
82
+ const waiters = this.waiters.get(conversationId);
83
+ if (!waiters)
84
+ return;
85
+ for (const controller of waiters) {
86
+ if (!controller.signal.aborted)
87
+ controller.abort(createAiSteerRefreshError());
88
+ }
89
+ }
90
+ end(conversationId) {
91
+ this.active.delete(conversationId);
92
+ this.pending.delete(conversationId);
93
+ this.waiters.delete(conversationId);
94
+ }
95
+ }
96
+ //# sourceMappingURL=ai-stream-steer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ai-stream-steer.js","sourceRoot":"","sources":["../src/ai-stream-steer.ts"],"names":[],"mappings":"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"}
@@ -3,6 +3,8 @@ import { analysisTaskReadModules } from "./user-auth.js";
3
3
  import { AppError } from "./errors.js";
4
4
  import { logger } from "./logger.js";
5
5
  import { id as randomId, json, now } from "./utils.js";
6
+ import { characterAttributesInputSchema, characterStateInputSchema } from "./character-structured-fields.js";
7
+ import { DEFAULT_CHAPTER_ANNOTATION_NOTE_MAX_LENGTH } from "./chapter-annotation-note.js";
6
8
  import { canReadWorkModule, canWriteWorkModule, emptyWorkModulePermissions, fullWorkModulePermissions, storedWorkModulePermissions, workPermissionModuleLabels } from "./work-permissions.js";
7
9
  /**
8
10
  * AI 可写工具与持久化审批工作流。
@@ -116,9 +118,9 @@ const characterInputSchema = z.object({
116
118
  aliases: z.array(z.string().trim().min(1).max(200)).max(100).optional(),
117
119
  raceId: identifierSchema.nullable().optional(),
118
120
  organizationIds: z.array(identifierSchema).max(100).optional(),
119
- attributes: jsonObjectSchema.optional(),
121
+ attributes: characterAttributesInputSchema.optional().describe("身份与扩展属性。只使用 identity 字符串和 details[{label,value}];其他键会在入库时折入 details。"),
120
122
  profile: jsonObjectSchema.optional(),
121
- currentState: jsonObjectSchema.optional()
123
+ currentState: characterStateInputSchema.optional()
122
124
  }).strict();
123
125
  /** 种族可写字段:成员归属与分节设定结构保留给人工。 */
124
126
  const raceInputSchema = z.object({
@@ -247,7 +249,7 @@ function toolInputJsonSchema(schema) {
247
249
  * 每个操作类型和实体类型都使用独立分支,避免通用对象把 entityId、scope 等
248
250
  * 仅属于其他操作的字段错误地暴露给 create_entry。
249
251
  */
250
- export function aiWritePlanOperationToolSchemas(toggles) {
252
+ export function aiWritePlanOperationToolSchemas(toggles, chapterAnnotationNoteMaxLength = DEFAULT_CHAPTER_ANNOTATION_NOTE_MAX_LENGTH) {
251
253
  const entityTypes = [
252
254
  ...(toggles.settings ? ["setting"] : []),
253
255
  ...(toggles.characters ? ["character"] : []),
@@ -300,7 +302,7 @@ export function aiWritePlanOperationToolSchemas(toggles) {
300
302
  kind: { type: "string", enum: ["note", "todo"] },
301
303
  startLine: { type: "integer", minimum: 1 },
302
304
  endLine: { type: "integer", minimum: 1 },
303
- note: { type: "string", minLength: 1, maxLength: 2000 }
305
+ note: { type: "string", minLength: 1, maxLength: chapterAnnotationNoteMaxLength }
304
306
  },
305
307
  required: ["opType", "chapterId", "kind", "startLine", "endLine", "note"],
306
308
  additionalProperties: false
@@ -353,37 +355,39 @@ export const aiAnalysisTaskTypeLabels = {
353
355
  "book-analysis": "整书分析",
354
356
  "relationship-analysis": "人物关系分析"
355
357
  };
356
- const createOperationSchema = z.discriminatedUnion("opType", [
357
- z.object({
358
- opType: z.literal("create_entry"),
359
- entityType: entryEntityTypeSchema,
360
- /** 章节大纲按章节定位。 */
361
- chapterId: identifierSchema.optional(),
362
- input: z.unknown()
363
- }).strict(),
364
- z.object({
365
- opType: z.literal("update_entry"),
366
- entityType: entryEntityTypeSchema,
367
- entityId: identifierSchema.optional(),
368
- chapterId: identifierSchema.optional(),
369
- input: z.unknown()
370
- }).strict(),
371
- z.object({
372
- opType: z.literal("create_annotation"),
373
- chapterId: identifierSchema,
374
- kind: z.enum(["note", "todo"]),
375
- startLine: z.number().int().positive(),
376
- endLine: z.number().int().positive(),
377
- note: z.string().trim().min(1).max(2000)
378
- }).strict(),
379
- z.object({
380
- opType: z.literal("create_task"),
381
- taskType: z.enum([...AI_ANALYSIS_TASK_TYPES, "relationship-analysis"]),
382
- scope: jsonObjectSchema.optional(),
383
- modelId: identifierSchema.optional()
384
- }).strict()
385
- ]);
386
- const planOperationsSchema = z.array(createOperationSchema).min(1);
358
+ function createPlanOperationSchema(chapterAnnotationNoteMaxLength) {
359
+ return z.discriminatedUnion("opType", [
360
+ z.object({
361
+ opType: z.literal("create_entry"),
362
+ entityType: entryEntityTypeSchema,
363
+ /** 章节大纲按章节定位。 */
364
+ chapterId: identifierSchema.optional(),
365
+ input: z.unknown()
366
+ }).strict(),
367
+ z.object({
368
+ opType: z.literal("update_entry"),
369
+ entityType: entryEntityTypeSchema,
370
+ entityId: identifierSchema.optional(),
371
+ chapterId: identifierSchema.optional(),
372
+ input: z.unknown()
373
+ }).strict(),
374
+ z.object({
375
+ opType: z.literal("create_annotation"),
376
+ chapterId: identifierSchema,
377
+ kind: z.enum(["note", "todo"]),
378
+ startLine: z.number().int().positive(),
379
+ endLine: z.number().int().positive(),
380
+ note: z.string().trim().min(1).max(chapterAnnotationNoteMaxLength)
381
+ }).strict(),
382
+ z.object({
383
+ opType: z.literal("create_task"),
384
+ taskType: z.enum([...AI_ANALYSIS_TASK_TYPES, "relationship-analysis"]),
385
+ scope: jsonObjectSchema.optional(),
386
+ modelId: identifierSchema.optional()
387
+ }).strict()
388
+ ]);
389
+ }
390
+ const createOperationSchema = createPlanOperationSchema(DEFAULT_CHAPTER_ANNOTATION_NOTE_MAX_LENGTH);
387
391
  export const createAiWritePlanInputSchema = z.object({
388
392
  aiSummary: z.string().trim().min(1).max(2000),
389
393
  operations: z.unknown()
@@ -851,8 +855,8 @@ export function planOperationRequirements(operation) {
851
855
  }
852
856
  return { toolId: toolId, writeModules: [...writeModules], readModules: [...readModules] };
853
857
  }
854
- export function normalizePlanOperations(rawOperations, maxOperations) {
855
- const rawList = planOperationsSchema.max(maxOperations, `单次计划最多包含 ${maxOperations} 个操作`).safeParse(rawOperations);
858
+ export function normalizePlanOperations(rawOperations, maxOperations, chapterAnnotationNoteMaxLength = DEFAULT_CHAPTER_ANNOTATION_NOTE_MAX_LENGTH) {
859
+ const rawList = z.array(createPlanOperationSchema(chapterAnnotationNoteMaxLength)).min(1).max(maxOperations, `单次计划最多包含 ${maxOperations} 个操作`).safeParse(rawOperations);
856
860
  if (!rawList.success) {
857
861
  const firstIssue = rawList.error.issues[0];
858
862
  throw new AppError(400, "AI_PLAN_OPERATION_INVALID", `${firstIssue?.path.join(".") || "operation"}:${firstIssue?.message ?? "操作格式无效"}`);
@@ -997,6 +1001,7 @@ export class AiWritePlanManager {
997
1001
  startAnalysisTask;
998
1002
  planTtlMs;
999
1003
  questionTtlMs;
1004
+ chapterAnnotationNoteMaxLength;
1000
1005
  constructor(deps, options = {}) {
1001
1006
  this.database = deps.database;
1002
1007
  this.store = deps.store;
@@ -1005,6 +1010,7 @@ export class AiWritePlanManager {
1005
1010
  this.startAnalysisTask = deps.startAnalysisTask;
1006
1011
  this.planTtlMs = options.planTtlMs ?? AI_WRITE_PLAN_TTL_MS;
1007
1012
  this.questionTtlMs = options.questionTtlMs ?? AI_USER_QUESTION_TTL_MS;
1013
+ this.chapterAnnotationNoteMaxLength = options.chapterAnnotationNoteMaxLength ?? DEFAULT_CHAPTER_ANNOTATION_NOTE_MAX_LENGTH;
1008
1014
  this.recoverStaleExecutingPlans();
1009
1015
  }
1010
1016
  // --------------------------------------------------------------- 工具开关
@@ -1237,7 +1243,7 @@ export class AiWritePlanManager {
1237
1243
  if (!summaryParse.success)
1238
1244
  throw new AppError(400, "AI_PLAN_SUMMARY_REQUIRED", "AI 必须提供一段简要说明");
1239
1245
  const maxOperations = resolveAiWritePlanMaxOperations(process.env.AI_WRITE_PLAN_MAX_OPERATIONS);
1240
- const operations = normalizePlanOperations(input.operations, maxOperations);
1246
+ const operations = normalizePlanOperations(input.operations, maxOperations, this.chapterAnnotationNoteMaxLength);
1241
1247
  const toggles = this.getEnabledTools(workId);
1242
1248
  const effective = this.effectiveWritePermissions(input.initiator, input.conversationOwnerUserId, workId);
1243
1249
  // 先统一做逐操作校验,收集标题与详情。