@musnows/scriverse 0.7.8 → 0.7.10

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/ai.js CHANGED
@@ -7,7 +7,7 @@ import { characterExtractionHash, characterExtractionSelectionFingerprint, edita
7
7
  import { PLATFORM_AI_WORK_ID } from "./database.js";
8
8
  import { AppError, notFound } from "./errors.js";
9
9
  import { assertOfficialGoogleVertexBaseUrl, fetchGoogleOAuthAccessToken, GoogleVertexTokenCache, maskServiceAccountHint, parseGoogleServiceAccount } from "./google-vertex-auth.js";
10
- import { HYBRID_SEARCH_TYPES, MAXIMUM_WORK_SEARCH_QUERY_LENGTH, buildHybridSearchSnippet, documentParagraphLineRange, fuseHybridSearchChannels, normalizeWorkSearchQuery } from "./hybrid-search.js";
10
+ import { HYBRID_SEARCH_TYPES, MAXIMUM_WORK_SEARCH_QUERY_LENGTH, buildHybridSearchSnippet, documentParagraphLineRangesFromLines, fuseHybridSearchChannels, normalizeWorkSearchQuery } from "./hybrid-search.js";
11
11
  import { logger, sanitizeError } from "./logger.js";
12
12
  import { paginated, paginationSql } from "./pagination.js";
13
13
  import { currentRequestActor } from "./request-context.js";
@@ -183,6 +183,19 @@ const RELATIONSHIP_MAX_FUZZY_SCAN_CHARACTERS = 4_000_000;
183
183
  const RELATIONSHIP_MAX_FUZZY_MATCHES = 600;
184
184
  const RELATIONSHIP_MAX_SOURCE_MATCHES = 256;
185
185
  const RELATIONSHIP_PREFILTER_DISABLE_HINT = "请取消勾选“分析前按人物名称和拼音过滤来源”后重新预览";
186
+ function createHybridChapterLineRangeFallbackState() {
187
+ return {
188
+ chapters: new Map(),
189
+ attemptedCandidates: 0,
190
+ chapterLoads: 0,
191
+ repairedCandidates: 0,
192
+ invalidCandidateRows: 0,
193
+ missingChapters: 0,
194
+ chapterVersionMismatches: 0,
195
+ missingParagraphRanges: 0,
196
+ paragraphContentMismatches: 0
197
+ };
198
+ }
186
199
  function relationshipCandidateLimitMessage(message) {
187
200
  return `${message};${RELATIONSHIP_PREFILTER_DISABLE_HINT}`;
188
201
  }
@@ -1621,8 +1634,12 @@ export class AiManager {
1621
1634
  if (!normalizedQuery)
1622
1635
  return [];
1623
1636
  const requestedTypes = options.type ? new Set([options.type]) : new Set(HYBRID_SEARCH_TYPES);
1624
- if (options.includeAgentHistory === false)
1625
- requestedTypes.delete("agent-history");
1637
+ if (options.allowedTypes) {
1638
+ const allowedTypes = new Set(options.allowedTypes);
1639
+ for (const type of requestedTypes)
1640
+ if (!allowedTypes.has(type))
1641
+ requestedTypes.delete(type);
1642
+ }
1626
1643
  if (requestedTypes.size === 0)
1627
1644
  return [];
1628
1645
  const hasIndexedSourceTypes = [...requestedTypes].some((type) => type !== "chapter" && type !== "agent-history");
@@ -1632,8 +1649,9 @@ export class AiManager {
1632
1649
  const channelLimit = Math.min(200, Math.max(50, resultLimit * 4));
1633
1650
  const accepts = (type) => requestedTypes.has(type);
1634
1651
  const metadataDetails = new Map();
1652
+ const chapterLineRangeFallbackState = createHybridChapterLineRangeFallbackState();
1635
1653
  const metadataCandidates = [...requestedTypes].some((type) => type !== "agent-history")
1636
- ? this.store.search(workId, normalizedQuery).flatMap((item) => {
1654
+ ? this.store.search(workId, normalizedQuery, requestedTypes).flatMap((item) => {
1637
1655
  const type = String(item.type);
1638
1656
  const itemId = String(item.id ?? "");
1639
1657
  if (!itemId || !accepts(type))
@@ -1654,14 +1672,19 @@ export class AiManager {
1654
1672
  }).slice(0, channelLimit)
1655
1673
  : [];
1656
1674
  const exactCandidates = [
1657
- ...(requestedTypes.has("chapter") ? this.hybridChapterMatches(workId, normalizedQuery, "exact", channelLimit) : []),
1675
+ ...(requestedTypes.has("chapter")
1676
+ ? this.hybridChapterMatches(workId, normalizedQuery, "exact", channelLimit, chapterLineRangeFallbackState)
1677
+ : []),
1658
1678
  ...(hasIndexedSourceTypes ? this.hybridIndexedSourceMatches(workId, normalizedQuery, "exact", requestedTypes, channelLimit) : []),
1659
1679
  ...(requestedTypes.has("agent-history") ? this.hybridAgentHistoryMatches(workId, normalizedQuery, channelLimit) : [])
1660
1680
  ];
1661
1681
  const phoneticCandidates = [
1662
- ...(requestedTypes.has("chapter") ? this.hybridChapterMatches(workId, normalizedQuery, "phonetic", channelLimit) : []),
1682
+ ...(requestedTypes.has("chapter")
1683
+ ? this.hybridChapterMatches(workId, normalizedQuery, "phonetic", channelLimit, chapterLineRangeFallbackState)
1684
+ : []),
1663
1685
  ...(hasIndexedSourceTypes ? this.hybridIndexedSourceMatches(workId, normalizedQuery, "phonetic", requestedTypes, channelLimit) : [])
1664
1686
  ];
1687
+ this.logHybridChapterLineRangeFallback(workId, chapterLineRangeFallbackState);
1665
1688
  return fuseHybridSearchChannels([
1666
1689
  { weight: 1.4, candidates: metadataCandidates },
1667
1690
  { weight: 1, candidates: exactCandidates },
@@ -1709,16 +1732,19 @@ export class AiManager {
1709
1732
  };
1710
1733
  }).filter((candidate) => candidate.id && candidate.conversationId);
1711
1734
  }
1712
- hybridChapterMatches(workId, query, matchKind, limit) {
1735
+ hybridChapterMatches(workId, query, matchKind, limit, fallbackState) {
1713
1736
  let rows;
1714
1737
  if (matchKind === "phonetic") {
1715
1738
  const tokens = relationshipPinyinSearchTokens(query);
1716
1739
  if (tokens.length === 0)
1717
1740
  return [];
1718
- rows = this.store.db.all(`SELECT paragraph.chapter_id, paragraph.paragraph_order, paragraph.content AS paragraph_content,
1719
- chapter.title AS chapter_title, chapter.content AS chapter_content, volume.title AS volume_title
1741
+ rows = this.store.db.all(`SELECT paragraph.id AS paragraph_id, paragraph.chapter_id, paragraph.paragraph_order,
1742
+ paragraph.content AS paragraph_content, line_range.chapter_version AS range_chapter_version,
1743
+ line_range.start_line, line_range.end_line, chapter.version_no AS chapter_version,
1744
+ chapter.title AS chapter_title, volume.title AS volume_title
1720
1745
  FROM chapter_paragraph_pinyin_fts pinyin
1721
1746
  JOIN chapter_paragraph_search paragraph ON paragraph.id = pinyin.rowid
1747
+ LEFT JOIN chapter_paragraph_line_ranges line_range ON line_range.paragraph_id = paragraph.id
1722
1748
  JOIN chapters chapter ON chapter.id = paragraph.chapter_id
1723
1749
  JOIN volumes volume ON volume.id = chapter.volume_id
1724
1750
  WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL
@@ -1727,10 +1753,13 @@ export class AiManager {
1727
1753
  LIMIT ?`, workId, ftsPhrase(tokens), limit);
1728
1754
  }
1729
1755
  else if ([...query].length < 3) {
1730
- rows = this.store.db.all(`SELECT paragraph.chapter_id, paragraph.paragraph_order, paragraph.content AS paragraph_content,
1731
- chapter.title AS chapter_title, chapter.content AS chapter_content, volume.title AS volume_title
1756
+ rows = this.store.db.all(`SELECT paragraph.id AS paragraph_id, paragraph.chapter_id, paragraph.paragraph_order,
1757
+ paragraph.content AS paragraph_content, line_range.chapter_version AS range_chapter_version,
1758
+ line_range.start_line, line_range.end_line, chapter.version_no AS chapter_version,
1759
+ chapter.title AS chapter_title, volume.title AS volume_title
1732
1760
  FROM chapter_paragraph_short_terms term
1733
1761
  JOIN chapter_paragraph_search paragraph ON paragraph.id = term.paragraph_id
1762
+ LEFT JOIN chapter_paragraph_line_ranges line_range ON line_range.paragraph_id = paragraph.id
1734
1763
  JOIN chapters chapter ON chapter.id = paragraph.chapter_id
1735
1764
  JOIN volumes volume ON volume.id = chapter.volume_id
1736
1765
  WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL AND term.term = ?
@@ -1738,10 +1767,13 @@ export class AiManager {
1738
1767
  LIMIT ?`, workId, query, limit);
1739
1768
  }
1740
1769
  else {
1741
- rows = this.store.db.all(`SELECT paragraph.chapter_id, paragraph.paragraph_order, paragraph.content AS paragraph_content,
1742
- chapter.title AS chapter_title, chapter.content AS chapter_content, volume.title AS volume_title
1770
+ rows = this.store.db.all(`SELECT paragraph.id AS paragraph_id, paragraph.chapter_id, paragraph.paragraph_order,
1771
+ paragraph.content AS paragraph_content, line_range.chapter_version AS range_chapter_version,
1772
+ line_range.start_line, line_range.end_line, chapter.version_no AS chapter_version,
1773
+ chapter.title AS chapter_title, volume.title AS volume_title
1743
1774
  FROM chapter_paragraph_search_fts fts
1744
1775
  JOIN chapter_paragraph_search paragraph ON paragraph.id = fts.rowid
1776
+ LEFT JOIN chapter_paragraph_line_ranges line_range ON line_range.paragraph_id = paragraph.id
1745
1777
  JOIN chapters chapter ON chapter.id = paragraph.chapter_id
1746
1778
  JOIN volumes volume ON volume.id = chapter.volume_id
1747
1779
  WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL
@@ -1755,8 +1787,10 @@ export class AiManager {
1755
1787
  const key = `chapter:${chapterId}`;
1756
1788
  if (!chapterId || seen.has(key))
1757
1789
  return [];
1790
+ const range = this.hybridChapterLineRange(workId, row, fallbackState);
1791
+ if (!range)
1792
+ return [];
1758
1793
  seen.add(key);
1759
- const range = documentParagraphLineRange(String(row.chapter_content ?? ""), Number(row.paragraph_order));
1760
1794
  return [{
1761
1795
  key,
1762
1796
  type: "chapter",
@@ -1765,20 +1799,118 @@ export class AiManager {
1765
1799
  subtitle: String(row.volume_title ?? ""),
1766
1800
  snippet: buildHybridSearchSnippet(String(row.paragraph_content ?? ""), query),
1767
1801
  matchKind,
1768
- ...(range ?? {})
1802
+ ...range
1769
1803
  }];
1770
1804
  });
1771
1805
  }
1806
+ hybridChapterLineRange(workId, row, fallbackState) {
1807
+ const chapterVersion = Number(row.chapter_version);
1808
+ const rangeVersion = Number(row.range_chapter_version);
1809
+ const startLine = Number(row.start_line);
1810
+ const endLine = Number(row.end_line);
1811
+ if (Number.isSafeInteger(chapterVersion)
1812
+ && chapterVersion >= 1
1813
+ && rangeVersion === chapterVersion
1814
+ && Number.isSafeInteger(startLine)
1815
+ && Number.isSafeInteger(endLine)
1816
+ && startLine >= 1
1817
+ && endLine >= startLine) {
1818
+ return { startLine, endLine };
1819
+ }
1820
+ fallbackState.attemptedCandidates += 1;
1821
+ const chapterId = String(row.chapter_id ?? "");
1822
+ const paragraphOrder = Number(row.paragraph_order);
1823
+ const paragraphId = Number(row.paragraph_id);
1824
+ if (!chapterId || !Number.isSafeInteger(paragraphOrder) || paragraphOrder < 0 || !Number.isSafeInteger(paragraphId) || paragraphId < 1) {
1825
+ fallbackState.invalidCandidateRows += 1;
1826
+ return null;
1827
+ }
1828
+ let cachedChapter = fallbackState.chapters.get(chapterId);
1829
+ if (!fallbackState.chapters.has(chapterId)) {
1830
+ fallbackState.chapterLoads += 1;
1831
+ const chapter = this.store.db.get("SELECT content, version_no FROM chapters WHERE id = ? AND work_id = ? AND deleted_at IS NULL", chapterId, workId);
1832
+ if (!chapter) {
1833
+ fallbackState.chapters.set(chapterId, null);
1834
+ cachedChapter = null;
1835
+ }
1836
+ else {
1837
+ const lines = chapter.content.replace(/\r\n?/gu, "\n").split("\n");
1838
+ cachedChapter = {
1839
+ chapterVersion: Number(chapter.version_no),
1840
+ lines,
1841
+ ranges: documentParagraphLineRangesFromLines(lines)
1842
+ };
1843
+ fallbackState.chapters.set(chapterId, cachedChapter);
1844
+ }
1845
+ }
1846
+ if (!cachedChapter) {
1847
+ fallbackState.missingChapters += 1;
1848
+ return null;
1849
+ }
1850
+ if (cachedChapter.chapterVersion !== chapterVersion) {
1851
+ fallbackState.chapterVersionMismatches += 1;
1852
+ return null;
1853
+ }
1854
+ const currentRange = cachedChapter.ranges[paragraphOrder];
1855
+ if (!currentRange) {
1856
+ fallbackState.missingParagraphRanges += 1;
1857
+ return null;
1858
+ }
1859
+ const currentParagraph = cachedChapter.lines
1860
+ .slice(currentRange.startLine - 1, currentRange.endLine)
1861
+ .join("\n")
1862
+ .trim();
1863
+ if (currentParagraph !== String(row.paragraph_content ?? "")) {
1864
+ fallbackState.paragraphContentMismatches += 1;
1865
+ return null;
1866
+ }
1867
+ this.store.db.run(`INSERT INTO chapter_paragraph_line_ranges (paragraph_id, chapter_version, start_line, end_line)
1868
+ VALUES (?, ?, ?, ?)
1869
+ ON CONFLICT(paragraph_id) DO UPDATE SET
1870
+ chapter_version = excluded.chapter_version,
1871
+ start_line = excluded.start_line,
1872
+ end_line = excluded.end_line`, paragraphId, chapterVersion, currentRange.startLine, currentRange.endLine);
1873
+ fallbackState.repairedCandidates += 1;
1874
+ return currentRange;
1875
+ }
1876
+ logHybridChapterLineRangeFallback(workId, state) {
1877
+ if (state.attemptedCandidates === 0)
1878
+ return;
1879
+ const fields = {
1880
+ workId,
1881
+ attemptedCandidates: state.attemptedCandidates,
1882
+ chapterLoads: state.chapterLoads,
1883
+ repairedCandidates: state.repairedCandidates,
1884
+ invalidCandidateRows: state.invalidCandidateRows,
1885
+ missingChapters: state.missingChapters,
1886
+ chapterVersionMismatches: state.chapterVersionMismatches,
1887
+ missingParagraphRanges: state.missingParagraphRanges,
1888
+ paragraphContentMismatches: state.paragraphContentMismatches
1889
+ };
1890
+ const failedCandidates = state.invalidCandidateRows
1891
+ + state.missingChapters
1892
+ + state.chapterVersionMismatches
1893
+ + state.missingParagraphRanges
1894
+ + state.paragraphContentMismatches;
1895
+ if (failedCandidates > 0)
1896
+ logger.warn("search.chapter_line_range_fallback", fields);
1897
+ else
1898
+ logger.info("search.chapter_line_range_fallback", fields);
1899
+ }
1772
1900
  hybridIndexedSourceMatches(workId, query, matchKind, requestedTypes, limit) {
1773
1901
  const tokens = matchKind === "exact" ? relationshipCharacterTokens(query) : relationshipPinyinSearchTokens(query);
1774
1902
  if (tokens.length === 0)
1775
1903
  return [];
1776
1904
  const table = matchKind === "exact" ? "relationship_source_exact_fts" : "relationship_source_pinyin_fts";
1905
+ const sourceTypes = [...requestedTypes].filter((type) => type !== "chapter" && type !== "agent-history");
1906
+ if (sourceTypes.length === 0)
1907
+ return [];
1908
+ const sourceTypePlaceholders = sourceTypes.map(() => "?").join(", ");
1777
1909
  const rows = this.store.db.all(`SELECT source.source_type, source.source_id FROM ${table} search_index
1778
1910
  JOIN relationship_source_search source ON source.id = search_index.rowid
1779
- WHERE source.work_id = ? AND ${table} MATCH ?
1911
+ WHERE source.work_id = ? AND source.source_type IN (${sourceTypePlaceholders}) AND ${table} MATCH ?
1780
1912
  ORDER BY bm25(${table}), source.source_type, source.source_id
1781
- LIMIT ?`, workId, ftsPhrase(tokens), limit);
1913
+ LIMIT ?`, workId, ...sourceTypes, ftsPhrase(tokens), limit);
1782
1914
  return rows.flatMap((row) => {
1783
1915
  const sourceType = String(row.source_type ?? "");
1784
1916
  const sourceId = String(row.source_id ?? "");
@@ -3577,22 +3709,25 @@ export class AiManager {
3577
3709
  logger.warn("ai.task.cancelled", { taskId, workId: task.workId });
3578
3710
  return task;
3579
3711
  }
3580
- contextBudget(input, model) {
3712
+ contextBudget(input, model, existingConversation) {
3581
3713
  const contextWindow = numberValue(model, "context_window") || DEFAULT_CONTEXT_WINDOW;
3582
3714
  const preset = safeJsonObject(stringValue(model, "preset_json"));
3583
3715
  const configuredOutputTokens = typeof preset.max_tokens === "number" ? preset.max_tokens : DEFAULT_MAX_TOKENS;
3584
3716
  const outputReserveTokens = Math.max(MIN_OUTPUT_RESERVE_TOKENS, Math.min(configuredOutputTokens, Math.floor(contextWindow * 0.25), contextWindow - MIN_OUTPUT_RESERVE_TOKENS));
3585
3717
  const availableInputTokens = Math.max(256, contextWindow - outputReserveTokens - 512);
3586
- const conversation = input.conversationId
3587
- ? this.store.getAiConversationContext(input.conversationId, input.workId, input.excludeConversationMessageId)
3588
- : null;
3718
+ const conversation = existingConversation === undefined
3719
+ ? input.conversationId
3720
+ ? this.store.getAiConversationContext(input.conversationId, input.workId, input.excludeConversationMessageId)
3721
+ : null
3722
+ : existingConversation;
3589
3723
  const renderedMemory = conversation?.summary ? renderConversationMemory(conversation.summary) : "";
3590
3724
  const conversationTokens = conversation
3591
3725
  ? estimateAiTokens(renderedMemory) + conversation.messages.reduce((total, message) => total + estimateAiTokens(message.content), 0)
3592
3726
  : 0;
3593
3727
  const conversationBudgetTokens = Math.max(256, Math.floor(availableInputTokens * 0.32));
3594
3728
  const instructionTokens = estimateAiTokens(input.instruction);
3595
- const functionTokens = estimateAiTokens(JSON.stringify(this.enabledAgentTools(input.workId, input.taskType, input.agentToolIds, input.conversationId)));
3729
+ const roleplayCharacterId = this.roleplayCharacterIdFromConversation(input.workId, conversation);
3730
+ const functionTokens = estimateAiTokens(JSON.stringify(this.enabledAgentTools(input.workId, input.taskType, input.agentToolIds, input.conversationId, roleplayCharacterId)));
3596
3731
  const workContextBudgetTokens = Math.max(256, availableInputTokens
3597
3732
  - Math.min(conversationTokens, conversationBudgetTokens)
3598
3733
  - Math.min(instructionTokens, Math.floor(availableInputTokens * 0.25))
@@ -3614,10 +3749,11 @@ export class AiManager {
3614
3749
  getContextUsage(input) {
3615
3750
  const { model } = this.resolveModel(input.workId, input.taskType, input.modelId);
3616
3751
  const budget = this.contextBudget(input, model);
3752
+ const conversation = budget.conversation;
3617
3753
  const contextPlan = this.buildContextPlan(input, model, budget);
3618
3754
  const context = contextPlan.context;
3619
- const messages = this.buildMessages(input, context);
3620
- const tools = this.enabledAgentTools(input.workId, input.taskType, input.agentToolIds, input.conversationId);
3755
+ const messages = this.buildMessages(input, context, conversation);
3756
+ const tools = this.enabledAgentTools(input.workId, input.taskType, input.agentToolIds, input.conversationId, this.roleplayCharacterIdFromConversation(input.workId, conversation));
3621
3757
  const contextWindow = numberValue(model, "context_window") || DEFAULT_CONTEXT_WINDOW;
3622
3758
  const messageTokens = messages.reduce((total, message) => total + estimateAiTokens(completionMessageText(message.content)), 0);
3623
3759
  const systemPromptTokens = estimateAiTokens(completionMessageText(messages[0]?.content));
@@ -3628,7 +3764,6 @@ export class AiManager {
3628
3764
  // 超窗时把可交互上下文压到剩余份额,保证五段分布之和始终等于 contextWindow。
3629
3765
  const contextInteractionTokens = Math.max(0, contextWindow - systemPromptTokens - functionTokens - skillsTokens - remainingTokens);
3630
3766
  const threshold = Math.min(90, Math.max(50, Number(this.store.getWorkAiSettings(input.workId).contextCompactThreshold) || 85));
3631
- const conversation = budget.conversation;
3632
3767
  const conversationUsagePercent = Number(budget.conversationUsagePercent) || 0;
3633
3768
  const configuredOutputTokens = Number(budget.configuredOutputTokens) || DEFAULT_MAX_TOKENS;
3634
3769
  const maxOutputUsagePercent = Math.min(100, Math.round(configuredOutputTokens / contextWindow * 100));
@@ -3715,8 +3850,7 @@ export class AiManager {
3715
3850
  async prepareConversationContext(input, options = {}) {
3716
3851
  const inspection = this.inspectConversationContext(input);
3717
3852
  if (inspection.action === "ready") {
3718
- const conversation = this.store.getAiConversationContext(input.conversationId, input.workId);
3719
- if (conversation.warningPending)
3853
+ if (inspection.usage.contextWarningPending === true)
3720
3854
  this.store.setAiConversationContextWarning(input.conversationId, false);
3721
3855
  return inspection;
3722
3856
  }
@@ -3766,7 +3900,7 @@ export class AiManager {
3766
3900
  async compactConversation(input) {
3767
3901
  const conversation = this.store.getAiConversationContext(input.conversationId, input.workId, input.excludeConversationMessageId);
3768
3902
  const { model } = this.resolveModel(input.workId, "chat", input.modelId);
3769
- const budget = this.contextBudget({ ...input, taskType: "chat", instruction: "" }, model);
3903
+ const budget = this.contextBudget({ ...input, taskType: "chat", instruction: "" }, model, conversation);
3770
3904
  const recentTokenBudget = Math.max(128, Math.floor(Number(budget.conversationBudgetTokens) * 0.75));
3771
3905
  let retainedMessageCount = 0;
3772
3906
  let retainedTokens = 0;
@@ -3827,12 +3961,17 @@ export class AiManager {
3827
3961
  changed: true
3828
3962
  };
3829
3963
  }
3830
- buildMessages(input, context) {
3831
- const roleplayCharacterId = this.roleplayCharacterId(input.workId, input.conversationId);
3964
+ buildMessages(input, context, existingConversation) {
3965
+ const conversation = existingConversation === undefined
3966
+ ? input.conversationId
3967
+ ? this.store.getAiConversationContext(input.conversationId, input.workId, input.excludeConversationMessageId)
3968
+ : null
3969
+ : existingConversation;
3970
+ const roleplayCharacterId = this.roleplayCharacterIdFromConversation(input.workId, conversation);
3832
3971
  const roleplayPrompt = roleplayCharacterId ? this.buildRoleplaySystemPrompt(roleplayCharacterId) : "";
3833
3972
  const platformPrompt = roleplayCharacterId ? "" : String(this.store.getPlatformAiSettings().systemPrompt ?? "").trim();
3834
3973
  const workPrompt = roleplayCharacterId ? "" : String(this.store.getWorkAiSettings(input.workId).systemPrompt ?? "").trim();
3835
- const enabledToolIds = this.enabledAgentToolIds(input.workId, input.taskType, input.agentToolIds, input.conversationId);
3974
+ const enabledToolIds = this.enabledAgentToolIds(input.workId, input.taskType, input.agentToolIds, input.conversationId, roleplayCharacterId);
3836
3975
  const toolGuidance = enabledToolIds.includes("recall_self") || enabledToolIds.includes("recall_relationship")
3837
3976
  ? [
3838
3977
  `当前可用的内部记忆能力是:${enabledToolIds.join("、")}。不要向用户提及工具、调用过程、资料库或检索结果。`,
@@ -3904,9 +4043,6 @@ export class AiManager {
3904
4043
  ]);
3905
4044
  // 分析任务指令含服务端 CHAPTER/json 等标记,不能转义;分区边界仍靠外层标签约束。
3906
4045
  const currentInstruction = wrapAiContextRegion(roleplayCharacterId ? "user_message" : "author_instruction", input.instruction, { escape: false });
3907
- const conversation = input.conversationId
3908
- ? this.store.getAiConversationContext(input.conversationId, input.workId, input.excludeConversationMessageId)
3909
- : null;
3910
4046
  if (!conversation) {
3911
4047
  return [
3912
4048
  { role: "system", content: systemPrompt },
@@ -3945,7 +4081,8 @@ export class AiManager {
3945
4081
  }
3946
4082
  buildContextPlan(input, model, existingBudget, persistKeywordInjections = false) {
3947
4083
  const budget = existingBudget ?? this.contextBudget(input, model);
3948
- const roleplayCharacterId = this.roleplayCharacterId(input.workId, input.conversationId);
4084
+ const conversation = budget.conversation;
4085
+ const roleplayCharacterId = this.roleplayCharacterIdFromConversation(input.workId, conversation);
3949
4086
  const settings = this.store.getWorkAiSettings(input.workId);
3950
4087
  const configuredScope = {
3951
4088
  ...input.scope,
@@ -4013,13 +4150,18 @@ export class AiManager {
4013
4150
  }
4014
4151
  return this.mergeInstructionEntityMatches(scope, matches);
4015
4152
  }
4016
- buildContext(input, model) {
4017
- return collapseAiBlankLines(this.buildContextPlan(input, model, undefined, true).context);
4153
+ buildContext(input, model, existingBudget) {
4154
+ return collapseAiBlankLines(this.buildContextPlan(input, model, existingBudget, true).context);
4018
4155
  }
4019
4156
  roleplayCharacterId(workId, conversationId) {
4020
4157
  if (!conversationId)
4021
4158
  return null;
4022
4159
  const conversation = this.store.getAiConversationContext(conversationId, workId);
4160
+ return this.roleplayCharacterIdFromConversation(workId, conversation);
4161
+ }
4162
+ roleplayCharacterIdFromConversation(workId, conversation) {
4163
+ if (!conversation)
4164
+ return null;
4023
4165
  if (conversation.roleplayCharacterId) {
4024
4166
  const permissions = this.store.getWork(workId).modulePermissions;
4025
4167
  if (!canReadWorkModule(permissions, "characters")) {
@@ -4058,10 +4200,12 @@ export class AiManager {
4058
4200
  JSON.stringify(roleCard)
4059
4201
  ].join("\n");
4060
4202
  }
4061
- enabledAgentToolIds(workId, taskType, requestedToolIds, conversationId) {
4203
+ enabledAgentToolIds(workId, taskType, requestedToolIds, conversationId, roleplayCharacterIdOverride) {
4062
4204
  if (taskType !== "chat" && requestedToolIds === undefined)
4063
4205
  return [];
4064
- const roleplayCharacterId = this.roleplayCharacterId(workId, conversationId);
4206
+ const roleplayCharacterId = roleplayCharacterIdOverride === undefined
4207
+ ? this.roleplayCharacterId(workId, conversationId)
4208
+ : roleplayCharacterIdOverride;
4065
4209
  const permissions = this.store.getWork(workId).modulePermissions;
4066
4210
  if (roleplayCharacterId) {
4067
4211
  const requested = requestedToolIds ? new Set(requestedToolIds) : null;
@@ -4085,8 +4229,9 @@ export class AiManager {
4085
4229
  && (!requested || requested.has(toolId))
4086
4230
  && this.canReadWithAgentTool(permissions, toolId));
4087
4231
  }
4088
- enabledAgentTools(workId, taskType, requestedToolIds, conversationId) {
4089
- return this.enabledAgentToolIds(workId, taskType, requestedToolIds, conversationId).map((toolId) => AGENT_TOOL_DEFINITIONS[toolId]);
4232
+ enabledAgentTools(workId, taskType, requestedToolIds, conversationId, roleplayCharacterIdOverride) {
4233
+ return this.enabledAgentToolIds(workId, taskType, requestedToolIds, conversationId, roleplayCharacterIdOverride)
4234
+ .map((toolId) => AGENT_TOOL_DEFINITIONS[toolId]);
4090
4235
  }
4091
4236
  canReadWithAgentTool(permissions, toolId) {
4092
4237
  if (toolId === "search_story_entities") {
@@ -4524,11 +4669,7 @@ export class AiManager {
4524
4669
  if (name === "story_index") {
4525
4670
  const { offset, limit, cursor } = args;
4526
4671
  const work = this.store.getWork(workId);
4527
- const tree = this.store.getWorkTree(workId);
4528
- const summaries = new Map(this.store.listCurrentChapterInsights(workId).map((item) => [String(item.chapterId), String(item.summary)]));
4529
- const chapters = tree.volumes.flatMap((volume) => volume.chapters.map((chapter) => ({
4530
- id: String(chapter.id), volumeTitle: String(volume.title), title: String(chapter.title), versionNo: Number(chapter.versionNo), summary: summaries.get(String(chapter.id)) ?? ""
4531
- })));
4672
+ const chapterPage = this.store.getStoryIndexChapterPage(workId, offset, limit);
4532
4673
  const workRecords = structuralToolResultRecords([{
4533
4674
  id: work.id,
4534
4675
  title: work.title,
@@ -4539,7 +4680,7 @@ export class AiManager {
4539
4680
  chapterCount: work.chapterCount,
4540
4681
  wordCount: work.wordCount
4541
4682
  }], maximumRecordChars).map((record) => ({ ...record, _toolResultSection: "work" }));
4542
- const chapterRecords = structuralToolResultRecords(chapters.slice(offset, offset + limit), maximumRecordChars)
4683
+ const chapterRecords = structuralToolResultRecords(chapterPage.chapters, maximumRecordChars)
4543
4684
  .map((record) => ({ ...record, _toolResultSection: "chapter" }));
4544
4685
  const result = paginateToolResultRecords([...workRecords, ...chapterRecords], cursor, (page, pagination) => {
4545
4686
  const pageWork = page.flatMap((record) => {
@@ -4559,10 +4700,10 @@ export class AiManager {
4559
4700
  data: {
4560
4701
  ...(pageWork[0] ? { work: pageWork[0] } : {}),
4561
4702
  ...(pageWork.length > 1 ? { workFragments: pageWork } : {}),
4562
- totalChapters: chapters.length,
4703
+ totalChapters: chapterPage.totalChapters,
4563
4704
  offset,
4564
4705
  chapters: pageChapters,
4565
- nextOffset: pagination.nextCursor === null && offset + limit < chapters.length ? offset + limit : null
4706
+ nextOffset: pagination.nextCursor === null && offset + limit < chapterPage.totalChapters ? offset + limit : null
4566
4707
  },
4567
4708
  pagination
4568
4709
  };
@@ -4775,7 +4916,10 @@ export class AiManager {
4775
4916
  });
4776
4917
  }
4777
4918
  async generate(input, onDelta) {
4778
- const generationRoleplayCharacterId = this.roleplayCharacterId(input.workId, input.conversationId);
4919
+ const conversation = input.conversationId
4920
+ ? this.store.getAiConversationContext(input.conversationId, input.workId, input.excludeConversationMessageId)
4921
+ : null;
4922
+ const generationRoleplayCharacterId = this.roleplayCharacterIdFromConversation(input.workId, conversation);
4779
4923
  const { model, provider } = this.resolveModel(input.workId, input.taskType, input.modelId);
4780
4924
  const preset = safeJsonObject(stringValue(model, "preset_json"));
4781
4925
  const requestedParameters = {
@@ -4785,14 +4929,15 @@ export class AiManager {
4785
4929
  const configuredOutputTokens = Number(requestedParameters.max_tokens) || DEFAULT_MAX_TOKENS;
4786
4930
  const contextCompactThreshold = Math.min(90, Math.max(50, Number(this.store.getWorkAiSettings(input.workId).contextCompactThreshold) || 85));
4787
4931
  let effectiveInput = input;
4788
- let context = this.buildContext(effectiveInput, model);
4789
- let messages = this.buildMessages(effectiveInput, context);
4932
+ let effectiveBudget = this.contextBudget(effectiveInput, model, conversation);
4933
+ let context = this.buildContext(effectiveInput, model, effectiveBudget);
4934
+ let messages = this.buildMessages(effectiveInput, context, conversation);
4790
4935
  const allowedToolIds = new Set(input.disableTools
4791
4936
  ? []
4792
- : this.enabledAgentToolIds(input.workId, input.taskType, input.agentToolIds, input.conversationId));
4937
+ : this.enabledAgentToolIds(input.workId, input.taskType, input.agentToolIds, input.conversationId, generationRoleplayCharacterId));
4793
4938
  let tools = input.disableTools
4794
4939
  ? []
4795
- : this.enabledAgentTools(input.workId, input.taskType, input.agentToolIds, input.conversationId);
4940
+ : this.enabledAgentTools(input.workId, input.taskType, input.agentToolIds, input.conversationId, generationRoleplayCharacterId);
4796
4941
  let parameters;
4797
4942
  try {
4798
4943
  parameters = this.constrainParametersForContext(model, messages, requestedParameters, tools);
@@ -4803,8 +4948,9 @@ export class AiManager {
4803
4948
  if (tools.length === 0)
4804
4949
  throw initialContextWindowError(error, provider, model);
4805
4950
  effectiveInput = { ...input, agentToolIds: [] };
4806
- context = this.buildContext(effectiveInput, model);
4807
- messages = this.buildMessages(effectiveInput, context);
4951
+ effectiveBudget = this.contextBudget(effectiveInput, model, conversation);
4952
+ context = this.buildContext(effectiveInput, model, effectiveBudget);
4953
+ messages = this.buildMessages(effectiveInput, context, conversation);
4808
4954
  tools = [];
4809
4955
  allowedToolIds.clear();
4810
4956
  try {