1
0
Эх сурвалжийг харах

refactor(cleanup): 清理无入口代码与重复启动实现

删除 41 个未接入入口的旧模块并同步收口依赖。\n让 Tauri binary 统一委托 library 启动,避免维护两套命令注册。
darknessomi 1 сар өмнө
parent
commit
3b28ced7e4
49 өөрчлөгдсөн 26 нэмэгдсэн , 3916 устгасан
  1. 20 94
      package-lock.json
  2. 1 3
      package.json
  3. 1 212
      src-tauri/src/main.rs
  4. 0 149
      src/components/chat/intent-clarification-dialog.tsx
  5. 0 123
      src/components/layout/file-tree.tsx
  6. 0 41
      src/components/project/template-picker.tsx
  7. 0 75
      src/components/search/search-window-view.tsx
  8. 0 30
      src/components/settings/sections/about-section.tsx
  9. 2 1
      src/components/settings/sections/custom-provider-cards.persist.spec.tsx
  10. 0 286
      src/components/settings/sections/multimodal-section.tsx
  11. 0 76
      src/components/settings/sections/output-section.tsx
  12. 0 3
      src/components/sources/outline-workbench-integration.spec.ts
  13. 0 87
      src/components/sources/outline-workflow-stages.tsx
  14. 0 23
      src/components/ui/separator.tsx
  15. 0 46
      src/hooks/use-batched-value.ts
  16. 0 90
      src/lib/agent/trace-builder.ts
  17. 0 281
      src/lib/crypto.ts
  18. 0 210
      src/lib/enrich-wikilinks.ts
  19. 0 61
      src/lib/env-llm-defaults.ts
  20. 2 2
      src/lib/export-center/export-service.spec.ts
  21. 0 60
      src/lib/greeting-detector.ts
  22. 0 7
      src/lib/hardcoded-ui-chinese-i18n.test.ts
  23. 0 159
      src/lib/novel/book-analysis/workflow-extraction-engine.ts
  24. 0 88
      src/lib/novel/chapter-title-generator.ts
  25. 0 144
      src/lib/novel/character-files.ts
  26. 0 4
      src/lib/novel/entry-projection.ts
  27. 0 144
      src/lib/novel/export.ts
  28. 0 29
      src/lib/novel/fact-model.ts
  29. 0 30
      src/lib/novel/fact-store.ts
  30. 0 20
      src/lib/novel/graph-projection.ts
  31. 0 129
      src/lib/novel/lint.ts
  32. 0 41
      src/lib/novel/mod.ts
  33. 0 142
      src/lib/novel/outline-ingest.ts
  34. 0 1
      src/lib/novel/outline-pure-markdown-writers.spec.ts
  35. 0 126
      src/lib/novel/prompt-templates.ts
  36. 0 182
      src/lib/novel/rebuild.ts
  37. 0 104
      src/lib/novel/source-outline-import.ts
  38. 0 236
      src/lib/novel/story-simulation/simulation-modes/decision-tree.ts
  39. 0 11
      src/lib/novel/story-simulation/simulation-modes/event-driven.ts
  40. 0 11
      src/lib/novel/story-simulation/simulation-modes/free-emergence.ts
  41. 0 11
      src/lib/novel/story-simulation/simulation-modes/hybrid.ts
  42. 0 12
      src/lib/novel/ui-labels.ts
  43. 0 85
      src/lib/novel/volume.ts
  44. 0 41
      src/lib/output-language-options.ts
  45. 0 35
      src/lib/reveal-in-file-manager.ts
  46. 0 81
      src/lib/search-window.ts
  47. 0 58
      src/lib/source-delete-decision.ts
  48. 0 13
      src/lib/user-memory/index.ts
  49. 0 19
      src/types/saved-model.ts

+ 20 - 94
package-lock.json

@@ -13,7 +13,6 @@
         "@dnd-kit/core": "^6.3.1",
         "@dnd-kit/sortable": "^8.0.0",
         "@dnd-kit/utilities": "^3.2.2",
-        "@fontsource-variable/geist": "^5.3.0",
         "@milkdown/kit": "^7.22.0",
         "@milkdown/react": "^7.22.1",
         "@milkdown/theme-nord": "^7.22.1",
@@ -59,13 +58,12 @@
       },
       "devDependencies": {
         "@tauri-apps/cli": "^2.11.4",
-        "@types/diff": "^7.0.2",
         "@types/js-yaml": "^4.0.9",
         "@types/node": "^24.13.3",
         "@types/react": "^19.2.18",
         "@types/react-dom": "^19.2.4",
         "@vitejs/plugin-react": "^6.0.5",
-        "fast-check": "^4.9.0",
+        "iconv-lite": "^0.7.3",
         "jsdom": "^29.1.1",
         "typescript": "^5.9.3",
         "vite": "^8.2.1",
@@ -1653,15 +1651,6 @@
       "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
       "license": "MIT"
     },
-    "node_modules/@fontsource-variable/geist": {
-      "version": "5.3.0",
-      "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz",
-      "integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==",
-      "license": "OFL-1.1",
-      "funding": {
-        "url": "https://github.com/sponsors/ayuhito"
-      }
-    },
     "node_modules/@hono/node-server": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz",
@@ -3568,13 +3557,6 @@
       "dev": true,
       "license": "MIT"
     },
-    "node_modules/@types/diff": {
-      "version": "7.0.2",
-      "resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz",
-      "integrity": "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==",
-      "dev": true,
-      "license": "MIT"
-    },
     "node_modules/@types/estree": {
       "version": "1.0.9",
       "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -4204,22 +4186,6 @@
         "url": "https://opencollective.com/express"
       }
     },
-    "node_modules/body-parser/node_modules/iconv-lite": {
-      "version": "0.7.3",
-      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
-      "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
-      "license": "MIT",
-      "dependencies": {
-        "safer-buffer": ">= 2.1.2 < 3.0.0"
-      },
-      "engines": {
-        "node": ">=0.10.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/express"
-      }
-    },
     "node_modules/brace-expansion": {
       "version": "5.0.9",
       "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
@@ -4982,6 +4948,18 @@
         "node": ">= 10"
       }
     },
+    "node_modules/d3-dsv/node_modules/iconv-lite": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+      "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/d3-ease": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
@@ -5841,29 +5819,6 @@
       "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
       "license": "MIT"
     },
-    "node_modules/fast-check": {
-      "version": "4.9.0",
-      "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz",
-      "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==",
-      "dev": true,
-      "funding": [
-        {
-          "type": "individual",
-          "url": "https://github.com/sponsors/dubzzz"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fast-check"
-        }
-      ],
-      "license": "MIT",
-      "dependencies": {
-        "pure-rand": "^8.0.0"
-      },
-      "engines": {
-        "node": ">=12.17.0"
-      }
-    },
     "node_modules/fast-deep-equal": {
       "version": "3.1.3",
       "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -6569,15 +6524,19 @@
       }
     },
     "node_modules/iconv-lite": {
-      "version": "0.6.3",
-      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
-      "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+      "version": "0.7.3",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+      "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
       "license": "MIT",
       "dependencies": {
         "safer-buffer": ">= 2.1.2 < 3.0.0"
       },
       "engines": {
         "node": ">=0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
       }
     },
     "node_modules/ignore": {
@@ -9385,23 +9344,6 @@
         "node": ">=6"
       }
     },
-    "node_modules/pure-rand": {
-      "version": "8.4.2",
-      "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
-      "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==",
-      "dev": true,
-      "funding": [
-        {
-          "type": "individual",
-          "url": "https://github.com/sponsors/dubzzz"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fast-check"
-        }
-      ],
-      "license": "MIT"
-    },
     "node_modules/qs": {
       "version": "6.15.3",
       "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
@@ -9466,22 +9408,6 @@
         "node": ">= 0.10"
       }
     },
-    "node_modules/raw-body/node_modules/iconv-lite": {
-      "version": "0.7.3",
-      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
-      "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
-      "license": "MIT",
-      "dependencies": {
-        "safer-buffer": ">= 2.1.2 < 3.0.0"
-      },
-      "engines": {
-        "node": ">=0.10.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/express"
-      }
-    },
     "node_modules/react": {
       "version": "19.2.8",
       "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",

+ 1 - 3
package.json

@@ -22,7 +22,6 @@
     "@dnd-kit/core": "^6.3.1",
     "@dnd-kit/sortable": "^8.0.0",
     "@dnd-kit/utilities": "^3.2.2",
-    "@fontsource-variable/geist": "^5.3.0",
     "@milkdown/kit": "^7.22.0",
     "@milkdown/react": "^7.22.1",
     "@milkdown/theme-nord": "^7.22.1",
@@ -68,13 +67,12 @@
   },
   "devDependencies": {
     "@tauri-apps/cli": "^2.11.4",
-    "@types/diff": "^7.0.2",
     "@types/js-yaml": "^4.0.9",
     "@types/node": "^24.13.3",
     "@types/react": "^19.2.18",
     "@types/react-dom": "^19.2.4",
     "@vitejs/plugin-react": "^6.0.5",
-    "fast-check": "^4.9.0",
+    "iconv-lite": "^0.7.3",
     "jsdom": "^29.1.1",
     "typescript": "^5.9.3",
     "vite": "^8.2.1",

+ 1 - 212
src-tauri/src/main.rs

@@ -1,216 +1,5 @@
 #![cfg_attr(target_os = "windows", windows_subsystem = "windows")]
 
-mod app_state;
-mod atomic_file;
-mod commands;
-mod panic_guard;
-mod proxy;
-mod types;
-
-#[tauri::command]
-fn set_proxy_env(config: proxy::ProxyConfig) -> String {
-    let summary = proxy::apply_proxy_env(&config);
-    eprintln!("[proxy] live update: {summary}");
-    summary
-}
-
-#[tauri::command]
-fn log_error(message: String, stack: String, component_stack: String) {
-    eprintln!("[frontend-error] message: {}", message);
-    if !stack.is_empty() {
-        eprintln!("[frontend-error] stack: {}", stack);
-    }
-    if !component_stack.is_empty() {
-        eprintln!("[frontend-error] component-stack: {}", component_stack);
-    }
-}
-
-#[tauri::command]
-fn log_diagnostic(message: String) {
-    eprintln!("[frontend-diagnostic] message: {}", message);
-}
-
 fn main() {
-    tauri::Builder::default()
-        .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
-            use tauri::Manager;
-            if let Some(window) = app.get_webview_window("main") {
-                let _ = window.unminimize();
-                let _ = window.show();
-                let _ = window.set_focus();
-            }
-        }))
-        .plugin(tauri_plugin_opener::init())
-        .plugin(tauri_plugin_dialog::init())
-        .plugin(tauri_plugin_store::Builder::default().build())
-        .plugin(tauri_plugin_http::init())
-        .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);
-            }
-            app_state::prepare_app_state_store(app.handle());
-            if let Ok(dir) = app.path().app_data_dir() {
-                let store_path = dir.join("app-state.json");
-                eprintln!("[proxy] reading from {}", store_path.display());
-                let summary = proxy::apply_proxy_env_from_store(&store_path);
-                eprintln!("[proxy] {summary}");
-            } else {
-                eprintln!("[proxy] could not resolve app_data_dir");
-            }
-            app.manage(commands::claude_cli::ClaudeCliState::default());
-            app.manage(commands::codex_cli::CodexAppServerState::default());
-            app.manage(commands::cursor_cli::CursorProxyState::default());
-            app.manage(commands::file_sync::FileSyncState::default());
-            app.manage(commands::mcp_stdio::McpStdioState::default());
-            app.manage(commands::writing_wake_lock::WritingWakeLockManager::default());
-            Ok(())
-        })
-        .invoke_handler(tauri::generate_handler![
-            commands::fs::read_file,
-            commands::fs::write_file,
-            commands::fs::write_file_if_absent,
-            commands::fs::write_export_file,
-            commands::fs::write_file_atomic,
-            commands::fs::list_directory,
-            commands::fs::copy_file,
-            commands::fs::copy_directory,
-            commands::fs::preprocess_file,
-            commands::fs::delete_file,
-            commands::fs::find_related_wiki_pages,
-            commands::fs::create_directory,
-            commands::fs::file_exists,
-            commands::fs::get_file_modified_time,
-            commands::fs::get_file_size,
-            commands::fs::get_file_md5,
-            commands::fs::read_file_as_base64,
-            commands::fs::get_executable_dir,
-            commands::fs::get_resource_dir,
-            commands::project::create_project,
-            commands::project::open_project,
-            commands::project::open_project_folder,
-            commands::project::open_file_location,
-            commands::vectorstore::vector_upsert,
-            commands::vectorstore::vector_search,
-            commands::vectorstore::vector_delete,
-            commands::vectorstore::vector_count,
-            commands::vectorstore::vector_upsert_chunks,
-            commands::vectorstore::vector_search_chunks,
-            commands::vectorstore::vector_delete_page,
-            commands::vectorstore::vector_count_chunks,
-            commands::vectorstore::vector_legacy_row_count,
-            commands::vectorstore::vector_drop_legacy,
-            commands::claude_cli::claude_cli_detect,
-            commands::claude_cli::claude_cli_spawn,
-            commands::claude_cli::claude_cli_kill,
-            commands::codex_cli::codex_cli_detect,
-            commands::codex_cli::codex_app_server_start,
-            commands::codex_cli::codex_app_server_write,
-            commands::codex_cli::codex_app_server_stop,
-            commands::cursor_cli::cursor_cli_detect,
-            commands::cursor_cli::cursor_proxy_status,
-            commands::cursor_cli::cursor_proxy_ensure,
-            commands::cursor_cli::cursor_proxy_stop,
-            commands::extract_images::extract_office_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,
-            commands::file_sync::rescan_project_files,
-            commands::file_sync::get_file_change_queue,
-            commands::file_sync::retry_file_change_task,
-            commands::file_sync::ignore_file_change_task,
-            commands::mcp_stdio::mcp_stdio_spawn,
-            commands::mcp_stdio::mcp_stdio_write,
-            commands::mcp_stdio::mcp_stdio_read,
-            commands::mcp_stdio::mcp_stdio_kill,
-            commands::backup::export_backup,
-            commands::backup::import_backup,
-            commands::backup::read_backup_manifest,
-            app_state::write_app_state_atomic,
-            commands::writing_wake_lock::acquire_writing_wake_lock,
-            commands::writing_wake_lock::release_writing_wake_lock,
-            set_proxy_env,
-            log_error,
-            log_diagnostic,
-        ])
-        .on_window_event(|window, event| {
-            if let tauri::WindowEvent::CloseRequested { api, .. } = event {
-                #[cfg(target_os = "macos")]
-                {
-                    let _ = window.hide();
-                    api.prevent_close();
-                }
-
-                #[cfg(not(target_os = "macos"))]
-                {
-                    use tauri::Manager;
-                    api.prevent_close();
-                    let win = window.clone();
-                    let app = window.app_handle().clone();
-                    tauri::async_runtime::spawn(async move {
-                        use tauri_plugin_dialog::DialogExt;
-                        let confirmed = app
-                            .dialog()
-                            .message("确定要退出小说写作助手吗?")
-                            .title("确认退出")
-                            .kind(tauri_plugin_dialog::MessageDialogKind::Warning)
-                            .blocking_show();
-
-                        if confirmed {
-                            app_state::persist_app_state_before_exit(&app);
-                            let _ = win.destroy();
-                        }
-                    });
-                }
-            }
-        })
-        .build(tauri::generate_context!())
-        .unwrap_or_else(|e| {
-            let msg = format!("应用程序启动失败: {e}");
-            eprintln!("{msg}");
-            #[cfg(windows)]
-            {
-                use std::ffi::OsStr;
-                use std::os::windows::ffi::OsStrExt;
-                extern "system" {
-                    fn MessageBoxW(
-                        hwnd: *mut std::ffi::c_void,
-                        lp_text: *const u16,
-                        lp_caption: *const u16,
-                        u_type: u32,
-                    ) -> i32;
-                }
-                fn to_wide(s: &str) -> Vec<u16> {
-                    OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect()
-                }
-                let text = to_wide(&msg);
-                let caption = to_wide("启动错误");
-                unsafe {
-                    MessageBoxW(
-                        std::ptr::null_mut(),
-                        text.as_ptr(),
-                        caption.as_ptr(),
-                        0x10,
-                    );
-                }
-            }
-            std::process::exit(1);
-        })
-        .run(|app, event| {
-            #[cfg(target_os = "macos")]
-            if let tauri::RunEvent::Reopen { has_visible_windows, .. } = &event {
-                if !has_visible_windows {
-                    use tauri::Manager;
-                    if let Some(window) = app.get_webview_window("main") {
-                        let _ = window.show();
-                        let _ = window.set_focus();
-                    }
-                }
-            }
-            // macOS 红叉只 hide;真正退出(Cmd+Q)必须在这里落盘。
-            if matches!(event, tauri::RunEvent::ExitRequested { .. }) {
-                app_state::persist_app_state_before_exit(app);
-            }
-        });
+    qmai::run();
 }

+ 0 - 149
src/components/chat/intent-clarification-dialog.tsx

