@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.
- package/README.en.md +3 -0
- package/README.md +3 -0
- package/dist/ai-connectivity-test.js +109 -0
- package/dist/ai-connectivity-test.js.map +1 -0
- package/dist/ai-conversation-export.js +70 -0
- package/dist/ai-conversation-export.js.map +1 -0
- package/dist/ai-stream-timeout.js +18 -0
- package/dist/ai-stream-timeout.js.map +1 -0
- package/dist/ai.js +681 -107
- package/dist/ai.js.map +1 -1
- package/dist/app.js +326 -53
- package/dist/app.js.map +1 -1
- package/dist/character-extraction.js +133 -0
- package/dist/character-extraction.js.map +1 -0
- package/dist/cli-core.js +7 -6
- package/dist/cli-core.js.map +1 -1
- package/dist/database.js +175 -2
- package/dist/database.js.map +1 -1
- package/dist/epub-export.js +319 -0
- package/dist/epub-export.js.map +1 -0
- package/dist/hybrid-search.js +8 -0
- package/dist/hybrid-search.js.map +1 -1
- package/dist/public/ai-connectivity-test.d.ts +7 -0
- package/dist/public/ai-connectivity-test.js +82 -0
- package/dist/public/ai-request-manager.js +99 -0
- package/dist/public/ai-stream-protocol.js +51 -0
- package/dist/public/app.js +2567 -265
- package/dist/public/chapter-version-diff.d.ts +20 -0
- package/dist/public/chapter-version-diff.js +116 -0
- package/dist/public/foreshadow-reminder.d.ts +32 -0
- package/dist/public/foreshadow-reminder.js +73 -0
- package/dist/public/global-replace-refresh.js +60 -0
- package/dist/public/index.html +120 -12
- package/dist/public/outline-board.d.ts +61 -0
- package/dist/public/outline-board.js +137 -0
- package/dist/public/page-route.d.ts +1 -0
- package/dist/public/page-route.js +8 -0
- package/dist/public/reading-preview.d.ts +32 -0
- package/dist/public/reading-preview.js +136 -0
- package/dist/public/styles.css +485 -4
- package/dist/public/upload-progress.d.ts +2 -0
- package/dist/public/upload-progress.js +10 -0
- package/dist/security.js +5 -2
- package/dist/security.js.map +1 -1
- package/dist/server-runtime.js +2 -0
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +825 -88
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +45 -9
- package/dist/user-auth.js.map +1 -1
- package/dist/utils.js +3 -0
- package/dist/utils.js.map +1 -1
- package/dist/version.js +1 -1
- 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
|
|
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
|
|
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
|
-
|
|
949
|
-
this.
|
|
950
|
-
this.db.run("
|
|
951
|
-
this.
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
-
|
|
1172
|
-
|
|
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,
|
|
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
|
-
|
|
1224
|
-
|
|
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,
|
|
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(
|
|
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
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
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
|
-
|
|
1310
|
-
|
|
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.
|
|
1313
|
-
this.
|
|
1314
|
-
|
|
1315
|
-
|
|
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(
|
|
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
|
|
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.
|
|
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.
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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,
|
|
4921
|
-
? JSON.stringify(normalizeWorkAgentTools(this.getWorkAiSettings(
|
|
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
|
-
|
|
5798
|
-
|
|
5799
|
-
|
|
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
|
|
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,
|
|
7098
|
+
const characterSections = this.searchCharacterProfileSections(workId, normalizedQuery, 30);
|
|
6391
7099
|
const snippet = (content) => {
|
|
6392
|
-
const index = content.toLocaleLowerCase().indexOf(
|
|
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
|