@musnows/scriverse 0.7.11 → 0.7.13
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/dist/ai.js +468 -90
- package/dist/ai.js.map +1 -1
- package/dist/app.js +67 -2
- package/dist/app.js.map +1 -1
- package/dist/public/app.js +321 -29
- package/dist/public/index.html +4 -4
- package/dist/public/plain-text-paste.js +89 -0
- package/dist/public/stream-typewriter.d.ts +12 -1
- package/dist/public/stream-typewriter.js +65 -5
- package/dist/public/styles.css +85 -1
- package/dist/store.js +384 -97
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +1 -1
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/store.js
CHANGED
|
@@ -13,6 +13,7 @@ import { countWords, documentShortSearchTerms, escapeSqlLikePattern, id, json, n
|
|
|
13
13
|
import { buildWritingCalendar, writingDateKey } from "./writing-progress-time.js";
|
|
14
14
|
import { resolveMaxAgentToolCallLimit } from "./ai-tool-results.js";
|
|
15
15
|
const WORK_LIST_BATCH_SIZE = 500;
|
|
16
|
+
const ENTITY_LIST_BATCH_SIZE = 400;
|
|
16
17
|
export const RECYCLE_BIN_RETENTION_DAYS = 30;
|
|
17
18
|
function recycleBinExpiresAt(deletedAt) {
|
|
18
19
|
return new Date(new Date(deletedAt).getTime() + RECYCLE_BIN_RETENTION_DAYS * 24 * 60 * 60_000).toISOString();
|
|
@@ -154,6 +155,29 @@ function knowledgeSectionsFromInput(sections, settingsMarkdown, settings, fallba
|
|
|
154
155
|
function settingsFromKnowledgeSections(sections) {
|
|
155
156
|
return sections.map((section) => section.contentMarkdown).filter((content) => content.trim());
|
|
156
157
|
}
|
|
158
|
+
export const chapterOutlineBoardForeshadowSortSql = {
|
|
159
|
+
cte: `WITH foreshadow_associations AS MATERIALIZED (
|
|
160
|
+
SELECT foreshadow.work_id, occurrence.chapter_id, foreshadow.id AS foreshadow_id, foreshadow.status
|
|
161
|
+
FROM foreshadows foreshadow
|
|
162
|
+
JOIN foreshadow_occurrences occurrence ON occurrence.foreshadow_id = foreshadow.id
|
|
163
|
+
WHERE foreshadow.work_id = ?
|
|
164
|
+
UNION
|
|
165
|
+
SELECT foreshadow.work_id, foreshadow.planned_payoff_chapter_id AS chapter_id,
|
|
166
|
+
foreshadow.id AS foreshadow_id, foreshadow.status
|
|
167
|
+
FROM foreshadows foreshadow
|
|
168
|
+
WHERE foreshadow.work_id = ? AND foreshadow.planned_payoff_chapter_id IS NOT NULL
|
|
169
|
+
), foreshadow_association_counts AS MATERIALIZED (
|
|
170
|
+
SELECT association.work_id, association.chapter_id,
|
|
171
|
+
SUM(CASE WHEN association.status IN ('planned', 'planted') THEN 1 ELSE 0 END) AS unresolved_count,
|
|
172
|
+
COUNT(*) AS total_count
|
|
173
|
+
FROM foreshadow_associations association
|
|
174
|
+
GROUP BY association.work_id, association.chapter_id
|
|
175
|
+
)`,
|
|
176
|
+
join: `LEFT JOIN foreshadow_association_counts sorted_association
|
|
177
|
+
ON sorted_association.work_id = chapter.work_id AND sorted_association.chapter_id = chapter.id`,
|
|
178
|
+
order: `COALESCE(sorted_association.unresolved_count, 0) DESC,
|
|
179
|
+
COALESCE(sorted_association.total_count, 0) DESC`
|
|
180
|
+
};
|
|
157
181
|
const CHAPTER_OUTLINE_BOARD_PREVIEW_LENGTH = 600;
|
|
158
182
|
function chapterOutlineBoardLikePattern(value) {
|
|
159
183
|
return `%${value.normalize("NFKC").toLocaleLowerCase("zh-CN").replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_")}%`;
|
|
@@ -315,6 +339,18 @@ export class Store {
|
|
|
315
339
|
const row = this.db.get("SELECT MAX(version_no) AS version_no FROM entity_versions WHERE entity_type = ? AND entity_id = ?", type, entityId);
|
|
316
340
|
return numberValue(row ?? {}, "version_no");
|
|
317
341
|
}
|
|
342
|
+
currentEntityVersionNos(type, entityIds) {
|
|
343
|
+
const versions = new Map();
|
|
344
|
+
for (let offset = 0; offset < entityIds.length; offset += ENTITY_LIST_BATCH_SIZE) {
|
|
345
|
+
const batchIds = entityIds.slice(offset, offset + ENTITY_LIST_BATCH_SIZE);
|
|
346
|
+
const placeholders = batchIds.map(() => "?").join(", ");
|
|
347
|
+
const rows = this.db.all(`SELECT entity_id, MAX(version_no) AS version_no FROM entity_versions
|
|
348
|
+
WHERE entity_type = ? AND entity_id IN (${placeholders}) GROUP BY entity_id`, type, ...batchIds);
|
|
349
|
+
for (const row of rows)
|
|
350
|
+
versions.set(requiredString(row, "entity_id"), numberValue(row, "version_no"));
|
|
351
|
+
}
|
|
352
|
+
return versions;
|
|
353
|
+
}
|
|
318
354
|
currentChapterVersionNo(chapterId) {
|
|
319
355
|
return numberValue(this.db.get("SELECT MAX(version_no) AS version_no FROM chapter_versions WHERE chapter_id = ?", chapterId) ?? {}, "version_no");
|
|
320
356
|
}
|
|
@@ -1240,18 +1276,19 @@ export class Store {
|
|
|
1240
1276
|
}));
|
|
1241
1277
|
return { ...work, volumes, directoryPage: pageResult };
|
|
1242
1278
|
}
|
|
1243
|
-
getStoryIndexChapterPage(workId, offset, limit) {
|
|
1279
|
+
getStoryIndexChapterPage(workId, offset, limit, options = {}) {
|
|
1244
1280
|
const work = this.getWork(workId);
|
|
1245
1281
|
const permissions = work.modulePermissions;
|
|
1246
1282
|
if (permissions.prose === "none")
|
|
1247
1283
|
return { totalChapters: 0, chapters: [] };
|
|
1284
|
+
const authorNoteFilter = options.excludeAuthorNotes ? " AND chapter.chapter_type <> '作者的话'" : "";
|
|
1248
1285
|
const countRow = this.db.get(`SELECT COUNT(*) AS count FROM chapters chapter
|
|
1249
1286
|
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
1250
|
-
WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL`, workId);
|
|
1287
|
+
WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL${authorNoteFilter}`, workId);
|
|
1251
1288
|
const chapterRows = this.db.all(`SELECT chapter.id, chapter.title, chapter.version_no, volume.title AS volume_title
|
|
1252
1289
|
FROM chapters chapter
|
|
1253
1290
|
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
1254
|
-
WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL
|
|
1291
|
+
WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL${authorNoteFilter}
|
|
1255
1292
|
ORDER BY volume.sort_order, volume.created_at, chapter.sort_order, chapter.created_at
|
|
1256
1293
|
LIMIT ? OFFSET ?`, workId, limit, offset);
|
|
1257
1294
|
const chapterIds = chapterRows.map((row) => requiredString(row, "id"));
|
|
@@ -1492,7 +1529,12 @@ export class Store {
|
|
|
1492
1529
|
this.db.run(`UPDATE analysis_tasks SET status = 'expired', updated_at = ?
|
|
1493
1530
|
WHERE work_id = ? AND status IN ('pending', 'running', 'completed', 'partial', 'review')
|
|
1494
1531
|
AND (json_extract(scope_json, '$.type') = 'book'
|
|
1495
|
-
OR (json_extract(scope_json, '$.type') = 'volume' AND json_extract(scope_json, '$.volumeId') = ?)
|
|
1532
|
+
OR (json_extract(scope_json, '$.type') = 'volume' AND json_extract(scope_json, '$.volumeId') = ?)
|
|
1533
|
+
OR EXISTS (SELECT 1 FROM json_each(scope_json, '$.volumeIds') WHERE json_each.value = ?)
|
|
1534
|
+
OR EXISTS (
|
|
1535
|
+
SELECT 1 FROM json_each(scope_json, '$.chapterIds') selected_chapter
|
|
1536
|
+
WHERE selected_chapter.value IN (SELECT id FROM chapters WHERE volume_id = ?)
|
|
1537
|
+
))`, timestamp, workId, volumeId, volumeId, volumeId);
|
|
1496
1538
|
this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", timestamp, workId);
|
|
1497
1539
|
this.audit(workId, "volume.deleted", "volume", volumeId, {
|
|
1498
1540
|
versionNo,
|
|
@@ -2412,12 +2454,13 @@ export class Store {
|
|
|
2412
2454
|
this.db.run(`UPDATE chapter_paragraph_line_ranges SET chapter_version = ?
|
|
2413
2455
|
WHERE paragraph_id IN (SELECT id FROM chapter_paragraph_search WHERE chapter_id = ?)`, versionNo, chapterId);
|
|
2414
2456
|
}
|
|
2415
|
-
searchChapterParagraphs(workId, keyword, limit = 20) {
|
|
2457
|
+
searchChapterParagraphs(workId, keyword, limit = 20, options = {}) {
|
|
2416
2458
|
this.getWork(workId);
|
|
2417
2459
|
const normalizedKeyword = normalizeDocumentSearchText(keyword.trim());
|
|
2418
2460
|
if (!normalizedKeyword)
|
|
2419
2461
|
return [];
|
|
2420
2462
|
const safeLimit = Math.min(100, Math.max(1, Math.trunc(limit)));
|
|
2463
|
+
const authorNoteFilter = options.excludeAuthorNotes ? " AND chapter.chapter_type <> '作者的话'" : "";
|
|
2421
2464
|
const columns = `SELECT paragraph.chapter_id, chapter.title AS chapter_title, paragraph.content
|
|
2422
2465
|
FROM chapter_paragraph_search paragraph
|
|
2423
2466
|
JOIN chapters chapter ON chapter.id = paragraph.chapter_id
|
|
@@ -2425,12 +2468,12 @@ export class Store {
|
|
|
2425
2468
|
const rows = [...normalizedKeyword].length < 3
|
|
2426
2469
|
? this.db.all(`${columns}
|
|
2427
2470
|
JOIN chapter_paragraph_short_terms term ON term.paragraph_id = paragraph.id
|
|
2428
|
-
WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL AND term.term = ?
|
|
2471
|
+
WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL${authorNoteFilter} AND term.term = ?
|
|
2429
2472
|
ORDER BY volume.sort_order, chapter.sort_order, paragraph.paragraph_order
|
|
2430
2473
|
LIMIT ?`, workId, normalizedKeyword, safeLimit)
|
|
2431
2474
|
: this.db.all(`${columns}
|
|
2432
2475
|
JOIN chapter_paragraph_search_fts fts ON fts.rowid = paragraph.id
|
|
2433
|
-
WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL AND chapter_paragraph_search_fts MATCH ?
|
|
2476
|
+
WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL${authorNoteFilter} AND chapter_paragraph_search_fts MATCH ?
|
|
2434
2477
|
ORDER BY volume.sort_order, chapter.sort_order, paragraph.paragraph_order
|
|
2435
2478
|
LIMIT ?`, workId, `"${normalizedKeyword.replaceAll('"', '""')}"`, safeLimit);
|
|
2436
2479
|
return rows.map((row) => ({
|
|
@@ -2445,9 +2488,14 @@ export class Store {
|
|
|
2445
2488
|
AND NOT (status = 'pending' AND task_type = 'chapter-analysis'
|
|
2446
2489
|
AND json_extract(scope_json, '$.chapterId') = ?)
|
|
2447
2490
|
AND (json_extract(scope_json, '$.chapterId') = ?
|
|
2491
|
+
OR EXISTS (SELECT 1 FROM json_each(scope_json, '$.chapterIds') WHERE json_each.value = ?)
|
|
2448
2492
|
OR json_extract(scope_json, '$.type') = 'book'
|
|
2449
2493
|
OR (json_extract(scope_json, '$.type') = 'volume'
|
|
2450
|
-
AND json_extract(scope_json, '$.volumeId') = (SELECT volume_id FROM chapters WHERE id = ?)
|
|
2494
|
+
AND (json_extract(scope_json, '$.volumeId') = (SELECT volume_id FROM chapters WHERE id = ?)
|
|
2495
|
+
OR EXISTS (
|
|
2496
|
+
SELECT 1 FROM json_each(scope_json, '$.volumeIds')
|
|
2497
|
+
WHERE json_each.value = (SELECT volume_id FROM chapters WHERE id = ?)
|
|
2498
|
+
))))`, now(), workId, chapterId, chapterId, chapterId, chapterId, chapterId);
|
|
2451
2499
|
const existing = this.db.get(`SELECT id FROM analysis_tasks WHERE work_id = ? AND task_type = 'chapter-analysis' AND status = 'pending'
|
|
2452
2500
|
AND json_extract(scope_json, '$.chapterId') = ?`, workId, chapterId);
|
|
2453
2501
|
if (!existing) {
|
|
@@ -2653,33 +2701,25 @@ export class Store {
|
|
|
2653
2701
|
whereParams.push(...Array.from({ length: 8 }, () => pattern));
|
|
2654
2702
|
}
|
|
2655
2703
|
const whereSql = where.join(" AND ");
|
|
2656
|
-
const associationCount = (statusSql = "") => `(
|
|
2657
|
-
SELECT COUNT(*) FROM foreshadows sorted_foreshadow
|
|
2658
|
-
WHERE sorted_foreshadow.work_id = chapter.work_id${statusSql}
|
|
2659
|
-
AND (
|
|
2660
|
-
sorted_foreshadow.planned_payoff_chapter_id = chapter.id
|
|
2661
|
-
OR EXISTS (
|
|
2662
|
-
SELECT 1 FROM foreshadow_occurrences sorted_occurrence
|
|
2663
|
-
WHERE sorted_occurrence.foreshadow_id = sorted_foreshadow.id
|
|
2664
|
-
AND sorted_occurrence.chapter_id = chapter.id
|
|
2665
|
-
)
|
|
2666
|
-
)
|
|
2667
|
-
)`;
|
|
2668
2704
|
const chapterTreeOrder = "chapter.sort_order, chapter.created_at, chapter.id";
|
|
2669
2705
|
const chapterOrder = filters.sort === "status"
|
|
2670
2706
|
? `CASE WHEN outline.chapter_id IS NULL THEN 0 WHEN outline.status = 'draft' THEN 1 WHEN outline.status = 'ready' THEN 2 ELSE 3 END, ${chapterTreeOrder}`
|
|
2671
2707
|
: filters.sort === "foreshadows"
|
|
2672
|
-
? `${
|
|
2708
|
+
? `${chapterOutlineBoardForeshadowSortSql.order}, ${chapterTreeOrder}`
|
|
2673
2709
|
: filters.sort === "title"
|
|
2674
2710
|
? `chapter.title COLLATE NOCASE, ${chapterTreeOrder}`
|
|
2675
2711
|
: chapterTreeOrder;
|
|
2676
2712
|
const orderSql = `volume.sort_order, volume.created_at, volume.id, ${chapterOrder}`;
|
|
2713
|
+
const foreshadowSortCte = filters.sort === "foreshadows" ? chapterOutlineBoardForeshadowSortSql.cte : "";
|
|
2714
|
+
const foreshadowSortJoin = filters.sort === "foreshadows" ? chapterOutlineBoardForeshadowSortSql.join : "";
|
|
2715
|
+
const foreshadowSortParams = filters.sort === "foreshadows" ? [workId, workId] : [];
|
|
2677
2716
|
const total = numberValue(this.db.get(`SELECT COUNT(*) AS count
|
|
2678
2717
|
FROM chapters chapter
|
|
2679
2718
|
JOIN volumes volume ON volume.id = chapter.volume_id AND volume.work_id = chapter.work_id
|
|
2680
2719
|
LEFT JOIN chapter_outlines outline ON outline.chapter_id = chapter.id
|
|
2681
2720
|
WHERE ${whereSql}`, ...whereParams) ?? {}, "count");
|
|
2682
|
-
const pageRows = this.db.all(
|
|
2721
|
+
const pageRows = this.db.all(`${foreshadowSortCte}
|
|
2722
|
+
SELECT volume.id AS volume_id, volume.title AS volume_title, volume.sort_order AS volume_order,
|
|
2683
2723
|
chapter.id AS chapter_id, chapter.title AS chapter_title, chapter.chapter_type,
|
|
2684
2724
|
chapter.sort_order AS chapter_order,
|
|
2685
2725
|
outline.chapter_id AS outline_chapter_id,
|
|
@@ -2691,9 +2731,10 @@ export class Store {
|
|
|
2691
2731
|
FROM chapters chapter
|
|
2692
2732
|
JOIN volumes volume ON volume.id = chapter.volume_id AND volume.work_id = chapter.work_id
|
|
2693
2733
|
LEFT JOIN chapter_outlines outline ON outline.chapter_id = chapter.id
|
|
2734
|
+
${foreshadowSortJoin}
|
|
2694
2735
|
WHERE ${whereSql}
|
|
2695
2736
|
ORDER BY ${orderSql}
|
|
2696
|
-
LIMIT ? OFFSET ?`, previewLength, previewLength, previewLength, previewLength, previewLength, previewLength, previewLength, previewLength, ...whereParams, pagination.limit + 1, pagination.offset);
|
|
2737
|
+
LIMIT ? OFFSET ?`, ...foreshadowSortParams, previewLength, previewLength, previewLength, previewLength, previewLength, previewLength, previewLength, previewLength, ...whereParams, pagination.limit + 1, pagination.offset);
|
|
2697
2738
|
const chapterPage = paginated(pageRows, pagination, total);
|
|
2698
2739
|
const volumeRows = this.db.all(`SELECT volume.id, volume.title, volume.sort_order,
|
|
2699
2740
|
COUNT(chapter.id) AS chapter_count
|
|
@@ -3003,20 +3044,25 @@ export class Store {
|
|
|
3003
3044
|
WHERE fo.foreshadow_id = ? ORDER BY v.sort_order, c.sort_order, fo.created_at`, foreshadowId).map((item) => this.mapForeshadowOccurrence(item));
|
|
3004
3045
|
const status = requiredString(row, "status");
|
|
3005
3046
|
const plannedPayoffChapterId = optionalString(row, "planned_payoff_chapter_id");
|
|
3047
|
+
const overdue = Boolean(currentChapterId && plannedPayoffChapterId && ["planned", "planted"].includes(status)
|
|
3048
|
+
&& this.chapterSequence(workId, plannedPayoffChapterId) < this.chapterSequence(workId, currentChapterId));
|
|
3049
|
+
return this.mapForeshadow(row, occurrences, this.currentEntityVersionNo("foreshadow", foreshadowId), overdue);
|
|
3050
|
+
}
|
|
3051
|
+
mapForeshadow(row, occurrences, versionNo, overdue) {
|
|
3052
|
+
const status = requiredString(row, "status");
|
|
3006
3053
|
return {
|
|
3007
3054
|
id: requiredString(row, "id"),
|
|
3008
|
-
workId,
|
|
3055
|
+
workId: requiredString(row, "work_id"),
|
|
3009
3056
|
title: requiredString(row, "title"),
|
|
3010
3057
|
description: requiredString(row, "description"),
|
|
3011
3058
|
status,
|
|
3012
3059
|
importance: requiredString(row, "importance"),
|
|
3013
|
-
plannedPayoffChapterId,
|
|
3060
|
+
plannedPayoffChapterId: optionalString(row, "planned_payoff_chapter_id"),
|
|
3014
3061
|
resolutionNote: requiredString(row, "resolution_note"),
|
|
3015
3062
|
unresolved: status === "planned" || status === "planted",
|
|
3016
|
-
overdue
|
|
3017
|
-
&& this.chapterSequence(workId, plannedPayoffChapterId) < this.chapterSequence(workId, currentChapterId)),
|
|
3063
|
+
overdue,
|
|
3018
3064
|
occurrences,
|
|
3019
|
-
versionNo
|
|
3065
|
+
versionNo,
|
|
3020
3066
|
createdAt: requiredString(row, "created_at"),
|
|
3021
3067
|
updatedAt: requiredString(row, "updated_at")
|
|
3022
3068
|
};
|
|
@@ -3028,8 +3074,9 @@ export class Store {
|
|
|
3028
3074
|
const where = status === "unresolved"
|
|
3029
3075
|
? "AND status IN ('planned', 'planted')"
|
|
3030
3076
|
: status === "resolved" ? "AND status IN ('resolved', 'abandoned')" : "";
|
|
3031
|
-
|
|
3032
|
-
ORDER BY CASE importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, created_at`, workId)
|
|
3077
|
+
const rows = this.db.all(`SELECT * FROM foreshadows WHERE work_id = ? ${where}
|
|
3078
|
+
ORDER BY CASE importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, created_at`, workId);
|
|
3079
|
+
return this.mapForeshadowList(rows, currentChapterId);
|
|
3033
3080
|
}
|
|
3034
3081
|
listForeshadowsPage(workId, pagination, status = "all", currentChapterId) {
|
|
3035
3082
|
this.getWork(workId);
|
|
@@ -3039,9 +3086,63 @@ export class Store {
|
|
|
3039
3086
|
? "AND status IN ('planned', 'planted')"
|
|
3040
3087
|
: status === "resolved" ? "AND status IN ('resolved', 'abandoned')" : "";
|
|
3041
3088
|
const page = paginationSql(pagination);
|
|
3042
|
-
const rows = this.db.all(`SELECT
|
|
3089
|
+
const rows = this.db.all(`SELECT * FROM foreshadows WHERE work_id = ? ${where}
|
|
3043
3090
|
ORDER BY CASE importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, created_at${page.sql}`, workId, ...page.params);
|
|
3044
|
-
return paginated(
|
|
3091
|
+
return paginated(this.mapForeshadowList(rows, currentChapterId), pagination);
|
|
3092
|
+
}
|
|
3093
|
+
mapForeshadowList(rows, currentChapterId) {
|
|
3094
|
+
if (rows.length === 0)
|
|
3095
|
+
return [];
|
|
3096
|
+
const foreshadowIds = rows.map((row) => requiredString(row, "id"));
|
|
3097
|
+
const batch = {
|
|
3098
|
+
occurrences: new Map(),
|
|
3099
|
+
versions: this.currentEntityVersionNos("foreshadow", foreshadowIds),
|
|
3100
|
+
chapterSequences: new Map()
|
|
3101
|
+
};
|
|
3102
|
+
for (let offset = 0; offset < foreshadowIds.length; offset += ENTITY_LIST_BATCH_SIZE) {
|
|
3103
|
+
const batchIds = foreshadowIds.slice(offset, offset + ENTITY_LIST_BATCH_SIZE);
|
|
3104
|
+
const placeholders = batchIds.map(() => "?").join(", ");
|
|
3105
|
+
const occurrences = this.db.all(`SELECT fo.*, c.title AS chapter_title, c.volume_id, c.sort_order AS chapter_order,
|
|
3106
|
+
v.title AS volume_title, v.sort_order AS volume_order
|
|
3107
|
+
FROM foreshadow_occurrences fo
|
|
3108
|
+
JOIN chapters c ON c.id = fo.chapter_id
|
|
3109
|
+
JOIN volumes v ON v.id = c.volume_id
|
|
3110
|
+
WHERE fo.foreshadow_id IN (${placeholders})
|
|
3111
|
+
ORDER BY fo.foreshadow_id, v.sort_order, c.sort_order, fo.created_at`, ...batchIds);
|
|
3112
|
+
for (const occurrence of occurrences) {
|
|
3113
|
+
const foreshadowId = requiredString(occurrence, "foreshadow_id");
|
|
3114
|
+
const grouped = batch.occurrences.get(foreshadowId) ?? [];
|
|
3115
|
+
grouped.push(this.mapForeshadowOccurrence(occurrence));
|
|
3116
|
+
batch.occurrences.set(foreshadowId, grouped);
|
|
3117
|
+
}
|
|
3118
|
+
}
|
|
3119
|
+
if (currentChapterId) {
|
|
3120
|
+
const chapterIds = [...new Set([
|
|
3121
|
+
currentChapterId,
|
|
3122
|
+
...rows.map((row) => optionalString(row, "planned_payoff_chapter_id")).filter((chapterId) => Boolean(chapterId))
|
|
3123
|
+
])];
|
|
3124
|
+
for (let offset = 0; offset < chapterIds.length; offset += ENTITY_LIST_BATCH_SIZE) {
|
|
3125
|
+
const batchIds = chapterIds.slice(offset, offset + ENTITY_LIST_BATCH_SIZE);
|
|
3126
|
+
const placeholders = batchIds.map(() => "?").join(", ");
|
|
3127
|
+
const sequences = this.db.all(`SELECT c.id, v.sort_order * 1000000 + c.sort_order AS sequence
|
|
3128
|
+
FROM chapters c JOIN volumes v ON v.id = c.volume_id
|
|
3129
|
+
WHERE c.id IN (${placeholders}) AND c.work_id = ?`, ...batchIds, requiredString(rows[0] ?? {}, "work_id"));
|
|
3130
|
+
for (const sequence of sequences) {
|
|
3131
|
+
batch.chapterSequences.set(requiredString(sequence, "id"), numberValue(sequence, "sequence"));
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
const currentChapterSequence = currentChapterId
|
|
3136
|
+
? batch.chapterSequences.get(currentChapterId) ?? Number.MAX_SAFE_INTEGER
|
|
3137
|
+
: Number.MAX_SAFE_INTEGER;
|
|
3138
|
+
return rows.map((row) => {
|
|
3139
|
+
const foreshadowId = requiredString(row, "id");
|
|
3140
|
+
const status = requiredString(row, "status");
|
|
3141
|
+
const plannedPayoffChapterId = optionalString(row, "planned_payoff_chapter_id");
|
|
3142
|
+
const overdue = Boolean(currentChapterId && plannedPayoffChapterId && ["planned", "planted"].includes(status)
|
|
3143
|
+
&& (batch.chapterSequences.get(plannedPayoffChapterId) ?? Number.MAX_SAFE_INTEGER) < currentChapterSequence);
|
|
3144
|
+
return this.mapForeshadow(row, batch.occurrences.get(foreshadowId) ?? [], batch.versions.get(foreshadowId) ?? 0, overdue);
|
|
3145
|
+
});
|
|
3045
3146
|
}
|
|
3046
3147
|
listChapterForeshadowReminders(workId, chapterId) {
|
|
3047
3148
|
this.getWork(workId);
|
|
@@ -3724,13 +3825,16 @@ export class Store {
|
|
|
3724
3825
|
}
|
|
3725
3826
|
listOrganizations(workId, includeMarkdown = true) {
|
|
3726
3827
|
this.getWork(workId);
|
|
3727
|
-
|
|
3828
|
+
const rows = this.db.all("SELECT * FROM organizations WHERE work_id = ? ORDER BY name", workId);
|
|
3829
|
+
const batch = this.organizationListBatch(rows);
|
|
3830
|
+
return rows.map((row) => this.mapOrganization(row, includeMarkdown, batch));
|
|
3728
3831
|
}
|
|
3729
3832
|
listOrganizationsPage(workId, pagination, includeMarkdown = true) {
|
|
3730
3833
|
this.getWork(workId);
|
|
3731
3834
|
const page = paginationSql(pagination);
|
|
3732
3835
|
const rows = this.db.all(`SELECT * FROM organizations WHERE work_id = ? ORDER BY name${page.sql}`, workId, ...page.params);
|
|
3733
|
-
|
|
3836
|
+
const batch = this.organizationListBatch(rows);
|
|
3837
|
+
return paginated(rows.map((row) => this.mapOrganization(row, includeMarkdown, batch)), pagination);
|
|
3734
3838
|
}
|
|
3735
3839
|
getOrganization(organizationId) {
|
|
3736
3840
|
const row = this.db.get("SELECT * FROM organizations WHERE id = ?", organizationId);
|
|
@@ -3817,20 +3921,50 @@ export class Store {
|
|
|
3817
3921
|
});
|
|
3818
3922
|
return { mergeId, target: this.getOrganization(targetOrganizationId), source };
|
|
3819
3923
|
}
|
|
3820
|
-
|
|
3924
|
+
organizationListBatch(rows) {
|
|
3925
|
+
const organizationIds = rows.map((row) => requiredString(row, "id"));
|
|
3926
|
+
const batch = {
|
|
3927
|
+
members: new Map(),
|
|
3928
|
+
versions: this.currentEntityVersionNos("organization", organizationIds)
|
|
3929
|
+
};
|
|
3930
|
+
for (let offset = 0; offset < organizationIds.length; offset += ENTITY_LIST_BATCH_SIZE) {
|
|
3931
|
+
const batchIds = organizationIds.slice(offset, offset + ENTITY_LIST_BATCH_SIZE);
|
|
3932
|
+
const placeholders = batchIds.map(() => "?").join(", ");
|
|
3933
|
+
const members = this.db.all(`SELECT m.organization_id, c.id, c.name, m.role, m.note
|
|
3934
|
+
FROM character_organization_memberships m
|
|
3935
|
+
JOIN characters c ON c.id = m.character_id
|
|
3936
|
+
WHERE m.organization_id IN (${placeholders}) ORDER BY m.organization_id, c.name`, ...batchIds);
|
|
3937
|
+
for (const member of members) {
|
|
3938
|
+
const organizationId = requiredString(member, "organization_id");
|
|
3939
|
+
const grouped = batch.members.get(organizationId) ?? [];
|
|
3940
|
+
grouped.push({
|
|
3941
|
+
characterId: requiredString(member, "id"),
|
|
3942
|
+
name: requiredString(member, "name"),
|
|
3943
|
+
role: requiredString(member, "role"),
|
|
3944
|
+
note: requiredString(member, "note")
|
|
3945
|
+
});
|
|
3946
|
+
batch.members.set(organizationId, grouped);
|
|
3947
|
+
}
|
|
3948
|
+
}
|
|
3949
|
+
return batch;
|
|
3950
|
+
}
|
|
3951
|
+
mapOrganization(row, includeMarkdown = true, batch) {
|
|
3952
|
+
const organizationId = requiredString(row, "id");
|
|
3821
3953
|
const settingsSections = knowledgeSectionsFromStored(row.settings_sections_json, json(requiredString(row, "settings_json"), []));
|
|
3822
3954
|
const settings = settingsFromKnowledgeSections(settingsSections);
|
|
3823
|
-
const members =
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3955
|
+
const members = batch
|
|
3956
|
+
? batch.members.get(organizationId) ?? []
|
|
3957
|
+
: this.db.all(`SELECT c.id, c.name, m.role, m.note
|
|
3958
|
+
FROM character_organization_memberships m
|
|
3959
|
+
JOIN characters c ON c.id = m.character_id
|
|
3960
|
+
WHERE m.organization_id = ? ORDER BY c.name`, organizationId).map((member) => ({
|
|
3961
|
+
characterId: requiredString(member, "id"),
|
|
3962
|
+
name: requiredString(member, "name"),
|
|
3963
|
+
role: requiredString(member, "role"),
|
|
3964
|
+
note: requiredString(member, "note")
|
|
3965
|
+
}));
|
|
3832
3966
|
return {
|
|
3833
|
-
id:
|
|
3967
|
+
id: organizationId,
|
|
3834
3968
|
workId: requiredString(row, "work_id"),
|
|
3835
3969
|
name: requiredString(row, "name"),
|
|
3836
3970
|
description: requiredString(row, "description"),
|
|
@@ -3840,7 +3974,7 @@ export class Store {
|
|
|
3840
3974
|
: { settings: [], settingsCount: settingsSections.length }),
|
|
3841
3975
|
memberIds: members.map((member) => member.characterId),
|
|
3842
3976
|
members,
|
|
3843
|
-
versionNo: this.currentEntityVersionNo("organization",
|
|
3977
|
+
versionNo: batch ? batch.versions.get(organizationId) ?? 0 : this.currentEntityVersionNo("organization", organizationId),
|
|
3844
3978
|
createdAt: requiredString(row, "created_at"),
|
|
3845
3979
|
updatedAt: requiredString(row, "updated_at")
|
|
3846
3980
|
};
|
|
@@ -5626,6 +5760,43 @@ export class Store {
|
|
|
5626
5760
|
throw notFound("AI 对话消息");
|
|
5627
5761
|
return this.mapAiConversationMessage(persistInterruption(message));
|
|
5628
5762
|
}
|
|
5763
|
+
upsertAiConversationAssistantMessage(conversationId, requestId, content, metadata = {}, syncSearchIndex = false) {
|
|
5764
|
+
const normalizedRequestId = requestId.trim();
|
|
5765
|
+
if (!normalizedRequestId)
|
|
5766
|
+
throw new AppError(400, "AI_MESSAGE_REQUEST_ID_REQUIRED", "AI 助手消息缺少请求标识");
|
|
5767
|
+
const existing = this.db.get("SELECT * FROM ai_conversation_messages WHERE conversation_id = ? AND request_id = ?", conversationId, normalizedRequestId);
|
|
5768
|
+
if (!existing) {
|
|
5769
|
+
return this.addAiConversationMessage(conversationId, {
|
|
5770
|
+
role: "assistant",
|
|
5771
|
+
content,
|
|
5772
|
+
requestId: normalizedRequestId,
|
|
5773
|
+
metadata
|
|
5774
|
+
});
|
|
5775
|
+
}
|
|
5776
|
+
if (requiredString(existing, "role") !== "assistant") {
|
|
5777
|
+
throw new AppError(409, "AI_MESSAGE_ROLE_MISMATCH", "请求标识已用于用户消息");
|
|
5778
|
+
}
|
|
5779
|
+
const currentMetadata = json(requiredString(existing, "metadata_json"), {});
|
|
5780
|
+
const nextMetadata = { ...currentMetadata, ...metadata };
|
|
5781
|
+
const contentChanged = requiredString(existing, "content") !== content;
|
|
5782
|
+
const metadataChanged = JSON.stringify(currentMetadata) !== JSON.stringify(nextMetadata);
|
|
5783
|
+
if (!contentChanged && !metadataChanged) {
|
|
5784
|
+
if (syncSearchIndex)
|
|
5785
|
+
this.syncAiHistorySearchShortTermsForSource("message", requiredString(existing, "id"));
|
|
5786
|
+
return this.mapAiConversationMessage(existing);
|
|
5787
|
+
}
|
|
5788
|
+
const timestamp = now();
|
|
5789
|
+
this.db.transaction(() => {
|
|
5790
|
+
this.db.run("UPDATE ai_conversation_messages SET content = ?, metadata_json = ? WHERE id = ?", content, JSON.stringify(nextMetadata), requiredString(existing, "id"));
|
|
5791
|
+
this.db.run("UPDATE ai_conversations SET updated_at = ? WHERE id = ?", timestamp, conversationId);
|
|
5792
|
+
if (syncSearchIndex)
|
|
5793
|
+
this.syncAiHistorySearchShortTermsForSource("message", requiredString(existing, "id"));
|
|
5794
|
+
});
|
|
5795
|
+
const updated = this.db.get("SELECT * FROM ai_conversation_messages WHERE id = ?", requiredString(existing, "id"));
|
|
5796
|
+
if (!updated)
|
|
5797
|
+
throw notFound("AI 对话消息");
|
|
5798
|
+
return this.mapAiConversationMessage(updated);
|
|
5799
|
+
}
|
|
5629
5800
|
beginAiConversationStreamRequest(input, referenceTime = new Date()) {
|
|
5630
5801
|
const timestamp = referenceTime.toISOString();
|
|
5631
5802
|
const leaseExpiresAt = new Date(referenceTime.getTime() + AI_CONVERSATION_STREAM_REQUEST_LEASE_MS).toISOString();
|
|
@@ -5743,10 +5914,18 @@ export class Store {
|
|
|
5743
5914
|
if (!assistant)
|
|
5744
5915
|
throw new AppError(400, "AI_STREAM_ASSISTANT_MISMATCH", "AI 回复消息不属于当前对话请求");
|
|
5745
5916
|
}
|
|
5917
|
+
const resolvedAssistantMessageId = assistantMessageId ?? (() => {
|
|
5918
|
+
const userMessageId = optionalString(request, "user_message_id");
|
|
5919
|
+
if (!userMessageId)
|
|
5920
|
+
return null;
|
|
5921
|
+
const assistant = this.db.get(`SELECT id FROM ai_conversation_messages
|
|
5922
|
+
WHERE conversation_id = ? AND role = 'assistant' AND request_id = ?`, requiredString(request, "conversation_id"), `assistant:${userMessageId}`);
|
|
5923
|
+
return assistant ? requiredString(assistant, "id") : null;
|
|
5924
|
+
})();
|
|
5746
5925
|
this.db.run(`UPDATE ai_conversation_stream_requests
|
|
5747
5926
|
SET status = ?, terminal_reason = ?, assistant_message_id = COALESCE(assistant_message_id, ?),
|
|
5748
5927
|
lease_expires_at = NULL, updated_at = ?, completed_at = ?
|
|
5749
|
-
WHERE id = ? AND status = 'in_progress'`, status, terminalReason.slice(0, 500),
|
|
5928
|
+
WHERE id = ? AND status = 'in_progress'`, status, terminalReason.slice(0, 500), resolvedAssistantMessageId, timestamp, timestamp, requestId);
|
|
5750
5929
|
const completed = this.db.get("SELECT * FROM ai_conversation_stream_requests WHERE id = ?", requestId);
|
|
5751
5930
|
if (!completed)
|
|
5752
5931
|
throw notFound("AI 对话请求");
|
|
@@ -5936,65 +6115,101 @@ export class Store {
|
|
|
5936
6115
|
}
|
|
5937
6116
|
relationshipRosterSourceVersions(workId) {
|
|
5938
6117
|
const versions = {};
|
|
5939
|
-
for (const
|
|
5940
|
-
versions[`character:${
|
|
5941
|
-
}
|
|
5942
|
-
for (const race of this.listRaces(workId))
|
|
5943
|
-
versions[`race:${String(race.id)}`] = Number(race.versionNo);
|
|
5944
|
-
for (const organization of this.listOrganizations(workId)) {
|
|
5945
|
-
versions[`organization:${String(organization.id)}`] = Number(organization.versionNo);
|
|
5946
|
-
}
|
|
5947
|
-
for (const relationship of this.listRelationships(workId)) {
|
|
5948
|
-
versions[`relationship:${String(relationship.id)}`] = Number(relationship.versionNo);
|
|
6118
|
+
for (const row of this.db.all("SELECT id, version_no FROM characters WHERE work_id = ? AND merged_into_character_id IS NULL", workId)) {
|
|
6119
|
+
versions[`character:${requiredString(row, "id")}`] = numberValue(row, "version_no");
|
|
5949
6120
|
}
|
|
6121
|
+
this.appendVersionedEntitySourceVersions(versions, workId, "race", "races");
|
|
6122
|
+
this.appendVersionedEntitySourceVersions(versions, workId, "organization", "organizations");
|
|
6123
|
+
this.appendVersionedEntitySourceVersions(versions, workId, "relationship", "relationships");
|
|
5950
6124
|
return versions;
|
|
5951
6125
|
}
|
|
6126
|
+
appendVersionedEntitySourceVersions(versions, workId, entityType, table) {
|
|
6127
|
+
const rows = this.db.all(`SELECT entity.id, COALESCE(MAX(version.version_no), 0) AS version_no
|
|
6128
|
+
FROM ${table} entity
|
|
6129
|
+
LEFT JOIN entity_versions version
|
|
6130
|
+
ON version.work_id = entity.work_id AND version.entity_type = ? AND version.entity_id = entity.id
|
|
6131
|
+
WHERE entity.work_id = ?
|
|
6132
|
+
GROUP BY entity.id`, entityType, workId);
|
|
6133
|
+
for (const row of rows) {
|
|
6134
|
+
versions[`${entityType}:${requiredString(row, "id")}`] = numberValue(row, "version_no");
|
|
6135
|
+
}
|
|
6136
|
+
}
|
|
5952
6137
|
relationshipSettingsSourceVersions(workId) {
|
|
5953
6138
|
const versions = this.relationshipRosterSourceVersions(workId);
|
|
5954
|
-
const work = this.
|
|
5955
|
-
|
|
5956
|
-
|
|
5957
|
-
|
|
5958
|
-
|
|
5959
|
-
|
|
5960
|
-
|
|
5961
|
-
|
|
5962
|
-
|
|
5963
|
-
|
|
5964
|
-
|
|
5965
|
-
|
|
5966
|
-
|
|
5967
|
-
for (const
|
|
5968
|
-
|
|
5969
|
-
|
|
5970
|
-
|
|
5971
|
-
|
|
5972
|
-
|
|
5973
|
-
|
|
6139
|
+
const work = this.db.get("SELECT version_no FROM works WHERE id = ? AND deleted_at IS NULL", workId);
|
|
6140
|
+
if (!work)
|
|
6141
|
+
throw notFound("作品");
|
|
6142
|
+
versions[`work:${workId}`] = numberValue(work, "version_no");
|
|
6143
|
+
this.appendVersionedEntitySourceVersions(versions, workId, "setting", "settings");
|
|
6144
|
+
for (const row of this.db.all(`SELECT section.id, section.version_no
|
|
6145
|
+
FROM character_profile_sections section
|
|
6146
|
+
JOIN characters character ON character.id = section.character_id
|
|
6147
|
+
WHERE section.work_id = ? AND character.merged_into_character_id IS NULL`, workId)) {
|
|
6148
|
+
versions[`character-section:${requiredString(row, "id")}`] = numberValue(row, "version_no");
|
|
6149
|
+
}
|
|
6150
|
+
this.appendVersionedEntitySourceVersions(versions, workId, "timeline-track", "timeline_tracks");
|
|
6151
|
+
this.appendVersionedEntitySourceVersions(versions, workId, "timeline-event", "timeline_events");
|
|
6152
|
+
for (const row of this.db.all(`SELECT chapter.id, chapter.version_no AS chapter_version_no,
|
|
6153
|
+
COALESCE(MAX(version.version_no), 0) AS outline_version_no
|
|
6154
|
+
FROM chapter_outlines outline
|
|
6155
|
+
JOIN chapters chapter ON chapter.id = outline.chapter_id
|
|
6156
|
+
LEFT JOIN entity_versions version
|
|
6157
|
+
ON version.work_id = chapter.work_id
|
|
6158
|
+
AND version.entity_type = 'chapter-outline'
|
|
6159
|
+
AND version.entity_id = chapter.id
|
|
6160
|
+
WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL
|
|
6161
|
+
GROUP BY chapter.id, chapter.version_no`, workId)) {
|
|
6162
|
+
const chapterId = requiredString(row, "id");
|
|
6163
|
+
versions[`chapter-meta:${chapterId}`] = numberValue(row, "chapter_version_no");
|
|
6164
|
+
versions[`chapter-outline:${chapterId}`] = numberValue(row, "outline_version_no");
|
|
5974
6165
|
}
|
|
5975
|
-
|
|
5976
|
-
|
|
6166
|
+
this.appendVersionedEntitySourceVersions(versions, workId, "foreshadow", "foreshadows");
|
|
6167
|
+
for (const row of this.db.all("SELECT id, updated_at FROM review_items WHERE work_id = ?", workId)) {
|
|
6168
|
+
versions[`review:${requiredString(row, "id")}`] = requiredString(row, "updated_at");
|
|
5977
6169
|
}
|
|
5978
6170
|
return versions;
|
|
5979
6171
|
}
|
|
5980
6172
|
analysisTaskSourceVersions(workId, scope) {
|
|
5981
6173
|
const sourceVersions = {};
|
|
5982
|
-
|
|
5983
|
-
|
|
5984
|
-
|
|
6174
|
+
const selectedChapterIds = [
|
|
6175
|
+
...(typeof scope.chapterId === "string" ? [scope.chapterId] : []),
|
|
6176
|
+
...(Array.isArray(scope.chapterIds) ? scope.chapterIds.filter((value) => typeof value === "string") : [])
|
|
6177
|
+
];
|
|
6178
|
+
for (const chapterId of [...new Set(selectedChapterIds)]) {
|
|
6179
|
+
const chapter = this.db.get("SELECT work_id, version_no, chapter_type FROM chapters WHERE id = ? AND deleted_at IS NULL", chapterId);
|
|
6180
|
+
if (!chapter)
|
|
6181
|
+
throw notFound("章节");
|
|
6182
|
+
if (requiredString(chapter, "work_id") !== workId)
|
|
5985
6183
|
throw new AppError(400, "CHAPTER_WORK_MISMATCH", "章节不属于当前作品");
|
|
5986
|
-
|
|
5987
|
-
|
|
5988
|
-
|
|
5989
|
-
|
|
5990
|
-
|
|
5991
|
-
const
|
|
5992
|
-
|
|
5993
|
-
:
|
|
5994
|
-
|
|
5995
|
-
|
|
5996
|
-
|
|
5997
|
-
|
|
6184
|
+
if (requiredString(chapter, "chapter_type") === "作者的话")
|
|
6185
|
+
continue;
|
|
6186
|
+
sourceVersions[chapterId] = numberValue(chapter, "version_no");
|
|
6187
|
+
}
|
|
6188
|
+
if (scope.type === "book" || scope.type === "volume") {
|
|
6189
|
+
const selectedVolumeIds = [
|
|
6190
|
+
...(typeof scope.volumeId === "string" ? [scope.volumeId] : []),
|
|
6191
|
+
...(Array.isArray(scope.volumeIds) ? scope.volumeIds.filter((value) => typeof value === "string") : [])
|
|
6192
|
+
];
|
|
6193
|
+
const selectedVolumeIdSet = new Set(selectedVolumeIds);
|
|
6194
|
+
if (scope.type === "volume") {
|
|
6195
|
+
if (selectedVolumeIdSet.size === 0)
|
|
6196
|
+
throw notFound("卷");
|
|
6197
|
+
const volumePlaceholders = [...selectedVolumeIdSet].map(() => "?").join(", ");
|
|
6198
|
+
const volumes = this.db.all(`SELECT id FROM volumes WHERE work_id = ? AND deleted_at IS NULL AND id IN (${volumePlaceholders})`, workId, ...selectedVolumeIdSet);
|
|
6199
|
+
if (volumes.length !== selectedVolumeIdSet.size)
|
|
6200
|
+
throw notFound("卷");
|
|
6201
|
+
}
|
|
6202
|
+
const volumeFilter = scope.type === "volume"
|
|
6203
|
+
? `AND chapter.volume_id IN (${[...selectedVolumeIdSet].map(() => "?").join(", ")})`
|
|
6204
|
+
: "";
|
|
6205
|
+
const chapterRows = this.db.all(`SELECT chapter.id, chapter.version_no
|
|
6206
|
+
FROM chapters chapter
|
|
6207
|
+
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
6208
|
+
WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL
|
|
6209
|
+
AND chapter.chapter_type <> '作者的话'
|
|
6210
|
+
${volumeFilter}`, workId, ...(scope.type === "volume" ? [...selectedVolumeIdSet] : []));
|
|
6211
|
+
for (const chapter of chapterRows) {
|
|
6212
|
+
sourceVersions[requiredString(chapter, "id")] = numberValue(chapter, "version_no");
|
|
5998
6213
|
}
|
|
5999
6214
|
}
|
|
6000
6215
|
if (scope.previewRelationshipChanges === true) {
|
|
@@ -6029,10 +6244,12 @@ export class Store {
|
|
|
6029
6244
|
for (const characterId of scope.characterIds) {
|
|
6030
6245
|
if (typeof characterId !== "string")
|
|
6031
6246
|
throw new AppError(400, "CHARACTER_REQUIRED", "被分析角色标识无效");
|
|
6032
|
-
const character = this.
|
|
6033
|
-
if (character
|
|
6247
|
+
const character = this.db.get("SELECT work_id, name FROM characters WHERE id = ?", characterId);
|
|
6248
|
+
if (!character)
|
|
6249
|
+
throw notFound("角色");
|
|
6250
|
+
if (requiredString(character, "work_id") !== workId)
|
|
6034
6251
|
throw new AppError(400, "CHARACTER_WORK_MISMATCH", "被分析角色不属于当前作品");
|
|
6035
|
-
targetCharacters.push({ id: characterId, name:
|
|
6252
|
+
targetCharacters.push({ id: characterId, name: requiredString(character, "name") });
|
|
6036
6253
|
}
|
|
6037
6254
|
if (targetCharacters.length > 0)
|
|
6038
6255
|
scope.targetCharacters = targetCharacters;
|
|
@@ -7158,8 +7375,22 @@ export class Store {
|
|
|
7158
7375
|
}
|
|
7159
7376
|
taskScopeSummaryFromMaps(scope, chapterSummaries, volumeTitles, characterNames, includeCharacterNames = true) {
|
|
7160
7377
|
const targetedSuffix = this.taskTargetedSuffix(scope, characterNames, includeCharacterNames);
|
|
7378
|
+
if (Array.isArray(scope.chapterIds) && scope.chapterIds.length > 0) {
|
|
7379
|
+
const labels = scope.chapterIds
|
|
7380
|
+
.filter((chapterId) => typeof chapterId === "string")
|
|
7381
|
+
.map((chapterId) => chapterSummaries.get(chapterId) ?? "章节已删除");
|
|
7382
|
+
const preview = labels.slice(0, 3).join("、");
|
|
7383
|
+
return `指定章节(${labels.length}):${preview}${labels.length > 3 ? "……" : ""}${targetedSuffix}`;
|
|
7384
|
+
}
|
|
7161
7385
|
if (typeof scope.chapterId === "string")
|
|
7162
7386
|
return `${chapterSummaries.get(scope.chapterId) ?? "章节已删除"}${targetedSuffix}`;
|
|
7387
|
+
if (Array.isArray(scope.volumeIds) && scope.volumeIds.length > 0) {
|
|
7388
|
+
const labels = scope.volumeIds
|
|
7389
|
+
.filter((volumeId) => typeof volumeId === "string")
|
|
7390
|
+
.map((volumeId) => volumeTitles.get(volumeId) ? `分卷 · ${volumeTitles.get(volumeId)}` : "分卷已删除");
|
|
7391
|
+
const preview = labels.slice(0, 3).join("、");
|
|
7392
|
+
return `指定分卷(${labels.length}):${preview}${labels.length > 3 ? "……" : ""}${targetedSuffix}`;
|
|
7393
|
+
}
|
|
7163
7394
|
if (scope.type === "volume" && typeof scope.volumeId === "string") {
|
|
7164
7395
|
const title = volumeTitles.get(scope.volumeId);
|
|
7165
7396
|
return `${title ? `分卷 · ${title}` : "分卷已删除"}${targetedSuffix}`;
|
|
@@ -7178,6 +7409,17 @@ export class Store {
|
|
|
7178
7409
|
}
|
|
7179
7410
|
taskScopeSummary(workId, scope, characterNames, includeCharacterNames = true) {
|
|
7180
7411
|
const targetedSuffix = this.taskTargetedSuffix(scope, characterNames, includeCharacterNames);
|
|
7412
|
+
if (Array.isArray(scope.chapterIds) && scope.chapterIds.length > 0) {
|
|
7413
|
+
const labels = scope.chapterIds.map((chapterId) => {
|
|
7414
|
+
const chapter = this.db.get(`SELECT chapter.title AS title, volume.title AS volume_title
|
|
7415
|
+
FROM chapters chapter
|
|
7416
|
+
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
7417
|
+
WHERE chapter.id = ? AND chapter.work_id = ? AND chapter.deleted_at IS NULL`, chapterId, workId);
|
|
7418
|
+
return chapter ? `${requiredString(chapter, "volume_title")} · ${requiredString(chapter, "title")}` : "章节已删除";
|
|
7419
|
+
});
|
|
7420
|
+
const preview = labels.slice(0, 3).join("、");
|
|
7421
|
+
return `指定章节(${labels.length}):${preview}${labels.length > 3 ? "……" : ""}${targetedSuffix}`;
|
|
7422
|
+
}
|
|
7181
7423
|
if (typeof scope.chapterId === "string") {
|
|
7182
7424
|
const chapter = this.db.get(`SELECT chapter.title AS title, volume.title AS volume_title
|
|
7183
7425
|
FROM chapters chapter
|
|
@@ -7189,6 +7431,14 @@ export class Store {
|
|
|
7189
7431
|
const volumeTitle = requiredString(chapter, "volume_title");
|
|
7190
7432
|
return `${volumeTitle} · ${title}${targetedSuffix}`;
|
|
7191
7433
|
}
|
|
7434
|
+
if (Array.isArray(scope.volumeIds) && scope.volumeIds.length > 0) {
|
|
7435
|
+
const labels = scope.volumeIds.map((volumeId) => {
|
|
7436
|
+
const volume = this.db.get("SELECT title FROM volumes WHERE id = ? AND work_id = ?", volumeId, workId);
|
|
7437
|
+
return volume ? `分卷 · ${requiredString(volume, "title")}` : "分卷已删除";
|
|
7438
|
+
});
|
|
7439
|
+
const preview = labels.slice(0, 3).join("、");
|
|
7440
|
+
return `指定分卷(${labels.length}):${preview}${labels.length > 3 ? "……" : ""}${targetedSuffix}`;
|
|
7441
|
+
}
|
|
7192
7442
|
if (scope.type === "volume" && typeof scope.volumeId === "string") {
|
|
7193
7443
|
const volume = this.db.get("SELECT title FROM volumes WHERE id = ? AND work_id = ?", scope.volumeId, workId);
|
|
7194
7444
|
return `${volume ? `分卷 · ${requiredString(volume, "title")}` : "分卷已删除"}${targetedSuffix}`;
|
|
@@ -7296,6 +7546,25 @@ export class Store {
|
|
|
7296
7546
|
return snapshotNames;
|
|
7297
7547
|
}
|
|
7298
7548
|
taskScopeDetails(workId, scope) {
|
|
7549
|
+
if (Array.isArray(scope.chapterIds) && scope.chapterIds.length > 0) {
|
|
7550
|
+
return scope.chapterIds.map((chapterId) => {
|
|
7551
|
+
const chapter = this.db.get(`SELECT chapter.id AS id, chapter.title AS title, chapter.version_no AS version_no,
|
|
7552
|
+
volume.id AS volume_id, volume.title AS volume_title
|
|
7553
|
+
FROM chapters chapter
|
|
7554
|
+
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
7555
|
+
WHERE chapter.id = ? AND chapter.work_id = ? AND chapter.deleted_at IS NULL`, chapterId, workId);
|
|
7556
|
+
if (!chapter)
|
|
7557
|
+
return { type: "chapter", chapterId, missing: true };
|
|
7558
|
+
return {
|
|
7559
|
+
type: "chapter",
|
|
7560
|
+
chapterId: requiredString(chapter, "id"),
|
|
7561
|
+
title: requiredString(chapter, "title"),
|
|
7562
|
+
versionNo: numberValue(chapter, "version_no"),
|
|
7563
|
+
volumeId: requiredString(chapter, "volume_id"),
|
|
7564
|
+
volumeTitle: requiredString(chapter, "volume_title")
|
|
7565
|
+
};
|
|
7566
|
+
});
|
|
7567
|
+
}
|
|
7299
7568
|
if (typeof scope.chapterId === "string") {
|
|
7300
7569
|
const chapter = this.db.get(`SELECT chapter.id AS id, chapter.title AS title, chapter.version_no AS version_no,
|
|
7301
7570
|
volume.id AS volume_id, volume.title AS volume_title
|
|
@@ -7313,6 +7582,24 @@ export class Store {
|
|
|
7313
7582
|
volumeTitle: requiredString(chapter, "volume_title")
|
|
7314
7583
|
}];
|
|
7315
7584
|
}
|
|
7585
|
+
if (Array.isArray(scope.volumeIds) && scope.volumeIds.length > 0) {
|
|
7586
|
+
return scope.volumeIds.map((volumeId) => {
|
|
7587
|
+
const volume = this.db.get("SELECT id, title FROM volumes WHERE id = ? AND work_id = ?", volumeId, workId);
|
|
7588
|
+
if (!volume)
|
|
7589
|
+
return { type: "volume", volumeId, missing: true };
|
|
7590
|
+
const chapters = this.db.all("SELECT id, title, version_no FROM chapters WHERE volume_id = ? AND deleted_at IS NULL ORDER BY sort_order, created_at", volumeId);
|
|
7591
|
+
return {
|
|
7592
|
+
type: "volume",
|
|
7593
|
+
volumeId: requiredString(volume, "id"),
|
|
7594
|
+
title: requiredString(volume, "title"),
|
|
7595
|
+
chapters: chapters.map((item) => ({
|
|
7596
|
+
chapterId: requiredString(item, "id"),
|
|
7597
|
+
title: requiredString(item, "title"),
|
|
7598
|
+
versionNo: numberValue(item, "version_no")
|
|
7599
|
+
}))
|
|
7600
|
+
};
|
|
7601
|
+
});
|
|
7602
|
+
}
|
|
7316
7603
|
if (scope.type === "volume" && typeof scope.volumeId === "string") {
|
|
7317
7604
|
const volume = this.db.get("SELECT id, title FROM volumes WHERE id = ? AND work_id = ?", scope.volumeId, workId);
|
|
7318
7605
|
if (!volume)
|