maintenance-section.tsx 48 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381
  1. import { useCallback, useEffect, useMemo, useState } from "react"
  2. import { useTranslation } from "react-i18next"
  3. import {
  4. Wrench,
  5. Loader2,
  6. AlertTriangle,
  7. CheckCircle2,
  8. XCircle,
  9. Trash2,
  10. RotateCcw,
  11. Clock,
  12. HelpCircle,
  13. ChevronDown,
  14. ChevronUp,
  15. BookOpen,
  16. Users,
  17. Lightbulb,
  18. } from "lucide-react"
  19. import { Button } from "@/components/ui/button"
  20. import { Label } from "@/components/ui/label"
  21. import { ChatModelSelector } from "@/components/chat/chat-model-selector"
  22. import { useWikiStore } from "@/stores/wiki-store"
  23. import { getFirstAvailableModelKey } from "@/lib/llm-model-keys"
  24. import { hasUsableLlm } from "@/lib/has-usable-llm"
  25. import { normalizePath } from "@/lib/path-utils"
  26. import { resolveDefaultModel, resolveModelConfig } from "@/lib/novel/model-resolver"
  27. import { runDuplicateDetection, type DedupScanStage } from "@/lib/dedup-runner"
  28. import { addNotDuplicate } from "@/lib/dedup-storage"
  29. import {
  30. loadDedupScanCache,
  31. saveDedupScanCache,
  32. type DedupScanCacheEntry,
  33. } from "@/lib/dedup-scan-cache"
  34. import {
  35. loadDedupModelPrefs,
  36. saveDedupModelPrefs,
  37. } from "@/lib/dedup-model-prefs"
  38. import { registerDedupScanSessionApi } from "@/lib/dedup-scan-session"
  39. import { toast } from "@/lib/toast"
  40. import { fileExists } from "@/commands/fs"
  41. import {
  42. enqueueMerge,
  43. cancelTask,
  44. retryTask,
  45. getQueue,
  46. getMergeProgress,
  47. getMergeLogs,
  48. groupKey,
  49. ensureQueueActive,
  50. onDedupMergeComplete,
  51. type DedupTask,
  52. } from "@/lib/dedup-queue"
  53. import type { DedupMergeStage } from "@/lib/dedup-runner"
  54. import type { WikiProject } from "@/types/wiki"
  55. import type { DuplicateGroup } from "@/lib/dedup"
  56. function confidenceRank(confidence: DuplicateGroup["confidence"]): number {
  57. switch (confidence) {
  58. case "high":
  59. return 0
  60. case "medium":
  61. return 1
  62. case "low":
  63. return 2
  64. }
  65. }
  66. function sortGroupEntriesByConfidence(entries: GroupUiEntry[]): GroupUiEntry[] {
  67. return [...entries].sort(
  68. (a, b) =>
  69. confidenceRank(a.group.confidence) - confidenceRank(b.group.confidence),
  70. )
  71. }
  72. interface GroupUiEntry {
  73. group: DuplicateGroup
  74. canonicalSlug: string
  75. /** Becomes true when the user marks the group as "not duplicates"
  76. * in this session — the card transitions to skipped state. */
  77. skipped: boolean
  78. }
  79. interface MaintenanceScanState {
  80. projectId: string | null
  81. projectPath: string | null
  82. scanning: boolean
  83. scanError: string | null
  84. groups: GroupUiEntry[]
  85. scanCompleted: boolean
  86. scannedPageCount: number | null
  87. }
  88. const emptyScanState: MaintenanceScanState = {
  89. projectId: null,
  90. projectPath: null,
  91. scanning: false,
  92. scanError: null,
  93. groups: [],
  94. scanCompleted: false,
  95. scannedPageCount: null,
  96. }
  97. function normalizeProjectPath(path: string): string {
  98. return normalizePath(path).replace(/\/+$/, "")
  99. }
  100. function scanStateBelongsToProject(
  101. state: Pick<MaintenanceScanState, "projectId" | "projectPath">,
  102. project: WikiProject,
  103. ): boolean {
  104. if (state.projectId && state.projectId === project.id) return true
  105. if (!state.projectPath) return false
  106. return normalizeProjectPath(state.projectPath) === normalizeProjectPath(project.path)
  107. }
  108. function setSharedScanState(patch: Partial<MaintenanceScanState>): void {
  109. const normalizedPatch = patch.projectPath
  110. ? { ...patch, projectPath: normalizeProjectPath(patch.projectPath) }
  111. : patch
  112. sharedScanState = { ...sharedScanState, ...normalizedPatch }
  113. for (const listener of scanListeners) listener(sharedScanState)
  114. }
  115. function replaceSharedScanState(state: MaintenanceScanState): void {
  116. sharedScanState = {
  117. ...state,
  118. projectPath: state.projectPath ? normalizeProjectPath(state.projectPath) : state.projectPath,
  119. }
  120. for (const listener of scanListeners) listener(sharedScanState)
  121. }
  122. function toScanCache(
  123. state: MaintenanceScanState,
  124. projectId: string,
  125. detectModelId: string,
  126. mergeModelId: string,
  127. ): Parameters<typeof saveDedupScanCache>[1] {
  128. return {
  129. version: 1,
  130. projectId,
  131. scannedAt: Date.now(),
  132. scannedPageCount: state.scannedPageCount,
  133. modelId: detectModelId.trim() || undefined,
  134. mergeModelId: mergeModelId.trim() || undefined,
  135. groups: state.groups as DedupScanCacheEntry[],
  136. }
  137. }
  138. let sharedScanState: MaintenanceScanState = emptyScanState
  139. const scanListeners = new Set<(state: MaintenanceScanState) => void>()
  140. function subscribeScanState(listener: (state: MaintenanceScanState) => void): () => void {
  141. scanListeners.add(listener)
  142. listener(sharedScanState)
  143. return () => scanListeners.delete(listener)
  144. }
  145. /** Match a card to its task in the queue (if any) by slug-set. */
  146. function findTaskForGroup(
  147. tasks: readonly DedupTask[],
  148. slugs: readonly string[],
  149. ): DedupTask | undefined {
  150. const key = groupKey(slugs)
  151. return tasks.find((t) => groupKey(t.group.slugs) === key)
  152. }
  153. export function MaintenanceSection() {
  154. const { t } = useTranslation()
  155. const llmConfig = useWikiStore((s) => s.llmConfig)
  156. const providerConfigs = useWikiStore((s) => s.providerConfigs)
  157. const novelConfig = useWikiStore((s) => s.novelConfig)
  158. const defaultLlmModel = novelConfig.defaultLlmModel
  159. const aiChatModel = useWikiStore((s) => s.aiChatModel)
  160. const project = useWikiStore((s) => s.project)
  161. const [detectModelId, setDetectModelId] = useState("")
  162. const [mergeModelId, setMergeModelId] = useState("")
  163. const [modelsHydrated, setModelsHydrated] = useState(false)
  164. const [isScanning, setIsScanning] = useState(false)
  165. const [scanStage, setScanStage] = useState<DedupScanStage | null>(null)
  166. const [scanLogs, setScanLogs] = useState<string[]>([])
  167. const [scanState, setScanState] = useState<MaintenanceScanState>(sharedScanState)
  168. const [localScanState, setLocalScanState] = useState<MaintenanceScanState | null>(null)
  169. useEffect(() => subscribeScanState(setScanState), [])
  170. useEffect(() => {
  171. if (!project) {
  172. setLocalScanState(null)
  173. setModelsHydrated(false)
  174. return
  175. }
  176. let cancelled = false
  177. setModelsHydrated(false)
  178. void (async () => {
  179. const [cached, prefs] = await Promise.all([
  180. loadDedupScanCache(project.path),
  181. loadDedupModelPrefs(project.path),
  182. ])
  183. if (cancelled) return
  184. const detectFromDisk =
  185. prefs?.detectModelId?.trim() ||
  186. cached?.modelId?.trim() ||
  187. ""
  188. const mergeFromDisk =
  189. prefs?.mergeModelId?.trim() ||
  190. cached?.mergeModelId?.trim() ||
  191. ""
  192. // Prefer disk prefs on project open (overwrite empty / stale session).
  193. if (detectFromDisk) setDetectModelId(detectFromDisk)
  194. if (mergeFromDisk) setMergeModelId(mergeFromDisk)
  195. // Only keep the in-memory session while a scan is actively running.
  196. // Otherwise prefer disk cache — it is updated by the merge queue even
  197. // when this section was unmounted, so remount must not revive stale groups.
  198. if (
  199. scanStateBelongsToProject(sharedScanState, project) &&
  200. sharedScanState.scanning
  201. ) {
  202. setLocalScanState({ ...sharedScanState })
  203. setModelsHydrated(true)
  204. return
  205. }
  206. if (!cached || cached.projectId !== project.id) {
  207. if (
  208. scanStateBelongsToProject(sharedScanState, project) &&
  209. sharedScanState.scanCompleted
  210. ) {
  211. setLocalScanState({ ...sharedScanState })
  212. } else {
  213. setLocalScanState(null)
  214. }
  215. setModelsHydrated(true)
  216. return
  217. }
  218. const hydrated: MaintenanceScanState = {
  219. projectId: project.id,
  220. projectPath: normalizeProjectPath(project.path),
  221. scanning: false,
  222. scanError: null,
  223. groups: cached.groups,
  224. scanCompleted: true,
  225. scannedPageCount: cached.scannedPageCount,
  226. }
  227. replaceSharedScanState(hydrated)
  228. setLocalScanState(hydrated)
  229. setModelsHydrated(true)
  230. })()
  231. return () => {
  232. cancelled = true
  233. }
  234. }, [project?.id, project?.path])
  235. useEffect(() => {
  236. if (!project) {
  237. setIsScanning(false)
  238. return
  239. }
  240. const activeState =
  241. localScanState && scanStateBelongsToProject(localScanState, project)
  242. ? localScanState
  243. : scanStateBelongsToProject(scanState, project)
  244. ? scanState
  245. : null
  246. setIsScanning(!!activeState?.scanning)
  247. }, [project, localScanState, scanState])
  248. useEffect(() => {
  249. if (!modelsHydrated) return
  250. const preferred = defaultLlmModel.trim() || aiChatModel.trim()
  251. const fallback = preferred || getFirstAvailableModelKey(providerConfigs)
  252. setDetectModelId((current) => current.trim() || fallback)
  253. setMergeModelId((current) => current.trim() || fallback)
  254. }, [modelsHydrated, defaultLlmModel, aiChatModel, providerConfigs])
  255. // Persist model choices per project after initial hydrate.
  256. useEffect(() => {
  257. if (!project || !modelsHydrated) return
  258. if (!detectModelId.trim() && !mergeModelId.trim()) return
  259. void saveDedupModelPrefs(project.path, {
  260. detectModelId: detectModelId.trim() || undefined,
  261. mergeModelId: mergeModelId.trim() || undefined,
  262. }).catch((err) => {
  263. console.error("[Maintenance] save model prefs failed:", err)
  264. })
  265. }, [project?.id, project?.path, modelsHydrated, detectModelId, mergeModelId])
  266. const hasAvailableModels = useMemo(() => {
  267. for (const key of Object.keys(providerConfigs)) {
  268. const config = providerConfigs[key]
  269. if (key.startsWith("custom-")) {
  270. if (config.enabled === false) continue
  271. } else {
  272. if (config.enabled !== true) continue
  273. }
  274. if (config.savedModels && config.savedModels.length > 0) {
  275. return true
  276. }
  277. }
  278. return false
  279. }, [providerConfigs])
  280. // Poll the queue at 1Hz so the UI reflects pending → processing →
  281. // failed transitions and cross-window queue activity (e.g. a merge
  282. // that completed while the user was on a different settings tab).
  283. // Same pattern activity-panel uses for ingest-queue.
  284. const [tasks, setTasks] = useState<readonly DedupTask[]>([])
  285. const [mergeProgress, setMergeProgress] = useState<{
  286. taskId: string
  287. stage: DedupMergeStage
  288. } | null>(null)
  289. const [mergeLogs, setMergeLogs] = useState<readonly string[]>([])
  290. const [enqueueingKey, setEnqueueingKey] = useState<string | null>(null)
  291. const [mergeErrors, setMergeErrors] = useState<Record<string, string>>({})
  292. useEffect(() => {
  293. if (!project) {
  294. setTasks([])
  295. setMergeProgress(null)
  296. setMergeLogs([])
  297. return
  298. }
  299. let cancelled = false
  300. void ensureQueueActive(project.id, project.path)
  301. .then(() => {
  302. if (!cancelled) {
  303. setTasks([...getQueue()])
  304. setMergeProgress(getMergeProgress())
  305. setMergeLogs([...getMergeLogs()])
  306. }
  307. })
  308. .catch((err) => {
  309. console.error("[Maintenance] ensureQueueActive failed:", err)
  310. })
  311. const id = setInterval(() => {
  312. setTasks([...getQueue()])
  313. setMergeProgress(getMergeProgress())
  314. setMergeLogs([...getMergeLogs()])
  315. }, 500)
  316. return () => {
  317. cancelled = true
  318. clearInterval(id)
  319. }
  320. }, [project?.id, project?.path])
  321. const effectiveDetectConfig = useMemo(
  322. () =>
  323. detectModelId.trim()
  324. ? resolveModelConfig(detectModelId, llmConfig, providerConfigs)
  325. : resolveDefaultModel(llmConfig),
  326. [detectModelId, llmConfig, providerConfigs],
  327. )
  328. const effectiveMergeConfig = useMemo(
  329. () =>
  330. mergeModelId.trim()
  331. ? resolveModelConfig(mergeModelId, llmConfig, providerConfigs)
  332. : detectModelId.trim()
  333. ? resolveModelConfig(detectModelId, llmConfig, providerConfigs)
  334. : resolveDefaultModel(llmConfig),
  335. [mergeModelId, detectModelId, llmConfig, providerConfigs],
  336. )
  337. const detectLlmReady = hasUsableLlm(effectiveDetectConfig, providerConfigs)
  338. const mergeLlmReady = hasUsableLlm(effectiveMergeConfig, providerConfigs)
  339. const llmReady = detectLlmReady
  340. const projectReady = !!project
  341. const activeScanState = useMemo(() => {
  342. if (!project) return emptyScanState
  343. if (localScanState && scanStateBelongsToProject(localScanState, project)) {
  344. return localScanState
  345. }
  346. if (scanStateBelongsToProject(scanState, project)) {
  347. return scanState
  348. }
  349. return emptyScanState
  350. }, [project, localScanState, scanState])
  351. const scanning = isScanning || activeScanState.scanning
  352. const { scanError, groups, scanCompleted, scannedPageCount } = activeScanState
  353. const applyScanState = useCallback(
  354. (patch: Partial<MaintenanceScanState>, options?: { persist?: boolean }) => {
  355. setSharedScanState(patch)
  356. const next = { ...sharedScanState }
  357. setLocalScanState({ ...next })
  358. if (
  359. options?.persist !== false &&
  360. project &&
  361. next.projectId === project.id &&
  362. next.scanCompleted &&
  363. !next.scanning
  364. ) {
  365. void saveDedupScanCache(
  366. project.path,
  367. toScanCache(next, project.id, detectModelId, mergeModelId),
  368. ).catch((err) => {
  369. console.error("[Maintenance] save scan cache failed:", err)
  370. })
  371. }
  372. },
  373. [project, detectModelId, mergeModelId],
  374. )
  375. const removeMergedGroup = useCallback(
  376. (slugs: readonly string[]) => {
  377. const key = groupKey(slugs)
  378. const active = useWikiStore.getState().project
  379. // Drop by slug-set whenever present. Do not gate on belongs-check —
  380. // that early-return left stale cards after merge (second merge 404s).
  381. const remaining = sharedScanState.groups.filter((g) => groupKey(g.group.slugs) !== key)
  382. if (remaining.length === sharedScanState.groups.length) return
  383. applyScanState({
  384. groups: remaining,
  385. ...(active && !sharedScanState.projectId
  386. ? {
  387. projectId: active.id,
  388. projectPath: normalizeProjectPath(active.path),
  389. }
  390. : {}),
  391. })
  392. },
  393. [applyScanState],
  394. )
  395. // Keep a live bridge so the merge queue can drop cards while this
  396. // section is mounted (and disk cache is always updated by the queue).
  397. useEffect(() => {
  398. registerDedupScanSessionApi({ removeGroup: removeMergedGroup })
  399. return () => registerDedupScanSessionApi(null)
  400. }, [removeMergedGroup])
  401. useEffect(() => {
  402. if (!project) return
  403. return onDedupMergeComplete((task) => {
  404. if (task.projectId !== project.id) return
  405. // Session API already removes the group; refresh queue chips.
  406. setTasks([...getQueue()])
  407. })
  408. }, [project?.id])
  409. const handleScan = useCallback(async () => {
  410. if (!project) return
  411. const projectPath = normalizeProjectPath(project.path)
  412. const projectId = project.id
  413. if (!hasUsableLlm(effectiveDetectConfig, providerConfigs)) {
  414. applyScanState({
  415. projectId,
  416. projectPath,
  417. scanning: false,
  418. scanError: t("settings.sections.maintenance.dedup.selectDetectModel", {
  419. defaultValue: "请先选择检测模型。",
  420. }),
  421. groups: [],
  422. scanCompleted: false,
  423. scannedPageCount: null,
  424. })
  425. return
  426. }
  427. const appendScanLog = (message: string) => {
  428. const stamp = new Date().toLocaleTimeString()
  429. const line = `${stamp} ${message}`
  430. setScanLogs((prev) => [...prev, line])
  431. console.log(`[Maintenance Dedup] ${message}`)
  432. }
  433. setIsScanning(true)
  434. setScanStage("loading")
  435. setScanLogs([])
  436. appendScanLog(
  437. detectModelId.trim()
  438. ? `检测模型 id:${detectModelId.trim()}`
  439. : "未选择检测模型 id,使用默认模型",
  440. )
  441. appendScanLog(
  442. mergeModelId.trim()
  443. ? `合并模型 id:${mergeModelId.trim()}(入队时使用)`
  444. : "未单独设置合并模型,入队时回退检测模型",
  445. )
  446. applyScanState({
  447. projectId,
  448. projectPath,
  449. scanning: true,
  450. scanError: null,
  451. groups: [],
  452. scanCompleted: false,
  453. scannedPageCount: null,
  454. })
  455. try {
  456. const { groups: detected, scannedPageCount } = await runDuplicateDetection(
  457. projectPath,
  458. effectiveDetectConfig,
  459. {
  460. onProgress: setScanStage,
  461. onLog: appendScanLog,
  462. },
  463. )
  464. applyScanState({
  465. projectId,
  466. projectPath,
  467. scanning: false,
  468. groups: detected.map((g) => ({
  469. group: g,
  470. canonicalSlug: g.slugs[0],
  471. skipped: false,
  472. })),
  473. scanCompleted: true,
  474. scannedPageCount,
  475. })
  476. } catch (err) {
  477. const message = err instanceof Error ? err.message : String(err)
  478. appendScanLog(`失败:${message}`)
  479. applyScanState({
  480. projectId,
  481. projectPath,
  482. scanning: false,
  483. scanError: message,
  484. scanCompleted: false,
  485. scannedPageCount: null,
  486. })
  487. } finally {
  488. setIsScanning(false)
  489. setScanStage(null)
  490. }
  491. }, [
  492. project,
  493. effectiveDetectConfig,
  494. providerConfigs,
  495. t,
  496. applyScanState,
  497. detectModelId,
  498. mergeModelId,
  499. ])
  500. const handleCanonicalChange = useCallback(
  501. (idx: number, slug: string) => {
  502. const source =
  503. localScanState && project && scanStateBelongsToProject(localScanState, project)
  504. ? localScanState
  505. : sharedScanState
  506. const groups = source.groups.map((g, i) => (i === idx ? { ...g, canonicalSlug: slug } : g))
  507. applyScanState({ groups })
  508. },
  509. [applyScanState, localScanState, project],
  510. )
  511. const handleEnqueue = useCallback(
  512. async (entry: GroupUiEntry) => {
  513. if (!project) return
  514. const key = groupKey(entry.group.slugs)
  515. setEnqueueingKey(key)
  516. setMergeErrors((prev) => {
  517. if (!prev[key]) return prev
  518. const next = { ...prev }
  519. delete next[key]
  520. return next
  521. })
  522. try {
  523. const pp = normalizeProjectPath(project.path)
  524. const missing: string[] = []
  525. for (const slug of entry.group.slugs) {
  526. const entityPath = `${pp}/wiki/entities/${slug}.md`
  527. const conceptPath = `${pp}/wiki/concepts/${slug}.md`
  528. const exists =
  529. (await fileExists(entityPath)) || (await fileExists(conceptPath))
  530. if (!exists) missing.push(slug)
  531. }
  532. if (missing.length > 0) {
  533. removeMergedGroup(entry.group.slugs)
  534. const message = t("settings.sections.maintenance.dedup.pagesMissing", {
  535. defaultValue: "部分页面已不存在(可能已合并):{{slugs}}。已从列表移除。",
  536. slugs: missing.join(", "),
  537. })
  538. toast.error(message)
  539. return
  540. }
  541. if (!mergeLlmReady) {
  542. const message = t("settings.sections.maintenance.dedup.selectMergeModel", {
  543. defaultValue: "请先选择合并模型。",
  544. })
  545. setMergeErrors((prev) => ({ ...prev, [key]: message }))
  546. toast.error(message)
  547. return
  548. }
  549. const mergeIdForTask = mergeModelId.trim() || detectModelId.trim() || undefined
  550. await enqueueMerge(
  551. project.id,
  552. entry.group,
  553. entry.canonicalSlug,
  554. mergeIdForTask,
  555. )
  556. setTasks([...getQueue()])
  557. } catch (err) {
  558. const message = err instanceof Error ? err.message : String(err)
  559. console.error("[Maintenance] enqueue failed:", err)
  560. setMergeErrors((prev) => ({ ...prev, [key]: message }))
  561. toast.error(message)
  562. } finally {
  563. setEnqueueingKey(null)
  564. }
  565. },
  566. [project, mergeModelId, detectModelId, mergeLlmReady, removeMergedGroup, t],
  567. )
  568. const handleCancel = useCallback(async (taskId: string) => {
  569. await cancelTask(taskId)
  570. setTasks([...getQueue()])
  571. }, [])
  572. const handleRetry = useCallback(async (taskId: string) => {
  573. await retryTask(taskId)
  574. setTasks([...getQueue()])
  575. }, [])
  576. const handleNotDuplicate = useCallback(
  577. async (idx: number) => {
  578. if (!project) return
  579. const entry = groups[idx]
  580. if (!entry) return
  581. try {
  582. await addNotDuplicate(project.path, entry.group.slugs)
  583. const source =
  584. localScanState && scanStateBelongsToProject(localScanState, project)
  585. ? localScanState
  586. : sharedScanState
  587. applyScanState({
  588. groups: source.groups.map((g, i) => (i === idx ? { ...g, skipped: true } : g)),
  589. })
  590. } catch (err) {
  591. console.error("[Maintenance] addNotDuplicate failed:", err)
  592. }
  593. },
  594. [project, groups, applyScanState, localScanState],
  595. )
  596. const visibleGroups = useMemo(
  597. () => sortGroupEntriesByConfidence(groups.filter((entry) => !entry.skipped)),
  598. [groups],
  599. )
  600. // Pending position helper: "queued (N ahead)" — count pending tasks
  601. // before this one in arrival order.
  602. const pendingPositionByTaskId = useMemo(() => {
  603. const positions = new Map<string, number>()
  604. let position = 0
  605. for (const t of tasks) {
  606. if (t.status === "pending") {
  607. positions.set(t.id, position)
  608. position++
  609. }
  610. }
  611. return positions
  612. }, [tasks])
  613. return (
  614. <div className="space-y-6">
  615. <div>
  616. <h2 className="text-xl font-semibold">
  617. {t("settings.sections.maintenance.title", { defaultValue: "维护工具" })}
  618. </h2>
  619. <p className="mt-1 text-sm text-muted-foreground">
  620. {t("settings.sections.maintenance.description", {
  621. defaultValue:
  622. "用于清理资料库的工具:检测并合并那些在多次重新摄取后被大模型以不同名称创建出来的重复实体或概念。",
  623. })}
  624. </p>
  625. </div>
  626. <div className="space-y-3 rounded-lg border border-border/60 bg-muted/20 p-4">
  627. <div className="flex items-center gap-2">
  628. <Wrench className="h-4 w-4 text-muted-foreground" />
  629. <h3 className="text-sm font-semibold">
  630. {t("settings.sections.maintenance.dedup.title", {
  631. defaultValue: "检测重复实体 / 概念",
  632. })}
  633. </h3>
  634. </div>
  635. <p className="text-xs leading-relaxed text-muted-foreground">
  636. {t("settings.sections.maintenance.dedup.description", {
  637. defaultValue:
  638. "让大模型扫描全部实体 / 概念页面,并把那些很可能只是名称不同、实则指向同一主题的条目分组出来(例如中英文名称、单复数、简称与全称)。每组都需要你确认后才会合并。合并任务会进入队列并逐个执行,以保持交叉引用一致。",
  639. })}
  640. </p>
  641. {/* 小说写作场景详细说明 */}
  642. <NovelScenarioHelp />
  643. {!projectReady && (
  644. <p className="text-xs text-amber-700 dark:text-amber-400">
  645. {t("settings.sections.maintenance.noProject", {
  646. defaultValue: "请先打开一个项目。",
  647. })}
  648. </p>
  649. )}
  650. {projectReady && !hasAvailableModels && (
  651. <p className="text-xs text-amber-700 dark:text-amber-400">
  652. {t("settings.sections.maintenance.dedup.noModel", {
  653. defaultValue: "请先在「设置 → 大语言模型」中添加并启用一个模型。",
  654. })}
  655. </p>
  656. )}
  657. {projectReady && hasAvailableModels && !detectLlmReady && (
  658. <p className="text-xs text-amber-700 dark:text-amber-400">
  659. {detectModelId.trim()
  660. ? t("settings.sections.maintenance.noLlm", {
  661. defaultValue: "请先配置大模型提供方。",
  662. })
  663. : t("settings.sections.maintenance.dedup.selectDetectModel", {
  664. defaultValue: "请先选择检测模型。",
  665. })}
  666. </p>
  667. )}
  668. {projectReady && hasAvailableModels && detectLlmReady && !mergeLlmReady && (
  669. <p className="text-xs text-amber-700 dark:text-amber-400">
  670. {t("settings.sections.maintenance.dedup.selectMergeModel", {
  671. defaultValue: "请先选择合并模型。",
  672. })}
  673. </p>
  674. )}
  675. {projectReady && hasAvailableModels && (
  676. <div className="grid gap-3 sm:grid-cols-2">
  677. <div className="space-y-1.5">
  678. <Label className="text-xs">
  679. {t("settings.sections.maintenance.dedup.detectModelLabel", {
  680. defaultValue: "检测模型",
  681. })}
  682. </Label>
  683. <ChatModelSelector
  684. value={detectModelId}
  685. onChange={setDetectModelId}
  686. disabled={scanning}
  687. />
  688. <p className="text-[11px] leading-relaxed text-muted-foreground">
  689. {t("settings.sections.maintenance.dedup.detectModelHint", {
  690. defaultValue: "扫描分组用。实体多时上下文更大,建议用更强、上下文更长的模型。",
  691. })}
  692. </p>
  693. </div>
  694. <div className="space-y-1.5">
  695. <Label className="text-xs">
  696. {t("settings.sections.maintenance.dedup.mergeModelLabel", {
  697. defaultValue: "合并模型",
  698. })}
  699. </Label>
  700. <ChatModelSelector
  701. value={mergeModelId}
  702. onChange={setMergeModelId}
  703. disabled={scanning}
  704. />
  705. <p className="text-[11px] leading-relaxed text-muted-foreground">
  706. {t("settings.sections.maintenance.dedup.mergeModelHint", {
  707. defaultValue: "写合并正文用。输入通常更短,可用相对便宜的模型。",
  708. })}
  709. </p>
  710. </div>
  711. </div>
  712. )}
  713. <Button
  714. onClick={() => void handleScan()}
  715. disabled={scanning || !projectReady || !hasAvailableModels || !llmReady}
  716. >
  717. {scanning ? (
  718. <>
  719. <Loader2 className="h-3.5 w-3.5 animate-spin" />
  720. {t("settings.sections.maintenance.dedup.scanning", {
  721. defaultValue: "扫描中...",
  722. })}
  723. </>
  724. ) : (
  725. t("settings.sections.maintenance.dedup.scanButton", {
  726. defaultValue: "开始扫描重复项",
  727. })
  728. )}
  729. </Button>
  730. {scanning && (
  731. <div className="flex items-start gap-1.5 rounded border border-border/60 bg-background/80 px-2 py-1.5 text-xs text-muted-foreground">
  732. <Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin" />
  733. <div>
  734. {scanStage === "loading"
  735. ? t("settings.sections.maintenance.dedup.scanStageLoading", {
  736. defaultValue: "正在读取实体 / 概念页面…",
  737. })
  738. : scanStage === "detecting"
  739. ? t("settings.sections.maintenance.dedup.scanStageDetecting", {
  740. defaultValue: "正在调用模型分析…",
  741. })
  742. : t("settings.sections.maintenance.dedup.scanningHint", {
  743. defaultValue:
  744. "正在扫描实体 / 概念页面并调用模型分析,可能需要一会儿…",
  745. })}
  746. </div>
  747. </div>
  748. )}
  749. {(scanning || scanLogs.length > 0) && (
  750. <ProcessLogPanel
  751. title={t("settings.sections.maintenance.dedup.processLogTitle", {
  752. defaultValue: "分析过程日志",
  753. })}
  754. lines={scanLogs}
  755. live={scanning}
  756. />
  757. )}
  758. {scanError && (
  759. <div className="flex items-start gap-1.5 rounded border border-rose-500/40 bg-rose-500/5 px-2 py-1.5 text-xs text-rose-700 dark:text-rose-400">
  760. <XCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
  761. <div>{scanError}</div>
  762. </div>
  763. )}
  764. {scanCompleted && visibleGroups.length > 0 && !scanError && (
  765. <div className="flex items-start gap-1.5 rounded border border-sky-500/40 bg-sky-500/5 px-2 py-1.5 text-xs text-sky-800 dark:text-sky-300">
  766. <CheckCircle2 className="mt-0.5 h-3.5 w-3.5 shrink-0" />
  767. <div>
  768. {t("settings.sections.maintenance.dedup.groupsFound", {
  769. count: visibleGroups.length,
  770. defaultValue: "发现 {{count}} 组重复候选,请在下方确认是否合并。",
  771. })}
  772. </div>
  773. </div>
  774. )}
  775. {scanCompleted && scannedPageCount !== null && scannedPageCount < 2 && !scanError && (
  776. <div className="flex items-start gap-1.5 rounded border border-amber-500/40 bg-amber-500/5 px-2 py-1.5 text-xs text-amber-700 dark:text-amber-400">
  777. <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
  778. <div>
  779. {t("settings.sections.maintenance.dedup.insufficientPages", {
  780. count: scannedPageCount,
  781. defaultValue: "至少需要 2 个实体 / 概念页面才能检测重复,当前只有 {{count}} 个。",
  782. })}
  783. </div>
  784. </div>
  785. )}
  786. {scanCompleted && visibleGroups.length === 0 && groups.length === 0 && !scanError && scannedPageCount !== null && scannedPageCount >= 2 && (
  787. <div className="flex items-start gap-1.5 rounded border border-emerald-500/40 bg-emerald-500/5 px-2 py-1.5 text-xs text-emerald-700 dark:text-emerald-400">
  788. <CheckCircle2 className="mt-0.5 h-3.5 w-3.5 shrink-0" />
  789. <div>
  790. {t("settings.sections.maintenance.dedup.noneFound", {
  791. defaultValue: "未发现重复分组,当前资料库很干净。",
  792. })}
  793. </div>
  794. </div>
  795. )}
  796. </div>
  797. <QueueOrphanList
  798. tasks={tasks}
  799. groups={groups}
  800. mergeProgress={mergeProgress}
  801. onCancel={(id) => void handleCancel(id)}
  802. onRetry={(id) => void handleRetry(id)}
  803. pendingPositionByTaskId={pendingPositionByTaskId}
  804. />
  805. {mergeLogs.length > 0 && (
  806. <ProcessLogPanel
  807. title={t("settings.sections.maintenance.dedup.mergeLogTitle", {
  808. defaultValue: "合并过程日志",
  809. })}
  810. lines={mergeLogs}
  811. live={!!mergeProgress}
  812. />
  813. )}
  814. {visibleGroups.map((entry) => {
  815. const entryKey = groupKey(entry.group.slugs)
  816. const idx = groups.findIndex((g) => groupKey(g.group.slugs) === entryKey)
  817. const task = findTaskForGroup(tasks, entry.group.slugs)
  818. return (
  819. <DuplicateGroupCard
  820. key={entry.group.slugs.join(",")}
  821. entry={entry}
  822. task={task}
  823. mergeProgress={mergeProgress}
  824. enqueueing={enqueueingKey === entryKey}
  825. mergeReady={mergeLlmReady}
  826. mergeError={mergeErrors[entryKey] ?? null}
  827. pendingPosition={
  828. task && task.status === "pending"
  829. ? pendingPositionByTaskId.get(task.id) ?? 0
  830. : 0
  831. }
  832. onCanonicalChange={(slug) => handleCanonicalChange(idx, slug)}
  833. onEnqueue={() => void handleEnqueue(entry)}
  834. onCancel={() => task && void handleCancel(task.id)}
  835. onRetry={() => task && void handleRetry(task.id)}
  836. onNotDuplicate={() => void handleNotDuplicate(idx)}
  837. />
  838. )
  839. })}
  840. </div>
  841. )
  842. }
  843. // --- helpers ---------------------------------------------------------------
  844. function ProcessLogPanel({
  845. title,
  846. lines,
  847. live,
  848. }: {
  849. title: string
  850. lines: readonly string[]
  851. live?: boolean
  852. }) {
  853. return (
  854. <div className="space-y-1.5 rounded border border-border/60 bg-background/80 px-2 py-1.5">
  855. <div className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground">
  856. {live ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
  857. <span>{title}</span>
  858. </div>
  859. <pre className="max-h-40 overflow-auto whitespace-pre-wrap break-all rounded bg-muted/40 px-2 py-1.5 font-mono text-[11px] leading-relaxed text-foreground/90">
  860. {lines.length > 0
  861. ? lines.join("\n")
  862. : "…"}
  863. </pre>
  864. </div>
  865. )
  866. }
  867. interface QueueOrphanListProps {
  868. tasks: readonly DedupTask[]
  869. groups: GroupUiEntry[]
  870. mergeProgress: { taskId: string; stage: DedupMergeStage } | null
  871. onCancel: (taskId: string) => void
  872. onRetry: (taskId: string) => void
  873. pendingPositionByTaskId: Map<string, number>
  874. }
  875. /**
  876. * Render queued tasks that don't have a matching card on screen. This
  877. * happens after the user closes the Maintenance pane and re-opens it,
  878. * or after an app restart with pending tasks: those tasks are real
  879. * but the user hasn't re-scanned, so without this list they'd be
  880. * invisible.
  881. */
  882. function QueueOrphanList({
  883. tasks,
  884. groups,
  885. mergeProgress,
  886. onCancel,
  887. onRetry,
  888. pendingPositionByTaskId,
  889. }: QueueOrphanListProps) {
  890. const { t } = useTranslation()
  891. const groupKeys = new Set(groups.map((g) => groupKey(g.group.slugs)))
  892. const orphans = tasks.filter((t) => !groupKeys.has(groupKey(t.group.slugs)))
  893. if (orphans.length === 0) return null
  894. return (
  895. <div className="space-y-2 rounded-lg border border-border/60 bg-muted/10 p-4">
  896. <div className="flex items-center gap-2">
  897. <Clock className="h-4 w-4 text-muted-foreground" />
  898. <h3 className="text-sm font-semibold">
  899. {t("settings.sections.maintenance.dedup.queueTitle", {
  900. defaultValue: "进行中的合并任务",
  901. })}
  902. </h3>
  903. </div>
  904. <p className="text-xs text-muted-foreground">
  905. {t("settings.sections.maintenance.dedup.queueDescription", {
  906. defaultValue:
  907. "这里显示上一次扫描后仍未完成的任务。合并会逐个排队执行。",
  908. })}
  909. </p>
  910. {orphans.map((task) => (
  911. <div
  912. key={task.id}
  913. className="flex flex-wrap items-center gap-2 rounded border border-border/40 bg-background px-3 py-2 text-xs"
  914. >
  915. <code className="font-mono">{task.group.slugs.join(" + ")}</code>
  916. <span className="text-muted-foreground">
  917. →{" "}
  918. <code className="font-mono">{task.canonicalSlug}</code>
  919. </span>
  920. <span className="ml-auto inline-flex items-center gap-1">
  921. <TaskStatusChip
  922. task={task}
  923. pendingPosition={pendingPositionByTaskId.get(task.id) ?? 0}
  924. mergeStage={
  925. mergeProgress?.taskId === task.id ? mergeProgress.stage : null
  926. }
  927. />
  928. {task.status === "failed" && (
  929. <Button
  930. size="sm"
  931. variant="ghost"
  932. onClick={() => onRetry(task.id)}
  933. >
  934. <RotateCcw className="h-3.5 w-3.5" />
  935. {t("settings.sections.maintenance.dedup.retry", {
  936. defaultValue: "重试",
  937. })}
  938. </Button>
  939. )}
  940. <Button size="sm" variant="ghost" onClick={() => onCancel(task.id)}>
  941. <Trash2 className="h-3.5 w-3.5" />
  942. {t("settings.sections.maintenance.dedup.delete", {
  943. defaultValue: "删除",
  944. })}
  945. </Button>
  946. </span>
  947. {task.error && task.status === "failed" && (
  948. <div className="basis-full rounded border border-rose-500/40 bg-rose-500/5 px-2 py-1 text-rose-700 dark:text-rose-400">
  949. {task.error}
  950. </div>
  951. )}
  952. </div>
  953. ))}
  954. </div>
  955. )
  956. }
  957. interface ChipProps {
  958. task: DedupTask
  959. pendingPosition: number
  960. mergeStage?: DedupMergeStage | null
  961. }
  962. function mergeStageLabel(
  963. stage: DedupMergeStage,
  964. t: ReturnType<typeof useTranslation>["t"],
  965. ): string {
  966. switch (stage) {
  967. case "loading":
  968. return t("settings.sections.maintenance.dedup.mergeStageLoading", {
  969. defaultValue: "正在读取 wiki…",
  970. })
  971. case "merging":
  972. return t("settings.sections.maintenance.dedup.mergeStageMerging", {
  973. defaultValue: "正在合并内容(LLM)…",
  974. })
  975. case "writing":
  976. return t("settings.sections.maintenance.dedup.mergeStageWriting", {
  977. defaultValue: "正在写入文件…",
  978. })
  979. }
  980. }
  981. function TaskStatusChip({ task, pendingPosition, mergeStage }: ChipProps) {
  982. const { t } = useTranslation()
  983. if (task.status === "processing") {
  984. const label =
  985. mergeStage != null
  986. ? mergeStageLabel(mergeStage, t)
  987. : t("settings.sections.maintenance.dedup.merging", {
  988. defaultValue: "合并中...",
  989. })
  990. return (
  991. <span className="inline-flex items-center gap-1 rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-amber-700 dark:text-amber-400">
  992. <Loader2 className="h-3 w-3 animate-spin" />
  993. {label}
  994. </span>
  995. )
  996. }
  997. if (task.status === "pending") {
  998. if (pendingPosition === 0) {
  999. return (
  1000. <span className="inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase text-muted-foreground">
  1001. {t("settings.sections.maintenance.dedup.queued", {
  1002. defaultValue: "已排队",
  1003. })}
  1004. </span>
  1005. )
  1006. }
  1007. return (
  1008. <span className="inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase text-muted-foreground">
  1009. {t("settings.sections.maintenance.dedup.queuedAhead", {
  1010. defaultValue: "已排队(前方还有 {{n}} 项)",
  1011. n: pendingPosition,
  1012. })}
  1013. </span>
  1014. )
  1015. }
  1016. if (task.status === "failed") {
  1017. return (
  1018. <span className="inline-flex items-center gap-1 rounded bg-rose-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-rose-700 dark:text-rose-400">
  1019. <AlertTriangle className="h-3 w-3" />
  1020. {t("settings.sections.maintenance.dedup.failed", {
  1021. defaultValue: "失败({{retries}}/3)",
  1022. retries: task.retryCount,
  1023. })}
  1024. </span>
  1025. )
  1026. }
  1027. return null
  1028. }
  1029. interface CardProps {
  1030. entry: GroupUiEntry
  1031. task: DedupTask | undefined
  1032. mergeProgress: { taskId: string; stage: DedupMergeStage } | null
  1033. enqueueing: boolean
  1034. mergeReady: boolean
  1035. mergeError: string | null
  1036. pendingPosition: number
  1037. onCanonicalChange: (slug: string) => void
  1038. onEnqueue: () => void
  1039. onCancel: () => void
  1040. onRetry: () => void
  1041. onNotDuplicate: () => void
  1042. }
  1043. function DuplicateGroupCard({
  1044. entry,
  1045. task,
  1046. mergeProgress,
  1047. enqueueing,
  1048. mergeReady,
  1049. mergeError,
  1050. pendingPosition,
  1051. onCanonicalChange,
  1052. onEnqueue,
  1053. onCancel,
  1054. onRetry,
  1055. onNotDuplicate,
  1056. }: CardProps) {
  1057. const { t } = useTranslation()
  1058. const { group, canonicalSlug, skipped } = entry
  1059. const inFlight = !!task && (task.status === "pending" || task.status === "processing")
  1060. const failed = !!task && task.status === "failed"
  1061. const finished = skipped
  1062. const confidenceClass =
  1063. group.confidence === "high"
  1064. ? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400"
  1065. : group.confidence === "medium"
  1066. ? "bg-amber-500/15 text-amber-700 dark:text-amber-400"
  1067. : "bg-muted text-muted-foreground"
  1068. return (
  1069. <div
  1070. className={`space-y-3 rounded-lg border px-4 py-3 ${
  1071. finished ? "border-border/40 bg-muted/10 opacity-60" : "border-border bg-background"
  1072. }`}
  1073. >
  1074. <div className="flex items-center gap-2">
  1075. <span className={`rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase ${confidenceClass}`}>
  1076. {group.confidence}
  1077. </span>
  1078. <span className="text-xs text-muted-foreground">
  1079. {t("settings.sections.maintenance.dedup.candidates", {
  1080. defaultValue: "{{n}} 个候选",
  1081. n: group.slugs.length,
  1082. })}
  1083. </span>
  1084. {skipped && (
  1085. <span className="ml-auto inline-flex items-center gap-1 text-xs text-muted-foreground">
  1086. {t("settings.sections.maintenance.dedup.skipped", { defaultValue: "已标记为不重复" })}
  1087. </span>
  1088. )}
  1089. {task && !finished && (
  1090. <span className="ml-auto">
  1091. <TaskStatusChip
  1092. task={task}
  1093. pendingPosition={pendingPosition}
  1094. mergeStage={
  1095. mergeProgress?.taskId === task.id ? mergeProgress.stage : null
  1096. }
  1097. />
  1098. </span>
  1099. )}
  1100. </div>
  1101. {group.reason && (
  1102. <div className="text-xs italic leading-relaxed text-muted-foreground">{group.reason}</div>
  1103. )}
  1104. {!finished && (
  1105. <>
  1106. <div className="space-y-1.5">
  1107. <Label className="text-xs">
  1108. {t("settings.sections.maintenance.dedup.canonicalLabel", {
  1109. defaultValue: "保留以下 slug 作为主条目:",
  1110. })}
  1111. </Label>
  1112. {group.slugs.map((slug) => (
  1113. <label
  1114. key={slug}
  1115. className="flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 text-sm hover:bg-accent"
  1116. >
  1117. <input
  1118. type="radio"
  1119. name={`canonical-${group.slugs.join(",")}`}
  1120. checked={canonicalSlug === slug}
  1121. onChange={() => onCanonicalChange(slug)}
  1122. disabled={inFlight}
  1123. />
  1124. <code className="font-mono text-xs">{slug}</code>
  1125. </label>
  1126. ))}
  1127. </div>
  1128. <div className="flex flex-wrap gap-2">
  1129. {!task && (
  1130. <>
  1131. <Button size="sm" onClick={onEnqueue} disabled={enqueueing || !mergeReady}>
  1132. {enqueueing ? (
  1133. <>
  1134. <Loader2 className="h-3.5 w-3.5 animate-spin" />
  1135. {t("settings.sections.maintenance.dedup.enqueueing", {
  1136. defaultValue: "加入队列...",
  1137. })}
  1138. </>
  1139. ) : (
  1140. t("settings.sections.maintenance.dedup.mergeButton", {
  1141. defaultValue: "合并到 {{slug}}",
  1142. slug: canonicalSlug,
  1143. })
  1144. )}
  1145. </Button>
  1146. <Button size="sm" variant="ghost" onClick={onNotDuplicate} disabled={enqueueing}>
  1147. {t("settings.sections.maintenance.dedup.notDuplicates", {
  1148. defaultValue: "不是重复",
  1149. })}
  1150. </Button>
  1151. </>
  1152. )}
  1153. {inFlight && (
  1154. <Button size="sm" variant="ghost" onClick={onCancel}>
  1155. <Trash2 className="h-3.5 w-3.5" />
  1156. {t("settings.sections.maintenance.dedup.cancel", {
  1157. defaultValue: "取消",
  1158. })}
  1159. </Button>
  1160. )}
  1161. {failed && (
  1162. <>
  1163. <Button size="sm" onClick={onRetry}>
  1164. <RotateCcw className="h-3.5 w-3.5" />
  1165. {t("settings.sections.maintenance.dedup.retry", {
  1166. defaultValue: "重试",
  1167. })}
  1168. </Button>
  1169. <Button size="sm" variant="ghost" onClick={onCancel}>
  1170. <Trash2 className="h-3.5 w-3.5" />
  1171. {t("settings.sections.maintenance.dedup.delete", {
  1172. defaultValue: "删除",
  1173. })}
  1174. </Button>
  1175. </>
  1176. )}
  1177. </div>
  1178. </>
  1179. )}
  1180. {mergeError && !task && (
  1181. <div className="flex items-start gap-1.5 rounded border border-rose-500/40 bg-rose-500/5 px-2 py-1.5 text-xs text-rose-700 dark:text-rose-400">
  1182. <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
  1183. <div>{mergeError}</div>
  1184. </div>
  1185. )}
  1186. {failed && task?.error && (
  1187. <div className="flex items-start gap-1.5 rounded border border-rose-500/40 bg-rose-500/5 px-2 py-1.5 text-xs text-rose-700 dark:text-rose-400">
  1188. <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
  1189. <div>{task.error}</div>
  1190. </div>
  1191. )}
  1192. </div>
  1193. )
  1194. }
  1195. // ─── 小说写作场景详细说明 ───────────────────────────────────────────────────────
  1196. function NovelScenarioHelp() {
  1197. const { t } = useTranslation()
  1198. const [open, setOpen] = useState(false)
  1199. return (
  1200. <div className="rounded-md border border-border/40 bg-background/60">
  1201. <button
  1202. type="button"
  1203. onClick={() => setOpen((v) => !v)}
  1204. className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
  1205. >
  1206. <HelpCircle className="h-3.5 w-3.5 shrink-0" />
  1207. <span className="flex-1">
  1208. {t("settings.sections.maintenance.dedup.novelHelpTitle", {
  1209. defaultValue: "写小说的话,这个功能有什么用?",
  1210. })}
  1211. </span>
  1212. {open ? (
  1213. <ChevronUp className="h-3.5 w-3.5 shrink-0" />
  1214. ) : (
  1215. <ChevronDown className="h-3.5 w-3.5 shrink-0" />
  1216. )}
  1217. </button>
  1218. {open && (
  1219. <div className="space-y-3 border-t border-border/40 px-3 py-3 text-xs leading-relaxed text-muted-foreground">
  1220. <p>
  1221. {t("settings.sections.maintenance.dedup.novelHelpIntro", {
  1222. defaultValue:
  1223. "简单说:当你的角色库、设定库里出现了「同一个人/同一个东西有好几个页面」的情况,这个工具能帮你找出来并合并成一个。",
  1224. })}
  1225. </p>
  1226. <div className="space-y-2">
  1227. <div className="flex items-start gap-2">
  1228. <Users className="mt-0.5 h-3.5 w-3.5 shrink-0 text-foreground/70" />
  1229. <div>
  1230. <div className="font-medium text-foreground/80">
  1231. {t("settings.sections.maintenance.dedup.novelHelpExample1Title", {
  1232. defaultValue: "角色重复(最常见)",
  1233. })}
  1234. </div>
  1235. <p>
  1236. {t("settings.sections.maintenance.dedup.novelHelpExample1", {
  1237. defaultValue:
  1238. "比如 AI 一会儿叫「张三」、一会儿叫「张小三」、一会儿又叫「男主」,其实是同一个人。每出现一个新名字,就会多出一个角色页面。",
  1239. })}
  1240. </p>
  1241. </div>
  1242. </div>
  1243. <div className="flex items-start gap-2">
  1244. <BookOpen className="mt-0.5 h-3.5 w-3.5 shrink-0 text-foreground/70" />
  1245. <div>
  1246. <div className="font-medium text-foreground/80">
  1247. {t("settings.sections.maintenance.dedup.novelHelpExample2Title", {
  1248. defaultValue: "设定/功法/地名重复",
  1249. })}
  1250. </div>
  1251. <p>
  1252. {t("settings.sections.maintenance.dedup.novelHelpExample2", {
  1253. defaultValue:
  1254. "比如「九阳神功」和「九阳真经」、「青云宗」和「青云派」,名字略有不同但说的是一回事,资料库会越攒越乱。",
  1255. })}
  1256. </p>
  1257. </div>
  1258. </div>
  1259. <div className="flex items-start gap-2">
  1260. <Lightbulb className="mt-0.5 h-3.5 w-3.5 shrink-0 text-foreground/70" />
  1261. <div>
  1262. <div className="font-medium text-foreground/80">
  1263. {t("settings.sections.maintenance.dedup.novelHelpHowTitle", {
  1264. defaultValue: "合并之后会怎样?",
  1265. })}
  1266. </div>
  1267. <ul className="list-disc space-y-0.5 pl-4">
  1268. <li>
  1269. {t("settings.sections.maintenance.dedup.novelHelpHow1", {
  1270. defaultValue: "两个页面的内容会合二为一,不会丢信息",
  1271. })}
  1272. </li>
  1273. <li>
  1274. {t("settings.sections.maintenance.dedup.novelHelpHow2", {
  1275. defaultValue: "所有章节里引用了旧名字的地方,会自动改成新名字,不会有死链",
  1276. })}
  1277. </li>
  1278. <li>
  1279. {t("settings.sections.maintenance.dedup.novelHelpHow3", {
  1280. defaultValue: "合并前会自动备份,合并错了也能恢复",
  1281. })}
  1282. </li>
  1283. </ul>
  1284. </div>
  1285. </div>
  1286. </div>
  1287. <p className="pt-1 text-[11px] text-muted-foreground/70">
  1288. {t("settings.sections.maintenance.dedup.novelHelpTip", {
  1289. defaultValue:
  1290. "💡 小提示:扫描结果只是 AI 的猜测,需要你确认后才会真正合并。觉得不是重复的可以点「不是重复」,下次扫描就不会再出现了。",
  1291. })}
  1292. </p>
  1293. </div>
  1294. )}
  1295. </div>
  1296. )
  1297. }