@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 +209 -56
- package/dist/ai.js.map +1 -1
- package/dist/app.js +24 -6
- package/dist/app.js.map +1 -1
- package/dist/database.js +36 -2
- package/dist/database.js.map +1 -1
- package/dist/hybrid-search.js +36 -5
- package/dist/hybrid-search.js.map +1 -1
- package/dist/public/ai-message-meta.js +11 -2
- package/dist/public/app.js +113 -93
- package/dist/public/index.html +3 -3
- package/dist/public/styles.css +12 -7
- package/dist/store.js +138 -27
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +5 -4
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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,
|
|
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.
|
|
1625
|
-
|
|
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")
|
|
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")
|
|
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.
|
|
1719
|
-
|
|
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.
|
|
1731
|
-
|
|
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.
|
|
1742
|
-
|
|
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
|
-
...
|
|
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 =
|
|
3580
|
-
?
|
|
3581
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
4703
|
+
totalChapters: chapterPage.totalChapters,
|
|
4556
4704
|
offset,
|
|
4557
4705
|
chapters: pageChapters,
|
|
4558
|
-
nextOffset: pagination.nextCursor === null && offset + limit <
|
|
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
|
|
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
|
|
4782
|
-
let
|
|
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
|
-
|
|
4800
|
-
|
|
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 {
|