@musnows/scriverse 0.7.12 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.en.md +4 -0
  2. package/README.md +5 -0
  3. package/dist/ai-chat-tab-limit.js +14 -0
  4. package/dist/ai-chat-tab-limit.js.map +1 -0
  5. package/dist/ai-protocol.js +10 -2
  6. package/dist/ai-protocol.js.map +1 -1
  7. package/dist/ai-retry.js +52 -0
  8. package/dist/ai-retry.js.map +1 -0
  9. package/dist/ai.js +611 -152
  10. package/dist/ai.js.map +1 -1
  11. package/dist/app.js +41 -12
  12. package/dist/app.js.map +1 -1
  13. package/dist/cli-contract.js +2 -1
  14. package/dist/cli-contract.js.map +1 -1
  15. package/dist/database.js +127 -2
  16. package/dist/database.js.map +1 -1
  17. package/dist/domain.js +1 -0
  18. package/dist/domain.js.map +1 -1
  19. package/dist/public/ai-chat-tabs.js +75 -0
  20. package/dist/public/ai-request-manager.js +32 -15
  21. package/dist/public/app.js +1340 -315
  22. package/dist/public/background-task-center.d.ts +9 -0
  23. package/dist/public/background-task-center.js +18 -0
  24. package/dist/public/chapter-search.d.ts +6 -0
  25. package/dist/public/chapter-search.js +23 -0
  26. package/dist/public/character-filters.d.ts +3 -1
  27. package/dist/public/character-filters.js +17 -2
  28. package/dist/public/character-version.js +1 -0
  29. package/dist/public/display-labels.d.ts +1 -0
  30. package/dist/public/display-labels.js +5 -1
  31. package/dist/public/index.html +68 -27
  32. package/dist/public/model-config.d.ts +3 -1
  33. package/dist/public/model-config.js +13 -0
  34. package/dist/public/relationship-filters.d.ts +5 -0
  35. package/dist/public/relationship-filters.js +31 -0
  36. package/dist/public/relationship-graph.js +289 -58
  37. package/dist/public/stream-typewriter.d.ts +12 -1
  38. package/dist/public/stream-typewriter.js +65 -5
  39. package/dist/public/styles.css +239 -45
  40. package/dist/server-runtime.js +6 -0
  41. package/dist/server-runtime.js.map +1 -1
  42. package/dist/store.js +264 -65
  43. package/dist/store.js.map +1 -1
  44. package/dist/version.js +6 -1
  45. package/dist/version.js.map +1 -1
  46. package/package.json +1 -1
package/dist/store.js CHANGED
@@ -1,4 +1,4 @@
1
- import { DRAFT_SETTING_MODULES } from "./domain.js";
1
+ import { CHARACTER_GENDERS, DRAFT_SETTING_MODULES } from "./domain.js";
2
2
  import { createHash } from "node:crypto";
3
3
  import { ENTITY_VERSION_BASELINE_MIGRATION_VERSION, PLATFORM_AI_WORK_ID } from "./database.js";
4
4
  import { exportWorkDocx } from "./docx-export.js";
