@musnows/scriverse 0.9.4 → 0.9.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 (45) hide show
  1. package/dist/ai-protocol.js +6 -0
  2. package/dist/ai-protocol.js.map +1 -1
  3. package/dist/ai-skills.js +134 -0
  4. package/dist/ai-skills.js.map +1 -0
  5. package/dist/ai-write-plans.js +2279 -0
  6. package/dist/ai-write-plans.js.map +1 -0
  7. package/dist/ai.js +2781 -154
  8. package/dist/ai.js.map +1 -1
  9. package/dist/app.js +328 -17
  10. package/dist/app.js.map +1 -1
  11. package/dist/chapter-annotation-anchor.js +327 -0
  12. package/dist/chapter-annotation-anchor.js.map +1 -0
  13. package/dist/chapter-title-numbering.js +56 -0
  14. package/dist/chapter-title-numbering.js.map +1 -0
  15. package/dist/database.js +478 -3
  16. package/dist/database.js.map +1 -1
  17. package/dist/public/ai-context-meter.js +3 -3
  18. package/dist/public/ai-interactive.js +483 -0
  19. package/dist/public/app.js +1991 -268
  20. package/dist/public/chapter-editor-behavior.js +40 -0
  21. package/dist/public/chapter-line-id-tracker.d.ts +25 -0
  22. package/dist/public/chapter-line-id-tracker.js +151 -0
  23. package/dist/public/index.html +79 -19
  24. package/dist/public/model-config.d.ts +1 -0
  25. package/dist/public/model-config.js +9 -4
  26. package/dist/public/styles.css +337 -57
  27. package/dist/public/theme-init.js +25 -1
  28. package/dist/remote-mcp.js +496 -0
  29. package/dist/remote-mcp.js.map +1 -0
  30. package/dist/roleplay-memory.js +53 -0
  31. package/dist/roleplay-memory.js.map +1 -0
  32. package/dist/security.js +4 -0
  33. package/dist/security.js.map +1 -1
  34. package/dist/semantic-search.js +225 -0
  35. package/dist/semantic-search.js.map +1 -0
  36. package/dist/skills/continue-writing/SKILL.md +23 -0
  37. package/dist/skills/polish-writing/SKILL.md +23 -0
  38. package/dist/store.js +695 -51
  39. package/dist/store.js.map +1 -1
  40. package/dist/ui-module-preload.js +27 -0
  41. package/dist/ui-module-preload.js.map +1 -0
  42. package/dist/user-auth.js +33 -1
  43. package/dist/user-auth.js.map +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +6 -1
