@musnows/scriverse 0.6.1 → 0.6.3
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 -2
- package/README.md +3 -2
- package/dist/ai-tool-results.js +71 -0
- package/dist/ai-tool-results.js.map +1 -1
- package/dist/ai.js +267 -54
- package/dist/ai.js.map +1 -1
- package/dist/app.js +256 -57
- package/dist/app.js.map +1 -1
- package/dist/attachment-storage.js +9 -1
- package/dist/attachment-storage.js.map +1 -1
- package/dist/cli-contract.js +4 -0
- package/dist/cli-contract.js.map +1 -1
- package/dist/cli-core.js +23 -7
- package/dist/cli-core.js.map +1 -1
- package/dist/credential-vault.js +7 -5
- package/dist/credential-vault.js.map +1 -1
- package/dist/database.js +243 -2
- package/dist/database.js.map +1 -1
- package/dist/docx-export.js +89 -0
- package/dist/docx-export.js.map +1 -0
- package/dist/domain.js +9 -0
- package/dist/domain.js.map +1 -1
- package/dist/image-captcha.js +8 -5
- package/dist/image-captcha.js.map +1 -1
- package/dist/public/ai-context-meter.js +4 -0
- package/dist/public/ai-message-time.js +3 -9
- package/dist/public/ai-tool-call.js +4 -0
- package/dist/public/app.js +765 -111
- package/dist/public/index.html +49 -17
- package/dist/public/markdown.js +1 -2
- package/dist/public/page-route.js +2 -2
- package/dist/public/styles.css +87 -29
- package/dist/public/system-status.d.ts +12 -0
- package/dist/public/system-status.js +16 -0
- package/dist/public/theme-init.js +2 -2
- package/dist/security.js +143 -16
- package/dist/security.js.map +1 -1
- package/dist/server-runtime.js +56 -1
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +289 -39
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +56 -88
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/dist/writing-progress-time.js +15 -0
- package/dist/writing-progress-time.js.map +1 -1
- package/package.json +2 -1
package/dist/store.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { DRAFT_SETTING_MODULES } from "./domain.js";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import { PLATFORM_AI_WORK_ID } from "./database.js";
|
|
4
|
+
import { exportWorkDocx } from "./docx-export.js";
|
|
3
5
|
import { AppError, notFound } from "./errors.js";
|
|
4
6
|
import { accountReference, logger } from "./logger.js";
|
|
5
7
|
import { paginated, paginationSql } from "./pagination.js";
|
|
@@ -7,6 +9,32 @@ import { currentRequestActor } from "./request-context.js";
|
|
|
7
9
|
import { classifyWorkModulePermissions, emptyWorkModulePermissions, fullWorkModulePermissions, storedWorkModulePermissions } from "./work-permissions.js";
|
|
8
10
|
import { countWords, documentShortSearchTerms, id, json, normalizeDocumentSearchText, normalizeParagraphSpacing, now, splitDocumentParagraphs } from "./utils.js";
|
|
9
11
|
import { buildWritingCalendar, writingDateKey } from "./writing-progress-time.js";
|
|
12
|
+
export const attachmentPermissionModules = ["prose", "drafts", "settings", "characters", "races", "organizations"];
|
|
13
|
+
export const WORK_AGENT_TOOL_IDS = [
|
|
14
|
+
"story_index",
|
|
15
|
+
"read_chapters",
|
|
16
|
+
"grep",
|
|
17
|
+
"search_story_entities",
|
|
18
|
+
"read_character_sections",
|
|
19
|
+
"search_drafts"
|
|
20
|
+
];
|
|
21
|
+
const DEFAULT_WORK_AGENT_TOOLS = [...WORK_AGENT_TOOL_IDS];
|
|
22
|
+
export function normalizeWorkAgentTools(value) {
|
|
23
|
+
const source = Array.isArray(value)
|
|
24
|
+
? value
|
|
25
|
+
: typeof value === "string"
|
|
26
|
+
? json(value, DEFAULT_WORK_AGENT_TOOLS)
|
|
27
|
+
: DEFAULT_WORK_AGENT_TOOLS;
|
|
28
|
+
const enabled = new Set();
|
|
29
|
+
for (const item of source) {
|
|
30
|
+
if (typeof item !== "string")
|
|
31
|
+
continue;
|
|
32
|
+
const toolId = item === "query_story_knowledge" ? "search_story_entities" : item;
|
|
33
|
+
if (WORK_AGENT_TOOL_IDS.includes(toolId))
|
|
34
|
+
enabled.add(toolId);
|
|
35
|
+
}
|
|
36
|
+
return WORK_AGENT_TOOL_IDS.filter((toolId) => enabled.has(toolId));
|
|
37
|
+
}
|
|
10
38
|
const defaultPlatformPageSizes = {
|
|
11
39
|
drafts: 30,
|
|
12
40
|
settings: 30,
|
|
@@ -302,6 +330,8 @@ export class Store {
|
|
|
302
330
|
if (type === "draft")
|
|
303
331
|
return {
|
|
304
332
|
draftType: entity.draftType,
|
|
333
|
+
volumeId: entity.volumeId,
|
|
334
|
+
settingModule: entity.settingModule,
|
|
305
335
|
title: entity.title,
|
|
306
336
|
content: entity.content
|
|
307
337
|
};
|
|
@@ -681,6 +711,9 @@ export class Store {
|
|
|
681
711
|
return {
|
|
682
712
|
workId,
|
|
683
713
|
systemPrompt: String(row?.system_prompt ?? ""),
|
|
714
|
+
dailyTokenQuota: row?.daily_token_quota === null || row?.daily_token_quota === undefined
|
|
715
|
+
? null
|
|
716
|
+
: Math.max(10_000, Number(row.daily_token_quota)),
|
|
684
717
|
autoRunEnabled: Number(row?.auto_run_enabled ?? 0) === 1,
|
|
685
718
|
autoRunConcurrency: Math.min(8, Math.max(1, Number(row?.auto_run_concurrency ?? 2) || 2)),
|
|
686
719
|
autoRunBatchLimit: Math.min(200, Math.max(1, Number(row?.auto_run_batch_limit ?? 20) || 20)),
|
|
@@ -692,9 +725,9 @@ export class Store {
|
|
|
692
725
|
autoRunConsecutiveFailures: Math.max(0, Number(row?.auto_run_consecutive_failures ?? 0) || 0),
|
|
693
726
|
bookSummaryContextPercent: Math.min(90, Math.max(1, Number(row?.book_summary_context_percent ?? 50) || 50)),
|
|
694
727
|
contextCompactThreshold: Math.min(90, Math.max(50, Number(row?.context_compact_threshold ?? 85) || 85)),
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
728
|
+
agentToolCallLimit: Math.min(48, Math.max(5, Number(row?.agent_tool_call_limit ?? 12) || 12)),
|
|
729
|
+
agentToolCallGlobalMultiplier: Math.min(6, Math.max(1, Number(row?.agent_tool_call_global_multiplier ?? 3) || 3)),
|
|
730
|
+
agentTools: normalizeWorkAgentTools(row?.agent_tools_json),
|
|
698
731
|
titleGenerationModelId: row?.title_generation_model_id === null || row?.title_generation_model_id === undefined
|
|
699
732
|
? null
|
|
700
733
|
: String(row.title_generation_model_id),
|
|
@@ -706,6 +739,9 @@ export class Store {
|
|
|
706
739
|
const current = this.getWorkAiSettings(workId);
|
|
707
740
|
const timestamp = now();
|
|
708
741
|
const nextPrompt = input.systemPrompt ?? String(current.systemPrompt);
|
|
742
|
+
const nextDailyTokenQuota = input.dailyTokenQuota === undefined
|
|
743
|
+
? (current.dailyTokenQuota === null ? null : Number(current.dailyTokenQuota))
|
|
744
|
+
: input.dailyTokenQuota;
|
|
709
745
|
const nextEnabled = input.autoRunEnabled ?? Boolean(current.autoRunEnabled);
|
|
710
746
|
const nextConcurrency = input.autoRunConcurrency ?? Number(current.autoRunConcurrency);
|
|
711
747
|
const nextBatchLimit = input.autoRunBatchLimit ?? Number(current.autoRunBatchLimit);
|
|
@@ -713,18 +749,22 @@ export class Store {
|
|
|
713
749
|
const nextFailureThreshold = input.autoRunFailureThreshold ?? Number(current.autoRunFailureThreshold);
|
|
714
750
|
const nextBookSummaryContextPercent = input.bookSummaryContextPercent ?? Number(current.bookSummaryContextPercent);
|
|
715
751
|
const nextContextCompactThreshold = input.contextCompactThreshold ?? Number(current.contextCompactThreshold);
|
|
716
|
-
const
|
|
752
|
+
const nextAgentToolCallLimit = input.agentToolCallLimit ?? Number(current.agentToolCallLimit);
|
|
753
|
+
const nextAgentToolCallGlobalMultiplier = input.agentToolCallGlobalMultiplier ?? Number(current.agentToolCallGlobalMultiplier);
|
|
754
|
+
const nextAgentTools = normalizeWorkAgentTools(input.agentTools ?? current.agentTools);
|
|
717
755
|
const nextTitleGenerationModelId = input.titleGenerationModelId === undefined
|
|
718
756
|
? (current.titleGenerationModelId ? String(current.titleGenerationModelId) : null)
|
|
719
757
|
: input.titleGenerationModelId?.trim() || null;
|
|
720
758
|
this.db.run(`INSERT INTO work_ai_settings (
|
|
721
|
-
work_id, system_prompt, auto_run_enabled, auto_run_concurrency, auto_run_batch_limit,
|
|
759
|
+
work_id, system_prompt, daily_token_quota, auto_run_enabled, auto_run_concurrency, auto_run_batch_limit,
|
|
722
760
|
auto_run_daily_task_limit, auto_run_failure_threshold, auto_run_paused, auto_run_pause_reason,
|
|
723
761
|
auto_run_resume_at, auto_run_consecutive_failures, book_summary_context_percent,
|
|
724
|
-
context_compact_threshold,
|
|
725
|
-
|
|
762
|
+
context_compact_threshold, agent_tool_call_limit, agent_tool_call_global_multiplier,
|
|
763
|
+
agent_tools_json, title_generation_model_id, updated_at
|
|
764
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
726
765
|
ON CONFLICT(work_id) DO UPDATE SET
|
|
727
766
|
system_prompt = excluded.system_prompt,
|
|
767
|
+
daily_token_quota = excluded.daily_token_quota,
|
|
728
768
|
auto_run_enabled = excluded.auto_run_enabled,
|
|
729
769
|
auto_run_concurrency = excluded.auto_run_concurrency,
|
|
730
770
|
auto_run_batch_limit = excluded.auto_run_batch_limit,
|
|
@@ -736,11 +776,14 @@ export class Store {
|
|
|
736
776
|
auto_run_consecutive_failures = excluded.auto_run_consecutive_failures,
|
|
737
777
|
book_summary_context_percent = excluded.book_summary_context_percent,
|
|
738
778
|
context_compact_threshold = excluded.context_compact_threshold,
|
|
779
|
+
agent_tool_call_limit = excluded.agent_tool_call_limit,
|
|
780
|
+
agent_tool_call_global_multiplier = excluded.agent_tool_call_global_multiplier,
|
|
739
781
|
agent_tools_json = excluded.agent_tools_json,
|
|
740
782
|
title_generation_model_id = excluded.title_generation_model_id,
|
|
741
|
-
updated_at = excluded.updated_at`, workId, nextPrompt, nextEnabled ? 1 : 0, Math.min(8, Math.max(1, nextConcurrency)), Math.min(200, Math.max(1, nextBatchLimit)), Math.min(10_000, Math.max(0, nextDailyTaskLimit)), Math.min(10, Math.max(1, nextFailureThreshold)), current.autoRunPaused ? 1 : 0, String(current.autoRunPauseReason ?? ""), current.autoRunResumeAt === null ? null : String(current.autoRunResumeAt), Math.max(0, Number(current.autoRunConsecutiveFailures) || 0), Math.min(90, Math.max(1, nextBookSummaryContextPercent)), Math.min(90, Math.max(50, nextContextCompactThreshold)), JSON.stringify(nextAgentTools), nextTitleGenerationModelId, timestamp);
|
|
783
|
+
updated_at = excluded.updated_at`, workId, nextPrompt, nextDailyTokenQuota, nextEnabled ? 1 : 0, Math.min(8, Math.max(1, nextConcurrency)), Math.min(200, Math.max(1, nextBatchLimit)), Math.min(10_000, Math.max(0, nextDailyTaskLimit)), Math.min(10, Math.max(1, nextFailureThreshold)), current.autoRunPaused ? 1 : 0, String(current.autoRunPauseReason ?? ""), current.autoRunResumeAt === null ? null : String(current.autoRunResumeAt), Math.max(0, Number(current.autoRunConsecutiveFailures) || 0), Math.min(90, Math.max(1, nextBookSummaryContextPercent)), Math.min(90, Math.max(50, nextContextCompactThreshold)), Math.min(48, Math.max(5, nextAgentToolCallLimit)), Math.min(6, Math.max(1, nextAgentToolCallGlobalMultiplier)), JSON.stringify(nextAgentTools), nextTitleGenerationModelId, timestamp);
|
|
742
784
|
this.audit(workId, "work.ai-settings.updated", "work-ai-settings", workId, {
|
|
743
785
|
systemPromptChanged: input.systemPrompt !== undefined,
|
|
786
|
+
dailyTokenQuota: nextDailyTokenQuota,
|
|
744
787
|
autoRunEnabled: nextEnabled,
|
|
745
788
|
autoRunConcurrency: Math.min(8, Math.max(1, nextConcurrency)),
|
|
746
789
|
autoRunBatchLimit: Math.min(200, Math.max(1, nextBatchLimit)),
|
|
@@ -748,6 +791,8 @@ export class Store {
|
|
|
748
791
|
autoRunFailureThreshold: Math.min(10, Math.max(1, nextFailureThreshold)),
|
|
749
792
|
bookSummaryContextPercent: Math.min(90, Math.max(1, nextBookSummaryContextPercent)),
|
|
750
793
|
contextCompactThreshold: Math.min(90, Math.max(50, nextContextCompactThreshold)),
|
|
794
|
+
agentToolCallLimit: Math.min(48, Math.max(5, nextAgentToolCallLimit)),
|
|
795
|
+
agentToolCallGlobalMultiplier: Math.min(6, Math.max(1, nextAgentToolCallGlobalMultiplier)),
|
|
751
796
|
agentTools: nextAgentTools,
|
|
752
797
|
titleGenerationModelId: nextTitleGenerationModelId
|
|
753
798
|
});
|
|
@@ -817,13 +862,23 @@ export class Store {
|
|
|
817
862
|
const storageKeys = this.db.all("SELECT DISTINCT storage_key FROM attachments WHERE work_id = ?", workId)
|
|
818
863
|
.map((row) => requiredString(row, "storage_key"));
|
|
819
864
|
this.db.transaction(() => {
|
|
865
|
+
this.db.raw.exec("PRAGMA defer_foreign_keys = ON");
|
|
820
866
|
const current = this.getWork(workId);
|
|
821
867
|
this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
|
|
822
868
|
this.recordEntityVersion("work", workId, "delete", null, "删除作品");
|
|
823
869
|
this.audit(null, "work.deleted", "work", workId, { title: work.title });
|
|
870
|
+
this.db.run("DELETE FROM characters WHERE work_id = ?", workId);
|
|
871
|
+
this.db.run("DELETE FROM organizations WHERE work_id = ?", workId);
|
|
872
|
+
this.db.run("UPDATE races SET parent_race_id = NULL WHERE work_id = ? AND parent_race_id IS NOT NULL", workId);
|
|
873
|
+
this.db.run("DELETE FROM races WHERE work_id = ?", workId);
|
|
824
874
|
this.db.run("DELETE FROM works WHERE id = ?", workId);
|
|
875
|
+
this.db.run("DELETE FROM relationship_source_index_queue WHERE work_id = ?", workId);
|
|
876
|
+
for (const storageKey of storageKeys) {
|
|
877
|
+
if (!this.attachmentStorageKeyInUse(storageKey))
|
|
878
|
+
this.enqueueAttachmentCleanup(storageKey);
|
|
879
|
+
}
|
|
825
880
|
});
|
|
826
|
-
return storageKeys.filter((storageKey) =>
|
|
881
|
+
return storageKeys.filter((storageKey) => !this.attachmentStorageKeyInUse(storageKey));
|
|
827
882
|
}
|
|
828
883
|
setWorkCover(workId, mimeType, content, expectedVersionNo) {
|
|
829
884
|
const sha256 = createHash("sha256").update(content).digest("hex");
|
|
@@ -842,12 +897,22 @@ export class Store {
|
|
|
842
897
|
return this.getWork(workId);
|
|
843
898
|
}
|
|
844
899
|
getWorkCover(workId) {
|
|
900
|
+
const cover = this.findWorkCover(workId);
|
|
901
|
+
if (!cover)
|
|
902
|
+
throw notFound("作品封面");
|
|
903
|
+
return cover;
|
|
904
|
+
}
|
|
905
|
+
findWorkCover(workId) {
|
|
845
906
|
this.getWork(workId);
|
|
846
907
|
const row = this.db.get("SELECT * FROM work_covers WHERE work_id = ?", workId);
|
|
847
908
|
if (!row)
|
|
848
|
-
|
|
909
|
+
return null;
|
|
910
|
+
const mimeType = requiredString(row, "mime_type");
|
|
911
|
+
if (mimeType !== "image/jpeg" && mimeType !== "image/png" && mimeType !== "image/webp") {
|
|
912
|
+
throw new AppError(500, "INVALID_COVER_MIME", "作品封面类型无效");
|
|
913
|
+
}
|
|
849
914
|
return {
|
|
850
|
-
mimeType
|
|
915
|
+
mimeType,
|
|
851
916
|
content: Buffer.from(row.content),
|
|
852
917
|
byteLength: numberValue(row, "byte_length"),
|
|
853
918
|
sha256: requiredString(row, "sha256"),
|
|
@@ -1026,6 +1091,7 @@ export class Store {
|
|
|
1026
1091
|
for (const row of this.db.all("SELECT id FROM volumes WHERE work_id = ?", workId)) {
|
|
1027
1092
|
this.recordEntityVersion("volume", requiredString(row, "id"), "delete", fileVersionId, "替换作品树前保存分卷历史");
|
|
1028
1093
|
}
|
|
1094
|
+
this.clearDraftVolumeBindings(workId, null, fileVersionId, "恢复文件版本时原分卷已被替换");
|
|
1029
1095
|
this.db.run("DELETE FROM volumes WHERE work_id = ?", workId);
|
|
1030
1096
|
for (const volume of volumes) {
|
|
1031
1097
|
const volumeId = id("volume");
|
|
@@ -1077,6 +1143,7 @@ export class Store {
|
|
|
1077
1143
|
for (const row of this.db.all("SELECT id FROM volumes WHERE work_id = ?", workId)) {
|
|
1078
1144
|
this.recordEntityVersion("volume", requiredString(row, "id"), "delete", fileVersionId, "导入前保存分卷历史");
|
|
1079
1145
|
}
|
|
1146
|
+
this.clearDraftVolumeBindings(workId, null, fileVersionId, "覆盖导入时原分卷已被替换");
|
|
1080
1147
|
this.db.run("DELETE FROM volumes WHERE work_id = ?", workId);
|
|
1081
1148
|
}
|
|
1082
1149
|
else {
|
|
@@ -1163,19 +1230,22 @@ export class Store {
|
|
|
1163
1230
|
throw new AppError(409, "VOLUME_HAS_DELETED_CHAPTERS", "分卷回收站中仍有章节,请先彻底删除或恢复并移动这些章节");
|
|
1164
1231
|
}
|
|
1165
1232
|
this.recordEntityVersion("volume", volumeId, "delete", null, "删除分卷");
|
|
1233
|
+
this.clearDraftVolumeBindings(String(current.workId), [volumeId], null, "绑定的分卷已删除");
|
|
1166
1234
|
this.db.run("DELETE FROM volumes WHERE id = ?", volumeId);
|
|
1167
1235
|
this.audit(String(current.workId), "volume.deleted", "volume", volumeId, { versionNo: Number(current.versionNo) });
|
|
1168
1236
|
});
|
|
1169
1237
|
}
|
|
1170
1238
|
createChapter(workId, input) {
|
|
1171
|
-
this.
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1239
|
+
return this.db.transaction(() => {
|
|
1240
|
+
this.getWork(workId);
|
|
1241
|
+
const volume = this.getVolume(input.volumeId);
|
|
1242
|
+
if (volume.workId !== workId)
|
|
1243
|
+
throw new AppError(400, "VOLUME_WORK_MISMATCH", "卷不属于当前作品");
|
|
1244
|
+
const last = this.db.get("SELECT COALESCE(MAX(sort_order), -1) AS value FROM chapters WHERE volume_id = ? AND deleted_at IS NULL", input.volumeId);
|
|
1245
|
+
const chapterId = this.insertChapter(workId, input.volumeId, input.title, input.content ?? "", numberValue(last ?? {}, "value") + 1, "manual", null, input.chapterType ?? "正文");
|
|
1246
|
+
this.audit(workId, "chapter.created", "chapter", chapterId);
|
|
1247
|
+
return this.getChapter(chapterId);
|
|
1248
|
+
});
|
|
1179
1249
|
}
|
|
1180
1250
|
getChapter(chapterId) {
|
|
1181
1251
|
const row = this.db.get("SELECT * FROM chapters WHERE id = ? AND deleted_at IS NULL", chapterId);
|
|
@@ -2276,9 +2346,10 @@ export class Store {
|
|
|
2276
2346
|
}
|
|
2277
2347
|
insertDraftWithId(workId, draftId, input, source = "create", sourceRef = null, changeNote = "") {
|
|
2278
2348
|
const timestamp = now();
|
|
2349
|
+
const binding = this.normalizeDraftBinding(workId, input.draftType, input.volumeId ?? null, input.settingModule ?? null);
|
|
2279
2350
|
this.db.transaction(() => {
|
|
2280
|
-
this.db.run(`INSERT INTO drafts (id, work_id, draft_type, title, content, created_at, updated_at)
|
|
2281
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`, draftId, workId, input.draftType, input.title, input.content, timestamp, timestamp);
|
|
2351
|
+
this.db.run(`INSERT INTO drafts (id, work_id, draft_type, volume_id, setting_module, title, content, created_at, updated_at)
|
|
2352
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, draftId, workId, input.draftType, binding.volumeId, binding.settingModule, input.title, input.content, timestamp, timestamp);
|
|
2282
2353
|
this.syncMarkdownAttachmentReferences(workId, "draft", draftId, input.content);
|
|
2283
2354
|
this.recordEntityVersion("draft", draftId, source, sourceRef, changeNote || "建立创作想法", timestamp);
|
|
2284
2355
|
this.audit(workId, source === "restore" ? "draft.restored" : "draft.created", "draft", draftId, {
|
|
@@ -2291,14 +2362,18 @@ export class Store {
|
|
|
2291
2362
|
}
|
|
2292
2363
|
listDrafts(workId, draftType, includeContent = false) {
|
|
2293
2364
|
this.getWork(workId);
|
|
2294
|
-
return this.db.all(`SELECT
|
|
2295
|
-
|
|
2365
|
+
return this.db.all(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
|
|
2366
|
+
LEFT JOIN volumes volume ON volume.id = draft.volume_id
|
|
2367
|
+
WHERE draft.work_id = ? AND (? IS NULL OR draft.draft_type = ?)
|
|
2368
|
+
ORDER BY draft.updated_at DESC, draft.title`, workId, draftType ?? null, draftType ?? null).map((row) => this.mapDraft(row, includeContent));
|
|
2296
2369
|
}
|
|
2297
2370
|
listDraftsPage(workId, pagination, draftType, includeContent = false) {
|
|
2298
2371
|
this.getWork(workId);
|
|
2299
2372
|
const page = paginationSql(pagination);
|
|
2300
|
-
const rows = this.db.all(`SELECT
|
|
2301
|
-
|
|
2373
|
+
const rows = this.db.all(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
|
|
2374
|
+
LEFT JOIN volumes volume ON volume.id = draft.volume_id
|
|
2375
|
+
WHERE draft.work_id = ? AND (? IS NULL OR draft.draft_type = ?)
|
|
2376
|
+
ORDER BY draft.updated_at DESC, draft.title${page.sql}`, workId, draftType ?? null, draftType ?? null, ...page.params);
|
|
2302
2377
|
return paginated(rows.map((row) => this.mapDraft(row, includeContent)), pagination);
|
|
2303
2378
|
}
|
|
2304
2379
|
searchDrafts(workId, query, draftType, limit = 20) {
|
|
@@ -2308,17 +2383,21 @@ export class Store {
|
|
|
2308
2383
|
const escapedQuery = normalizedQuery.replace(/[\\%_]/gu, "\\$&");
|
|
2309
2384
|
const pattern = `%${escapedQuery}%`;
|
|
2310
2385
|
const rows = normalizedQuery
|
|
2311
|
-
? this.db.all(`SELECT
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2386
|
+
? this.db.all(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
|
|
2387
|
+
LEFT JOIN volumes volume ON volume.id = draft.volume_id
|
|
2388
|
+
WHERE draft.work_id = ? AND (? IS NULL OR draft.draft_type = ?)
|
|
2389
|
+
AND (draft.title LIKE ? ESCAPE '\\' COLLATE NOCASE OR draft.content LIKE ? ESCAPE '\\' COLLATE NOCASE)
|
|
2390
|
+
ORDER BY CASE WHEN draft.title LIKE ? ESCAPE '\\' COLLATE NOCASE THEN 0 ELSE 1 END, draft.updated_at DESC
|
|
2315
2391
|
LIMIT ?`, workId, draftType ?? null, draftType ?? null, pattern, pattern, pattern, safeLimit)
|
|
2316
|
-
: this.db.all(`SELECT
|
|
2317
|
-
|
|
2392
|
+
: this.db.all(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
|
|
2393
|
+
LEFT JOIN volumes volume ON volume.id = draft.volume_id
|
|
2394
|
+
WHERE draft.work_id = ? AND (? IS NULL OR draft.draft_type = ?)
|
|
2395
|
+
ORDER BY draft.updated_at DESC, draft.title LIMIT ?`, workId, draftType ?? null, draftType ?? null, safeLimit);
|
|
2318
2396
|
return rows.map((row) => this.mapDraft(row, true));
|
|
2319
2397
|
}
|
|
2320
2398
|
getDraft(draftId) {
|
|
2321
|
-
const row = this.db.get(
|
|
2399
|
+
const row = this.db.get(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
|
|
2400
|
+
LEFT JOIN volumes volume ON volume.id = draft.volume_id WHERE draft.id = ?`, draftId);
|
|
2322
2401
|
if (!row)
|
|
2323
2402
|
throw notFound("想法");
|
|
2324
2403
|
return this.mapDraft(row, true);
|
|
@@ -2326,9 +2405,15 @@ export class Store {
|
|
|
2326
2405
|
updateDraft(draftId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
|
|
2327
2406
|
const current = this.getDraft(draftId);
|
|
2328
2407
|
const content = input.content ?? String(current.content);
|
|
2408
|
+
const draftType = input.draftType ?? current.draftType;
|
|
2409
|
+
const typeChanged = input.draftType !== undefined && input.draftType !== current.draftType;
|
|
2410
|
+
const restoreMissingBinding = source === "restore";
|
|
2411
|
+
const volumeId = Object.hasOwn(input, "volumeId") ? input.volumeId ?? null : typeChanged || restoreMissingBinding ? null : current.volumeId;
|
|
2412
|
+
const settingModule = Object.hasOwn(input, "settingModule") ? input.settingModule ?? null : typeChanged || restoreMissingBinding ? null : current.settingModule;
|
|
2413
|
+
const binding = this.normalizeDraftBinding(String(current.workId), draftType, volumeId, settingModule);
|
|
2329
2414
|
this.db.transaction(() => {
|
|
2330
2415
|
this.assertExpectedVersion("draft", draftId, expectedVersionNo, "想法");
|
|
2331
|
-
this.db.run("UPDATE drafts SET draft_type = ?, title = ?, content = ?, updated_at = ? WHERE id = ?",
|
|
2416
|
+
this.db.run("UPDATE drafts SET draft_type = ?, volume_id = ?, setting_module = ?, title = ?, content = ?, updated_at = ? WHERE id = ?", draftType, binding.volumeId, binding.settingModule, input.title ?? String(current.title), content, now(), draftId);
|
|
2332
2417
|
this.syncMarkdownAttachmentReferences(String(current.workId), "draft", draftId, content);
|
|
2333
2418
|
this.recordEntityVersion("draft", draftId, source, sourceRef, changeNote || "更新创作想法");
|
|
2334
2419
|
this.audit(String(current.workId), "draft.updated", "draft", draftId, { fields: Object.keys(input), source, sourceRef });
|
|
@@ -2351,6 +2436,9 @@ export class Store {
|
|
|
2351
2436
|
id: requiredString(row, "id"),
|
|
2352
2437
|
workId: requiredString(row, "work_id"),
|
|
2353
2438
|
draftType: requiredString(row, "draft_type"),
|
|
2439
|
+
volumeId: optionalString(row, "volume_id"),
|
|
2440
|
+
volumeTitle: optionalString(row, "volume_title"),
|
|
2441
|
+
settingModule: optionalString(row, "setting_module"),
|
|
2354
2442
|
title: requiredString(row, "title"),
|
|
2355
2443
|
...(includeContent ? { content } : { contentPreview: content.replace(/\s+/gu, " ").trim().slice(0, 320) }),
|
|
2356
2444
|
versionNo: this.currentEntityVersionNo("draft", requiredString(row, "id")),
|
|
@@ -2358,6 +2446,41 @@ export class Store {
|
|
|
2358
2446
|
updatedAt: requiredString(row, "updated_at")
|
|
2359
2447
|
};
|
|
2360
2448
|
}
|
|
2449
|
+
normalizeDraftBinding(workId, draftType, volumeId, settingModule) {
|
|
2450
|
+
if (draftType === "prose") {
|
|
2451
|
+
if (settingModule !== null) {
|
|
2452
|
+
throw new AppError(400, "DRAFT_BINDING_TYPE_MISMATCH", "正文想法不能绑定设定模块");
|
|
2453
|
+
}
|
|
2454
|
+
if (volumeId !== null) {
|
|
2455
|
+
const volume = this.getVolume(volumeId);
|
|
2456
|
+
if (volume.workId !== workId) {
|
|
2457
|
+
throw new AppError(400, "DRAFT_VOLUME_WORK_MISMATCH", "分卷不属于当前作品");
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
return { volumeId, settingModule: null };
|
|
2461
|
+
}
|
|
2462
|
+
if (volumeId !== null) {
|
|
2463
|
+
throw new AppError(400, "DRAFT_BINDING_TYPE_MISMATCH", "设定想法不能绑定分卷");
|
|
2464
|
+
}
|
|
2465
|
+
if (settingModule !== null && !DRAFT_SETTING_MODULES.includes(settingModule)) {
|
|
2466
|
+
throw new AppError(400, "DRAFT_SETTING_MODULE_INVALID", "设定想法绑定模块无效");
|
|
2467
|
+
}
|
|
2468
|
+
return { volumeId: null, settingModule };
|
|
2469
|
+
}
|
|
2470
|
+
clearDraftVolumeBindings(workId, volumeIds, sourceRef, changeNote) {
|
|
2471
|
+
const drafts = volumeIds === null
|
|
2472
|
+
? this.db.all("SELECT id FROM drafts WHERE work_id = ? AND volume_id IS NOT NULL", workId)
|
|
2473
|
+
: volumeIds.length
|
|
2474
|
+
? this.db.all(`SELECT id FROM drafts WHERE work_id = ? AND volume_id IN (${volumeIds.map(() => "?").join(", ")})`, workId, ...volumeIds)
|
|
2475
|
+
: [];
|
|
2476
|
+
const timestamp = now();
|
|
2477
|
+
for (const draft of drafts) {
|
|
2478
|
+
const draftId = requiredString(draft, "id");
|
|
2479
|
+
this.db.run("UPDATE drafts SET volume_id = NULL, updated_at = ? WHERE id = ?", timestamp, draftId);
|
|
2480
|
+
this.recordEntityVersion("draft", draftId, "manual", sourceRef, changeNote, timestamp);
|
|
2481
|
+
this.audit(workId, "draft.updated", "draft", draftId, { fields: ["volumeId"], source: "volume-deleted", sourceRef });
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2361
2484
|
createSetting(workId, input, source = "create", sourceRef = null) {
|
|
2362
2485
|
this.getWork(workId);
|
|
2363
2486
|
return this.insertSettingWithId(workId, id("setting"), input, source, sourceRef);
|
|
@@ -3224,11 +3347,16 @@ export class Store {
|
|
|
3224
3347
|
createdAt: requiredString(row, "created_at")
|
|
3225
3348
|
};
|
|
3226
3349
|
}
|
|
3227
|
-
createAttachment(workId, input) {
|
|
3350
|
+
createAttachment(workId, input, accessModule = "settings") {
|
|
3228
3351
|
this.getWork(workId);
|
|
3229
3352
|
const existing = this.db.get("SELECT * FROM attachments WHERE work_id = ? AND stored_sha256 = ?", workId, input.storedSha256);
|
|
3230
|
-
if (existing)
|
|
3353
|
+
if (existing) {
|
|
3354
|
+
this.db.transaction(() => {
|
|
3355
|
+
this.db.run("INSERT OR IGNORE INTO attachment_access_modules (attachment_id, module, created_at) VALUES (?, ?, ?)", requiredString(existing, "id"), accessModule, now());
|
|
3356
|
+
this.db.run("DELETE FROM attachment_cleanup_queue WHERE storage_key = ?", requiredString(existing, "storage_key"));
|
|
3357
|
+
});
|
|
3231
3358
|
return { attachment: this.mapAttachment(existing), created: false };
|
|
3359
|
+
}
|
|
3232
3360
|
const attachmentId = id("attachment");
|
|
3233
3361
|
const timestamp = now();
|
|
3234
3362
|
this.db.transaction(() => {
|
|
@@ -3236,6 +3364,8 @@ export class Store {
|
|
|
3236
3364
|
(id, work_id, original_name, original_mime_type, stored_mime_type, original_byte_length, stored_byte_length,
|
|
3237
3365
|
original_sha256, stored_sha256, storage_key, width, height, page_count, animated, created_at, created_by_user_id)
|
|
3238
3366
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, attachmentId, workId, input.originalName, input.originalMimeType, input.storedMimeType, input.originalByteLength, input.storedByteLength, input.originalSha256, input.storedSha256, input.storageKey, input.width, input.height, input.pageCount, input.animated ? 1 : 0, timestamp, currentRequestActor()?.userId ?? null);
|
|
3367
|
+
this.db.run("INSERT INTO attachment_access_modules (attachment_id, module, created_at) VALUES (?, ?, ?)", attachmentId, accessModule, timestamp);
|
|
3368
|
+
this.db.run("DELETE FROM attachment_cleanup_queue WHERE storage_key = ?", input.storageKey);
|
|
3239
3369
|
this.audit(workId, "attachment.created", "attachment", attachmentId, {
|
|
3240
3370
|
originalMimeType: input.originalMimeType,
|
|
3241
3371
|
storedMimeType: input.storedMimeType,
|
|
@@ -3262,18 +3392,100 @@ export class Store {
|
|
|
3262
3392
|
throw notFound("附件");
|
|
3263
3393
|
return this.mapAttachment(row);
|
|
3264
3394
|
}
|
|
3395
|
+
attachmentModules(attachmentId) {
|
|
3396
|
+
this.getAttachment(attachmentId);
|
|
3397
|
+
const modules = new Set();
|
|
3398
|
+
for (const row of this.db.all("SELECT module FROM attachment_access_modules WHERE attachment_id = ?", attachmentId)) {
|
|
3399
|
+
const module = String(row.module);
|
|
3400
|
+
if (attachmentPermissionModules.includes(module))
|
|
3401
|
+
modules.add(module);
|
|
3402
|
+
}
|
|
3403
|
+
const referenceModules = {
|
|
3404
|
+
chapter: "prose",
|
|
3405
|
+
draft: "drafts",
|
|
3406
|
+
setting: "settings",
|
|
3407
|
+
"character-section": "characters",
|
|
3408
|
+
race: "races",
|
|
3409
|
+
organization: "organizations"
|
|
3410
|
+
};
|
|
3411
|
+
for (const row of this.db.all("SELECT DISTINCT entity_type FROM attachment_references WHERE attachment_id = ?", attachmentId)) {
|
|
3412
|
+
const module = referenceModules[String(row.entity_type)];
|
|
3413
|
+
if (module)
|
|
3414
|
+
modules.add(module);
|
|
3415
|
+
}
|
|
3416
|
+
return [...modules];
|
|
3417
|
+
}
|
|
3418
|
+
attachmentStorageKeyInUse(storageKey) {
|
|
3419
|
+
return Number(this.db.get("SELECT COUNT(*) AS count FROM attachments WHERE storage_key = ?", storageKey)?.count ?? 0) > 0;
|
|
3420
|
+
}
|
|
3421
|
+
enqueueAttachmentCleanup(storageKey) {
|
|
3422
|
+
const timestamp = now();
|
|
3423
|
+
this.db.run(`INSERT INTO attachment_cleanup_queue (storage_key, attempts, last_error, created_at, updated_at)
|
|
3424
|
+
VALUES (?, 0, NULL, ?, ?) ON CONFLICT(storage_key) DO UPDATE SET updated_at = excluded.updated_at`, storageKey, timestamp, timestamp);
|
|
3425
|
+
}
|
|
3426
|
+
listAttachmentCleanupQueue(limit = 100) {
|
|
3427
|
+
return this.db.all("SELECT storage_key, attempts FROM attachment_cleanup_queue ORDER BY updated_at, storage_key LIMIT ?", Math.max(1, Math.min(1_000, Math.trunc(limit)))).map((row) => ({ storageKey: requiredString(row, "storage_key"), attempts: numberValue(row, "attempts") }));
|
|
3428
|
+
}
|
|
3429
|
+
attachmentCleanupStillRequired(storageKey) {
|
|
3430
|
+
return !this.attachmentStorageKeyInUse(storageKey);
|
|
3431
|
+
}
|
|
3432
|
+
completeAttachmentCleanup(storageKey) {
|
|
3433
|
+
this.db.run("DELETE FROM attachment_cleanup_queue WHERE storage_key = ?", storageKey);
|
|
3434
|
+
}
|
|
3435
|
+
failAttachmentCleanup(storageKey, message) {
|
|
3436
|
+
this.db.run("UPDATE attachment_cleanup_queue SET attempts = attempts + 1, last_error = ?, updated_at = ? WHERE storage_key = ?", message.slice(0, 500), now(), storageKey);
|
|
3437
|
+
}
|
|
3438
|
+
attachmentHistoricalReferenceCount(attachmentId) {
|
|
3439
|
+
const needle = `attachment://${attachmentId}`;
|
|
3440
|
+
const sources = [
|
|
3441
|
+
["entity_versions", "snapshot_json"],
|
|
3442
|
+
["character_profile_section_versions", "snapshot_json"],
|
|
3443
|
+
["character_versions", "snapshot_json"],
|
|
3444
|
+
["chapter_versions", "content"],
|
|
3445
|
+
["file_versions", "snapshot_json"]
|
|
3446
|
+
];
|
|
3447
|
+
return sources.reduce((count, [table, column]) => count + Number(this.db.get(`SELECT COUNT(*) AS count FROM ${table} WHERE instr(${column}, ?) > 0`, needle)?.count ?? 0), 0);
|
|
3448
|
+
}
|
|
3449
|
+
queueUnreferencedAttachments(retentionMs = 24 * 60 * 60_000, limit = 100) {
|
|
3450
|
+
const cutoff = new Date(Date.now() - Math.max(0, retentionMs)).toISOString();
|
|
3451
|
+
const candidates = this.db.all(`SELECT attachment.* FROM attachments attachment
|
|
3452
|
+
WHERE attachment.created_at <= ?
|
|
3453
|
+
AND NOT EXISTS (
|
|
3454
|
+
SELECT 1 FROM attachment_references reference WHERE reference.attachment_id = attachment.id
|
|
3455
|
+
)
|
|
3456
|
+
ORDER BY attachment.created_at, attachment.id LIMIT ?`, cutoff, Math.max(1, Math.min(1_000, Math.trunc(limit))));
|
|
3457
|
+
let queued = 0;
|
|
3458
|
+
for (const candidate of candidates) {
|
|
3459
|
+
const attachmentId = requiredString(candidate, "id");
|
|
3460
|
+
if (this.attachmentHistoricalReferenceCount(attachmentId) > 0)
|
|
3461
|
+
continue;
|
|
3462
|
+
const storageKey = requiredString(candidate, "storage_key");
|
|
3463
|
+
this.db.transaction(() => {
|
|
3464
|
+
this.db.run("DELETE FROM attachments WHERE id = ?", attachmentId);
|
|
3465
|
+
this.audit(requiredString(candidate, "work_id"), "attachment.garbage-collected", "attachment", attachmentId, { storageKey });
|
|
3466
|
+
if (!this.attachmentStorageKeyInUse(storageKey))
|
|
3467
|
+
this.enqueueAttachmentCleanup(storageKey);
|
|
3468
|
+
});
|
|
3469
|
+
queued += 1;
|
|
3470
|
+
}
|
|
3471
|
+
return queued;
|
|
3472
|
+
}
|
|
3265
3473
|
deleteAttachment(attachmentId) {
|
|
3266
3474
|
const attachment = this.getAttachment(attachmentId);
|
|
3267
3475
|
const references = Number(this.db.get("SELECT COUNT(*) AS count FROM attachment_references WHERE attachment_id = ?", attachmentId)?.count ?? 0);
|
|
3268
3476
|
if (references > 0)
|
|
3269
3477
|
throw new AppError(409, "ATTACHMENT_IN_USE", "附件仍被资料引用,无法删除");
|
|
3478
|
+
if (this.attachmentHistoricalReferenceCount(attachmentId) > 0) {
|
|
3479
|
+
throw new AppError(409, "ATTACHMENT_IN_VERSION_HISTORY", "附件仍被历史版本引用,无法删除");
|
|
3480
|
+
}
|
|
3270
3481
|
const storageKey = String(attachment.storageKey);
|
|
3271
3482
|
this.db.transaction(() => {
|
|
3272
3483
|
this.db.run("DELETE FROM attachments WHERE id = ?", attachmentId);
|
|
3273
3484
|
this.audit(String(attachment.workId), "attachment.deleted", "attachment", attachmentId, { storageKey });
|
|
3485
|
+
if (!this.attachmentStorageKeyInUse(storageKey))
|
|
3486
|
+
this.enqueueAttachmentCleanup(storageKey);
|
|
3274
3487
|
});
|
|
3275
|
-
|
|
3276
|
-
return { storageKey, removeStoredFile: remaining === 0 };
|
|
3488
|
+
return { storageKey, cleanupQueued: !this.attachmentStorageKeyInUse(storageKey) };
|
|
3277
3489
|
}
|
|
3278
3490
|
getCharacter(characterId) {
|
|
3279
3491
|
const row = this.db.get("SELECT * FROM characters WHERE id = ?", characterId);
|
|
@@ -4128,7 +4340,8 @@ export class Store {
|
|
|
4128
4340
|
this.getWork(workId);
|
|
4129
4341
|
const conversationId = id("conversation");
|
|
4130
4342
|
const timestamp = now();
|
|
4131
|
-
|
|
4343
|
+
const agentTools = normalizeWorkAgentTools(this.getWorkAiSettings(workId).agentTools);
|
|
4344
|
+
this.db.run("INSERT INTO ai_conversations (id, work_id, title, agent_tools_json, created_at, updated_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?)", conversationId, workId, title.trim() || "新对话", JSON.stringify(agentTools), timestamp, timestamp, currentRequestActor()?.userId ?? null);
|
|
4132
4345
|
return this.getAiConversation(conversationId);
|
|
4133
4346
|
}
|
|
4134
4347
|
listAiConversations(workId) {
|
|
@@ -4286,7 +4499,9 @@ export class Store {
|
|
|
4286
4499
|
const forkCompactedCount = targetIndex + 1 >= sourceCompactedCount ? Math.min(sourceCompactedCount, targetIndex + 1) : 0;
|
|
4287
4500
|
const forkSummary = forkCompactedCount ? requiredString(conversation, "compacted_summary") : "";
|
|
4288
4501
|
this.db.transaction(() => {
|
|
4289
|
-
this.db.run("INSERT INTO ai_conversations (id, work_id, title, compacted_summary, compacted_message_count, created_at, updated_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", forkId, requiredString(conversation, "work_id"), title.slice(0, 200), forkSummary, forkCompactedCount,
|
|
4502
|
+
this.db.run("INSERT INTO ai_conversations (id, work_id, title, compacted_summary, compacted_message_count, agent_tools_json, created_at, updated_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", forkId, requiredString(conversation, "work_id"), title.slice(0, 200), forkSummary, forkCompactedCount, conversation.agent_tools_json == null
|
|
4503
|
+
? JSON.stringify(normalizeWorkAgentTools(this.getWorkAiSettings(requiredString(conversation, "work_id")).agentTools))
|
|
4504
|
+
: String(conversation.agent_tools_json), timestamp, timestamp, currentRequestActor()?.userId ?? null);
|
|
4290
4505
|
for (const message of messages.slice(0, targetIndex + 1)) {
|
|
4291
4506
|
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);
|
|
4292
4507
|
}
|
|
@@ -4303,10 +4518,29 @@ export class Store {
|
|
|
4303
4518
|
compactedMessageCount: numberValue(row, "compacted_message_count"),
|
|
4304
4519
|
hasCompactedSummary: Boolean(requiredString(row, "compacted_summary")),
|
|
4305
4520
|
contextWarningPending: Boolean(optionalString(row, "context_warning_at")),
|
|
4521
|
+
agentTools: row.agent_tools_json == null || row.agent_tools_json === undefined
|
|
4522
|
+
? null
|
|
4523
|
+
: normalizeWorkAgentTools(row.agent_tools_json),
|
|
4306
4524
|
createdAt: requiredString(row, "created_at"),
|
|
4307
4525
|
updatedAt: requiredString(row, "updated_at")
|
|
4308
4526
|
};
|
|
4309
4527
|
}
|
|
4528
|
+
/** 锁定本对话可用工具集;已锁定则保持不变,避免中途改作品设置破坏 prompt cache。 */
|
|
4529
|
+
ensureAiConversationAgentTools(conversationId, workId) {
|
|
4530
|
+
const conversation = this.db.get("SELECT id, work_id, agent_tools_json FROM ai_conversations WHERE id = ?", conversationId);
|
|
4531
|
+
if (!conversation)
|
|
4532
|
+
throw notFound("AI 对话");
|
|
4533
|
+
if (requiredString(conversation, "work_id") !== workId) {
|
|
4534
|
+
throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
|
|
4535
|
+
}
|
|
4536
|
+
if (conversation.agent_tools_json != null && conversation.agent_tools_json !== undefined) {
|
|
4537
|
+
return normalizeWorkAgentTools(conversation.agent_tools_json);
|
|
4538
|
+
}
|
|
4539
|
+
const agentTools = normalizeWorkAgentTools(this.getWorkAiSettings(workId).agentTools);
|
|
4540
|
+
this.db.run("UPDATE ai_conversations SET agent_tools_json = ?, updated_at = ? WHERE id = ? AND agent_tools_json IS NULL", JSON.stringify(agentTools), now(), conversationId);
|
|
4541
|
+
const locked = this.db.get("SELECT agent_tools_json FROM ai_conversations WHERE id = ?", conversationId);
|
|
4542
|
+
return normalizeWorkAgentTools(locked?.agent_tools_json ?? agentTools);
|
|
4543
|
+
}
|
|
4310
4544
|
mapAiConversationMessage(row) {
|
|
4311
4545
|
return {
|
|
4312
4546
|
id: requiredString(row, "id"),
|
|
@@ -5710,6 +5944,22 @@ export class Store {
|
|
|
5710
5944
|
}
|
|
5711
5945
|
return lines.join("\n").trimEnd() + "\n";
|
|
5712
5946
|
}
|
|
5947
|
+
async exportDocx(workId) {
|
|
5948
|
+
const tree = this.getWorkTree(workId);
|
|
5949
|
+
const cover = this.findWorkCover(workId);
|
|
5950
|
+
const volumes = tree.volumes.map((volume) => ({
|
|
5951
|
+
title: String(volume.title),
|
|
5952
|
+
chapters: volume.chapters.map((chapter) => ({
|
|
5953
|
+
title: String(chapter.title),
|
|
5954
|
+
content: String(chapter.content ?? "")
|
|
5955
|
+
}))
|
|
5956
|
+
}));
|
|
5957
|
+
return exportWorkDocx({
|
|
5958
|
+
title: String(tree.title),
|
|
5959
|
+
volumes,
|
|
5960
|
+
cover: cover ? { mimeType: cover.mimeType, content: cover.content } : null
|
|
5961
|
+
});
|
|
5962
|
+
}
|
|
5713
5963
|
listAuditLogs(workId) {
|
|
5714
5964
|
this.getWork(workId);
|
|
5715
5965
|
return this.db.all(`SELECT log.*, user.display_name AS actor_display_name, user.username AS actor_username
|