source-lifecycle.ts 10 KB

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