package/dist/store.js CHANGED
@@ -4,34 +4,70 @@ import { ENTITY_VERSION_BASELINE_MIGRATION_VERSION, PLATFORM_AI_WORK_ID, SYSTEM_
4
4
  import { exportWorkDocx } from "./docx-export.js";
5
5
  import { createEpubArchive } from "./epub-export.js";
6
6
  import { AppError, notFound } from "./errors.js";
7
- import { documentParagraphLineRanges, normalizeWorkSearchQuery } from "./hybrid-search.js";
7
+ import { documentParagraphLineRanges, hybridSearchPermissionModule, normalizeWorkSearchQuery } from "./hybrid-search.js";
8
8
  import { accountReference, logger } from "./logger.js";
9
9
  import { paginated, paginationSql } from "./pagination.js";
10
10
  import { currentRequestActor } from "./request-context.js";
11
- import { canWriteWorkModule, classifyWorkModulePermissions, emptyWorkModulePermissions, fullWorkModulePermissions, storedWorkModulePermissions } from "./work-permissions.js";
11
+ import { canReadWorkModule, canWriteWorkModule, classifyWorkModulePermissions, emptyWorkModulePermissions, fullWorkModulePermissions, storedWorkModulePermissions } from "./work-permissions.js";
12
12
  import { countWords, documentShortSearchTerms, escapeSqlLikePattern, id, json, normalizeDocumentSearchText, 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
15
  import { DEFAULT_AI_STREAM_IDLE_TIMEOUT_SECONDS, normalizeAiStreamIdleTimeoutSeconds } from "./ai-stream-timeout.js";
16
16
  import { normalizeRoleplayScenePin, roleplayUserTurnDisplayText, roleplayUserTurnTitleSource } from "./roleplay-turn.js";
17
+ import { chapterAnnotationLineHashes, createChapterLineIds, MAX_CHAPTER_LINE_IDS, parseChapterAnnotationLineIds, parseChapterAnnotationLineHashes, parseChapterLineIds, reconcileChapterLineIds, reanchorChapterAnnotations } from "./chapter-annotation-anchor.js";
18
+ import { renumberChapterTitle } from "./chapter-title-numbering.js";
19
+ import { normalizeRoleplayMemoryContent, roleplayMemoryCandidateIsSafe } from "./roleplay-memory.js";
17
20
  const WORK_LIST_BATCH_SIZE = 500;
18
21
  const ENTITY_LIST_BATCH_SIZE = 400;
19
22
  export const RECYCLE_BIN_RETENTION_DAYS = 30;
20
23
  function recycleBinExpiresAt(deletedAt) {
21
24
  return new Date(new Date(deletedAt).getTime() + RECYCLE_BIN_RETENTION_DAYS * 24 * 60 * 60_000).toISOString();
22
25
  }
26
+ function chapterAnnotationListFilter(kinds, filters = {}) {
27
+ const where = [];
28
+ const params = [];
29
+ if (kinds !== undefined) {
30
+ if (kinds.length > 0) {
31
+ where.push(`annotation.kind IN (${kinds.map(() => "?").join(",")})`);
32
+ params.push(...kinds);
33
+ }
34
+ else {
35
+ where.push("1 = 0");
36
+ }
37
+ }
38
+ if (filters.chapterId) {
39
+ where.push("annotation.chapter_id = ?");
40
+ params.push(filters.chapterId);
41
+ }
42
+ const normalizedQuery = filters.query?.normalize("NFKC").trim().slice(0, 100) ?? "";
43
+ if (normalizedQuery) {
44
+ const pattern = `%${escapeSqlLikePattern(normalizedQuery)}%`;
45
+ where.push(`(
46
+ annotation.note LIKE ? ESCAPE '\\' COLLATE NOCASE
47
+ OR annotation.quote LIKE ? ESCAPE '\\' COLLATE NOCASE
48
+ OR chapter.title LIKE ? ESCAPE '\\' COLLATE NOCASE
49
+ OR volume.title LIKE ? ESCAPE '\\' COLLATE NOCASE
50
+ )`);
51
+ params.push(pattern, pattern, pattern, pattern);
52
+ }
53
+ return {
54
+ sql: where.map((clause) => ` AND ${clause}`).join(""),
55
+ params
56
+ };
57
+ }
23
58
  export const attachmentPermissionModules = ["prose", "drafts", "settings", "characters", "races", "organizations", "ai-chat"];
24
59
  export const WORK_AGENT_TOOL_IDS = [
25
60
  "story_index",
26
61
  "read_chapters",
27
62
  "grep",
28
63
  "search_story_entities",
64
+ "semantic_search_story",
29
65
  "read_character_sections",
30
66
  "search_drafts",
31
67
  "image",
32
68
  "calculate_time"
33
69
  ];
34
- const DEFAULT_WORK_AGENT_TOOLS = [...WORK_AGENT_TOOL_IDS];
70
+ const DEFAULT_WORK_AGENT_TOOLS = WORK_AGENT_TOOL_IDS.filter((toolId) => toolId !== "semantic_search_story");
35
71
  const LEGACY_DEFAULT_WORK_AGENT_TOOLS = [
36
72
  "story_index",
37
73
  "read_chapters",
@@ -217,7 +253,12 @@ const legacyFavoriteTables = {
217
253
  organization: "organizations"
218
254
  };
219
255
  export const AI_CONVERSATION_STREAM_REQUEST_LEASE_MS = 3 * 60_000;
220
- export const aiConversationTaskTypes = ["chat", "roleplay", "continue", "polish"];
256
+ export const aiConversationTaskTypes = ["chat", "roleplay"];
257
+ function normalizeAiConversationTaskType(value, roleplayCharacterId) {
258
+ if (roleplayCharacterId || value === "roleplay")
259
+ return "roleplay";
260
+ return "chat";
261
+ }
221
262
  export function defaultAiConversationTitle(prompt) {
222
263
  const normalized = roleplayUserTurnTitleSource(prompt).replace(/\s+/gu, " ").trim();
223
264
  return Array.from(normalized).slice(0, 15).join("") || "新对话";
@@ -534,6 +575,8 @@ export class Store {
534
575
  language: entity.language,
535
576
  coverUrl: entity.coverUrl,
536
577
  tags: entity.tags,
578
+ editorAutoIndentEnabled: entity.editorAutoIndentEnabled,
579
+ editorTypewriterModeEnabled: entity.editorTypewriterModeEnabled,
537
580
  ownerUserId: entity.ownerUserId
538
581
  };
539
582
  if (type === "volume")
@@ -802,8 +845,9 @@ export class Store {
802
845
  return this.db.transaction(() => {
803
846
  const ownerUserId = this.resolveWorkOwnerUserId(typeof snapshot.ownerUserId === "string" ? snapshot.ownerUserId : null, true);
804
847
  const timestamp = now();
805
- this.db.run(`INSERT INTO works (id, title, author, description, language, cover_url, tags_json, version_no, created_at, updated_at, owner_user_id)
806
- VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`, entityId, String(snapshot.title ?? "未命名作品"), String(snapshot.author ?? ""), String(snapshot.description ?? ""), String(snapshot.language ?? "zh-CN"), snapshot.coverUrl ?? null, JSON.stringify(Array.isArray(snapshot.tags) ? snapshot.tags : []), timestamp, timestamp, ownerUserId);
848
+ this.db.run(`INSERT INTO works (id, title, author, description, language, cover_url, tags_json,
849
+ editor_auto_indent_enabled, editor_typewriter_mode_enabled, version_no, created_at, updated_at, owner_user_id)
850
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`, entityId, String(snapshot.title ?? "未命名作品"), String(snapshot.author ?? ""), String(snapshot.description ?? ""), String(snapshot.language ?? "zh-CN"), snapshot.coverUrl ?? null, JSON.stringify(Array.isArray(snapshot.tags) ? snapshot.tags : []), snapshot.editorAutoIndentEnabled === true ? 1 : 0, snapshot.editorTypewriterModeEnabled === true ? 1 : 0, timestamp, timestamp, ownerUserId);
807
851
  if (ownerUserId !== SYSTEM_USER_ID) {
808
852
  this.db.run("INSERT INTO work_memberships (work_id, user_id, role, invited_by_user_id, created_at) VALUES (?, ?, 'owner', ?, ?)", entityId, ownerUserId, ownerUserId, timestamp);
809
853
  }
@@ -868,8 +912,9 @@ export class Store {
868
912
  const timestamp = now();
869
913
  const resolvedOwnerUserId = this.resolveWorkOwnerUserId(ownerUserId);
870
914
  this.db.transaction(() => {
871
- this.db.run(`INSERT INTO works (id, title, author, description, language, cover_url, tags_json, created_at, updated_at, owner_user_id)
872
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, workId, input.title, input.author ?? "", input.description ?? "", input.language ?? "zh-CN", input.coverUrl ?? null, JSON.stringify(input.tags ?? []), timestamp, timestamp, resolvedOwnerUserId);
915
+ this.db.run(`INSERT INTO works (id, title, author, description, language, cover_url, tags_json,
916
+ editor_auto_indent_enabled, editor_typewriter_mode_enabled, created_at, updated_at, owner_user_id)
917
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, workId, input.title, input.author ?? "", input.description ?? "", input.language ?? "zh-CN", input.coverUrl ?? null, JSON.stringify(input.tags ?? []), input.editorAutoIndentEnabled === true ? 1 : 0, input.editorTypewriterModeEnabled === true ? 1 : 0, timestamp, timestamp, resolvedOwnerUserId);
873
918
  if (resolvedOwnerUserId !== SYSTEM_USER_ID) {
874
919
  this.db.run("INSERT INTO work_memberships (work_id, user_id, role, invited_by_user_id, created_at) VALUES (?, ?, 'owner', ?, ?)", workId, resolvedOwnerUserId, resolvedOwnerUserId, timestamp);
875
920
  }
@@ -1065,6 +1110,18 @@ export class Store {
1065
1110
  titleGenerationModelId: row?.title_generation_model_id === null || row?.title_generation_model_id === undefined
1066
1111
  ? null
1067
1112
  : String(row.title_generation_model_id),
1113
+ semanticSearchEnabled: Number(row?.semantic_search_enabled ?? 0) === 1,
1114
+ semanticEmbeddingModelId: row?.semantic_embedding_model_id === null || row?.semantic_embedding_model_id === undefined
1115
+ ? null
1116
+ : String(row.semantic_embedding_model_id),
1117
+ semanticRerankModelId: row?.semantic_rerank_model_id === null || row?.semantic_rerank_model_id === undefined
1118
+ ? null
1119
+ : String(row.semantic_rerank_model_id),
1120
+ semanticVectorDimension: Math.min(65_536, Math.max(1, Number(row?.semantic_vector_dimension ?? 1_024) || 1_024)),
1121
+ semanticRecallLimit: Math.min(200, Math.max(1, Number(row?.semantic_recall_limit ?? 20) || 20)),
1122
+ semanticResultLimit: Math.min(100, Math.max(1, Number(row?.semantic_result_limit ?? 12) || 12)),
1123
+ semanticBudgetTokens: Math.min(100_000, Math.max(256, Number(row?.semantic_budget_tokens ?? 4_000) || 4_000)),
1124
+ semanticChannelWeight: Math.min(5, Math.max(0.1, Number(row?.semantic_channel_weight ?? 1) || 1)),
1068
1125
  updatedAt: String(row?.updated_at ?? "")
1069
1126
  };
1070
1127
  }
@@ -1149,6 +1206,95 @@ export class Store {
1149
1206
  });
1150
1207
  return this.getWorkAiSettings(workId);
1151
1208
  }
1209
+ updateWorkSemanticSearchSettings(workId, input) {
1210
+ this.getWork(workId);
1211
+ const current = this.getWorkAiSettings(workId);
1212
+ const enabled = input.enabled ?? Boolean(current.semanticSearchEnabled);
1213
+ const embeddingModelId = input.embeddingModelId === undefined
1214
+ ? current.semanticEmbeddingModelId ? String(current.semanticEmbeddingModelId) : null
1215
+ : input.embeddingModelId?.trim() || null;
1216
+ const rerankModelId = input.rerankModelId === undefined
1217
+ ? current.semanticRerankModelId ? String(current.semanticRerankModelId) : null
1218
+ : input.rerankModelId?.trim() || null;
1219
+ const vectorDimension = Math.min(65_536, Math.max(1, Math.trunc(input.vectorDimension ?? Number(current.semanticVectorDimension))));
1220
+ const recallLimit = Math.min(200, Math.max(1, Math.trunc(input.recallLimit ?? Number(current.semanticRecallLimit))));
1221
+ const resultLimit = Math.min(100, Math.max(1, Math.trunc(input.resultLimit ?? Number(current.semanticResultLimit))));
1222
+ const budgetTokens = Math.min(100_000, Math.max(256, Math.trunc(input.budgetTokens ?? Number(current.semanticBudgetTokens))));
1223
+ const channelWeight = Math.min(5, Math.max(0.1, Number(input.channelWeight ?? current.semanticChannelWeight)));
1224
+ const timestamp = now();
1225
+ this.db.transaction(() => {
1226
+ this.db.run(`INSERT INTO work_ai_settings (
1227
+ work_id, semantic_search_enabled, semantic_embedding_model_id, semantic_rerank_model_id,
1228
+ semantic_vector_dimension, semantic_recall_limit, semantic_result_limit,
1229
+ semantic_budget_tokens, semantic_channel_weight, updated_at
1230
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1231
+ ON CONFLICT(work_id) DO UPDATE SET
1232
+ semantic_search_enabled = excluded.semantic_search_enabled,
1233
+ semantic_embedding_model_id = excluded.semantic_embedding_model_id,
1234
+ semantic_rerank_model_id = excluded.semantic_rerank_model_id,
1235
+ semantic_vector_dimension = excluded.semantic_vector_dimension,
1236
+ semantic_recall_limit = excluded.semantic_recall_limit,
1237
+ semantic_result_limit = excluded.semantic_result_limit,
1238
+ semantic_budget_tokens = excluded.semantic_budget_tokens,
1239
+ semantic_channel_weight = excluded.semantic_channel_weight,
1240
+ updated_at = excluded.updated_at`, workId, enabled ? 1 : 0, embeddingModelId, rerankModelId, vectorDimension, recallLimit, resultLimit, budgetTokens, channelWeight, timestamp);
1241
+ this.audit(workId, "semantic.settings.updated", "work-ai-settings", workId, {
1242
+ enabled,
1243
+ embeddingModelId,
1244
+ rerankModelId,
1245
+ vectorDimension,
1246
+ recallLimit,
1247
+ resultLimit,
1248
+ budgetTokens,
1249
+ channelWeight
1250
+ });
1251
+ });
1252
+ return this.getWorkAiSettings(workId);
1253
+ }
1254
+ getSemanticContextSnapshot(snapshotId, workId) {
1255
+ const work = this.getWork(workId);
1256
+ const row = this.db.get("SELECT * FROM semantic_context_snapshots WHERE id = ? AND work_id = ?", snapshotId, workId);
1257
+ if (!row)
1258
+ throw notFound("语义上下文快照");
1259
+ const actor = currentRequestActor();
1260
+ const createdByUserId = row.created_by_user_id === null || row.created_by_user_id === undefined
1261
+ ? null
1262
+ : String(row.created_by_user_id);
1263
+ if (actor?.userId && createdByUserId && actor.userId !== createdByUserId) {
1264
+ throw new AppError(403, "SEMANTIC_SNAPSHOT_ACCESS_DENIED", "不能读取其他用户创建的语义上下文快照");
1265
+ }
1266
+ const permissions = work.modulePermissions;
1267
+ const items = this.db.all("SELECT * FROM semantic_context_snapshot_items WHERE snapshot_id = ? ORDER BY position", snapshotId).flatMap((item) => {
1268
+ const module = hybridSearchPermissionModule(String(item.source_type));
1269
+ if (!module || !canReadWorkModule(permissions, module))
1270
+ return [];
1271
+ return [{
1272
+ position: Number(item.position),
1273
+ entryId: String(item.entry_id),
1274
+ sourceType: String(item.source_type),
1275
+ sourceId: String(item.source_id),
1276
+ sectionId: String(item.section_id) || null,
1277
+ sourceVersion: String(item.source_version),
1278
+ sourceTitle: String(item.source_title),
1279
+ startLine: Number(item.start_line),
1280
+ endLine: Number(item.end_line),
1281
+ content: String(item.content),
1282
+ estimatedTokens: Number(item.estimated_tokens),
1283
+ matchKinds: json(String(item.match_kinds_json), ["semantic"])
1284
+ }];
1285
+ });
1286
+ return {
1287
+ id: String(row.id),
1288
+ workId: String(row.work_id),
1289
+ conversationId: row.conversation_id === null ? null : String(row.conversation_id),
1290
+ query: String(row.query),
1291
+ scope: json(String(row.scope_json), {}),
1292
+ configFingerprint: String(row.config_fingerprint),
1293
+ createdByUserId,
1294
+ createdAt: String(row.created_at),
1295
+ items
1296
+ };
1297
+ }
1152
1298
  clearAutoRunPause(workId) {
1153
1299
  this.getWork(workId);
1154
1300
  const current = this.getWorkAiSettings(workId);
@@ -1201,8 +1347,9 @@ export class Store {
1201
1347
  const current = this.getWork(workId);
1202
1348
  this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
1203
1349
  const timestamp = now();
1204
- this.db.run(`UPDATE works SET title = ?, author = ?, description = ?, language = ?, cover_url = ?, tags_json = ?, version_no = version_no + 1, updated_at = ?
1205
- WHERE id = ?`, input.title ?? String(current.title), input.author ?? String(current.author), input.description ?? String(current.description), input.language ?? String(current.language), input.coverUrl === undefined ? current.coverUrl : input.coverUrl, JSON.stringify(input.tags ?? current.tags), timestamp, workId);
1350
+ this.db.run(`UPDATE works SET title = ?, author = ?, description = ?, language = ?, cover_url = ?, tags_json = ?,
1351
+ editor_auto_indent_enabled = ?, editor_typewriter_mode_enabled = ?, version_no = version_no + 1, updated_at = ?
1352
+ WHERE id = ?`, input.title ?? String(current.title), input.author ?? String(current.author), input.description ?? String(current.description), input.language ?? String(current.language), input.coverUrl === undefined ? current.coverUrl : input.coverUrl, JSON.stringify(input.tags ?? current.tags), input.editorAutoIndentEnabled === undefined ? (current.editorAutoIndentEnabled ? 1 : 0) : input.editorAutoIndentEnabled ? 1 : 0, input.editorTypewriterModeEnabled === undefined ? (current.editorTypewriterModeEnabled ? 1 : 0) : input.editorTypewriterModeEnabled ? 1 : 0, timestamp, workId);
1206
1353
  this.recordEntityVersion("work", workId, source, sourceRef, changeNote || "更新作品信息", timestamp);
1207
1354
  this.audit(workId, "work.updated", "work", workId, { fields: Object.keys(input), versionNo: Number(current.versionNo) + 1, source, sourceRef, changeNote });
1208
1355
  });
@@ -2106,6 +2253,7 @@ export class Store {
2106
2253
  const nextContent = input.content === undefined ? String(current.content) : input.content;
2107
2254
  const nextExcluded = input.excludedFromAnalysis ?? Boolean(current.excludedFromAnalysis);
2108
2255
  const nextChapterType = input.chapterType ?? String(current.chapterType);
2256
+ const hasContentChange = nextContent !== current.content;
2109
2257
  const hasTextChange = nextTitle !== current.title || nextContent !== current.content;
2110
2258
  const hasTypeChange = nextChapterType !== current.chapterType;
2111
2259
  const hasOtherChange = nextExcluded !== current.excludedFromAnalysis || hasTypeChange;
@@ -2116,12 +2264,23 @@ export class Store {
2116
2264
  this.db.transaction(() => {
2117
2265
  const lockedCurrent = this.getChapter(chapterId);
2118
2266
  this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(lockedCurrent.versionNo));
2267
+ const storedLineIds = parseChapterLineIds(lockedCurrent.lineIds, String(lockedCurrent.content));
2268
+ const beforeLineIds = storedLineIds.length > 0
2269
+ ? storedLineIds
2270
+ : createChapterLineIds(String(lockedCurrent.content), () => id("chapterLine"));
2271
+ const nextLineIds = hasContentChange
2272
+ ? reconcileChapterLineIds(String(lockedCurrent.content), nextContent, beforeLineIds, input.lineIds, () => id("chapterLine"))
2273
+ : beforeLineIds;
2274
+ if (!nextLineIds)
2275
+ throw new AppError(400, "CHAPTER_LINE_IDS_INVALID", "正文行身份与当前版本不匹配,请刷新后重试");
2119
2276
  this.db.run(`UPDATE chapters SET title = ?, content = ?, chapter_type = ?, word_count = ?, version_no = ?, analysis_status = ?,
2120
- excluded_from_analysis = ?, updated_at = ? WHERE id = ?`, nextTitle, nextContent, nextChapterType, countWords(nextContent), versionNo, hasTextChange || hasTypeChange ? "expired" : String(current.analysisStatus), nextExcluded ? 1 : 0, timestamp, chapterId);
2277
+ excluded_from_analysis = ?, line_ids_json = ?, updated_at = ? WHERE id = ?`, nextTitle, nextContent, nextChapterType, countWords(nextContent), versionNo, hasTextChange || hasTypeChange ? "expired" : String(current.analysisStatus), nextExcluded ? 1 : 0, JSON.stringify(nextLineIds), timestamp, chapterId);
2121
2278
  if (hasTextChange)
2122
2279
  this.syncChapterParagraphSearch(String(current.workId), chapterId, nextContent);
2123
2280
  else if (hasTypeChange)
2124
2281
  this.syncChapterParagraphSearchVersion(chapterId, versionNo);
2282
+ if (hasContentChange)
2283
+ this.reanchorChapterAnnotations(String(current.workId), chapterId, String(lockedCurrent.content), nextContent, nextLineIds, source, timestamp);
2125
2284
  if (hasTextChange || hasTypeChange) {
2126
2285
  this.insertChapterVersionRow({
2127
2286
  workId: String(current.workId),
@@ -2145,6 +2304,36 @@ export class Store {
2145
2304
  });
2146
2305
  return this.getChapter(chapterId);
2147
2306
  }
2307
+ reanchorChapterAnnotations(workId, chapterId, beforeContent, afterContent, afterLineIds, source, timestamp) {
2308
+ const annotations = this.db.all(`SELECT id, start_line, end_line, quote, line_hashes_json, anchor_line_ids_json
2309
+ FROM chapter_annotations
2310
+ WHERE chapter_id = ? AND deleted_at IS NULL`, chapterId).map((row) => ({
2311
+ id: requiredString(row, "id"),
2312
+ startLine: numberValue(row, "start_line"),
2313
+ endLine: numberValue(row, "end_line"),
2314
+ quote: requiredString(row, "quote"),
2315
+ lineHashes: parseChapterAnnotationLineHashes(row.line_hashes_json, requiredString(row, "quote")),
2316
+ lineIds: parseChapterAnnotationLineIds(row.anchor_line_ids_json)
2317
+ }));
2318
+ for (const annotation of reanchorChapterAnnotations(beforeContent, afterContent, annotations, afterLineIds)) {
2319
+ if (!annotation.changed)
2320
+ continue;
2321
+ this.db.run(`UPDATE chapter_annotations
2322
+ SET start_line = ?, end_line = ?, quote = ?, line_hashes_json = ?, anchor_line_ids_json = ?, version_no = version_no + 1
2323
+ WHERE id = ?`, annotation.startLine, annotation.endLine, annotation.quote, JSON.stringify(annotation.lineHashes), JSON.stringify(annotation.lineIds), annotation.id);
2324
+ const updated = this.getChapterAnnotation(annotation.id);
2325
+ this.recordChapterAnnotationVersion(updated, "reanchor", timestamp);
2326
+ this.audit(workId, "chapter.annotation.updated", "chapter-annotation", annotation.id, {
2327
+ chapterId,
2328
+ startLine: annotation.startLine,
2329
+ endLine: annotation.endLine,
2330
+ versionNo: updated.versionNo,
2331
+ anchorStrategy: annotation.anchorStrategy,
2332
+ reason: "reanchor",
2333
+ source
2334
+ });
2335
+ }
2336
+ }
2148
2337
  replaceWorkText(workId, input) {
2149
2338
  const find = input.find;
2150
2339
  if (!find)
@@ -2280,10 +2469,11 @@ export class Store {
2280
2469
  ? numberValue(this.db.get("SELECT COALESCE(MAX(sort_order), -1) AS sort_order FROM chapters WHERE volume_id = ? AND deleted_at IS NULL", volumeId) ?? {}, "sort_order") + 1
2281
2470
  : numberValue(version, "sort_order");
2282
2471
  const timestamp = now();
2472
+ const lineIds = createChapterLineIds(content, () => id("chapterLine"));
2283
2473
  const nextVersionNo = numberValue(this.db.get("SELECT COALESCE(MAX(version_no), 0) AS version_no FROM chapter_versions WHERE chapter_id = ?", chapterId) ?? {}, "version_no") + 1;
2284
2474
  this.db.transaction(() => {
2285
- this.db.run(`INSERT INTO chapters (id, work_id, volume_id, title, content, chapter_type, sort_order, word_count, version_no, analysis_status, created_at, updated_at)
2286
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`, chapterId, workId, volumeId, title, content, chapterType, sortOrder, countWords(content), nextVersionNo, timestamp, timestamp);
2475
+ this.db.run(`INSERT INTO chapters (id, work_id, volume_id, title, content, line_ids_json, chapter_type, sort_order, word_count, version_no, analysis_status, created_at, updated_at)
2476
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`, chapterId, workId, volumeId, title, content, JSON.stringify(lineIds), chapterType, sortOrder, countWords(content), nextVersionNo, timestamp, timestamp);
2287
2477
  this.syncChapterParagraphSearch(workId, chapterId, content);
2288
2478
  this.insertChapterVersionRow({
2289
2479
  workId,
@@ -2437,11 +2627,14 @@ export class Store {
2437
2627
  };
2438
2628
  }
2439
2629
  chapterAnnotationSnapshot(annotation) {
2630
+ const anchor = this.db.get("SELECT anchor_line_ids_json FROM chapter_annotations WHERE id = ?", String(annotation.id));
2440
2631
  return {
2441
2632
  kind: annotation.kind,
2442
2633
  startLine: annotation.startLine,
2443
2634
  endLine: annotation.endLine,
2444
2635
  quote: annotation.quote,
2636
+ lineHashes: chapterAnnotationLineHashes(String(annotation.quote)),
2637
+ lineIds: parseChapterAnnotationLineIds(anchor?.anchor_line_ids_json),
2445
2638
  note: annotation.note,
2446
2639
  status: annotation.status,
2447
2640
  deletedAt: annotation.deletedAt ?? null
@@ -2484,7 +2677,8 @@ export class Store {
2484
2677
  return this.db.all(`WITH RECURSIVE annotation_lines(line, end_line) AS (
2485
2678
  SELECT annotation.start_line, annotation.end_line
2486
2679
  FROM chapter_annotations annotation
2487
- WHERE annotation.chapter_id = ? AND annotation.deleted_at IS NULL${kindFilter.sql}
2680
+ WHERE annotation.chapter_id = ? AND annotation.deleted_at IS NULL
2681
+ AND NOT (annotation.kind = 'todo' AND annotation.status = 'resolved')${kindFilter.sql}
2488
2682
  UNION ALL
2489
2683
  SELECT line + 1, end_line
2490
2684
  FROM annotation_lines
@@ -2495,55 +2689,69 @@ export class Store {
2495
2689
  GROUP BY line
2496
2690
  ORDER BY line`, chapterId, ...kindFilter.params).map((row) => ({ line: Number(row.line), count: Number(row.count) }));
2497
2691
  }
2498
- listWorkChapterAnnotations(workId, kinds) {
2692
+ listWorkChapterAnnotations(workId, kinds, filters = {}) {
2499
2693
  this.getWork(workId);
2500
- const kindFilter = kinds === undefined
2501
- ? { sql: "", params: [] }
2502
- : kinds.length > 0
2503
- ? { sql: ` AND annotation.kind IN (${kinds.map(() => "?").join(",")})`, params: [...kinds] }
2504
- : { sql: " AND 1 = 0", params: [] };
2694
+ const filter = chapterAnnotationListFilter(kinds, filters);
2505
2695
  return this.db.all(`SELECT annotation.*, user.display_name AS actor_display_name, user.username AS actor_username,
2506
2696
  chapter.title AS chapter_title, volume.title AS volume_title
2507
2697
  FROM chapter_annotations annotation
2508
2698
  JOIN chapters chapter ON chapter.id = annotation.chapter_id
2509
2699
  JOIN volumes volume ON volume.id = chapter.volume_id
2510
2700
  LEFT JOIN users user ON user.id = annotation.updated_by_user_id
2511
- WHERE annotation.work_id = ? AND annotation.deleted_at IS NULL AND chapter.deleted_at IS NULL${kindFilter.sql}
2512
- ORDER BY CASE annotation.status WHEN 'open' THEN 0 ELSE 1 END,
2701
+ WHERE annotation.work_id = ? AND annotation.deleted_at IS NULL AND chapter.deleted_at IS NULL${filter.sql}
2702
+ ORDER BY CASE
2703
+ WHEN annotation.kind = 'todo' AND annotation.status = 'resolved' THEN 2
2704
+ WHEN annotation.status = 'open' THEN 0
2705
+ ELSE 1
2706
+ END,
2513
2707
  volume.sort_order, volume.created_at, chapter.sort_order, chapter.created_at,
2514
- annotation.start_line, annotation.created_at`, workId, ...kindFilter.params).map((row) => ({
2708
+ annotation.start_line, annotation.created_at`, workId, ...filter.params).map((row) => ({
2515
2709
  ...this.mapChapterAnnotation(row),
2516
2710
  volumeTitle: requiredString(row, "volume_title"),
2517
2711
  chapterTitle: requiredString(row, "chapter_title")
2518
2712
  }));
2519
2713
  }
2520
- listWorkChapterAnnotationsPage(workId, pagination, kinds) {
2714
+ listWorkChapterAnnotationsPage(workId, pagination, kinds, filters = {}) {
2521
2715
  this.getWork(workId);
2522
2716
  const page = paginationSql(pagination);
2523
- const kindFilter = kinds === undefined
2524
- ? { sql: "", params: [] }
2525
- : kinds.length > 0
2526
- ? { sql: ` AND annotation.kind IN (${kinds.map(() => "?").join(",")})`, params: [...kinds] }
2527
- : { sql: " AND 1 = 0", params: [] };
2717
+ const filter = chapterAnnotationListFilter(kinds, filters);
2718
+ const optionFilter = chapterAnnotationListFilter(kinds);
2528
2719
  const rows = this.db.all(`SELECT annotation.*, user.display_name AS actor_display_name, user.username AS actor_username,
2529
2720
  chapter.title AS chapter_title, volume.title AS volume_title
2530
2721
  FROM chapter_annotations annotation
2531
2722
  JOIN chapters chapter ON chapter.id = annotation.chapter_id
2532
2723
  JOIN volumes volume ON volume.id = chapter.volume_id
2533
2724
  LEFT JOIN users user ON user.id = annotation.updated_by_user_id
2534
- WHERE annotation.work_id = ? AND annotation.deleted_at IS NULL AND chapter.deleted_at IS NULL${kindFilter.sql}
2535
- ORDER BY CASE annotation.status WHEN 'open' THEN 0 ELSE 1 END,
2725
+ WHERE annotation.work_id = ? AND annotation.deleted_at IS NULL AND chapter.deleted_at IS NULL${filter.sql}
2726
+ ORDER BY CASE
2727
+ WHEN annotation.kind = 'todo' AND annotation.status = 'resolved' THEN 2
2728
+ WHEN annotation.status = 'open' THEN 0
2729
+ ELSE 1
2730
+ END,
2536
2731
  volume.sort_order, volume.created_at, chapter.sort_order, chapter.created_at,
2537
- annotation.start_line, annotation.created_at${page.sql}`, workId, ...kindFilter.params, ...page.params);
2732
+ annotation.start_line, annotation.created_at${page.sql}`, workId, ...filter.params, ...page.params);
2538
2733
  const total = numberValue(this.db.get(`SELECT COUNT(*) AS count
2539
2734
  FROM chapter_annotations annotation
2540
2735
  JOIN chapters chapter ON chapter.id = annotation.chapter_id
2541
- WHERE annotation.work_id = ? AND annotation.deleted_at IS NULL AND chapter.deleted_at IS NULL${kindFilter.sql}`, workId, ...kindFilter.params) ?? {}, "count");
2542
- return paginated(rows.map((row) => ({
2543
- ...this.mapChapterAnnotation(row),
2544
- volumeTitle: requiredString(row, "volume_title"),
2545
- chapterTitle: requiredString(row, "chapter_title")
2546
- })), pagination, total);
2736
+ JOIN volumes volume ON volume.id = chapter.volume_id
2737
+ WHERE annotation.work_id = ? AND annotation.deleted_at IS NULL AND chapter.deleted_at IS NULL${filter.sql}`, workId, ...filter.params) ?? {}, "count");
2738
+ const chapterOptions = this.db.all(`SELECT DISTINCT chapter.id, chapter.title, volume.title AS volume_title,
2739
+ volume.sort_order AS volume_sort_order, volume.created_at AS volume_created_at,
2740
+ chapter.sort_order AS chapter_sort_order, chapter.created_at AS chapter_created_at
2741
+ FROM chapter_annotations annotation
2742
+ JOIN chapters chapter ON chapter.id = annotation.chapter_id
2743
+ JOIN volumes volume ON volume.id = chapter.volume_id
2744
+ WHERE annotation.work_id = ? AND annotation.deleted_at IS NULL AND chapter.deleted_at IS NULL${optionFilter.sql}
2745
+ ORDER BY volume.sort_order, volume.created_at, chapter.sort_order, chapter.created_at`, workId, ...optionFilter.params).map((row) => ({
2746
+ id: requiredString(row, "id"),
2747
+ title: requiredString(row, "title"),
2748
+ volumeTitle: requiredString(row, "volume_title")
2749
+ }));
2750
+ return { ...paginated(rows.map((row) => ({
2751
+ ...this.mapChapterAnnotation(row),
2752
+ volumeTitle: requiredString(row, "volume_title"),
2753
+ chapterTitle: requiredString(row, "chapter_title")
2754
+ })), pagination, total), chapterOptions };
2547
2755
  }
2548
2756
  createChapterAnnotation(chapterId, input) {
2549
2757
  const chapter = this.getChapter(chapterId);
@@ -2555,9 +2763,15 @@ export class Store {
2555
2763
  const annotationId = id("chapterAnnotation");
2556
2764
  const timestamp = now();
2557
2765
  const actorId = currentRequestActor()?.userId ?? null;
2766
+ const quote = lines.slice(input.startLine - 1, input.endLine).join("\n");
2767
+ const chapterLineIds = parseChapterLineIds(chapter.lineIds, String(chapter.content));
2768
+ if (lines.length <= MAX_CHAPTER_LINE_IDS && chapterLineIds.length !== lines.length) {
2769
+ throw new AppError(409, "CHAPTER_LINE_IDS_MISSING", "正文行身份尚未初始化,请重新保存正文后再添加评论");
2770
+ }
2771
+ const anchorLineIds = chapterLineIds.slice(input.startLine - 1, input.endLine);
2558
2772
  this.db.transaction(() => {
2559
- this.db.run(`INSERT INTO chapter_annotations (id, work_id, chapter_id, kind, start_line, end_line, quote, note, status, version_no, created_at, updated_at, created_by_user_id, updated_by_user_id)
2560
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', 1, ?, ?, ?, ?)`, annotationId, String(chapter.workId), chapterId, input.kind, input.startLine, input.endLine, lines.slice(input.startLine - 1, input.endLine).join("\n"), input.note.trim(), timestamp, timestamp, actorId, actorId);
2773
+ this.db.run(`INSERT INTO chapter_annotations (id, work_id, chapter_id, kind, start_line, end_line, quote, line_hashes_json, anchor_line_ids_json, note, status, version_no, created_at, updated_at, created_by_user_id, updated_by_user_id)
2774
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', 1, ?, ?, ?, ?)`, annotationId, String(chapter.workId), chapterId, input.kind, input.startLine, input.endLine, quote, JSON.stringify(chapterAnnotationLineHashes(quote)), JSON.stringify(anchorLineIds), input.note.trim(), timestamp, timestamp, actorId, actorId);
2561
2775
  const annotation = this.getChapterAnnotation(annotationId);
2562
2776
  this.recordChapterAnnotationVersion(annotation, "create", timestamp);
2563
2777
  this.audit(String(chapter.workId), "chapter.annotation.created", "chapter-annotation", annotationId, { kind: input.kind, chapterId, startLine: input.startLine, endLine: input.endLine });
@@ -2603,6 +2817,64 @@ export class Store {
2603
2817
  return chapter;
2604
2818
  });
2605
2819
  const timestamp = now();
2820
+ if (action.type === "renumberTitles") {
2821
+ if (action.startAt + currentChapters.length - 1 > 999_999) {
2822
+ throw new AppError(400, "CHAPTER_NUMBER_RANGE", "起始序号与所选章节数量超出最大序号 999999");
2823
+ }
2824
+ const currentById = new Map(currentChapters.map((chapter) => [String(chapter.id), chapter]));
2825
+ const placeholders = currentChapters.map(() => "?").join(", ");
2826
+ const orderedChapters = this.db.all(`SELECT chapter.id FROM chapters chapter
2827
+ JOIN volumes volume ON volume.id = chapter.volume_id
2828
+ WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL
2829
+ AND volume.deleted_at IS NULL AND chapter.id IN (${placeholders})
2830
+ ORDER BY volume.sort_order, volume.created_at, volume.id,
2831
+ chapter.sort_order, chapter.created_at, chapter.id`, workId, ...currentChapters.map((chapter) => String(chapter.id))).map((row) => currentById.get(requiredString(row, "id"))).filter((chapter) => Boolean(chapter));
2832
+ const renumbered = orderedChapters.map((chapter, index) => ({
2833
+ chapter,
2834
+ title: renumberChapterTitle(String(chapter.title), action.startAt + index, action.template, action.numberStyle),
2835
+ sequence: action.startAt + index
2836
+ }));
2837
+ const oversized = renumbered.find((item) => item.title.length > 300);
2838
+ if (oversized) {
2839
+ throw new AppError(400, "CHAPTER_TITLE_TOO_LONG", `章节“${String(oversized.chapter.title).slice(0, 40)}”重排后的标题超过 300 个字符`);
2840
+ }
2841
+ let updated = 0;
2842
+ for (const item of renumbered) {
2843
+ const chapter = item.chapter;
2844
+ if (item.title === chapter.title)
2845
+ continue;
2846
+ const chapterId = String(chapter.id);
2847
+ const versionNo = Number(chapter.versionNo) + 1;
2848
+ this.db.run("UPDATE chapters SET title = ?, version_no = ?, analysis_status = 'expired', updated_at = ? WHERE id = ?", item.title, versionNo, timestamp, chapterId);
2849
+ this.syncChapterParagraphSearchVersion(chapterId, versionNo);
2850
+ this.insertChapterVersionRow({
2851
+ workId,
2852
+ chapterId,
2853
+ versionNo,
2854
+ title: item.title,
2855
+ content: String(chapter.content),
2856
+ volumeId: String(chapter.volumeId),
2857
+ sortOrder: Number(chapter.sortOrder),
2858
+ chapterType: String(chapter.chapterType),
2859
+ source: "manual",
2860
+ sourceRef: null,
2861
+ changeNote: "批量重排章节标题序号",
2862
+ timestamp
2863
+ });
2864
+ this.invalidateChapter(workId, chapterId, versionNo);
2865
+ this.audit(workId, "chapter.saved", "chapter", chapterId, {
2866
+ previousTitle: chapter.title,
2867
+ title: item.title,
2868
+ sequence: item.sequence,
2869
+ versionNo,
2870
+ batch: true,
2871
+ renumbered: true
2872
+ });
2873
+ updated += 1;
2874
+ }
2875
+ this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", timestamp, workId);
2876
+ return { processed: currentChapters.length, updated, action: action.type };
2877
+ }
2606
2878
  if (action.type === "move") {
2607
2879
  const targetVolume = this.getVolume(action.volumeId);
2608
2880
  if (targetVolume.workId !== workId)
@@ -2787,8 +3059,9 @@ export class Store {
2787
3059
  insertChapter(workId, volumeId, title, content, sortOrder, source, sourceRef, chapterType = "正文") {
2788
3060
  const chapterId = id("chapter");
2789
3061
  const timestamp = now();
2790
- this.db.run(`INSERT INTO chapters (id, work_id, volume_id, title, content, chapter_type, sort_order, word_count, version_no, analysis_status, created_at, updated_at)
2791
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 'pending', ?, ?)`, chapterId, workId, volumeId, title, content, chapterType, sortOrder, countWords(content), timestamp, timestamp);
3062
+ const lineIds = createChapterLineIds(content, () => id("chapterLine"));
3063
+ this.db.run(`INSERT INTO chapters (id, work_id, volume_id, title, content, line_ids_json, chapter_type, sort_order, word_count, version_no, analysis_status, created_at, updated_at)
3064
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 'pending', ?, ?)`, chapterId, workId, volumeId, title, content, JSON.stringify(lineIds), chapterType, sortOrder, countWords(content), timestamp, timestamp);
2792
3065
  this.syncChapterParagraphSearch(workId, chapterId, content);
2793
3066
  this.insertChapterVersionRow({
2794
3067
  workId,
@@ -3091,6 +3364,8 @@ export class Store {
3091
3364
  : optionalString(row, "cover_url"),
3092
3365
  tags: json(requiredString(row, "tags_json"), []),
3093
3366
  offlineAccessEnabled: numberValue(row, "offline_access_enabled") === 1,
3367
+ editorAutoIndentEnabled: numberValue(row, "editor_auto_indent_enabled") === 1,
3368
+ editorTypewriterModeEnabled: numberValue(row, "editor_typewriter_mode_enabled") === 1,
3094
3369
  versionNo: numberValue(row, "version_no") || this.currentEntityVersionNo("work", workId),
3095
3370
  ownerUserId,
3096
3371
  accessRole,
@@ -3123,7 +3398,8 @@ export class Store {
3123
3398
  mapChapter(row) {
3124
3399
  return {
3125
3400
  ...this.mapChapterDirectoryEntry(row),
3126
- content: requiredString(row, "content")
3401
+ content: requiredString(row, "content"),
3402
+ lineIds: parseChapterLineIds(row.line_ids_json, requiredString(row, "content"))
3127
3403
  };
3128
3404
  }
3129
3405
  mapChapterDirectoryEntry(row) {
@@ -5578,6 +5854,7 @@ export class Store {
5578
5854
  const timelineEvents = this.listTimelineEvents(workId).filter((event) => event.participantIds.includes(sourceId));
5579
5855
  const sourceMemberships = this.db.all("SELECT * FROM character_organization_memberships WHERE character_id = ? ORDER BY organization_id", sourceId);
5580
5856
  const referenceSnapshot = { relationships: sourceRelationships, timelineEvents, memberships: sourceMemberships };
5857
+ let roleplayMemoryMerge = { migrated: 0, deduplicated: 0 };
5581
5858
  this.db.transaction(() => {
5582
5859
  const lockedTarget = this.getCharacter(targetId);
5583
5860
  const lockedSource = this.getCharacter(sourceId);
@@ -5645,6 +5922,7 @@ export class Store {
5645
5922
  this.db.run("UPDATE character_profile_sections SET character_id = ?, updated_at = ? WHERE character_id = ?", targetId, timestamp, sourceId);
5646
5923
  this.db.run("UPDATE character_profile_section_versions SET character_id = ? WHERE character_id = ?", targetId, sourceId);
5647
5924
  this.db.run("UPDATE character_profile_section_search SET character_id = ? WHERE character_id = ?", targetId, sourceId);
5925
+ roleplayMemoryMerge = this.mergeRoleplayMemoriesForCharacters(workId, targetId, sourceId, timestamp);
5648
5926
  const sourceVersionNo = Number(source.versionNo) + 1;
5649
5927
  this.db.run("UPDATE characters SET merged_into_character_id = ?, merged_at = ?, version_no = ?, updated_at = ? WHERE id = ?", targetId, timestamp, sourceVersionNo, timestamp, sourceId);
5650
5928
  this.insertCharacterVersion(sourceId, sourceVersionNo, "merge", mergeId, `合并至角色“${String(target.name)}”`, timestamp);
@@ -5657,16 +5935,60 @@ export class Store {
5657
5935
  this.audit(workId, "character.merged", "character", targetId, {
5658
5936
  mergeId,
5659
5937
  sourceCharacterId: sourceId,
5660
- reviewId: input.reviewId
5938
+ reviewId: input.reviewId,
5939
+ roleplayMemoryMerge
5661
5940
  });
5662
5941
  });
5663
5942
  return {
5664
5943
  mergeId,
5665
5944
  target: this.getCharacter(targetId),
5666
5945
  source: this.getCharacter(sourceId),
5667
- review: input.reviewId ? this.getReviewItem(input.reviewId) : null
5946
+ review: input.reviewId ? this.getReviewItem(input.reviewId) : null,
5947
+ roleplayMemoryMerge
5668
5948
  };
5669
5949
  }
5950
+ mergeRoleplayMemoriesForCharacters(workId, targetCharacterId, sourceCharacterId, timestamp) {
5951
+ let migrated = 0;
5952
+ let deduplicated = 0;
5953
+ const sourceMemories = this.db.all("SELECT * FROM roleplay_memories WHERE work_id = ? AND character_id = ? ORDER BY created_at, id", workId, sourceCharacterId);
5954
+ for (const sourceMemory of sourceMemories) {
5955
+ const sourceMemoryId = requiredString(sourceMemory, "id");
5956
+ const duplicate = this.db.get("SELECT * FROM roleplay_memories WHERE character_id = ? AND content_hash = ?", targetCharacterId, requiredString(sourceMemory, "content_hash"));
5957
+ if (!duplicate) {
5958
+ this.db.run("UPDATE roleplay_memories SET character_id = ?, updated_by_user_id = ?, updated_at = ? WHERE id = ?", targetCharacterId, currentRequestActor()?.userId ?? null, timestamp, sourceMemoryId);
5959
+ migrated += 1;
5960
+ continue;
5961
+ }
5962
+ const targetMemoryId = requiredString(duplicate, "id");
5963
+ this.db.run("UPDATE roleplay_memories SET superseded_by_memory_id = ? WHERE superseded_by_memory_id = ?", targetMemoryId, sourceMemoryId);
5964
+ for (const source of this.db.all("SELECT * FROM roleplay_memory_sources WHERE memory_id = ? ORDER BY created_at, id", sourceMemoryId)) {
5965
+ this.db.run(`INSERT OR IGNORE INTO roleplay_memory_sources (
5966
+ id, memory_id, conversation_id, message_id, message_role, source_created_by_user_id,
5967
+ source_created_at, evidence_snapshot, created_at
5968
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, id("roleplay_memory_source"), targetMemoryId, optionalString(source, "conversation_id"), optionalString(source, "message_id"), requiredString(source, "message_role"), optionalString(source, "source_created_by_user_id"), requiredString(source, "source_created_at"), requiredString(source, "evidence_snapshot"), requiredString(source, "created_at"));
5969
+ }
5970
+ let nextVersion = numberValue(duplicate, "version_no");
5971
+ for (const version of this.db.all("SELECT * FROM roleplay_memory_versions WHERE memory_id = ? ORDER BY version_no", sourceMemoryId)) {
5972
+ nextVersion += 1;
5973
+ this.db.run(`INSERT INTO roleplay_memory_versions (
5974
+ id, memory_id, version_no, category, content, importance, certainty,
5975
+ status, is_pinned, action, actor_user_id, created_at
5976
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'merged', ?, ?)`, id("roleplay_memory_version"), targetMemoryId, nextVersion, requiredString(version, "category"), requiredString(version, "content"), requiredString(version, "importance"), requiredString(version, "certainty"), requiredString(version, "status"), booleanValue(version, "is_pinned") ? 1 : 0, currentRequestActor()?.userId ?? optionalString(version, "actor_user_id"), requiredString(version, "created_at"));
5977
+ }
5978
+ nextVersion += 1;
5979
+ this.db.run(`UPDATE roleplay_memories SET is_pinned = CASE WHEN is_pinned = 1 OR ? = 1 THEN 1 ELSE 0 END,
5980
+ status = CASE WHEN status = 'active' OR ? = 'active' THEN 'active' ELSE status END,
5981
+ superseded_by_memory_id = CASE WHEN status = 'active' OR ? = 'active' THEN NULL ELSE superseded_by_memory_id END,
5982
+ version_no = ?, updated_by_user_id = ?, updated_at = ? WHERE id = ?`, booleanValue(sourceMemory, "is_pinned") ? 1 : 0, requiredString(sourceMemory, "status"), requiredString(sourceMemory, "status"), nextVersion, currentRequestActor()?.userId ?? null, timestamp, targetMemoryId);
5983
+ const mergedTarget = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", targetMemoryId);
5984
+ if (!mergedTarget)
5985
+ throw notFound("角色扮演记忆");
5986
+ this.insertRoleplayMemoryVersion(mergedTarget, "merged");
5987
+ this.db.run("DELETE FROM roleplay_memories WHERE id = ?", sourceMemoryId);
5988
+ deduplicated += 1;
5989
+ }
5990
+ return { migrated, deduplicated };
5991
+ }
5670
5992
  resolveCharacterDuplicateReview(reviewId) {
5671
5993
  const review = this.getReviewItem(reviewId);
5672
5994
  if (review.itemType !== "character-duplicate" || review.status !== "pending") {
@@ -6310,6 +6632,270 @@ export class Store {
6310
6632
  messagesPage
6311
6633
  };
6312
6634
  }
6635
+ roleplayMemoryContentHash(content) {
6636
+ return this.hashContent(normalizeRoleplayMemoryContent(content).toLocaleLowerCase("zh-CN"));
6637
+ }
6638
+ insertRoleplayMemoryVersion(memory, action) {
6639
+ this.db.run(`INSERT INTO roleplay_memory_versions (
6640
+ id, memory_id, version_no, category, content, importance, certainty,
6641
+ status, is_pinned, action, actor_user_id, created_at
6642
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id("roleplay_memory_version"), requiredString(memory, "id"), numberValue(memory, "version_no"), requiredString(memory, "category"), requiredString(memory, "content"), requiredString(memory, "importance"), requiredString(memory, "certainty"), requiredString(memory, "status"), booleanValue(memory, "is_pinned") ? 1 : 0, action, currentRequestActor()?.userId ?? optionalString(memory, "updated_by_user_id"), now());
6643
+ }
6644
+ roleplayMemoryListRows(characterId, options = {}) {
6645
+ const cursor = Math.max(0, Math.min(100_000, options.cursor ?? 0));
6646
+ const limit = Math.max(1, Math.min(100, options.limit ?? 20));
6647
+ const where = ["memory.character_id = ?"];
6648
+ const params = [characterId];
6649
+ const statuses = options.statuses?.length ? [...new Set(options.statuses)] : ["active"];
6650
+ where.push(`memory.status IN (${statuses.map(() => "?").join(", ")})`);
6651
+ params.push(...statuses);
6652
+ const categories = options.categories?.length ? [...new Set(options.categories)] : [];
6653
+ if (categories.length > 0) {
6654
+ where.push(`memory.category IN (${categories.map(() => "?").join(", ")})`);
6655
+ params.push(...categories);
6656
+ }
6657
+ const query = normalizeRoleplayMemoryContent(options.query ?? "", 200).toLocaleLowerCase("zh-CN");
6658
+ if (query) {
6659
+ if (Array.from(query).length >= 3) {
6660
+ where.push("memory.id IN (SELECT memory_id FROM roleplay_memory_fts WHERE roleplay_memory_fts MATCH ?)");
6661
+ params.push(JSON.stringify(query));
6662
+ }
6663
+ else {
6664
+ where.push("lower(memory.content) LIKE ? ESCAPE '\\'");
6665
+ params.push(`%${escapeSqlLikePattern(query)}%`);
6666
+ }
6667
+ }
6668
+ const whereSql = where.join(" AND ");
6669
+ const total = Number(this.db.get(`SELECT COUNT(*) AS count FROM roleplay_memories memory WHERE ${whereSql}`, ...params)?.count ?? 0);
6670
+ const rows = this.db.all(`SELECT memory.* FROM roleplay_memories memory
6671
+ WHERE ${whereSql}
6672
+ ORDER BY memory.is_pinned DESC,
6673
+ CASE memory.importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END,
6674
+ memory.updated_at DESC, memory.id
6675
+ LIMIT ? OFFSET ?`, ...params, limit, cursor);
6676
+ return { rows, total, cursor, limit };
6677
+ }
6678
+ listRoleplayMemories(characterId, options = {}) {
6679
+ const character = this.getCharacter(characterId);
6680
+ const page = this.roleplayMemoryListRows(characterId, options);
6681
+ const nextCursor = page.cursor + page.rows.length < page.total ? page.cursor + page.rows.length : null;
6682
+ return {
6683
+ character: { id: character.id, name: character.name, workId: character.workId },
6684
+ items: page.rows.map((memory) => this.mapRoleplayMemory(memory, true)),
6685
+ pagination: { cursor: page.cursor, limit: page.limit, total: page.total, nextCursor }
6686
+ };
6687
+ }
6688
+ isRoleplayMemorySourceTarget(sourceId, conversationId, messageId) {
6689
+ return Boolean(this.db.get(`SELECT source.id
6690
+ FROM roleplay_memory_sources source
6691
+ INNER JOIN roleplay_memories memory ON memory.id = source.memory_id
6692
+ WHERE source.id = ? AND source.conversation_id = ? AND source.message_id = ?`, sourceId, conversationId, messageId));
6693
+ }
6694
+ getRoleplayMemoryPromptItems(workId, characterId, limit = 12) {
6695
+ const character = this.getCharacter(characterId);
6696
+ if (String(character.workId) !== workId)
6697
+ throw new AppError(400, "ROLEPLAY_CHARACTER_WORK_MISMATCH", "角色不属于当前作品");
6698
+ return this.roleplayMemoryListRows(characterId, {
6699
+ statuses: ["active"],
6700
+ limit: Math.max(1, Math.min(30, limit))
6701
+ }).rows.map((memory) => this.mapRoleplayMemory(memory, false));
6702
+ }
6703
+ recallRoleplayMemories(workId, characterId, query, categories, cursor) {
6704
+ const character = this.getCharacter(characterId);
6705
+ if (String(character.workId) !== workId)
6706
+ throw new AppError(400, "ROLEPLAY_CHARACTER_WORK_MISMATCH", "角色不属于当前作品");
6707
+ const result = this.listRoleplayMemories(characterId, { query, categories, statuses: ["active"], cursor, limit: 20 });
6708
+ return {
6709
+ origin: "roleplay",
6710
+ canonical: false,
6711
+ character: result.character,
6712
+ memories: result.items,
6713
+ pagination: result.pagination
6714
+ };
6715
+ }
6716
+ createRoleplayMemory(characterId, input) {
6717
+ const character = this.getCharacter(characterId);
6718
+ if (character.mergedIntoCharacterId)
6719
+ throw new AppError(409, "ROLEPLAY_MEMORY_CHARACTER_MERGED", "已合并角色不能新增角色扮演记忆");
6720
+ const workId = String(character.workId);
6721
+ const content = normalizeRoleplayMemoryContent(input.content);
6722
+ if (!content)
6723
+ throw new AppError(400, "ROLEPLAY_MEMORY_CONTENT_REQUIRED", "请输入记忆内容");
6724
+ const memoryId = id("roleplay_memory");
6725
+ const contentHash = this.roleplayMemoryContentHash(content);
6726
+ const timestamp = now();
6727
+ this.db.transaction(() => {
6728
+ if (this.db.get("SELECT id FROM roleplay_memories WHERE character_id = ? AND content_hash = ?", characterId, contentHash)) {
6729
+ throw new AppError(409, "ROLEPLAY_MEMORY_DUPLICATE", "该角色已经有相同内容的角色扮演记忆");
6730
+ }
6731
+ this.db.run(`INSERT INTO roleplay_memories (
6732
+ id, work_id, character_id, category, content, content_hash, importance, certainty,
6733
+ status, is_pinned, version_no, source_type, created_by_user_id, updated_by_user_id, created_at, updated_at
6734
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, 1, 'manual', ?, ?, ?, ?)`, memoryId, workId, characterId, input.category, content, contentHash, input.importance ?? "medium", input.certainty ?? "experienced", input.isPinned ? 1 : 0, currentRequestActor()?.userId ?? null, currentRequestActor()?.userId ?? null, timestamp, timestamp);
6735
+ const memory = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
6736
+ if (!memory)
6737
+ throw notFound("角色扮演记忆");
6738
+ this.insertRoleplayMemoryVersion(memory, "created");
6739
+ this.audit(workId, "roleplay-memory.created", "roleplay-memory", memoryId, { characterId });
6740
+ });
6741
+ const memory = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
6742
+ if (!memory)
6743
+ throw notFound("角色扮演记忆");
6744
+ return this.mapRoleplayMemory(memory, true);
6745
+ }
6746
+ commitRoleplayMemoryCandidates(conversationId, assistantMessageId, sourceUserMessageId, candidates) {
6747
+ if (candidates.length === 0)
6748
+ return [];
6749
+ const conversation = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
6750
+ if (!conversation)
6751
+ throw notFound("AI 对话");
6752
+ const characterId = optionalString(conversation, "roleplay_character_id");
6753
+ if (!characterId)
6754
+ throw new AppError(409, "ROLEPLAY_MEMORY_REQUIRES_ROLEPLAY", "只有角色扮演对话可以写入角色扮演记忆");
6755
+ const workId = requiredString(conversation, "work_id");
6756
+ const assistant = this.db.get("SELECT * FROM ai_conversation_messages WHERE id = ? AND conversation_id = ? AND role = 'assistant'", assistantMessageId, conversationId);
6757
+ if (!assistant)
6758
+ throw new AppError(400, "ROLEPLAY_MEMORY_ASSISTANT_MISMATCH", "角色扮演记忆来源回复不属于当前对话");
6759
+ const userMessage = this.db.get("SELECT * FROM ai_conversation_messages WHERE id = ? AND conversation_id = ? AND role = 'user'", sourceUserMessageId, conversationId);
6760
+ if (!userMessage)
6761
+ throw new AppError(400, "ROLEPLAY_MEMORY_USER_MESSAGE_MISMATCH", "角色扮演记忆来源消息不属于当前对话");
6762
+ const committedIds = [];
6763
+ this.db.transaction(() => {
6764
+ const validCandidates = candidates.slice(0, 8).flatMap((candidate) => {
6765
+ const content = normalizeRoleplayMemoryContent(candidate.content, 500);
6766
+ if (!content || !roleplayMemoryCandidateIsSafe(content))
6767
+ return [];
6768
+ return [{ ...candidate, content }];
6769
+ });
6770
+ const insertable = validCandidates.flatMap((candidate) => {
6771
+ const idempotencyKey = this.hashContent(`${assistantMessageId}:${JSON.stringify(candidate)}`);
6772
+ const contentHash = this.roleplayMemoryContentHash(candidate.content);
6773
+ const existing = this.db.get("SELECT id FROM roleplay_memories WHERE character_id = ? AND (idempotency_key = ? OR content_hash = ?)", characterId, idempotencyKey, contentHash);
6774
+ return existing ? [] : [{ candidate, idempotencyKey, contentHash }];
6775
+ });
6776
+ if (insertable.length === 0)
6777
+ return;
6778
+ const timestamp = now();
6779
+ for (const { candidate, idempotencyKey, contentHash } of insertable) {
6780
+ const memoryId = id("roleplay_memory");
6781
+ const superseded = candidate.supersedesMemoryId
6782
+ ? this.db.get("SELECT * FROM roleplay_memories WHERE id = ? AND character_id = ? AND status = 'active'", candidate.supersedesMemoryId, characterId)
6783
+ : undefined;
6784
+ this.db.run(`INSERT INTO roleplay_memories (
6785
+ id, work_id, character_id, category, content, content_hash, importance, certainty, status, is_pinned, version_no,
6786
+ source_type, source_assistant_message_id, idempotency_key,
6787
+ created_by_user_id, updated_by_user_id, created_at, updated_at
6788
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', 0, 1, 'ai', ?, ?, ?, ?, ?, ?)`, memoryId, workId, characterId, candidate.category, candidate.content, contentHash, candidate.importance, candidate.certainty, assistantMessageId, idempotencyKey, optionalString(conversation, "created_by_user_id"), optionalString(conversation, "created_by_user_id"), timestamp, timestamp);
6789
+ const memory = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
6790
+ if (!memory)
6791
+ throw notFound("角色扮演记忆");
6792
+ this.insertRoleplayMemoryVersion(memory, "created");
6793
+ for (const source of [userMessage, assistant]) {
6794
+ this.db.run(`INSERT INTO roleplay_memory_sources (
6795
+ id, memory_id, conversation_id, message_id, message_role, source_created_by_user_id,
6796
+ source_created_at, evidence_snapshot, created_at
6797
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, id("roleplay_memory_source"), memoryId, conversationId, requiredString(source, "id"), requiredString(source, "role"), optionalString(conversation, "created_by_user_id"), requiredString(source, "created_at"), Array.from(requiredString(source, "content")
6798
+ .replace(/\r\n?/gu, "\n")
6799
+ .replace(/\n{3,}/gu, "\n\n")
6800
+ .trim()).slice(0, 2_000).join(""), timestamp);
6801
+ }
6802
+ if (superseded) {
6803
+ const nextVersion = numberValue(superseded, "version_no") + 1;
6804
+ this.db.run(`UPDATE roleplay_memories SET status = 'superseded', superseded_by_memory_id = ?,
6805
+ version_no = ?, updated_by_user_id = ?, updated_at = ? WHERE id = ?`, memoryId, nextVersion, optionalString(conversation, "created_by_user_id"), timestamp, requiredString(superseded, "id"));
6806
+ const updatedSuperseded = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", requiredString(superseded, "id"));
6807
+ if (updatedSuperseded)
6808
+ this.insertRoleplayMemoryVersion(updatedSuperseded, "superseded");
6809
+ }
6810
+ committedIds.push(memoryId);
6811
+ }
6812
+ this.audit(workId, "roleplay-memory.ai-committed", "character", characterId, {
6813
+ conversationId,
6814
+ assistantMessageId,
6815
+ memoryCount: committedIds.length
6816
+ });
6817
+ });
6818
+ return committedIds.flatMap((memoryId) => {
6819
+ const memory = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
6820
+ return memory ? [this.mapRoleplayMemory(memory, true)] : [];
6821
+ });
6822
+ }
6823
+ getRoleplayMemoryAccess(memoryId) {
6824
+ const row = this.db.get("SELECT work_id, character_id FROM roleplay_memories WHERE id = ?", memoryId);
6825
+ if (!row)
6826
+ throw notFound("角色扮演记忆");
6827
+ return { workId: requiredString(row, "work_id"), characterId: requiredString(row, "character_id") };
6828
+ }
6829
+ updateRoleplayMemory(memoryId, input) {
6830
+ const memory = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
6831
+ if (!memory)
6832
+ throw notFound("角色扮演记忆");
6833
+ if (numberValue(memory, "version_no") !== input.expectedVersion) {
6834
+ throw new AppError(409, "ROLEPLAY_MEMORY_VERSION_CONFLICT", "记忆已被其他操作更新,请刷新后重试");
6835
+ }
6836
+ const content = input.content === undefined ? requiredString(memory, "content") : normalizeRoleplayMemoryContent(input.content);
6837
+ if (!content)
6838
+ throw new AppError(400, "ROLEPLAY_MEMORY_CONTENT_REQUIRED", "请输入记忆内容");
6839
+ const category = input.category ?? requiredString(memory, "category");
6840
+ const importance = input.importance ?? requiredString(memory, "importance");
6841
+ const certainty = input.certainty ?? requiredString(memory, "certainty");
6842
+ const isPinned = input.isPinned ?? booleanValue(memory, "is_pinned");
6843
+ const contentChanged = content !== requiredString(memory, "content")
6844
+ || category !== requiredString(memory, "category")
6845
+ || importance !== requiredString(memory, "importance")
6846
+ || certainty !== requiredString(memory, "certainty");
6847
+ const pinnedChanged = isPinned !== booleanValue(memory, "is_pinned");
6848
+ if (!contentChanged && !pinnedChanged)
6849
+ return this.mapRoleplayMemory(memory, true);
6850
+ this.db.transaction(() => {
6851
+ const timestamp = now();
6852
+ const contentHash = this.roleplayMemoryContentHash(content);
6853
+ const duplicate = this.db.get("SELECT id FROM roleplay_memories WHERE character_id = ? AND content_hash = ? AND id <> ?", requiredString(memory, "character_id"), contentHash, memoryId);
6854
+ if (duplicate)
6855
+ throw new AppError(409, "ROLEPLAY_MEMORY_DUPLICATE", "该角色已经有相同内容的角色扮演记忆");
6856
+ this.db.run(`UPDATE roleplay_memories SET category = ?, content = ?, content_hash = ?, importance = ?, certainty = ?, is_pinned = ?,
6857
+ version_no = version_no + 1, updated_by_user_id = ?, updated_at = ? WHERE id = ?`, category, content, contentHash, importance, certainty, isPinned ? 1 : 0, currentRequestActor()?.userId ?? null, timestamp, memoryId);
6858
+ const updated = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
6859
+ if (!updated)
6860
+ throw notFound("角色扮演记忆");
6861
+ this.insertRoleplayMemoryVersion(updated, contentChanged ? "edited" : "pinned");
6862
+ this.audit(requiredString(memory, "work_id"), "roleplay-memory.updated", "roleplay-memory", memoryId, {
6863
+ characterId: requiredString(memory, "character_id")
6864
+ });
6865
+ });
6866
+ const updated = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
6867
+ if (!updated)
6868
+ throw notFound("角色扮演记忆");
6869
+ return this.mapRoleplayMemory(updated, true);
6870
+ }
6871
+ setRoleplayMemoryArchived(memoryId, archived, expectedVersion) {
6872
+ const memory = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
6873
+ if (!memory)
6874
+ throw notFound("角色扮演记忆");
6875
+ if (numberValue(memory, "version_no") !== expectedVersion) {
6876
+ throw new AppError(409, "ROLEPLAY_MEMORY_VERSION_CONFLICT", "记忆已被其他操作更新,请刷新后重试");
6877
+ }
6878
+ const nextStatus = archived ? "archived" : "active";
6879
+ if (requiredString(memory, "status") === nextStatus)
6880
+ return this.mapRoleplayMemory(memory, true);
6881
+ this.db.transaction(() => {
6882
+ const timestamp = now();
6883
+ this.db.run(`UPDATE roleplay_memories SET status = ?,
6884
+ superseded_by_memory_id = CASE WHEN ? = 'active' THEN NULL ELSE superseded_by_memory_id END,
6885
+ version_no = version_no + 1, updated_by_user_id = ?, updated_at = ? WHERE id = ?`, nextStatus, nextStatus, currentRequestActor()?.userId ?? null, timestamp, memoryId);
6886
+ const updated = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
6887
+ if (!updated)
6888
+ throw notFound("角色扮演记忆");
6889
+ this.insertRoleplayMemoryVersion(updated, archived ? "archived" : "restored");
6890
+ this.audit(requiredString(memory, "work_id"), archived ? "roleplay-memory.archived" : "roleplay-memory.restored", "roleplay-memory", memoryId, {
6891
+ characterId: requiredString(memory, "character_id")
6892
+ });
6893
+ });
6894
+ const updated = this.db.get("SELECT * FROM roleplay_memories WHERE id = ?", memoryId);
6895
+ if (!updated)
6896
+ throw notFound("角色扮演记忆");
6897
+ return this.mapRoleplayMemory(updated, true);
6898
+ }
6313
6899
  getAiConversationContext(conversationId, workId, excludeMessageId) {
6314
6900
  const conversation = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
6315
6901
  if (!conversation)
@@ -6335,10 +6921,13 @@ export class Store {
6335
6921
  ORDER BY created_at, rowid LIMIT ?`, conversationId, requiredString(boundary, "created_at"), requiredString(boundary, "created_at"), numberValue(boundary, "rowid"), tailMessageCount);
6336
6922
  }
6337
6923
  }
6924
+ const roleplayCharacterId = optionalString(conversation, "roleplay_character_id");
6338
6925
  return {
6339
6926
  workId,
6340
- roleplayCharacterId: optionalString(conversation, "roleplay_character_id"),
6927
+ taskType: normalizeAiConversationTaskType(optionalString(conversation, "task_type"), roleplayCharacterId),
6928
+ roleplayCharacterId,
6341
6929
  roleplayUserCharacterId: optionalString(conversation, "roleplay_user_character_id"),
6930
+ roleplayMemories: roleplayCharacterId ? this.getRoleplayMemoryPromptItems(workId, roleplayCharacterId) : [],
6342
6931
  summary: requiredString(conversation, "compacted_summary"),
6343
6932
  compactedMessageCount,
6344
6933
  totalMessageCount,
@@ -6577,7 +7166,7 @@ export class Store {
6577
7166
  throw notFound("AI 对话");
6578
7167
  const workId = requiredString(conversation, "work_id");
6579
7168
  const previousCharacterId = optionalString(conversation, "roleplay_character_id");
6580
- const previousTaskType = optionalString(conversation, "task_type") ?? (previousCharacterId ? "roleplay" : "chat");
7169
+ const previousTaskType = normalizeAiConversationTaskType(optionalString(conversation, "task_type"), previousCharacterId);
6581
7170
  if (previousTaskType === taskType)
6582
7171
  return this.getAiConversationSummary(conversationId);
6583
7172
  const messageCount = Number(this.db.get("SELECT COUNT(*) AS count FROM ai_conversation_messages WHERE conversation_id = ?", conversationId)?.count ?? 0);
@@ -6881,7 +7470,7 @@ export class Store {
6881
7470
  const systemClockText = optionalString(conversation, "system_clock_text") ?? "";
6882
7471
  const scenePinJson = optionalString(conversation, "scene_pin_json") ?? "{}";
6883
7472
  this.db.transaction(() => {
6884
- 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
7473
+ 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"), normalizeAiConversationTaskType(optionalString(conversation, "task_type"), optionalString(conversation, "roleplay_character_id")), optionalString(conversation, "context_scope_json"), title.slice(0, 200), forkSummary, forkCompactedCount, conversation.agent_tools_json == null
6885
7474
  ? JSON.stringify(normalizeWorkAgentTools(this.getWorkAiSettings(workId).agentTools))
6886
7475
  : String(conversation.agent_tools_json), injectedEntitiesJson, systemClockText, scenePinJson, timestamp, timestamp, currentRequestActor()?.userId ?? null);
6887
7476
  for (const message of messages.slice(0, targetIndex + 1)) {
@@ -6954,7 +7543,7 @@ export class Store {
6954
7543
  compactedMessageCount: numberValue(row, "compacted_message_count"),
6955
7544
  hasCompactedSummary: Boolean(requiredString(row, "compacted_summary")),
6956
7545
  contextWarningPending: Boolean(optionalString(row, "context_warning_at")),
6957
- taskType: optionalString(row, "task_type") ?? (roleplayCharacterId ? "roleplay" : "chat"),
7546
+ taskType: normalizeAiConversationTaskType(optionalString(row, "task_type"), roleplayCharacterId),
6958
7547
  ...(lockedModelId ? { modelId: lockedModelId } : {}),
6959
7548
  ...(hasImageAttachments ? { hasImageAttachments: true, modelLockedByImage: true } : {}),
6960
7549
  contextScope: json(optionalString(row, "context_scope_json") ?? "", { type: "none" }),
@@ -7043,6 +7632,49 @@ export class Store {
7043
7632
  createdAt: requiredString(row, "created_at")
7044
7633
  };
7045
7634
  }
7635
+ mapRoleplayMemory(row, includeDetails) {
7636
+ const memoryId = requiredString(row, "id");
7637
+ const actor = currentRequestActor();
7638
+ const sources = includeDetails
7639
+ ? this.db.all(`SELECT source.*, conversation.created_by_user_id AS conversation_owner_id
7640
+ FROM roleplay_memory_sources source
7641
+ LEFT JOIN ai_conversations conversation ON conversation.id = source.conversation_id
7642
+ WHERE source.memory_id = ? ORDER BY source.source_created_at, source.id`, memoryId).map((source) => {
7643
+ const sourceOwnerId = optionalString(source, "conversation_owner_id") ?? optionalString(source, "source_created_by_user_id");
7644
+ const canOpen = actor === null || actor.role === "admin" || (sourceOwnerId !== null && sourceOwnerId === actor.userId);
7645
+ return {
7646
+ id: requiredString(source, "id"),
7647
+ role: requiredString(source, "message_role"),
7648
+ sourceType: requiredString(row, "source_type"),
7649
+ sourceAt: requiredString(source, "source_created_at"),
7650
+ canOpen,
7651
+ restricted: !canOpen,
7652
+ conversationId: canOpen ? optionalString(source, "conversation_id") : null,
7653
+ messageId: canOpen ? optionalString(source, "message_id") : null,
7654
+ evidence: canOpen ? requiredString(source, "evidence_snapshot") : null
7655
+ };
7656
+ })
7657
+ : undefined;
7658
+ return {
7659
+ id: memoryId,
7660
+ workId: requiredString(row, "work_id"),
7661
+ characterId: requiredString(row, "character_id"),
7662
+ category: requiredString(row, "category"),
7663
+ content: requiredString(row, "content"),
7664
+ importance: requiredString(row, "importance"),
7665
+ certainty: requiredString(row, "certainty"),
7666
+ origin: "roleplay",
7667
+ canonical: false,
7668
+ status: requiredString(row, "status"),
7669
+ isPinned: booleanValue(row, "is_pinned"),
7670
+ versionNo: numberValue(row, "version_no"),
7671
+ supersededByMemoryId: optionalString(row, "superseded_by_memory_id"),
7672
+ sourceType: requiredString(row, "source_type"),
7673
+ ...(sources ? { sources } : {}),
7674
+ createdAt: requiredString(row, "created_at"),
7675
+ updatedAt: requiredString(row, "updated_at")
7676
+ };
7677
+ }
7046
7678
  hashContent(content) {
7047
7679
  return createHash("sha256").update(content).digest("hex");
7048
7680
  }
@@ -7750,8 +8382,20 @@ export class Store {
7750
8382
  return [];
7751
8383
  }
7752
8384
  });
7753
- summary = `提取并写入 ${events.length} 个时间轴事件候选。`;
7754
- metrics = [metric("写入事件", events.length), metric("已不存在", Math.max(0, ids.length - events.length))];
8385
+ const hasChunkMetrics = typeof result.coveredChapterCount === "number" || typeof result.batchCount === "number";
8386
+ summary = hasChunkMetrics
8387
+ ? `覆盖 ${Number(result.coveredChapterCount ?? 0)} 章正文,识别 ${Number(result.rawCandidateCount ?? events.length)} 个原始候选,写入 ${events.length} 个时间轴事件候选。`
8388
+ : `提取并写入 ${events.length} 个时间轴事件候选。`;
8389
+ metrics = [
8390
+ metric("写入事件", events.length),
8391
+ ...(hasChunkMetrics ? [
8392
+ metric("覆盖章节", result.coveredChapterCount),
8393
+ metric("正文分片", result.batchCount),
8394
+ metric("归并批次", result.aggregationBatchCount),
8395
+ metric("原始候选", result.rawCandidateCount)
8396
+ ] : []),
8397
+ metric("已不存在", Math.max(0, ids.length - events.length))
8398
+ ];
7755
8399
  storageTargets.unshift({ label: "时间轴候选", entity: "时间轴与事件", key: "timeline", count: events.length, note: "以候选状态写入,等待作者确认。" });
7756
8400
  sections = [section("事件候选", events, "没有形成可写入的时间轴事件。")];
7757
8401
  }