@musnows/scriverse 0.8.4 → 0.8.6
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.md +1 -0
- package/dist/ai-model-pricing.js +295 -0
- package/dist/ai-model-pricing.js.map +1 -0
- package/dist/ai-protocol.js +335 -15
- package/dist/ai-protocol.js.map +1 -1
- package/dist/ai-retry.js +1 -1
- package/dist/ai-retry.js.map +1 -1
- package/dist/ai-stream-timeout.js +9 -6
- package/dist/ai-stream-timeout.js.map +1 -1
- package/dist/ai.js +1103 -142
- package/dist/ai.js.map +1 -1
- package/dist/app.js +230 -28
- package/dist/app.js.map +1 -1
- package/dist/attachment-download.js +26 -0
- package/dist/attachment-download.js.map +1 -0
- package/dist/attachment-storage.js +8 -6
- package/dist/attachment-storage.js.map +1 -1
- package/dist/cli-contract.js +6 -4
- package/dist/cli-contract.js.map +1 -1
- package/dist/database.js +352 -3
- package/dist/database.js.map +1 -1
- package/dist/public/ai-image-attachments.d.ts +21 -0
- package/dist/public/ai-image-attachments.js +42 -0
- package/dist/public/ai-usage.d.ts +1 -0
- package/dist/public/ai-usage.js +11 -0
- package/dist/public/app.js +1125 -150
- package/dist/public/display-labels.d.ts +2 -1
- package/dist/public/display-labels.js +6 -6
- package/dist/public/index.html +42 -7
- package/dist/public/model-config.d.ts +3 -1
- package/dist/public/model-config.js +9 -2
- package/dist/public/styles.css +120 -6
- package/dist/s3-backup.js +21 -0
- package/dist/s3-backup.js.map +1 -1
- package/dist/security.js +2 -1
- package/dist/security.js.map +1 -1
- package/dist/server-runtime.js +10 -2
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +562 -47
- package/dist/store.js.map +1 -1
- package/dist/upload-limits.js +10 -4
- package/dist/upload-limits.js.map +1 -1
- package/dist/user-auth.js +3 -0
- package/dist/user-auth.js.map +1 -1
- package/dist/utils.js +1 -1
- package/dist/utils.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 +1 -1
package/dist/store.js
CHANGED
|
@@ -12,13 +12,14 @@ import { canWriteWorkModule, classifyWorkModulePermissions, emptyWorkModulePermi
|
|
|
12
12
|
import { countWords, documentShortSearchTerms, escapeSqlLikePattern, id, json, normalizeDocumentSearchText, normalizeParagraphSpacing, now, splitDocumentParagraphs } from "./utils.js";
|
|
13
13
|
import { buildWritingCalendar, writingDateKey } from "./writing-progress-time.js";
|
|
14
14
|
import { resolveMaxAgentToolCallLimit } from "./ai-tool-results.js";
|
|
15
|
+
import { DEFAULT_AI_STREAM_IDLE_TIMEOUT_SECONDS, normalizeAiStreamIdleTimeoutSeconds } from "./ai-stream-timeout.js";
|
|
15
16
|
const WORK_LIST_BATCH_SIZE = 500;
|
|
16
17
|
const ENTITY_LIST_BATCH_SIZE = 400;
|
|
17
18
|
export const RECYCLE_BIN_RETENTION_DAYS = 30;
|
|
18
19
|
function recycleBinExpiresAt(deletedAt) {
|
|
19
20
|
return new Date(new Date(deletedAt).getTime() + RECYCLE_BIN_RETENTION_DAYS * 24 * 60 * 60_000).toISOString();
|
|
20
21
|
}
|
|
21
|
-
export const attachmentPermissionModules = ["prose", "drafts", "settings", "characters", "races", "organizations"];
|
|
22
|
+
export const attachmentPermissionModules = ["prose", "drafts", "settings", "characters", "races", "organizations", "ai-chat"];
|
|
22
23
|
export const WORK_AGENT_TOOL_IDS = [
|
|
23
24
|
"story_index",
|
|
24
25
|
"read_chapters",
|
|
@@ -245,9 +246,13 @@ function parseRestorableFileSnapshot(value, workId) {
|
|
|
245
246
|
}
|
|
246
247
|
const description = volumeValue.description === undefined ? "" : volumeValue.description;
|
|
247
248
|
const keywords = volumeValue.keywords === undefined ? [] : volumeValue.keywords;
|
|
249
|
+
const storyOrder = volumeValue.storyOrder === undefined ? volumeValue.sortOrder : volumeValue.storyOrder;
|
|
248
250
|
if (typeof description !== "string" || !Array.isArray(keywords) || !keywords.every((keyword) => typeof keyword === "string")) {
|
|
249
251
|
return invalidFileSnapshot();
|
|
250
252
|
}
|
|
253
|
+
if (typeof storyOrder !== "number" || !Number.isInteger(storyOrder) || storyOrder < 0 || storyOrder > 1_000_000) {
|
|
254
|
+
return invalidFileSnapshot();
|
|
255
|
+
}
|
|
251
256
|
const chapters = volumeValue.chapters.map((chapterValue) => {
|
|
252
257
|
if (!isRecord(chapterValue) || typeof chapterValue.title !== "string" || typeof chapterValue.content !== "string"
|
|
253
258
|
|| typeof chapterValue.sortOrder !== "number" || !Number.isFinite(chapterValue.sortOrder)
|
|
@@ -272,6 +277,7 @@ function parseRestorableFileSnapshot(value, workId) {
|
|
|
272
277
|
description,
|
|
273
278
|
keywords: [...keywords],
|
|
274
279
|
sortOrder: volumeValue.sortOrder,
|
|
280
|
+
storyOrder,
|
|
275
281
|
chapters
|
|
276
282
|
};
|
|
277
283
|
});
|
|
@@ -452,7 +458,8 @@ export class Store {
|
|
|
452
458
|
source: entity.source,
|
|
453
459
|
description: entity.description,
|
|
454
460
|
keywords: entity.keywords,
|
|
455
|
-
sortOrder: entity.sortOrder
|
|
461
|
+
sortOrder: entity.sortOrder,
|
|
462
|
+
storyOrder: entity.storyOrder
|
|
456
463
|
};
|
|
457
464
|
if (type === "draft")
|
|
458
465
|
return {
|
|
@@ -838,22 +845,30 @@ export class Store {
|
|
|
838
845
|
}
|
|
839
846
|
getPlatformAiSettings() {
|
|
840
847
|
const row = this.db.get("SELECT * FROM platform_ai_settings WHERE id = 1");
|
|
848
|
+
const streamIdleTimeoutValue = Number(row?.stream_idle_timeout_seconds);
|
|
841
849
|
return {
|
|
842
850
|
systemPrompt: String(row?.system_prompt ?? ""),
|
|
843
851
|
imageToolModelId: row?.image_tool_model_id === null || row?.image_tool_model_id === undefined
|
|
844
852
|
? null
|
|
845
853
|
: String(row.image_tool_model_id),
|
|
854
|
+
streamIdleTimeoutSeconds: Number.isSafeInteger(streamIdleTimeoutValue)
|
|
855
|
+
? normalizeAiStreamIdleTimeoutSeconds(streamIdleTimeoutValue)
|
|
856
|
+
: DEFAULT_AI_STREAM_IDLE_TIMEOUT_SECONDS,
|
|
846
857
|
updatedAt: String(row?.updated_at ?? "")
|
|
847
858
|
};
|
|
848
859
|
}
|
|
849
860
|
updatePlatformAiSettings(input) {
|
|
850
861
|
const timestamp = now();
|
|
851
862
|
const current = this.getPlatformAiSettings();
|
|
852
|
-
|
|
863
|
+
const currentStreamIdleTimeoutSeconds = Number(current.streamIdleTimeoutSeconds);
|
|
864
|
+
const streamIdleTimeoutSeconds = normalizeAiStreamIdleTimeoutSeconds(input.streamIdleTimeoutSeconds ?? currentStreamIdleTimeoutSeconds);
|
|
865
|
+
this.db.run(`INSERT INTO platform_ai_settings (id, system_prompt, image_tool_model_id, stream_idle_timeout_seconds, updated_at) VALUES (1, ?, ?, ?, ?)
|
|
853
866
|
ON CONFLICT(id) DO UPDATE SET system_prompt = excluded.system_prompt,
|
|
854
|
-
image_tool_model_id = excluded.image_tool_model_id,
|
|
867
|
+
image_tool_model_id = excluded.image_tool_model_id,
|
|
868
|
+
stream_idle_timeout_seconds = excluded.stream_idle_timeout_seconds,
|
|
869
|
+
updated_at = excluded.updated_at`, input.systemPrompt ?? String(current.systemPrompt), input.imageToolModelId === undefined
|
|
855
870
|
? (current.imageToolModelId === null ? null : String(current.imageToolModelId))
|
|
856
|
-
: input.imageToolModelId, timestamp);
|
|
871
|
+
: input.imageToolModelId, streamIdleTimeoutSeconds, timestamp);
|
|
857
872
|
return this.getPlatformAiSettings();
|
|
858
873
|
}
|
|
859
874
|
getPlatformUiSettings() {
|
|
@@ -922,7 +937,10 @@ export class Store {
|
|
|
922
937
|
systemPrompt: String(row?.system_prompt ?? ""),
|
|
923
938
|
dailyTokenQuota: row?.daily_token_quota === null || row?.daily_token_quota === undefined
|
|
924
939
|
? null
|
|
925
|
-
: Math.max(
|
|
940
|
+
: Math.max(1, Number(row.daily_token_quota)),
|
|
941
|
+
monthlyTokenQuota: row?.monthly_token_quota === null || row?.monthly_token_quota === undefined
|
|
942
|
+
? null
|
|
943
|
+
: Math.max(1, Number(row.monthly_token_quota)),
|
|
926
944
|
autoRunEnabled: Number(row?.auto_run_enabled ?? 0) === 1,
|
|
927
945
|
autoRunConcurrency: Math.min(8, Math.max(1, Number(row?.auto_run_concurrency ?? 2) || 2)),
|
|
928
946
|
autoRunBatchLimit: Math.min(200, Math.max(1, Number(row?.auto_run_batch_limit ?? 20) || 20)),
|
|
@@ -958,6 +976,9 @@ export class Store {
|
|
|
958
976
|
const nextDailyTokenQuota = input.dailyTokenQuota === undefined
|
|
959
977
|
? (current.dailyTokenQuota === null ? null : Number(current.dailyTokenQuota))
|
|
960
978
|
: input.dailyTokenQuota;
|
|
979
|
+
const nextMonthlyTokenQuota = input.monthlyTokenQuota === undefined
|
|
980
|
+
? (current.monthlyTokenQuota === null ? null : Number(current.monthlyTokenQuota))
|
|
981
|
+
: input.monthlyTokenQuota;
|
|
961
982
|
const nextEnabled = input.autoRunEnabled ?? Boolean(current.autoRunEnabled);
|
|
962
983
|
const nextConcurrency = input.autoRunConcurrency ?? Number(current.autoRunConcurrency);
|
|
963
984
|
const nextBatchLimit = input.autoRunBatchLimit ?? Number(current.autoRunBatchLimit);
|
|
@@ -977,15 +998,16 @@ export class Store {
|
|
|
977
998
|
? (current.titleGenerationModelId ? String(current.titleGenerationModelId) : null)
|
|
978
999
|
: input.titleGenerationModelId?.trim() || null;
|
|
979
1000
|
this.db.run(`INSERT INTO work_ai_settings (
|
|
980
|
-
work_id, system_prompt, daily_token_quota, auto_run_enabled, auto_run_concurrency, auto_run_batch_limit,
|
|
1001
|
+
work_id, system_prompt, daily_token_quota, monthly_token_quota, auto_run_enabled, auto_run_concurrency, auto_run_batch_limit,
|
|
981
1002
|
auto_run_daily_task_limit, auto_run_failure_threshold, auto_run_stability_delay_minutes, auto_run_paused, auto_run_pause_reason,
|
|
982
1003
|
auto_run_resume_at, auto_run_consecutive_failures, book_summary_context_percent,
|
|
983
1004
|
context_compact_threshold, agent_tool_call_limit, agent_tool_call_global_multiplier,
|
|
984
1005
|
agent_tools_json, title_generation_model_id, image_tool_model_id, always_include_setting_info, updated_at
|
|
985
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1006
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
986
1007
|
ON CONFLICT(work_id) DO UPDATE SET
|
|
987
1008
|
system_prompt = excluded.system_prompt,
|
|
988
1009
|
daily_token_quota = excluded.daily_token_quota,
|
|
1010
|
+
monthly_token_quota = excluded.monthly_token_quota,
|
|
989
1011
|
auto_run_enabled = excluded.auto_run_enabled,
|
|
990
1012
|
auto_run_concurrency = excluded.auto_run_concurrency,
|
|
991
1013
|
auto_run_batch_limit = excluded.auto_run_batch_limit,
|
|
@@ -1004,10 +1026,11 @@ export class Store {
|
|
|
1004
1026
|
title_generation_model_id = excluded.title_generation_model_id,
|
|
1005
1027
|
image_tool_model_id = excluded.image_tool_model_id,
|
|
1006
1028
|
always_include_setting_info = excluded.always_include_setting_info,
|
|
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);
|
|
1029
|
+
updated_at = excluded.updated_at`, workId, nextPrompt, nextDailyTokenQuota, nextMonthlyTokenQuota, 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);
|
|
1008
1030
|
this.audit(workId, "work.ai-settings.updated", "work-ai-settings", workId, {
|
|
1009
1031
|
systemPromptChanged: input.systemPrompt !== undefined,
|
|
1010
1032
|
dailyTokenQuota: nextDailyTokenQuota,
|
|
1033
|
+
monthlyTokenQuota: nextMonthlyTokenQuota,
|
|
1011
1034
|
autoRunEnabled: nextEnabled,
|
|
1012
1035
|
autoRunConcurrency: Math.min(8, Math.max(1, nextConcurrency)),
|
|
1013
1036
|
autoRunBatchLimit: Math.min(200, Math.max(1, nextBatchLimit)),
|
|
@@ -1313,18 +1336,43 @@ export class Store {
|
|
|
1313
1336
|
const work = this.getWork(workId);
|
|
1314
1337
|
const permissions = work.modulePermissions;
|
|
1315
1338
|
if (permissions.prose === "none")
|
|
1316
|
-
return { totalChapters: 0, chapters: [] };
|
|
1339
|
+
return { totalChapters: 0, latestChaptersByStructure: [], chapters: [] };
|
|
1317
1340
|
const authorNoteFilter = options.excludeAuthorNotes ? " AND chapter.chapter_type <> '作者的话'" : "";
|
|
1318
1341
|
const countRow = this.db.get(`SELECT COUNT(*) AS count FROM chapters chapter
|
|
1319
1342
|
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
1320
1343
|
WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL${authorNoteFilter}`, workId);
|
|
1321
|
-
const chapterRows = this.db.all(`SELECT chapter.id, chapter.title, chapter.version_no
|
|
1344
|
+
const chapterRows = this.db.all(`SELECT chapter.id, chapter.title, chapter.version_no
|
|
1322
1345
|
FROM chapters chapter
|
|
1323
1346
|
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
1324
1347
|
WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL${authorNoteFilter}
|
|
1325
|
-
ORDER BY volume.sort_order, volume.created_at, chapter.sort_order, chapter.created_at
|
|
1348
|
+
ORDER BY volume.story_order, volume.sort_order, volume.created_at, chapter.sort_order, chapter.created_at
|
|
1326
1349
|
LIMIT ? OFFSET ?`, workId, limit, offset);
|
|
1327
|
-
const
|
|
1350
|
+
const latestChapterRows = this.db.all(`SELECT chapter.id, chapter.title, chapter.version_no
|
|
1351
|
+
FROM chapters chapter
|
|
1352
|
+
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
1353
|
+
WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL
|
|
1354
|
+
AND chapter.chapter_type = '正文'
|
|
1355
|
+
AND volume.story_order = (
|
|
1356
|
+
SELECT MAX(candidate_volume.story_order)
|
|
1357
|
+
FROM volumes candidate_volume
|
|
1358
|
+
WHERE candidate_volume.work_id = ? AND candidate_volume.deleted_at IS NULL
|
|
1359
|
+
AND EXISTS (
|
|
1360
|
+
SELECT 1 FROM chapters candidate_volume_chapter
|
|
1361
|
+
WHERE candidate_volume_chapter.volume_id = candidate_volume.id
|
|
1362
|
+
AND candidate_volume_chapter.deleted_at IS NULL
|
|
1363
|
+
AND candidate_volume_chapter.chapter_type = '正文'
|
|
1364
|
+
)
|
|
1365
|
+
)
|
|
1366
|
+
AND chapter.sort_order = (
|
|
1367
|
+
SELECT MAX(candidate_chapter.sort_order)
|
|
1368
|
+
FROM chapters candidate_chapter
|
|
1369
|
+
WHERE candidate_chapter.volume_id = chapter.volume_id
|
|
1370
|
+
AND candidate_chapter.deleted_at IS NULL
|
|
1371
|
+
AND candidate_chapter.chapter_type = '正文'
|
|
1372
|
+
)
|
|
1373
|
+
ORDER BY volume.created_at, volume.id, chapter.created_at, chapter.id`, workId, workId);
|
|
1374
|
+
const chapterIds = [...new Set([...chapterRows, ...latestChapterRows].map((row) => requiredString(row, "id")))];
|
|
1375
|
+
const storyOrders = this.getChapterStoryOrders(workId, chapterIds, { includeTimeline: options.includeTimeline });
|
|
1328
1376
|
const summaries = new Map();
|
|
1329
1377
|
if (chapterIds.length > 0) {
|
|
1330
1378
|
const placeholders = chapterIds.map(() => "?").join(", ");
|
|
@@ -1343,18 +1391,116 @@ export class Store {
|
|
|
1343
1391
|
}
|
|
1344
1392
|
return {
|
|
1345
1393
|
totalChapters: numberValue(countRow ?? {}, "count"),
|
|
1394
|
+
latestChaptersByStructure: latestChapterRows.map((row) => {
|
|
1395
|
+
const chapterId = requiredString(row, "id");
|
|
1396
|
+
return {
|
|
1397
|
+
id: chapterId,
|
|
1398
|
+
title: requiredString(row, "title"),
|
|
1399
|
+
versionNo: numberValue(row, "version_no"),
|
|
1400
|
+
storyOrder: storyOrders.get(chapterId),
|
|
1401
|
+
summary: summaries.get(chapterId) ?? ""
|
|
1402
|
+
};
|
|
1403
|
+
}),
|
|
1346
1404
|
chapters: chapterRows.map((row) => {
|
|
1347
1405
|
const chapterId = requiredString(row, "id");
|
|
1348
1406
|
return {
|
|
1349
1407
|
id: chapterId,
|
|
1350
|
-
volumeTitle: requiredString(row, "volume_title"),
|
|
1351
1408
|
title: requiredString(row, "title"),
|
|
1352
1409
|
versionNo: numberValue(row, "version_no"),
|
|
1410
|
+
storyOrder: storyOrders.get(chapterId),
|
|
1353
1411
|
summary: summaries.get(chapterId) ?? ""
|
|
1354
1412
|
};
|
|
1355
1413
|
})
|
|
1356
1414
|
};
|
|
1357
1415
|
}
|
|
1416
|
+
getChapterStoryOrders(workId, chapterIds, options = {}) {
|
|
1417
|
+
const work = this.getWork(workId);
|
|
1418
|
+
const permissions = work.modulePermissions;
|
|
1419
|
+
if (permissions.prose === "none")
|
|
1420
|
+
return new Map();
|
|
1421
|
+
const includeTimeline = options.includeTimeline === true && permissions.timeline !== "none";
|
|
1422
|
+
const uniqueChapterIds = [...new Set(chapterIds)].slice(0, 500);
|
|
1423
|
+
if (!uniqueChapterIds.length)
|
|
1424
|
+
return new Map();
|
|
1425
|
+
const placeholders = uniqueChapterIds.map(() => "?").join(", ");
|
|
1426
|
+
const rows = this.db.all(`SELECT chapter.id AS chapter_id, chapter.chapter_type, chapter.sort_order AS chapter_order,
|
|
1427
|
+
volume.id AS volume_id, volume.title AS volume_title, volume.sort_order AS volume_directory_order,
|
|
1428
|
+
volume.story_order AS volume_story_order,
|
|
1429
|
+
CASE WHEN chapter.chapter_type = '正文'
|
|
1430
|
+
AND volume.story_order = (
|
|
1431
|
+
SELECT MAX(candidate_volume.story_order)
|
|
1432
|
+
FROM volumes candidate_volume
|
|
1433
|
+
WHERE candidate_volume.work_id = chapter.work_id AND candidate_volume.deleted_at IS NULL
|
|
1434
|
+
AND EXISTS (
|
|
1435
|
+
SELECT 1 FROM chapters candidate_volume_chapter
|
|
1436
|
+
WHERE candidate_volume_chapter.volume_id = candidate_volume.id
|
|
1437
|
+
AND candidate_volume_chapter.deleted_at IS NULL AND candidate_volume_chapter.chapter_type = '正文'
|
|
1438
|
+
)
|
|
1439
|
+
)
|
|
1440
|
+
AND chapter.sort_order = (
|
|
1441
|
+
SELECT MAX(candidate_chapter.sort_order)
|
|
1442
|
+
FROM chapters candidate_chapter
|
|
1443
|
+
WHERE candidate_chapter.volume_id = chapter.volume_id
|
|
1444
|
+
AND candidate_chapter.deleted_at IS NULL AND candidate_chapter.chapter_type = '正文'
|
|
1445
|
+
)
|
|
1446
|
+
THEN 1 ELSE 0 END AS is_latest_by_structure
|
|
1447
|
+
FROM chapters chapter
|
|
1448
|
+
JOIN volumes volume ON volume.id = chapter.volume_id AND volume.work_id = chapter.work_id
|
|
1449
|
+
WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL
|
|
1450
|
+
AND chapter.id IN (${placeholders})`, workId, ...uniqueChapterIds);
|
|
1451
|
+
const result = new Map();
|
|
1452
|
+
for (const row of rows) {
|
|
1453
|
+
result.set(requiredString(row, "chapter_id"), {
|
|
1454
|
+
volume: {
|
|
1455
|
+
volumeId: requiredString(row, "volume_id"),
|
|
1456
|
+
volumeTitle: requiredString(row, "volume_title"),
|
|
1457
|
+
directoryOrder: numberValue(row, "volume_directory_order"),
|
|
1458
|
+
storyOrder: numberValue(row, "volume_story_order")
|
|
1459
|
+
},
|
|
1460
|
+
chapter: {
|
|
1461
|
+
order: numberValue(row, "chapter_order"),
|
|
1462
|
+
type: requiredString(row, "chapter_type"),
|
|
1463
|
+
isLatestByStructure: booleanValue(row, "is_latest_by_structure")
|
|
1464
|
+
},
|
|
1465
|
+
...(includeTimeline ? { confirmedTimelineEvents: [] } : {})
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
if (!includeTimeline || result.size === 0)
|
|
1469
|
+
return result;
|
|
1470
|
+
const timelineRows = this.db.all(`SELECT event.id, event.name, event.event_type, event.time_label, event.time_sort,
|
|
1471
|
+
event.track_id, track.name AS track_name, track.sort_order AS track_order, event.chapter_ids_json
|
|
1472
|
+
FROM timeline_events event
|
|
1473
|
+
LEFT JOIN timeline_tracks track ON track.id = event.track_id
|
|
1474
|
+
WHERE event.work_id = ? AND event.status = 'confirmed' AND event.time_sort IS NOT NULL
|
|
1475
|
+
AND typeof(event.time_sort) IN ('integer', 'real') AND json_valid(event.chapter_ids_json)
|
|
1476
|
+
AND EXISTS (
|
|
1477
|
+
SELECT 1 FROM json_each(event.chapter_ids_json) linked_chapter
|
|
1478
|
+
WHERE linked_chapter.value IN (${placeholders})
|
|
1479
|
+
)
|
|
1480
|
+
ORDER BY event.track_id IS NOT NULL, track.sort_order, event.time_sort, event.created_at, event.id`, workId, ...uniqueChapterIds);
|
|
1481
|
+
const requestedIds = new Set(uniqueChapterIds);
|
|
1482
|
+
for (const row of timelineRows) {
|
|
1483
|
+
const timeSort = Number(row.time_sort);
|
|
1484
|
+
if (!Number.isFinite(timeSort))
|
|
1485
|
+
continue;
|
|
1486
|
+
const event = {
|
|
1487
|
+
id: requiredString(row, "id"),
|
|
1488
|
+
name: requiredString(row, "name"),
|
|
1489
|
+
eventType: requiredString(row, "event_type"),
|
|
1490
|
+
timeLabel: requiredString(row, "time_label"),
|
|
1491
|
+
timeSort,
|
|
1492
|
+
trackId: optionalString(row, "track_id"),
|
|
1493
|
+
trackName: optionalString(row, "track_name"),
|
|
1494
|
+
trackOrder: row.track_order === null || row.track_order === undefined ? null : numberValue(row, "track_order")
|
|
1495
|
+
};
|
|
1496
|
+
for (const chapterId of json(requiredString(row, "chapter_ids_json"), [])) {
|
|
1497
|
+
if (!requestedIds.has(chapterId))
|
|
1498
|
+
continue;
|
|
1499
|
+
result.get(chapterId)?.confirmedTimelineEvents?.push(event);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
return result;
|
|
1503
|
+
}
|
|
1358
1504
|
listFileVersions(workId) {
|
|
1359
1505
|
this.getWork(workId);
|
|
1360
1506
|
return this.db
|
|
@@ -1428,7 +1574,8 @@ export class Store {
|
|
|
1428
1574
|
source: volume.source,
|
|
1429
1575
|
description: volume.description,
|
|
1430
1576
|
keywords: volume.keywords,
|
|
1431
|
-
sortOrder: volume.sortOrder
|
|
1577
|
+
sortOrder: volume.sortOrder,
|
|
1578
|
+
storyOrder: volume.storyOrder
|
|
1432
1579
|
}, "restore", fileVersionId, `恢复文件版本 ${fileVersionId}`);
|
|
1433
1580
|
for (const chapter of volume.chapters) {
|
|
1434
1581
|
this.insertChapter(workId, volumeId, chapter.title, chapter.content, chapter.sortOrder, "restore", fileVersionId, chapter.chapterType);
|
|
@@ -1466,6 +1613,7 @@ export class Store {
|
|
|
1466
1613
|
this.db.run(`INSERT INTO file_versions (id, work_id, file_name, file_type, word_count, paragraph_count, warnings_json, snapshot_json, created_at, created_by_user_id)
|
|
1467
1614
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, fileVersionId, workId, fileName, fileType, parsed.wordCount, parsed.paragraphCount, JSON.stringify(parsed.warnings), JSON.stringify(snapshot), timestamp, currentRequestActor()?.userId ?? null);
|
|
1468
1615
|
let volumeOrderOffset = 0;
|
|
1616
|
+
let volumeStoryOrderOffset = 0;
|
|
1469
1617
|
if (mode === "overwrite") {
|
|
1470
1618
|
const activeVolumeIds = this.db.all("SELECT id FROM volumes WHERE work_id = ? AND deleted_at IS NULL", workId)
|
|
1471
1619
|
.map((row) => requiredString(row, "id"));
|
|
@@ -1477,7 +1625,9 @@ export class Store {
|
|
|
1477
1625
|
}
|
|
1478
1626
|
else {
|
|
1479
1627
|
const lastVolume = this.db.get("SELECT COALESCE(MAX(sort_order), -1) AS value FROM volumes WHERE work_id = ? AND deleted_at IS NULL", workId);
|
|
1628
|
+
const lastStoryVolume = this.db.get("SELECT COALESCE(MAX(story_order), -1) AS value FROM volumes WHERE work_id = ? AND deleted_at IS NULL", workId);
|
|
1480
1629
|
volumeOrderOffset = numberValue(lastVolume ?? {}, "value") + 1;
|
|
1630
|
+
volumeStoryOrderOffset = numberValue(lastStoryVolume ?? {}, "value") + 1;
|
|
1481
1631
|
}
|
|
1482
1632
|
let firstImportedChapterId = null;
|
|
1483
1633
|
for (const volume of parsed.volumes) {
|
|
@@ -1486,7 +1636,8 @@ export class Store {
|
|
|
1486
1636
|
title: volume.title,
|
|
1487
1637
|
kind: volume.kind,
|
|
1488
1638
|
source: volume.source,
|
|
1489
|
-
sortOrder: volumeOrderOffset + volume.order
|
|
1639
|
+
sortOrder: volumeOrderOffset + volume.order,
|
|
1640
|
+
storyOrder: volumeStoryOrderOffset + volume.order
|
|
1490
1641
|
}, "import", fileVersionId, "导入分卷");
|
|
1491
1642
|
for (const chapter of volume.chapters) {
|
|
1492
1643
|
const chapterId = this.insertChapter(workId, volumeId, chapter.title, chapter.content, chapter.order, "import", fileVersionId, chapter.chapterType);
|
|
@@ -1519,8 +1670,9 @@ export class Store {
|
|
|
1519
1670
|
this.getWork(workId);
|
|
1520
1671
|
const timestamp = now();
|
|
1521
1672
|
const last = this.db.get("SELECT COALESCE(MAX(sort_order), -1) AS value FROM volumes WHERE work_id = ? AND deleted_at IS NULL", workId);
|
|
1522
|
-
this.db.
|
|
1523
|
-
|
|
1673
|
+
const lastStory = this.db.get("SELECT COALESCE(MAX(story_order), -1) AS value FROM volumes WHERE work_id = ? AND deleted_at IS NULL", workId);
|
|
1674
|
+
this.db.run(`INSERT INTO volumes (id, work_id, title, kind, source, description, keywords_json, sort_order, story_order, version_no, created_at, updated_at)
|
|
1675
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`, volumeId, workId, input.title, input.kind ?? "main", input.source ?? "manual", input.description?.trim() ?? "", JSON.stringify(this.normalizeVolumeKeywords(input.keywords ?? [])), input.sortOrder ?? numberValue(last ?? {}, "value") + 1, input.storyOrder ?? numberValue(lastStory ?? {}, "value") + 1, timestamp, timestamp);
|
|
1524
1676
|
const versionNo = this.recordEntityVersion("volume", volumeId, source, sourceRef, changeNote || "建立分卷", timestamp);
|
|
1525
1677
|
if (versionNo !== 1)
|
|
1526
1678
|
this.db.run("UPDATE volumes SET version_no = ? WHERE id = ?", versionNo, volumeId);
|
|
@@ -1539,7 +1691,7 @@ export class Store {
|
|
|
1539
1691
|
const current = this.getVolume(volumeId);
|
|
1540
1692
|
this.assertExpectedVersion("volume", volumeId, expectedVersionNo, "分卷", Number(current.versionNo));
|
|
1541
1693
|
const timestamp = now();
|
|
1542
|
-
this.db.run("UPDATE volumes SET title = ?, kind = ?, description = ?, keywords_json = ?, sort_order = ?, source = ?, version_no = version_no + 1, updated_at = ? WHERE id = ?", input.title ?? String(current.title), input.kind ?? String(current.kind), input.description?.trim() ?? String(current.description), JSON.stringify(input.keywords === undefined ? current.keywords : this.normalizeVolumeKeywords(input.keywords)), input.sortOrder ?? Number(current.sortOrder), source === "restore" ? String(current.source) : "manual", timestamp, volumeId);
|
|
1694
|
+
this.db.run("UPDATE volumes SET title = ?, kind = ?, description = ?, keywords_json = ?, sort_order = ?, story_order = ?, source = ?, version_no = version_no + 1, updated_at = ? WHERE id = ?", input.title ?? String(current.title), input.kind ?? String(current.kind), input.description?.trim() ?? String(current.description), JSON.stringify(input.keywords === undefined ? current.keywords : this.normalizeVolumeKeywords(input.keywords)), input.sortOrder ?? Number(current.sortOrder), input.storyOrder ?? Number(current.storyOrder), source === "restore" ? String(current.source) : "manual", timestamp, volumeId);
|
|
1543
1695
|
this.recordEntityVersion("volume", volumeId, source, sourceRef, changeNote || "更新分卷信息", timestamp);
|
|
1544
1696
|
this.audit(String(current.workId), "volume.updated", "volume", volumeId, { ...input, versionNo: Number(current.versionNo) + 1, source, sourceRef, changeNote });
|
|
1545
1697
|
});
|
|
@@ -2541,33 +2693,185 @@ export class Store {
|
|
|
2541
2693
|
this.db.run(`UPDATE chapter_paragraph_line_ranges SET chapter_version = ?
|
|
2542
2694
|
WHERE paragraph_id IN (SELECT id FROM chapter_paragraph_search WHERE chapter_id = ?)`, versionNo, chapterId);
|
|
2543
2695
|
}
|
|
2696
|
+
chapterParagraphSearchPlan(workId, normalizedKeyword, options) {
|
|
2697
|
+
const shortKeyword = [...normalizedKeyword].length < 3;
|
|
2698
|
+
const scopedChapterIds = options.chapterIds
|
|
2699
|
+
? [...new Set(options.chapterIds.filter(Boolean))].slice(0, 10_000)
|
|
2700
|
+
: null;
|
|
2701
|
+
const scopeFilter = scopedChapterIds
|
|
2702
|
+
? " AND chapter.id IN (SELECT CAST(value AS TEXT) FROM json_each(?))"
|
|
2703
|
+
: "";
|
|
2704
|
+
const authorNoteFilter = options.excludeAuthorNotes ? " AND chapter.chapter_type <> '作者的话'" : "";
|
|
2705
|
+
return {
|
|
2706
|
+
joinSql: shortKeyword
|
|
2707
|
+
? "JOIN chapter_paragraph_short_terms term ON term.paragraph_id = paragraph.id"
|
|
2708
|
+
: "JOIN chapter_paragraph_search_fts fts ON fts.rowid = paragraph.id",
|
|
2709
|
+
whereSql: `paragraph.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL${authorNoteFilter}${scopeFilter}
|
|
2710
|
+
AND ${shortKeyword ? "term.term = ?" : "chapter_paragraph_search_fts MATCH ?"}`,
|
|
2711
|
+
params: [
|
|
2712
|
+
workId,
|
|
2713
|
+
...(scopedChapterIds ? [JSON.stringify(scopedChapterIds)] : []),
|
|
2714
|
+
shortKeyword ? normalizedKeyword : `"${normalizedKeyword.replaceAll('"', '""')}"`
|
|
2715
|
+
]
|
|
2716
|
+
};
|
|
2717
|
+
}
|
|
2718
|
+
mapChapterParagraphMatches(workId, rows, options) {
|
|
2719
|
+
const chapterIds = rows.map((row) => requiredString(row, "chapter_id"));
|
|
2720
|
+
const storyOrders = options.includeStoryOrder
|
|
2721
|
+
? this.getChapterStoryOrders(workId, chapterIds, { includeTimeline: options.includeTimeline })
|
|
2722
|
+
: new Map();
|
|
2723
|
+
return rows.map((row) => {
|
|
2724
|
+
const chapterId = requiredString(row, "chapter_id");
|
|
2725
|
+
const storyOrder = storyOrders.get(chapterId);
|
|
2726
|
+
return {
|
|
2727
|
+
chapterId,
|
|
2728
|
+
chapterTitle: requiredString(row, "chapter_title"),
|
|
2729
|
+
paragraph: requiredString(row, "content"),
|
|
2730
|
+
...(options.includeParagraphOrder ? { paragraphOrder: numberValue(row, "paragraph_order") } : {}),
|
|
2731
|
+
...(storyOrder ? { storyOrder } : {})
|
|
2732
|
+
};
|
|
2733
|
+
});
|
|
2734
|
+
}
|
|
2544
2735
|
searchChapterParagraphs(workId, keyword, limit = 20, options = {}) {
|
|
2545
|
-
this.getWork(workId);
|
|
2736
|
+
const work = this.getWork(workId);
|
|
2737
|
+
if (work.modulePermissions.prose === "none")
|
|
2738
|
+
return [];
|
|
2546
2739
|
const normalizedKeyword = normalizeDocumentSearchText(keyword.trim());
|
|
2547
2740
|
if (!normalizedKeyword)
|
|
2548
2741
|
return [];
|
|
2742
|
+
if (options.chapterIds && options.chapterIds.length === 0)
|
|
2743
|
+
return [];
|
|
2549
2744
|
const safeLimit = Math.min(100, Math.max(1, Math.trunc(limit)));
|
|
2550
|
-
const
|
|
2551
|
-
const columns = `SELECT paragraph.chapter_id, chapter.title AS chapter_title, paragraph.content
|
|
2745
|
+
const search = this.chapterParagraphSearchPlan(workId, normalizedKeyword, options);
|
|
2746
|
+
const columns = `SELECT paragraph.chapter_id, chapter.title AS chapter_title, paragraph.content, paragraph.paragraph_order
|
|
2552
2747
|
FROM chapter_paragraph_search paragraph
|
|
2553
2748
|
JOIN chapters chapter ON chapter.id = paragraph.chapter_id
|
|
2554
|
-
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2749
|
+
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
2750
|
+
${search.joinSql}`;
|
|
2751
|
+
const orderSql = options.order === "story_desc"
|
|
2752
|
+
? "volume.story_order DESC, chapter.sort_order DESC, paragraph.paragraph_order DESC, volume.id, chapter.id, paragraph.id"
|
|
2753
|
+
: options.order === "story_asc"
|
|
2754
|
+
? "volume.story_order, chapter.sort_order, paragraph.paragraph_order, volume.id, chapter.id, paragraph.id"
|
|
2755
|
+
: "volume.sort_order, chapter.sort_order, paragraph.paragraph_order, volume.id, chapter.id, paragraph.id";
|
|
2756
|
+
const rows = this.db.all(`${columns}
|
|
2757
|
+
WHERE ${search.whereSql}
|
|
2758
|
+
ORDER BY ${orderSql}
|
|
2759
|
+
LIMIT ?`, ...search.params, safeLimit);
|
|
2760
|
+
return this.mapChapterParagraphMatches(workId, rows, {
|
|
2761
|
+
includeStoryOrder: options.includeStoryOrder,
|
|
2762
|
+
includeTimeline: options.includeTimeline,
|
|
2763
|
+
includeParagraphOrder: options.includeStoryOrder
|
|
2764
|
+
});
|
|
2765
|
+
}
|
|
2766
|
+
searchLatestChapterParagraphsByStructure(workId, keyword, options = {}) {
|
|
2767
|
+
const work = this.getWork(workId);
|
|
2768
|
+
if (work.modulePermissions.prose === "none")
|
|
2769
|
+
return [];
|
|
2770
|
+
const normalizedKeyword = normalizeDocumentSearchText(keyword.trim());
|
|
2771
|
+
if (!normalizedKeyword || (options.chapterIds && options.chapterIds.length === 0))
|
|
2772
|
+
return [];
|
|
2773
|
+
const search = this.chapterParagraphSearchPlan(workId, normalizedKeyword, options);
|
|
2774
|
+
const rows = this.db.all(`WITH matched_paragraphs AS (
|
|
2775
|
+
SELECT DISTINCT paragraph.id AS paragraph_id, paragraph.chapter_id, chapter.title AS chapter_title,
|
|
2776
|
+
paragraph.content, paragraph.paragraph_order, chapter.sort_order AS chapter_order,
|
|
2777
|
+
volume.id AS volume_id, volume.story_order AS volume_story_order
|
|
2778
|
+
FROM chapter_paragraph_search paragraph
|
|
2779
|
+
JOIN chapters chapter ON chapter.id = paragraph.chapter_id
|
|
2780
|
+
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
2781
|
+
${search.joinSql}
|
|
2782
|
+
WHERE ${search.whereSql}
|
|
2783
|
+
), latest_story_order AS (
|
|
2784
|
+
SELECT MAX(volume_story_order) AS value FROM matched_paragraphs
|
|
2785
|
+
), ranked_paragraphs AS (
|
|
2786
|
+
SELECT matched_paragraphs.*,
|
|
2787
|
+
DENSE_RANK() OVER (
|
|
2788
|
+
PARTITION BY volume_id
|
|
2789
|
+
ORDER BY chapter_order DESC, paragraph_order DESC
|
|
2790
|
+
) AS occurrence_rank
|
|
2791
|
+
FROM matched_paragraphs
|
|
2792
|
+
JOIN latest_story_order ON matched_paragraphs.volume_story_order = latest_story_order.value
|
|
2793
|
+
)
|
|
2794
|
+
SELECT chapter_id, chapter_title, content, paragraph_order
|
|
2795
|
+
FROM ranked_paragraphs
|
|
2796
|
+
WHERE occurrence_rank = 1
|
|
2797
|
+
ORDER BY volume_id, chapter_id, paragraph_id`, ...search.params);
|
|
2798
|
+
return this.mapChapterParagraphMatches(workId, rows, {
|
|
2799
|
+
includeStoryOrder: true,
|
|
2800
|
+
includeTimeline: options.includeTimeline,
|
|
2801
|
+
includeParagraphOrder: true
|
|
2802
|
+
});
|
|
2803
|
+
}
|
|
2804
|
+
searchLatestChapterParagraphsByTimelineTrack(workId, keyword, options = {}) {
|
|
2805
|
+
const work = this.getWork(workId);
|
|
2806
|
+
const permissions = work.modulePermissions;
|
|
2807
|
+
if (permissions.prose === "none" || permissions.timeline === "none")
|
|
2808
|
+
return [];
|
|
2809
|
+
const normalizedKeyword = normalizeDocumentSearchText(keyword.trim());
|
|
2810
|
+
if (!normalizedKeyword || (options.chapterIds && options.chapterIds.length === 0))
|
|
2811
|
+
return [];
|
|
2812
|
+
const search = this.chapterParagraphSearchPlan(workId, normalizedKeyword, options);
|
|
2813
|
+
const rows = this.db.all(`WITH matched_paragraphs AS (
|
|
2814
|
+
SELECT DISTINCT paragraph.id AS paragraph_id, paragraph.chapter_id, chapter.title AS chapter_title,
|
|
2815
|
+
paragraph.content, paragraph.paragraph_order, chapter.sort_order AS chapter_order,
|
|
2816
|
+
volume.story_order AS volume_story_order
|
|
2817
|
+
FROM chapter_paragraph_search paragraph
|
|
2818
|
+
JOIN chapters chapter ON chapter.id = paragraph.chapter_id
|
|
2819
|
+
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
2820
|
+
${search.joinSql}
|
|
2821
|
+
WHERE ${search.whereSql}
|
|
2822
|
+
), ranked_links AS (
|
|
2823
|
+
SELECT matched_paragraphs.*, event.id AS event_id, event.name AS event_name,
|
|
2824
|
+
event.event_type, event.time_label, event.time_sort, event.track_id,
|
|
2825
|
+
track.name AS track_name, track.sort_order AS track_order,
|
|
2826
|
+
ROW_NUMBER() OVER (
|
|
2827
|
+
PARTITION BY event.track_id
|
|
2828
|
+
ORDER BY event.time_sort DESC, matched_paragraphs.volume_story_order DESC,
|
|
2829
|
+
matched_paragraphs.chapter_order DESC, matched_paragraphs.paragraph_order DESC,
|
|
2830
|
+
event.id, matched_paragraphs.paragraph_id
|
|
2831
|
+
) AS track_rank,
|
|
2832
|
+
COUNT(*) OVER (PARTITION BY event.track_id, event.time_sort) AS matching_links_at_time
|
|
2833
|
+
FROM matched_paragraphs
|
|
2834
|
+
JOIN timeline_events event ON event.work_id = ?
|
|
2835
|
+
AND event.status = 'confirmed' AND event.time_sort IS NOT NULL
|
|
2836
|
+
AND typeof(event.time_sort) IN ('integer', 'real')
|
|
2837
|
+
AND event.time_sort BETWEEN -1.0e308 AND 1.0e308
|
|
2838
|
+
AND json_valid(event.chapter_ids_json)
|
|
2839
|
+
JOIN json_each(event.chapter_ids_json) linked_chapter
|
|
2840
|
+
ON CAST(linked_chapter.value AS TEXT) = matched_paragraphs.chapter_id
|
|
2841
|
+
LEFT JOIN timeline_tracks track ON track.id = event.track_id AND track.work_id = event.work_id
|
|
2842
|
+
)
|
|
2843
|
+
SELECT chapter_id, chapter_title, content, paragraph_order,
|
|
2844
|
+
event_id, event_name, event_type, time_label, time_sort,
|
|
2845
|
+
track_id, track_name, track_order, matching_links_at_time
|
|
2846
|
+
FROM ranked_links
|
|
2847
|
+
WHERE track_rank = 1
|
|
2848
|
+
ORDER BY track_order IS NULL, track_order, track_id
|
|
2849
|
+
LIMIT 100`, ...search.params, workId);
|
|
2850
|
+
const occurrences = this.mapChapterParagraphMatches(workId, rows, {
|
|
2851
|
+
includeStoryOrder: true,
|
|
2852
|
+
includeTimeline: false,
|
|
2853
|
+
includeParagraphOrder: true
|
|
2854
|
+
});
|
|
2855
|
+
return rows.flatMap((row, index) => {
|
|
2856
|
+
const timeSort = Number(row.time_sort);
|
|
2857
|
+
const occurrence = occurrences[index];
|
|
2858
|
+
if (!occurrence || !Number.isFinite(timeSort))
|
|
2859
|
+
return [];
|
|
2860
|
+
return [{
|
|
2861
|
+
trackId: optionalString(row, "track_id"),
|
|
2862
|
+
trackName: optionalString(row, "track_name"),
|
|
2863
|
+
trackOrder: row.track_order === null || row.track_order === undefined ? null : numberValue(row, "track_order"),
|
|
2864
|
+
timeSort,
|
|
2865
|
+
timeLabel: requiredString(row, "time_label"),
|
|
2866
|
+
timelineEvent: {
|
|
2867
|
+
id: requiredString(row, "event_id"),
|
|
2868
|
+
name: requiredString(row, "event_name"),
|
|
2869
|
+
eventType: requiredString(row, "event_type")
|
|
2870
|
+
},
|
|
2871
|
+
occurrence,
|
|
2872
|
+
matchingLinksAtLatestTime: numberValue(row, "matching_links_at_time")
|
|
2873
|
+
}];
|
|
2874
|
+
});
|
|
2571
2875
|
}
|
|
2572
2876
|
invalidateChapter(workId, chapterId, versionNo) {
|
|
2573
2877
|
this.db.run(`UPDATE analysis_tasks SET status = 'expired', updated_at = ?
|
|
@@ -2671,6 +2975,7 @@ export class Store {
|
|
|
2671
2975
|
description: optionalString(row, "description") ?? "",
|
|
2672
2976
|
keywords: json(optionalString(row, "keywords_json"), []),
|
|
2673
2977
|
sortOrder: numberValue(row, "sort_order"),
|
|
2978
|
+
storyOrder: numberValue(row, "story_order"),
|
|
2674
2979
|
versionNo: numberValue(row, "version_no") || this.currentEntityVersionNo("volume", requiredString(row, "id")),
|
|
2675
2980
|
createdAt: requiredString(row, "created_at"),
|
|
2676
2981
|
updatedAt: requiredString(row, "updated_at")
|
|
@@ -4473,6 +4778,48 @@ export class Store {
|
|
|
4473
4778
|
throw notFound("附件");
|
|
4474
4779
|
return this.mapAttachment(row);
|
|
4475
4780
|
}
|
|
4781
|
+
getAttachmentDownloadContextName(attachmentId) {
|
|
4782
|
+
const reference = this.db.get(`SELECT CASE reference.entity_type
|
|
4783
|
+
WHEN 'setting' THEN (SELECT title FROM settings WHERE id = reference.entity_id AND work_id = reference.work_id)
|
|
4784
|
+
WHEN 'character-section' THEN (
|
|
4785
|
+
SELECT character.name
|
|
4786
|
+
FROM character_profile_sections section
|
|
4787
|
+
JOIN characters character ON character.id = section.character_id
|
|
4788
|
+
WHERE section.id = reference.entity_id AND section.work_id = reference.work_id
|
|
4789
|
+
)
|
|
4790
|
+
WHEN 'race' THEN (SELECT name FROM races WHERE id = reference.entity_id AND work_id = reference.work_id)
|
|
4791
|
+
WHEN 'organization' THEN (SELECT name FROM organizations WHERE id = reference.entity_id AND work_id = reference.work_id)
|
|
4792
|
+
WHEN 'chapter' THEN (SELECT title FROM chapters WHERE id = reference.entity_id AND work_id = reference.work_id)
|
|
4793
|
+
WHEN 'draft' THEN (SELECT title FROM drafts WHERE id = reference.entity_id AND work_id = reference.work_id)
|
|
4794
|
+
ELSE NULL
|
|
4795
|
+
END AS context_name
|
|
4796
|
+
FROM attachment_references reference
|
|
4797
|
+
WHERE reference.attachment_id = ?
|
|
4798
|
+
ORDER BY CASE reference.entity_type
|
|
4799
|
+
WHEN 'setting' THEN 0
|
|
4800
|
+
WHEN 'character-section' THEN 1
|
|
4801
|
+
WHEN 'race' THEN 2
|
|
4802
|
+
WHEN 'organization' THEN 3
|
|
4803
|
+
WHEN 'chapter' THEN 4
|
|
4804
|
+
WHEN 'draft' THEN 5
|
|
4805
|
+
ELSE 6
|
|
4806
|
+
END, reference.created_at DESC, reference.entity_id
|
|
4807
|
+
LIMIT 1`, attachmentId);
|
|
4808
|
+
const contextName = optionalString(reference ?? {}, "context_name")?.trim();
|
|
4809
|
+
if (contextName)
|
|
4810
|
+
return contextName;
|
|
4811
|
+
const access = this.db.get("SELECT module FROM attachment_access_modules WHERE attachment_id = ? ORDER BY module LIMIT 1", attachmentId);
|
|
4812
|
+
const moduleLabels = {
|
|
4813
|
+
settings: "设定库",
|
|
4814
|
+
characters: "角色",
|
|
4815
|
+
races: "种族",
|
|
4816
|
+
organizations: "组织",
|
|
4817
|
+
drafts: "想法",
|
|
4818
|
+
prose: "正文",
|
|
4819
|
+
"ai-chat": "AI对话"
|
|
4820
|
+
};
|
|
4821
|
+
return moduleLabels[String(access?.module ?? "")] ?? null;
|
|
4822
|
+
}
|
|
4476
4823
|
getSettingAttachment(workId, attachmentId) {
|
|
4477
4824
|
const row = this.db.get(`SELECT attachment.*
|
|
4478
4825
|
FROM attachments attachment
|
|
@@ -4557,7 +4904,9 @@ export class Store {
|
|
|
4557
4904
|
["chapter_versions", "content"],
|
|
4558
4905
|
["file_versions", "snapshot_json"]
|
|
4559
4906
|
];
|
|
4560
|
-
|
|
4907
|
+
const historicalCount = sources.reduce((count, [table, column]) => count + Number(this.db.get(`SELECT COUNT(*) AS count FROM ${table} WHERE instr(${column}, ?) > 0`, needle)?.count ?? 0), 0);
|
|
4908
|
+
const conversationCount = Number(this.db.get("SELECT COUNT(*) AS count FROM ai_conversation_messages WHERE instr(metadata_json, ?) > 0", attachmentId)?.count ?? 0);
|
|
4909
|
+
return historicalCount + conversationCount;
|
|
4561
4910
|
}
|
|
4562
4911
|
queueUnreferencedAttachments(retentionMs = 24 * 60 * 60_000, limit = 100) {
|
|
4563
4912
|
const cutoff = new Date(Date.now() - Math.max(0, retentionMs)).toISOString();
|
|
@@ -4606,6 +4955,69 @@ export class Store {
|
|
|
4606
4955
|
throw notFound("角色");
|
|
4607
4956
|
return this.mapCharacter(row);
|
|
4608
4957
|
}
|
|
4958
|
+
getCharacterAvatar(characterId) {
|
|
4959
|
+
const character = this.db.get("SELECT id FROM characters WHERE id = ?", characterId);
|
|
4960
|
+
if (!character)
|
|
4961
|
+
throw notFound("角色");
|
|
4962
|
+
const row = this.db.get("SELECT * FROM character_avatars WHERE character_id = ?", characterId);
|
|
4963
|
+
if (!row)
|
|
4964
|
+
return null;
|
|
4965
|
+
return {
|
|
4966
|
+
mimeType: requiredString(row, "mime_type"),
|
|
4967
|
+
byteLength: numberValue(row, "byte_length"),
|
|
4968
|
+
sha256: requiredString(row, "sha256"),
|
|
4969
|
+
storageKey: requiredString(row, "storage_key"),
|
|
4970
|
+
width: numberValue(row, "width"),
|
|
4971
|
+
height: numberValue(row, "height"),
|
|
4972
|
+
updatedAt: requiredString(row, "updated_at")
|
|
4973
|
+
};
|
|
4974
|
+
}
|
|
4975
|
+
setCharacterAvatar(characterId, input) {
|
|
4976
|
+
const character = this.db.get("SELECT work_id, merged_into_character_id FROM characters WHERE id = ?", characterId);
|
|
4977
|
+
if (!character)
|
|
4978
|
+
throw notFound("角色");
|
|
4979
|
+
if (character.merged_into_character_id)
|
|
4980
|
+
throw new AppError(409, "CHARACTER_ALREADY_MERGED", "已合并角色不能直接设置头像");
|
|
4981
|
+
const previous = this.db.get("SELECT storage_key FROM character_avatars WHERE character_id = ?", characterId);
|
|
4982
|
+
const previousStorageKey = optionalString(previous ?? {}, "storage_key");
|
|
4983
|
+
const timestamp = now();
|
|
4984
|
+
this.db.transaction(() => {
|
|
4985
|
+
this.db.run(`INSERT INTO character_avatars (character_id, mime_type, byte_length, sha256, storage_key, width, height, updated_at)
|
|
4986
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
4987
|
+
ON CONFLICT(character_id) DO UPDATE SET mime_type = excluded.mime_type, byte_length = excluded.byte_length,
|
|
4988
|
+
sha256 = excluded.sha256, storage_key = excluded.storage_key, width = excluded.width,
|
|
4989
|
+
height = excluded.height, updated_at = excluded.updated_at`, characterId, input.mimeType, input.byteLength, input.sha256, input.storageKey, input.width, input.height, timestamp);
|
|
4990
|
+
this.audit(character.work_id, "character.avatar-updated", "character", characterId, {
|
|
4991
|
+
mimeType: input.mimeType,
|
|
4992
|
+
byteLength: input.byteLength,
|
|
4993
|
+
width: input.width,
|
|
4994
|
+
height: input.height
|
|
4995
|
+
});
|
|
4996
|
+
});
|
|
4997
|
+
return { character: this.getCharacter(characterId), previousStorageKey };
|
|
4998
|
+
}
|
|
4999
|
+
deleteCharacterAvatar(characterId) {
|
|
5000
|
+
const character = this.db.get("SELECT work_id FROM characters WHERE id = ?", characterId);
|
|
5001
|
+
if (!character)
|
|
5002
|
+
throw notFound("角色");
|
|
5003
|
+
const current = this.db.get("SELECT storage_key FROM character_avatars WHERE character_id = ?", characterId);
|
|
5004
|
+
const storageKey = optionalString(current ?? {}, "storage_key");
|
|
5005
|
+
if (storageKey) {
|
|
5006
|
+
this.db.transaction(() => {
|
|
5007
|
+
this.db.run("DELETE FROM character_avatars WHERE character_id = ?", characterId);
|
|
5008
|
+
this.audit(character.work_id, "character.avatar-deleted", "character", characterId);
|
|
5009
|
+
});
|
|
5010
|
+
}
|
|
5011
|
+
return { character: this.getCharacter(characterId), storageKey };
|
|
5012
|
+
}
|
|
5013
|
+
listCharacterAvatarStorageKeysForWork(workId) {
|
|
5014
|
+
return this.db.all(`SELECT avatar.storage_key FROM character_avatars avatar
|
|
5015
|
+
JOIN characters character ON character.id = avatar.character_id
|
|
5016
|
+
WHERE character.work_id = ?`, workId).map((row) => requiredString(row, "storage_key"));
|
|
5017
|
+
}
|
|
5018
|
+
characterAvatarStorageKeyInUse(storageKey) {
|
|
5019
|
+
return Number(this.db.get("SELECT COUNT(*) AS count FROM character_avatars WHERE storage_key = ?", storageKey)?.count ?? 0) > 0;
|
|
5020
|
+
}
|
|
4609
5021
|
updateCharacter(characterId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
|
|
4610
5022
|
const current = this.getCharacter(characterId);
|
|
4611
5023
|
this.assertExpectedRevision("character", characterId, expectedVersionNo, "人物", Number(current.versionNo));
|
|
@@ -4792,6 +5204,8 @@ export class Store {
|
|
|
4792
5204
|
versionNo: section.versionNo
|
|
4793
5205
|
}));
|
|
4794
5206
|
}
|
|
5207
|
+
const avatar = this.db.get("SELECT sha256 FROM character_avatars WHERE character_id = ?", characterId);
|
|
5208
|
+
const avatarSha256 = optionalString(avatar ?? {}, "sha256");
|
|
4795
5209
|
return {
|
|
4796
5210
|
id: characterId,
|
|
4797
5211
|
workId: requiredString(row, "work_id"),
|
|
@@ -4815,6 +5229,9 @@ export class Store {
|
|
|
4815
5229
|
profileSectionCount,
|
|
4816
5230
|
currentState: json(requiredString(row, "current_state_json"), {}),
|
|
4817
5231
|
isDead: booleanValue(row, "is_dead"),
|
|
5232
|
+
avatarUrl: avatarSha256
|
|
5233
|
+
? `/api/characters/${encodeURIComponent(characterId)}/avatar?v=${encodeURIComponent(avatarSha256)}`
|
|
5234
|
+
: null,
|
|
4818
5235
|
lockedFields: json(requiredString(row, "locked_fields_json"), []),
|
|
4819
5236
|
firstChapterId: optionalString(row, "first_chapter_id"),
|
|
4820
5237
|
mergedIntoCharacterId: optionalString(row, "merged_into_character_id"),
|
|
@@ -5464,17 +5881,18 @@ export class Store {
|
|
|
5464
5881
|
});
|
|
5465
5882
|
return this.getAiConversation(conversationId);
|
|
5466
5883
|
}
|
|
5467
|
-
listAiConversations(workId) {
|
|
5884
|
+
listAiConversations(workId, userId) {
|
|
5468
5885
|
this.getWork(workId);
|
|
5469
5886
|
return this.db.all(`SELECT conversation.*,
|
|
5470
5887
|
(SELECT COUNT(*) FROM ai_conversation_messages message WHERE message.conversation_id = conversation.id) AS message_count,
|
|
5471
5888
|
COALESCE((SELECT content FROM ai_conversation_messages message WHERE message.conversation_id = conversation.id ORDER BY message.created_at DESC, message.rowid DESC LIMIT 1), '') AS preview
|
|
5472
5889
|
FROM ai_conversations conversation
|
|
5473
5890
|
WHERE conversation.work_id = ?
|
|
5891
|
+
AND (? IS NULL OR conversation.created_by_user_id = ?)
|
|
5474
5892
|
ORDER BY conversation.is_favorite DESC, conversation.updated_at DESC, conversation.created_at DESC
|
|
5475
|
-
LIMIT 100`, workId).map((row) => this.mapAiConversation(row));
|
|
5893
|
+
LIMIT 100`, workId, userId ?? null, userId ?? null).map((row) => this.mapAiConversation(row));
|
|
5476
5894
|
}
|
|
5477
|
-
listAiConversationsPage(workId, pagination) {
|
|
5895
|
+
listAiConversationsPage(workId, pagination, userId) {
|
|
5478
5896
|
this.getWork(workId);
|
|
5479
5897
|
const page = paginationSql(pagination);
|
|
5480
5898
|
const rows = this.db.all(`SELECT conversation.*,
|
|
@@ -5482,9 +5900,77 @@ export class Store {
|
|
|
5482
5900
|
COALESCE((SELECT content FROM ai_conversation_messages message WHERE message.conversation_id = conversation.id ORDER BY message.created_at DESC, message.rowid DESC LIMIT 1), '') AS preview
|
|
5483
5901
|
FROM ai_conversations conversation
|
|
5484
5902
|
WHERE conversation.work_id = ?
|
|
5485
|
-
|
|
5903
|
+
AND (? IS NULL OR conversation.created_by_user_id = ?)
|
|
5904
|
+
ORDER BY conversation.is_favorite DESC, conversation.updated_at DESC, conversation.created_at DESC${page.sql}`, workId, userId ?? null, userId ?? null, ...page.params);
|
|
5486
5905
|
return paginated(rows.map((row) => this.mapAiConversation(row)), pagination);
|
|
5487
5906
|
}
|
|
5907
|
+
assertAiConversationOwner(conversationId, userId) {
|
|
5908
|
+
const conversation = this.db.get("SELECT created_by_user_id FROM ai_conversations WHERE id = ?", conversationId);
|
|
5909
|
+
if (!conversation)
|
|
5910
|
+
throw notFound("AI 对话");
|
|
5911
|
+
if (optionalString(conversation, "created_by_user_id") !== userId) {
|
|
5912
|
+
throw new AppError(403, "AI_CONVERSATION_ACCESS_DENIED", "你只能访问自己创建的 AI 对话");
|
|
5913
|
+
}
|
|
5914
|
+
}
|
|
5915
|
+
listAdminAiConversationsPage(pagination, filters = {}) {
|
|
5916
|
+
const where = [];
|
|
5917
|
+
const params = [];
|
|
5918
|
+
if (filters.workId) {
|
|
5919
|
+
where.push("conversation.work_id = ?");
|
|
5920
|
+
params.push(filters.workId);
|
|
5921
|
+
}
|
|
5922
|
+
if (filters.userId) {
|
|
5923
|
+
where.push("conversation.created_by_user_id = ?");
|
|
5924
|
+
params.push(filters.userId);
|
|
5925
|
+
}
|
|
5926
|
+
const normalizedQuery = filters.query?.normalize("NFKC").trim() ?? "";
|
|
5927
|
+
if (normalizedQuery) {
|
|
5928
|
+
const pattern = `%${escapeSqlLikePattern(normalizedQuery)}%`;
|
|
5929
|
+
where.push(`(
|
|
5930
|
+
conversation.title LIKE ? ESCAPE '\\' COLLATE NOCASE
|
|
5931
|
+
OR work.title LIKE ? ESCAPE '\\' COLLATE NOCASE
|
|
5932
|
+
OR creator.username LIKE ? ESCAPE '\\' COLLATE NOCASE
|
|
5933
|
+
OR creator.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
|
5934
|
+
OR EXISTS (
|
|
5935
|
+
SELECT 1 FROM ai_conversation_messages searched_message
|
|
5936
|
+
WHERE searched_message.conversation_id = conversation.id
|
|
5937
|
+
AND searched_message.content LIKE ? ESCAPE '\\' COLLATE NOCASE
|
|
5938
|
+
)
|
|
5939
|
+
)`);
|
|
5940
|
+
params.push(pattern, pattern, pattern, pattern, pattern);
|
|
5941
|
+
}
|
|
5942
|
+
const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
|
|
5943
|
+
const page = paginationSql(pagination);
|
|
5944
|
+
const rows = this.db.all(`SELECT conversation.*,
|
|
5945
|
+
work.title AS work_title,
|
|
5946
|
+
work.deleted_at AS work_deleted_at,
|
|
5947
|
+
creator.username AS creator_username,
|
|
5948
|
+
creator.display_name AS creator_display_name,
|
|
5949
|
+
creator.role AS creator_role,
|
|
5950
|
+
creator.status AS creator_status,
|
|
5951
|
+
(SELECT COUNT(*) FROM ai_conversation_messages message WHERE message.conversation_id = conversation.id) AS message_count,
|
|
5952
|
+
COALESCE((SELECT content FROM ai_conversation_messages message WHERE message.conversation_id = conversation.id ORDER BY message.created_at DESC, message.rowid DESC LIMIT 1), '') AS preview
|
|
5953
|
+
FROM ai_conversations conversation
|
|
5954
|
+
JOIN works work ON work.id = conversation.work_id
|
|
5955
|
+
LEFT JOIN users creator ON creator.id = conversation.created_by_user_id
|
|
5956
|
+
${whereSql}
|
|
5957
|
+
ORDER BY conversation.updated_at DESC, conversation.created_at DESC${page.sql}`, ...params, ...page.params);
|
|
5958
|
+
return paginated(rows.map((row) => ({
|
|
5959
|
+
...this.mapAiConversation(row),
|
|
5960
|
+
work: {
|
|
5961
|
+
id: requiredString(row, "work_id"),
|
|
5962
|
+
title: requiredString(row, "work_title"),
|
|
5963
|
+
deleted: Boolean(optionalString(row, "work_deleted_at"))
|
|
5964
|
+
},
|
|
5965
|
+
creator: optionalString(row, "created_by_user_id") ? {
|
|
5966
|
+
userId: requiredString(row, "created_by_user_id"),
|
|
5967
|
+
username: requiredString(row, "creator_username"),
|
|
5968
|
+
displayName: requiredString(row, "creator_display_name"),
|
|
5969
|
+
role: requiredString(row, "creator_role"),
|
|
5970
|
+
status: requiredString(row, "creator_status")
|
|
5971
|
+
} : null
|
|
5972
|
+
})), pagination);
|
|
5973
|
+
}
|
|
5488
5974
|
getAiConversationSummary(conversationId) {
|
|
5489
5975
|
const row = this.db.get(`SELECT conversation.*,
|
|
5490
5976
|
(SELECT COUNT(*) FROM ai_conversation_messages message WHERE message.conversation_id = conversation.id) AS message_count,
|
|
@@ -5584,6 +6070,14 @@ export class Store {
|
|
|
5584
6070
|
throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
|
|
5585
6071
|
return this.aiConversationLockedModelId(conversationId);
|
|
5586
6072
|
}
|
|
6073
|
+
getAiConversationHasImageAttachments(conversationId, workId) {
|
|
6074
|
+
const conversation = this.db.get("SELECT work_id FROM ai_conversations WHERE id = ?", conversationId);
|
|
6075
|
+
if (!conversation)
|
|
6076
|
+
throw notFound("AI 对话");
|
|
6077
|
+
if (requiredString(conversation, "work_id") !== workId)
|
|
6078
|
+
throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
|
|
6079
|
+
return this.aiConversationHasImageAttachments(conversationId);
|
|
6080
|
+
}
|
|
5587
6081
|
aiConversationLockedModelId(conversationId) {
|
|
5588
6082
|
const messages = this.db.all(`SELECT metadata_json FROM ai_conversation_messages
|
|
5589
6083
|
WHERE conversation_id = ? AND role = 'user'
|
|
@@ -5595,6 +6089,16 @@ export class Store {
|
|
|
5595
6089
|
}
|
|
5596
6090
|
return null;
|
|
5597
6091
|
}
|
|
6092
|
+
aiConversationHasImageAttachments(conversationId) {
|
|
6093
|
+
const messages = this.db.all(`SELECT metadata_json FROM ai_conversation_messages
|
|
6094
|
+
WHERE conversation_id = ? AND role = 'user'
|
|
6095
|
+
ORDER BY created_at, rowid`, conversationId);
|
|
6096
|
+
return messages.some((message) => {
|
|
6097
|
+
const metadata = json(requiredString(message, "metadata_json"), {});
|
|
6098
|
+
return Array.isArray(metadata.chatImageAttachmentIds)
|
|
6099
|
+
&& metadata.chatImageAttachmentIds.some((attachmentId) => typeof attachmentId === "string" && attachmentId.trim().length > 0);
|
|
6100
|
+
});
|
|
6101
|
+
}
|
|
5598
6102
|
getAiConversationInjectedEntities(conversationId, workId) {
|
|
5599
6103
|
const conversation = this.db.get("SELECT work_id, injected_entities_json FROM ai_conversations WHERE id = ?", conversationId);
|
|
5600
6104
|
if (!conversation)
|
|
@@ -6064,6 +6568,8 @@ export class Store {
|
|
|
6064
6568
|
const timestamp = now();
|
|
6065
6569
|
const workId = requiredString(conversation, "work_id");
|
|
6066
6570
|
const sourceTitle = requiredString(conversation, "title");
|
|
6571
|
+
const sourceHasImageAttachments = this.aiConversationHasImageAttachments(conversationId);
|
|
6572
|
+
const sourceLockedModelId = sourceHasImageAttachments ? this.aiConversationLockedModelId(conversationId) : null;
|
|
6067
6573
|
const title = requestedTitle?.trim() || `${sourceTitle} · 分支`;
|
|
6068
6574
|
const sourceCompactedCount = Math.max(0, numberValue(conversation, "compacted_message_count"));
|
|
6069
6575
|
const forkCompactedCount = targetIndex + 1 >= sourceCompactedCount ? Math.min(sourceCompactedCount, targetIndex + 1) : 0;
|
|
@@ -6078,8 +6584,14 @@ export class Store {
|
|
|
6078
6584
|
for (const message of messages.slice(0, targetIndex + 1)) {
|
|
6079
6585
|
const role = requiredString(message, "role");
|
|
6080
6586
|
const inheritedMetadata = json(requiredString(message, "metadata_json"), {});
|
|
6081
|
-
if (role === "user")
|
|
6082
|
-
|
|
6587
|
+
if (role === "user") {
|
|
6588
|
+
if (sourceHasImageAttachments && sourceLockedModelId && typeof inheritedMetadata.modelId !== "string") {
|
|
6589
|
+
inheritedMetadata.modelId = sourceLockedModelId;
|
|
6590
|
+
}
|
|
6591
|
+
else if (!sourceHasImageAttachments) {
|
|
6592
|
+
delete inheritedMetadata.modelId;
|
|
6593
|
+
}
|
|
6594
|
+
}
|
|
6083
6595
|
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, role, requiredString(message, "content"), requiredString(message, "citations_json"), JSON.stringify(inheritedMetadata), optionalString(message, "request_id"), requiredString(message, "created_at"), currentRequestActor()?.userId ?? null);
|
|
6084
6596
|
}
|
|
6085
6597
|
if (normalizedRequestId) {
|
|
@@ -6120,7 +6632,9 @@ export class Store {
|
|
|
6120
6632
|
mapAiConversation(row) {
|
|
6121
6633
|
const roleplayCharacterId = optionalString(row, "roleplay_character_id");
|
|
6122
6634
|
const roleplayUserCharacterId = optionalString(row, "roleplay_user_character_id");
|
|
6123
|
-
const
|
|
6635
|
+
const conversationId = requiredString(row, "id");
|
|
6636
|
+
const lockedModelId = this.aiConversationLockedModelId(conversationId);
|
|
6637
|
+
const hasImageAttachments = this.aiConversationHasImageAttachments(conversationId);
|
|
6124
6638
|
const roleplayCharacter = roleplayCharacterId
|
|
6125
6639
|
? this.db.get("SELECT id, name, code FROM characters WHERE id = ? AND work_id = ?", roleplayCharacterId, requiredString(row, "work_id"))
|
|
6126
6640
|
: undefined;
|
|
@@ -6139,6 +6653,7 @@ export class Store {
|
|
|
6139
6653
|
contextWarningPending: Boolean(optionalString(row, "context_warning_at")),
|
|
6140
6654
|
taskType: optionalString(row, "task_type") ?? (roleplayCharacterId ? "roleplay" : "chat"),
|
|
6141
6655
|
...(lockedModelId ? { modelId: lockedModelId } : {}),
|
|
6656
|
+
...(hasImageAttachments ? { hasImageAttachments: true, modelLockedByImage: true } : {}),
|
|
6142
6657
|
contextScope: json(optionalString(row, "context_scope_json") ?? "", { type: "none" }),
|
|
6143
6658
|
roleplayCharacter: roleplayCharacter ? {
|
|
6144
6659
|
id: requiredString(roleplayCharacter, "id"),
|