@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.
@@ -0,0 +1,2279 @@
1
+ import { z } from "zod";
2
+ import { analysisTaskReadModules } from "./user-auth.js";
3
+ import { AppError } from "./errors.js";
4
+ import { logger } from "./logger.js";
5
+ import { id as randomId, json, now } from "./utils.js";
6
+ import { canReadWorkModule, canWriteWorkModule, emptyWorkModulePermissions, fullWorkModulePermissions, storedWorkModulePermissions, workPermissionModuleLabels } from "./work-permissions.js";
7
+ /**
8
+ * AI 可写工具与持久化审批工作流。
9
+ *
10
+ * 设计要点(与 issue 需求一一对应):
11
+ * - AI 永远只提交“修改计划”,确认接口只接收审批 ID;计划内容由系统根据当前数据库生成并固化。
12
+ * - 所有可写操作在执行前重新校验双方权限、工具开关与目标版本,任何变化都会把计划标记为已失效。
13
+ * - 单个计划的执行在一个数据库事务内完成,失败整体回滚,保证不产生半成品数据。
14
+ */
15
+ // ---------------------------------------------------------------------------
16
+ // 工具开关常量
17
+ // ---------------------------------------------------------------------------
18
+ /** 可独立开关的 AI 写入类工具:与作品设置页的开关一一对应。 */
19
+ export const AI_WRITE_TOOL_IDS = [
20
+ "settings",
21
+ "characters",
22
+ "races",
23
+ "organizations",
24
+ "timeline",
25
+ "relationships",
26
+ "outlines",
27
+ "annotations",
28
+ "analysis_tasks",
29
+ "ask_user_questions"
30
+ ];
31
+ export const aiWriteToolLabels = {
32
+ settings: "世界设定",
33
+ characters: "角色",
34
+ races: "种族",
35
+ organizations: "组织",
36
+ timeline: "时间线",
37
+ relationships: "人物关系",
38
+ outlines: "大纲/伏笔",
39
+ annotations: "正文评论与待办",
40
+ analysis_tasks: "分析任务",
41
+ ask_user_questions: "用户提问"
42
+ };
43
+ export const aiWriteToolDescriptions = {
44
+ settings: "允许侧边栏 AI 创建或编辑世界设定词条(不能删除)。",
45
+ characters: "允许侧边栏 AI 创建或编辑角色条目(不能删除)。",
46
+ races: "允许侧边栏 AI 创建或编辑种族设定(不能删除)。",
47
+ organizations: "允许侧边栏 AI 创建或编辑组织设定(不能删除成员归属之外的内容,不能删除组织)。",
48
+ timeline: "允许侧边栏 AI 创建或编辑时间轴轨道与事件(不能删除)。",
49
+ relationships: "允许侧边栏 AI 创建或编辑人物关系(不能删除)。",
50
+ outlines: "允许侧边栏 AI 编辑章节大纲以及创建或编辑伏笔(不能删除)。",
51
+ annotations: "允许侧边栏 AI 复用现有批注能力,在正文指定位置创建评论或待办。",
52
+ analysis_tasks: "允许侧边栏 AI 触发已有类型的分析任务进入现有队列。",
53
+ ask_user_questions: "允许侧边栏 AI 通过 AskUserQuestions 向用户提出单选问题。"
54
+ };
55
+ /** 全部关闭时的默认开关状态。 */
56
+ export function defaultAiWriteToolToggles() {
57
+ return Object.fromEntries(AI_WRITE_TOOL_IDS.map((toolId) => [toolId, false]));
58
+ }
59
+ const aiWriteToolIdSchema = z.enum(AI_WRITE_TOOL_IDS);
60
+ /** 作品设置页提交的工具开关增量;未知工具 ID 由管理器在合并时拒绝。 */
61
+ export const aiWriteToolsUpdateSchema = z.object({
62
+ tools: z.record(z.string(), z.boolean()).refine((value) => Object.keys(value).length > 0, { message: "至少需要更新一个工具开关" })
63
+ }).strict();
64
+ // ---------------------------------------------------------------------------
65
+ // 计划上限解析
66
+ // ---------------------------------------------------------------------------
67
+ export const DEFAULT_AI_WRITE_PLAN_MAX_OPERATIONS = 5;
68
+ export const MIN_AI_WRITE_PLAN_MAX_OPERATIONS = 1;
69
+ export const MAX_AI_WRITE_PLAN_MAX_OPERATIONS = 20;
70
+ /**
71
+ * 解析环境变量 AI_WRITE_PLAN_MAX_OPERATIONS。
72
+ * 默认 5,有效范围 1-20;超出范围或非法值时直接抛出错误阻止启动。
73
+ */
74
+ export function resolveAiWritePlanMaxOperations(rawEnvironmentValue) {
75
+ const raw = rawEnvironmentValue === undefined || rawEnvironmentValue === null ? "" : String(rawEnvironmentValue).trim();
76
+ if (!raw)
77
+ return DEFAULT_AI_WRITE_PLAN_MAX_OPERATIONS;
78
+ if (!/^\d+$/u.test(raw)) {
79
+ throw new Error(`环境变量 AI_WRITE_PLAN_MAX_OPERATIONS 的值无效:"${raw}"(必须为 1-20 的整数)`);
80
+ }
81
+ const parsed = Number(raw);
82
+ if (parsed < MIN_AI_WRITE_PLAN_MAX_OPERATIONS || parsed > MAX_AI_WRITE_PLAN_MAX_OPERATIONS) {
83
+ throw new Error(`环境变量 AI_WRITE_PLAN_MAX_OPERATIONS 的值 ${parsed} 超出有效范围(1-20)`);
84
+ }
85
+ return parsed;
86
+ }
87
+ // ---------------------------------------------------------------------------
88
+ // 时间与有效期
89
+ // ---------------------------------------------------------------------------
90
+ /** 待确认计划的有效期:默认 24 小时。 */
91
+ export const AI_WRITE_PLAN_TTL_MS = 24 * 60 * 60 * 1000;
92
+ /** 用户提问的有效期:默认 10 分钟。 */
93
+ export const AI_USER_QUESTION_TTL_MS = 10 * 60 * 1000;
94
+ function isoFromNow(baseIso, ttlMs) {
95
+ const base = Date.parse(baseIso);
96
+ return Number.isFinite(base) ? new Date(base + ttlMs).toISOString() : baseIso;
97
+ }
98
+ // ---------------------------------------------------------------------------
99
+ // 操作输入模式(字段白名单与既有路由 schema 对齐)
100
+ // ---------------------------------------------------------------------------
101
+ const identifierSchema = z.string().trim().min(1).max(200);
102
+ const jsonObjectSchema = z.record(z.string(), z.unknown());
103
+ /** 设定条目可写字段:锁定标记、审核状态等治理字段保留给人工。 */
104
+ const settingInputSchema = z.object({
105
+ title: z.string().trim().min(1).max(200),
106
+ category: z.string().trim().min(1).max(100),
107
+ content: z.string().trim().min(1).max(200_000),
108
+ tags: z.array(z.string().trim().min(1).max(200)).max(50).optional(),
109
+ authorNote: z.string().max(20_000).optional()
110
+ }).strict();
111
+ /** 角色可写字段:合并、锁定字段、首次出场章节保留给人工。 */
112
+ const characterInputSchema = z.object({
113
+ name: z.string().trim().min(1).max(200),
114
+ isDead: z.boolean().optional(),
115
+ code: z.string().trim().max(200).optional(),
116
+ aliases: z.array(z.string().trim().min(1).max(200)).max(100).optional(),
117
+ raceId: identifierSchema.nullable().optional(),
118
+ organizationIds: z.array(identifierSchema).max(100).optional(),
119
+ attributes: jsonObjectSchema.optional(),
120
+ profile: jsonObjectSchema.optional(),
121
+ currentState: jsonObjectSchema.optional()
122
+ }).strict();
123
+ /** 种族可写字段:成员归属与分节设定结构保留给人工。 */
124
+ const raceInputSchema = z.object({
125
+ name: z.string().trim().min(1).max(200),
126
+ isExtinct: z.boolean().optional(),
127
+ parentRaceId: identifierSchema.nullable().optional(),
128
+ description: z.string().max(100_000).optional(),
129
+ settingsMarkdown: z.string().max(200_000).optional()
130
+ }).strict();
131
+ /** 组织可写字段:与种族一致,但不含父子层级。 */
132
+ const organizationInputSchema = z.object({
133
+ name: z.string().trim().min(1).max(200),
134
+ isDissolved: z.boolean().optional(),
135
+ description: z.string().max(100_000).optional(),
136
+ settingsMarkdown: z.string().max(200_000).optional()
137
+ }).strict();
138
+ const timelineTrackInputSchema = z.object({
139
+ name: z.string().trim().min(1).max(200),
140
+ description: z.string().max(20_000).optional(),
141
+ sortOrder: z.number().int().min(0).max(100_000).optional()
142
+ }).strict();
143
+ const timelineEventInputSchema = z.object({
144
+ name: z.string().trim().min(1).max(300),
145
+ trackId: identifierSchema.nullable().optional(),
146
+ description: z.string().max(100_000).optional(),
147
+ eventType: z.string().max(100).optional(),
148
+ timeLabel: z.string().max(300).optional(),
149
+ timeSort: z.number().finite().nullable().optional(),
150
+ chapterIds: z.array(identifierSchema).max(100).optional(),
151
+ participantIds: z.array(identifierSchema).max(200).optional(),
152
+ location: z.string().max(500).optional(),
153
+ causes: z.array(z.string().trim().min(1).max(200)).max(30).optional(),
154
+ impactScope: z.enum(["personal", "organization", "regional", "world", "galaxy"]).optional(),
155
+ status: z.enum(["candidate", "pending", "confirmed", "deprecated"]).optional()
156
+ }).strict();
157
+ /**
158
+ * 人物关系:分类必须是既有枚举;update 不允许改端点人物,
159
+ * 避免 AI 借机重接关系指向其他作品的人物。
160
+ */
161
+ const relationshipCreateInputSchema = z.object({
162
+ fromCharacterId: identifierSchema,
163
+ toCharacterId: identifierSchema,
164
+ category: z.enum(["family", "social", "emotional", "conflict", "uncertain"]),
165
+ subtype: z.string().max(100).optional(),
166
+ keywords: z.array(z.string().trim().min(1).max(100)).max(30).optional(),
167
+ directed: z.boolean().optional(),
168
+ currentStatus: z.string().max(100).optional(),
169
+ timeRange: jsonObjectSchema.optional(),
170
+ confidence: z.number().min(0).max(1).optional()
171
+ }).strict().refine((input) => input.fromCharacterId !== input.toCharacterId, {
172
+ message: "人物关系不能指向自身"
173
+ });
174
+ const relationshipUpdateInputSchema = z.object({
175
+ category: z.enum(["family", "social", "emotional", "conflict", "uncertain"]).optional(),
176
+ subtype: z.string().max(100).optional(),
177
+ keywords: z.array(z.string().trim().min(1).max(100)).max(30).optional(),
178
+ directed: z.boolean().optional(),
179
+ currentStatus: z.string().max(100).optional(),
180
+ timeRange: jsonObjectSchema.optional(),
181
+ confidence: z.number().min(0).max(1).optional()
182
+ }).strict().refine((input) => Object.keys(input).length > 0, {
183
+ message: "至少需要提供一个修改字段"
184
+ });
185
+ const chapterOutlineInputSchema = z.object({
186
+ goal: z.string().max(100_000).optional(),
187
+ conflict: z.string().max(100_000).optional(),
188
+ turningPoint: z.string().max(100_000).optional(),
189
+ notes: z.string().max(100_000).optional(),
190
+ status: z.enum(["draft", "ready", "completed"]).optional()
191
+ }).strict();
192
+ const foreshadowInputSchema = z.object({
193
+ title: z.string().trim().min(1).max(300),
194
+ description: z.string().max(100_000).optional(),
195
+ status: z.enum(["planned", "planted", "resolved", "abandoned"]).optional(),
196
+ importance: z.enum(["low", "medium", "high"]).optional(),
197
+ plannedPayoffChapterId: identifierSchema.nullable().optional(),
198
+ resolutionNote: z.string().max(100_000).optional()
199
+ }).strict();
200
+ /** 词条实体类型:用于 create_entry / update_entry。 */
201
+ export const AI_ENTRY_ENTITY_TYPES = [
202
+ "setting",
203
+ "character",
204
+ "race",
205
+ "organization",
206
+ "timeline-track",
207
+ "timeline-event",
208
+ "relationship",
209
+ "chapter-outline",
210
+ "foreshadow"
211
+ ];
212
+ const entryEntityTypeSchema = z.enum(AI_ENTRY_ENTITY_TYPES);
213
+ const entryEntitySchemas = {
214
+ setting: settingInputSchema,
215
+ character: characterInputSchema,
216
+ race: raceInputSchema,
217
+ organization: organizationInputSchema,
218
+ "timeline-track": timelineTrackInputSchema,
219
+ "timeline-event": timelineEventInputSchema,
220
+ relationship: relationshipCreateInputSchema,
221
+ "chapter-outline": chapterOutlineInputSchema,
222
+ foreshadow: foreshadowInputSchema
223
+ };
224
+ /** 编辑操作:所有实体类型都使用 partial 形态,且必须包含至少一个修改字段。 */
225
+ const entryEntityUpdateSchemas = {
226
+ setting: settingInputSchema.partial().refine(hasAtLeastOneField, { message: "至少需要提供一个修改字段" }),
227
+ character: characterInputSchema.partial().refine(hasAtLeastOneField, { message: "至少需要提供一个修改字段" }),
228
+ race: raceInputSchema.partial().refine(hasAtLeastOneField, { message: "至少需要提供一个修改字段" }),
229
+ organization: organizationInputSchema.partial().refine(hasAtLeastOneField, { message: "至少需要提供一个修改字段" }),
230
+ "timeline-track": timelineTrackInputSchema.partial().refine(hasAtLeastOneField, { message: "至少需要提供一个修改字段" }),
231
+ "timeline-event": timelineEventInputSchema.partial().refine(hasAtLeastOneField, { message: "至少需要提供一个修改字段" }),
232
+ relationship: relationshipUpdateInputSchema,
233
+ "chapter-outline": chapterOutlineInputSchema.partial().refine(hasAtLeastOneField, { message: "至少需要提供一个修改字段" }),
234
+ foreshadow: foreshadowInputSchema.partial().refine(hasAtLeastOneField, { message: "至少需要提供一个修改字段" })
235
+ };
236
+ function toolInputJsonSchema(schema) {
237
+ const { $schema: _dialect, ...jsonSchema } = z.toJSONSchema(schema, {
238
+ target: "draft-07",
239
+ unrepresentable: "any",
240
+ io: "input"
241
+ });
242
+ return jsonSchema;
243
+ }
244
+ /**
245
+ * 为模型生成与服务端严格校验一致的操作 schema。
246
+ *
247
+ * 每个操作类型和实体类型都使用独立分支,避免通用对象把 entityId、scope 等
248
+ * 仅属于其他操作的字段错误地暴露给 create_entry。
249
+ */
250
+ export function aiWritePlanOperationToolSchemas(toggles) {
251
+ const entityTypes = [
252
+ ...(toggles.settings ? ["setting"] : []),
253
+ ...(toggles.characters ? ["character"] : []),
254
+ ...(toggles.races ? ["race"] : []),
255
+ ...(toggles.organizations ? ["organization"] : []),
256
+ ...(toggles.timeline ? ["timeline-track", "timeline-event"] : []),
257
+ ...(toggles.relationships ? ["relationship"] : []),
258
+ ...(toggles.outlines ? ["chapter-outline", "foreshadow"] : [])
259
+ ];
260
+ const identifierJsonSchema = { type: "string", minLength: 1, maxLength: 200 };
261
+ const operations = entityTypes.flatMap((entityType) => {
262
+ const targetProperty = entityType === "chapter-outline"
263
+ ? { chapterId: identifierJsonSchema }
264
+ : { entityId: identifierJsonSchema };
265
+ const targetName = entityType === "chapter-outline" ? "chapterId" : "entityId";
266
+ return [
267
+ {
268
+ type: "object",
269
+ description: `新建 ${entityType};系统会生成对象 ID,不得传 entityId。`,
270
+ properties: {
271
+ opType: { type: "string", enum: ["create_entry"] },
272
+ entityType: { type: "string", enum: [entityType] },
273
+ ...(entityType === "chapter-outline" ? { chapterId: identifierJsonSchema } : {}),
274
+ input: toolInputJsonSchema(entryEntitySchemas[entityType])
275
+ },
276
+ required: ["opType", "entityType", ...(entityType === "chapter-outline" ? ["chapterId"] : []), "input"],
277
+ additionalProperties: false
278
+ },
279
+ {
280
+ type: "object",
281
+ description: `编辑已有 ${entityType};${targetName} 必须来自读取工具返回的真实对象。`,
282
+ properties: {
283
+ opType: { type: "string", enum: ["update_entry"] },
284
+ entityType: { type: "string", enum: [entityType] },
285
+ ...targetProperty,
286
+ input: toolInputJsonSchema(entryEntityUpdateSchemas[entityType])
287
+ },
288
+ required: ["opType", "entityType", targetName, "input"],
289
+ additionalProperties: false
290
+ }
291
+ ];
292
+ });
293
+ if (toggles.annotations) {
294
+ operations.push({
295
+ type: "object",
296
+ description: "在已有章节的行区间创建评论或待办。",
297
+ properties: {
298
+ opType: { type: "string", enum: ["create_annotation"] },
299
+ chapterId: identifierJsonSchema,
300
+ kind: { type: "string", enum: ["note", "todo"] },
301
+ startLine: { type: "integer", minimum: 1 },
302
+ endLine: { type: "integer", minimum: 1 },
303
+ note: { type: "string", minLength: 1, maxLength: 2000 }
304
+ },
305
+ required: ["opType", "chapterId", "kind", "startLine", "endLine", "note"],
306
+ additionalProperties: false
307
+ });
308
+ }
309
+ if (toggles.analysis_tasks) {
310
+ operations.push({
311
+ type: "object",
312
+ description: "使用当前已固化模型和范围创建分析任务。",
313
+ properties: {
314
+ opType: { type: "string", enum: ["create_task"] },
315
+ taskType: { type: "string", enum: [...AI_ANALYSIS_TASK_TYPES, "relationship-analysis"] },
316
+ scope: { type: "object" },
317
+ modelId: identifierJsonSchema
318
+ },
319
+ required: ["opType", "taskType"],
320
+ additionalProperties: false
321
+ });
322
+ }
323
+ return operations;
324
+ }
325
+ function hasAtLeastOneField(value) {
326
+ return Object.keys(value).length > 0;
327
+ }
328
+ /** 分析任务类型:镜像 src/app.ts 的 analysisTaskTypeSchema。 */
329
+ export const AI_ANALYSIS_TASK_TYPES = [
330
+ "structure",
331
+ "chapter-analysis",
332
+ "character-extraction",
333
+ "character-summary",
334
+ "character-identity-audit",
335
+ "timeline-analysis",
336
+ "worldview-analysis",
337
+ "setting-extraction",
338
+ "consistency-check",
339
+ "report-update",
340
+ "book-analysis"
341
+ ];
342
+ export const aiAnalysisTaskTypeLabels = {
343
+ structure: "结构分析",
344
+ "chapter-analysis": "章节分析",
345
+ "character-extraction": "角色抽取",
346
+ "character-summary": "角色小结",
347
+ "character-identity-audit": "身份一致性审计",
348
+ "timeline-analysis": "时间线分析",
349
+ "worldview-analysis": "世界观分析",
350
+ "setting-extraction": "设定抽取",
351
+ "consistency-check": "一致性检查",
352
+ "report-update": "报告更新",
353
+ "book-analysis": "整书分析",
354
+ "relationship-analysis": "人物关系分析"
355
+ };
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);
387
+ export const createAiWritePlanInputSchema = z.object({
388
+ aiSummary: z.string().trim().min(1).max(2000),
389
+ operations: z.unknown()
390
+ }).strict();
391
+ /** 提问选项数量限制:一次一个问题和 2-6 个预设选项。 */
392
+ export const MIN_AI_QUESTION_OPTIONS = 2;
393
+ export const MAX_AI_QUESTION_OPTIONS = 6;
394
+ export const MAX_AI_QUESTION_ANSWER_CHARS = 3000;
395
+ export const askAiUserQuestionInputSchema = z.object({
396
+ question: z.string().trim().min(1).max(2000),
397
+ options: z.array(z.string().trim().min(1).max(200))
398
+ .min(MIN_AI_QUESTION_OPTIONS)
399
+ .max(MAX_AI_QUESTION_OPTIONS)
400
+ }).strict();
401
+ export const answerAiUserQuestionSchema = z.object({
402
+ selectedOption: z.number().int().min(0).optional(),
403
+ customAnswer: z.string().trim().min(1).max(MAX_AI_QUESTION_ANSWER_CHARS).optional()
404
+ }).strict().refine(
405
+ // 至少提供一种回答;选择预设项后仍可附带自定义补充信息。
406
+ (input) => input.selectedOption !== undefined || input.customAnswer !== undefined, { message: "必须选择预设选项或填写自定义回答" });
407
+ // ---------------------------------------------------------------------------
408
+ // 标签与展示辅助
409
+ // ---------------------------------------------------------------------------
410
+ export function aiEntryEntityTypeLabel(entityType) {
411
+ switch (entityType) {
412
+ case "setting": return "世界设定";
413
+ case "character": return "角色";
414
+ case "race": return "种族";
415
+ case "organization": return "组织";
416
+ case "timeline-track": return "时间轴轨道";
417
+ case "timeline-event": return "时间轴事件";
418
+ case "relationship": return "人物关系";
419
+ case "chapter-outline": return "章节大纲";
420
+ case "foreshadow": return "伏笔";
421
+ }
422
+ }
423
+ const opTypeLabels = {
424
+ create_entry: "新增",
425
+ update_entry: "编辑",
426
+ create_annotation: "新增批注",
427
+ create_task: "新建分析任务"
428
+ };
429
+ export function aiOpTypeLabel(opType) {
430
+ return opTypeLabels[opType] ?? opType;
431
+ }
432
+ export const aiPlanStatusLabels = {
433
+ pending: "待确认",
434
+ rejected: "已拒绝",
435
+ expired: "已过期",
436
+ invalidated: "已失效",
437
+ executing: "执行中",
438
+ executed: "执行成功",
439
+ failed: "执行失败"
440
+ };
441
+ /** 审批计划的全部状态,供路由层校验过滤参数。 */
442
+ export const AI_WRITE_PLAN_STATUSES = Object.keys(aiPlanStatusLabels);
443
+ export const aiQuestionStatusLabels = {
444
+ pending: "待回答",
445
+ answered: "已回答",
446
+ rejected: "已拒绝",
447
+ expired: "已过期"
448
+ };
449
+ /** 提问的全部状态,供路由层校验过滤参数。 */
450
+ export const AI_USER_QUESTION_STATUSES = Object.keys(aiQuestionStatusLabels);
451
+ export const annotationKindLabels = {
452
+ note: "评论",
453
+ todo: "待办"
454
+ };
455
+ const fieldLabelsByEntity = {
456
+ setting: {
457
+ title: "标题",
458
+ category: "分类",
459
+ content: "内容",
460
+ tags: "标签",
461
+ authorNote: "作者备注"
462
+ },
463
+ character: {
464
+ name: "姓名",
465
+ isDead: "死亡状态",
466
+ code: "代号",
467
+ aliases: "别名",
468
+ raceId: "所属种族",
469
+ organizationIds: "所属组织",
470
+ attributes: "扩展属性",
471
+ profile: "人物档案",
472
+ currentState: "当前状态"
473
+ },
474
+ race: {
475
+ name: "名称",
476
+ isExtinct: "是否灭亡",
477
+ parentRaceId: "父级种族",
478
+ description: "描述",
479
+ settingsMarkdown: "设定内容"
480
+ },
481
+ organization: {
482
+ name: "名称",
483
+ isDissolved: "是否解散",
484
+ description: "描述",
485
+ settingsMarkdown: "设定内容"
486
+ },
487
+ "timeline-track": {
488
+ name: "轨道名称",
489
+ description: "说明",
490
+ sortOrder: "排序值"
491
+ },
492
+ "timeline-event": {
493
+ name: "事件名称",
494
+ trackId: "所属轨道",
495
+ description: "描述",
496
+ eventType: "事件类型",
497
+ timeLabel: "时间标签",
498
+ timeSort: "时间排序",
499
+ chapterIds: "关联章节",
500
+ participantIds: "参与人物",
501
+ location: "地点",
502
+ causes: "起因",
503
+ impactScope: "影响范围",
504
+ status: "状态"
505
+ },
506
+ relationship: {
507
+ fromCharacterId: "起始人物",
508
+ toCharacterId: "目标人物",
509
+ category: "关系类别",
510
+ subtype: "关系细分",
511
+ keywords: "关键词",
512
+ directed: "是否有向",
513
+ currentStatus: "当前状态",
514
+ timeRange: "时间范围",
515
+ confidence: "置信度"
516
+ },
517
+ "chapter-outline": {
518
+ goal: "章节目标",
519
+ conflict: "冲突",
520
+ turningPoint: "转折点",
521
+ notes: "笔记",
522
+ status: "状态"
523
+ },
524
+ foreshadow: {
525
+ title: "标题",
526
+ description: "描述",
527
+ status: "状态",
528
+ importance: "重要程度",
529
+ plannedPayoffChapterId: "计划回收章节",
530
+ resolutionNote: "回收说明"
531
+ }
532
+ };
533
+ export const impactScopeLabels = {
534
+ personal: "个人",
535
+ organization: "组织",
536
+ regional: "地区",
537
+ world: "世界",
538
+ galaxy: "星系"
539
+ };
540
+ const outlineStatusLabels = { draft: "草稿", ready: "就绪", completed: "已完成" };
541
+ const foreshadowStatusLabels = { planned: "规划中", planted: "已埋设", resolved: "已回收", abandoned: "已废弃" };
542
+ const eventStatusLabels = { candidate: "候选", pending: "待定", confirmed: "已确认", deprecated: "已弃用" };
543
+ const relationshipCategoryLabels = {
544
+ family: "亲缘",
545
+ social: "社会",
546
+ emotional: "情感",
547
+ conflict: "冲突",
548
+ uncertain: "不确定"
549
+ };
550
+ /** 按 `${entityType}.${key}` 定位的枚举值中文标签。 */
551
+ const enumValueLabelsByField = {
552
+ "timeline-event.impactScope": impactScopeLabels,
553
+ "timeline-event.status": eventStatusLabels,
554
+ "chapter-outline.status": outlineStatusLabels,
555
+ "foreshadow.status": foreshadowStatusLabels,
556
+ "relationship.category": relationshipCategoryLabels
557
+ };
558
+ function completeCreatePreview(entityType, input) {
559
+ const defaults = {
560
+ setting: { tags: [], authorNote: "" },
561
+ character: { isDead: false, code: "", aliases: [], raceId: null, organizationIds: [], attributes: {}, profile: {}, currentState: {} },
562
+ race: { isExtinct: false, parentRaceId: null, description: "", settingsMarkdown: "" },
563
+ organization: { isDissolved: false, description: "", settingsMarkdown: "" },
564
+ "timeline-track": { description: "", sortOrder: 0 },
565
+ "timeline-event": {
566
+ trackId: null,
567
+ description: "",
568
+ eventType: "other",
569
+ timeLabel: "时间待定",
570
+ timeSort: null,
571
+ chapterIds: [],
572
+ participantIds: [],
573
+ location: "",
574
+ causes: [],
575
+ impactScope: "personal",
576
+ status: "candidate"
577
+ },
578
+ relationship: { subtype: "", keywords: [], directed: false, currentStatus: "", timeRange: {}, confidence: 1 },
579
+ "chapter-outline": { goal: "", conflict: "", turningPoint: "", notes: "", status: "draft" },
580
+ foreshadow: { description: "", status: "planned", importance: "medium", plannedPayoffChapterId: null, resolutionNote: "" }
581
+ };
582
+ return { ...defaults[entityType], ...input };
583
+ }
584
+ /** 字段值的人类可读展示(不泄露模型密钥等信息,仅面向业务字段)。 */
585
+ export function formatFieldValue(contextKey, value) {
586
+ if (value === undefined || value === "")
587
+ return "";
588
+ if (value === null)
589
+ return "空";
590
+ if (typeof value === "boolean")
591
+ return value ? "是" : "否";
592
+ if (Array.isArray(value)) {
593
+ if (value.length === 0)
594
+ return "空列表";
595
+ return value.map((item) => formatFieldValue(contextKey, item)).join("、");
596
+ }
597
+ if (typeof value === "object")
598
+ return JSON.stringify(value);
599
+ const text = String(value);
600
+ return enumValueLabelsByField[contextKey]?.[text] ?? text;
601
+ }
602
+ const MAX_DIFF_LINES = 200;
603
+ const MAX_DIFF_MATRIX = 120 * 120;
604
+ function splitLines(text) {
605
+ // 空文本视为零行,避免空字符串到有内容时出现虚假的删除行。
606
+ return text === "" ? [] : text.split(/\r?\n/u);
607
+ }
608
+ /**
609
+ * 行级 diff:对短文本使用 LCS,超出矩阵规模的退化为首尾公共前后缀裁剪,
610
+ * 保证长文本 diff 有界且稳定。
611
+ */
612
+ export function lineDiff(beforeText, afterText) {
613
+ const before = splitLines(String(beforeText ?? ""));
614
+ const after = splitLines(String(afterText ?? ""));
615
+ if (before.length * after.length <= MAX_DIFF_MATRIX)
616
+ return lcsDiffLines(before, after);
617
+ return boundedDiffLines(before, after);
618
+ }
619
+ function lcsDiffLines(before, after) {
620
+ const rows = before.length;
621
+ const columns = after.length;
622
+ const stride = columns + 1;
623
+ const lengths = new Uint32Array((rows + 1) * stride);
624
+ const readLength = (row, column) => lengths[row * stride + column] ?? 0;
625
+ for (let i = rows - 1; i >= 0; i -= 1) {
626
+ for (let j = columns - 1; j >= 0; j -= 1) {
627
+ lengths[i * stride + j] = before[i] === after[j]
628
+ ? readLength(i + 1, j + 1) + 1
629
+ : Math.max(readLength(i + 1, j), readLength(i, j + 1));
630
+ }
631
+ }
632
+ const lines = [];
633
+ let i = 0;
634
+ let j = 0;
635
+ let last = null;
636
+ const push = (kind, text) => {
637
+ if (last && last.kind === kind && last.text === text)
638
+ return;
639
+ last = { kind, text };
640
+ lines.push(last);
641
+ };
642
+ while (i < rows && j < columns) {
643
+ if (before[i] === after[j]) {
644
+ push("same", before[i] ?? "");
645
+ i += 1;
646
+ j += 1;
647
+ }
648
+ else if (readLength(i + 1, j) >= readLength(i, j + 1)) {
649
+ push("del", before[i] ?? "");
650
+ i += 1;
651
+ }
652
+ else {
653
+ push("add", after[j] ?? "");
654
+ j += 1;
655
+ }
656
+ }
657
+ while (i < rows) {
658
+ push("del", before[i] ?? "");
659
+ i += 1;
660
+ }
661
+ while (j < columns) {
662
+ push("add", after[j] ?? "");
663
+ j += 1;
664
+ }
665
+ return collapseConsecutive(lines);
666
+ }
667
+ function boundedDiffLines(before, after) {
668
+ let prefixStart = 0;
669
+ while (prefixStart < before.length && prefixStart < after.length && before[prefixStart] === after[prefixStart])
670
+ prefixStart += 1;
671
+ let suffixLength = 0;
672
+ while (suffixLength < before.length - prefixStart
673
+ && suffixLength < after.length - prefixStart
674
+ && before[before.length - 1 - suffixLength] === after[after.length - 1 - suffixLength])
675
+ suffixLength += 1;
676
+ const removedCore = before.slice(prefixStart, before.length - suffixLength);
677
+ const addedCore = after.slice(prefixStart, after.length - suffixLength);
678
+ const suffix = before.slice(before.length - suffixLength).map((text) => ({ kind: "same", text }));
679
+ const head = before.slice(0, prefixStart).map((text) => ({ kind: "same", text }));
680
+ const tailDiff = collapseConsecutive([
681
+ ...removedCore.map((text) => ({ kind: "del", text })),
682
+ ...addedCore.map((text) => ({ kind: "add", text }))
683
+ ]);
684
+ return [...head, ...tailDiff, ...suffix];
685
+ }
686
+ function collapseConsecutive(lines) {
687
+ if (lines.length <= MAX_DIFF_LINES)
688
+ return mergeAdjacent(lines);
689
+ const kept = mergeAdjacent(lines.filter((line) => line.kind !== "same"));
690
+ const added = kept.filter((line) => line.kind === "add").length;
691
+ const removed = kept.filter((line) => line.kind === "del").length;
692
+ return [
693
+ ...kept.slice(0, MAX_DIFF_LINES - 2),
694
+ { kind: "same", text: `… 其余变更行已省略(新增 ${added} 行 / 删除 ${removed} 行)` }
695
+ ];
696
+ }
697
+ function mergeAdjacent(lines) {
698
+ const merged = [];
699
+ for (const line of lines) {
700
+ const previous = merged[merged.length - 1];
701
+ if (previous && previous.kind === line.kind)
702
+ previous.text = `${previous.text}\n${line.text}`;
703
+ else
704
+ merged.push({ ...line });
705
+ }
706
+ return merged;
707
+ }
708
+ export function summarizeLineChanges(lines) {
709
+ let added = 0;
710
+ let removed = 0;
711
+ for (const line of lines) {
712
+ if (line.kind === "add")
713
+ added += 1;
714
+ if (line.kind === "del")
715
+ removed += 1;
716
+ }
717
+ return { added, removed };
718
+ }
719
+ /**
720
+ * 基于当前库内值与计划输入构建字段级 diff。
721
+ * 未出现在 updates 中的字段不会出现;出现的字段如果与当前值一致则仍显示(changed=false)。
722
+ */
723
+ export function buildFieldDiffs(entityType, current, updates) {
724
+ const labels = fieldLabelsByEntity[entityType] ?? {};
725
+ const items = [];
726
+ for (const key of Object.keys(updates)) {
727
+ const after = updates[key];
728
+ const rawBefore = current ? current[key] : undefined;
729
+ const equalValues = sameComparableValue(rawBefore, after);
730
+ items.push({
731
+ key,
732
+ label: labels[key] ?? key,
733
+ beforeRaw: rawBefore ?? null,
734
+ before: current === null ? "" : formatFieldValue(`${entityType}.${key}`, rawBefore),
735
+ after: formatFieldValue(`${entityType}.${key}`, after),
736
+ changed: !equalValues,
737
+ lines: equalValues ? [] : lineDiff(textualize(rawBefore), textualize(after))
738
+ });
739
+ }
740
+ return items;
741
+ }
742
+ function textualize(value) {
743
+ if (value === undefined || value === null)
744
+ return "";
745
+ if (typeof value === "string")
746
+ return value;
747
+ if (typeof value === "object")
748
+ return JSON.stringify(value, null, 2);
749
+ return String(value);
750
+ }
751
+ /** 比较库内值与计划值是否一致;undefined 与 null 视为相同。 */
752
+ function sameComparableValue(left, right) {
753
+ return JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
754
+ }
755
+ // ---------------------------------------------------------------------------
756
+ // 权限交叉计算
757
+ // ---------------------------------------------------------------------------
758
+ const moduleAccessRank = { none: 0, read: 1, write: 2 };
759
+ /** 取两个用户权限的交集:任一方没有读取权限即视为不可见,写入需双方均可写。 */
760
+ export function intersectWorkModulePermissions(left, right) {
761
+ const result = {};
762
+ for (const module of Object.keys(left)) {
763
+ const rankLeft = moduleAccessRank[left[module]];
764
+ const rankRight = moduleAccessRank[right[module]];
765
+ const minRank = Math.min(rankLeft, rankRight);
766
+ result[module] = minRank >= 2 ? "write" : minRank >= 1 ? "read" : "none";
767
+ }
768
+ return result;
769
+ }
770
+ const moduleForTool = {
771
+ settings: "settings",
772
+ characters: "characters",
773
+ races: "races",
774
+ organizations: "organizations",
775
+ timeline: "timeline",
776
+ relationships: "relationships",
777
+ outlines: "outlines"
778
+ };
779
+ function moduleForEntityType(entityType) {
780
+ switch (entityType) {
781
+ case "setting": return "settings";
782
+ case "character": return "characters";
783
+ case "race": return "races";
784
+ case "organization": return "organizations";
785
+ case "timeline-track":
786
+ case "timeline-event": return "timeline";
787
+ case "relationship": return "relationships";
788
+ case "chapter-outline":
789
+ case "foreshadow": return "outlines";
790
+ }
791
+ }
792
+ /**
793
+ * 推导一个规范化操作的权限需求:
794
+ * - 写入对应模块写权限;注释需要正文权限;任务需要 AI 分析权限加上范围材料的读取权限。
795
+ */
796
+ export function planOperationRequirements(operation) {
797
+ if (operation.opType === "create_annotation") {
798
+ return { toolId: "annotations", writeModules: ["prose"], readModules: [] };
799
+ }
800
+ if (operation.opType === "create_task") {
801
+ const readModules = analysisTaskReadModules(operation.taskType, operation.scope ?? { type: "book" });
802
+ return { toolId: "analysis_tasks", writeModules: ["ai-analysis"], readModules };
803
+ }
804
+ const module = moduleForEntityType(operation.entityType);
805
+ const toolId = module === "relationships"
806
+ ? "relationships"
807
+ : (Object.entries(moduleForTool).find(([, value]) => value === module)?.[0] ?? "outlines");
808
+ const writeModules = new Set([module]);
809
+ const readModules = new Set();
810
+ const input = operation.input;
811
+ if (operation.entityType === "character") {
812
+ if (Object.prototype.hasOwnProperty.call(input, "raceId"))
813
+ writeModules.add("races");
814
+ if (Object.prototype.hasOwnProperty.call(input, "organizationIds"))
815
+ writeModules.add("organizations");
816
+ }
817
+ if (operation.entityType === "timeline-event") {
818
+ if (Object.prototype.hasOwnProperty.call(input, "chapterIds"))
819
+ writeModules.add("prose");
820
+ if (Object.prototype.hasOwnProperty.call(input, "participantIds"))
821
+ writeModules.add("characters");
822
+ }
823
+ if (operation.entityType === "relationship" && operation.opType === "create_entry") {
824
+ writeModules.add("characters");
825
+ }
826
+ if (operation.entityType === "chapter-outline")
827
+ readModules.add("prose");
828
+ if (operation.entityType === "foreshadow" && Object.prototype.hasOwnProperty.call(input, "plannedPayoffChapterId")) {
829
+ readModules.add("prose");
830
+ }
831
+ return { toolId: toolId, writeModules: [...writeModules], readModules: [...readModules] };
832
+ }
833
+ export function normalizePlanOperations(rawOperations, maxOperations) {
834
+ const rawList = planOperationsSchema.max(maxOperations, `单次计划最多包含 ${maxOperations} 个操作`).safeParse(rawOperations);
835
+ if (!rawList.success) {
836
+ const firstIssue = rawList.error.issues[0];
837
+ throw new AppError(400, "AI_PLAN_OPERATION_INVALID", `${firstIssue?.path.join(".") || "operation"}:${firstIssue?.message ?? "操作格式无效"}`);
838
+ }
839
+ const normalized = [];
840
+ for (const [offset, item] of rawList.data.entries()) {
841
+ const index = offset + 1;
842
+ switch (item.opType) {
843
+ case "create_entry": {
844
+ const { entityType } = item;
845
+ const resolved = entryEntitySchemas[entityType].safeParse(item.input);
846
+ if (!resolved.success)
847
+ throw planInputError(offset, resolved.error);
848
+ if (entityType === "chapter-outline" && !item.chapterId) {
849
+ throw new AppError(400, "AI_PLAN_OPERATION_INVALID", `第 ${index} 个操作缺少章节 ID`);
850
+ }
851
+ normalized.push({ opType: "create_entry", entityType, chapterId: item.chapterId, input: resolved.data });
852
+ break;
853
+ }
854
+ case "update_entry": {
855
+ const { entityType } = item;
856
+ const resolved = entryEntityUpdateSchemas[entityType].safeParse(item.input);
857
+ if (!resolved.success)
858
+ throw planInputError(offset, resolved.error);
859
+ if (entityType === "chapter-outline") {
860
+ if (!item.chapterId)
861
+ throw new AppError(400, "AI_PLAN_OPERATION_INVALID", `第 ${index} 个操作缺少章节 ID`);
862
+ }
863
+ else if (!item.entityId) {
864
+ throw new AppError(400, "AI_PLAN_OPERATION_INVALID", `第 ${index} 个操作缺少目标对象 ID`);
865
+ }
866
+ normalized.push({
867
+ opType: "update_entry",
868
+ entityType,
869
+ entityId: item.entityId,
870
+ chapterId: item.chapterId,
871
+ input: resolved.data
872
+ });
873
+ break;
874
+ }
875
+ case "create_annotation": {
876
+ if (item.endLine < item.startLine) {
877
+ throw new AppError(400, "AI_PLAN_OPERATION_INVALID", `第 ${index} 个操作的结束行不能早于开始行`);
878
+ }
879
+ normalized.push({
880
+ opType: "create_annotation",
881
+ chapterId: item.chapterId,
882
+ kind: item.kind,
883
+ startLine: item.startLine,
884
+ endLine: item.endLine,
885
+ note: item.note
886
+ });
887
+ break;
888
+ }
889
+ case "create_task":
890
+ normalized.push({ opType: "create_task", taskType: item.taskType, scope: item.scope, modelId: item.modelId });
891
+ break;
892
+ }
893
+ }
894
+ return normalized;
895
+ }
896
+ function planInputError(index, error) {
897
+ const firstIssue = error.issues[0];
898
+ return new AppError(400, "AI_PLAN_OPERATION_INVALID", `第 ${index + 1} 个操作的 ${firstIssue?.path.join(".") || "input"} 字段无效:${firstIssue?.message ?? "输入不符合要求"}`);
899
+ }
900
+ function resolvedQuestionAnswer(row) {
901
+ const options = json(row.options_json, []);
902
+ const selectedOption = row.selected_option === null ? null : Number(row.selected_option);
903
+ const selectedOptionLabel = selectedOption !== null && selectedOption >= 0 && selectedOption < options.length
904
+ ? options[selectedOption] ?? null
905
+ : null;
906
+ const customAnswer = row.is_custom_answer === 1 ? row.answer_text : "";
907
+ const answerText = selectedOptionLabel
908
+ ? (customAnswer ? `${selectedOptionLabel}\n补充信息:${customAnswer}` : selectedOptionLabel)
909
+ : (customAnswer || row.answer_text);
910
+ return { selectedOption, selectedOptionLabel, customAnswer, answerText };
911
+ }
912
+ export class AiWritePlanManager {
913
+ database;
914
+ store;
915
+ auth;
916
+ resolveAnalysisTask;
917
+ startAnalysisTask;
918
+ planTtlMs;
919
+ questionTtlMs;
920
+ constructor(deps, options = {}) {
921
+ this.database = deps.database;
922
+ this.store = deps.store;
923
+ this.auth = deps.auth;
924
+ this.resolveAnalysisTask = deps.resolveAnalysisTask;
925
+ this.startAnalysisTask = deps.startAnalysisTask;
926
+ this.planTtlMs = options.planTtlMs ?? AI_WRITE_PLAN_TTL_MS;
927
+ this.questionTtlMs = options.questionTtlMs ?? AI_USER_QUESTION_TTL_MS;
928
+ this.recoverStaleExecutingPlans();
929
+ }
930
+ // --------------------------------------------------------------- 工具开关
931
+ getEnabledTools(workId) {
932
+ const row = this.database.get("SELECT tools_json FROM work_ai_tool_settings WHERE work_id = ?", workId);
933
+ const enabled = json(row?.tools_json, {});
934
+ const view = defaultAiWriteToolToggles();
935
+ for (const toolId of AI_WRITE_TOOL_IDS) {
936
+ if (enabled[toolId] === true)
937
+ view[toolId] = true;
938
+ }
939
+ return view;
940
+ }
941
+ getConversationTools(workId, conversationId) {
942
+ const conversation = this.database.get(`SELECT conversation.work_id, conversation.ai_write_tools_json,
943
+ (SELECT COUNT(*) FROM ai_conversation_messages message WHERE message.conversation_id = conversation.id) AS message_count
944
+ FROM ai_conversations conversation WHERE conversation.id = ?`, conversationId);
945
+ if (!conversation || conversation.work_id !== workId) {
946
+ throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
947
+ }
948
+ const current = this.getEnabledTools(workId);
949
+ if (Number(conversation.message_count) === 0) {
950
+ this.database.run("UPDATE ai_conversations SET ai_write_tools_json = ?, updated_at = ? WHERE id = ?", JSON.stringify(current), now(), conversationId);
951
+ return current;
952
+ }
953
+ const stored = json(conversation.ai_write_tools_json ?? "{}", {});
954
+ if (conversation.ai_write_tools_json) {
955
+ const snapshot = defaultAiWriteToolToggles();
956
+ for (const toolId of AI_WRITE_TOOL_IDS)
957
+ snapshot[toolId] = stored[toolId] === true;
958
+ return snapshot;
959
+ }
960
+ this.database.run("UPDATE ai_conversations SET ai_write_tools_json = ?, updated_at = ? WHERE id = ? AND ai_write_tools_json IS NULL", JSON.stringify(current), now(), conversationId);
961
+ return current;
962
+ }
963
+ /** 增量更新工具开关:未提及的开关保持不变;未知工具 ID 直接拒绝。 */
964
+ updateToolSettings(workId, updates, updaterUserId) {
965
+ this.store.getWork(workId);
966
+ const next = this.getEnabledTools(workId);
967
+ for (const [toolId, enabled] of Object.entries(updates)) {
968
+ if (!(toolId in next))
969
+ throw new AppError(400, "AI_TOOL_UNKNOWN", `未知的 AI 工具:${toolId}`);
970
+ next[toolId] = Boolean(enabled);
971
+ }
972
+ const timestamp = now();
973
+ this.database.run(`INSERT INTO work_ai_tool_settings (work_id, tools_json, updated_at, updated_by_user_id)
974
+ VALUES (?, ?, ?, ?)
975
+ ON CONFLICT(work_id) DO UPDATE SET tools_json = excluded.tools_json, updated_at = excluded.updated_at, updated_by_user_id = excluded.updated_by_user_id`, workId, JSON.stringify(next), timestamp, updaterUserId);
976
+ this.store.audit(workId, "ai.tool_settings.updated", "work-ai-tool-settings", workId, {
977
+ tools: updates,
978
+ updatedByUserId: updaterUserId
979
+ });
980
+ logger.info("ai_write_tools.updated", { workId, tools: updates });
981
+ return next;
982
+ }
983
+ assertToolEnabled(workId, toolId) {
984
+ if (!this.getEnabledTools(workId)[toolId]) {
985
+ throw new AppError(403, "AI_TOOL_DISABLED", `作品未开启「${aiWriteToolLabels[toolId]}」工具`);
986
+ }
987
+ }
988
+ // --------------------------------------------------------------- 权限解析
989
+ /**
990
+ * 会话发起人的实时权限。未登录(开发直通模式)回退为全权限,
991
+ * 与 requestPermissions 保持一致的语义;否则严格取会员权限。
992
+ */
993
+ livePermissions(actor, workId) {
994
+ if (!actor?.userId)
995
+ return fullWorkModulePermissions();
996
+ if (this.userIsAdmin(actor.userId))
997
+ return fullWorkModulePermissions();
998
+ return this.auth.workModulePermissions({ userId: actor.userId, role: actor.role }, workId, true)
999
+ ?? emptyWorkModulePermissions();
1000
+ }
1001
+ userIsAdmin(userId) {
1002
+ const row = this.database.get("SELECT role FROM users WHERE id = ?", userId);
1003
+ return row?.role === "admin";
1004
+ }
1005
+ assertPlanViewer(row, workId, viewer) {
1006
+ if (row.work_id !== workId)
1007
+ throw notFoundPlan();
1008
+ if (!viewer?.userId)
1009
+ return;
1010
+ if (row.initiator_user_id !== viewer.userId && row.conversation_owner_user_id !== viewer.userId) {
1011
+ throw notFoundPlan();
1012
+ }
1013
+ }
1014
+ assertQuestionViewer(row, workId, viewer) {
1015
+ if (row.work_id !== workId)
1016
+ throw new AppError(404, "AI_QUESTION_NOT_FOUND", "问题不存在");
1017
+ if (!viewer?.userId)
1018
+ return;
1019
+ if (row.initiator_user_id !== viewer.userId && row.recipient_user_id !== viewer.userId) {
1020
+ throw new AppError(404, "AI_QUESTION_NOT_FOUND", "问题不存在");
1021
+ }
1022
+ }
1023
+ /** 从数据库静态解析指定用户的模块权限(用于对话属主,避免依赖登录态)。 */
1024
+ permissionsForStoredUser(userId, workId) {
1025
+ if (!userId)
1026
+ return emptyWorkModulePermissions();
1027
+ if (this.userIsAdmin(userId))
1028
+ return fullWorkModulePermissions();
1029
+ const work = this.database.get("SELECT owner_user_id FROM works WHERE id = ?", workId);
1030
+ if (!work)
1031
+ return emptyWorkModulePermissions();
1032
+ if (String(work.owner_user_id ?? "") === userId)
1033
+ return fullWorkModulePermissions();
1034
+ const membership = this.database.get("SELECT role, permissions_json FROM work_memberships WHERE work_id = ? AND user_id = ?", workId, userId);
1035
+ if (!membership)
1036
+ return emptyWorkModulePermissions();
1037
+ // 与 user-auth.workModulePermissions 相同的兼容逻辑:role + stored permissions JSON。
1038
+ return storedWorkModulePermissions(membership.role, membership.permissions_json ?? "");
1039
+ }
1040
+ /** 发起人 × 对话属主的交集权限(写入判断依据);对话属主未知时退化为发起人自身权限。 */
1041
+ effectiveWritePermissions(initiator, ownerUserId, workId) {
1042
+ const initiatorPermissions = this.livePermissions(initiator, workId);
1043
+ if (!ownerUserId)
1044
+ return initiatorPermissions;
1045
+ return intersectWorkModulePermissions(initiatorPermissions, this.permissionsForStoredUser(ownerUserId, workId));
1046
+ }
1047
+ // --------------------------------------------------------------- 版本定位
1048
+ currentEntityVersionNo(entityType, entityId) {
1049
+ if (entityType === "character") {
1050
+ const row = this.database.get("SELECT MAX(version_no) AS version_no FROM character_versions WHERE character_id = ?", entityId);
1051
+ return row?.version_no === null || row?.version_no === undefined ? null : Number(row.version_no);
1052
+ }
1053
+ if (entityType === "setting") {
1054
+ const row = this.database.get("SELECT MAX(version_no) AS version_no FROM entity_versions WHERE entity_type = 'setting' AND entity_id = ?", entityId);
1055
+ return row?.version_no === null || row?.version_no === undefined ? null : Number(row.version_no);
1056
+ }
1057
+ const row = this.database.get("SELECT MAX(version_no) AS version_no FROM entity_versions WHERE entity_type = ? AND entity_id = ?", entityType, entityId);
1058
+ return row?.version_no === null || row?.version_no === undefined ? null : Number(row.version_no);
1059
+ }
1060
+ referenceVersion(workId, entityType, entityId) {
1061
+ if (entityType === "chapter") {
1062
+ const chapter = this.store.getChapter(entityId);
1063
+ if (String(chapter.workId) !== workId)
1064
+ throw crossWorkError();
1065
+ return Number(chapter.versionNo);
1066
+ }
1067
+ if (entityType === "chapter-outline") {
1068
+ const chapter = this.store.getChapter(entityId);
1069
+ if (String(chapter.workId) !== workId)
1070
+ throw crossWorkError();
1071
+ return this.currentEntityVersionNo("chapter-outline", entityId);
1072
+ }
1073
+ const loaders = {
1074
+ setting: () => this.store.getSetting(entityId),
1075
+ character: () => this.store.getCharacter(entityId),
1076
+ race: () => this.store.getRace(entityId),
1077
+ organization: () => this.store.getOrganization(entityId),
1078
+ "timeline-track": () => this.store.getTimelineTrack(entityId),
1079
+ "timeline-event": () => this.store.getTimelineEvent(entityId),
1080
+ relationship: () => this.store.getRelationship(entityId),
1081
+ foreshadow: () => this.store.getForeshadow(entityId)
1082
+ };
1083
+ const normalizedType = entityType === "timeline" ? "timeline-event" : entityType;
1084
+ const loader = loaders[normalizedType];
1085
+ if (!loader)
1086
+ throw new AppError(400, "AI_PLAN_REFERENCE_TYPE_INVALID", `不支持的版本引用类型:${entityType}`);
1087
+ const entity = loader();
1088
+ if (String(entity.workId) !== workId)
1089
+ throw crossWorkError();
1090
+ return this.currentEntityVersionNo(normalizedType, entityId);
1091
+ }
1092
+ operationVersionSnapshot(workId, operation) {
1093
+ const snapshots = new Map();
1094
+ const add = (entityType, rawId) => {
1095
+ if (typeof rawId !== "string" || !rawId.trim())
1096
+ return;
1097
+ const entityId = rawId.trim();
1098
+ const key = `${entityType}:${entityId}`;
1099
+ if (!snapshots.has(key))
1100
+ snapshots.set(key, { entityType, entityId, versionNo: this.referenceVersion(workId, entityType, entityId) });
1101
+ };
1102
+ const addMany = (entityType, value) => {
1103
+ if (Array.isArray(value))
1104
+ value.forEach((item) => add(entityType, item));
1105
+ };
1106
+ if (operation.opType === "create_annotation")
1107
+ add("chapter", operation.chapterId);
1108
+ if (operation.opType === "create_task") {
1109
+ const scope = operation.scope ?? {};
1110
+ add("chapter", scope.chapterId);
1111
+ addMany("chapter", scope.chapterIds);
1112
+ addMany("character", scope.characterIds);
1113
+ addMany("character", scope.mentionCharacterIds);
1114
+ addMany("setting", scope.settingIds);
1115
+ addMany("race", scope.raceIds);
1116
+ addMany("organization", scope.organizationIds);
1117
+ if (Array.isArray(scope.relationshipSourceRefs)) {
1118
+ for (const reference of scope.relationshipSourceRefs) {
1119
+ if (!reference || typeof reference !== "object" || Array.isArray(reference))
1120
+ continue;
1121
+ const value = reference;
1122
+ add(String(value.sourceType ?? ""), value.sourceId);
1123
+ }
1124
+ }
1125
+ return [...snapshots.values()];
1126
+ }
1127
+ if (operation.opType === "create_entry" || operation.opType === "update_entry") {
1128
+ const input = operation.input;
1129
+ if (operation.entityType === "chapter-outline")
1130
+ add("chapter", operation.chapterId);
1131
+ if (operation.entityType === "character") {
1132
+ add("race", input.raceId);
1133
+ addMany("organization", input.organizationIds);
1134
+ }
1135
+ if (operation.entityType === "timeline-event") {
1136
+ add("timeline-track", input.trackId);
1137
+ addMany("chapter", input.chapterIds);
1138
+ addMany("character", input.participantIds);
1139
+ }
1140
+ if (operation.entityType === "relationship") {
1141
+ add("character", input.fromCharacterId);
1142
+ add("character", input.toCharacterId);
1143
+ }
1144
+ if (operation.entityType === "foreshadow")
1145
+ add("chapter", input.plannedPayoffChapterId);
1146
+ }
1147
+ return [...snapshots.values()];
1148
+ }
1149
+ // --------------------------------------------------------------- 计划创建
1150
+ createWritePlan(input) {
1151
+ const { workId } = input;
1152
+ this.store.getWork(workId);
1153
+ if (input.conversationId && this.latestPendingQuestion(input.conversationId)) {
1154
+ throw new AppError(409, "AI_QUESTION_PENDING", "当前对话仍有待回答问题,不能提交依赖未确认选择的写入计划");
1155
+ }
1156
+ const summaryParse = z.string().trim().min(1).max(2000).safeParse(input.aiSummary);
1157
+ if (!summaryParse.success)
1158
+ throw new AppError(400, "AI_PLAN_SUMMARY_REQUIRED", "AI 必须提供一段简要说明");
1159
+ const maxOperations = resolveAiWritePlanMaxOperations(process.env.AI_WRITE_PLAN_MAX_OPERATIONS);
1160
+ const operations = normalizePlanOperations(input.operations, maxOperations);
1161
+ const toggles = this.getEnabledTools(workId);
1162
+ const effective = this.effectiveWritePermissions(input.initiator, input.conversationOwnerUserId, workId);
1163
+ // 先统一做逐操作校验,收集标题与详情。
1164
+ const prepared = operations.map((operation) => this.prepareOperation(workId, operation, effective, toggles));
1165
+ const planId = randomId("aiPlan");
1166
+ const timestamp = now();
1167
+ const expiresAt = isoFromNow(timestamp, this.planTtlMs);
1168
+ this.database.transaction(() => {
1169
+ this.database.run(`INSERT INTO ai_write_plans (
1170
+ id, work_id, conversation_id, plan_kind, status, ai_summary, max_operations,
1171
+ initiator_user_id, conversation_owner_user_id, created_at
1172
+ ) VALUES (?, ?, ?, 'write', 'pending', ?, ?, ?, ?, ?)`, planId, workId, input.conversationId, summaryParse.data, operations.length, input.initiator?.userId ?? null, input.conversationOwnerUserId, timestamp);
1173
+ for (const [index, item] of prepared.entries()) {
1174
+ this.database.run(`INSERT INTO ai_write_plan_operations (
1175
+ id, plan_id, seq, op_type, module, entity_type, entity_id, target_version_no, title,
1176
+ operation_input_json, detail_json, required_modules_json
1177
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, randomId("aiPlanOp"), planId, index + 1, item.operation.opType, item.requirement.writeModules[0] ?? "", "entityType" in item.operation ? item.operation.entityType : "", item.targetEntityId, item.targetVersionNo, item.title, JSON.stringify(item.operation), JSON.stringify(item.detail), JSON.stringify([...item.requirement.writeModules]));
1178
+ }
1179
+ });
1180
+ this.store.audit(workId, "ai.write_plan.created", "ai_write_plan", planId, {
1181
+ operationCount: prepared.length,
1182
+ createdBy: input.initiator?.userId ?? null
1183
+ });
1184
+ logger.info("ai_write_plan.created", { workId, planId, operations: prepared.length });
1185
+ return this.getPlanDetail(planId, workId, input.initiator);
1186
+ }
1187
+ /** 创建期的单个操作准备:工具开关、版本、diff 与目标标题都在这里定型。 */
1188
+ prepareOperation(workId, operation, effective, toggles) {
1189
+ const requirement = planOperationRequirements(operation);
1190
+ if (!toggles[requirement.toolId]) {
1191
+ throw new AppError(403, "AI_TOOL_DISABLED", `第 ${this.seqHintOf(operation)} 处使用了未开启的「${aiWriteToolLabels[requirement.toolId]}」工具`);
1192
+ }
1193
+ for (const module of requirement.readModules) {
1194
+ if (!canReadWorkModule(effective, module)) {
1195
+ throw new AppError(403, "WORK_MODULE_READ_DENIED", `你没有读取“${workPermissionModuleLabels[module]}”相关资料的权限`);
1196
+ }
1197
+ }
1198
+ for (const module of requirement.writeModules) {
1199
+ if (!canWriteWorkModule(effective, module)) {
1200
+ throw new AppError(403, "WORK_MODULE_WRITE_DENIED", `「${aiWriteToolLabels[requirement.toolId]}」需要双方都具备“${workPermissionModuleLabels[module]}”模块的编辑权限`);
1201
+ }
1202
+ }
1203
+ if (operation.opType === "create_annotation") {
1204
+ const versionSnapshot = this.operationVersionSnapshot(workId, operation);
1205
+ const chapter = this.store.getChapter(operation.chapterId);
1206
+ if (String(chapter.workId) !== workId)
1207
+ throw crossWorkError();
1208
+ const totalLines = splitLines(String(chapter.content)).length;
1209
+ if (operation.endLine > totalLines) {
1210
+ throw new AppError(400, "AI_PLAN_LINE_OUT_OF_RANGE", `批注位置超出了《${String(chapter.title)}》的实际行数(${totalLines} 行)`);
1211
+ }
1212
+ const contentLines = splitLines(String(chapter.content));
1213
+ const quote = contentLines.slice(operation.startLine - 1, operation.endLine).join("\n").slice(0, 1000);
1214
+ return {
1215
+ operation,
1216
+ requirement,
1217
+ targetEntityId: operation.chapterId,
1218
+ targetVersionNo: null,
1219
+ title: String(chapter.title),
1220
+ detail: {
1221
+ affectedModule: requirement.writeModules[0],
1222
+ affectedModuleLabel: workPermissionModuleLabels.prose,
1223
+ action: "新增批注",
1224
+ chapterId: operation.chapterId,
1225
+ chapterTitle: String(chapter.title),
1226
+ annotationKind: operation.kind,
1227
+ annotationKindLabel: annotationKindLabels[operation.kind],
1228
+ startLine: operation.startLine,
1229
+ endLine: operation.endLine,
1230
+ quote,
1231
+ note: operation.note,
1232
+ versionSnapshot
1233
+ }
1234
+ };
1235
+ }
1236
+ if (operation.opType === "create_task") {
1237
+ const resolvedOperation = this.resolveAnalysisTask(workId, operation);
1238
+ const normalizedOperation = { opType: "create_task", ...resolvedOperation };
1239
+ const versionSnapshot = this.operationVersionSnapshot(workId, normalizedOperation);
1240
+ const taskTypeLabel = aiAnalysisTaskTypeLabels[resolvedOperation.taskType] ?? resolvedOperation.taskType;
1241
+ const scopePreview = summarizeTaskScope(resolvedOperation.taskType, resolvedOperation.scope);
1242
+ return {
1243
+ operation: normalizedOperation,
1244
+ requirement,
1245
+ targetEntityId: null,
1246
+ targetVersionNo: null,
1247
+ title: taskTypeLabel,
1248
+ detail: {
1249
+ affectedModule: "ai-analysis",
1250
+ affectedModuleLabel: workPermissionModuleLabels["ai-analysis"],
1251
+ action: "新建分析任务",
1252
+ taskType: resolvedOperation.taskType,
1253
+ taskTypeLabel,
1254
+ modelId: resolvedOperation.modelId ?? null,
1255
+ scope: resolvedOperation.scope,
1256
+ scopeSummary: scopePreview.summary,
1257
+ scopeDescription: scopePreview.description,
1258
+ versionSnapshot
1259
+ }
1260
+ };
1261
+ }
1262
+ // 词条创建:无需当前快照,title 由输入决定。
1263
+ if (operation.opType === "create_entry") {
1264
+ const versionSnapshot = this.operationVersionSnapshot(workId, operation);
1265
+ const input = operation.input;
1266
+ let title = String(input.name ?? input.title ?? "");
1267
+ if (operation.entityType === "chapter-outline") {
1268
+ const chapter = this.store.getChapter(operation.chapterId ?? "");
1269
+ if (String(chapter.workId) !== workId)
1270
+ throw crossWorkError();
1271
+ title = String(chapter.title);
1272
+ }
1273
+ return {
1274
+ operation,
1275
+ requirement,
1276
+ targetEntityId: operation.entityType === "chapter-outline" ? operation.chapterId ?? null : null,
1277
+ targetVersionNo: null,
1278
+ title,
1279
+ detail: {
1280
+ affectedModule: requirement.writeModules[0],
1281
+ affectedModuleLabel: workPermissionModuleLabels[requirement.writeModules[0]],
1282
+ targetTypeLabel: aiEntryEntityTypeLabel(operation.entityType),
1283
+ action: "新增",
1284
+ title,
1285
+ versionSnapshot,
1286
+ fields: Object.entries(completeCreatePreview(operation.entityType, operation.input)).map(([key, value]) => ({
1287
+ key,
1288
+ label: fieldLabelsByEntity[operation.entityType][key] ?? key,
1289
+ after: formatFieldValue(key, value),
1290
+ changed: true
1291
+ }))
1292
+ }
1293
+ };
1294
+ }
1295
+ // 词条编辑:读取当前实体与版本,生成系统侧 before/after diff。
1296
+ const lookup = this.locateUpdateTarget(workId, operation);
1297
+ if (!lookup.exists) {
1298
+ throw new AppError(404, "AI_PLAN_TARGET_NOT_FOUND", `要编辑的${aiEntryEntityTypeLabel(operation.entityType)}不存在:${lookup.targetTitle || operation.entityId || ""}`);
1299
+ }
1300
+ const detail = {
1301
+ affectedModule: requirement.writeModules[0],
1302
+ affectedModuleLabel: workPermissionModuleLabels[requirement.writeModules[0]],
1303
+ targetTypeLabel: aiEntryEntityTypeLabel(operation.entityType),
1304
+ action: "编辑",
1305
+ target: lookup.targetTitle,
1306
+ title: lookup.targetTitle,
1307
+ targetVersionNo: lookup.currentVersionNo,
1308
+ versionSnapshot: this.operationVersionSnapshot(workId, operation),
1309
+ fields: buildFieldDiffs(operation.entityType, lookup.current, operation.input)
1310
+ .filter((field) => field.changed)
1311
+ .map((field) => ({
1312
+ key: field.key,
1313
+ label: field.label,
1314
+ // 保存库内原始修改前值:撤销执行需要它还原,而不是人类可读文本。
1315
+ beforeRaw: field.beforeRaw,
1316
+ before: field.before,
1317
+ after: field.after,
1318
+ changed: field.changed,
1319
+ lines: summarizeLineChanges(field.lines),
1320
+ linePreview: field.lines.slice(0, 40)
1321
+ }))
1322
+ };
1323
+ return {
1324
+ operation,
1325
+ requirement,
1326
+ targetEntityId: lookup.entityId,
1327
+ targetVersionNo: lookup.currentVersionNo,
1328
+ title: lookup.targetTitle,
1329
+ detail
1330
+ };
1331
+ }
1332
+ seqHintOf(operation) {
1333
+ switch (operation.opType) {
1334
+ case "create_entry": return aiEntryEntityTypeLabel(operation.entityType);
1335
+ case "update_entry": return `${aiEntryEntityTypeLabel(operation.entityType)}${operation.entityId}`;
1336
+ case "create_annotation": return "正文批注";
1337
+ case "create_task": return "分析任务";
1338
+ }
1339
+ }
1340
+ locateUpdateTarget(workId, operation) {
1341
+ const { entityType } = operation;
1342
+ if (entityType === "chapter-outline") {
1343
+ // 大纲采用 upsert 语义:计划时不存在是合法状态(版本号为空表示“仍不存在”)。
1344
+ const chapterId = operation.chapterId ?? "";
1345
+ const chapter = this.store.getChapter(chapterId);
1346
+ if (String(chapter.workId) !== workId)
1347
+ throw crossWorkError();
1348
+ const current = this.tryGetChapterOutline(chapterId);
1349
+ return {
1350
+ exists: true,
1351
+ entityId: chapterId,
1352
+ current,
1353
+ currentVersionNo: current ? this.currentEntityVersionNo("chapter-outline", chapterId) : null,
1354
+ targetTitle: String(chapter.title)
1355
+ };
1356
+ }
1357
+ const entityId = operation.entityId ?? "";
1358
+ switch (entityType) {
1359
+ case "setting": {
1360
+ const current = this.safeGet(() => this.store.getSetting(entityId));
1361
+ this.assertSameWork(current, workId);
1362
+ return {
1363
+ exists: current !== undefined,
1364
+ entityId,
1365
+ current: current ?? null,
1366
+ currentVersionNo: this.currentEntityVersionNo(entityType, entityId),
1367
+ targetTitle: String(current?.title ?? "")
1368
+ };
1369
+ }
1370
+ case "character": {
1371
+ const current = this.safeGet(() => this.store.getCharacter(entityId));
1372
+ this.assertSameWork(current, workId);
1373
+ return {
1374
+ exists: current !== undefined,
1375
+ entityId,
1376
+ current: current ?? null,
1377
+ currentVersionNo: this.currentEntityVersionNo(entityType, entityId),
1378
+ targetTitle: String(current?.name ?? "")
1379
+ };
1380
+ }
1381
+ case "race": {
1382
+ const current = this.safeGet(() => this.store.getRace(entityId));
1383
+ this.assertSameWork(current, workId);
1384
+ return {
1385
+ exists: current !== undefined,
1386
+ entityId,
1387
+ current: current ?? null,
1388
+ currentVersionNo: this.currentEntityVersionNo(entityType, entityId),
1389
+ targetTitle: String(current?.name ?? "")
1390
+ };
1391
+ }
1392
+ case "organization": {
1393
+ const current = this.safeGet(() => this.store.getOrganization(entityId));
1394
+ this.assertSameWork(current, workId);
1395
+ return {
1396
+ exists: current !== undefined,
1397
+ entityId,
1398
+ current: current ?? null,
1399
+ currentVersionNo: this.currentEntityVersionNo(entityType, entityId),
1400
+ targetTitle: String(current?.name ?? "")
1401
+ };
1402
+ }
1403
+ case "timeline-track": {
1404
+ const current = this.safeGet(() => this.store.getTimelineTrack(entityId));
1405
+ this.assertSameWork(current, workId);
1406
+ return {
1407
+ exists: current !== undefined,
1408
+ entityId,
1409
+ current: current ?? null,
1410
+ currentVersionNo: this.currentEntityVersionNo(entityType, entityId),
1411
+ targetTitle: String(current?.name ?? "")
1412
+ };
1413
+ }
1414
+ case "timeline-event": {
1415
+ const current = this.safeGet(() => this.store.getTimelineEvent(entityId));
1416
+ this.assertSameWork(current, workId);
1417
+ return {
1418
+ exists: current !== undefined,
1419
+ entityId,
1420
+ current: current ?? null,
1421
+ currentVersionNo: this.currentEntityVersionNo(entityType, entityId),
1422
+ targetTitle: String(current?.name ?? "")
1423
+ };
1424
+ }
1425
+ case "relationship": {
1426
+ const current = this.safeGet(() => this.store.getRelationship(entityId));
1427
+ this.assertSameWork(current, workId);
1428
+ return {
1429
+ exists: current !== undefined,
1430
+ entityId,
1431
+ current: current ?? null,
1432
+ currentVersionNo: this.currentEntityVersionNo(entityType, entityId),
1433
+ targetTitle: describeRelationshipTitle(current)
1434
+ };
1435
+ }
1436
+ case "foreshadow": {
1437
+ const current = this.safeGet(() => this.store.getForeshadow(entityId));
1438
+ this.assertSameWork(current, workId);
1439
+ return {
1440
+ exists: current !== undefined,
1441
+ entityId,
1442
+ current: current ?? null,
1443
+ currentVersionNo: this.currentEntityVersionNo(entityType, entityId),
1444
+ targetTitle: String(current?.title ?? "")
1445
+ };
1446
+ }
1447
+ }
1448
+ }
1449
+ safeGet(loader) {
1450
+ try {
1451
+ return loader();
1452
+ }
1453
+ catch {
1454
+ return undefined;
1455
+ }
1456
+ }
1457
+ tryGetChapterOutline(chapterId) {
1458
+ try {
1459
+ return this.store.getChapterOutline(chapterId);
1460
+ }
1461
+ catch {
1462
+ return null;
1463
+ }
1464
+ }
1465
+ assertSameWork(current, workId) {
1466
+ if (current && String(current.workId ?? "") !== workId)
1467
+ throw crossWorkError();
1468
+ }
1469
+ // --------------------------------------------------------------- 计划查询
1470
+ expireStalePlan(planRow) {
1471
+ if (planRow.status !== "pending")
1472
+ return planRow;
1473
+ if (Date.parse(planRow.created_at) + this.planTtlMs > Date.now())
1474
+ return planRow;
1475
+ this.database.run("UPDATE ai_write_plans SET status = 'expired', decided_at = ? WHERE id = ? AND status = 'pending'", now(), planRow.id);
1476
+ this.store.audit(planRow.work_id, "ai.write_plan.expired", "ai_write_plan", planRow.id, {});
1477
+ return this.loadPlan(planRow.id);
1478
+ }
1479
+ loadPlan(planId) {
1480
+ const row = this.database.get("SELECT * FROM ai_write_plans WHERE id = ?", planId);
1481
+ if (!row)
1482
+ throw notFoundPlan();
1483
+ return row;
1484
+ }
1485
+ listPlansForWork(workId, viewer, options = {}) {
1486
+ this.recoverStaleExecutingPlans();
1487
+ const limit = Math.min(Math.max(options.limit ?? 50, 1), 200);
1488
+ const participantClause = viewer?.userId ? " AND (initiator_user_id = ? OR conversation_owner_user_id = ?)" : "";
1489
+ const participantParams = viewer?.userId ? [viewer.userId, viewer.userId] : [];
1490
+ const rows = options.status
1491
+ ? this.database.all(`SELECT * FROM ai_write_plans WHERE work_id = ? AND status = ?${participantClause} ORDER BY created_at DESC LIMIT ?`, workId, options.status, ...participantParams, limit)
1492
+ : this.database.all(`SELECT * FROM ai_write_plans WHERE work_id = ?${participantClause} ORDER BY created_at DESC LIMIT ?`, workId, ...participantParams, limit);
1493
+ return rows.map((row) => this.expireStalePlan(row)).map((row) => this.toSummaryView(row));
1494
+ }
1495
+ getPlanDetail(planId, workId, viewer) {
1496
+ let row = this.loadPlan(planId);
1497
+ this.assertPlanViewer(row, workId, viewer);
1498
+ row = this.expireStalePlan(row);
1499
+ return this.toDetailView(row, viewer);
1500
+ }
1501
+ // --------------------------------------------------------------- 决策流水线
1502
+ /**
1503
+ * 确认执行。只有待确认状态可以进入执行;重复确认会得到明确的失败提示且绝不重复写入。
1504
+ */
1505
+ async confirmPlan(planId, workId, confirmer) {
1506
+ await Promise.resolve();
1507
+ let row = this.expireStalePlan(this.loadPlan(planId));
1508
+ this.assertPlanViewer(row, workId, confirmer);
1509
+ if (row.status !== "pending")
1510
+ throw decisionConflict(row);
1511
+ const claim = this.database.run(`UPDATE ai_write_plans SET status = 'executing', decided_at = ?, executed_by_user_id = ?
1512
+ WHERE id = ? AND status = 'pending'`, now(), confirmer?.userId ?? null, planId);
1513
+ if (claim.changes !== 1) {
1514
+ row = this.loadPlan(planId);
1515
+ throw decisionConflict(row);
1516
+ }
1517
+ this.store.audit(row.work_id, "ai.write_plan.confirmed", "ai_write_plan", planId, { confirmedBy: confirmer?.userId ?? null });
1518
+ try {
1519
+ const outcome = this.database.transaction(() => this.executePlanWithinTransaction(planId, confirmer));
1520
+ if (outcome.ok) {
1521
+ logger.info("ai_write_plan.executed", { planId, operations: outcome.operationResults.length });
1522
+ return this.getPlanDetail(planId, workId, confirmer);
1523
+ }
1524
+ const invalidated = this.database.run(`UPDATE ai_write_plans SET status = 'invalidated', invalid_reason = ?
1525
+ WHERE id = ? AND status = 'executing'`, outcome.reason, planId);
1526
+ if (invalidated.changes === 1) {
1527
+ this.store.audit(row.work_id, "ai.write_plan.invalidated", "ai_write_plan", planId, { reason: outcome.reason });
1528
+ }
1529
+ logger.warn("ai_write_plan.invalidated", { planId, reason: outcome.reason });
1530
+ throw new AppError(409, "AI_PLAN_INVALIDATED", outcome.reason);
1531
+ }
1532
+ catch (error) {
1533
+ if (error instanceof AppError && error.code === "AI_PLAN_INVALIDATED")
1534
+ throw error;
1535
+ const message = sanitizeFailure(error);
1536
+ this.database.run(`UPDATE ai_write_plans SET status = 'failed', failure_message = ?
1537
+ WHERE id = ? AND status = 'executing'`, message, planId);
1538
+ this.store.audit(row.work_id, "ai.write_plan.failed", "ai_write_plan", planId, { message });
1539
+ logger.error("ai_write_plan.failed", { planId, message });
1540
+ throw new AppError(409, "AI_PLAN_EXECUTION_FAILED", `计划执行失败,未产生任何写入:${message}`);
1541
+ }
1542
+ }
1543
+ executePlanWithinTransaction(planId, confirmer) {
1544
+ const row = this.loadPlan(planId);
1545
+ const operations = this.database.all("SELECT * FROM ai_write_plan_operations WHERE plan_id = ? ORDER BY seq", planId);
1546
+ // ---- 再校验 1:双方权限是否仍然满足(R8/R13)。
1547
+ const effective = this.effectiveWritePermissions(confirmer, row.conversation_owner_user_id, row.work_id);
1548
+ const initiatorPermissions = this.livePermissions(confirmer, row.work_id);
1549
+ const ownersPermissions = this.permissionsForStoredUser(row.conversation_owner_user_id, row.work_id);
1550
+ void initiatorPermissions;
1551
+ void ownersPermissions;
1552
+ for (const operation of operations) {
1553
+ const requiredModules = json(operation.required_modules_json, []);
1554
+ for (const moduleName of requiredModules) {
1555
+ if (!canWriteWorkModule(effective, moduleName)) {
1556
+ return {
1557
+ ok: false,
1558
+ reason: `执行前校验失败:当前不再具备“${workPermissionModuleLabels[moduleName]}”模块的编辑权限(操作 ${operation.seq})`
1559
+ };
1560
+ }
1561
+ }
1562
+ // ---- 再校验 2:工具开关仍然开启(R7/R13)。
1563
+ const requirement = requirementForStoredOperation(operation);
1564
+ if (!this.getEnabledTools(row.work_id)[requirement.toolId]) {
1565
+ return { ok: false, reason: `执行前校验失败:「${aiWriteToolLabels[requirement.toolId]}」工具已被关闭(操作 ${operation.seq})` };
1566
+ }
1567
+ const detail = json(operation.detail_json, {});
1568
+ const versionSnapshot = Array.isArray(detail.versionSnapshot)
1569
+ ? detail.versionSnapshot
1570
+ : [];
1571
+ for (const reference of versionSnapshot) {
1572
+ const entityType = String(reference.entityType ?? "");
1573
+ const entityId = String(reference.entityId ?? "");
1574
+ const expectedVersion = reference.versionNo === null ? null : Number(reference.versionNo);
1575
+ let currentVersion;
1576
+ try {
1577
+ currentVersion = this.referenceVersion(row.work_id, entityType, entityId);
1578
+ }
1579
+ catch {
1580
+ return { ok: false, reason: `执行前校验失败:关联对象已不存在或不再属于当前作品(${entityType}:${entityId},操作 ${operation.seq})` };
1581
+ }
1582
+ if (currentVersion !== expectedVersion) {
1583
+ return { ok: false, reason: `执行前校验失败:关联对象已发生变化(${entityType}:${entityId},版本 ${expectedVersion ?? "不存在"} -> ${currentVersion ?? "不存在"},操作 ${operation.seq})` };
1584
+ }
1585
+ }
1586
+ // ---- 再校验 3:目标对象与版本没有变化(R10/R13)。
1587
+ if (operation.op_type === "update_entry" && operation.entity_type && operation.entity_id) {
1588
+ const entityType = operation.entity_type;
1589
+ const currentVersionNo = this.currentEntityVersionNo(entityType, operation.entity_id);
1590
+ const expected = operation.target_version_no;
1591
+ // 章节大纲允许“计划时不存在”的编辑计划(upsert 语义):要求此刻仍不存在。
1592
+ const outlineStillAbsent = entityType === "chapter-outline" && expected === null;
1593
+ if (outlineStillAbsent ? currentVersionNo !== null : (currentVersionNo === null || expected === null || currentVersionNo !== expected)) {
1594
+ const target = { title: operation.title };
1595
+ return {
1596
+ ok: false,
1597
+ reason: `执行前校验失败:「${target.title || aiEntryEntityTypeLabel(entityType)}」已发生变化,请让 AI 重新提交计划(版本 ${expected ?? "?"} -> ${currentVersionNo ?? "不存在"},操作 ${operation.seq})`
1598
+ };
1599
+ }
1600
+ }
1601
+ if (operation.op_type === "create_entry" && operation.entity_type === "chapter-outline" && operation.entity_id) {
1602
+ const currentVersionNo = this.currentEntityVersionNo("chapter-outline", operation.entity_id);
1603
+ if (currentVersionNo !== null) {
1604
+ return { ok: false, reason: `执行前校验失败:「${operation.title}」的大纲已被创建,请让 AI 重新提交计划(操作 ${operation.seq})` };
1605
+ }
1606
+ }
1607
+ }
1608
+ const operationResults = operations.map((operation) => this.applyPlanOperation(row.work_id, planId, operation, confirmer));
1609
+ this.database.run("UPDATE ai_write_plans SET status = 'executed', executed_at = ? WHERE id = ?", now(), planId);
1610
+ this.store.audit(row.work_id, "ai.write_plan.executed", "ai_write_plan", planId, {
1611
+ executedBy: confirmer?.userId ?? null,
1612
+ operations: operationResults.map((result) => result.seq)
1613
+ });
1614
+ return { ok: true, operationResults };
1615
+ }
1616
+ /** 执行单个操作并把结果写回操作记录。 */
1617
+ applyPlanOperation(workId, planId, operation, confirmer) {
1618
+ const sourceRef = `ai-plan:${planId}:${operation.seq}`;
1619
+ const changeNote = `AI 审批计划 ${planId}`;
1620
+ const input = json(operation.operation_input_json, {});
1621
+ const detail = json(operation.detail_json, {});
1622
+ let resultEntityId = null;
1623
+ let resultVersionNo = null;
1624
+ let resultSummary = "";
1625
+ if (operation.op_type === "create_entry") {
1626
+ const entityType = operation.entity_type;
1627
+ const entityInput = stripMetadata(input.input);
1628
+ let created;
1629
+ switch (entityType) {
1630
+ case "setting":
1631
+ created = this.store.createSetting(workId, entityInput, "ai", sourceRef);
1632
+ break;
1633
+ case "character":
1634
+ created = this.store.createCharacter(workId, entityInput);
1635
+ break;
1636
+ case "race":
1637
+ created = this.store.createRace(workId, entityInput);
1638
+ break;
1639
+ case "organization":
1640
+ created = this.store.createOrganization(workId, entityInput);
1641
+ break;
1642
+ case "timeline-track":
1643
+ created = this.store.createTimelineTrack(workId, entityInput, "ai", sourceRef);
1644
+ break;
1645
+ case "timeline-event":
1646
+ created = this.store.createTimelineEvent(workId, entityInput, "ai", sourceRef);
1647
+ break;
1648
+ case "relationship":
1649
+ created = this.store.createRelationship(workId, entityInput, "ai", sourceRef);
1650
+ break;
1651
+ case "chapter-outline":
1652
+ created = this.store.upsertChapterOutline(String(operation.entity_id), entityInput, "ai", sourceRef, changeNote);
1653
+ break;
1654
+ case "foreshadow":
1655
+ created = this.store.createForeshadow(workId, entityInput);
1656
+ break;
1657
+ }
1658
+ resultEntityId = String(created.id ?? "");
1659
+ resultVersionNo = entityType === "character"
1660
+ ? Number(created.versionNo ?? 1)
1661
+ : this.currentEntityVersionNoAfterWrite(entityType, resultEntityId);
1662
+ resultSummary = `已创建${aiEntryEntityTypeLabel(entityType)}「${operation.title}」`;
1663
+ }
1664
+ else if (operation.op_type === "update_entry") {
1665
+ const entityType = operation.entity_type;
1666
+ const entityId = String(operation.entity_id);
1667
+ const entityInput = stripMetadata(input.input);
1668
+ const expectedVersionNo = operation.target_version_no === null ? undefined : operation.target_version_no;
1669
+ const undoPayload = prepareUndoPayload(detail, entityType, entityId);
1670
+ switch (entityType) {
1671
+ case "setting":
1672
+ this.store.updateSetting(entityId, entityInput, "ai", sourceRef, changeNote, expectedVersionNo);
1673
+ break;
1674
+ case "character":
1675
+ this.store.updateCharacter(entityId, entityInput, "ai", sourceRef, changeNote, expectedVersionNo);
1676
+ break;
1677
+ case "race":
1678
+ this.store.updateRace(entityId, entityInput, "ai", sourceRef, changeNote, expectedVersionNo);
1679
+ break;
1680
+ case "organization":
1681
+ this.store.updateOrganization(entityId, entityInput, "ai", sourceRef, changeNote, expectedVersionNo);
1682
+ break;
1683
+ case "timeline-track":
1684
+ this.store.updateTimelineTrack(entityId, entityInput, "ai", sourceRef, changeNote, expectedVersionNo);
1685
+ break;
1686
+ case "timeline-event":
1687
+ this.store.updateTimelineEvent(entityId, entityInput, "ai", sourceRef, changeNote, expectedVersionNo);
1688
+ break;
1689
+ case "relationship":
1690
+ this.store.updateRelationship(entityId, entityInput, "ai", sourceRef, changeNote, expectedVersionNo);
1691
+ break;
1692
+ case "chapter-outline":
1693
+ this.store.upsertChapterOutline(entityId, entityInput, "ai", sourceRef, changeNote, expectedVersionNo);
1694
+ break;
1695
+ case "foreshadow":
1696
+ this.store.updateForeshadow(entityId, entityInput, "ai", sourceRef, changeNote, expectedVersionNo);
1697
+ break;
1698
+ }
1699
+ resultEntityId = entityId;
1700
+ resultVersionNo = entityType === "chapter-outline"
1701
+ ? this.currentEntityVersionNo(entityType, entityId)
1702
+ : this.currentEntityVersionNo(entityType, entityId);
1703
+ resultSummary = `已更新${aiEntryEntityTypeLabel(entityType)}「${operation.title}」(版本 ${resultVersionNo ?? "?"})`;
1704
+ if (undoPayload) {
1705
+ this.database.run("UPDATE ai_write_plan_operations SET detail_json = ? WHERE id = ?", JSON.stringify({ ...detail, undoPayload: undoPayload.undoJson }), operation.id);
1706
+ }
1707
+ }
1708
+ else if (operation.op_type === "create_annotation") {
1709
+ const created = this.store.createChapterAnnotation(String(operation.entity_id), {
1710
+ kind: String(input.kind) === "todo" ? "todo" : "note",
1711
+ startLine: Number(input.startLine),
1712
+ endLine: Number(input.endLine),
1713
+ note: String(input.note)
1714
+ });
1715
+ resultEntityId = String(created.id ?? "");
1716
+ resultVersionNo = Number(created.versionNo ?? 1);
1717
+ resultSummary = `已在「${detail.chapterTitle ?? operation.title}」第 ${input.startLine}-${input.endLine} 行创建${annotationKindLabels[String(input.kind)] ?? "批注"}`;
1718
+ }
1719
+ else if (operation.op_type === "create_task") {
1720
+ const created = this.startAnalysisTask(workId, {
1721
+ taskType: String(input.taskType),
1722
+ scope: input.scope && typeof input.scope === "object" && !Array.isArray(input.scope)
1723
+ ? input.scope
1724
+ : { type: "book" },
1725
+ ...(input.modelId ? { modelId: String(input.modelId) } : {})
1726
+ });
1727
+ resultEntityId = String(created.id ?? "");
1728
+ resultSummary = `已创建${aiAnalysisTaskTypeLabels[String(input.taskType)] ?? String(input.taskType)}分析任务`;
1729
+ void confirmer;
1730
+ }
1731
+ this.database.run(`UPDATE ai_write_plan_operations
1732
+ SET result_entity_id = ?, result_version_no = ?, result_summary = ?
1733
+ WHERE id = ?`, resultEntityId, resultVersionNo, resultSummary, operation.id);
1734
+ this.store.audit(workId, `ai.plan_operation.${operation.op_type}`, "ai_write_plan_operation", operation.id, {
1735
+ planId,
1736
+ seq: operation.seq,
1737
+ entityId: resultEntityId,
1738
+ versionNo: resultVersionNo
1739
+ });
1740
+ return { seq: operation.seq };
1741
+ }
1742
+ currentEntityVersionNoAfterWrite(entityType, entityId) {
1743
+ return this.currentEntityVersionNo(entityType, entityId);
1744
+ }
1745
+ rejectPlan(planId, workId, rejecter) {
1746
+ const row = this.expireStalePlan(this.loadPlan(planId));
1747
+ this.assertPlanViewer(row, workId, rejecter);
1748
+ if (row.status !== "pending")
1749
+ throw decisionConflict(row);
1750
+ const updated = this.database.run("UPDATE ai_write_plans SET status = 'rejected', decided_at = ?, executed_by_user_id = ? WHERE id = ? AND status = 'pending'", now(), rejecter?.userId ?? null, planId);
1751
+ if (updated.changes !== 1)
1752
+ throw decisionConflict(this.loadPlan(planId));
1753
+ this.store.audit(row.work_id, "ai.write_plan.rejected", "ai_write_plan", planId, { rejectedBy: rejecter?.userId ?? null });
1754
+ logger.info("ai_write_plan.rejected", { planId });
1755
+ return this.getPlanDetail(planId, workId, rejecter);
1756
+ }
1757
+ /** 服务恢复后清理卡在执行中的陈旧计划:其事务必然没有提交,可以安全判定为失败。 */
1758
+ recoverStaleExecutingPlans() {
1759
+ const rows = this.database.all("SELECT * FROM ai_write_plans WHERE status = 'executing' ORDER BY created_at LIMIT 20");
1760
+ for (const row of rows) {
1761
+ const updatedAt = row.decided_at ?? row.created_at;
1762
+ // 宽限 10 分钟:正常执行远快于此;超时的执行事务一定没有提交。
1763
+ if (Date.parse(updatedAt) + 600_000 > Date.now())
1764
+ continue;
1765
+ this.database.run("UPDATE ai_write_plans SET status = 'failed', failure_message = '服务中断导致执行未完成,未产生写入,请重新发起' WHERE id = ? AND status = 'executing'", row.id);
1766
+ logger.warn("ai_write_plan.stale_recovered", { planId: row.id });
1767
+ }
1768
+ }
1769
+ // --------------------------------------------------------------- 撤销
1770
+ /** 已成功审批中哪些操作仍支持撤销。 */
1771
+ undoEligibility(planId) {
1772
+ const row = this.loadPlan(planId);
1773
+ const operations = this.database.all("SELECT * FROM ai_write_plan_operations WHERE plan_id = ? ORDER BY seq", planId);
1774
+ const eligibleOps = [];
1775
+ const skippedOps = [];
1776
+ for (const operation of operations) {
1777
+ const reversible = operation.op_type === "update_entry"
1778
+ && operation.result_entity_id
1779
+ && operation.result_version_no !== null
1780
+ && this.currentEntityVersionNo(operation.entity_type, String(operation.result_entity_id)) === operation.result_version_no;
1781
+ if (reversible)
1782
+ eligibleOps.push({ seq: operation.seq, title: operation.title });
1783
+ else if (operation.op_type === "create_entry") {
1784
+ skippedOps.push({ seq: operation.seq, title: operation.title, reason: "AI 新建的条目不支持通过撤销删除" });
1785
+ }
1786
+ else if (operation.op_type === "create_annotation" || operation.op_type === "create_task") {
1787
+ skippedOps.push({ seq: operation.seq, title: operation.title, reason: "批注与分析任务不在撤销范围内" });
1788
+ }
1789
+ else {
1790
+ skippedOps.push({ seq: operation.seq, title: operation.title, reason: "对象已被后续修改,无法撤销" });
1791
+ }
1792
+ }
1793
+ void row;
1794
+ return { undoAvailable: eligibleOps.length > 0, eligibleOps, skippedOps };
1795
+ }
1796
+ /** 为已成功审批创建撤销计划(撤销本身也需要再次确认)。 */
1797
+ createUndoPlan(sourcePlanId, workId, requester) {
1798
+ const source = this.loadPlan(sourcePlanId);
1799
+ this.assertPlanViewer(source, workId, requester);
1800
+ if (source.status !== "executed")
1801
+ throw new AppError(409, "AI_UNDO_SOURCE_NOT_EXECUTED", "只有执行成功的审批才能撤销");
1802
+ const eligibility = this.undoEligibility(sourcePlanId);
1803
+ if (!eligibility.undoAvailable) {
1804
+ throw new AppError(409, "AI_UNDO_NOT_AVAILABLE", "该审批已经没有可撤销的操作(目标可能被后续修改)");
1805
+ }
1806
+ const operations = this.database.all("SELECT * FROM ai_write_plan_operations WHERE plan_id = ? ORDER BY seq", sourcePlanId);
1807
+ const planId = randomId("aiPlan");
1808
+ const timestamp = now();
1809
+ const titles = [];
1810
+ this.database.transaction(() => {
1811
+ this.database.run(`INSERT INTO ai_write_plans (
1812
+ id, work_id, conversation_id, plan_kind, status, ai_summary, max_operations,
1813
+ initiator_user_id, conversation_owner_user_id, source_plan_id, created_at
1814
+ ) VALUES (?, ?, ?, 'undo', 'pending', ?, ?, ?, ?, ?, ?)`, planId, source.work_id, source.conversation_id, `撤销审批 ${sourcePlanId} 中已被修改的条目`, operations.length, requester?.userId ?? null, source.conversation_owner_user_id, sourcePlanId, timestamp);
1815
+ for (const operation of operations) {
1816
+ const detail = json(operation.detail_json, {});
1817
+ const undoPayload = detail.undoPayload;
1818
+ // 仅恢复“编辑已有条目”且执行成功的操作;AI 新建条目不可通过撤销删除。
1819
+ if (!undoPayload)
1820
+ continue;
1821
+ if (String(operation.op_type) !== "update_entry")
1822
+ continue;
1823
+ if (!operation.result_entity_id || operation.result_version_no === null)
1824
+ continue;
1825
+ if (String(undoPayload.entityId) !== String(operation.result_entity_id))
1826
+ continue;
1827
+ const entityType = operation.entity_type;
1828
+ titles.push(`#${operation.seq} ${operation.title}`);
1829
+ this.database.run(`INSERT INTO ai_write_plan_operations (
1830
+ id, plan_id, seq, op_type, module, entity_type, entity_id, target_version_no, title,
1831
+ operation_input_json, detail_json, required_modules_json
1832
+ ) VALUES (?, ?, ?, 'update_entry', ?, ?, ?, ?, ?, ?, ?, ?)`, randomId("aiPlanOp"), planId, operation.seq, operation.module, operation.entity_type, operation.result_entity_id, operation.result_version_no, `撤销:${operation.title}`, JSON.stringify({ opType: "update_entry", entityType, entityId: operation.result_entity_id, input: undoPayload.beforeFields }), JSON.stringify({
1833
+ affectedModule: operation.module,
1834
+ targetTypeLabel: aiEntryEntityTypeLabel(entityType),
1835
+ action: "撤销",
1836
+ target: operation.title,
1837
+ revertedFromPlanId: sourcePlanId,
1838
+ sourceSeq: operation.seq
1839
+ }), operation.required_modules_json);
1840
+ }
1841
+ });
1842
+ if (titles.length === 0) {
1843
+ throw new AppError(409, "AI_UNDO_NOT_AVAILABLE", "该审批中已没有任何可撤销的操作");
1844
+ }
1845
+ this.store.audit(source.work_id, "ai.undo_plan.created", "ai_write_plan", planId, {
1846
+ sourcePlanId,
1847
+ requestedBy: requester?.userId ?? null,
1848
+ operations: titles.length
1849
+ });
1850
+ return this.getPlanDetail(planId, workId, requester);
1851
+ }
1852
+ // --------------------------------------------------------------- 投影视图
1853
+ toSummaryView(row) {
1854
+ const counts = this.database.all("SELECT DISTINCT module FROM ai_write_plan_operations WHERE plan_id = ?", row.id);
1855
+ const operator = row.executed_by_user_id
1856
+ ? this.database.get("SELECT display_name, username FROM users WHERE id = ?", row.executed_by_user_id)
1857
+ : undefined;
1858
+ return {
1859
+ id: row.id,
1860
+ workId: row.work_id,
1861
+ conversationId: row.conversation_id,
1862
+ kind: row.plan_kind,
1863
+ kindLabel: row.plan_kind === "undo" ? "撤销审批" : "写入审批",
1864
+ status: row.status,
1865
+ statusLabel: aiPlanStatusLabels[row.status] ?? row.status,
1866
+ aiSummary: row.ai_summary,
1867
+ operationCount: this.countPlanOperations(row.id),
1868
+ moduleLabels: counts.map((item) => workPermissionModuleLabels[item.module] ?? item.module),
1869
+ createdAt: row.created_at,
1870
+ decidedAt: row.decided_at,
1871
+ executedAt: row.executed_at,
1872
+ expiresAt: row.status === "pending" ? isoFromNow(row.created_at, this.planTtlMs) : null,
1873
+ initiatorUserId: row.initiator_user_id,
1874
+ conversationOwnerUserId: row.conversation_owner_user_id,
1875
+ decidedByUserId: row.executed_by_user_id,
1876
+ decidedByName: operator ? String(operator.display_name || operator.username) : null,
1877
+ sourcePlanId: row.source_plan_id
1878
+ };
1879
+ }
1880
+ countPlanOperations(planId) {
1881
+ const row = this.database.get("SELECT COUNT(*) AS count FROM ai_write_plan_operations WHERE plan_id = ?", planId);
1882
+ return Number(row?.count ?? 0);
1883
+ }
1884
+ toDetailView(row, viewer) {
1885
+ const base = this.toSummaryView(row);
1886
+ const operations = this.database.all("SELECT * FROM ai_write_plan_operations WHERE plan_id = ? ORDER BY seq", row.id);
1887
+ const viewerPermissions = this.livePermissions(viewer, row.work_id);
1888
+ const undoAvailability = row.plan_kind === "write" && row.status === "executed" ? this.undoEligibility(row.id).undoAvailable : false;
1889
+ return {
1890
+ ...base,
1891
+ invalidReason: row.invalid_reason,
1892
+ failureMessage: row.failure_message,
1893
+ undoAvailable: undoAvailability,
1894
+ sourcePlanId: row.source_plan_id,
1895
+ operations: operations.map((operation) => this.toOperationView(operation, viewerPermissions))
1896
+ };
1897
+ }
1898
+ toOperationView(operation, viewerPermissions) {
1899
+ const detail = json(operation.detail_json, {});
1900
+ const requiredModules = json(operation.required_modules_json, []);
1901
+ const restricted = requiredModules.some((module) => !canReadWorkModule(viewerPermissions, module));
1902
+ const fields = Array.isArray(detail.fields) ? detail.fields : [];
1903
+ const auditRecords = this.database.all(`SELECT log.action, log.actor, log.user_id, log.created_at,
1904
+ user.display_name AS actor_display_name, user.username AS actor_username
1905
+ FROM audit_logs log LEFT JOIN users user ON user.id = log.user_id
1906
+ WHERE log.entity_type = 'ai_write_plan_operation' AND log.entity_id = ?
1907
+ ORDER BY log.created_at`, operation.id).map((record) => ({
1908
+ action: record.action,
1909
+ actor: String(record.actor_display_name || record.actor_username || record.actor),
1910
+ userId: record.user_id,
1911
+ createdAt: record.created_at
1912
+ }));
1913
+ return {
1914
+ seq: operation.seq,
1915
+ opType: operation.op_type,
1916
+ opTypeLabel: aiOpTypeLabel(operation.op_type),
1917
+ module: operation.module,
1918
+ moduleLabel: workPermissionModuleLabels[operation.module] ?? operation.module,
1919
+ entityType: operation.entity_type,
1920
+ entityId: operation.entity_id,
1921
+ targetVersionNo: operation.target_version_no,
1922
+ title: restricted ? "无权查看该模块内容" : operation.title,
1923
+ annotation: operation.op_type === "create_annotation"
1924
+ ? {
1925
+ kind: String(detail.annotationKind ?? ""),
1926
+ kindLabel: String(detail.annotationKindLabel ?? ""),
1927
+ startLine: Number(detail.startLine ?? 0),
1928
+ endLine: Number(detail.endLine ?? 0),
1929
+ quote: restricted ? "" : String(detail.quote ?? ""),
1930
+ note: restricted ? "" : String(detail.note ?? "")
1931
+ }
1932
+ : null,
1933
+ task: operation.op_type === "create_task"
1934
+ ? {
1935
+ taskType: String(detail.taskType ?? ""),
1936
+ taskTypeLabel: String(detail.taskTypeLabel ?? ""),
1937
+ scopeSummary: restricted ? "" : String(detail.scopeDescription ?? detail.scopeSummary ?? ""),
1938
+ modelId: detail.modelId ? String(detail.modelId) : null
1939
+ }
1940
+ : null,
1941
+ fields: restricted
1942
+ ? fields.map((field) => ({
1943
+ key: String(field.key ?? ""),
1944
+ label: String(field.label ?? ""),
1945
+ before: null,
1946
+ after: null,
1947
+ changed: field.changed === true,
1948
+ addedLines: 0,
1949
+ removedLines: 0,
1950
+ previewBefore: null,
1951
+ previewAfter: null
1952
+ }))
1953
+ : fields.map((field) => ({
1954
+ key: String(field.key ?? ""),
1955
+ label: String(field.label ?? ""),
1956
+ before: field.before === undefined || field.before === null ? "" : String(field.before),
1957
+ after: field.after === undefined || field.after === null ? "" : String(field.after),
1958
+ changed: field.changed === true,
1959
+ addedLines: Number(field.lines?.added ?? 0),
1960
+ removedLines: Number(field.lines?.removed ?? 0),
1961
+ previewBefore: diffPreview(field.linePreview ?? [], "del"),
1962
+ previewAfter: diffPreview(field.linePreview ?? [], "add")
1963
+ })),
1964
+ requiredModuleLabels: requiredModules.map((module) => workPermissionModuleLabels[module] ?? module),
1965
+ restricted,
1966
+ result: operation.result_summary
1967
+ ? { entityId: operation.result_entity_id, versionNo: operation.result_version_no, summary: operation.result_summary }
1968
+ : null,
1969
+ auditRecords
1970
+ };
1971
+ }
1972
+ // --------------------------------------------------------------- 用户提问
1973
+ createQuestion(input) {
1974
+ this.assertToolEnabled(input.workId, "ask_user_questions");
1975
+ const parsed = askAiUserQuestionInputSchema.parse({ question: input.question, options: input.options });
1976
+ const questionId = randomId("aiQ");
1977
+ const timestamp = now();
1978
+ const expiresAt = isoFromNow(timestamp, this.questionTtlMs);
1979
+ this.database.transaction(() => {
1980
+ const pending = input.conversationId
1981
+ ? this.database.get("SELECT 1 AS present FROM ai_user_questions WHERE conversation_id = ? AND status = 'pending'", input.conversationId)
1982
+ : undefined;
1983
+ if (pending)
1984
+ throw new AppError(409, "AI_QUESTION_PENDING", "当前对话已有一个待回答问题");
1985
+ this.database.run(`INSERT INTO ai_user_questions (
1986
+ id, work_id, conversation_id, initiator_user_id, recipient_user_id, question,
1987
+ options_json, status, tool_call_id, created_at, expires_at
1988
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)`, questionId, input.workId, input.conversationId, input.initiator?.userId ?? null, input.recipientUserId, parsed.question, JSON.stringify(parsed.options), input.toolCallId ?? null, timestamp, expiresAt);
1989
+ });
1990
+ this.store.audit(input.workId, "ai.question.asked", "ai_user_question", questionId, {
1991
+ conversationId: input.conversationId,
1992
+ askedBy: input.initiator?.userId ?? null
1993
+ });
1994
+ return this.getQuestion(questionId, input.workId, input.initiator);
1995
+ }
1996
+ answerQuestion(questionId, workId, respondent, payload) {
1997
+ const row = this.assertAnswerable(questionId, workId, respondent);
1998
+ const options = json(row.options_json, []);
1999
+ const customAnswer = payload.customAnswer?.trim() ?? "";
2000
+ if (payload.customAnswer !== undefined && !customAnswer) {
2001
+ throw new AppError(400, "AI_QUESTION_CUSTOM_ANSWER_INVALID", "自定义回答不能为空");
2002
+ }
2003
+ if (customAnswer.length > MAX_AI_QUESTION_ANSWER_CHARS) {
2004
+ throw new AppError(400, "AI_QUESTION_CUSTOM_ANSWER_TOO_LONG", `自定义回答不能超过 ${MAX_AI_QUESTION_ANSWER_CHARS} 个字符`);
2005
+ }
2006
+ let selectedOption = null;
2007
+ let selectedOptionLabel = "";
2008
+ if (payload.selectedOption !== undefined) {
2009
+ if (payload.selectedOption < 0 || payload.selectedOption >= options.length) {
2010
+ throw new AppError(400, "AI_QUESTION_OPTION_INVALID", "选择的选项编号无效");
2011
+ }
2012
+ selectedOption = payload.selectedOption;
2013
+ selectedOptionLabel = options[selectedOption] ?? "";
2014
+ }
2015
+ if (selectedOption === null && !customAnswer)
2016
+ throw new AppError(400, "AI_QUESTION_ANSWER_REQUIRED", "必须提供回答");
2017
+ const isCustom = Boolean(customAnswer);
2018
+ const storedAnswerText = customAnswer || selectedOptionLabel;
2019
+ const updated = this.database.run(`UPDATE ai_user_questions
2020
+ SET status = 'answered', selected_option = ?, answer_text = ?, is_custom_answer = ?, decided_at = ?
2021
+ WHERE id = ? AND status = 'pending'`, selectedOption, storedAnswerText, isCustom ? 1 : 0, now(), questionId);
2022
+ if (updated.changes !== 1)
2023
+ throw new AppError(409, "AI_QUESTION_ALREADY_DECIDED", "该问题已被处理");
2024
+ this.store.audit(row.work_id, "ai.question.answered", "ai_user_question", questionId, {
2025
+ answeredBy: respondent?.userId ?? null,
2026
+ isCustomAnswer: isCustom,
2027
+ hasSelectedOption: selectedOption !== null
2028
+ });
2029
+ return this.getQuestion(questionId, workId, respondent);
2030
+ }
2031
+ rejectQuestion(questionId, workId, respondent) {
2032
+ const row = this.assertAnswerable(questionId, workId, respondent);
2033
+ const updated = this.database.run("UPDATE ai_user_questions SET status = 'rejected', decided_at = ? WHERE id = ? AND status = 'pending'", now(), questionId);
2034
+ if (updated.changes !== 1)
2035
+ throw new AppError(409, "AI_QUESTION_ALREADY_DECIDED", "该问题已被处理");
2036
+ this.store.audit(row.work_id, "ai.question.rejected", "ai_user_question", questionId, {
2037
+ rejectedBy: respondent?.userId ?? null
2038
+ });
2039
+ return this.getQuestion(questionId, workId, respondent);
2040
+ }
2041
+ assertAnswerable(questionId, workId, respondent) {
2042
+ let row = this.database.get("SELECT * FROM ai_user_questions WHERE id = ?", questionId);
2043
+ if (!row)
2044
+ throw new AppError(404, "AI_QUESTION_NOT_FOUND", "问题不存在");
2045
+ this.assertQuestionViewer(row, workId, respondent);
2046
+ if (Date.parse(row.expires_at) < Date.now() && row.status === "pending") {
2047
+ this.database.run("UPDATE ai_user_questions SET status = 'expired', decided_at = ? WHERE id = ? AND status = 'pending'", now(), questionId);
2048
+ row = this.database.get("SELECT * FROM ai_user_questions WHERE id = ?", questionId);
2049
+ }
2050
+ if (row.status !== "pending") {
2051
+ const labels = { answered: "已回答", rejected: "已拒绝", expired: "已过期" };
2052
+ throw new AppError(409, "AI_QUESTION_CLOSED", `问题已被处理:${labels[row.status] ?? row.status}`);
2053
+ }
2054
+ if (row.recipient_user_id && respondent?.userId && row.recipient_user_id !== respondent.userId) {
2055
+ throw new AppError(403, "AI_QUESTION_RECIPIENT_ONLY", "该提问仅限目标用户回答");
2056
+ }
2057
+ return row;
2058
+ }
2059
+ getQuestion(questionId, workId, viewer) {
2060
+ const row = this.database.get("SELECT * FROM ai_user_questions WHERE id = ?", questionId);
2061
+ if (!row)
2062
+ throw new AppError(404, "AI_QUESTION_NOT_FOUND", "问题不存在");
2063
+ this.assertQuestionViewer(row, workId, viewer);
2064
+ return this.toQuestionView(row);
2065
+ }
2066
+ listQuestions(workId, viewer, filters = {}) {
2067
+ const limit = Math.min(Math.max(filters.limit ?? 30, 1), 200);
2068
+ const participantClause = viewer?.userId ? " AND (initiator_user_id = ? OR recipient_user_id = ?)" : "";
2069
+ const participantParams = viewer?.userId ? [viewer.userId, viewer.userId] : [];
2070
+ const rows = filters.conversationId
2071
+ ? this.database.all(`SELECT * FROM ai_user_questions WHERE work_id = ? AND conversation_id = ?${participantClause} ORDER BY created_at DESC LIMIT ?`, workId, filters.conversationId, ...participantParams, limit)
2072
+ : this.database.all(`SELECT * FROM ai_user_questions WHERE work_id = ?${participantClause} ORDER BY created_at DESC LIMIT ?`, workId, ...participantParams, limit);
2073
+ return rows
2074
+ .map((row) => (row.status === "pending" && Date.parse(row.expires_at) < Date.now() ? this.expireQuestion(row) : row))
2075
+ .filter((row) => !filters.status || row.status === filters.status)
2076
+ .map((row) => this.toQuestionView(row));
2077
+ }
2078
+ /** 会话中的最新待回答问题(供聊天界面提示与上下文注入)。 */
2079
+ latestPendingQuestion(conversationId) {
2080
+ const row = this.database.get("SELECT * FROM ai_user_questions WHERE conversation_id = ? AND status = 'pending' ORDER BY created_at DESC LIMIT 1", conversationId);
2081
+ if (!row)
2082
+ return null;
2083
+ if (Date.parse(row.expires_at) < Date.now()) {
2084
+ return this.toQuestionView(this.expireQuestion(row));
2085
+ }
2086
+ return this.toQuestionView(row);
2087
+ }
2088
+ saveQuestionContinuation(questionId, continuation) {
2089
+ const updated = this.database.run("UPDATE ai_user_questions SET continuation_json = ? WHERE id = ? AND status = 'pending'", JSON.stringify(continuation), questionId);
2090
+ if (updated.changes !== 1)
2091
+ throw new AppError(409, "AI_QUESTION_CLOSED", "问题已经无法挂起当前工作流");
2092
+ }
2093
+ claimQuestionContinuation(questionId, workId, viewer) {
2094
+ const row = this.database.get("SELECT * FROM ai_user_questions WHERE id = ?", questionId);
2095
+ if (!row)
2096
+ throw new AppError(404, "AI_QUESTION_NOT_FOUND", "问题不存在");
2097
+ this.assertQuestionViewer(row, workId, viewer);
2098
+ const continuation = json(row.continuation_json, {});
2099
+ if (typeof continuation.conversationId !== "string" || !continuation.conversationId)
2100
+ return null;
2101
+ const claimed = this.database.run("UPDATE ai_user_questions SET resume_state = 'claimed', resumed_at = ? WHERE id = ? AND resume_state = 'pending' AND status IN ('answered', 'rejected', 'expired')", now(), questionId);
2102
+ if (claimed.changes !== 1)
2103
+ return null;
2104
+ const answer = resolvedQuestionAnswer(row);
2105
+ return {
2106
+ ...continuation,
2107
+ questionId,
2108
+ status: row.status,
2109
+ answerText: answer.answerText,
2110
+ selectedOption: answer.selectedOption,
2111
+ selectedOptionLabel: answer.selectedOptionLabel,
2112
+ customAnswer: answer.customAnswer,
2113
+ toolCallId: row.tool_call_id,
2114
+ questionView: this.toQuestionView(row)
2115
+ };
2116
+ }
2117
+ finishQuestionContinuation(questionId, result, failed = false) {
2118
+ this.database.run("UPDATE ai_user_questions SET resume_state = ?, resume_result_json = ? WHERE id = ? AND resume_state = 'claimed'", failed ? "failed" : "completed", JSON.stringify(result), questionId);
2119
+ }
2120
+ /** 聊天流结束前调用的兜底清理:把过期待回答的问题统一落成过期态。 */
2121
+ expireStaleQuestions(workId) {
2122
+ const timestamp = now();
2123
+ this.database.run("UPDATE ai_user_questions SET status = 'expired', decided_at = ? WHERE work_id = ? AND status = 'pending' AND expires_at < ?", timestamp, workId, timestamp);
2124
+ }
2125
+ /**
2126
+ * 解析一次对话的“发起人/对话属主”:
2127
+ * 侧边栏对话以 created_by_user_id 为唯一归属,发起人与属主一致,
2128
+ * 写入权限取两者交集后仍然等价于归属用户本人的权限。
2129
+ */
2130
+ resolveConversationActor(conversationId) {
2131
+ if (!conversationId)
2132
+ return { viewer: null, conversationOwnerUserId: null };
2133
+ const row = this.database.get("SELECT created_by_user_id FROM ai_conversations WHERE id = ?", conversationId);
2134
+ if (!row)
2135
+ return { viewer: null, conversationOwnerUserId: null };
2136
+ const ownerId = row.created_by_user_id === null || row.created_by_user_id === undefined ? null : String(row.created_by_user_id);
2137
+ return { viewer: ownerId ? { userId: ownerId, role: "member" } : null, conversationOwnerUserId: ownerId };
2138
+ }
2139
+ /** 会话最近的审批计划(用于系统上下文注入与回显)。 */
2140
+ listRecentPlansForConversation(workId, conversationId, limit = 5) {
2141
+ const rows = this.database.all("SELECT * FROM ai_write_plans WHERE work_id = ? AND conversation_id = ? ORDER BY created_at DESC LIMIT ?", workId, conversationId, Math.min(Math.max(limit, 1), 10));
2142
+ return rows.map((row) => this.expireStalePlan(row)).map((row) => this.toSummaryView(row));
2143
+ }
2144
+ /** 会话最近的问题(含已回答/已过期),供系统上下文注入。 */
2145
+ listRecentQuestionsForConversation(conversationId, limit = 3) {
2146
+ const rows = this.database.all("SELECT * FROM ai_user_questions WHERE conversation_id = ? ORDER BY created_at DESC LIMIT ?", conversationId, Math.min(Math.max(limit, 1), 10));
2147
+ return rows.map((row) => this.toQuestionView(row));
2148
+ }
2149
+ expireQuestion(row) {
2150
+ this.database.run("UPDATE ai_user_questions SET status = 'expired', decided_at = ? WHERE id = ? AND status = 'pending'", now(), row.id);
2151
+ return this.database.get("SELECT * FROM ai_user_questions WHERE id = ?", row.id);
2152
+ }
2153
+ toQuestionView(row) {
2154
+ const options = json(row.options_json, []);
2155
+ const answer = resolvedQuestionAnswer(row);
2156
+ return {
2157
+ id: row.id,
2158
+ workId: row.work_id,
2159
+ conversationId: row.conversation_id,
2160
+ question: row.question,
2161
+ status: row.status,
2162
+ statusLabel: aiQuestionStatusLabels[row.status] ?? row.status,
2163
+ options: options.map((label, index) => ({ index, label, recommended: index === 0 })),
2164
+ selectedOption: answer.selectedOption,
2165
+ selectedOptionLabel: answer.selectedOptionLabel,
2166
+ customAnswer: answer.customAnswer,
2167
+ answerText: answer.answerText,
2168
+ isCustomAnswer: row.is_custom_answer === 1,
2169
+ createdAt: row.created_at,
2170
+ expiresAt: row.expires_at,
2171
+ decidedAt: row.decided_at,
2172
+ resumeState: row.resume_state
2173
+ };
2174
+ }
2175
+ }
2176
+ // ---------------------------------------------------------------------------
2177
+ // 内部小工具
2178
+ // ---------------------------------------------------------------------------
2179
+ function crossWorkError() {
2180
+ return new AppError(403, "CROSS_WORK_REFERENCE", "跨作品的对象引用不允许出现在同一个计划里");
2181
+ }
2182
+ function notFoundPlan() {
2183
+ return new AppError(404, "AI_PLAN_NOT_FOUND", "审批计划不存在");
2184
+ }
2185
+ function decisionConflict(row) {
2186
+ const label = aiPlanStatusLabels[row.status] ?? row.status;
2187
+ return new AppError(409, "AI_PLAN_ALREADY_DECIDED", `该审批已被处理(当前状态:${label}),请刷新审批中心查看结果`);
2188
+ }
2189
+ function sanitizeFailure(error) {
2190
+ if (error instanceof AppError)
2191
+ return error.message;
2192
+ if (error instanceof Error)
2193
+ return error.message.slice(0, 500);
2194
+ return String(error).slice(0, 500);
2195
+ }
2196
+ function requirementForStoredOperation(operation) {
2197
+ const input = json(operation.operation_input_json, {});
2198
+ if (operation.op_type === "create_annotation")
2199
+ return { toolId: "annotations", writeModules: ["prose"], readModules: [] };
2200
+ if (operation.op_type === "create_task") {
2201
+ const readModules = analysisTaskReadModules(String(input.taskType), input.scope ?? { type: "book" });
2202
+ return { toolId: "analysis_tasks", writeModules: ["ai-analysis"], readModules };
2203
+ }
2204
+ const entityType = operation.entity_type;
2205
+ return { toolId: moduleForEntityType(entityType), writeModules: [], readModules: [] };
2206
+ }
2207
+ /**
2208
+ * 执行前根据系统生成的字段 diff 准备撤销载荷:
2209
+ * 只有真正变化的字段会参与还原(使用库内原始值)。
2210
+ * 版本校验由 undoEligibility 基于执行结果版本完成,这里不重复记录。
2211
+ */
2212
+ function prepareUndoPayload(detail, entityType, entityId) {
2213
+ void entityType;
2214
+ const fields = Array.isArray(detail.fields) ? detail.fields : [];
2215
+ const beforeFields = {};
2216
+ let hasChangedField = false;
2217
+ for (const field of fields) {
2218
+ if (field.changed !== true)
2219
+ continue;
2220
+ if ("beforeRaw" in field && field.beforeRaw !== undefined) {
2221
+ beforeFields[String(field.key)] = field.beforeRaw;
2222
+ }
2223
+ else if (typeof field.before === "string" && field.before.length > 0) {
2224
+ // 兜底:没有原始值时退回展示文本(旧数据),仅对纯文本字段有意义。
2225
+ beforeFields[String(field.key)] = field.before;
2226
+ }
2227
+ hasChangedField = true;
2228
+ }
2229
+ if (!hasChangedField)
2230
+ return null;
2231
+ return {
2232
+ undoJson: { entityId, beforeFields },
2233
+ beforeFields,
2234
+ entityId
2235
+ };
2236
+ }
2237
+ function summarizeTaskScope(taskType, scope) {
2238
+ if (!scope || Object.keys(scope).length === 0) {
2239
+ return { summary: "{}", description: "全书范围(默认)" };
2240
+ }
2241
+ if (scope.type === "book") {
2242
+ return { summary: JSON.stringify(scope), description: "全书范围" };
2243
+ }
2244
+ const parts = [];
2245
+ if (scope.type)
2246
+ parts.push(`范围 ${String(scope.type)}`);
2247
+ if (taskType === "relationship-analysis" && Array.isArray(scope.characterIds)) {
2248
+ parts.push(`${scope.characterIds.length} 位指定人物`);
2249
+ }
2250
+ if (typeof scope.additionalPrompt === "string" && scope.additionalPrompt.trim()) {
2251
+ parts.push("含补充提示");
2252
+ }
2253
+ if (scope.includeAllSettings === true)
2254
+ parts.push("包含全部设定");
2255
+ const summary = JSON.stringify(scope).slice(0, 500);
2256
+ const description = parts.length > 0 ? parts.join(",") : "自定义分析范围";
2257
+ return { summary, description };
2258
+ }
2259
+ function diffPreview(lines, kind) {
2260
+ const matched = lines.filter((line) => line.kind === kind).map((line) => line.text.split("\n")).flat();
2261
+ if (matched.length === 0)
2262
+ return null;
2263
+ return matched.slice(0, 12).join("\n");
2264
+ }
2265
+ /** 存储行的截断值交给现成的 store 输入类型即可,这里仅去掉元数据包装。 */
2266
+ function stripMetadata(value) {
2267
+ return value;
2268
+ }
2269
+ /** 关系条目的展示标题:优先使用双方人物名称,缺失时退回 ID。 */
2270
+ function describeRelationshipTitle(current) {
2271
+ if (!current)
2272
+ return "";
2273
+ const from = String(current.fromCharacterName ?? current.fromCharacterId ?? "");
2274
+ const to = String(current.toCharacterName ?? current.toCharacterId ?? "");
2275
+ if (from && to)
2276
+ return `${from} → ${to}`;
2277
+ return String(current.id ?? "");
2278
+ }
2279
+ //# sourceMappingURL=ai-write-plans.js.map