@musnows/scriverse 0.7.2 → 0.7.4

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 (82) hide show
  1. package/README.en.md +3 -0
  2. package/README.md +8 -0
  3. package/dist/ai-connectivity-test.js +109 -0
  4. package/dist/ai-connectivity-test.js.map +1 -0
  5. package/dist/ai-conversation-export.js +70 -0
  6. package/dist/ai-conversation-export.js.map +1 -0
  7. package/dist/ai-stream-timeout.js +18 -0
  8. package/dist/ai-stream-timeout.js.map +1 -0
  9. package/dist/ai.js +926 -354
  10. package/dist/ai.js.map +1 -1
  11. package/dist/app.js +498 -80
  12. package/dist/app.js.map +1 -1
  13. package/dist/attachment-storage.js +28 -12
  14. package/dist/attachment-storage.js.map +1 -1
  15. package/dist/backup-encryption.js +129 -0
  16. package/dist/backup-encryption.js.map +1 -0
  17. package/dist/character-extraction.js +133 -0
  18. package/dist/character-extraction.js.map +1 -0
  19. package/dist/cli-core.js +7 -6
  20. package/dist/cli-core.js.map +1 -1
  21. package/dist/collaboration-presence.js +200 -18
  22. package/dist/collaboration-presence.js.map +1 -1
  23. package/dist/database.js +335 -3
  24. package/dist/database.js.map +1 -1
  25. package/dist/docx-export.js +1 -1
  26. package/dist/docx-export.js.map +1 -1
  27. package/dist/domain.js +18 -0
  28. package/dist/domain.js.map +1 -1
  29. package/dist/epub-export.js +319 -0
  30. package/dist/epub-export.js.map +1 -0
  31. package/dist/hybrid-search.js +8 -0
  32. package/dist/hybrid-search.js.map +1 -1
  33. package/dist/image-metadata.js +17 -2
  34. package/dist/image-metadata.js.map +1 -1
  35. package/dist/presence-store.js +160 -0
  36. package/dist/presence-store.js.map +1 -0
  37. package/dist/public/ai-connectivity-test.d.ts +7 -0
  38. package/dist/public/ai-connectivity-test.js +82 -0
  39. package/dist/public/ai-context-meter.js +27 -0
  40. package/dist/public/ai-mentions.js +14 -0
  41. package/dist/public/ai-request-manager.js +99 -0
  42. package/dist/public/ai-stream-protocol.js +51 -0
  43. package/dist/public/app.js +3587 -559
  44. package/dist/public/background-task-center.js +1 -1
  45. package/dist/public/chapter-editor-virtualization.js +52 -0
  46. package/dist/public/chapter-version-diff.d.ts +20 -0
  47. package/dist/public/chapter-version-diff.js +116 -0
  48. package/dist/public/foreshadow-reminder.d.ts +32 -0
  49. package/dist/public/foreshadow-reminder.js +73 -0
  50. package/dist/public/global-replace-refresh.js +60 -0
  51. package/dist/public/index.html +161 -31
  52. package/dist/public/outline-board.d.ts +61 -0
  53. package/dist/public/outline-board.js +137 -0
  54. package/dist/public/page-route.d.ts +1 -0
  55. package/dist/public/page-route.js +8 -0
  56. package/dist/public/presence-client-id.d.ts +10 -0
  57. package/dist/public/presence-client-id.js +31 -0
  58. package/dist/public/reading-preview.d.ts +32 -0
  59. package/dist/public/reading-preview.js +136 -0
  60. package/dist/public/s3-backup-ui.d.ts +10 -0
  61. package/dist/public/s3-backup-ui.js +29 -0
  62. package/dist/public/setting-filters.d.ts +16 -0
  63. package/dist/public/setting-filters.js +19 -0
  64. package/dist/public/styles.css +522 -11
  65. package/dist/public/upload-progress.d.ts +2 -0
  66. package/dist/public/upload-progress.js +10 -0
  67. package/dist/s3-backup.js +197 -12
  68. package/dist/s3-backup.js.map +1 -1
  69. package/dist/security.js +86 -21
  70. package/dist/security.js.map +1 -1
  71. package/dist/server-runtime.js +38 -18
  72. package/dist/server-runtime.js.map +1 -1
  73. package/dist/store.js +899 -104
  74. package/dist/store.js.map +1 -1
  75. package/dist/upload-limits.js +35 -0
  76. package/dist/upload-limits.js.map +1 -0
  77. package/dist/user-auth.js +56 -10
  78. package/dist/user-auth.js.map +1 -1
  79. package/dist/utils.js +3 -0
  80. package/dist/utils.js.map +1 -1
  81. package/dist/version.js +1 -1
  82. package/package.json +4 -2
package/dist/store.js CHANGED
@@ -1,15 +1,22 @@
1
1
  import { DRAFT_SETTING_MODULES } from "./domain.js";
2
2
  import { createHash } from "node:crypto";
3
- import { PLATFORM_AI_WORK_ID } from "./database.js";
3
+ import { ENTITY_VERSION_BASELINE_MIGRATION_VERSION, PLATFORM_AI_WORK_ID } from "./database.js";
4
4
  import { exportWorkDocx } from "./docx-export.js";
5
+ import { createEpubArchive } from "./epub-export.js";
5
6
  import { AppError, notFound } from "./errors.js";
7
+ import { normalizeWorkSearchQuery } from "./hybrid-search.js";
6
8
  import { accountReference, logger } from "./logger.js";
7
9
  import { paginated, paginationSql } from "./pagination.js";
8
10
  import { currentRequestActor } from "./request-context.js";
9
11
  import { canWriteWorkModule, classifyWorkModulePermissions, emptyWorkModulePermissions, fullWorkModulePermissions, storedWorkModulePermissions } from "./work-permissions.js";
10
- import { countWords, documentShortSearchTerms, id, json, normalizeDocumentSearchText, normalizeParagraphSpacing, now, splitDocumentParagraphs } from "./utils.js";
12
+ import { countWords, documentShortSearchTerms, escapeSqlLikePattern, id, json, normalizeDocumentSearchText, normalizeParagraphSpacing, now, splitDocumentParagraphs } from "./utils.js";
11
13
  import { buildWritingCalendar, writingDateKey } from "./writing-progress-time.js";
12
14
  import { resolveMaxAgentToolCallLimit } from "./ai-tool-results.js";
15
+ const WORK_LIST_BATCH_SIZE = 500;
16
+ export const RECYCLE_BIN_RETENTION_DAYS = 30;
17
+ function recycleBinExpiresAt(deletedAt) {
18
+ return new Date(new Date(deletedAt).getTime() + RECYCLE_BIN_RETENTION_DAYS * 24 * 60 * 60_000).toISOString();
19
+ }
13
20
  export const attachmentPermissionModules = ["prose", "drafts", "settings", "characters", "races", "organizations"];
