context-engine.ts 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272
  1. import { resolveContextPackTokenBudget } from "@/lib/context-budget"
  2. import { listDirectory, readFile } from "@/commands/fs"
  3. import i18n from "@/i18n"
  4. import { searchWiki, tokenizeQuery } from "@/lib/search"
  5. import { normalizePath } from "@/lib/path-utils"
  6. import { useWikiStore } from "@/stores/wiki-store"
  7. import { parseChapterMeta } from "./chapter-meta"
  8. import { parseFrontmatter } from "@/lib/frontmatter"
  9. import { listSnapshots, loadSnapshot, type ChapterSnapshot } from "./chapter-ingest"
  10. import { buildRevisionDirectives } from "./revision-feedback"
  11. import { extractChapterOutlineStatus } from "./outline-quality-check"
  12. import { loadCognitionState, cognitionToContextText } from "./character-cognition"
  13. import { getChapterVolumes } from "./volume"
  14. import { isAuthoritativeGenerationPath, isHistoricalProjectionSnippet, novelMixedSearch } from "./search-adapter"
  15. import { rerankCandidates } from "@/lib/rerank"
  16. import type { FileNode } from "@/types/wiki"
  17. import {
  18. DataSourceRegistry,
  19. type ContextLoadContext,
  20. type DataSourceLoadAdapter,
  21. } from "./context-data-source"
  22. import { getAllDataSources, getDataSourcesForCategories } from "./context-data-sources"
  23. import type { DataSourceCategory } from "./classification"
  24. const FIELD_PRIORITY: Record<string, number> = {
  25. sectionBriefing: 0,
  26. task: 1,
  27. chapterGoal: 2,
  28. mustDo: 3,
  29. mustAvoid: 4,
  30. soulDoc: 5,
  31. outline: 6,
  32. recentSummaries: 7,
  33. previousChapterEnding: 8,
  34. characterStates: 9,
  35. characterAuras: 10,
  36. foreshadowingStates: 11,
  37. recentChapterContents: 12,
  38. revisionDirectives: 13,
  39. cognitionStates: 14,
  40. timeline: 15,
  41. relatedSettings: 16,
  42. canonRules: 17,
  43. nextChapterAdvice: 18,
  44. writingStyle: 19,
  45. searchResults: 20,
  46. graphSearchResults: 21,
  47. }
  48. export interface TrimResult {
  49. prompt: string
  50. trimmedFields: string[]
  51. partiallyTrimmedField?: {
  52. fieldKey: string
  53. originalChars: number
  54. keptChars: number
  55. }
  56. trimmedChars: number
  57. originalChars: number
  58. finalChars: number
  59. }
  60. export interface ContextPack {
  61. task: string
  62. chapterGoal: string
  63. outline: string
  64. recentChapterContents?: string[]
  65. recentSummaries: string[]
  66. previousChapterEnding: string
  67. characterStates: string
  68. soulDoc: string
  69. characterAuras: string
  70. cognitionStates: string
  71. foreshadowingStates: string
  72. sectionBriefing?: string
  73. timeline: string
  74. relatedSettings: string
  75. canonRules: string
  76. writingStyle: string
  77. searchResults: string
  78. graphSearchResults: string
  79. mustDo: string
  80. mustAvoid: string
  81. nextChapterAdvice: string
  82. revisionDirectives: string
  83. }
  84. export async function buildContextPack(
  85. projectPath: string,
  86. task: string,
  87. chapterNumber?: number,
  88. options?: { categories?: DataSourceCategory[]; loadAdapter?: DataSourceLoadAdapter },
  89. ): Promise<ContextPack> {
  90. const pp = normalizePath(projectPath)
  91. const novelMode = useWikiStore.getState().novelMode
  92. if (!novelMode) {
  93. return emptyPack(task)
  94. }
  95. // 构建加载上下文
  96. const context = buildLoadContext(pp, task, chapterNumber)
  97. // 创建数据源注册器并加载所有数据
  98. const registry = createDataSourceRegistry(options?.categories, options?.loadAdapter)
  99. const rawData = await registry.loadAll(context)
  100. // 从原始数据构建上下文包
  101. return buildContextPackFromRawData(rawData, context)
  102. }
  103. /**
  104. * 构建加载上下文配置
  105. */
  106. function buildLoadContext(
  107. projectPath: string,
  108. task: string,
  109. chapterNumber?: number,
  110. ): ContextLoadContext {
  111. const novelConfig = useWikiStore.getState().novelConfig
  112. const revisionFeedbackWindowConfig = useWikiStore.getState().revisionFeedbackWindowConfig
  113. return {
  114. projectPath,
  115. task,
  116. chapterNumber: chapterNumber ?? extractChapterNumberFromTask(task),
  117. config: {
  118. recentSummaryWindow: novelConfig.recentSummaryWindow > 0 ? novelConfig.recentSummaryWindow : 8,
  119. searchTopK: novelConfig.searchTopK > 0 ? novelConfig.searchTopK : 5,
  120. snapshotLookback: 3,
  121. revisionFeedbackWindowConfig,
  122. },
  123. }
  124. }
  125. /**
  126. * 创建并配置数据源注册器
  127. */
  128. function createDataSourceRegistry(
  129. categories?: DataSourceCategory[],
  130. loadAdapter?: DataSourceLoadAdapter,
  131. ): DataSourceRegistry {
  132. const registry = new DataSourceRegistry({ loadAdapter })
  133. registry.registerAll(categories?.length ? getDataSourcesForCategories(categories) : getAllDataSources())
  134. return registry
  135. }
  136. /**
  137. * 从原始数据构建上下文包
  138. */
  139. async function buildContextPackFromRawData(
  140. rawData: Record<string, any>,
  141. context: ContextLoadContext,
  142. ): Promise<ContextPack> {
  143. const searchResults = joinNonEmpty([
  144. rawData.searchResults || "",
  145. rawData.bookAnalysisReferences || "",
  146. ], "\n\n")
  147. // 合并快照数据和降级数据,优先使用 retrieval 索引
  148. const retrievalRecentSummaries = Array.isArray(rawData.retrieval?.recentSummaries)
  149. ? rawData.retrieval.recentSummaries
  150. : []
  151. const snapshotRecentSummaries = Array.isArray(rawData.snapshots?.recentSummaries)
  152. ? rawData.snapshots.recentSummaries
  153. : []
  154. const recentSummaries = retrievalRecentSummaries.length > 0
  155. ? retrievalRecentSummaries
  156. : snapshotRecentSummaries.length > 0
  157. ? snapshotRecentSummaries
  158. : rawData.fallbackRecentSummaries
  159. const recentChapterContents = Array.isArray(rawData.recentChapterContents)
  160. ? rawData.recentChapterContents
  161. : []
  162. const previousChapterEnding = rawData.snapshots.previousChapterEnding
  163. || rawData.fallbackPreviousEnding
  164. const retrievalCharacterStates = rawData.retrieval?.characterStates || ""
  165. const snapshotCharacterStates = rawData.snapshots?.characterStates || ""
  166. const characterStates = joinNonEmpty([
  167. retrievalCharacterStates,
  168. snapshotCharacterStates,
  169. rawData.fallbackCharacterStates
  170. ], "\n\n")
  171. const retrievalTimeline = rawData.retrieval?.timeline || ""
  172. const snapshotTimeline = rawData.snapshots?.timeline || ""
  173. const timeline = joinNonEmpty([
  174. retrievalTimeline,
  175. snapshotTimeline,
  176. rawData.fallbackTimeline
  177. ], "\n\n")
  178. const retrievalForeshadowingSignals = Array.isArray(rawData.retrieval?.foreshadowingSignals)
  179. ? rawData.retrieval.foreshadowingSignals
  180. : []
  181. const snapshotForeshadowingSignals = Array.isArray(rawData.snapshots?.foreshadowingSignals)
  182. ? rawData.snapshots.foreshadowingSignals
  183. : []
  184. const foreshadowingSignals = retrievalForeshadowingSignals.length > 0
  185. ? retrievalForeshadowingSignals
  186. : snapshotForeshadowingSignals
  187. const foreshadowingStates = mergeForeshadowingSignals(
  188. foreshadowingSignals.length > 0
  189. ? foreshadowingSignals
  190. : [rawData.fallbackForeshadowingStates].filter(Boolean),
  191. searchResults,
  192. )
  193. // 构建章节目标
  194. const chapterGoal = buildChapterGoal(
  195. rawData.outline,
  196. rawData.chapterOutline,
  197. context.chapterNumber
  198. )
  199. // 合并大纲信息
  200. const mergedOutline = joinNonEmpty([
  201. rawData.outline,
  202. rawData.volumeContext,
  203. rawData.chapterOutline
  204. ], "\n\n")
  205. // 构建修订指令
  206. const revisionDirectives = buildRevisionDirectives(rawData.revisionFeedback)
  207. // 构建角色氛围上下文(依赖其他数据)
  208. const { buildCharacterAuraContext } = await import("./character-aura")
  209. const characterAuras = await buildCharacterAuraContext(context.projectPath, context.task, {
  210. matchingText: joinNonEmpty([
  211. chapterGoal,
  212. rawData.chapterOutline,
  213. rawData.fallbackCharacterStates,
  214. rawData.snapshots.characterStates,
  215. rawData.cognitionText,
  216. ], "\n\n"),
  217. })
  218. return {
  219. task: context.task,
  220. chapterGoal,
  221. outline: mergedOutline,
  222. recentChapterContents,
  223. recentSummaries,
  224. previousChapterEnding,
  225. characterStates,
  226. soulDoc: rawData.soulDoc,
  227. sectionBriefing: rawData.sectionBriefing || "",
  228. characterAuras,
  229. cognitionStates: rawData.cognitionText,
  230. foreshadowingStates,
  231. timeline,
  232. relatedSettings: rawData.relatedSettings,
  233. canonRules: rawData.canonRules,
  234. writingStyle: rawData.writingStyle,
  235. searchResults,
  236. graphSearchResults: rawData.graphSearchResults,
  237. mustDo: buildMustDo(chapterGoal, previousChapterEnding, foreshadowingStates),
  238. mustAvoid: buildMustAvoid(rawData.canonRules, timeline, characterStates),
  239. nextChapterAdvice: buildNextChapterAdvice({
  240. chapterGoal,
  241. recentSummaries,
  242. previousChapterEnding,
  243. foreshadowingStates,
  244. timeline,
  245. searchResults,
  246. }),
  247. revisionDirectives,
  248. }
  249. }
  250. export function extractChapterNumberFromTask(task: string): number | undefined {
  251. const patterns = [
  252. /\u7b2c\s*(\d+)\s*\u7ae0/i,
  253. /chapter\s*(\d+)/i,
  254. /ch\.?\s*(\d+)/i,
  255. ]
  256. for (const pattern of patterns) {
  257. const match = task.match(pattern)
  258. if (match) {
  259. const value = Number(match[1])
  260. if (Number.isFinite(value) && value > 0) return value
  261. }
  262. }
  263. return undefined
  264. }
  265. export function selectLookbackChapterNumbers(chapterNumber: number, lookback: number): number[] {
  266. const result: number[] = []
  267. for (let current = chapterNumber - 1; current >= 1 && result.length < lookback; current -= 1) {
  268. result.push(current)
  269. }
  270. return result
  271. }
  272. export function mergeForeshadowingSignals(signals: string[], searchResults: string): string {
  273. const normalized = signals
  274. .map((signal) => signal.trim())
  275. .filter(Boolean)
  276. if (normalized.length === 0 && !searchResults.trim()) return ""
  277. const unresolved = normalized.filter(signal => /未回收|未解决|新增伏笔/i.test(signal))
  278. const repeated = unresolved.filter(signal => {
  279. const keyword = signal.split(/[::]/)[0]?.trim()
  280. return keyword && searchResults.includes(keyword)
  281. })
  282. const sections = [normalized.join("\n")]
  283. if (repeated.length > 0) {
  284. const names = repeated
  285. .map(signal => signal.split(/[::]/)[0]?.trim())
  286. .filter(Boolean)
  287. sections.push(`以下伏笔近期反复出现,但尚未明显推进,需注意是否在本章继续铺设或回收:${Array.from(new Set(names)).join("、")}`)
  288. }
  289. return sections.filter(Boolean).join("\n\n")
  290. }
  291. export function buildChapterGoal(outline: string, chapterOutline: string, chapterNumber?: number): string {
  292. const parts: string[] = []
  293. const fromOutline = extractChapterGoal(outline, chapterNumber)
  294. const fromChapterOutline = extractChapterGoal(chapterOutline, chapterNumber)
  295. if (fromOutline) parts.push(fromOutline)
  296. if (fromChapterOutline && !parts.includes(fromChapterOutline)) parts.push(fromChapterOutline)
  297. return parts.join("\n")
  298. }
  299. export function buildMustDo(chapterGoal: string, previousChapterEnding: string, foreshadowingStates: string): string {
  300. const items: string[] = []
  301. chapterGoal.split("\n").map((line) => line.trim()).filter(Boolean).forEach((line) => items.push(`- ${line}`))
  302. if (previousChapterEnding.trim()) {
  303. items.push(i18n.t("novel.contextPack.mustDo.previousChapterEnding", { value: previousChapterEnding.trim() }))
  304. }
  305. if (foreshadowingStates.trim()) {
  306. const firstForeshadowing = foreshadowingStates.split("\n").find(Boolean)
  307. if (firstForeshadowing) {
  308. items.push(i18n.t("novel.contextPack.mustDo.foreshadowing", { value: firstForeshadowing.trim() }))
  309. }
  310. }
  311. return items.join("\n")
  312. }
  313. export function buildMustAvoid(canonRules: string, timeline: string, characterStates: string): string {
  314. const items: string[] = []
  315. if (canonRules.trim()) items.push(i18n.t("novel.contextPack.mustAvoid.canonRules", { value: canonRules.trim() }))
  316. if (timeline.trim()) items.push(i18n.t("novel.contextPack.mustAvoid.timeline", { value: timeline.trim() }))
  317. if (characterStates.trim()) items.push(i18n.t("novel.contextPack.mustAvoid.characterStates", { value: characterStates.trim() }))
  318. return items.join("\n")
  319. }
  320. export function buildNextChapterAdvice(input: {
  321. chapterGoal: string
  322. recentSummaries: string[]
  323. previousChapterEnding: string
  324. foreshadowingStates: string
  325. timeline: string
  326. searchResults: string
  327. }): string {
  328. const advice: string[] = []
  329. if (input.previousChapterEnding.trim()) {
  330. advice.push(i18n.t("novel.contextPack.nextChapterAdvice.previousChapterEnding", { value: input.previousChapterEnding.trim() }))
  331. }
  332. if (input.chapterGoal.trim()) {
  333. advice.push(i18n.t("novel.contextPack.nextChapterAdvice.chapterGoal", { value: input.chapterGoal.trim() }))
  334. }
  335. if (input.foreshadowingStates.trim()) {
  336. const firstForeshadowing = input.foreshadowingStates.split("\n").find(Boolean)
  337. if (firstForeshadowing) {
  338. advice.push(i18n.t("novel.contextPack.nextChapterAdvice.foreshadowing", { value: firstForeshadowing.trim() }))
  339. }
  340. }
  341. if (input.timeline.trim()) {
  342. advice.push(i18n.t("novel.contextPack.nextChapterAdvice.timeline", { value: input.timeline.trim() }))
  343. }
  344. if (input.searchResults.trim()) {
  345. advice.push(i18n.t("novel.contextPack.nextChapterAdvice.searchResults", { value: input.searchResults.trim() }))
  346. }
  347. if (input.recentSummaries.length > 0) {
  348. advice.push(i18n.t("novel.contextPack.nextChapterAdvice.recentSummaries", { value: input.recentSummaries.slice(-2).join(";") }))
  349. }
  350. return advice.join("\n")
  351. }
  352. export function joinNonEmpty(parts: string[], separator: string): string {
  353. return parts.map((part) => part.trim()).filter(Boolean).join(separator)
  354. }
  355. function emptyPack(task: string): ContextPack {
  356. return {
  357. task,
  358. chapterGoal: "",
  359. outline: "",
  360. recentChapterContents: [],
  361. recentSummaries: [],
  362. previousChapterEnding: "",
  363. characterStates: "",
  364. soulDoc: "",
  365. characterAuras: "",
  366. cognitionStates: "",
  367. foreshadowingStates: "",
  368. sectionBriefing: "",
  369. timeline: "",
  370. relatedSettings: "",
  371. canonRules: "",
  372. writingStyle: "",
  373. searchResults: "",
  374. graphSearchResults: "",
  375. mustDo: "",
  376. mustAvoid: "",
  377. nextChapterAdvice: "",
  378. revisionDirectives: "",
  379. }
  380. }
  381. export async function readOutlineContent(pp: string): Promise<string> {
  382. try {
  383. const results = await searchWiki(pp, "outline type:outline")
  384. if (results.length > 0) {
  385. const contents = await Promise.all(
  386. results.map(async (result) => {
  387. try {
  388. return await readFile(result.path)
  389. } catch {
  390. return ""
  391. }
  392. }),
  393. )
  394. return joinNonEmpty(contents, "\n\n---\n\n")
  395. }
  396. } catch {}
  397. return ""
  398. }
  399. function flattenOutlineMarkdownFiles(nodes: FileNode[]): FileNode[] {
  400. const files: FileNode[] = []
  401. for (const node of nodes) {
  402. if (node.is_dir) {
  403. if (node.children) files.push(...flattenOutlineMarkdownFiles(node.children))
  404. continue
  405. }
  406. if (node.name.toLowerCase().endsWith(".md")) files.push(node)
  407. }
  408. return files
  409. }
  410. function readFrontmatterChapterNumber(content: string): number | undefined {
  411. const raw = parseFrontmatter(content).frontmatter?.chapter_number
  412. const value = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN
  413. return Number.isFinite(value) && value > 0 ? value : undefined
  414. }
  415. function numberToChineseChapter(value: number): string {
  416. const digits = ["零", "一", "二", "三", "四", "五", "六", "七", "八", "九"]
  417. if (value <= 10) {
  418. if (value === 10) return "十"
  419. return digits[value] ?? String(value)
  420. }
  421. if (value < 20) return `十${digits[value - 10]}`
  422. if (value < 100) {
  423. const tens = Math.floor(value / 10)
  424. const ones = value % 10
  425. return `${digits[tens]}十${ones === 0 ? "" : digits[ones]}`
  426. }
  427. if (value < 1000) {
  428. const hundreds = Math.floor(value / 100)
  429. const rest = value % 100
  430. if (rest === 0) return `${digits[hundreds]}百`
  431. if (rest < 10) return `${digits[hundreds]}百零${digits[rest]}`
  432. return `${digits[hundreds]}百${numberToChineseChapter(rest)}`
  433. }
  434. return String(value)
  435. }
  436. function chapterLabels(chapterNumber: number): string[] {
  437. return [`第${chapterNumber}章`, `第${numberToChineseChapter(chapterNumber)}章`]
  438. }
  439. function includesChapterMarker(text: string, chapterNumber: number): boolean {
  440. const compact = text.replace(/\s+/g, "")
  441. return chapterLabels(chapterNumber).some((label) => compact.includes(label)) ||
  442. new RegExp(`chapter\\s*${chapterNumber}\\b`, "i").test(text)
  443. }
  444. export function pickChapterOutlineByNumber(
  445. candidates: Array<{ path: string; content: string }>,
  446. chapterNumber: number,
  447. ): string {
  448. const frontmatterMatch = candidates.find((candidate) => readFrontmatterChapterNumber(candidate.content) === chapterNumber)
  449. if (frontmatterMatch) return frontmatterMatch.content.slice(0, 4000)
  450. const headingMatch = candidates.find((candidate) =>
  451. includesChapterMarker(candidate.content, chapterNumber) || includesChapterMarker(candidate.path, chapterNumber),
  452. )
  453. if (headingMatch) return headingMatch.content.slice(0, 4000)
  454. return ""
  455. }
  456. async function readChapterOutlineDirect(pp: string, chapterNumber: number): Promise<string> {
  457. try {
  458. const tree = await listDirectory(`${pp}/wiki/outlines`)
  459. const files = flattenOutlineMarkdownFiles(tree)
  460. const candidates = await Promise.all(
  461. files.slice(0, 80).map(async (file) => ({
  462. path: file.path,
  463. content: await readFile(file.path).catch(() => ""),
  464. })),
  465. )
  466. return pickChapterOutlineByNumber(
  467. candidates.filter((candidate) => candidate.content.trim()),
  468. chapterNumber,
  469. )
  470. } catch {
  471. return ""
  472. }
  473. }
  474. export async function readChapterOutlineContent(pp: string, chapterNumber?: number): Promise<string> {
  475. if (!chapterNumber) return ""
  476. const direct = await readChapterOutlineDirect(pp, chapterNumber)
  477. if (direct.trim()) return annotateChapterOutlineStatus(direct)
  478. const queries = [
  479. `第${chapterNumber}章细纲 outline`,
  480. `chapter ${chapterNumber} outline`,
  481. `chapter_number:${chapterNumber} outline_type:chapter-outline`,
  482. ]
  483. for (const query of queries) {
  484. try {
  485. const results = await searchWiki(pp, query)
  486. if (results.length > 0) {
  487. return annotateChapterOutlineStatus(await readFile(results[0].path)).slice(0, 3000)
  488. }
  489. } catch {}
  490. }
  491. return ""
  492. }
  493. export function annotateChapterOutlineStatus(content: string): string {
  494. const status = extractChapterOutlineStatus(content)
  495. if (status === "已确认") return content
  496. const label = status === "未知" ? "未标明当前状态" : `当前状态为「${status}」`
  497. return [
  498. `【章纲状态提示】该章纲${label},普通 AI 会话生成正文前应提醒用户确认是否继续使用;不得自行补写或改写章纲。`,
  499. "",
  500. content,
  501. ].join("\n")
  502. }
  503. // 以下函数已被数据源模式使用,但通过动态导入,TypeScript 无法检测到
  504. // @ts-expect-error - 函数通过动态导入在 context-data-sources.ts 中使用
  505. async function readSnapshotContext(
  506. pp: string,
  507. chapterNumber: number | undefined,
  508. recentSummaryWindow: number,
  509. snapshotLookback: number,
  510. ): Promise<{
  511. recentSummaries: string[]
  512. previousChapterEnding: string
  513. characterStates: string
  514. foreshadowingSignals: string[]
  515. timeline: string
  516. }> {
  517. const snapshotNumbers = await listSnapshots(pp)
  518. if (snapshotNumbers.length === 0) {
  519. return {
  520. recentSummaries: [],
  521. previousChapterEnding: "",
  522. characterStates: "",
  523. foreshadowingSignals: [],
  524. timeline: "",
  525. }
  526. }
  527. const lookbackNumbers = chapterNumber
  528. ? selectLookbackChapterNumbers(chapterNumber, snapshotLookback)
  529. : [...snapshotNumbers].sort((a, b) => b - a).slice(0, snapshotLookback)
  530. const summaryNumbers = chapterNumber
  531. ? snapshotNumbers.filter((n) => n < chapterNumber).slice(-recentSummaryWindow)
  532. : snapshotNumbers.slice(-recentSummaryWindow)
  533. const [lookbackSnapshots, summarySnapshots] = await Promise.all([
  534. Promise.all(lookbackNumbers.map((n) => loadSnapshot(pp, n))),
  535. Promise.all(summaryNumbers.map((n) => loadSnapshot(pp, n))),
  536. ])
  537. const validLookback = lookbackSnapshots.filter((snapshot): snapshot is ChapterSnapshot => Boolean(snapshot))
  538. const validSummarySnapshots = summarySnapshots.filter((snapshot): snapshot is ChapterSnapshot => Boolean(snapshot))
  539. const previousSnapshot = validLookback[0]
  540. const recentSummaries = validSummarySnapshots.map((snapshot) => `第${snapshot.chapterNumber}章:${snapshot.summary}`)
  541. const characterStates = joinNonEmpty(
  542. validLookback
  543. .flatMap((snapshot) => snapshot.characterStateChanges.map((change) => `第${snapshot.chapterNumber}章:${change}`)),
  544. "\n",
  545. )
  546. const foreshadowingSignals = validLookback.flatMap((snapshot) => snapshot.foreshadowingChanges)
  547. const timeline = joinNonEmpty(
  548. validLookback
  549. .flatMap((snapshot) => snapshot.timelineEvents.map((event) => `第${snapshot.chapterNumber}章:${event}`)),
  550. "\n",
  551. )
  552. return {
  553. recentSummaries,
  554. previousChapterEnding: previousSnapshot?.endingHook || "",
  555. characterStates,
  556. foreshadowingSignals,
  557. timeline,
  558. }
  559. }
  560. // @ts-expect-error - 函数通过动态导入在 context-data-sources.ts 中使用
  561. async function readRecentChapterSummaries(pp: string, count: number): Promise<string[]> {
  562. const summaries: string[] = []
  563. try {
  564. const results = await searchWiki(pp, "type:chapter")
  565. for (const r of results.slice(0, count)) {
  566. try {
  567. const content = await readFile(r.path)
  568. const parsed = parseFrontmatter(content)
  569. const fm = parsed.frontmatter as Record<string, unknown> | null
  570. const meta = fm ? parseChapterMeta(fm) : null
  571. if (meta) {
  572. const bodyStart = content.indexOf("---", 4)
  573. const body = bodyStart >= 0 ? content.slice(bodyStart + 3).trim() : content
  574. summaries.push(`第${meta.chapterNumber}章 (${meta.status}): ${body.slice(0, 500)}`)
  575. }
  576. } catch {}
  577. }
  578. } catch {}
  579. return summaries
  580. }
  581. // @ts-expect-error - 函数通过动态导入在 context-data-sources.ts 中使用
  582. async function readPreviousChapterEnding(pp: string, chapterNumber?: number): Promise<string> {
  583. if (!chapterNumber || chapterNumber <= 1) return ""
  584. try {
  585. const results = await searchWiki(pp, `chapter_number:${chapterNumber - 1}`)
  586. if (results.length > 0) {
  587. const content = await readFile(results[0].path)
  588. const lines = content.split("\n")
  589. const lastLines = lines.slice(-10).join("\n")
  590. return lastLines
  591. }
  592. } catch {}
  593. return ""
  594. }
  595. // @ts-expect-error - 函数通过动态导入在 context-data-sources.ts 中使用
  596. async function readCharacterStates(pp: string): Promise<string> {
  597. try {
  598. const results = await searchWiki(pp, "type:entity character")
  599. if (results.length > 0) {
  600. const contents = await Promise.all(results.slice(0, 5).map(r => readFile(r.path).catch(() => "")))
  601. return contents.filter(Boolean).join("\n---\n").slice(0, 3000)
  602. }
  603. } catch {}
  604. return ""
  605. }
  606. // @ts-expect-error - 函数通过动态导入在 context-data-sources.ts 中使用
  607. async function readCognitionStates(pp: string): Promise<string> {
  608. try {
  609. const state = await loadCognitionState(pp)
  610. if (!state) return ""
  611. return cognitionToContextText(state)
  612. } catch {}
  613. return ""
  614. }
  615. // @ts-expect-error - 函数通过动态导入在 context-data-sources.ts 中使用
  616. async function readForeshadowingStates(pp: string): Promise<string> {
  617. try {
  618. const results = await searchWiki(pp, "伏笔 foreshadowing")
  619. if (results.length > 0) {
  620. const contents = await Promise.all(results.slice(0, 3).map(r => readFile(r.path).catch(() => "")))
  621. return contents.filter(Boolean).join("\n---\n").slice(0, 2000)
  622. }
  623. } catch {}
  624. return ""
  625. }
  626. // @ts-expect-error - 函数通过动态导入在 context-data-sources.ts 中使用
  627. async function readTimeline(pp: string): Promise<string> {
  628. try {
  629. const results = await searchWiki(pp, "timeline 时间线")
  630. if (results.length > 0) {
  631. const content = await readFile(results[0].path)
  632. return content.slice(0, 2000)
  633. }
  634. } catch {}
  635. return ""
  636. }
  637. // @ts-expect-error - 函数通过动态导入在 context-data-sources.ts 中使用
  638. async function readRelatedSettings(pp: string): Promise<string> {
  639. try {
  640. const results = await searchWiki(pp, "setting 设定 location 地点")
  641. if (results.length > 0) {
  642. const contents = await Promise.all(results.slice(0, 3).map(r => readFile(r.path).catch(() => "")))
  643. return contents.filter(Boolean).join("\n---\n").slice(0, 2000)
  644. }
  645. } catch {}
  646. return ""
  647. }
  648. // @ts-expect-error - 函数通过动态导入在 context-data-sources.ts 中使用
  649. async function readCanonRules(pp: string): Promise<string> {
  650. try {
  651. const results = await searchWiki(pp, "canon 正史 rule 规则")
  652. if (results.length > 0) {
  653. const content = await readFile(results[0].path)
  654. return content.slice(0, 2000)
  655. }
  656. } catch {}
  657. return ""
  658. }
  659. // @ts-expect-error - 函数通过动态导入在 context-data-sources.ts 中使用
  660. async function readWritingStyle(pp: string): Promise<string> {
  661. // 优先:已启用的拆书作品文风预设(feature/book-style-extraction)。
  662. // buildWritingStyleContext 内部已做长度上限与"只学文风不借剧情"硬约束。
  663. try {
  664. const { buildWritingStyleContext } = await import("./writing-style-store")
  665. const styleContext = await buildWritingStyleContext(pp)
  666. if (styleContext.trim()) return styleContext
  667. } catch {}
  668. // 回退:wiki 中的风格页(旧行为)。
  669. try {
  670. const results = await searchWiki(pp, "style 风格 writing 写作")
  671. if (results.length > 0) {
  672. const content = await readFile(results[0].path)
  673. return content.slice(0, 1000)
  674. }
  675. } catch {}
  676. return ""
  677. }
  678. // @ts-expect-error - 函数通过动态导入在 context-data-sources.ts 中使用
  679. async function readVolumeContext(
  680. pp: string,
  681. chapterNumber: number | undefined,
  682. ): Promise<string> {
  683. if (!chapterNumber) return ""
  684. try {
  685. const volumes = await getChapterVolumes(pp, chapterNumber)
  686. if (volumes.length === 0) return ""
  687. return volumes
  688. .map(v => {
  689. const parts = [`第${v.volumeNumber}卷:${v.title}`]
  690. if (v.summary) parts.push(`概要:${v.summary}`)
  691. if (v.chapterRangeStart !== undefined && v.chapterRangeEnd !== undefined) {
  692. parts.push(`章节范围:第${v.chapterRangeStart}章 - 第${v.chapterRangeEnd}章`)
  693. }
  694. return parts.join("\n")
  695. })
  696. .join("\n\n")
  697. } catch {
  698. return ""
  699. }
  700. }
  701. export async function searchRelevantContent(
  702. pp: string,
  703. task: string,
  704. chapterNumber: number | undefined,
  705. limit: number,
  706. ): Promise<string> {
  707. const tokens = tokenizeQuery(task)
  708. const entityHints = tokens.filter(t => t.length >= 2).slice(0, 5)
  709. const queryParts = [task]
  710. if (chapterNumber) {
  711. queryParts.push(`第${chapterNumber}章`)
  712. }
  713. if (entityHints.length > 0) {
  714. queryParts.push(entityHints.join(" "), "伏笔", "人物", "设定", "时间线")
  715. } else {
  716. queryParts.push("伏笔", "人物", "设定")
  717. }
  718. const query = queryParts.join(" ")
  719. const [keywordResults, indexResults, vectorResults] = await Promise.all([
  720. searchWiki(pp, query).catch(() => []),
  721. searchWiki(pp, `关键词索引 向量索引 ${task}`).catch(() => []),
  722. runVectorSearchForContext(pp, query, limit).catch(() => []),
  723. ])
  724. const seen = new Set<string>()
  725. const merged: string[] = []
  726. const add = (title: string, snippet: string) => {
  727. const key = `${title}|${snippet.slice(0, 50)}`
  728. if (!seen.has(key)) {
  729. seen.add(key)
  730. merged.push(`- ${title}: ${snippet}`)
  731. }
  732. }
  733. for (const r of keywordResults.slice(0, limit)) {
  734. add(r.title, r.snippet ?? "")
  735. }
  736. for (const r of indexResults.slice(0, limit)) {
  737. add(r.title, r.snippet ?? "")
  738. }
  739. for (const r of vectorResults.slice(0, limit)) {
  740. add(r.title, r.snippet)
  741. }
  742. return merged.slice(0, Math.max(limit, limit * 2)).join("\n")
  743. }
  744. export async function searchRelevantContentUnified(
  745. pp: string,
  746. task: string,
  747. chapterNumber: number | undefined,
  748. limit: number,
  749. ): Promise<string> {
  750. const tokens = tokenizeQuery(task)
  751. const entityHints = tokens.filter((t) => t.length >= 2).slice(0, 5)
  752. const queryParts = [task]
  753. if (chapterNumber) {
  754. queryParts.push(`chapter ${chapterNumber}`)
  755. }
  756. if (entityHints.length > 0) {
  757. queryParts.push(entityHints.join(" "), "伏笔", "人物", "设定", "时间线")
  758. } else {
  759. queryParts.push("伏笔", "人物", "设定")
  760. }
  761. const query = queryParts.join(" ")
  762. const [semanticResults, indexResults, vectorResults] = await Promise.all([
  763. novelMixedSearch({
  764. projectPath: pp,
  765. query,
  766. chapterNumber,
  767. topK: Math.max(limit * 2, 6),
  768. authoritativeOnly: true,
  769. includeKeyword: true,
  770. includeVector: true,
  771. includeGraph: true,
  772. includeRecentChapters: true,
  773. includeCanon: true,
  774. }).catch(() => []),
  775. searchWiki(pp, `关键词索引 向量索引 ${task}`, {
  776. rerank: true,
  777. topK: Math.max(limit, 4),
  778. rerankPurpose: "用于补充剧情上下文中的索引和记忆条目。",
  779. }).catch(() => []),
  780. runVectorSearchForContext(pp, query, limit).catch(() => []),
  781. ])
  782. const candidates = [
  783. ...semanticResults.map((result) => ({
  784. id: `${result.type}:${result.path}`,
  785. path: result.path,
  786. title: result.title,
  787. snippet: result.snippet ?? "",
  788. source: result.type,
  789. })),
  790. ...indexResults.map((result) => ({
  791. id: `index:${result.path}`,
  792. path: result.path,
  793. title: result.title,
  794. snippet: result.snippet ?? "",
  795. source: "index",
  796. })),
  797. ...vectorResults.map((result, index) => ({
  798. id: `vector-context:${index}:${result.title}`,
  799. path: result.path,
  800. title: result.title,
  801. snippet: result.snippet,
  802. source: "vector_context",
  803. })),
  804. ].filter((item) => {
  805. const path = typeof (item as { path?: unknown }).path === "string"
  806. ? (item as { path?: string }).path ?? ""
  807. : ""
  808. const snippet = item.snippet ?? ""
  809. if (!path || isHistoricalProjectionSnippet(path, snippet)) return false
  810. return isAuthoritativeGenerationPath(path)
  811. })
  812. const reranked = await rerankCandidates(query, candidates, {
  813. topK: Math.max(limit * 2, limit),
  814. purpose: "用于构建小说写作上下文,优先保留最能支撑当前章节任务的记忆、设定、伏笔和正史约束。",
  815. }).catch(() => candidates)
  816. const merged: string[] = []
  817. const seen = new Set<string>()
  818. for (const result of reranked) {
  819. const key = `${result.title}|${result.snippet.slice(0, 50)}`
  820. if (seen.has(key)) continue
  821. seen.add(key)
  822. merged.push(`- ${result.title}: ${result.snippet}`)
  823. }
  824. return merged.slice(0, Math.max(limit * 2, limit)).join("\n")
  825. }
  826. async function runVectorSearchForContext(
  827. pp: string,
  828. query: string,
  829. limit: number,
  830. ): Promise<{ title: string; snippet: string; path: string }[]> {
  831. const embCfg = useWikiStore.getState().embeddingConfig
  832. if (!embCfg.enabled || !embCfg.model) return []
  833. try {
  834. const { searchByEmbedding } = await import("@/lib/embedding")
  835. const vectorResults = await searchByEmbedding(pp, query, embCfg, Math.max(limit * 2, 10))
  836. if (vectorResults.length === 0) return []
  837. const items: { title: string; snippet: string; path: string }[] = []
  838. const dirs = ["entities", "concepts", "sources", "synthesis", "comparison", "queries"]
  839. for (const vr of vectorResults.slice(0, limit)) {
  840. let found = false
  841. for (const dir of dirs) {
  842. const tryPath = `${pp}/wiki/${dir}/${vr.id}.md`
  843. try {
  844. const content = await readFile(tryPath)
  845. const title = content.match(/^#\s+(.+)/m)?.[1]?.trim()
  846. ?? content.match(/^---\ntitle:\s*(.+)/m)?.[1]?.trim()
  847. ?? vr.id
  848. items.push({ title, snippet: content.slice(0, 300).replace(/\n/g, " "), path: tryPath })
  849. found = true
  850. break
  851. } catch {}
  852. }
  853. if (!found) {
  854. const tryPath = `${pp}/wiki/${vr.id}.md`
  855. try {
  856. const content = await readFile(tryPath)
  857. items.push({ title: vr.id, snippet: content.slice(0, 300).replace(/\n/g, " "), path: tryPath })
  858. } catch {}
  859. }
  860. }
  861. return items
  862. } catch {
  863. return []
  864. }
  865. }
  866. export async function searchGraphRelevantContent(
  867. pp: string,
  868. task: string,
  869. _chapterNumber: number | undefined,
  870. ): Promise<string> {
  871. try {
  872. const { buildRetrievalGraph, getRelatedNodes } = await import("@/lib/graph-relevance")
  873. const graph = await buildRetrievalGraph(pp)
  874. if (graph.nodes.size === 0) return ""
  875. const tokens = tokenizeQuery(task)
  876. const candidateNames = new Set<string>()
  877. for (const token of tokens) {
  878. if (token.length >= 2) candidateNames.add(token)
  879. }
  880. for (const [, node] of graph.nodes) {
  881. if (task.includes(node.title) || task.includes(node.id)) {
  882. candidateNames.add(node.title)
  883. candidateNames.add(node.id)
  884. }
  885. for (const name of candidateNames) {
  886. if (node.title.includes(name) || node.id.includes(name)) {
  887. candidateNames.add(node.title)
  888. candidateNames.add(node.id)
  889. }
  890. }
  891. }
  892. const seenIds = new Set<string>()
  893. const scoredNodes: { title: string; snippet: string; relevance: number }[] = []
  894. for (const name of candidateNames) {
  895. const matchedNodes = Array.from(graph.nodes.values()).filter(
  896. n => n.title.includes(name) || n.id.includes(name),
  897. )
  898. for (const matchedNode of matchedNodes) {
  899. if (seenIds.has(matchedNode.id)) continue
  900. seenIds.add(matchedNode.id)
  901. const related = getRelatedNodes(matchedNode.id, graph, 5)
  902. for (const { node, relevance } of related) {
  903. if (seenIds.has(node.id)) continue
  904. seenIds.add(node.id)
  905. try {
  906. const content = await readFile(node.path)
  907. scoredNodes.push({
  908. title: node.title,
  909. snippet: content.slice(0, 300).replace(/\n/g, " "),
  910. relevance: Math.round(relevance * 100) / 100,
  911. })
  912. } catch {}
  913. }
  914. }
  915. }
  916. scoredNodes.sort((a, b) => b.relevance - a.relevance)
  917. const topNodes = await rerankCandidates(
  918. task,
  919. scoredNodes.slice(0, 10).map((node, index) => ({
  920. id: `graph:${index}:${node.title}`,
  921. title: node.title,
  922. snippet: node.snippet,
  923. source: "graph_context",
  924. relevance: node.relevance,
  925. })),
  926. {
  927. topK: 10,
  928. purpose: "用于补充图谱关联上下文,优先保留和当前任务最直接相关的关联节点。",
  929. },
  930. ).catch(() => scoredNodes.slice(0, 10))
  931. const nodeResults = topNodes.length > 0
  932. ? topNodes.map(
  933. n => `- 【${n.title}】(关联度 ${n.relevance}): ${n.snippet}`,
  934. ).join("\n")
  935. : ""
  936. // 追加社区摘要向量检索
  937. let communityResults = ""
  938. try {
  939. const { searchCommunitySummaries } = await import("./community-summary")
  940. communityResults = await searchCommunitySummaries(pp, task, 3)
  941. } catch {
  942. // 社区摘要检索失败不影响主流程
  943. }
  944. return [nodeResults, communityResults].filter(Boolean).join("\n")
  945. } catch {
  946. return ""
  947. }
  948. }
  949. export function extractChapterGoal(outline: string, chapterNumber?: number): string {
  950. if (!chapterNumber || !outline) return ""
  951. const cleaned = outline.replace(/^---[\s\S]*?---\s*/m, "").trim()
  952. for (const line of cleaned.split(/\r?\n/)) {
  953. const trimmed = line.trim()
  954. if (!trimmed) continue
  955. const compact = trimmed.replace(/\s+/g, "")
  956. for (const label of chapterLabels(chapterNumber)) {
  957. if (compact.includes(label)) {
  958. const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
  959. const rest = trimmed.replace(new RegExp(`^#*\\s*${escapedLabel}[::、\\s-]*`), "").trim()
  960. return (rest || cleaned).slice(0, 2500)
  961. }
  962. }
  963. const englishMatch = trimmed.match(new RegExp(`^#*\\s*Chapter\\s*${chapterNumber}[::\\s-]*(.+)?$`, "i"))
  964. if (englishMatch) {
  965. return ((englishMatch[1] ?? "").trim() || cleaned).slice(0, 2500)
  966. }
  967. }
  968. if (includesChapterMarker(cleaned, chapterNumber)) return cleaned.slice(0, 2500)
  969. return ""
  970. }
  971. interface FieldConfig {
  972. titleKey: string
  973. fieldKey: keyof ContextPack
  974. }
  975. const FIELD_CONFIGS: FieldConfig[] = [
  976. { titleKey: "novel.contextPack.sectionBriefing", fieldKey: "sectionBriefing" },
  977. { titleKey: "novel.contextPack.currentChapterGoal", fieldKey: "chapterGoal" },
  978. { titleKey: "novel.contextPack.mustDo.title", fieldKey: "mustDo" },
  979. { titleKey: "novel.contextPack.mustAvoid.title", fieldKey: "mustAvoid" },
  980. { titleKey: "novel.contextPack.nextChapterAdvice.title", fieldKey: "nextChapterAdvice" },
  981. { titleKey: "novel.contextPack.soulDoc", fieldKey: "soulDoc" },
  982. { titleKey: "novel.contextPack.recentRevisionDirectives", fieldKey: "revisionDirectives" },
  983. { titleKey: "novel.contextPack.requiredOutline", fieldKey: "outline" },
  984. { titleKey: "novel.contextPack.recentChapterContents", fieldKey: "recentChapterContents" },
  985. { titleKey: "novel.contextPack.recentPlotSummaries", fieldKey: "recentSummaries" },
  986. { titleKey: "novel.contextPack.previousChapterEnding", fieldKey: "previousChapterEnding" },
  987. { titleKey: "novel.contextPack.characterStates", fieldKey: "characterStates" },
  988. { titleKey: "novel.contextPack.characterAuras", fieldKey: "characterAuras" },
  989. { titleKey: "novel.contextPack.cognitionStates", fieldKey: "cognitionStates" },
  990. { titleKey: "novel.contextPack.foreshadowingStates", fieldKey: "foreshadowingStates" },
  991. { titleKey: "novel.contextPack.timeline", fieldKey: "timeline" },
  992. { titleKey: "novel.contextPack.relatedSettings", fieldKey: "relatedSettings" },
  993. { titleKey: "novel.contextPack.canonRules", fieldKey: "canonRules" },
  994. { titleKey: "novel.contextPack.writingStyle", fieldKey: "writingStyle" },
  995. { titleKey: "novel.contextPack.searchResults", fieldKey: "searchResults" },
  996. { titleKey: "novel.contextPack.graphSearchResults", fieldKey: "graphSearchResults" },
  997. ]
  998. export function contextPackToPrompt(
  999. pack: ContextPack,
  1000. tokenBudget?: number,
  1001. options?: { excludeOutline?: boolean; maxContextSize?: number },
  1002. ): string {
  1003. const result = trimContextPack(pack, tokenBudget, options)
  1004. return result.prompt
  1005. }
  1006. function trimFieldContent(content: string | string[], maxChars: number): string | string[] {
  1007. if (Array.isArray(content)) {
  1008. if (content.length === 0) return content
  1009. const result: string[] = []
  1010. let total = 0
  1011. for (let i = content.length - 1; i >= 0; i--) {
  1012. const item = content[i]
  1013. if (total + item.length <= maxChars) {
  1014. result.unshift(item)
  1015. total += item.length
  1016. } else {
  1017. break
  1018. }
  1019. }
  1020. if (result.length === 0 && content.length > 0) {
  1021. const last = content[content.length - 1]
  1022. return [last.slice(0, maxChars) + "..."]
  1023. }
  1024. return result
  1025. } else {
  1026. if (content.length <= maxChars) return content
  1027. if (maxChars < 50) return content.slice(0, maxChars) + "..."
  1028. const headChars = Math.floor(maxChars * 0.4)
  1029. const tailChars = maxChars - headChars - 5
  1030. return content.slice(0, headChars) + "\n...\n" + content.slice(-tailChars)
  1031. }
  1032. }
  1033. export function trimContextPack(
  1034. pack: ContextPack,
  1035. tokenBudget?: number,
  1036. options?: { excludeOutline?: boolean; maxContextSize?: number }
  1037. ): TrimResult {
  1038. const sections: string[] = []
  1039. sections.push(i18n.t("novel.contextPack.title"))
  1040. sections.push("")
  1041. sections.push(i18n.t("novel.contextPack.currentTask"))
  1042. sections.push(pack.task)
  1043. sections.push("")
  1044. const fieldData: { fieldKey: string; title: string; content: string | string[]; priority: number; charCount: number }[] = []
  1045. for (const config of FIELD_CONFIGS) {
  1046. if (options?.excludeOutline && config.fieldKey === "outline") {
  1047. continue
  1048. }
  1049. const rawContent = pack[config.fieldKey as keyof ContextPack] as string | string[] | undefined
  1050. const content = Array.isArray(rawContent) ? rawContent : rawContent ?? ""
  1051. const hasContent = Array.isArray(content) ? content.length > 0 : Boolean(content)
  1052. if (!hasContent) continue
  1053. const charCount = Array.isArray(content)
  1054. ? content.reduce((sum, item) => sum + item.length, 0)
  1055. : content.length
  1056. fieldData.push({
  1057. fieldKey: config.fieldKey,
  1058. title: i18n.t(config.titleKey),
  1059. content,
  1060. priority: FIELD_PRIORITY[config.fieldKey] ?? 999,
  1061. charCount,
  1062. })
  1063. }
  1064. fieldData.sort((a, b) => a.priority - b.priority)
  1065. const headerChars = sections.join("\n").length + 2
  1066. let totalChars = headerChars + fieldData.reduce((sum, f) => sum + f.charCount + f.title.length + 3, 0)
  1067. const originalChars = totalChars
  1068. const trimmedFields: string[] = []
  1069. const resolvedTokenBudget = tokenBudget && tokenBudget > 0
  1070. ? tokenBudget
  1071. : resolveContextPackTokenBudget({ maxContextSize: options?.maxContextSize })
  1072. const targetChars = resolvedTokenBudget * 4
  1073. if (totalChars <= targetChars) {
  1074. for (const { title, content } of fieldData) {
  1075. sections.push(title)
  1076. if (Array.isArray(content)) {
  1077. content.forEach(item => sections.push(item))
  1078. } else {
  1079. sections.push(content)
  1080. }
  1081. sections.push("")
  1082. }
  1083. return {
  1084. prompt: sections.join("\n"),
  1085. trimmedFields: [],
  1086. trimmedChars: 0,
  1087. originalChars,
  1088. finalChars: originalChars,
  1089. }
  1090. }
  1091. const sortedByPriorityAsc = [...fieldData].sort((a, b) => a.priority - b.priority)
  1092. let accumulatedChars = headerChars
  1093. let keepCount = 0
  1094. for (let i = 0; i < sortedByPriorityAsc.length; i++) {
  1095. const field = sortedByPriorityAsc[i]
  1096. const fieldTotalChars = field.charCount + field.title.length + 3
  1097. if (accumulatedChars + fieldTotalChars <= targetChars) {
  1098. accumulatedChars += fieldTotalChars
  1099. keepCount = i + 1
  1100. } else {
  1101. break
  1102. }
  1103. }
  1104. for (let i = keepCount; i < sortedByPriorityAsc.length; i++) {
  1105. trimmedFields.push(sortedByPriorityAsc[i].fieldKey)
  1106. totalChars -= sortedByPriorityAsc[i].charCount + sortedByPriorityAsc[i].title.length + 3
  1107. }
  1108. let partiallyTrimmed: { fieldKey: string; originalChars: number; keptChars: number } | null = null
  1109. if (keepCount < sortedByPriorityAsc.length) {
  1110. const nextField = sortedByPriorityAsc[keepCount]
  1111. const remainingBudget = targetChars - accumulatedChars
  1112. const minKeepChars = 100
  1113. const targetContentChars = remainingBudget - nextField.title.length - 3
  1114. const originalFieldChars = nextField.charCount
  1115. if (targetContentChars > minKeepChars && nextField.charCount > targetContentChars) {
  1116. const trimmedContent = trimFieldContent(nextField.content, targetContentChars)
  1117. const keptContentChars = Array.isArray(trimmedContent)
  1118. ? trimmedContent.reduce((sum, item) => sum + item.length, 0)
  1119. : trimmedContent.length
  1120. if (keptContentChars > 0) {
  1121. nextField.content = trimmedContent
  1122. nextField.charCount = keptContentChars
  1123. totalChars = accumulatedChars + keptContentChars + nextField.title.length + 3
  1124. partiallyTrimmed = {
  1125. fieldKey: nextField.fieldKey,
  1126. originalChars: originalFieldChars,
  1127. keptChars: keptContentChars,
  1128. }
  1129. const idx = trimmedFields.indexOf(nextField.fieldKey)
  1130. if (idx > -1) trimmedFields.splice(idx, 1)
  1131. keepCount++
  1132. }
  1133. }
  1134. }
  1135. const keptFields = sortedByPriorityAsc.slice(0, keepCount)
  1136. for (const { title, content } of keptFields) {
  1137. sections.push(title)
  1138. if (Array.isArray(content)) {
  1139. content.forEach(item => sections.push(item))
  1140. } else {
  1141. sections.push(content)
  1142. }
  1143. sections.push("")
  1144. }
  1145. const trimmedChars = originalChars - totalChars
  1146. if (trimmedFields.length > 0) {
  1147. sections.push(`[...已裁剪 ${trimmedFields.length} 个低优先级上下文字段,约 ${trimmedChars} 字符...]`)
  1148. sections.push("")
  1149. }
  1150. return {
  1151. prompt: sections.join("\n"),
  1152. trimmedFields,
  1153. partiallyTrimmedField: partiallyTrimmed ?? undefined,
  1154. trimmedChars,
  1155. originalChars,
  1156. finalChars: totalChars,
  1157. }
  1158. }