@musnows/scriverse 0.8.4 → 0.8.6

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