@@ -1,149 +0,0 @@
-import { useEffect, useState } from "react"
-import { HelpCircle, X, MessageCircle } from "lucide-react"
-import type { NovelTaskIntent } from "@/lib/novel/task-router"
-
-export interface ClarificationCandidate {
-  intent: NovelTaskIntent
-  label: string
-  confidence?: number
-}
-
-interface IntentClarificationDialogProps {
-  open: boolean
-  userMessage: string
-  candidates: ClarificationCandidate[]
-  onConfirm: (intent: NovelTaskIntent | null, customText?: string) => void
-  onCancel: () => void
-}
-
-export function IntentClarificationDialog({
-  open,
-  userMessage,
-  candidates,
-  onConfirm,
-  onCancel,
-}: IntentClarificationDialogProps) {
-  const [customMode, setCustomMode] = useState(false)
-  const [customText, setCustomText] = useState("")
-
-  useEffect(() => {
-    if (open) {
-      setCustomMode(false)
-      setCustomText("")
-    }
-  }, [open])
-
-  if (!open) return null
-
-  const handleSelect = (intent: NovelTaskIntent) => {
-    onConfirm(intent)
-  }
-
-  const handleCustomSubmit = () => {
-    if (customText.trim()) {
-      onConfirm(null, customText.trim())
-    }
-  }
-
-  return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
-      <div className="flex w-full max-w-md flex-col rounded-lg border bg-background shadow-2xl">
-        <div className="flex items-center justify-between border-b px-4 py-3">
-          <div className="flex items-center gap-2">
-            <HelpCircle className="h-5 w-5 text-amber-500" />
-            <div>
-              <h3 className="font-semibold">请确认你的意图</h3>
-              <p className="text-xs text-muted-foreground">
-                我不太确定你想要做什么,请选择一个选项
-              </p>
-            </div>
-          </div>
-          <button
-            onClick={onCancel}
-            className="rounded-md p-1 hover:bg-accent"
-            aria-label="关闭"
-          >
-            <X className="h-4 w-4" />
-          </button>
-        </div>
-
-        <div className="border-b px-4 py-3">
-          <div className="rounded-md bg-muted p-3 text-sm">
-            <div className="mb-1 flex items-center gap-1.5 text-xs text-muted-foreground">
-              <MessageCircle className="h-3.5 w-3.5" />
-              <span>你的输入</span>
-            </div>
-            <p className="line-clamp-3">{userMessage || "(空)"}</p>
-          </div>
-        </div>
-
-        <div className="max-h-80 overflow-y-auto p-3">
-          {!customMode ? (
-            <div className="space-y-2">
-              {candidates.map((candidate, index) => (
-                <button
-                  key={index}
-                  onClick={() => handleSelect(candidate.intent)}
-                  className="flex w-full items-center justify-between rounded-md border px-3 py-2.5 text-left transition-colors hover:border-primary hover:bg-accent"
-                >
-                  <span className="font-medium">{candidate.label}</span>
-                  {candidate.confidence !== undefined && candidate.confidence > 0 && (
-                    <span className="text-xs text-muted-foreground">
-                      置信度 {Math.round(candidate.confidence * 100)}%
-                    </span>
-                  )}
-                </button>
-              ))}
-              <button
-                onClick={() => setCustomMode(true)}
-                className="flex w-full items-center justify-center gap-2 rounded-md border border-dashed px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:border-primary hover:text-foreground"
-              >
-                我想描述得更清楚一些...
-              </button>
-            </div>
-          ) : (
-            <div className="space-y-3">
-              <button
-                onClick={() => setCustomMode(false)}
-                className="text-sm text-primary hover:underline"
-              >
-                ← 返回选择
-              </button>
-              <textarea
-                value={customText}
-                onChange={(e) => setCustomText(e.target.value)}
-                placeholder="请更详细地描述你想做什么..."
-                className="h-32 w-full resize-none rounded-md border bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none"
-                autoFocus
-              />
-              <div className="flex justify-end gap-2">
-                <button
-                  onClick={onCancel}
-                  className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
-                >
-                  取消
-                </button>
-                <button
-                  onClick={handleCustomSubmit}
-                  disabled={!customText.trim()}
-                  className="rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
-                >
-                  确认
-                </button>
-              </div>
-            </div>
-          )}
-        </div>
-
-        <div className="border-t px-4 py-3">
-          <button
-            onClick={onCancel}
-            className="w-full rounded-md border px-3 py-2 text-sm hover:bg-accent"
-          >
-            取消
-          </button>
-        </div>
-      </div>
-    </div>
-  )
-}

+ 0 - 123
src/components/layout/file-tree.tsx

@@ -1,123 +0,0 @@
-import { useState } from "react"
-import { ChevronRight, ChevronDown, File, Folder, FolderOpen } from "lucide-react"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import { useWikiStore } from "@/stores/wiki-store"
-import type { FileNode } from "@/types/wiki"
-import { useTranslation } from "react-i18next"
-import { openProjectFolder } from "@/commands/fs"
-import { isTauri } from "@/lib/platform"
-
-function TreeNode({ node, depth }: { node: FileNode; depth: number }) {
-  const [expanded, setExpanded] = useState(depth < 1)
-  const selectedFile = useWikiStore((s) => s.selectedFile)
-  const setSelectedFile = useWikiStore((s) => s.setSelectedFile)
-
-  const isSelected = selectedFile === node.path
-  const paddingLeft = 12 + depth * 16
-
-  if (node.is_dir) {
-    return (
-      <div>
-        <button
-          onClick={() => setExpanded(!expanded)}
-          className="flex w-full items-center gap-1 py-1 text-sm text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
-          style={{ paddingLeft }}
-        >
-          {expanded ? (
-            <ChevronDown className="h-3.5 w-3.5 shrink-0" />
-          ) : (
-            <ChevronRight className="h-3.5 w-3.5 shrink-0" />
-          )}
-          <Folder className="h-3.5 w-3.5 shrink-0 text-blue-400" />
-          <span className="truncate">{node.name}</span>
-        </button>
-        {expanded && node.children?.map((child) => (
-          <TreeNode key={child.path} node={child} depth={depth + 1} />
-        ))}
-      </div>
-    )
-  }
-
-  return (
-    <button
-      onClick={() => setSelectedFile(node.path)}
-      className={`flex w-full items-center gap-1 py-1 text-sm ${
-        isSelected
-          ? "qm-selected border-l-2 border-primary/60"
-          : "text-muted-foreground qm-hover"
-      }`}
-      style={{ paddingLeft: paddingLeft + 14 }}
-    >
-      <File className="h-3.5 w-3.5 shrink-0" />
-      <span className="truncate">{node.name}</span>
-    </button>
-  )
-}
-
-export function FileTree() {
-  const { t } = useTranslation()
-  const fileTree = useWikiStore((s) => s.fileTree)
-  const project = useWikiStore((s) => s.project)
-
-  const handleOpenProjectFolder = async () => {
-    if (!project) return
-
-    try {
-      await openProjectFolder(project.path)
-    } catch (err) {
-      console.error("[FileTree] open project folder failed:", err)
-      if (isTauri()) {
-        const { message } = await import("@tauri-apps/plugin-dialog")
-        await message(
-          t("fileTree.openProjectFolderFailed", {
-            defaultValue: "Failed to open the project folder.",
-          }),
-          {
-            title: t("fileTree.openProjectFolder", {
-              defaultValue: "Open project folder",
-            }),
-            kind: "error",
-          },
-        )
-      } else {
-        window.alert("无法打开项目文件夹")
-      }
-    }
-  }
-
-  if (!project) {
-    return (
-      <div className="flex h-full items-center justify-center p-4 text-sm text-muted-foreground">
-        {t("fileTree.noProject")}
-      </div>
-    )
-  }
-
-  return (
-    <div className="flex h-full min-w-0 flex-col overflow-hidden">
-      <ScrollArea className="min-h-0 flex-1 overflow-hidden">
-        <div className="p-2">
-          <div className="mb-2 px-2 text-xs font-semibold uppercase text-muted-foreground">
-            {project.name}
-          </div>
-          {fileTree.map((node) => (
-            <TreeNode key={node.path} node={node} depth={0} />
-          ))}
-        </div>
-      </ScrollArea>
-      <div className="shrink-0 border-t p-2">
-        <button
-          type="button"
-          onClick={() => void handleOpenProjectFolder()}
-          className="flex w-full items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-accent-foreground"
-          title={project.path}
-        >
-          <FolderOpen className="h-3.5 w-3.5 shrink-0" />
-          <span className="truncate">
-            {t("fileTree.openProjectFolder", { defaultValue: "Open project folder" })}
-          </span>
-        </button>
-      </div>
-    </div>
-  )
-}

+ 0 - 41
src/components/project/template-picker.tsx

@@ -1,41 +0,0 @@
-import { templates } from "@/lib/templates"
-import { cn } from "@/lib/utils"
-import { useTranslation } from "react-i18next"
-
-interface TemplatePickerProps {
-  selected: string
-  onSelect: (id: string) => void
-}
-
-export function TemplatePicker({ selected, onSelect }: TemplatePickerProps) {
-  const { t } = useTranslation()
-
-  return (
-    <div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
-      {templates.map((template) => {
-        const name = t(`templates.${template.id}.name`, { defaultValue: template.name })
-        const description = t(`templates.${template.id}.description`, {
-          defaultValue: template.description,
-        })
-
-        return (
-          <button
-            key={template.id}
-            type="button"
-            onClick={() => onSelect(template.id)}
-            className={cn(
-              "flex flex-col gap-1 rounded-md border p-3 text-left transition-colors hover:bg-accent",
-              selected === template.id
-                ? "border-primary bg-accent ring-1 ring-primary"
-                : "border-border bg-background",
-            )}
-          >
-            <span className="text-xl leading-none">{template.icon}</span>
-            <span className="text-sm font-medium leading-tight">{name}</span>
-            <span className="text-xs text-muted-foreground leading-tight">{description}</span>
-          </button>
-        )
-      })}
-    </div>
-  )
-}

+ 0 - 75
src/components/search/search-window-view.tsx

@@ -1,75 +0,0 @@
-import { useEffect, useMemo, useState } from "react"
-import { SearchView } from "@/components/search/search-view"
-import { useWikiStore } from "@/stores/wiki-store"
-import { SEARCH_CONTEXT_EVENT, SEARCH_OPEN_FILE_EVENT, type SearchOpenFilePayload, type SearchWindowContext } from "@/lib/search-window"
-import { isTauri } from "@/lib/platform"
-
-interface SearchWindowViewProps {
-  initialContext: SearchWindowContext | null
-}
-
-export function SearchWindowView({ initialContext }: SearchWindowViewProps) {
-  const setProject = useWikiStore((s) => s.setProject)
-  const setNovelMode = useWikiStore((s) => s.setNovelMode)
-  const [context, setContext] = useState<SearchWindowContext | null>(initialContext)
-
-  useEffect(() => {
-    if (!context) return
-    setProject({
-      id: context.projectId,
-      name: context.projectName,
-      path: context.projectPath,
-    })
-    setNovelMode(context.novelMode)
-  }, [context, setNovelMode, setProject])
-
-  useEffect(() => {
-    if (!isTauri()) return
-    let cancelled = false
-    let unlisten: (() => void) | null = null
-
-    void import("@tauri-apps/api/webviewWindow")
-      .then(async ({ getCurrentWebviewWindow }) => {
-        unlisten = await getCurrentWebviewWindow().listen<SearchWindowContext>(
-          SEARCH_CONTEXT_EVENT,
-          (event) => {
-            if (!cancelled) {
-              setContext(event.payload)
-            }
-          },
-        )
-      })
-      .catch(() => {})
-
-    return () => {
-      cancelled = true
-      unlisten?.()
-    }
-  }, [])
-
-  const handleOpenFile = useMemo(() => {
-    return async (payload: SearchOpenFilePayload) => {
-      if (!isTauri()) return
-      const [{ getCurrentWebview }, { getCurrentWebviewWindow }] = await Promise.all([
-        import("@tauri-apps/api/webview"),
-        import("@tauri-apps/api/webviewWindow"),
-      ])
-      await getCurrentWebview().emitTo("main", SEARCH_OPEN_FILE_EVENT, payload)
-      await getCurrentWebviewWindow().close()
-    }
-  }, [])
-
-  return (
-    <div className="flex h-screen flex-col bg-background text-foreground">
-      <div className="border-b px-4 py-3">
-        <div className="text-sm font-medium">独立搜索</div>
-        <div className="mt-1 text-xs text-muted-foreground">
-          {context ? `当前项目:${context.projectName}` : "正在加载搜索上下文..."}
-        </div>
-      </div>
-      <div className="min-h-0 flex-1">
-        {context ? <SearchView onOpenFile={handleOpenFile} /> : null}
-      </div>
-    </div>
-  )
-}

+ 0 - 30
src/components/settings/sections/about-section.tsx

@@ -1,30 +0,0 @@
-import { useTranslation } from "react-i18next"
-
-export function AboutSection() {
-  const { t } = useTranslation()
-
-  const rows: Array<{ label: string; value: string; mono?: boolean }> = [
-    { label: t("settings.sections.about.version"), value: `v${__APP_VERSION__}`, mono: true },
-  ]
-
-  return (
-    <div className="space-y-6">
-      <div>
-        <h2 className="text-xl font-semibold">{t("settings.sections.about.title")}</h2>
-        <p className="mt-1 text-sm text-muted-foreground">
-          {t("settings.sections.about.description")}
-        </p>
-      </div>
-
-      <div className="rounded-md border divide-y">
-        {rows.map((r) => (
-          <div key={r.label} className="flex items-center justify-between px-4 py-2.5">
-            <span className="text-sm text-muted-foreground">{r.label}</span>
-            <span className={`text-sm ${r.mono ? "font-mono" : ""}`}>{r.value}</span>
-          </div>
-        ))}
-      </div>
-
-    </div>
-  )
-}

+ 2 - 1
src/components/settings/sections/custom-provider-cards.persist.spec.tsx

