@musnows/scriverse 0.7.8 → 0.7.9

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
@@ -4,7 +4,7 @@ import { ENTITY_VERSION_BASELINE_MIGRATION_VERSION, PLATFORM_AI_WORK_ID } from "
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 { normalizeWorkSearchQuery } from "./hybrid-search.js";
7
+ import { documentParagraphLineRanges, 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";
@@ -1240,6 +1240,51 @@ export class Store {
1240
1240
  }));
1241
1241
  return { ...work, volumes, directoryPage: pageResult };
1242
1242
  }
1243
+ getStoryIndexChapterPage(workId, offset, limit) {
1244
+ const work = this.getWork(workId);
1245
+ const permissions = work.modulePermissions;
1246
+ if (permissions.prose === "none")
1247
+ return { totalChapters: 0, chapters: [] };
1248
+ const countRow = this.db.get(`SELECT COUNT(*) AS count FROM chapters chapter
1249
+ JOIN volumes volume ON volume.id = chapter.volume_id
1250
+ WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL`, workId);
1251
+ const chapterRows = this.db.all(`SELECT chapter.id, chapter.title, chapter.version_no, volume.title AS volume_title
1252
+ FROM chapters chapter
1253
+ JOIN volumes volume ON volume.id = chapter.volume_id
1254
+ WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL
1255
+ ORDER BY volume.sort_order, volume.created_at, chapter.sort_order, chapter.created_at
1256
+ LIMIT ? OFFSET ?`, workId, limit, offset);
1257
+ const chapterIds = chapterRows.map((row) => requiredString(row, "id"));
1258
+ const summaries = new Map();
1259
+ if (chapterIds.length > 0) {
1260
+ const placeholders = chapterIds.map(() => "?").join(", ");
1261
+ const insightRows = this.db.all(`SELECT insight.chapter_id, insight.summary
1262
+ FROM chapter_insights insight
1263
+ JOIN chapters chapter ON chapter.id = insight.chapter_id AND chapter.version_no = insight.chapter_version
1264
+ WHERE chapter.work_id = ? AND insight.chapter_id IN (${placeholders})
1265
+ AND NOT EXISTS (
1266
+ SELECT 1 FROM chapter_insights newer
1267
+ WHERE newer.chapter_id = insight.chapter_id
1268
+ AND newer.chapter_version = insight.chapter_version
1269
+ AND (newer.created_at > insight.created_at OR (newer.created_at = insight.created_at AND newer.id > insight.id))
1270
+ )`, workId, ...chapterIds);
1271
+ for (const row of insightRows)
1272
+ summaries.set(requiredString(row, "chapter_id"), requiredString(row, "summary"));
1273
+ }
1274
+ return {
1275
+ totalChapters: numberValue(countRow ?? {}, "count"),
1276
+ chapters: chapterRows.map((row) => {
1277
+ const chapterId = requiredString(row, "id");
1278
+ return {
1279
+ id: chapterId,
1280
+ volumeTitle: requiredString(row, "volume_title"),
1281
+ title: requiredString(row, "title"),
1282
+ versionNo: numberValue(row, "version_no"),
1283
+ summary: summaries.get(chapterId) ?? ""
1284
+ };
1285
+ })
1286
+ };
1287
+ }
1243
1288
  listFileVersions(workId) {
1244
1289
  this.getWork(workId);
1245
1290
  return this.db
@@ -1989,6 +2034,7 @@ export class Store {
1989
2034
  AND json_extract(scope_json, '$.type') = 'volume'
1990
2035
  AND json_extract(scope_json, '$.volumeId') IN (?, ?)`, timestamp, String(lockedChapter.workId), sourceVolumeId, targetVolumeId);
1991
2036
  this.db.run("UPDATE chapters SET version_no = ?, analysis_status = 'expired', updated_at = ? WHERE id = ?", versionNo, timestamp, chapterId);
2037
+ this.syncChapterParagraphSearchVersion(chapterId, versionNo);
1992
2038
  this.insertChapterVersionRow({
1993
2039
  workId: String(lockedChapter.workId),
1994
2040
  chapterId,
@@ -2181,6 +2227,7 @@ export class Store {
2181
2227
  const versionNo = Number(chapter.versionNo) + 1;
2182
2228
  const sortOrder = orderedByVolume.get(action.volumeId)?.indexOf(chapterId) ?? 0;
2183
2229
  this.db.run("UPDATE chapters SET version_no = ?, analysis_status = 'expired', updated_at = ? WHERE id = ?", versionNo, timestamp, chapterId);
2230
+ this.syncChapterParagraphSearchVersion(chapterId, versionNo);
2184
2231
  this.insertChapterVersionRow({
2185
2232
  workId,
2186
2233
  chapterId,
@@ -2344,16 +2391,27 @@ export class Store {
2344
2391
  return chapterId;
2345
2392
  }
2346
2393
  syncChapterParagraphSearch(workId, chapterId, content) {
2394
+ const chapterVersion = numberValue(this.db.get("SELECT version_no FROM chapters WHERE id = ? AND work_id = ?", chapterId, workId) ?? {}, "version_no");
2395
+ const ranges = documentParagraphLineRanges(content);
2347
2396
  this.db.run("DELETE FROM chapter_paragraph_search WHERE chapter_id = ?", chapterId);
2348
2397
  for (const [paragraphOrder, paragraph] of splitDocumentParagraphs(content).entries()) {
2349
2398
  const searchContent = normalizeDocumentSearchText(paragraph);
2350
2399
  const inserted = this.db.run(`INSERT INTO chapter_paragraph_search (work_id, chapter_id, paragraph_order, content, search_content)
2351
2400
  VALUES (?, ?, ?, ?, ?)`, workId, chapterId, paragraphOrder, paragraph, searchContent);
2401
+ const range = ranges[paragraphOrder];
2402
+ if (range) {
2403
+ this.db.run(`INSERT INTO chapter_paragraph_line_ranges (paragraph_id, chapter_version, start_line, end_line)
2404
+ VALUES (?, ?, ?, ?)`, inserted.lastInsertRowid, chapterVersion, range.startLine, range.endLine);
2405
+ }
2352
2406
  for (const term of documentShortSearchTerms(searchContent)) {
2353
2407
  this.db.run("INSERT INTO chapter_paragraph_short_terms (paragraph_id, term) VALUES (?, ?)", inserted.lastInsertRowid, term);
2354
2408
  }
2355
2409
  }
2356
2410
  }
2411
+ syncChapterParagraphSearchVersion(chapterId, versionNo) {
2412
+ this.db.run(`UPDATE chapter_paragraph_line_ranges SET chapter_version = ?
2413
+ WHERE paragraph_id IN (SELECT id FROM chapter_paragraph_search WHERE chapter_id = ?)`, versionNo, chapterId);
2414
+ }
2357
2415
  searchChapterParagraphs(workId, keyword, limit = 20) {
2358
2416
  this.getWork(workId);
2359
2417
  const normalizedKeyword = normalizeDocumentSearchText(keyword.trim());
@@ -4119,7 +4177,7 @@ export class Store {
4119
4177
  searchCharacterProfileSections(workId, query, limit = 20) {
4120
4178
  this.getWork(workId);
4121
4179
  const normalized = normalizeDocumentSearchText(query);
4122
- const columns = `SELECT section.*, character.name AS character_name
4180
+ const columns = `SELECT section.*, character.name AS character_name, character.is_dead AS character_is_dead
4123
4181
  FROM character_profile_section_search search
4124
4182
  JOIN character_profile_sections section ON section.id = search.section_id
4125
4183
  JOIN characters character ON character.id = search.character_id`;
@@ -4131,7 +4189,11 @@ export class Store {
4131
4189
  WHERE search.work_id = ? AND character.merged_into_character_id IS NULL
4132
4190
  AND character_profile_section_search_fts MATCH ?
4133
4191
  ORDER BY bm25(character_profile_section_search_fts), character.name, section.sort_order LIMIT ?`, workId, `"${normalized.replaceAll('"', '""')}"`, limit);
4134
- return rows.map((row) => ({ ...this.mapCharacterProfileSection(row), characterName: requiredString(row, "character_name") }));
4192
+ return rows.map((row) => ({
4193
+ ...this.mapCharacterProfileSection(row),
4194
+ characterName: requiredString(row, "character_name"),
4195
+ isDead: booleanValue(row, "character_is_dead")
4196
+ }));
4135
4197
  }
4136
4198
  mapAttachment(row) {
4137
4199
  const attachmentId = requiredString(row, "id");
@@ -5264,18 +5326,34 @@ export class Store {
5264
5326
  throw notFound("AI 对话");
5265
5327
  if (requiredString(conversation, "work_id") !== workId)
5266
5328
  throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
5267
- const rows = this.db.all("SELECT id, role, content, metadata_json FROM ai_conversation_messages WHERE conversation_id = ? ORDER BY created_at, rowid", conversationId);
5268
- const compactedMessageCount = Math.min(rows.length, Math.max(0, numberValue(conversation, "compacted_message_count")));
5329
+ const countRow = this.db.get("SELECT COUNT(*) AS count FROM ai_conversation_messages WHERE conversation_id = ?", conversationId);
5330
+ const totalMessageCount = numberValue(countRow ?? {}, "count");
5331
+ const compactedMessageCount = Math.min(totalMessageCount, Math.max(0, numberValue(conversation, "compacted_message_count")));
5332
+ const tailMessageCount = totalMessageCount - compactedMessageCount;
5333
+ let rows = [];
5334
+ if (tailMessageCount > 0 && compactedMessageCount === 0) {
5335
+ rows = this.db.all(`SELECT id, role, content, metadata_json FROM ai_conversation_messages
5336
+ WHERE conversation_id = ? ORDER BY created_at, rowid LIMIT ?`, conversationId, tailMessageCount);
5337
+ }
5338
+ else if (tailMessageCount > 0) {
5339
+ const boundary = this.db.get(`SELECT created_at, rowid FROM ai_conversation_messages
5340
+ WHERE conversation_id = ? ORDER BY created_at, rowid LIMIT 1 OFFSET ?`, conversationId, compactedMessageCount - 1);
5341
+ if (boundary) {
5342
+ rows = this.db.all(`SELECT id, role, content, metadata_json FROM ai_conversation_messages
5343
+ WHERE conversation_id = ?
5344
+ AND (created_at > ? OR (created_at = ? AND rowid > ?))
5345
+ ORDER BY created_at, rowid LIMIT ?`, conversationId, requiredString(boundary, "created_at"), requiredString(boundary, "created_at"), numberValue(boundary, "rowid"), tailMessageCount);
5346
+ }
5347
+ }
5269
5348
  return {
5270
5349
  workId,
5271
5350
  roleplayCharacterId: optionalString(conversation, "roleplay_character_id"),
5272
5351
  summary: requiredString(conversation, "compacted_summary"),
5273
5352
  compactedMessageCount,
5274
- totalMessageCount: rows.length,
5353
+ totalMessageCount,
5275
5354
  warningPending: Boolean(optionalString(conversation, "context_warning_at")),
5276
5355
  injectedEntities: parseAiInjectedEntities(optionalString(conversation, "injected_entities_json") ?? EMPTY_AI_INJECTED_ENTITIES),
5277
- messages: rows.slice(compactedMessageCount)
5278
- .filter((message) => requiredString(message, "id") !== excludeMessageId)
5356
+ messages: rows.filter((message) => requiredString(message, "id") !== excludeMessageId)
5279
5357
  .map((message) => ({
5280
5358
  id: requiredString(message, "id"),
5281
5359
  role: requiredString(message, "role") === "assistant" ? "assistant" : "user",
@@ -7236,26 +7314,33 @@ export class Store {
7236
7314
  return [{ type: "none" }];
7237
7315
  return [{ type: "unknown", scope }];
7238
7316
  }
7239
- search(workId, query) {
7317
+ search(workId, query, requestedTypes) {
7240
7318
  this.getWork(workId);
7241
7319
  const normalizedQuery = normalizeWorkSearchQuery(query);
7242
7320
  if (!normalizedQuery)
7243
7321
  return [];
7244
7322
  const pattern = `%${escapeSqlLikePattern(normalizedQuery)}%`;
7245
- const chapters = this.db.all("SELECT id, title, content, volume_id FROM chapters WHERE work_id = ? AND deleted_at IS NULL AND (title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\') LIMIT 50", workId, pattern, pattern);
7246
- const races = this.listRaces(workId).filter((race) => {
7247
- const lineage = race.lineage;
7248
- const effectiveSettings = race.effectiveSettings;
7249
- return [
7250
- race.name,
7251
- race.description,
7252
- ...race.settings,
7253
- ...lineage.map((item) => item.name),
7254
- ...effectiveSettings.flatMap((item) => [item.value, item.sourceRaceName])
7255
- ].join("\n").toLocaleLowerCase("zh-CN").includes(normalizedQuery);
7256
- }).slice(0, 50);
7257
- const settings = this.db.all("SELECT id, title, content, category FROM settings WHERE work_id = ? AND (title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\') LIMIT 50", workId, pattern, pattern);
7258
- const characters = this.db.all(`WITH RECURSIVE character_race_lineage(character_id, race_id, parent_race_id, name, path) AS (
7323
+ const accepts = (type) => !requestedTypes || requestedTypes.has(type);
7324
+ const chapters = accepts("chapter")
7325
+ ? this.db.all("SELECT id, title, content, volume_id FROM chapters WHERE work_id = ? AND deleted_at IS NULL AND (title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\') LIMIT 50", workId, pattern, pattern)
7326
+ : [];
7327
+ const races = accepts("race")
7328
+ ? this.listRaces(workId).filter((race) => {
7329
+ const lineage = race.lineage;
7330
+ const effectiveSettings = race.effectiveSettings;
7331
+ return [
7332
+ race.name,
7333
+ race.description,
7334
+ ...race.settings,
7335
+ ...lineage.map((item) => item.name),
7336
+ ...effectiveSettings.flatMap((item) => [item.value, item.sourceRaceName])
7337
+ ].join("\n").toLocaleLowerCase("zh-CN").includes(normalizedQuery);
7338
+ }).slice(0, 50)
7339
+ : [];
7340
+ const settings = accepts("setting")
7341
+ ? this.db.all("SELECT id, title, content, category FROM settings WHERE work_id = ? AND (title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\') LIMIT 50", workId, pattern, pattern)
7342
+ : [];
7343
+ const characters = accepts("character") ? this.db.all(`WITH RECURSIVE character_race_lineage(character_id, race_id, parent_race_id, name, path) AS (
7259
7344
  SELECT character.id, race.id, race.parent_race_id, race.name, race.name
7260
7345
  FROM characters character JOIN races race ON race.id = character.race_id
7261
7346
  WHERE character.work_id = ?
@@ -7271,9 +7356,11 @@ export class Store {
7271
7356
  WHERE character.work_id = ? AND character.merged_into_character_id IS NULL AND (
7272
7357
  character.name LIKE ? ESCAPE '\\' OR character.aliases_json LIKE ? ESCAPE '\\' OR character.species LIKE ? ESCAPE '\\'
7273
7358
  OR EXISTS (SELECT 1 FROM character_race_lineage lineage WHERE lineage.character_id = character.id AND lineage.name LIKE ? ESCAPE '\\')
7274
- ) LIMIT 50`, workId, workId, pattern, pattern, pattern, pattern);
7275
- const organizations = this.db.all("SELECT id, name, description, is_dissolved, settings_json FROM organizations WHERE work_id = ? AND (name LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\' OR settings_json LIKE ? ESCAPE '\\') LIMIT 50", workId, pattern, pattern, pattern);
7276
- const characterSections = this.searchCharacterProfileSections(workId, normalizedQuery, 30);
7359
+ ) LIMIT 50`, workId, workId, pattern, pattern, pattern, pattern) : [];
7360
+ const organizations = accepts("organization")
7361
+ ? this.db.all("SELECT id, name, description, is_dissolved, settings_json FROM organizations WHERE work_id = ? AND (name LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\' OR settings_json LIKE ? ESCAPE '\\') LIMIT 50", workId, pattern, pattern, pattern)
7362
+ : [];
7363
+ const characterSections = accepts("character") ? this.searchCharacterProfileSections(workId, normalizedQuery, 30) : [];
7277
7364
  const snippet = (content) => {
7278
7365
  const index = content.toLocaleLowerCase().indexOf(normalizedQuery);
7279
7366
  const start = Math.max(0, index - 40);
@@ -7295,7 +7382,7 @@ export class Store {
7295
7382
  title: `${String(section.characterName)} / ${String(section.title)}`,
7296
7383
  snippet: snippet(String(section.contentMarkdown)),
7297
7384
  sectionType: String(section.sectionType),
7298
- isDead: Boolean(this.getCharacter(String(section.characterId)).isDead)
7385
+ isDead: Boolean(section.isDead)
7299
7386
  })),
7300
7387
  ...settings.map((row) => ({ type: "setting", id: requiredString(row, "id"), title: requiredString(row, "title"), snippet: snippet(requiredString(row, "content")), category: requiredString(row, "category") })),
7301
7388
  ...races.map((race) => {