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