Pārlūkot izejas kodu

refactor: 移除 PDF/PDFium 支持与原始资料导入 UI

卸掉 pdfium-render 与抽图/抽文本入口,read_file 对残留 .pdf 返回不支持占位。
删除未挂载的资料导入侧栏和已隐藏的监控/定时导入设置,保存设置不再启动监视。
大纲导入白名单与监视默认列表去掉 pdf;Office 解析与章节/大纲导入保留。

Co-authored-by: darknessomi <darknessomi@users.noreply.github.com>
Cursor Agent 3 nedēļas atpakaļ
vecāks
revīzija
f44093997a
37 mainītis faili ar 55 papildinājumiem un 2426 dzēšanām
  1. 1 1
      .agents/AGENTS.md
  2. 0 21
      .github/workflows/build.yml
  3. 0 19
      .github/workflows/ci.yml
  4. 2 2
      README.md
  5. 1 13
      scripts/build-portable.mjs
  6. 3 5
      src-tauri/Cargo.toml
  7. 8 422
      src-tauri/src/commands/extract_images.rs
  8. 1 1
      src-tauri/src/commands/file_sync.rs
  9. 9 371
      src-tauri/src/commands/fs.rs
  10. 0 5
      src-tauri/src/lib.rs
  11. 0 5
      src-tauri/src/main.rs
  12. 1 1
      src-tauri/src/panic_guard.rs
  13. 0 5
      src-tauri/tauri.linux.conf.json
  14. 0 7
      src-tauri/tauri.macos.conf.json
  15. 1 1
      src-tauri/tauri.windows.conf.json
  16. 1 61
      src/App.tsx
  17. 0 2
      src/components/editor/file-preview.tsx
  18. 0 2
      src/components/editor/frontmatter-panel.tsx
  19. 2 2
      src/components/search/search-view.tsx
  20. 4 4
      src/components/settings/sections/multimodal-section.tsx
  21. 0 169
      src/components/settings/sections/scheduled-import-section.tsx
  22. 0 239
      src/components/settings/sections/source-watch-section.tsx
  23. 1 9
      src/components/settings/settings-types.ts
  24. 1 90
      src/components/settings/settings-view.tsx
  25. 0 806
      src/components/sources/source-sidebar.tsx
  26. 2 2
      src/i18n/en.json
  27. 2 2
      src/i18n/zh.json
  28. 8 11
      src/lib/extract-source-images.ts
  29. 1 4
      src/lib/file-types.ts
  30. 0 24
      src/lib/hardcoded-ui-chinese-i18n.test.ts
  31. 1 1
      src/lib/ingest.ts
  32. 0 1
      src/lib/novel/outline-import.ts
  33. 1 1
      src/lib/raw-source-resolver.ts
  34. 3 3
      src/lib/search.ts
  35. 0 112
      src/lib/source-lifecycle.ts
  36. 1 1
      src/lib/source-watch-config.ts
  37. 0 1
      src/lib/source-watch-defaults.json

+ 1 - 1
.agents/AGENTS.md