14
21
  export const WORK_AGENT_TOOL_IDS = [
15
22
  "story_index",
@@ -147,6 +154,7 @@ function knowledgeSectionsFromInput(sections, settingsMarkdown, settings, fallba
147
154
  function settingsFromKnowledgeSections(sections) {
148
155
  return sections.map((section) => section.contentMarkdown).filter((content) => content.trim());
149
156
  }
157
+ const CHAPTER_OUTLINE_BOARD_PREVIEW_LENGTH = 600;
150
158
  export const versionedEntityTypes = [
151
159
  "work",
152
160
  "volume",
@@ -160,6 +168,7 @@ export const versionedEntityTypes = [
160
168
  "chapter-outline",
161
169
  "foreshadow"
162
170
  ];
171
+ export const AI_CONVERSATION_STREAM_REQUEST_LEASE_MS = 3 * 60_000;
163
172
  export const aiConversationTaskTypes = ["chat", "roleplay", "continue", "polish"];
164
173
  export function defaultAiConversationTitle(prompt) {
165
174
  const normalized = prompt.replace(/\s+/gu, " ").trim();
@@ -266,7 +275,38 @@ export class Store {
266
275
  db;
267
276
  constructor(db) {
268
277
  this.db = db;
269
- this.backfillEntityVersionBaselines();
278
+ this.migrateEntityVersionBaselines();
279
+ this.purgeExpiredRecycleBin();
280
+ }
281
+ purgeExpiredRecycleBin(referenceTime = new Date()) {
282
+ const cutoff = new Date(referenceTime.getTime() - RECYCLE_BIN_RETENTION_DAYS * 24 * 60 * 60_000).toISOString();
283
+ return this.db.transaction(() => {
284
+ let works = 0;
285
+ let volumes = 0;
286
+ let chapters = 0;
287
+ for (const work of this.db.all("SELECT * FROM works WHERE deleted_at IS NOT NULL AND deleted_at <= ?", cutoff)) {
288
+ this.permanentlyRemoveWorkRow(work, "retention-expired");
289
+ works += 1;
290
+ }
291
+ for (const volume of this.db.all(`SELECT volume.* FROM volumes volume JOIN works work ON work.id = volume.work_id
292
+ WHERE work.deleted_at IS NULL AND volume.deleted_at IS NOT NULL AND volume.deleted_at <= ?`, cutoff)) {
293
+ this.permanentlyRemoveVolumeRow(volume, "retention-expired");
294
+ volumes += 1;
295
+ }
296
+ for (const chapter of this.db.all(`SELECT chapter.* FROM chapters chapter
297
+ JOIN works work ON work.id = chapter.work_id
298
+ JOIN volumes volume ON volume.id = chapter.volume_id
299
+ WHERE work.deleted_at IS NULL AND volume.deleted_at IS NULL
300
+ AND chapter.deleted_at IS NOT NULL AND chapter.deleted_via_volume_id IS NULL
301
+ AND chapter.deleted_at <= ?`, cutoff)) {
302
+ this.permanentlyRemoveChapterRow(chapter, "retention-expired");
303
+ chapters += 1;
304
+ }
305
+ if (works || volumes || chapters) {
306
+ logger.info("recycle_bin.retention_purged", { works, volumes, chapters, retentionDays: RECYCLE_BIN_RETENTION_DAYS });
307
+ }
308
+ return { works, volumes, chapters };
309
+ });
270
310
  }
271
311
  currentEntityVersionNo(type, entityId) {
272
312
  const row = this.db.get("SELECT MAX(version_no) AS version_no FROM entity_versions WHERE entity_type = ? AND entity_id = ?", type, entityId);
@@ -490,6 +530,26 @@ export class Store {
490
530
  }
491
531
  });
492
532
  }
533
+ migrateEntityVersionBaselines() {
534
+ const migrationSql = "SELECT 1 AS present FROM schema_migrations WHERE version = ?";
535
+ if (this.db.get(migrationSql, ENTITY_VERSION_BASELINE_MIGRATION_VERSION))
536
+ return;
537
+ this.db.transaction(() => {
538
+ if (this.db.get(migrationSql, ENTITY_VERSION_BASELINE_MIGRATION_VERSION))
539
+ return;
540
+ if (!this.db.get("SELECT 1 AS present FROM entity_versions LIMIT 1")) {
541
+ this.backfillEntityVersionBaselines();
542
+ }
543
+ const integrity = this.db.all("PRAGMA integrity_check");
544
+ if (integrity.some((row) => row.integrity_check !== "ok")) {
545
+ throw new Error(`数据库完整性检查失败:${integrity.map((row) => row.integrity_check).join(";")}`);
546
+ }
547
+ const foreignKeys = this.db.all("PRAGMA foreign_key_check");
548
+ if (foreignKeys.length > 0)
549
+ throw new Error(`数据库外键检查失败:发现 ${foreignKeys.length} 条异常记录`);
550
+ this.db.run("INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)", ENTITY_VERSION_BASELINE_MIGRATION_VERSION, now());
551
+ });
552
+ }
493
553
  listEntityVersions(type, entityId) {
494
554
  const rows = this.db.all(`SELECT version.*, user.display_name AS actor_display_name, user.username AS actor_username
495
555
  FROM entity_versions version LEFT JOIN users user ON user.id = version.created_by_user_id
@@ -534,6 +594,9 @@ export class Store {
534
594
  })), pagination);
535
595
  }
536
596
  restoreEntityVersion(type, entityId, versionNo, expectedVersionNo) {
597
+ if (type === "volume" && this.db.get("SELECT 1 AS present FROM volumes WHERE id = ? AND deleted_at IS NOT NULL", entityId)) {
598
+ throw new AppError(409, "ENTITY_IN_RECYCLE_BIN", "分卷位于回收站,请先从回收站恢复");
599
+ }
537
600
  const version = this.db.get("SELECT * FROM entity_versions WHERE entity_type = ? AND entity_id = ? AND version_no = ?", type, entityId, versionNo);
538
601
  if (!version)
539
602
  throw notFound("历史版本");
@@ -662,28 +725,61 @@ export class Store {
662
725
  listWorks() {
663
726
  const actor = currentRequestActor();
664
727
  if (!actor || (actor.role === "admin" && actor.authentication !== "api-key")) {
665
- return this.db.all("SELECT * FROM works WHERE COALESCE(is_internal, 0) = 0 ORDER BY updated_at DESC").map((row) => this.mapWork(row));
728
+ return this.mapWorks(this.db.all("SELECT * FROM works WHERE COALESCE(is_internal, 0) = 0 AND deleted_at IS NULL ORDER BY updated_at DESC"));
666
729
  }
667
- return this.db.all(`SELECT DISTINCT work.* FROM works work LEFT JOIN work_memberships membership ON membership.work_id = work.id
668
- WHERE COALESCE(work.is_internal, 0) = 0 AND (work.owner_user_id = ? OR membership.user_id = ?)
669
- ORDER BY work.updated_at DESC`, actor.userId, actor.userId).map((row) => this.mapWork(row));
730
+ return this.mapWorks(this.db.all(`SELECT DISTINCT work.* FROM works work LEFT JOIN work_memberships membership ON membership.work_id = work.id
731
+ WHERE COALESCE(work.is_internal, 0) = 0 AND work.deleted_at IS NULL
732
+ AND (work.owner_user_id = ? OR membership.user_id = ?)
733
+ ORDER BY work.updated_at DESC`, actor.userId, actor.userId));
670
734
  }
671
735
  listWorksPage(pagination) {
672
736
  const actor = currentRequestActor();
673
737
  const page = paginationSql(pagination);
674
738
  const rows = !actor || (actor.role === "admin" && actor.authentication !== "api-key")
675
- ? this.db.all(`SELECT * FROM works WHERE COALESCE(is_internal, 0) = 0 ORDER BY updated_at DESC${page.sql}`, ...page.params)
739
+ ? this.db.all(`SELECT * FROM works WHERE COALESCE(is_internal, 0) = 0 AND deleted_at IS NULL ORDER BY updated_at DESC${page.sql}`, ...page.params)
676
740
  : this.db.all(`SELECT DISTINCT work.* FROM works work LEFT JOIN work_memberships membership ON membership.work_id = work.id
677
- WHERE COALESCE(work.is_internal, 0) = 0 AND (work.owner_user_id = ? OR membership.user_id = ?)
741
+ WHERE COALESCE(work.is_internal, 0) = 0 AND work.deleted_at IS NULL
742
+ AND (work.owner_user_id = ? OR membership.user_id = ?)
678
743
  ORDER BY work.updated_at DESC${page.sql}`, actor.userId, actor.userId, ...page.params);
679
- return paginated(rows.map((row) => this.mapWork(row)), pagination);
744
+ return paginated(this.mapWorks(rows), pagination);
680
745
  }
681
746
  getWork(workId) {
682
- const row = this.db.get("SELECT * FROM works WHERE id = ?", workId);
747
+ const row = this.db.get("SELECT * FROM works WHERE id = ? AND deleted_at IS NULL", workId);
683
748
  if (!row)
684
749
  throw notFound("作品");
685
750
  return this.mapWork(row);
686
751
  }
752
+ listDeletedWorks() {
753
+ const actor = currentRequestActor();
754
+ const actorRestricted = Boolean(actor && !(actor.role === "admin" && actor.authentication !== "api-key"));
755
+ const rows = this.db.all(`SELECT work.*,
756
+ (SELECT COUNT(*) FROM volumes volume WHERE volume.work_id = work.id) AS volume_count,
757
+ (SELECT COUNT(*) FROM chapters chapter WHERE chapter.work_id = work.id) AS chapter_count,
758
+ user.display_name AS actor_display_name, user.username AS actor_username
759
+ FROM works work
760
+ LEFT JOIN entity_versions version
761
+ ON version.entity_type = 'work' AND version.entity_id = work.id
762
+ AND version.version_no = work.version_no AND version.source = 'delete'
763
+ LEFT JOIN users user ON user.id = version.created_by_user_id
764
+ WHERE COALESCE(work.is_internal, 0) = 0 AND work.deleted_at IS NOT NULL
765
+ ${actorRestricted ? "AND work.owner_user_id = ?" : ""}
766
+ ORDER BY work.deleted_at DESC, work.id DESC`, ...(actorRestricted ? [actor.userId] : []));
767
+ return rows.map((row) => {
768
+ const deletedAt = requiredString(row, "deleted_at");
769
+ return {
770
+ id: requiredString(row, "id"),
771
+ title: requiredString(row, "title"),
772
+ author: requiredString(row, "author"),
773
+ description: requiredString(row, "description"),
774
+ versionNo: numberValue(row, "version_no"),
775
+ volumeCount: numberValue(row, "volume_count"),
776
+ chapterCount: numberValue(row, "chapter_count"),
777
+ deletedAt,
778
+ expiresAt: recycleBinExpiresAt(deletedAt),
779
+ actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? "历史数据"
780
+ };
781
+ });
782
+ }
687
783
  getPlatformAiSettings() {
688
784
  const row = this.db.get("SELECT * FROM platform_ai_settings WHERE id = 1");
689
785
  return {
@@ -917,26 +1013,74 @@ export class Store {
917
1013
  return this.getWork(workId);
918
1014
  }
919
1015
  deleteWork(workId, expectedVersionNo) {
920
- const work = this.getWork(workId);
921
- const storageKeys = this.db.all("SELECT DISTINCT storage_key FROM attachments WHERE work_id = ?", workId)
922
- .map((row) => requiredString(row, "storage_key"));
923
1016
  this.db.transaction(() => {
924
- this.db.raw.exec("PRAGMA defer_foreign_keys = ON");
925
1017
  const current = this.getWork(workId);
926
1018
  this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
927
- this.recordEntityVersion("work", workId, "delete", null, "删除作品");
928
- this.audit(null, "work.deleted", "work", workId, { title: work.title });
929
- this.db.run("DELETE FROM characters WHERE work_id = ?", workId);
930
- this.db.run("DELETE FROM organizations WHERE work_id = ?", workId);
931
- this.db.run("UPDATE races SET parent_race_id = NULL WHERE work_id = ? AND parent_race_id IS NOT NULL", workId);
932
- this.db.run("DELETE FROM races WHERE work_id = ?", workId);
933
- this.db.run("DELETE FROM works WHERE id = ?", workId);
934
- this.db.run("DELETE FROM relationship_source_index_queue WHERE work_id = ?", workId);
935
- for (const storageKey of storageKeys) {
936
- if (!this.attachmentStorageKeyInUse(storageKey))
937
- this.enqueueAttachmentCleanup(storageKey);
938
- }
1019
+ const timestamp = now();
1020
+ const versionNo = this.recordEntityVersion("work", workId, "delete", null, "删除作品(可恢复)", timestamp);
1021
+ this.db.run("UPDATE works SET version_no = ?, deleted_at = ?, updated_at = ? WHERE id = ?", versionNo, timestamp, timestamp, workId);
1022
+ this.audit(workId, "work.deleted", "work", workId, {
1023
+ title: current.title,
1024
+ versionNo,
1025
+ recoverable: true,
1026
+ expiresAt: recycleBinExpiresAt(timestamp)
1027
+ });
1028
+ });
1029
+ return [];
1030
+ }
1031
+ restoreWork(workId, expectedVersionNo) {
1032
+ const deleted = this.db.get("SELECT * FROM works WHERE id = ? AND deleted_at IS NOT NULL", workId);
1033
+ if (!deleted)
1034
+ throw notFound("回收站作品");
1035
+ this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", numberValue(deleted, "version_no"));
1036
+ this.db.transaction(() => {
1037
+ const locked = this.db.get("SELECT * FROM works WHERE id = ? AND deleted_at IS NOT NULL", workId);
1038
+ if (!locked)
1039
+ throw new AppError(409, "WORK_ALREADY_RESTORED", "作品已经恢复");
1040
+ this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", numberValue(locked, "version_no"));
1041
+ const deletionVersion = this.db.get("SELECT id FROM entity_versions WHERE entity_type = 'work' AND entity_id = ? AND source = 'delete' ORDER BY version_no DESC LIMIT 1", workId);
1042
+ const timestamp = now();
1043
+ this.db.run("UPDATE works SET deleted_at = NULL, updated_at = ? WHERE id = ?", timestamp, workId);
1044
+ const versionNo = this.recordEntityVersion("work", workId, "restore", optionalString(deletionVersion ?? {}, "id"), "从回收站恢复作品", timestamp);
1045
+ this.db.run("UPDATE works SET version_no = ? WHERE id = ?", versionNo, workId);
1046
+ this.audit(workId, "work.restored", "work", workId, { versionNo, fromRecycleBin: true });
939
1047
  });
1048
+ return this.getWorkDirectory(workId);
1049
+ }
1050
+ permanentlyDeleteWork(workId, expectedVersionNo, reason = "manual") {
1051
+ const deleted = this.db.get("SELECT * FROM works WHERE id = ? AND deleted_at IS NOT NULL", workId);
1052
+ if (!deleted)
1053
+ throw new AppError(409, "WORK_NOT_IN_RECYCLE_BIN", "仅回收站中的作品可以彻底删除");
1054
+ this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", numberValue(deleted, "version_no"));
1055
+ return this.db.transaction(() => {
1056
+ const locked = this.db.get("SELECT * FROM works WHERE id = ? AND deleted_at IS NOT NULL", workId);
1057
+ if (!locked)
1058
+ throw new AppError(409, "WORK_NOT_IN_RECYCLE_BIN", "仅回收站中的作品可以彻底删除");
1059
+ this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", numberValue(locked, "version_no"));
1060
+ return this.permanentlyRemoveWorkRow(locked, reason);
1061
+ });
1062
+ }
1063
+ permanentlyRemoveWorkRow(work, reason) {
1064
+ const workId = requiredString(work, "id");
1065
+ const storageKeys = this.db.all("SELECT DISTINCT storage_key FROM attachments WHERE work_id = ?", workId)
1066
+ .map((row) => requiredString(row, "storage_key"));
1067
+ this.db.raw.exec("PRAGMA defer_foreign_keys = ON");
1068
+ this.audit(null, "work.purged", "work", workId, {
1069
+ title: requiredString(work, "title"),
1070
+ versionNo: numberValue(work, "version_no"),
1071
+ reason,
1072
+ recoverable: false
1073
+ });
1074
+ this.db.run("DELETE FROM characters WHERE work_id = ?", workId);
1075
+ this.db.run("DELETE FROM organizations WHERE work_id = ?", workId);
1076
+ this.db.run("UPDATE races SET parent_race_id = NULL WHERE work_id = ? AND parent_race_id IS NOT NULL", workId);
1077
+ this.db.run("DELETE FROM races WHERE work_id = ?", workId);
1078
+ this.db.run("DELETE FROM works WHERE id = ?", workId);
1079
+ this.db.run("DELETE FROM relationship_source_index_queue WHERE work_id = ?", workId);
1080
+ for (const storageKey of storageKeys) {
1081
+ if (!this.attachmentStorageKeyInUse(storageKey))
1082
+ this.enqueueAttachmentCleanup(storageKey);
1083
+ }
940
1084
  return storageKeys.filter((storageKey) => !this.attachmentStorageKeyInUse(storageKey));
941
1085
  }
942
1086
  setWorkCover(workId, mimeType, content, expectedVersionNo) {
@@ -967,7 +1111,7 @@ export class Store {
967
1111
  if (!row)
968
1112
  return null;
969
1113
  const mimeType = requiredString(row, "mime_type");
970
- if (mimeType !== "image/jpeg" && mimeType !== "image/png" && mimeType !== "image/webp") {
1114
+ if (mimeType !== "image/jpeg" && mimeType !== "image/png" && mimeType !== "image/webp" && mimeType !== "image/gif") {
971
1115
  throw new AppError(500, "INVALID_COVER_MIME", "作品封面类型无效");
972
1116
  }
973
1117
  return {
@@ -991,7 +1135,7 @@ export class Store {
991
1135
  }
992
1136
  getWorkTree(workId) {
993
1137
  const work = this.getWork(workId);
994
- const volumeRows = this.db.all("SELECT * FROM volumes WHERE work_id = ? ORDER BY sort_order, created_at", workId);
1138
+ const volumeRows = this.db.all("SELECT * FROM volumes WHERE work_id = ? AND deleted_at IS NULL ORDER BY sort_order, created_at", workId);
995
1139
  const chapterRows = this.db.all("SELECT * FROM chapters WHERE work_id = ? AND deleted_at IS NULL ORDER BY sort_order, created_at", workId);
996
1140
  const chaptersByVolume = new Map();
997
1141
  for (const row of chapterRows) {
@@ -1012,7 +1156,7 @@ export class Store {
1012
1156
  const permissions = work.modulePermissions;
1013
1157
  if (permissions.prose === "none")
1014
1158
  return { ...work, volumes: [] };
1015
- const volumeRows = this.db.all("SELECT * FROM volumes WHERE work_id = ? ORDER BY sort_order, created_at", workId);
1159
+ const volumeRows = this.db.all("SELECT * FROM volumes WHERE work_id = ? AND deleted_at IS NULL ORDER BY sort_order, created_at", workId);
1016
1160
  const chapterRows = this.db.all(`SELECT id, work_id, volume_id, title, chapter_type, sort_order, word_count, version_no,
1017
1161
  analysis_status, excluded_from_analysis, created_at, updated_at
1018
1162
  FROM chapters WHERE work_id = ? AND deleted_at IS NULL ORDER BY sort_order, created_at`, workId);
@@ -1037,7 +1181,7 @@ export class Store {
1037
1181
  return { ...work, volumes: [] };
1038
1182
  const volumeRows = this.db.all(`SELECT volume.*,
1039
1183
  (SELECT COUNT(*) FROM chapters chapter WHERE chapter.volume_id = volume.id AND chapter.deleted_at IS NULL) AS chapter_count
1040
- FROM volumes volume WHERE volume.work_id = ? ORDER BY volume.sort_order, volume.created_at`, workId);
1184
+ FROM volumes volume WHERE volume.work_id = ? AND volume.deleted_at IS NULL ORDER BY volume.sort_order, volume.created_at`, workId);
1041
1185
  const volumes = volumeRows.map((row) => ({
1042
1186
  ...this.mapVolume(row),
1043
1187
  chapterCount: numberValue(row, "chapter_count"),
@@ -1070,7 +1214,7 @@ export class Store {
1070
1214
  const permissions = work.modulePermissions;
1071
1215
  if (permissions.prose === "none")
1072
1216
  return { ...work, volumes: [], directoryPage: paginated([], pagination) };
1073
- const volumeRows = this.db.all("SELECT * FROM volumes WHERE work_id = ? ORDER BY sort_order, created_at", workId);
1217
+ const volumeRows = this.db.all("SELECT * FROM volumes WHERE work_id = ? AND deleted_at IS NULL ORDER BY sort_order, created_at", workId);
1074
1218
  const page = paginationSql(pagination);
1075
1219
  const chapterRows = this.db.all(`SELECT id, work_id, volume_id, title, chapter_type, sort_order, word_count, version_no,
1076
1220
  analysis_status, excluded_from_analysis, created_at, updated_at
@@ -1147,11 +1291,13 @@ export class Store {
1147
1291
  const timestamp = now();
1148
1292
  this.db.run(`INSERT INTO file_versions (id, work_id, file_name, file_type, word_count, paragraph_count, warnings_json, snapshot_json, created_at, created_by_user_id)
1149
1293
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, restorePointId, workId, `before-restore:${requiredString(version, "file_name")}`, "snapshot", wordCount, paragraphCount, "[]", JSON.stringify(currentTree), timestamp, currentRequestActor()?.userId ?? null);
1150
- for (const row of this.db.all("SELECT id FROM volumes WHERE work_id = ?", workId)) {
1151
- this.recordEntityVersion("volume", requiredString(row, "id"), "delete", fileVersionId, "替换作品树前保存分卷历史");
1294
+ const activeVolumeIds = this.db.all("SELECT id FROM volumes WHERE work_id = ? AND deleted_at IS NULL", workId)
1295
+ .map((row) => requiredString(row, "id"));
1296
+ for (const volumeId of activeVolumeIds) {
1297
+ this.recordEntityVersion("volume", volumeId, "delete", fileVersionId, "替换作品树前保存分卷历史");
1152
1298
  }
1153
- this.clearDraftVolumeBindings(workId, null, fileVersionId, "恢复文件版本时原分卷已被替换");
1154
- this.db.run("DELETE FROM volumes WHERE work_id = ?", workId);
1299
+ this.clearDraftVolumeBindings(workId, activeVolumeIds, fileVersionId, "恢复文件版本时原分卷已被替换");
1300
+ this.db.run("DELETE FROM volumes WHERE work_id = ? AND deleted_at IS NULL", workId);
1155
1301
  for (const volume of volumes) {
1156
1302
  const volumeId = id("volume");
1157
1303
  this.insertVolumeWithId(workId, volumeId, {
@@ -1199,14 +1345,16 @@ export class Store {
1199
1345
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, fileVersionId, workId, fileName, fileType, parsed.wordCount, parsed.paragraphCount, JSON.stringify(parsed.warnings), JSON.stringify(snapshot), timestamp, currentRequestActor()?.userId ?? null);
1200
1346
  let volumeOrderOffset = 0;
1201
1347
  if (mode === "overwrite") {
1202
- for (const row of this.db.all("SELECT id FROM volumes WHERE work_id = ?", workId)) {
1203
- this.recordEntityVersion("volume", requiredString(row, "id"), "delete", fileVersionId, "导入前保存分卷历史");
1348
+ const activeVolumeIds = this.db.all("SELECT id FROM volumes WHERE work_id = ? AND deleted_at IS NULL", workId)
1349
+ .map((row) => requiredString(row, "id"));
1350
+ for (const volumeId of activeVolumeIds) {
1351
+ this.recordEntityVersion("volume", volumeId, "delete", fileVersionId, "导入前保存分卷历史");
1204
1352
  }
1205
- this.clearDraftVolumeBindings(workId, null, fileVersionId, "覆盖导入时原分卷已被替换");
1206
- this.db.run("DELETE FROM volumes WHERE work_id = ?", workId);
1353
+ this.clearDraftVolumeBindings(workId, activeVolumeIds, fileVersionId, "覆盖导入时原分卷已被替换");
1354
+ this.db.run("DELETE FROM volumes WHERE work_id = ? AND deleted_at IS NULL", workId);
1207
1355
  }
1208
1356
  else {
1209
- const lastVolume = this.db.get("SELECT COALESCE(MAX(sort_order), -1) AS value FROM volumes WHERE work_id = ?", workId);
1357
+ const lastVolume = this.db.get("SELECT COALESCE(MAX(sort_order), -1) AS value FROM volumes WHERE work_id = ? AND deleted_at IS NULL", workId);
1210
1358
  volumeOrderOffset = numberValue(lastVolume ?? {}, "value") + 1;
1211
1359
  }
1212
1360
  let firstImportedChapterId = null;
@@ -1248,7 +1396,7 @@ export class Store {
1248
1396
  insertVolumeWithId(workId, volumeId, input, source = "create", sourceRef = null, changeNote = "") {
1249
1397
  this.getWork(workId);
1250
1398
  const timestamp = now();
1251
- const last = this.db.get("SELECT COALESCE(MAX(sort_order), -1) AS value FROM volumes WHERE work_id = ?", workId);
1399
+ const last = this.db.get("SELECT COALESCE(MAX(sort_order), -1) AS value FROM volumes WHERE work_id = ? AND deleted_at IS NULL", workId);
1252
1400
  this.db.run(`INSERT INTO volumes (id, work_id, title, kind, source, description, keywords_json, sort_order, version_no, created_at, updated_at)
1253
1401
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`, volumeId, workId, input.title, input.kind ?? "main", input.source ?? "manual", input.description?.trim() ?? "", JSON.stringify(this.normalizeVolumeKeywords(input.keywords ?? [])), input.sortOrder ?? numberValue(last ?? {}, "value") + 1, timestamp, timestamp);
1254
1402
  const versionNo = this.recordEntityVersion("volume", volumeId, source, sourceRef, changeNote || "建立分卷", timestamp);
@@ -1258,7 +1406,8 @@ export class Store {
1258
1406
  return this.getVolume(volumeId);
1259
1407
  }
1260
1408
  getVolume(volumeId) {
1261
- const row = this.db.get("SELECT * FROM volumes WHERE id = ?", volumeId);
1409
+ const row = this.db.get(`SELECT volume.* FROM volumes volume JOIN works work ON work.id = volume.work_id
1410
+ WHERE volume.id = ? AND volume.deleted_at IS NULL AND work.deleted_at IS NULL`, volumeId);
1262
1411
  if (!row)
1263
1412
  throw notFound("卷");
1264
1413
  return this.mapVolume(row);
@@ -1278,20 +1427,109 @@ export class Store {
1278
1427
  this.db.transaction(() => {
1279
1428
  const current = this.getVolume(volumeId);
1280
1429
  this.assertExpectedVersion("volume", volumeId, expectedVersionNo, "分卷", Number(current.versionNo));
1281
- const counts = this.db.get(`SELECT
1282
- SUM(CASE WHEN deleted_at IS NULL THEN 1 ELSE 0 END) AS active_count,
1283
- SUM(CASE WHEN deleted_at IS NOT NULL THEN 1 ELSE 0 END) AS deleted_count
1284
- FROM chapters WHERE volume_id = ?`, volumeId);
1285
- if (numberValue(counts ?? {}, "active_count") > 0) {
1286
- throw new AppError(409, "VOLUME_NOT_EMPTY", "卷内仍有章节,需先移动或删除章节");
1430
+ const timestamp = now();
1431
+ const workId = String(current.workId);
1432
+ const versionNo = this.recordEntityVersion("volume", volumeId, "delete", null, "删除分卷(可恢复)", timestamp);
1433
+ const activeChapters = this.db.all("SELECT id FROM chapters WHERE volume_id = ? AND deleted_at IS NULL", volumeId);
1434
+ this.db.run("UPDATE volumes SET version_no = ?, deleted_at = ?, updated_at = ? WHERE id = ?", versionNo, timestamp, timestamp, volumeId);
1435
+ for (const chapter of activeChapters) {
1436
+ const chapterId = requiredString(chapter, "id");
1437
+ this.db.run("UPDATE chapters SET deleted_at = ?, deleted_via_volume_id = ?, updated_at = ? WHERE id = ?", timestamp, volumeId, timestamp, chapterId);
1438
+ this.db.run("DELETE FROM chapter_paragraph_search WHERE chapter_id = ?", chapterId);
1287
1439
  }
1288
- if (numberValue(counts ?? {}, "deleted_count") > 0) {
1289
- throw new AppError(409, "VOLUME_HAS_DELETED_CHAPTERS", "分卷回收站中仍有章节,请先彻底删除或恢复并移动这些章节");
1440
+ this.db.run(`UPDATE analysis_tasks SET status = 'expired', updated_at = ?
1441
+ WHERE work_id = ? AND status IN ('pending', 'running', 'completed', 'partial', 'review')
1442
+ AND (json_extract(scope_json, '$.type') = 'book'
1443
+ OR (json_extract(scope_json, '$.type') = 'volume' AND json_extract(scope_json, '$.volumeId') = ?))`, timestamp, workId, volumeId);
1444
+ this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", timestamp, workId);
1445
+ this.audit(workId, "volume.deleted", "volume", volumeId, {
1446
+ versionNo,
1447
+ chapterCount: activeChapters.length,
1448
+ recoverable: true,
1449
+ expiresAt: recycleBinExpiresAt(timestamp)
1450
+ });
1451
+ });
1452
+ }
1453
+ listDeletedVolumes(workId) {
1454
+ this.getWork(workId);
1455
+ return this.db.all(`SELECT volume.*,
1456
+ (SELECT COUNT(*) FROM chapters chapter WHERE chapter.volume_id = volume.id) AS chapter_count,
1457
+ user.display_name AS actor_display_name, user.username AS actor_username
1458
+ FROM volumes volume
1459
+ LEFT JOIN entity_versions version
1460
+ ON version.entity_type = 'volume' AND version.entity_id = volume.id
1461
+ AND version.version_no = volume.version_no AND version.source = 'delete'
1462
+ LEFT JOIN users user ON user.id = version.created_by_user_id
1463
+ WHERE volume.work_id = ? AND volume.deleted_at IS NOT NULL
1464
+ ORDER BY volume.deleted_at DESC, volume.id DESC`, workId).map((row) => {
1465
+ const deletedAt = requiredString(row, "deleted_at");
1466
+ return {
1467
+ ...this.mapVolume(row),
1468
+ chapterCount: numberValue(row, "chapter_count"),
1469
+ deletedAt,
1470
+ expiresAt: recycleBinExpiresAt(deletedAt),
1471
+ actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? "历史数据"
1472
+ };
1473
+ });
1474
+ }
1475
+ restoreVolume(volumeId, expectedVersionNo) {
1476
+ const deleted = this.db.get("SELECT * FROM volumes WHERE id = ? AND deleted_at IS NOT NULL", volumeId);
1477
+ if (!deleted)
1478
+ throw notFound("回收站分卷");
1479
+ const workId = requiredString(deleted, "work_id");
1480
+ this.getWork(workId);
1481
+ this.assertExpectedVersion("volume", volumeId, expectedVersionNo, "分卷", numberValue(deleted, "version_no"));
1482
+ this.db.transaction(() => {
1483
+ const locked = this.db.get("SELECT * FROM volumes WHERE id = ? AND deleted_at IS NOT NULL", volumeId);
1484
+ if (!locked)
1485
+ throw new AppError(409, "VOLUME_ALREADY_RESTORED", "分卷已经恢复");
1486
+ this.assertExpectedVersion("volume", volumeId, expectedVersionNo, "分卷", numberValue(locked, "version_no"));
1487
+ const deletionVersion = this.db.get("SELECT id FROM entity_versions WHERE entity_type = 'volume' AND entity_id = ? AND source = 'delete' ORDER BY version_no DESC LIMIT 1", volumeId);
1488
+ const timestamp = now();
1489
+ this.db.run("UPDATE volumes SET deleted_at = NULL, updated_at = ? WHERE id = ?", timestamp, volumeId);
1490
+ const versionNo = this.recordEntityVersion("volume", volumeId, "restore", optionalString(deletionVersion ?? {}, "id"), "从回收站恢复分卷", timestamp);
1491
+ this.db.run("UPDATE volumes SET version_no = ? WHERE id = ?", versionNo, volumeId);
1492
+ const chapters = this.db.all("SELECT id, content, version_no FROM chapters WHERE deleted_via_volume_id = ?", volumeId);
1493
+ for (const chapter of chapters) {
1494
+ const chapterId = requiredString(chapter, "id");
1495
+ this.db.run("UPDATE chapters SET deleted_at = NULL, deleted_via_volume_id = NULL, analysis_status = 'expired', updated_at = ? WHERE id = ?", timestamp, chapterId);
1496
+ this.syncChapterParagraphSearch(workId, chapterId, requiredString(chapter, "content"));
1497
+ this.invalidateChapter(workId, chapterId, numberValue(chapter, "version_no"));
1290
1498
  }
1291
- this.recordEntityVersion("volume", volumeId, "delete", null, "删除分卷");
1292
- this.clearDraftVolumeBindings(String(current.workId), [volumeId], null, "绑定的分卷已删除");
1293
- this.db.run("DELETE FROM volumes WHERE id = ?", volumeId);
1294
- this.audit(String(current.workId), "volume.deleted", "volume", volumeId, { versionNo: Number(current.versionNo) });
1499
+ this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", timestamp, workId);
1500
+ this.audit(workId, "volume.restored", "volume", volumeId, { versionNo, chapterCount: chapters.length, fromRecycleBin: true });
1501
+ });
1502
+ return this.getVolume(volumeId);
1503
+ }
1504
+ permanentlyDeleteVolume(volumeId, expectedVersionNo, reason = "manual") {
1505
+ const deleted = this.db.get("SELECT * FROM volumes WHERE id = ? AND deleted_at IS NOT NULL", volumeId);
1506
+ if (!deleted)
1507
+ throw new AppError(409, "VOLUME_NOT_IN_RECYCLE_BIN", "仅回收站中的分卷可以彻底删除");
1508
+ this.assertExpectedVersion("volume", volumeId, expectedVersionNo, "分卷", numberValue(deleted, "version_no"));
1509
+ this.db.transaction(() => {
1510
+ const locked = this.db.get("SELECT * FROM volumes WHERE id = ? AND deleted_at IS NOT NULL", volumeId);
1511
+ if (!locked)
1512
+ throw new AppError(409, "VOLUME_NOT_IN_RECYCLE_BIN", "仅回收站中的分卷可以彻底删除");
1513
+ this.assertExpectedVersion("volume", volumeId, expectedVersionNo, "分卷", numberValue(locked, "version_no"));
1514
+ this.permanentlyRemoveVolumeRow(locked, reason);
1515
+ });
1516
+ }
1517
+ permanentlyRemoveVolumeRow(volume, reason) {
1518
+ const volumeId = requiredString(volume, "id");
1519
+ const workId = requiredString(volume, "work_id");
1520
+ const chapters = this.db.all("SELECT * FROM chapters WHERE volume_id = ?", volumeId);
1521
+ for (const chapter of chapters)
1522
+ this.permanentlyRemoveChapterRow(chapter, reason, false);
1523
+ this.clearDraftVolumeBindings(workId, [volumeId], null, "绑定的分卷已彻底删除");
1524
+ this.db.run("DELETE FROM entity_versions WHERE entity_type = 'volume' AND entity_id = ?", volumeId);
1525
+ this.db.run("DELETE FROM volumes WHERE id = ?", volumeId);
1526
+ this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", now(), workId);
1527
+ this.audit(workId, "volume.purged", "volume", volumeId, {
1528
+ title: requiredString(volume, "title"),
1529
+ chapterCount: chapters.length,
1530
+ versionNo: numberValue(volume, "version_no"),
1531
+ reason,
1532
+ recoverable: false
1295
1533
  });
1296
1534
  }
1297
1535
  createChapter(workId, input) {
@@ -1307,7 +1545,11 @@ export class Store {
1307
1545
  });
1308
1546
  }
1309
1547
  getChapter(chapterId) {
1310
- const row = this.db.get("SELECT * FROM chapters WHERE id = ? AND deleted_at IS NULL", chapterId);
1548
+ const row = this.db.get(`SELECT chapter.* FROM chapters chapter
1549
+ JOIN volumes volume ON volume.id = chapter.volume_id
1550
+ JOIN works work ON work.id = chapter.work_id
1551
+ WHERE chapter.id = ? AND chapter.deleted_at IS NULL
1552
+ AND volume.deleted_at IS NULL AND work.deleted_at IS NULL`, chapterId);
1311
1553
  if (!row)
1312
1554
  throw notFound("章节");
1313
1555
  return this.mapChapter(row);
@@ -1331,9 +1573,11 @@ export class Store {
1331
1573
  ON version.chapter_id = chapter.id AND version.version_no = chapter.version_no AND version.source = 'delete'
1332
1574
  LEFT JOIN users user ON user.id = version.created_by_user_id
1333
1575
  WHERE chapter.work_id = ? AND chapter.deleted_at IS NOT NULL
1576
+ AND chapter.deleted_via_volume_id IS NULL AND volume.deleted_at IS NULL
1334
1577
  ORDER BY chapter.deleted_at DESC, chapter.id DESC${pageSql}`, workId, ...pageParams);
1335
1578
  }
1336
1579
  mapDeletedChapter(row) {
1580
+ const deletedAt = requiredString(row, "deleted_at");
1337
1581
  return {
1338
1582
  id: requiredString(row, "id"),
1339
1583
  workId: requiredString(row, "work_id"),
@@ -1343,10 +1587,18 @@ export class Store {
1343
1587
  contentPreview: requiredString(row, "content").slice(0, 300),
1344
1588
  wordCount: numberValue(row, "word_count"),
1345
1589
  versionNo: numberValue(row, "version_no"),
1346
- deletedAt: requiredString(row, "deleted_at"),
1590
+ deletedAt,
1591
+ expiresAt: recycleBinExpiresAt(deletedAt),
1347
1592
  actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? "历史数据"
1348
1593
  };
1349
1594
  }
1595
+ getRecycleBin(workId) {
1596
+ return {
1597
+ retentionDays: RECYCLE_BIN_RETENTION_DAYS,
1598
+ volumes: this.listDeletedVolumes(workId),
1599
+ chapters: this.listDeletedChapters(workId)
1600
+ };
1601
+ }
1350
1602
  findChapterVersionRows(chapterId) {
1351
1603
  return this.db.all(`SELECT version.*, user.display_name AS actor_display_name, user.username AS actor_username
1352
1604
  FROM chapter_versions version LEFT JOIN users user ON user.id = version.created_by_user_id
@@ -1393,7 +1645,7 @@ export class Store {
1393
1645
  this.getChapter(chapterId);
1394
1646
  return paginated([], pagination);
1395
1647
  }
1396
- return paginated(rows.slice(pagination.offset, pagination.offset + pagination.limit + 1).map((row) => this.mapChapterVersionRow(row)), pagination);
1648
+ return paginated(rows.map((row) => this.mapChapterVersionRow(row)), pagination);
1397
1649
  }
1398
1650
  listChapterInsights(chapterId) {
1399
1651
  this.getChapter(chapterId);
@@ -1646,6 +1898,9 @@ export class Store {
1646
1898
  const deleted = this.db.get("SELECT * FROM chapters WHERE id = ? AND deleted_at IS NOT NULL", chapterId);
1647
1899
  if (!deleted)
1648
1900
  throw notFound("章节");
1901
+ if (optionalString(deleted, "deleted_via_volume_id")) {
1902
+ throw new AppError(409, "CHAPTER_DELETED_WITH_VOLUME", "章节随分卷进入回收站,请先恢复分卷");
1903
+ }
1649
1904
  const currentVersionNo = numberValue(deleted, "version_no");
1650
1905
  this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", currentVersionNo);
1651
1906
  const workId = requiredString(deleted, "work_id");
@@ -1664,12 +1919,15 @@ export class Store {
1664
1919
  const nextVersionNo = Math.max(currentVersionNo, numberValue(this.db.get("SELECT COALESCE(MAX(version_no), 0) AS version_no FROM chapter_versions WHERE chapter_id = ?", chapterId) ?? {}, "version_no")) + 1;
1665
1920
  const timestamp = now();
1666
1921
  this.db.transaction(() => {
1667
- const locked = this.db.get("SELECT version_no, deleted_at FROM chapters WHERE id = ?", chapterId);
1922
+ const locked = this.db.get("SELECT version_no, deleted_at, deleted_via_volume_id FROM chapters WHERE id = ?", chapterId);
1668
1923
  if (!locked?.deleted_at)
1669
1924
  throw new AppError(409, "CHAPTER_ALREADY_RESTORED", "章节已经恢复");
1925
+ if (optionalString(locked, "deleted_via_volume_id")) {
1926
+ throw new AppError(409, "CHAPTER_DELETED_WITH_VOLUME", "章节随分卷进入回收站,请先恢复分卷");
1927
+ }
1670
1928
  this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", numberValue(locked, "version_no"));
1671
1929
  this.db.run(`UPDATE chapters SET volume_id = ?, title = ?, content = ?, chapter_type = ?, sort_order = ?, word_count = ?,
1672
- version_no = ?, analysis_status = 'pending', deleted_at = NULL, updated_at = ? WHERE id = ?`, volumeId, title, content, chapterType, sortOrder, countWords(content), nextVersionNo, timestamp, chapterId);
1930
+ version_no = ?, analysis_status = 'pending', deleted_at = NULL, deleted_via_volume_id = NULL, updated_at = ? WHERE id = ?`, volumeId, title, content, chapterType, sortOrder, countWords(content), nextVersionNo, timestamp, chapterId);
1673
1931
  this.syncChapterParagraphSearch(workId, chapterId, content);
1674
1932
  this.insertChapterVersionRow({
1675
1933
  workId,
@@ -1951,7 +2209,7 @@ export class Store {
1951
2209
  for (const chapter of currentChapters) {
1952
2210
  const chapterId = String(chapter.id);
1953
2211
  const versionNo = Number(chapter.versionNo) + 1;
1954
- this.db.run("UPDATE chapters SET version_no = ?, deleted_at = ?, updated_at = ? WHERE id = ?", versionNo, timestamp, timestamp, chapterId);
2212
+ this.db.run("UPDATE chapters SET version_no = ?, deleted_at = ?, deleted_via_volume_id = NULL, updated_at = ? WHERE id = ?", versionNo, timestamp, timestamp, chapterId);
1955
2213
  this.insertChapterVersionRow({
1956
2214
  workId,
1957
2215
  chapterId,
@@ -1988,7 +2246,7 @@ export class Store {
1988
2246
  this.db.transaction(() => {
1989
2247
  const lockedChapter = this.getChapter(chapterId);
1990
2248
  this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(lockedChapter.versionNo));
1991
- this.db.run("UPDATE chapters SET version_no = ?, deleted_at = ?, updated_at = ? WHERE id = ?", versionNo, timestamp, timestamp, chapterId);
2249
+ this.db.run("UPDATE chapters SET version_no = ?, deleted_at = ?, deleted_via_volume_id = NULL, updated_at = ? WHERE id = ?", versionNo, timestamp, timestamp, chapterId);
1992
2250
  this.insertChapterVersionRow({
1993
2251
  workId: String(chapter.workId),
1994
2252
  chapterId,
@@ -2014,33 +2272,45 @@ export class Store {
2014
2272
  this.audit(String(chapter.workId), "chapter.deleted", "chapter", chapterId, { versionNo });
2015
2273
  });
2016
2274
  }
2017
- permanentlyDeleteChapter(chapterId, expectedVersionNo) {
2275
+ permanentlyDeleteChapter(chapterId, expectedVersionNo, reason = "manual") {
2018
2276
  const chapter = this.db.get("SELECT * FROM chapters WHERE id = ?", chapterId);
2019
2277
  if (!chapter)
2020
2278
  throw notFound("章节");
2021
2279
  if (!chapter.deleted_at)
2022
2280
  throw new AppError(409, "CHAPTER_NOT_IN_RECYCLE_BIN", "仅回收站中的章节可以彻底删除");
2281
+ if (optionalString(chapter, "deleted_via_volume_id")) {
2282
+ throw new AppError(409, "CHAPTER_DELETED_WITH_VOLUME", "章节随分卷进入回收站,请在分卷条目上操作");
2283
+ }
2023
2284
  this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", numberValue(chapter, "version_no"));
2024
- const workId = requiredString(chapter, "work_id");
2025
- const timestamp = now();
2026
2285
  this.db.transaction(() => {
2027
2286
  const locked = this.db.get("SELECT * FROM chapters WHERE id = ?", chapterId);
2028
2287
  if (!locked)
2029
2288
  throw notFound("章节");
2030
2289
  if (!locked.deleted_at)
2031
2290
  throw new AppError(409, "CHAPTER_NOT_IN_RECYCLE_BIN", "仅回收站中的章节可以彻底删除");
2291
+ if (optionalString(locked, "deleted_via_volume_id")) {
2292
+ throw new AppError(409, "CHAPTER_DELETED_WITH_VOLUME", "章节随分卷进入回收站,请在分卷条目上操作");
2293
+ }
2032
2294
  this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", numberValue(locked, "version_no"));
2033
- this.db.run("DELETE FROM chapter_versions WHERE chapter_id = ?", chapterId);
2034
- this.db.run("DELETE FROM entity_versions WHERE entity_type = 'chapter-outline' AND entity_id = ?", chapterId);
2035
- this.db.run("DELETE FROM attachment_references WHERE entity_type = 'chapter' AND entity_id = ?", chapterId);
2036
- this.db.run("DELETE FROM chapters WHERE id = ?", chapterId);
2037
- this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", timestamp, workId);
2038
- this.audit(workId, "chapter.purged", "chapter", chapterId, {
2039
- title: requiredString(locked, "title"),
2040
- volumeId: requiredString(locked, "volume_id"),
2041
- versionNo: numberValue(locked, "version_no"),
2042
- recoverable: false
2043
- });
2295
+ this.permanentlyRemoveChapterRow(locked, reason);
2296
+ });
2297
+ }
2298
+ permanentlyRemoveChapterRow(chapter, reason, recordAudit = true) {
2299
+ const chapterId = requiredString(chapter, "id");
2300
+ const workId = requiredString(chapter, "work_id");
2301
+ this.db.run("DELETE FROM chapter_versions WHERE chapter_id = ?", chapterId);
2302
+ this.db.run("DELETE FROM entity_versions WHERE entity_type = 'chapter-outline' AND entity_id = ?", chapterId);
2303
+ this.db.run("DELETE FROM attachment_references WHERE entity_type = 'chapter' AND entity_id = ?", chapterId);
2304
+ this.db.run("DELETE FROM chapters WHERE id = ?", chapterId);
2305
+ if (!recordAudit)
2306
+ return;
2307
+ this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", now(), workId);
2308
+ this.audit(workId, "chapter.purged", "chapter", chapterId, {
2309
+ title: requiredString(chapter, "title"),
2310
+ volumeId: requiredString(chapter, "volume_id"),
2311
+ versionNo: numberValue(chapter, "version_no"),
2312
+ reason,
2313
+ recoverable: false
2044
2314
  });
2045
2315
  }
2046
2316
  insertChapter(workId, volumeId, title, content, sortOrder, source, sourceRef, chapterType = "正文") {
@@ -2125,12 +2395,45 @@ export class Store {
2125
2395
  }
2126
2396
  this.notifyAnalysisTaskQueued(workId);
2127
2397
  }
2128
- mapWork(row) {
2398
+ mapWorks(rows) {
2399
+ if (rows.length === 0)
2400
+ return [];
2401
+ const actor = currentRequestActor();
2402
+ const workIds = [...new Set(rows.map((row) => requiredString(row, "id")))];
2403
+ const batch = {
2404
+ memberships: new Map(),
2405
+ counts: new Map(),
2406
+ covers: new Map()
2407
+ };
2408
+ for (let offset = 0; offset < workIds.length; offset += WORK_LIST_BATCH_SIZE) {
2409
+ const batchIds = workIds.slice(offset, offset + WORK_LIST_BATCH_SIZE);
2410
+ const placeholders = batchIds.map(() => "?").join(", ");
2411
+ if (actor) {
2412
+ const memberships = this.db.all(`SELECT work_id, role, permissions_json FROM work_memberships
2413
+ WHERE user_id = ? AND work_id IN (${placeholders})`, actor.userId, ...batchIds);
2414
+ for (const membership of memberships) {
2415
+ batch.memberships.set(requiredString(membership, "work_id"), membership);
2416
+ }
2417
+ }
2418
+ const counts = this.db.all(`SELECT work_id, COUNT(*) AS chapter_count, COALESCE(SUM(word_count), 0) AS word_count
2419
+ FROM chapters WHERE work_id IN (${placeholders}) AND deleted_at IS NULL GROUP BY work_id`, ...batchIds);
2420
+ for (const count of counts)
2421
+ batch.counts.set(requiredString(count, "work_id"), count);
2422
+ const covers = this.db.all(`SELECT work_id, updated_at FROM work_covers WHERE work_id IN (${placeholders})`, ...batchIds);
2423
+ for (const cover of covers)
2424
+ batch.covers.set(requiredString(cover, "work_id"), cover);
2425
+ }
2426
+ return rows.map((row) => this.mapWork(row, batch));
2427
+ }
2428
+ mapWork(row, batch) {
2129
2429
  const actor = currentRequestActor();
2430
+ const workId = requiredString(row, "id");
2130
2431
  const ownerUserId = optionalString(row, "owner_user_id");
2131
- const membership = actor
2132
- ? this.db.get("SELECT role, permissions_json FROM work_memberships WHERE work_id = ? AND user_id = ?", requiredString(row, "id"), actor.userId)
2133
- : undefined;
2432
+ const membership = batch
2433
+ ? batch.memberships.get(workId)
2434
+ : actor
2435
+ ? this.db.get("SELECT role, permissions_json FROM work_memberships WHERE work_id = ? AND user_id = ?", workId, actor.userId)
2436
+ : undefined;
2134
2437
  const membershipRole = String(membership?.role ?? "");
2135
2438
  const ownerAccess = ownerUserId === actor?.userId;
2136
2439
  const adminAccess = actor?.role === "admin" && actor.authentication !== "api-key";
@@ -2144,19 +2447,23 @@ export class Store {
2144
2447
  : adminAccess
2145
2448
  ? "admin"
2146
2449
  : membershipRole ? classifyWorkModulePermissions(modulePermissions) : null;
2147
- const count = this.db.get("SELECT COUNT(*) AS chapter_count, COALESCE(SUM(word_count), 0) AS word_count FROM chapters WHERE work_id = ? AND deleted_at IS NULL", requiredString(row, "id"));
2148
- const cover = this.db.get("SELECT updated_at FROM work_covers WHERE work_id = ?", requiredString(row, "id"));
2450
+ const count = batch
2451
+ ? batch.counts.get(workId)
2452
+ : this.db.get("SELECT COUNT(*) AS chapter_count, COALESCE(SUM(word_count), 0) AS word_count FROM chapters WHERE work_id = ? AND deleted_at IS NULL", workId);
2453
+ const cover = batch
2454
+ ? batch.covers.get(workId)
2455
+ : this.db.get("SELECT updated_at FROM work_covers WHERE work_id = ?", workId);
2149
2456
  return {
2150
- id: requiredString(row, "id"),
2457
+ id: workId,
2151
2458
  title: requiredString(row, "title"),
2152
2459
  author: requiredString(row, "author"),
2153
2460
  description: requiredString(row, "description"),
2154
2461
  language: requiredString(row, "language"),
2155
2462
  coverUrl: cover
2156
- ? `/api/works/${encodeURIComponent(requiredString(row, "id"))}/cover?v=${encodeURIComponent(requiredString(cover, "updated_at"))}`
2463
+ ? `/api/works/${encodeURIComponent(workId)}/cover?v=${encodeURIComponent(requiredString(cover, "updated_at"))}`
2157
2464
  : optionalString(row, "cover_url"),
2158
2465
  tags: json(requiredString(row, "tags_json"), []),
2159
- versionNo: numberValue(row, "version_no") || this.currentEntityVersionNo("work", requiredString(row, "id")),
2466
+ versionNo: numberValue(row, "version_no") || this.currentEntityVersionNo("work", workId),
2160
2467
  ownerUserId,
2161
2468
  accessRole,
2162
2469
  modulePermissions,
@@ -2213,6 +2520,142 @@ export class Store {
2213
2520
  return null;
2214
2521
  return this.mapChapterOutline(row, chapter);
2215
2522
  }
2523
+ getChapterOutlineBoard(workId) {
2524
+ this.getWork(workId);
2525
+ const previewLength = CHAPTER_OUTLINE_BOARD_PREVIEW_LENGTH;
2526
+ const rows = this.db.all(`SELECT volume.id AS volume_id, volume.title AS volume_title, volume.sort_order AS volume_order,
2527
+ chapter.id AS chapter_id, chapter.title AS chapter_title, chapter.chapter_type,
2528
+ chapter.sort_order AS chapter_order,
2529
+ outline.chapter_id AS outline_chapter_id,
2530
+ substr(outline.goal, 1, ?) AS goal, length(outline.goal) > ? AS goal_truncated,
2531
+ substr(outline.conflict, 1, ?) AS conflict, length(outline.conflict) > ? AS conflict_truncated,
2532
+ substr(outline.turning_point, 1, ?) AS turning_point, length(outline.turning_point) > ? AS turning_point_truncated,
2533
+ substr(outline.notes, 1, ?) AS notes, length(outline.notes) > ? AS notes_truncated,
2534
+ outline.status, outline.updated_at AS outline_updated_at
2535
+ FROM volumes volume
2536
+ LEFT JOIN chapters chapter
2537
+ ON chapter.volume_id = volume.id AND chapter.work_id = volume.work_id AND chapter.deleted_at IS NULL
2538
+ LEFT JOIN chapter_outlines outline ON outline.chapter_id = chapter.id
2539
+ WHERE volume.work_id = ? AND volume.deleted_at IS NULL
2540
+ ORDER BY volume.sort_order, volume.created_at, volume.id,
2541
+ chapter.sort_order, chapter.created_at, chapter.id`, previewLength, previewLength, previewLength, previewLength, previewLength, previewLength, previewLength, previewLength, workId);
2542
+ const volumeById = new Map();
2543
+ const chapterById = new Map();
2544
+ let outlinedChapterCount = 0;
2545
+ for (const row of rows) {
2546
+ const volumeId = requiredString(row, "volume_id");
2547
+ let volume = volumeById.get(volumeId);
2548
+ if (!volume) {
2549
+ volume = {
2550
+ id: volumeId,
2551
+ title: requiredString(row, "volume_title"),
2552
+ sortOrder: numberValue(row, "volume_order"),
2553
+ chapters: []
2554
+ };
2555
+ volumeById.set(volumeId, volume);
2556
+ }
2557
+ const chapterId = optionalString(row, "chapter_id");
2558
+ if (!chapterId)
2559
+ continue;
2560
+ const hasOutline = optionalString(row, "outline_chapter_id") !== null;
2561
+ if (hasOutline)
2562
+ outlinedChapterCount += 1;
2563
+ const chapter = {
2564
+ id: chapterId,
2565
+ title: requiredString(row, "chapter_title"),
2566
+ chapterType: requiredString(row, "chapter_type") || "正文",
2567
+ sortOrder: numberValue(row, "chapter_order"),
2568
+ outline: hasOutline ? {
2569
+ goal: optionalString(row, "goal") ?? "",
2570
+ conflict: optionalString(row, "conflict") ?? "",
2571
+ turningPoint: optionalString(row, "turning_point") ?? "",
2572
+ notes: optionalString(row, "notes") ?? "",
2573
+ status: optionalString(row, "status") ?? "draft",
2574
+ truncated: ["goal_truncated", "conflict_truncated", "turning_point_truncated", "notes_truncated"]
2575
+ .some((field) => booleanValue(row, field)),
2576
+ updatedAt: optionalString(row, "outline_updated_at")
2577
+ } : null,
2578
+ foreshadows: []
2579
+ };
2580
+ volume.chapters.push(chapter);
2581
+ chapterById.set(chapterId, chapter);
2582
+ }
2583
+ const associations = new Map();
2584
+ const associate = (chapterId, source, role, plannedPayoff = false) => {
2585
+ if (!chapterId || !chapterById.has(chapterId))
2586
+ return;
2587
+ const byForeshadow = associations.get(chapterId) ?? new Map();
2588
+ const summary = byForeshadow.get(source.id) ?? {
2589
+ ...source,
2590
+ roles: new Set(),
2591
+ plannedPayoff: false
2592
+ };
2593
+ if (role)
2594
+ summary.roles.add(role);
2595
+ if (plannedPayoff)
2596
+ summary.plannedPayoff = true;
2597
+ byForeshadow.set(source.id, summary);
2598
+ associations.set(chapterId, byForeshadow);
2599
+ };
2600
+ const foreshadowRows = this.db.all(`SELECT foreshadow.id, foreshadow.title, foreshadow.status, foreshadow.importance,
2601
+ occurrence_chapter.id AS occurrence_chapter_id, occurrence_volume.id AS occurrence_volume_id,
2602
+ occurrence.role, payoff_chapter.id AS payoff_chapter_id, payoff_volume.id AS payoff_volume_id
2603
+ FROM foreshadows foreshadow
2604
+ LEFT JOIN foreshadow_occurrences occurrence ON occurrence.foreshadow_id = foreshadow.id
2605
+ LEFT JOIN chapters occurrence_chapter
2606
+ ON occurrence_chapter.id = occurrence.chapter_id
2607
+ AND occurrence_chapter.work_id = foreshadow.work_id
2608
+ AND occurrence_chapter.deleted_at IS NULL
2609
+ LEFT JOIN volumes occurrence_volume
2610
+ ON occurrence_volume.id = occurrence_chapter.volume_id AND occurrence_volume.deleted_at IS NULL
2611
+ LEFT JOIN chapters payoff_chapter
2612
+ ON payoff_chapter.id = foreshadow.planned_payoff_chapter_id
2613
+ AND payoff_chapter.work_id = foreshadow.work_id
2614
+ AND payoff_chapter.deleted_at IS NULL
2615
+ LEFT JOIN volumes payoff_volume
2616
+ ON payoff_volume.id = payoff_chapter.volume_id AND payoff_volume.deleted_at IS NULL
2617
+ WHERE foreshadow.work_id = ?
2618
+ ORDER BY CASE foreshadow.importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END,
2619
+ foreshadow.created_at, foreshadow.id, occurrence.created_at, occurrence.id`, workId);
2620
+ const foreshadowIds = new Set();
2621
+ const unresolvedForeshadowIds = new Set();
2622
+ for (const row of foreshadowRows) {
2623
+ const source = {
2624
+ id: requiredString(row, "id"),
2625
+ title: requiredString(row, "title"),
2626
+ status: requiredString(row, "status"),
2627
+ importance: requiredString(row, "importance")
2628
+ };
2629
+ foreshadowIds.add(source.id);
2630
+ if (source.status === "planned" || source.status === "planted")
2631
+ unresolvedForeshadowIds.add(source.id);
2632
+ const occurrenceChapterId = optionalString(row, "occurrence_volume_id")
2633
+ ? optionalString(row, "occurrence_chapter_id")
2634
+ : null;
2635
+ const role = optionalString(row, "role");
2636
+ associate(occurrenceChapterId, source, role === "setup" || role === "reminder" || role === "payoff" ? role : undefined);
2637
+ associate(optionalString(row, "payoff_volume_id") ? optionalString(row, "payoff_chapter_id") : null, source, undefined, true);
2638
+ }
2639
+ for (const [chapterId, byForeshadow] of associations) {
2640
+ const chapter = chapterById.get(chapterId);
2641
+ if (!chapter)
2642
+ continue;
2643
+ chapter.foreshadows = [...byForeshadow.values()].map((summary) => ({
2644
+ ...summary,
2645
+ roles: [...summary.roles]
2646
+ }));
2647
+ }
2648
+ return {
2649
+ workId,
2650
+ volumes: [...volumeById.values()],
2651
+ stats: {
2652
+ chapterCount: chapterById.size,
2653
+ outlinedChapterCount,
2654
+ foreshadowCount: foreshadowIds.size,
2655
+ unresolvedForeshadowCount: unresolvedForeshadowIds.size
2656
+ }
2657
+ };
2658
+ }
2216
2659
  listChapterOutlines(workId) {
2217
2660
  this.getWork(workId);
2218
2661
  const rows = this.db.all(`SELECT c.id AS chapter_id, c.title AS chapter_title, c.volume_id, c.sort_order AS chapter_order,
@@ -2391,6 +2834,63 @@ export class Store {
2391
2834
  ORDER BY CASE importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, created_at${page.sql}`, workId, ...page.params);
2392
2835
  return paginated(rows.map((row) => this.getForeshadow(requiredString(row, "id"), currentChapterId)), pagination);
2393
2836
  }
2837
+ listChapterForeshadowReminders(workId, chapterId) {
2838
+ this.getWork(workId);
2839
+ this.assertChapterInWork(chapterId, workId);
2840
+ const seenForeshadowIds = new Set();
2841
+ return this.db.all(`SELECT occurrence.id AS occurrence_id, occurrence.foreshadow_id, occurrence.role, occurrence.note,
2842
+ foreshadow.title, foreshadow.description, foreshadow.status, foreshadow.importance,
2843
+ foreshadow.updated_at,
2844
+ (SELECT MAX(version.version_no) FROM entity_versions version
2845
+ WHERE version.entity_type = 'foreshadow' AND version.entity_id = foreshadow.id) AS version_no
2846
+ FROM foreshadow_occurrences occurrence
2847
+ JOIN foreshadows foreshadow ON foreshadow.id = occurrence.foreshadow_id
2848
+ JOIN chapters chapter ON chapter.id = occurrence.chapter_id
2849
+ WHERE foreshadow.work_id = ? AND chapter.work_id = ? AND occurrence.chapter_id = ?
2850
+ AND foreshadow.status IN ('planned', 'planted')
2851
+ AND occurrence.role IN ('reminder', 'payoff')
2852
+ ORDER BY CASE occurrence.role WHEN 'payoff' THEN 0 ELSE 1 END,
2853
+ CASE foreshadow.importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END,
2854
+ foreshadow.created_at, occurrence.created_at`, workId, workId, chapterId).flatMap((row) => {
2855
+ const foreshadowId = requiredString(row, "foreshadow_id");
2856
+ if (seenForeshadowIds.has(foreshadowId))
2857
+ return [];
2858
+ seenForeshadowIds.add(foreshadowId);
2859
+ return [{
2860
+ foreshadowId,
2861
+ occurrenceId: requiredString(row, "occurrence_id"),
2862
+ title: requiredString(row, "title"),
2863
+ description: requiredString(row, "description"),
2864
+ status: requiredString(row, "status"),
2865
+ importance: requiredString(row, "importance"),
2866
+ role: requiredString(row, "role"),
2867
+ note: requiredString(row, "note"),
2868
+ versionNo: numberValue(row, "version_no"),
2869
+ updatedAt: requiredString(row, "updated_at")
2870
+ }];
2871
+ });
2872
+ }
2873
+ resolveChapterForeshadowReminder(workId, chapterId, foreshadowId, expectedVersionNo) {
2874
+ this.getWork(workId);
2875
+ this.assertChapterInWork(chapterId, workId);
2876
+ const reminder = this.db.get(`SELECT occurrence.id AS occurrence_id
2877
+ FROM foreshadow_occurrences occurrence
2878
+ JOIN foreshadows foreshadow ON foreshadow.id = occurrence.foreshadow_id
2879
+ WHERE foreshadow.id = ? AND foreshadow.work_id = ? AND occurrence.chapter_id = ?
2880
+ AND foreshadow.status IN ('planned', 'planted')
2881
+ AND occurrence.role IN ('reminder', 'payoff')
2882
+ ORDER BY CASE occurrence.role WHEN 'payoff' THEN 0 ELSE 1 END, occurrence.created_at
2883
+ LIMIT 1`, foreshadowId, workId, chapterId);
2884
+ if (!reminder)
2885
+ throw notFound("伏笔提醒");
2886
+ const updated = this.updateForeshadow(foreshadowId, { status: "resolved" }, "manual", requiredString(reminder, "occurrence_id"), "在编辑器标记伏笔已回收", expectedVersionNo);
2887
+ return {
2888
+ foreshadowId: String(updated.id),
2889
+ status: String(updated.status),
2890
+ versionNo: Number(updated.versionNo),
2891
+ updatedAt: String(updated.updatedAt)
2892
+ };
2893
+ }
2394
2894
  updateForeshadow(foreshadowId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
2395
2895
  const current = this.getForeshadow(foreshadowId);
2396
2896
  const workId = String(current.workId);
@@ -2531,7 +3031,7 @@ export class Store {
2531
3031
  this.getWork(workId);
2532
3032
  const safeLimit = Math.min(30, Math.max(1, Math.trunc(limit)));
2533
3033
  const normalizedQuery = query.normalize("NFKC").trim();
2534
- const escapedQuery = normalizedQuery.replace(/[\\%_]/gu, "\\$&");
3034
+ const escapedQuery = escapeSqlLikePattern(normalizedQuery);
2535
3035
  const pattern = `%${escapedQuery}%`;
2536
3036
  const rows = normalizedQuery
2537
3037
  ? this.db.all(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
@@ -3215,7 +3715,7 @@ export class Store {
3215
3715
  this.audit(String(current.workId), "character.versioned", "character", characterId, { versionNo, source, sourceRef });
3216
3716
  }
3217
3717
  }
3218
- createCharacter(workId, input) {
3718
+ createCharacter(workId, input, source = "create", sourceRef = null, changeNote = "建立人物档案") {
3219
3719
  this.getWork(workId);
3220
3720
  const characterId = id("character");
3221
3721
  const timestamp = now();
@@ -3236,8 +3736,8 @@ export class Store {
3236
3736
  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);
3237
3737
  this.insertCharacterNames(workId, characterId, names.entries);
3238
3738
  this.replaceCharacterOrganizations(characterId, organizationIds);
3239
- this.insertCharacterVersion(characterId, 1, "create", null, "建立人物档案", timestamp);
3240
- this.audit(workId, "character.created", "character", characterId);
3739
+ this.insertCharacterVersion(characterId, 1, source, sourceRef, changeNote, timestamp);
3740
+ this.audit(workId, "character.created", "character", characterId, { source, sourceRef });
3241
3741
  });
3242
3742
  return this.getCharacter(characterId);
3243
3743
  }
@@ -4812,10 +5312,24 @@ export class Store {
4812
5312
  if (!conversation)
4813
5313
  throw notFound("AI 对话");
4814
5314
  const requestId = input.requestId?.trim() || null;
5315
+ const persistInterruption = (message) => {
5316
+ if (input.role !== "assistant" || input.metadata?.interrupted !== true || requiredString(message, "role") !== "assistant") {
5317
+ return message;
5318
+ }
5319
+ const currentMetadata = json(requiredString(message, "metadata_json"), {});
5320
+ const nextMetadata = { ...currentMetadata, ...input.metadata, interrupted: true };
5321
+ if (JSON.stringify(currentMetadata) === JSON.stringify(nextMetadata))
5322
+ return message;
5323
+ this.db.transaction(() => {
5324
+ this.db.run("UPDATE ai_conversation_messages SET metadata_json = ? WHERE id = ?", JSON.stringify(nextMetadata), requiredString(message, "id"));
5325
+ this.db.run("UPDATE ai_conversations SET updated_at = ? WHERE id = ?", now(), conversationId);
5326
+ });
5327
+ return this.db.get("SELECT * FROM ai_conversation_messages WHERE id = ?", requiredString(message, "id")) ?? message;
5328
+ };
4815
5329
  if (requestId) {
4816
5330
  const existing = this.db.get("SELECT * FROM ai_conversation_messages WHERE conversation_id = ? AND request_id = ?", conversationId, requestId);
4817
5331
  if (existing)
4818
- return this.mapAiConversationMessage(existing);
5332
+ return this.mapAiConversationMessage(persistInterruption(existing));
4819
5333
  }
4820
5334
  const messageId = id("message");
4821
5335
  const timestamp = now();
@@ -4838,18 +5352,156 @@ export class Store {
4838
5352
  : this.db.get("SELECT * FROM ai_conversation_messages WHERE id = ?", messageId);
4839
5353
  if (!message)
4840
5354
  throw notFound("AI 对话消息");
4841
- return this.mapAiConversationMessage(message);
5355
+ return this.mapAiConversationMessage(persistInterruption(message));
4842
5356
  }
4843
- forkAiConversation(conversationId, messageId, requestedTitle) {
5357
+ beginAiConversationStreamRequest(input, referenceTime = new Date()) {
5358
+ const timestamp = referenceTime.toISOString();
5359
+ const leaseExpiresAt = new Date(referenceTime.getTime() + AI_CONVERSATION_STREAM_REQUEST_LEASE_MS).toISOString();
5360
+ return this.db.transaction(() => {
5361
+ const conversation = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", input.conversationId);
5362
+ if (!conversation)
5363
+ throw notFound("AI 对话");
5364
+ if (requiredString(conversation, "work_id") !== input.workId) {
5365
+ throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
5366
+ }
5367
+ let existing = this.db.get(`SELECT * FROM ai_conversation_stream_requests
5368
+ WHERE actor_scope = ? AND work_id = ? AND idempotency_key = ?`, input.actorScope, input.workId, input.idempotencyKey);
5369
+ if (existing) {
5370
+ if (requiredString(existing, "status") === "in_progress"
5371
+ && String(existing.lease_expires_at ?? "") <= timestamp) {
5372
+ this.expireAiConversationStreamLease(input.conversationId, timestamp);
5373
+ existing = this.db.get("SELECT * FROM ai_conversation_stream_requests WHERE id = ?", requiredString(existing, "id"));
5374
+ if (!existing)
5375
+ throw notFound("AI 对话请求");
5376
+ }
5377
+ if (requiredString(existing, "conversation_id") !== input.conversationId
5378
+ || requiredString(existing, "request_hash") !== input.requestHash) {
5379
+ throw new AppError(409, "IDEMPOTENCY_KEY_REUSED", "该请求标识已用于另一项 AI 对话请求");
5380
+ }
5381
+ const request = this.mapAiConversationStreamRequest(existing);
5382
+ return {
5383
+ disposition: request.status === "in_progress" ? "in_progress" : "terminal",
5384
+ request,
5385
+ userMessage: this.aiConversationMessageOrNull(request.userMessageId),
5386
+ assistantMessage: this.aiConversationMessageOrNull(request.assistantMessageId)
5387
+ };
5388
+ }
5389
+ this.expireAiConversationStreamLease(input.conversationId, timestamp);
5390
+ const active = this.db.get("SELECT id FROM ai_conversation_stream_requests WHERE conversation_id = ? AND status = 'in_progress'", input.conversationId);
5391
+ if (active) {
5392
+ throw new AppError(409, "AI_CONVERSATION_RESPONSE_IN_PROGRESS", "当前对话仍在生成回复,请等待完成或取消后再发送");
5393
+ }
5394
+ const requestId = id("chat_request");
5395
+ const existingMessage = input.userMessage.existingMessageId
5396
+ ? this.db.get(`SELECT * FROM ai_conversation_messages
5397
+ WHERE id = ? AND conversation_id = ? AND role = 'user'`, input.userMessage.existingMessageId, input.conversationId)
5398
+ : undefined;
5399
+ if (input.userMessage.existingMessageId && !existingMessage) {
5400
+ throw new AppError(400, "AI_STREAM_USER_MESSAGE_MISMATCH", "当前用户消息不属于目标 AI 对话");
5401
+ }
5402
+ const messageId = existingMessage ? requiredString(existingMessage, "id") : id("message");
5403
+ const previousTitle = requiredString(conversation, "title");
5404
+ const title = !existingMessage && previousTitle === "新对话"
5405
+ ? defaultAiConversationTitle(input.userMessage.content)
5406
+ : previousTitle;
5407
+ this.db.run(`INSERT INTO ai_conversation_stream_requests
5408
+ (id, work_id, conversation_id, actor_scope, idempotency_key, request_hash, status,
5409
+ lease_expires_at, created_at, updated_at)
5410
+ VALUES (?, ?, ?, ?, ?, ?, 'in_progress', ?, ?, ?)`, requestId, input.workId, input.conversationId, input.actorScope, input.idempotencyKey, input.requestHash, leaseExpiresAt, timestamp, timestamp);
5411
+ if (!existingMessage) {
5412
+ this.db.run(`INSERT INTO ai_conversation_messages
5413
+ (id, conversation_id, role, content, citations_json, metadata_json, request_id, created_at, created_by_user_id)
5414
+ VALUES (?, ?, 'user', ?, ?, ?, ?, ?, ?)`, messageId, input.conversationId, input.userMessage.content, JSON.stringify(input.userMessage.citations ?? []), JSON.stringify(input.userMessage.metadata ?? {}), `stream:${requestId}:user`, timestamp, currentRequestActor()?.userId ?? null);
5415
+ }
5416
+ this.db.run("UPDATE ai_conversation_stream_requests SET user_message_id = ? WHERE id = ?", messageId, requestId);
5417
+ if (!existingMessage) {
5418
+ this.db.run("UPDATE ai_conversations SET title = ?, updated_at = ? WHERE id = ?", title, timestamp, input.conversationId);
5419
+ if (title !== previousTitle)
5420
+ this.syncAiHistorySearchShortTermsForSource("conversation", input.conversationId);
5421
+ this.syncAiHistorySearchShortTermsForSource("message", messageId);
5422
+ }
5423
+ const created = this.db.get("SELECT * FROM ai_conversation_stream_requests WHERE id = ?", requestId);
5424
+ if (!created)
5425
+ throw notFound("AI 对话请求");
5426
+ return {
5427
+ disposition: "started",
5428
+ request: this.mapAiConversationStreamRequest(created),
5429
+ userMessage: this.aiConversationMessageOrNull(messageId),
5430
+ assistantMessage: null
5431
+ };
5432
+ });
5433
+ }
5434
+ findAiConversationStreamRequest(actorScope, workId, idempotencyKey) {
5435
+ const request = this.db.get(`SELECT * FROM ai_conversation_stream_requests
5436
+ WHERE actor_scope = ? AND work_id = ? AND idempotency_key = ?`, actorScope, workId, idempotencyKey);
5437
+ return request ? this.mapAiConversationStreamRequest(request) : null;
5438
+ }
5439
+ assertAiConversationStreamAvailable(conversationId, referenceTime = new Date()) {
5440
+ const timestamp = referenceTime.toISOString();
5441
+ this.db.transaction(() => {
5442
+ this.expireAiConversationStreamLease(conversationId, timestamp);
5443
+ const active = this.db.get("SELECT id FROM ai_conversation_stream_requests WHERE conversation_id = ? AND status = 'in_progress'", conversationId);
5444
+ if (active) {
5445
+ throw new AppError(409, "AI_CONVERSATION_RESPONSE_IN_PROGRESS", "当前对话仍在生成回复,请等待完成或取消后再发送");
5446
+ }
5447
+ });
5448
+ }
5449
+ touchAiConversationStreamRequest(requestId, referenceTime = new Date()) {
5450
+ const timestamp = referenceTime.toISOString();
5451
+ const leaseExpiresAt = new Date(referenceTime.getTime() + AI_CONVERSATION_STREAM_REQUEST_LEASE_MS).toISOString();
5452
+ return this.db.run(`UPDATE ai_conversation_stream_requests SET lease_expires_at = ?, updated_at = ?
5453
+ WHERE id = ? AND status = 'in_progress'`, leaseExpiresAt, timestamp, requestId).changes === 1;
5454
+ }
5455
+ cancelActiveAiConversationStreamRequests(referenceTime = new Date()) {
5456
+ const timestamp = referenceTime.toISOString();
5457
+ return this.db.run(`UPDATE ai_conversation_stream_requests
5458
+ SET status = 'cancelled', terminal_reason = 'runtime_shutdown', lease_expires_at = NULL,
5459
+ updated_at = ?, completed_at = ?
5460
+ WHERE status = 'in_progress'`, timestamp, timestamp).changes;
5461
+ }
5462
+ finishAiConversationStreamRequest(requestId, status, terminalReason, assistantMessageId, referenceTime = new Date()) {
5463
+ const timestamp = referenceTime.toISOString();
5464
+ return this.db.transaction(() => {
5465
+ const request = this.db.get("SELECT * FROM ai_conversation_stream_requests WHERE id = ?", requestId);
5466
+ if (!request)
5467
+ throw notFound("AI 对话请求");
5468
+ if (assistantMessageId) {
5469
+ const assistant = this.db.get(`SELECT id FROM ai_conversation_messages
5470
+ WHERE id = ? AND conversation_id = ? AND role = 'assistant'`, assistantMessageId, requiredString(request, "conversation_id"));
5471
+ if (!assistant)
5472
+ throw new AppError(400, "AI_STREAM_ASSISTANT_MISMATCH", "AI 回复消息不属于当前对话请求");
5473
+ }
5474
+ this.db.run(`UPDATE ai_conversation_stream_requests
5475
+ SET status = ?, terminal_reason = ?, assistant_message_id = COALESCE(assistant_message_id, ?),
5476
+ lease_expires_at = NULL, updated_at = ?, completed_at = ?
5477
+ WHERE id = ? AND status = 'in_progress'`, status, terminalReason.slice(0, 500), assistantMessageId ?? null, timestamp, timestamp, requestId);
5478
+ const completed = this.db.get("SELECT * FROM ai_conversation_stream_requests WHERE id = ?", requestId);
5479
+ if (!completed)
5480
+ throw notFound("AI 对话请求");
5481
+ return this.mapAiConversationStreamRequest(completed);
5482
+ });
5483
+ }
5484
+ forkAiConversation(conversationId, messageId, requestedTitle, requestId) {
4844
5485
  const conversation = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
4845
5486
  if (!conversation)
4846
5487
  throw notFound("AI 对话");
5488
+ const normalizedRequestId = requestId?.trim() || null;
5489
+ if (normalizedRequestId) {
5490
+ const existingFork = this.db.get("SELECT source_message_id, conversation_id FROM ai_conversation_forks WHERE source_conversation_id = ? AND request_id = ?", conversationId, normalizedRequestId);
5491
+ if (existingFork) {
5492
+ if (requiredString(existingFork, "source_message_id") !== messageId) {
5493
+ throw new AppError(409, "IDEMPOTENCY_KEY_REUSED", "该续写请求标识已用于另一条历史消息");
5494
+ }
5495
+ return this.getAiConversation(requiredString(existingFork, "conversation_id"));
5496
+ }
5497
+ }
4847
5498
  const messages = this.db.all("SELECT * FROM ai_conversation_messages WHERE conversation_id = ? ORDER BY created_at, rowid", conversationId);
4848
5499
  const targetIndex = messages.findIndex((message) => requiredString(message, "id") === messageId);
4849
5500
  if (targetIndex < 0)
4850
5501
  throw notFound("AI 对话消息");
4851
5502
  const forkId = id("conversation");
4852
5503
  const timestamp = now();
5504
+ const workId = requiredString(conversation, "work_id");
4853
5505
  const sourceTitle = requiredString(conversation, "title");
4854
5506
  const title = requestedTitle?.trim() || `${sourceTitle} · 分支`;
4855
5507
  const sourceCompactedCount = Math.max(0, numberValue(conversation, "compacted_message_count"));
@@ -4859,13 +5511,21 @@ export class Store {
4859
5511
  ?? JSON.stringify(EMPTY_AI_INJECTED_ENTITIES);
4860
5512
  const systemClockText = optionalString(conversation, "system_clock_text") ?? "";
4861
5513
  this.db.transaction(() => {
4862
- this.db.run("INSERT INTO ai_conversations (id, work_id, roleplay_character_id, task_type, context_scope_json, title, compacted_summary, compacted_message_count, agent_tools_json, injected_entities_json, system_clock_text, created_at, updated_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", forkId, requiredString(conversation, "work_id"), optionalString(conversation, "roleplay_character_id"), optionalString(conversation, "task_type"), optionalString(conversation, "context_scope_json"), title.slice(0, 200), forkSummary, forkCompactedCount, conversation.agent_tools_json == null
4863
- ? JSON.stringify(normalizeWorkAgentTools(this.getWorkAiSettings(requiredString(conversation, "work_id")).agentTools))
5514
+ this.db.run("INSERT INTO ai_conversations (id, work_id, roleplay_character_id, task_type, context_scope_json, title, compacted_summary, compacted_message_count, agent_tools_json, injected_entities_json, system_clock_text, created_at, updated_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", forkId, workId, optionalString(conversation, "roleplay_character_id"), optionalString(conversation, "task_type"), optionalString(conversation, "context_scope_json"), title.slice(0, 200), forkSummary, forkCompactedCount, conversation.agent_tools_json == null
5515
+ ? JSON.stringify(normalizeWorkAgentTools(this.getWorkAiSettings(workId).agentTools))
4864
5516
  : String(conversation.agent_tools_json), injectedEntitiesJson, systemClockText, timestamp, timestamp, currentRequestActor()?.userId ?? null);
4865
5517
  for (const message of messages.slice(0, targetIndex + 1)) {
4866
5518
  this.db.run("INSERT INTO ai_conversation_messages (id, conversation_id, role, content, citations_json, metadata_json, request_id, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", id("message"), forkId, requiredString(message, "role"), requiredString(message, "content"), requiredString(message, "citations_json"), requiredString(message, "metadata_json"), optionalString(message, "request_id"), requiredString(message, "created_at"), currentRequestActor()?.userId ?? null);
4867
5519
  }
5520
+ if (normalizedRequestId) {
5521
+ this.db.run("INSERT INTO ai_conversation_forks (source_conversation_id, source_message_id, request_id, conversation_id, created_at) VALUES (?, ?, ?, ?, ?)", conversationId, messageId, normalizedRequestId, forkId, timestamp);
5522
+ }
4868
5523
  this.syncAiHistorySearchShortTermsForConversation(forkId);
5524
+ this.audit(workId, "ai-conversation.forked", "ai-conversation", forkId, {
5525
+ sourceConversationId: conversationId,
5526
+ sourceMessageId: messageId,
5527
+ messageCount: targetIndex + 1
5528
+ });
4869
5529
  });
4870
5530
  return this.getAiConversation(forkId);
4871
5531
  }
@@ -4920,6 +5580,45 @@ export class Store {
4920
5580
  updatedAt: requiredString(row, "updated_at")
4921
5581
  };
4922
5582
  }
5583
+ aiConversationMessageOrNull(messageId) {
5584
+ if (!messageId)
5585
+ return null;
5586
+ const message = this.db.get("SELECT * FROM ai_conversation_messages WHERE id = ?", messageId);
5587
+ return message ? this.mapAiConversationMessage(message) : null;
5588
+ }
5589
+ expireAiConversationStreamLease(conversationId, timestamp) {
5590
+ const expired = this.db.get(`SELECT * FROM ai_conversation_stream_requests
5591
+ WHERE conversation_id = ? AND status = 'in_progress' AND lease_expires_at <= ?`, conversationId, timestamp);
5592
+ if (!expired)
5593
+ return;
5594
+ const userMessageId = optionalString(expired, "user_message_id");
5595
+ const assistant = userMessageId
5596
+ ? this.db.get(`SELECT id FROM ai_conversation_messages
5597
+ WHERE conversation_id = ? AND role = 'assistant' AND request_id = ?`, conversationId, `assistant:${userMessageId}`)
5598
+ : undefined;
5599
+ this.db.run(`UPDATE ai_conversation_stream_requests
5600
+ SET status = ?, terminal_reason = ?, assistant_message_id = ?, lease_expires_at = NULL,
5601
+ updated_at = ?, completed_at = ?
5602
+ WHERE id = ? AND status = 'in_progress'`, assistant ? "completed" : "abandoned", assistant ? "recovered_completed_response" : "lease_expired", assistant ? requiredString(assistant, "id") : null, timestamp, timestamp, requiredString(expired, "id"));
5603
+ }
5604
+ mapAiConversationStreamRequest(row) {
5605
+ return {
5606
+ id: requiredString(row, "id"),
5607
+ workId: requiredString(row, "work_id"),
5608
+ conversationId: requiredString(row, "conversation_id"),
5609
+ actorScope: requiredString(row, "actor_scope"),
5610
+ idempotencyKey: requiredString(row, "idempotency_key"),
5611
+ requestHash: requiredString(row, "request_hash"),
5612
+ status: requiredString(row, "status"),
5613
+ terminalReason: optionalString(row, "terminal_reason"),
5614
+ userMessageId: optionalString(row, "user_message_id"),
5615
+ assistantMessageId: optionalString(row, "assistant_message_id"),
5616
+ leaseExpiresAt: optionalString(row, "lease_expires_at"),
5617
+ createdAt: requiredString(row, "created_at"),
5618
+ updatedAt: requiredString(row, "updated_at"),
5619
+ completedAt: optionalString(row, "completed_at")
5620
+ };
5621
+ }
4923
5622
  /** 锁定本对话可用工具集;已锁定则保持不变,避免中途改作品设置破坏 prompt cache。 */
4924
5623
  ensureAiConversationAgentTools(conversationId, workId) {
4925
5624
  const conversation = this.db.get("SELECT id, work_id, agent_tools_json FROM ai_conversations WHERE id = ?", conversationId);
@@ -5203,7 +5902,7 @@ export class Store {
5203
5902
  }
5204
5903
  updateTask(taskId, input) {
5205
5904
  const current = this.getTask(taskId);
5206
- const terminal = ["completed", "partial", "review", "expired", "cancelled"];
5905
+ const terminal = ["completed", "partial", "failed", "review", "expired", "cancelled"];
5207
5906
  if (terminal.includes(String(current.status)) && input.status !== current.status) {
5208
5907
  throw new AppError(409, "INVALID_TASK_TRANSITION", "终态任务不能再变更状态");
5209
5908
  }
@@ -5571,6 +6270,7 @@ export class Store {
5571
6270
  let metrics = [];
5572
6271
  let sections = [];
5573
6272
  let relationshipChangePreviewSummary = null;
6273
+ let characterExtractionPreviewSummary = null;
5574
6274
  if (taskType === "chapter-analysis") {
5575
6275
  let chapterTitle = String(result.chapterId ?? "指定章节");
5576
6276
  if (typeof result.chapterId === "string") {
@@ -5710,6 +6410,11 @@ export class Store {
5710
6410
  ];
5711
6411
  }
5712
6412
  else if (taskType === "character-extraction" || taskType === "character-summary") {
6413
+ const extractedCandidates = this.taskResultObjects(result.characterCandidates);
6414
+ const application = result.characterApplication && typeof result.characterApplication === "object"
6415
+ && !Array.isArray(result.characterApplication)
6416
+ ? result.characterApplication
6417
+ : null;
5713
6418
  const ids = idList(result.characterIds);
5714
6419
  const characters = ids.flatMap((characterId) => {
5715
6420
  try {
@@ -5732,13 +6437,71 @@ export class Store {
5732
6437
  return [];
5733
6438
  }
5734
6439
  });
5735
- summary = `识别 ${Number(result.candidateCount ?? characters.length)} 个角色候选,保存 ${characters.length} 个角色档案。`;
5736
6440
  const verification = result.verification && typeof result.verification === "object" && !Array.isArray(result.verification)
5737
6441
  ? result.verification
5738
6442
  : {};
5739
- metrics = [metric("保存角色", characters.length), metric("跳过", Array.isArray(result.skipped) ? result.skipped.length : 0), metric("覆盖章节", result.coveredChapterCount), metric("身份复核", verification.pairCount)];
5740
- storageTargets.unshift({ label: "角色档案", entity: "角色库", key: "characters", count: characters.length, note: "新角色会创建档案,命中已有角色时会合并可靠信息。" });
5741
- sections = [section("保存的角色", characters, "没有形成可保存的角色档案。"), section("未写入候选", result.skipped, "没有候选被跳过。")];
6443
+ if (extractedCandidates.length > 0 || application) {
6444
+ const applicationStatus = application?.status === "applied" ? "applied" : "pending";
6445
+ const candidateItems = extractedCandidates.map((candidate) => ({
6446
+ name: String(candidate.name ?? "未命名角色"),
6447
+ aliases: Array.isArray(candidate.aliases) ? candidate.aliases.map(String) : [],
6448
+ identity: String(candidate.identity ?? ""),
6449
+ species: String(candidate.species ?? ""),
6450
+ evidence: candidate.firstEvidence ? [candidate.firstEvidence] : []
6451
+ }));
6452
+ const appliedItems = this.taskResultObjects(application?.items).map((item) => ({
6453
+ title: String(item.characterName ?? item.candidateId ?? "角色候选"),
6454
+ subtitle: item.status === "created" ? "已新建档案"
6455
+ : item.status === "merged" ? "已合并可靠信息"
6456
+ : item.status === "unchanged" ? "已有档案保持不变"
6457
+ : "已跳过",
6458
+ description: Array.isArray(item.conflicts) ? item.conflicts.map(String).join(";") : ""
6459
+ }));
6460
+ const totalCount = Number(application?.totalCount ?? result.candidateCount ?? extractedCandidates.length);
6461
+ const createdCount = Number(application?.createdCount ?? 0);
6462
+ const mergedCount = Number(application?.mergedCount ?? 0);
6463
+ const unchangedCount = Number(application?.unchangedCount ?? 0);
6464
+ const skippedCount = Number(application?.skippedCount ?? 0);
6465
+ characterExtractionPreviewSummary = {
6466
+ status: applicationStatus,
6467
+ totalCount,
6468
+ createdCount,
6469
+ mergedCount,
6470
+ unchangedCount,
6471
+ skippedCount,
6472
+ ...(typeof application?.generatedAt === "string" ? { generatedAt: application.generatedAt } : {}),
6473
+ ...(typeof application?.appliedAt === "string" ? { appliedAt: application.appliedAt } : {})
6474
+ };
6475
+ summary = applicationStatus === "applied"
6476
+ ? `已处理 ${totalCount} 个角色候选:新建 ${createdCount} 个、合并 ${mergedCount} 个、保持不变 ${unchangedCount} 个、跳过 ${skippedCount} 个。`
6477
+ : `识别 ${totalCount} 个角色候选,角色库尚未修改;请预览、勾选并确认新建或合并策略。`;
6478
+ metrics = [
6479
+ metric(applicationStatus === "applied" ? "新建角色" : "待确认", applicationStatus === "applied" ? createdCount : totalCount),
6480
+ metric("合并", mergedCount),
6481
+ metric("跳过", applicationStatus === "applied" ? skippedCount : Array.isArray(result.skipped) ? result.skipped.length : 0),
6482
+ metric("身份复核", verification.pairCount)
6483
+ ];
6484
+ storageTargets.unshift({
6485
+ label: "角色档案",
6486
+ entity: "角色库",
6487
+ key: "characters",
6488
+ count: characters.length,
6489
+ note: applicationStatus === "applied"
6490
+ ? "仅写入用户确认的新建或合并项;冲突字段保留原档案。"
6491
+ : "当前仅保存结构化预览,角色库尚未修改。"
6492
+ });
6493
+ sections = [
6494
+ section(applicationStatus === "applied" ? "抽取的角色候选" : "待确认角色候选", candidateItems, "没有形成可应用的角色候选。"),
6495
+ ...(applicationStatus === "applied" ? [section("应用结果", appliedItems, "没有角色候选被应用。")] : []),
6496
+ section("抽取阶段跳过的候选", result.skipped, "抽取阶段没有候选被跳过。")
6497
+ ];
6498
+ }
6499
+ else {
6500
+ summary = `识别 ${Number(result.candidateCount ?? characters.length)} 个角色候选,保存 ${characters.length} 个角色档案。`;
6501
+ metrics = [metric("保存角色", characters.length), metric("跳过", Array.isArray(result.skipped) ? result.skipped.length : 0), metric("覆盖章节", result.coveredChapterCount), metric("身份复核", verification.pairCount)];
6502
+ storageTargets.unshift({ label: "角色档案", entity: "角色库", key: "characters", count: characters.length, note: "该历史任务在生成结果时直接写入角色档案。" });
6503
+ sections = [section("保存的角色", characters, "没有形成可保存的角色档案。"), section("未写入候选", result.skipped, "没有候选被跳过。")];
6504
+ }
5742
6505
  }
5743
6506
  else if (taskType === "book-analysis") {
5744
6507
  const timelineIds = [...new Set([...idList(result.eventIds), ...idList(result.timelineEventIds)])];
@@ -5970,7 +6733,8 @@ export class Store {
5970
6733
  metrics,
5971
6734
  storageTargets: productStorageTargets(storageTargets),
5972
6735
  sections,
5973
- ...(relationshipChangePreviewSummary ? { relationshipChangePreview: relationshipChangePreviewSummary } : {})
6736
+ ...(relationshipChangePreviewSummary ? { relationshipChangePreview: relationshipChangePreviewSummary } : {}),
6737
+ ...(characterExtractionPreviewSummary ? { characterExtractionPreview: characterExtractionPreviewSummary } : {})
5974
6738
  };
5975
6739
  }
5976
6740
  taskResultForClient(result) {
@@ -6296,9 +7060,11 @@ export class Store {
6296
7060
  }
6297
7061
  search(workId, query) {
6298
7062
  this.getWork(workId);
6299
- const pattern = `%${query.replaceAll("%", "\\%").replaceAll("_", "\\_")}%`;
7063
+ const normalizedQuery = normalizeWorkSearchQuery(query);
7064
+ if (!normalizedQuery)
7065
+ return [];
7066
+ const pattern = `%${escapeSqlLikePattern(normalizedQuery)}%`;
6300
7067
  const chapters = this.db.all("SELECT id, title, content, volume_id FROM chapters WHERE work_id = ? AND deleted_at IS NULL AND (title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\') LIMIT 50", workId, pattern, pattern);
6301
- const normalizedQuery = query.toLocaleLowerCase("zh-CN");
6302
7068
  const races = this.listRaces(workId).filter((race) => {
6303
7069
  const lineage = race.lineage;
6304
7070
  const effectiveSettings = race.effectiveSettings;
@@ -6329,9 +7095,9 @@ export class Store {
6329
7095
  OR EXISTS (SELECT 1 FROM character_race_lineage lineage WHERE lineage.character_id = character.id AND lineage.name LIKE ? ESCAPE '\\')
6330
7096
  ) LIMIT 50`, workId, workId, pattern, pattern, pattern, pattern);
6331
7097
  const organizations = this.db.all("SELECT id, name, description, is_dissolved, settings_json FROM organizations WHERE work_id = ? AND (name LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\' OR settings_json LIKE ? ESCAPE '\\') LIMIT 50", workId, pattern, pattern, pattern);
6332
- const characterSections = this.searchCharacterProfileSections(workId, query, 30);
7098
+ const characterSections = this.searchCharacterProfileSections(workId, normalizedQuery, 30);
6333
7099
  const snippet = (content) => {
6334
- const index = content.toLocaleLowerCase().indexOf(query.toLocaleLowerCase());
7100
+ const index = content.toLocaleLowerCase().indexOf(normalizedQuery);
6335
7101
  const start = Math.max(0, index - 40);
6336
7102
  return content.slice(start, start + 120);
6337
7103
  };
@@ -6418,6 +7184,35 @@ export class Store {
6418
7184
  cover: cover ? { mimeType: cover.mimeType, content: cover.content } : null
6419
7185
  });
6420
7186
  }
7187
+ async exportEpub(workId, volumeId) {
7188
+ const tree = this.getWorkTree(workId);
7189
+ const allVolumes = tree.volumes;
7190
+ const selectedVolume = volumeId ? allVolumes.find((volume) => String(volume.id) === volumeId) : undefined;
7191
+ if (volumeId && !selectedVolume)
7192
+ throw notFound("分卷");
7193
+ const sourceVolumes = selectedVolume ? [selectedVolume] : allVolumes;
7194
+ const title = selectedVolume ? `${String(tree.title)} - ${String(selectedVolume.title)}` : String(tree.title);
7195
+ const cover = this.findWorkCover(workId);
7196
+ const archive = await createEpubArchive({
7197
+ title,
7198
+ author: String(tree.author ?? ""),
7199
+ description: String(tree.description ?? ""),
7200
+ language: String(tree.language ?? "zh-CN"),
7201
+ volumes: sourceVolumes.map((volume) => ({
7202
+ title: String(volume.title),
7203
+ chapters: volume.chapters.map((chapter) => ({
7204
+ title: String(chapter.title),
7205
+ content: String(chapter.content ?? "")
7206
+ }))
7207
+ })),
7208
+ cover: cover ? { mimeType: cover.mimeType, content: cover.content } : null
7209
+ });
7210
+ return { title, archive };
7211
+ }
7212
+ async exportVolumeEpub(volumeId) {
7213
+ const volume = this.getVolume(volumeId);
7214
+ return this.exportEpub(String(volume.workId), volumeId);
7215
+ }
6421
7216
  listAuditLogs(workId) {
6422
7217
  this.getWork(workId);
6423
7218
  return this.db.all(`SELECT log.*, user.display_name AS actor_display_name, user.username AS actor_username