@musnows/scriverse 0.7.3 → 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 (54) hide show
  1. package/README.en.md +3 -0
  2. package/README.md +3 -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 +681 -107
  10. package/dist/ai.js.map +1 -1
  11. package/dist/app.js +326 -53
  12. package/dist/app.js.map +1 -1
  13. package/dist/character-extraction.js +133 -0
  14. package/dist/character-extraction.js.map +1 -0
  15. package/dist/cli-core.js +7 -6
  16. package/dist/cli-core.js.map +1 -1
  17. package/dist/database.js +175 -2
  18. package/dist/database.js.map +1 -1
  19. package/dist/epub-export.js +319 -0
  20. package/dist/epub-export.js.map +1 -0
  21. package/dist/hybrid-search.js +8 -0
  22. package/dist/hybrid-search.js.map +1 -1
  23. package/dist/public/ai-connectivity-test.d.ts +7 -0
  24. package/dist/public/ai-connectivity-test.js +82 -0
  25. package/dist/public/ai-request-manager.js +99 -0
  26. package/dist/public/ai-stream-protocol.js +51 -0
  27. package/dist/public/app.js +2567 -265
  28. package/dist/public/chapter-version-diff.d.ts +20 -0
  29. package/dist/public/chapter-version-diff.js +116 -0
  30. package/dist/public/foreshadow-reminder.d.ts +32 -0
  31. package/dist/public/foreshadow-reminder.js +73 -0
  32. package/dist/public/global-replace-refresh.js +60 -0
  33. package/dist/public/index.html +120 -12
  34. package/dist/public/outline-board.d.ts +61 -0
  35. package/dist/public/outline-board.js +137 -0
  36. package/dist/public/page-route.d.ts +1 -0
  37. package/dist/public/page-route.js +8 -0
  38. package/dist/public/reading-preview.d.ts +32 -0
  39. package/dist/public/reading-preview.js +136 -0
  40. package/dist/public/styles.css +485 -4
  41. package/dist/public/upload-progress.d.ts +2 -0
  42. package/dist/public/upload-progress.js +10 -0
  43. package/dist/security.js +5 -2
  44. package/dist/security.js.map +1 -1
  45. package/dist/server-runtime.js +2 -0
  46. package/dist/server-runtime.js.map +1 -1
  47. package/dist/store.js +825 -88
  48. package/dist/store.js.map +1 -1
  49. package/dist/user-auth.js +45 -9
  50. package/dist/user-auth.js.map +1 -1
  51. package/dist/utils.js +3 -0
  52. package/dist/utils.js.map +1 -1
  53. package/dist/version.js +1 -1
  54. package/package.json +2 -1
package/dist/store.js CHANGED
@@ -2,15 +2,21 @@ import { 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";
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";
13
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
+ }
14
20
  export const attachmentPermissionModules = ["prose", "drafts", "settings", "characters", "races", "organizations"];
15
21
  export const WORK_AGENT_TOOL_IDS = [
16
22
  "story_index",
@@ -148,6 +154,7 @@ function knowledgeSectionsFromInput(sections, settingsMarkdown, settings, fallba
148
154
  function settingsFromKnowledgeSections(sections) {
149
155
  return sections.map((section) => section.contentMarkdown).filter((content) => content.trim());
150
156
  }
157
+ const CHAPTER_OUTLINE_BOARD_PREVIEW_LENGTH = 600;
151
158
  export const versionedEntityTypes = [
152
159
  "work",
153
160
  "volume",
@@ -161,6 +168,7 @@ export const versionedEntityTypes = [
161
168
  "chapter-outline",
162
169
  "foreshadow"
163
170
  ];
171
+ export const AI_CONVERSATION_STREAM_REQUEST_LEASE_MS = 3 * 60_000;
164
172
  export const aiConversationTaskTypes = ["chat", "roleplay", "continue", "polish"];
165
173
  export function defaultAiConversationTitle(prompt) {
166
174
  const normalized = prompt.replace(/\s+/gu, " ").trim();
@@ -268,6 +276,37 @@ export class Store {
268
276
  constructor(db) {
269
277
  this.db = db;
270
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
+ });
271
310
  }
272
311
  currentEntityVersionNo(type, entityId) {
273
312
  const row = this.db.get("SELECT MAX(version_no) AS version_no FROM entity_versions WHERE entity_type = ? AND entity_id = ?", type, entityId);
@@ -555,6 +594,9 @@ export class Store {
555
594
  })), pagination);
556
595
  }
