@musnows/scriverse 0.8.7 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/store.js CHANGED
@@ -13,6 +13,7 @@ import { countWords, documentShortSearchTerms, escapeSqlLikePattern, id, json, n
13
13
  import { buildWritingCalendar, writingDateKey } from "./writing-progress-time.js";
14
14
  import { resolveMaxAgentToolCallLimit } from "./ai-tool-results.js";
15
15
  import { DEFAULT_AI_STREAM_IDLE_TIMEOUT_SECONDS, normalizeAiStreamIdleTimeoutSeconds } from "./ai-stream-timeout.js";
16
+ import { normalizeRoleplayScenePin, roleplayUserTurnDisplayText, roleplayUserTurnTitleSource } from "./roleplay-turn.js";
16
17
  const WORK_LIST_BATCH_SIZE = 500;
17
18
  const ENTITY_LIST_BATCH_SIZE = 400;
18
19
  export const RECYCLE_BIN_RETENTION_DAYS = 30;
@@ -211,7 +212,7 @@ export const versionedEntityTypes = [
211
212
  export const AI_CONVERSATION_STREAM_REQUEST_LEASE_MS = 3 * 60_000;
212
213
  export const aiConversationTaskTypes = ["chat", "roleplay", "continue", "polish"];
213
214
  export function defaultAiConversationTitle(prompt) {
214
- const normalized = prompt.replace(/\s+/gu, " ").trim();
215
+ const normalized = roleplayUserTurnTitleSource(prompt).replace(/\s+/gu, " ").trim();
215
216
  return Array.from(normalized).slice(0, 15).join("") || "新对话";
216
217
  }
217
218
  function isRecord(value) {
@@ -571,8 +572,16 @@ export class Store {
571
572
  const versionNo = latest ? numberValue(latest, "version_no") + 1 : 1;
572
573
  this.db.run(`INSERT INTO entity_versions (id, work_id, entity_type, entity_id, version_no, snapshot_json, source, source_ref, change_note, created_at, created_by_user_id)
573
574
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id("entityVersion"), type === "work" ? entityId : String(entity.workId), type, entityId, versionNo, snapshotJson, source, sourceRef, changeNote.trim(), timestamp ?? now(), currentRequestActor()?.userId ?? null);
575
+ if (type === "setting") {
576
+ this.recordSyncChange(String(entity.workId), "setting", entityId, source === "delete" ? "delete" : "upsert", versionNo, timestamp);
577
+ }
574
578
  return versionNo;
575
579
  }
580
+ recordSyncChange(workId, entityType, entityId, operation, versionNo, timestamp) {
581
+ this.db.run(`INSERT INTO sync_changes (
582
+ work_id, entity_type, entity_id, operation, version_no, changed_by_user_id, changed_at
583
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`, workId, entityType, entityId, operation, versionNo, currentRequestActor()?.userId ?? null, timestamp ?? now());
584
+ }
576
585
  backfillEntityVersionBaselines() {
577
586
  const entities = [
578
587
  ...this.db.all("SELECT id, updated_at FROM works").map((row) => ["work", requiredString(row, "id"), requiredString(row, "updated_at")]),
@@ -787,7 +796,7 @@ export class Store {
787
796
  }
788
797
  listWorks() {
789
798
  const actor = currentRequestActor();
790
- if (!actor || (actor.role === "admin" && actor.authentication !== "api-key")) {
799
+ if (!actor) {
791
800
  return this.mapWorks(this.db.all("SELECT * FROM works WHERE COALESCE(is_internal, 0) = 0 AND deleted_at IS NULL ORDER BY updated_at DESC"));
792
801
  }
793
802
  return this.mapWorks(this.db.all(`SELECT DISTINCT work.* FROM works work LEFT JOIN work_memberships membership ON membership.work_id = work.id
@@ -798,7 +807,7 @@ export class Store {
798
807
  listWorksPage(pagination) {
799
808
  const actor = currentRequestActor();
800
809
  const page = paginationSql(pagination);
801
- const rows = !actor || (actor.role === "admin" && actor.authentication !== "api-key")
810
+ const rows = !actor
802
811
  ? this.db.all(`SELECT * FROM works WHERE COALESCE(is_internal, 0) = 0 AND deleted_at IS NULL ORDER BY updated_at DESC${page.sql}`, ...page.params)
803
812
  : this.db.all(`SELECT DISTINCT work.* FROM works work LEFT JOIN work_memberships membership ON membership.work_id = work.id
804
813
  WHERE COALESCE(work.is_internal, 0) = 0 AND work.deleted_at IS NULL
@@ -814,7 +823,7 @@ export class Store {
814
823
  }
815
824
  listDeletedWorks() {
816
825
  const actor = currentRequestActor();
817
- const actorRestricted = Boolean(actor && !(actor.role === "admin" && actor.authentication !== "api-key"));
826
+ const actorRestricted = Boolean(actor);
818
827
  const rows = this.db.all(`SELECT work.*,
819
828
  (SELECT COUNT(*) FROM volumes volume WHERE volume.work_id = work.id) AS volume_count,
820
829
  (SELECT COUNT(*) FROM chapters chapter WHERE chapter.work_id = work.id) AS chapter_count,
@@ -1107,6 +1116,18 @@ export class Store {
1107
1116
  });
1108
1117
  return this.getWork(workId);
1109
1118
  }
1119
+ setWorkOfflineAccess(workId, enabled) {
1120
+ this.db.transaction(() => {
1121
+ const current = this.getWork(workId);
1122
+ if (Boolean(current.offlineAccessEnabled) === enabled)
1123
+ return;
1124
+ const timestamp = now();
1125
+ this.db.run("UPDATE works SET offline_access_enabled = ?, version_no = version_no + 1, updated_at = ? WHERE id = ?", enabled ? 1 : 0, timestamp, workId);
1126
+ this.recordEntityVersion("work", workId, "manual", null, enabled ? "允许 Desktop 离线访问" : "禁止 Desktop 离线访问", timestamp);
1127
+ this.audit(workId, enabled ? "work.offline-access.enabled" : "work.offline-access.disabled", "work", workId, { enabled });
1128
+ });
1129
+ return this.getWork(workId);
1130
+ }
1110
1131
  deleteWork(workId, expectedVersionNo) {
1111
1132
  return this.db.transaction(() => {
1112
1133
  const current = this.getWork(workId);
@@ -1902,10 +1923,12 @@ export class Store {
1902
1923
  };
1903
1924
  }
1904
1925
  insertChapterVersionRow(input) {
1926
+ const timestamp = input.timestamp ?? now();
1905
1927
  this.db.run(`INSERT INTO chapter_versions (
1906
1928
  id, work_id, chapter_id, version_no, title, content, volume_id, sort_order, chapter_type,
1907
1929
  source, source_ref, change_note, created_at, created_by_user_id
1908
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id("chapterVersion"), input.workId, input.chapterId, input.versionNo, input.title, input.content, input.volumeId, input.sortOrder, input.chapterType, input.source, input.sourceRef, input.changeNote.trim(), input.timestamp ?? now(), currentRequestActor()?.userId ?? null);
1930
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id("chapterVersion"), input.workId, input.chapterId, input.versionNo, input.title, input.content, input.volumeId, input.sortOrder, input.chapterType, input.source, input.sourceRef, input.changeNote.trim(), timestamp, currentRequestActor()?.userId ?? null);
1931
+ this.recordSyncChange(input.workId, "chapter", input.chapterId, input.source === "delete" ? "delete" : "upsert", input.versionNo, timestamp);
1909
1932
  }
1910
1933
  listChapterVersions(chapterId) {
1911
1934
  const rows = this.findChapterVersionRows(chapterId);
@@ -1997,7 +2020,7 @@ export class Store {
1997
2020
  if (!hasTextChange && !hasOtherChange)
1998
2021
  return current;
1999
2022
  const timestamp = now();
2000
- const versionNo = Number(current.versionNo) + (hasTextChange ? 1 : 0);
2023
+ const versionNo = Number(current.versionNo) + (hasTextChange || hasTypeChange ? 1 : 0);
2001
2024
  this.db.transaction(() => {
2002
2025
  const lockedCurrent = this.getChapter(chapterId);
2003
2026
  this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(lockedCurrent.versionNo));
@@ -2005,7 +2028,9 @@ export class Store {
2005
2028
  excluded_from_analysis = ?, updated_at = ? WHERE id = ?`, nextTitle, nextContent, nextChapterType, countWords(nextContent), versionNo, hasTextChange || hasTypeChange ? "expired" : String(current.analysisStatus), nextExcluded ? 1 : 0, timestamp, chapterId);
2006
2029
  if (hasTextChange)
2007
2030
  this.syncChapterParagraphSearch(String(current.workId), chapterId, nextContent);
2008
- if (hasTextChange) {
2031
+ else if (hasTypeChange)
2032
+ this.syncChapterParagraphSearchVersion(chapterId, versionNo);
2033
+ if (hasTextChange || hasTypeChange) {
2009
2034
  this.insertChapterVersionRow({
2010
2035
  workId: String(current.workId),
2011
2036
  chapterId,
@@ -2017,7 +2042,7 @@ export class Store {
2017
2042
  chapterType: nextChapterType,
2018
2043
  source,
2019
2044
  sourceRef,
2020
- changeNote: changeNote || "更新章节正文",
2045
+ changeNote: changeNote || (hasTextChange ? "更新章节正文" : "更新章节类型"),
2021
2046
  timestamp
2022
2047
  });
2023
2048
  }
@@ -2529,9 +2554,28 @@ export class Store {
2529
2554
  }
2530
2555
  else if (action.type === "setType") {
2531
2556
  for (const chapter of currentChapters) {
2532
- this.db.run("UPDATE chapters SET chapter_type = ?, analysis_status = 'expired', updated_at = ? WHERE id = ?", action.chapterType, timestamp, String(chapter.id));
2533
- this.invalidateChapter(workId, String(chapter.id), Number(chapter.versionNo));
2534
- this.audit(workId, "chapter.saved", "chapter", String(chapter.id), { chapterType: action.chapterType, batch: true });
2557
+ if (chapter.chapterType === action.chapterType)
2558
+ continue;
2559
+ const chapterId = String(chapter.id);
2560
+ const versionNo = Number(chapter.versionNo) + 1;
2561
+ this.db.run("UPDATE chapters SET chapter_type = ?, version_no = ?, analysis_status = 'expired', updated_at = ? WHERE id = ?", action.chapterType, versionNo, timestamp, chapterId);
2562
+ this.syncChapterParagraphSearchVersion(chapterId, versionNo);
2563
+ this.insertChapterVersionRow({
2564
+ workId,
2565
+ chapterId,
2566
+ versionNo,
2567
+ title: String(chapter.title),
2568
+ content: String(chapter.content),
2569
+ volumeId: String(chapter.volumeId),
2570
+ sortOrder: Number(chapter.sortOrder),
2571
+ chapterType: action.chapterType,
2572
+ source: "manual",
2573
+ sourceRef: null,
2574
+ changeNote: "批量更新章节类型",
2575
+ timestamp
2576
+ });
2577
+ this.invalidateChapter(workId, chapterId, versionNo);
2578
+ this.audit(workId, "chapter.saved", "chapter", chapterId, { chapterType: action.chapterType, versionNo, batch: true });
2535
2579
  }
2536
2580
  }
2537
2581
  else if (action.type === "setAnalysisExclusion") {
@@ -2955,6 +2999,7 @@ export class Store {
2955
2999
  ? `/api/works/${encodeURIComponent(workId)}/cover?v=${encodeURIComponent(requiredString(cover, "updated_at"))}`
2956
3000
  : optionalString(row, "cover_url"),
2957
3001
  tags: json(requiredString(row, "tags_json"), []),
3002
+ offlineAccessEnabled: numberValue(row, "offline_access_enabled") === 1,
2958
3003
  versionNo: numberValue(row, "version_no") || this.currentEntityVersionNo("work", workId),
2959
3004
  ownerUserId,
2960
3005
  accessRole,
@@ -3706,7 +3751,7 @@ export class Store {
3706
3751
  return this.db.all(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
3707
3752
  LEFT JOIN volumes volume ON volume.id = draft.volume_id
3708
3753
  WHERE draft.work_id = ? AND (? IS NULL OR draft.draft_type = ?)
3709
- ORDER BY draft.updated_at DESC, draft.title`, workId, draftType ?? null, draftType ?? null).map((row) => this.mapDraft(row, includeContent));
3754
+ ORDER BY draft.is_favorite DESC, draft.updated_at DESC, draft.title`, workId, draftType ?? null, draftType ?? null).map((row) => this.mapDraft(row, includeContent));
3710
3755
  }
3711
3756
  listDraftsPage(workId, pagination, draftType, includeContent = false) {
3712
3757
  this.getWork(workId);
@@ -3714,7 +3759,7 @@ export class Store {
3714
3759
  const rows = this.db.all(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
3715
3760
  LEFT JOIN volumes volume ON volume.id = draft.volume_id
3716
3761
  WHERE draft.work_id = ? AND (? IS NULL OR draft.draft_type = ?)
3717
- ORDER BY draft.updated_at DESC, draft.title${page.sql}`, workId, draftType ?? null, draftType ?? null, ...page.params);
3762
+ ORDER BY draft.is_favorite DESC, draft.updated_at DESC, draft.title${page.sql}`, workId, draftType ?? null, draftType ?? null, ...page.params);
3718
3763
  return paginated(rows.map((row) => this.mapDraft(row, includeContent)), pagination);
3719
3764
  }
3720
3765
  searchDrafts(workId, query, draftType, limit = 20) {
@@ -3743,6 +3788,21 @@ export class Store {
3743
3788
  throw notFound("想法");
3744
3789
  return this.mapDraft(row, true);
3745
3790
  }
3791
+ setDraftFavorite(draftId, isFavorite) {
3792
+ const draft = this.db.get("SELECT id, work_id, is_favorite FROM drafts WHERE id = ?", draftId);
3793
+ if (!draft)
3794
+ throw notFound("想法");
3795
+ const workId = requiredString(draft, "work_id");
3796
+ const previousFavorite = booleanValue(draft, "is_favorite");
3797
+ this.db.transaction(() => {
3798
+ this.db.run("UPDATE drafts SET is_favorite = ? WHERE id = ?", isFavorite ? 1 : 0, draftId);
3799
+ this.audit(workId, "draft.favorite-updated", "draft", draftId, {
3800
+ previousFavorite,
3801
+ isFavorite
3802
+ });
3803
+ });
3804
+ return this.getDraft(draftId);
3805
+ }
3746
3806
  updateDraft(draftId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
3747
3807
  const current = this.getDraft(draftId);
3748
3808
  const content = input.content ?? String(current.content);
@@ -3781,6 +3841,7 @@ export class Store {
3781
3841
  volumeTitle: optionalString(row, "volume_title"),
3782
3842
  settingModule: optionalString(row, "setting_module"),
3783
3843
  title: requiredString(row, "title"),
3844
+ isFavorite: booleanValue(row, "is_favorite"),
3784
3845
  ...(includeContent ? { content } : { contentPreview: content.replace(/\s+/gu, " ").trim().slice(0, 320) }),
3785
3846
  versionNo: this.currentEntityVersionNo("draft", requiredString(row, "id")),
3786
3847
  createdAt: requiredString(row, "created_at"),
@@ -3844,12 +3905,12 @@ export class Store {
3844
3905
  }
3845
3906
  listSettings(workId, includeContent = true) {
3846
3907
  this.getWork(workId);
3847
- return this.db.all("SELECT * FROM settings WHERE work_id = ? ORDER BY locked DESC, category, title", workId).map((row) => this.mapSetting(row, includeContent));
3908
+ return this.db.all("SELECT * FROM settings WHERE work_id = ? ORDER BY is_favorite DESC, locked DESC, category, title", workId).map((row) => this.mapSetting(row, includeContent));
3848
3909
  }
3849
3910
  listSettingsPage(workId, pagination, includeContent = true) {
3850
3911
  this.getWork(workId);
3851
3912
  const page = paginationSql(pagination);
3852
- const rows = this.db.all(`SELECT * FROM settings WHERE work_id = ? ORDER BY locked DESC, category, title${page.sql}`, workId, ...page.params);
3913
+ const rows = this.db.all(`SELECT * FROM settings WHERE work_id = ? ORDER BY is_favorite DESC, locked DESC, category, title${page.sql}`, workId, ...page.params);
3853
3914
  return paginated(rows.map((row) => this.mapSetting(row, includeContent)), pagination);
3854
3915
  }
3855
3916
  getSetting(settingId) {
@@ -3858,6 +3919,21 @@ export class Store {
3858
3919
  throw notFound("设定");
3859
3920
  return this.mapSetting(row);
3860
3921
  }
3922
+ setSettingFavorite(settingId, isFavorite) {
3923
+ const setting = this.db.get("SELECT id, work_id, is_favorite FROM settings WHERE id = ?", settingId);
3924
+ if (!setting)
3925
+ throw notFound("设定");
3926
+ const workId = requiredString(setting, "work_id");
3927
+ const previousFavorite = booleanValue(setting, "is_favorite");
3928
+ this.db.transaction(() => {
3929
+ this.db.run("UPDATE settings SET is_favorite = ? WHERE id = ?", isFavorite ? 1 : 0, settingId);
3930
+ this.audit(workId, "setting.favorite-updated", "setting", settingId, {
3931
+ previousFavorite,
3932
+ isFavorite
3933
+ });
3934
+ });
3935
+ return this.getSetting(settingId);
3936
+ }
3861
3937
  updateSetting(settingId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
3862
3938
  const current = this.getSetting(settingId);
3863
3939
  const content = input.content ?? String(current.content);
@@ -3892,6 +3968,7 @@ export class Store {
3892
3968
  tags: json(requiredString(row, "tags_json"), []),
3893
3969
  status: requiredString(row, "status"),
3894
3970
  locked: booleanValue(row, "locked"),
3971
+ isFavorite: booleanValue(row, "is_favorite"),
3895
3972
  evidence: json(requiredString(row, "evidence_json"), []),
3896
3973
  scope: json(requiredString(row, "scope_json"), {}),
3897
3974
  authorNote: requiredString(row, "author_note"),
@@ -4205,14 +4282,14 @@ export class Store {
4205
4282
  }
4206
4283
  listOrganizations(workId, includeMarkdown = true) {
4207
4284
  this.getWork(workId);
4208
- const rows = this.db.all("SELECT * FROM organizations WHERE work_id = ? ORDER BY name", workId);
4285
+ const rows = this.db.all("SELECT * FROM organizations WHERE work_id = ? ORDER BY is_favorite DESC, name", workId);
4209
4286
  const batch = this.organizationListBatch(rows);
4210
4287
  return rows.map((row) => this.mapOrganization(row, includeMarkdown, batch));
4211
4288
  }
4212
4289
  listOrganizationsPage(workId, pagination, includeMarkdown = true) {
4213
4290
  this.getWork(workId);
4214
4291
  const page = paginationSql(pagination);
4215
- const rows = this.db.all(`SELECT * FROM organizations WHERE work_id = ? ORDER BY name${page.sql}`, workId, ...page.params);
4292
+ const rows = this.db.all(`SELECT * FROM organizations WHERE work_id = ? ORDER BY is_favorite DESC, name${page.sql}`, workId, ...page.params);
4216
4293
  const batch = this.organizationListBatch(rows);
4217
4294
  return paginated(rows.map((row) => this.mapOrganization(row, includeMarkdown, batch)), pagination);
4218
4295
  }
@@ -4222,6 +4299,21 @@ export class Store {
4222
4299
  throw notFound("组织");
4223
4300
  return this.mapOrganization(row);
4224
4301
  }
4302
+ setOrganizationFavorite(organizationId, isFavorite) {
4303
+ const organization = this.db.get("SELECT id, work_id, is_favorite FROM organizations WHERE id = ?", organizationId);
4304
+ if (!organization)
4305
+ throw notFound("组织");
4306
+ const workId = requiredString(organization, "work_id");
4307
+ const previousFavorite = booleanValue(organization, "is_favorite");
4308
+ this.db.transaction(() => {
4309
+ this.db.run("UPDATE organizations SET is_favorite = ? WHERE id = ?", isFavorite ? 1 : 0, organizationId);
4310
+ this.audit(workId, "organization.favorite-updated", "organization", organizationId, {
4311
+ previousFavorite,
4312
+ isFavorite
4313
+ });
4314
+ });
4315
+ return this.getOrganization(organizationId);
4316
+ }
4225
4317
  updateOrganization(organizationId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
4226
4318
  const current = this.getOrganization(organizationId);
4227
4319
  const workId = String(current.workId);
@@ -4349,6 +4441,7 @@ export class Store {
4349
4441
  name: requiredString(row, "name"),
4350
4442
  description: requiredString(row, "description"),
4351
4443
  isDissolved: booleanValue(row, "is_dissolved"),
4444
+ isFavorite: booleanValue(row, "is_favorite"),
4352
4445
  ...(includeMarkdown
4353
4446
  ? { settings, settingsMarkdown: settingsMarkdownFromList(settings), settingsSections }
4354
4447
  : { settings: [], settingsCount: settingsSections.length }),
@@ -4467,14 +4560,14 @@ export class Store {
4467
4560
  }
4468
4561
  listCharacters(workId, includeProfileSections = false, includeMerged = false, includeRaceMarkdown = true) {
4469
4562
  this.getWork(workId);
4470
- return this.db.all(`SELECT * FROM characters WHERE work_id = ?${includeMerged ? "" : " AND merged_into_character_id IS NULL"} ORDER BY name`, workId)
4563
+ return this.db.all(`SELECT * FROM characters WHERE work_id = ?${includeMerged ? "" : " AND merged_into_character_id IS NULL"} ORDER BY is_favorite DESC, name`, workId)
4471
4564
  .map((row) => this.mapCharacter(row, includeProfileSections, includeRaceMarkdown));
4472
4565
  }
4473
4566
  listCharactersPage(workId, pagination, includeProfileSections = false, includeMerged = false, includeRaceMarkdown = true) {
4474
4567
  this.getWork(workId);
4475
4568
  const page = paginationSql(pagination);
4476
4569
  const count = this.db.get(`SELECT COUNT(*) AS count FROM characters WHERE work_id = ?${includeMerged ? "" : " AND merged_into_character_id IS NULL"}`, workId);
4477
- const rows = this.db.all(`SELECT * FROM characters WHERE work_id = ?${includeMerged ? "" : " AND merged_into_character_id IS NULL"} ORDER BY name${page.sql}`, workId, ...page.params);
4570
+ const rows = this.db.all(`SELECT * FROM characters WHERE work_id = ?${includeMerged ? "" : " AND merged_into_character_id IS NULL"} ORDER BY is_favorite DESC, name${page.sql}`, workId, ...page.params);
4478
4571
  return paginated(rows.map((row) => this.mapCharacter(row, includeProfileSections, includeRaceMarkdown)), pagination, Number(count?.count ?? 0));
4479
4572
  }
4480
4573
  mapCharacterProfileSection(row) {
@@ -4955,6 +5048,26 @@ export class Store {
4955
5048
  throw notFound("角色");
4956
5049
  return this.mapCharacter(row);
4957
5050
  }
5051
+ setCharacterFavorite(characterId, isFavorite) {
5052
+ const character = this.db.get("SELECT id, work_id, is_favorite, merged_into_character_id FROM characters WHERE id = ?", characterId);
5053
+ if (!character)
5054
+ throw notFound("角色");
5055
+ if (optionalString(character, "merged_into_character_id")) {
5056
+ throw new AppError(409, "CHARACTER_ALREADY_MERGED", "已合并角色不能收藏");
5057
+ }
5058
+ const workId = requiredString(character, "work_id");
5059
+ const previousFavorite = booleanValue(character, "is_favorite");
5060
+ if (previousFavorite === isFavorite)
5061
+ return this.getCharacter(characterId);
5062
+ this.db.transaction(() => {
5063
+ this.db.run("UPDATE characters SET is_favorite = ? WHERE id = ?", isFavorite ? 1 : 0, characterId);
5064
+ this.audit(workId, "character.favorite-updated", "character", characterId, {
5065
+ previousFavorite,
5066
+ isFavorite
5067
+ });
5068
+ });
5069
+ return this.getCharacter(characterId);
5070
+ }
4958
5071
  getCharacterAvatar(characterId) {
4959
5072
  const character = this.db.get("SELECT id FROM characters WHERE id = ?", characterId);
4960
5073
  if (!character)
@@ -5229,6 +5342,7 @@ export class Store {
5229
5342
  profileSectionCount,
5230
5343
  currentState: json(requiredString(row, "current_state_json"), {}),
5231
5344
  isDead: booleanValue(row, "is_dead"),
5345
+ isFavorite: booleanValue(row, "is_favorite"),
5232
5346
  avatarUrl: avatarSha256
5233
5347
  ? `/api/characters/${encodeURIComponent(characterId)}/avatar?v=${encodeURIComponent(avatarSha256)}`
5234
5348
  : null,
@@ -6053,6 +6167,7 @@ export class Store {
6053
6167
  totalMessageCount,
6054
6168
  warningPending: Boolean(optionalString(conversation, "context_warning_at")),
6055
6169
  injectedEntities: parseAiInjectedEntities(optionalString(conversation, "injected_entities_json") ?? EMPTY_AI_INJECTED_ENTITIES),
6170
+ scenePin: normalizeRoleplayScenePin(json(optionalString(conversation, "scene_pin_json") ?? "{}", {})),
6056
6171
  messages: rows.filter((message) => requiredString(message, "id") !== excludeMessageId)
6057
6172
  .map((message) => ({
6058
6173
  id: requiredString(message, "id"),
@@ -6134,6 +6249,16 @@ export class Store {
6134
6249
  const refreshed = this.db.get("SELECT system_clock_text FROM ai_conversations WHERE id = ?", conversationId);
6135
6250
  return (optionalString(refreshed ?? {}, "system_clock_text") ?? clock).trim() || clock;
6136
6251
  }
6252
+ setAiConversationScenePin(conversationId, workId, pin) {
6253
+ const conversation = this.db.get("SELECT work_id FROM ai_conversations WHERE id = ?", conversationId);
6254
+ if (!conversation)
6255
+ throw notFound("AI 对话");
6256
+ if (requiredString(conversation, "work_id") !== workId)
6257
+ throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
6258
+ const normalized = normalizeRoleplayScenePin(pin);
6259
+ this.db.run("UPDATE ai_conversations SET scene_pin_json = ?, updated_at = ? WHERE id = ?", JSON.stringify(normalized), now(), conversationId);
6260
+ return normalized;
6261
+ }
6137
6262
  listCharacterNameEntries(workId) {
6138
6263
  this.getWork(workId);
6139
6264
  return this.db.all(`SELECT character_id, normalized_name, display_name, kind FROM character_names
@@ -6577,10 +6702,11 @@ export class Store {
6577
6702
  const injectedEntitiesJson = optionalString(conversation, "injected_entities_json")
6578
6703
  ?? JSON.stringify(EMPTY_AI_INJECTED_ENTITIES);
6579
6704
  const systemClockText = optionalString(conversation, "system_clock_text") ?? "";
6705
+ const scenePinJson = optionalString(conversation, "scene_pin_json") ?? "{}";
6580
6706
  this.db.transaction(() => {
6581
- this.db.run("INSERT INTO ai_conversations (id, work_id, roleplay_character_id, roleplay_user_character_id, task_type, context_scope_json, title, compacted_summary, compacted_message_count, agent_tools_json, injected_entities_json, system_clock_text, created_at, updated_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", forkId, workId, optionalString(conversation, "roleplay_character_id"), optionalString(conversation, "roleplay_user_character_id"), optionalString(conversation, "task_type"), optionalString(conversation, "context_scope_json"), title.slice(0, 200), forkSummary, forkCompactedCount, conversation.agent_tools_json == null
6707
+ this.db.run("INSERT INTO ai_conversations (id, work_id, roleplay_character_id, roleplay_user_character_id, task_type, context_scope_json, title, compacted_summary, compacted_message_count, agent_tools_json, injected_entities_json, system_clock_text, scene_pin_json, created_at, updated_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", forkId, workId, optionalString(conversation, "roleplay_character_id"), optionalString(conversation, "roleplay_user_character_id"), optionalString(conversation, "task_type"), optionalString(conversation, "context_scope_json"), title.slice(0, 200), forkSummary, forkCompactedCount, conversation.agent_tools_json == null
6582
6708
  ? JSON.stringify(normalizeWorkAgentTools(this.getWorkAiSettings(workId).agentTools))
6583
- : String(conversation.agent_tools_json), injectedEntitiesJson, systemClockText, timestamp, timestamp, currentRequestActor()?.userId ?? null);
6709
+ : String(conversation.agent_tools_json), injectedEntitiesJson, systemClockText, scenePinJson, timestamp, timestamp, currentRequestActor()?.userId ?? null);
6584
6710
  for (const message of messages.slice(0, targetIndex + 1)) {
6585
6711
  const role = requiredString(message, "role");
6586
6712
  const inheritedMetadata = json(requiredString(message, "metadata_json"), {});
@@ -6647,7 +6773,7 @@ export class Store {
6647
6773
  title: requiredString(row, "title"),
6648
6774
  isFavorite: booleanValue(row, "is_favorite"),
6649
6775
  messageCount: numberValue(row, "message_count"),
6650
- preview: requiredString(row, "preview"),
6776
+ preview: roleplayUserTurnDisplayText(requiredString(row, "preview")),
6651
6777
  compactedMessageCount: numberValue(row, "compacted_message_count"),
6652
6778
  hasCompactedSummary: Boolean(requiredString(row, "compacted_summary")),
6653
6779
  contextWarningPending: Boolean(optionalString(row, "context_warning_at")),
@@ -6655,6 +6781,7 @@ export class Store {
6655
6781
  ...(lockedModelId ? { modelId: lockedModelId } : {}),
6656
6782
  ...(hasImageAttachments ? { hasImageAttachments: true, modelLockedByImage: true } : {}),
6657
6783
  contextScope: json(optionalString(row, "context_scope_json") ?? "", { type: "none" }),
6784
+ scenePin: normalizeRoleplayScenePin(json(optionalString(row, "scene_pin_json") ?? "{}", {})),
6658
6785
  roleplayCharacter: roleplayCharacter ? {
6659
6786
  id: requiredString(roleplayCharacter, "id"),
6660
6787
  name: requiredString(roleplayCharacter, "name"),
@@ -6884,8 +7011,9 @@ export class Store {
6884
7011
  scope.targetCharacters = targetCharacters;
6885
7012
  }
6886
7013
  const sourceVersions = this.analysisTaskSourceVersions(workId, scope);
6887
- this.db.run(`INSERT INTO analysis_tasks (id, work_id, model_id, task_type, scope_json, status, source_versions_json, created_at, updated_at, created_by_user_id)
6888
- VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?)`, taskId, workId, input.modelId ?? null, input.taskType, JSON.stringify(scope), JSON.stringify(sourceVersions), timestamp, timestamp, currentRequestActor()?.userId ?? null);
7014
+ const actor = currentRequestActor();
7015
+ this.db.run(`INSERT INTO analysis_tasks (id, work_id, model_id, task_type, scope_json, status, source_versions_json, created_at, updated_at, created_by_user_id, created_via_api_key)
7016
+ VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?)`, taskId, workId, input.modelId ?? null, input.taskType, JSON.stringify(scope), JSON.stringify(sourceVersions), timestamp, timestamp, actor?.userId ?? null, actor?.authentication === "api-key" ? 1 : 0);
6889
7017
  this.audit(workId, "task.created", "analysis-task", taskId, {
6890
7018
  taskType: input.taskType,
6891
7019
  scope,