@@ -13,6 +13,7 @@ import { countWords, documentShortSearchTerms, escapeSqlLikePattern, id, json, n
13
13
  import { buildWritingCalendar, writingDateKey } from "./writing-progress-time.js";
14
14
  import { resolveMaxAgentToolCallLimit } from "./ai-tool-results.js";
15
15
  const WORK_LIST_BATCH_SIZE = 500;
16
+ const ENTITY_LIST_BATCH_SIZE = 400;
16
17
  export const RECYCLE_BIN_RETENTION_DAYS = 30;
17
18
  function recycleBinExpiresAt(deletedAt) {
18
19
  return new Date(new Date(deletedAt).getTime() + RECYCLE_BIN_RETENTION_DAYS * 24 * 60 * 60_000).toISOString();
@@ -25,9 +26,19 @@ export const WORK_AGENT_TOOL_IDS = [
25
26
  "search_story_entities",
26
27
  "read_character_sections",
27
28
  "search_drafts",
28
- "image"
29
+ "image",
30
+ "calculate_time"
29
31
  ];
30
32
  const DEFAULT_WORK_AGENT_TOOLS = [...WORK_AGENT_TOOL_IDS];
33
+ const LEGACY_DEFAULT_WORK_AGENT_TOOLS = [
34
+ "story_index",
35
+ "read_chapters",
36
+ "grep",
37
+ "search_story_entities",
38
+ "read_character_sections",
39
+ "search_drafts",
40
+ "image"
41
+ ];
31
42
  export function normalizeWorkAgentTools(value) {
32
43
  const source = Array.isArray(value)
33
44
  ? value
@@ -42,6 +53,8 @@ export function normalizeWorkAgentTools(value) {
42
53
  if (WORK_AGENT_TOOL_IDS.includes(toolId))
43
54
  enabled.add(toolId);
44
55
  }
56
+ if (LEGACY_DEFAULT_WORK_AGENT_TOOLS.every((toolId) => enabled.has(toolId)))
57
+ enabled.add("calculate_time");
45
58
  return WORK_AGENT_TOOL_IDS.filter((toolId) => enabled.has(toolId));
46
59
  }
47
60
  const galaxyFrameRates = [24, 30, 60, 90, 120, 144, 165, 240];
@@ -203,6 +216,11 @@ export function defaultAiConversationTitle(prompt) {
203
216
  function isRecord(value) {
204
217
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
205
218
  }
219
+ function characterGender(value) {
220
+ return typeof value === "string" && CHARACTER_GENDERS.includes(value)
221
+ ? value
222
+ : "unknown";
223
+ }
206
224
  function invalidFileSnapshot() {
207
225
  throw new AppError(409, "FILE_VERSION_INVALID", "正文历史快照已损坏,未执行恢复");
208
226
  }
@@ -338,6 +356,18 @@ export class Store {
338
356
  const row = this.db.get("SELECT MAX(version_no) AS version_no FROM entity_versions WHERE entity_type = ? AND entity_id = ?", type, entityId);
339
357
  return numberValue(row ?? {}, "version_no");
340
358
  }
359
+ currentEntityVersionNos(type, entityIds) {
360
+ const versions = new Map();
361
+ for (let offset = 0; offset < entityIds.length; offset += ENTITY_LIST_BATCH_SIZE) {
362
+ const batchIds = entityIds.slice(offset, offset + ENTITY_LIST_BATCH_SIZE);
363
+ const placeholders = batchIds.map(() => "?").join(", ");
364
+ const rows = this.db.all(`SELECT entity_id, MAX(version_no) AS version_no FROM entity_versions
365
+ WHERE entity_type = ? AND entity_id IN (${placeholders}) GROUP BY entity_id`, type, ...batchIds);
366
+ for (const row of rows)
367
+ versions.set(requiredString(row, "entity_id"), numberValue(row, "version_no"));
368
+ }
369
+ return versions;
370
+ }
341
371
  currentChapterVersionNo(chapterId) {
342
372
  return numberValue(this.db.get("SELECT MAX(version_no) AS version_no FROM chapter_versions WHERE chapter_id = ?", chapterId) ?? {}, "version_no");
343
373
  }
@@ -856,10 +886,14 @@ export class Store {
856
886
  return this.getPlatformUiSettings();
857
887
  }
858
888
  analysisTaskQueuedHandler = null;
889
+ chapterAnalysisInvalidatedHandler = null;
859
890
  relationshipIndexQueuedHandler = null;
860
891
  setAnalysisTaskQueuedHandler(handler) {
861
892
  this.analysisTaskQueuedHandler = handler;
862
893
  }
894
+ setChapterAnalysisInvalidatedHandler(handler) {
895
+ this.chapterAnalysisInvalidatedHandler = handler;
896
+ }
863
897
  setRelationshipIndexQueuedHandler(handler) {
864
898
  this.relationshipIndexQueuedHandler = handler;
865
899
  }
@@ -871,6 +905,14 @@ export class Store {
871
905
  // 自动运行调度失败不影响主写入路径
872
906
  }
873
907
  }
908
+ notifyChapterAnalysisInvalidated(workId, chapterId, versionNo) {
909
+ try {
910
+ this.chapterAnalysisInvalidatedHandler?.(workId, chapterId, versionNo);
911
+ }
912
+ catch {
913
+ // 稳定等待调度失败不影响主写入路径
914
+ }
915
+ }
874
916
  getWorkAiSettings(workId) {
875
917
  this.getWork(workId);
876
918
  const row = this.db.get("SELECT * FROM work_ai_settings WHERE work_id = ?", workId);
@@ -886,6 +928,7 @@ export class Store {
886
928
  autoRunBatchLimit: Math.min(200, Math.max(1, Number(row?.auto_run_batch_limit ?? 20) || 20)),
887
929
  autoRunDailyTaskLimit: Math.min(10_000, Math.max(0, Number(row?.auto_run_daily_task_limit ?? 0) || 0)),
888
930
  autoRunFailureThreshold: Math.min(10, Math.max(1, Number(row?.auto_run_failure_threshold ?? 3) || 3)),
931
+ autoRunStabilityDelayMinutes: Math.min(120, Math.max(1, Number(row?.auto_run_stability_delay_minutes ?? 2) || 2)),
889
932
  autoRunPaused: Number(row?.auto_run_paused ?? 0) === 1,
890
933
  autoRunPauseReason: String(row?.auto_run_pause_reason ?? ""),
891
934
  autoRunResumeAt: row?.auto_run_resume_at === null || row?.auto_run_resume_at === undefined ? null : String(row.auto_run_resume_at),
@@ -920,6 +963,7 @@ export class Store {
920
963
  const nextBatchLimit = input.autoRunBatchLimit ?? Number(current.autoRunBatchLimit);
921
964
  const nextDailyTaskLimit = input.autoRunDailyTaskLimit ?? Number(current.autoRunDailyTaskLimit);
922
965
  const nextFailureThreshold = input.autoRunFailureThreshold ?? Number(current.autoRunFailureThreshold);
966
+ const nextStabilityDelayMinutes = input.autoRunStabilityDelayMinutes ?? Number(current.autoRunStabilityDelayMinutes);
923
967
  const nextBookSummaryContextPercent = input.bookSummaryContextPercent ?? Number(current.bookSummaryContextPercent);
924
968
  const nextContextCompactThreshold = input.contextCompactThreshold ?? Number(current.contextCompactThreshold);
925
969
  const nextAgentToolCallLimit = input.agentToolCallLimit ?? Number(current.agentToolCallLimit);
@@ -934,11 +978,11 @@ export class Store {
934
978
  : input.titleGenerationModelId?.trim() || null;
935
979
  this.db.run(`INSERT INTO work_ai_settings (
936
980
  work_id, system_prompt, daily_token_quota, auto_run_enabled, auto_run_concurrency, auto_run_batch_limit,
937
- auto_run_daily_task_limit, auto_run_failure_threshold, auto_run_paused, auto_run_pause_reason,
981
+ auto_run_daily_task_limit, auto_run_failure_threshold, auto_run_stability_delay_minutes, auto_run_paused, auto_run_pause_reason,
938
982
  auto_run_resume_at, auto_run_consecutive_failures, book_summary_context_percent,
939
983
  context_compact_threshold, agent_tool_call_limit, agent_tool_call_global_multiplier,
940
984
  agent_tools_json, title_generation_model_id, image_tool_model_id, always_include_setting_info, updated_at
941
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
985
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
942
986
  ON CONFLICT(work_id) DO UPDATE SET
943
987
  system_prompt = excluded.system_prompt,
944
988
  daily_token_quota = excluded.daily_token_quota,
@@ -947,6 +991,7 @@ export class Store {
947
991
  auto_run_batch_limit = excluded.auto_run_batch_limit,
948
992
  auto_run_daily_task_limit = excluded.auto_run_daily_task_limit,
949
993
  auto_run_failure_threshold = excluded.auto_run_failure_threshold,
994
+ auto_run_stability_delay_minutes = excluded.auto_run_stability_delay_minutes,
950
995
  auto_run_paused = excluded.auto_run_paused,
951
996
  auto_run_pause_reason = excluded.auto_run_pause_reason,
952
997
  auto_run_resume_at = excluded.auto_run_resume_at,
@@ -959,7 +1004,7 @@ export class Store {
959
1004
  title_generation_model_id = excluded.title_generation_model_id,
960
1005
  image_tool_model_id = excluded.image_tool_model_id,
961
1006
  always_include_setting_info = excluded.always_include_setting_info,
962
- updated_at = excluded.updated_at`, workId, nextPrompt, nextDailyTokenQuota, nextEnabled ? 1 : 0, Math.min(8, Math.max(1, nextConcurrency)), Math.min(200, Math.max(1, nextBatchLimit)), Math.min(10_000, Math.max(0, nextDailyTaskLimit)), Math.min(10, Math.max(1, nextFailureThreshold)), current.autoRunPaused ? 1 : 0, String(current.autoRunPauseReason ?? ""), current.autoRunResumeAt === null ? null : String(current.autoRunResumeAt), Math.max(0, Number(current.autoRunConsecutiveFailures) || 0), Math.min(90, Math.max(1, nextBookSummaryContextPercent)), Math.min(90, Math.max(50, nextContextCompactThreshold)), Math.min(maximumAgentToolCallLimit, Math.max(5, nextAgentToolCallLimit)), Math.min(6, Math.max(1, nextAgentToolCallGlobalMultiplier)), JSON.stringify(nextAgentTools), nextTitleGenerationModelId, nextImageToolModelId, nextAlwaysIncludeSettingInfo ? 1 : 0, timestamp);
1007
+ updated_at = excluded.updated_at`, workId, nextPrompt, nextDailyTokenQuota, nextEnabled ? 1 : 0, Math.min(8, Math.max(1, nextConcurrency)), Math.min(200, Math.max(1, nextBatchLimit)), Math.min(10_000, Math.max(0, nextDailyTaskLimit)), Math.min(10, Math.max(1, nextFailureThreshold)), Math.min(120, Math.max(1, nextStabilityDelayMinutes)), current.autoRunPaused ? 1 : 0, String(current.autoRunPauseReason ?? ""), current.autoRunResumeAt === null ? null : String(current.autoRunResumeAt), Math.max(0, Number(current.autoRunConsecutiveFailures) || 0), Math.min(90, Math.max(1, nextBookSummaryContextPercent)), Math.min(90, Math.max(50, nextContextCompactThreshold)), Math.min(maximumAgentToolCallLimit, Math.max(5, nextAgentToolCallLimit)), Math.min(6, Math.max(1, nextAgentToolCallGlobalMultiplier)), JSON.stringify(nextAgentTools), nextTitleGenerationModelId, nextImageToolModelId, nextAlwaysIncludeSettingInfo ? 1 : 0, timestamp);
963
1008
  this.audit(workId, "work.ai-settings.updated", "work-ai-settings", workId, {
964
1009
  systemPromptChanged: input.systemPrompt !== undefined,
965
1010
  dailyTokenQuota: nextDailyTokenQuota,
@@ -968,6 +1013,7 @@ export class Store {
968
1013
  autoRunBatchLimit: Math.min(200, Math.max(1, nextBatchLimit)),
969
1014
  autoRunDailyTaskLimit: Math.min(10_000, Math.max(0, nextDailyTaskLimit)),
970
1015
  autoRunFailureThreshold: Math.min(10, Math.max(1, nextFailureThreshold)),
1016
+ autoRunStabilityDelayMinutes: Math.min(120, Math.max(1, nextStabilityDelayMinutes)),
971
1017
  bookSummaryContextPercent: Math.min(90, Math.max(1, nextBookSummaryContextPercent)),
972
1018
  contextCompactThreshold: Math.min(90, Math.max(50, nextContextCompactThreshold)),
973
1019
  agentToolCallLimit: Math.min(maximumAgentToolCallLimit, Math.max(5, nextAgentToolCallLimit)),
@@ -1263,18 +1309,19 @@ export class Store {
1263
1309
  }));
1264
1310
  return { ...work, volumes, directoryPage: pageResult };
1265
1311
  }
1266
- getStoryIndexChapterPage(workId, offset, limit) {
1312
+ getStoryIndexChapterPage(workId, offset, limit, options = {}) {
1267
1313
  const work = this.getWork(workId);
1268
1314
  const permissions = work.modulePermissions;
1269
1315
  if (permissions.prose === "none")
1270
1316
  return { totalChapters: 0, chapters: [] };
1317
+ const authorNoteFilter = options.excludeAuthorNotes ? " AND chapter.chapter_type <> '作者的话'" : "";
1271
1318
  const countRow = this.db.get(`SELECT COUNT(*) AS count FROM chapters chapter
1272
1319
  JOIN volumes volume ON volume.id = chapter.volume_id
1273
- WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL`, workId);
1320
+ WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL${authorNoteFilter}`, workId);
1274
1321
  const chapterRows = this.db.all(`SELECT chapter.id, chapter.title, chapter.version_no, volume.title AS volume_title
1275
1322
  FROM chapters chapter
1276
1323
  JOIN volumes volume ON volume.id = chapter.volume_id
1277
- WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL
1324
+ WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL${authorNoteFilter}
1278
1325
  ORDER BY volume.sort_order, volume.created_at, chapter.sort_order, chapter.created_at
1279
1326
  LIMIT ? OFFSET ?`, workId, limit, offset);
1280
1327
  const chapterIds = chapterRows.map((row) => requiredString(row, "id"));
@@ -1837,6 +1884,16 @@ export class Store {
1837
1884
  throw new AppError(400, "REPLACE_SCOPE_INVALID", "替换范围无效");
1838
1885
  }
1839
1886
  const work = this.getWork(workId);
1887
+ const volumeId = input.volumeId ?? null;
1888
+ if (volumeId) {
1889
+ const volume = this.getVolume(volumeId);
1890
+ if (String(volume.workId) !== workId) {
1891
+ throw new AppError(400, "REPLACE_VOLUME_INVALID", "分卷不属于当前作品");
1892
+ }
1893
+ if (input.scope === "settings") {
1894
+ throw new AppError(400, "REPLACE_VOLUME_SCOPE_INVALID", "分卷范围只能用于正文替换");
1895
+ }
1896
+ }
1840
1897
  const permissions = work.modulePermissions;
1841
1898
  const requestedProse = input.scope === "prose" || input.scope === "prose-and-settings";
1842
1899
  const requestedSettings = input.scope === "settings" || input.scope === "prose-and-settings";
@@ -1873,7 +1930,9 @@ export class Store {
1873
1930
  };
1874
1931
  this.db.transaction(() => {
1875
1932
  if (includeProse) {
1876
- const chapters = this.db.all("SELECT id, content FROM chapters WHERE work_id = ? AND deleted_at IS NULL ORDER BY sort_order, created_at", workId);
1933
+ const chapters = volumeId
1934
+ ? this.db.all("SELECT id, content FROM chapters WHERE work_id = ? AND volume_id = ? AND deleted_at IS NULL ORDER BY sort_order, created_at", workId, volumeId)
1935
+ : this.db.all("SELECT id, content FROM chapters WHERE work_id = ? AND deleted_at IS NULL ORDER BY sort_order, created_at", workId);
1877
1936
  for (const row of chapters) {
1878
1937
  const chapterId = requiredString(row, "id");
1879
1938
  const result = replaceLiteral(requiredString(row, "content"));
@@ -1902,6 +1961,7 @@ export class Store {
1902
1961
  this.audit(workId, "work.global-replace", "work", workId, {
1903
1962
  operationId,
1904
1963
  scope: input.scope,
1964
+ volumeId,
1905
1965
  chapterCount,
1906
1966
  settingCount,
1907
1967
  totalMatches,
@@ -1913,6 +1973,7 @@ export class Store {
1913
1973
  return {
1914
1974
  operationId,
1915
1975
  scope: input.scope,
1976
+ volumeId,
1916
1977
  chapterCount,
1917
1978
  settingCount,
1918
1979
  totalMatches,
@@ -2440,12 +2501,13 @@ export class Store {
2440
2501
  this.db.run(`UPDATE chapter_paragraph_line_ranges SET chapter_version = ?
2441
2502
  WHERE paragraph_id IN (SELECT id FROM chapter_paragraph_search WHERE chapter_id = ?)`, versionNo, chapterId);
2442
2503
  }
2443
- searchChapterParagraphs(workId, keyword, limit = 20) {
2504
+ searchChapterParagraphs(workId, keyword, limit = 20, options = {}) {
2444
2505
  this.getWork(workId);
2445
2506
  const normalizedKeyword = normalizeDocumentSearchText(keyword.trim());
2446
2507
  if (!normalizedKeyword)
2447
2508
  return [];
2448
2509
  const safeLimit = Math.min(100, Math.max(1, Math.trunc(limit)));
2510
+ const authorNoteFilter = options.excludeAuthorNotes ? " AND chapter.chapter_type <> '作者的话'" : "";
2449
2511
  const columns = `SELECT paragraph.chapter_id, chapter.title AS chapter_title, paragraph.content
2450
2512
  FROM chapter_paragraph_search paragraph
2451
2513
  JOIN chapters chapter ON chapter.id = paragraph.chapter_id
@@ -2453,12 +2515,12 @@ export class Store {
2453
2515
  const rows = [...normalizedKeyword].length < 3
2454
2516
  ? this.db.all(`${columns}
2455
2517
  JOIN chapter_paragraph_short_terms term ON term.paragraph_id = paragraph.id
2456
- WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL AND term.term = ?
2518
+ WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL${authorNoteFilter} AND term.term = ?
2457
2519
  ORDER BY volume.sort_order, chapter.sort_order, paragraph.paragraph_order
2458
2520
  LIMIT ?`, workId, normalizedKeyword, safeLimit)
2459
2521
  : this.db.all(`${columns}
2460
2522
  JOIN chapter_paragraph_search_fts fts ON fts.rowid = paragraph.id
2461
- WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL AND chapter_paragraph_search_fts MATCH ?
2523
+ WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL${authorNoteFilter} AND chapter_paragraph_search_fts MATCH ?
2462
2524
  ORDER BY volume.sort_order, chapter.sort_order, paragraph.paragraph_order
2463
2525
  LIMIT ?`, workId, `"${normalizedKeyword.replaceAll('"', '""')}"`, safeLimit);
2464
2526
  return rows.map((row) => ({
@@ -2470,8 +2532,6 @@ export class Store {
2470
2532
  invalidateChapter(workId, chapterId, versionNo) {
2471
2533
  this.db.run(`UPDATE analysis_tasks SET status = 'expired', updated_at = ?
2472
2534
  WHERE work_id = ? AND status IN ('pending', 'running', 'completed', 'partial', 'review')
2473
- AND NOT (status = 'pending' AND task_type = 'chapter-analysis'
2474
- AND json_extract(scope_json, '$.chapterId') = ?)
2475
2535
  AND (json_extract(scope_json, '$.chapterId') = ?
2476
2536
  OR EXISTS (SELECT 1 FROM json_each(scope_json, '$.chapterIds') WHERE json_each.value = ?)
2477
2537
  OR json_extract(scope_json, '$.type') = 'book'
@@ -2480,18 +2540,8 @@ export class Store {
2480
2540
  OR EXISTS (
2481
2541
  SELECT 1 FROM json_each(scope_json, '$.volumeIds')
2482
2542
  WHERE json_each.value = (SELECT volume_id FROM chapters WHERE id = ?)
2483
- ))))`, now(), workId, chapterId, chapterId, chapterId, chapterId, chapterId);
2484
- const existing = this.db.get(`SELECT id FROM analysis_tasks WHERE work_id = ? AND task_type = 'chapter-analysis' AND status = 'pending'
2485
- AND json_extract(scope_json, '$.chapterId') = ?`, workId, chapterId);
2486
- if (!existing) {
2487
- const timestamp = now();
2488
- this.db.run(`INSERT INTO analysis_tasks (id, work_id, task_type, scope_json, status, source_versions_json, created_at, updated_at, created_by_user_id)
2489
- VALUES (?, ?, 'chapter-analysis', ?, 'pending', ?, ?, ?, ?)`, id("task"), workId, JSON.stringify({ type: "chapter", chapterId }), JSON.stringify({ [chapterId]: versionNo }), timestamp, timestamp, currentRequestActor()?.userId ?? null);
2490
- }
2491
- else {
2492
- this.db.run("UPDATE analysis_tasks SET source_versions_json = ?, updated_at = ? WHERE id = ?", JSON.stringify({ [chapterId]: versionNo }), now(), requiredString(existing, "id"));
2493
- }
2494
- this.notifyAnalysisTaskQueued(workId);
2543
+ ))))`, now(), workId, chapterId, chapterId, chapterId, chapterId);
2544
+ this.notifyChapterAnalysisInvalidated(workId, chapterId, versionNo);
2495
2545
  }
2496
2546
  mapWorks(rows) {
2497
2547
  if (rows.length === 0)
@@ -3029,20 +3079,25 @@ export class Store {
3029
3079
  WHERE fo.foreshadow_id = ? ORDER BY v.sort_order, c.sort_order, fo.created_at`, foreshadowId).map((item) => this.mapForeshadowOccurrence(item));
3030
3080
  const status = requiredString(row, "status");
3031
3081
  const plannedPayoffChapterId = optionalString(row, "planned_payoff_chapter_id");
3082
+ const overdue = Boolean(currentChapterId && plannedPayoffChapterId && ["planned", "planted"].includes(status)
3083
+ && this.chapterSequence(workId, plannedPayoffChapterId) < this.chapterSequence(workId, currentChapterId));
3084
+ return this.mapForeshadow(row, occurrences, this.currentEntityVersionNo("foreshadow", foreshadowId), overdue);
3085
+ }
3086
+ mapForeshadow(row, occurrences, versionNo, overdue) {
3087
+ const status = requiredString(row, "status");
3032
3088
  return {
3033
3089
  id: requiredString(row, "id"),
3034
- workId,
3090
+ workId: requiredString(row, "work_id"),
3035
3091
  title: requiredString(row, "title"),
3036
3092
  description: requiredString(row, "description"),
3037
3093
  status,
3038
3094
  importance: requiredString(row, "importance"),
3039
- plannedPayoffChapterId,
3095
+ plannedPayoffChapterId: optionalString(row, "planned_payoff_chapter_id"),
3040
3096
  resolutionNote: requiredString(row, "resolution_note"),
3041
3097
  unresolved: status === "planned" || status === "planted",
3042
- overdue: Boolean(currentChapterId && plannedPayoffChapterId && ["planned", "planted"].includes(status)
3043
- && this.chapterSequence(workId, plannedPayoffChapterId) < this.chapterSequence(workId, currentChapterId)),
3098
+ overdue,
3044
3099
  occurrences,
3045
- versionNo: this.currentEntityVersionNo("foreshadow", foreshadowId),
3100
+ versionNo,
3046
3101
  createdAt: requiredString(row, "created_at"),
3047
3102
  updatedAt: requiredString(row, "updated_at")
3048
3103
  };
@@ -3054,8 +3109,9 @@ export class Store {
3054
3109
  const where = status === "unresolved"
3055
3110
  ? "AND status IN ('planned', 'planted')"
3056
3111
  : status === "resolved" ? "AND status IN ('resolved', 'abandoned')" : "";
3057
- return this.db.all(`SELECT id FROM foreshadows WHERE work_id = ? ${where}
3058
- ORDER BY CASE importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, created_at`, workId).map((row) => this.getForeshadow(requiredString(row, "id"), currentChapterId));
3112
+ const rows = this.db.all(`SELECT * FROM foreshadows WHERE work_id = ? ${where}
3113
+ ORDER BY CASE importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, created_at`, workId);
3114
+ return this.mapForeshadowList(rows, currentChapterId);
3059
3115
  }
3060
3116
  listForeshadowsPage(workId, pagination, status = "all", currentChapterId) {
3061
3117
  this.getWork(workId);
@@ -3065,9 +3121,63 @@ export class Store {
3065
3121
  ? "AND status IN ('planned', 'planted')"
3066
3122
  : status === "resolved" ? "AND status IN ('resolved', 'abandoned')" : "";
3067
3123
  const page = paginationSql(pagination);
3068
- const rows = this.db.all(`SELECT id FROM foreshadows WHERE work_id = ? ${where}
3124
+ const rows = this.db.all(`SELECT * FROM foreshadows WHERE work_id = ? ${where}
3069
3125
  ORDER BY CASE importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, created_at${page.sql}`, workId, ...page.params);
3070
- return paginated(rows.map((row) => this.getForeshadow(requiredString(row, "id"), currentChapterId)), pagination);
3126
+ return paginated(this.mapForeshadowList(rows, currentChapterId), pagination);
3127
+ }
3128
+ mapForeshadowList(rows, currentChapterId) {
3129
+ if (rows.length === 0)
3130
+ return [];
3131
+ const foreshadowIds = rows.map((row) => requiredString(row, "id"));
3132
+ const batch = {
3133
+ occurrences: new Map(),
3134
+ versions: this.currentEntityVersionNos("foreshadow", foreshadowIds),
3135
+ chapterSequences: new Map()
3136
+ };
3137
+ for (let offset = 0; offset < foreshadowIds.length; offset += ENTITY_LIST_BATCH_SIZE) {
3138
+ const batchIds = foreshadowIds.slice(offset, offset + ENTITY_LIST_BATCH_SIZE);
3139
+ const placeholders = batchIds.map(() => "?").join(", ");
3140
+ const occurrences = this.db.all(`SELECT fo.*, c.title AS chapter_title, c.volume_id, c.sort_order AS chapter_order,
3141
+ v.title AS volume_title, v.sort_order AS volume_order
3142
+ FROM foreshadow_occurrences fo
3143
+ JOIN chapters c ON c.id = fo.chapter_id
3144
+ JOIN volumes v ON v.id = c.volume_id
3145
+ WHERE fo.foreshadow_id IN (${placeholders})
3146
+ ORDER BY fo.foreshadow_id, v.sort_order, c.sort_order, fo.created_at`, ...batchIds);
3147
+ for (const occurrence of occurrences) {
3148
+ const foreshadowId = requiredString(occurrence, "foreshadow_id");
3149
+ const grouped = batch.occurrences.get(foreshadowId) ?? [];
3150
+ grouped.push(this.mapForeshadowOccurrence(occurrence));
3151
+ batch.occurrences.set(foreshadowId, grouped);
3152
+ }
3153
+ }
3154
+ if (currentChapterId) {
3155
+ const chapterIds = [...new Set([
3156
+ currentChapterId,
3157
+ ...rows.map((row) => optionalString(row, "planned_payoff_chapter_id")).filter((chapterId) => Boolean(chapterId))
3158
+ ])];
3159
+ for (let offset = 0; offset < chapterIds.length; offset += ENTITY_LIST_BATCH_SIZE) {
3160
+ const batchIds = chapterIds.slice(offset, offset + ENTITY_LIST_BATCH_SIZE);
3161
+ const placeholders = batchIds.map(() => "?").join(", ");
3162
+ const sequences = this.db.all(`SELECT c.id, v.sort_order * 1000000 + c.sort_order AS sequence
3163
+ FROM chapters c JOIN volumes v ON v.id = c.volume_id
3164
+ WHERE c.id IN (${placeholders}) AND c.work_id = ?`, ...batchIds, requiredString(rows[0] ?? {}, "work_id"));
3165
+ for (const sequence of sequences) {
3166
+ batch.chapterSequences.set(requiredString(sequence, "id"), numberValue(sequence, "sequence"));
3167
+ }
3168
+ }
3169
+ }
3170
+ const currentChapterSequence = currentChapterId
3171
+ ? batch.chapterSequences.get(currentChapterId) ?? Number.MAX_SAFE_INTEGER
3172
+ : Number.MAX_SAFE_INTEGER;
3173
+ return rows.map((row) => {
3174
+ const foreshadowId = requiredString(row, "id");
3175
+ const status = requiredString(row, "status");
3176
+ const plannedPayoffChapterId = optionalString(row, "planned_payoff_chapter_id");
3177
+ const overdue = Boolean(currentChapterId && plannedPayoffChapterId && ["planned", "planted"].includes(status)
3178
+ && (batch.chapterSequences.get(plannedPayoffChapterId) ?? Number.MAX_SAFE_INTEGER) < currentChapterSequence);
3179
+ return this.mapForeshadow(row, batch.occurrences.get(foreshadowId) ?? [], batch.versions.get(foreshadowId) ?? 0, overdue);
3180
+ });
3071
3181
  }
3072
3182
  listChapterForeshadowReminders(workId, chapterId) {
3073
3183
  this.getWork(workId);
@@ -3750,13 +3860,16 @@ export class Store {
3750
3860
  }
3751
3861
  listOrganizations(workId, includeMarkdown = true) {
3752
3862
  this.getWork(workId);
3753
- return this.db.all("SELECT * FROM organizations WHERE work_id = ? ORDER BY name", workId).map((row) => this.mapOrganization(row, includeMarkdown));
3863
+ const rows = this.db.all("SELECT * FROM organizations WHERE work_id = ? ORDER BY name", workId);
3864
+ const batch = this.organizationListBatch(rows);
3865
+ return rows.map((row) => this.mapOrganization(row, includeMarkdown, batch));
3754
3866
  }
3755
3867
  listOrganizationsPage(workId, pagination, includeMarkdown = true) {
3756
3868
  this.getWork(workId);
3757
3869
  const page = paginationSql(pagination);
3758
3870
  const rows = this.db.all(`SELECT * FROM organizations WHERE work_id = ? ORDER BY name${page.sql}`, workId, ...page.params);
3759
- return paginated(rows.map((row) => this.mapOrganization(row, includeMarkdown)), pagination);
3871
+ const batch = this.organizationListBatch(rows);
3872
+ return paginated(rows.map((row) => this.mapOrganization(row, includeMarkdown, batch)), pagination);
3760
3873
  }
3761
3874
  getOrganization(organizationId) {
3762
3875
  const row = this.db.get("SELECT * FROM organizations WHERE id = ?", organizationId);
@@ -3843,20 +3956,50 @@ export class Store {
3843
3956
  });
3844
3957
  return { mergeId, target: this.getOrganization(targetOrganizationId), source };
3845
3958
  }
3846
- mapOrganization(row, includeMarkdown = true) {
3959
+ organizationListBatch(rows) {
3960
+ const organizationIds = rows.map((row) => requiredString(row, "id"));
3961
+ const batch = {
3962
+ members: new Map(),
3963
+ versions: this.currentEntityVersionNos("organization", organizationIds)
3964
+ };
3965
+ for (let offset = 0; offset < organizationIds.length; offset += ENTITY_LIST_BATCH_SIZE) {
3966
+ const batchIds = organizationIds.slice(offset, offset + ENTITY_LIST_BATCH_SIZE);
3967
+ const placeholders = batchIds.map(() => "?").join(", ");
3968
+ const members = this.db.all(`SELECT m.organization_id, c.id, c.name, m.role, m.note
3969
+ FROM character_organization_memberships m
3970
+ JOIN characters c ON c.id = m.character_id
3971
+ WHERE m.organization_id IN (${placeholders}) ORDER BY m.organization_id, c.name`, ...batchIds);
3972
+ for (const member of members) {
3973
+ const organizationId = requiredString(member, "organization_id");
3974
+ const grouped = batch.members.get(organizationId) ?? [];
3975
+ grouped.push({
3976
+ characterId: requiredString(member, "id"),
3977
+ name: requiredString(member, "name"),
3978
+ role: requiredString(member, "role"),
3979
+ note: requiredString(member, "note")
3980
+ });
3981
+ batch.members.set(organizationId, grouped);
3982
+ }
3983
+ }
3984
+ return batch;
3985
+ }
3986
+ mapOrganization(row, includeMarkdown = true, batch) {
3987
+ const organizationId = requiredString(row, "id");
3847
3988
  const settingsSections = knowledgeSectionsFromStored(row.settings_sections_json, json(requiredString(row, "settings_json"), []));
3848
3989
  const settings = settingsFromKnowledgeSections(settingsSections);
3849
- const members = this.db.all(`SELECT c.id, c.name, m.role, m.note
3850
- FROM character_organization_memberships m
3851
- JOIN characters c ON c.id = m.character_id
3852
- WHERE m.organization_id = ? ORDER BY c.name`, requiredString(row, "id")).map((member) => ({
3853
- characterId: requiredString(member, "id"),
3854
- name: requiredString(member, "name"),
3855
- role: requiredString(member, "role"),
3856
- note: requiredString(member, "note")
3857
- }));
3990
+ const members = batch
3991
+ ? batch.members.get(organizationId) ?? []
3992
+ : this.db.all(`SELECT c.id, c.name, m.role, m.note
3993
+ FROM character_organization_memberships m
3994
+ JOIN characters c ON c.id = m.character_id
3995
+ WHERE m.organization_id = ? ORDER BY c.name`, organizationId).map((member) => ({
3996
+ characterId: requiredString(member, "id"),
3997
+ name: requiredString(member, "name"),
3998
+ role: requiredString(member, "role"),
3999
+ note: requiredString(member, "note")
4000
+ }));
3858
4001
  return {
3859
- id: requiredString(row, "id"),
4002
+ id: organizationId,
3860
4003
  workId: requiredString(row, "work_id"),
3861
4004
  name: requiredString(row, "name"),
3862
4005
  description: requiredString(row, "description"),
@@ -3866,7 +4009,7 @@ export class Store {
3866
4009
  : { settings: [], settingsCount: settingsSections.length }),
3867
4010
  memberIds: members.map((member) => member.characterId),
3868
4011
  members,
3869
- versionNo: this.currentEntityVersionNo("organization", requiredString(row, "id")),
4012
+ versionNo: batch ? batch.versions.get(organizationId) ?? 0 : this.currentEntityVersionNo("organization", organizationId),
3870
4013
  createdAt: requiredString(row, "created_at"),
3871
4014
  updatedAt: requiredString(row, "updated_at")
3872
4015
  };
@@ -3913,6 +4056,7 @@ export class Store {
3913
4056
  delete profile.sections;
3914
4057
  return {
3915
4058
  name: String(character.name),
4059
+ gender: characterGender(character.gender),
3916
4060
  isDead: Boolean(character.isDead),
3917
4061
  code: String(character.code),
3918
4062
  aliases: [...character.aliases],
@@ -3966,9 +4110,9 @@ export class Store {
3966
4110
  const organizationIds = [...new Set(input.organizationIds ?? [])];
3967
4111
  this.assertOrganizationsInWork(workId, organizationIds);
3968
4112
  this.db.transaction(() => {
3969
- this.db.run(`INSERT INTO characters (id, work_id, name, code, aliases_json, species, race_id, attributes_json, profile_json, current_state_json,
4113
+ this.db.run(`INSERT INTO characters (id, work_id, name, code, gender, aliases_json, species, race_id, attributes_json, profile_json, current_state_json,
3970
4114
  is_dead, locked_fields_json, first_chapter_id, created_at, updated_at)
3971
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, characterId, workId, names.name, input.code?.trim() ?? "", JSON.stringify(names.aliases), species, raceId, JSON.stringify(input.attributes ?? {}), JSON.stringify(input.profile ?? {}), JSON.stringify(input.currentState ?? {}), input.isDead ? 1 : 0, JSON.stringify(input.lockedFields ?? []), input.firstChapterId ?? null, timestamp, timestamp);
4115
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, characterId, workId, names.name, input.code?.trim() ?? "", input.gender ?? "unknown", JSON.stringify(names.aliases), species, raceId, JSON.stringify(input.attributes ?? {}), JSON.stringify(input.profile ?? {}), JSON.stringify(input.currentState ?? {}), input.isDead ? 1 : 0, JSON.stringify(input.lockedFields ?? []), input.firstChapterId ?? null, timestamp, timestamp);
3972
4116
  this.insertCharacterNames(workId, characterId, names.entries);
3973
4117
  this.replaceCharacterOrganizations(characterId, organizationIds);
3974
4118
  this.insertCharacterVersion(characterId, 1, source, sourceRef, changeNote, timestamp);
@@ -4203,7 +4347,8 @@ export class Store {
4203
4347
  searchCharacterProfileSections(workId, query, limit = 20) {
4204
4348
  this.getWork(workId);
4205
4349
  const normalized = normalizeDocumentSearchText(query);
4206
- const columns = `SELECT section.*, character.name AS character_name, character.is_dead AS character_is_dead
4350
+ const columns = `SELECT section.*, character.name AS character_name, character.gender AS character_gender,
4351
+ character.is_dead AS character_is_dead
4207
4352
  FROM character_profile_section_search search
4208
4353
  JOIN character_profile_sections section ON section.id = search.section_id
4209
4354
  JOIN characters character ON character.id = search.character_id`;
@@ -4218,6 +4363,7 @@ export class Store {
4218
4363
  return rows.map((row) => ({
4219
4364
  ...this.mapCharacterProfileSection(row),
4220
4365
  characterName: requiredString(row, "character_name"),
4366
+ gender: requiredString(row, "character_gender"),
4221
4367
  isDead: booleanValue(row, "character_is_dead")
4222
4368
  }));
4223
4369
  }
@@ -4445,8 +4591,8 @@ export class Store {
4445
4591
  this.db.transaction(() => {
4446
4592
  const lockedCurrent = this.getCharacter(characterId);
4447
4593
  this.assertExpectedRevision("character", characterId, expectedVersionNo, "人物", Number(lockedCurrent.versionNo));
4448
- this.db.run(`UPDATE characters SET name = ?, code = ?, aliases_json = ?, species = ?, race_id = ?, attributes_json = ?, profile_json = ?, current_state_json = ?,
4449
- is_dead = ?, locked_fields_json = ?, first_chapter_id = ?, updated_at = ? WHERE id = ?`, names.name, input.code === undefined ? String(current.code) : input.code.trim(), JSON.stringify(names.aliases), species, raceId, JSON.stringify(attributes), JSON.stringify(input.profile ?? current.profile), JSON.stringify(input.currentState ?? current.currentState), input.isDead === undefined ? (current.isDead ? 1 : 0) : (input.isDead ? 1 : 0), JSON.stringify(input.lockedFields ?? current.lockedFields), input.firstChapterId === undefined ? current.firstChapterId : input.firstChapterId, now(), characterId);
4594
+ this.db.run(`UPDATE characters SET name = ?, code = ?, gender = ?, aliases_json = ?, species = ?, race_id = ?, attributes_json = ?, profile_json = ?, current_state_json = ?,
4595
+ is_dead = ?, locked_fields_json = ?, first_chapter_id = ?, updated_at = ? WHERE id = ?`, names.name, input.code === undefined ? String(current.code) : input.code.trim(), input.gender ?? characterGender(current.gender), JSON.stringify(names.aliases), species, raceId, JSON.stringify(attributes), JSON.stringify(input.profile ?? current.profile), JSON.stringify(input.currentState ?? current.currentState), input.isDead === undefined ? (current.isDead ? 1 : 0) : (input.isDead ? 1 : 0), JSON.stringify(input.lockedFields ?? current.lockedFields), input.firstChapterId === undefined ? current.firstChapterId : input.firstChapterId, now(), characterId);
4450
4596
  this.db.run("DELETE FROM character_names WHERE character_id = ?", characterId);
4451
4597
  this.insertCharacterNames(workId, characterId, names.entries);
4452
4598
  if (organizationIds)
@@ -4515,7 +4661,7 @@ export class Store {
4515
4661
  this.assertExpectedRevision("character", characterId, expectedVersionNo, "人物", this.currentCharacterVersionNo(characterId));
4516
4662
  return this.recreateCharacterFromVersion(characterId, version, snapshot, versionNo);
4517
4663
  }
4518
- return this.updateCharacter(characterId, { ...snapshot, isDead: snapshot.isDead ?? false, code: snapshot.code ?? "" }, "restore", requiredString(version, "id"), `恢复至 v${versionNo}`, expectedVersionNo);
4664
+ return this.updateCharacter(characterId, { ...snapshot, gender: snapshot.gender ?? "unknown", isDead: snapshot.isDead ?? false, code: snapshot.code ?? "" }, "restore", requiredString(version, "id"), `恢复至 v${versionNo}`, expectedVersionNo);
4519
4665
  }
4520
4666
  recreateCharacterFromVersion(characterId, version, snapshot, versionNo) {
4521
4667
  const workId = requiredString(version, "work_id");
@@ -4532,9 +4678,9 @@ export class Store {
4532
4678
  const timestamp = now();
4533
4679
  const nextVersionNo = numberValue(this.db.get("SELECT COALESCE(MAX(version_no), 0) AS version_no FROM character_versions WHERE character_id = ?", characterId) ?? {}, "version_no") + 1;
4534
4680
  this.db.transaction(() => {
4535
- this.db.run(`INSERT INTO characters (id, work_id, name, code, aliases_json, species, race_id, attributes_json, profile_json, current_state_json,
4681
+ this.db.run(`INSERT INTO characters (id, work_id, name, code, gender, aliases_json, species, race_id, attributes_json, profile_json, current_state_json,
4536
4682
  is_dead, locked_fields_json, first_chapter_id, version_no, created_at, updated_at)
4537
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, characterId, workId, names.name, snapshot.code ?? "", JSON.stringify(names.aliases), species, raceId, JSON.stringify(snapshot.attributes ?? {}), JSON.stringify(snapshot.profile ?? {}), JSON.stringify(snapshot.currentState ?? {}), snapshot.isDead ? 1 : 0, JSON.stringify(snapshot.lockedFields ?? []), snapshot.firstChapterId ?? null, nextVersionNo, timestamp, timestamp);
4683
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, characterId, workId, names.name, snapshot.code ?? "", snapshot.gender ?? "unknown", JSON.stringify(names.aliases), species, raceId, JSON.stringify(snapshot.attributes ?? {}), JSON.stringify(snapshot.profile ?? {}), JSON.stringify(snapshot.currentState ?? {}), snapshot.isDead ? 1 : 0, JSON.stringify(snapshot.lockedFields ?? []), snapshot.firstChapterId ?? null, nextVersionNo, timestamp, timestamp);
4538
4684
  this.insertCharacterNames(workId, characterId, names.entries);
4539
4685
  this.replaceCharacterOrganizations(characterId, organizationIds);
4540
4686
  this.insertCharacterVersion(characterId, nextVersionNo, "restore", requiredString(version, "id"), `恢复至 v${versionNo}`, timestamp, workId);
@@ -4611,6 +4757,7 @@ export class Store {
4611
4757
  workId: requiredString(row, "work_id"),
4612
4758
  name: requiredString(row, "name"),
4613
4759
  code: requiredString(row, "code"),
4760
+ gender: characterGender(row.gender),
4614
4761
  aliases: indexedAliases.length > 0 ? indexedAliases : json(requiredString(row, "aliases_json"), []),
4615
4762
  raceId: race ? String(race.id) : null,
4616
4763
  race: race ? {
@@ -5652,6 +5799,43 @@ export class Store {
5652
5799
  throw notFound("AI 对话消息");
5653
5800
  return this.mapAiConversationMessage(persistInterruption(message));
5654
5801
  }
5802
+ upsertAiConversationAssistantMessage(conversationId, requestId, content, metadata = {}, syncSearchIndex = false) {
5803
+ const normalizedRequestId = requestId.trim();
5804
+ if (!normalizedRequestId)
5805
+ throw new AppError(400, "AI_MESSAGE_REQUEST_ID_REQUIRED", "AI 助手消息缺少请求标识");
5806
+ const existing = this.db.get("SELECT * FROM ai_conversation_messages WHERE conversation_id = ? AND request_id = ?", conversationId, normalizedRequestId);
5807
+ if (!existing) {
5808
+ return this.addAiConversationMessage(conversationId, {
5809
+ role: "assistant",
5810
+ content,
5811
+ requestId: normalizedRequestId,
5812
+ metadata
5813
+ });
5814
+ }
5815
+ if (requiredString(existing, "role") !== "assistant") {
5816
+ throw new AppError(409, "AI_MESSAGE_ROLE_MISMATCH", "请求标识已用于用户消息");
5817
+ }
5818
+ const currentMetadata = json(requiredString(existing, "metadata_json"), {});
5819
+ const nextMetadata = { ...currentMetadata, ...metadata };
5820
+ const contentChanged = requiredString(existing, "content") !== content;
5821
+ const metadataChanged = JSON.stringify(currentMetadata) !== JSON.stringify(nextMetadata);
5822
+ if (!contentChanged && !metadataChanged) {
5823
+ if (syncSearchIndex)
5824
+ this.syncAiHistorySearchShortTermsForSource("message", requiredString(existing, "id"));
5825
+ return this.mapAiConversationMessage(existing);
5826
+ }
5827
+ const timestamp = now();
5828
+ this.db.transaction(() => {
5829
+ this.db.run("UPDATE ai_conversation_messages SET content = ?, metadata_json = ? WHERE id = ?", content, JSON.stringify(nextMetadata), requiredString(existing, "id"));
5830
+ this.db.run("UPDATE ai_conversations SET updated_at = ? WHERE id = ?", timestamp, conversationId);
5831
+ if (syncSearchIndex)
5832
+ this.syncAiHistorySearchShortTermsForSource("message", requiredString(existing, "id"));
5833
+ });
5834
+ const updated = this.db.get("SELECT * FROM ai_conversation_messages WHERE id = ?", requiredString(existing, "id"));
5835
+ if (!updated)
5836
+ throw notFound("AI 对话消息");
5837
+ return this.mapAiConversationMessage(updated);
5838
+ }
5655
5839
  beginAiConversationStreamRequest(input, referenceTime = new Date()) {
5656
5840
  const timestamp = referenceTime.toISOString();
5657
5841
  const leaseExpiresAt = new Date(referenceTime.getTime() + AI_CONVERSATION_STREAM_REQUEST_LEASE_MS).toISOString();
@@ -5769,10 +5953,18 @@ export class Store {
5769
5953
  if (!assistant)
5770
5954
  throw new AppError(400, "AI_STREAM_ASSISTANT_MISMATCH", "AI 回复消息不属于当前对话请求");
5771
5955
  }
5956
+ const resolvedAssistantMessageId = assistantMessageId ?? (() => {
5957
+ const userMessageId = optionalString(request, "user_message_id");
5958
+ if (!userMessageId)
5959
+ return null;
5960
+ const assistant = this.db.get(`SELECT id FROM ai_conversation_messages
5961
+ WHERE conversation_id = ? AND role = 'assistant' AND request_id = ?`, requiredString(request, "conversation_id"), `assistant:${userMessageId}`);
5962
+ return assistant ? requiredString(assistant, "id") : null;
5963
+ })();
5772
5964
  this.db.run(`UPDATE ai_conversation_stream_requests
5773
5965
  SET status = ?, terminal_reason = ?, assistant_message_id = COALESCE(assistant_message_id, ?),
5774
5966
  lease_expires_at = NULL, updated_at = ?, completed_at = ?
5775
- WHERE id = ? AND status = 'in_progress'`, status, terminalReason.slice(0, 500), assistantMessageId ?? null, timestamp, timestamp, requestId);
5967
+ WHERE id = ? AND status = 'in_progress'`, status, terminalReason.slice(0, 500), resolvedAssistantMessageId, timestamp, timestamp, requestId);
5776
5968
  const completed = this.db.get("SELECT * FROM ai_conversation_stream_requests WHERE id = ?", requestId);
5777
5969
  if (!completed)
5778
5970
  throw notFound("AI 对话请求");
@@ -6023,11 +6215,13 @@ export class Store {
6023
6215
  ...(Array.isArray(scope.chapterIds) ? scope.chapterIds.filter((value) => typeof value === "string") : [])
6024
6216
  ];
6025
6217
  for (const chapterId of [...new Set(selectedChapterIds)]) {
6026
- const chapter = this.db.get("SELECT work_id, version_no FROM chapters WHERE id = ? AND deleted_at IS NULL", chapterId);
6218
+ const chapter = this.db.get("SELECT work_id, version_no, chapter_type FROM chapters WHERE id = ? AND deleted_at IS NULL", chapterId);
6027
6219
  if (!chapter)
6028
6220
  throw notFound("章节");
6029
6221
  if (requiredString(chapter, "work_id") !== workId)
6030
6222
  throw new AppError(400, "CHAPTER_WORK_MISMATCH", "章节不属于当前作品");
6223
+ if (requiredString(chapter, "chapter_type") === "作者的话")
6224
+ continue;
6031
6225
  sourceVersions[chapterId] = numberValue(chapter, "version_no");
6032
6226
  }
6033
6227
  if (scope.type === "book" || scope.type === "volume") {
@@ -6051,6 +6245,7 @@ export class Store {
6051
6245
  FROM chapters chapter
6052
6246
  JOIN volumes volume ON volume.id = chapter.volume_id
6053
6247
  WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL
6248
+ AND chapter.chapter_type <> '作者的话'
6054
6249
  ${volumeFilter}`, workId, ...(scope.type === "volume" ? [...selectedVolumeIdSet] : []));
6055
6250
  for (const chapter of chapterRows) {
6056
6251
  sourceVersions[requiredString(chapter, "id")] = numberValue(chapter, "version_no");
@@ -7219,15 +7414,16 @@ export class Store {
7219
7414
  }
7220
7415
  taskScopeSummaryFromMaps(scope, chapterSummaries, volumeTitles, characterNames, includeCharacterNames = true) {
7221
7416
  const targetedSuffix = this.taskTargetedSuffix(scope, characterNames, includeCharacterNames);
7417
+ const chapterSettingsLabel = scope.includeAllSettings === true ? " + 设定集" : "";
7222
7418
  if (Array.isArray(scope.chapterIds) && scope.chapterIds.length > 0) {
7223
7419
  const labels = scope.chapterIds
7224
7420
  .filter((chapterId) => typeof chapterId === "string")
7225
7421
  .map((chapterId) => chapterSummaries.get(chapterId) ?? "章节已删除");
7226
7422
  const preview = labels.slice(0, 3).join("、");
7227
- return `指定章节(${labels.length}):${preview}${labels.length > 3 ? "……" : ""}${targetedSuffix}`;
7423
+ return `指定章节${chapterSettingsLabel}(${labels.length}):${preview}${labels.length > 3 ? "……" : ""}${targetedSuffix}`;
7228
7424
  }
7229
7425
  if (typeof scope.chapterId === "string")
7230
- return `${chapterSummaries.get(scope.chapterId) ?? "章节已删除"}${targetedSuffix}`;
7426
+ return `${chapterSummaries.get(scope.chapterId) ?? "章节已删除"}${chapterSettingsLabel}${targetedSuffix}`;
7231
7427
  if (Array.isArray(scope.volumeIds) && scope.volumeIds.length > 0) {
7232
7428
  const labels = scope.volumeIds
7233
7429
  .filter((volumeId) => typeof volumeId === "string")
@@ -7253,6 +7449,7 @@ export class Store {
7253
7449
  }
7254
7450
  taskScopeSummary(workId, scope, characterNames, includeCharacterNames = true) {
7255
7451
  const targetedSuffix = this.taskTargetedSuffix(scope, characterNames, includeCharacterNames);
7452
+ const chapterSettingsLabel = scope.includeAllSettings === true ? " + 设定集" : "";
7256
7453
  if (Array.isArray(scope.chapterIds) && scope.chapterIds.length > 0) {
7257
7454
  const labels = scope.chapterIds.map((chapterId) => {
7258
7455
  const chapter = this.db.get(`SELECT chapter.title AS title, volume.title AS volume_title
@@ -7262,7 +7459,7 @@ export class Store {
7262
7459
  return chapter ? `${requiredString(chapter, "volume_title")} · ${requiredString(chapter, "title")}` : "章节已删除";
7263
7460
  });
7264
7461
  const preview = labels.slice(0, 3).join("、");
7265
- return `指定章节(${labels.length}):${preview}${labels.length > 3 ? "……" : ""}${targetedSuffix}`;
7462
+ return `指定章节${chapterSettingsLabel}(${labels.length}):${preview}${labels.length > 3 ? "……" : ""}${targetedSuffix}`;
7266
7463
  }
7267
7464
  if (typeof scope.chapterId === "string") {
7268
7465
  const chapter = this.db.get(`SELECT chapter.title AS title, volume.title AS volume_title
@@ -7270,10 +7467,10 @@ export class Store {
7270
7467
  JOIN volumes volume ON volume.id = chapter.volume_id
7271
7468
  WHERE chapter.id = ? AND chapter.work_id = ? AND chapter.deleted_at IS NULL`, scope.chapterId, workId);
7272
7469
  if (!chapter)
7273
- return `章节已删除${targetedSuffix}`;
7470
+ return `章节已删除${chapterSettingsLabel}${targetedSuffix}`;
7274
7471
  const title = requiredString(chapter, "title");
7275
7472
  const volumeTitle = requiredString(chapter, "volume_title");
7276
- return `${volumeTitle} · ${title}${targetedSuffix}`;
7473
+ return `${volumeTitle} · ${title}${chapterSettingsLabel}${targetedSuffix}`;
7277
7474
  }
7278
7475
  if (Array.isArray(scope.volumeIds) && scope.volumeIds.length > 0) {
7279
7476
  const labels = scope.volumeIds.map((volumeId) => {
@@ -7512,7 +7709,7 @@ export class Store {
7512
7709
  ), character_race_paths AS (
7513
7710
  SELECT character_id, path FROM character_race_lineage WHERE parent_race_id IS NULL
7514
7711
  )
7515
- SELECT character.id, character.name, character.aliases_json, character.species, character.is_dead,
7712
+ SELECT character.id, character.name, character.aliases_json, character.species, character.gender, character.is_dead,
7516
7713
  COALESCE(path.path, character.species) AS race_path
7517
7714
  FROM characters character LEFT JOIN character_race_paths path ON path.character_id = character.id
7518
7715
  WHERE character.work_id = ? AND character.merged_into_character_id IS NULL AND (
@@ -7535,6 +7732,7 @@ export class Store {
7535
7732
  title: requiredString(row, "name"),
7536
7733
  snippet: [requiredString(row, "race_path"), ...json(requiredString(row, "aliases_json"), [])].filter(Boolean).join("、"),
7537
7734
  racePath: requiredString(row, "race_path"),
7735
+ gender: requiredString(row, "gender"),
7538
7736
  isDead: booleanValue(row, "is_dead")
7539
7737
  })),
7540
7738
  ...characterSections.map((section) => ({
@@ -7544,6 +7742,7 @@ export class Store {
7544
7742
  title: `${String(section.characterName)} / ${String(section.title)}`,
7545
7743
  snippet: snippet(String(section.contentMarkdown)),
7546
7744
  sectionType: String(section.sectionType),
7745
+ gender: String(section.gender),
7547
7746
  isDead: Boolean(section.isDead)
7548
7747
  })),
7549
7748
  ...settings.map((row) => ({ type: "setting", id: requiredString(row, "id"), title: requiredString(row, "title"), snippet: snippet(requiredString(row, "content")), category: requiredString(row, "category") })),