557
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
+ }
558
600
  const version = this.db.get("SELECT * FROM entity_versions WHERE entity_type = ? AND entity_id = ? AND version_no = ?", type, entityId, versionNo);
559
601
  if (!version)
560
602
  throw notFound("历史版本");
@@ -683,28 +725,61 @@ export class Store {
683
725
  listWorks() {
684
726
  const actor = currentRequestActor();
685
727
  if (!actor || (actor.role === "admin" && actor.authentication !== "api-key")) {
686
- return this.mapWorks(this.db.all("SELECT * FROM works WHERE COALESCE(is_internal, 0) = 0 ORDER BY updated_at DESC"));
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"));
687
729
  }
688
730
  return this.mapWorks(this.db.all(`SELECT DISTINCT work.* FROM works work LEFT JOIN work_memberships membership ON membership.work_id = work.id
689
- WHERE COALESCE(work.is_internal, 0) = 0 AND (work.owner_user_id = ? OR membership.user_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 = ?)
690
733
  ORDER BY work.updated_at DESC`, actor.userId, actor.userId));
691
734
  }
692
735
  listWorksPage(pagination) {
693
736
  const actor = currentRequestActor();
694
737
  const page = paginationSql(pagination);
695
738
  const rows = !actor || (actor.role === "admin" && actor.authentication !== "api-key")
696
- ? 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)
697
740
  : this.db.all(`SELECT DISTINCT work.* FROM works work LEFT JOIN work_memberships membership ON membership.work_id = work.id
698
- 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 = ?)
699
743
  ORDER BY work.updated_at DESC${page.sql}`, actor.userId, actor.userId, ...page.params);
700
744
  return paginated(this.mapWorks(rows), pagination);
701
745
  }
702
746
  getWork(workId) {
703
- 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);
704
748
  if (!row)
705
749
  throw notFound("作品");
706
750
  return this.mapWork(row);
