@musnows/scriverse 0.7.12 → 0.8.0
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 +4 -0
- package/README.md +5 -0
- package/dist/ai-chat-tab-limit.js +14 -0
- package/dist/ai-chat-tab-limit.js.map +1 -0
- package/dist/ai-protocol.js +10 -2
- package/dist/ai-protocol.js.map +1 -1
- package/dist/ai-retry.js +52 -0
- package/dist/ai-retry.js.map +1 -0
- package/dist/ai.js +611 -152
- package/dist/ai.js.map +1 -1
- package/dist/app.js +41 -12
- package/dist/app.js.map +1 -1
- package/dist/cli-contract.js +2 -1
- package/dist/cli-contract.js.map +1 -1
- package/dist/database.js +127 -2
- package/dist/database.js.map +1 -1
- package/dist/domain.js +1 -0
- package/dist/domain.js.map +1 -1
- package/dist/public/ai-chat-tabs.js +75 -0
- package/dist/public/ai-request-manager.js +32 -15
- package/dist/public/app.js +1340 -315
- package/dist/public/background-task-center.d.ts +9 -0
- package/dist/public/background-task-center.js +18 -0
- package/dist/public/chapter-search.d.ts +6 -0
- package/dist/public/chapter-search.js +23 -0
- package/dist/public/character-filters.d.ts +3 -1
- package/dist/public/character-filters.js +17 -2
- package/dist/public/character-version.js +1 -0
- package/dist/public/display-labels.d.ts +1 -0
- package/dist/public/display-labels.js +5 -1
- package/dist/public/index.html +68 -27
- package/dist/public/model-config.d.ts +3 -1
- package/dist/public/model-config.js +13 -0
- package/dist/public/relationship-filters.d.ts +5 -0
- package/dist/public/relationship-filters.js +31 -0
- package/dist/public/relationship-graph.js +289 -58
- package/dist/public/stream-typewriter.d.ts +12 -1
- package/dist/public/stream-typewriter.js +65 -5
- package/dist/public/styles.css +239 -45
- package/dist/server-runtime.js +6 -0
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +264 -65
- package/dist/store.js.map +1 -1
- package/dist/version.js +6 -1
- package/dist/version.js.map +1 -1
- package/package.json +1 -1
package/dist/store.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DRAFT_SETTING_MODULES } from "./domain.js";
|
|
1
|
+
import { CHARACTER_GENDERS, DRAFT_SETTING_MODULES } from "./domain.js";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { ENTITY_VERSION_BASELINE_MIGRATION_VERSION, PLATFORM_AI_WORK_ID } from "./database.js";
|
|
4
4
|
import { exportWorkDocx } from "./docx-export.js";
|
|
@@ -13,6 +13,7 @@ import { countWords, documentShortSearchTerms, escapeSqlLikePattern, id, json, n
|
|
|
13
13
|
import { buildWritingCalendar, writingDateKey } from "./writing-progress-time.js";
|
|
14
14
|
import { resolveMaxAgentToolCallLimit } from "./ai-tool-results.js";
|
|
15
15
|
const WORK_LIST_BATCH_SIZE = 500;
|
|
16
|
+
const ENTITY_LIST_BATCH_SIZE = 400;
|
|
16
17
|
export const RECYCLE_BIN_RETENTION_DAYS = 30;
|
|
17
18
|
function recycleBinExpiresAt(deletedAt) {
|
|
18
19
|
return new Date(new Date(deletedAt).getTime() + RECYCLE_BIN_RETENTION_DAYS * 24 * 60 * 60_000).toISOString();
|
|
@@ -25,9 +26,19 @@ export const WORK_AGENT_TOOL_IDS = [
|
|
|
25
26
|
"search_story_entities",
|
|
26
27
|
"read_character_sections",
|
|
27
28
|
"search_drafts",
|
|
28
|
-
"image"
|
|
29
|
+
"image",
|
|
30
|
+
"calculate_time"
|
|
29
31
|
];
|
|
30
32
|
const DEFAULT_WORK_AGENT_TOOLS = [...WORK_AGENT_TOOL_IDS];
|
|
33
|
+
const LEGACY_DEFAULT_WORK_AGENT_TOOLS = [
|
|
34
|
+
"story_index",
|
|
35
|
+
"read_chapters",
|
|
36
|
+
"grep",
|
|
37
|
+
"search_story_entities",
|
|
38
|
+
"read_character_sections",
|
|
39
|
+
"search_drafts",
|
|
40
|
+
"image"
|
|
41
|
+
];
|
|
31
42
|
export function normalizeWorkAgentTools(value) {
|
|
32
43
|
const source = Array.isArray(value)
|
|
33
44
|
? value
|
|
@@ -42,6 +53,8 @@ export function normalizeWorkAgentTools(value) {
|
|
|
42
53
|
if (WORK_AGENT_TOOL_IDS.includes(toolId))
|
|
43
54
|
enabled.add(toolId);
|
|
44
55
|
}
|
|
56
|
+
if (LEGACY_DEFAULT_WORK_AGENT_TOOLS.every((toolId) => enabled.has(toolId)))
|
|
57
|
+
enabled.add("calculate_time");
|
|
45
58
|
return WORK_AGENT_TOOL_IDS.filter((toolId) => enabled.has(toolId));
|
|
46
59
|
}
|
|
47
60
|
const galaxyFrameRates = [24, 30, 60, 90, 120, 144, 165, 240];
|
|
@@ -203,6 +216,11 @@ export function defaultAiConversationTitle(prompt) {
|
|
|
203
216
|
function isRecord(value) {
|
|
204
217
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
205
218
|
}
|
|
219
|
+
function characterGender(value) {
|
|
220
|
+
return typeof value === "string" && CHARACTER_GENDERS.includes(value)
|
|
221
|
+
? value
|
|
222
|
+
: "unknown";
|
|
223
|
+
}
|
|
206
224
|
function invalidFileSnapshot() {
|
|
207
225
|
throw new AppError(409, "FILE_VERSION_INVALID", "正文历史快照已损坏,未执行恢复");
|
|
208
226
|
}
|
|
@@ -338,6 +356,18 @@ export class Store {
|
|
|
338
356
|
const row = this.db.get("SELECT MAX(version_no) AS version_no FROM entity_versions WHERE entity_type = ? AND entity_id = ?", type, entityId);
|
|
339
357
|
return numberValue(row ?? {}, "version_no");
|
|
340
358
|
}
|
|
359
|
+
currentEntityVersionNos(type, entityIds) {
|
|
360
|
+
const versions = new Map();
|
|
361
|
+
for (let offset = 0; offset < entityIds.length; offset += ENTITY_LIST_BATCH_SIZE) {
|
|
362
|
+
const batchIds = entityIds.slice(offset, offset + ENTITY_LIST_BATCH_SIZE);
|
|
363
|
+
const placeholders = batchIds.map(() => "?").join(", ");
|
|
364
|
+
const rows = this.db.all(`SELECT entity_id, MAX(version_no) AS version_no FROM entity_versions
|
|
365
|
+
WHERE entity_type = ? AND entity_id IN (${placeholders}) GROUP BY entity_id`, type, ...batchIds);
|
|
366
|
+
for (const row of rows)
|
|
367
|
+
versions.set(requiredString(row, "entity_id"), numberValue(row, "version_no"));
|
|
368
|
+
}
|
|
369
|
+
return versions;
|
|
370
|
+
}
|
|
341
371
|
currentChapterVersionNo(chapterId) {
|
|
342
372
|
return numberValue(this.db.get("SELECT MAX(version_no) AS version_no FROM chapter_versions WHERE chapter_id = ?", chapterId) ?? {}, "version_no");
|
|
343
373
|
}
|
|
@@ -856,10 +886,14 @@ export class Store {
|
|
|
856
886
|
return this.getPlatformUiSettings();
|
|
857
887
|
}
|
|
858
888
|
analysisTaskQueuedHandler = null;
|
|
889
|
+
chapterAnalysisInvalidatedHandler = null;
|
|
859
890
|
relationshipIndexQueuedHandler = null;
|
|
860
891
|
setAnalysisTaskQueuedHandler(handler) {
|
|
861
892
|
this.analysisTaskQueuedHandler = handler;
|
|
862
893
|
}
|
|
894
|
+
setChapterAnalysisInvalidatedHandler(handler) {
|
|
895
|
+
this.chapterAnalysisInvalidatedHandler = handler;
|
|
896
|
+
}
|
|
863
897
|
setRelationshipIndexQueuedHandler(handler) {
|
|
864
898
|
this.relationshipIndexQueuedHandler = handler;
|
|
865
899
|
}
|
|
@@ -871,6 +905,14 @@ export class Store {
|
|
|
871
905
|
// 自动运行调度失败不影响主写入路径
|
|
872
906
|
}
|
|
873
907
|
}
|
|
908
|
+
notifyChapterAnalysisInvalidated(workId, chapterId, versionNo) {
|
|
909
|
+
try {
|
|
910
|
+
this.chapterAnalysisInvalidatedHandler?.(workId, chapterId, versionNo);
|
|
911
|
+
}
|
|
912
|
+
catch {
|
|
913
|
+
// 稳定等待调度失败不影响主写入路径
|
|
914
|
+
}
|
|
915
|
+
}
|
|
874
916
|
getWorkAiSettings(workId) {
|
|
875
917
|
this.getWork(workId);
|
|
876
918
|
const row = this.db.get("SELECT * FROM work_ai_settings WHERE work_id = ?", workId);
|
|
@@ -886,6 +928,7 @@ export class Store {
|
|
|
886
928
|
autoRunBatchLimit: Math.min(200, Math.max(1, Number(row?.auto_run_batch_limit ?? 20) || 20)),
|
|
887
929
|
autoRunDailyTaskLimit: Math.min(10_000, Math.max(0, Number(row?.auto_run_daily_task_limit ?? 0) || 0)),
|
|
888
930
|
autoRunFailureThreshold: Math.min(10, Math.max(1, Number(row?.auto_run_failure_threshold ?? 3) || 3)),
|
|
931
|
+
autoRunStabilityDelayMinutes: Math.min(120, Math.max(1, Number(row?.auto_run_stability_delay_minutes ?? 2) || 2)),
|
|
889
932
|
autoRunPaused: Number(row?.auto_run_paused ?? 0) === 1,
|
|
890
933
|
autoRunPauseReason: String(row?.auto_run_pause_reason ?? ""),
|
|
891
934
|
autoRunResumeAt: row?.auto_run_resume_at === null || row?.auto_run_resume_at === undefined ? null : String(row.auto_run_resume_at),
|
|
@@ -920,6 +963,7 @@ export class Store {
|
|
|
920
963
|
const nextBatchLimit = input.autoRunBatchLimit ?? Number(current.autoRunBatchLimit);
|
|
921
964
|
const nextDailyTaskLimit = input.autoRunDailyTaskLimit ?? Number(current.autoRunDailyTaskLimit);
|
|
922
965
|
const nextFailureThreshold = input.autoRunFailureThreshold ?? Number(current.autoRunFailureThreshold);
|
|
966
|
+
const nextStabilityDelayMinutes = input.autoRunStabilityDelayMinutes ?? Number(current.autoRunStabilityDelayMinutes);
|
|
923
967
|
const nextBookSummaryContextPercent = input.bookSummaryContextPercent ?? Number(current.bookSummaryContextPercent);
|
|
924
968
|
const nextContextCompactThreshold = input.contextCompactThreshold ?? Number(current.contextCompactThreshold);
|
|
925
969
|
const nextAgentToolCallLimit = input.agentToolCallLimit ?? Number(current.agentToolCallLimit);
|
|
@@ -934,11 +978,11 @@ export class Store {
|
|
|
934
978
|
: input.titleGenerationModelId?.trim() || null;
|
|
935
979
|
this.db.run(`INSERT INTO work_ai_settings (
|
|
936
980
|
work_id, system_prompt, daily_token_quota, auto_run_enabled, auto_run_concurrency, auto_run_batch_limit,
|
|
937
|
-
auto_run_daily_task_limit, auto_run_failure_threshold, auto_run_paused, auto_run_pause_reason,
|
|
981
|
+
auto_run_daily_task_limit, auto_run_failure_threshold, auto_run_stability_delay_minutes, auto_run_paused, auto_run_pause_reason,
|
|
938
982
|
auto_run_resume_at, auto_run_consecutive_failures, book_summary_context_percent,
|
|
939
983
|
context_compact_threshold, agent_tool_call_limit, agent_tool_call_global_multiplier,
|
|
940
984
|
agent_tools_json, title_generation_model_id, image_tool_model_id, always_include_setting_info, updated_at
|
|
941
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
985
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
942
986
|
ON CONFLICT(work_id) DO UPDATE SET
|
|
943
987
|
system_prompt = excluded.system_prompt,
|
|
944
988
|
daily_token_quota = excluded.daily_token_quota,
|
|
@@ -947,6 +991,7 @@ export class Store {
|
|
|
947
991
|
auto_run_batch_limit = excluded.auto_run_batch_limit,
|
|
948
992
|
auto_run_daily_task_limit = excluded.auto_run_daily_task_limit,
|
|
949
993
|
auto_run_failure_threshold = excluded.auto_run_failure_threshold,
|
|
994
|
+
auto_run_stability_delay_minutes = excluded.auto_run_stability_delay_minutes,
|
|
950
995
|
auto_run_paused = excluded.auto_run_paused,
|
|
951
996
|
auto_run_pause_reason = excluded.auto_run_pause_reason,
|
|
952
997
|
auto_run_resume_at = excluded.auto_run_resume_at,
|
|
@@ -959,7 +1004,7 @@ export class Store {
|
|
|
959
1004
|
title_generation_model_id = excluded.title_generation_model_id,
|
|
960
1005
|
image_tool_model_id = excluded.image_tool_model_id,
|
|
961
1006
|
always_include_setting_info = excluded.always_include_setting_info,
|
|
962
|
-
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(maximumAgentToolCallLimit, Math.max(5, nextAgentToolCallLimit)), Math.min(6, Math.max(1, nextAgentToolCallGlobalMultiplier)), JSON.stringify(nextAgentTools), nextTitleGenerationModelId, nextImageToolModelId, nextAlwaysIncludeSettingInfo ? 1 : 0, timestamp);
|
|
1007
|
+
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)), Math.min(120, Math.max(1, nextStabilityDelayMinutes)), 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(maximumAgentToolCallLimit, Math.max(5, nextAgentToolCallLimit)), Math.min(6, Math.max(1, nextAgentToolCallGlobalMultiplier)), JSON.stringify(nextAgentTools), nextTitleGenerationModelId, nextImageToolModelId, nextAlwaysIncludeSettingInfo ? 1 : 0, timestamp);
|
|
963
1008
|
this.audit(workId, "work.ai-settings.updated", "work-ai-settings", workId, {
|
|
964
1009
|
systemPromptChanged: input.systemPrompt !== undefined,
|
|
965
1010
|
dailyTokenQuota: nextDailyTokenQuota,
|
|
@@ -968,6 +1013,7 @@ export class Store {
|
|
|
968
1013
|
autoRunBatchLimit: Math.min(200, Math.max(1, nextBatchLimit)),
|
|
969
1014
|
autoRunDailyTaskLimit: Math.min(10_000, Math.max(0, nextDailyTaskLimit)),
|
|
970
1015
|
autoRunFailureThreshold: Math.min(10, Math.max(1, nextFailureThreshold)),
|
|
1016
|
+
autoRunStabilityDelayMinutes: Math.min(120, Math.max(1, nextStabilityDelayMinutes)),
|
|
971
1017
|
bookSummaryContextPercent: Math.min(90, Math.max(1, nextBookSummaryContextPercent)),
|
|
972
1018
|
contextCompactThreshold: Math.min(90, Math.max(50, nextContextCompactThreshold)),
|
|
973
1019
|
agentToolCallLimit: Math.min(maximumAgentToolCallLimit, Math.max(5, nextAgentToolCallLimit)),
|
|
@@ -1263,18 +1309,19 @@ export class Store {
|
|
|
1263
1309
|
}));
|
|
1264
1310
|
return { ...work, volumes, directoryPage: pageResult };
|
|
1265
1311
|
}
|
|
1266
|
-
getStoryIndexChapterPage(workId, offset, limit) {
|
|
1312
|
+
getStoryIndexChapterPage(workId, offset, limit, options = {}) {
|
|
1267
1313
|
const work = this.getWork(workId);
|
|
1268
1314
|
const permissions = work.modulePermissions;
|
|
1269
1315
|
if (permissions.prose === "none")
|
|
1270
1316
|
return { totalChapters: 0, chapters: [] };
|
|
1317
|
+
const authorNoteFilter = options.excludeAuthorNotes ? " AND chapter.chapter_type <> '作者的话'" : "";
|
|
1271
1318
|
const countRow = this.db.get(`SELECT COUNT(*) AS count FROM chapters chapter
|
|
1272
1319
|
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
1273
|
-
WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL`, workId);
|
|
1320
|
+
WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL${authorNoteFilter}`, workId);
|
|
1274
1321
|
const chapterRows = this.db.all(`SELECT chapter.id, chapter.title, chapter.version_no, volume.title AS volume_title
|
|
1275
1322
|
FROM chapters chapter
|
|
1276
1323
|
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
1277
|
-
WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL
|
|
1324
|
+
WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL${authorNoteFilter}
|
|
1278
1325
|
ORDER BY volume.sort_order, volume.created_at, chapter.sort_order, chapter.created_at
|
|
1279
1326
|
LIMIT ? OFFSET ?`, workId, limit, offset);
|
|
1280
1327
|
const chapterIds = chapterRows.map((row) => requiredString(row, "id"));
|
|
@@ -1837,6 +1884,16 @@ export class Store {
|
|
|
1837
1884
|
throw new AppError(400, "REPLACE_SCOPE_INVALID", "替换范围无效");
|
|
1838
1885
|
}
|
|
1839
1886
|
const work = this.getWork(workId);
|
|
1887
|
+
const volumeId = input.volumeId ?? null;
|
|
1888
|
+
if (volumeId) {
|
|
1889
|
+
const volume = this.getVolume(volumeId);
|
|
1890
|
+
if (String(volume.workId) !== workId) {
|
|
1891
|
+
throw new AppError(400, "REPLACE_VOLUME_INVALID", "分卷不属于当前作品");
|
|
1892
|
+
}
|
|
1893
|
+
if (input.scope === "settings") {
|
|
1894
|
+
throw new AppError(400, "REPLACE_VOLUME_SCOPE_INVALID", "分卷范围只能用于正文替换");
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1840
1897
|
const permissions = work.modulePermissions;
|
|
1841
1898
|
const requestedProse = input.scope === "prose" || input.scope === "prose-and-settings";
|
|
1842
1899
|
const requestedSettings = input.scope === "settings" || input.scope === "prose-and-settings";
|
|
@@ -1873,7 +1930,9 @@ export class Store {
|
|
|
1873
1930
|
};
|
|
1874
1931
|
this.db.transaction(() => {
|
|
1875
1932
|
if (includeProse) {
|
|
1876
|
-
const chapters =
|
|
1933
|
+
const chapters = volumeId
|
|
1934
|
+
? this.db.all("SELECT id, content FROM chapters WHERE work_id = ? AND volume_id = ? AND deleted_at IS NULL ORDER BY sort_order, created_at", workId, volumeId)
|
|
1935
|
+
: this.db.all("SELECT id, content FROM chapters WHERE work_id = ? AND deleted_at IS NULL ORDER BY sort_order, created_at", workId);
|
|
1877
1936
|
for (const row of chapters) {
|
|
1878
1937
|
const chapterId = requiredString(row, "id");
|
|
1879
1938
|
const result = replaceLiteral(requiredString(row, "content"));
|
|
@@ -1902,6 +1961,7 @@ export class Store {
|
|
|
1902
1961
|
this.audit(workId, "work.global-replace", "work", workId, {
|
|
1903
1962
|
operationId,
|
|
1904
1963
|
scope: input.scope,
|
|
1964
|
+
volumeId,
|
|
1905
1965
|
chapterCount,
|
|
1906
1966
|
settingCount,
|
|
1907
1967
|
totalMatches,
|
|
@@ -1913,6 +1973,7 @@ export class Store {
|
|
|
1913
1973
|
return {
|
|
1914
1974
|
operationId,
|
|
1915
1975
|
scope: input.scope,
|
|
1976
|
+
volumeId,
|
|
1916
1977
|
chapterCount,
|
|
1917
1978
|
settingCount,
|
|
1918
1979
|
totalMatches,
|
|
@@ -2440,12 +2501,13 @@ export class Store {
|
|
|
2440
2501
|
this.db.run(`UPDATE chapter_paragraph_line_ranges SET chapter_version = ?
|
|
2441
2502
|
WHERE paragraph_id IN (SELECT id FROM chapter_paragraph_search WHERE chapter_id = ?)`, versionNo, chapterId);
|
|
2442
2503
|
}
|
|
2443
|
-
searchChapterParagraphs(workId, keyword, limit = 20) {
|
|
2504
|
+
searchChapterParagraphs(workId, keyword, limit = 20, options = {}) {
|
|
2444
2505
|
this.getWork(workId);
|
|
2445
2506
|
const normalizedKeyword = normalizeDocumentSearchText(keyword.trim());
|
|
2446
2507
|
if (!normalizedKeyword)
|
|
2447
2508
|
return [];
|
|
2448
2509
|
const safeLimit = Math.min(100, Math.max(1, Math.trunc(limit)));
|
|
2510
|
+
const authorNoteFilter = options.excludeAuthorNotes ? " AND chapter.chapter_type <> '作者的话'" : "";
|
|
2449
2511
|
const columns = `SELECT paragraph.chapter_id, chapter.title AS chapter_title, paragraph.content
|
|
2450
2512
|
FROM chapter_paragraph_search paragraph
|
|
2451
2513
|
JOIN chapters chapter ON chapter.id = paragraph.chapter_id
|
|
@@ -2453,12 +2515,12 @@ export class Store {
|
|
|
2453
2515
|
const rows = [...normalizedKeyword].length < 3
|
|
2454
2516
|
? this.db.all(`${columns}
|
|
2455
2517
|
JOIN chapter_paragraph_short_terms term ON term.paragraph_id = paragraph.id
|
|
2456
|
-
WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL AND term.term = ?
|
|
2518
|
+
WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL${authorNoteFilter} AND term.term = ?
|
|
2457
2519
|
ORDER BY volume.sort_order, chapter.sort_order, paragraph.paragraph_order
|
|
2458
2520
|
LIMIT ?`, workId, normalizedKeyword, safeLimit)
|
|
2459
2521
|
: this.db.all(`${columns}
|
|
2460
2522
|
JOIN chapter_paragraph_search_fts fts ON fts.rowid = paragraph.id
|
|
2461
|
-
WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL AND chapter_paragraph_search_fts MATCH ?
|
|
2523
|
+
WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL${authorNoteFilter} AND chapter_paragraph_search_fts MATCH ?
|
|
2462
2524
|
ORDER BY volume.sort_order, chapter.sort_order, paragraph.paragraph_order
|
|
2463
2525
|
LIMIT ?`, workId, `"${normalizedKeyword.replaceAll('"', '""')}"`, safeLimit);
|
|
2464
2526
|
return rows.map((row) => ({
|
|
@@ -2470,8 +2532,6 @@ export class Store {
|
|
|
2470
2532
|
invalidateChapter(workId, chapterId, versionNo) {
|
|
2471
2533
|
this.db.run(`UPDATE analysis_tasks SET status = 'expired', updated_at = ?
|
|
2472
2534
|
WHERE work_id = ? AND status IN ('pending', 'running', 'completed', 'partial', 'review')
|
|
2473
|
-
AND NOT (status = 'pending' AND task_type = 'chapter-analysis'
|
|
2474
|
-
AND json_extract(scope_json, '$.chapterId') = ?)
|
|
2475
2535
|
AND (json_extract(scope_json, '$.chapterId') = ?
|
|
2476
2536
|
OR EXISTS (SELECT 1 FROM json_each(scope_json, '$.chapterIds') WHERE json_each.value = ?)
|
|
2477
2537
|
OR json_extract(scope_json, '$.type') = 'book'
|
|
@@ -2480,18 +2540,8 @@ export class Store {
|
|
|
2480
2540
|
OR EXISTS (
|
|
2481
2541
|
SELECT 1 FROM json_each(scope_json, '$.volumeIds')
|
|
2482
2542
|
WHERE json_each.value = (SELECT volume_id FROM chapters WHERE id = ?)
|
|
2483
|
-
))))`, now(), workId, chapterId, chapterId, chapterId, chapterId
|
|
2484
|
-
|
|
2485
|
-
AND json_extract(scope_json, '$.chapterId') = ?`, workId, chapterId);
|
|
2486
|
-
if (!existing) {
|
|
2487
|
-
const timestamp = now();
|
|
2488
|
-
this.db.run(`INSERT INTO analysis_tasks (id, work_id, task_type, scope_json, status, source_versions_json, created_at, updated_at, created_by_user_id)
|
|
2489
|
-
VALUES (?, ?, 'chapter-analysis', ?, 'pending', ?, ?, ?, ?)`, id("task"), workId, JSON.stringify({ type: "chapter", chapterId }), JSON.stringify({ [chapterId]: versionNo }), timestamp, timestamp, currentRequestActor()?.userId ?? null);
|
|
2490
|
-
}
|
|
2491
|
-
else {
|
|
2492
|
-
this.db.run("UPDATE analysis_tasks SET source_versions_json = ?, updated_at = ? WHERE id = ?", JSON.stringify({ [chapterId]: versionNo }), now(), requiredString(existing, "id"));
|
|
2493
|
-
}
|
|
2494
|
-
this.notifyAnalysisTaskQueued(workId);
|
|
2543
|
+
))))`, now(), workId, chapterId, chapterId, chapterId, chapterId);
|
|
2544
|
+
this.notifyChapterAnalysisInvalidated(workId, chapterId, versionNo);
|
|
2495
2545
|
}
|
|
2496
2546
|
mapWorks(rows) {
|
|
2497
2547
|
if (rows.length === 0)
|
|
@@ -3029,20 +3079,25 @@ export class Store {
|
|
|
3029
3079
|
WHERE fo.foreshadow_id = ? ORDER BY v.sort_order, c.sort_order, fo.created_at`, foreshadowId).map((item) => this.mapForeshadowOccurrence(item));
|
|
3030
3080
|
const status = requiredString(row, "status");
|
|
3031
3081
|
const plannedPayoffChapterId = optionalString(row, "planned_payoff_chapter_id");
|
|
3082
|
+
const overdue = Boolean(currentChapterId && plannedPayoffChapterId && ["planned", "planted"].includes(status)
|
|
3083
|
+
&& this.chapterSequence(workId, plannedPayoffChapterId) < this.chapterSequence(workId, currentChapterId));
|
|
3084
|
+
return this.mapForeshadow(row, occurrences, this.currentEntityVersionNo("foreshadow", foreshadowId), overdue);
|
|
3085
|
+
}
|
|
3086
|
+
mapForeshadow(row, occurrences, versionNo, overdue) {
|
|
3087
|
+
const status = requiredString(row, "status");
|
|
3032
3088
|
return {
|
|
3033
3089
|
id: requiredString(row, "id"),
|
|
3034
|
-
workId,
|
|
3090
|
+
workId: requiredString(row, "work_id"),
|
|
3035
3091
|
title: requiredString(row, "title"),
|
|
3036
3092
|
description: requiredString(row, "description"),
|
|
3037
3093
|
status,
|
|
3038
3094
|
importance: requiredString(row, "importance"),
|
|
3039
|
-
plannedPayoffChapterId,
|
|
3095
|
+
plannedPayoffChapterId: optionalString(row, "planned_payoff_chapter_id"),
|
|
3040
3096
|
resolutionNote: requiredString(row, "resolution_note"),
|
|
3041
3097
|
unresolved: status === "planned" || status === "planted",
|
|
3042
|
-
overdue
|
|
3043
|
-
&& this.chapterSequence(workId, plannedPayoffChapterId) < this.chapterSequence(workId, currentChapterId)),
|
|
3098
|
+
overdue,
|
|
3044
3099
|
occurrences,
|
|
3045
|
-
versionNo
|
|
3100
|
+
versionNo,
|
|
3046
3101
|
createdAt: requiredString(row, "created_at"),
|
|
3047
3102
|
updatedAt: requiredString(row, "updated_at")
|
|
3048
3103
|
};
|
|
@@ -3054,8 +3109,9 @@ export class Store {
|
|
|
3054
3109
|
const where = status === "unresolved"
|
|
3055
3110
|
? "AND status IN ('planned', 'planted')"
|
|
3056
3111
|
: status === "resolved" ? "AND status IN ('resolved', 'abandoned')" : "";
|
|
3057
|
-
|
|
3058
|
-
ORDER BY CASE importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, created_at`, workId)
|
|
3112
|
+
const rows = this.db.all(`SELECT * FROM foreshadows WHERE work_id = ? ${where}
|
|
3113
|
+
ORDER BY CASE importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, created_at`, workId);
|
|
3114
|
+
return this.mapForeshadowList(rows, currentChapterId);
|
|
3059
3115
|
}
|
|
3060
3116
|
listForeshadowsPage(workId, pagination, status = "all", currentChapterId) {
|
|
3061
3117
|
this.getWork(workId);
|
|
@@ -3065,9 +3121,63 @@ export class Store {
|
|
|
3065
3121
|
? "AND status IN ('planned', 'planted')"
|
|
3066
3122
|
: status === "resolved" ? "AND status IN ('resolved', 'abandoned')" : "";
|
|
3067
3123
|
const page = paginationSql(pagination);
|
|
3068
|
-
const rows = this.db.all(`SELECT
|
|
3124
|
+
const rows = this.db.all(`SELECT * FROM foreshadows WHERE work_id = ? ${where}
|
|
3069
3125
|
ORDER BY CASE importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, created_at${page.sql}`, workId, ...page.params);
|
|
3070
|
-
return paginated(
|
|
3126
|
+
return paginated(this.mapForeshadowList(rows, currentChapterId), pagination);
|
|
3127
|
+
}
|
|
3128
|
+
mapForeshadowList(rows, currentChapterId) {
|
|
3129
|
+
if (rows.length === 0)
|
|
3130
|
+
return [];
|
|
3131
|
+
const foreshadowIds = rows.map((row) => requiredString(row, "id"));
|
|
3132
|
+
const batch = {
|
|
3133
|
+
occurrences: new Map(),
|
|
3134
|
+
versions: this.currentEntityVersionNos("foreshadow", foreshadowIds),
|
|
3135
|
+
chapterSequences: new Map()
|
|
3136
|
+
};
|
|
3137
|
+
for (let offset = 0; offset < foreshadowIds.length; offset += ENTITY_LIST_BATCH_SIZE) {
|
|
3138
|
+
const batchIds = foreshadowIds.slice(offset, offset + ENTITY_LIST_BATCH_SIZE);
|
|
3139
|
+
const placeholders = batchIds.map(() => "?").join(", ");
|
|
3140
|
+
const occurrences = this.db.all(`SELECT fo.*, c.title AS chapter_title, c.volume_id, c.sort_order AS chapter_order,
|
|
3141
|
+
v.title AS volume_title, v.sort_order AS volume_order
|
|
3142
|
+
FROM foreshadow_occurrences fo
|
|
3143
|
+
JOIN chapters c ON c.id = fo.chapter_id
|
|
3144
|
+
JOIN volumes v ON v.id = c.volume_id
|
|
3145
|
+
WHERE fo.foreshadow_id IN (${placeholders})
|
|
3146
|
+
ORDER BY fo.foreshadow_id, v.sort_order, c.sort_order, fo.created_at`, ...batchIds);
|
|
3147
|
+
for (const occurrence of occurrences) {
|
|
3148
|
+
const foreshadowId = requiredString(occurrence, "foreshadow_id");
|
|
3149
|
+
const grouped = batch.occurrences.get(foreshadowId) ?? [];
|
|
3150
|
+
grouped.push(this.mapForeshadowOccurrence(occurrence));
|
|
3151
|
+
batch.occurrences.set(foreshadowId, grouped);
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
3154
|
+
if (currentChapterId) {
|
|
3155
|
+
const chapterIds = [...new Set([
|
|
3156
|
+
currentChapterId,
|
|
3157
|
+
...rows.map((row) => optionalString(row, "planned_payoff_chapter_id")).filter((chapterId) => Boolean(chapterId))
|
|
3158
|
+
])];
|
|
3159
|
+
for (let offset = 0; offset < chapterIds.length; offset += ENTITY_LIST_BATCH_SIZE) {
|
|
3160
|
+
const batchIds = chapterIds.slice(offset, offset + ENTITY_LIST_BATCH_SIZE);
|
|
3161
|
+
const placeholders = batchIds.map(() => "?").join(", ");
|
|
3162
|
+
const sequences = this.db.all(`SELECT c.id, v.sort_order * 1000000 + c.sort_order AS sequence
|
|
3163
|
+
FROM chapters c JOIN volumes v ON v.id = c.volume_id
|
|
3164
|
+
WHERE c.id IN (${placeholders}) AND c.work_id = ?`, ...batchIds, requiredString(rows[0] ?? {}, "work_id"));
|
|
3165
|
+
for (const sequence of sequences) {
|
|
3166
|
+
batch.chapterSequences.set(requiredString(sequence, "id"), numberValue(sequence, "sequence"));
|
|
3167
|
+
}
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
const currentChapterSequence = currentChapterId
|
|
3171
|
+
? batch.chapterSequences.get(currentChapterId) ?? Number.MAX_SAFE_INTEGER
|
|
3172
|
+
: Number.MAX_SAFE_INTEGER;
|
|
3173
|
+
return rows.map((row) => {
|
|
3174
|
+
const foreshadowId = requiredString(row, "id");
|
|
3175
|
+
const status = requiredString(row, "status");
|
|
3176
|
+
const plannedPayoffChapterId = optionalString(row, "planned_payoff_chapter_id");
|
|
3177
|
+
const overdue = Boolean(currentChapterId && plannedPayoffChapterId && ["planned", "planted"].includes(status)
|
|
3178
|
+
&& (batch.chapterSequences.get(plannedPayoffChapterId) ?? Number.MAX_SAFE_INTEGER) < currentChapterSequence);
|
|
3179
|
+
return this.mapForeshadow(row, batch.occurrences.get(foreshadowId) ?? [], batch.versions.get(foreshadowId) ?? 0, overdue);
|
|
3180
|
+
});
|
|
3071
3181
|
}
|
|
3072
3182
|
listChapterForeshadowReminders(workId, chapterId) {
|
|
3073
3183
|
this.getWork(workId);
|
|
@@ -3750,13 +3860,16 @@ export class Store {
|
|
|
3750
3860
|
}
|
|
3751
3861
|
listOrganizations(workId, includeMarkdown = true) {
|
|
3752
3862
|
this.getWork(workId);
|
|
3753
|
-
|
|
3863
|
+
const rows = this.db.all("SELECT * FROM organizations WHERE work_id = ? ORDER BY name", workId);
|
|
3864
|
+
const batch = this.organizationListBatch(rows);
|
|
3865
|
+
return rows.map((row) => this.mapOrganization(row, includeMarkdown, batch));
|
|
3754
3866
|
}
|
|
3755
3867
|
listOrganizationsPage(workId, pagination, includeMarkdown = true) {
|
|
3756
3868
|
this.getWork(workId);
|
|
3757
3869
|
const page = paginationSql(pagination);
|
|
3758
3870
|
const rows = this.db.all(`SELECT * FROM organizations WHERE work_id = ? ORDER BY name${page.sql}`, workId, ...page.params);
|
|
3759
|
-
|
|
3871
|
+
const batch = this.organizationListBatch(rows);
|
|
3872
|
+
return paginated(rows.map((row) => this.mapOrganization(row, includeMarkdown, batch)), pagination);
|
|
3760
3873
|
}
|
|
3761
3874
|
getOrganization(organizationId) {
|
|
3762
3875
|
const row = this.db.get("SELECT * FROM organizations WHERE id = ?", organizationId);
|
|
@@ -3843,20 +3956,50 @@ export class Store {
|
|
|
3843
3956
|
});
|
|
3844
3957
|
return { mergeId, target: this.getOrganization(targetOrganizationId), source };
|
|
3845
3958
|
}
|
|
3846
|
-
|
|
3959
|
+
organizationListBatch(rows) {
|
|
3960
|
+
const organizationIds = rows.map((row) => requiredString(row, "id"));
|
|
3961
|
+
const batch = {
|
|
3962
|
+
members: new Map(),
|
|
3963
|
+
versions: this.currentEntityVersionNos("organization", organizationIds)
|
|
3964
|
+
};
|
|
3965
|
+
for (let offset = 0; offset < organizationIds.length; offset += ENTITY_LIST_BATCH_SIZE) {
|
|
3966
|
+
const batchIds = organizationIds.slice(offset, offset + ENTITY_LIST_BATCH_SIZE);
|
|
3967
|
+
const placeholders = batchIds.map(() => "?").join(", ");
|
|
3968
|
+
const members = this.db.all(`SELECT m.organization_id, c.id, c.name, m.role, m.note
|
|
3969
|
+
FROM character_organization_memberships m
|
|
3970
|
+
JOIN characters c ON c.id = m.character_id
|
|
3971
|
+
WHERE m.organization_id IN (${placeholders}) ORDER BY m.organization_id, c.name`, ...batchIds);
|
|
3972
|
+
for (const member of members) {
|
|
3973
|
+
const organizationId = requiredString(member, "organization_id");
|
|
3974
|
+
const grouped = batch.members.get(organizationId) ?? [];
|
|
3975
|
+
grouped.push({
|
|
3976
|
+
characterId: requiredString(member, "id"),
|
|
3977
|
+
name: requiredString(member, "name"),
|
|
3978
|
+
role: requiredString(member, "role"),
|
|
3979
|
+
note: requiredString(member, "note")
|
|
3980
|
+
});
|
|
3981
|
+
batch.members.set(organizationId, grouped);
|
|
3982
|
+
}
|
|
3983
|
+
}
|
|
3984
|
+
return batch;
|
|
3985
|
+
}
|
|
3986
|
+
mapOrganization(row, includeMarkdown = true, batch) {
|
|
3987
|
+
const organizationId = requiredString(row, "id");
|
|
3847
3988
|
const settingsSections = knowledgeSectionsFromStored(row.settings_sections_json, json(requiredString(row, "settings_json"), []));
|
|
3848
3989
|
const settings = settingsFromKnowledgeSections(settingsSections);
|
|
3849
|
-
const members =
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3990
|
+
const members = batch
|
|
3991
|
+
? batch.members.get(organizationId) ?? []
|
|
3992
|
+
: this.db.all(`SELECT c.id, c.name, m.role, m.note
|
|
3993
|
+
FROM character_organization_memberships m
|
|
3994
|
+
JOIN characters c ON c.id = m.character_id
|
|
3995
|
+
WHERE m.organization_id = ? ORDER BY c.name`, organizationId).map((member) => ({
|
|
3996
|
+
characterId: requiredString(member, "id"),
|
|
3997
|
+
name: requiredString(member, "name"),
|
|
3998
|
+
role: requiredString(member, "role"),
|
|
3999
|
+
note: requiredString(member, "note")
|
|
4000
|
+
}));
|
|
3858
4001
|
return {
|
|
3859
|
-
id:
|
|
4002
|
+
id: organizationId,
|
|
3860
4003
|
workId: requiredString(row, "work_id"),
|
|
3861
4004
|
name: requiredString(row, "name"),
|
|
3862
4005
|
description: requiredString(row, "description"),
|
|
@@ -3866,7 +4009,7 @@ export class Store {
|
|
|
3866
4009
|
: { settings: [], settingsCount: settingsSections.length }),
|
|
3867
4010
|
memberIds: members.map((member) => member.characterId),
|
|
3868
4011
|
members,
|
|
3869
|
-
versionNo: this.currentEntityVersionNo("organization",
|
|
4012
|
+
versionNo: batch ? batch.versions.get(organizationId) ?? 0 : this.currentEntityVersionNo("organization", organizationId),
|
|
3870
4013
|
createdAt: requiredString(row, "created_at"),
|
|
3871
4014
|
updatedAt: requiredString(row, "updated_at")
|
|
3872
4015
|
};
|
|
@@ -3913,6 +4056,7 @@ export class Store {
|
|
|
3913
4056
|
delete profile.sections;
|
|
3914
4057
|
return {
|
|
3915
4058
|
name: String(character.name),
|
|
4059
|
+
gender: characterGender(character.gender),
|
|
3916
4060
|
isDead: Boolean(character.isDead),
|
|
3917
4061
|
code: String(character.code),
|
|
3918
4062
|
aliases: [...character.aliases],
|
|
@@ -3966,9 +4110,9 @@ export class Store {
|
|
|
3966
4110
|
const organizationIds = [...new Set(input.organizationIds ?? [])];
|
|
3967
4111
|
this.assertOrganizationsInWork(workId, organizationIds);
|
|
3968
4112
|
this.db.transaction(() => {
|
|
3969
|
-
this.db.run(`INSERT INTO characters (id, work_id, name, code, aliases_json, species, race_id, attributes_json, profile_json, current_state_json,
|
|
4113
|
+
this.db.run(`INSERT INTO characters (id, work_id, name, code, gender, aliases_json, species, race_id, attributes_json, profile_json, current_state_json,
|
|
3970
4114
|
is_dead, locked_fields_json, first_chapter_id, created_at, updated_at)
|
|
3971
|
-
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);
|
|
4115
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, characterId, workId, names.name, input.code?.trim() ?? "", input.gender ?? "unknown", 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);
|
|
3972
4116
|
this.insertCharacterNames(workId, characterId, names.entries);
|
|
3973
4117
|
this.replaceCharacterOrganizations(characterId, organizationIds);
|
|
3974
4118
|
this.insertCharacterVersion(characterId, 1, source, sourceRef, changeNote, timestamp);
|
|
@@ -4203,7 +4347,8 @@ export class Store {
|
|
|
4203
4347
|
searchCharacterProfileSections(workId, query, limit = 20) {
|
|
4204
4348
|
this.getWork(workId);
|
|
4205
4349
|
const normalized = normalizeDocumentSearchText(query);
|
|
4206
|
-
const columns = `SELECT section.*, character.name AS character_name, character.
|
|
4350
|
+
const columns = `SELECT section.*, character.name AS character_name, character.gender AS character_gender,
|
|
4351
|
+
character.is_dead AS character_is_dead
|
|
4207
4352
|
FROM character_profile_section_search search
|
|
4208
4353
|
JOIN character_profile_sections section ON section.id = search.section_id
|
|
4209
4354
|
JOIN characters character ON character.id = search.character_id`;
|
|
@@ -4218,6 +4363,7 @@ export class Store {
|
|
|
4218
4363
|
return rows.map((row) => ({
|
|
4219
4364
|
...this.mapCharacterProfileSection(row),
|
|
4220
4365
|
characterName: requiredString(row, "character_name"),
|
|
4366
|
+
gender: requiredString(row, "character_gender"),
|
|
4221
4367
|
isDead: booleanValue(row, "character_is_dead")
|
|
4222
4368
|
}));
|
|
4223
4369
|
}
|
|
@@ -4445,8 +4591,8 @@ export class Store {
|
|
|
4445
4591
|
this.db.transaction(() => {
|
|
4446
4592
|
const lockedCurrent = this.getCharacter(characterId);
|
|
4447
4593
|
this.assertExpectedRevision("character", characterId, expectedVersionNo, "人物", Number(lockedCurrent.versionNo));
|
|
4448
|
-
this.db.run(`UPDATE characters SET name = ?, code = ?, aliases_json = ?, species = ?, race_id = ?, attributes_json = ?, profile_json = ?, current_state_json = ?,
|
|
4449
|
-
is_dead = ?, locked_fields_json = ?, first_chapter_id = ?, updated_at = ? WHERE id = ?`, names.name, input.code === undefined ? String(current.code) : input.code.trim(), JSON.stringify(names.aliases), species, raceId, JSON.stringify(attributes), JSON.stringify(input.profile ?? current.profile), JSON.stringify(input.currentState ?? current.currentState), input.isDead === undefined ? (current.isDead ? 1 : 0) : (input.isDead ? 1 : 0), JSON.stringify(input.lockedFields ?? current.lockedFields), input.firstChapterId === undefined ? current.firstChapterId : input.firstChapterId, now(), characterId);
|
|
4594
|
+
this.db.run(`UPDATE characters SET name = ?, code = ?, gender = ?, aliases_json = ?, species = ?, race_id = ?, attributes_json = ?, profile_json = ?, current_state_json = ?,
|
|
4595
|
+
is_dead = ?, locked_fields_json = ?, first_chapter_id = ?, updated_at = ? WHERE id = ?`, names.name, input.code === undefined ? String(current.code) : input.code.trim(), input.gender ?? characterGender(current.gender), JSON.stringify(names.aliases), species, raceId, JSON.stringify(attributes), JSON.stringify(input.profile ?? current.profile), JSON.stringify(input.currentState ?? current.currentState), input.isDead === undefined ? (current.isDead ? 1 : 0) : (input.isDead ? 1 : 0), JSON.stringify(input.lockedFields ?? current.lockedFields), input.firstChapterId === undefined ? current.firstChapterId : input.firstChapterId, now(), characterId);
|
|
4450
4596
|
this.db.run("DELETE FROM character_names WHERE character_id = ?", characterId);
|
|
4451
4597
|
this.insertCharacterNames(workId, characterId, names.entries);
|
|
4452
4598
|
if (organizationIds)
|
|
@@ -4515,7 +4661,7 @@ export class Store {
|
|
|
4515
4661
|
this.assertExpectedRevision("character", characterId, expectedVersionNo, "人物", this.currentCharacterVersionNo(characterId));
|
|
4516
4662
|
return this.recreateCharacterFromVersion(characterId, version, snapshot, versionNo);
|
|
4517
4663
|
}
|
|
4518
|
-
return this.updateCharacter(characterId, { ...snapshot, isDead: snapshot.isDead ?? false, code: snapshot.code ?? "" }, "restore", requiredString(version, "id"), `恢复至 v${versionNo}`, expectedVersionNo);
|
|
4664
|
+
return this.updateCharacter(characterId, { ...snapshot, gender: snapshot.gender ?? "unknown", isDead: snapshot.isDead ?? false, code: snapshot.code ?? "" }, "restore", requiredString(version, "id"), `恢复至 v${versionNo}`, expectedVersionNo);
|
|
4519
4665
|
}
|
|
4520
4666
|
recreateCharacterFromVersion(characterId, version, snapshot, versionNo) {
|
|
4521
4667
|
const workId = requiredString(version, "work_id");
|
|
@@ -4532,9 +4678,9 @@ export class Store {
|
|
|
4532
4678
|
const timestamp = now();
|
|
4533
4679
|
const nextVersionNo = numberValue(this.db.get("SELECT COALESCE(MAX(version_no), 0) AS version_no FROM character_versions WHERE character_id = ?", characterId) ?? {}, "version_no") + 1;
|
|
4534
4680
|
this.db.transaction(() => {
|
|
4535
|
-
this.db.run(`INSERT INTO characters (id, work_id, name, code, aliases_json, species, race_id, attributes_json, profile_json, current_state_json,
|
|
4681
|
+
this.db.run(`INSERT INTO characters (id, work_id, name, code, gender, aliases_json, species, race_id, attributes_json, profile_json, current_state_json,
|
|
4536
4682
|
is_dead, locked_fields_json, first_chapter_id, version_no, created_at, updated_at)
|
|
4537
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, characterId, workId, names.name, snapshot.code ?? "", JSON.stringify(names.aliases), species, raceId, JSON.stringify(snapshot.attributes ?? {}), JSON.stringify(snapshot.profile ?? {}), JSON.stringify(snapshot.currentState ?? {}), snapshot.isDead ? 1 : 0, JSON.stringify(snapshot.lockedFields ?? []), snapshot.firstChapterId ?? null, nextVersionNo, timestamp, timestamp);
|
|
4683
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, characterId, workId, names.name, snapshot.code ?? "", snapshot.gender ?? "unknown", JSON.stringify(names.aliases), species, raceId, JSON.stringify(snapshot.attributes ?? {}), JSON.stringify(snapshot.profile ?? {}), JSON.stringify(snapshot.currentState ?? {}), snapshot.isDead ? 1 : 0, JSON.stringify(snapshot.lockedFields ?? []), snapshot.firstChapterId ?? null, nextVersionNo, timestamp, timestamp);
|
|
4538
4684
|
this.insertCharacterNames(workId, characterId, names.entries);
|
|
4539
4685
|
this.replaceCharacterOrganizations(characterId, organizationIds);
|
|
4540
4686
|
this.insertCharacterVersion(characterId, nextVersionNo, "restore", requiredString(version, "id"), `恢复至 v${versionNo}`, timestamp, workId);
|
|
@@ -4611,6 +4757,7 @@ export class Store {
|
|
|
4611
4757
|
workId: requiredString(row, "work_id"),
|
|
4612
4758
|
name: requiredString(row, "name"),
|
|
4613
4759
|
code: requiredString(row, "code"),
|
|
4760
|
+
gender: characterGender(row.gender),
|
|
4614
4761
|
aliases: indexedAliases.length > 0 ? indexedAliases : json(requiredString(row, "aliases_json"), []),
|
|
4615
4762
|
raceId: race ? String(race.id) : null,
|
|
4616
4763
|
race: race ? {
|
|
@@ -5652,6 +5799,43 @@ export class Store {
|
|
|
5652
5799
|
throw notFound("AI 对话消息");
|
|
5653
5800
|
return this.mapAiConversationMessage(persistInterruption(message));
|
|
5654
5801
|
}
|
|
5802
|
+
upsertAiConversationAssistantMessage(conversationId, requestId, content, metadata = {}, syncSearchIndex = false) {
|
|
5803
|
+
const normalizedRequestId = requestId.trim();
|
|
5804
|
+
if (!normalizedRequestId)
|
|
5805
|
+
throw new AppError(400, "AI_MESSAGE_REQUEST_ID_REQUIRED", "AI 助手消息缺少请求标识");
|
|
5806
|
+
const existing = this.db.get("SELECT * FROM ai_conversation_messages WHERE conversation_id = ? AND request_id = ?", conversationId, normalizedRequestId);
|
|
5807
|
+
if (!existing) {
|
|
5808
|
+
return this.addAiConversationMessage(conversationId, {
|
|
5809
|
+
role: "assistant",
|
|
5810
|
+
content,
|
|
5811
|
+
requestId: normalizedRequestId,
|
|
5812
|
+
metadata
|
|
5813
|
+
});
|
|
5814
|
+
}
|
|
5815
|
+
if (requiredString(existing, "role") !== "assistant") {
|
|
5816
|
+
throw new AppError(409, "AI_MESSAGE_ROLE_MISMATCH", "请求标识已用于用户消息");
|
|
5817
|
+
}
|
|
5818
|
+
const currentMetadata = json(requiredString(existing, "metadata_json"), {});
|
|
5819
|
+
const nextMetadata = { ...currentMetadata, ...metadata };
|
|
5820
|
+
const contentChanged = requiredString(existing, "content") !== content;
|
|
5821
|
+
const metadataChanged = JSON.stringify(currentMetadata) !== JSON.stringify(nextMetadata);
|
|
5822
|
+
if (!contentChanged && !metadataChanged) {
|
|
5823
|
+
if (syncSearchIndex)
|
|
5824
|
+
this.syncAiHistorySearchShortTermsForSource("message", requiredString(existing, "id"));
|
|
5825
|
+
return this.mapAiConversationMessage(existing);
|
|
5826
|
+
}
|
|
5827
|
+
const timestamp = now();
|
|
5828
|
+
this.db.transaction(() => {
|
|
5829
|
+
this.db.run("UPDATE ai_conversation_messages SET content = ?, metadata_json = ? WHERE id = ?", content, JSON.stringify(nextMetadata), requiredString(existing, "id"));
|
|
5830
|
+
this.db.run("UPDATE ai_conversations SET updated_at = ? WHERE id = ?", timestamp, conversationId);
|
|
5831
|
+
if (syncSearchIndex)
|
|
5832
|
+
this.syncAiHistorySearchShortTermsForSource("message", requiredString(existing, "id"));
|
|
5833
|
+
});
|
|
5834
|
+
const updated = this.db.get("SELECT * FROM ai_conversation_messages WHERE id = ?", requiredString(existing, "id"));
|
|
5835
|
+
if (!updated)
|
|
5836
|
+
throw notFound("AI 对话消息");
|
|
5837
|
+
return this.mapAiConversationMessage(updated);
|
|
5838
|
+
}
|
|
5655
5839
|
beginAiConversationStreamRequest(input, referenceTime = new Date()) {
|
|
5656
5840
|
const timestamp = referenceTime.toISOString();
|
|
5657
5841
|
const leaseExpiresAt = new Date(referenceTime.getTime() + AI_CONVERSATION_STREAM_REQUEST_LEASE_MS).toISOString();
|
|
@@ -5769,10 +5953,18 @@ export class Store {
|
|
|
5769
5953
|
if (!assistant)
|
|
5770
5954
|
throw new AppError(400, "AI_STREAM_ASSISTANT_MISMATCH", "AI 回复消息不属于当前对话请求");
|
|
5771
5955
|
}
|
|
5956
|
+
const resolvedAssistantMessageId = assistantMessageId ?? (() => {
|
|
5957
|
+
const userMessageId = optionalString(request, "user_message_id");
|
|
5958
|
+
if (!userMessageId)
|
|
5959
|
+
return null;
|
|
5960
|
+
const assistant = this.db.get(`SELECT id FROM ai_conversation_messages
|
|
5961
|
+
WHERE conversation_id = ? AND role = 'assistant' AND request_id = ?`, requiredString(request, "conversation_id"), `assistant:${userMessageId}`);
|
|
5962
|
+
return assistant ? requiredString(assistant, "id") : null;
|
|
5963
|
+
})();
|
|
5772
5964
|
this.db.run(`UPDATE ai_conversation_stream_requests
|
|
5773
5965
|
SET status = ?, terminal_reason = ?, assistant_message_id = COALESCE(assistant_message_id, ?),
|
|
5774
5966
|
lease_expires_at = NULL, updated_at = ?, completed_at = ?
|
|
5775
|
-
WHERE id = ? AND status = 'in_progress'`, status, terminalReason.slice(0, 500),
|
|
5967
|
+
WHERE id = ? AND status = 'in_progress'`, status, terminalReason.slice(0, 500), resolvedAssistantMessageId, timestamp, timestamp, requestId);
|
|
5776
5968
|
const completed = this.db.get("SELECT * FROM ai_conversation_stream_requests WHERE id = ?", requestId);
|
|
5777
5969
|
if (!completed)
|
|
5778
5970
|
throw notFound("AI 对话请求");
|
|
@@ -6023,11 +6215,13 @@ export class Store {
|
|
|
6023
6215
|
...(Array.isArray(scope.chapterIds) ? scope.chapterIds.filter((value) => typeof value === "string") : [])
|
|
6024
6216
|
];
|
|
6025
6217
|
for (const chapterId of [...new Set(selectedChapterIds)]) {
|
|
6026
|
-
const chapter = this.db.get("SELECT work_id, version_no FROM chapters WHERE id = ? AND deleted_at IS NULL", chapterId);
|
|
6218
|
+
const chapter = this.db.get("SELECT work_id, version_no, chapter_type FROM chapters WHERE id = ? AND deleted_at IS NULL", chapterId);
|
|
6027
6219
|
if (!chapter)
|
|
6028
6220
|
throw notFound("章节");
|
|
6029
6221
|
if (requiredString(chapter, "work_id") !== workId)
|
|
6030
6222
|
throw new AppError(400, "CHAPTER_WORK_MISMATCH", "章节不属于当前作品");
|
|
6223
|
+
if (requiredString(chapter, "chapter_type") === "作者的话")
|
|
6224
|
+
continue;
|
|
6031
6225
|
sourceVersions[chapterId] = numberValue(chapter, "version_no");
|
|
6032
6226
|
}
|
|
6033
6227
|
if (scope.type === "book" || scope.type === "volume") {
|
|
@@ -6051,6 +6245,7 @@ export class Store {
|
|
|
6051
6245
|
FROM chapters chapter
|
|
6052
6246
|
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
6053
6247
|
WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL
|
|
6248
|
+
AND chapter.chapter_type <> '作者的话'
|
|
6054
6249
|
${volumeFilter}`, workId, ...(scope.type === "volume" ? [...selectedVolumeIdSet] : []));
|
|
6055
6250
|
for (const chapter of chapterRows) {
|
|
6056
6251
|
sourceVersions[requiredString(chapter, "id")] = numberValue(chapter, "version_no");
|
|
@@ -7219,15 +7414,16 @@ export class Store {
|
|
|
7219
7414
|
}
|
|
7220
7415
|
taskScopeSummaryFromMaps(scope, chapterSummaries, volumeTitles, characterNames, includeCharacterNames = true) {
|
|
7221
7416
|
const targetedSuffix = this.taskTargetedSuffix(scope, characterNames, includeCharacterNames);
|
|
7417
|
+
const chapterSettingsLabel = scope.includeAllSettings === true ? " + 设定集" : "";
|
|
7222
7418
|
if (Array.isArray(scope.chapterIds) && scope.chapterIds.length > 0) {
|
|
7223
7419
|
const labels = scope.chapterIds
|
|
7224
7420
|
.filter((chapterId) => typeof chapterId === "string")
|
|
7225
7421
|
.map((chapterId) => chapterSummaries.get(chapterId) ?? "章节已删除");
|
|
7226
7422
|
const preview = labels.slice(0, 3).join("、");
|
|
7227
|
-
return
|
|
7423
|
+
return `指定章节${chapterSettingsLabel}(${labels.length}):${preview}${labels.length > 3 ? "……" : ""}${targetedSuffix}`;
|
|
7228
7424
|
}
|
|
7229
7425
|
if (typeof scope.chapterId === "string")
|
|
7230
|
-
return `${chapterSummaries.get(scope.chapterId) ?? "章节已删除"}${targetedSuffix}`;
|
|
7426
|
+
return `${chapterSummaries.get(scope.chapterId) ?? "章节已删除"}${chapterSettingsLabel}${targetedSuffix}`;
|
|
7231
7427
|
if (Array.isArray(scope.volumeIds) && scope.volumeIds.length > 0) {
|
|
7232
7428
|
const labels = scope.volumeIds
|
|
7233
7429
|
.filter((volumeId) => typeof volumeId === "string")
|
|
@@ -7253,6 +7449,7 @@ export class Store {
|
|
|
7253
7449
|
}
|
|
7254
7450
|
taskScopeSummary(workId, scope, characterNames, includeCharacterNames = true) {
|
|
7255
7451
|
const targetedSuffix = this.taskTargetedSuffix(scope, characterNames, includeCharacterNames);
|
|
7452
|
+
const chapterSettingsLabel = scope.includeAllSettings === true ? " + 设定集" : "";
|
|
7256
7453
|
if (Array.isArray(scope.chapterIds) && scope.chapterIds.length > 0) {
|
|
7257
7454
|
const labels = scope.chapterIds.map((chapterId) => {
|
|
7258
7455
|
const chapter = this.db.get(`SELECT chapter.title AS title, volume.title AS volume_title
|
|
@@ -7262,7 +7459,7 @@ export class Store {
|
|
|
7262
7459
|
return chapter ? `${requiredString(chapter, "volume_title")} · ${requiredString(chapter, "title")}` : "章节已删除";
|
|
7263
7460
|
});
|
|
7264
7461
|
const preview = labels.slice(0, 3).join("、");
|
|
7265
|
-
return
|
|
7462
|
+
return `指定章节${chapterSettingsLabel}(${labels.length}):${preview}${labels.length > 3 ? "……" : ""}${targetedSuffix}`;
|
|
7266
7463
|
}
|
|
7267
7464
|
if (typeof scope.chapterId === "string") {
|
|
7268
7465
|
const chapter = this.db.get(`SELECT chapter.title AS title, volume.title AS volume_title
|
|
@@ -7270,10 +7467,10 @@ export class Store {
|
|
|
7270
7467
|
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
7271
7468
|
WHERE chapter.id = ? AND chapter.work_id = ? AND chapter.deleted_at IS NULL`, scope.chapterId, workId);
|
|
7272
7469
|
if (!chapter)
|
|
7273
|
-
return `章节已删除${targetedSuffix}`;
|
|
7470
|
+
return `章节已删除${chapterSettingsLabel}${targetedSuffix}`;
|
|
7274
7471
|
const title = requiredString(chapter, "title");
|
|
7275
7472
|
const volumeTitle = requiredString(chapter, "volume_title");
|
|
7276
|
-
return `${volumeTitle} · ${title}${targetedSuffix}`;
|
|
7473
|
+
return `${volumeTitle} · ${title}${chapterSettingsLabel}${targetedSuffix}`;
|
|
7277
7474
|
}
|
|
7278
7475
|
if (Array.isArray(scope.volumeIds) && scope.volumeIds.length > 0) {
|
|
7279
7476
|
const labels = scope.volumeIds.map((volumeId) => {
|
|
@@ -7512,7 +7709,7 @@ export class Store {
|
|
|
7512
7709
|
), character_race_paths AS (
|
|
7513
7710
|
SELECT character_id, path FROM character_race_lineage WHERE parent_race_id IS NULL
|
|
7514
7711
|
)
|
|
7515
|
-
SELECT character.id, character.name, character.aliases_json, character.species, character.is_dead,
|
|
7712
|
+
SELECT character.id, character.name, character.aliases_json, character.species, character.gender, character.is_dead,
|
|
7516
7713
|
COALESCE(path.path, character.species) AS race_path
|
|
7517
7714
|
FROM characters character LEFT JOIN character_race_paths path ON path.character_id = character.id
|
|
7518
7715
|
WHERE character.work_id = ? AND character.merged_into_character_id IS NULL AND (
|
|
@@ -7535,6 +7732,7 @@ export class Store {
|
|
|
7535
7732
|
title: requiredString(row, "name"),
|
|
7536
7733
|
snippet: [requiredString(row, "race_path"), ...json(requiredString(row, "aliases_json"), [])].filter(Boolean).join("、"),
|
|
7537
7734
|
racePath: requiredString(row, "race_path"),
|
|
7735
|
+
gender: requiredString(row, "gender"),
|
|
7538
7736
|
isDead: booleanValue(row, "is_dead")
|
|
7539
7737
|
})),
|
|
7540
7738
|
...characterSections.map((section) => ({
|
|
@@ -7544,6 +7742,7 @@ export class Store {
|
|
|
7544
7742
|
title: `${String(section.characterName)} / ${String(section.title)}`,
|
|
7545
7743
|
snippet: snippet(String(section.contentMarkdown)),
|
|
7546
7744
|
sectionType: String(section.sectionType),
|
|
7745
|
+
gender: String(section.gender),
|
|
7547
7746
|
isDead: Boolean(section.isDead)
|
|
7548
7747
|
})),
|
|
7549
7748
|
...settings.map((row) => ({ type: "setting", id: requiredString(row, "id"), title: requiredString(row, "title"), snippet: snippet(requiredString(row, "content")), category: requiredString(row, "category") })),
|