source-lifecycle.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. import {
  2. copyDirectory,
  3. copyFile,
  4. deleteFile,
  5. fileExists,
  6. listDirectory,
  7. preprocessFile,
  8. readFile,
  9. writeFile,
  10. } from "@/commands/fs"
  11. import type { WikiProject, FileNode } from "@/types/wiki"
  12. import type { LlmConfig } from "@/stores/wiki-store"
  13. import { useWikiStore } from "@/stores/wiki-store"
  14. import { enqueueBatch } from "@/lib/ingest-queue"
  15. import { hasUsableLlm } from "@/lib/has-usable-llm"
  16. import { getFileName, getFileStem, normalizePath } from "@/lib/path-utils"
  17. import {
  18. parseFrontmatterArray,
  19. parseSources,
  20. writeFrontmatterArray,
  21. writeSources,
  22. } from "@/lib/sources-merge"
  23. import { removeFromIngestCache } from "@/lib/ingest-cache"
  24. import { removePageEmbedding } from "@/lib/embedding"
  25. import {
  26. buildDeletedKeys,
  27. cleanIndexListing,
  28. normalizeWikiRefKey,
  29. stripDeletedWikilinks,
  30. } from "@/lib/wiki-cleanup"
  31. import { collectAllFilesIncludingDot } from "@/lib/sources-tree-delete"
  32. export const INGESTABLE_SOURCE_EXTENSIONS = new Set([
  33. "md",
  34. "mdx",
  35. "txt",
  36. "pdf",
  37. "docx",
  38. "pptx",
  39. "xlsx",
  40. "odt",
  41. "odp",
  42. "ods",
  43. "xls",
  44. "csv",
  45. "json",
  46. "html",
  47. "htm",
  48. "rtf",
  49. "xml",
  50. "yaml",
  51. "yml",
  52. ])
  53. export interface DeleteSourceResult {
  54. deletedWikiPaths: string[]
  55. rewrittenSourcePages: number
  56. }
  57. export interface DeleteSourceFolderResult {
  58. deletedWikiPaths: string[]
  59. }
  60. export interface DeleteSourcesResult {
  61. deletedWikiPaths: string[]
  62. rewrittenSourcePages: number
  63. skippedPages: number
  64. }
  65. export interface SourceImportOptions {
  66. autoExtract?: boolean
  67. }
  68. export interface SourceImportResult {
  69. importedPaths: string[]
  70. taskIdsByPath: Record<string, string[]>
  71. }
  72. export function isIngestableSourcePath(path: string): boolean {
  73. const normalized = normalizePath(path)
  74. if (normalized.split("/").includes(".cache")) return false
  75. const fileName = normalized.split("/").pop() ?? ""
  76. if (!fileName || fileName.startsWith(".")) return false
  77. const ext = fileName.includes(".") ? fileName.split(".").pop()?.toLowerCase() : ""
  78. return ext ? INGESTABLE_SOURCE_EXTENSIONS.has(ext) : false
  79. }
  80. export function folderContextForSourcePath(sourcePath: string, sourcesRoot = "raw/sources"): string {
  81. const path = normalizePath(sourcePath)
  82. const root = normalizePath(sourcesRoot)
  83. const rawMarker = "/raw/sources/"
  84. const rel = path.startsWith(`${root}/`)
  85. ? path.slice(root.length + 1)
  86. : path.includes(rawMarker)
  87. ? path.slice(path.indexOf(rawMarker) + rawMarker.length)
  88. : path
  89. const parts = rel.split("/")
  90. parts.pop()
  91. return parts.join(" > ")
  92. }
  93. export async function enqueueSourceIngest(
  94. project: WikiProject,
  95. sourcePaths: string[],
  96. llmConfig: LlmConfig,
  97. options: { sourceRoot?: string; rootContext?: string } = {},
  98. ): Promise<string[]> {
  99. if (!hasUsableLlm(llmConfig, useWikiStore.getState().providerConfigs)) return []
  100. const files = sourcePaths
  101. .filter(isIngestableSourcePath)
  102. .map((sourcePath) => ({
  103. sourcePath,
  104. folderContext: withRootContext(
  105. folderContextForSourcePath(sourcePath, options.sourceRoot),
  106. options.rootContext,
  107. ),
  108. }))
  109. if (files.length === 0) return []
  110. return enqueueBatch(project.id, files)
  111. }
  112. export async function importSourceFiles(
  113. project: WikiProject,
  114. sourcePaths: string[],
  115. llmConfig: LlmConfig,
  116. options: SourceImportOptions = {},
  117. ): Promise<SourceImportResult> {
  118. const pp = normalizePath(project.path)
  119. const importedPaths: string[] = []
  120. for (const sourcePath of sourcePaths) {
  121. const originalName = getFileName(sourcePath) || "unknown"
  122. const destPath = await getUniqueDestPath(`${pp}/raw/sources`, originalName)
  123. try {
  124. await copyFile(sourcePath, destPath)
  125. importedPaths.push(destPath)
  126. preprocessFile(destPath).catch(() => {})
  127. } catch (err) {
  128. console.error(`Failed to import ${originalName}:`, err)
  129. }
  130. }
  131. const taskIdsByPath = options.autoExtract === false
  132. ? {}
  133. : await enqueueSourceIngestWithTaskMap(project, importedPaths, llmConfig)
  134. return { importedPaths, taskIdsByPath }
  135. }
  136. export async function importSourceFolder(
  137. project: WikiProject,
  138. selectedFolder: string,
  139. llmConfig: LlmConfig,
  140. options: SourceImportOptions = {},
  141. ): Promise<SourceImportResult> {
  142. const pp = normalizePath(project.path)
  143. const folderName = getFileName(selectedFolder) || "imported"
  144. const destDir = `${pp}/raw/sources/${folderName}`
  145. const copiedFiles = await copyDirectory(selectedFolder, destDir)
  146. for (const filePath of copiedFiles) {
  147. preprocessFile(filePath).catch(() => {})
  148. }
  149. const taskIdsByPath = options.autoExtract === false
  150. ? {}
  151. : await enqueueSourceIngestWithTaskMap(project, copiedFiles, llmConfig, {
  152. sourceRoot: destDir,
  153. rootContext: folderName,
  154. })
  155. return { importedPaths: copiedFiles, taskIdsByPath }
  156. }
  157. export async function deleteSourceFile(
  158. projectPath: string,
  159. sourcePath: string,
  160. options: { fileAlreadyDeleted?: boolean; logReason?: string } = {},
  161. ): Promise<DeleteSourceResult> {
  162. const result = await deleteSourceFiles(projectPath, [sourcePath], options)
  163. return {
  164. deletedWikiPaths: result.deletedWikiPaths,
  165. rewrittenSourcePages: result.rewrittenSourcePages,
  166. }
  167. }
  168. export async function deleteSourceFiles(
  169. projectPath: string,
  170. sourcePaths: string[],
  171. options: { fileAlreadyDeleted?: boolean; logReason?: string } = {},
  172. ): Promise<DeleteSourcesResult> {
  173. const pp = normalizePath(projectPath)
  174. const normalizedSources = sourcePaths.map(normalizePath)
  175. const fileNames = normalizedSources
  176. .map((source) => source.split("/").pop() ?? "")
  177. .filter(Boolean)
  178. if (fileNames.length === 0) {
  179. return { deletedWikiPaths: [], rewrittenSourcePages: 0, skippedPages: 0 }
  180. }
  181. const deletingNames = new Set(fileNames.map((name) => name.toLowerCase()))
  182. if (!options.fileAlreadyDeleted) {
  183. for (const source of normalizedSources) {
  184. await deleteFile(source)
  185. }
  186. }
  187. for (const fileName of fileNames) {
  188. try {
  189. await deleteFile(`${pp}/raw/sources/.cache/${fileName}.txt`)
  190. } catch {
  191. // cache file may not exist
  192. }
  193. try {
  194. await removeFromIngestCache(pp, fileName)
  195. } catch {
  196. // non-critical
  197. }
  198. }
  199. const pagesToDelete: string[] = []
  200. let rewrittenSourcePages = 0
  201. let skippedPages = 0
  202. let allMd: FileNode[] = []
  203. try {
  204. allMd = flattenMd(await listDirectory(`${pp}/wiki`))
  205. } catch (err) {
  206. console.warn("[source-lifecycle] 删除期间扫描 wiki 源文件失败:", err)
  207. }
  208. for (const file of allMd) {
  209. let content: string
  210. try {
  211. content = await readFile(file.path)
  212. } catch (err) {
  213. console.warn(`[source-lifecycle] 读取 ${file.path} 失败:`, err)
  214. continue
  215. }
  216. const sources = parseSources(content)
  217. if (sources.length === 0) {
  218. skippedPages++
  219. continue
  220. }
  221. const survivors = sources.filter((source) => !sourceNameMatchesAny(source, deletingNames))
  222. if (survivors.length === sources.length) {
  223. continue
  224. }
  225. if (survivors.length === 0) {
  226. pagesToDelete.push(file.path)
  227. } else {
  228. try {
  229. await writeFile(file.path, writeSources(content, survivors))
  230. rewrittenSourcePages++
  231. } catch (err) {
  232. console.warn(`[source-lifecycle] 重写 ${file.path} 的源引用失败:`, err)
  233. }
  234. }
  235. }
  236. let deletedWikiPaths: string[] = []
  237. if (pagesToDelete.length > 0) {
  238. const { cascadeDeleteWikiPagesWithRefs } = await import("@/lib/wiki-page-delete")
  239. const result = await cascadeDeleteWikiPagesWithRefs(pp, pagesToDelete)
  240. deletedWikiPaths = result.deletedPaths
  241. }
  242. await appendSourceDeleteLog(pp, fileNames, {
  243. reason: options.logReason ?? (options.fileAlreadyDeleted ? "外部删除" : "删除"),
  244. deletedWikiCount: deletedWikiPaths.length,
  245. keptWikiCount: rewrittenSourcePages,
  246. })
  247. if (skippedPages > 0) {
  248. console.debug(
  249. `[source-lifecycle] 删除 ${fileNames.length} 个源文件时,跳过 ${skippedPages} 个无可解析来源的 wiki 页面`,
  250. )
  251. }
  252. return { deletedWikiPaths, rewrittenSourcePages, skippedPages }
  253. }
  254. export async function deleteSourceFolder(
  255. projectPath: string,
  256. folder: FileNode,
  257. options: { folderAlreadyDeleted?: boolean } = {},
  258. ): Promise<DeleteSourceFolderResult> {
  259. const deletedWikiPaths: string[] = []
  260. const files = collectAllFilesIncludingDot(folder).map((file) => file.path)
  261. if (files.length > 0) {
  262. const result = await deleteSourceFiles(projectPath, files, {
  263. fileAlreadyDeleted: options.folderAlreadyDeleted,
  264. logReason: options.folderAlreadyDeleted ? "外部文件夹删除" : "文件夹删除",
  265. })
  266. deletedWikiPaths.push(...result.deletedWikiPaths)
  267. }
  268. if (!options.folderAlreadyDeleted) {
  269. try {
  270. await deleteFile(folder.path)
  271. } catch (err) {
  272. console.warn(`删除文件夹 ${folder.path} 失败:`, err)
  273. }
  274. }
  275. return { deletedWikiPaths }
  276. }
  277. export async function cleanupDeletedWikiPages(
  278. projectPath: string,
  279. relativePaths: string[],
  280. ): Promise<void> {
  281. const pp = normalizePath(projectPath)
  282. const deletedInfos = relativePaths
  283. .map((path) => ({ slug: getFileStem(path), title: "" }))
  284. .filter((info) => info.slug.length > 0 && !info.slug.startsWith("."))
  285. if (deletedInfos.length === 0) return
  286. for (const info of deletedInfos) {
  287. await removePageEmbedding(pp, info.slug)
  288. try {
  289. await deleteFile(`${pp}/wiki/media/${info.slug}`)
  290. } catch {
  291. // only source-summary pages usually own media; absence is normal
  292. }
  293. }
  294. const deletedKeys = buildDeletedKeys(deletedInfos)
  295. const wikiTree = await listDirectory(`${pp}/wiki`)
  296. const allMd = flattenMd(wikiTree)
  297. for (const file of allMd) {
  298. let content: string
  299. try {
  300. content = await readFile(file.path)
  301. } catch {
  302. continue
  303. }
  304. let updated = content
  305. if (file.path === `${pp}/wiki/index.md` || file.name === "index.md") {
  306. updated = cleanIndexListing(updated, deletedKeys)
  307. }
  308. updated = stripDeletedWikilinks(updated, deletedKeys)
  309. const related = parseFrontmatterArray(updated, "related")
  310. if (related.length > 0) {
  311. const filtered = related.filter((s) => !deletedKeys.has(normalizeWikiRefKey(s)))
  312. if (filtered.length !== related.length) {
  313. updated = writeFrontmatterArray(updated, "related", filtered)
  314. }
  315. }
  316. if (updated !== content) {
  317. try {
  318. await writeFile(file.path, updated)
  319. } catch (err) {
  320. console.warn(`[source-lifecycle] 重写 ${file.path} 失败:`, err)
  321. }
  322. }
  323. }
  324. }
  325. async function getUniqueDestPath(dir: string, fileName: string): Promise<string> {
  326. const basePath = `${dir}/${fileName}`
  327. if (!(await fileExists(basePath))) {
  328. return basePath
  329. }
  330. const ext = fileName.includes(".") ? fileName.slice(fileName.lastIndexOf(".")) : ""
  331. const nameWithoutExt = ext ? fileName.slice(0, -ext.length) : fileName
  332. const date = new Date().toISOString().slice(0, 10).replace(/-/g, "")
  333. const withDate = `${dir}/${nameWithoutExt}-${date}${ext}`
  334. if (!(await fileExists(withDate))) {
  335. return withDate
  336. }
  337. for (let i = 2; i <= 99; i++) {
  338. const withCounter = `${dir}/${nameWithoutExt}-${date}-${i}${ext}`
  339. if (!(await fileExists(withCounter))) {
  340. return withCounter
  341. }
  342. }
  343. return `${dir}/${nameWithoutExt}-${date}-${Date.now()}${ext}`
  344. }
  345. async function appendSourceDeleteLog(
  346. projectPath: string,
  347. fileNames: string | string[],
  348. detail: { reason: string; deletedWikiCount: number; keptWikiCount: number },
  349. ): Promise<void> {
  350. try {
  351. const names = Array.isArray(fileNames) ? fileNames : [fileNames]
  352. const logPath = `${projectPath}/wiki/log.md`
  353. const logContent = await readFile(logPath).catch(() => "# Wiki Log\n")
  354. const date = new Date().toISOString().slice(0, 10)
  355. const subject = names.length === 1 ? names[0] : `${names.length} 个源文件`
  356. const listed = names.length === 1 ? "" : `\n\n源文件:\n${names.map((name) => `- ${name}`).join("\n")}`
  357. const logEntry = `\n## [${date}] ${detail.reason} | ${subject}\n\n已删除 ${names.length} 个源文件和 ${detail.deletedWikiCount} 个知识页面。${detail.keptWikiCount > 0 ? ` ${detail.keptWikiCount} 个共享页面已保留(有其他源文件引用)。` : ""}${listed}\n`
  358. await writeFile(logPath, logContent.trimEnd() + logEntry)
  359. } catch (err) {
  360. console.warn("[source-lifecycle] 追加删除日志失败:", err)
  361. }
  362. }
  363. function flattenMd(nodes: readonly FileNode[]): FileNode[] {
  364. const out: FileNode[] = []
  365. function walk(items: readonly FileNode[]): void {
  366. for (const item of items) {
  367. if (item.is_dir) {
  368. if (item.children) walk(item.children)
  369. } else if (item.name.endsWith(".md")) {
  370. out.push(item)
  371. }
  372. }
  373. }
  374. walk(nodes)
  375. return out
  376. }
  377. function sourceNameMatchesAny(source: string, deletingNames: Set<string>): boolean {
  378. const normalizedSource = normalizePath(source).split("/").pop()?.toLowerCase() ?? ""
  379. return deletingNames.has(normalizedSource)
  380. }
  381. function withRootContext(context: string, rootContext?: string): string {
  382. if (!rootContext) return context
  383. if (!context) return rootContext
  384. return `${rootContext} > ${context}`
  385. }
  386. async function enqueueSourceIngestWithTaskMap(
  387. project: WikiProject,
  388. sourcePaths: string[],
  389. llmConfig: LlmConfig,
  390. options: { sourceRoot?: string; rootContext?: string } = {},
  391. ): Promise<Record<string, string[]>> {
  392. const taskIds = await enqueueSourceIngest(project, sourcePaths, llmConfig, options)
  393. const ingestablePaths = sourcePaths.filter(isIngestableSourcePath).map(normalizePath)
  394. const taskIdsByPath: Record<string, string[]> = {}
  395. for (const [index, sourcePath] of ingestablePaths.entries()) {
  396. const taskId = taskIds[index]
  397. if (!taskId) continue
  398. taskIdsByPath[sourcePath] = [...(taskIdsByPath[sourcePath] ?? []), taskId]
  399. }
  400. return taskIdsByPath
  401. }