Parcourir la source

feat(dedup): 拆分检测/合并模型并完善扫描合并体验

检测与合并模型独立选择并持久化;补充扫描/合并过程日志;合并后同步清理缓存与 UI;embedding 并发写入加锁。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi il y a 1 mois
Parent
commit
b3e1af50d9

+ 272 - 51
src/components/settings/sections/maintenance-section.tsx

@@ -31,13 +31,20 @@ import {
   saveDedupScanCache,
   saveDedupScanCache,
   type DedupScanCacheEntry,
   type DedupScanCacheEntry,
 } from "@/lib/dedup-scan-cache"
 } from "@/lib/dedup-scan-cache"
+import {
+  loadDedupModelPrefs,
+  saveDedupModelPrefs,
+} from "@/lib/dedup-model-prefs"
+import { registerDedupScanSessionApi } from "@/lib/dedup-scan-session"
 import { toast } from "@/lib/toast"
 import { toast } from "@/lib/toast"
+import { fileExists } from "@/commands/fs"
 import {
 import {
   enqueueMerge,
   enqueueMerge,
   cancelTask,
   cancelTask,
   retryTask,
   retryTask,
   getQueue,
   getQueue,
   getMergeProgress,
   getMergeProgress,
+  getMergeLogs,
   groupKey,
   groupKey,
   ensureQueueActive,
   ensureQueueActive,
   onDedupMergeComplete,
   onDedupMergeComplete,
@@ -125,14 +132,16 @@ function replaceSharedScanState(state: MaintenanceScanState): void {
 function toScanCache(
 function toScanCache(
   state: MaintenanceScanState,
   state: MaintenanceScanState,
   projectId: string,
   projectId: string,
-  modelId: string,
+  detectModelId: string,
+  mergeModelId: string,
 ): Parameters<typeof saveDedupScanCache>[1] {
 ): Parameters<typeof saveDedupScanCache>[1] {
   return {
   return {
     version: 1,
     version: 1,
     projectId,
     projectId,
     scannedAt: Date.now(),
     scannedAt: Date.now(),
     scannedPageCount: state.scannedPageCount,
     scannedPageCount: state.scannedPageCount,
-    modelId: modelId.trim() || undefined,
+    modelId: detectModelId.trim() || undefined,
+    mergeModelId: mergeModelId.trim() || undefined,
     groups: state.groups as DedupScanCacheEntry[],
     groups: state.groups as DedupScanCacheEntry[],
   }
   }
 }
 }
@@ -164,9 +173,12 @@ export function MaintenanceSection() {
   const aiChatModel = useWikiStore((s) => s.aiChatModel)
   const aiChatModel = useWikiStore((s) => s.aiChatModel)
   const project = useWikiStore((s) => s.project)
   const project = useWikiStore((s) => s.project)
 
 
-  const [dedupModelId, setDedupModelId] = useState("")
+  const [detectModelId, setDetectModelId] = useState("")
+  const [mergeModelId, setMergeModelId] = useState("")
+  const [modelsHydrated, setModelsHydrated] = useState(false)
   const [isScanning, setIsScanning] = useState(false)
   const [isScanning, setIsScanning] = useState(false)
   const [scanStage, setScanStage] = useState<DedupScanStage | null>(null)
   const [scanStage, setScanStage] = useState<DedupScanStage | null>(null)
+  const [scanLogs, setScanLogs] = useState<string[]>([])
   const [scanState, setScanState] = useState<MaintenanceScanState>(sharedScanState)
   const [scanState, setScanState] = useState<MaintenanceScanState>(sharedScanState)
   const [localScanState, setLocalScanState] = useState<MaintenanceScanState | null>(null)
   const [localScanState, setLocalScanState] = useState<MaintenanceScanState | null>(null)
 
 
@@ -175,25 +187,53 @@ export function MaintenanceSection() {
   useEffect(() => {
   useEffect(() => {
     if (!project) {
     if (!project) {
       setLocalScanState(null)
       setLocalScanState(null)
+      setModelsHydrated(false)
       return
       return
     }
     }
     let cancelled = false
     let cancelled = false
-    void loadDedupScanCache(project.path).then((cached) => {
+    setModelsHydrated(false)
+    void (async () => {
+      const [cached, prefs] = await Promise.all([
+        loadDedupScanCache(project.path),
+        loadDedupModelPrefs(project.path),
+      ])
       if (cancelled) return
       if (cancelled) return
+
+      const detectFromDisk =
+        prefs?.detectModelId?.trim() ||
+        cached?.modelId?.trim() ||
+        ""
+      const mergeFromDisk =
+        prefs?.mergeModelId?.trim() ||
+        cached?.mergeModelId?.trim() ||
+        ""
+      // Prefer disk prefs on project open (overwrite empty / stale session).
+      if (detectFromDisk) setDetectModelId(detectFromDisk)
+      if (mergeFromDisk) setMergeModelId(mergeFromDisk)
+
+      // Only keep the in-memory session while a scan is actively running.
+      // Otherwise prefer disk cache — it is updated by the merge queue even
+      // when this section was unmounted, so remount must not revive stale groups.
       if (
       if (
         scanStateBelongsToProject(sharedScanState, project) &&
         scanStateBelongsToProject(sharedScanState, project) &&
-        (sharedScanState.scanning || sharedScanState.scanCompleted)
+        sharedScanState.scanning
       ) {
       ) {
         setLocalScanState({ ...sharedScanState })
         setLocalScanState({ ...sharedScanState })
+        setModelsHydrated(true)
         return
         return
       }
       }
       if (!cached || cached.projectId !== project.id) {
       if (!cached || cached.projectId !== project.id) {
-        setLocalScanState(null)
+        if (
+          scanStateBelongsToProject(sharedScanState, project) &&
+          sharedScanState.scanCompleted
+        ) {
+          setLocalScanState({ ...sharedScanState })
+        } else {
+          setLocalScanState(null)
+        }
+        setModelsHydrated(true)
         return
         return
       }
       }
-      if (cached.modelId?.trim()) {
-        setDedupModelId((current) => current || cached.modelId!.trim())
-      }
       const hydrated: MaintenanceScanState = {
       const hydrated: MaintenanceScanState = {
         projectId: project.id,
         projectId: project.id,
         projectPath: normalizeProjectPath(project.path),
         projectPath: normalizeProjectPath(project.path),
@@ -205,7 +245,8 @@ export function MaintenanceSection() {
       }
       }
       replaceSharedScanState(hydrated)
       replaceSharedScanState(hydrated)
       setLocalScanState(hydrated)
       setLocalScanState(hydrated)
-    })
+      setModelsHydrated(true)
+    })()
     return () => {
     return () => {
       cancelled = true
       cancelled = true
     }
     }
@@ -226,13 +267,24 @@ export function MaintenanceSection() {
   }, [project, localScanState, scanState])
   }, [project, localScanState, scanState])
 
 
   useEffect(() => {
   useEffect(() => {
-    setDedupModelId((current) => {
-      if (current.trim()) return current
-      const preferred = defaultLlmModel.trim() || aiChatModel.trim()
-      if (preferred) return preferred
-      return getFirstAvailableModelKey(providerConfigs)
+    if (!modelsHydrated) return
+    const preferred = defaultLlmModel.trim() || aiChatModel.trim()
+    const fallback = preferred || getFirstAvailableModelKey(providerConfigs)
+    setDetectModelId((current) => current.trim() || fallback)
+    setMergeModelId((current) => current.trim() || fallback)
+  }, [modelsHydrated, defaultLlmModel, aiChatModel, providerConfigs])
+
+  // Persist model choices per project after initial hydrate.
+  useEffect(() => {
+    if (!project || !modelsHydrated) return
+    if (!detectModelId.trim() && !mergeModelId.trim()) return
+    void saveDedupModelPrefs(project.path, {
+      detectModelId: detectModelId.trim() || undefined,
+      mergeModelId: mergeModelId.trim() || undefined,
+    }).catch((err) => {
+      console.error("[Maintenance] save model prefs failed:", err)
     })
     })
-  }, [defaultLlmModel, aiChatModel, providerConfigs])
+  }, [project?.id, project?.path, modelsHydrated, detectModelId, mergeModelId])
 
 
   const hasAvailableModels = useMemo(() => {
   const hasAvailableModels = useMemo(() => {
     for (const key of Object.keys(providerConfigs)) {
     for (const key of Object.keys(providerConfigs)) {
@@ -258,6 +310,7 @@ export function MaintenanceSection() {
     taskId: string
     taskId: string
     stage: DedupMergeStage
     stage: DedupMergeStage
   } | null>(null)
   } | null>(null)
+  const [mergeLogs, setMergeLogs] = useState<readonly string[]>([])
   const [enqueueingKey, setEnqueueingKey] = useState<string | null>(null)
   const [enqueueingKey, setEnqueueingKey] = useState<string | null>(null)
   const [mergeErrors, setMergeErrors] = useState<Record<string, string>>({})
   const [mergeErrors, setMergeErrors] = useState<Record<string, string>>({})
 
 
@@ -265,6 +318,7 @@ export function MaintenanceSection() {
     if (!project) {
     if (!project) {
       setTasks([])
       setTasks([])
       setMergeProgress(null)
       setMergeProgress(null)
+      setMergeLogs([])
       return
       return
     }
     }
     let cancelled = false
     let cancelled = false
@@ -273,6 +327,7 @@ export function MaintenanceSection() {
         if (!cancelled) {
         if (!cancelled) {
           setTasks([...getQueue()])
           setTasks([...getQueue()])
           setMergeProgress(getMergeProgress())
           setMergeProgress(getMergeProgress())
+          setMergeLogs([...getMergeLogs()])
         }
         }
       })
       })
       .catch((err) => {
       .catch((err) => {
@@ -281,6 +336,7 @@ export function MaintenanceSection() {
     const id = setInterval(() => {
     const id = setInterval(() => {
       setTasks([...getQueue()])
       setTasks([...getQueue()])
       setMergeProgress(getMergeProgress())
       setMergeProgress(getMergeProgress())
+      setMergeLogs([...getMergeLogs()])
     }, 500)
     }, 500)
     return () => {
     return () => {
       cancelled = true
       cancelled = true
@@ -288,14 +344,25 @@ export function MaintenanceSection() {
     }
     }
   }, [project?.id, project?.path])
   }, [project?.id, project?.path])
 
 
-  const effectiveDedupConfig = useMemo(
+  const effectiveDetectConfig = useMemo(
     () =>
     () =>
-      dedupModelId.trim()
-        ? resolveModelConfig(dedupModelId, llmConfig, providerConfigs)
+      detectModelId.trim()
+        ? resolveModelConfig(detectModelId, llmConfig, providerConfigs)
         : resolveDefaultModel(llmConfig),
         : resolveDefaultModel(llmConfig),
-    [dedupModelId, llmConfig, providerConfigs],
+    [detectModelId, llmConfig, providerConfigs],
+  )
+  const effectiveMergeConfig = useMemo(
+    () =>
+      mergeModelId.trim()
+        ? resolveModelConfig(mergeModelId, llmConfig, providerConfigs)
+        : detectModelId.trim()
+          ? resolveModelConfig(detectModelId, llmConfig, providerConfigs)
+          : resolveDefaultModel(llmConfig),
+    [mergeModelId, detectModelId, llmConfig, providerConfigs],
   )
   )
-  const llmReady = hasUsableLlm(effectiveDedupConfig, providerConfigs)
+  const detectLlmReady = hasUsableLlm(effectiveDetectConfig, providerConfigs)
+  const mergeLlmReady = hasUsableLlm(effectiveMergeConfig, providerConfigs)
+  const llmReady = detectLlmReady
   const projectReady = !!project
   const projectReady = !!project
   const activeScanState = useMemo(() => {
   const activeScanState = useMemo(() => {
     if (!project) return emptyScanState
     if (!project) return emptyScanState
@@ -324,47 +391,63 @@ export function MaintenanceSection() {
       ) {
       ) {
         void saveDedupScanCache(
         void saveDedupScanCache(
           project.path,
           project.path,
-          toScanCache(next, project.id, dedupModelId),
+          toScanCache(next, project.id, detectModelId, mergeModelId),
         ).catch((err) => {
         ).catch((err) => {
           console.error("[Maintenance] save scan cache failed:", err)
           console.error("[Maintenance] save scan cache failed:", err)
         })
         })
       }
       }
     },
     },
-    [project, dedupModelId],
+    [project, detectModelId, mergeModelId],
   )
   )
 
 
   const removeMergedGroup = useCallback(
   const removeMergedGroup = useCallback(
     (slugs: readonly string[]) => {
     (slugs: readonly string[]) => {
-      const active = useWikiStore.getState().project
-      if (!active || !scanStateBelongsToProject(sharedScanState, active)) return
       const key = groupKey(slugs)
       const key = groupKey(slugs)
+      const active = useWikiStore.getState().project
+      // Drop by slug-set whenever present. Do not gate on belongs-check —
+      // that early-return left stale cards after merge (second merge 404s).
       const remaining = sharedScanState.groups.filter((g) => groupKey(g.group.slugs) !== key)
       const remaining = sharedScanState.groups.filter((g) => groupKey(g.group.slugs) !== key)
       if (remaining.length === sharedScanState.groups.length) return
       if (remaining.length === sharedScanState.groups.length) return
-      applyScanState({ groups: remaining })
+      applyScanState({
+        groups: remaining,
+        ...(active && !sharedScanState.projectId
+          ? {
+              projectId: active.id,
+              projectPath: normalizeProjectPath(active.path),
+            }
+          : {}),
+      })
     },
     },
     [applyScanState],
     [applyScanState],
   )
   )
 
 
+  // Keep a live bridge so the merge queue can drop cards while this
+  // section is mounted (and disk cache is always updated by the queue).
+  useEffect(() => {
+    registerDedupScanSessionApi({ removeGroup: removeMergedGroup })
+    return () => registerDedupScanSessionApi(null)
+  }, [removeMergedGroup])
+
   useEffect(() => {
   useEffect(() => {
     if (!project) return
     if (!project) return
     return onDedupMergeComplete((task) => {
     return onDedupMergeComplete((task) => {
       if (task.projectId !== project.id) return
       if (task.projectId !== project.id) return
-      removeMergedGroup(task.group.slugs)
+      // Session API already removes the group; refresh queue chips.
       setTasks([...getQueue()])
       setTasks([...getQueue()])
     })
     })
-  }, [project?.id, removeMergedGroup])
+  }, [project?.id])
 
 
   const handleScan = useCallback(async () => {
   const handleScan = useCallback(async () => {
     if (!project) return
     if (!project) return
     const projectPath = normalizeProjectPath(project.path)
     const projectPath = normalizeProjectPath(project.path)
     const projectId = project.id
     const projectId = project.id
 
 
-    if (!hasUsableLlm(effectiveDedupConfig, providerConfigs)) {
+    if (!hasUsableLlm(effectiveDetectConfig, providerConfigs)) {
       applyScanState({
       applyScanState({
         projectId,
         projectId,
         projectPath,
         projectPath,
         scanning: false,
         scanning: false,
-        scanError: t("settings.sections.maintenance.dedup.selectModel", {
+        scanError: t("settings.sections.maintenance.dedup.selectDetectModel", {
           defaultValue: "请先选择检测模型。",
           defaultValue: "请先选择检测模型。",
         }),
         }),
         groups: [],
         groups: [],
@@ -374,8 +457,26 @@ export function MaintenanceSection() {
       return
       return
     }
     }
 
 
+    const appendScanLog = (message: string) => {
+      const stamp = new Date().toLocaleTimeString()
+      const line = `${stamp}  ${message}`
+      setScanLogs((prev) => [...prev, line])
+      console.log(`[Maintenance Dedup] ${message}`)
+    }
+
     setIsScanning(true)
     setIsScanning(true)
     setScanStage("loading")
     setScanStage("loading")
+    setScanLogs([])
+    appendScanLog(
+      detectModelId.trim()
+        ? `检测模型 id:${detectModelId.trim()}`
+        : "未选择检测模型 id,使用默认模型",
+    )
+    appendScanLog(
+      mergeModelId.trim()
+        ? `合并模型 id:${mergeModelId.trim()}(入队时使用)`
+        : "未单独设置合并模型,入队时回退检测模型",
+    )
     applyScanState({
     applyScanState({
       projectId,
       projectId,
       projectPath,
       projectPath,
@@ -388,8 +489,11 @@ export function MaintenanceSection() {
     try {
     try {
       const { groups: detected, scannedPageCount } = await runDuplicateDetection(
       const { groups: detected, scannedPageCount } = await runDuplicateDetection(
         projectPath,
         projectPath,
-        effectiveDedupConfig,
-        { onProgress: setScanStage },
+        effectiveDetectConfig,
+        {
+          onProgress: setScanStage,
+          onLog: appendScanLog,
+        },
       )
       )
       applyScanState({
       applyScanState({
         projectId,
         projectId,
@@ -404,11 +508,13 @@ export function MaintenanceSection() {
         scannedPageCount,
         scannedPageCount,
       })
       })
     } catch (err) {
     } catch (err) {
+      const message = err instanceof Error ? err.message : String(err)
+      appendScanLog(`失败:${message}`)
       applyScanState({
       applyScanState({
         projectId,
         projectId,
         projectPath,
         projectPath,
         scanning: false,
         scanning: false,
-        scanError: err instanceof Error ? err.message : String(err),
+        scanError: message,
         scanCompleted: false,
         scanCompleted: false,
         scannedPageCount: null,
         scannedPageCount: null,
       })
       })
@@ -416,7 +522,15 @@ export function MaintenanceSection() {
       setIsScanning(false)
       setIsScanning(false)
       setScanStage(null)
       setScanStage(null)
     }
     }
-  }, [project, effectiveDedupConfig, providerConfigs, t, applyScanState])
+  }, [
+    project,
+    effectiveDetectConfig,
+    providerConfigs,
+    t,
+    applyScanState,
+    detectModelId,
+    mergeModelId,
+  ])
 
 
   const handleCanonicalChange = useCallback(
   const handleCanonicalChange = useCallback(
     (idx: number, slug: string) => {
     (idx: number, slug: string) => {
@@ -442,11 +556,40 @@ export function MaintenanceSection() {
         return next
         return next
       })
       })
       try {
       try {
+        const pp = normalizeProjectPath(project.path)
+        const missing: string[] = []
+        for (const slug of entry.group.slugs) {
+          const entityPath = `${pp}/wiki/entities/${slug}.md`
+          const conceptPath = `${pp}/wiki/concepts/${slug}.md`
+          const exists =
+            (await fileExists(entityPath)) || (await fileExists(conceptPath))
+          if (!exists) missing.push(slug)
+        }
+        if (missing.length > 0) {
+          removeMergedGroup(entry.group.slugs)
+          const message = t("settings.sections.maintenance.dedup.pagesMissing", {
+            defaultValue: "部分页面已不存在(可能已合并):{{slugs}}。已从列表移除。",
+            slugs: missing.join(", "),
+          })
+          toast.error(message)
+          return
+        }
+
+        if (!mergeLlmReady) {
+          const message = t("settings.sections.maintenance.dedup.selectMergeModel", {
+            defaultValue: "请先选择合并模型。",
+          })
+          setMergeErrors((prev) => ({ ...prev, [key]: message }))
+          toast.error(message)
+          return
+        }
+
+        const mergeIdForTask = mergeModelId.trim() || detectModelId.trim() || undefined
         await enqueueMerge(
         await enqueueMerge(
           project.id,
           project.id,
           entry.group,
           entry.group,
           entry.canonicalSlug,
           entry.canonicalSlug,
-          dedupModelId.trim() || undefined,
+          mergeIdForTask,
         )
         )
         setTasks([...getQueue()])
         setTasks([...getQueue()])
       } catch (err) {
       } catch (err) {
@@ -458,7 +601,7 @@ export function MaintenanceSection() {
         setEnqueueingKey(null)
         setEnqueueingKey(null)
       }
       }
     },
     },
-    [project, dedupModelId],
+    [project, mergeModelId, detectModelId, mergeLlmReady, removeMergedGroup, t],
   )
   )
 
 
   const handleCancel = useCallback(async (taskId: string) => {
   const handleCancel = useCallback(async (taskId: string) => {
@@ -558,30 +701,61 @@ export function MaintenanceSection() {
             })}
             })}
           </p>
           </p>
         )}
         )}
-        {projectReady && hasAvailableModels && !llmReady && (
+        {projectReady && hasAvailableModels && !detectLlmReady && (
           <p className="text-xs text-amber-700 dark:text-amber-400">
           <p className="text-xs text-amber-700 dark:text-amber-400">
-            {dedupModelId.trim()
+            {detectModelId.trim()
               ? t("settings.sections.maintenance.noLlm", {
               ? t("settings.sections.maintenance.noLlm", {
                   defaultValue: "请先配置大模型提供方。",
                   defaultValue: "请先配置大模型提供方。",
                 })
                 })
-              : t("settings.sections.maintenance.dedup.selectModel", {
+              : t("settings.sections.maintenance.dedup.selectDetectModel", {
                   defaultValue: "请先选择检测模型。",
                   defaultValue: "请先选择检测模型。",
                 })}
                 })}
           </p>
           </p>
         )}
         )}
+        {projectReady && hasAvailableModels && detectLlmReady && !mergeLlmReady && (
+          <p className="text-xs text-amber-700 dark:text-amber-400">
+            {t("settings.sections.maintenance.dedup.selectMergeModel", {
+              defaultValue: "请先选择合并模型。",
+            })}
+          </p>
+        )}
 
 
         {projectReady && hasAvailableModels && (
         {projectReady && hasAvailableModels && (
-          <div className="space-y-1.5">
-            <Label className="text-xs">
-              {t("settings.sections.maintenance.dedup.modelLabel", {
-                defaultValue: "检测模型",
-              })}
-            </Label>
-            <ChatModelSelector
-              value={dedupModelId}
-              onChange={setDedupModelId}
-              disabled={scanning}
-            />
+          <div className="grid gap-3 sm:grid-cols-2">
+            <div className="space-y-1.5">
+              <Label className="text-xs">
+                {t("settings.sections.maintenance.dedup.detectModelLabel", {
+                  defaultValue: "检测模型",
+                })}
+              </Label>
+              <ChatModelSelector
+                value={detectModelId}
+                onChange={setDetectModelId}
+                disabled={scanning}
+              />
+              <p className="text-[11px] leading-relaxed text-muted-foreground">
+                {t("settings.sections.maintenance.dedup.detectModelHint", {
+                  defaultValue: "扫描分组用。实体多时上下文更大,建议用更强、上下文更长的模型。",
+                })}
+              </p>
+            </div>
+            <div className="space-y-1.5">
+              <Label className="text-xs">
+                {t("settings.sections.maintenance.dedup.mergeModelLabel", {
+                  defaultValue: "合并模型",
+                })}
+              </Label>
+              <ChatModelSelector
+                value={mergeModelId}
+                onChange={setMergeModelId}
+                disabled={scanning}
+              />
+              <p className="text-[11px] leading-relaxed text-muted-foreground">
+                {t("settings.sections.maintenance.dedup.mergeModelHint", {
+                  defaultValue: "写合并正文用。输入通常更短,可用相对便宜的模型。",
+                })}
+              </p>
+            </div>
           </div>
           </div>
         )}
         )}
 
 
@@ -623,6 +797,16 @@ export function MaintenanceSection() {
           </div>
           </div>
         )}
         )}
 
 
+        {(scanning || scanLogs.length > 0) && (
+          <ProcessLogPanel
+            title={t("settings.sections.maintenance.dedup.processLogTitle", {
+              defaultValue: "分析过程日志",
+            })}
+            lines={scanLogs}
+            live={scanning}
+          />
+        )}
+
         {scanError && (
         {scanError && (
           <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">
           <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">
             <XCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
             <XCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
@@ -675,6 +859,16 @@ export function MaintenanceSection() {
         pendingPositionByTaskId={pendingPositionByTaskId}
         pendingPositionByTaskId={pendingPositionByTaskId}
       />
       />
 
 
+      {mergeLogs.length > 0 && (
+        <ProcessLogPanel
+          title={t("settings.sections.maintenance.dedup.mergeLogTitle", {
+            defaultValue: "合并过程日志",
+          })}
+          lines={mergeLogs}
+          live={!!mergeProgress}
+        />
+      )}
+
       {visibleGroups.map((entry) => {
       {visibleGroups.map((entry) => {
         const entryKey = groupKey(entry.group.slugs)
         const entryKey = groupKey(entry.group.slugs)
         const idx = groups.findIndex((g) => groupKey(g.group.slugs) === entryKey)
         const idx = groups.findIndex((g) => groupKey(g.group.slugs) === entryKey)
@@ -686,6 +880,7 @@ export function MaintenanceSection() {
             task={task}
             task={task}
             mergeProgress={mergeProgress}
             mergeProgress={mergeProgress}
             enqueueing={enqueueingKey === entryKey}
             enqueueing={enqueueingKey === entryKey}
+            mergeReady={mergeLlmReady}
             mergeError={mergeErrors[entryKey] ?? null}
             mergeError={mergeErrors[entryKey] ?? null}
             pendingPosition={
             pendingPosition={
               task && task.status === "pending"
               task && task.status === "pending"
@@ -706,6 +901,30 @@ export function MaintenanceSection() {
 
 
 // --- helpers ---------------------------------------------------------------
 // --- helpers ---------------------------------------------------------------
 
 
+function ProcessLogPanel({
+  title,
+  lines,
+  live,
+}: {
+  title: string
+  lines: readonly string[]
+  live?: boolean
+}) {
+  return (
+    <div className="space-y-1.5 rounded border border-border/60 bg-background/80 px-2 py-1.5">
+      <div className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground">
+        {live ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
+        <span>{title}</span>
+      </div>
+      <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">
+        {lines.length > 0
+          ? lines.join("\n")
+          : "…"}
+      </pre>
+    </div>
+  )
+}
+
 interface QueueOrphanListProps {
 interface QueueOrphanListProps {
   tasks: readonly DedupTask[]
   tasks: readonly DedupTask[]
   groups: GroupUiEntry[]
   groups: GroupUiEntry[]
@@ -880,6 +1099,7 @@ interface CardProps {
   task: DedupTask | undefined
   task: DedupTask | undefined
   mergeProgress: { taskId: string; stage: DedupMergeStage } | null
   mergeProgress: { taskId: string; stage: DedupMergeStage } | null
   enqueueing: boolean
   enqueueing: boolean
+  mergeReady: boolean
   mergeError: string | null
   mergeError: string | null
   pendingPosition: number
   pendingPosition: number
   onCanonicalChange: (slug: string) => void
   onCanonicalChange: (slug: string) => void
@@ -894,6 +1114,7 @@ function DuplicateGroupCard({
   task,
   task,
   mergeProgress,
   mergeProgress,
   enqueueing,
   enqueueing,
+  mergeReady,
   mergeError,
   mergeError,
   pendingPosition,
   pendingPosition,
   onCanonicalChange,
   onCanonicalChange,
@@ -982,7 +1203,7 @@ function DuplicateGroupCard({
           <div className="flex flex-wrap gap-2">
           <div className="flex flex-wrap gap-2">
             {!task && (
             {!task && (
               <>
               <>
-                <Button size="sm" onClick={onEnqueue} disabled={enqueueing}>
+                <Button size="sm" onClick={onEnqueue} disabled={enqueueing || !mergeReady}>
                   {enqueueing ? (
                   {enqueueing ? (
                     <>
                     <>
                       <Loader2 className="h-3.5 w-3.5 animate-spin" />
                       <Loader2 className="h-3.5 w-3.5 animate-spin" />

+ 9 - 0
src/i18n/en.json

@@ -1097,11 +1097,20 @@
           "scanButton": "Scan for Duplicates",
           "scanButton": "Scan for Duplicates",
           "scanning": "Scanning...",
           "scanning": "Scanning...",
           "modelLabel": "Detection model",
           "modelLabel": "Detection model",
+          "detectModelLabel": "Detection model",
+          "mergeModelLabel": "Merge model",
+          "detectModelHint": "Used for scanning duplicate groups. Large wikis need longer context — prefer a stronger, long-context model.",
+          "mergeModelHint": "Used for writing merged page bodies. Input is usually smaller, so a cheaper model is often enough.",
           "noModel": "Add and enable a model under Settings → LLM first.",
           "noModel": "Add and enable a model under Settings → LLM first.",
           "selectModel": "Select a detection model first.",
           "selectModel": "Select a detection model first.",
+          "selectDetectModel": "Select a detection model first.",
+          "selectMergeModel": "Select a merge model first.",
           "scanningHint": "Scanning entity / concept pages and analyzing with the model. This may take a while…",
           "scanningHint": "Scanning entity / concept pages and analyzing with the model. This may take a while…",
           "scanStageLoading": "Reading entity / concept pages…",
           "scanStageLoading": "Reading entity / concept pages…",
           "scanStageDetecting": "Analyzing with the model…",
           "scanStageDetecting": "Analyzing with the model…",
+          "processLogTitle": "Analysis log",
+          "mergeLogTitle": "Merge log",
+          "pagesMissing": "Some pages no longer exist (possibly already merged): {{slugs}}. Removed from the list.",
           "groupsFound": "Found {{count}} duplicate candidate groups. Review them below before merging.",
           "groupsFound": "Found {{count}} duplicate candidate groups. Review them below before merging.",
           "insufficientPages": "At least 2 entity / concept pages are required to detect duplicates. Currently only {{count}}.",
           "insufficientPages": "At least 2 entity / concept pages are required to detect duplicates. Currently only {{count}}.",
           "noneFound": "No duplicate groups found. The knowledge base is clean.",
           "noneFound": "No duplicate groups found. The knowledge base is clean.",

+ 9 - 0
src/i18n/zh.json

@@ -812,11 +812,20 @@
           "scanButton": "开始扫描重复项",
           "scanButton": "开始扫描重复项",
           "scanning": "扫描中...",
           "scanning": "扫描中...",
           "modelLabel": "检测模型",
           "modelLabel": "检测模型",
+          "detectModelLabel": "检测模型",
+          "mergeModelLabel": "合并模型",
+          "detectModelHint": "扫描分组用。实体多时上下文更大,建议用更强、上下文更长的模型。",
+          "mergeModelHint": "写合并正文用。输入通常更短,可用相对便宜的模型。",
           "noModel": "请先在「设置 → 大语言模型」中添加并启用一个模型。",
           "noModel": "请先在「设置 → 大语言模型」中添加并启用一个模型。",
           "selectModel": "请先选择检测模型。",
           "selectModel": "请先选择检测模型。",
+          "selectDetectModel": "请先选择检测模型。",
+          "selectMergeModel": "请先选择合并模型。",
           "scanningHint": "正在扫描实体 / 概念页面并调用模型分析,可能需要一会儿…",
           "scanningHint": "正在扫描实体 / 概念页面并调用模型分析,可能需要一会儿…",
           "scanStageLoading": "正在读取实体 / 概念页面…",
           "scanStageLoading": "正在读取实体 / 概念页面…",
           "scanStageDetecting": "正在调用模型分析…",
           "scanStageDetecting": "正在调用模型分析…",
+          "processLogTitle": "分析过程日志",
+          "mergeLogTitle": "合并过程日志",
+          "pagesMissing": "部分页面已不存在(可能已合并):{{slugs}}。已从列表移除。",
           "groupsFound": "发现 {{count}} 组重复候选,请在下方确认是否合并。",
           "groupsFound": "发现 {{count}} 组重复候选,请在下方确认是否合并。",
           "insufficientPages": "至少需要 2 个实体 / 概念页面才能检测重复,当前只有 {{count}} 个。",
           "insufficientPages": "至少需要 2 个实体 / 概念页面才能检测重复,当前只有 {{count}} 个。",
           "noneFound": "未发现重复分组,当前资料库很干净。",
           "noneFound": "未发现重复分组,当前资料库很干净。",

+ 43 - 0
src/lib/dedup-model-prefs.spec.ts

@@ -0,0 +1,43 @@
+import { beforeEach, expect, test, vi } from "vitest"
+
+vi.mock("@/commands/fs", () => ({
+  readFile: vi.fn(),
+  writeFile: vi.fn(),
+  fileExists: vi.fn(),
+}))
+
+import { readFile, writeFile, fileExists } from "@/commands/fs"
+import { loadDedupModelPrefs, saveDedupModelPrefs } from "./dedup-model-prefs"
+
+const mockedReadFile = vi.mocked(readFile)
+const mockedWriteFile = vi.mocked(writeFile)
+const mockedFileExists = vi.mocked(fileExists)
+
+beforeEach(() => {
+  vi.clearAllMocks()
+})
+
+test("saveDedupModelPrefs writes detect and merge ids", async () => {
+  await saveDedupModelPrefs("/Project", {
+    detectModelId: "cheap/model",
+    mergeModelId: "strong/model",
+  })
+  expect(mockedWriteFile).toHaveBeenCalledTimes(1)
+  const written = JSON.parse(String(mockedWriteFile.mock.calls[0]?.[1]))
+  expect(written).toEqual({
+    detectModelId: "cheap/model",
+    mergeModelId: "strong/model",
+  })
+})
+
+test("loadDedupModelPrefs accepts legacy modelId as detect", async () => {
+  mockedFileExists.mockResolvedValue(true)
+  mockedReadFile.mockResolvedValue(
+    JSON.stringify({ modelId: "legacy/detect-only" }),
+  )
+  const prefs = await loadDedupModelPrefs("/Project")
+  expect(prefs).toEqual({
+    detectModelId: "legacy/detect-only",
+    mergeModelId: undefined,
+  })
+})

+ 61 - 0
src/lib/dedup-model-prefs.ts

@@ -0,0 +1,61 @@
+/**
+ * Per-project preferences for duplicate-detection / merge models.
+ * Kept separate from the scan-result cache so model choices survive
+ * even when there is no completed scan yet.
+ */
+import { readFile, writeFile, fileExists } from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
+
+const FILE_NAME = ".qmai/dedup-models.json"
+
+export interface DedupModelPrefs {
+  detectModelId?: string
+  mergeModelId?: string
+}
+
+function prefsFilePath(projectPath: string): string {
+  return `${normalizePath(projectPath)}/${FILE_NAME}`
+}
+
+function parsePrefs(raw: unknown): DedupModelPrefs | null {
+  if (!raw || typeof raw !== "object") return null
+  const obj = raw as Record<string, unknown>
+  const detectModelId =
+    typeof obj.detectModelId === "string" ? obj.detectModelId.trim() || undefined : undefined
+  const mergeModelId =
+    typeof obj.mergeModelId === "string" ? obj.mergeModelId.trim() || undefined : undefined
+  // Backward compat: older caches only had modelId (= detect).
+  const legacy =
+    typeof obj.modelId === "string" ? obj.modelId.trim() || undefined : undefined
+  return {
+    detectModelId: detectModelId || legacy,
+    mergeModelId,
+  }
+}
+
+export async function loadDedupModelPrefs(
+  projectPath: string,
+): Promise<DedupModelPrefs | null> {
+  const filePath = prefsFilePath(projectPath)
+  try {
+    if (!(await fileExists(filePath))) return null
+  } catch {
+    return null
+  }
+  try {
+    return parsePrefs(JSON.parse(await readFile(filePath)))
+  } catch {
+    return null
+  }
+}
+
+export async function saveDedupModelPrefs(
+  projectPath: string,
+  prefs: DedupModelPrefs,
+): Promise<void> {
+  const payload: DedupModelPrefs = {
+    detectModelId: prefs.detectModelId?.trim() || undefined,
+    mergeModelId: prefs.mergeModelId?.trim() || undefined,
+  }
+  await writeFile(prefsFilePath(projectPath), JSON.stringify(payload, null, 2))
+}

+ 56 - 0
src/lib/dedup-queue.ts

@@ -21,6 +21,8 @@ import { normalizePath } from "@/lib/path-utils"
 import { getProjectPathById } from "@/lib/project-identity"
 import { getProjectPathById } from "@/lib/project-identity"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { resolveDefaultModel, resolveModelConfig } from "@/lib/novel/model-resolver"
 import { resolveDefaultModel, resolveModelConfig } from "@/lib/novel/model-resolver"
+import { removeGroupFromDedupScanCache } from "@/lib/dedup-scan-cache"
+import { removeMergedDedupGroupFromSession } from "@/lib/dedup-scan-session"
 import { executeMerge, type DedupMergeStage } from "@/lib/dedup-runner"
 import { executeMerge, type DedupMergeStage } from "@/lib/dedup-runner"
 import type { DuplicateGroup } from "@/lib/dedup"
 import type { DuplicateGroup } from "@/lib/dedup"
 
 
@@ -46,6 +48,7 @@ let currentProjectId = ""
 let currentProjectPath = ""
 let currentProjectPath = ""
 let currentAbortController: AbortController | null = null
 let currentAbortController: AbortController | null = null
 let currentMergeProgress: { taskId: string; stage: DedupMergeStage } | null = null
 let currentMergeProgress: { taskId: string; stage: DedupMergeStage } | null = null
+let currentMergeLogs: string[] = []
 
 
 type MergeCompleteListener = (task: DedupTask) => void
 type MergeCompleteListener = (task: DedupTask) => void
 const mergeCompleteListeners = new Set<MergeCompleteListener>()
 const mergeCompleteListeners = new Set<MergeCompleteListener>()
@@ -231,6 +234,11 @@ export function getMergeProgress(): { taskId: string; stage: DedupMergeStage } |
   return currentMergeProgress
   return currentMergeProgress
 }
 }
 
 
+/** In-memory process logs for the currently processing merge (not persisted). */
+export function getMergeLogs(): readonly string[] {
+  return currentMergeLogs
+}
+
 export function getQueueSummary(): {
 export function getQueueSummary(): {
   pending: number
   pending: number
   processing: number
   processing: number
@@ -260,6 +268,7 @@ export function clearQueueState(): void {
   currentProjectPath = ""
   currentProjectPath = ""
   currentAbortController = null
   currentAbortController = null
   currentMergeProgress = null
   currentMergeProgress = null
+  currentMergeLogs = []
 }
 }
 
 
 /**
 /**
@@ -278,6 +287,7 @@ export async function pauseQueue(): Promise<void> {
   }
   }
   processing = false
   processing = false
   currentMergeProgress = null
   currentMergeProgress = null
+  currentMergeLogs = []
 
 
   for (const task of queue) {
   for (const task of queue) {
     if (task.status === "processing") {
     if (task.status === "processing") {
@@ -392,6 +402,9 @@ async function processNext(projectId: string): Promise<void> {
     next.error = "LLM 未配置,请在设置中配置大模型提供方"
     next.error = "LLM 未配置,请在设置中配置大模型提供方"
     processing = false
     processing = false
     currentMergeProgress = null
     currentMergeProgress = null
+    currentMergeLogs = [
+      `${new Date().toLocaleTimeString()}  LLM 未配置,请在设置中配置大模型提供方`,
+    ]
     await saveQueue(pp)
     await saveQueue(pp)
     return
     return
   }
   }
@@ -402,6 +415,19 @@ async function processNext(projectId: string): Promise<void> {
 
 
   currentAbortController = new AbortController()
   currentAbortController = new AbortController()
   currentMergeProgress = { taskId: next.id, stage: "loading" }
   currentMergeProgress = { taskId: next.id, stage: "loading" }
+  currentMergeLogs = []
+
+  const appendMergeLog = (message: string) => {
+    const stamp = new Date().toLocaleTimeString()
+    currentMergeLogs = [...currentMergeLogs, `${stamp}  ${message}`]
+    console.log(`[Dedup Queue] ${message}`)
+  }
+
+    appendMergeLog(
+    next.modelId?.trim()
+      ? `合并模型 id:${next.modelId.trim()}`
+      : "任务未指定合并 modelId,回退默认模型",
+  )
 
 
   try {
   try {
     await executeMerge(pp, next.group, next.canonicalSlug, llmConfig, {
     await executeMerge(pp, next.group, next.canonicalSlug, llmConfig, {
@@ -409,11 +435,22 @@ async function processNext(projectId: string): Promise<void> {
       onProgress: (stage) => {
       onProgress: (stage) => {
         currentMergeProgress = { taskId: next.id, stage }
         currentMergeProgress = { taskId: next.id, stage }
       },
       },
+      onLog: appendMergeLog,
     })
     })
+
+    // Merge already wrote to disk. Always drop the candidate group from
+    // scan cache + live UI session — even if the active project changed
+    // mid-flight (otherwise the card stays and a second merge 404s).
+    await removeGroupFromDedupScanCache(pp, next.group.slugs).catch((err) => {
+      console.error("[Dedup Queue] failed to update scan cache after merge:", err)
+    })
+    removeMergedDedupGroupFromSession(next.group.slugs)
+
     if (currentProjectId !== projectId) return
     if (currentProjectId !== projectId) return
 
 
     currentAbortController = null
     currentAbortController = null
     currentMergeProgress = null
     currentMergeProgress = null
+    // Keep logs until the next task starts so the UI can show completion briefly.
     const completedTask = { ...next }
     const completedTask = { ...next }
     queue = queue.filter((t) => t.id !== next.id)
     queue = queue.filter((t) => t.id !== next.id)
     await saveQueue(pp)
     await saveQueue(pp)
@@ -427,6 +464,25 @@ async function processNext(projectId: string): Promise<void> {
     currentAbortController = null
     currentAbortController = null
     currentMergeProgress = null
     currentMergeProgress = null
     const message = err instanceof Error ? err.message : String(err)
     const message = err instanceof Error ? err.message : String(err)
+    appendMergeLog(`失败:${message}`)
+
+    // Stale candidate after a prior successful merge (UI/list not cleared):
+    // drop it so the user cannot keep retrying a deleted page.
+    const missingPage =
+      /not found on disk|ENOENT|No such file|文件不存在|was the page deleted/i.test(
+        message,
+      )
+    if (missingPage) {
+      await removeGroupFromDedupScanCache(pp, next.group.slugs).catch(() => {})
+      removeMergedDedupGroupFromSession(next.group.slugs)
+      queue = queue.filter((t) => t.id !== next.id)
+      await saveQueue(pp)
+      appendMergeLog("候选页面已不存在,已从列表移除")
+      processing = false
+      processNext(projectId)
+      return
+    }
+
     next.retryCount++
     next.retryCount++
     next.error = message
     next.error = message
 
 

+ 21 - 0
src/lib/dedup-runner.spec.ts

@@ -252,3 +252,24 @@ test("runDuplicateDetection invokes onProgress for loading and detecting", async
 
 
   expect(stages).toEqual(["loading", "detecting"])
   expect(stages).toEqual(["loading", "detecting"])
 })
 })
+
+test("runDuplicateDetection emits process logs via onLog", async () => {
+  const logs: string[] = []
+  mockedListDirectory.mockResolvedValue(wikiTree("/Project", 2))
+  mockedReadFile.mockImplementation(async (path) => {
+    const match = path.match(/page-(\d+)\.md$/)
+    const idx = match?.[1] ?? "0"
+    return entityMarkdown(`Page ${idx}`)
+  })
+
+  await runDuplicateDetection("/Project", testLlmConfig, {
+    onLog: (message) => {
+      logs.push(message)
+    },
+  })
+
+  expect(logs.some((line) => line.includes("开始扫描"))).toBe(true)
+  expect(logs.some((line) => line.includes("openai/gpt-4"))).toBe(true)
+  expect(logs.some((line) => line.includes("已读取 2 个"))).toBe(true)
+  expect(logs.some((line) => line.includes("模型分析完成"))).toBe(true)
+})

+ 42 - 1
src/lib/dedup-runner.ts

@@ -28,15 +28,26 @@ export type DedupMergeStage = "loading" | "merging" | "writing"
 
 
 export type DedupScanStage = "loading" | "detecting"
 export type DedupScanStage = "loading" | "detecting"
 
 
+/** Append-only process log line for UI / console. */
+export type DedupLogFn = (message: string) => void
+
 export interface ExecuteMergeOptions {
 export interface ExecuteMergeOptions {
   signal?: AbortSignal
   signal?: AbortSignal
   onProgress?: (stage: DedupMergeStage) => void
   onProgress?: (stage: DedupMergeStage) => void
+  onLog?: DedupLogFn
 }
 }
 
 
 export interface RunDuplicateDetectionOptions {
 export interface RunDuplicateDetectionOptions {
   signal?: AbortSignal
   signal?: AbortSignal
   summaries?: EntitySummary[]
   summaries?: EntitySummary[]
   onProgress?: (stage: DedupScanStage) => void
   onProgress?: (stage: DedupScanStage) => void
+  onLog?: DedupLogFn
+}
+
+function describeLlm(llmConfig: LlmConfig): string {
+  const provider = llmConfig.provider?.trim() || "unknown"
+  const model = llmConfig.model?.trim() || "unknown"
+  return `${provider}/${model}`
 }
 }
 
 
 export interface DuplicateDetectionResult {
 export interface DuplicateDetectionResult {
@@ -214,21 +225,31 @@ export async function runDuplicateDetection(
   llmConfig: LlmConfig,
   llmConfig: LlmConfig,
   options: RunDuplicateDetectionOptions = {},
   options: RunDuplicateDetectionOptions = {},
 ): Promise<DuplicateDetectionResult> {
 ): Promise<DuplicateDetectionResult> {
+  const log = options.onLog
+  log?.(`开始扫描,模型:${describeLlm(llmConfig)}`)
   options.onProgress?.("loading")
   options.onProgress?.("loading")
+  log?.("正在读取实体 / 概念页面…")
   const summaries =
   const summaries =
     options.summaries ?? (await loadAllEntitySummaries(projectPath))
     options.summaries ?? (await loadAllEntitySummaries(projectPath))
+  log?.(`已读取 ${summaries.length} 个实体 / 概念页面`)
 
 
   if (summaries.length < 2) {
   if (summaries.length < 2) {
+    log?.("页面不足 2 个,跳过模型检测")
     return { groups: [], scannedPageCount: summaries.length }
     return { groups: [], scannedPageCount: summaries.length }
   }
   }
 
 
   options.onProgress?.("detecting")
   options.onProgress?.("detecting")
   const notDup = await loadNotDuplicates(projectPath)
   const notDup = await loadNotDuplicates(projectPath)
+  if (notDup.length > 0) {
+    log?.(`已加载 ${notDup.length} 组「非重复」白名单`)
+  }
+  log?.("正在调用模型分析重复候选…")
   const llm = buildDedupLlmCall(llmConfig)
   const llm = buildDedupLlmCall(llmConfig)
   const groups = await detectDuplicateGroups(summaries, llm, {
   const groups = await detectDuplicateGroups(summaries, llm, {
     signal: options.signal,
     signal: options.signal,
     notDuplicates: notDup,
     notDuplicates: notDup,
   })
   })
+  log?.(`模型分析完成,得到 ${groups.length} 组重复候选`)
   return { groups, scannedPageCount: summaries.length }
   return { groups, scannedPageCount: summaries.length }
 }
 }
 
 
@@ -256,11 +277,17 @@ export async function executeMerge(
   options: ExecuteMergeOptions = {},
   options: ExecuteMergeOptions = {},
 ): Promise<MergeResult> {
 ): Promise<MergeResult> {
   const pp = normalizePath(projectPath)
   const pp = normalizePath(projectPath)
-  const { signal, onProgress } = options
+  const { signal, onProgress, onLog: log } = options
+
+  log?.(
+    `开始合并 ${group.slugs.join(", ")} → ${canonicalSlug},模型:${describeLlm(llmConfig)}`,
+  )
 
 
   // 1. Resolve each group slug to its actual on-disk path + content
   // 1. Resolve each group slug to its actual on-disk path + content
   onProgress?.("loading")
   onProgress?.("loading")
+  log?.("正在读取 wiki 页面…")
   const allPages = await loadAllWikiPages(pp)
   const allPages = await loadAllWikiPages(pp)
+  log?.(`已读取 ${allPages.length} 个 wiki 页面`)
   const pathBySlug = new Map<string, string>()
   const pathBySlug = new Map<string, string>()
   for (const p of allPages) {
   for (const p of allPages) {
     const base = p.path.split("/").pop() ?? ""
     const base = p.path.split("/").pop() ?? ""
@@ -288,6 +315,7 @@ export async function executeMerge(
 
 
   const llm = buildDedupLlmCall(llmConfig)
   const llm = buildDedupLlmCall(llmConfig)
   onProgress?.("merging")
   onProgress?.("merging")
+  log?.("正在调用模型合并正文…")
   const result = await mergeDuplicateGroup(
   const result = await mergeDuplicateGroup(
     {
     {
       group: groupPages,
       group: groupPages,
@@ -297,8 +325,10 @@ export async function executeMerge(
     llm,
     llm,
     { signal },
     { signal },
   )
   )
+  log?.("模型正文合并完成")
 
 
   onProgress?.("writing")
   onProgress?.("writing")
+  log?.("正在写入备份与文件…")
 
 
   // 2. Snapshot backup before any writes. If a write fails partway
   // 2. Snapshot backup before any writes. If a write fails partway
   //    through, the user has the pre-merge state intact in
   //    through, the user has the pre-merge state intact in
@@ -309,14 +339,19 @@ export async function executeMerge(
     const sanitized = b.path.replace(/[/\\]/g, "_")
     const sanitized = b.path.replace(/[/\\]/g, "_")
     await writeFile(`${backupDir}/${sanitized}`, b.content)
     await writeFile(`${backupDir}/${sanitized}`, b.content)
   })
   })
+  log?.(`已备份 ${result.backup.length} 个文件 → ${backupDir}`)
 
 
   // 3. Write canonical
   // 3. Write canonical
   await writeFile(`${pp}/${result.canonicalPath}`, result.canonicalContent)
   await writeFile(`${pp}/${result.canonicalPath}`, result.canonicalContent)
+  log?.(`已写入主条目 ${result.canonicalPath}`)
 
 
   // 4. Apply rewrites
   // 4. Apply rewrites
   await runWithConcurrency(result.rewrites, WIKI_WRITE_CONCURRENCY, async (r) => {
   await runWithConcurrency(result.rewrites, WIKI_WRITE_CONCURRENCY, async (r) => {
     await writeFile(`${pp}/${r.path}`, r.newContent)
     await writeFile(`${pp}/${r.path}`, r.newContent)
   })
   })
+  if (result.rewrites.length > 0) {
+    log?.(`已改写 ${result.rewrites.length} 个交叉引用页面`)
+  }
 
 
   // 5. Delete merged-away pages
   // 5. Delete merged-away pages
   await runWithConcurrency(result.pagesToDelete, WIKI_WRITE_CONCURRENCY, async (dead) => {
   await runWithConcurrency(result.pagesToDelete, WIKI_WRITE_CONCURRENCY, async (dead) => {
@@ -325,8 +360,12 @@ export async function executeMerge(
     } catch (err) {
     } catch (err) {
       // Surface as a warning — backup is still safe.
       // Surface as a warning — backup is still safe.
       console.warn(`[dedup] failed to delete ${dead}: ${err}`)
       console.warn(`[dedup] failed to delete ${dead}: ${err}`)
+      log?.(`删除失败(已有备份):${dead}`)
     }
     }
   })
   })
+  if (result.pagesToDelete.length > 0) {
+    log?.(`已删除 ${result.pagesToDelete.length} 个合并掉的页面`)
+  }
 
 
   // 6. Rewrite index.md to drop merged-away entries.
   // 6. Rewrite index.md to drop merged-away entries.
   const indexPath = `${pp}/wiki/index.md`
   const indexPath = `${pp}/wiki/index.md`
@@ -338,8 +377,10 @@ export async function executeMerge(
     const rewritten = rewriteIndexMd(indexEntry.content, removed)
     const rewritten = rewriteIndexMd(indexEntry.content, removed)
     if (rewritten !== indexEntry.content) {
     if (rewritten !== indexEntry.content) {
       await writeFile(indexPath, rewritten)
       await writeFile(indexPath, rewritten)
+      log?.("已更新 wiki/index.md")
     }
     }
   }
   }
 
 
+  log?.("合并完成")
   return result
   return result
 }
 }

+ 97 - 0
src/lib/dedup-scan-cache.spec.ts

@@ -0,0 +1,97 @@
+import { beforeEach, expect, test, vi } from "vitest"
+
+vi.mock("@/commands/fs", () => ({
+  readFile: vi.fn(),
+  writeFile: vi.fn(),
+  fileExists: vi.fn(),
+}))
+
+import { readFile, writeFile, fileExists } from "@/commands/fs"
+import {
+  loadDedupScanCache,
+  removeGroupFromDedupScanCache,
+  saveDedupScanCache,
+} from "./dedup-scan-cache"
+
+const mockedReadFile = vi.mocked(readFile)
+const mockedWriteFile = vi.mocked(writeFile)
+const mockedFileExists = vi.mocked(fileExists)
+
+beforeEach(() => {
+  vi.clearAllMocks()
+})
+
+test("removeGroupFromDedupScanCache drops matching group and rewrites file", async () => {
+  mockedFileExists.mockResolvedValue(true)
+  mockedReadFile.mockResolvedValue(
+    JSON.stringify({
+      version: 1,
+      projectId: "p1",
+      scannedAt: 100,
+      scannedPageCount: 4,
+      modelId: "openai/gpt-4",
+      groups: [
+        {
+          group: { slugs: ["alpha", "beta"], reason: "same", confidence: "high" },
+          canonicalSlug: "alpha",
+          skipped: false,
+        },
+        {
+          group: { slugs: ["gamma", "delta"], reason: "same", confidence: "medium" },
+          canonicalSlug: "gamma",
+          skipped: false,
+        },
+      ],
+    }),
+  )
+
+  const removed = await removeGroupFromDedupScanCache("/Project", ["beta", "alpha"])
+  expect(removed).toBe(true)
+  expect(mockedWriteFile).toHaveBeenCalledTimes(1)
+  const written = JSON.parse(String(mockedWriteFile.mock.calls[0]?.[1]))
+  expect(written.groups).toHaveLength(1)
+  expect(written.groups[0].group.slugs).toEqual(["gamma", "delta"])
+})
+
+test("removeGroupFromDedupScanCache is a no-op when group absent", async () => {
+  mockedFileExists.mockResolvedValue(true)
+  mockedReadFile.mockResolvedValue(
+    JSON.stringify({
+      version: 1,
+      projectId: "p1",
+      scannedAt: 100,
+      scannedPageCount: 2,
+      groups: [
+        {
+          group: { slugs: ["gamma", "delta"], reason: "same", confidence: "low" },
+          canonicalSlug: "gamma",
+          skipped: false,
+        },
+      ],
+    }),
+  )
+
+  const removed = await removeGroupFromDedupScanCache("/Project", ["alpha", "beta"])
+  expect(removed).toBe(false)
+  expect(mockedWriteFile).not.toHaveBeenCalled()
+})
+
+test("save + load round-trip keeps modelId", async () => {
+  mockedFileExists.mockResolvedValue(true)
+  let stored = ""
+  mockedWriteFile.mockImplementation(async (_path, content) => {
+    stored = String(content)
+  })
+  mockedReadFile.mockImplementation(async () => stored)
+
+  await saveDedupScanCache("/Project", {
+    version: 1,
+    projectId: "p1",
+    scannedAt: 1,
+    scannedPageCount: 0,
+    modelId: "x/y",
+    groups: [],
+  })
+  const loaded = await loadDedupScanCache("/Project")
+  expect(loaded?.modelId).toBe("x/y")
+})

+ 31 - 0
src/lib/dedup-scan-cache.ts

@@ -19,7 +19,10 @@ export interface DedupScanCache {
   projectId: string
   projectId: string
   scannedAt: number
   scannedAt: number
   scannedPageCount: number | null
   scannedPageCount: number | null
+  /** Detection model used for the last scan. */
   modelId?: string
   modelId?: string
+  /** Merge model preferred when enqueueing from these results. */
+  mergeModelId?: string
   groups: DedupScanCacheEntry[]
   groups: DedupScanCacheEntry[]
 }
 }
 
 
@@ -70,12 +73,15 @@ function parseCache(raw: unknown): DedupScanCache | null {
     .map(parseEntry)
     .map(parseEntry)
     .filter((entry): entry is DedupScanCacheEntry => entry !== null)
     .filter((entry): entry is DedupScanCacheEntry => entry !== null)
   const modelId = typeof obj.modelId === "string" ? obj.modelId : undefined
   const modelId = typeof obj.modelId === "string" ? obj.modelId : undefined
+  const mergeModelId =
+    typeof obj.mergeModelId === "string" ? obj.mergeModelId : undefined
   return {
   return {
     version: 1,
     version: 1,
     projectId: obj.projectId,
     projectId: obj.projectId,
     scannedAt: obj.scannedAt,
     scannedAt: obj.scannedAt,
     scannedPageCount,
     scannedPageCount,
     modelId,
     modelId,
+    mergeModelId,
     groups,
     groups,
   }
   }
 }
 }
@@ -103,3 +109,28 @@ export async function saveDedupScanCache(
 ): Promise<void> {
 ): Promise<void> {
   await writeFile(cacheFilePath(projectPath), JSON.stringify(cache, null, 2))
   await writeFile(cacheFilePath(projectPath), JSON.stringify(cache, null, 2))
 }
 }
+
+function groupKey(slugs: readonly string[]): string {
+  return [...slugs].map((s) => s.toLowerCase()).sort().join(",")
+}
+
+/**
+ * Drop a merged candidate group from the on-disk scan cache.
+ * Returns true when the cache was rewritten.
+ */
+export async function removeGroupFromDedupScanCache(
+  projectPath: string,
+  slugs: readonly string[],
+): Promise<boolean> {
+  const cached = await loadDedupScanCache(projectPath)
+  if (!cached) return false
+  const key = groupKey(slugs)
+  const remaining = cached.groups.filter((g) => groupKey(g.group.slugs) !== key)
+  if (remaining.length === cached.groups.length) return false
+  await saveDedupScanCache(projectPath, {
+    ...cached,
+    groups: remaining,
+    scannedAt: Date.now(),
+  })
+  return true
+}

+ 31 - 0
src/lib/dedup-scan-session.ts

@@ -0,0 +1,31 @@
+/**
+ * In-memory dedup scan session bridge.
+ *
+ * Merge completion must remove candidate groups even when the Maintenance
+ * UI is unmounted (settings tab switch). The queue calls
+ * `removeMergedDedupGroupFromSession` after a successful merge; the
+ * Maintenance section registers the live session updater on mount.
+ */
+export type DedupScanSessionApi = {
+  /** Remove a candidate group from the live scan list + persist cache. */
+  removeGroup: (slugs: readonly string[]) => void
+}
+
+let sessionApi: DedupScanSessionApi | null = null
+
+export function registerDedupScanSessionApi(
+  api: DedupScanSessionApi | null,
+): void {
+  sessionApi = api
+}
+
+/** Best-effort live UI update. No-op when Maintenance section is unmounted. */
+export function removeMergedDedupGroupFromSession(
+  slugs: readonly string[],
+): void {
+  try {
+    sessionApi?.removeGroup(slugs)
+  } catch (err) {
+    console.error("[Dedup Scan Session] removeGroup failed:", err)
+  }
+}

+ 46 - 17
src/lib/embedding.ts

@@ -24,10 +24,35 @@ import { readFile, listDirectory } from "@/commands/fs"
 import { invoke } from "@tauri-apps/api/core"
 import { invoke } from "@tauri-apps/api/core"
 import type { EmbeddingConfig } from "@/stores/wiki-store"
 import type { EmbeddingConfig } from "@/stores/wiki-store"
 import type { FileNode } from "@/types/wiki"
 import type { FileNode } from "@/types/wiki"
+import { mapWithConcurrency } from "@/lib/async-pool"
 import { normalizePath } from "@/lib/path-utils"
 import { normalizePath } from "@/lib/path-utils"
 import { getHttpFetch, isFetchNetworkError } from "@/lib/tauri-fetch"
 import { getHttpFetch, isFetchNetworkError } from "@/lib/tauri-fetch"
 import { chunkMarkdown, type Chunk } from "@/lib/text-chunker"
 import { chunkMarkdown, type Chunk } from "@/lib/text-chunker"
 
 
+/**
+ * Bound concurrent page reindexes. Wider than this mostly burns
+ * embedding-API rate limits / Tauri IPC without wall-clock wins;
+ * narrower leaves HTTP idle. 8 matches the search-read / caption
+ * pool sweet spot used elsewhere.
+ */
+const EMBED_ALL_CONCURRENCY = 8
+
+/**
+ * Serialize LanceDB v2 mutations. Concurrent delete+add on the same
+ * table races (especially first-create), so every upsert/delete
+ * chains through this promise queue regardless of caller.
+ */
+let vectorWriteChain: Promise<void> = Promise.resolve()
+
+function withVectorWriteLock<T>(fn: () => Promise<T>): Promise<T> {
+  const run = vectorWriteChain.then(fn, fn)
+  vectorWriteChain = run.then(
+    () => undefined,
+    () => undefined,
+  )
+  return run
+}
+
 // ── Error surfacing ──────────────────────────────────────────────────────
 // ── Error surfacing ──────────────────────────────────────────────────────
 
 
 /**
 /**
@@ -277,16 +302,18 @@ async function vectorUpsertChunks(
   chunks: ChunkUpsertInput[],
   chunks: ChunkUpsertInput[],
 ): Promise<void> {
 ): Promise<void> {
   const pp = normalizePath(projectPath)
   const pp = normalizePath(projectPath)
-  await invoke("vector_upsert_chunks", {
-    projectPath: pp,
-    pageId,
-    chunks: chunks.map((c) => ({
-      chunk_index: c.chunkIndex,
-      chunk_text: c.chunkText,
-      heading_path: c.headingPath,
-      embedding: c.embedding.map((v) => Math.fround(v)),
-    })),
-  })
+  await withVectorWriteLock(() =>
+    invoke("vector_upsert_chunks", {
+      projectPath: pp,
+      pageId,
+      chunks: chunks.map((c) => ({
+        chunk_index: c.chunkIndex,
+        chunk_text: c.chunkText,
+        heading_path: c.headingPath,
+        embedding: c.embedding.map((v) => Math.fround(v)),
+      })),
+    }),
+  )
 }
 }
 
 
 interface ChunkSearchResult {
 interface ChunkSearchResult {
@@ -313,10 +340,12 @@ async function vectorSearchChunks(
 
 
 async function vectorDeletePage(projectPath: string, pageId: string): Promise<void> {
 async function vectorDeletePage(projectPath: string, pageId: string): Promise<void> {
   const pp = normalizePath(projectPath)
   const pp = normalizePath(projectPath)
-  await invoke("vector_delete_page", {
-    projectPath: pp,
-    pageId,
-  })
+  await withVectorWriteLock(() =>
+    invoke("vector_delete_page", {
+      projectPath: pp,
+      pageId,
+    }),
+  )
 }
 }
 
 
 async function vectorCountChunks(projectPath: string): Promise<number> {
 async function vectorCountChunks(projectPath: string): Promise<number> {
@@ -458,7 +487,7 @@ export async function embedAllPages(
   walk(tree)
   walk(tree)
 
 
   let done = 0
   let done = 0
-  for (const file of mdFiles) {
+  await mapWithConcurrency(mdFiles, EMBED_ALL_CONCURRENCY, async (file) => {
     try {
     try {
       const content = await readFile(file.path)
       const content = await readFile(file.path)
       const titleMatch = content.match(/^---\n[\s\S]*?^title:\s*["']?(.+?)["']?\s*$/m)
       const titleMatch = content.match(/^---\n[\s\S]*?^title:\s*["']?(.+?)["']?\s*$/m)
@@ -468,8 +497,8 @@ export async function embedAllPages(
       // skip — individual file failure doesn't halt the batch
       // skip — individual file failure doesn't halt the batch
     }
     }
     done++
     done++
-    if (onProgress) onProgress(done, mdFiles.length)
-  }
+    onProgress?.(done, mdFiles.length)
+  })
 
 
   return done
   return done
 }
 }

+ 16 - 0
src/lib/hardcoded-ui-chinese-i18n.test.ts

@@ -183,6 +183,22 @@ describe("user-facing hardcoded chinese strings", () => {
     expect(maintenanceSection).toContain('defaultValue: "请先打开一个项目。"')
     expect(maintenanceSection).toContain('defaultValue: "请先打开一个项目。"')
     expect(maintenanceSection).toContain('t("settings.sections.maintenance.noLlm", {')
     expect(maintenanceSection).toContain('t("settings.sections.maintenance.noLlm", {')
     expect(maintenanceSection).toContain('defaultValue: "请先配置大模型提供方。"')
     expect(maintenanceSection).toContain('defaultValue: "请先配置大模型提供方。"')
+    expect(maintenanceSection).toContain('t("settings.sections.maintenance.dedup.scanStageDetecting", {')
+    expect(maintenanceSection).toContain('defaultValue: "正在调用模型分析…"')
+    expect(maintenanceSection).toContain('t("settings.sections.maintenance.dedup.processLogTitle", {')
+    expect(maintenanceSection).toContain('defaultValue: "分析过程日志"')
+    expect(maintenanceSection).toContain('t("settings.sections.maintenance.dedup.mergeLogTitle", {')
+    expect(maintenanceSection).toContain('defaultValue: "合并过程日志"')
+    expect(maintenanceSection).toContain('t("settings.sections.maintenance.dedup.detectModelLabel", {')
+    expect(maintenanceSection).toContain('defaultValue: "检测模型"')
+    expect(maintenanceSection).toContain('t("settings.sections.maintenance.dedup.mergeModelLabel", {')
+    expect(maintenanceSection).toContain('defaultValue: "合并模型"')
+    expect(maintenanceSection).toContain('t("settings.sections.maintenance.dedup.detectModelHint", {')
+    expect(maintenanceSection).toContain('defaultValue: "扫描分组用。实体多时上下文更大,建议用更强、上下文更长的模型。"')
+    expect(maintenanceSection).toContain('t("settings.sections.maintenance.dedup.mergeModelHint", {')
+    expect(maintenanceSection).toContain('defaultValue: "写合并正文用。输入通常更短,可用相对便宜的模型。"')
+    expect(maintenanceSection).toContain('t("settings.sections.maintenance.dedup.selectDetectModel", {')
+    expect(maintenanceSection).toContain('t("settings.sections.maintenance.dedup.selectMergeModel", {')
     expect(maintenanceSection).toContain('t("settings.sections.maintenance.dedup.scanButton", {')
     expect(maintenanceSection).toContain('t("settings.sections.maintenance.dedup.scanButton", {')
     expect(maintenanceSection).toContain('defaultValue: "开始扫描重复项"')
     expect(maintenanceSection).toContain('defaultValue: "开始扫描重复项"')
     expect(maintenanceSection).toContain('t("settings.sections.maintenance.dedup.noneFound", {')
     expect(maintenanceSection).toContain('t("settings.sections.maintenance.dedup.noneFound", {')