707
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
+ }
708
783
  getPlatformAiSettings() {
709
784
  const row = this.db.get("SELECT * FROM platform_ai_settings WHERE id = 1");
710
785
  return {
@@ -938,26 +1013,74 @@ export class Store {
938
1013
  return this.getWork(workId);
939
1014
  }
940
1015
  deleteWork(workId, expectedVersionNo) {
941
- const work = this.getWork(workId);
942
- const storageKeys = this.db.all("SELECT DISTINCT storage_key FROM attachments WHERE work_id = ?", workId)
943
- .map((row) => requiredString(row, "storage_key"));
944
1016
  this.db.transaction(() => {
945
- this.db.raw.exec("PRAGMA defer_foreign_keys = ON");
946
1017
  const current = this.getWork(workId);
947
1018
  this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
948
- this.recordEntityVersion("work", workId, "delete", null, "删除作品");
949
- this.audit(null, "work.deleted", "work", workId, { title: work.title });
950
- this.db.run("DELETE FROM characters WHERE work_id = ?", workId);
951
- this.db.run("DELETE FROM organizations WHERE work_id = ?", workId);
952
- this.db.run("UPDATE races SET parent_race_id = NULL WHERE work_id = ? AND parent_race_id IS NOT NULL", workId);
953
- this.db.run("DELETE FROM races WHERE work_id = ?", workId);
954
- this.db.run("DELETE FROM works WHERE id = ?", workId);
955
- this.db.run("DELETE FROM relationship_source_index_queue WHERE work_id = ?", workId);
956
- for (const storageKey of storageKeys) {
957
- if (!this.attachmentStorageKeyInUse(storageKey))
958
- this.enqueueAttachmentCleanup(storageKey);
959
- }
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 });
960
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
+ }
961
1084
  return storageKeys.filter((storageKey) => !this.attachmentStorageKeyInUse(storageKey));
962
1085
  }
963
1086
  setWorkCover(workId, mimeType, content, expectedVersionNo) {
@@ -1012,7 +1135,7 @@ export class Store {
1012
1135
  }
1013
1136
  getWorkTree(workId) {
1014
1137
  const work = this.getWork(workId);
1015
- 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);
1016
1139
  const chapterRows = this.db.all("SELECT * FROM chapters WHERE work_id = ? AND deleted_at IS NULL ORDER BY sort_order, created_at", workId);
1017
1140
  const chaptersByVolume = new Map();
1018
1141
  for (const row of chapterRows) {
@@ -1033,7 +1156,7 @@ export class Store {
1033
1156
  const permissions = work.modulePermissions;
1034
1157
  if (permissions.prose === "none")
1035
1158
  return { ...work, volumes: [] };
1036
- 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);
1037
1160
  const chapterRows = this.db.all(`SELECT id, work_id, volume_id, title, chapter_type, sort_order, word_count, version_no,
1038
1161
  analysis_status, excluded_from_analysis, created_at, updated_at
1039
1162
  FROM chapters WHERE work_id = ? AND deleted_at IS NULL ORDER BY sort_order, created_at`, workId);
@@ -1058,7 +1181,7 @@ export class Store {
1058
1181
  return { ...work, volumes: [] };
1059
1182
  const volumeRows = this.db.all(`SELECT volume.*,
1060
1183
  (SELECT COUNT(*) FROM chapters chapter WHERE chapter.volume_id = volume.id AND chapter.deleted_at IS NULL) AS chapter_count
1061
- 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);
1062
1185
  const volumes = volumeRows.map((row) => ({
1063
1186
  ...this.mapVolume(row),
1064
1187
  chapterCount: numberValue(row, "chapter_count"),
@@ -1091,7 +1214,7 @@ export class Store {
1091
1214
  const permissions = work.modulePermissions;
1092
1215
  if (permissions.prose === "none")
1093
1216
  return { ...work, volumes: [], directoryPage: paginated([], pagination) };
1094
- 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);
1095
1218
  const page = paginationSql(pagination);
1096
1219
  const chapterRows = this.db.all(`SELECT id, work_id, volume_id, title, chapter_type, sort_order, word_count, version_no,
1097
1220
  analysis_status, excluded_from_analysis, created_at, updated_at
@@ -1168,11 +1291,13 @@ export class Store {
1168
1291
  const timestamp = now();
1169
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)
1170
1293
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, restorePointId, workId, `before-restore:${requiredString(version, "file_name")}`, "snapshot", wordCount, paragraphCount, "[]", JSON.stringify(currentTree), timestamp, currentRequestActor()?.userId ?? null);
1171
- for (const row of this.db.all("SELECT id FROM volumes WHERE work_id = ?", workId)) {
1172
- 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, "替换作品树前保存分卷历史");
1173
1298
  }
1174
- this.clearDraftVolumeBindings(workId, null, fileVersionId, "恢复文件版本时原分卷已被替换");
1175
- 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);
1176
1301
  for (const volume of volumes) {
1177
1302
  const volumeId = id("volume");
1178
1303
  this.insertVolumeWithId(workId, volumeId, {
@@ -1220,14 +1345,16 @@ export class Store {
1220
1345
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, fileVersionId, workId, fileName, fileType, parsed.wordCount, parsed.paragraphCount, JSON.stringify(parsed.warnings), JSON.stringify(snapshot), timestamp, currentRequestActor()?.userId ?? null);
1221
1346
  let volumeOrderOffset = 0;
1222
1347
  if (mode === "overwrite") {
1223
- for (const row of this.db.all("SELECT id FROM volumes WHERE work_id = ?", workId)) {
1224
- 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, "导入前保存分卷历史");
1225
1352
  }
1226
- this.clearDraftVolumeBindings(workId, null, fileVersionId, "覆盖导入时原分卷已被替换");
1227
- 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);
1228
1355
  }
1229
1356
  else {
1230
- 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);
1231
1358
  volumeOrderOffset = numberValue(lastVolume ?? {}, "value") + 1;
1232
1359
  }
1233
1360
  let firstImportedChapterId = null;
@@ -1269,7 +1396,7 @@ export class Store {
1269
1396
  insertVolumeWithId(workId, volumeId, input, source = "create", sourceRef = null, changeNote = "") {
1270
1397
  this.getWork(workId);
1271
1398
  const timestamp = now();
1272
- 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);
1273
1400
  this.db.run(`INSERT INTO volumes (id, work_id, title, kind, source, description, keywords_json, sort_order, version_no, created_at, updated_at)
1274
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);
1275
1402
  const versionNo = this.recordEntityVersion("volume", volumeId, source, sourceRef, changeNote || "建立分卷", timestamp);
@@ -1279,7 +1406,8 @@ export class Store {
1279
1406
  return this.getVolume(volumeId);
1280
1407
  }
1281
1408
  getVolume(volumeId) {
1282
- 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);
1283
1411
  if (!row)
1284
1412
  throw notFound("卷");
1285
1413
  return this.mapVolume(row);
@@ -1299,20 +1427,109 @@ export class Store {
1299
1427
  this.db.transaction(() => {
1300
1428
  const current = this.getVolume(volumeId);
1301
1429
  this.assertExpectedVersion("volume", volumeId, expectedVersionNo, "分卷", Number(current.versionNo));
1302
- const counts = this.db.get(`SELECT
1303
- SUM(CASE WHEN deleted_at IS NULL THEN 1 ELSE 0 END) AS active_count,
1304
- SUM(CASE WHEN deleted_at IS NOT NULL THEN 1 ELSE 0 END) AS deleted_count
1305
- FROM chapters WHERE volume_id = ?`, volumeId);
1306
- if (numberValue(counts ?? {}, "active_count") > 0) {
1307
- 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);
1308
1439
  }
1309
- if (numberValue(counts ?? {}, "deleted_count") > 0) {
1310
- 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"));
1311
1498
  }
1312
- this.recordEntityVersion("volume", volumeId, "delete", null, "删除分卷");
1313
- this.clearDraftVolumeBindings(String(current.workId), [volumeId], null, "绑定的分卷已删除");
1314
- this.db.run("DELETE FROM volumes WHERE id = ?", volumeId);
1315
- 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
1316
1533
  });
1317
1534
  }
1318
1535
  createChapter(workId, input) {
@@ -1328,7 +1545,11 @@ export class Store {
1328
1545
  });
1329
1546
  }
1330
1547
  getChapter(chapterId) {
1331
- 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);
1332
1553
  if (!row)
1333
1554
  throw notFound("章节");
1334
1555
  return this.mapChapter(row);
@@ -1352,9 +1573,11 @@ export class Store {
1352
1573
  ON version.chapter_id = chapter.id AND version.version_no = chapter.version_no AND version.source = 'delete'
1353
1574
  LEFT JOIN users user ON user.id = version.created_by_user_id
1354
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
1355
1577
  ORDER BY chapter.deleted_at DESC, chapter.id DESC${pageSql}`, workId, ...pageParams);
1356
1578
  }
1357
1579
  mapDeletedChapter(row) {
1580
+ const deletedAt = requiredString(row, "deleted_at");
1358
1581
  return {
1359
1582
  id: requiredString(row, "id"),
1360
1583
  workId: requiredString(row, "work_id"),
@@ -1364,10 +1587,18 @@ export class Store {
1364
1587
  contentPreview: requiredString(row, "content").slice(0, 300),
1365
1588
  wordCount: numberValue(row, "word_count"),
1366
1589
  versionNo: numberValue(row, "version_no"),
1367
- deletedAt: requiredString(row, "deleted_at"),
1590
+ deletedAt,
1591
+ expiresAt: recycleBinExpiresAt(deletedAt),
1368
1592
  actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? "历史数据"
1369
1593
  };
1370
1594
  }
1595
+ getRecycleBin(workId) {
1596
+ return {
1597
+ retentionDays: RECYCLE_BIN_RETENTION_DAYS,
1598
+ volumes: this.listDeletedVolumes(workId),
1599
+ chapters: this.listDeletedChapters(workId)
1600
+ };
1601
+ }
1371
1602
  findChapterVersionRows(chapterId) {
1372
1603
  return this.db.all(`SELECT version.*, user.display_name AS actor_display_name, user.username AS actor_username
1373
1604
  FROM chapter_versions version LEFT JOIN users user ON user.id = version.created_by_user_id
@@ -1414,7 +1645,7 @@ export class Store {
1414
1645
  this.getChapter(chapterId);
1415
1646
  return paginated([], pagination);
1416
1647
  }
1417
- 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);
1418
1649
  }
1419
1650
  listChapterInsights(chapterId) {
1420
1651
  this.getChapter(chapterId);
@@ -1667,6 +1898,9 @@ export class Store {
1667
1898
  const deleted = this.db.get("SELECT * FROM chapters WHERE id = ? AND deleted_at IS NOT NULL", chapterId);
1668
1899
  if (!deleted)
1669
1900
  throw notFound("章节");
1901
+ if (optionalString(deleted, "deleted_via_volume_id")) {
1902
+ throw new AppError(409, "CHAPTER_DELETED_WITH_VOLUME", "章节随分卷进入回收站,请先恢复分卷");
1903
+ }
1670
1904
  const currentVersionNo = numberValue(deleted, "version_no");
1671
1905
  this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", currentVersionNo);
1672
1906
  const workId = requiredString(deleted, "work_id");
@@ -1685,12 +1919,15 @@ export class Store {
1685
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;
1686
1920
  const timestamp = now();
1687
1921
  this.db.transaction(() => {
1688
- 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);
1689
1923
  if (!locked?.deleted_at)
1690
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
+ }
1691
1928
  this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", numberValue(locked, "version_no"));
1692
1929
  this.db.run(`UPDATE chapters SET volume_id = ?, title = ?, content = ?, chapter_type = ?, sort_order = ?, word_count = ?,
1693
- 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);
1694
1931
  this.syncChapterParagraphSearch(workId, chapterId, content);
1695
1932
  this.insertChapterVersionRow({
1696
1933
  workId,
@@ -1972,7 +2209,7 @@ export class Store {
1972
2209
  for (const chapter of currentChapters) {
1973
2210
  const chapterId = String(chapter.id);
1974
2211
  const versionNo = Number(chapter.versionNo) + 1;
1975
- 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);
1976
2213
  this.insertChapterVersionRow({
1977
2214
  workId,
1978
2215
  chapterId,
@@ -2009,7 +2246,7 @@ export class Store {
2009
2246
  this.db.transaction(() => {
2010
2247
  const lockedChapter = this.getChapter(chapterId);
2011
2248
  this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(lockedChapter.versionNo));
2012
- 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);
2013
2250
  this.insertChapterVersionRow({
2014
2251
  workId: String(chapter.workId),
2015
2252
  chapterId,
@@ -2035,33 +2272,45 @@ export class Store {
2035
2272
  this.audit(String(chapter.workId), "chapter.deleted", "chapter", chapterId, { versionNo });
2036
2273
  });
2037
2274
  }
2038
- permanentlyDeleteChapter(chapterId, expectedVersionNo) {
2275
+ permanentlyDeleteChapter(chapterId, expectedVersionNo, reason = "manual") {
2039
2276
  const chapter = this.db.get("SELECT * FROM chapters WHERE id = ?", chapterId);
2040
2277
  if (!chapter)
2041
2278
  throw notFound("章节");
2042
2279
  if (!chapter.deleted_at)
2043
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
+ }
2044
2284
  this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", numberValue(chapter, "version_no"));
2045
- const workId = requiredString(chapter, "work_id");
2046
- const timestamp = now();
2047
2285
  this.db.transaction(() => {
2048
2286
  const locked = this.db.get("SELECT * FROM chapters WHERE id = ?", chapterId);
2049
2287
  if (!locked)
2050
2288
  throw notFound("章节");
2051
2289
  if (!locked.deleted_at)
2052
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
+ }
2053
2294
  this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", numberValue(locked, "version_no"));
2054
- this.db.run("DELETE FROM chapter_versions WHERE chapter_id = ?", chapterId);
2055
- this.db.run("DELETE FROM entity_versions WHERE entity_type = 'chapter-outline' AND entity_id = ?", chapterId);
2056
- this.db.run("DELETE FROM attachment_references WHERE entity_type = 'chapter' AND entity_id = ?", chapterId);
2057
- this.db.run("DELETE FROM chapters WHERE id = ?", chapterId);
2058
- this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", timestamp, workId);
2059
- this.audit(workId, "chapter.purged", "chapter", chapterId, {
2060
- title: requiredString(locked, "title"),
2061
- volumeId: requiredString(locked, "volume_id"),
2062
- versionNo: numberValue(locked, "version_no"),
2063
- recoverable: false
2064
- });
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
2065
2314
  });
2066
2315
  }
2067
2316
  insertChapter(workId, volumeId, title, content, sortOrder, source, sourceRef, chapterType = "正文") {
@@ -2271,6 +2520,142 @@ export class Store {
2271
2520
  return null;
2272
2521
  return this.mapChapterOutline(row, chapter);
2273
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
+ }
2274
2659
  listChapterOutlines(workId) {
2275
2660
  this.getWork(workId);
2276
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,
@@ -2449,6 +2834,63 @@ export class Store {
2449
2834
  ORDER BY CASE importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, created_at${page.sql}`, workId, ...page.params);
2450
2835
  return paginated(rows.map((row) => this.getForeshadow(requiredString(row, "id"), currentChapterId)), pagination);
2451
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
+ }
2452
2894
  updateForeshadow(foreshadowId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
2453
2895
  const current = this.getForeshadow(foreshadowId);
2454
2896
  const workId = String(current.workId);
@@ -2589,7 +3031,7 @@ export class Store {
2589
3031
  this.getWork(workId);
2590
3032
  const safeLimit = Math.min(30, Math.max(1, Math.trunc(limit)));
2591
3033
  const normalizedQuery = query.normalize("NFKC").trim();
2592
- const escapedQuery = normalizedQuery.replace(/[\\%_]/gu, "\\$&");
3034
+ const escapedQuery = escapeSqlLikePattern(normalizedQuery);
2593
3035
  const pattern = `%${escapedQuery}%`;
2594
3036
  const rows = normalizedQuery
2595
3037
  ? this.db.all(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
@@ -3273,7 +3715,7 @@ export class Store {
3273
3715
  this.audit(String(current.workId), "character.versioned", "character", characterId, { versionNo, source, sourceRef });
3274
3716
  }
3275
3717
  }
3276
- createCharacter(workId, input) {
3718
+ createCharacter(workId, input, source = "create", sourceRef = null, changeNote = "建立人物档案") {
3277
3719
  this.getWork(workId);
3278
3720
  const characterId = id("character");
3279
3721
  const timestamp = now();
@@ -3294,8 +3736,8 @@ export class Store {
3294
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);
3295
3737
  this.insertCharacterNames(workId, characterId, names.entries);
3296
3738
  this.replaceCharacterOrganizations(characterId, organizationIds);
3297
- this.insertCharacterVersion(characterId, 1, "create", null, "建立人物档案", timestamp);
3298
- 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 });
3299
3741
  });
3300
3742
  return this.getCharacter(characterId);
3301
3743
  }
@@ -4870,10 +5312,24 @@ export class Store {
4870
5312
  if (!conversation)
4871
5313
  throw notFound("AI 对话");
4872
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
+ };
4873
5329
  if (requestId) {
4874
5330
  const existing = this.db.get("SELECT * FROM ai_conversation_messages WHERE conversation_id = ? AND request_id = ?", conversationId, requestId);
4875
5331
  if (existing)
4876
- return this.mapAiConversationMessage(existing);
5332
+ return this.mapAiConversationMessage(persistInterruption(existing));
4877
5333
  }
4878
5334
  const messageId = id("message");
4879
5335
  const timestamp = now();
@@ -4896,18 +5352,156 @@ export class Store {
4896
5352
  : this.db.get("SELECT * FROM ai_conversation_messages WHERE id = ?", messageId);
4897
5353
  if (!message)
4898
5354
  throw notFound("AI 对话消息");
4899
- return this.mapAiConversationMessage(message);
5355
+ return this.mapAiConversationMessage(persistInterruption(message));
5356
+ }
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;
4900
5438
  }
4901
- forkAiConversation(conversationId, messageId, requestedTitle) {
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) {
4902
5485
  const conversation = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
4903
5486
  if (!conversation)
4904
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
+ }
4905
5498
  const messages = this.db.all("SELECT * FROM ai_conversation_messages WHERE conversation_id = ? ORDER BY created_at, rowid", conversationId);
4906
5499
  const targetIndex = messages.findIndex((message) => requiredString(message, "id") === messageId);
4907
5500
  if (targetIndex < 0)
4908
5501
  throw notFound("AI 对话消息");
4909
5502
  const forkId = id("conversation");
4910
5503
  const timestamp = now();
5504
+ const workId = requiredString(conversation, "work_id");
4911
5505
  const sourceTitle = requiredString(conversation, "title");
4912
5506
  const title = requestedTitle?.trim() || `${sourceTitle} · 分支`;
4913
5507
  const sourceCompactedCount = Math.max(0, numberValue(conversation, "compacted_message_count"));
@@ -4917,13 +5511,21 @@ export class Store {
4917
5511
  ?? JSON.stringify(EMPTY_AI_INJECTED_ENTITIES);
4918
5512
  const systemClockText = optionalString(conversation, "system_clock_text") ?? "";
4919
5513
  this.db.transaction(() => {
4920
- 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
4921
- ? 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))
4922
5516
  : String(conversation.agent_tools_json), injectedEntitiesJson, systemClockText, timestamp, timestamp, currentRequestActor()?.userId ?? null);
4923
5517
  for (const message of messages.slice(0, targetIndex + 1)) {
4924
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);
4925
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
+ }
4926
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
+ });
4927
5529
  });
4928
5530
  return this.getAiConversation(forkId);
4929
5531
  }
@@ -4978,6 +5580,45 @@ export class Store {
4978
5580
  updatedAt: requiredString(row, "updated_at")
4979
5581
  };
4980
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
+ }
4981
5622
  /** 锁定本对话可用工具集;已锁定则保持不变,避免中途改作品设置破坏 prompt cache。 */
4982
5623
  ensureAiConversationAgentTools(conversationId, workId) {
4983
5624
  const conversation = this.db.get("SELECT id, work_id, agent_tools_json FROM ai_conversations WHERE id = ?", conversationId);
@@ -5629,6 +6270,7 @@ export class Store {
5629
6270
  let metrics = [];
5630
6271
  let sections = [];
5631
6272
  let relationshipChangePreviewSummary = null;
6273
+ let characterExtractionPreviewSummary = null;
5632
6274
  if (taskType === "chapter-analysis") {
5633
6275
  let chapterTitle = String(result.chapterId ?? "指定章节");
5634
6276
  if (typeof result.chapterId === "string") {
@@ -5768,6 +6410,11 @@ export class Store {
5768
6410
  ];
5769
6411
  }
5770
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;
5771
6418
  const ids = idList(result.characterIds);
5772
6419
  const characters = ids.flatMap((characterId) => {
5773
6420
  try {
@@ -5790,13 +6437,71 @@ export class Store {
5790
6437
  return [];
5791
6438
  }
5792
6439
  });
5793
- summary = `识别 ${Number(result.candidateCount ?? characters.length)} 个角色候选,保存 ${characters.length} 个角色档案。`;
5794
6440
  const verification = result.verification && typeof result.verification === "object" && !Array.isArray(result.verification)
5795
6441
  ? result.verification
5796
6442
  : {};
5797
- metrics = [metric("保存角色", characters.length), metric("跳过", Array.isArray(result.skipped) ? result.skipped.length : 0), metric("覆盖章节", result.coveredChapterCount), metric("身份复核", verification.pairCount)];
5798
- storageTargets.unshift({ label: "角色档案", entity: "角色库", key: "characters", count: characters.length, note: "新角色会创建档案,命中已有角色时会合并可靠信息。" });
5799
- 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
+ }
5800
6505
  }
5801
6506
  else if (taskType === "book-analysis") {
5802
6507
  const timelineIds = [...new Set([...idList(result.eventIds), ...idList(result.timelineEventIds)])];
@@ -6028,7 +6733,8 @@ export class Store {
6028
6733
  metrics,
6029
6734
  storageTargets: productStorageTargets(storageTargets),
6030
6735
  sections,
6031
- ...(relationshipChangePreviewSummary ? { relationshipChangePreview: relationshipChangePreviewSummary } : {})
6736
+ ...(relationshipChangePreviewSummary ? { relationshipChangePreview: relationshipChangePreviewSummary } : {}),
6737
+ ...(characterExtractionPreviewSummary ? { characterExtractionPreview: characterExtractionPreviewSummary } : {})
6032
6738
  };
6033
6739
  }
6034
6740
  taskResultForClient(result) {
@@ -6354,9 +7060,11 @@ export class Store {
6354
7060
  }
6355
7061
  search(workId, query) {
6356
7062
  this.getWork(workId);
6357
- const pattern = `%${query.replaceAll("%", "\\%").replaceAll("_", "\\_")}%`;
7063
+ const normalizedQuery = normalizeWorkSearchQuery(query);
7064
+ if (!normalizedQuery)
7065
+ return [];
7066
+ const pattern = `%${escapeSqlLikePattern(normalizedQuery)}%`;
6358
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);
6359
- const normalizedQuery = query.toLocaleLowerCase("zh-CN");
6360
7068
  const races = this.listRaces(workId).filter((race) => {
6361
7069
  const lineage = race.lineage;
6362
7070
  const effectiveSettings = race.effectiveSettings;
@@ -6387,9 +7095,9 @@ export class Store {
6387
7095
  OR EXISTS (SELECT 1 FROM character_race_lineage lineage WHERE lineage.character_id = character.id AND lineage.name LIKE ? ESCAPE '\\')
6388
7096
  ) LIMIT 50`, workId, workId, pattern, pattern, pattern, pattern);
6389
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);
6390
- const characterSections = this.searchCharacterProfileSections(workId, query, 30);
7098
+ const characterSections = this.searchCharacterProfileSections(workId, normalizedQuery, 30);
6391
7099
  const snippet = (content) => {
6392
- const index = content.toLocaleLowerCase().indexOf(query.toLocaleLowerCase());
7100
+ const index = content.toLocaleLowerCase().indexOf(normalizedQuery);
6393
7101
  const start = Math.max(0, index - 40);
6394
7102
  return content.slice(start, start + 120);
6395
7103
  };
@@ -6476,6 +7184,35 @@ export class Store {
6476
7184
  cover: cover ? { mimeType: cover.mimeType, content: cover.content } : null
6477
7185
  });
6478
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
+ }
6479
7216
  listAuditLogs(workId) {
6480
7217
  this.getWork(workId);
6481
7218
  return this.db.all(`SELECT log.*, user.display_name AS actor_display_name, user.username AS actor_username