@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.
Files changed (48) hide show
  1. package/README.md +1 -0
  2. package/dist/ai-model-pricing.js +530 -0
  3. package/dist/ai-model-pricing.js.map +1 -0
  4. package/dist/ai-protocol.js +335 -15
  5. package/dist/ai-protocol.js.map +1 -1
  6. package/dist/ai-retry.js +1 -1
  7. package/dist/ai-retry.js.map +1 -1
  8. package/dist/ai.js +1083 -139
  9. package/dist/ai.js.map +1 -1
  10. package/dist/app.js +222 -25
  11. package/dist/app.js.map +1 -1
  12. package/dist/attachment-download.js +26 -0
  13. package/dist/attachment-download.js.map +1 -0
  14. package/dist/attachment-storage.js +8 -6
  15. package/dist/attachment-storage.js.map +1 -1
  16. package/dist/cli-contract.js +6 -4
  17. package/dist/cli-contract.js.map +1 -1
  18. package/dist/database.js +363 -4
  19. package/dist/database.js.map +1 -1
  20. package/dist/public/ai-image-attachments.d.ts +21 -0
  21. package/dist/public/ai-image-attachments.js +42 -0
  22. package/dist/public/ai-usage.d.ts +1 -0
  23. package/dist/public/ai-usage.js +11 -0
  24. package/dist/public/app.js +1030 -106
  25. package/dist/public/display-labels.d.ts +2 -1
  26. package/dist/public/display-labels.js +6 -6
  27. package/dist/public/index.html +42 -7
  28. package/dist/public/model-config.d.ts +6 -4
  29. package/dist/public/model-config.js +10 -2
  30. package/dist/public/styles.css +157 -6
  31. package/dist/s3-backup.js +21 -0
  32. package/dist/s3-backup.js.map +1 -1
  33. package/dist/security.js +2 -1
  34. package/dist/security.js.map +1 -1
  35. package/dist/server-runtime.js +6 -0
  36. package/dist/server-runtime.js.map +1 -1
  37. package/dist/store.js +550 -44
  38. package/dist/store.js.map +1 -1
  39. package/dist/upload-limits.js +10 -4
  40. package/dist/upload-limits.js.map +1 -1
  41. package/dist/user-auth.js +3 -0
  42. package/dist/user-auth.js.map +1 -1
  43. package/dist/utils.js +1 -1
  44. package/dist/utils.js.map +1 -1
  45. package/dist/version.js +1 -1
  46. package/dist/writing-progress-time.js +15 -0
  47. package/dist/writing-progress-time.js.map +1 -1
  48. 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(10_000, Number(row.daily_token_quota)),
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, volume.title AS volume_title
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 chapterIds = chapterRows.map((row) => requiredString(row, "id"));
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.run(`INSERT INTO volumes (id, work_id, title, kind, source, description, keywords_json, sort_order, version_no, created_at, updated_at)
1532
- 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, timestamp, timestamp);
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 authorNoteFilter = options.excludeAuthorNotes ? " AND chapter.chapter_type <> '作者的话'" : "";
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
- const rows = [...normalizedKeyword].length < 3
2565
- ? this.db.all(`${columns}
2566
- JOIN chapter_paragraph_short_terms term ON term.paragraph_id = paragraph.id
2567
- WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL${authorNoteFilter} AND term.term = ?
2568
- ORDER BY volume.sort_order, chapter.sort_order, paragraph.paragraph_order
2569
- LIMIT ?`, workId, normalizedKeyword, safeLimit)
2570
- : this.db.all(`${columns}
2571
- JOIN chapter_paragraph_search_fts fts ON fts.rowid = paragraph.id
2572
- WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL${authorNoteFilter} AND chapter_paragraph_search_fts MATCH ?
2573
- ORDER BY volume.sort_order, chapter.sort_order, paragraph.paragraph_order
2574
- LIMIT ?`, workId, `"${normalizedKeyword.replaceAll('"', '""')}"`, safeLimit);
2575
- return rows.map((row) => ({
2576
- chapterId: requiredString(row, "chapter_id"),
2577
- chapterTitle: requiredString(row, "chapter_title"),
2578
- paragraph: requiredString(row, "content")
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
- return sources.reduce((count, [table, column]) => count + Number(this.db.get(`SELECT COUNT(*) AS count FROM ${table} WHERE instr(${column}, ?) > 0`, needle)?.count ?? 0), 0);
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
- ORDER BY conversation.is_favorite DESC, conversation.updated_at DESC, conversation.created_at DESC${page.sql}`, workId, ...page.params);
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
- delete inheritedMetadata.modelId;
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 lockedModelId = this.aiConversationLockedModelId(requiredString(row, "id"));
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"),