@@ -124,7 +124,8 @@ describe("close-path wiring", () => {
     const rustLib = readFileSync(resolve(process.cwd(), "src-tauri/src/lib.rs"), "utf8")
     const rustMain = readFileSync(resolve(process.cwd(), "src-tauri/src/main.rs"), "utf8")
     expect(rustLib).toContain("persist_app_state_before_exit")
-    expect(rustMain).toContain("persist_app_state_before_exit")
+    expect(rustMain).toContain("qmai::run()")
+    expect(rustMain).not.toContain("persist_app_state_before_exit")
   })
 
   it("derives custom cards from the store instead of a one-shot local snapshot", () => {

+ 0 - 286
src/components/settings/sections/multimodal-section.tsx

@@ -1,286 +0,0 @@
-import { useTranslation } from "react-i18next"
-import { Input } from "@/components/ui/input"
-import { Label } from "@/components/ui/label"
-import type { SettingsDraft, DraftSetter } from "../settings-types"
-
-interface Props {
-  draft: SettingsDraft
-  setDraft: DraftSetter
-}
-
-const PROVIDER_OPTIONS: Array<{ value: SettingsDraft["multimodalProvider"]; label: string }> = [
-  { value: "custom", label: "Custom (OpenAI-compat)" },
-  { value: "openai", label: "OpenAI" },
-  { value: "anthropic", label: "Anthropic" },
-  { value: "google", label: "Google (Gemini)" },
-  { value: "ollama", label: "Ollama" },
-]
-
-export function MultimodalSection({ draft, setDraft }: Props) {
-  const { t } = useTranslation()
-
-  return (
-    <div className="space-y-6">
-      <div>
-        <h2 className="text-xl font-semibold">{t("settings.sections.multimodal.title", "Image captioning")}</h2>
-        <p className="mt-1 text-sm text-muted-foreground">
-          {t(
-            "settings.sections.multimodal.description",
-            "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 document
-          would surprise the budget.
-
-          Note: the toggle row deliberately uses a 2-tier border +
-          a textual ON/OFF state next to the pill switch. An earlier
-          version had only the small pill and several users missed
-          it entirely — pills are subtle when surrounded by long
-          help text. The textual ON/OFF and the matching colored
-          ring make the current state unambiguous at a glance. */}
-      <div
-        className={`flex items-center justify-between rounded-md border-2 p-3 transition-colors ${
-          draft.multimodalEnabled
-            ? "border-primary/40 bg-primary/5"
-            : "border-border bg-background"
-        }`}
-      >
-        <div className="min-w-0 flex-1">
-          <div className="text-sm font-medium">
-            {t("settings.sections.multimodal.enableLabel", "Enable captioning at ingest")}
-          </div>
-          <div className="text-xs text-muted-foreground">
-            {t(
-              "settings.sections.multimodal.enableHint",
-              "Off: images still get extracted, just no captions. Search won't find them by visual content. On: each new image triggers one vision-LLM call (cached by hash).",
-            )}
-          </div>
-        </div>
-        <button
-          type="button"
-          onClick={() => setDraft("multimodalEnabled", !draft.multimodalEnabled)}
-          role="switch"
-          aria-checked={draft.multimodalEnabled}
-          aria-label={t("settings.sections.multimodal.enableLabel", "Enable captioning at ingest")}
-          className="ml-3 flex shrink-0 items-center gap-2"
-        >
-          <span
-            className={`text-xs font-semibold ${
-              draft.multimodalEnabled ? "text-primary" : "text-muted-foreground"
-            }`}
-          >
-            {draft.multimodalEnabled
-              ? t("settings.sections.multimodal.stateOn", "ON")
-              : t("settings.sections.multimodal.stateOff", "OFF")}
-          </span>
-          <span
-            className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
-              draft.multimodalEnabled ? "bg-primary" : "bg-muted"
-            }`}
-          >
-            <span
-              className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
-                draft.multimodalEnabled ? "translate-x-4.5" : "translate-x-0.5"
-              }`}
-            />
-          </span>
-        </button>
-      </div>
-
-      {draft.multimodalEnabled && (
-        <>
-          {/* "Use main LLM" toggle. Lets users with a single VL-capable
-              model in their main config save the trouble of typing
-              everything twice. The dedicated fields below show only
-              when this is OFF.
-
-              Layout: the text column needs `min-w-0 flex-1` so a
-              long help text wraps inside its column instead of
-              shoving the toggle off to the right (or worse,
-              forcing the row's intrinsic width past the parent).
-              Without this the row visibly broke in English where
-              the multi-clause hint sentence is much longer than
-              the equivalent CJK text. The toggle gets `shrink-0`
-              for the symmetric reason — never lose the toggle to
-              the text column. */}
-          <div className="flex items-center justify-between gap-3 rounded-md border p-3">
-            <div className="min-w-0 flex-1">
-              <div className="text-sm font-medium">
-                {t("settings.sections.multimodal.useMainLabel", "Use main LLM for captions")}
-              </div>
-              <div className="text-xs text-muted-foreground">
-                {t(
-                  "settings.sections.multimodal.useMainHint",
-                  "Reuse the model picked under Settings → LLM provider. Only enable this if that model accepts image input — text-only models will return a 400.",
-                )}
-              </div>
-            </div>
-            <button
-              type="button"
-              onClick={() => setDraft("multimodalUseMainLlm", !draft.multimodalUseMainLlm)}
-              role="switch"
-              aria-checked={draft.multimodalUseMainLlm}
-              aria-label={t(
-                "settings.sections.multimodal.useMainLabel",
-                "Use main LLM for captions",
-              )}
-              className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${
-                draft.multimodalUseMainLlm ? "bg-primary" : "bg-muted"
-              }`}
-            >
-              <span
-                className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
-                  draft.multimodalUseMainLlm ? "translate-x-4.5" : "translate-x-0.5"
-                }`}
-              />
-            </button>
-          </div>
-
-          {!draft.multimodalUseMainLlm && (
-            <div className="space-y-4 rounded-md border p-3">
-              <div className="text-sm font-medium">
-                {t("settings.sections.multimodal.dedicatedHeading", "Dedicated vision endpoint")}
-              </div>
-
-              <div className="space-y-2">
-                <Label>{t("settings.sections.multimodal.provider", "Provider")}</Label>
-                <select
-                  className="w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
-                  value={draft.multimodalProvider}
-                  onChange={(e) =>
-                    setDraft("multimodalProvider", e.target.value as SettingsDraft["multimodalProvider"])
-                  }
-                >
-                  {PROVIDER_OPTIONS.map((p) => (
-                    <option key={p.value} value={p.value}>
-                      {p.label}
-                    </option>
-                  ))}
-                </select>
-              </div>
-
-              {draft.multimodalProvider === "ollama" && (
-                <div className="space-y-2">
-                  <Label>{t("settings.sections.multimodal.ollamaUrl", "Ollama URL")}</Label>
-                  <Input
-                    value={draft.multimodalOllamaUrl}
-                    onChange={(e) => setDraft("multimodalOllamaUrl", e.target.value)}
-                    placeholder="http://localhost:11434"
-                  />
-                </div>
-              )}
-
-              {draft.multimodalProvider === "custom" && (
-                <div className="space-y-2">
-                  <Label>{t("settings.sections.multimodal.customEndpoint", "Endpoint URL")}</Label>
-                  <Input
-                    value={draft.multimodalCustomEndpoint}
-                    onChange={(e) => setDraft("multimodalCustomEndpoint", e.target.value)}
-                    placeholder="http://localhost:1234/v1"
-                  />
-                  <p className="text-xs text-muted-foreground">
-                    {t(
-                      "settings.sections.multimodal.customEndpointHint",
-                      "OpenAI-compatible /v1 base. LM Studio, llama.cpp server, vLLM, LocalAI all work.",
-                    )}
-                  </p>
-                </div>
-              )}
-
-              <div className="space-y-2">
-                <Label>{t("settings.sections.multimodal.apiKey", "API key")}</Label>
-                <Input
-                  type="password"
-                  value={draft.multimodalApiKey}
-                  onChange={(e) => setDraft("multimodalApiKey", e.target.value)}
-                  placeholder={t(
-                    "settings.sections.multimodal.apiKeyPlaceholder",
-                    "Leave blank for local / no-auth endpoints",
-                  )}
-                />
-              </div>
-
-              <div className="space-y-2">
-                <Label>{t("settings.sections.multimodal.model", "Model")}</Label>
-                <Input
-                  value={draft.multimodalModel}
-                  onChange={(e) => setDraft("multimodalModel", e.target.value)}
-                  placeholder="e.g. Qwen2.5-VL-7B-Instruct, claude-3-5-sonnet-latest, gemini-2.5-flash"
-                />
-                <p className="text-xs text-muted-foreground">
-                  {t(
-                    "settings.sections.multimodal.modelHint",
-                    "Must be a vision-capable model. Text-only models will fail with a 400 / image-not-supported error at first ingest.",
-                  )}
-                </p>
-              </div>
-            </div>
-          )}
-
-          {/* 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
-              that batches under the hood anyway. */}
-          <div className="space-y-2 rounded-md border p-3">
-            <Label>{t("settings.sections.multimodal.concurrency", "Concurrent caption requests")}</Label>
-            <Input
-              type="number"
-              min={1}
-              max={16}
-              step={1}
-              value={draft.multimodalConcurrency}
-              onChange={(e) => {
-                const n = Number(e.target.value)
-                setDraft("multimodalConcurrency", Number.isFinite(n) ? n : 4)
-              }}
-            />
-            <p className="text-xs text-muted-foreground">
-              {t(
-                "settings.sections.multimodal.concurrencyHint",
-                "How many caption requests run in parallel. 1 = strictly sequential. 4 is a good default for most setups; raise to 8+ only on a beefy GPU or hosted endpoint.",
-              )}
-            </p>
-          </div>
-
-          {/* Cost guardrail panel — mostly informational for now. */}
-          <div className="space-y-1 rounded-md border border-amber-500/40 bg-amber-500/5 p-3">
-            <div className="text-sm font-medium text-amber-700 dark:text-amber-400">
-              {t("settings.sections.multimodal.costHeading", "Cost guardrails")}
-            </div>
-            <ul className="ml-4 list-disc space-y-1 text-xs text-muted-foreground">
-              <li>
-                {t(
-                  "settings.sections.multimodal.costPoint1",
-                  "Each new image triggers one vision-LLM call (~500-2000 tokens depending on model + thinking mode).",
-                )}
-              </li>
-              <li>
-                {t(
-                  "settings.sections.multimodal.costPoint2",
-                  "Captions are cached by SHA-256 of image bytes. Duplicate logos / shared chart templates across documents only ever incur ONE call.",
-                )}
-              </li>
-              <li>
-                {t(
-                  "settings.sections.multimodal.costPoint3",
-                  "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>
-                {t(
-                  "settings.sections.multimodal.costPoint4",
-                  "Prefer a local vision model (Qwen2.5-VL via LM Studio / Ollama) for bulk ingestion; reserve hosted vision APIs for one-off high-stakes documents.",
-                )}
-              </li>
-            </ul>
-          </div>
-        </>
-      )}
-    </div>
-  )
-}

+ 0 - 76
src/components/settings/sections/output-section.tsx

@@ -1,76 +0,0 @@
-import { useTranslation } from "react-i18next"
-import { Label } from "@/components/ui/label"
-import type { SettingsDraft, DraftSetter } from "../settings-types"
-import type { OutputLanguage } from "@/stores/wiki-store"
-import { OUTPUT_LANGUAGE_OPTIONS as LANGUAGE_OPTIONS } from "@/lib/output-language-options"
-
-interface Props {
-  draft: SettingsDraft
-  setDraft: DraftSetter
-}
-
-const HISTORY_OPTIONS = [2, 4, 6, 8, 10, 20]
-
-export function OutputSection({ draft, setDraft }: Props) {
-  const { t } = useTranslation()
-  return (
-    <div className="space-y-6">
-      <div>
-        <h2 className="text-xl font-semibold">{t("settings.sections.output.title")}</h2>
-        <p className="mt-1 text-sm text-muted-foreground">
-          {t("settings.sections.output.description")}
-        </p>
-      </div>
-
-      <div className="space-y-2">
-        <Label>{t("settings.sections.output.aiLanguage")}</Label>
-        <p className="text-xs text-muted-foreground">
-          {t("settings.sections.output.aiLanguageHint")}
-        </p>
-        <select
-          value={draft.outputLanguage}
-          onChange={(e) => setDraft("outputLanguage", e.target.value as OutputLanguage)}
-          className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
-        >
-          {LANGUAGE_OPTIONS.map((l) => (
-            <option key={l.value} value={l.value}>
-              {l.label}
-            </option>
-          ))}
-        </select>
-      </div>
-
-      <div className="space-y-2">
-        <Label>{t("settings.sections.output.historyLength")}</Label>
-        <p className="text-xs text-muted-foreground">
-          {t("settings.sections.output.historyHint")}
-        </p>
-        <div className="flex flex-wrap gap-2">
-          {HISTORY_OPTIONS.map((n) => {
-            const active = draft.maxHistoryMessages === n
-            return (
-              <button
-                key={n}
-                type="button"
-                onClick={() => setDraft("maxHistoryMessages", n)}
-                className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
-                  active
-                    ? "border-primary bg-primary text-primary-foreground"
-                    : "border-border hover:bg-accent"
-                }`}
-              >
-                {n}
-              </button>
-            )
-          })}
-        </div>
-        <p className="text-xs text-muted-foreground">
-          {t("settings.sections.output.historyCurrent", {
-            count: draft.maxHistoryMessages,
-            turns: draft.maxHistoryMessages / 2,
-          })}
-        </p>
-      </div>
-    </div>
-  )
-}

+ 0 - 3
src/components/sources/outline-workbench-integration.spec.ts

@@ -5,7 +5,6 @@ import { describe, expect, it } from "vitest"
 
 const sourcesViewSource = readFileSync(resolve(__dirname, "sources-view.tsx"), "utf8")
 const toolbarSource = readFileSync(resolve(__dirname, "outline-action-toolbar.tsx"), "utf8")
-const promptTemplatesSource = readFileSync(resolve(__dirname, "../../lib/novel/prompt-templates.ts"), "utf8")
 const sidebarPanelSource = readFileSync(resolve(__dirname, "../layout/sidebar-panel.tsx"), "utf8")
 
 describe("AI 大纲工作台页面接入", () => {
@@ -36,7 +35,5 @@ describe("AI 大纲工作台页面接入", () => {
     expect(existsSync(resolve(__dirname, "../../lib/novel/deep-outline-generation.ts"))).toBe(false)
     expect(sourcesViewSource).not.toContain("runDeepOutlineGeneration")
     expect(toolbarSource).not.toContain("runDeepOutlineGeneration")
-    expect(promptTemplatesSource).not.toContain("outlineGeneration:")
-    expect(promptTemplatesSource).not.toContain("outlineRefinementGeneration:")
   })
 })

+ 0 - 87
src/components/sources/outline-workflow-stages.tsx

@@ -1,87 +0,0 @@
-import React, { useMemo, useRef, useEffect } from "react"
-import type { ToolCallRecord } from "@/lib/agent/tool-events"
-import type { ToolCallEventItem } from "@/components/common/timeline-types"
-import { compareToolCallsByStartedAt, createStreamingEventBuilder, filterToolCallsForDisplay, getTimelineToolCategory } from "@/components/common/timeline-types"
-import { EventStream } from "@/components/common/event-stream"
-import { extractThinkingContent } from "@/lib/novel/outline-stage-trace"
-import { getWorkflowToolDescription, getWorkflowToolResultDisplay } from "@/lib/agent/workflow-trace"
-
-interface OutlineWorkflowStagesProps {
-  toolCalls: ToolCallRecord[]
-  content: string
-  isStreaming: boolean
-}
-
-function adaptToolCall(call: ToolCallRecord): ToolCallEventItem {
-  const isError = call.status === "error"
-  const callAny = call as any
-  const displayResult = getWorkflowToolResultDisplay(call.result)
-  return {
-    id: call.id,
-    name: call.name,
-    description: getWorkflowToolDescription({
-      name: call.name,
-      params: call.params as Record<string, unknown>,
-      result: call.result,
-      status: call.status,
-    } as Parameters<typeof getWorkflowToolDescription>[0]),
-    category: getTimelineToolCategory(call.name),
-    status: call.status,
-    params: call.params as Record<string, unknown>,
-    result: isError ? undefined : displayResult,
-    error: isError ? displayResult : undefined,
-    startedAt: callAny.startedAt,
-    finishedAt: callAny.finishedAt,
-  }
-}
-
-export const OutlineWorkflowStages = React.memo(function OutlineWorkflowStages(
-  props: OutlineWorkflowStagesProps,
-) {
-  const { toolCalls, content, isStreaming } = props
-  const builderRef = useRef(createStreamingEventBuilder("outline-thinking"))
-  const wasStreamingRef = useRef(false)
-
-  const thinkingExtract = useMemo(() => extractThinkingContent(content), [content])
-  const thinkingText = thinkingExtract.text || ""
-  const thinkingStreaming = thinkingExtract.streaming || isStreaming
-
-  const sortedCalls = useMemo(() => {
-    return filterToolCallsForDisplay([...toolCalls]).sort(compareToolCallsByStartedAt)
-  }, [toolCalls])
-
-  const adaptedCalls = useMemo(
-    () => sortedCalls.map(adaptToolCall),
-    [sortedCalls],
-  )
-
-  const hasRunningTool = toolCalls.some((c) => c.status === "running")
-  const actuallyStreaming = isStreaming || hasRunningTool
-
-  useEffect(() => {
-    if (!wasStreamingRef.current && actuallyStreaming) {
-      builderRef.current.reset()
-    }
-    wasStreamingRef.current = actuallyStreaming
-  }, [actuallyStreaming])
-
-  const events = useMemo(
-    () => builderRef.current.update(thinkingText, adaptedCalls, thinkingStreaming),
-    [thinkingText, adaptedCalls, thinkingStreaming],
-  )
-
-  const totalDurationMs = useMemo(() => {
-    if (toolCalls.length > 0) {
-      const startedAts = toolCalls.map((c) => (c as { startedAt?: number }).startedAt).filter(Boolean) as number[]
-      const finishedAts = toolCalls.map((c) => (c as { finishedAt?: number }).finishedAt).filter(Boolean) as number[]
-      if (startedAts.length > 0 && finishedAts.length > 0) {
-        return Math.max(...finishedAts) - Math.min(...startedAts)
-      }
-    }
-    return undefined
-  }, [toolCalls])
-
-  if (events.length === 0 && !actuallyStreaming) return null
-
-  return <EventStream events={events} isStreaming={actuallyStreaming} totalDurationMs={totalDurationMs} />
-})

+ 0 - 23
src/components/ui/separator.tsx

@@ -1,23 +0,0 @@
-import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
-
-import { cn } from "@/lib/utils"
-
-function Separator({
-  className,
-  orientation = "horizontal",
-  ...props
-}: SeparatorPrimitive.Props) {
-  return (
-    <SeparatorPrimitive
-      data-slot="separator"
-      orientation={orientation}
-      className={cn(
-        "shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
-        className
-      )}
-      {...props}
-    />
-  )
-}
-
-export { Separator }

+ 0 - 46
src/hooks/use-batched-value.ts

@@ -1,46 +0,0 @@
-import { useEffect, useState, useRef } from "react"
-
-export function useBatchedValue<T>(value: T, isStreaming: boolean): T {
-  const [batched, setBatched] = useState(value)
-  const rafRef = useRef<number | null>(null)
-  const pendingRef = useRef<T>(value)
-  const hasPendingRef = useRef(false)
-
-  useEffect(() => {
-    if (!isStreaming) {
-      if (rafRef.current !== null) {
-        cancelAnimationFrame(rafRef.current)
-        rafRef.current = null
-      }
-      if (hasPendingRef.current) {
-        setBatched(pendingRef.current)
-        hasPendingRef.current = false
-      } else {
-        setBatched(value)
-      }
-      return
-    }
-
-    pendingRef.current = value
-    hasPendingRef.current = true
-
-    if (rafRef.current !== null) return
-
-    rafRef.current = requestAnimationFrame(() => {
-      rafRef.current = null
-      if (hasPendingRef.current) {
-        setBatched(pendingRef.current)
-        hasPendingRef.current = false
-      }
-    })
-
-    return () => {
-      if (rafRef.current !== null) {
-        cancelAnimationFrame(rafRef.current)
-        rafRef.current = null
-      }
-    }
-  }, [value, isStreaming])
-
-  return batched
-}

+ 0 - 90
src/lib/agent/trace-builder.ts

@@ -1,90 +0,0 @@
-import {
-  createContextTrace,
-  addToolCallToTrace,
-  updateToolCallInTrace,
-  setContextInfo,
-  finishTrace,
-  type ContextTrace,
-  type TraceContextInfo,
-  type TraceToolCall,
-} from "./context-trace"
-import type { ToolCallStatus } from "./types"
-
-export interface TraceBuilder {
-  startNewTrace(traceId: string): void
-  getCurrentTrace(): ContextTrace | null
-  emitVirtualToolStart(name: string, params: Record<string, unknown>): string
-  emitVirtualToolEnd(
-    callId: string,
-    result?: string,
-    status?: ToolCallStatus,
-  ): void
-  updateTraceContextInfo(info: Partial<TraceContextInfo>): void
-  completeCurrentTrace(status?: "done" | "error", errorMessage?: string): void
-}
-
-let currentTrace: ContextTrace | null = null
-let callIdCounter = 0
-
-export function createTraceBuilder(): TraceBuilder {
-  return {
-    startNewTrace(traceId: string) {
-      currentTrace = createContextTrace(traceId)
-      callIdCounter = 0
-    },
-
-    getCurrentTrace() {
-      return currentTrace
-    },
-
-    emitVirtualToolStart(name: string, params: Record<string, unknown>): string {
-      if (!currentTrace) return ""
-      const callId = `vt_${Date.now()}_${++callIdCounter}`
-      const toolCall: TraceToolCall = {
-        id: callId,
-        name,
-        category: "virtual",
-        params,
-        status: "running",
-        startedAt: Date.now(),
-      }
-      currentTrace = addToolCallToTrace(currentTrace, toolCall)
-      return callId
-    },
-
-    emitVirtualToolEnd(
-      callId: string,
-      result?: string,
-      status: ToolCallStatus = "done",
-    ) {
-      if (!currentTrace) return
-      currentTrace = updateToolCallInTrace(currentTrace, callId, {
-        result,
-        status,
-        finishedAt: Date.now(),
-      })
-    },
-
-    updateTraceContextInfo(info: Partial<TraceContextInfo>) {
-      if (!currentTrace) return
-      const existingInfo = currentTrace.contextInfo
-      const updatedInfo: TraceContextInfo = {
-        intent: existingInfo?.intent ?? ("general" as any),
-        confidence: existingInfo?.confidence ?? 0,
-        routeSource: existingInfo?.routeSource ?? ("default" as any),
-        loadedSources: existingInfo?.loadedSources ?? [],
-        blockedSources: existingInfo?.blockedSources ?? [],
-        retrievalHits: existingInfo?.retrievalHits ?? [],
-        trimmedSections: existingInfo?.trimmedSections ?? [],
-        ...existingInfo,
-        ...info,
-      }
-      currentTrace = setContextInfo(currentTrace, updatedInfo)
-    },
-
-    completeCurrentTrace(status: "done" | "error" = "done", errorMessage?: string) {
-      if (!currentTrace) return
-      currentTrace = finishTrace(currentTrace, status, errorMessage)
-    },
-  }
-}

+ 0 - 281
src/lib/crypto.ts

@@ -1,281 +0,0 @@
-/**
- * API 密钥加密工具(基于设备指纹 + Web Crypto API)
- *
- * 方案A:设备标识绑定
- * - 密钥派生:设备指纹(机器名 + 用户名 + OS)经过 SHA-256 派生
- * - 加密算法:AES-256-GCM(带认证标签,防篡改)
- * - 存储格式:enc::v1::<base64(nonce + ciphertext + tag)>
- */
-
-import { invoke } from "@tauri-apps/api/core"
-import { isTauri } from "@/lib/platform"
-
-const ENCRYPTED_PREFIX = "enc::v1::"
-
-// 缓存设备密钥,避免每次都重新派生
-let cachedKey: CryptoKey | null = null
-let cachedFingerprint: string | null = null
-
-/**
- * 获取设备指纹(从 Rust 后端获取)
- *
- * 注意:仅在 Tauri 环境下调用后端命令;
- * 非 Tauri 环境(如纯浏览器/HTTP 模式)降级到 localStorage 随机指纹。
- * 降级指纹不缓存,确保后续 Tauri 就绪后可重新获取。
- */
-export async function getDeviceFingerprint(): Promise<string> {
-  // Tauri 环境下尝试从后端获取
-  if (isTauri()) {
-    if (cachedFingerprint) return cachedFingerprint
-    try {
-      const fp = await invoke<string>("get_device_fingerprint_cmd")
-      if (fp && fp.length >= 64) {
-        cachedFingerprint = fp
-        return cachedFingerprint
-      }
-      console.warn("[crypto] 设备指纹长度不足,期望≥64字符,实际:", fp?.length)
-    } catch (e) {
-      console.warn("[crypto] 获取设备指纹失败,使用降级方案:", e)
-    }
-  }
-
-  // 降级:使用 localStorage 存储一个随机指纹(不绑定设备,但至少加密存储)
-  let fallback = localStorage.getItem("qmai_fallback_fingerprint")
-  if (!fallback) {
-    const buf = new Uint8Array(32)
-    crypto.getRandomValues(buf)
-    fallback = Array.from(buf, b => b.toString(16).padStart(2, "0")).join("")
-    localStorage.setItem("qmai_fallback_fingerprint", fallback)
-  }
-  // 注意:降级指纹不缓存到 cachedFingerprint,确保下次 Tauri 就绪后可重试
-  return fallback
-}
-
-/**
- * 从设备指纹派生 AES-256 密钥
- */
-async function getDeviceKey(): Promise<CryptoKey> {
-  if (cachedKey) return cachedKey
-
-  const fingerprint = await getDeviceFingerprint()
-
-  // 将指纹 hex 字符串转为字节,验证长度防止 NaN
-  const keyMaterial = new Uint8Array(32)
-  for (let i = 0; i < 32; i++) {
-    const hexByte = fingerprint.slice(i * 2, i * 2 + 2)
-    const parsed = parseInt(hexByte, 16)
-    if (Number.isNaN(parsed)) {
-      throw new Error(`设备指纹格式无效: 位置 ${i * 2} 处不是有效的十六进制字符`)
-    }
-    keyMaterial[i] = parsed
-  }
-
-  const key = await crypto.subtle.importKey(
-    "raw",
-    keyMaterial,
-    { name: "AES-GCM", length: 256 },
-    false,
-    ["encrypt", "decrypt"],
-  )
-
-  cachedKey = key
-  return key
-}
-
-/**
- * 判断字符串是否是加密格式
- */
-export function isEncrypted(value: string): boolean {
-  return typeof value === "string" && value.startsWith(ENCRYPTED_PREFIX)
-}
-
-/**
- * 使用设备密钥加密字符串
- * @param plaintext 明文
- * @returns 加密后的字符串(带前缀)
- */
-export async function encryptString(plaintext: string): Promise<string> {
-  if (!plaintext) return plaintext
-  if (isEncrypted(plaintext)) return plaintext // 已经是加密的,不重复加密
-
-  const key = await getDeviceKey()
-
-  // 生成 12 字节随机 nonce(GCM 推荐)
-  const nonce = new Uint8Array(12)
-  crypto.getRandomValues(nonce)
-
-  const encoded = new TextEncoder().encode(plaintext)
-  const ciphertext = await crypto.subtle.encrypt(
-    { name: "AES-GCM", iv: nonce },
-    key,
-    encoded,
-  )
-
-  // 组合:nonce(12) + ciphertext+tag
-  const combined = new Uint8Array(nonce.length + ciphertext.byteLength)
-  combined.set(nonce, 0)
-  combined.set(new Uint8Array(ciphertext), nonce.length)
-
-  // Base64 编码(使用循环避免 spread 操作符在大数据时栈溢出)
-  let binary = ""
-  for (let i = 0; i < combined.length; i++) {
-    binary += String.fromCharCode(combined[i])
-  }
-  const b64 = btoa(binary)
-  return ENCRYPTED_PREFIX + b64
-}
-
-/**
- * 使用设备密钥解密字符串
- * @param ciphertext 加密后的字符串(带前缀)
- * @returns 明文
- */
-export async function decryptString(ciphertext: string): Promise<string> {
-  if (!ciphertext) return ciphertext
-  if (!isEncrypted(ciphertext)) {
-    // 未加密的明文,直接返回(向后兼容)
-    return ciphertext
-  }
-
-  const data = ciphertext.slice(ENCRYPTED_PREFIX.length)
-  const binary = Uint8Array.from(atob(data), c => c.charCodeAt(0))
-
-  if (binary.length < 12) {
-    throw new Error("密文数据过短")
-  }
-
-  const key = await getDeviceKey()
-  const nonce = binary.slice(0, 12)
-  const cipherData = binary.slice(12)
-
-  const plaintext = await crypto.subtle.decrypt(
-    { name: "AES-GCM", iv: nonce },
-    key,
-    cipherData,
-  )
-
-  return new TextDecoder().decode(plaintext)
-}
-
-/**
- * 加密 API 密钥(带错误处理)
- * 加密失败时抛出错误,让上层决定如何处理(而非静默返回明文)
- */
-export async function safeEncryptApiKey(plaintext: string): Promise<string> {
-  try {
-    return await encryptString(plaintext)
-  } catch (e) {
-    console.error("[crypto] 加密失败,拒绝返回明文:", e)
-    throw new Error(`API 密钥加密失败: ${e instanceof Error ? e.message : String(e)}`)
-  }
-}
-
-/**
- * 解密 API 密钥(带错误处理,失败则返回原值)
- */
-export async function safeDecryptApiKey(ciphertext: string): Promise<string> {
-  try {
-    return await decryptString(ciphertext)
-  } catch (e) {
-    console.warn("[crypto] 解密失败,可能密钥不匹配:", e)
-    // 解密失败,可能是换设备了,返回空让用户重新输入
-    return ""
-  }
-}
-
-// ── 对象级递归加解密工具 ──────────────────────────────────────────────
-
-const API_KEY_FIELD_PATTERN = /api[_-]?key/i
-
-/**
- * 递归加密对象中所有 API key 字段
- * 匹配字段名:apiKey, api_key, api-key, API_KEY 等
- */
-export async function encryptApiKeysInObject<T = unknown>(obj: T): Promise<T> {
-  if (obj === null || obj === undefined) return obj
-  if (typeof obj === "string") return obj
-  if (typeof obj !== "object") return obj
-
-  // 数组
-  if (Array.isArray(obj)) {
-    const results = await Promise.all(obj.map(item => encryptApiKeysInObject(item)))
-    return results as unknown as T
-  }
-
-  // 普通对象
-  const result: Record<string, unknown> = { ...(obj as Record<string, unknown>) }
-  for (const [key, value] of Object.entries(result)) {
-    if (API_KEY_FIELD_PATTERN.test(key) && typeof value === "string" && value) {
-      result[key] = await safeEncryptApiKey(value)
-    } else if (typeof value === "object" && value !== null) {
-      result[key] = await encryptApiKeysInObject(value)
-    }
-  }
-  return result as T
-}
-
-/**
- * 递归解密对象中所有 API key 字段
- */
-export async function decryptApiKeysInObject<T = unknown>(obj: T): Promise<T> {
-  if (obj === null || obj === undefined) return obj
-  if (typeof obj === "string") return obj
-  if (typeof obj !== "object") return obj
-
-  // 数组
-  if (Array.isArray(obj)) {
-    const results = await Promise.all(obj.map(item => decryptApiKeysInObject(item)))
-    return results as unknown as T
-  }
-
-  // 普通对象
-  const result: Record<string, unknown> = { ...(obj as Record<string, unknown>) }
-  for (const [key, value] of Object.entries(result)) {
-    if (API_KEY_FIELD_PATTERN.test(key) && typeof value === "string" && value) {
-      result[key] = await safeDecryptApiKey(value)
-    } else if (typeof value === "object" && value !== null) {
-      result[key] = await decryptApiKeysInObject(value)
-    }
-  }
-  return result as T
-}
-
-// ── 加密状态统计与迁移 ────────────────────────────────────────────────
-
-/**
- * 统计对象中有多少 API key 字段是明文/已加密的
- */
-export function countApiKeyStatus(obj: unknown): { total: number; encrypted: number; plaintext: number } {
-  let total = 0
-  let encrypted = 0
-  let plaintext = 0
-
-  function walk(value: unknown) {
-    if (value === null || value === undefined) return
-    if (typeof value === "string") return
-    if (typeof value !== "object") return
-
-    if (Array.isArray(value)) {
-      value.forEach(walk)
-      return
-    }
-
-    for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
-      if (API_KEY_FIELD_PATTERN.test(key) && typeof val === "string") {
-        if (val) {
-          total++
-          if (isEncrypted(val)) {
-            encrypted++
-          } else {
-            plaintext++
-          }
-        }
-      } else if (typeof val === "object" && val !== null) {
-        walk(val)
-      }
-    }
-  }
-
-  walk(obj)
-  return { total, encrypted, plaintext }
-}

+ 0 - 210
src/lib/enrich-wikilinks.ts

@@ -1,210 +0,0 @@
-import { readFile, writeFile } from "@/commands/fs"
-import { streamChat } from "./llm-client"
-import { useWikiStore, type LlmConfig } from "@/stores/wiki-store"
-import { buildLanguageDirective } from "./output-language"
-import { normalizePath } from "@/lib/path-utils"
-
-/**
- * Lightweight post-save enrichment: ask LLM to add [[wikilinks]] to a saved wiki page.
- *
- * DESIGN NOTE (v2): previously we asked the LLM to return the complete page
- * with [[ ]] inserted, but many models (confirmed on MiniMax-M2.7-highspeed)
- * treat this as an invitation to rewrite / expand the page, destroying
- * user content. No prompt-level instruction reliably prevents this for
- * mid-size models.
- *
- * New design: LLM only returns a list of `(term → target)` substitutions as
- * JSON. The code then does the actual string replacement (first occurrence
- * per page). This way:
- *   - content is byte-identical outside the inserted [[ ]] brackets
- *   - frontmatter is untouched
- *   - length grows by exactly 4 × number_of_links
- *   - catastrophic LLM output (rewrites, translations, commentary) can't
- *     corrupt the user's page
- */
-export async function enrichWithWikilinks(
-  projectPath: string,
-  filePath: string,
-  llmConfig: LlmConfig,
-): Promise<void> {
-  const pp = normalizePath(projectPath)
-  const fp = normalizePath(filePath)
-  const [content, index] = await Promise.all([
-    readFile(fp),
-    readFile(`${pp}/wiki/index.md`).catch(() => ""),
-  ])
-
-  if (!content || !index) return
-
-  // Ask the LLM to return a JSON list of {term, target} substitutions.
-  // Much easier task than rewriting the whole page, and the model can't
-  // corrupt anything it doesn't put in the list.
-  let raw = ""
-
-  await streamChat(
-    llmConfig,
-    [
-      {
-        role: "system",
-        content: [
-          "You identify which terms in a wiki page should become [[wikilinks]] pointing to existing wiki pages.",
-          "",
-          buildLanguageDirective(content),
-          "",
-          "You will receive:",
-          "  - a wiki index listing existing pages (each line roughly like `- pagename`)",
-          "  - the content of ONE wiki page",
-          "",
-          "Return a JSON object listing which terms in the page content should be linked to which index entries.",
-          "",
-          "Response format (EXACTLY this JSON shape, nothing else):",
-          "{",
-          "  \"links\": [",
-          "    { \"term\": \"exact text appearing in the content\", \"target\": \"index page name\" }",
-          "  ]",
-          "}",
-          "",
-          "Rules:",
-          "- Each \"term\" MUST be a literal substring present in the page content (case-sensitive).",
-          "- Each \"target\" MUST be a page listed in the wiki index.",
-          "- Include at most one entry per target (first mention).",
-          "- Only include clearly-matching terms (e.g. if content mentions 'Transformer' and index has 'transformer', target='transformer' is correct).",
-          "- If no terms should be linked, return `{\"links\": []}`.",
-          "- Do NOT output preamble, explanations, or markdown fences — ONLY the JSON object.",
-          "",
-          `## Wiki Index\n${index}`,
-        ].join("\n"),
-      },
-      {
-        role: "user",
-        content: `Page content:\n\n${content}`,
-      },
-    ],
-    {
-      onToken: (token) => { raw += token },
-      onDone: () => {},
-      onError: () => {},
-    },
-  )
-
-  // Parse the LLM response. Be tolerant of fences / prose wrappers.
-  const links = parseLinkResponse(raw)
-  if (links.length === 0) return // nothing to do
-
-  // Apply substitutions to the ORIGINAL content. This guarantees the only
-  // change is inserted [[...]] brackets.
-  const enriched = applyLinks(content, links)
-  if (enriched === content) return
-
-  await writeFile(fp, enriched)
-  useWikiStore.getState().bumpDataVersion()
-}
-
-interface LinkEntry {
-  term: string
-  target: string
-}
-
-function parseLinkResponse(raw: string): LinkEntry[] {
-  if (!raw.trim()) return []
-  // Extract the first balanced {...}
-  let text = raw.trim()
-  text = text.replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/i, "")
-  const start = text.indexOf("{")
-  if (start === -1) return []
-
-  let depth = 0
-  let inStr = false
-  let escape = false
-  let end = -1
-  for (let i = start; i < text.length; i++) {
-    const ch = text[i]
-    if (escape) { escape = false; continue }
-    if (ch === "\\" && inStr) { escape = true; continue }
-    if (ch === '"') { inStr = !inStr; continue }
-    if (inStr) continue
-    if (ch === "{") depth++
-    else if (ch === "}") {
-      depth--
-      if (depth === 0) { end = i; break }
-    }
-  }
-  if (end === -1) return []
-
-  try {
-    const parsed = JSON.parse(text.slice(start, end + 1)) as { links?: unknown }
-    if (!parsed || !Array.isArray(parsed.links)) return []
-    const result: LinkEntry[] = []
-    for (const item of parsed.links) {
-      if (
-        item &&
-        typeof item === "object" &&
-        typeof (item as LinkEntry).term === "string" &&
-        typeof (item as LinkEntry).target === "string" &&
-        (item as LinkEntry).term.length > 0 &&
-        (item as LinkEntry).target.length > 0
-      ) {
-        result.push({
-          term: (item as LinkEntry).term,
-          target: (item as LinkEntry).target,
-        })
-      }
-    }
-    return result
-  } catch {
-    return []
-  }
-}
-
-/**
- * For each {term, target}, replace the FIRST literal occurrence of `term`
- * in content (outside of frontmatter and existing [[...]]) with `[[target|term]]`
- * if the displayed text should differ from target, or `[[target]]` if they
- * already match case-insensitively. Skip terms that don't appear as a
- * literal substring. Skip terms already inside an existing wikilink.
- */
-function applyLinks(content: string, links: LinkEntry[]): string {
-  // Split off YAML frontmatter so we don't touch it
-  const fmEnd = content.startsWith("---\n") ? content.indexOf("\n---\n", 3) : -1
-  const frontmatter = fmEnd > 0 ? content.slice(0, fmEnd + 5) : ""
-  let body = fmEnd > 0 ? content.slice(fmEnd + 5) : content
-
-  // Track what we've already linked so we don't double-link
-  const linkedTargets = new Set<string>()
-
-  for (const { term, target } of links) {
-    if (linkedTargets.has(target.toLowerCase())) continue
-    if (!term || !target) continue
-
-    // Find first literal occurrence NOT already inside a [[...]] block
-    const idx = findUnlinkedOccurrence(body, term)
-    if (idx === -1) continue
-
-    const displayEqualsTarget = term.toLowerCase() === target.toLowerCase()
-    const replacement = displayEqualsTarget
-      ? `[[${term}]]`
-      : `[[${target}|${term}]]`
-    body = body.slice(0, idx) + replacement + body.slice(idx + term.length)
-    linkedTargets.add(target.toLowerCase())
-  }
-
-  return frontmatter + body
-}
-
-/** Find the first occurrence of `term` in text that isn't already wrapped in [[...]]. */
-function findUnlinkedOccurrence(text: string, term: string): number {
-  let searchFrom = 0
-  while (searchFrom < text.length) {
-    const idx = text.indexOf(term, searchFrom)
-    if (idx === -1) return -1
-    // Check a small window before for [[ (existing wikilink open)
-    const windowStart = Math.max(0, idx - 2)
-    const window = text.slice(windowStart, idx)
-    if (window.endsWith("[[")) {
-      searchFrom = idx + term.length
-      continue
-    }
-    return idx
-  }
-  return -1
-}

+ 0 - 61
src/lib/env-llm-defaults.ts

@@ -1,61 +0,0 @@
-import type { LlmConfig, ProviderConfigs } from "@/stores/wiki-store"
-import {
-  normalizeUserLlmContextSize,
-  normalizeUserLlmMaxOutputTokens,
-} from "@/lib/llm-context-size"
-
-const trimEnv = (value: unknown): string => {
-  return typeof value === "string" ? value.trim() : ""
-}
-
-const readContextSize = (): number => {
-  const raw = Number(trimEnv(import.meta.env.VITE_QMAI_LLM_CONTEXT_SIZE))
-  return normalizeUserLlmContextSize(raw)
-}
-
-const readMaxOutputTokens = (): number => {
-  const raw = Number(trimEnv(import.meta.env.VITE_QMAI_LLM_MAX_OUTPUT_TOKENS))
-  return normalizeUserLlmMaxOutputTokens(raw)
-}
-
-export function loadEnvLlmDefault(): {
-  config: LlmConfig
-  providerConfigs: ProviderConfigs
-  activePresetId: string
-} | null {
-  const apiKey = trimEnv(import.meta.env.VITE_QMAI_LLM_API_KEY)
-  const customEndpoint = trimEnv(import.meta.env.VITE_QMAI_LLM_ENDPOINT)
-  const model = trimEnv(import.meta.env.VITE_QMAI_LLM_MODEL)
-
-  if (!apiKey || !customEndpoint || !model) return null
-
-  const maxContextSize = readContextSize()
-  const maxOutputTokens = readMaxOutputTokens()
-  const config: LlmConfig = {
-    provider: "custom",
-    apiKey,
-    model,
-    ollamaUrl: "http://localhost:11434",
-    customEndpoint,
-    maxContextSize,
-    maxOutputTokens,
-    apiMode: "chat_completions",
-    reasoning: { mode: "auto" },
-  }
-
-  return {
-    config,
-    providerConfigs: {
-      custom: {
-        apiKey,
-        model,
-        baseUrl: customEndpoint,
-        apiMode: "chat_completions",
-        maxContextSize,
-        maxOutputTokens,
-        reasoning: { mode: "auto" },
-      },
-    },
-    activePresetId: "custom",
-  }
-}

+ 2 - 2
src/lib/export-center/export-service.spec.ts

@@ -125,8 +125,8 @@ it("默认二进制写入桥接在 Rust 后端实现并注册", () => {
   expect(fsSource).toContain("validate_export_base64_length")
   expect(fsSource).toContain("write_export_file_enforces_64_mib_boundary")
   expect(libSource).toContain("commands::fs::write_export_file")
-  // default-run is main.rs; registration only in lib.rs leaves the command dead in the real binary.
-  expect(mainSource).toContain("commands::fs::write_export_file")
+  expect(mainSource).toContain("qmai::run()")
+  expect(mainSource).not.toContain("commands::fs::write_export_file")
   const serviceSource = readFileSync(resolve(process.cwd(), "src/lib/export-center/export-service.ts"), "utf8")
   expect(serviceSource).toContain("contentsBase64: encodeExportBytesBase64(bytes)")
 })

+ 0 - 60
src/lib/greeting-detector.ts

@@ -1,60 +0,0 @@
-/**
- * Pure-rule detector for "nothing but a greeting" — lets the chat path
- * skip the wiki retrieval pipeline for casual openers like "hi", "你好",
- * "嗨" that have no information need. Cheap, deterministic, no LLM call.
- *
- * Matches are *whole-string* (after trim + trailing-punctuation strip),
- * not substring — so "hello, how do I train a transformer?" is NOT a
- * greeting. A hard length cap keeps pathological inputs out of the
- * regex loop and protects against false positives on long messages
- * that happen to start with "hi".
- */
-
-const MAX_GREETING_LEN = 20
-
-// Trailing punctuation / emoji-ish tails that users tack onto openers.
-// Leading-period not included on purpose — a message starting with "." is
-// almost never a greeting.
-const TRAILING_PUNCT = /[\s!!。.??~,,、;;::\u3002\uFF01\uFF1F]+$/u
-
-const GREETING_PATTERNS: RegExp[] = [
-  // ── English ──────────────────────────────────────────────────────
-  /^(hi|hello|hey|yo|sup|howdy|hiya|heya|hullo)( there| y'all| you| folks| everyone)?$/,
-  /^good (morning|afternoon|evening|day|night)$/,
-  /^(what'?s up|wassup|whaddup)$/,
-  /^greetings$/,
-
-  // ── Chinese (简体 + 繁體) ──────────────────────────────────────
-  // Standalone greetings with optional trailing filler particle.
-  /^(你好|您好|大家好|嗨|哈喽|哈啰|哈囉|哈罗|喂)[啊呀吖呢么呗哦哈]?$/,
-  // Time-of-day greetings.
-  /^(早|早啊|早安|早上好|中午好|下午好|晚上好|晚安)[啊呀吖呢么呗哦哈]?$/,
-  // "Are you there?" openers.
-  /^(在吗|在嗎|在不在|有人吗|有人嗎|有人在吗|有人在嗎)$/,
-
-  // ── Japanese ────────────────────────────────────────────────────
-  /^(こんにちは|こんばんは|おはよう|おはようございます|やあ|どうも|はじめまして)$/,
-
-  // ── Korean ──────────────────────────────────────────────────────
-  /^(안녕|안녕하세요|안녕하십니까)$/,
-
-  // ── European casual openers ─────────────────────────────────────
-  /^(hola|bonjour|salut|coucou|hallo|servus|hej|hejsan|ciao|saluton|ola|olá|privet|привет)$/,
-]
-
-export function isGreeting(text: string): boolean {
-  if (!text) return false
-
-  // Strip outer whitespace + trailing punctuation, lowercase for ASCII
-  // matching. CJK chars are unaffected by toLowerCase().
-  const normalized = text
-    .trim()
-    .replace(TRAILING_PUNCT, "")
-    .trim()
-    .toLowerCase()
-
-  if (!normalized) return false
-  if (normalized.length > MAX_GREETING_LEN) return false
-
-  return GREETING_PATTERNS.some((re) => re.test(normalized))
-}

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

@@ -10,7 +10,6 @@ describe("user-facing hardcoded chinese strings", () => {
     const graphView = read("../components/graph/graph-view.tsx")
     const lint = read("../lib/lint.ts")
     const lintLib = read("../components/lint/lint-view.tsx")
-    const outlineIngest = read("../lib/novel/outline-ingest.ts")
     const sourcesView = read("../components/sources/sources-view.tsx")
     const outlineActionToolbar = read("../components/sources/outline-action-toolbar.tsx")
     const appLayout = read("../components/layout/app-layout.tsx")
@@ -49,12 +48,6 @@ describe("user-facing hardcoded chinese strings", () => {
     expect(lint).not.toContain("target page not found")
     expect(lintLib).not.toContain("# Wiki Index\n")
 
-    expect(outlineIngest).not.toContain("大纲已生成完成")
-    expect(outlineIngest).not.toContain("已打开大纲")
-    expect(outlineIngest).not.toContain("正在摄取大纲...")
-    expect(outlineIngest).not.toContain("大纲摄取成功")
-    expect(outlineIngest).not.toContain("大纲摄取失败")
-
     expect(sourcesView).not.toContain("OUTLINE_SECTION_GENERATION_CONFIGS")
     expect(sourcesView).not.toContain("setOutlineDialogSectionKey")
     expect(sourcesView).not.toContain("novel.outlineGenerator.sectionButtons")

+ 0 - 159
src/lib/novel/book-analysis/workflow-extraction-engine.ts

@@ -1,159 +0,0 @@
-/**
- * 基于 Workflow 的角色提取引擎(并行版本)
- * 使用 Claude Code Workflow 并行处理多个章节
- */
-
-import type { LlmConfig } from "@/stores/wiki-store"
-import type { ExtractedCharacter, BookAnalysisMetadata } from "./types"
-import { readFile } from "@/commands/fs"
-import { joinPath } from "@/lib/path-utils"
-
-export interface WorkflowExtractionInput {
-  bookPath: string
-  selectedChapterIds: string[]
-  metadata: BookAnalysisMetadata
-  llmConfig: LlmConfig
-  onProgress?: (progress: {
-    stage: string
-    stageLabel: string
-    completed: number
-    total: number
-    percentage: number
-    currentItem?: string
-  }) => void
-  signal?: AbortSignal
-}
-
-export interface WorkflowExtractionResult {
-  success: boolean
-  characters: ExtractedCharacter[]
-}
-
-/**
- * 使用 Workflow 并行提取角色(推荐方式)
- */
-export async function extractCharactersWithWorkflow(
-  input: WorkflowExtractionInput
-): Promise<WorkflowExtractionResult> {
-  const {
-    bookPath,
-    selectedChapterIds,
-    onProgress,
-    signal,
-  } = input
-
-  try {
-    // 1. 加载章节内容
-    onProgress?.({
-      stage: "loading",
-      stageLabel: "加载章节内容",
-      completed: 0,
-      total: selectedChapterIds.length,
-      percentage: 0,
-    })
-
-    const chaptersDir = joinPath(bookPath, "chapters")
-    const chapters = []
-
-    for (let i = 0; i < selectedChapterIds.length; i++) {
-      if (signal?.aborted) {
-        throw new Error("用户取消分析")
-      }
-
-      const chapterId = selectedChapterIds[i]
-      const chapterPath = joinPath(chaptersDir, `${chapterId}.txt`)
-
-      try {
-        const content = await readFile(chapterPath)
-
-        // 从 ID 推断章节信息
-        const chapterOrder = i + 1
-        const chapterTitle = `第${chapterOrder}章`
-
-        if (content) {
-          chapters.push({
-            id: chapterId,
-            title: chapterTitle,
-            content: content.substring(0, 10000), // 限制长度避免 token 过多
-            order: chapterOrder,
-          })
-        }
-      } catch (err) {
-        console.error(`加载章节 ${chapterId} 失败:`, err)
-      }
-
-      onProgress?.({
-        stage: "loading",
-        stageLabel: "加载章节内容",
-        completed: i + 1,
-        total: selectedChapterIds.length,
-        percentage: ((i + 1) / selectedChapterIds.length) * 20, // 占20%进度
-        currentItem: `第${i + 1}章`,
-      })
-    }
-
-    if (chapters.length === 0) {
-      return {
-        success: false,
-        characters: [],
-      }
-    }
-
-    // 2. 调用 Workflow(这里暂时用注释,因为 Workflow 工具在前端不可用)
-    // 实际应该通过 Tauri 命令调用后端,后端再调用 Workflow
-    onProgress?.({
-      stage: "workflow",
-      stageLabel: "Workflow 并行分析",
-      completed: 0,
-      total: 100,
-      percentage: 20,
-      currentItem: "启动 Workflow...",
-    })
-
-    // TODO: 实际项目中应该:
-    // const result = await invoke("run_workflow", {
-    //   scriptPath: ".claude/workflows/book-analysis-parallel.js",
-    //   args: {
-    //     chapters,
-    //     bookMetadata: {
-    //       title: input.metadata.title,
-    //       author: input.metadata.author,
-    //     },
-    //     outputDir: bookPath,
-    //   }
-    // })
-
-    // 暂时返回模拟数据表示功能已实现
-    onProgress?.({
-      stage: "workflow",
-      stageLabel: "Workflow 执行完成",
-      completed: 100,
-      total: 100,
-      percentage: 100,
-      currentItem: "分析完成",
-    })
-
-    // 在实际实现中,这里应该返回 workflow 的结果
-    // 现在返回空结果,表示功能框架已就绪
-    return {
-      success: true,
-      characters: [],
-    }
-  } catch (error) {
-    console.error("Workflow 提取失败:", error)
-    return {
-      success: false,
-      characters: [],
-    }
-  }
-}
-
-/**
- * 检测是否支持 Workflow
- * 在实际项目中,应该检查是否在 Claude Code 环境中
- */
-export function isWorkflowSupported(): boolean {
-  // TODO: 实际检测逻辑
-  // 可以尝试调用一个测试命令来确认
-  return false // 暂时返回 false,等后端支持后改为 true
-}

+ 0 - 88
src/lib/novel/chapter-title-generator.ts

@@ -1,88 +0,0 @@
-import type { LlmConfig } from "@/stores/wiki-store"
-import { streamChat, type ChatMessage } from "@/lib/llm-client"
-
-/**
- * 使用 LLM 为章节生成标题。
- *
- * @param content 章节正文内容
- * @param chapterNumber 章节号
- * @param llmConfig LLM 配置(使用当前会话模型)
- * @param signal AbortSignal 用于取消请求
- * @returns 生成的完整标题,格式为 "第X章 标题文字"
- *
- * 设计说明:
- * - 使用当前会话模型,保证风格一致性
- * - 失败时抛出错误,由调用方决定降级策略(回退到"第X章")
- * - 温度设为 0.7,兼顾创意和稳定性
- */
-export async function generateChapterTitle(
-  content: string,
-  chapterNumber: number,
-  llmConfig: LlmConfig,
-  signal?: AbortSignal,
-): Promise<string> {
-  // 截取内容的前 3000 字符用于标题生成,避免 token 浪费
-  const contentPreview = content.length > 3000 ? content.slice(0, 3000) + "..." : content
-
-  const prompt = `请为下面的小说章节内容生成一个简洁有力的章节标题。
-
-要求:
-1. 只输出标题本身,不要任何解释、前缀、后缀或引号
-2. 标题长度控制在 4-12 个字之间
-3. 要能概括本章核心内容或关键冲突
-4. 要有文学性,符合小说风格
-5. 不要包含"第${chapterNumber}章"字样,我会自己加
-
-章节内容:
-${contentPreview}
-
-请直接输出标题:`
-
-  const messages: ChatMessage[] = [
-    {
-      role: "user",
-      content: prompt,
-    },
-  ]
-
-  const tokens: string[] = []
-  let streamError: Error | null = null
-
-  await streamChat(
-    llmConfig,
-    messages,
-    {
-      onToken: (t) => tokens.push(t),
-      onDone: () => {},
-      onError: (e) => {
-        streamError = e
-      },
-    },
-    signal,
-    {
-      temperature: 0.7,
-      max_tokens: 100,
-    },
-  )
-
-  if (streamError) {
-    throw streamError as Error
-  }
-
-  let title = tokens.join("").trim()
-
-  // 清理:去除可能的引号、书名号、前缀等
-  title = title.replace(/^["'《「“]+/, "").replace(/["'》」”]+$/, "")
-  title = title.replace(/^第\s*\d+\s*章[::\s]*/, "") // 去掉模型可能加的"第X章:"前缀
-  title = title.replace(/^标题[::\s]*/, "")
-  title = title.trim()
-
-  // 如果生成结果为空,回退到默认标题
-  if (!title) {
-    title = `第${chapterNumber}章`
-  } else {
-    title = `第${chapterNumber}章 ${title}`
-  }
-
-  return title
-}

+ 0 - 144
src/lib/novel/character-files.ts

@@ -1,144 +0,0 @@
-/**
- * 独立角色文件管理
- *
- * 支持 wiki/characters/ 目录,每个主要角色一个独立 .md 文件。
- * 角色文件模板包含:基本信息、外在表现、内在分析、语言风格、出场记录、别名。
- */
-import { createDirectory, fileExists, listDirectory, readFile, writeFile } from "@/commands/fs"
-import { normalizePath } from "@/lib/path-utils"
-import { makeSafeFileSlug } from "@/lib/wiki-filename"
-
-export const CHARACTERS_DIR = "wiki/characters"
-
-export interface CharacterFileData {
-  name: string
-  aliases: string[]
-  basicInfo: string
-  appearance: string
-  innerAnalysis: string
-  languageStyle: string
- 出场记录: string
-}
-
-export const CHARACTER_FILE_TEMPLATE = `---
-type: character
----
-
-# {name}
-
-## 基本信息
-
-{basicInfo}
-
-## 外在表现
-
-{appearance}
-
-## 内在分析
-
-{innerAnalysis}
-
-## 语言风格
-
-{languageStyle}
-
-## 出场记录
-
-{appearanceRecords}
-
-## 别名
-
-{aliases}
-`
-
-function buildCharacterFileContent(data: CharacterFileData): string {
-  const aliasesText = data.aliases.length > 0 ? data.aliases.join("、") : "暂无"
-  return CHARACTER_FILE_TEMPLATE
-    .replace("{name}", data.name)
-    .replace("{basicInfo}", data.basicInfo || "待补充")
-    .replace("{appearance}", data.appearance || "待补充")
-    .replace("{innerAnalysis}", data.innerAnalysis || "待补充")
-    .replace("{languageStyle}", data.languageStyle || "待补充")
-    .replace("{appearanceRecords}", data.出场记录 || "待补充")
-    .replace("{aliases}", aliasesText)
-}
-
-function charactersDir(projectPath: string): string {
-  return `${normalizePath(projectPath)}/${CHARACTERS_DIR}`
-}
-
-function characterFilePath(projectPath: string, name: string): string {
-  return `${charactersDir(projectPath)}/${makeSafeFileSlug(name)}.md`
-}
-
-/**
- * 创建角色文件。如果文件已存在,返回现有路径。
- */
-export async function createCharacterFile(
-  projectPath: string,
-  data: CharacterFileData,
-): Promise<string> {
-  const dir = charactersDir(projectPath)
-  await createDirectory(dir)
-  const filePath = characterFilePath(projectPath, data.name)
-  if (await fileExists(filePath)) return filePath
-  const content = buildCharacterFileContent(data)
-  await writeFile(filePath, content)
-  return filePath
-}
-
-/**
- * 读取角色文件内容。返回原始 markdown 文本。
- */
-export async function readCharacterFile(
-  projectPath: string,
-  characterName: string,
-): Promise<string> {
-  const filePath = characterFilePath(projectPath, characterName)
-  return await readFile(filePath)
-}
-
-/**
- * 更新角色文件内容。
- */
-export async function updateCharacterFile(
-  projectPath: string,
-  characterName: string,
-  content: string,
-): Promise<void> {
-  const filePath = characterFilePath(projectPath, characterName)
-  await writeFile(filePath, content)
-}
-
-/**
- * 列出所有角色文件名(不含路径和扩展名)。
- */
-export async function listCharacterFiles(projectPath: string): Promise<string[]> {
-  const dir = charactersDir(projectPath)
-  try {
-    const nodes = await listDirectory(dir)
-    return nodes
-      .filter((node) => !node.is_dir && node.name.endsWith(".md"))
-      .map((node) => node.name.replace(/\.md$/, ""))
-      .sort((a, b) => a.localeCompare(b, "zh-CN"))
-  } catch {
-    return []
-  }
-}
-
-/**
- * 检查角色文件是否存在。
- */
-export async function characterFileExists(
-  projectPath: string,
-  characterName: string,
-): Promise<boolean> {
-  return fileExists(characterFilePath(projectPath, characterName))
-}
-
-/**
- * 获取角色文件路径。
- */
-export function getCharacterFilePath(projectPath: string, characterName: string): string {
-  return characterFilePath(projectPath, characterName)
-}

+ 0 - 4
src/lib/novel/entry-projection.ts

@@ -1,4 +0,0 @@
-import type { FactRecord } from "./fact-model";
-
-export const projectVerifiedEntries = (facts: FactRecord[]): FactRecord[] =>
-  facts.filter((fact) => fact.canon_status === "verified");

+ 0 - 144
src/lib/novel/export.ts

@@ -1,144 +0,0 @@
-import { readFile, writeFile, listDirectory, createDirectory } from "@/commands/fs"
-import { normalizePath } from "@/lib/path-utils"
-import { parseFrontmatter } from "@/lib/frontmatter"
-import { listSnapshots, loadSnapshot } from "./chapter-ingest"
-import { loadCharacterStates } from "./character-state"
-import { loadForeshadowingTracker } from "./foreshadowing-tracker"
-import { loadCognitionState } from "./character-cognition"
-import type { FileNode } from "@/types/wiki"
-
-export interface ExportOptions {
-  projectPath: string
-  exportPath: string
-  includeChapters?: boolean
-  includeSnapshots?: boolean
-  includeMeta?: boolean
-}
-
-export interface ExportResult {
-  success: boolean
-  exportedPath: string
-  chapterCount: number
-  message: string
-}
-
-function flattenMdFiles(nodes: FileNode[]): Array<{ name: string; path: string }> {
-  const out: Array<{ name: string; path: string }> = []
-  for (const node of nodes) {
-    if (node.is_dir) {
-      if (node.children) out.push(...flattenMdFiles(node.children))
-      continue
-    }
-    if (node.name.endsWith(".md")) out.push({ name: node.name, path: node.path })
-  }
-  return out
-}
-
-export async function exportProject(options: ExportOptions): Promise<ExportResult> {
-  const pp = normalizePath(options.projectPath)
-  const {
-    exportPath,
-    includeChapters = true,
-    includeSnapshots = true,
-    includeMeta = true,
-  } = options
-
-  try {
-    await createDirectory(exportPath)
-    let chapterCount = 0
-
-    if (includeChapters) {
-      const chaptersDir = `${pp}/wiki/chapters`
-      let files: { name: string; path: string }[] = []
-      try {
-        const tree = await listDirectory(chaptersDir)
-        files = flattenMdFiles(tree)
-      } catch {
-        files = []
-      }
-
-      const chapters: { num: number; title: string; content: string }[] = []
-
-      for (const file of files) {
-        try {
-          const raw = await readFile(file.path)
-          const parsed = parseFrontmatter(raw)
-          const fm = parsed.frontmatter as Record<string, unknown> | null
-          const status = fm?.chapter_status as string | undefined
-          if (status && status !== "final") continue
-          const num = typeof fm?.chapter_number === "number" ? fm.chapter_number as number : 0
-          const title = (typeof fm?.title === "string" ? fm.title : file.name.replace(/\.md$/, "")) as string
-          const body = parsed.body.trim()
-          chapters.push({ num, title, content: body })
-          chapterCount++
-        } catch {
-          // skip unreadable files
-        }
-      }
-
-      chapters.sort((a, b) => a.num - b.num)
-
-      const mergedContent = chapters
-        .map(c => `# ${c.title}\n\n${c.content}`)
-        .join("\n\n---\n\n")
-      await writeFile(`${exportPath}/complete-novel.md`, mergedContent)
-    }
-
-    if (includeSnapshots) {
-      const snapshotsDir = `${exportPath}/snapshots`
-      await createDirectory(snapshotsDir)
-      try {
-        const nums = await listSnapshots(pp)
-        for (const num of nums) {
-          const snap = await loadSnapshot(pp, num)
-          if (snap) {
-            await writeFile(
-              `${snapshotsDir}/${String(num).padStart(3, "0")}.snapshot.json`,
-              JSON.stringify(snap, null, 2),
-            )
-          }
-        }
-      } catch {
-        // snapshots optional
-      }
-    }
-
-    if (includeMeta) {
-      const metaDir = `${exportPath}/meta`
-      await createDirectory(metaDir)
-
-      try {
-        const chars = await loadCharacterStates(pp)
-        await writeFile(`${metaDir}/character-states.json`, JSON.stringify(chars, null, 2))
-      } catch {
-        // optional
-      }
-
-      try {
-        const foreshadows = await loadForeshadowingTracker(pp)
-        await writeFile(`${metaDir}/foreshadowing-tracker.json`, JSON.stringify(foreshadows, null, 2))
-      } catch {
-        // optional
-      }
-
-      try {
-        const cognition = await loadCognitionState(pp)
-        if (cognition) {
-          await writeFile(`${metaDir}/cognition-state.json`, JSON.stringify(cognition, null, 2))
-        }
-      } catch {
-        // optional
-      }
-    }
-
-    return {
-      success: true,
-      exportedPath: exportPath,
-      chapterCount,
-      message: `导出完成:${chapterCount} 个章节`,
-    }
-  } catch (error) {
-    const message = error instanceof Error ? error.message : String(error)
-    return { success: false, exportedPath: exportPath, chapterCount: 0, message }
-  }
-}

+ 0 - 29
src/lib/novel/fact-model.ts

@@ -1,29 +0,0 @@
-export type CanonStatus = "candidate" | "verified" | "rejected" | "pending_review";
-
-export interface FactRecord {
-  fact_id: string;
-  fact_type: string;
-  subject_id: string;
-  predicate: string;
-  object_id_or_value: string;
-  time_scope: string;
-  chapter_ref: string;
-  evidence_anchor: string;
-  confidence: number;
-  canon_status: CanonStatus;
-}
-
-export const createFactRecord = (partial: Partial<FactRecord>): FactRecord => ({
-  fact_id: partial.fact_id ?? "fact:test",
-  fact_type: partial.fact_type ?? "event",
-  subject_id: partial.subject_id ?? "entity:test",
-  predicate: partial.predicate ?? "does",
-  object_id_or_value: partial.object_id_or_value ?? "value:test",
-  time_scope: partial.time_scope ?? "chapter",
-  chapter_ref: partial.chapter_ref ?? "ch-1@v1",
-  evidence_anchor: partial.evidence_anchor ?? "p1:s1",
-  confidence: partial.confidence ?? 0.8,
-  canon_status: partial.canon_status ?? "candidate",
-});
-
-export const canPromoteFactToGraph = (fact: FactRecord): boolean => fact.canon_status === "verified";

+ 0 - 30
src/lib/novel/fact-store.ts

@@ -1,30 +0,0 @@
-import type { CanonStatus } from "./fact-model";
-
-export interface FactEvent {
-  fact_id: string;
-  from: CanonStatus;
-  to: CanonStatus;
-  fact_batch_id: string;
-  at?: number;
-}
-
-export interface FactStore {
-  append: (event: FactEvent) => void;
-  timeline: (factId: string) => FactEvent[];
-  batch: (factBatchId: string) => FactEvent[];
-}
-
-export const createInMemoryFactStore = (): FactStore => {
-  const events: FactEvent[] = [];
-  return {
-    append(event) {
-      events.push({ ...event, at: event.at ?? Date.now() });
-    },
-    timeline(factId) {
-      return events.filter((event) => event.fact_id === factId);
-    },
-    batch(factBatchId) {
-      return events.filter((event) => event.fact_batch_id === factBatchId);
-    },
-  };
-};

+ 0 - 20
src/lib/novel/graph-projection.ts

@@ -1,20 +0,0 @@
-import type { FactRecord } from "./fact-model";
-
-export interface ActiveGraphEdge {
-  fact_id: string;
-  source: string;
-  relation: string;
-  target: string;
-  active: true;
-}
-
-export const projectActiveEdges = (facts: FactRecord[]): ActiveGraphEdge[] =>
-  facts
-    .filter((fact) => fact.canon_status === "verified")
-    .map((fact) => ({
-      fact_id: fact.fact_id,
-      source: fact.subject_id,
-      relation: fact.predicate,
-      target: fact.object_id_or_value,
-      active: true,
-    }));

+ 0 - 129
src/lib/novel/lint.ts

@@ -1,129 +0,0 @@
-import { DEFAULT_LLM_REQUEST_TIMEOUT_MS, streamChat, type StreamCallbacks } from "@/lib/llm-client"
-import i18n from "@/i18n"
-import type { ChatMessage } from "@/lib/llm-providers"
-import { useWikiStore } from "@/stores/wiki-store"
-import { getOutputLanguage, buildLanguageReminder } from "@/lib/output-language"
-import { contextPackToPrompt, buildContextPack, type ContextPack } from "./context-engine"
-import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "./chapter-excerpts"
-import { resolveNovelModel } from "./model-resolver"
-import { hasUsableLlm } from "@/lib/has-usable-llm"
-
-export interface NovelLintResult {
-  severity: "error" | "warning" | "info"
-  type: string
-  message: string
-  evidence: string
-  relatedMemory: string
-  suggestion: string
-}
-
-const NOVEL_LINT_DIMENSIONS = [
-  "是否违背总大纲",
-  "是否违背分卷大纲",
-  "是否违背章节目标",
-  "本章必须完成项是否已完成",
-  "本章避免违背项是否存在违背",
-  "下一章推进建议是否被忽略或反向推进",
-  "是否人设崩坏",
-  "是否人物动机不一致",
-  "是否时间线错误",
-  "是否地点错误",
-  "是否能力体系崩坏",
-  "是否伏笔遗忘",
-  "是否提前泄露秘密",
-  "是否角色知道了不该知道的信息",
-  "是否新增未登记设定",
-  "是否剧情水文",
-  "是否缺少章节钩子",
-]
-
-export function buildNovelLintPrompt(
-  pack: ContextPack,
-  chapterContent: string,
-  maxContextSize?: number,
-): string {
-  return `${contextPackToPrompt(pack, undefined, { maxContextSize })}
-
-${i18n.t("novel.lint.lintInstruction", { defaultValue: "请对以下章节进行连贯性检查,逐一核对以下维度:" })}
-${NOVEL_LINT_DIMENSIONS.map((key, i) => `${i + 1}. ${key}`).join("\n")}
-
-${i18n.t("novel.lint.lintOutputFormat", { defaultValue: "请严格按照 JSON 数组格式输出检查结果。每个问题包含以下字段:severity(error/warning/info)、type(问题类型)、message(问题描述)、evidence(正文证据)、relatedMemory(相关记忆引用)、suggestion(修改建议)。如果没有发现问题,输出空数组 []。不要输出任何其他内容。" })}
-
-${i18n.t("novel.lint.chapterContent", { defaultValue: "章节正文:" })}
-${chapterContent.slice(0, CHAPTER_BODY_EXCERPT_MAX_CHARS)}`
-}
-
-export async function runNovelLint(
-  projectPath: string,
-  chapterContent: string,
-  chapterNumber?: number,
-): Promise<NovelLintResult[]> {
-  const state = useWikiStore.getState()
-  const llmConfig = resolveNovelModel(
-    state.llmConfig,
-    state.novelConfig,
-    "lint",
-  )
-  if (!hasUsableLlm(llmConfig, state.providerConfigs)) return []
-
-  const novelMode = state.novelMode
-  if (!novelMode) return []
-
-  const contextPack = await buildContextPack(
-    projectPath,
-    `连贯性检查第${chapterNumber || "?"}章`,
-    chapterNumber,
-  )
-
-  const outputLang = getOutputLanguage()
-  const langReminder = buildLanguageReminder(outputLang)
-
-  const systemPrompt = `你是一个专业的小说连贯性检查编辑。你的任务是逐一检查章节是否存在连贯性、人设、时间线、伏笔等方面的问题。
-请严格按照 JSON 数组格式输出检查结果,不要输出任何其他内容。
-${langReminder}`
-
-  const userPrompt = buildNovelLintPrompt(contextPack, chapterContent, llmConfig.maxContextSize)
-
-  try {
-    const messages: ChatMessage[] = [
-      { role: "system", content: systemPrompt },
-      { role: "user", content: userPrompt },
-    ]
-
-    let result = ""
-    const callbacks: StreamCallbacks = {
-      onToken: (token: string) => {
-        result += token
-      },
-      onDone: () => {},
-      onError: (error: Error) => {
-        console.error("[Novel Lint] Stream error:", error)
-      },
-    }
-
-    await streamChat(llmConfig, messages, callbacks, AbortSignal.timeout(DEFAULT_LLM_REQUEST_TIMEOUT_MS))
-
-    const jsonMatch = result.match(/\[[\s\S]*\]/)
-    if (!jsonMatch) return []
-
-    const parsed = JSON.parse(jsonMatch[0])
-    if (!Array.isArray(parsed)) return []
-
-    return parsed.map((item: Record<string, unknown>) => ({
-      severity: validateLintSeverity(item.severity),
-      type: String(item.type || "unknown"),
-      message: String(item.message || ""),
-      evidence: String(item.evidence || ""),
-      relatedMemory: String(item.relatedMemory || ""),
-      suggestion: String(item.suggestion || ""),
-    }))
-  } catch (err) {
-    console.error("[Novel Lint] Failed:", err)
-    return []
-  }
-}
-
-function validateLintSeverity(value: unknown): "error" | "warning" | "info" {
-  if (value === "error" || value === "warning" || value === "info") return value
-  return "warning"
-}

+ 0 - 41
src/lib/novel/mod.ts

@@ -1,41 +0,0 @@
-export { useNovelLabel, useNovelMode } from "./ui-labels"
-export { parseChapterMeta, isChapterPage, isOutlinePage, type ChapterMeta, type ChapterStatus, type OutlineType } from "./chapter-meta"
-export { parseVolumeMeta, isVolumePage, getChapterVolumes, type VolumeMeta } from "./volume"
-export { createChapterPipeline, type ChapterPipeline, type ChapterPipelineDeps } from "./chapter-pipeline"
-export { buildContextPack, contextPackToPrompt, type ContextPack } from "./context-engine"
-export { ingestChapter, ingestChapterPipeline, ingestOutline, loadSnapshot, listSnapshots, deleteChapterSnapshots, finalizeProjectMemoryRebuild, syncSnapshotToMemory, type ChapterSnapshot, type CharacterDetail, type LocationDetail, type OrganizationDetail, type ItemDetail, type EventDetail, type IngestResult, type IngestFailReason, type OutlineIngestResult, type IngestOutlineOptions, type SyncSnapshotToMemoryOptions } from "./chapter-ingest"
-export { reviewChapter, type NovelReviewResult } from "./review-adapter"
-export { runNovelLint, buildNovelLintPrompt, type NovelLintResult } from "./lint"
-export { resolveNovelModel, resolveAgentSessionModel, type NovelTaskType } from "./model-resolver"
-export { resolveReviewModel } from "./review-model"
-export { novelMixedSearch, searchPlot, type NovelSearchParams, type NovelSearchResult } from "./search-adapter"
-export { PROMPTS } from "./prompt-templates"
-export { snapshotToGraphNodes, snapshotToGraphEdges, writeSnapshotToWiki, writePatchFieldsToWiki, detectNodeType, NOVEL_NODE_TYPE_LABELS, NOVEL_RELATION_LABELS, type NovelGraphNode, type NovelGraphEdge, type NovelNodeType } from "./graph-adapter"
-export { emptyCognitionState, mergeCognitionFromSnapshot, loadCognitionState, saveCognitionState, cognitionToContextText, type CharacterCognition, type CognitionState } from "./character-cognition"
-export { getNextChapterNumber, resolveTargetChapterNumberForChat, extractChapterNumber, flattenMdFiles, type ResolveTargetChapterNumberForChatInput } from "./chapter-utils"
-export {
-  createEmptyCharacterStateStore,
-  saveCharacterStates,
-  loadCharacterStates,
-  characterStatesToContextText,
-  type CharacterState,
-  type CharacterStateStore,
-} from "./character-state"
-export {
-  createEmptyForeshadowingStore,
-  saveForeshadowingTracker,
-  loadForeshadowingTracker,
-  foreshadowingToContextText,
-  type Foreshadowing,
-  type ForeshadowingStore,
-} from "./foreshadowing-tracker"
-export { exportProject, type ExportOptions, type ExportResult } from "./export"
-export { routeTask, buildTaskDirective, type NovelTaskIntent, type TaskRouteResult } from "./task-router"
-export { createDefaultNovelProjectMeta, saveNovelProjectMeta, loadNovelProjectMeta, updateNovelProjectStats, type NovelProjectMeta } from "./project-meta"
-export { buildDeAiSystemPrompt, buildDeAiRewriteMessages, injectDeAiDirective, loadCustomDeAiSkill } from "./de-ai-adapter"
-export { analyzePreviousChapters, readPreviousChapterBodies, type PreviousChapterAnalysis } from "./previous-chapters-analysis"
-export { rebuildAllSnapshots, rebuildVectorIndex, type RebuildProgress, type RebuildProgressCallback } from "./rebuild"
-export { runFactCheck, verifyFactCheckLlm, type FactCheckResult, type FactCheckReport, type FactCheckOptions } from "./fact-snapshot"
-export { scoreReviewResults, CALIBRATED_DIMENSION_WEIGHTS, CALIBRATED_SEVERITY_DEDUCTION, type DimensionScore, type ReviewScoreReport, type ReviewScoringOptions } from "./review-scoring"
-export { readSoulDoc, writeSoulDoc, SOUL_DOC_FILENAME } from "./soul-doc"
-export { analyzeForeshadowingDebt, type ForeshadowingDebtItem, type ForeshadowingDebtReport, type ForeshadowingDebtOptions } from "./foreshadowing-debt"

+ 0 - 142
src/lib/novel/outline-ingest.ts

@@ -1,142 +0,0 @@
-import { fileExists, listDirectory } from "@/commands/fs"
-import i18n from "@/i18n"
-import { normalizePath } from "@/lib/path-utils"
-import { refreshProjectState } from "@/lib/project-refresh"
-import { useImportProgressStore } from "@/stores/import-progress-store"
-import { useOutlineGenerationStore } from "@/stores/outline-generation-store"
-import { ingestOutline } from "./chapter-ingest"
-
-export function createOutlineIngestTask(projectPath: string, outlinePath: string): string {
-  return useOutlineGenerationStore.getState().createTask({
-    projectPath: normalizePath(projectPath),
-    kind: "ingest",
-    outlinePath: normalizePath(outlinePath),
-    status: "ingesting",
-    message: i18n.t("novel.outlineGenerator.ingestingNotification"),
-    error: null,
-  })
-}
-
-export function startOutlineIngestTask(projectPath: string, outlinePath: string): string {
-  const taskId = createOutlineIngestTask(projectPath, outlinePath)
-  void runOutlineIngestTask(taskId)
-  return taskId
-}
-
-function collectOutlineMarkdownPaths(
-  nodes: Array<{ path: string; name: string; is_dir: boolean; children?: Array<{ path: string; name: string; is_dir: boolean; children?: unknown[] }> }>,
-): string[] {
-  const paths: string[] = []
-  for (const node of nodes) {
-    if (node.is_dir && node.children) {
-      paths.push(...collectOutlineMarkdownPaths(node.children as Array<{ path: string; name: string; is_dir: boolean; children?: Array<{ path: string; name: string; is_dir: boolean; children?: unknown[] }> }>))
-      continue
-    }
-    if (!node.is_dir && node.name.endsWith(".md")) {
-      paths.push(normalizePath(node.path))
-    }
-  }
-  return paths
-}
-
-export async function runBulkOutlineIngest(projectPath: string): Promise<{
-  total: number
-  succeeded: number
-  failed: number
-}> {
-  const pp = normalizePath(projectPath)
-  let outlinePaths: string[] = []
-
-  try {
-    const tree = await listDirectory(`${pp}/wiki/outlines`)
-    outlinePaths = collectOutlineMarkdownPaths(tree as Array<{ path: string; name: string; is_dir: boolean; children?: Array<{ path: string; name: string; is_dir: boolean; children?: unknown[] }> }>)
-      .sort((a, b) => a.localeCompare(b, "zh-CN"))
-  } catch {
-    return { total: 0, succeeded: 0, failed: 0 }
-  }
-
-  let succeeded = 0
-  let failed = 0
-
-  for (const outlinePath of outlinePaths) {
-    const taskId = createOutlineIngestTask(pp, outlinePath)
-    await runOutlineIngestTask(taskId)
-    const task = useOutlineGenerationStore.getState().tasks.find((item) => item.id === taskId)
-    if (task?.status === "done") succeeded += 1
-    else failed += 1
-  }
-
-  return {
-    total: outlinePaths.length,
-    succeeded,
-    failed,
-  }
-}
-
-export async function runOutlineIngestTask(taskId: string): Promise<void> {
-  const task = useOutlineGenerationStore.getState().tasks.find((item) => item.id === taskId)
-  if (!task?.outlinePath) return
-
-  const outlineFileName = task.outlinePath.split("/").pop()?.replace(".md", "") || "大纲"
-  const abortController = new AbortController()
-  const progressTaskId = useImportProgressStore.getState().startTask({
-    projectPath: task.projectPath,
-    kind: "outline",
-    total: 1,
-    currentTitle: outlineFileName,
-    message: "正在提取大纲记忆",
-    abortController,
-  })
-
-  try {
-    useOutlineGenerationStore.getState().updateTask(taskId, {
-      status: "ingesting",
-      message: i18n.t("novel.outlineGenerator.ingestingNotification"),
-      error: null,
-    })
-    const snapshot = await ingestOutline(task.projectPath, task.outlinePath, abortController.signal)
-    if (snapshot) {
-      await refreshProjectState(task.projectPath)
-    }
-    useOutlineGenerationStore.getState().updateTask(taskId, {
-      status: snapshot ? "done" : "error",
-      message: snapshot
-        ? i18n.t("novel.outlineGenerator.ingestSuccessNotification")
-        : i18n.t("novel.outlineGenerator.ingestFailedNotification"),
-      error: snapshot ? null : i18n.t("novel.outlineGenerator.ingestFailedNotification"),
-    })
-    useImportProgressStore.getState().finishTask(progressTaskId, snapshot ? "done" : "error", {
-      completed: snapshot ? 1 : 0,
-      total: 1,
-      currentTitle: "",
-      message: snapshot ? `${outlineFileName} 提取完成` : `${outlineFileName} 提取失败`,
-    })
-  } catch (err) {
-    const message = err instanceof Error ? err.message : String(err)
-    useOutlineGenerationStore.getState().updateTask(taskId, {
-      status: "error",
-      message,
-      error: message,
-    })
-    useImportProgressStore.getState().finishTask(progressTaskId, "error", {
-      completed: 0,
-      total: 1,
-      currentTitle: "",
-      message: `${outlineFileName} 提取失败`,
-    })
-  }
-}
-
-export async function hasIngestedOutlineSnapshot(projectPath: string, outlinePath: string): Promise<boolean> {
-  const normalizedOutlinePath = normalizePath(outlinePath)
-  const fileName = normalizedOutlinePath.split("/").pop() ?? "outline"
-  const outlineName = fileName.replace(/\.\w+$/, "")
-  let hash = 0
-  for (let i = 0; i < outlineName.length; i++) {
-    hash = ((hash << 5) - hash + outlineName.charCodeAt(i)) | 0
-  }
-  const outlineNum = -(Math.abs(hash % 999) + 1)
-  const prefix = `outline-${String(Math.abs(outlineNum)).padStart(3, "0")}`
-  const jsonPath = `${normalizePath(projectPath)}/.novel/snapshots/${prefix}.snapshot.json`
-  return fileExists(jsonPath).catch(() => false)
-}

+ 0 - 1
src/lib/novel/outline-pure-markdown-writers.spec.ts

@@ -6,7 +6,6 @@ const writerFiles = [
   "../../components/layout/sidebar-panel.tsx",
   "../../components/novel/outline-editor.tsx",
   "outline-import.ts",
-  "source-outline-import.ts",
 ]
 
 describe("所有大纲创建入口使用纯 Markdown", () => {

+ 0 - 126
src/lib/novel/prompt-templates.ts

@@ -1,126 +0,0 @@
-import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "./chapter-excerpts"
-
-export const PROMPTS = {
-  chapterGeneration: (contextPack: string, chapterGoal: string) =>
-    [
-      contextPack,
-      "",
-      "请根据以上上下文包和大纲,撰写以下章节:",
-      chapterGoal,
-      "",
-      "要求:",
-      "1. 严格遵循大纲中的情节走向",
-      "2. 保持人物性格和说话方式一致",
-      "3. 不要泄露角色尚不知道的信息",
-      "4. 注意伏笔的埋设和回收",
-      "5. 章节结尾要有钩子,吸引读者继续阅读",
-      "6. 字数控制在 3000-5000 字",
-    ].join("\n"),
-
-  chapterContinuation: (contextPack: string, lastParagraph: string) =>
-    [
-      contextPack,
-      "",
-      "上一段内容:",
-      lastParagraph,
-      "",
-      "请续写以下内容,保持风格和节奏一致:",
-    ].join("\n"),
-
-  chapterRevision: (contextPack: string, originalContent: string, revisionNotes: string) =>
-    [
-      contextPack,
-      "",
-      "原始章节内容:",
-      originalContent.slice(0, 6000),
-      "",
-      "修改要求:",
-      revisionNotes,
-      "",
-      "请根据修改要求重写章节,保持其他部分不变:",
-    ].join("\n"),
-
-  consistencyCheck: (contextPack: string, chapterContent: string) =>
-    [
-      contextPack,
-      "",
-      "请检查以下章节的连贯性:",
-      chapterContent.slice(0, CHAPTER_BODY_EXCERPT_MAX_CHARS),
-      "",
-      "检查维度:",
-      "1. 人设一致性",
-      "2. 时间线准确性",
-      "3. 伏笔状态",
-      "4. 角色认知合理性",
-      "5. 设定一致性",
-    ].join("\n"),
-
-  OUTLINE_METHODOLOGY_VOLUME: [
-    "## 卷纲方法论",
-    "",
-    "### 八节点结构",
-    "每卷必须标注以下四个关键节点:",
-    "- 1/4 节点:第一个重要转折",
-    "- 中点:本卷中点关键事件",
-    "- 3/4 节点:推进至最终高潮的关键转折",
-    "- 高潮节点:本卷最高潮",
-    "",
-    "### 章节定位分类",
-    "每章需标注定位标签:开场 / 发展 / 铺垫 / 转折 / 高潮 / 收束",
-    "",
-    "### 情绪弧线设计原则",
-    "采用W形折线:起点→下降→回升→再降→最终回升",
-    "",
-    "### 爽点节律公式",
-    "每章需标注章节区间、爽点类型、具体内容和预期效果",
-  ].join("\n"),
-
-  OUTLINE_METHODOLOGY_CHAPTER: [
-    "## 细纲方法论",
-    "",
-    "### 五段式结构",
-    "每章细纲必须按五段组织:起因 → 发展 → 转折 → 高潮 → 结尾",
-    "",
-    "### 多线安排",
-    "需标注主线推进、辅线推进、事件线/任务线、感情线/关系线、逻辑线",
-    "",
-    "### 情节点功能标签",
-    "每个情节点需标注功能标签和代价/收益兑现",
-    "",
-    "### 情绪变化标注",
-    "标注起点→终点情绪变化路径",
-    "",
-    "### 钩子类型",
-    "章首钩子类型:悬念对话开局 / 闪前碎片 / 倒计时开局 / 神秘独白 / 反差场景 / 未完成动作开局 / 意象预示",
-    "章尾钩子类型:突然揭示 / 紧急危机 / 未完成动作 / 身份反转 / 两难抉择 / 神秘物品/线索 / 倒计时 / 承诺/威胁 / 离奇消失 / 隐藏含义 / 意象钩子 / 回声钩子 / 留白钩子",
-  ].join("\n"),
-
-  TRACKING_AWARENESS: [
-    "## 伏笔与状态追踪要求",
-    "",
-    "1. 伏笔管理:明确标注伏笔的埋设、推进和回收节点,避免伏笔悬空",
-    "2. 角色状态变化:角色能力、认知、关系、位置的变化必须可追踪,前后一致",
-    "3. 信息差管理:不同角色之间的信息差必须明确标注,并控制信息释放节奏",
-  ].join("\n"),
-
-  WRITING_STYLE_TEMPLATE: [
-    "## 文风设定",
-    "",
-    "以下为作品的文风偏好设定,请在撰写时严格遵循:",
-    "",
-    "### 句式偏好",
-    "{sentencePreference}",
-    "",
-    "### 用词习惯",
-    "{wordHabits}",
-    "",
-    "### 对话风格",
-    "{dialogueStyle}",
-    "",
-    "### 节奏特点",
-    "{rhythmFeatures}",
-    "",
-      "### 禁忌表达",
-    "{tabooExpressions}",
-  ].join("\n"),
-} as const

+ 0 - 182
src/lib/novel/rebuild.ts

@@ -1,182 +0,0 @@
-/**
- * 索引和图谱重建模块
- * 支持一键重建所有章节快照、图谱节点和搜索索引
- */
-
-import { readFile, listDirectory } from "@/commands/fs"
-import { normalizePath } from "@/lib/path-utils"
-import { parseFrontmatter } from "@/lib/frontmatter"
-import { isChapterPage, isFinalChapter } from "./chapter-meta"
-import { ingestChapter } from "./chapter-ingest"
-import { useWikiStore } from "@/stores/wiki-store"
-import type { FileNode } from "@/types/wiki"
-
-export interface RebuildProgress {
-  total: number
-  completed: number
-  current: string
-  errors: string[]
-}
-
-export type RebuildProgressCallback = (progress: RebuildProgress) => void
-
-function flattenMdFiles(nodes: FileNode[]): Array<{ name: string; path: string }> {
-  const out: Array<{ name: string; path: string }> = []
-  for (const node of nodes) {
-    if (node.is_dir) {
-      if (node.children) out.push(...flattenMdFiles(node.children))
-      continue
-    }
-    if (node.name.endsWith(".md")) out.push({ name: node.name, path: node.path })
-  }
-  return out
-}
-
-/**
- * 重建所有正式章节的快照和图谱
- * 会按章节号顺序依次重新摄取
- */
-export async function rebuildAllSnapshots(
-  projectPath: string,
-  onProgress?: RebuildProgressCallback,
-): Promise<{ success: number; failed: number; errors: string[] }> {
-  const pp = normalizePath(projectPath)
-  const novelMode = useWikiStore.getState().novelMode
-  if (!novelMode) {
-    return { success: 0, failed: 0, errors: ["小说模式未开启"] }
-  }
-
-  // 找到所有章节文件
-  const chaptersDir = `${pp}/wiki/chapters`
-  let files: { name: string; path: string }[] = []
-  try {
-    const tree = await listDirectory(chaptersDir)
-    files = flattenMdFiles(tree)
-  } catch {
-    // 尝试从 wiki 根目录搜索
-    try {
-      const tree = await listDirectory(`${pp}/wiki`)
-      files = flattenMdFiles(tree)
-    } catch {
-      return { success: 0, failed: 0, errors: ["无法读取章节目录"] }
-    }
-  }
-
-  // 筛选正式章节并排序
-  const chapters: { path: string; chapterNumber: number }[] = []
-  for (const file of files) {
-    try {
-      const content = await readFile(file.path)
-      const parsed = parseFrontmatter(content)
-      const fm = parsed.frontmatter as Record<string, unknown> | null
-      if (!fm) continue
-      if (!isChapterPage(fm)) continue
-      if (!isFinalChapter(fm)) continue
-      const num = typeof fm.chapter_number === "number" ? fm.chapter_number : 0
-      chapters.push({ path: file.path, chapterNumber: num })
-    } catch {
-      // skip unreadable
-    }
-  }
-
-  chapters.sort((a, b) => a.chapterNumber - b.chapterNumber)
-
-  const errors: string[] = []
-  let success = 0
-  let failed = 0
-
-  for (let i = 0; i < chapters.length; i++) {
-    const chapter = chapters[i]
-    onProgress?.({
-      total: chapters.length,
-      completed: i,
-      current: `第${chapter.chapterNumber}章`,
-      errors,
-    })
-
-    try {
-      const result = await ingestChapter(pp, chapter.path)
-      if (result.snapshot) {
-        success++
-      } else {
-        failed++
-        const reason = result.failReason === "invalid_chapter_number" ? "章节编号无效" : result.failReason === "no_llm" ? "LLM 未配置" : "摄取返回空结果"
-        errors.push(`第${chapter.chapterNumber}章:${reason}`)
-      }
-    } catch (err) {
-      failed++
-      const msg = err instanceof Error ? err.message : String(err)
-      errors.push(`第${chapter.chapterNumber}章:${msg}`)
-    }
-  }
-
-  onProgress?.({
-    total: chapters.length,
-    completed: chapters.length,
-    current: "完成",
-    errors,
-  })
-
-  return { success, failed, errors }
-}
-
-/**
- * 重建向量索引
- * 重新嵌入所有 wiki 页面
- */
-export async function rebuildVectorIndex(
-  projectPath: string,
-  onProgress?: RebuildProgressCallback,
-): Promise<{ indexed: number; errors: string[] }> {
-  const pp = normalizePath(projectPath)
-  const embCfg = useWikiStore.getState().embeddingConfig
-
-  if (!embCfg.enabled || !embCfg.model) {
-    return { indexed: 0, errors: ["向量嵌入未启用或未配置模型"] }
-  }
-
-  const wikiDir = `${pp}/wiki`
-  let files: { name: string; path: string }[] = []
-  try {
-    const tree = await listDirectory(wikiDir)
-    files = flattenMdFiles(tree)
-  } catch {
-    return { indexed: 0, errors: ["无法读取 wiki 目录"] }
-  }
-
-  const errors: string[] = []
-  let indexed = 0
-
-  const { embedPage } = await import("@/lib/embedding")
-
-  for (let i = 0; i < files.length; i++) {
-    const file = files[i]
-    onProgress?.({
-      total: files.length,
-      completed: i,
-      current: file.name,
-      errors,
-    })
-
-    try {
-      const content = await readFile(file.path)
-      const pageId = file.name.replace(/\.md$/, "")
-      const titleMatch = content.match(/^#\s+(.+)/m)
-      const title = titleMatch?.[1]?.trim() ?? pageId
-      await embedPage(pp, pageId, title, content, embCfg)
-      indexed++
-    } catch (err) {
-      const msg = err instanceof Error ? err.message : String(err)
-      errors.push(`${file.name}:${msg}`)
-    }
-  }
-
-  onProgress?.({
-    total: files.length,
-    completed: files.length,
-    current: "完成",
-    errors,
-  })
-
-  return { indexed, errors }
-}

+ 0 - 104
src/lib/novel/source-outline-import.ts

@@ -1,104 +0,0 @@
-import { createDirectory, fileExists, readFile, writeFile } from "@/commands/fs"
-import { getFileName, getRelativePath, normalizePath } from "@/lib/path-utils"
-import { makeSafeFileSlug } from "@/lib/wiki-filename"
-import { buildPureOutlineMarkdown } from "./outline-markdown"
-
-export type SourceOutlineImportTarget =
-  | "story-outline"
-  | "chapter-outline"
-  | "character-briefs"
-  | "locations"
-  | "organizations"
-  | "power-system"
-  | "golden-finger"
-  | "background-setting"
-  | "geography-setting"
-  | "foreshadowing-plan"
-
-interface SourceOutlineImportConfig {
-  id: SourceOutlineImportTarget
-  title: string
-  folderName: string
-  outlineType?: "story-outline" | "chapter-outline" | "volume-outline"
-  category: string
-}
-
-export const SOURCE_OUTLINE_IMPORT_TARGETS: SourceOutlineImportConfig[] = [
-  { id: "story-outline", title: "总大纲", folderName: "总大纲", outlineType: "story-outline", category: "story" },
-  { id: "chapter-outline", title: "章节细纲", folderName: "章节细纲", outlineType: "chapter-outline", category: "chapter" },
-  { id: "character-briefs", title: "人物小传", folderName: "人物小传", category: "characters" },
-  { id: "locations", title: "地点设定", folderName: "地点设定", category: "locations" },
-  { id: "organizations", title: "势力设定", folderName: "势力设定", category: "organizations" },
-  { id: "power-system", title: "力量体系", folderName: "力量体系", category: "power-system" },
-  { id: "golden-finger", title: "金手指设定", folderName: "金手指设定", category: "golden-finger" },
-  { id: "background-setting", title: "背景设定", folderName: "背景设定", category: "background-setting" },
-  { id: "geography-setting", title: "地理设定", folderName: "地理设定", category: "geography-setting" },
-  { id: "foreshadowing-plan", title: "伏笔计划", folderName: "伏笔计划", category: "foreshadowing" },
-]
-
-function getTargetConfig(target: SourceOutlineImportTarget): SourceOutlineImportConfig {
-  const config = SOURCE_OUTLINE_IMPORT_TARGETS.find((item) => item.id === target)
-  if (!config) throw new Error(`Unknown outline import target: ${target}`)
-  return config
-}
-
-function stripExtension(fileName: string): string {
-  return fileName.replace(/\.[^.]+$/, "")
-}
-
-async function getUniqueOutlinePath(dir: string, fileName: string): Promise<string> {
-  const firstPath = `${dir}/${fileName}`
-  if (!(await fileExists(firstPath))) return firstPath
-
-  const extensionIndex = fileName.lastIndexOf(".")
-  const stem = extensionIndex > 0 ? fileName.slice(0, extensionIndex) : fileName
-  const extension = extensionIndex > 0 ? fileName.slice(extensionIndex) : ""
-  for (let index = 2; index <= 99; index += 1) {
-    const candidate = `${dir}/${stem}-${index}${extension}`
-    if (!(await fileExists(candidate))) return candidate
-  }
-
-  return `${dir}/${stem}-${Date.now()}${extension}`
-}
-
-function buildSourceSection(sourcePath: string, relativeSourcePath: string, sourceContent: string): string {
-  const sourceName = getFileName(sourcePath) || relativeSourcePath
-  return [
-    `## 来源:${sourceName}`,
-    "",
-    `> 原始来源:${relativeSourcePath}`,
-    "",
-    sourceContent.trim(),
-    "",
-  ].join("\n")
-}
-
-function buildOutlinePage(
-  pageTitle: string,
-  section: string,
-): string {
-  return buildPureOutlineMarkdown(pageTitle, section)
-}
-
-export async function addSourceToOutlineCategory(
-  projectPath: string,
-  sourcePath: string,
-  target: SourceOutlineImportTarget,
-): Promise<string> {
-  const pp = normalizePath(projectPath)
-  const normalizedSourcePath = normalizePath(sourcePath)
-  const config = getTargetConfig(target)
-  const outlinesDir = `${pp}/wiki/outlines`
-  const targetDir = `${outlinesDir}/${config.folderName}`
-  const relativeSourcePath = getRelativePath(normalizedSourcePath, pp)
-  const sourceContent = await readFile(normalizedSourcePath)
-  const sourceName = getFileName(normalizedSourcePath) || relativeSourcePath
-  const pageTitle = stripExtension(sourceName)
-  const outlinePath = await getUniqueOutlinePath(targetDir, `${makeSafeFileSlug(pageTitle)}.md`)
-  const section = buildSourceSection(normalizedSourcePath, relativeSourcePath, sourceContent)
-
-  await createDirectory(outlinesDir)
-  await createDirectory(targetDir)
-  await writeFile(outlinePath, buildOutlinePage(pageTitle, section))
-  return outlinePath
-}

+ 0 - 236
src/lib/novel/story-simulation/simulation-modes/decision-tree.ts

@@ -1,236 +0,0 @@
-import { runSimulation, type SimulationCallbacks } from "../simulation-engine"
-import type { ChatMessage } from "@/lib/llm-client"
-import { streamChat } from "@/lib/llm-client"
-import type { LlmConfig } from "@/stores/wiki-store"
-import type {
-  ExtractionResult,
-  NovelAgent,
-  SimulationEvent,
-  SimulationInput,
-  StoryFramework,
-} from "../types"
-
-// 每个分支最多推演的节点数,用于限制深度、控制 token 消耗。
-const MAX_BRANCH_NODES = 2
-// 为关键角色生成的决策选项数量。
-const DECISION_OPTION_COUNT = 3
-
-interface DecisionOption {
-  title: string
-  description: string
-}
-
-// 将 streamChat 的流式回调收拢为一个完整字符串(与 simulation-engine 内部实现一致)。
-async function collectStream(
-  config: LlmConfig,
-  messages: ChatMessage[],
-  signal?: AbortSignal,
-): Promise<string> {
-  let result = ""
-  let streamError: Error | null = null
-
-  await streamChat(
-    config,
-    messages,
-    {
-      onToken: (token) => {
-        result += token
-      },
-      onDone: () => {},
-      onError: (err) => {
-        streamError = err
-      },
-    },
-    signal,
-  )
-
-  if (streamError) throw streamError
-  return result
-}
-
-// 从 LLM 文本中解析 JSON 数组形式的决策选项,失败返回 null。
-function parseDecisionOptions(raw: string): DecisionOption[] | null {
-  const trimmed = raw.trim()
-
-  const tryParse = (text: string): DecisionOption[] | null => {
-    try {
-      const parsed = JSON.parse(text) as unknown
-      if (!Array.isArray(parsed)) return null
-      const options: DecisionOption[] = []
-      for (const item of parsed) {
-        if (item && typeof item === "object") {
-          const obj = item as Record<string, unknown>
-          const title = obj.title !== undefined ? String(obj.title) : ""
-          const description =
-            obj.description !== undefined ? String(obj.description) : ""
-          if (title || description) options.push({ title, description })
-        } else if (typeof item === "string" && item.trim()) {
-          options.push({ title: item.trim(), description: item.trim() })
-        }
-      }
-      return options.length > 0 ? options : null
-    } catch {
-      return null
-    }
-  }
-
-  // 直接解析
-  const direct = tryParse(trimmed)
-  if (direct) return direct
-
-  // 从 markdown 代码块中提取
-  const codeBlockMatch = /```(?:json)?\s*([\s\S]*?)```/.exec(trimmed)
-  if (codeBlockMatch) {
-    const fromBlock = tryParse(codeBlockMatch[1].trim())
-    if (fromBlock) return fromBlock
-  }
-
-  // 从文本中查找第一个 JSON 数组
-  const arrayMatch = /\[[\s\S]*\]/.exec(trimmed)
-  if (arrayMatch) {
-    const fromArray = tryParse(arrayMatch[0])
-    if (fromArray) return fromArray
-  }
-
-  return null
-}
-
-// 选取关键角色:优先取第一个节点涉及的 agent,否则取第一个 agent。
-function pickKeyAgent(input: SimulationInput): NovelAgent | undefined {
-  const firstNode = input.framework.nodes[0]
-  if (firstNode) {
-    const involved = input.agents.find((a) =>
-      firstNode.involvedCharacters.includes(a.name),
-    )
-    if (involved) return involved
-  }
-  return input.agents[0]
-}
-
-// 构建生成决策选项的 LLM 消息。
-function buildDecisionMessages(
-  input: SimulationInput,
-  extraction: ExtractionResult,
-): ChatMessage[] {
-  const agent = pickKeyAgent(input)
-  const framework = input.framework
-  const firstNode = framework.nodes[0]
-
-  const system = [
-    "你是一位小说剧情推演助手。",
-    "你的任务是为关键角色生成几个不同方向的决策选项,用于分支推演。",
-    "只输出一个 JSON 数组,数组中每个元素是一个对象:",
-    '  { "title": "决策标题(简短)", "description": "决策的具体内容与动机" }',
-    `请生成 ${DECISION_OPTION_COUNT} 个选项,每个选项代表一种截然不同的行动方向。`,
-    "不要输出任何其他文字。",
-  ].join("\n")
-
-  const user = [
-    `故事前提:${framework.premise}`,
-    firstNode ? `当前节点:${firstNode.title}(核心冲突:${firstNode.coreConflict})` : "",
-    `关键角色:${agent?.name ?? "主角"}`,
-    agent ? `角色设定:${agent.profile}` : "",
-    `世界规则:${extraction.worldRules || "(无)"}`,
-    "",
-    `请为「${agent?.name ?? "主角"}」生成 ${DECISION_OPTION_COUNT} 个决策选项。`,
-  ]
-    .filter((line) => line !== undefined && line !== "")
-    .join("\n")
-
-  return [
-    { role: "system", content: system },
-    { role: "user", content: user },
-  ]
-}
-
-// 限制框架节点数,控制每个分支的推演深度。
-function trimFramework(
-  framework: StoryFramework,
-  maxNodes: number,
-): StoryFramework {
-  if (framework.nodes.length <= maxNodes) return framework
-  return {
-    ...framework,
-    nodes: framework.nodes.slice(0, maxNodes),
-  }
-}
-
-export async function runDecisionTreeSimulation(
-  input: SimulationInput,
-  extraction: ExtractionResult,
-  callbacks: SimulationCallbacks,
-  signal?: AbortSignal,
-): Promise<SimulationEvent[]> {
-  const allEvents: SimulationEvent[] = []
-
-  // 1. 为关键角色生成决策选项
-  let options: DecisionOption[] | null = null
-  try {
-    const messages = buildDecisionMessages(input, extraction)
-    const raw = await collectStream(input.llmConfig, messages, signal)
-    if (signal?.aborted) return allEvents
-    options = parseDecisionOptions(raw)
-  } catch {
-    options = null
-  }
-
-  // 无法生成决策选项时,回退到普通 runSimulation
-  if (!options || options.length === 0) {
-    callbacks.onProgress(0, "无法生成决策选项,回退到普通推演")
-    return runSimulation(
-      { ...input, mode: "decision-tree" },
-      extraction,
-      callbacks,
-      signal,
-    )
-  }
-
-  const trimmedFramework = trimFramework(input.framework, MAX_BRANCH_NODES)
-  const branchCount = options.length
-
-  // 2. 对每个决策选项推演一条分支
-  for (let i = 0; i < branchCount; i++) {
-    if (signal?.aborted) break
-
-    const option = options[i]
-    const injectionEvent = `【决策分支 ${i + 1}】${option.title}:${option.description}`
-
-    callbacks.onProgress(
-      Math.round((i / branchCount) * 100),
-      `推演决策分支 ${i + 1}/${branchCount}:${option.title}`,
-    )
-
-    // 子分支内部事件不转发给外部回调,避免输出过多
-    const branchCallbacks: SimulationCallbacks = {
-      onEvent: () => {},
-      onProgress: () => {},
-      onComplete: () => {},
-      onError: () => {},
-    }
-
-    try {
-      const branchInput: SimulationInput = {
-        ...input,
-        mode: "decision-tree",
-        framework: trimmedFramework,
-        injectionEvent,
-      }
-      const branchEvents = await runSimulation(
-        branchInput,
-        extraction,
-        branchCallbacks,
-        signal,
-      )
-      allEvents.push(...branchEvents)
-    } catch {
-      // 单个分支失败不影响其他分支的推演
-    }
-  }
-
-  if (!signal?.aborted) {
-    callbacks.onProgress(100, "决策树推演完成")
-    callbacks.onComplete(allEvents)
-  }
-
-  return allEvents
-}

+ 0 - 11
src/lib/novel/story-simulation/simulation-modes/event-driven.ts

@@ -1,11 +0,0 @@
-import { runSimulation, type SimulationCallbacks } from "../simulation-engine"
-import type { SimulationInput, ExtractionResult, SimulationEvent } from "../types"
-
-export async function runEventDrivenSimulation(
-  input: SimulationInput,
-  extraction: ExtractionResult,
-  callbacks: SimulationCallbacks,
-  signal?: AbortSignal,
-): Promise<SimulationEvent[]> {
-  return runSimulation({ ...input, mode: "event-driven" }, extraction, callbacks, signal)
-}

+ 0 - 11
src/lib/novel/story-simulation/simulation-modes/free-emergence.ts

@@ -1,11 +0,0 @@
-import { runSimulation, type SimulationCallbacks } from "../simulation-engine"
-import type { SimulationInput, ExtractionResult, SimulationEvent } from "../types"
-
-export async function runFreeEmergenceSimulation(
-  input: SimulationInput,
-  extraction: ExtractionResult,
-  callbacks: SimulationCallbacks,
-  signal?: AbortSignal,
-): Promise<SimulationEvent[]> {
-  return runSimulation({ ...input, mode: "free-emergence", injectionEvent: undefined }, extraction, callbacks, signal)
-}

+ 0 - 11
src/lib/novel/story-simulation/simulation-modes/hybrid.ts

@@ -1,11 +0,0 @@
-import { runSimulation, type SimulationCallbacks } from "../simulation-engine"
-import type { SimulationInput, ExtractionResult, SimulationEvent } from "../types"
-
-export async function runHybridSimulation(
-  input: SimulationInput,
-  extraction: ExtractionResult,
-  callbacks: SimulationCallbacks,
-  signal?: AbortSignal,
-): Promise<SimulationEvent[]> {
-  return runSimulation({ ...input, mode: "hybrid" }, extraction, callbacks, signal)
-}

+ 0 - 12
src/lib/novel/ui-labels.ts

@@ -1,12 +0,0 @@
-import { useTranslation } from "react-i18next"
-import { useWikiStore } from "@/stores/wiki-store"
-
-export function useNovelLabel(originalKey: string, novelKey: string): string {
-  const { t } = useTranslation()
-  const novelMode = useWikiStore((s) => s.novelMode)
-  return novelMode ? t(novelKey) : t(originalKey)
-}
-
-export function useNovelMode(): boolean {
-  return useWikiStore((s) => s.novelMode)
-}

+ 0 - 85
src/lib/novel/volume.ts

@@ -1,85 +0,0 @@
-import { searchWiki } from "@/lib/search"
-import { readFile } from "@/commands/fs"
-import { normalizePath } from "@/lib/path-utils"
-
-export interface VolumeMeta {
-  volumeNumber: number
-  title: string
-  summary: string
-  chapterRangeStart: number | undefined
-  chapterRangeEnd: number | undefined
-}
-
-export function parseVolumeMeta(fm: Record<string, unknown>): VolumeMeta | null {
-  const rawVolumeNumber = fm.volume_number
-  const volumeNumber = typeof rawVolumeNumber === "string" ? Number(rawVolumeNumber) : typeof rawVolumeNumber === "number" ? rawVolumeNumber : null
-  if (volumeNumber === null || !Number.isFinite(volumeNumber) || volumeNumber <= 0) return null
-
-  return {
-    volumeNumber,
-    title: typeof fm.title === "string" ? fm.title : `第${volumeNumber}卷`,
-    summary: typeof fm.summary === "string" ? fm.summary : "",
-    chapterRangeStart: typeof fm.chapter_range_start === "number" ? fm.chapter_range_start
-      : typeof fm.chapter_range_start === "string" ? Number(fm.chapter_range_start) : undefined,
-    chapterRangeEnd: typeof fm.chapter_range_end === "number" ? fm.chapter_range_end
-      : typeof fm.chapter_range_end === "string" ? Number(fm.chapter_range_end) : undefined,
-  }
-}
-
-export function isVolumePage(fm: Record<string, unknown>): boolean {
-  if (fm.type === "volume") return true
-  if (fm.outline_type === "volume-outline") return true
-  if (typeof fm.volume_number === "number" || typeof fm.volume_number === "string") {
-    return true
-  }
-  return false
-}
-
-export async function getChapterVolumes(
-  projectPath: string,
-  chapterNumber: number,
-): Promise<VolumeMeta[]> {
-  const pp = normalizePath(projectPath)
-  const results: VolumeMeta[] = []
-  try {
-    const searchResults = await searchWiki(pp, "volume 第 卷 chapter_range")
-    for (const r of searchResults) {
-      try {
-        const content = await readFile(r.path)
-        const fm = parseFrontmatterFromMarkdown(content)
-        if (!fm) continue
-        const meta = parseVolumeMeta(fm)
-        if (!meta) continue
-        if (
-          meta.chapterRangeStart !== undefined &&
-          meta.chapterRangeEnd !== undefined &&
-          chapterNumber >= meta.chapterRangeStart &&
-          chapterNumber <= meta.chapterRangeEnd
-        ) {
-          results.push(meta)
-        }
-      } catch {}
-    }
-  } catch {}
-  return results
-}
-
-function parseFrontmatterFromMarkdown(content: string): Record<string, unknown> | null {
-  const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
-  if (!fmMatch) return null
-
-  const fmRaw = fmMatch[1]
-  const result: Record<string, unknown> = {}
-
-  const scalarMatches = fmRaw.matchAll(/^(\w[\w_]*):\s*["']?([^"'\n]+)["']?\s*$/gm)
-  for (const m of scalarMatches) {
-    const key = m[1]
-    const value = m[2].trim()
-    if (value === "true") result[key] = true
-    else if (value === "false") result[key] = false
-    else if (/^\d+$/.test(value)) result[key] = Number(value)
-    else result[key] = value
-  }
-
-  return result
-}

+ 0 - 41
src/lib/output-language-options.ts

@@ -1,41 +0,0 @@
-/**
- * Shared list of selectable output-language values for any UI that
- * needs to ask the user "what language should the AI generate in?".
- *
- * Currently consumed by:
- *   - Settings → Output: post-create change of the preference
- *   - Create-Project dialog: required choice at project creation
- *     time so a fresh project never starts in the implicit
- *     "auto-detect from whatever text we see first" mode
- *
- * `value` strings are the exact tokens the rest of the codebase
- * compares against (`OutputLanguage` type in stores/wiki-store.ts);
- * keep them in sync. Labels mix the native script + the English
- * name so a user who can't read the native form still recognizes
- * the language.
- */
-export const OUTPUT_LANGUAGE_OPTIONS = [
-  { value: "auto", label: "Auto (detect from input/source)" },
-  { value: "English", label: "English" },
-  { value: "Chinese", label: "简体中文 (Simplified Chinese)" },
-  { value: "Traditional Chinese", label: "繁體中文 (Traditional Chinese)" },
-  { value: "Japanese", label: "日本語 (Japanese)" },
-  { value: "Korean", label: "한국어 (Korean)" },
-  { value: "Vietnamese", label: "Tiếng Việt (Vietnamese)" },
-  { value: "French", label: "Français (French)" },
-  { value: "German", label: "Deutsch (German)" },
-  { value: "Spanish", label: "Español (Spanish)" },
-  { value: "Portuguese", label: "Português (Portuguese)" },
-  { value: "Italian", label: "Italiano (Italian)" },
-  { value: "Russian", label: "Русский (Russian)" },
-  { value: "Arabic", label: "العربية (Arabic)" },
-  { value: "Persian", label: "فارسی (Persian / Farsi)" },
-  { value: "Hindi", label: "हिन्दी (Hindi)" },
-  { value: "Turkish", label: "Türkçe (Turkish)" },
-  { value: "Dutch", label: "Nederlands (Dutch)" },
-  { value: "Polish", label: "Polski (Polish)" },
-  { value: "Swedish", label: "Svenska (Swedish)" },
-  { value: "Indonesian", label: "Bahasa Indonesia (Indonesian)" },
-  { value: "Thai", label: "ไทย (Thai)" },
-  { value: "Ukrainian", label: "Українська (Ukrainian)" },
-] as const

+ 0 - 35
src/lib/reveal-in-file-manager.ts

@@ -1,35 +0,0 @@
-/**
- * 在系统文件管理器中显示文件
- * - Tauri: 用 opener 插件的 revealItemInDir
- * - 浏览器: 退化为在浏览器里打开文件链接(多数情况不可用,但不会硬崩)
- */
-
-import { isTauri } from "@/lib/platform"
-
-export async function revealInFileManager(filePath: string): Promise<void> {
-  if (!filePath) return
-  if (isTauri()) {
-    try {
-      const opener = await import("@tauri-apps/plugin-opener")
-      const reveal = (opener as { revealItemInDir?: (path: string) => Promise<void> })
-        .revealItemInDir
-      if (typeof reveal === "function") {
-        await reveal(filePath)
-        return
-      }
-      const openPath = (opener as { openPath?: (path: string) => Promise<void> }).openPath
-      if (typeof openPath === "function") {
-        await openPath(filePath)
-        return
-      }
-    } catch (error) {
-      console.warn("[revealInFileManager] Tauri opener 失败", error)
-    }
-  }
-  // 兜底:浏览器里打开(多数 OS 不会真的显示)
-  try {
-    window.open(`file://${filePath.replace(/\\/g, "/")}`, "_blank", "noopener,noreferrer")
-  } catch (error) {
-    console.warn("[revealInFileManager] 浏览器兜底失败", error)
-  }
-}

+ 0 - 81
src/lib/search-window.ts

@@ -1,81 +0,0 @@
-import { isTauri } from "@/lib/platform"
-
-export const SEARCH_WINDOW_LABEL = "search"
-export const SEARCH_CONTEXT_EVENT = "qmai://search-window-context"
-export const SEARCH_OPEN_FILE_EVENT = "qmai://search-open-file"
-
-export interface SearchWindowContext {
-  projectId: string
-  projectName: string
-  projectPath: string
-  novelMode: boolean
-}
-
-export interface SearchOpenFilePayload {
-  path: string
-  scrollImageSrc?: string | null
-}
-
-export function getSearchWindowContextFromLocation(): SearchWindowContext | null {
-  if (typeof window === "undefined") return null
-  const raw = new URLSearchParams(window.location.search).get("searchContext")
-  if (!raw) return null
-  try {
-    const parsed = JSON.parse(decodeURIComponent(raw)) as Partial<SearchWindowContext>
-    if (
-      typeof parsed.projectId !== "string" ||
-      typeof parsed.projectName !== "string" ||
-      typeof parsed.projectPath !== "string" ||
-      typeof parsed.novelMode !== "boolean"
-    ) {
-      return null
-    }
-    return {
-      projectId: parsed.projectId,
-      projectName: parsed.projectName,
-      projectPath: parsed.projectPath,
-      novelMode: parsed.novelMode,
-    }
-  } catch {
-    return null
-  }
-}
-
-export async function openSearchWindow(context: SearchWindowContext): Promise<boolean> {
-  if (!isTauri()) return false
-
-  const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow")
-
-  const existing = await WebviewWindow.getByLabel(SEARCH_WINDOW_LABEL)
-  if (existing) {
-    await existing.emit(SEARCH_CONTEXT_EVENT, context)
-    await existing.setFocus()
-    await existing.show()
-    return true
-  }
-
-  const searchContext = encodeURIComponent(JSON.stringify(context))
-  const url = `/?searchContext=${searchContext}`
-  const searchWindow = new WebviewWindow(SEARCH_WINDOW_LABEL, {
-    title: "搜索",
-    url,
-    width: 760,
-    height: 860,
-    minWidth: 560,
-    minHeight: 640,
-    center: true,
-    resizable: true,
-    focus: true,
-  })
-
-  searchWindow.once("tauri://created", async () => {
-    await searchWindow.emit(SEARCH_CONTEXT_EVENT, context)
-  }).catch(() => {})
-
-  searchWindow.once("tauri://error", (event) => {
-    console.error("创建搜索窗口失败:", event.payload)
-  }).catch(() => {})
-
-  return true
-}
-

+ 0 - 58
src/lib/source-delete-decision.ts

@@ -1,58 +0,0 @@
-/**
- * Decide what to do with a wiki page when the user deletes a source
- * document. This is a pure function so the branching is unit-testable
- * in isolation — the previous inline version in sources-view.tsx was
- * entangled with React and silently harboured a data-loss bug (pages
- * whose sole source was NOT the one being deleted got wiped anyway,
- * because findRelatedWikiPages had returned them via a loose
- * substring match).
- */
-
-export type DeleteDecision =
-  /** Keep the page on disk; rewrite its sources to the returned list. */
-  | { action: "keep"; updatedSources: string[] }
-  /** Delete the page — the deleting source was its sole contributor. */
-  | { action: "delete" }
-  /** Leave the page alone entirely — it ended up in findRelatedWikiPages'
-   *  results by accident (loose frontmatter substring match) but its
-   *  sources list doesn't actually include the deleting source. */
-  | { action: "skip"; reason: string }
-
-/**
- * Decide whether a page should be kept, deleted, or skipped in response
- * to the user removing `deletingSource`. Case-insensitive throughout so
- * "Test.md" and "test.md" are treated as the same source.
- *
- *   - If `deletingSource` is NOT in `frontmatterSources` → skip.
- *     Guards against findRelatedWikiPages' loose match taking out
- *     innocent pages.
- *   - If it IS and there are OTHER sources too → keep, return the
- *     filtered list so caller can rewrite the frontmatter.
- *   - If it IS and it's the ONLY source → delete.
- */
-export function decidePageFate(
-  frontmatterSources: readonly string[],
-  deletingSource: string,
-): DeleteDecision {
-  const targetLower = deletingSource.toLowerCase()
-
-  const inList = frontmatterSources.some(
-    (s) => s.toLowerCase() === targetLower,
-  )
-  if (!inList) {
-    return {
-      action: "skip",
-      reason: `page sources do not include "${deletingSource}"`,
-    }
-  }
-
-  const survivors = frontmatterSources.filter(
-    (s) => s.toLowerCase() !== targetLower,
-  )
-
-  if (survivors.length > 0) {
-    return { action: "keep", updatedSources: survivors }
-  }
-
-  return { action: "delete" }
-}

+ 0 - 13
src/lib/user-memory/index.ts

@@ -1,13 +0,0 @@
-export * from "./types"
-export * from "./store"
-export * from "./extractor"
-export * from "./selector"
-export * from "./compiler"
-export * from "./learning-service"
-export * from "./request-integration"
-export * from "./prefilter"
-export * from "./learning-budget"
-export * from "./governance"
-export * from "./decision-trace"
-export * from "./feedback-service"
-export * from "./maintenance"

+ 0 - 19
src/types/saved-model.ts

@@ -1,19 +0,0 @@
-/**
- * 已保存的模型配置项
- */
-export interface SavedModel {
-  /** 唯一ID */
-  id: string
-  /** 显示名称 */
-  name: string
-  /** 模型ID */
-  model: string
-  /** API密钥(可选,可以复用供应商的密钥) */
-  apiKey?: string
-  /** 自定义接口地址(可选,可以复用供应商的地址) */
-  customEndpoint?: string
-  /** 备注说明 */
-  description?: string
-  /** 创建时间 */
-  createdAt: number
-}