@musnows/scriverse 0.7.7 → 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/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 ?? "");
@@ -3084,7 +3216,9 @@ export class AiManager {
3084
3216
  const effectiveInput = input.taskType === "continue"
3085
3217
  ? { ...input, scope: this.enrichContinuationScope(input.workId, input.scope, input.instruction) }
3086
3218
  : input;
3219
+ const processStartedAt = process.hrtime.bigint();
3087
3220
  const generated = await this.generate(effectiveInput);
3221
+ const processDurationMs = Math.min(86_400_000, Math.max(0, Math.round(Number(process.hrtime.bigint() - processStartedAt) / 1_000_000)));
3088
3222
  const chapter = effectiveInput.scope.chapterId ? this.store.getChapter(effectiveInput.scope.chapterId) : null;
3089
3223
  const suggestionId = id("suggestion");
3090
3224
  this.store.db.run(`INSERT INTO ai_suggestions (id, call_id, work_id, chapter_id, chapter_version, task_type, instruction,
@@ -3094,6 +3228,7 @@ export class AiManager {
3094
3228
  return {
3095
3229
  ...this.getSuggestion(suggestionId),
3096
3230
  outputTokens: generated.outputTokens,
3231
+ processDurationMs,
3097
3232
  ...(generated.cacheHitPercent === undefined ? {} : { cacheHitPercent: generated.cacheHitPercent }),
3098
3233
  toolCalls: generated.toolCalls,
3099
3234
  processSteps: generated.processSteps,
@@ -3115,7 +3250,9 @@ export class AiManager {
3115
3250
  && firstUserContent
3116
3251
  && titleModelId
3117
3252
  && (conversationBefore?.title === "新对话" || conversationBefore?.title === defaultTitle));
3253
+ const processStartedAt = process.hrtime.bigint();
3118
3254
  const generated = await this.generate({ ...input, taskType: "chat" }, onDelta);
3255
+ const processDurationMs = Math.min(86_400_000, Math.max(0, Math.round(Number(process.hrtime.bigint() - processStartedAt) / 1_000_000)));
3119
3256
  const chapter = input.scope.chapterId ? this.store.getChapter(input.scope.chapterId) : null;
3120
3257
  const suggestionId = id("suggestion");
3121
3258
  this.store.db.run(`INSERT INTO ai_suggestions (id, call_id, work_id, chapter_id, chapter_version, task_type, instruction,
@@ -3129,6 +3266,7 @@ export class AiManager {
3129
3266
  metadata: {
3130
3267
  ...(modelDisplayName ? { modelDisplayName } : {}),
3131
3268
  outputTokens: generated.outputTokens,
3269
+ processDurationMs,
3132
3270
  ...(generated.reasoningContent === undefined ? {} : { reasoningContent: generated.reasoningContent }),
3133
3271
  ...(generated.cacheHitPercent === undefined ? {} : { cacheHitPercent: generated.cacheHitPercent }),
3134
3272
  toolCalls: generated.toolCalls,
@@ -3145,6 +3283,7 @@ export class AiManager {
3145
3283
  return {
3146
3284
  ...this.getSuggestion(suggestionId),
3147
3285
  outputTokens: generated.outputTokens,
3286
+ processDurationMs,
3148
3287
  ...(generated.cacheHitPercent === undefined ? {} : { cacheHitPercent: generated.cacheHitPercent }),
3149
3288
  toolCalls: generated.toolCalls,
3150
3289
  processSteps: generated.processSteps,
@@ -3570,22 +3709,25 @@ export class AiManager {
3570
3709
  logger.warn("ai.task.cancelled", { taskId, workId: task.workId });
3571
3710
  return task;
3572
3711
  }
3573
- contextBudget(input, model) {
3712
+ contextBudget(input, model, existingConversation) {
3574
3713
  const contextWindow = numberValue(model, "context_window") || DEFAULT_CONTEXT_WINDOW;
3575
3714
  const preset = safeJsonObject(stringValue(model, "preset_json"));
3576
3715
  const configuredOutputTokens = typeof preset.max_tokens === "number" ? preset.max_tokens : DEFAULT_MAX_TOKENS;
3577
3716
  const outputReserveTokens = Math.max(MIN_OUTPUT_RESERVE_TOKENS, Math.min(configuredOutputTokens, Math.floor(contextWindow * 0.25), contextWindow - MIN_OUTPUT_RESERVE_TOKENS));
3578
3717
  const availableInputTokens = Math.max(256, contextWindow - outputReserveTokens - 512);
3579
- const conversation = input.conversationId
3580
- ? this.store.getAiConversationContext(input.conversationId, input.workId, input.excludeConversationMessageId)
3581
- : null;
3718
+ const conversation = existingConversation === undefined
3719
+ ? input.conversationId
3720
+ ? this.store.getAiConversationContext(input.conversationId, input.workId, input.excludeConversationMessageId)
3721
+ : null
3722
+ : existingConversation;
3582
3723
  const renderedMemory = conversation?.summary ? renderConversationMemory(conversation.summary) : "";
3583
3724
  const conversationTokens = conversation
3584
3725
  ? estimateAiTokens(renderedMemory) + conversation.messages.reduce((total, message) => total + estimateAiTokens(message.content), 0)
3585
3726
  : 0;
3586
3727
  const conversationBudgetTokens = Math.max(256, Math.floor(availableInputTokens * 0.32));
3587
3728
  const instructionTokens = estimateAiTokens(input.instruction);
3588
- 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)));
3589
3731
  const workContextBudgetTokens = Math.max(256, availableInputTokens
3590
3732
  - Math.min(conversationTokens, conversationBudgetTokens)
3591
3733
  - Math.min(instructionTokens, Math.floor(availableInputTokens * 0.25))
@@ -3607,10 +3749,11 @@ export class AiManager {
3607
3749
  getContextUsage(input) {
3608
3750
  const { model } = this.resolveModel(input.workId, input.taskType, input.modelId);
3609
3751
  const budget = this.contextBudget(input, model);
3752
+ const conversation = budget.conversation;
3610
3753
  const contextPlan = this.buildContextPlan(input, model, budget);
3611
3754
  const context = contextPlan.context;
3612
- const messages = this.buildMessages(input, context);
3613
- 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));
3614
3757
  const contextWindow = numberValue(model, "context_window") || DEFAULT_CONTEXT_WINDOW;
3615
3758
  const messageTokens = messages.reduce((total, message) => total + estimateAiTokens(completionMessageText(message.content)), 0);
3616
3759
  const systemPromptTokens = estimateAiTokens(completionMessageText(messages[0]?.content));
@@ -3621,7 +3764,6 @@ export class AiManager {
3621
3764
  // 超窗时把可交互上下文压到剩余份额,保证五段分布之和始终等于 contextWindow。
3622
3765
  const contextInteractionTokens = Math.max(0, contextWindow - systemPromptTokens - functionTokens - skillsTokens - remainingTokens);
3623
3766
  const threshold = Math.min(90, Math.max(50, Number(this.store.getWorkAiSettings(input.workId).contextCompactThreshold) || 85));
3624
- const conversation = budget.conversation;
3625
3767
  const conversationUsagePercent = Number(budget.conversationUsagePercent) || 0;
3626
3768
  const configuredOutputTokens = Number(budget.configuredOutputTokens) || DEFAULT_MAX_TOKENS;
3627
3769
  const maxOutputUsagePercent = Math.min(100, Math.round(configuredOutputTokens / contextWindow * 100));
@@ -3708,8 +3850,7 @@ export class AiManager {
3708
3850
  async prepareConversationContext(input, options = {}) {
3709
3851
  const inspection = this.inspectConversationContext(input);
3710
3852
  if (inspection.action === "ready") {
3711
- const conversation = this.store.getAiConversationContext(input.conversationId, input.workId);
3712
- if (conversation.warningPending)
3853
+ if (inspection.usage.contextWarningPending === true)
3713
3854
  this.store.setAiConversationContextWarning(input.conversationId, false);
3714
3855
  return inspection;
3715
3856
  }
@@ -3759,7 +3900,7 @@ export class AiManager {
3759
3900
  async compactConversation(input) {
3760
3901
  const conversation = this.store.getAiConversationContext(input.conversationId, input.workId, input.excludeConversationMessageId);
3761
3902
  const { model } = this.resolveModel(input.workId, "chat", input.modelId);
3762
- const budget = this.contextBudget({ ...input, taskType: "chat", instruction: "" }, model);
3903
+ const budget = this.contextBudget({ ...input, taskType: "chat", instruction: "" }, model, conversation);
3763
3904
  const recentTokenBudget = Math.max(128, Math.floor(Number(budget.conversationBudgetTokens) * 0.75));
3764
3905
  let retainedMessageCount = 0;
3765
3906
  let retainedTokens = 0;
@@ -3820,12 +3961,17 @@ export class AiManager {
3820
3961
  changed: true
3821
3962
  };
3822
3963
  }
3823
- buildMessages(input, context) {
3824
- 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);
3825
3971
  const roleplayPrompt = roleplayCharacterId ? this.buildRoleplaySystemPrompt(roleplayCharacterId) : "";
3826
3972
  const platformPrompt = roleplayCharacterId ? "" : String(this.store.getPlatformAiSettings().systemPrompt ?? "").trim();
3827
3973
  const workPrompt = roleplayCharacterId ? "" : String(this.store.getWorkAiSettings(input.workId).systemPrompt ?? "").trim();
3828
- 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);
3829
3975
  const toolGuidance = enabledToolIds.includes("recall_self") || enabledToolIds.includes("recall_relationship")
3830
3976
  ? [
3831
3977
  `当前可用的内部记忆能力是:${enabledToolIds.join("、")}。不要向用户提及工具、调用过程、资料库或检索结果。`,
@@ -3897,9 +4043,6 @@ export class AiManager {
3897
4043
  ]);
3898
4044
  // 分析任务指令含服务端 CHAPTER/json 等标记,不能转义;分区边界仍靠外层标签约束。
3899
4045
  const currentInstruction = wrapAiContextRegion(roleplayCharacterId ? "user_message" : "author_instruction", input.instruction, { escape: false });
3900
- const conversation = input.conversationId
3901
- ? this.store.getAiConversationContext(input.conversationId, input.workId, input.excludeConversationMessageId)
3902
- : null;
3903
4046
  if (!conversation) {
3904
4047
  return [
3905
4048
  { role: "system", content: systemPrompt },
@@ -3938,7 +4081,8 @@ export class AiManager {
3938
4081
  }
3939
4082
  buildContextPlan(input, model, existingBudget, persistKeywordInjections = false) {
3940
4083
  const budget = existingBudget ?? this.contextBudget(input, model);
3941
- const roleplayCharacterId = this.roleplayCharacterId(input.workId, input.conversationId);
4084
+ const conversation = budget.conversation;
4085
+ const roleplayCharacterId = this.roleplayCharacterIdFromConversation(input.workId, conversation);
3942
4086
  const settings = this.store.getWorkAiSettings(input.workId);
3943
4087
  const configuredScope = {
3944
4088
  ...input.scope,
@@ -4006,13 +4150,18 @@ export class AiManager {
4006
4150
  }
4007
4151
  return this.mergeInstructionEntityMatches(scope, matches);
4008
4152
  }
4009
- buildContext(input, model) {
4010
- return collapseAiBlankLines(this.buildContextPlan(input, model, undefined, true).context);
4153
+ buildContext(input, model, existingBudget) {
4154
+ return collapseAiBlankLines(this.buildContextPlan(input, model, existingBudget, true).context);
4011
4155
  }
4012
4156
  roleplayCharacterId(workId, conversationId) {
4013
4157
  if (!conversationId)
4014
4158
  return null;
4015
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;
4016
4165
  if (conversation.roleplayCharacterId) {
4017
4166
  const permissions = this.store.getWork(workId).modulePermissions;
4018
4167
  if (!canReadWorkModule(permissions, "characters")) {
@@ -4051,10 +4200,12 @@ export class AiManager {
4051
4200
  JSON.stringify(roleCard)
4052
4201
  ].join("\n");
4053
4202
  }
4054
- enabledAgentToolIds(workId, taskType, requestedToolIds, conversationId) {
4203
+ enabledAgentToolIds(workId, taskType, requestedToolIds, conversationId, roleplayCharacterIdOverride) {
4055
4204
  if (taskType !== "chat" && requestedToolIds === undefined)
4056
4205
  return [];
4057
- const roleplayCharacterId = this.roleplayCharacterId(workId, conversationId);
4206
+ const roleplayCharacterId = roleplayCharacterIdOverride === undefined
4207
+ ? this.roleplayCharacterId(workId, conversationId)
4208
+ : roleplayCharacterIdOverride;
4058
4209
  const permissions = this.store.getWork(workId).modulePermissions;
4059
4210
  if (roleplayCharacterId) {
4060
4211
  const requested = requestedToolIds ? new Set(requestedToolIds) : null;
@@ -4078,8 +4229,9 @@ export class AiManager {
4078
4229
  && (!requested || requested.has(toolId))
4079
4230
  && this.canReadWithAgentTool(permissions, toolId));
4080
4231
  }
4081
- enabledAgentTools(workId, taskType, requestedToolIds, conversationId) {
4082
- 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]);
4083
4235
  }
4084
4236
  canReadWithAgentTool(permissions, toolId) {
4085
4237
  if (toolId === "search_story_entities") {
@@ -4517,11 +4669,7 @@ export class AiManager {
4517
4669
  if (name === "story_index") {
4518
4670
  const { offset, limit, cursor } = args;
4519
4671
  const work = this.store.getWork(workId);
4520
- const tree = this.store.getWorkTree(workId);
4521
- const summaries = new Map(this.store.listCurrentChapterInsights(workId).map((item) => [String(item.chapterId), String(item.summary)]));
4522
- const chapters = tree.volumes.flatMap((volume) => volume.chapters.map((chapter) => ({
4523
- id: String(chapter.id), volumeTitle: String(volume.title), title: String(chapter.title), versionNo: Number(chapter.versionNo), summary: summaries.get(String(chapter.id)) ?? ""
4524
- })));
4672
+ const chapterPage = this.store.getStoryIndexChapterPage(workId, offset, limit);
4525
4673
  const workRecords = structuralToolResultRecords([{
4526
4674
  id: work.id,
4527
4675
  title: work.title,
@@ -4532,7 +4680,7 @@ export class AiManager {
4532
4680
  chapterCount: work.chapterCount,
4533
4681
  wordCount: work.wordCount
4534
4682
  }], maximumRecordChars).map((record) => ({ ...record, _toolResultSection: "work" }));
4535
- const chapterRecords = structuralToolResultRecords(chapters.slice(offset, offset + limit), maximumRecordChars)
4683
+ const chapterRecords = structuralToolResultRecords(chapterPage.chapters, maximumRecordChars)
4536
4684
  .map((record) => ({ ...record, _toolResultSection: "chapter" }));
4537
4685
  const result = paginateToolResultRecords([...workRecords, ...chapterRecords], cursor, (page, pagination) => {
4538
4686
  const pageWork = page.flatMap((record) => {
@@ -4552,10 +4700,10 @@ export class AiManager {
4552
4700
  data: {
4553
4701
  ...(pageWork[0] ? { work: pageWork[0] } : {}),
4554
4702
  ...(pageWork.length > 1 ? { workFragments: pageWork } : {}),
4555
- totalChapters: chapters.length,
4703
+ totalChapters: chapterPage.totalChapters,
4556
4704
  offset,
4557
4705
  chapters: pageChapters,
4558
- nextOffset: pagination.nextCursor === null && offset + limit < chapters.length ? offset + limit : null
4706
+ nextOffset: pagination.nextCursor === null && offset + limit < chapterPage.totalChapters ? offset + limit : null
4559
4707
  },
4560
4708
  pagination
4561
4709
  };
@@ -4768,7 +4916,10 @@ export class AiManager {
4768
4916
  });
4769
4917
  }
4770
4918
  async generate(input, onDelta) {
4771
- 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);
4772
4923
  const { model, provider } = this.resolveModel(input.workId, input.taskType, input.modelId);
4773
4924
  const preset = safeJsonObject(stringValue(model, "preset_json"));
4774
4925
  const requestedParameters = {
@@ -4778,14 +4929,15 @@ export class AiManager {
4778
4929
  const configuredOutputTokens = Number(requestedParameters.max_tokens) || DEFAULT_MAX_TOKENS;
4779
4930
  const contextCompactThreshold = Math.min(90, Math.max(50, Number(this.store.getWorkAiSettings(input.workId).contextCompactThreshold) || 85));
4780
4931
  let effectiveInput = input;
4781
- let context = this.buildContext(effectiveInput, model);
4782
- 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);
4783
4935
  const allowedToolIds = new Set(input.disableTools
4784
4936
  ? []
4785
- : this.enabledAgentToolIds(input.workId, input.taskType, input.agentToolIds, input.conversationId));
4937
+ : this.enabledAgentToolIds(input.workId, input.taskType, input.agentToolIds, input.conversationId, generationRoleplayCharacterId));
4786
4938
  let tools = input.disableTools
4787
4939
  ? []
4788
- : this.enabledAgentTools(input.workId, input.taskType, input.agentToolIds, input.conversationId);
4940
+ : this.enabledAgentTools(input.workId, input.taskType, input.agentToolIds, input.conversationId, generationRoleplayCharacterId);
4789
4941
  let parameters;
4790
4942
  try {
4791
4943
  parameters = this.constrainParametersForContext(model, messages, requestedParameters, tools);
@@ -4796,8 +4948,9 @@ export class AiManager {
4796
4948
  if (tools.length === 0)
4797
4949
  throw initialContextWindowError(error, provider, model);
4798
4950
  effectiveInput = { ...input, agentToolIds: [] };
4799
- context = this.buildContext(effectiveInput, model);
4800
- 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);
4801
4954
  tools = [];
4802
4955
  allowedToolIds.clear();
4803
4956
  try {