@musnows/scriverse 0.9.5 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/store.js CHANGED
@@ -4,17 +4,18 @@ import { ENTITY_VERSION_BASELINE_MIGRATION_VERSION, PLATFORM_AI_WORK_ID, SYSTEM_
4
4
  import { exportWorkDocx } from "./docx-export.js";
5
5
  import { createEpubArchive } from "./epub-export.js";
6
6
  import { AppError, notFound } from "./errors.js";
7
- import { documentParagraphLineRanges, normalizeWorkSearchQuery } from "./hybrid-search.js";
7
+ import { documentParagraphLineRanges, hybridSearchPermissionModule, normalizeWorkSearchQuery } from "./hybrid-search.js";
8
8
  import { accountReference, logger } from "./logger.js";
9
9
  import { paginated, paginationSql } from "./pagination.js";
10
10
  import { currentRequestActor } from "./request-context.js";
11
- import { canWriteWorkModule, classifyWorkModulePermissions, emptyWorkModulePermissions, fullWorkModulePermissions, storedWorkModulePermissions } from "./work-permissions.js";
11
+ import { canReadWorkModule, canWriteWorkModule, classifyWorkModulePermissions, emptyWorkModulePermissions, fullWorkModulePermissions, storedWorkModulePermissions } from "./work-permissions.js";
12
12
  import { countWords, documentShortSearchTerms, escapeSqlLikePattern, id, json, normalizeDocumentSearchText, now, splitDocumentParagraphs } from "./utils.js";
13
13
  import { buildWritingCalendar, writingDateKey } from "./writing-progress-time.js";
14
14
  import { resolveMaxAgentToolCallLimit } from "./ai-tool-results.js";
15
15
  import { DEFAULT_AI_STREAM_IDLE_TIMEOUT_SECONDS, normalizeAiStreamIdleTimeoutSeconds } from "./ai-stream-timeout.js";
16
16
  import { normalizeRoleplayScenePin, roleplayUserTurnDisplayText, roleplayUserTurnTitleSource } from "./roleplay-turn.js";
17
17
  import { chapterAnnotationLineHashes, createChapterLineIds, MAX_CHAPTER_LINE_IDS, parseChapterAnnotationLineIds, parseChapterAnnotationLineHashes, parseChapterLineIds, reconcileChapterLineIds, reanchorChapterAnnotations } from "./chapter-annotation-anchor.js";
18
+ import { renumberChapterTitle } from "./chapter-title-numbering.js";
18
19
  import { normalizeRoleplayMemoryContent, roleplayMemoryCandidateIsSafe } from "./roleplay-memory.js";
19
20
  const WORK_LIST_BATCH_SIZE = 500;
20
21
  const ENTITY_LIST_BATCH_SIZE = 400;
@@ -22,18 +23,51 @@ export const RECYCLE_BIN_RETENTION_DAYS = 30;
22
23
  function recycleBinExpiresAt(deletedAt) {
23
24
  return new Date(new Date(deletedAt).getTime() + RECYCLE_BIN_RETENTION_DAYS * 24 * 60 * 60_000).toISOString();
24
25
  }
26
+ function chapterAnnotationListFilter(kinds, filters = {}) {
27
+ const where = [];
28
+ const params = [];
29
+ if (kinds !== undefined) {
30
+ if (kinds.length > 0) {
31
+ where.push(`annotation.kind IN (${kinds.map(() => "?").join(",")})`);
32
+ params.push(...kinds);
33
+ }
34
+ else {
35
+ where.push("1 = 0");
36
+ }
37
+ }
38
+ if (filters.chapterId) {
39
+ where.push("annotation.chapter_id = ?");
40
+ params.push(filters.chapterId);
41
+ }
42
+ const normalizedQuery = filters.query?.normalize("NFKC").trim().slice(0, 100) ?? "";
43
+ if (normalizedQuery) {
44
+ const pattern = `%${escapeSqlLikePattern(normalizedQuery)}%`;
45
+ where.push(`(
46
+ annotation.note LIKE ? ESCAPE '\\' COLLATE NOCASE
47
+ OR annotation.quote LIKE ? ESCAPE '\\' COLLATE NOCASE
48
+ OR chapter.title LIKE ? ESCAPE '\\' COLLATE NOCASE
49
+ OR volume.title LIKE ? ESCAPE '\\' COLLATE NOCASE
50
+ )`);
51
+ params.push(pattern, pattern, pattern, pattern);
52
+ }
53
+ return {
54
+ sql: where.map((clause) => ` AND ${clause}`).join(""),
55
+ params
56
+ };
57
+ }
25
58
  export const attachmentPermissionModules = ["prose", "drafts", "settings", "characters", "races", "organizations", "ai-chat"];
26
59
  export const WORK_AGENT_TOOL_IDS = [
27
60
  "story_index",
28
61
  "read_chapters",
29
62
  "grep",
30
63
  "search_story_entities",
64
+ "semantic_search_story",
31
65
  "read_character_sections",
32
66
  "search_drafts",
33
67
  "image",
34
68
  "calculate_time"
35
69
  ];
36
- const DEFAULT_WORK_AGENT_TOOLS = [...WORK_AGENT_TOOL_IDS];
70
+ const DEFAULT_WORK_AGENT_TOOLS = WORK_AGENT_TOOL_IDS.filter((toolId) => toolId !== "semantic_search_story");
37
71
  const LEGACY_DEFAULT_WORK_AGENT_TOOLS = [
38
72
  "story_index",
39
73
  "read_chapters",
@@ -219,7 +253,12 @@ const legacyFavoriteTables = {
219
253
  organization: "organizations"
220
254
  };
221
255
  export const AI_CONVERSATION_STREAM_REQUEST_LEASE_MS = 3 * 60_000;
222
- export const aiConversationTaskTypes = ["chat", "roleplay", "continue", "polish"];
256
+ export const aiConversationTaskTypes = ["chat", "roleplay"];
257
+ function normalizeAiConversationTaskType(value, roleplayCharacterId) {
258
+ if (roleplayCharacterId || value === "roleplay")
259
+ return "roleplay";
260
+ return "chat";
261
+ }
223
262
  export function defaultAiConversationTitle(prompt) {
224
263
  const normalized = roleplayUserTurnTitleSource(prompt).replace(/\s+/gu, " ").trim();
225
264
  return Array.from(normalized).slice(0, 15).join("") || "新对话";
@@ -1071,6 +1110,18 @@ export class Store {
1071
1110
  titleGenerationModelId: row?.title_generation_model_id === null || row?.title_generation_model_id === undefined
1072
1111
  ? null
1073
1112
  : String(row.title_generation_model_id),
1113
+ semanticSearchEnabled: Number(row?.semantic_search_enabled ?? 0) === 1,
1114
+ semanticEmbeddingModelId: row?.semantic_embedding_model_id === null || row?.semantic_embedding_model_id === undefined
1115
+ ? null
1116
+ : String(row.semantic_embedding_model_id),
1117
+ semanticRerankModelId: row?.semantic_rerank_model_id === null || row?.semantic_rerank_model_id === undefined
1118
+ ? null
1119
+ : String(row.semantic_rerank_model_id),
1120
+ semanticVectorDimension: Math.min(65_536, Math.max(1, Number(row?.semantic_vector_dimension ?? 1_024) || 1_024)),
1121
+ semanticRecallLimit: Math.min(200, Math.max(1, Number(row?.semantic_recall_limit ?? 20) || 20)),
1122
+ semanticResultLimit: Math.min(100, Math.max(1, Number(row?.semantic_result_limit ?? 12) || 12)),
1123
+ semanticBudgetTokens: Math.min(100_000, Math.max(256, Number(row?.semantic_budget_tokens ?? 4_000) || 4_000)),
1124
+ semanticChannelWeight: Math.min(5, Math.max(0.1, Number(row?.semantic_channel_weight ?? 1) || 1)),
1074
1125
  updatedAt: String(row?.updated_at ?? "")
1075
1126
  };
1076
1127
  }
@@ -1155,6 +1206,95 @@ export class Store {
1155
1206
  });
1156
1207
  return this.getWorkAiSettings(workId);
1157
1208
  }
