@musnows/scriverse 0.8.5 → 0.8.7
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 +530 -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.js +1083 -139
- package/dist/ai.js.map +1 -1
- package/dist/app.js +222 -25
- 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 +363 -4
- 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 +1030 -106
- 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 +6 -4
- package/dist/public/model-config.js +10 -2
- package/dist/public/styles.css +157 -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 +6 -0
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +550 -44
- 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
|
@@ -19,7 +19,7 @@ export const RECYCLE_BIN_RETENTION_DAYS = 30;
|
|
|
19
19
|
function recycleBinExpiresAt(deletedAt) {
|
|
20
20
|
return new Date(new Date(deletedAt).getTime() + RECYCLE_BIN_RETENTION_DAYS * 24 * 60 * 60_000).toISOString();
|
|
21
21
|
}
|
|
22
|
-
export const attachmentPermissionModules = ["prose", "drafts", "settings", "characters", "races", "organizations"];
|
|
22
|
+
export const attachmentPermissionModules = ["prose", "drafts", "settings", "characters", "races", "organizations", "ai-chat"];
|
|
23
23
|
export const WORK_AGENT_TOOL_IDS = [
|
|
24
24
|
"story_index",
|
|
25
25
|
"read_chapters",
|
|
@@ -246,9 +246,13 @@ function parseRestorableFileSnapshot(value, workId) {
|
|
|
246
246
|
}
|
|
247
247
|
const description = volumeValue.description === undefined ? "" : volumeValue.description;
|
|
248
248
|
const keywords = volumeValue.keywords === undefined ? [] : volumeValue.keywords;
|
|
249
|
+
const storyOrder = volumeValue.storyOrder === undefined ? volumeValue.sortOrder : volumeValue.storyOrder;
|
|
249
250
|
if (typeof description !== "string" || !Array.isArray(keywords) || !keywords.every((keyword) => typeof keyword === "string")) {
|
|
250
251
|
return invalidFileSnapshot();
|
|
251
252
|
}
|
|
253
|
+
if (typeof storyOrder !== "number" || !Number.isInteger(storyOrder) || storyOrder < 0 || storyOrder > 1_000_000) {
|
|
254
|
+
return invalidFileSnapshot();
|
|
255
|
+
}
|
|
252
256
|
const chapters = volumeValue.chapters.map((chapterValue) => {
|
|
253
257
|
if (!isRecord(chapterValue) || typeof chapterValue.title !== "string" || typeof chapterValue.content !== "string"
|
|
254
258
|
|| typeof chapterValue.sortOrder !== "number" || !Number.isFinite(chapterValue.sortOrder)
|
|
@@ -273,6 +277,7 @@ function parseRestorableFileSnapshot(value, workId) {
|
|
|
273
277
|
description,
|
|
274
278
|
keywords: [...keywords],
|
|
275
279
|
sortOrder: volumeValue.sortOrder,
|
|
280
|
+
storyOrder,
|
|
276
281
|
chapters
|
|
277
282
|
};
|
|
278
283
|
});
|
|
@@ -453,7 +458,8 @@ export class Store {
|
|
|
453
458
|
source: entity.source,
|
|
454
459
|
description: entity.description,
|
|
455
460
|
keywords: entity.keywords,
|
|
456
|
-
sortOrder: entity.sortOrder
|
|
461
|
+
sortOrder: entity.sortOrder,
|
|
462
|
+
storyOrder: entity.storyOrder
|
|
457
463
|
};
|
|
458
464
|
if (type === "draft")
|
|
459
465
|
return {
|
|
@@ -931,7 +937,10 @@ export class Store {
|
|
|
931
937
|
systemPrompt: String(row?.system_prompt ?? ""),
|
|
932
938
|
dailyTokenQuota: row?.daily_token_quota === null || row?.daily_token_quota === undefined
|
|
933
939
|
? null
|
|
934
|
-
: 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)),
|
|
935
944
|
autoRunEnabled: Number(row?.auto_run_enabled ?? 0) === 1,
|
|
936
945
|
autoRunConcurrency: Math.min(8, Math.max(1, Number(row?.auto_run_concurrency ?? 2) || 2)),
|
|
937
946
|
autoRunBatchLimit: Math.min(200, Math.max(1, Number(row?.auto_run_batch_limit ?? 20) || 20)),
|
|
@@ -967,6 +976,9 @@ export class Store {
|
|
|
967
976
|
const nextDailyTokenQuota = input.dailyTokenQuota === undefined
|
|
968
977
|
? (current.dailyTokenQuota === null ? null : Number(current.dailyTokenQuota))
|
|
969
978
|
: input.dailyTokenQuota;
|
|
979
|
+
const nextMonthlyTokenQuota = input.monthlyTokenQuota === undefined
|
|
980
|
+
? (current.monthlyTokenQuota === null ? null : Number(current.monthlyTokenQuota))
|
|
981
|
+
: input.monthlyTokenQuota;
|
|
970
982
|
const nextEnabled = input.autoRunEnabled ?? Boolean(current.autoRunEnabled);
|
|
971
983
|
const nextConcurrency = input.autoRunConcurrency ?? Number(current.autoRunConcurrency);
|
|
972
984
|
const nextBatchLimit = input.autoRunBatchLimit ?? Number(current.autoRunBatchLimit);
|
|
@@ -986,15 +998,16 @@ export class Store {
|
|
|
986
998
|
? (current.titleGenerationModelId ? String(current.titleGenerationModelId) : null)
|
|
987
999
|
: input.titleGenerationModelId?.trim() || null;
|
|
988
1000
|
this.db.run(`INSERT INTO work_ai_settings (
|
|
989
|
-
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,
|
|
990
1002
|
auto_run_daily_task_limit, auto_run_failure_threshold, auto_run_stability_delay_minutes, auto_run_paused, auto_run_pause_reason,
|
|
991
1003
|
auto_run_resume_at, auto_run_consecutive_failures, book_summary_context_percent,
|
|
992
1004
|
context_compact_threshold, agent_tool_call_limit, agent_tool_call_global_multiplier,
|
|
993
1005
|
agent_tools_json, title_generation_model_id, image_tool_model_id, always_include_setting_info, updated_at
|
|
994
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1006
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
995
1007
|
ON CONFLICT(work_id) DO UPDATE SET
|
|
996
1008
|
system_prompt = excluded.system_prompt,
|
|
997
1009
|
daily_token_quota = excluded.daily_token_quota,
|
|
1010
|
+
monthly_token_quota = excluded.monthly_token_quota,
|
|
998
1011
|
auto_run_enabled = excluded.auto_run_enabled,
|
|
999
1012
|
auto_run_concurrency = excluded.auto_run_concurrency,
|
|
1000
1013
|
auto_run_batch_limit = excluded.auto_run_batch_limit,
|
|
@@ -1013,10 +1026,11 @@ export class Store {
|
|
|
1013
1026
|
title_generation_model_id = excluded.title_generation_model_id,
|
|
1014
1027
|
image_tool_model_id = excluded.image_tool_model_id,
|
|
1015
1028
|
always_include_setting_info = excluded.always_include_setting_info,
|
|
1016
|
-
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);
|
|
1017
1030
|
this.audit(workId, "work.ai-settings.updated", "work-ai-settings", workId, {
|
|
1018
1031
|
systemPromptChanged: input.systemPrompt !== undefined,
|
|
1019
1032
|
dailyTokenQuota: nextDailyTokenQuota,
|
|
1033
|
+
monthlyTokenQuota: nextMonthlyTokenQuota,
|
|
1020
1034
|
autoRunEnabled: nextEnabled,
|
|
1021
1035
|
autoRunConcurrency: Math.min(8, Math.max(1, nextConcurrency)),
|
|
1022
1036
|
autoRunBatchLimit: Math.min(200, Math.max(1, nextBatchLimit)),
|
|
@@ -1322,18 +1336,43 @@ export class Store {
|
|
|
1322
1336
|
const work = this.getWork(workId);
|
|
1323
1337
|
const permissions = work.modulePermissions;
|
|
1324
1338
|
if (permissions.prose === "none")
|
|
1325
|
-
return { totalChapters: 0, chapters: [] };
|
|
1339
|
+
return { totalChapters: 0, latestChaptersByStructure: [], chapters: [] };
|
|
1326
1340
|
const authorNoteFilter = options.excludeAuthorNotes ? " AND chapter.chapter_type <> '作者的话'" : "";
|
|
1327
1341
|
const countRow = this.db.get(`SELECT COUNT(*) AS count FROM chapters chapter
|
|
1328
1342
|
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
1329
1343
|
WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL${authorNoteFilter}`, workId);
|
|
1330
|
-
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
|
|
1331
1345
|
FROM chapters chapter
|
|
1332
1346
|
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
1333
1347
|
WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL${authorNoteFilter}
|
|
1334
|
-
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
|
|
1335
1349
|
LIMIT ? OFFSET ?`, workId, limit, offset);
|
|
1336
|
-
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 });
|
|
1337
1376
|
const summaries = new Map();
|
|
1338
1377
|
if (chapterIds.length > 0) {
|
|
1339
1378
|
const placeholders = chapterIds.map(() => "?").join(", ");
|
|
@@ -1352,18 +1391,116 @@ export class Store {
|
|
|
1352
1391
|
}
|
|
1353
1392
|
return {
|
|
1354
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
|
+
}),
|
|
1355
1404
|
chapters: chapterRows.map((row) => {
|
|
1356
1405
|
const chapterId = requiredString(row, "id");
|
|
1357
1406
|
return {
|
|
1358
1407
|
id: chapterId,
|
|
1359
|
-
volumeTitle: requiredString(row, "volume_title"),
|
|
1360
1408
|
title: requiredString(row, "title"),
|
|
1361
1409
|
versionNo: numberValue(row, "version_no"),
|
|
1410
|
+
storyOrder: storyOrders.get(chapterId),
|
|
1362
1411
|
summary: summaries.get(chapterId) ?? ""
|
|
1363
1412
|
};
|
|
1364
1413
|
})
|
|
1365
1414
|
};
|
|
1366
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
|
+
}
|
|
1367
1504
|
listFileVersions(workId) {
|
|
1368
1505
|
this.getWork(workId);
|
|
1369
1506
|
return this.db
|
|
@@ -1437,7 +1574,8 @@ export class Store {
|
|
|
1437
1574
|
source: volume.source,
|
|
1438
1575
|
description: volume.description,
|
|
1439
1576
|
keywords: volume.keywords,
|
|
1440
|
-
sortOrder: volume.sortOrder
|
|
1577
|
+
sortOrder: volume.sortOrder,
|
|
1578
|
+
storyOrder: volume.storyOrder
|
|
1441
1579
|
}, "restore", fileVersionId, `恢复文件版本 ${fileVersionId}`);
|
|
1442
1580
|
for (const chapter of volume.chapters) {
|
|
1443
1581
|
this.insertChapter(workId, volumeId, chapter.title, chapter.content, chapter.sortOrder, "restore", fileVersionId, chapter.chapterType);
|
|
@@ -1475,6 +1613,7 @@ export class Store {
|
|
|
1475
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)
|
|
1476
1614
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, fileVersionId, workId, fileName, fileType, parsed.wordCount, parsed.paragraphCount, JSON.stringify(parsed.warnings), JSON.stringify(snapshot), timestamp, currentRequestActor()?.userId ?? null);
|
|
1477
1615
|
let volumeOrderOffset = 0;
|
|
1616
|
+
let volumeStoryOrderOffset = 0;
|
|
1478
1617
|
if (mode === "overwrite") {
|
|
1479
1618
|
const activeVolumeIds = this.db.all("SELECT id FROM volumes WHERE work_id = ? AND deleted_at IS NULL", workId)
|
|
1480
1619
|
.map((row) => requiredString(row, "id"));
|
|
@@ -1486,7 +1625,9 @@ export class Store {
|
|
|
1486
1625
|
}
|
|
1487
1626
|
else {
|
|
1488
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);
|
|
1489
1629
|
volumeOrderOffset = numberValue(lastVolume ?? {}, "value") + 1;
|
|
1630
|
+
volumeStoryOrderOffset = numberValue(lastStoryVolume ?? {}, "value") + 1;
|
|
1490
1631
|
}
|
|
1491
1632
|
let firstImportedChapterId = null;
|
|
1492
1633
|
for (const volume of parsed.volumes) {
|
|
@@ -1495,7 +1636,8 @@ export class Store {
|
|
|
1495
1636
|
title: volume.title,
|
|
1496
1637
|
kind: volume.kind,
|
|
1497
1638
|
source: volume.source,
|
|
1498
|
-
sortOrder: volumeOrderOffset + volume.order
|
|
1639
|
+
sortOrder: volumeOrderOffset + volume.order,
|
|
1640
|
+
storyOrder: volumeStoryOrderOffset + volume.order
|
|
1499
1641
|
}, "import", fileVersionId, "导入分卷");
|
|
1500
1642
|
for (const chapter of volume.chapters) {
|
|
1501
1643
|
const chapterId = this.insertChapter(workId, volumeId, chapter.title, chapter.content, chapter.order, "import", fileVersionId, chapter.chapterType);
|
|
@@ -1528,8 +1670,9 @@ export class Store {
|
|
|
1528
1670
|
this.getWork(workId);
|
|
1529
1671
|
const timestamp = now();
|
|
1530
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);
|
|
1531
|
-
this.db.
|
|
1532
|
-
|
|
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);
|
|
1533
1676
|
const versionNo = this.recordEntityVersion("volume", volumeId, source, sourceRef, changeNote || "建立分卷", timestamp);
|
|
1534
1677
|
if (versionNo !== 1)
|
|
1535
1678
|
this.db.run("UPDATE volumes SET version_no = ? WHERE id = ?", versionNo, volumeId);
|
|
@@ -1548,7 +1691,7 @@ export class Store {
|
|
|
1548
1691
|
const current = this.getVolume(volumeId);
|
|
1549
1692
|
this.assertExpectedVersion("volume", volumeId, expectedVersionNo, "分卷", Number(current.versionNo));
|
|
1550
1693
|
const timestamp = now();
|
|
1551
|
-
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);
|
|
1552
1695
|
this.recordEntityVersion("volume", volumeId, source, sourceRef, changeNote || "更新分卷信息", timestamp);
|
|
1553
1696
|
this.audit(String(current.workId), "volume.updated", "volume", volumeId, { ...input, versionNo: Number(current.versionNo) + 1, source, sourceRef, changeNote });
|
|
1554
1697
|
});
|
|
@@ -2550,33 +2693,185 @@ export class Store {
|
|
|
2550
2693
|
this.db.run(`UPDATE chapter_paragraph_line_ranges SET chapter_version = ?
|
|
2551
2694
|
WHERE paragraph_id IN (SELECT id FROM chapter_paragraph_search WHERE chapter_id = ?)`, versionNo, chapterId);
|
|
2552
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
|
+
}
|
|
2553
2735
|
searchChapterParagraphs(workId, keyword, limit = 20, options = {}) {
|
|
2554
|
-
this.getWork(workId);
|
|
2736
|
+
const work = this.getWork(workId);
|
|
2737
|
+
if (work.modulePermissions.prose === "none")
|
|
2738
|
+
return [];
|
|
2555
2739
|
const normalizedKeyword = normalizeDocumentSearchText(keyword.trim());
|
|
2556
2740
|
if (!normalizedKeyword)
|
|
2557
2741
|
return [];
|
|
2742
|
+
if (options.chapterIds && options.chapterIds.length === 0)
|
|
2743
|
+
return [];
|
|
2558
2744
|
const safeLimit = Math.min(100, Math.max(1, Math.trunc(limit)));
|
|
2559
|
-
const
|
|
2560
|
-
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
|
|
2561
2747
|
FROM chapter_paragraph_search paragraph
|
|
2562
2748
|
JOIN chapters chapter ON chapter.id = paragraph.chapter_id
|
|
2563
|
-
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
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
|
+
});
|
|
2580
2875
|
}
|
|
2581
2876
|
invalidateChapter(workId, chapterId, versionNo) {
|
|
2582
2877
|
this.db.run(`UPDATE analysis_tasks SET status = 'expired', updated_at = ?
|
|
@@ -2680,6 +2975,7 @@ export class Store {
|
|
|
2680
2975
|
description: optionalString(row, "description") ?? "",
|
|
2681
2976
|
keywords: json(optionalString(row, "keywords_json"), []),
|
|
2682
2977
|
sortOrder: numberValue(row, "sort_order"),
|
|
2978
|
+
storyOrder: numberValue(row, "story_order"),
|
|
2683
2979
|
versionNo: numberValue(row, "version_no") || this.currentEntityVersionNo("volume", requiredString(row, "id")),
|
|
2684
2980
|
createdAt: requiredString(row, "created_at"),
|
|
2685
2981
|
updatedAt: requiredString(row, "updated_at")
|
|
@@ -4482,6 +4778,48 @@ export class Store {
|
|
|
4482
4778
|
throw notFound("附件");
|
|
4483
4779
|
return this.mapAttachment(row);
|
|
4484
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
|
+
}
|
|
4485
4823
|
getSettingAttachment(workId, attachmentId) {
|
|
4486
4824
|
const row = this.db.get(`SELECT attachment.*
|
|
4487
4825
|
FROM attachments attachment
|
|
@@ -4566,7 +4904,9 @@ export class Store {
|
|
|
4566
4904
|
["chapter_versions", "content"],
|
|
4567
4905
|
["file_versions", "snapshot_json"]
|
|
4568
4906
|
];
|
|
4569
|
-
|
|
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;
|
|
4570
4910
|
}
|
|
4571
4911
|
queueUnreferencedAttachments(retentionMs = 24 * 60 * 60_000, limit = 100) {
|
|
4572
4912
|
const cutoff = new Date(Date.now() - Math.max(0, retentionMs)).toISOString();
|
|
@@ -4615,6 +4955,69 @@ export class Store {
|
|
|
4615
4955
|
throw notFound("角色");
|
|
4616
4956
|
return this.mapCharacter(row);
|
|
4617
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
|
+
}
|
|
4618
5021
|
updateCharacter(characterId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
|
|
4619
5022
|
const current = this.getCharacter(characterId);
|
|
4620
5023
|
this.assertExpectedRevision("character", characterId, expectedVersionNo, "人物", Number(current.versionNo));
|
|
@@ -4801,6 +5204,8 @@ export class Store {
|
|
|
4801
5204
|
versionNo: section.versionNo
|
|
4802
5205
|
}));
|
|
4803
5206
|
}
|
|
5207
|
+
const avatar = this.db.get("SELECT sha256 FROM character_avatars WHERE character_id = ?", characterId);
|
|
5208
|
+
const avatarSha256 = optionalString(avatar ?? {}, "sha256");
|
|
4804
5209
|
return {
|
|
4805
5210
|
id: characterId,
|
|
4806
5211
|
workId: requiredString(row, "work_id"),
|
|
@@ -4824,6 +5229,9 @@ export class Store {
|
|
|
4824
5229
|
profileSectionCount,
|
|
4825
5230
|
currentState: json(requiredString(row, "current_state_json"), {}),
|
|
4826
5231
|
isDead: booleanValue(row, "is_dead"),
|
|
5232
|
+
avatarUrl: avatarSha256
|
|
5233
|
+
? `/api/characters/${encodeURIComponent(characterId)}/avatar?v=${encodeURIComponent(avatarSha256)}`
|
|
5234
|
+
: null,
|
|
4827
5235
|
lockedFields: json(requiredString(row, "locked_fields_json"), []),
|
|
4828
5236
|
firstChapterId: optionalString(row, "first_chapter_id"),
|
|
4829
5237
|
mergedIntoCharacterId: optionalString(row, "merged_into_character_id"),
|
|
@@ -5473,17 +5881,18 @@ export class Store {
|
|
|
5473
5881
|
});
|
|
5474
5882
|
return this.getAiConversation(conversationId);
|
|
5475
5883
|
}
|
|
5476
|
-
listAiConversations(workId) {
|
|
5884
|
+
listAiConversations(workId, userId) {
|
|
5477
5885
|
this.getWork(workId);
|
|
5478
5886
|
return this.db.all(`SELECT conversation.*,
|
|
5479
5887
|
(SELECT COUNT(*) FROM ai_conversation_messages message WHERE message.conversation_id = conversation.id) AS message_count,
|
|
5480
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
|
|
5481
5889
|
FROM ai_conversations conversation
|
|
5482
5890
|
WHERE conversation.work_id = ?
|
|
5891
|
+
AND (? IS NULL OR conversation.created_by_user_id = ?)
|
|
5483
5892
|
ORDER BY conversation.is_favorite DESC, conversation.updated_at DESC, conversation.created_at DESC
|
|
5484
|
-
LIMIT 100`, workId).map((row) => this.mapAiConversation(row));
|
|
5893
|
+
LIMIT 100`, workId, userId ?? null, userId ?? null).map((row) => this.mapAiConversation(row));
|
|
5485
5894
|
}
|
|
5486
|
-
listAiConversationsPage(workId, pagination) {
|
|
5895
|
+
listAiConversationsPage(workId, pagination, userId) {
|
|
5487
5896
|
this.getWork(workId);
|
|
5488
5897
|
const page = paginationSql(pagination);
|
|
5489
5898
|
const rows = this.db.all(`SELECT conversation.*,
|
|
@@ -5491,9 +5900,77 @@ export class Store {
|
|
|
5491
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
|
|
5492
5901
|
FROM ai_conversations conversation
|
|
5493
5902
|
WHERE conversation.work_id = ?
|
|
5494
|
-
|
|
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);
|
|
5495
5905
|
return paginated(rows.map((row) => this.mapAiConversation(row)), pagination);
|
|
5496
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
|
+
}
|
|
5497
5974
|
getAiConversationSummary(conversationId) {
|
|
5498
5975
|
const row = this.db.get(`SELECT conversation.*,
|
|
5499
5976
|
(SELECT COUNT(*) FROM ai_conversation_messages message WHERE message.conversation_id = conversation.id) AS message_count,
|
|
@@ -5593,6 +6070,14 @@ export class Store {
|
|
|
5593
6070
|
throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
|
|
5594
6071
|
return this.aiConversationLockedModelId(conversationId);
|
|
5595
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
|
+
}
|
|
5596
6081
|
aiConversationLockedModelId(conversationId) {
|
|
5597
6082
|
const messages = this.db.all(`SELECT metadata_json FROM ai_conversation_messages
|
|
5598
6083
|
WHERE conversation_id = ? AND role = 'user'
|
|
@@ -5604,6 +6089,16 @@ export class Store {
|
|
|
5604
6089
|
}
|
|
5605
6090
|
return null;
|
|
5606
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
|
+
}
|
|
5607
6102
|
getAiConversationInjectedEntities(conversationId, workId) {
|
|
5608
6103
|
const conversation = this.db.get("SELECT work_id, injected_entities_json FROM ai_conversations WHERE id = ?", conversationId);
|
|
5609
6104
|
if (!conversation)
|
|
@@ -6073,6 +6568,8 @@ export class Store {
|
|
|
6073
6568
|
const timestamp = now();
|
|
6074
6569
|
const workId = requiredString(conversation, "work_id");
|
|
6075
6570
|
const sourceTitle = requiredString(conversation, "title");
|
|
6571
|
+
const sourceHasImageAttachments = this.aiConversationHasImageAttachments(conversationId);
|
|
6572
|
+
const sourceLockedModelId = sourceHasImageAttachments ? this.aiConversationLockedModelId(conversationId) : null;
|
|
6076
6573
|
const title = requestedTitle?.trim() || `${sourceTitle} · 分支`;
|
|
6077
6574
|
const sourceCompactedCount = Math.max(0, numberValue(conversation, "compacted_message_count"));
|
|
6078
6575
|
const forkCompactedCount = targetIndex + 1 >= sourceCompactedCount ? Math.min(sourceCompactedCount, targetIndex + 1) : 0;
|
|
@@ -6087,8 +6584,14 @@ export class Store {
|
|
|
6087
6584
|
for (const message of messages.slice(0, targetIndex + 1)) {
|
|
6088
6585
|
const role = requiredString(message, "role");
|
|
6089
6586
|
const inheritedMetadata = json(requiredString(message, "metadata_json"), {});
|
|
6090
|
-
if (role === "user")
|
|
6091
|
-
|
|
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
|
+
}
|
|
6092
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);
|
|
6093
6596
|
}
|
|
6094
6597
|
if (normalizedRequestId) {
|
|
@@ -6129,7 +6632,9 @@ export class Store {
|
|
|
6129
6632
|
mapAiConversation(row) {
|
|
6130
6633
|
const roleplayCharacterId = optionalString(row, "roleplay_character_id");
|
|
6131
6634
|
const roleplayUserCharacterId = optionalString(row, "roleplay_user_character_id");
|
|
6132
|
-
const
|
|
6635
|
+
const conversationId = requiredString(row, "id");
|
|
6636
|
+
const lockedModelId = this.aiConversationLockedModelId(conversationId);
|
|
6637
|
+
const hasImageAttachments = this.aiConversationHasImageAttachments(conversationId);
|
|
6133
6638
|
const roleplayCharacter = roleplayCharacterId
|
|
6134
6639
|
? this.db.get("SELECT id, name, code FROM characters WHERE id = ? AND work_id = ?", roleplayCharacterId, requiredString(row, "work_id"))
|
|
6135
6640
|
: undefined;
|
|
@@ -6148,6 +6653,7 @@ export class Store {
|
|
|
6148
6653
|
contextWarningPending: Boolean(optionalString(row, "context_warning_at")),
|
|
6149
6654
|
taskType: optionalString(row, "task_type") ?? (roleplayCharacterId ? "roleplay" : "chat"),
|
|
6150
6655
|
...(lockedModelId ? { modelId: lockedModelId } : {}),
|
|
6656
|
+
...(hasImageAttachments ? { hasImageAttachments: true, modelLockedByImage: true } : {}),
|
|
6151
6657
|
contextScope: json(optionalString(row, "context_scope_json") ?? "", { type: "none" }),
|
|
6152
6658
|
roleplayCharacter: roleplayCharacter ? {
|
|
6153
6659
|
id: requiredString(roleplayCharacter, "id"),
|