@@ -29,7 +29,7 @@ QMAI 是**长篇小说记忆型 AI 写作桌面系统**(Tauri 2 + React 19 + T
 | 状态 | `src/stores/` | Zustand(wiki-store、review-store 等) |
 | 小说引擎 | `src/lib/novel/` | 记忆、上下文、摄取、审查、图谱、拆书 |
 | 通用工具 | `src/lib/` | LLM 客户端、搜索、嵌入、持久化 |
-| 后端 | `src-tauri/` | 文件系统、向量存储、PDF、进程 |
+| 后端 | `src-tauri/` | 文件系统、向量存储、进程 |
 
 小说相关逻辑优先放 `src/lib/novel/`,通过 `mod.ts` 导出;不要散落在 UI 组件里。
 

+ 0 - 21
.github/workflows/build.yml

@@ -26,8 +26,6 @@ jobs:
             runner: windows-latest
             rust_targets: ""
             tauri_args: "--bundles nsis"
-            pdfium_asset: ""
-            pdfium_library: ""
             artifact_globs: |
               src-tauri/target/**/release/bundle/nsis/*.exe
               src-tauri/target/**/release/bundle/nsis/*.exe.sig
@@ -37,8 +35,6 @@ jobs:
             runner: macos-latest
             rust_targets: "aarch64-apple-darwin"
             tauri_args: "--target aarch64-apple-darwin --bundles dmg,app --no-sign"
-            pdfium_asset: "pdfium-mac-arm64.tgz"
-            pdfium_library: "libpdfium.dylib"
             artifact_globs: |
               src-tauri/target/**/release/bundle/dmg/*.dmg
               release-assets/*.app.tar.gz
@@ -48,8 +44,6 @@ jobs:
             runner: macos-15-intel
             rust_targets: "x86_64-apple-darwin"
             tauri_args: "--target x86_64-apple-darwin --bundles dmg,app --no-sign"
-            pdfium_asset: "pdfium-mac-x64.tgz"
-            pdfium_library: "libpdfium.dylib"
             artifact_globs: |
               src-tauri/target/**/release/bundle/dmg/*.dmg
               release-assets/*.app.tar.gz
@@ -59,8 +53,6 @@ jobs:
             runner: ubuntu-22.04
             rust_targets: ""
             tauri_args: "--bundles deb,appimage"
-            pdfium_asset: "pdfium-linux-x64.tgz"
-            pdfium_library: "libpdfium.so"
             artifact_globs: |
               src-tauri/target/release/bundle/deb/*.deb
               src-tauri/target/release/bundle/appimage/*.AppImage
@@ -100,19 +92,6 @@ jobs:
           sudo apt-get update
           sudo apt-get install -y libwebkit2gtk-4.1-dev librsvg2-dev patchelf protobuf-compiler libgtk-3-dev libayatana-appindicator3-dev
 
-      - name: Install PDFium binary
-        if: matrix.pdfium_asset != ''
-        shell: bash
-        run: |
-          set -euo pipefail
-          mkdir -p "$RUNNER_TEMP/pdfium" src-tauri/pdfium
-          curl -L --fail --retry 5 --retry-delay 2 \
-            -o "$RUNNER_TEMP/pdfium.tgz" \
-            "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/${{ matrix.pdfium_asset }}"
-          tar -xzf "$RUNNER_TEMP/pdfium.tgz" -C "$RUNNER_TEMP/pdfium"
-          cp "$RUNNER_TEMP/pdfium/lib/${{ matrix.pdfium_library }}" "src-tauri/pdfium/${{ matrix.pdfium_library }}"
-          ls -la src-tauri/pdfium
-
       - name: Setup Node.js
         uses: actions/setup-node@v4
         with:

+ 0 - 19
.github/workflows/ci.yml

@@ -13,14 +13,8 @@ jobs:
       matrix:
         include:
           - platform: macos-latest
-            pdfium_asset: pdfium-mac-arm64.tgz
-            pdfium_library: libpdfium.dylib
           - platform: ubuntu-22.04
-            pdfium_asset: pdfium-linux-x64.tgz
-            pdfium_library: libpdfium.so
           - platform: windows-latest
-            pdfium_asset: ""
-            pdfium_library: ""
 
     runs-on: ${{ matrix.platform }}
 
@@ -45,19 +39,6 @@ jobs:
         if: matrix.platform == 'windows-latest'
         run: choco install protoc -y
 
-      - name: Install PDFium binary
-        if: matrix.pdfium_asset != ''
-        shell: bash
-        run: |
-          set -euo pipefail
-          mkdir -p "$RUNNER_TEMP/pdfium" src-tauri/pdfium
-          curl -L --fail --retry 5 --retry-delay 2 \
-            -o "$RUNNER_TEMP/pdfium.tgz" \
-            "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/${{ matrix.pdfium_asset }}"
-          tar -xzf "$RUNNER_TEMP/pdfium.tgz" -C "$RUNNER_TEMP/pdfium"
-          cp "$RUNNER_TEMP/pdfium/lib/${{ matrix.pdfium_library }}" "src-tauri/pdfium/${{ matrix.pdfium_library }}"
-          ls -la src-tauri/pdfium
-
       - name: Rust cache
         uses: Swatinem/rust-cache@v2
         with:

+ 2 - 2
README.md

@@ -298,7 +298,7 @@ AI 生成的章节默认为草稿状态。草稿支持预览、编辑、重新
 │              Tauri IPC 通信层                     │
 ├─────────────────────────────────────────────────┤
 │                Rust 后端命令层                     │
-│  文件系统 / 向量存储 / PDF提取 / 进程管理 / 代理  
+│  文件系统 / 向量存储 / Office解析 / 进程管理 / 代理
 ├─────────────────────────────────────────────────┤
 │                  本地文件系统                      │
 │        项目目录 (Markdown + JSON + 向量索引)      │
@@ -320,7 +320,7 @@ AI 生成的章节默认为草稿状态。草稿支持预览、编辑、重新
 | 拼音匹配 | pinyin-pro |
 | 后端 | Rust |
 | 向量存储 | LanceDB |
-| PDF 解析 | PDFium |
+| Office 解析 | docx-rs / calamine |
 | 自动更新 | tauri-plugin-updater |
 | CI/CD | GitHub Actions |
 

+ 1 - 13
scripts/build-portable.mjs

@@ -1,7 +1,7 @@
 import { cpSync, existsSync, mkdirSync, rmSync, statSync, writeFileSync, renameSync } from "node:fs"
 import { readFile } from "node:fs/promises"
 import { execSync } from "node:child_process"
-import { dirname, resolve } from "node:path"
+import { resolve } from "node:path"
 import { fileURLToPath } from "node:url"
 
 const root = resolve(dirname(fileURLToPath(import.meta.url)), "..")
@@ -16,12 +16,8 @@ const isStorySimulationBranch = currentBranch === "feature-story-simulation"
 const releaseExe = resolve(root, "src-tauri/target/release/qmai.exe")
 const portableDevExe = resolve(root, "src-tauri/target/portable-dev/qmai.exe")
 const sourceExe = existsSync(portableDevExe) ? portableDevExe : releaseExe
-const releasePdfium = resolve(root, "src-tauri/target/release/pdfium/pdfium.dll")
-const portableDevPdfium = resolve(root, "src-tauri/target/portable-dev/pdfium/pdfium.dll")
-const sourcePdfium = existsSync(portableDevPdfium) ? portableDevPdfium : releasePdfium
 const outDir = resolve(root, "release-portable")
 const outExe = resolve(outDir, isStorySimulationBranch ? "QMaiWrite-剧情推演版.exe" : "QMaiWrite.exe")
-const outPdfium = resolve(outDir, "pdfium/pdfium.dll")
 const outSkillDir = resolve(outDir, "skills")
 const manifest = resolve(outDir, "version-info.json")
 const backupDir = resolve(root, "release-portable-backup")
@@ -65,13 +61,6 @@ try {
   console.warn("警告:无法完全替换正在运行的 exe,保留旧版本,但已更新其他资源")
 }
 
-if (existsSync(sourcePdfium)) {
-  mkdirSync(dirname(outPdfium), { recursive: true })
-  try {
-    cpSync(sourcePdfium, outPdfium)
-  } catch {}
-}
-
 // 复制 skills 文件夹到便携版目录
 const sourceSkillDir = resolve(root, "skills")
 if (existsSync(sourceSkillDir)) {
@@ -90,7 +79,6 @@ writeFileSync(manifest, JSON.stringify({
   sourceExe,
   portableExe: outExe,
   exeBytes: exeStat.size,
-  includesPdfium: existsSync(outPdfium),
   includesSkills: existsSync(outSkillDir),
   ...(isStorySimulationBranch ? { variant: "story-simulation", branch: currentBranch } : {}),
 }, null, 2), "utf8")

+ 3 - 5
src-tauri/Cargo.toml

@@ -23,7 +23,6 @@ serde_json = "1"
 chrono = { version = "0.4", features = ["clock"] }
 tauri-plugin-dialog = "2.7.2"
 tauri-plugin-updater = "2"
-pdfium-render = "0.9"
 tauri-plugin-store = "2.4.4"
 tauri-plugin-http = { version = "2", features = ["unsafe-headers"] }
 zip = "2"
@@ -43,10 +42,9 @@ tokio = { version = "1", features = ["process", "io-util", "sync", "macros", "rt
 which = "7"
 uuid = { version = "1", features = ["v4"] }
 toml = "0.8"
-# Multimodal image extraction (Phase 1):
-#   `image` re-encodes pdfium's raw bitmap output to PNG so the IPC
-#   payload is self-contained (the frontend doesn't need to know
-#   about pdfium's internal RGBA layout).
+# Multimodal image extraction (Office docs):
+#   `image` decodes embedded PPTX/DOCX raster bytes so we can apply
+#   the size filter before writing them to disk.
 #   `base64` serializes binary image data for Tauri IPC, which is
 #   JSON-only — Vec<u8> roundtrips ~1.33× larger than raw bytes but
 #   that's acceptable for our ~MB-scale per-image payloads.

+ 8 - 422
src-tauri/src/commands/extract_images.rs

@@ -1,4 +1,4 @@
-//! Image extraction from PDF / PPTX / DOCX (Phase 1 of the multimodal
+//! Image extraction from PPTX / DOCX (Phase 1 of the multimodal
 //! pipeline; see plans/multimodal-images.md).
 //!
 //! NO LLM calls happen in this module. Output is the raw extracted
@@ -6,11 +6,6 @@
 //!   - the vision-caption helper (Phase 3) which sends them to a VLM
 //!   - direct write-to-disk in `wiki/media/<source-slug>/`
 //!
-//! This module is intentionally separate from `fs.rs` (which already
-//! has its own pdfium binding lifecycle for text extraction). PDF
-//! image extraction reuses the same global `Pdfium` instance via the
-//! `pdfium()` helper exposed by `fs.rs`.
-//!
 //! Outputs are deterministic for a given input file (same image
 //! ordering, same `index` per image), so the dedup cache in Phase 3
 //! can key purely on the SHA-256 of `data_base64`.
@@ -34,7 +29,7 @@ pub struct ExtractOptions {
     pub min_width: u32,
     pub min_height: u32,
     /// Hard cap on the number of images returned per document. A
-    /// pathological 5000-image PDF would otherwise blow up memory
+    /// pathological 5000-image document would otherwise blow up memory
     /// (each image base64'd is ~MB-scale) AND blow up downstream VLM
     /// cost during Phase 3.
     pub max_images: usize,
@@ -58,10 +53,10 @@ pub struct ExtractedImage {
     /// `wiki/media/<slug>/img-<index>.<ext>`.
     pub index: u32,
     /// MIME type ("image/png" / "image/jpeg" / etc.). PNG for any
-    /// image we re-encode (PDFs always); pass-through for office docs
+    /// image we re-encode; pass-through for office docs
     /// where the original bytes are already in a web-friendly format.
     pub mime_type: String,
-    /// 1-based page number for PDFs / 1-based slide number for PPTX.
+    /// 1-based slide number for PPTX.
     /// `None` for DOCX (which doesn't have a per-image page concept
     /// at extraction time without parsing document.xml position
     /// markers, which is more work than this phase needs).
@@ -76,265 +71,6 @@ pub struct ExtractedImage {
     pub sha256: String,
 }
 
-// ── PDF (pdfium) ────────────────────────────────────────────────────────
-
-/// Combined PDF text + image extraction in a single pdfium session.
-///
-/// Output is a markdown string with `## Page N` headers, the page's
-/// extracted text, and `![](url)` references to images embedded on
-/// that page — interleaved per-page so the document reads top-to-
-/// bottom the way the source did.
-///
-/// When `media_dest_dir` is `Some`, every embedded raster image
-/// passing the size filter is written to that directory as
-/// `img-<N>.png` (1-based across the whole document) and referenced
-/// in the markdown via `media_url_prefix + "/img-<N>.png"`. Pass an
-/// absolute path as the prefix when you want the markdown to render
-/// regardless of where the file is opened (this is what the raw-
-/// source preview wants — see `extract_pdf_text` in fs.rs).
-///
-/// When `media_dest_dir` is `None`, image objects are skipped
-/// entirely and the output is text + page headers only — useful for
-/// PDFs outside the project's `raw/sources/` layout where there's no
-/// stable place to land the image files.
-///
-/// Holds the global pdfium lock for its full duration. Callers MUST
-/// NOT acquire the lock themselves before calling this (would
-/// deadlock — `std::sync::Mutex` is non-reentrant).
-pub fn extract_pdf_markdown(
-    path: &str,
-    media_dest_dir: Option<&Path>,
-    media_url_prefix: &str,
-    options: &ExtractOptions,
-) -> Result<String, String> {
-    use pdfium_render::prelude::*;
-
-    let _guard = crate::commands::fs::lock_pdfium();
-    let pdfium = crate::commands::fs::pdfium()?;
-    let doc = pdfium
-        .load_pdf_from_file(path, None)
-        .map_err(|e| match e {
-            PdfiumError::PdfiumLibraryInternalError(
-                PdfiumInternalError::PasswordError,
-            ) => format!("PDF is password-protected and cannot be read: '{path}'"),
-            _ => format!("Failed to open PDF '{path}': {e}"),
-        })?;
-
-    let mut out = String::new();
-    let mut idx: u32 = 0;
-    let mut total_saved: u32 = 0;
-    // Strip a single trailing slash from the prefix so we can always
-    // emit `prefix + "/" + name` without producing `path//name`.
-    let prefix = media_url_prefix.trim_end_matches('/');
-
-    let page_count = doc.pages().len();
-    if media_dest_dir.is_some() {
-        eprintln!(
-            "[extract_pdf_markdown] '{path}': {page_count} page(s), images→{:?}",
-            media_dest_dir.map(|d| d.display().to_string())
-        );
-    }
-
-    for (page_idx, page) in doc.pages().iter().enumerate() {
-        let page_num = page_idx + 1;
-        if !out.is_empty() {
-            out.push_str("\n\n");
-        }
-        out.push_str(&format!("## Page {page_num}\n\n"));
-
-        let page_text = page
-            .text()
-            .map_err(|e| {
-                format!("Page {page_num} text extraction failed in '{path}': {e}")
-            })?;
-        out.push_str(&page_text.all());
-        // Single trailing newline so the next block starts on its own
-        // line; the `\n\n` separator before the next `## Page` heading
-        // gets prepended by the loop entry above.
-        out.push('\n');
-
-        // Skip image extraction when the caller didn't supply a
-        // destination — no point burning pdfium cycles to throw the
-        // pixels away.
-        let dest_dir = match media_dest_dir {
-            Some(d) => d,
-            None => continue,
-        };
-
-        let mut page_image_md: Vec<String> = Vec::new();
-        for object in page.objects().iter() {
-            let image = match object.as_image_object() {
-                Some(img) => img,
-                None => continue,
-            };
-            let dyn_img = match image.get_raw_image() {
-                Ok(b) => b,
-                Err(e) => {
-                    eprintln!(
-                        "[extract_pdf_markdown] page {page_num} image read failed: {e}"
-                    );
-                    continue;
-                }
-            };
-            let width = dyn_img.width();
-            let height = dyn_img.height();
-            if width < options.min_width || height < options.min_height {
-                continue;
-            }
-            let mut png_bytes: Vec<u8> = Vec::new();
-            if let Err(e) = dyn_img.write_to(
-                &mut std::io::Cursor::new(&mut png_bytes),
-                image::ImageFormat::Png,
-            ) {
-                eprintln!(
-                    "[extract_pdf_markdown] page {page_num} PNG encode failed: {e}"
-                );
-                continue;
-            }
-            idx += 1;
-            let file_name = format!("img-{idx}.png");
-            // `dest_dir_relative_to` is unused here (we don't need a
-            // rel_path return — the markdown uses media_url_prefix);
-            // pass dest_dir for both args so save_one_image's
-            // strip_prefix is a no-op.
-            if let Err(e) = save_one_image(&png_bytes, dest_dir, dest_dir, &file_name) {
-                eprintln!(
-                    "[extract_pdf_markdown] page {page_num} save failed: {e}"
-                );
-                continue;
-            }
-            total_saved += 1;
-            // Empty alt-text on purpose: until the vision-caption
-            // helper lands (Phase 3a) we have nothing meaningful to
-            // put there, and a placeholder like "image" or the file
-            // name only adds noise to the LLM and to screen readers.
-            page_image_md.push(format!("![]({prefix}/{file_name})"));
-            if total_saved as usize >= options.max_images {
-                eprintln!(
-                    "[extract_pdf_markdown] reached max_images={} cap; skipped rest",
-                    options.max_images
-                );
-                break;
-            }
-        }
-        if !page_image_md.is_empty() {
-            out.push('\n');
-            for img_md in &page_image_md {
-                out.push_str(img_md);
-                out.push('\n');
-            }
-        }
-        if total_saved as usize >= options.max_images {
-            break;
-        }
-    }
-
-    if media_dest_dir.is_some() {
-        eprintln!(
-            "[extract_pdf_markdown] '{path}' DONE — pages={page_count}, saved={total_saved}"
-        );
-    }
-
-    Ok(out)
-}
-
-/// Iterate every PDF page, extract every embedded raster image, and
-/// re-encode each to PNG. Vector content (paths, glyph outlines) is
-/// NOT extracted here — that's a Phase 1.5 follow-up if needed (would
-/// involve rendering the entire page to a bitmap as a fallback).
-pub fn extract_pdf_images(
-    path: &str,
-    options: &ExtractOptions,
-) -> Result<Vec<ExtractedImage>, String> {
-    use pdfium_render::prelude::*;
-
-    // Hold the global PDFium lock for the entire call. The C library
-    // is NOT safe for concurrent access — see `lock_pdfium` in fs.rs
-    // for the full rationale. Held for the whole document lifetime so
-    // page iteration doesn't race a concurrent `load_pdf_from_file`
-    // on a different worker thread.
-    let _guard = crate::commands::fs::lock_pdfium();
-    let pdfium = crate::commands::fs::pdfium()?;
-    let doc = pdfium
-        .load_pdf_from_file(path, None)
-        .map_err(|e| format!("Failed to open PDF '{path}': {e}"))?;
-
-    let mut out: Vec<ExtractedImage> = Vec::new();
-    let mut idx: u32 = 0;
-
-    'pages: for (page_idx, page) in doc.pages().iter().enumerate() {
-        for object in page.objects().iter() {
-            // Only image objects. Path / text / shading / form / etc.
-            // are all skipped — we don't try to rasterize vector charts
-            // in this phase.
-            let image = match object.as_image_object() {
-                Some(img) => img,
-                None => continue,
-            };
-
-            // get_raw_image returns an `image::DynamicImage`. PDFium
-            // can fail per-image on a corrupt embed; we log + skip
-            // rather than aborting the whole document.
-            let dyn_img = match image.get_raw_image() {
-                Ok(b) => b,
-                Err(e) => {
-                    eprintln!(
-                        "[extract_pdf_images] page {} image read failed: {e}",
-                        page_idx + 1
-                    );
-                    continue;
-                }
-            };
-
-            let width = dyn_img.width();
-            let height = dyn_img.height();
-            if width < options.min_width || height < options.min_height {
-                continue;
-            }
-
-            // Re-encode to PNG. We don't try to preserve the source
-            // codec — PDFium often hands us raw RGBA, and even when
-            // the embedded form was JPEG, decode → re-encode is fine
-            // for the kind of resolutions inside PDFs.
-            let mut png_bytes: Vec<u8> = Vec::new();
-            if let Err(e) = dyn_img.write_to(
-                &mut std::io::Cursor::new(&mut png_bytes),
-                image::ImageFormat::Png,
-            ) {
-                eprintln!(
-                    "[extract_pdf_images] page {} PNG encode failed: {e}",
-                    page_idx + 1
-                );
-                continue;
-            }
-
-            idx += 1;
-            let data_base64 = B64.encode(&png_bytes);
-            let sha256 = sha256_hex(&png_bytes);
-
-            out.push(ExtractedImage {
-                index: idx,
-                mime_type: "image/png".to_string(),
-                page: Some((page_idx + 1) as u32),
-                width,
-                height,
-                data_base64,
-                sha256,
-            });
-
-            if out.len() >= options.max_images {
-                eprintln!(
-                    "[extract_pdf_images] reached max_images={} cap; remaining images skipped",
-                    options.max_images
-                );
-                break 'pages;
-            }
-        }
-    }
-
-    Ok(out)
-}
-
 // ── PPTX / DOCX (zip) ──────────────────────────────────────────────────
 
 /// Office Open XML formats (PPTX, DOCX) embed images verbatim under
@@ -632,121 +368,6 @@ fn ext_for_mime(mime: &str) -> &'static str {
     }
 }
 
-/// PDF: extract every embedded image AND write each to
-/// `dest_dir / img-<index>.<ext>`. `rel_to` is the directory the
-/// returned `rel_path` is anchored at (typically the wiki root).
-/// PNG re-encoding is unconditional (pdfium hands us decoded bitmaps
-/// regardless of source codec).
-pub fn extract_and_save_pdf_images(
-    path: &str,
-    dest_dir: &Path,
-    rel_to: &Path,
-    options: &ExtractOptions,
-) -> Result<Vec<SavedImage>, String> {
-    use pdfium_render::prelude::*;
-
-    // See `extract_pdf_images` for why this lock is mandatory.
-    let _guard = crate::commands::fs::lock_pdfium();
-    let pdfium = crate::commands::fs::pdfium()?;
-    let doc = pdfium
-        .load_pdf_from_file(path, None)
-        .map_err(|e| format!("Failed to open PDF '{path}': {e}"))?;
-
-    let mut out: Vec<SavedImage> = Vec::new();
-    let mut idx: u32 = 0;
-    // Diagnostic counters — when extraction returns empty, the user's
-    // first question is "did the PDF actually have raster images?"
-    // These let us answer it from logs without having to crack open
-    // the PDF in a debugger.
-    let mut total_objects: u32 = 0;
-    let mut total_image_objects: u32 = 0;
-    let mut filtered_too_small: u32 = 0;
-    let mut filtered_decode_err: u32 = 0;
-    let mut filtered_encode_err: u32 = 0;
-
-    let page_count = doc.pages().len();
-    eprintln!(
-        "[extract_and_save_pdf_images] '{path}': {} page(s), filter=({}x{}) min, max={}",
-        page_count, options.min_width, options.min_height, options.max_images
-    );
-
-    'pages: for (page_idx, page) in doc.pages().iter().enumerate() {
-        for object in page.objects().iter() {
-            total_objects += 1;
-            let image = match object.as_image_object() {
-                Some(img) => img,
-                None => continue,
-            };
-            total_image_objects += 1;
-            let dyn_img = match image.get_raw_image() {
-                Ok(b) => b,
-                Err(e) => {
-                    filtered_decode_err += 1;
-                    eprintln!(
-                        "[extract_and_save_pdf_images] page {} image read failed: {e}",
-                        page_idx + 1
-                    );
-                    continue;
-                }
-            };
-            let width = dyn_img.width();
-            let height = dyn_img.height();
-            if width < options.min_width || height < options.min_height {
-                filtered_too_small += 1;
-                eprintln!(
-                    "[extract_and_save_pdf_images] page {} image {}x{} < min ({}x{}) — skipped",
-                    page_idx + 1, width, height, options.min_width, options.min_height
-                );
-                continue;
-            }
-
-            let mut png_bytes: Vec<u8> = Vec::new();
-            if let Err(e) = dyn_img.write_to(
-                &mut std::io::Cursor::new(&mut png_bytes),
-                image::ImageFormat::Png,
-            ) {
-                filtered_encode_err += 1;
-                eprintln!(
-                    "[extract_and_save_pdf_images] page {} PNG encode failed: {e}",
-                    page_idx + 1
-                );
-                continue;
-            }
-
-            idx += 1;
-            let file_name = format!("img-{idx}.png");
-            let (rel_path, abs_path) = save_one_image(&png_bytes, dest_dir, rel_to, &file_name)?;
-            let sha256 = sha256_hex(&png_bytes);
-
-            out.push(SavedImage {
-                index: idx,
-                mime_type: "image/png".to_string(),
-                page: Some((page_idx + 1) as u32),
-                width,
-                height,
-                rel_path,
-                abs_path,
-                sha256,
-            });
-
-            if out.len() >= options.max_images {
-                eprintln!(
-                    "[extract_and_save_pdf_images] reached max_images={} cap; skipped rest",
-                    options.max_images
-                );
-                break 'pages;
-            }
-        }
-    }
-
-    eprintln!(
-        "[extract_and_save_pdf_images] '{path}' DONE — saved={}, total_objects={}, image_objects={}, too_small={}, decode_err={}, encode_err={}",
-        out.len(), total_objects, total_image_objects, filtered_too_small, filtered_decode_err, filtered_encode_err,
-    );
-
-    Ok(out)
-}
-
 /// PPTX/DOCX: pull embedded images directly from the zip media/
 /// directory and write each to `dest_dir`. Source format is
 /// preserved (PNG stays PNG, JPEG stays JPEG) since pulling the raw
@@ -847,25 +468,10 @@ pub fn extract_and_save_office_images(
 // ── Tauri command bindings ─────────────────────────────────────────────
 
 // Why every cmd below is `spawn_blocking`:
-// PDFium FFI calls and zip+image-decode are all blocking. Running
-// them inside an `async fn` body (as we did before this fix) kept
-// them on a tokio worker thread, blocking other async tasks on
-// that worker for the full duration of the extraction. `spawn_
-// blocking` moves the work to tokio's blocking pool — that's the
-// pool's contract. (Combined with the PDFium mutex inside
-// `extract_pdf_images`, this also prevents the segfault that hit
-// when two PDF extractions raced on different workers.)
-
-#[tauri::command]
-pub async fn extract_pdf_images_cmd(path: String) -> Result<Vec<ExtractedImage>, String> {
-    tauri::async_runtime::spawn_blocking(move || {
-        crate::panic_guard::run_guarded("extract_pdf_images", || {
-            extract_pdf_images(&path, &ExtractOptions::default())
-        })
-    })
-    .await
-    .map_err(|e| format!("extract_pdf_images blocking task join error: {e}"))?
-}
+// zip+image-decode are blocking. Running them inside an `async fn`
+// body kept them on a tokio worker thread, blocking other async
+// tasks for the full duration of the extraction. `spawn_blocking`
+// moves the work to tokio's blocking pool.
 
 #[tauri::command]
 pub async fn extract_office_images_cmd(path: String) -> Result<Vec<ExtractedImage>, String> {
@@ -878,26 +484,6 @@ pub async fn extract_office_images_cmd(path: String) -> Result<Vec<ExtractedImag
     .map_err(|e| format!("extract_office_images blocking task join error: {e}"))?
 }
 
-#[tauri::command]
-pub async fn extract_and_save_pdf_images_cmd(
-    source_path: String,
-    dest_dir: String,
-    rel_to: String,
-) -> Result<Vec<SavedImage>, String> {
-    tauri::async_runtime::spawn_blocking(move || {
-        crate::panic_guard::run_guarded("extract_and_save_pdf_images", || {
-            extract_and_save_pdf_images(
-                &source_path,
-                Path::new(&dest_dir),
-                Path::new(&rel_to),
-                &ExtractOptions::default(),
-            )
-        })
-    })
-    .await
-    .map_err(|e| format!("extract_and_save_pdf_images blocking task join error: {e}"))?
-}
-
 #[tauri::command]
 pub async fn extract_and_save_office_images_cmd(
     source_path: String,

+ 1 - 1
src-tauri/src/commands/file_sync.rs

@@ -1324,7 +1324,7 @@ mod tests {
     #[test]
     fn source_watch_config_filters_raw_source_extensions_and_dirs() {
         let config = SourceWatchConfig {
-            include_extensions: vec!["md".into(), "pdf".into()],
+            include_extensions: vec!["md".into(), "docx".into()],
             exclude_dirs: vec!["drafts".into(), "subdir/drafts".into()],
             exclude_globs: vec!["*.private.*".into()],
             ..SourceWatchConfig::default()

+ 9 - 371
src-tauri/src/commands/fs.rs

@@ -19,7 +19,7 @@ const MEDIA_EXTS: &[&str] = &[
     "mp4", "webm", "mov", "avi", "mkv", "flv", "wmv", "m4v",
     "mp3", "wav", "ogg", "flac", "aac", "m4a", "wma",
 ];
-const LEGACY_DOC_EXTS: &[&str] = &["doc", "xls", "ppt", "pages", "numbers", "key", "epub"];
+const LEGACY_DOC_EXTS: &[&str] = &["pdf", "doc", "xls", "ppt", "pages", "numbers", "key", "epub"];
 const KNOWLEDGE_DIR: &str = "QM";
 const LEGACY_KNOWLEDGE_DIR: &str = "wiki";
 const META_DIR: &str = ".qmai";
@@ -108,7 +108,6 @@ pub fn do_read_file(path: &str) -> Result<String, String> {
         }
 
         match ext.as_str() {
-            "pdf" => extract_pdf_text(&path),
             e if OFFICE_EXTS.contains(&e) => extract_office_text(&path, e),
             "doc" => extract_legacy_doc_text(&path),
             e if IMAGE_EXTS.contains(&e) => {
@@ -164,7 +163,6 @@ pub fn do_preprocess_file(path: &str) -> Result<String, String> {
             .to_lowercase();
 
         let text = match ext.as_str() {
-            "pdf" => extract_pdf_text(&path)?,
             e if OFFICE_EXTS.contains(&e) => extract_office_text(&path, e)?,
             e if is_plain_text_ext(e) => read_plain_text_file(&path)?,
             _ => return Ok("no preprocessing needed".to_string()),
@@ -263,243 +261,6 @@ fn write_cache(original: &Path, text: &str) -> Result<(), String> {
         .map_err(|e| format!("Failed to write cache: {}", e))
 }
 
-/// Global PDFium instance — the library prefers a single binding shared
-/// across threads over repeatedly binding/unbinding.
-static PDFIUM: std::sync::OnceLock<Result<pdfium_render::prelude::Pdfium, String>> =
-    std::sync::OnceLock::new();
-
-/// Serializes every PDFium call. PDFium's C library is documented as
-/// safe across threads only when no PDFium object is touched from
-/// two threads simultaneously — interleaved calls are UB and have
-/// caused EXC_BAD_ACCESS segfaults on macOS ARM64 in production.
-///
-/// This mutex matters because our heavy fs commands are now `async
-/// fn`, so Tauri schedules them on the tokio multi-threaded runtime
-/// instead of running them on a single thread. Without this lock,
-/// two concurrent `read_file`/`extract_*_pdf` calls can land on
-/// different worker threads and interleave inside pdfium → crash.
-///
-/// We use `std::sync::Mutex` (not `tokio::sync::Mutex`) because the
-/// lock is acquired *inside* `spawn_blocking`, never held across
-/// `.await` — async-aware mutexes would just add overhead for no
-/// benefit here.
-static PDFIUM_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
-
-/// Acquire the PDFium serialization lock. Auto-recovers from poison
-/// (a previous panic on a malformed PDF leaves the mutex poisoned,
-/// but pdfium has no shared state for that panic to have corrupted —
-/// the next caller can safely take the lock and proceed).
-pub(crate) fn lock_pdfium() -> std::sync::MutexGuard<'static, ()> {
-    PDFIUM_LOCK
-        .lock()
-        .unwrap_or_else(|poisoned| poisoned.into_inner())
-}
-
-/// Additional resource directory hint, set by the Tauri setup() callback
-/// once the AppHandle is available. Lets the pdfium resolver find the
-/// bundled dylib without re-implementing Tauri's platform-specific
-/// resource-dir logic.
-static RESOURCE_DIR_HINT: std::sync::OnceLock<std::path::PathBuf> =
-    std::sync::OnceLock::new();
-
-/// Called from Tauri's setup() with the resolved resource directory.
-/// No-op if already set.
-pub fn set_resource_dir_hint(dir: std::path::PathBuf) {
-    let _ = RESOURCE_DIR_HINT.set(dir);
-}
-
-/// Enumerate plausible locations for the PDFium dynamic library on the
-/// current platform. Order from most specific to least:
-///   1. `$PDFIUM_DYNAMIC_LIB_PATH` env var (local dev convenience)
-///   2. Tauri resource dir (set via setup()) — the authoritative location
-///   3. Paths relative to the executable where Tauri's bundler lands
-///      resources on each platform (macOS Frameworks / Resources /
-///      MacOS dir, Windows sibling, Linux sibling)
-///   4. OS dynamic loader search path (last resort)
-fn pdfium_candidate_paths() -> Vec<String> {
-    let mut v: Vec<String> = Vec::new();
-
-    if let Ok(p) = std::env::var("PDFIUM_DYNAMIC_LIB_PATH") {
-        v.push(p);
-    }
-
-    // Tauri-resolved resource directory (set during setup()).
-    //
-    // Tauri's `bundle.resources` array form preserves relative paths,
-    // so `"pdfium/pdfium.dll"` in tauri.<target>.conf.json lands at
-    // `<resource_dir>/pdfium/pdfium.dll` — NOT at the root. Older
-    // versions of this function only probed the root, which made
-    // Windows installs fail with "Failed to locate Pdfium library"
-    // (OS error 126) even though the DLL was in the installer.
-    // We now probe both the `pdfium/` subdir (where the current
-    // bundle config actually puts it) and the root (in case a future
-    // config change flattens it).
-    if let Some(resource_dir) = RESOURCE_DIR_HINT.get() {
-        let push = |v: &mut Vec<String>, p: std::path::PathBuf| {
-            v.push(p.to_string_lossy().into_owned());
-        };
-        #[cfg(target_os = "macos")]
-        {
-            push(&mut v, resource_dir.join("pdfium").join("libpdfium.dylib"));
-            push(&mut v, resource_dir.join("libpdfium.dylib"));
-        }
-        #[cfg(target_os = "windows")]
-        {
-            push(&mut v, resource_dir.join("pdfium").join("pdfium.dll"));
-            push(&mut v, resource_dir.join("pdfium").join("libpdfium.dll"));
-            push(&mut v, resource_dir.join("pdfium.dll"));
-            push(&mut v, resource_dir.join("libpdfium.dll"));
-        }
-        #[cfg(target_os = "linux")]
-        {
-            push(&mut v, resource_dir.join("pdfium").join("libpdfium.so"));
-            push(&mut v, resource_dir.join("libpdfium.so"));
-        }
-    }
-
-    if let Ok(exe) = std::env::current_exe() {
-        if let Some(exe_dir) = exe.parent() {
-            let push = |v: &mut Vec<String>, p: std::path::PathBuf| {
-                v.push(p.to_string_lossy().into_owned());
-            };
-
-            #[cfg(target_os = "macos")]
-            {
-                // Tauri .app bundle layout:
-                //   Contents/MacOS/<binary>
-                //   Contents/Frameworks/libpdfium.dylib   ← preferred (macOS config uses bundle.macOS.frameworks)
-                //   Contents/Resources/libpdfium.dylib    ← fallback
-                //   Contents/Resources/pdfium/libpdfium.dylib  ← if array-form resources ever used on macOS
-                push(&mut v, exe_dir.join("../Frameworks/libpdfium.dylib"));
-                push(&mut v, exe_dir.join("../Resources/pdfium/libpdfium.dylib"));
-                push(&mut v, exe_dir.join("../Resources/libpdfium.dylib"));
-                push(&mut v, exe_dir.join("libpdfium.dylib"));
-            }
-
-            #[cfg(target_os = "windows")]
-            {
-                // bblanchon/pdfium-binaries ships the Windows DLL as
-                // `pdfium.dll` (no `lib` prefix). Probe flat and
-                // `pdfium/` subdir forms at both exe root and the
-                // classic Tauri `resources/` sibling — covers every
-                // layout variant we've observed across NSIS / MSI /
-                // portable builds.
-                push(&mut v, exe_dir.join("pdfium.dll"));
-                push(&mut v, exe_dir.join("pdfium").join("pdfium.dll"));
-                push(&mut v, exe_dir.join("libpdfium.dll"));
-                push(&mut v, exe_dir.join("resources").join("pdfium.dll"));
-                push(&mut v, exe_dir.join("resources").join("pdfium").join("pdfium.dll"));
-            }
-
-            #[cfg(target_os = "linux")]
-            {
-                push(&mut v, exe_dir.join("libpdfium.so"));
-                push(&mut v, exe_dir.join("pdfium").join("libpdfium.so"));
-                push(&mut v, exe_dir.join("resources").join("libpdfium.so"));
-                push(&mut v, exe_dir.join("resources").join("pdfium").join("libpdfium.so"));
-                push(&mut v, exe_dir.join("../lib/libpdfium.so"));
-            }
-        }
-    }
-
-    v
-}
-
-pub(crate) fn pdfium() -> Result<&'static pdfium_render::prelude::Pdfium, String> {
-    PDFIUM
-        .get_or_init(|| {
-            use pdfium_render::prelude::*;
-            let candidates = pdfium_candidate_paths();
-            for path in &candidates {
-                if let Ok(bindings) = Pdfium::bind_to_library(path) {
-                    eprintln!("[pdfium] loaded dynamic library from {path}");
-                    return Ok(Pdfium::new(bindings));
-                }
-            }
-            // Last resort: let the OS dynamic loader find it.
-            Pdfium::bind_to_system_library()
-                .map(Pdfium::new)
-                .map_err(|e| {
-                    format!(
-                        "Failed to locate Pdfium library. Tried: {} — and the system search path. Last error: {e}",
-                        if candidates.is_empty() {
-                            "(no candidates)".to_string()
-                        } else {
-                            candidates.join(", ")
-                        }
-                    )
-                })
-        })
-        .as_ref()
-        .map_err(|e| e.clone())
-}
-
-/// Extract a PDF as markdown — text + per-page image references
-/// when the file lives under a project's `raw/sources/` (the
-/// layout the import pipeline produces). Falls back to text-only
-/// when the PDF is opened from anywhere else.
-///
-/// Layout heuristic: a PDF at `<project>/raw/sources/<name>.pdf`
-/// implies project root = `<project>` and image dest =
-/// `<project>/QM/media/<name>/`. We use absolute filesystem paths
-/// in the emitted `![](url)` references so the markdown previews
-/// (raw-source view AND wiki-summary view) both render via
-/// `convertFileSrc` without anyone having to know which directory
-/// they're rendering from.
-///
-/// Lock: delegates to `extract_pdf_markdown`, which acquires the
-/// pdfium lock internally. We must NOT take it here too —
-/// `std::sync::Mutex` is non-reentrant.
-fn extract_pdf_text(path: &str) -> Result<String, String> {
-    use crate::commands::extract_images::{extract_pdf_markdown, ExtractOptions};
-
-    let p = Path::new(path);
-    let parent = p.parent();
-    let stem = p
-        .file_stem()
-        .and_then(|s| s.to_str())
-        .unwrap_or("")
-        .to_string();
-
-    // The path-component check uses `ends_with` on `Path` which
-    // matches the LAST component (not a string-suffix check), so
-    // `/foo/raw/sources/bar.pdf` correctly identifies as under
-    // `raw/sources/` while `/foo/braw/source-thing/bar.pdf` does
-    // not.
-    let parent_is_sources = parent.map(|d| d.ends_with("sources")).unwrap_or(false);
-    let raw_dir = parent.and_then(|d| d.parent());
-    let raw_is_raw = raw_dir.map(|d| d.ends_with("raw")).unwrap_or(false);
-    let project_root = if parent_is_sources && raw_is_raw {
-        raw_dir.and_then(|d| d.parent())
-    } else {
-        None
-    };
-
-    if let Some(root) = project_root {
-        if !stem.is_empty() {
-            let media_dir = root.join(KNOWLEDGE_DIR).join("media").join(&stem);
-            // Forward-slash absolute path so we don't ship `\` into
-            // markdown that the JS-side resolver would then have to
-            // re-normalize. The resolver does handle backslashes,
-            // but emitting clean URLs in the first place avoids
-            // surprises in cache files we save to disk.
-            let url_prefix = media_dir.to_string_lossy().replace('\\', "/");
-            return extract_pdf_markdown(
-                path,
-                Some(&media_dir),
-                &url_prefix,
-                &ExtractOptions::default(),
-            );
-        }
-    }
-
-    // PDFs not under <project>/raw/sources/ — text-only fallback.
-    // Skip the image side of the extraction entirely (no media
-    // destination → extract_pdf_markdown only writes text + page
-    // headers, no pdfium image-object enumeration).
-    extract_pdf_markdown(path, None, "", &ExtractOptions::default())
-}
-
 /// Extract text from Office Open XML formats, converting to Markdown.
 fn extract_office_text(path: &str, ext: &str) -> Result<String, String> {
     // Spreadsheets: use calamine (supports xlsx, xls, ods)
@@ -2007,22 +1768,6 @@ mod tests {
         let _ = fs::remove_dir_all(root);
     }
 
-    /// Write `bytes` to a fresh tmp path with `.pdf` suffix and return
-    /// the path (the OS tmpdir is NOT cleaned up — acceptable for tests).
-    fn tmp_pdf_with_bytes(bytes: &[u8]) -> String {
-        let dir = std::env::temp_dir();
-        let path = dir.join(format!(
-            "panic-guard-{}.pdf",
-            std::time::SystemTime::now()
-                .duration_since(std::time::UNIX_EPOCH)
-                .unwrap()
-                .as_nanos()
-        ));
-        let mut f = fs::File::create(&path).unwrap();
-        f.write_all(bytes).unwrap();
-        path.to_string_lossy().to_string()
-    }
-
     fn tmp_text_with_bytes(ext: &str, bytes: &[u8]) -> String {
         let dir = std::env::temp_dir();
         let path = dir.join(format!(
@@ -2172,126 +1917,19 @@ mod tests {
         assert_ne!(result, "no preprocessing needed");
     }
 
-    /// Verify read_file does NOT crash the test process on malformed PDFs.
-    /// We try a handful of payloads that have historically caused
-    /// pdf-extract/lopdf panics — any process abort would fail the test
-    /// runner before it can report.
-    ///
-    /// `multi_thread` flavor: `read_file` now uses
-    /// `tauri::async_runtime::spawn_blocking`, which moves work onto
-    /// the tokio blocking pool. The blocking pool requires a multi-
-    /// threaded runtime — the default `#[tokio::test]` is single-
-    /// threaded current-thread, on which `.await` of a `spawn_blocking`
-    /// future deadlocks.
     #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
-    async fn read_file_survives_malformed_pdf_inputs() {
-        let payloads: &[(&str, &[u8])] = &[
-            ("empty", b""),
-            ("not_a_pdf", b"this is plainly not a PDF file"),
-            ("header_only", b"%PDF-1.4\n"),
-            (
-                "broken_xref",
-                b"%PDF-1.4\n1 0 obj\n<<>>\nendobj\nxref\nBROKENBROKEN\ntrailer\n<</Size 1>>\nstartxref\n999999\n%%EOF\n",
-            ),
-            (
-                "junk_after_header",
-                b"%PDF-1.4\n\x00\x01\x02\x03\x04\x05\x06\x07\xFF\xFE\xFDjunkgarbage",
-            ),
-        ];
-
-        for (name, bytes) in payloads {
-            let path = tmp_pdf_with_bytes(bytes);
-            let result = read_file(path.clone()).await;
-            let _ = fs::remove_file(&path);
-            eprintln!("[{name}] => {:?}", result.as_ref().map(|s| &s[..s.len().min(80)]));
-        }
+    async fn read_file_returns_unsupported_placeholder_for_pdf() {
+        let path = tmp_text_with_bytes("pdf", b"%PDF-1.4\n");
+        let result = read_file(path.clone()).await.unwrap();
+        let _ = fs::remove_file(&path);
+        assert!(result.contains("text extraction not supported"));
+        assert!(result.contains(".pdf"));
     }
 
-    /// Smoke test: a real PDF panic (synthesized) is caught. We can't
-    /// guarantee that any particular byte sequence above actually panics
-    /// pdf-extract across versions, so also trigger an explicit panic
-    /// through read_file's guarded path.
     #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
     async fn read_file_returns_err_on_missing_file_instead_of_panicking() {
-        let result = read_file("/nonexistent/path/that/does/not/exist.pdf".to_string()).await;
-        assert!(result.is_err() || result.is_ok()); // must at least return
-    }
-
-    /// Ad-hoc probe: run the production PDF extraction path against every
-    /// .pdf under a user-provided directory and print a per-file report of
-    /// Ok / Err (library returned an error) / Panic (library panicked and
-    /// was caught by panic_guard). Gated with #[ignore] so it never runs
-    /// in CI; execute locally with:
-    ///
-    ///   PDF_PROBE_DIR=/path/to/pdfs cargo test --lib \
-    ///     -- --ignored --nocapture pdf_probe
-    #[test]
-    #[ignore = "local probe; set PDF_PROBE_DIR"]
-    fn pdf_probe() {
-        let dir = std::env::var("PDF_PROBE_DIR")
-            .unwrap_or_else(|_| "/Users/nash_su/Downloads/pdftests".to_string());
-        let root = std::path::Path::new(&dir);
-        if !root.exists() {
-            eprintln!("[pdf_probe] dir not found: {}", root.display());
-            return;
-        }
-
-        let mut pdfs: Vec<std::path::PathBuf> = Vec::new();
-        fn walk(d: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
-            if let Ok(entries) = fs::read_dir(d) {
-                for entry in entries.flatten() {
-                    let p = entry.path();
-                    if p.is_dir() {
-                        walk(&p, out);
-                    } else if p.extension()
-                        .and_then(|e| e.to_str())
-                        .map(|e| e.eq_ignore_ascii_case("pdf"))
-                        .unwrap_or(false)
-                    {
-                        out.push(p);
-                    }
-                }
-            }
-        }
-        walk(root, &mut pdfs);
-        pdfs.sort();
-
-        eprintln!("\n[pdf_probe] found {} PDFs under {}\n", pdfs.len(), root.display());
-
-        let mut ok = 0usize;
-        let mut err = 0usize;
-        let mut panicked = 0usize;
-
-        for (idx, path) in pdfs.iter().enumerate() {
-            let display = path.display().to_string();
-            // Call extract_pdf_text directly (not read_file) so we bypass
-            // the .cache sibling dir and always exercise the parser.
-            let path_str = path.to_string_lossy().to_string();
-            let result = std::panic::catch_unwind(|| extract_pdf_text(&path_str));
-            match result {
-                Ok(Ok(text)) => {
-                    ok += 1;
-                    eprintln!("[{:>3}/{}] OK     ({:>7} chars)  {}", idx + 1, pdfs.len(), text.len(), display);
-                }
-                Ok(Err(e)) => {
-                    err += 1;
-                    eprintln!("[{:>3}/{}] ERR    {}  →  {}", idx + 1, pdfs.len(), display, e);
-                }
-                Err(payload) => {
-                    panicked += 1;
-                    let msg = if let Some(s) = payload.downcast_ref::<String>() {
-                        s.clone()
-                    } else if let Some(s) = payload.downcast_ref::<&str>() {
-                        (*s).to_string()
-                    } else {
-                        "(non-string panic)".to_string()
-                    };
-                    eprintln!("[{:>3}/{}] PANIC  {}  →  {}", idx + 1, pdfs.len(), display, msg);
-                }
-            }
-        }
-
-        eprintln!("\n[pdf_probe] summary: {} OK / {} ERR / {} PANIC (total {})", ok, err, panicked, pdfs.len());
+        let result = read_file("/nonexistent/path/that/does/not/exist.txt".to_string()).await;
+        assert!(result.is_err());
     }
 
     // ── collect_related_pages: regression coverage for the three match ─────

+ 0 - 5
src-tauri/src/lib.rs

@@ -43,9 +43,6 @@ pub fn run() {
         .plugin(tauri_plugin_updater::Builder::new().build())
         .setup(|app| {
             use tauri::Manager;
-            if let Ok(dir) = app.path().resource_dir() {
-                commands::fs::set_resource_dir_hint(dir);
-            }
             if let Ok(dir) = app.path().app_data_dir() {
                 let store_path = dir.join("app-state.json");
                 eprintln!("[proxy] reading from {}", store_path.display());
@@ -106,9 +103,7 @@ pub fn run() {
             commands::cursor_cli::cursor_proxy_status,
             commands::cursor_cli::cursor_proxy_ensure,
             commands::cursor_cli::cursor_proxy_stop,
-            commands::extract_images::extract_pdf_images_cmd,
             commands::extract_images::extract_office_images_cmd,
-            commands::extract_images::extract_and_save_pdf_images_cmd,
             commands::extract_images::extract_and_save_office_images_cmd,
             commands::file_sync::start_project_file_watcher,
             commands::file_sync::stop_project_file_watcher,

+ 0 - 5
src-tauri/src/main.rs

@@ -45,9 +45,6 @@ fn main() {
         .plugin(tauri_plugin_updater::Builder::new().build())
         .setup(|app| {
             use tauri::Manager;
-            if let Ok(dir) = app.path().resource_dir() {
-                commands::fs::set_resource_dir_hint(dir);
-            }
             if let Ok(dir) = app.path().app_data_dir() {
                 let store_path = dir.join("app-state.json");
                 eprintln!("[proxy] reading from {}", store_path.display());
@@ -108,9 +105,7 @@ fn main() {
             commands::cursor_cli::cursor_proxy_status,
             commands::cursor_cli::cursor_proxy_ensure,
             commands::cursor_cli::cursor_proxy_stop,
-            commands::extract_images::extract_pdf_images_cmd,
             commands::extract_images::extract_office_images_cmd,
-            commands::extract_images::extract_and_save_pdf_images_cmd,
             commands::extract_images::extract_and_save_office_images_cmd,
             commands::file_sync::start_project_file_watcher,
             commands::file_sync::stop_project_file_watcher,

+ 1 - 1
src-tauri/src/panic_guard.rs

@@ -1,6 +1,6 @@
 //! Panic-to-error boundary for Tauri commands.
 //!
-//! Third-party parsers (pdf-extract / lopdf, docx-rs, calamine, …) are
+//! Third-party parsers (docx-rs, calamine, …) are
 //! known to panic on malformed input instead of returning Err. Under
 //! `panic = "abort"` that kills the whole app; even with `panic =
 //! "unwind"`, letting a panic propagate through the `extern "C"` Tauri

+ 0 - 5
src-tauri/tauri.linux.conf.json

@@ -1,5 +0,0 @@
-{
-  "bundle": {
-    "resources": ["pdfium/libpdfium.so"]
-  }
-}

+ 0 - 7
src-tauri/tauri.macos.conf.json

@@ -1,7 +0,0 @@
-{
-  "bundle": {
-    "macOS": {
-      "frameworks": ["pdfium/libpdfium.dylib"]
-    }
-  }
-}

+ 1 - 1
src-tauri/tauri.windows.conf.json

@@ -1,6 +1,6 @@
 {
   "bundle": {
-    "resources": ["pdfium/pdfium.dll", "../skills"],
+    "resources": ["../skills"],
     "windows": {
       "webviewInstallMode": {
         "type": "skip"

+ 1 - 61
src/App.tsx

@@ -6,7 +6,7 @@ import { isTauri, pickDirectory } from "@/lib/platform"
 import { useChatStore } from "@/stores/chat-store"
 import { useOutlineChatStore } from "@/stores/outline-chat-store"
 import { openProject, fileExists, listDirectory, readFile } from "@/commands/fs"
-import { getLastProject, saveLastProject, loadLlmConfig, loadAiChatModel, loadDefaultLlmModel, loadLanguage, loadEmbeddingConfig, loadProviderConfigs, loadActivePresetId, loadProxyConfig, loadScheduledImportConfig, saveScheduledImportConfig, loadSourceWatchConfig, loadNovelMode, loadNovelConfig, loadRevisionFeedbackWindowConfig, loadTheme, loadMaxHistoryMessages, loadUiFontFamily, loadVisualStyle, saveLlmConfig, loadLastReadChapter, loadMcpConfig, loadSearchApiConfig } from "@/lib/project-store"
+import { getLastProject, saveLastProject, loadLlmConfig, loadAiChatModel, loadDefaultLlmModel, loadLanguage, loadEmbeddingConfig, loadProviderConfigs, loadActivePresetId, loadProxyConfig, loadNovelMode, loadNovelConfig, loadRevisionFeedbackWindowConfig, loadTheme, loadMaxHistoryMessages, loadUiFontFamily, loadVisualStyle, saveLlmConfig, loadLastReadChapter, loadMcpConfig, loadSearchApiConfig } from "@/lib/project-store"
 import { loadReviewItems, loadChatHistory, saveChatHistory, saveReviewItems } from "@/lib/persist"
 import { initializeAiOutlineModelFromStorage } from "@/lib/ai-outline-model-initialization"
 import { setupAutoSave, teardownAutoSave } from "@/lib/auto-save"
@@ -91,34 +91,6 @@ function App() {
     }
   }
 
-  async function hydrateScheduledImportAfterOpen(proj: WikiProject): Promise<void> {
-    try {
-      const savedScheduledImport = await loadScheduledImportConfig(proj.path)
-      if (!isCurrentProject(proj)) return
-      if (savedScheduledImport) {
-        let path = savedScheduledImport.path
-        if (path && !path.startsWith("/") && !path.match(/^[a-zA-Z]:[/\\]/)) {
-          path = `${proj.path}/${path}`
-        }
-        useWikiStore.getState().setScheduledImportConfig({
-          ...savedScheduledImport,
-          path,
-        })
-      }
-
-      if (!isTauri()) return
-      const scheduledImportConfig = useWikiStore.getState().scheduledImportConfig
-      if (!isCurrentProject(proj)) return
-      if (scheduledImportConfig.enabled && scheduledImportConfig.path && scheduledImportConfig.interval > 0) {
-        const { startScheduledImport } = await import("@/lib/scheduled-import")
-        if (!isCurrentProject(proj)) return
-        startScheduledImport(proj, scheduledImportConfig)
-      }
-    } catch (err) {
-      console.warn("[startup] 加载定时导入配置失败:", err)
-    }
-  }
-
   async function hydrateProjectBackgroundServices(proj: WikiProject): Promise<void> {
     if (!isTauri()) return
     if (!isCurrentProject(proj)) return
@@ -150,31 +122,11 @@ function App() {
     } catch (err) {
       console.error("恢复伏笔清理队列失败:", err)
     }
-
-    if (!isCurrentProject(proj)) return
-
-    try {
-      const { startProjectFileSync, stopProjectFileSync } = await import("@/lib/project-file-sync")
-      const config = await loadSourceWatchConfig(proj.id, proj.path)
-      if (!isCurrentProject(proj)) return
-      useWikiStore.getState().setSourceWatchConfig(config)
-      if (config.enabled) {
-        startProjectFileSync(proj, config).catch((err) =>
-          console.error("启动项目文件同步失败:", err)
-        )
-      } else {
-        stopProjectFileSync().catch(() => {})
-      }
-    } catch (err) {
-      console.error("配置项目文件同步失败:", err)
-    }
   }
 
   async function hydrateDeferredProjectState(proj: WikiProject): Promise<void> {
     await hydrateProjectBackgroundServices(proj)
     if (!isCurrentProject(proj)) return
-    await hydrateScheduledImportAfterOpen(proj)
-    if (!isCurrentProject(proj)) return
     await hydrateProjectSideStores(proj)
     if (!isCurrentProject(proj)) return
     // v3 新增:加载技能收藏(全局存储,但需 project path 用于 originProjectPath 标记)
@@ -491,18 +443,6 @@ function App() {
   }
 
   async function handleSwitchProject() {
-    // Stop scheduled import before switching projects
-    import("@/lib/scheduled-import").then(({ stopScheduledImport }) => {
-      stopScheduledImport()
-    }).catch(() => {})
-
-    // Save current project's scheduled import config before clearing
-    const currentProject = useWikiStore.getState().project
-    if (currentProject) {
-      const currentConfig = useWikiStore.getState().scheduledImportConfig
-      saveScheduledImportConfig(currentProject.path, currentConfig).catch(() => {})
-    }
-
     // Clear all per-project state BEFORE flipping back to the welcome screen
     // so old data cannot leak in via any async render pass.
     await resetProjectState()

+ 0 - 2
src/components/editor/file-preview.tsx

@@ -40,8 +40,6 @@ export function FilePreview({ filePath, textContent }: FilePreviewProps) {
       return <VideoPreview filePath={filePath} fileName={fileName} />
     case "audio":
       return <AudioPreview filePath={filePath} fileName={fileName} />
-    case "pdf":
-      return <TextPreview filePath={filePath} content={textContent} label="PDF (extracted text)" />
     case "code":
       return <CodePreview filePath={filePath} content={textContent} />
     case "data":

+ 0 - 2
src/components/editor/frontmatter-panel.tsx

@@ -284,8 +284,6 @@ function RelatedChip({
 function iconForSource(name: string) {
   const ext = name.split(".").pop()?.toLowerCase() ?? ""
   switch (ext) {
-    case "pdf":
-      return FileTextIcon
     case "xlsx":
     case "xls":
     case "csv":

+ 2 - 2
src/components/search/search-view.tsx

@@ -310,7 +310,7 @@ export function SearchView({ onClose, onOpenFile }: SearchViewProps) {
 
   /**
    * Lightbox jump-to-source: open the ORIGINAL raw source file
-   * (the PDF / DOCX / PPTX in `raw/sources/`), not the LLM-
+   * (the DOCX / PPTX in `raw/sources/`), not the LLM-
    * summarized `wiki/sources/<slug>.md`. The wiki summary is
    * abbreviated by design 鈥?the user's mental model when they
    * click a search-result image is "show me where this came
@@ -319,7 +319,7 @@ export function SearchView({ onClose, onOpenFile }: SearchViewProps) {
    * Path derivation: image URLs always live under
    * `<project>/wiki/media/<slug>/img-N.<ext>`. The slug matches
    * the basename of the original raw source (we wrote it that
-   * way at extraction time in extract_pdf_markdown / fs.rs's
+   * way at extraction time in fs.rs's
    * raw-sources-layout heuristic). We list `raw/sources/` once
    * and pick the file whose stem equals the slug.
    *

+ 4 - 4
src/components/settings/sections/multimodal-section.tsx

@@ -26,14 +26,14 @@ export function MultimodalSection({ draft, setDraft }: Props) {
         <p className="mt-1 text-sm text-muted-foreground">
           {t(
             "settings.sections.multimodal.description",
-            "Generate factual captions for images extracted from PDFs / DOCX / PPTX during ingest. Captions are inserted as alt text inside the source markdown — they're what semantic search matches when you search for image content. Cached by image hash so duplicate logos / charts only call the LLM once.",
+            "Generate factual captions for images extracted from DOCX / PPTX during ingest. Captions are inserted as alt text inside the source markdown — they're what semantic search matches when you search for image content. Cached by image hash so duplicate logos / charts only call the LLM once.",
           )}
         </p>
       </div>
 
       {/* Master toggle. Off by default — captioning is a non-trivial
           token spend (one VLM call per image), and silently turning
-          it on for every user the first time they import a PDF
+          it on for every user the first time they import a document
           would surprise the budget.
 
           Note: the toggle row deliberately uses a 2-tier border +
@@ -221,7 +221,7 @@ export function MultimodalSection({ draft, setDraft }: Props) {
             </div>
           )}
 
-          {/* Concurrency knob — practical impact: a 30-image PDF at
+          {/* Concurrency knob — practical impact: a 30-image PPTX at
               concurrency=1 with a 10s/image VLM is 5 minutes of
               ingest wall time; concurrency=4 makes it ~75s. Going
               wider than ~8 is rarely a win on a single-GPU server
@@ -268,7 +268,7 @@ export function MultimodalSection({ draft, setDraft }: Props) {
               <li>
                 {t(
                   "settings.sections.multimodal.costPoint3",
-                  "Rust-side filter drops images smaller than 100×100 px and caps at 500 images per source — pathological PDFs can't blow up the bill.",
+                  "Rust-side filter drops images smaller than 100×100 px and caps at 500 images per source — pathological documents can't blow up the bill.",
                 )}
               </li>
               <li>

+ 0 - 169
src/components/settings/sections/scheduled-import-section.tsx

@@ -1,169 +0,0 @@
-import { useState, useCallback } from "react"
-import { useTranslation } from "react-i18next"
-import { Input } from "@/components/ui/input"
-import { Label } from "@/components/ui/label"
-import { Button } from "@/components/ui/button"
-import { Folder, Play, RefreshCw } from "lucide-react"
-import type { SettingsDraft, DraftSetter } from "../settings-types"
-import { useWikiStore } from "@/stores/wiki-store"
-import { scanAndImport } from "@/lib/scheduled-import"
-import { pickDirectory } from "@/lib/platform"
-
-interface Props {
-  draft: SettingsDraft
-  setDraft: DraftSetter
-}
-
-export function ScheduledImportSection({ draft, setDraft }: Props) {
-  const { t } = useTranslation()
-  const project = useWikiStore((s) => s.project)
-  const scheduledImportConfig = useWikiStore((s) => s.scheduledImportConfig)
-  const [isScanning, setIsScanning] = useState(false)
-
-  const handleSelectDirectory = async () => {
-    const dir = await pickDirectory()
-    if (dir) setDraft("scheduledImportPath", dir)
-  }
-
-  const handleManualScan = useCallback(async () => {
-    if (!project || isScanning) return
-
-    setIsScanning(true)
-    try {
-      await scanAndImport(project, draft.scheduledImportPath)
-    } catch (err) {
-      console.error("[Scheduled Import] Manual scan failed:", err)
-    } finally {
-      setIsScanning(false)
-    }
-  }, [project, draft.scheduledImportPath, isScanning])
-
-  const lastScanDate = scheduledImportConfig.lastScan
-    ? new Date(scheduledImportConfig.lastScan).toLocaleString()
-    : t("settings.sections.scheduledImport.never", { defaultValue: "从未扫描" })
-
-  return (
-    <div className="space-y-6">
-      <div>
-        <h2 className="text-xl font-semibold">
-          {t("settings.sections.scheduledImport.title", {
-            defaultValue: "定时导入",
-          })}
-        </h2>
-        <p className="mt-1 text-sm text-muted-foreground">
-          {t("settings.sections.scheduledImport.description", {
-            defaultValue: "自动监控目录,并按固定间隔导入新增或更新的文件。",
-          })}
-        </p>
-      </div>
-
-      <label className="flex items-center gap-2">
-        <input
-          type="checkbox"
-          checked={draft.scheduledImportEnabled}
-          onChange={(e) => setDraft("scheduledImportEnabled", e.target.checked)}
-          className="h-4 w-4"
-        />
-        <span className="text-sm">
-          {t("settings.sections.scheduledImport.enable", {
-            defaultValue: "启用定时导入",
-          })}
-        </span>
-      </label>
-
-      {draft.scheduledImportEnabled && (
-        <div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/40 dark:text-amber-200">
-          {t("settings.sections.scheduledImport.privacyNotice", {
-            defaultValue: "所选目录中的文件可能会被复制到当前项目,并在摄取时发送给已配置的大模型。源目录删除文件后,项目内不会自动删除。",
-          })}
-        </div>
-      )}
-
-      <div className="space-y-2">
-        <Label>
-          {t("settings.sections.scheduledImport.directory", {
-            defaultValue: "监控目录",
-          })}
-        </Label>
-        <div className="flex gap-2">
-          <Input
-            value={draft.scheduledImportPath}
-            onChange={(e) => setDraft("scheduledImportPath", e.target.value)}
-            placeholder="raw/sources"
-            disabled={!draft.scheduledImportEnabled}
-            className="flex-1"
-          />
-          <Button
-            variant="outline"
-            size="icon"
-            onClick={handleSelectDirectory}
-            disabled={!draft.scheduledImportEnabled}
-            title={t("settings.sections.scheduledImport.browse", {
-              defaultValue: "浏览目录",
-            })}
-          >
-            <Folder className="h-4 w-4" />
-          </Button>
-        </div>
-        <p className="text-xs text-muted-foreground">
-          {t("settings.sections.scheduledImport.directoryHelp", {
-            defaultValue: "该目录及其子目录中的文件会自动导入;新增文件会复制到素材区,修改后的文件会重新摄取。",
-          })}
-        </p>
-      </div>
-
-      <div className="space-y-2">
-        <Label htmlFor="scheduled-import-interval">
-          {t("settings.sections.scheduledImport.interval", {
-            defaultValue: "扫描间隔(分钟)",
-          })}
-        </Label>
-        <Input
-          id="scheduled-import-interval"
-          type="number"
-          min={1}
-          max={1440}
-          value={draft.scheduledImportInterval}
-          onChange={(e) => {
-            const val = parseInt(e.target.value, 10)
-            if (!isNaN(val) && val >= 1) {
-              setDraft("scheduledImportInterval", val)
-            }
-          }}
-          disabled={!draft.scheduledImportEnabled}
-          className="w-32"
-        />
-        <p className="text-xs text-muted-foreground">
-          {t("settings.sections.scheduledImport.intervalHelp", {
-            defaultValue: "多久检查一次变更,最短为 1 分钟。",
-          })}
-        </p>
-      </div>
-
-      <div className="flex items-center gap-4">
-        <Button
-          variant="outline"
-          size="sm"
-          onClick={handleManualScan}
-          disabled={!draft.scheduledImportEnabled || !draft.scheduledImportPath || isScanning}
-        >
-          {isScanning ? (
-            <RefreshCw className="mr-2 h-4 w-4 animate-spin" />
-          ) : (
-            <Play className="mr-2 h-4 w-4" />
-          )}
-          {isScanning
-            ? t("settings.sections.scheduledImport.scanning", { defaultValue: "扫描中..." })
-            : t("settings.sections.scheduledImport.scanNow", { defaultValue: "立即扫描" })}
-        </Button>
-
-        <span className="text-xs text-muted-foreground">
-          {t("settings.sections.scheduledImport.lastScan", {
-            defaultValue: "上次扫描:{{time}}",
-            time: lastScanDate,
-          })}
-        </span>
-      </div>
-    </div>
-  )
-}

+ 0 - 239
src/components/settings/sections/source-watch-section.tsx

@@ -1,239 +0,0 @@
-import { useMemo } from "react"
-import { FolderSync, ShieldAlert } from "lucide-react"
-import { useTranslation } from "react-i18next"
-import type { SettingsDraft, DraftSetter } from "../settings-types"
-import {
-  normalizeSourceWatchConfig,
-  SOURCE_WATCH_FILE_TYPE_GROUPS,
-} from "@/lib/source-watch-config"
-
-interface Props {
-  draft: SettingsDraft
-  setDraft: DraftSetter
-  projectReady: boolean
-}
-
-function updateListValue(value: string): string[] {
-  return value
-    .split(/[,\n]/)
-    .map((item) => item.trim())
-    .filter(Boolean)
-}
-
-function joinList(values: readonly string[]): string {
-  return values.join(", ")
-}
-
-export function SourceWatchSection({ draft, setDraft, projectReady }: Props) {
-  const { t } = useTranslation()
-  const config = normalizeSourceWatchConfig(draft.sourceWatchConfig)
-  const selected = useMemo(() => new Set(config.includeExtensions), [config.includeExtensions])
-
-  const updateConfig = (patch: Partial<typeof config>) => {
-    setDraft("sourceWatchConfig", normalizeSourceWatchConfig({ ...config, ...patch }))
-  }
-
-  const toggleExtension = (ext: string, checked: boolean) => {
-    const next = new Set(config.includeExtensions)
-    if (checked) {
-      next.add(ext)
-    } else {
-      next.delete(ext)
-    }
-    updateConfig({ includeExtensions: [...next].sort() })
-  }
-
-  return (
-    <div className="space-y-6">
-      <div>
-        <h2 className="text-xl font-semibold">
-          {t("settings.sections.sourceWatch.title", { defaultValue: "Source Folder Auto Watch" })}
-        </h2>
-        <p className="mt-1 text-sm text-muted-foreground">
-          {t("settings.sections.sourceWatch.description", {
-            defaultValue:
-              "Monitor raw/sources for external changes and choose which files are allowed into automatic ingest.",
-          })}
-        </p>
-      </div>
-
-      <div className="space-y-4 rounded-lg border border-border/60 bg-muted/20 p-4">
-        <label className="flex items-start gap-3">
-          <input
-            type="checkbox"
-            checked={config.enabled}
-            onChange={(event) => updateConfig({ enabled: event.target.checked })}
-            disabled={!projectReady}
-            className="mt-1 h-4 w-4"
-          />
-          <div className="space-y-1">
-            <div className="flex items-center gap-2 text-sm font-semibold">
-              <FolderSync className="h-4 w-4 text-muted-foreground" />
-              {t("settings.sections.sourceWatch.enable", { defaultValue: "Monitor project source folder" })}
-            </div>
-            <p className="text-xs leading-relaxed text-muted-foreground">
-              {t("settings.sections.sourceWatch.enableDescription", {
-                defaultValue:
-                  "Refreshes the Sources view and indexes allowed new or modified files under raw/sources.",
-              })}
-            </p>
-          </div>
-        </label>
-
-        <label className="flex items-start gap-3">
-          <input
-            type="checkbox"
-            checked={config.autoIngest}
-            onChange={(event) => updateConfig({ autoIngest: event.target.checked })}
-            disabled={!projectReady || !config.enabled}
-            className="mt-1 h-4 w-4"
-          />
-          <div className="space-y-1">
-            <span className="text-sm font-semibold">
-              {t("settings.sections.sourceWatch.autoIngest", { defaultValue: "Auto-ingest allowed source files" })}
-            </span>
-            <p className="text-xs leading-relaxed text-muted-foreground">
-              {t("settings.sections.sourceWatch.autoIngestDescription", {
-                defaultValue:
-                  "When disabled, external changes still refresh the file tree but do not create ingest tasks.",
-              })}
-            </p>
-          </div>
-        </label>
-
-        {!projectReady && (
-          <p className="text-xs text-muted-foreground">
-            {t("settings.sections.sourceWatch.noProject", {
-              defaultValue: "Open a project to change this project-level setting.",
-            })}
-          </p>
-        )}
-      </div>
-
-      <div className="space-y-4 rounded-lg border border-border/60 bg-muted/20 p-4">
-        <div>
-          <h3 className="text-sm font-semibold">
-            {t("settings.sections.sourceWatch.fileTypes", { defaultValue: "Allowed file types" })}
-          </h3>
-          <p className="mt-1 text-xs leading-relaxed text-muted-foreground">
-            {t("settings.sections.sourceWatch.fileTypesDescription", {
-              defaultValue:
-                "Document types are enabled by default. Media, binaries, source code, and config-like formats stay off unless selected.",
-            })}
-          </p>
-        </div>
-
-        <div className="space-y-4">
-          {SOURCE_WATCH_FILE_TYPE_GROUPS.map((group) => (
-            <div key={group.id} className="space-y-2">
-              <div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
-                {t(`settings.sections.sourceWatch.groups.${group.id}`, { defaultValue: group.id })}
-              </div>
-              <div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
-                {group.extensions.map((ext) => (
-                  <label
-                    key={ext}
-                    className="flex items-center gap-2 rounded-md border border-border/60 bg-background px-2 py-1.5 text-xs"
-                  >
-                    <input
-                      type="checkbox"
-                      checked={selected.has(ext)}
-                      onChange={(event) => toggleExtension(ext, event.target.checked)}
-                      disabled={!projectReady || !config.enabled}
-                      className="h-3.5 w-3.5"
-                    />
-                    <span>.{ext}</span>
-                  </label>
-                ))}
-              </div>
-            </div>
-          ))}
-        </div>
-      </div>
-
-      <div className="space-y-4 rounded-lg border border-border/60 bg-muted/20 p-4">
-        <div className="flex items-center gap-2">
-          <ShieldAlert className="h-4 w-4 text-muted-foreground" />
-          <h3 className="text-sm font-semibold">
-            {t("settings.sections.sourceWatch.exclusions", { defaultValue: "Exclusions and limits" })}
-          </h3>
-        </div>
-
-        <label className="block space-y-1.5">
-          <span className="text-xs font-medium">
-            {t("settings.sections.sourceWatch.maxSize", { defaultValue: "Maximum auto-ingest file size (MB)" })}
-          </span>
-          <input
-            type="number"
-            min={1}
-            max={4096}
-            value={config.maxFileSizeMb}
-            onChange={(event) => updateConfig({ maxFileSizeMb: Number(event.target.value) || 1 })}
-            disabled={!projectReady || !config.enabled}
-            className="w-32 rounded-md border border-input bg-background px-2 py-1 text-sm"
-          />
-        </label>
-
-        <label className="block space-y-1.5">
-          <span className="text-xs font-medium">
-            {t("settings.sections.sourceWatch.excludeDirs", { defaultValue: "Excluded folders" })}
-          </span>
-          <textarea
-            value={joinList(config.excludeDirs)}
-            onChange={(event) => updateConfig({ excludeDirs: updateListValue(event.target.value) })}
-            placeholder={t("settings.sections.sourceWatch.excludeDirsPlaceholder", {
-              defaultValue: ".git, node_modules, drafts, subdir/drafts",
-            })}
-            disabled={!projectReady || !config.enabled}
-            rows={2}
-            className="w-full rounded-md border border-input bg-background px-2 py-1 text-sm"
-          />
-          <p className="text-[11px] leading-relaxed text-muted-foreground">
-            {t("settings.sections.sourceWatch.excludeDirsHint", {
-              defaultValue: "Folder names match any path segment. Paths such as subdir/drafts match that nested folder.",
-            })}
-          </p>
-        </label>
-
-        <label className="block space-y-1.5">
-          <span className="text-xs font-medium">
-            {t("settings.sections.sourceWatch.excludeExtensions", { defaultValue: "Excluded file extensions" })}
-          </span>
-          <textarea
-            value={joinList(config.excludeExtensions)}
-            onChange={(event) => updateConfig({ excludeExtensions: updateListValue(event.target.value) })}
-            placeholder={t("settings.sections.sourceWatch.excludeExtensionsPlaceholder", {
-              defaultValue: "tmp, bak, exe, dll, iso, dmg",
-            })}
-            disabled={!projectReady || !config.enabled}
-            rows={2}
-            className="w-full rounded-md border border-input bg-background px-2 py-1 text-sm"
-          />
-          <p className="text-[11px] leading-relaxed text-muted-foreground">
-            {t("settings.sections.sourceWatch.excludeExtensionsHint", {
-              defaultValue: "Use extensions without dots. These override the allowed file type checkboxes.",
-            })}
-          </p>
-        </label>
-
-        <label className="block space-y-1.5">
-          <span className="text-xs font-medium">
-            {t("settings.sections.sourceWatch.excludeGlobs", { defaultValue: "Excluded filename patterns" })}
-          </span>
-          <textarea
-            value={joinList(config.excludeGlobs)}
-            onChange={(event) => updateConfig({ excludeGlobs: updateListValue(event.target.value) })}
-            disabled={!projectReady || !config.enabled}
-            rows={2}
-            className="w-full rounded-md border border-input bg-background px-2 py-1 text-sm"
-          />
-          <p className="text-[11px] leading-relaxed text-muted-foreground">
-            {t("settings.sections.sourceWatch.excludeGlobsHint", {
-              defaultValue: "Use comma-separated patterns such as *.draft.*, ~$*, .~lock.*#.",
-            })}
-          </p>
-        </label>
-      </div>
-    </div>
-  )
-}

+ 1 - 9
src/components/settings/settings-types.ts

@@ -1,5 +1,5 @@
 import type { CustomApiMode } from "./llm-presets"
-import type { AzureModelFamily, ReasoningConfig, SourceWatchConfig, RevisionFeedbackWindowConfig, NovelConfig, RerankConfig, OutputLanguage } from "@/stores/wiki-store"
+import type { AzureModelFamily, ReasoningConfig, RevisionFeedbackWindowConfig, NovelConfig, RerankConfig, OutputLanguage } from "@/stores/wiki-store"
 import type { SidebarNavConfig } from "@/lib/sidebar-nav-preferences"
 import type { UiFontFamily } from "@/lib/font-settings"
 import type { VisualStyle } from "@/lib/visual-style-settings"
@@ -60,11 +60,6 @@ export interface SettingsDraft {
   proxyUrl: string
   proxyBypassLocal: boolean
 
-  // Scheduled Import
-  scheduledImportEnabled: boolean
-  scheduledImportPath: string
-  scheduledImportInterval: number // minutes
-
   // UI
   uiLanguage: string
   uiFontSizeScale: number
@@ -72,9 +67,6 @@ export interface SettingsDraft {
   visualStyle: VisualStyle
   sidebarNavConfig: SidebarNavConfig
 
-  // Source folder auto watch
-  sourceWatchConfig: SourceWatchConfig
-
   // Novel feedback window
   revisionFeedbackWindowConfig: RevisionFeedbackWindowConfig
 

+ 1 - 90
src/components/settings/settings-view.tsx

@@ -24,9 +24,8 @@ import { PanelHeaderWithHelp } from "@/components/layout/panel-header-with-help"
 import { useWikiStore } from "@/stores/wiki-store"
 import { isTauri } from "@/lib/platform"
 import { useChatStore } from "@/stores/chat-store"
-import { loadSourceWatchConfig, saveLanguage, loadNovelConfig, loadRerankConfig } from "@/lib/project-store"
+import { saveLanguage, loadNovelConfig, loadRerankConfig } from "@/lib/project-store"
 import type { SettingsDraft, DraftSetter } from "./settings-types"
-import { normalizeSourceWatchConfig } from "@/lib/source-watch-config"
 import type { SidebarNavConfig } from "@/lib/sidebar-nav-preferences"
 import type { UiFontFamily } from "@/lib/font-settings"
 import { LlmProviderSection } from "./sections/llm-provider-section"
@@ -114,8 +113,6 @@ function initialDraft(
   multimodal: ReturnType<typeof useWikiStore.getState>["multimodalConfig"],
   outputLanguage: ReturnType<typeof useWikiStore.getState>["outputLanguage"],
   proxy: ReturnType<typeof useWikiStore.getState>["proxyConfig"],
-  scheduledImport: ReturnType<typeof useWikiStore.getState>["scheduledImportConfig"],
-  sourceWatch: ReturnType<typeof useWikiStore.getState>["sourceWatchConfig"],
   revisionFeedbackWindowConfig: ReturnType<typeof useWikiStore.getState>["revisionFeedbackWindowConfig"],
   novelConfig: ReturnType<typeof useWikiStore.getState>["novelConfig"],
   maxHistoryMessages: number,
@@ -124,19 +121,7 @@ function initialDraft(
   uiFontFamily: UiFontFamily,
   visualStyle: SettingsDraft["visualStyle"],
   sidebarNavConfig: SidebarNavConfig,
-  projectPath?: string,
 ): SettingsDraft {
-  // Show absolute path: if stored path is empty, show default using project path
-  // If stored path is relative (legacy), prepend project path
-  // If stored path is absolute, show as-is
-  let displayPath = scheduledImport.path || ""
-  if (!displayPath && projectPath) {
-    displayPath = `${projectPath}/raw/sources`
-  } else if (displayPath && projectPath && !displayPath.startsWith("/") && !displayPath.match(/^[a-zA-Z]:[/\\]/)) {
-    // Legacy relative path - prepend project path for display
-    displayPath = `${projectPath}/${displayPath}`
-  }
-
   return {
     provider: llm.provider,
     apiKey: llm.apiKey,
@@ -173,10 +158,6 @@ function initialDraft(
     proxyEnabled: proxy.enabled,
     proxyUrl: proxy.url,
     proxyBypassLocal: proxy.bypassLocal,
-    scheduledImportEnabled: scheduledImport.enabled,
-    scheduledImportPath: displayPath,
-    scheduledImportInterval: scheduledImport.interval,
-    sourceWatchConfig: normalizeSourceWatchConfig(sourceWatch),
     revisionFeedbackWindowConfig,
     novelConfig,
     uiLanguage,
@@ -204,10 +185,6 @@ export function SettingsView() {
   const setOutputLanguage = useWikiStore((s) => s.setOutputLanguage)
   const proxyConfig = useWikiStore((s) => s.proxyConfig)
   const setProxyConfig = useWikiStore((s) => s.setProxyConfig)
-  const scheduledImportConfig = useWikiStore((s) => s.scheduledImportConfig)
-  const setScheduledImportConfig = useWikiStore((s) => s.setScheduledImportConfig)
-  const sourceWatchConfig = useWikiStore((s) => s.sourceWatchConfig)
-  const setSourceWatchConfig = useWikiStore((s) => s.setSourceWatchConfig)
   const revisionFeedbackWindowConfig = useWikiStore((s) => s.revisionFeedbackWindowConfig)
   const setRevisionFeedbackWindowConfig = useWikiStore((s) => s.setRevisionFeedbackWindowConfig)
   const novelConfig = useWikiStore((s) => s.novelConfig)
@@ -233,8 +210,6 @@ export function SettingsView() {
       multimodalConfig,
       outputLanguage,
       proxyConfig,
-      scheduledImportConfig,
-      sourceWatchConfig,
       revisionFeedbackWindowConfig,
       novelConfig,
       maxHistoryMessages,
@@ -243,7 +218,6 @@ export function SettingsView() {
       uiFontFamily,
       visualStyle,
       sidebarNavConfig,
-      project?.path,
     ),
   )
 
@@ -255,24 +229,6 @@ export function SettingsView() {
     setActiveSettingsCategory(null)
   }, [activeSettingsCategory, setActiveSettingsCategory])
 
-  useEffect(() => {
-    let cancelled = false
-    loadSourceWatchConfig(project?.id).then((config) => {
-      if (cancelled) return
-      const normalized = normalizeSourceWatchConfig(config)
-      setSourceWatchConfig(normalized)
-      setDraftState((prev) => ({ ...prev, sourceWatchConfig: normalized }))
-    }).catch(() => {
-      if (cancelled) return
-      const fallback = normalizeSourceWatchConfig()
-      setSourceWatchConfig(fallback)
-      setDraftState((prev) => ({ ...prev, sourceWatchConfig: fallback }))
-    })
-    return () => {
-      cancelled = true
-    }
-  }, [project?.id, setSourceWatchConfig])
-
   useEffect(() => {
     let cancelled = false
     loadNovelConfig(project?.id, project?.path).then((config) => {
@@ -314,8 +270,6 @@ export function SettingsView() {
         multimodalConfig,
         outputLanguage,
         proxyConfig,
-        scheduledImportConfig,
-        sourceWatchConfig,
         revisionFeedbackWindowConfig,
         novelConfig,
         maxHistoryMessages,
@@ -324,7 +278,6 @@ export function SettingsView() {
         uiFontFamily,
         visualStyle,
         sidebarNavConfig,
-        project?.path,
       ),
     )
   }, [
@@ -334,8 +287,6 @@ export function SettingsView() {
     multimodalConfig,
     outputLanguage,
     proxyConfig,
-    scheduledImportConfig,
-    sourceWatchConfig,
     revisionFeedbackWindowConfig,
     novelConfig,
     maxHistoryMessages,
@@ -343,7 +294,6 @@ export function SettingsView() {
     uiFontFamily,
     visualStyle,
     sidebarNavConfig,
-    project,
   ])
 
   const setDraft: DraftSetter = useCallback((key, value) => {
@@ -357,8 +307,6 @@ export function SettingsView() {
       saveRerankConfig,
       saveMultimodalConfig,
       saveProxyConfig,
-      saveScheduledImportConfig,
-      saveSourceWatchConfig,
       saveRevisionFeedbackWindowConfig,
       saveNovelConfig,
       saveDefaultLlmModel,
@@ -432,19 +380,6 @@ export function SettingsView() {
     await saveMultimodalConfig(newMultimodal)
     setProxyConfig(newProxy)
     await saveProxyConfig(newProxy)
-    const newSourceWatch = normalizeSourceWatchConfig(draft.sourceWatchConfig)
-    setSourceWatchConfig(newSourceWatch)
-    await saveSourceWatchConfig(newSourceWatch, project?.id, project?.path)
-    if (project) {
-      const { startProjectFileSync, stopProjectFileSync } = await import("@/lib/project-file-sync")
-      if (newSourceWatch.enabled) {
-        await startProjectFileSync(project, newSourceWatch).catch((err) =>
-          console.error("Failed to start project file sync:", err)
-        )
-      } else {
-        await stopProjectFileSync()
-      }
-    }
     // Apply the proxy env vars LIVE so the next outbound request
     // picks them up — no app restart needed. tauri-plugin-http
     // builds a fresh reqwest client per fetch and reqwest reads
@@ -458,27 +393,6 @@ export function SettingsView() {
       console.warn("[settings] live network update failed; restart will still apply:", err)
     }
 
-    const newScheduledImport = {
-      enabled: draft.scheduledImportEnabled,
-      path: draft.scheduledImportPath,
-      interval: Math.max(1, Math.min(1440, draft.scheduledImportInterval || 60)),
-      lastScan: scheduledImportConfig.lastScan,
-    }
-    setScheduledImportConfig(newScheduledImport)
-    if (project) {
-      await saveScheduledImportConfig(project.path, newScheduledImport)
-      const { startScheduledImport, stopScheduledImport } = await import("@/lib/scheduled-import")
-      if (
-        newScheduledImport.enabled &&
-        newScheduledImport.path &&
-        newScheduledImport.interval > 0
-      ) {
-        startScheduledImport(project, newScheduledImport)
-      } else {
-        stopScheduledImport()
-      }
-    }
-
     setRevisionFeedbackWindowConfig(draft.revisionFeedbackWindowConfig)
     await saveRevisionFeedbackWindowConfig(draft.revisionFeedbackWindowConfig, project?.id, project?.path)
 
@@ -515,11 +429,8 @@ export function SettingsView() {
     setRerankConfig,
     setOutputLanguage,
     setProxyConfig,
-    setScheduledImportConfig,
-    setSourceWatchConfig,
     setRevisionFeedbackWindowConfig,
     setNovelConfig,
-    scheduledImportConfig,
     setMaxHistoryMessages,
     outputLanguage,
     setUiFontSizeScale,

+ 0 - 806
src/components/sources/source-sidebar.tsx

@@ -1,806 +0,0 @@
-import { useState, useEffect, useCallback, useMemo, useRef } from "react"
-import { Plus, FileText, Trash2, Folder, ChevronRight, ChevronDown, BookOpen, Check, Loader2 } from "lucide-react"
-import { isTauri } from "@/lib/platform"
-import { Button } from "@/components/ui/button"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import { TooltipProvider } from "@/components/ui/tooltip"
-import { useWikiStore } from "@/stores/wiki-store"
-import { copyFile, deleteFile, fileExists, listDirectory } from "@/commands/fs"
-import type { FileNode } from "@/types/wiki"
-import { useTranslation } from "react-i18next"
-import { getFileName, getFileStem, normalizePath } from "@/lib/path-utils"
-import { decideDeleteClick } from "@/lib/sources-tree-delete"
-import { saveNovelConfig } from "@/lib/project-store"
-import {
-  deleteSourceFile,
-  deleteSourceFolder,
-  enqueueSourceIngest,
-  importSourceFiles,
-  importSourceFolder,
-} from "@/lib/source-lifecycle"
-import { getQueue } from "@/lib/ingest-queue"
-import type { KnowledgeCreateRequest } from "@/components/layout/knowledge-tree"
-
-const SOURCE_TREE_INITIAL_ROWS = 160
-const SOURCE_TREE_LOAD_BATCH = 160
-
-export function SourceSidebar({
-  onRequestCreate,
-}: {
-  onRequestCreate?: (request: KnowledgeCreateRequest) => void
-}) {
-  const { t } = useTranslation()
-  const project = useWikiStore((s) => s.project)
-  const selectedFile = useWikiStore((s) => s.selectedFile)
-  const setSelectedFile = useWikiStore((s) => s.setSelectedFile)
-  const setFileTree = useWikiStore((s) => s.setFileTree)
-  const novelConfig = useWikiStore((s) => s.novelConfig)
-  const setNovelConfig = useWikiStore((s) => s.setNovelConfig)
-  const llmConfig = useWikiStore((s) => s.llmConfig)
-  const dataVersion = useWikiStore((s) => s.dataVersion)
-  const [sources, setSources] = useState<FileNode[]>([])
-  const [importing, setImporting] = useState(false)
-  const [ingestingPath, setIngestingPath] = useState<string | null>(null)
-  const [extractTaskIdsByPath, setExtractTaskIdsByPath] = useState<Record<string, string[]>>({})
-  const [extractedPaths, setExtractedPaths] = useState<Set<string>>(() => new Set())
-  const [pendingDeletePath, setPendingDeletePath] = useState<string | null>(null)
-  const [importMenuOpen, setImportMenuOpen] = useState(false)
-  const [createMenu, setCreateMenu] = useState<{ x: number; y: number } | null>(null)
-  const [fileMenu, setFileMenu] = useState<{ path: string; x: number; y: number } | null>(null)
-  const [renamingPath, setRenamingPath] = useState<string | null>(null)
-  const [renameValue, setRenameValue] = useState("")
-  const [renamingBusy, setRenamingBusy] = useState(false)
-  const importMenuRef = useRef<HTMLDivElement | null>(null)
-  const containerRef = useRef<HTMLDivElement | null>(null)
-
-  const loadSources = useCallback(async () => {
-    if (!project) return
-    const pp = normalizePath(project.path)
-    try {
-      const tree = await listDirectory(`${pp}/raw/sources`)
-      setSources(filterTree(tree))
-    } catch {
-      setSources([])
-    }
-  }, [project])
-
-  useEffect(() => {
-    void loadSources()
-  }, [loadSources, dataVersion])
-
-  useEffect(() => {
-    if (!pendingDeletePath) return
-    const timer = setTimeout(() => setPendingDeletePath(null), 5000)
-    return () => clearTimeout(timer)
-  }, [pendingDeletePath])
-
-  useEffect(() => {
-    if (!importMenuOpen) return
-    const handlePointerDown = (event: MouseEvent) => {
-      const target = event.target
-      if (!(target instanceof Node)) return
-      if (importMenuRef.current?.contains(target)) return
-      setImportMenuOpen(false)
-    }
-    const handleEscape = (event: KeyboardEvent) => {
-      if (event.key === "Escape") setImportMenuOpen(false)
-    }
-    document.addEventListener("mousedown", handlePointerDown)
-    document.addEventListener("keydown", handleEscape)
-    return () => {
-      document.removeEventListener("mousedown", handlePointerDown)
-      document.removeEventListener("keydown", handleEscape)
-    }
-  }, [importMenuOpen])
-
-  useEffect(() => {
-    if (!createMenu && !fileMenu) return
-    const closeMenu = () => {
-      setCreateMenu(null)
-      setFileMenu(null)
-    }
-    document.addEventListener("mousedown", closeMenu)
-    document.addEventListener("keydown", closeMenu)
-    return () => {
-      document.removeEventListener("mousedown", closeMenu)
-      document.removeEventListener("keydown", closeMenu)
-    }
-  }, [createMenu, fileMenu])
-
-  useEffect(() => {
-    if (Object.keys(extractTaskIdsByPath).length === 0) return
-    const timer = window.setInterval(() => {
-      const queue = getQueue()
-      const completed: string[] = []
-
-      setExtractTaskIdsByPath((prev) => {
-        let changed = false
-        const next = { ...prev }
-        for (const [path, ids] of Object.entries(prev)) {
-          const tasks = ids.map((id) => queue.find((task) => task.id === id)).filter(Boolean)
-          const failed = tasks.some((task) => task?.status === "failed")
-          const stillActive = tasks.some((task) => task?.status === "pending" || task?.status === "processing")
-          if (!stillActive) {
-            delete next[path]
-            changed = true
-            if (!failed) completed.push(path)
-          }
-        }
-        return changed ? next : prev
-      })
-
-      if (completed.length > 0) {
-        setExtractedPaths((prev) => {
-          const next = new Set(prev)
-          for (const path of completed) next.add(path)
-          return next
-        })
-      }
-    }, 1000)
-
-    return () => window.clearInterval(timer)
-  }, [extractTaskIdsByPath])
-
-  const registerExtractTasks = useCallback((taskIdsByPath: Record<string, string[]>, importedPaths: string[] = []) => {
-    const normalizedPaths = importedPaths.map(normalizePath)
-    if (normalizedPaths.length > 0) {
-      setExtractedPaths((prev) => {
-        const next = new Set(prev)
-        for (const path of normalizedPaths) next.delete(path)
-        return next
-      })
-    }
-    if (Object.keys(taskIdsByPath).length === 0) return
-    setExtractTaskIdsByPath((prev) => ({ ...prev, ...taskIdsByPath }))
-  }, [])
-
-  async function handleToggleAutoExtract() {
-    if (!project) return
-    const nextValue = !novelConfig.autoExtractOnImport
-    const nextConfig = { ...novelConfig, autoExtractOnImport: nextValue }
-    setNovelConfig({ autoExtractOnImport: nextValue })
-    try {
-      await saveNovelConfig(nextConfig, project.id, project.path)
-    } catch (err) {
-      console.error("保存自动提取设置失败:", err)
-    }
-  }
-
-  async function handleImportFiles() {
-    if (!project) return
-    if (!isTauri()) {
-      window.alert("导入文件功能仅在桌面端可用")
-      return
-    }
-    const { open } = await import("@tauri-apps/plugin-dialog")
-    const selected = await open({
-      multiple: true,
-      title: t("sources.importSourceFiles"),
-      filters: [
-        { name: "文档", extensions: ["md", "mdx", "txt", "rtf", "pdf", "html", "htm", "xml", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods", "odp", "epub", "pages", "numbers", "key"] },
-        { name: "数据文件", extensions: ["json", "jsonl", "csv", "tsv", "yaml", "yml", "ndjson"] },
-        { name: "代码", extensions: ["py", "js", "ts", "jsx", "tsx", "rs", "go", "java", "c", "cpp", "h", "rb", "php", "swift", "sql", "sh"] },
-        { name: "图片", extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "tiff", "avif", "heic"] },
-        { name: "媒体", extensions: ["mp4", "webm", "mov", "avi", "mkv", "mp3", "wav", "ogg", "flac", "m4a"] },
-        { name: "所有文件", extensions: ["*"] },
-      ],
-    })
-    if (!selected || selected.length === 0) return
-
-    setImporting(true)
-    try {
-      const paths = Array.isArray(selected) ? selected : [selected]
-      const result = await importSourceFiles(project, paths, llmConfig, {
-        autoExtract: novelConfig.autoExtractOnImport,
-      })
-      registerExtractTasks(result.taskIdsByPath, result.importedPaths)
-      await loadSources()
-    } finally {
-      setImporting(false)
-      setImportMenuOpen(false)
-    }
-  }
-
-  async function handleImportFolder() {
-    if (!project) return
-    if (!isTauri()) {
-      window.alert("导入文件夹功能仅在桌面端可用")
-      return
-    }
-    const { open } = await import("@tauri-apps/plugin-dialog")
-    const selected = await open({
-      directory: true,
-      title: t("sources.importSourceFolder"),
-    })
-    if (!selected || typeof selected !== "string") return
-
-    setImporting(true)
-    try {
-      const result = await importSourceFolder(project, selected, llmConfig, {
-        autoExtract: novelConfig.autoExtractOnImport,
-      })
-      registerExtractTasks(result.taskIdsByPath, result.importedPaths)
-      await loadSources()
-    } finally {
-      setImporting(false)
-      setImportMenuOpen(false)
-    }
-  }
-
-  async function handleDelete(node: FileNode) {
-    if (!project) return
-    const pp = normalizePath(project.path)
-    try {
-      const result = await deleteSourceFile(pp, node.path)
-      await loadSources()
-      const tree = await listDirectory(pp)
-      setFileTree(tree)
-      useWikiStore.getState().bumpDataVersion()
-      if (selectedFile === node.path || result.deletedWikiPaths.includes(selectedFile ?? "")) {
-        setSelectedFile(null)
-      }
-    } catch (err) {
-      console.error("删除源文件失败:", err)
-      window.alert(`删除失败:${err}`)
-    }
-  }
-
-  async function handleDeleteFolder(folder: FileNode) {
-    if (!project) return
-    const pp = normalizePath(project.path)
-    try {
-      const result = await deleteSourceFolder(pp, folder)
-      await loadSources()
-      const tree = await listDirectory(pp)
-      setFileTree(tree)
-      useWikiStore.getState().bumpDataVersion()
-      if (selectedFile?.startsWith(folder.path + "/") || result.deletedWikiPaths.includes(selectedFile ?? "")) {
-        setSelectedFile(null)
-      }
-    } catch (err) {
-      console.error("删除文件夹失败:", err)
-      window.alert(`删除文件夹失败:${err}`)
-    }
-  }
-
-  async function handleIngest(node: FileNode) {
-    if (!project || ingestingPath) return
-    const normalizedNodePath = normalizePath(node.path)
-    setIngestingPath(node.path)
-    setExtractedPaths((prev) => {
-      const next = new Set(prev)
-      next.delete(normalizedNodePath)
-      return next
-    })
-    try {
-      const ids = await enqueueSourceIngest(project, [node.path], llmConfig)
-      if (ids.length > 0) {
-        setExtractTaskIdsByPath((prev) => ({ ...prev, [normalizedNodePath]: ids }))
-      }
-    } catch (err) {
-      console.error("加入提取队列失败:", err)
-    } finally {
-      setIngestingPath(null)
-    }
-  }
-
-  const openCreateMenu = useCallback((event: React.MouseEvent) => {
-    event.preventDefault()
-    event.stopPropagation()
-    const rect = containerRef.current?.getBoundingClientRect()
-    setCreateMenu({
-      x: rect ? event.clientX - rect.left : event.clientX,
-      y: rect ? event.clientY - rect.top : event.clientY,
-    })
-    setImportMenuOpen(false)
-  }, [])
-
-  const handleBlankContextMenu = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
-    const target = event.target
-    if (!(target instanceof HTMLElement)) return
-    if (target.closest("[data-source-interactive='true']")) return
-    openCreateMenu(event)
-  }, [openCreateMenu])
-
-  const startRename = useCallback((node: FileNode) => {
-    setFileMenu(null)
-    setRenamingPath(node.path)
-    setRenameValue(getFileStem(node.name))
-  }, [])
-
-  const cancelRename = useCallback(() => {
-    if (renamingBusy) return
-    setRenamingPath(null)
-    setRenameValue("")
-  }, [renamingBusy])
-
-  const submitRename = useCallback(async () => {
-    if (!project || !renamingPath || renamingBusy) return
-    const currentNode = findNodeByPath(sources, renamingPath)
-    const nextStem = renameValue.trim()
-    if (!currentNode || !nextStem) {
-      cancelRename()
-      return
-    }
-
-    const currentName = getFileName(currentNode.path)
-    const extensionMatch = currentName.match(/(\.[^.]+)$/)
-    const extension = extensionMatch?.[1] ?? ""
-    const candidateName = nextStem.endsWith(extension) ? nextStem : `${nextStem}${extension}`
-    const dir = normalizePath(currentNode.path).replace(/\/[^/]+$/, "")
-    let targetPath = `${dir}/${candidateName}`
-    if (normalizePath(targetPath) === normalizePath(currentNode.path)) {
-      cancelRename()
-      return
-    }
-
-    setRenamingBusy(true)
-    try {
-      if (await fileExists(targetPath)) {
-        const stem = extension ? candidateName.slice(0, -extension.length) : candidateName
-        let index = 2
-        while (await fileExists(`${dir}/${stem}-${index}${extension}`)) {
-          index += 1
-        }
-        targetPath = `${dir}/${stem}-${index}${extension}`
-      }
-      await copyFile(currentNode.path, targetPath)
-      await deleteFile(currentNode.path)
-      await loadSources()
-      const pp = normalizePath(project.path)
-      const tree = await listDirectory(pp)
-      setFileTree(tree)
-      useWikiStore.getState().bumpDataVersion()
-      if (selectedFile === currentNode.path) {
-        setSelectedFile(targetPath)
-      }
-    } catch (error) {
-      console.error("重命名源文件失败:", error)
-      window.alert(`重命名失败:${error}`)
-    } finally {
-      setRenamingBusy(false)
-      setRenamingPath(null)
-      setRenameValue("")
-    }
-  }, [cancelRename, loadSources, project, renameValue, renamingBusy, renamingPath, selectedFile, setFileTree, setSelectedFile, sources])
-
-  return (
-    <TooltipProvider delay={300}>
-      <div
-        ref={containerRef}
-        className="relative flex h-full flex-col"
-        onClick={() => {
-          setCreateMenu(null)
-          setFileMenu(null)
-        }}
-        onContextMenu={handleBlankContextMenu}
-      >
-        <div className="border-b px-3 py-2">
-          <div className="mb-2 text-sm font-semibold text-foreground">{t("sidebar.files")}</div>
-          <div className="flex flex-wrap items-center gap-1" data-source-interactive="true">
-            <div className="flex items-center gap-1 rounded-md border border-border/60 px-2 py-1" data-source-interactive="true">
-              <span className="text-[11px] text-muted-foreground">
-                {t("novel.sources.autoExtract", { defaultValue: "自动提取" })}
-              </span>
-              <button
-                type="button"
-                onClick={() => void handleToggleAutoExtract()}
-                className={`relative inline-flex h-4 w-8 shrink-0 rounded-full border border-transparent transition-colors ${
-                  novelConfig.autoExtractOnImport ? "bg-primary" : "bg-input"
-                }`}
-                aria-pressed={novelConfig.autoExtractOnImport}
-              >
-                <span
-                  className={`pointer-events-none inline-block h-3.5 w-3.5 rounded-full bg-background shadow transition-transform ${
-                    novelConfig.autoExtractOnImport ? "translate-x-4" : "translate-x-0"
-                  }`}
-                />
-              </button>
-            </div>
-            <div ref={importMenuRef} className="relative" data-source-interactive="true">
-              <Button
-                size="sm"
-                variant="outline"
-                onClick={() => setImportMenuOpen((prev) => !prev)}
-                disabled={importing}
-                className="h-7 px-2 text-xs"
-              >
-                <Plus className="mr-1 h-3.5 w-3.5" />
-                {importing ? t("sources.importing") : t("novel.sources.import", { defaultValue: "导入" })}
-              </Button>
-              {importMenuOpen ? (
-                <div className="absolute left-0 top-full z-20 mt-1 w-32 rounded-md border bg-popover py-1 text-xs text-popover-foreground shadow-lg" data-source-interactive="true">
-                  <button
-                    type="button"
-                    className="block w-full px-3 py-1.5 text-left hover:bg-accent"
-                    onClick={() => void handleImportFiles()}
-                  >
-                    {t("sources.importFiles")}
-                  </button>
-                  <button
-                    type="button"
-                    className="block w-full px-3 py-1.5 text-left hover:bg-accent"
-                    onClick={() => void handleImportFolder()}
-                  >
-                    {t("sources.importFolder")}
-                  </button>
-                </div>
-              ) : null}
-            </div>
-          </div>
-        </div>
-
-        <ScrollArea className="flex-1">
-          <div className="p-2">
-            {sources.length === 0 ? (
-              <div className="px-2 py-4 text-sm text-muted-foreground">{t("novel.sources.noSources")}</div>
-            ) : (
-              <SourceTree
-                nodes={sources}
-                onOpen={(node) => setSelectedFile(node.path)}
-                onIngest={handleIngest}
-                onDelete={handleDelete}
-                onDeleteFolder={handleDeleteFolder}
-                pendingDeletePath={pendingDeletePath}
-                setPendingDeletePath={setPendingDeletePath}
-                ingestingPath={ingestingPath}
-                extractTaskIdsByPath={extractTaskIdsByPath}
-                extractedPaths={extractedPaths}
-                renamingPath={renamingPath}
-                renameValue={renameValue}
-                setRenameValue={setRenameValue}
-                renamingBusy={renamingBusy}
-                onSubmitRename={submitRename}
-                onCancelRename={cancelRename}
-                onOpenFileMenu={(event, node) => {
-                  event.preventDefault()
-                  event.stopPropagation()
-                  const rect = containerRef.current?.getBoundingClientRect()
-                  setFileMenu({
-                    path: node.path,
-                    x: rect ? event.clientX - rect.left : event.clientX,
-                    y: rect ? event.clientY - rect.top : event.clientY,
-                  })
-                  setCreateMenu(null)
-                }}
-              />
-            )}
-          </div>
-        </ScrollArea>
-        {createMenu ? (
-          <div
-            className="absolute z-20 w-40 rounded-md border bg-background py-1 text-xs shadow-lg"
-            style={{ left: createMenu.x, top: createMenu.y }}
-            onMouseDown={(event) => event.stopPropagation()}
-            onClick={(event) => event.stopPropagation()}
-            data-source-interactive="true"
-          >
-            <button
-              type="button"
-              className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
-              onClick={() => {
-                onRequestCreate?.({ kind: "outline" })
-                setCreateMenu(null)
-              }}
-            >
-              <Plus className="h-3.5 w-3.5" />
-              {t("sidebar.newOutline")}
-            </button>
-            <button
-              type="button"
-              className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
-              onClick={() => {
-                onRequestCreate?.({ kind: "folder" })
-                setCreateMenu(null)
-              }}
-            >
-              <Folder className="h-3.5 w-3.5" />
-              {t("sidebar.newFolder")}
-            </button>
-          </div>
-        ) : null}
-        {fileMenu ? (
-          <div
-            className="absolute z-20 w-40 rounded-md border bg-background py-1 text-xs shadow-lg"
-            style={{ left: fileMenu.x, top: fileMenu.y }}
-            onMouseDown={(event) => event.stopPropagation()}
-            onClick={(event) => event.stopPropagation()}
-            data-source-interactive="true"
-          >
-            <button
-              type="button"
-              className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
-              onClick={() => {
-                const node = findNodeByPath(sources, fileMenu.path)
-                if (node && !node.is_dir) {
-                  startRename(node)
-                } else {
-                  setFileMenu(null)
-                }
-              }}
-            >
-              {t("knowledgeTree.rename")}
-            </button>
-          </div>
-        ) : null}
-      </div>
-    </TooltipProvider>
-  )
-}
-
-interface SourceTreeRow {
-  node: FileNode
-  depth: number
-}
-
-function filterTree(nodes: FileNode[]): FileNode[] {
-  return nodes
-    .filter((n) => !n.name.startsWith("."))
-    .map((n) => (n.is_dir && n.children ? { ...n, children: filterTree(n.children) } : n))
-    .filter((n) => !n.is_dir || (n.children && n.children.length > 0))
-}
-
-function countFiles(nodes: FileNode[]): number {
-  let count = 0
-  for (const node of nodes) {
-    if (node.is_dir && node.children) count += countFiles(node.children)
-    else if (!node.is_dir) count += 1
-  }
-  return count
-}
-
-function sortSourceNodes(nodes: readonly FileNode[]): FileNode[] {
-  return [...nodes].sort((a, b) => {
-    if (a.is_dir && !b.is_dir) return -1
-    if (!a.is_dir && b.is_dir) return 1
-    return a.name.localeCompare(b.name, "zh-CN")
-  })
-}
-
-function flattenVisibleRows(nodes: readonly FileNode[], collapsed: Record<string, boolean>, depth = 0): SourceTreeRow[] {
-  const rows: SourceTreeRow[] = []
-  for (const node of sortSourceNodes(nodes)) {
-    rows.push({ node, depth })
-    if (node.is_dir && node.children && !(collapsed[node.path] ?? false)) {
-      rows.push(...flattenVisibleRows(node.children, collapsed, depth + 1))
-    }
-  }
-  return rows
-}
-
-function findNodeByPath(nodes: readonly FileNode[], targetPath: string): FileNode | null {
-  for (const node of nodes) {
-    if (normalizePath(node.path) === normalizePath(targetPath)) return node
-    if (node.is_dir && node.children) {
-      const match = findNodeByPath(node.children, targetPath)
-      if (match) return match
-    }
-  }
-  return null
-}
-
-function SourceTree({
-  nodes,
-  onOpen,
-  onIngest,
-  onDelete,
-  onDeleteFolder,
-  pendingDeletePath,
-  setPendingDeletePath,
-  ingestingPath,
-  extractTaskIdsByPath,
-  extractedPaths,
-  renamingPath,
-  renameValue,
-  setRenameValue,
-  renamingBusy,
-  onSubmitRename,
-  onCancelRename,
-  onOpenFileMenu,
-}: {
-  nodes: FileNode[]
-  onOpen: (node: FileNode) => void
-  onIngest: (node: FileNode) => void
-  onDelete: (node: FileNode) => void
-  onDeleteFolder: (node: FileNode) => void
-  pendingDeletePath: string | null
-  setPendingDeletePath: (path: string | null) => void
-  ingestingPath: string | null
-  extractTaskIdsByPath: Record<string, string[]>
-  extractedPaths: Set<string>
-  renamingPath: string | null
-  renameValue: string
-  setRenameValue: (value: string) => void
-  renamingBusy: boolean
-  onSubmitRename: () => void
-  onCancelRename: () => void
-  onOpenFileMenu: (event: React.MouseEvent, node: FileNode) => void
-}) {
-  const { t } = useTranslation()
-  const selectedFile = useWikiStore((s) => s.selectedFile)
-  const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
-  const [visibleLimit, setVisibleLimit] = useState(SOURCE_TREE_INITIAL_ROWS)
-  const loadMoreRef = useRef<HTMLDivElement | null>(null)
-  const rows = useMemo(() => flattenVisibleRows(nodes, collapsed), [nodes, collapsed])
-  const visibleRows = rows.slice(0, visibleLimit)
-  const hasMore = visibleLimit < rows.length
-
-  useEffect(() => {
-    setVisibleLimit(SOURCE_TREE_INITIAL_ROWS)
-  }, [nodes])
-
-  useEffect(() => {
-    if (!hasMore) return
-    const target = loadMoreRef.current
-    if (!target) return
-    const observer = new IntersectionObserver((entries) => {
-      if (!entries.some((entry) => entry.isIntersecting)) return
-      setVisibleLimit((current) => Math.min(current + SOURCE_TREE_LOAD_BATCH, rows.length))
-    }, { rootMargin: "240px 0px" })
-    observer.observe(target)
-    return () => observer.disconnect()
-  }, [hasMore, rows.length])
-
-  const handleDeleteClick = (node: FileNode) => {
-    const action = decideDeleteClick(pendingDeletePath, node)
-    switch (action.kind) {
-      case "arm":
-        setPendingDeletePath(action.path)
-        return
-      case "fire-file":
-        setPendingDeletePath(null)
-        onDelete(action.node)
-        return
-      case "fire-folder":
-        setPendingDeletePath(null)
-        onDeleteFolder(action.node)
-        return
-    }
-  }
-
-  return (
-    <>
-      {visibleRows.map(({ node, depth }) => {
-        const isPendingDelete = pendingDeletePath === node.path
-        if (node.is_dir && node.children) {
-          const isCollapsed = collapsed[node.path] ?? false
-          return (
-            <div key={node.path}>
-              <div
-                data-source-interactive="true"
-                className="group flex w-full items-center gap-1 rounded-md text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground"
-                style={{ paddingLeft: `${depth * 16 + 4}px` }}
-              >
-                <button
-                  type="button"
-                  onClick={() => setCollapsed((prev) => ({ ...prev, [node.path]: !prev[node.path] }))}
-                  className="flex flex-1 items-center gap-1.5 px-1 py-1 text-left"
-                >
-                  {isCollapsed ? <ChevronRight className="h-3.5 w-3.5 shrink-0" /> : <ChevronDown className="h-3.5 w-3.5 shrink-0" />}
-                  <Folder className="h-4 w-4 shrink-0 text-amber-500" />
-                  <span className="truncate font-medium">{node.name}</span>
-                  <span className="ml-auto shrink-0 text-[10px] text-muted-foreground/60">{countFiles(node.children)}</span>
-                </button>
-                <DeleteButton
-                  isPending={isPendingDelete}
-                  onClick={() => handleDeleteClick(node)}
-                  hint={isPendingDelete ? t("sources.deleteFolderConfirm", { name: node.name }) : t("sources.deleteFolder", { name: node.name })}
-                />
-              </div>
-            </div>
-          )
-        }
-
-        const normalizedNodePath = normalizePath(node.path)
-        const isSelected = selectedFile === node.path
-        const isExtracting = ingestingPath === node.path || Boolean(extractTaskIdsByPath[normalizedNodePath]?.length)
-        const isExtracted = !isExtracting && extractedPaths.has(normalizedNodePath)
-
-        return (
-          <div
-            key={node.path}
-            data-source-interactive="true"
-            className={`flex w-full items-center gap-1 rounded-md px-1 py-1 text-sm transition-colors ${
-              isSelected ? "qm-selected" : "text-muted-foreground qm-hover"
-            }`}
-            style={{ paddingLeft: `${depth * 16 + 4}px` }}
-            onContextMenu={(event) => onOpenFileMenu(event, node)}
-          >
-            <button
-              type="button"
-              onClick={() => onOpen(node)}
-              disabled={renamingPath === node.path}
-              className="flex flex-1 items-center gap-2 truncate px-2 py-1 text-left"
-            >
-              <FileText className="h-4 w-4 shrink-0" />
-              {renamingPath === node.path ? (
-                <input
-                  type="text"
-                  value={renameValue}
-                  onChange={(event) => setRenameValue(event.target.value)}
-                  onMouseDown={(event) => event.stopPropagation()}
-                  onClick={(event) => event.stopPropagation()}
-                  onFocus={(event) => event.stopPropagation()}
-                  onBlur={() => void onSubmitRename()}
-                  onKeyDown={(event) => {
-                    event.stopPropagation()
-                    if (event.key === "Enter") {
-                      event.preventDefault()
-                      void onSubmitRename()
-                    } else if (event.key === "Escape") {
-                      event.preventDefault()
-                      onCancelRename()
-                    }
-                  }}
-                  className="w-full rounded border bg-background px-1.5 py-0.5 text-xs outline-none focus:ring-1 focus:ring-ring"
-                  autoFocus
-                  disabled={renamingBusy}
-                />
-              ) : (
-                <span className="truncate">{node.name}</span>
-              )}
-            </button>
-            <Button
-              variant="ghost"
-              size="icon"
-              className={`h-7 w-7 shrink-0 ${isExtracted ? "text-emerald-600 hover:text-emerald-700" : ""}`}
-              title={t("novel.outlineGenerator.ingest")}
-              disabled={isExtracting}
-              onClick={() => onIngest(node)}
-            >
-              {isExtracting ? <Loader2 className="h-4 w-4 animate-spin" /> : isExtracted ? <Check className="h-4 w-4" /> : <BookOpen className="h-4 w-4" />}
-            </Button>
-            <DeleteButton
-              isPending={isPendingDelete}
-              onClick={() => handleDeleteClick(node)}
-              hint={isPendingDelete ? t("sources.deleteFileConfirm", { name: node.name }) : t("sources.deleteFile", { name: node.name })}
-            />
-          </div>
-        )
-      })}
-      {hasMore ? (
-        <div ref={loadMoreRef} className="px-3 py-2 text-center text-[11px] text-muted-foreground">
-          {t("sources.loadingMore")}
-        </div>
-      ) : null}
-    </>
-  )
-}
-
-function DeleteButton({
-  isPending,
-  onClick,
-  hint,
-}: {
-  isPending: boolean
-  onClick: () => void
-  hint: string
-}) {
-  const { t } = useTranslation()
-  if (isPending) {
-    return (
-      <Button
-        variant="destructive"
-        size="sm"
-        className="h-7 shrink-0 px-2 text-[11px] font-semibold animate-pulse"
-        title={hint}
-        onClick={onClick}
-      >
-        <Trash2 className="mr-1 h-3.5 w-3.5" />
-        {t("sources.confirm")}
-      </Button>
-    )
-  }
-
-  return (
-    <Button
-      variant="ghost"
-      size="icon"
-      className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
-      title={hint}
-      onClick={onClick}
-    >
-      <Trash2 className="h-3.5 w-3.5" />
-    </Button>
-  )
-}

+ 2 - 2
src/i18n/en.json

@@ -305,7 +305,7 @@
     "importSourceFiles": "Import Source Files",
     "importSourceFolder": "Import Source Folder",
     "noSources": "No sources yet",
-    "importHint": "Import PDFs, markdown files, and other source materials",
+    "importHint": "Import markdown files and other source materials",
     "status": {
       "pending": "Pending",
       "queued": "Queued",
@@ -965,7 +965,7 @@
       },
       "multimodal": {
         "title": "Image Captions",
-        "description": "During import, call a vision model to generate factual descriptions for images extracted from PDF, DOCX, or PPTX files. Descriptions are written into alt text so semantic search can match image content.",
+        "description": "During import, call a vision model to generate factual descriptions for images extracted from DOCX or PPTX files. Descriptions are written into alt text so semantic search can match image content.",
         "enableLabel": "Generate image captions during import",
         "enableHint": "Off: image references are stripped from extracted content. On: each new image gets a factual caption generated and cached by hash.",
         "stateOn": "Enabled",

+ 2 - 2
src/i18n/zh.json

@@ -672,7 +672,7 @@
       },
       "multimodal": {
         "title": "图片描述",
-        "description": "导入时调用视觉模型为 PDF / DOCX / PPTX 中提取的图片生成事实性描述。描述会写入源 markdown 的 alt 属性,语义搜索按图片内容查询时就靠它命中。按图片字节 SHA-256 缓存,跨文档重复出现的 logo / 图表模板只调用一次。",
+        "description": "导入时调用视觉模型为 DOCX / PPTX 中提取的图片生成事实性描述。描述会写入源 markdown 的 alt 属性,语义搜索按图片内容查询时就靠它命中。按图片字节 SHA-256 缓存,跨文档重复出现的 logo / 图表模板只调用一次。",
         "enableLabel": "导入时生成图片描述",
         "enableHint": "关:提取时把图片引用从内容里剥掉 —— 资料库页面里不会出现图片,搜索也不会返回。(图片仍会落到资料库媒体目录,原始文档预览能看到。)开:每张新图调用视觉模型生成事实性图片描述(按哈希缓存),写入资料库页面图片说明属性。",
         "stateOn": "已开启",
@@ -693,7 +693,7 @@
         "costHeading": "费用提示",
         "costPoint1": "每张新图触发一次视觉模型调用(约 500-2000 token,取决于模型和 thinking 模式)。",
         "costPoint2": "Caption 按图片字节 SHA-256 缓存。跨文档重复的 logo / 通用图表模板只调用 ONE 次。",
-        "costPoint3": "Rust 端过滤掉小于 100×100 像素的图,并限制每文档最多 500 张 —— 病态 PDF 不会无限放大费用。",
+        "costPoint3": "Rust 端过滤掉小于 100×100 像素的图,并限制每文档最多 500 张 —— 病态文档不会无限放大费用。",
         "costPoint4": "批量 ingest 推荐用本地视觉模型(LM Studio / Ollama 跑 Qwen2.5-VL);托管视觉 API 留给少量高价值文档。"
       },
       "webSearch": {

+ 8 - 11
src/lib/extract-source-images.ts

@@ -1,12 +1,11 @@
 /**
  * Image extraction orchestration for the ingest pipeline.
  *
- * Pure dispatch + path-shaping layer over the Rust commands
- * `extract_and_save_pdf_images_cmd` / `extract_and_save_office_images_cmd`.
- * Decides which command to call based on file extension, computes the
- * destination directory (`wiki/media/<source-slug>/`), and gives back
- * a small markdown snippet ready to paste into the LLM's source
- * context.
+ * Pure dispatch + path-shaping layer over the Rust command
+ * `extract_and_save_office_images_cmd`.
+ * Computes the destination directory (`wiki/media/<source-slug>/`),
+ * and gives back a small markdown snippet ready to paste into the
+ * LLM's source context.
  *
  * NOTE: this layer does NOT call any LLM (no captions yet — that's
  * Phase 3a). The alt text on each image is a placeholder; once
@@ -21,7 +20,7 @@ import { isTauri } from "@/lib/platform"
 export interface SavedImage {
   index: number
   mimeType: string
-  /** PDF page or PPTX slide number (1-based). DOCX always null. */
+  /** PPTX slide number (1-based). DOCX always null. */
   page: number | null
   width: number
   height: number
@@ -36,7 +35,6 @@ export interface SavedImage {
  *  because spreadsheets generally don't have charts as images (charts
  *  are XML-rendered shapes, not embedded raster). Adding them later is
  *  a one-line change here. */
-const SUPPORTED_PDF_EXTS = ["pdf"] as const
 const SUPPORTED_OFFICE_EXTS = ["pptx", "docx", "ppt", "doc"] as const
 // Note: ppt / doc (legacy binary formats) won't actually work — they
 // aren't ZIP. Listed for completeness; Rust side will return Err which
@@ -64,9 +62,8 @@ export async function extractAndSaveSourceImages(
   const fileName = getFileName(sp)
   const ext = fileName.split(".").pop()?.toLowerCase() ?? ""
 
-  const isPdf = (SUPPORTED_PDF_EXTS as readonly string[]).includes(ext)
   const isOffice = (SUPPORTED_OFFICE_EXTS as readonly string[]).includes(ext)
-  if (!isPdf && !isOffice) return []
+  if (!isOffice) return []
 
   if (!isTauri()) {
     // 浏览器模式不支持本地图片提取(需要 Rust 命令)
@@ -79,7 +76,7 @@ export async function extractAndSaveSourceImages(
 
   try {
     const images = await invoke<unknown[]>(
-      isPdf ? "extract_and_save_pdf_images_cmd" : "extract_and_save_office_images_cmd",
+      "extract_and_save_office_images_cmd",
       { sourcePath: sp, destDir, relTo },
     )
     // Rust's `SavedImage` is `#[serde(rename_all = "camelCase")]`,

+ 1 - 4
src/lib/file-types.ts

@@ -5,7 +5,6 @@ export type FileCategory =
   | "image"
   | "video"
   | "audio"
-  | "pdf"
   | "document"
   | "data"
   | "unknown"
@@ -93,10 +92,8 @@ const EXT_MAP: Record<string, FileCategory> = {
   m4a: "audio",
   wma: "audio",
 
-  // PDF
-  pdf: "pdf",
-
   // Documents (binary, not directly previewable)
+  pdf: "document",
   doc: "document",
   docx: "document",
   xls: "document",

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

@@ -133,7 +133,6 @@ describe("user-facing hardcoded chinese strings", () => {
     const previewPanel = read("../components/layout/preview-panel.tsx")
     const trashPanel = read("../components/layout/trash-panel.tsx")
     const changelogSection = read("../components/settings/sections/changelog-section.tsx")
-    const scheduledImportSection = read("../components/settings/sections/scheduled-import-section.tsx")
     const maintenanceSection = read("../components/settings/sections/maintenance-section.tsx")
 
     expect(trashPanel).toContain('t("trash.title", { defaultValue: "回收站" })')
@@ -144,29 +143,6 @@ describe("user-facing hardcoded chinese strings", () => {
     expect(previewPanel).toContain('t("preview.chatSession", { defaultValue: "AI会话" })')
     expect(previewPanel).toContain('t("preview.formatWriting", { defaultValue: "一键排版" })')
     expect(changelogSection).toContain('t("settings.sections.changelog.title", { defaultValue: "软件更新日志" })')
-    expect(scheduledImportSection).toContain('t("settings.sections.scheduledImport.never", { defaultValue: "从未扫描" })')
-    expect(scheduledImportSection).toContain('t("settings.sections.scheduledImport.title", {')
-    expect(scheduledImportSection).toContain('defaultValue: "定时导入"')
-    expect(scheduledImportSection).toContain('t("settings.sections.scheduledImport.description", {')
-    expect(scheduledImportSection).toContain('defaultValue: "自动监控目录,并按固定间隔导入新增或更新的文件。"')
-    expect(scheduledImportSection).toContain('t("settings.sections.scheduledImport.enable", {')
-    expect(scheduledImportSection).toContain('defaultValue: "启用定时导入"')
-    expect(scheduledImportSection).toContain('t("settings.sections.scheduledImport.privacyNotice", {')
-    expect(scheduledImportSection).toContain('defaultValue: "所选目录中的文件可能会被复制到当前项目,并在摄取时发送给已配置的大模型。源目录删除文件后,项目内不会自动删除。"')
-    expect(scheduledImportSection).toContain('t("settings.sections.scheduledImport.directory", {')
-    expect(scheduledImportSection).toContain('defaultValue: "监控目录"')
-    expect(scheduledImportSection).toContain('t("settings.sections.scheduledImport.browse", {')
-    expect(scheduledImportSection).toContain('defaultValue: "浏览目录"')
-    expect(scheduledImportSection).toContain('t("settings.sections.scheduledImport.directoryHelp", {')
-    expect(scheduledImportSection).toContain('defaultValue: "该目录及其子目录中的文件会自动导入;新增文件会复制到素材区,修改后的文件会重新摄取。"')
-    expect(scheduledImportSection).toContain('t("settings.sections.scheduledImport.interval", {')
-    expect(scheduledImportSection).toContain('defaultValue: "扫描间隔(分钟)"')
-    expect(scheduledImportSection).toContain('t("settings.sections.scheduledImport.intervalHelp", {')
-    expect(scheduledImportSection).toContain('defaultValue: "多久检查一次变更,最短为 1 分钟。"')
-    expect(scheduledImportSection).toContain('t("settings.sections.scheduledImport.scanning", { defaultValue: "扫描中..." })')
-    expect(scheduledImportSection).toContain('t("settings.sections.scheduledImport.scanNow", { defaultValue: "立即扫描" })')
-    expect(scheduledImportSection).toContain('t("settings.sections.scheduledImport.lastScan", {')
-    expect(scheduledImportSection).toContain('defaultValue: "上次扫描:{{time}}"')
 
     expect(maintenanceSection).toContain('t("settings.sections.maintenance.title", { defaultValue: "维护工具" })')
     expect(maintenanceSection).toContain('t("settings.sections.maintenance.description", {')

+ 1 - 1
src/lib/ingest.ts

@@ -2185,7 +2185,7 @@ export async function startIngest(
   // `executeIngestWrites`) so the images are on disk before the user
   // even sees the analysis stream, and the cost is only paid once
   // per source: a follow-up `executeIngestWrites` will reuse the
-  // already-extracted set rather than re-running pdfium.
+  // already-extracted set rather than re-running Office image extraction.
   // Failure-tolerant — `extractAndSaveSourceImages` returns [] on
   // any error and logs internally; we never want image extraction
   // to break the ingest chat flow.

+ 0 - 1
src/lib/novel/outline-import.ts

@@ -8,7 +8,6 @@ export const OUTLINE_IMPORT_EXTENSIONS = [
   "md",
   "mdx",
   "txt",
-  "pdf",
   "docx",
   "pptx",
   "xlsx",

+ 1 - 1
src/lib/raw-source-resolver.ts

@@ -8,7 +8,7 @@
  * (`media/<slug>/img-N.png`, from the post-write safety-net section
  * in `wiki/sources/<slug>.md`). The slug equals the basename of
  * the original raw source file (we wrote it that way at extraction
- * time in `extract_pdf_markdown` / fs.rs's raw-sources-layout
+ * time in fs.rs's raw-sources-layout
  * heuristic), so finding the raw file is a stem match against
  * `<project>/raw/sources/`.
  *

+ 3 - 3
src/lib/search.ts

@@ -240,12 +240,12 @@ export async function searchWiki(
   //
   // We deliberately do NOT also search `raw/sources/` here anymore.
   // Previously this section walked every file under raw/sources/
-  // (including PDFs / DOCX / PPTX) and called `readFile` on each,
-  // which triggers the heavy pdfium / office text-extraction path
+  // (including DOCX / PPTX) and called `readFile` on each,
+  // which triggers the heavy office text-extraction path
   // — even on cache hits, that's an IPC round-trip per file plus
   // a cache file read of the now-large combined-markdown output
   // (text + per-page image refs after the unified extractor
-  // landed). On a project with ~50 PDFs this added 5-15s per
+  // landed). On a project with ~50 office docs this added 5-15s per
   // search, which the user reported as "very, very slow."
   //
   // The content lost: nothing material. Each ingested raw source

+ 0 - 112
src/lib/source-lifecycle.ts

@@ -1,10 +1,6 @@
 import {
-  copyDirectory,
-  copyFile,
   deleteFile,
-  fileExists,
   listDirectory,
-  preprocessFile,
   readFile,
   writeFile,
 } from "@/commands/fs"
@@ -35,7 +31,6 @@ export const INGESTABLE_SOURCE_EXTENSIONS = new Set([
   "md",
   "mdx",
   "txt",
-  "pdf",
   "docx",
   "pptx",
   "xlsx",
@@ -68,15 +63,6 @@ export interface DeleteSourcesResult {
   skippedPages: number
 }
 
-export interface SourceImportOptions {
-  autoExtract?: boolean
-}
-
-export interface SourceImportResult {
-  importedPaths: string[]
-  taskIdsByPath: Record<string, string[]>
-}
-
 export function isIngestableSourcePath(path: string): boolean {
   const normalized = normalizePath(path)
   if (normalized.split("/").includes(".cache")) return false
@@ -121,59 +107,6 @@ export async function enqueueSourceIngest(
   return enqueueBatch(project.id, files)
 }
 
-export async function importSourceFiles(
-  project: WikiProject,
-  sourcePaths: string[],
-  llmConfig: LlmConfig,
-  options: SourceImportOptions = {},
-): Promise<SourceImportResult> {
-  const pp = normalizePath(project.path)
-  const importedPaths: string[] = []
-
-  for (const sourcePath of sourcePaths) {
-    const originalName = getFileName(sourcePath) || "unknown"
-    const destPath = await getUniqueDestPath(`${pp}/raw/sources`, originalName)
-    try {
-      await copyFile(sourcePath, destPath)
-      importedPaths.push(destPath)
-      preprocessFile(destPath).catch(() => {})
-    } catch (err) {
-      console.error(`Failed to import ${originalName}:`, err)
-    }
-  }
-
-  const taskIdsByPath = options.autoExtract === false
-    ? {}
-    : await enqueueSourceIngestWithTaskMap(project, importedPaths, llmConfig)
-
-  return { importedPaths, taskIdsByPath }
-}
-
-export async function importSourceFolder(
-  project: WikiProject,
-  selectedFolder: string,
-  llmConfig: LlmConfig,
-  options: SourceImportOptions = {},
-): Promise<SourceImportResult> {
-  const pp = normalizePath(project.path)
-  const folderName = getFileName(selectedFolder) || "imported"
-  const destDir = `${pp}/raw/sources/${folderName}`
-  const copiedFiles = await copyDirectory(selectedFolder, destDir)
-
-  for (const filePath of copiedFiles) {
-    preprocessFile(filePath).catch(() => {})
-  }
-
-  const taskIdsByPath = options.autoExtract === false
-    ? {}
-    : await enqueueSourceIngestWithTaskMap(project, copiedFiles, llmConfig, {
-      sourceRoot: destDir,
-      rootContext: folderName,
-    })
-
-  return { importedPaths: copiedFiles, taskIdsByPath }
-}
-
 export async function deleteSourceFile(
   projectPath: string,
   sourcePath: string,
@@ -369,32 +302,6 @@ export async function cleanupDeletedWikiPages(
   }
 }
 
-async function getUniqueDestPath(dir: string, fileName: string): Promise<string> {
-  const basePath = `${dir}/${fileName}`
-
-  if (!(await fileExists(basePath))) {
-    return basePath
-  }
-
-  const ext = fileName.includes(".") ? fileName.slice(fileName.lastIndexOf(".")) : ""
-  const nameWithoutExt = ext ? fileName.slice(0, -ext.length) : fileName
-  const date = new Date().toISOString().slice(0, 10).replace(/-/g, "")
-
-  const withDate = `${dir}/${nameWithoutExt}-${date}${ext}`
-  if (!(await fileExists(withDate))) {
-    return withDate
-  }
-
-  for (let i = 2; i <= 99; i++) {
-    const withCounter = `${dir}/${nameWithoutExt}-${date}-${i}${ext}`
-    if (!(await fileExists(withCounter))) {
-      return withCounter
-    }
-  }
-
-  return `${dir}/${nameWithoutExt}-${date}-${Date.now()}${ext}`
-}
-
 async function appendSourceDeleteLog(
   projectPath: string,
   fileNames: string | string[],
@@ -439,22 +346,3 @@ function withRootContext(context: string, rootContext?: string): string {
   if (!context) return rootContext
   return `${rootContext} > ${context}`
 }
-
-async function enqueueSourceIngestWithTaskMap(
-  project: WikiProject,
-  sourcePaths: string[],
-  llmConfig: LlmConfig,
-  options: { sourceRoot?: string; rootContext?: string } = {},
-): Promise<Record<string, string[]>> {
-  const taskIds = await enqueueSourceIngest(project, sourcePaths, llmConfig, options)
-  const ingestablePaths = sourcePaths.filter(isIngestableSourcePath).map(normalizePath)
-  const taskIdsByPath: Record<string, string[]> = {}
-
-  for (const [index, sourcePath] of ingestablePaths.entries()) {
-    const taskId = taskIds[index]
-    if (!taskId) continue
-    taskIdsByPath[sourcePath] = [...(taskIdsByPath[sourcePath] ?? []), taskId]
-  }
-
-  return taskIdsByPath
-}

+ 1 - 1
src/lib/source-watch-config.ts

@@ -7,7 +7,7 @@ export const DEFAULT_SOURCE_WATCH_CONFIG: SourceWatchConfig = sourceWatchDefault
 export const SOURCE_WATCH_FILE_TYPE_GROUPS = [
   {
     id: "documents",
-    extensions: ["md", "mdx", "txt", "pdf", "docx", "odt", "rtf"],
+    extensions: ["md", "mdx", "txt", "docx", "odt", "rtf"],
   },
   {
     id: "presentations",

+ 0 - 1
src/lib/source-watch-defaults.json

@@ -5,7 +5,6 @@
     "md",
     "mdx",
     "txt",
-    "pdf",
     "docx",
     "pptx",
     "xls",