1209
+ updateWorkSemanticSearchSettings(workId, input) {
1210
+ this.getWork(workId);
1211
+ const current = this.getWorkAiSettings(workId);
1212
+ const enabled = input.enabled ?? Boolean(current.semanticSearchEnabled);
1213
+ const embeddingModelId = input.embeddingModelId === undefined
1214
+ ? current.semanticEmbeddingModelId ? String(current.semanticEmbeddingModelId) : null
1215
+ : input.embeddingModelId?.trim() || null;
1216
+ const rerankModelId = input.rerankModelId === undefined
1217
+ ? current.semanticRerankModelId ? String(current.semanticRerankModelId) : null
1218
+ : input.rerankModelId?.trim() || null;
1219
+ const vectorDimension = Math.min(65_536, Math.max(1, Math.trunc(input.vectorDimension ?? Number(current.semanticVectorDimension))));
1220
+ const recallLimit = Math.min(200, Math.max(1, Math.trunc(input.recallLimit ?? Number(current.semanticRecallLimit))));
1221
+ const resultLimit = Math.min(100, Math.max(1, Math.trunc(input.resultLimit ?? Number(current.semanticResultLimit))));
1222
+ const budgetTokens = Math.min(100_000, Math.max(256, Math.trunc(input.budgetTokens ?? Number(current.semanticBudgetTokens))));
1223
+ const channelWeight = Math.min(5, Math.max(0.1, Number(input.channelWeight ?? current.semanticChannelWeight)));
1224
+ const timestamp = now();
1225
+ this.db.transaction(() => {
1226
+ this.db.run(`INSERT INTO work_ai_settings (
1227
+ work_id, semantic_search_enabled, semantic_embedding_model_id, semantic_rerank_model_id,
1228
+ semantic_vector_dimension, semantic_recall_limit, semantic_result_limit,
1229
+ semantic_budget_tokens, semantic_channel_weight, updated_at
1230
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1231
+ ON CONFLICT(work_id) DO UPDATE SET
1232
+ semantic_search_enabled = excluded.semantic_search_enabled,
1233
+ semantic_embedding_model_id = excluded.semantic_embedding_model_id,
1234
+ semantic_rerank_model_id = excluded.semantic_rerank_model_id,
1235
+ semantic_vector_dimension = excluded.semantic_vector_dimension,
1236
+ semantic_recall_limit = excluded.semantic_recall_limit,
1237
+ semantic_result_limit = excluded.semantic_result_limit,
1238
+ semantic_budget_tokens = excluded.semantic_budget_tokens,
1239
+ semantic_channel_weight = excluded.semantic_channel_weight,
1240
+ updated_at = excluded.updated_at`, workId, enabled ? 1 : 0, embeddingModelId, rerankModelId, vectorDimension, recallLimit, resultLimit, budgetTokens, channelWeight, timestamp);
1241
+ this.audit(workId, "semantic.settings.updated", "work-ai-settings", workId, {
1242
+ enabled,
1243
+ embeddingModelId,
1244
+ rerankModelId,
1245
+ vectorDimension,
1246
+ recallLimit,
1247
+ resultLimit,
1248
+ budgetTokens,
1249
+ channelWeight
1250
+ });
1251
+ });
1252
+ return this.getWorkAiSettings(workId);
1253
+ }
1254
+ getSemanticContextSnapshot(snapshotId, workId) {
1255
+ const work = this.getWork(workId);
1256
+ const row = this.db.get("SELECT * FROM semantic_context_snapshots WHERE id = ? AND work_id = ?", snapshotId, workId);
1257
+ if (!row)
1258
+ throw notFound("语义上下文快照");
1259
+ const actor = currentRequestActor();
1260
+ const createdByUserId = row.created_by_user_id === null || row.created_by_user_id === undefined
1261
+ ? null
1262
+ : String(row.created_by_user_id);
1263
+ if (actor?.userId && createdByUserId && actor.userId !== createdByUserId) {
1264
+ throw new AppError(403, "SEMANTIC_SNAPSHOT_ACCESS_DENIED", "不能读取其他用户创建的语义上下文快照");
1265
+ }
1266
+ const permissions = work.modulePermissions;
1267
+ const items = this.db.all("SELECT * FROM semantic_context_snapshot_items WHERE snapshot_id = ? ORDER BY position", snapshotId).flatMap((item) => {
1268
+ const module = hybridSearchPermissionModule(String(item.source_type));
1269
+ if (!module || !canReadWorkModule(permissions, module))
1270
+ return [];
1271
+ return [{
1272
+ position: Number(item.position),
1273
+ entryId: String(item.entry_id),
1274
+ sourceType: String(item.source_type),
1275
+ sourceId: String(item.source_id),
1276
+ sectionId: String(item.section_id) || null,
1277
+ sourceVersion: String(item.source_version),
1278
+ sourceTitle: String(item.source_title),
1279
+ startLine: Number(item.start_line),
1280
+ endLine: Number(item.end_line),
1281
+ content: String(item.content),
1282
+ estimatedTokens: Number(item.estimated_tokens),
1283
+ matchKinds: json(String(item.match_kinds_json), ["semantic"])
1284
+ }];
1285
+ });
1286
+ return {
1287
+ id: String(row.id),
1288
+ workId: String(row.work_id),
1289
+ conversationId: row.conversation_id === null ? null : String(row.conversation_id),
1290
+ query: String(row.query),
1291
+ scope: json(String(row.scope_json), {}),
1292
+ configFingerprint: String(row.config_fingerprint),
1293
+ createdByUserId,
1294
+ createdAt: String(row.created_at),
1295
+ items
1296
+ };
1297
+ }
1158
1298
  clearAutoRunPause(workId) {
1159
1299
  this.getWork(workId);
1160
1300
  const current = this.getWorkAiSettings(workId);
@@ -2537,7 +2677,8 @@ export class Store {
2537
2677
  return this.db.all(`WITH RECURSIVE annotation_lines(line, end_line) AS (
2538
2678
  SELECT annotation.start_line, annotation.end_line
2539
2679
  FROM chapter_annotations annotation
2540
- WHERE annotation.chapter_id = ? AND annotation.deleted_at IS NULL${kindFilter.sql}
2680
+ WHERE annotation.chapter_id = ? AND annotation.deleted_at IS NULL
2681
+ AND NOT (annotation.kind = 'todo' AND annotation.status = 'resolved')${kindFilter.sql}
2541
2682
  UNION ALL
2542
2683
  SELECT line + 1, end_line
2543
2684
  FROM annotation_lines
@@ -2548,55 +2689,69 @@ export class Store {
2548
2689
  GROUP BY line
2549
2690
  ORDER BY line`, chapterId, ...kindFilter.params).map((row) => ({ line: Number(row.line), count: Number(row.count) }));
2550
2691
  }
2551
- listWorkChapterAnnotations(workId, kinds) {
2692
+ listWorkChapterAnnotations(workId, kinds, filters = {}) {
2552
2693
  this.getWork(workId);
2553
- const kindFilter = kinds === undefined
2554
- ? { sql: "", params: [] }
2555
- : kinds.length > 0
2556
- ? { sql: ` AND annotation.kind IN (${kinds.map(() => "?").join(",")})`, params: [...kinds] }
2557
- : { sql: " AND 1 = 0", params: [] };
2694
+ const filter = chapterAnnotationListFilter(kinds, filters);
2558
2695
  return this.db.all(`SELECT annotation.*, user.display_name AS actor_display_name, user.username AS actor_username,
2559
2696
  chapter.title AS chapter_title, volume.title AS volume_title
2560
2697
  FROM chapter_annotations annotation
2561
2698
  JOIN chapters chapter ON chapter.id = annotation.chapter_id
2562
2699
  JOIN volumes volume ON volume.id = chapter.volume_id
2563
2700
  LEFT JOIN users user ON user.id = annotation.updated_by_user_id
2564
- WHERE annotation.work_id = ? AND annotation.deleted_at IS NULL AND chapter.deleted_at IS NULL${kindFilter.sql}
2565
- ORDER BY CASE annotation.status WHEN 'open' THEN 0 ELSE 1 END,
2701
+ WHERE annotation.work_id = ? AND annotation.deleted_at IS NULL AND chapter.deleted_at IS NULL${filter.sql}
2702
+ ORDER BY CASE
2703
+ WHEN annotation.kind = 'todo' AND annotation.status = 'resolved' THEN 2
2704
+ WHEN annotation.status = 'open' THEN 0
2705
+ ELSE 1
2706
+ END,
2566
2707
  volume.sort_order, volume.created_at, chapter.sort_order, chapter.created_at,
2567
- annotation.start_line, annotation.created_at`, workId, ...kindFilter.params).map((row) => ({
2708
+ annotation.start_line, annotation.created_at`, workId, ...filter.params).map((row) => ({
2568
2709
  ...this.mapChapterAnnotation(row),
2569
2710
  volumeTitle: requiredString(row, "volume_title"),
2570
2711
  chapterTitle: requiredString(row, "chapter_title")
2571
2712
  }));
2572
2713
  }
2573
- listWorkChapterAnnotationsPage(workId, pagination, kinds) {
2714
+ listWorkChapterAnnotationsPage(workId, pagination, kinds, filters = {}) {
2574
2715
  this.getWork(workId);
2575
2716
  const page = paginationSql(pagination);
2576
- const kindFilter = kinds === undefined
2577
- ? { sql: "", params: [] }
2578
- : kinds.length > 0
2579
- ? { sql: ` AND annotation.kind IN (${kinds.map(() => "?").join(",")})`, params: [...kinds] }
2580
- : { sql: " AND 1 = 0", params: [] };
2717
+ const filter = chapterAnnotationListFilter(kinds, filters);
2718
+ const optionFilter = chapterAnnotationListFilter(kinds);
2581
2719
  const rows = this.db.all(`SELECT annotation.*, user.display_name AS actor_display_name, user.username AS actor_username,
2582
2720
  chapter.title AS chapter_title, volume.title AS volume_title
2583
2721
  FROM chapter_annotations annotation
2584
2722
  JOIN chapters chapter ON chapter.id = annotation.chapter_id
2585
2723
  JOIN volumes volume ON volume.id = chapter.volume_id
2586
2724
  LEFT JOIN users user ON user.id = annotation.updated_by_user_id
2587
- WHERE annotation.work_id = ? AND annotation.deleted_at IS NULL AND chapter.deleted_at IS NULL${kindFilter.sql}
2588
- ORDER BY CASE annotation.status WHEN 'open' THEN 0 ELSE 1 END,
2725
+ WHERE annotation.work_id = ? AND annotation.deleted_at IS NULL AND chapter.deleted_at IS NULL${filter.sql}
2726
+ ORDER BY CASE
2727
+ WHEN annotation.kind = 'todo' AND annotation.status = 'resolved' THEN 2
2728
+ WHEN annotation.status = 'open' THEN 0
2729
+ ELSE 1
2730
+ END,
2589
2731
  volume.sort_order, volume.created_at, chapter.sort_order, chapter.created_at,
2590
- annotation.start_line, annotation.created_at${page.sql}`, workId, ...kindFilter.params, ...page.params);
2732
+ annotation.start_line, annotation.created_at${page.sql}`, workId, ...filter.params, ...page.params);
2591
2733
  const total = numberValue(this.db.get(`SELECT COUNT(*) AS count
2592
2734
  FROM chapter_annotations annotation
2593
2735
  JOIN chapters chapter ON chapter.id = annotation.chapter_id
2594
- WHERE annotation.work_id = ? AND annotation.deleted_at IS NULL AND chapter.deleted_at IS NULL${kindFilter.sql}`, workId, ...kindFilter.params) ?? {}, "count");
2595
- return paginated(rows.map((row) => ({
2596
- ...this.mapChapterAnnotation(row),
2597
- volumeTitle: requiredString(row, "volume_title"),
2598
- chapterTitle: requiredString(row, "chapter_title")
2599
- })), pagination, total);
2736
+ JOIN volumes volume ON volume.id = chapter.volume_id
2737
+ WHERE annotation.work_id = ? AND annotation.deleted_at IS NULL AND chapter.deleted_at IS NULL${filter.sql}`, workId, ...filter.params) ?? {}, "count");
2738
+ const chapterOptions = this.db.all(`SELECT DISTINCT chapter.id, chapter.title, volume.title AS volume_title,
2739
+ volume.sort_order AS volume_sort_order, volume.created_at AS volume_created_at,
2740
+ chapter.sort_order AS chapter_sort_order, chapter.created_at AS chapter_created_at
2741
+ FROM chapter_annotations annotation
2742
+ JOIN chapters chapter ON chapter.id = annotation.chapter_id
2743
+ JOIN volumes volume ON volume.id = chapter.volume_id
2744
+ WHERE annotation.work_id = ? AND annotation.deleted_at IS NULL AND chapter.deleted_at IS NULL${optionFilter.sql}
2745
+ ORDER BY volume.sort_order, volume.created_at, chapter.sort_order, chapter.created_at`, workId, ...optionFilter.params).map((row) => ({
2746
+ id: requiredString(row, "id"),
2747
+ title: requiredString(row, "title"),
2748
+ volumeTitle: requiredString(row, "volume_title")
2749
+ }));
2750
+ return { ...paginated(rows.map((row) => ({
2751
+ ...this.mapChapterAnnotation(row),
2752
+ volumeTitle: requiredString(row, "volume_title"),
2753
+ chapterTitle: requiredString(row, "chapter_title")
2754
+ })), pagination, total), chapterOptions };
2600
2755
  }
2601
2756
  createChapterAnnotation(chapterId, input) {
2602
2757
  const chapter = this.getChapter(chapterId);
@@ -2662,6 +2817,64 @@ export class Store {
2662
2817
  return chapter;
2663
2818
  });
2664
2819
  const timestamp = now();
2820
+ if (action.type === "renumberTitles") {
2821
+ if (action.startAt + currentChapters.length - 1 > 999_999) {
2822
+ throw new AppError(400, "CHAPTER_NUMBER_RANGE", "起始序号与所选章节数量超出最大序号 999999");
2823
+ }
2824
+ const currentById = new Map(currentChapters.map((chapter) => [String(chapter.id), chapter]));
2825
+ const placeholders = currentChapters.map(() => "?").join(", ");
2826
+ const orderedChapters = this.db.all(`SELECT chapter.id FROM chapters chapter
2827
+ JOIN volumes volume ON volume.id = chapter.volume_id
2828
+ WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL
2829
+ AND volume.deleted_at IS NULL AND chapter.id IN (${placeholders})
2830
+ ORDER BY volume.sort_order, volume.created_at, volume.id,
2831
+ chapter.sort_order, chapter.created_at, chapter.id`, workId, ...currentChapters.map((chapter) => String(chapter.id))).map((row) => currentById.get(requiredString(row, "id"))).filter((chapter) => Boolean(chapter));
2832
+ const renumbered = orderedChapters.map((chapter, index) => ({
2833
+ chapter,
2834
+ title: renumberChapterTitle(String(chapter.title), action.startAt + index, action.template, action.numberStyle),
2835
+ sequence: action.startAt + index
2836
+ }));
2837
+ const oversized = renumbered.find((item) => item.title.length > 300);
2838
+ if (oversized) {
2839
+ throw new AppError(400, "CHAPTER_TITLE_TOO_LONG", `章节“${String(oversized.chapter.title).slice(0, 40)}”重排后的标题超过 300 个字符`);
2840
+ }
2841
+ let updated = 0;
2842
+ for (const item of renumbered) {
2843
+ const chapter = item.chapter;
2844
+ if (item.title === chapter.title)
2845
+ continue;
2846
+ const chapterId = String(chapter.id);
2847
+ const versionNo = Number(chapter.versionNo) + 1;
2848
+ this.db.run("UPDATE chapters SET title = ?, version_no = ?, analysis_status = 'expired', updated_at = ? WHERE id = ?", item.title, versionNo, timestamp, chapterId);
2849
+ this.syncChapterParagraphSearchVersion(chapterId, versionNo);
2850
+ this.insertChapterVersionRow({
2851
+ workId,
2852
+ chapterId,
2853
+ versionNo,
2854
+ title: item.title,
2855
+ content: String(chapter.content),
2856
+ volumeId: String(chapter.volumeId),
2857
+ sortOrder: Number(chapter.sortOrder),
2858
+ chapterType: String(chapter.chapterType),
2859
+ source: "manual",
2860
+ sourceRef: null,
2861
+ changeNote: "批量重排章节标题序号",
2862
+ timestamp
2863
+ });
2864
+ this.invalidateChapter(workId, chapterId, versionNo);
2865
+ this.audit(workId, "chapter.saved", "chapter", chapterId, {
2866
+ previousTitle: chapter.title,
2867
+ title: item.title,
2868
+ sequence: item.sequence,
2869
+ versionNo,
2870
+ batch: true,
2871
+ renumbered: true
2872
+ });
2873
+ updated += 1;
2874
+ }
2875
+ this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", timestamp, workId);
2876
+ return { processed: currentChapters.length, updated, action: action.type };
2877
+ }
2665
2878
  if (action.type === "move") {
2666
2879
  const targetVolume = this.getVolume(action.volumeId);
2667
2880
  if (targetVolume.workId !== workId)
@@ -6711,7 +6924,7 @@ export class Store {
6711
6924
  const roleplayCharacterId = optionalString(conversation, "roleplay_character_id");
6712
6925
  return {
6713
6926
  workId,
6714
- taskType: (optionalString(conversation, "task_type") ?? (roleplayCharacterId ? "roleplay" : "chat")),
6927
+ taskType: normalizeAiConversationTaskType(optionalString(conversation, "task_type"), roleplayCharacterId),
6715
6928
  roleplayCharacterId,
6716
6929
  roleplayUserCharacterId: optionalString(conversation, "roleplay_user_character_id"),
6717
6930
  roleplayMemories: roleplayCharacterId ? this.getRoleplayMemoryPromptItems(workId, roleplayCharacterId) : [],
@@ -6953,7 +7166,7 @@ export class Store {
6953
7166
  throw notFound("AI 对话");
6954
7167
  const workId = requiredString(conversation, "work_id");
6955
7168
  const previousCharacterId = optionalString(conversation, "roleplay_character_id");
6956
- const previousTaskType = optionalString(conversation, "task_type") ?? (previousCharacterId ? "roleplay" : "chat");
7169
+ const previousTaskType = normalizeAiConversationTaskType(optionalString(conversation, "task_type"), previousCharacterId);
6957
7170
  if (previousTaskType === taskType)
6958
7171
  return this.getAiConversationSummary(conversationId);
6959
7172
  const messageCount = Number(this.db.get("SELECT COUNT(*) AS count FROM ai_conversation_messages WHERE conversation_id = ?", conversationId)?.count ?? 0);
@@ -7257,7 +7470,7 @@ export class Store {
7257
7470
  const systemClockText = optionalString(conversation, "system_clock_text") ?? "";
7258
7471
  const scenePinJson = optionalString(conversation, "scene_pin_json") ?? "{}";
7259
7472
  this.db.transaction(() => {
7260
- this.db.run("INSERT INTO ai_conversations (id, work_id, roleplay_character_id, roleplay_user_character_id, task_type, context_scope_json, title, compacted_summary, compacted_message_count, agent_tools_json, injected_entities_json, system_clock_text, scene_pin_json, created_at, updated_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", forkId, workId, optionalString(conversation, "roleplay_character_id"), optionalString(conversation, "roleplay_user_character_id"), optionalString(conversation, "task_type"), optionalString(conversation, "context_scope_json"), title.slice(0, 200), forkSummary, forkCompactedCount, conversation.agent_tools_json == null
7473
+ this.db.run("INSERT INTO ai_conversations (id, work_id, roleplay_character_id, roleplay_user_character_id, task_type, context_scope_json, title, compacted_summary, compacted_message_count, agent_tools_json, injected_entities_json, system_clock_text, scene_pin_json, created_at, updated_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", forkId, workId, optionalString(conversation, "roleplay_character_id"), optionalString(conversation, "roleplay_user_character_id"), normalizeAiConversationTaskType(optionalString(conversation, "task_type"), optionalString(conversation, "roleplay_character_id")), optionalString(conversation, "context_scope_json"), title.slice(0, 200), forkSummary, forkCompactedCount, conversation.agent_tools_json == null
7261
7474
  ? JSON.stringify(normalizeWorkAgentTools(this.getWorkAiSettings(workId).agentTools))
7262
7475
  : String(conversation.agent_tools_json), injectedEntitiesJson, systemClockText, scenePinJson, timestamp, timestamp, currentRequestActor()?.userId ?? null);
7263
7476
  for (const message of messages.slice(0, targetIndex + 1)) {
@@ -7330,7 +7543,7 @@ export class Store {
7330
7543
  compactedMessageCount: numberValue(row, "compacted_message_count"),
7331
7544
  hasCompactedSummary: Boolean(requiredString(row, "compacted_summary")),
7332
7545
  contextWarningPending: Boolean(optionalString(row, "context_warning_at")),
7333
- taskType: optionalString(row, "task_type") ?? (roleplayCharacterId ? "roleplay" : "chat"),
7546
+ taskType: normalizeAiConversationTaskType(optionalString(row, "task_type"), roleplayCharacterId),
7334
7547
  ...(lockedModelId ? { modelId: lockedModelId } : {}),
7335
7548
  ...(hasImageAttachments ? { hasImageAttachments: true, modelLockedByImage: true } : {}),
7336
7549
  contextScope: json(optionalString(row, "context_scope_json") ?? "", { type: "none" }),