Browse Source

Merge pull request #80 from Mochocyang/codex/dead-code-cleanup

refactor(cleanup): 清理无用代码与依赖
darknessomi 1 month ago
parent
commit
10aa578753
100 changed files with 113 additions and 5073 deletions
  1. 1 1
      .github/workflows/ci.yml
  2. 20 94
      package-lock.json
  3. 1 3
      package.json
  4. 1 212
      src-tauri/src/main.rs
  5. 2 23
      src/commands/file-sync.ts
  6. 1 20
      src/commands/fs.ts
  7. 0 36
      src/components/chat/chat-input-resize.spec.ts
  8. 0 161
      src/components/chat/chat-input-resize.ts
  9. 0 377
      src/components/chat/chat-input.resize-interaction.spec.tsx
  10. 0 401
      src/components/chat/chat-input.tsx
  11. 1 1
      src/components/chat/chat-model-selector.tsx
  12. 0 6
      src/components/chat/chat-panel.tsx
  13. 1 1
      src/components/chat/chat-resume.ts
  14. 1 5
      src/components/chat/chat-shared.ts
  15. 1 1
      src/components/chat/context-usage-ring.tsx
  16. 0 149
      src/components/chat/intent-clarification-dialog.tsx
  17. 1 1
      src/components/chat/stream-session.ts
  18. 1 1
      src/components/chat/tool-call-timeline.tsx
  19. 1 1
      src/components/common/ai-change-compare-panel.tsx
  20. 0 147
      src/components/common/ai-change-review.spec.tsx
  21. 0 200
      src/components/common/ai-change-review.tsx
  22. 2 2
      src/components/common/context-hub-details.tsx
  23. 2 2
      src/components/common/context-hub-stats-summary.tsx
  24. 1 1
      src/components/common/conversation-run-status-icon.tsx
  25. 1 8
      src/components/common/timeline-types.ts
  26. 1 1
      src/components/editor/textarea-find-bar.tsx
  27. 1 1
      src/components/editor/textarea-find-highlights.tsx
  28. 0 123
      src/components/layout/file-tree.tsx
  29. 0 6
      src/components/layout/preview-panel.tsx
  30. 0 4
      src/components/novel/book-analysis-import-task-panel.tsx
  31. 1 1
      src/components/novel/book-analysis-module-view.tsx
  32. 1 1
      src/components/novel/character-selection-panel.tsx
  33. 8 0
      src/components/novel/de-ai-batch-review-dialog.spec.tsx
  34. 1 1
      src/components/novel/de-ai-batch-review-dialog.tsx
  35. 1 1
      src/components/novel/hooks/use-character-extraction.ts
  36. 1 1
      src/components/novel/hooks/use-character-recognition.ts
  37. 1 1
      src/components/novel/hooks/use-library-operations.ts
  38. 0 41
      src/components/project/template-picker.tsx
  39. 0 75
      src/components/search/search-window-view.tsx
  40. 1 1
      src/components/settings/context-size-selector.tsx
  41. 2 2
      src/components/settings/hooks/use-batch-model-test.ts
  42. 0 32
      src/components/settings/llm-presets.ts
  43. 1 1
      src/components/settings/model-select-input.tsx
  44. 1 1
      src/components/settings/output-tokens-selector.tsx
  45. 0 30
      src/components/settings/sections/about-section.tsx
  46. 2 1
      src/components/settings/sections/custom-provider-cards.persist.spec.tsx
  47. 1 1
      src/components/settings/sections/custom-provider-cards.tsx
  48. 0 286
      src/components/settings/sections/multimodal-section.tsx
  49. 0 76
      src/components/settings/sections/output-section.tsx
  50. 2 2
      src/components/skill-library/de-ai-skill-errors.ts
  51. 1 1
      src/components/skill-library/de-ai-skill-picker.tsx
  52. 0 132
      src/components/skill-library/unified-skill-model.spec.ts
  53. 0 169
      src/components/skill-library/unified-skill-model.ts
  54. 0 1
      src/components/sources/outline-chat-panel.spec.tsx
  55. 0 48
      src/components/sources/outline-chat-panel.tsx
  56. 0 434
      src/components/sources/outline-file-tree-panel.spec.tsx
  57. 0 395
      src/components/sources/outline-file-tree-panel.tsx
  58. 0 340
      src/components/sources/outline-file-tree.spec.tsx
  59. 0 618
      src/components/sources/outline-file-tree.tsx
  60. 0 3
      src/components/sources/outline-workbench-integration.spec.ts
  61. 0 87
      src/components/sources/outline-workflow-stages.tsx
  62. 1 1
      src/components/ui/button.tsx
  63. 0 12
      src/components/ui/dialog.tsx
  64. 1 1
      src/components/ui/scroll-area.tsx
  65. 0 23
      src/components/ui/separator.tsx
  66. 0 46
      src/hooks/use-batched-value.ts
  67. 6 6
      src/lib/agent/activity-trace.ts
  68. 2 2
      src/lib/agent/ai-chat-session.ts
  69. 1 1
      src/lib/agent/capabilities/registry.ts
  70. 1 1
      src/lib/agent/capabilities/selector.ts
  71. 2 2
      src/lib/agent/config.ts
  72. 1 21
      src/lib/agent/context-trace.ts
  73. 2 2
      src/lib/agent/novel-pre-plugin-chain.ts
  74. 1 1
      src/lib/agent/pipeline.ts
  75. 1 1
      src/lib/agent/plugins/build-system-prompt-plugin.ts
  76. 2 2
      src/lib/agent/plugins/confidence-gate-plugin.ts
  77. 1 1
      src/lib/agent/plugins/post-write-check-ai.ts
  78. 1 1
      src/lib/agent/plugins/post-write-check-plugin.ts
  79. 1 1
      src/lib/agent/plugins/resolve-chapter-plugin.ts
  80. 1 1
      src/lib/agent/plugins/route-task-plugin.ts
  81. 1 1
      src/lib/agent/plugins/soul-dialog-plugin.ts
  82. 1 1
      src/lib/agent/plugins/trim-context-plugin.ts
  83. 1 1
      src/lib/agent/required-tool-fallback.ts
  84. 2 2
      src/lib/agent/required-tools-gate.ts
  85. 1 1
      src/lib/agent/skills/draft-review-skill.ts
  86. 2 2
      src/lib/agent/tool-events.ts
  87. 1 1
      src/lib/agent/tool-executor.ts
  88. 1 1
      src/lib/agent/tools/list-chapters.ts
  89. 1 1
      src/lib/agent/tools/list-outlines.ts
  90. 0 53
      src/lib/agent/tools/mcp-tool.spec.ts
  91. 0 1
      src/lib/agent/tools/mcp-tool.ts
  92. 0 9
      src/lib/agent/tools/outline-list-helpers.ts
  93. 2 2
      src/lib/agent/tools/read-markdown-resource.ts
  94. 1 1
      src/lib/agent/tools/read-web-page.ts
  95. 1 1
      src/lib/agent/tools/run-chapter-workflow.ts
  96. 1 1
      src/lib/agent/tools/summarize-search-results.ts
  97. 1 1
      src/lib/agent/tools/web-search.ts
  98. 1 1
      src/lib/agent/tools/write-chapter.ts
  99. 0 90
      src/lib/agent/trace-builder.ts
  100. 4 4
      src/lib/agent/workflow-trace.ts

+ 1 - 1
.github/workflows/ci.yml

@@ -22,7 +22,7 @@ jobs:
 
       - name: Filter paths
         if: github.event_name != 'workflow_dispatch'
-        uses: dorny/paths-filter@v3
+        uses: dorny/paths-filter@v4
         id: filter
         with:
           filters: |

+ 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();
 }

+ 2 - 23
src/commands/file-sync.ts

@@ -22,33 +22,16 @@ export interface FileChangeTask {
   needsRerun: boolean
 }
 
-export interface FileChangeQueue {
+interface FileChangeQueue {
   version: number
   tasks: FileChangeTask[]
 }
 
-export interface FileChangeRescanResult {
+interface FileChangeRescanResult {
   queue: FileChangeQueue
   changedTasks: FileChangeTask[]
 }
 
-export interface FileSyncPayload {
-  projectId: string
-  tasks: FileChangeTask[]
-}
-
-export function startProjectFileWatcher(
-  projectId: string,
-  projectPath: string,
-  sourceWatchConfig?: SourceWatchConfig,
-): Promise<FileChangeQueue> {
-  return invoke<FileChangeQueue>("start_project_file_watcher", {
-    projectId,
-    projectPath,
-    sourceWatchConfig: normalizeSourceWatchConfig(sourceWatchConfig),
-  })
-}
-
 export function stopProjectFileWatcher(): Promise<void> {
   return invoke<void>("stop_project_file_watcher")
 }
@@ -65,10 +48,6 @@ export function rescanProjectFiles(
   })
 }
 
-export function getFileChangeQueue(projectPath: string): Promise<FileChangeQueue> {
-  return invoke<FileChangeQueue>("get_file_change_queue", { projectPath })
-}
-
 export function retryFileChangeTask(
   projectId: string,
   projectPath: string,

+ 1 - 20
src/commands/fs.ts

@@ -122,22 +122,11 @@ export async function copyDirectory(
   return invoke<string[]>("copy_directory", { source, destination })
 }
 
-export async function preprocessFile(path: string): Promise<string> {
-  return invoke<string>("preprocess_file", { path })
-}
-
 export async function deleteFile(path: string): Promise<void> {
   await invoke("delete_file", { path })
   notifyProjectFileMutation({ type: "delete", path })
 }
 
-export async function findRelatedWikiPages(
-  projectPath: string,
-  sourceName: string
-): Promise<string[]> {
-  return invoke<string[]>("find_related_wiki_pages", { projectPath, sourceName })
-}
-
 export async function createDirectory(path: string): Promise<void> {
   return invoke<void>("create_directory", { path })
 }
@@ -146,10 +135,6 @@ export async function fileExists(path: string): Promise<boolean> {
   return invoke<boolean>("file_exists", { path })
 }
 
-export async function getFileModifiedTime(path: string): Promise<number> {
-  return invoke<number>("get_file_modified_time", { path })
-}
-
 export async function getFileSize(path: string): Promise<number> {
   return invoke<number>("get_file_size", { path })
 }
@@ -158,7 +143,7 @@ export async function getFileMd5(path: string): Promise<string> {
   return invoke<string>("get_file_md5", { path })
 }
 
-export interface FileBase64 {
+interface FileBase64 {
   base64: string
   mimeType: string
 }
@@ -184,10 +169,6 @@ export async function openProject(path: string): Promise<WikiProject> {
   return { id, name: raw.name, path: raw.path }
 }
 
-export async function openProjectFolder(path: string): Promise<void> {
-  return invoke<void>("open_project_folder", { path })
-}
-
 export async function openFileLocation(path: string): Promise<void> {
   return invoke<void>("open_file_location", { path })
 }

+ 0 - 36
src/components/chat/chat-input-resize.spec.ts

@@ -1,36 +0,0 @@
-import { describe, expect, it } from "vitest"
-import { clampResizableInputHeight, getResizeBoundsForElement, resolveMaxHeightFromContext, DEFAULT_RESIZABLE_INPUT_HEIGHT } from "./chat-input-resize"
-
-describe("chat input resize bounds", () => {
-  it("keeps the input height between the default height and max height", () => {
-    expect(clampResizableInputHeight(20, { minHeight: 44, maxHeight: 300 })).toBe(44)
-    expect(clampResizableInputHeight(180, { minHeight: 44, maxHeight: 300 })).toBe(180)
-    expect(clampResizableInputHeight(500, { minHeight: 44, maxHeight: 300 })).toBe(300)
-  })
-
-  it("clamps invalid finite values to min height", () => {
-    expect(clampResizableInputHeight(Number.NaN, { minHeight: 44, maxHeight: 300 })).toBe(44)
-    expect(clampResizableInputHeight(Number.POSITIVE_INFINITY, { minHeight: 44, maxHeight: 300 })).toBe(300)
-  })
-
-  it("returns safe defaults when element is null", () => {
-    const bounds = getResizeBoundsForElement(null)
-    expect(bounds.minHeight).toBe(DEFAULT_RESIZABLE_INPUT_HEIGHT)
-    expect(bounds.maxHeight).toBeGreaterThanOrEqual(DEFAULT_RESIZABLE_INPUT_HEIGHT)
-  })
-
-  it("predicts max height from context allowing upward expansion", () => {
-    const ctx = {
-      startRootTop: 680,
-      startRootHeight: 120,
-      startInputHeight: 44,
-      viewportHeight: 800,
-      containerHeight: 700,
-      fixedOverhead: 76,
-    }
-    const initialMax = resolveMaxHeightFromContext(ctx, 44)
-    expect(initialMax).toBe(44)
-    const expandedMax = resolveMaxHeightFromContext(ctx, 200)
-    expect(expandedMax).toBeGreaterThan(initialMax)
-  })
-})

+ 0 - 161
src/components/chat/chat-input-resize.ts

@@ -1,161 +0,0 @@
-export const DEFAULT_RESIZABLE_INPUT_HEIGHT = 44
-const INPUT_BOTTOM_RESERVED = 80
-const MIN_MESSAGE_AREA_MIN = 80
-const MIN_MESSAGE_AREA_RATIO = 0.15
-const MIN_USABLE_CONTAINER_HEIGHT = DEFAULT_RESIZABLE_INPUT_HEIGHT + MIN_MESSAGE_AREA_MIN + 80
-
-export interface ResizableInputBounds {
-  minHeight: number
-  maxHeight: number
-}
-
-export interface ResizeContext {
-  startRootTop: number
-  startRootHeight: number
-  startInputHeight: number
-  viewportHeight: number
-  containerHeight: number
-  fixedOverhead: number
-}
-
-function computeMinMessageAreaHeight(containerHeight: number): number {
-  return Math.max(MIN_MESSAGE_AREA_MIN, Math.floor(containerHeight * MIN_MESSAGE_AREA_RATIO))
-}
-
-function getEffectiveRootRect(rootEl: HTMLDivElement): DOMRect {
-  const rootRect = rootEl.getBoundingClientRect()
-  if (rootRect.height > 0) return rootRect
-  const parentRect = rootEl.parentElement?.getBoundingClientRect()
-  return parentRect && parentRect.height > 0 && parentRect.height < MIN_USABLE_CONTAINER_HEIGHT
-    ? parentRect
-    : rootRect
-}
-
-export function createResizeContext(rootEl: HTMLDivElement | null, currentInputHeight: number): ResizeContext | null {
-  if (!rootEl) return null
-  const rootRect = getEffectiveRootRect(rootEl)
-  const containerEl = findChatContainer(rootEl)
-  const containerRect = containerEl?.getBoundingClientRect()
-  const containerHeight = containerRect?.height ?? window.innerHeight
-  const textareaEl = rootEl.querySelector("textarea")
-  const textareaRect = textareaEl?.getBoundingClientRect()
-  const actualTextareaHeight = textareaRect && textareaRect.height > 0 ? textareaRect.height : currentInputHeight
-  const chatInputOverhead = Math.max(0, rootRect.height - actualTextareaHeight)
-  const siblingOverhead = computeSiblingOverhead(rootEl, containerEl)
-  return {
-    startRootTop: rootRect.top,
-    startRootHeight: rootRect.height,
-    startInputHeight: currentInputHeight,
-    viewportHeight: window.innerHeight,
-    containerHeight,
-    fixedOverhead: chatInputOverhead + siblingOverhead,
-  }
-}
-
-export function findChatContainer(root: HTMLDivElement): HTMLElement | null {
-  let current: HTMLElement | null = root.parentElement
-  let fallback: HTMLElement | null = null
-  let sizeFallback: HTMLElement | null = null
-  for (let i = 0; i < 8 && current; i++) {
-    const tag = current.tagName
-    if (tag === "BODY" || tag === "HTML") break
-    const rect = current.getBoundingClientRect()
-    const hasUsableHeight = rect.height >= MIN_USABLE_CONTAINER_HEIGHT
-    if (!sizeFallback && hasUsableHeight) {
-      sizeFallback = current
-    }
-    const style = window.getComputedStyle(current)
-    const isFlexColumn = style.display === "flex" && style.flexDirection === "column"
-    if (isFlexColumn) {
-      const hasConstrainedHeight = style.height !== "auto" && style.height !== ""
-      const hasOverflowClip = style.overflowY === "hidden" || style.overflowY === "auto" || style.overflowY === "scroll"
-      if ((hasConstrainedHeight || hasOverflowClip) && hasUsableHeight) {
-        return current
-      }
-      if (!fallback && hasUsableHeight) {
-        fallback = current
-      }
-    }
-    current = current.parentElement
-  }
-  return fallback ?? sizeFallback ?? root.parentElement
-}
-
-/**
- * 计算 ChatInput 根 div 之前的所有非 flex-grow 兄弟元素总高度。
- * 用于在 maxHeight 计算中扣除 Header、Section 按钮区等外层固定开销,
- * 防止 textarea 被允许拉高到超出容器实际可用空间。
- */
-function computeSiblingOverhead(rootEl: HTMLDivElement, container: HTMLElement | null): number {
-  if (!container) return 0
-  let overhead = 0
-  let found = false
-  for (const child of Array.from(container.children)) {
-    if (child === rootEl || (child as HTMLElement).contains(rootEl)) {
-      found = true
-      break
-    }
-    const style = window.getComputedStyle(child as HTMLElement)
-    const flexGrow = parseFloat(style.flexGrow || "0")
-    // 跳过 flex-grow > 0 的元素(如 messages 区域 flex-1,会自适应)
-    if (flexGrow > 0) continue
-    const rect = (child as HTMLElement).getBoundingClientRect()
-    overhead += rect.height
-  }
-  return found ? overhead : 0
-}
-
-export function resolveMaxHeightFromContext(ctx: ResizeContext, nextHeight: number): number {
-  const predictedRootTop = ctx.startRootTop - (nextHeight - ctx.startInputHeight)
-  const viewportAvailable = ctx.viewportHeight - predictedRootTop - INPUT_BOTTOM_RESERVED
-  const minMessageArea = computeMinMessageAreaHeight(ctx.containerHeight)
-  const containerAvailable = ctx.containerHeight - minMessageArea - ctx.fixedOverhead
-  const effectiveMax = Math.min(
-    Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, Math.floor(viewportAvailable)),
-    Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, Math.floor(containerAvailable)),
-  )
-  return effectiveMax
-}
-
-export function getResizeBoundsForElement(rootEl: HTMLDivElement | null): ResizableInputBounds {
-  if (!rootEl) {
-    return { minHeight: DEFAULT_RESIZABLE_INPUT_HEIGHT, maxHeight: 200 }
-  }
-  const containerEl = findChatContainer(rootEl)
-  const containerRect = containerEl?.getBoundingClientRect()
-  const containerHeight = containerRect?.height ?? window.innerHeight
-  const rootRect = getEffectiveRootRect(rootEl)
-  const textareaEl = rootEl.querySelector("textarea")
-  const measuredTextareaHeight = textareaEl?.getBoundingClientRect().height ?? 0
-  const textareaHeight = measuredTextareaHeight > 0 ? measuredTextareaHeight : DEFAULT_RESIZABLE_INPUT_HEIGHT
-  const chatInputOverhead = Math.max(0, rootRect.height - textareaHeight)
-  const siblingOverhead = computeSiblingOverhead(rootEl, containerEl)
-  const minMessageArea = computeMinMessageAreaHeight(containerHeight)
-  const containerMax = Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, Math.floor(containerHeight - minMessageArea - chatInputOverhead - siblingOverhead))
-  const viewportMax = Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, Math.floor(window.innerHeight - rootRect.top - INPUT_BOTTOM_RESERVED))
-  return {
-    minHeight: DEFAULT_RESIZABLE_INPUT_HEIGHT,
-    maxHeight: Math.min(containerMax, viewportMax),
-  }
-}
-
-export function clampResizableInputHeight(
-  nextHeight: number,
-  bounds: ResizableInputBounds,
-): number {
-  const minHeight = Math.max(1, Math.floor(bounds.minHeight))
-  const maxHeight = Math.max(minHeight, Math.floor(bounds.maxHeight))
-  if (Number.isNaN(nextHeight)) return minHeight
-  if (nextHeight === Number.POSITIVE_INFINITY) return maxHeight
-  if (nextHeight === Number.NEGATIVE_INFINITY) return minHeight
-  return Math.min(maxHeight, Math.max(minHeight, Math.round(nextHeight)))
-}
-
-export function isHeightAtMax(currentHeight: number, bounds: ResizableInputBounds): boolean {
-  const maxHeight = Math.max(bounds.minHeight, Math.floor(bounds.maxHeight))
-  return currentHeight >= maxHeight - 1
-}
-
-export function isHeightAtMin(currentHeight: number, bounds: ResizableInputBounds): boolean {
-  return currentHeight <= bounds.minHeight + 1
-}

+ 0 - 377
src/components/chat/chat-input.resize-interaction.spec.tsx

@@ -1,377 +0,0 @@
-// @vitest-environment jsdom
-
-import { act } from "react"
-import { createRoot, type Root } from "react-dom/client"
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
-import { ChatInput } from "./chat-input"
-
-let host: HTMLDivElement
-let root: Root
-
-function createPointerEvent(
-  type: string,
-  init: {
-    button?: number
-    buttons?: number
-    clientY?: number
-    pointerId?: number
-  } = {},
-) {
-  const event = new MouseEvent(type, {
-    bubbles: true,
-    button: init.button ?? 0,
-    buttons: init.buttons ?? 0,
-    cancelable: true,
-    clientY: init.clientY ?? 0,
-  })
-
-  Object.defineProperty(event, "pointerId", { value: init.pointerId ?? 1 })
-  return event
-}
-
-async function flush() {
-  await act(async () => {
-    await Promise.resolve()
-  })
-}
-
-describe("ChatInput resize interaction", () => {
-  beforeEach(() => {
-    host = document.createElement("div")
-    document.body.appendChild(host)
-    root = createRoot(host)
-    window.innerHeight = 800
-    window.localStorage.removeItem("lk-chat-input-height")
-  })
-
-  afterEach(() => {
-    act(() => {
-      root.unmount()
-    })
-    host.remove()
-    vi.restoreAllMocks()
-  })
-
-  it("places left toolbar controls above the textarea", async () => {
-    await act(async () => {
-      root.render(
-        <ChatInput
-          onSend={() => {}}
-          onStop={() => {}}
-          isStreaming={false}
-          leftControls={<span data-testid="left-toolbar">left tools</span>}
-          rightControls={<span data-testid="right-toolbar">right tools</span>}
-        />,
-      )
-    })
-
-    const leftToolbar = host.querySelector<HTMLElement>('[data-testid="left-toolbar"]')
-    const textarea = host.querySelector<HTMLTextAreaElement>("textarea")
-    const rightToolbar = host.querySelector<HTMLElement>('[data-testid="right-toolbar"]')
-    if (!leftToolbar || !textarea || !rightToolbar) throw new Error("chat input toolbar elements not found")
-
-    expect(Boolean(leftToolbar.compareDocumentPosition(textarea) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true)
-    expect(Boolean(textarea.compareDocumentPosition(rightToolbar) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true)
-  })
-
-  it("places bottom left controls in the footer before right controls", async () => {
-    await act(async () => {
-      root.render(
-        <ChatInput
-          onSend={() => {}}
-          onStop={() => {}}
-          isStreaming={false}
-          bottomLeftControls={<span data-testid="bottom-left-toolbar">bottom left</span>}
-          rightControls={<span data-testid="right-toolbar">right tools</span>}
-        />,
-      )
-    })
-
-    const bottomLeftToolbar = host.querySelector<HTMLElement>('[data-testid="bottom-left-toolbar"]')
-    const textarea = host.querySelector<HTMLTextAreaElement>("textarea")
-    const rightToolbar = host.querySelector<HTMLElement>('[data-testid="right-toolbar"]')
-    if (!bottomLeftToolbar || !textarea || !rightToolbar) throw new Error("chat input footer elements not found")
-
-    expect(Boolean(textarea.compareDocumentPosition(bottomLeftToolbar) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true)
-    expect(Boolean(bottomLeftToolbar.compareDocumentPosition(rightToolbar) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true)
-  })
-
-  it("captures the pointer and resizes while dragging the height handle", async () => {
-    await act(async () => {
-      root.render(
-        <div
-          ref={(node) => {
-            if (!node) return
-            node.getBoundingClientRect = () => ({
-              x: 0,
-              y: 0,
-              top: 0,
-              right: 360,
-              bottom: 480,
-              left: 0,
-              width: 360,
-              height: 480,
-              toJSON: () => ({}),
-            })
-          }}
-        >
-          <ChatInput onSend={() => {}} onStop={() => {}} isStreaming={false} />
-        </div>,
-      )
-    })
-
-    const resizeHandle = host.querySelector<HTMLElement>('[role="separator"]')
-    const textarea = host.querySelector<HTMLTextAreaElement>("textarea")
-    if (!resizeHandle || !textarea) throw new Error("chat input resize elements not found")
-
-    const setPointerCapture = vi.fn()
-    const releasePointerCapture = vi.fn()
-    resizeHandle.setPointerCapture = setPointerCapture
-    resizeHandle.releasePointerCapture = releasePointerCapture
-
-    await act(async () => {
-      resizeHandle.dispatchEvent(createPointerEvent("pointerdown", {
-        button: 0,
-        buttons: 1,
-        clientY: 220,
-        pointerId: 17,
-      }))
-    })
-
-    expect(setPointerCapture).toHaveBeenCalledWith(17)
-
-    await act(async () => {
-      window.dispatchEvent(createPointerEvent("pointermove", {
-        buttons: 1,
-        clientY: 120,
-        pointerId: 17,
-      }))
-    })
-    await flush()
-
-    expect(textarea.style.height).toBe("144px")
-
-    await act(async () => {
-      window.dispatchEvent(createPointerEvent("pointerup", { pointerId: 17 }))
-    })
-
-    expect(releasePointerCapture).toHaveBeenCalledWith(17)
-  })
-
-  it("keeps the manually resized height after sending a message", async () => {
-    const onSend = vi.fn()
-
-    await act(async () => {
-      root.render(
-        <div
-          ref={(node) => {
-            if (!node) return
-            node.getBoundingClientRect = () => ({
-              x: 0,
-              y: 0,
-              top: 0,
-              right: 360,
-              bottom: 480,
-              left: 0,
-              width: 360,
-              height: 480,
-              toJSON: () => ({}),
-            })
-          }}
-        >
-          <ChatInput onSend={onSend} onStop={() => {}} isStreaming={false} />
-        </div>,
-      )
-    })
-
-    const resizeHandle = host.querySelector<HTMLElement>('[role="separator"]')
-    const textarea = host.querySelector<HTMLTextAreaElement>("textarea")
-    const sendButton = host.querySelector<HTMLButtonElement>("button")
-    if (!resizeHandle || !textarea || !sendButton) throw new Error("chat input elements not found")
-
-    resizeHandle.setPointerCapture = vi.fn()
-    resizeHandle.releasePointerCapture = vi.fn()
-
-    await act(async () => {
-      resizeHandle.dispatchEvent(createPointerEvent("pointerdown", {
-        button: 0,
-        buttons: 1,
-        clientY: 220,
-        pointerId: 20,
-      }))
-    })
-    await act(async () => {
-      window.dispatchEvent(createPointerEvent("pointermove", {
-        buttons: 1,
-        clientY: 120,
-        pointerId: 20,
-      }))
-    })
-    await act(async () => {
-      window.dispatchEvent(createPointerEvent("pointerup", { pointerId: 20 }))
-    })
-    await flush()
-
-    expect(textarea.style.height).toBe("144px")
-
-    await act(async () => {
-      const valueSetter = Object.getOwnPropertyDescriptor(
-        HTMLTextAreaElement.prototype,
-        "value",
-      )?.set
-      valueSetter?.call(textarea, "测试")
-      textarea.dispatchEvent(new InputEvent("input", {
-        bubbles: true,
-        data: "测试",
-        inputType: "insertText",
-      }))
-    })
-    await flush()
-
-    await act(async () => {
-      sendButton.click()
-    })
-    await flush()
-
-    expect(onSend).toHaveBeenCalledWith("测试")
-    expect(textarea.style.height).toBe("144px")
-  })
-
-  it("uses the nearest usable panel height when the chat input is wrapped in a short footer", async () => {
-    await act(async () => {
-      root.render(
-        <div
-          ref={(node) => {
-            if (!node) return
-            node.getBoundingClientRect = () => ({
-              x: 0,
-              y: 0,
-              top: 0,
-              right: 360,
-              bottom: 480,
-              left: 0,
-              width: 360,
-              height: 480,
-              toJSON: () => ({}),
-            })
-          }}
-        >
-          <div
-            ref={(node) => {
-              if (!node) return
-              node.getBoundingClientRect = () => ({
-                x: 0,
-                y: 420,
-                top: 420,
-                right: 360,
-                bottom: 480,
-                left: 0,
-                width: 360,
-                height: 60,
-                toJSON: () => ({}),
-              })
-            }}
-          >
-            <ChatInput onSend={() => {}} onStop={() => {}} isStreaming={false} />
-          </div>
-        </div>,
-      )
-    })
-
-    const resizeHandle = host.querySelector<HTMLElement>('[role="separator"]')
-    const textarea = host.querySelector<HTMLTextAreaElement>("textarea")
-    if (!resizeHandle || !textarea) throw new Error("chat input resize elements not found")
-
-    resizeHandle.setPointerCapture = vi.fn()
-    resizeHandle.releasePointerCapture = vi.fn()
-
-    await act(async () => {
-      resizeHandle.dispatchEvent(createPointerEvent("pointerdown", {
-        button: 0,
-        buttons: 1,
-        clientY: 220,
-        pointerId: 18,
-      }))
-    })
-
-    await act(async () => {
-      window.dispatchEvent(createPointerEvent("pointermove", {
-        buttons: 1,
-        clientY: 120,
-        pointerId: 18,
-      }))
-    })
-    await flush()
-
-    expect(textarea.style.height).toBe("144px")
-  })
-
-  it("does not let a grown chat footer become its own resize limit", async () => {
-    await act(async () => {
-      root.render(
-        <div
-          ref={(node) => {
-            if (!node) return
-            node.getBoundingClientRect = () => ({
-              x: 0,
-              y: 0,
-              top: 0,
-              right: 360,
-              bottom: 480,
-              left: 0,
-              width: 360,
-              height: 480,
-              toJSON: () => ({}),
-            })
-          }}
-        >
-          <div
-            ref={(node) => {
-              if (!node) return
-              node.getBoundingClientRect = () => ({
-                x: 0,
-                y: 360,
-                top: 360,
-                right: 360,
-                bottom: 480,
-                left: 0,
-                width: 360,
-                height: 120,
-                toJSON: () => ({}),
-              })
-            }}
-          >
-            <ChatInput onSend={() => {}} onStop={() => {}} isStreaming={false} />
-          </div>
-        </div>,
-      )
-    })
-
-    const resizeHandle = host.querySelector<HTMLElement>('[role="separator"]')
-    const textarea = host.querySelector<HTMLTextAreaElement>("textarea")
-    if (!resizeHandle || !textarea) throw new Error("chat input resize elements not found")
-
-    resizeHandle.setPointerCapture = vi.fn()
-    resizeHandle.releasePointerCapture = vi.fn()
-
-    await act(async () => {
-      resizeHandle.dispatchEvent(createPointerEvent("pointerdown", {
-        button: 0,
-        buttons: 1,
-        clientY: 320,
-        pointerId: 19,
-      }))
-    })
-
-    await act(async () => {
-      window.dispatchEvent(createPointerEvent("pointermove", {
-        buttons: 1,
-        clientY: 20,
-        pointerId: 19,
-      }))
-    })
-    await flush()
-
-    expect(textarea.style.height).toBe("324px")
-  })
-})

+ 0 - 401
src/components/chat/chat-input.tsx

@@ -1,401 +0,0 @@
-import { useRef, useState, useCallback, useEffect, type ReactNode } from "react"
-import { ArrowUp, Square } from "lucide-react"
-import { Button } from "@/components/ui/button"
-import { isImeComposing } from "@/lib/keyboard-utils"
-import { useChatStore } from "@/stores/chat-store"
-import {
-  clampResizableInputHeight,
-  DEFAULT_RESIZABLE_INPUT_HEIGHT,
-  getResizeBoundsForElement,
-  createResizeContext,
-  resolveMaxHeightFromContext,
-  isHeightAtMax,
-  isHeightAtMin,
-  findChatContainer,
-  type ResizeContext,
-  type ResizableInputBounds,
-} from "./chat-input-resize"
-
-const CHAT_INPUT_HEIGHT_KEY = "lk-chat-input-height"
-
-function loadSavedInputHeight(): number | null {
-  if (typeof localStorage === "undefined") return null
-  const raw = localStorage.getItem(CHAT_INPUT_HEIGHT_KEY)
-  if (!raw) return null
-  const v = Number(raw)
-  return Number.isFinite(v) && v >= DEFAULT_RESIZABLE_INPUT_HEIGHT ? Math.round(v) : null
-}
-
-function saveInputHeight(h: number) {
-  if (typeof localStorage === "undefined") return
-  localStorage.setItem(CHAT_INPUT_HEIGHT_KEY, String(Math.round(h)))
-}
-
-interface ChatInputProps {
-  onSend: (text: string) => void
-  onStop: () => void
-  isStreaming: boolean
-  placeholder?: string
-  leftControls?: ReactNode
-  bottomLeftControls?: ReactNode
-  rightControls?: ReactNode
-  value?: string
-  onChange?: (value: string) => void
-}
-
-export function ChatInput({ onSend, onStop, isStreaming, placeholder, leftControls, bottomLeftControls, rightControls, value: controlledValue, onChange }: ChatInputProps) {
-  const leftToolbarContent = leftControls
-  const bottomLeftToolbarContent = bottomLeftControls
-  const rightToolbarContent = rightControls
-  const activeConversationId = useChatStore((state) => state.activeConversationId)
-  const setConversationInputDraft = useChatStore((state) => state.setConversationInputDraft)
-  const conversation = useChatStore((state) =>
-    activeConversationId
-      ? state.conversations.find((c) => c.id === activeConversationId)
-      : undefined
-  )
-  const isControlled = controlledValue !== undefined
-  const [fallbackDraft, setFallbackDraft] = useState("")
-  const storeValue = conversation?.inputDraft ?? ""
-  const value = isControlled ? controlledValue : activeConversationId ? storeValue : fallbackDraft
-
-  const savedHeight = useRef<number | null>(loadSavedInputHeight())
-  const [inputHeight, setInputHeight] = useState<number>(() => savedHeight.current ?? DEFAULT_RESIZABLE_INPUT_HEIGHT)
-  const [userSetMinHeight, setUserSetMinHeight] = useState<number | null>(savedHeight.current)
-  const userSetMinHeightRef = useRef<number | null>(savedHeight.current)
-  const [isDragging, setIsDragging] = useState(false)
-  const [isAtLimitTop, setIsAtLimitTop] = useState(false)
-  const [isAtLimitBottom, setIsAtLimitBottom] = useState(true)
-  const rootRef = useRef<HTMLDivElement>(null)
-  const textareaRef = useRef<HTMLTextAreaElement>(null)
-  const resizeContextRef = useRef<ResizeContext | null>(null)
-  const dragHeightRef = useRef<number>(inputHeight)
-  const inputHeightRef = useRef<number>(inputHeight)
-
-  useEffect(() => {
-    inputHeightRef.current = inputHeight
-  }, [inputHeight])
-
-  const updateLimitState = useCallback((height: number) => {
-    const bounds = getResizeBoundsForElement(rootRef.current)
-    setIsAtLimitTop(isHeightAtMax(height, bounds))
-    setIsAtLimitBottom(isHeightAtMin(height, bounds))
-  }, [])
-
-  useEffect(() => {
-    updateLimitState(inputHeight)
-  }, [inputHeight, updateLimitState])
-
-  useEffect(() => {
-    userSetMinHeightRef.current = userSetMinHeight
-  }, [userSetMinHeight])
-
-  const handleContainerResize = useCallback(() => {
-    const userMin = userSetMinHeightRef.current
-    const ta = textareaRef.current
-
-    if (userMin != null) {
-      // 用户已手动设置高度:容器变化时只做上下边界检查,保持高度尽量不变
-      const bounds = getResizeBoundsForElement(rootRef.current)
-      const current = inputHeightRef.current
-      const next = clampResizableInputHeight(current, bounds)
-      if (next !== current) {
-        if (ta) {
-          ta.style.height = `${next}px`
-        }
-        setInputHeight(next)
-        setUserSetMinHeight(next)
-        // 同步到 localStorage,防止下次启动仍用超出边界的高度
-        saveInputHeight(next)
-      }
-      setIsAtLimitTop(isHeightAtMax(next, bounds))
-      setIsAtLimitBottom(isHeightAtMin(next, bounds))
-      return
-    }
-
-    if (ta) {
-      const bounds = getResizeBoundsForElement(rootRef.current)
-      ta.style.height = "auto"
-      const contentHeight = ta.scrollHeight
-      const current = inputHeightRef.current
-      const next = clampResizableInputHeight(Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, contentHeight, current), bounds)
-      ta.style.height = `${next}px`
-      setInputHeight(next)
-    } else {
-      const bounds = getResizeBoundsForElement(rootRef.current)
-      setInputHeight((prev) => clampResizableInputHeight(prev, bounds))
-    }
-  }, [])
-
-  // 监听 window resize
-  useEffect(() => {
-    window.addEventListener("resize", handleContainerResize)
-    return () => window.removeEventListener("resize", handleContainerResize)
-  }, [handleContainerResize])
-
-  // 使用 ResizeObserver 监听容器大小变化(停靠模式切换、容器拖拽等)
-  useEffect(() => {
-    const rootEl = rootRef.current
-    if (!rootEl) return
-    const containerEl = findChatContainer(rootEl)
-    if (!containerEl || typeof ResizeObserver === "undefined") return
-
-    const observer = new ResizeObserver(() => {
-      handleContainerResize()
-    })
-    observer.observe(containerEl)
-    return () => observer.disconnect()
-  }, [handleContainerResize])
-
-  // 挂载时校验 localStorage 中保存的高度是否超出当前容器 maxHeight
-  // 防止在不同停靠模式(右侧大容器 vs 底部小容器)间切换时 textarea 高度溢出
-  useEffect(() => {
-    const saved = savedHeight.current
-    if (saved == null) return
-    const bounds = getResizeBoundsForElement(rootRef.current)
-    const clamped = clampResizableInputHeight(saved, bounds)
-    if (clamped !== saved) {
-      const ta = textareaRef.current
-      if (ta) {
-        ta.style.height = `${clamped}px`
-      }
-      setInputHeight(clamped)
-      setUserSetMinHeight(clamped)
-      saveInputHeight(clamped)
-    }
-  }, [])
-
-  const autoFitTextarea = useCallback(() => {
-    const ta = textareaRef.current
-    if (!ta) return
-    const userMin = userSetMinHeightRef.current
-
-    // 用户已手动设置高度:保持固定高度,不随内容自适应
-    // 内容超出时 textarea 内部自动滚动,高度始终不变
-    if (userMin != null) {
-      return
-    }
-
-    const bounds = getResizeBoundsForElement(rootRef.current)
-    ta.style.height = "auto"
-    const contentHeight = ta.scrollHeight
-    const next = clampResizableInputHeight(Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, contentHeight), bounds)
-    ta.style.height = `${next}px`
-    setInputHeight(next)
-    setIsAtLimitTop(isHeightAtMax(next, bounds))
-    setIsAtLimitBottom(isHeightAtMin(next, bounds))
-  }, [])
-
-  useEffect(() => {
-    autoFitTextarea()
-  }, [value, autoFitTextarea])
-
-  const setValue = useCallback(
-    (draft: string) => {
-      if (isControlled) {
-        onChange?.(draft)
-      } else if (activeConversationId) {
-        setConversationInputDraft(activeConversationId, draft)
-      } else {
-        setFallbackDraft(draft)
-      }
-    },
-    [isControlled, onChange, activeConversationId, setConversationInputDraft]
-  )
-
-  const getStaticBounds = useCallback(() => {
-    return getResizeBoundsForElement(rootRef.current)
-  }, [])
-
-  const handleInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
-    setValue(e.target.value)
-    requestAnimationFrame(() => {
-      autoFitTextarea()
-    })
-  }, [setValue, autoFitTextarea])
-
-  const resetHeight = useCallback(() => {
-    setUserSetMinHeight(null)
-    const ta = textareaRef.current
-    const bounds = getResizeBoundsForElement(rootRef.current)
-    if (ta) {
-      ta.style.height = "auto"
-      const next = clampResizableInputHeight(Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, ta.scrollHeight), bounds)
-      ta.style.height = `${next}px`
-      setInputHeight(next)
-    } else {
-      setInputHeight(DEFAULT_RESIZABLE_INPUT_HEIGHT)
-    }
-  }, [])
-
-  const handleResizePointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
-    if (event.button !== 0) return
-    event.preventDefault()
-
-    const resizeHandle = event.currentTarget
-    const pointerId = event.pointerId
-    const startY = event.clientY
-    const startHeight = inputHeight
-    const previousCursor = document.body.style.cursor
-    document.body.style.cursor = "ns-resize"
-    setIsDragging(true)
-
-    const ctx = createResizeContext(rootRef.current, startHeight)
-    resizeContextRef.current = ctx
-    dragHeightRef.current = startHeight
-
-    try {
-      resizeHandle.setPointerCapture(pointerId)
-    } catch {
-      // Older WebViews can miss pointer capture support; window listeners still provide a fallback.
-    }
-
-    const handlePointerMove = (pointerEvent: PointerEvent) => {
-      const nextHeight = startHeight + (startY - pointerEvent.clientY)
-      let maxHeight: number
-      let bounds: ResizableInputBounds
-      if (resizeContextRef.current) {
-        maxHeight = resolveMaxHeightFromContext(resizeContextRef.current, nextHeight)
-        bounds = { minHeight: DEFAULT_RESIZABLE_INPUT_HEIGHT, maxHeight }
-      } else {
-        bounds = getStaticBounds()
-      }
-      const clamped = clampResizableInputHeight(nextHeight, bounds)
-      dragHeightRef.current = clamped
-      const ta = textareaRef.current
-      if (ta) {
-        ta.style.height = `${clamped}px`
-      }
-      setInputHeight(clamped)
-      setIsAtLimitTop(isHeightAtMax(clamped, bounds))
-      setIsAtLimitBottom(isHeightAtMin(clamped, bounds))
-    }
-    const handlePointerUp = () => {
-      const finalHeight = dragHeightRef.current
-      setUserSetMinHeight(finalHeight)
-      saveInputHeight(finalHeight)
-      resizeContextRef.current = null
-      setIsDragging(false)
-      try {
-        resizeHandle.releasePointerCapture(pointerId)
-      } catch {
-        // Ignore release errors when the pointer was already cancelled by the WebView.
-      }
-      document.body.style.cursor = previousCursor
-      window.removeEventListener("pointermove", handlePointerMove)
-      window.removeEventListener("pointerup", handlePointerUp)
-      window.removeEventListener("pointercancel", handlePointerUp)
-    }
-
-    window.addEventListener("pointermove", handlePointerMove)
-    window.addEventListener("pointerup", handlePointerUp)
-    window.addEventListener("pointercancel", handlePointerUp)
-  }, [inputHeight, getStaticBounds])
-
-  const handleDoubleClick = useCallback(() => {
-    resetHeight()
-  }, [resetHeight])
-
-  const handleSend = useCallback(() => {
-    const trimmed = value.trim()
-    if (!trimmed || isStreaming) return
-    onSend(trimmed)
-    setValue("")
-  }, [value, isStreaming, onSend, setValue])
-
-  const handleKeyDown = useCallback(
-    (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
-      if (isImeComposing(e)) return
-      if (e.key === "Enter" && !e.shiftKey) {
-        e.preventDefault()
-        handleSend()
-      }
-    },
-    [handleSend],
-  )
-
-  const handleBarColor = isDragging
-    ? isAtLimitTop
-      ? "bg-destructive/60"
-      : "bg-primary/60"
-    : isAtLimitTop
-      ? "bg-destructive/30"
-      : isAtLimitBottom
-        ? "bg-border"
-        : "bg-primary/30"
-
-  const resizeTitle = isAtLimitBottom
-    ? "向上拖动拉高输入框,双击重置高度"
-    : isAtLimitTop
-      ? "已达最大高度,向下拖动缩小,双击重置"
-      : "拖动调整输入框高度,双击重置"
-
-  return (
-    <div ref={rootRef} className="border-t px-3 pt-1.5 pb-0">
-      <div className="rounded-2xl border bg-background shadow-sm overflow-hidden flex flex-col">
-        <div
-          role="separator"
-          aria-label="拖动调整输入框高度"
-          title={resizeTitle}
-          className="flex h-2 cursor-ns-resize items-center justify-center transition-colors"
-          onPointerDown={handleResizePointerDown}
-          onDoubleClick={handleDoubleClick}
-        >
-          <span className={`h-0.5 w-10 rounded-full transition-colors ${handleBarColor}`} />
-        </div>
-        {leftToolbarContent ? (
-          <div className="flex items-center border-b bg-muted/10 px-3 py-2">
-            <div className="flex min-w-0 items-center gap-2 overflow-x-auto">
-              {leftToolbarContent}
-            </div>
-          </div>
-        ) : null}
-        <div className="px-3 pt-1.5">
-          <textarea
-            ref={textareaRef}
-            value={value}
-            dir="auto"
-            onChange={handleInput}
-            onKeyDown={handleKeyDown}
-            placeholder={placeholder ?? "输入消息,Enter 发送,Shift+Enter 换行"}
-            disabled={isStreaming}
-            rows={1}
-            className="w-full resize-none bg-transparent px-0 py-1.5 text-sm placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
-            style={{ height: inputHeight, maxHeight: inputHeight, overflowY: "auto" }}
-          />
-        </div>
-        <div className="flex items-center justify-between border-t px-3 py-2 gap-2">
-          {bottomLeftToolbarContent ? (
-            <div className="flex min-w-0 items-center gap-2 shrink-0">
-              {bottomLeftToolbarContent}
-            </div>
-          ) : null}
-          <div className="ml-auto flex items-center gap-2 shrink-0">
-            {rightToolbarContent}
-            {isStreaming ? (
-              <Button
-                variant="destructive"
-                size="icon"
-                onClick={onStop}
-                title="停止生成"
-                aria-label="停止生成"
-              >
-                <Square className="h-4 w-4" />
-              </Button>
-            ) : (
-              <Button
-                size="icon"
-                onClick={handleSend}
-                disabled={!value.trim()}
-                title="发送消息"
-                aria-label="发送消息"
-              >
-                <ArrowUp className="h-4 w-4" />
-              </Button>
-            )}
-          </div>
-        </div>
-      </div>
-    </div>
-  )
-}

+ 1 - 1
src/components/chat/chat-model-selector.tsx

@@ -24,7 +24,7 @@ const DROPDOWN_GAP = 4
 /** Prefer opening downward when at least this much space exists below the trigger. */
 const DROPDOWN_PREFER_BELOW_PX = 200
 
-export type ChatModelDropdownStyle = {
+type ChatModelDropdownStyle = {
   right: number
   width: number
   maxHeight: number

+ 0 - 6
src/components/chat/chat-panel.tsx

@@ -212,12 +212,6 @@ function formatDate(timestamp: number): string {
   return d.toLocaleDateString([], { month: "short", day: "numeric" })
 }
 
-export function getDeepChapterToggleButtonClass(enabled: boolean): string {
-  return enabled
-    ? "border-primary bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:text-primary-foreground"
-    : "text-muted-foreground hover:text-foreground"
-}
-
 export function getWorkflowModeButtonClass(active: boolean): string {
   return active
     ? "bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground"

+ 1 - 1
src/components/chat/chat-resume.ts

@@ -5,7 +5,7 @@ const THINK_BLOCK_RE = /<think(?:ing)?>[\s\S]*?(?:<\/think(?:ing)?>|$)/i
 const MAX_RESUME_CONTEXT_CHARS = 60_000
 const RESUME_CONTEXT_COMMENT_RE = /<!--\s*qmai-continue-unfinished-context:([\s\S]*?)\s*-->/g
 
-export interface ContinueUnfinishedDeepChapterContext {
+interface ContinueUnfinishedDeepChapterContext {
   originalRequest?: string
   resumeContext: string
   rootResumeContext?: string

+ 1 - 5
src/components/chat/chat-shared.ts

@@ -4,7 +4,7 @@ import { normalizePath } from "@/lib/path-utils"
 import { useWikiStore } from "@/stores/wiki-store"
 import type { FileNode } from "@/types/wiki"
 
-export interface QueryPageReference {
+interface QueryPageReference {
   title: string
   path: string
 }
@@ -34,10 +34,6 @@ export function getLastQueryPages(): QueryPageReference[] {
   return lastQueryPages
 }
 
-export function setLastQueryPages(pages: QueryPageReference[]) {
-  lastQueryPages = pages
-}
-
 function flattenNames(nodes: FileNode[]): string[] {
   const names: string[] = []
   for (const node of nodes) {

+ 1 - 1
src/components/chat/context-usage-ring.tsx

@@ -28,7 +28,7 @@ const SEGMENT_COLORS: Record<ContextUsageKey, string> = {
   currentInput: "#fb923c",
 }
 
-export interface ContextUsageRingProps {
+interface ContextUsageRingProps {
   usage?: ContextUsageSnapshot | null
   onCreateConversation?: () => void
   className?: string

+ 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>
-  )
-}

+ 1 - 1
src/components/chat/stream-session.ts

@@ -1,4 +1,4 @@
-export interface StreamSessionGuard {
+interface StreamSessionGuard {
   /** 为指定会话创建一个新的流式会话,返回 sessionId */
   start: (conversationId: string) => number
   /** 检查指定会话的 sessionId 是否仍然活跃 */

+ 1 - 1
src/components/chat/tool-call-timeline.tsx

@@ -4,7 +4,7 @@ import type { AgentRunRecord } from "@/lib/agent/types"
 import { getWorkflowToolResultDisplay } from "@/lib/agent/workflow-trace"
 import { cn } from "@/lib/utils"
 
-export type ToolCallRecord = AgentRunRecord["toolCalls"][number]
+type ToolCallRecord = AgentRunRecord["toolCalls"][number]
 
 interface ToolCallTimelineProps {
   toolCalls: ToolCallRecord[] | undefined

+ 1 - 1
src/components/common/ai-change-compare-panel.tsx

@@ -11,7 +11,7 @@ function normalizeDiffText(value: string): string {
   return `${normalized}\n`
 }
 
-export interface AiChangeComparePanelProps {
+interface AiChangeComparePanelProps {
   originalContent: string
   modifiedContent: string
   onModifiedContentChange: (content: string) => void

+ 0 - 147
src/components/common/ai-change-review.spec.tsx

@@ -1,147 +0,0 @@
-// @vitest-environment jsdom
-import React from "react"
-import { act } from "react"
-import { createRoot, type Root } from "react-dom/client"
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
-
-vi.mock("./source-diff-editor", () => ({
-  SourceDiffEditor: ({ originalValue, modifiedValue, onChange }: {
-    originalValue: string
-    modifiedValue: string
-    onChange: (value: string) => void
-  }) => {
-    const [value, setValue] = React.useState(modifiedValue)
-    return (
-      <div>
-        <pre aria-label="原始源码">{originalValue}</pre>
-        <textarea
-          aria-label="最新源码"
-          value={value}
-          onChange={(event) => {
-            const next = event.target.value
-            setValue(next)
-            onChange(next)
-          }}
-        />
-      </div>
-    )
-  },
-}))
-
-import { AiChangeReview, type AiChangeReviewItem } from "./ai-change-review"
-
-function findButton(container: HTMLElement, label: string): HTMLButtonElement {
-  const button = Array.from(container.querySelectorAll("button"))
-    .find((item) => item.textContent?.includes(label))
-  if (!button) throw new Error(`未找到按钮:${label}`)
-  return button
-}
-
-describe("AiChangeReview", () => {
-  let container: HTMLDivElement
-  let root: Root
-
-  beforeEach(() => {
-    container = document.createElement("div")
-    document.body.appendChild(container)
-    root = createRoot(container)
-  })
-
-  afterEach(async () => {
-    await act(async () => root.unmount())
-    container.remove()
-  })
-
-  it("按文件勾选并提交选中项;取消勾选的不提交", async () => {
-    const onConfirm = vi.fn()
-    const items: AiChangeReviewItem[] = [{
-      id: "outline",
-      fileName: "总纲.md",
-      originalContent: "# 旧总纲",
-      modifiedContent: "# 新总纲",
-      selected: true,
-    }, {
-      id: "character",
-      fileName: "林默.md",
-      originalContent: "",
-      modifiedContent: "# 林默",
-      selected: true,
-    }]
-
-    await act(async () => {
-      root.render(
-        <AiChangeReview
-          open
-          title="确认 AI 修改"
-          items={items}
-          onClose={() => {}}
-          onConfirm={onConfirm}
-        />,
-      )
-    })
-
-    const characterCheckbox = document.body.querySelector('input[aria-label="保存 林默.md"]') as HTMLInputElement
-    await act(async () => characterCheckbox.click())
-    await act(async () => findButton(document.body, "确认保存").click())
-
-    expect(onConfirm).toHaveBeenCalledOnce()
-    const submitted = onConfirm.mock.calls[0][0] as AiChangeReviewItem[]
-    expect(submitted).toHaveLength(1)
-    expect(submitted[0].id).toBe("outline")
-    expect(submitted[0].modifiedContent).toBe("# 新总纲")
-    expect(submitted.some((i: AiChangeReviewItem) => i.id === "character")).toBe(false)
-  })
-
-  it("未选择任何文件时禁用确认保存", async () => {
-    const onConfirm = vi.fn()
-    const items: AiChangeReviewItem[] = [{
-      id: "outline",
-      fileName: "总纲.md",
-      originalContent: "# 旧总纲",
-      modifiedContent: "# 新总纲",
-      selected: false,
-    }]
-
-    await act(async () => {
-      root.render(
-        <AiChangeReview
-          open
-          title="确认 AI 修改"
-          items={items}
-          onClose={() => {}}
-          onConfirm={onConfirm}
-        />,
-      )
-    })
-
-    const btn = findButton(document.body, "确认保存")
-    expect(btn.disabled).toBe(true)
-  })
-
-  it("可在源码对比和不带符号的渲染预览之间切换", async () => {
-    await act(async () => {
-      root.render(
-        <AiChangeReview
-          open
-          title="确认 AI 修改"
-          items={[{
-            id: "outline",
-            fileName: "总纲.md",
-            originalContent: "# 旧总纲",
-            modifiedContent: "# 新总纲",
-            selected: true,
-          }]}
-          onClose={() => {}}
-          onConfirm={() => {}}
-        />,
-      )
-    })
-
-    expect(document.body.querySelector('textarea[aria-label="最新源码"]')).not.toBeNull()
-    await act(async () => findButton(document.body, "渲染预览").click())
-    expect(document.body.textContent).toContain("原始内容预览")
-    expect(document.body.textContent).toContain("最新内容预览")
-    await act(async () => findButton(document.body, "源码对比").click())
-    expect(document.body.querySelector('textarea[aria-label="最新源码"]')).not.toBeNull()
-  })
-})

+ 0 - 200
src/components/common/ai-change-review.tsx

@@ -1,200 +0,0 @@
-import { useState, useMemo, useCallback, useEffect } from "react"
-import { X, Check, FileText } from "lucide-react"
-import { AiChangeComparePanel } from "./ai-change-compare-panel"
-
-export interface AiChangeReviewItem {
-  id: string
-  fileName: string
-  originalContent: string
-  modifiedContent: string
-  selected: boolean
-  error?: string
-  targetFolder?: string
-  writeMode?: string
-}
-
-interface AiChangeReviewProps {
-  open: boolean
-  title: string
-  items: AiChangeReviewItem[]
-  onClose: () => void
-  onConfirm: (items: AiChangeReviewItem[]) => void
-  description?: string
-}
-
-export function AiChangeReview({
-  open,
-  title,
-  items: initialItems,
-  onClose,
-  onConfirm,
-  description,
-}: AiChangeReviewProps) {
-  const [items, setItems] = useState<AiChangeReviewItem[]>(initialItems)
-  const [activeId, setActiveId] = useState<string | null>(null)
-  const [saving, setSaving] = useState(false)
-
-  useEffect(() => {
-    if (open) {
-      setItems(initialItems)
-      setActiveId(initialItems[0]?.id ?? null)
-      setSaving(false)
-    }
-  }, [open, initialItems])
-
-  const activeItem = useMemo(
-    () => items.find((item) => item.id === activeId) ?? null,
-    [items, activeId],
-  )
-
-  const selectedCount = useMemo(
-    () => items.filter((item) => item.selected).length,
-    [items],
-  )
-
-  const handleModifiedChange = useCallback(
-    (value: string) => {
-      setItems((prev) =>
-        prev.map((item) =>
-          item.id === activeId ? { ...item, modifiedContent: value } : item,
-        ),
-      )
-    },
-    [activeId],
-  )
-
-  const handleToggle = useCallback((id: string) => {
-    setItems((prev) =>
-      prev.map((item) =>
-        item.id === id ? { ...item, selected: !item.selected } : item,
-      ),
-    )
-  }, [])
-
-  const handleConfirm = useCallback(() => {
-    if (saving) return
-    setSaving(true)
-    onConfirm(items.filter((item) => item.selected))
-  }, [items, onConfirm, saving])
-
-  if (!open) return null
-
-  const isNew = !activeItem?.originalContent.trim()
-
-  return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
-      <div className="flex h-[85vh] w-full max-w-7xl 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">
-            <FileText className="h-5 w-5 text-blue-500" />
-            <div>
-              <h3 className="font-semibold">{title}</h3>
-              {description && (
-                <p className="text-xs text-muted-foreground">{description}</p>
-              )}
-            </div>
-          </div>
-          <div className="flex items-center gap-3">
-            <button
-              onClick={onClose}
-              className="rounded-md p-1 hover:bg-accent"
-              aria-label="关闭"
-            >
-              <X className="h-4 w-4" />
-            </button>
-          </div>
-        </div>
-
-        {/* 主体:文件栏 + 工作区 */}
-        <div className="flex min-h-0 flex-1">
-          {/* 左侧文件栏 */}
-          {items.length > 1 && (
-            <div className="w-40 shrink-0 border-r bg-muted/20 px-2 py-3">
-              <div className="mb-2 text-[10px] font-medium text-muted-foreground px-1">
-                待写入文件
-              </div>
-              {items.map((item) => (
-                <label
-                  key={item.id}
-                  className={`flex cursor-pointer items-start gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-accent ${
-                    activeId === item.id ? "bg-accent/60" : ""
-                  }`}
-                >
-                  <input
-                    aria-label={`保存 ${item.fileName}`}
-                    type="checkbox"
-                    checked={item.selected}
-                    onChange={() => handleToggle(item.id)}
-                    onClick={(e) => e.stopPropagation()}
-                    className="mt-0.5 h-3.5 w-3.5 shrink-0 accent-primary"
-                  />
-                  <span
-                    className="min-w-0 flex-1 truncate"
-                    onClick={() => setActiveId(item.id)}
-                  >
-                    {item.fileName}
-                    {!item.originalContent.trim() && (
-                      <span className="ml-1 text-[10px] text-blue-500">新建</span>
-                    )}
-                    {item.error && (
-                      <span className="block text-[10px] text-red-500">{item.error}</span>
-                    )}
-                  </span>
-                </label>
-              ))}
-            </div>
-          )}
-
-          {/* 中央工作区 */}
-          <div className="flex min-w-0 flex-1 flex-col">
-            {activeItem ? (
-              <>
-                <div className="flex items-center justify-between border-b px-3 py-1.5 text-xs text-muted-foreground">
-                  <span className="font-medium">{activeItem.fileName}</span>
-                  {isNew && (
-                    <span className="text-blue-600 dark:text-blue-400">新建文件</span>
-                  )}
-                </div>
-
-                <AiChangeComparePanel
-                  originalContent={activeItem.originalContent}
-                  modifiedContent={activeItem.modifiedContent}
-                  onModifiedContentChange={handleModifiedChange}
-                  resetKey={activeItem.id}
-                />
-              </>
-            ) : (
-              <div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
-                请从左侧选择文件
-              </div>
-            )}
-          </div>
-        </div>
-
-        {/* 底部栏 */}
-        <div className="flex items-center justify-between border-t px-4 py-3">
-          <span className="text-xs text-muted-foreground">
-            将保存 {selectedCount} 个文件
-          </span>
-          <div className="flex items-center gap-2">
-            <button
-              onClick={onClose}
-              className="rounded-md border px-4 py-1.5 text-sm hover:bg-accent"
-            >
-              取消
-            </button>
-            <button
-              onClick={handleConfirm}
-              disabled={selectedCount === 0 || saving}
-              className="flex items-center gap-1.5 rounded-md bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
-            >
-              <Check className="h-4 w-4" />
-              确认保存
-            </button>
-          </div>
-        </div>
-      </div>
-    </div>
-  )
-}

+ 2 - 2
src/components/common/context-hub-details.tsx

@@ -10,9 +10,9 @@ import {
   type ContextHubSnapshotRef,
   type ContextHubStats,
 } from "@/lib/context-hub/types"
-import { ContextHubStatsSummary, ProviderCacheUsage } from "./context-hub-stats-summary"
+import { ContextHubStatsSummary } from "./context-hub-stats-summary"
+
 
-export { ProviderCacheUsage }
 
 type ContextSection = "stableCore" | "sessionSummary" | "dynamicContext"
 

+ 2 - 2
src/components/common/context-hub-stats-summary.tsx

@@ -10,7 +10,7 @@ const STABLE_PREFIX_LABELS: Record<StablePrefixStatus, string> = {
   persist_failed: "持久化失败",
 }
 
-export interface ContextHubStatsSummaryProps {
+interface ContextHubStatsSummaryProps {
   stats: ContextHubStats
   warnings?: string[]
   /** Show composed/budget row (default true). */
@@ -53,7 +53,7 @@ const REQUEST_STATUS_LABELS: Record<LlmRequestCacheTrace["status"], string> = {
   network_error: "网络错误",
 }
 
-export function ProviderCacheUsage({ stats }: { stats: ContextHubStats }) {
+function ProviderCacheUsage({ stats }: { stats: ContextHubStats }) {
   const cachedTokens = stats.providerCachedTokens
   const inputTokens = stats.providerInputTokens
   const hitPercent = cachedTokens !== undefined && inputTokens !== undefined && inputTokens > 0

+ 1 - 1
src/components/common/conversation-run-status-icon.tsx

@@ -2,7 +2,7 @@ import { CircleAlert, CircleCheck, LoaderCircle, PauseCircle } from "lucide-reac
 import type { ConversationRunState } from "@/lib/conversation-run-state"
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
 
-export function getConversationRunStatusLabel(state: ConversationRunState): string {
+function getConversationRunStatusLabel(state: ConversationRunState): string {
   if (state.status === "running") return "正在生成"
   if (state.status === "completed_unread") return "已完成,点击查看"
   if (state.status === "failed") return `生成失败:${state.error ?? "未知错误"}`

+ 1 - 8
src/components/common/timeline-types.ts

@@ -83,13 +83,6 @@ export type TimelineEvent =
   | { kind: "tool_call"; data: ToolCallEventItem }
   | { kind: "tool_group"; data: ToolCallGroupItem }
 
-export interface EventStreamProps {
-  events: TimelineEvent[]
-  isStreaming: boolean
-  totalDurationMs?: number
-  totalTokens?: number
-}
-
 export function interleaveThinkingWithToolCalls(
   thinkingText: string,
   thinkingStreaming: boolean,
@@ -117,7 +110,7 @@ export function interleaveThinkingWithToolCalls(
   return events
 }
 
-export interface StreamingEventBuilder {
+interface StreamingEventBuilder {
   update: (
     thinkingText: string,
     toolCalls: ToolCallEventItem[],

+ 1 - 1
src/components/editor/textarea-find-bar.tsx

@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react"
 import { ChevronDown, ChevronUp, X } from "lucide-react"
 import { isImeComposing } from "@/lib/keyboard-utils"
 
-export interface TextareaFindBarProps {
+interface TextareaFindBarProps {
   open: boolean
   query: string
   activeMatchIndex: number

+ 1 - 1
src/components/editor/textarea-find-highlights.tsx

@@ -4,7 +4,7 @@ import {
   type FindHighlightPart,
 } from "@/lib/textarea-find"
 
-export interface TextareaFindHighlightsProps {
+interface TextareaFindHighlightsProps {
   text: string
   query: string
   matches: number[]

+ 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 - 6
src/components/layout/preview-panel.tsx

@@ -52,7 +52,6 @@ import {
   type ChapterSelectionAction,
 } from "@/lib/chapter-selection"
 import { shouldApplyDiskToEditor } from "@/lib/editor-disk-sync"
-import { registerEditorDiskSyncHandler } from "@/lib/editor-disk-sync-session"
 import { registerEditorExternalUpdateHandler } from "@/lib/editor-external-update-session"
 import { createChapterExternalUpdateCoordinator } from "@/lib/chapter-external-update-coordinator"
 import { applyOpenChapterBodyUpdate, createDeAiBatchChapterApplier } from "@/lib/novel/de-ai-batch/chapter-apply"
@@ -354,11 +353,6 @@ export function PreviewPanel() {
 
   useEffect(() => registerEditorExternalUpdateHandler(applyExternalChapterBody), [applyExternalChapterBody])
 
-  useEffect(() => {
-    registerEditorDiskSyncHandler(applyDiskSyncIfSafe)
-    return () => registerEditorDiskSyncHandler(null)
-  }, [applyDiskSyncIfSafe])
-
   useEffect(() => {
     setChapterDeAiSkillId(undefined)
   }, [project?.path])

+ 0 - 4
src/components/novel/book-analysis-import-task-panel.tsx

@@ -21,7 +21,6 @@ interface BookAnalysisImportTaskPanelProps {
   onRenameCompleted: (taskId: string, title: string) => void
   onOpenBook: (bookId: string) => void
 }
-
 interface TaskGroup {
   batchId?: string
   tasks: BatchImportTask[]
@@ -189,7 +188,6 @@ function BatchSummary({ tasks, missingCount }: { tasks: BatchImportTask[]; missi
     </p>
   )
 }
-
 function buildTaskGroups(batches: BatchImportBatch[], tasks: BatchImportTask[]) {
   const taskMap = new Map(tasks.map((task) => [task.id, task]))
   const batchMap = new Map(batches.map((batch) => [batch.id, batch]))
@@ -364,5 +362,3 @@ export function BookAnalysisImportTaskPanel({
     </section>
   )
 }
-
-export type { BookAnalysisImportTaskPanelProps }

+ 1 - 1
src/components/novel/book-analysis-module-view.tsx

@@ -12,7 +12,7 @@ import {
 import { BookAnalysisCharacterPanel } from "./book-analysis-character-panel"
 import { BookAnalysisStyleCard } from "./book-analysis-style-card"
 
-export type BookAnalysisModuleTab = "characters" | "story" | "style" | "evidence"
+type BookAnalysisModuleTab = "characters" | "story" | "style" | "evidence"
 
 interface BookAnalysisModuleViewProps {
   book: BookAnalysisLibraryBook

+ 1 - 1
src/components/novel/character-selection-panel.tsx

@@ -13,7 +13,7 @@ import {
 import type { RecognizedCharacter } from "@/lib/novel/book-analysis/types"
 import { Search } from "lucide-react"
 
-export interface CharacterSelectionPanelProps {
+interface CharacterSelectionPanelProps {
   characters: RecognizedCharacter[]
   selectedIds: string[]
   onToggle: (id: string) => void

+ 8 - 0
src/components/novel/de-ai-batch-review-dialog.spec.tsx

@@ -5,6 +5,14 @@ import { createRoot, type Root } from "react-dom/client"
 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
 import type { DeAiBatchTaskRecord } from "@/lib/novel/de-ai-batch/types"
 import { DeAiBatchReviewDialog } from "./de-ai-batch-review-dialog"
+
+vi.mock("react-diff-viewer-continued", () => ({
+  default: ({ oldValue, newValue }: { oldValue?: string; newValue?: string }) => (
+    <div>{oldValue}{newValue}</div>
+  ),
+  DiffMethod: { CHARS: "chars" },
+}))
+
 vi.mock("@/components/ui/resizable", () => ({
   ResizablePanelGroup: ({ direction: _direction, ...props }: ComponentPropsWithoutRef<"div"> & { direction?: string }) => <div {...props} />,
   ResizablePanel: ({ defaultSize: _defaultSize, minSize: _minSize, ...props }: ComponentPropsWithoutRef<"div"> & { defaultSize?: number; minSize?: number }) => <div {...props} />,

+ 1 - 1
src/components/novel/de-ai-batch-review-dialog.tsx

@@ -26,7 +26,7 @@ const CHAPTER_STATUS: Record<DeAiBatchChapter["status"], string> = {
   cancelled: "已取消",
 }
 
-export interface DeAiBatchReviewDialogProps {
+interface DeAiBatchReviewDialogProps {
   open: boolean
   record: DeAiBatchTaskRecord | null
   currentChapterId: string | null

+ 1 - 1
src/components/novel/hooks/use-character-extraction.ts

@@ -30,7 +30,7 @@ export interface ChapterSelectionData {
   extractionPhase?: "deep" | "simple" | null
 }
 
-export interface UseCharacterExtractionParams {
+interface UseCharacterExtractionParams {
   chapterSelectionData: ChapterSelectionData | null
   setChapterSelectionData: React.Dispatch<React.SetStateAction<ChapterSelectionData | null>>
   recognizedCharacters: RecognizedCharacter[]

+ 1 - 1
src/components/novel/hooks/use-character-recognition.ts

@@ -9,7 +9,7 @@ import { saveRecognizedCharacters } from "@/lib/novel/book-analysis/recognized-c
 import type { RecognizedCharacter } from "@/lib/novel/book-analysis/types"
 import type { ChapterSelectionData } from "./use-character-extraction"
 
-export interface UseCharacterRecognitionParams {
+interface UseCharacterRecognitionParams {
   chapterSelectionData: ChapterSelectionData | null
   setChapterSelectionData: React.Dispatch<React.SetStateAction<ChapterSelectionData | null>>
   recognizedCharacters: RecognizedCharacter[]

+ 1 - 1
src/components/novel/hooks/use-library-operations.ts

@@ -20,7 +20,7 @@ import { hasUsableLlm } from "@/lib/has-usable-llm"
 import type { AnalysisDepth } from "@/lib/novel/book-analysis/types"
 import type { ChapterSelectionData } from "./use-character-extraction"
 
-export interface UseLibraryOperationsParams {
+interface UseLibraryOperationsParams {
   currentProjectPath: string | null
   selectedLibraryBook: BookAnalysisLibraryBook | null
   libraryState: BookAnalysisLibraryState

+ 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>
-  )
-}

+ 1 - 1
src/components/settings/context-size-selector.tsx

@@ -1,7 +1,7 @@
 import { useTranslation } from "react-i18next"
 import { normalizeUserLlmContextSize } from "@/lib/llm-context-size"
 
-export const CONTEXT_PRESETS = [
+const CONTEXT_PRESETS = [
   { value: 204800, label: "200K" },
   { value: 262144, label: "256K" },
   { value: 307200, label: "300K" },

+ 2 - 2
src/components/settings/hooks/use-batch-model-test.ts

@@ -2,14 +2,14 @@ import { useState, useCallback } from "react"
 import { testSettingsLlmModel } from "@/lib/settings-model-test"
 import type { LlmConfig } from "@/stores/wiki-store"
 
-export interface BatchModelTestState {
+interface BatchModelTestState {
   loading: boolean
   success: boolean
   message: string
   failedModels?: string[]
 }
 
-export interface UseBatchModelTestResult {
+interface UseBatchModelTestResult {
   modelTestState: BatchModelTestState
   runBatchTest: (modelsToTest: string[], buildConfig: (modelId: string) => LlmConfig) => Promise<void>
   retryFailed: (buildConfig: (modelId: string) => LlmConfig) => Promise<void>

+ 0 - 32
src/components/settings/llm-presets.ts

@@ -575,35 +575,3 @@ export const LLM_PRESETS: LlmPreset[] = ALL_LLM_PRESETS
 export function findLlmPresetById(id: string): LlmPreset | undefined {
   return ALL_LLM_PRESETS.find((preset) => preset.id === id)
 }
-
-/**
- * Best-effort reverse lookup: given the current LlmConfig fields, which
- * preset does it most likely correspond to? Used so the dropdown can
- * show the user what they're effectively on.
- */
-export function matchPreset(params: {
-  provider: Provider
-  customEndpoint: string
-  ollamaUrl: string
-  apiMode?: CustomApiMode
-}): LlmPreset | null {
-  const norm = (u: string) => u.replace(/\/+$/, "").toLowerCase()
-  const { provider, customEndpoint, ollamaUrl, apiMode } = params
-
-  for (const preset of LLM_PRESETS) {
-    if (preset.provider !== provider) continue
-    if (provider === "custom") {
-      if (!preset.baseUrl) continue // skip the generic Custom catch-alls
-      if (norm(preset.baseUrl) !== norm(customEndpoint)) continue
-      if ((preset.apiMode ?? "chat_completions") !== (apiMode ?? "chat_completions"))
-        continue
-      return preset
-    }
-    if (provider === "ollama") {
-      if (preset.baseUrl && norm(preset.baseUrl) !== norm(ollamaUrl)) continue
-      return preset
-    }
-    return preset
-  }
-  return null
-}

+ 1 - 1
src/components/settings/model-select-input.tsx

@@ -14,7 +14,7 @@ interface ModelSelectOption {
   label: string
 }
 
-export function buildModelSelectOptions(value: string, options: string[]): ModelSelectOption[] {
+function buildModelSelectOptions(value: string, options: string[]): ModelSelectOption[] {
   const current = value.trim()
   const fetched = Array.from(new Set(options.map((item) => item.trim()).filter(Boolean)))
   const hasFetchedModels = fetched.length > 0

+ 1 - 1
src/components/settings/output-tokens-selector.tsx

@@ -1,7 +1,7 @@
 import { useTranslation } from "react-i18next"
 import { normalizeUserLlmMaxOutputTokens } from "@/lib/llm-context-size"
 
-export const OUTPUT_TOKEN_PRESETS = [
+const OUTPUT_TOKEN_PRESETS = [
   { value: 65536, label: "64K" },
   { value: 131072, label: "128K" },
   { value: 262144, label: "256K" },

+ 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", () => {

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

@@ -21,7 +21,7 @@ import {
   normalizeUserLlmMaxOutputTokens,
 } from "@/lib/llm-context-size"
 
-export interface CustomProviderCard {
+interface CustomProviderCard {
   id: string
   label: string
   apiMode: "chat_completions" | "responses" | "anthropic_messages"

+ 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>
-  )
-}

+ 2 - 2
src/components/skill-library/de-ai-skill-errors.ts

@@ -1,7 +1,7 @@
 import { isDeAiSkillConfigCorruptError } from "@/lib/novel/de-ai-skill-library"
 
-export const DE_AI_SKILL_CONFIG_CORRUPT_MESSAGE = "技能库配置文件损坏,请到技能库恢复配置"
-export const DE_AI_SKILL_LOAD_FAILED_MESSAGE = "读取去AI味技能失败"
+const DE_AI_SKILL_CONFIG_CORRUPT_MESSAGE = "技能库配置文件损坏,请到技能库恢复配置"
+const DE_AI_SKILL_LOAD_FAILED_MESSAGE = "读取去AI味技能失败"
 
 export function getDeAiSkillLoadErrorMessage(error: unknown): string {
   return isDeAiSkillConfigCorruptError(error)

+ 1 - 1
src/components/skill-library/de-ai-skill-picker.tsx

@@ -13,7 +13,7 @@ interface DeAiSkillPickerProps {
   showLibraryShortcut?: boolean
 }
 
-export { getDeAiSkillLoadErrorMessage } from "./de-ai-skill-errors"
+
 
 interface DeAiSkillOptionsPanelProps {
   loading: boolean

+ 0 - 132
src/components/skill-library/unified-skill-model.spec.ts

@@ -1,132 +0,0 @@
-import { describe, expect, it } from "vitest"
-import type { DeAiSkillConfig } from "@/lib/novel/de-ai-skill-library"
-import type { UserSkillConfig } from "@/lib/novel/user-skill-store"
-import {
-  buildUnifiedSkillEntries,
-  filterUnifiedSkillEntries,
-  getUnifiedSkillCategory,
-  getUnifiedSkillStatus,
-} from "./unified-skill-model"
-
-const deAiConfig: DeAiSkillConfig = {
-  version: 1,
-  defaultSkillId: "built-in:comprehensive",
-  disabledSkillIds: ["project:quiet"],
-  lastChapterDeAiSkillId: null,
-  projectSkills: [
-    {
-      id: "project:quiet",
-      name: "沉浸式去AI味",
-      description: "减少解释腔和总结腔",
-      templateId: "custom",
-      content: "删除协作口吻,保留角色语气。",
-      source: "project",
-      createdAt: 100,
-      updatedAt: 200,
-    },
-  ],
-  builtInSkillOverrides: [
-    {
-      id: "built-in:comprehensive",
-      name: "综合去AI味-项目版",
-      description: "项目覆盖版本",
-      templateId: "comprehensive",
-      content: "去掉模板句和机械总结。",
-      source: "built-in",
-      updatedAt: 300,
-    },
-  ],
-}
-
-const writingConfig: UserSkillConfig = {
-  version: 1,
-  selectedSkillId: "skill:rhythm",
-  disabledSkillIds: [],
-  categories: [],
-  skills: [
-    {
-      id: "skill:rhythm",
-      name: "节奏压迫",
-      description: "用于严格模式下审稿和改写章节节奏",
-      kind: ["review", "structure"],
-      stages: ["review", "rewrite"],
-      modes: ["strict"],
-      content: "检查中段塌陷、重复场景和结尾钩子。",
-      source: "uploaded",
-      priority: 0,
-      tags: [],
-      categoryId: "",
-      createdAt: 100,
-      updatedAt: 200,
-    },
-  ],
-}
-
-describe("unified skill model", () => {
-  it("builds unified entries from writing and de-AI skill configs", () => {
-    const entries = buildUnifiedSkillEntries(deAiConfig, writingConfig)
-
-    expect(entries.map((entry) => entry.id)).toContain("de-ai:project:quiet")
-    expect(entries.map((entry) => entry.id)).toContain("writing:skill:rhythm")
-
-    const deAi = entries.find((entry) => entry.id === "de-ai:project:quiet")
-    expect(deAi).toMatchObject({
-      library: "de-ai",
-      source: "project",
-      enabled: false,
-      defaultSkill: false,
-      category: "去AI味",
-      kind: ["style", "rewrite"],
-      stages: ["rewrite", "output"],
-      modes: ["fast", "standard", "strict"],
-    })
-
-    const writing = entries.find((entry) => entry.id === "writing:skill:rhythm")
-    expect(writing).toMatchObject({
-      library: "writing",
-      source: "uploaded",
-      enabled: true,
-      defaultSkill: true,
-      category: "审稿",
-      kind: ["review", "structure"],
-      stages: ["review", "rewrite"],
-      modes: ["strict"],
-    })
-  })
-
-  it("marks overridden built-in de-AI skill as modified", () => {
-    const entries = buildUnifiedSkillEntries(deAiConfig, writingConfig)
-    const overridden = entries.find((entry) => entry.id === "de-ai:built-in:comprehensive")
-
-    expect(overridden).toMatchObject({
-      name: "综合去AI味-项目版",
-      modified: true,
-      defaultSkill: true,
-      status: "启用",
-    })
-  })
-
-  it("filters entries by query, category, status, mode, stage and kind", () => {
-    const entries = buildUnifiedSkillEntries(deAiConfig, writingConfig)
-
-    expect(filterUnifiedSkillEntries(entries, { query: "压迫" }).map((entry) => entry.id))
-      .toEqual(["writing:skill:rhythm"])
-    expect(filterUnifiedSkillEntries(entries, { category: "去AI味" }).every((entry) => entry.library === "de-ai"))
-      .toBe(true)
-    expect(filterUnifiedSkillEntries(entries, { status: "disabled" }).map((entry) => entry.id))
-      .toEqual(["de-ai:project:quiet"])
-    expect(filterUnifiedSkillEntries(entries, { mode: "strict", stage: "review", kind: "review" }).map((entry) => entry.id))
-      .toEqual(["writing:skill:rhythm"])
-  })
-
-  it("returns user-facing category and status labels", () => {
-    const entries = buildUnifiedSkillEntries(deAiConfig, writingConfig)
-    const deAi = entries.find((entry) => entry.id === "de-ai:project:quiet")
-    const writing = entries.find((entry) => entry.id === "writing:skill:rhythm")
-
-    expect(deAi && getUnifiedSkillCategory(deAi)).toBe("去AI味")
-    expect(writing && getUnifiedSkillCategory(writing)).toBe("审稿")
-    expect(deAi && getUnifiedSkillStatus(deAi)).toBe("停用")
-    expect(writing && getUnifiedSkillStatus(writing)).toBe("启用")
-  })
-})

+ 0 - 169
src/components/skill-library/unified-skill-model.ts

@@ -1,169 +0,0 @@
-import {
-  getAllDeAiSkills,
-  isDeAiSkillModified,
-  type DeAiSkillConfig,
-  type DeAiSkillSource,
-} from "@/lib/novel/de-ai-skill-library"
-import {
-  SKILL_KIND_LABELS,
-  SKILL_STAGE_LABELS,
-  type SkillKind,
-  type SkillMode,
-  type SkillStage,
-} from "@/lib/novel/skill-library"
-import type { UserSkillConfig } from "@/lib/novel/user-skill-store"
-
-export type UnifiedSkillLibrary = "writing" | "de-ai"
-export type UnifiedSkillSource = DeAiSkillSource | "built-in" | "project" | "uploaded" | "linked"
-export type UnifiedSkillStatusFilter = "enabled" | "disabled"
-
-export interface UnifiedSkillEntry {
-  id: string
-  skillId: string
-  library: UnifiedSkillLibrary
-  name: string
-  description: string
-  content: string
-  kind: SkillKind[]
-  stages: SkillStage[]
-  modes: SkillMode[]
-  enabled: boolean
-  source: UnifiedSkillSource
-  modified: boolean
-  defaultSkill: boolean
-  category: string
-  status: "启用" | "停用"
-  searchText: string
-}
-
-export interface UnifiedSkillFilter {
-  query?: string
-  library?: UnifiedSkillLibrary
-  category?: string
-  status?: UnifiedSkillStatusFilter
-  mode?: SkillMode
-  stage?: SkillStage
-  kind?: SkillKind
-}
-
-const DE_AI_KIND: SkillKind[] = ["style", "rewrite"]
-const DE_AI_STAGES: SkillStage[] = ["rewrite", "output"]
-const DE_AI_MODES: SkillMode[] = ["fast", "standard", "strict"]
-
-function createSearchText(values: Array<string | string[] | undefined>): string {
-  return values
-    .flatMap((value) => Array.isArray(value) ? value : [value])
-    .filter((value): value is string => Boolean(value))
-    .join("\n")
-    .toLocaleLowerCase()
-}
-
-function getWritingSkillCategory(kind: SkillKind[], stages: SkillStage[]): string {
-  const preferredKind = kind.find((item) => item === "review" || item === "output" || item === "knowledge")
-    ?? kind[0]
-  if (preferredKind) return SKILL_KIND_LABELS[preferredKind]
-  const preferredStage = stages[0]
-  return preferredStage ? SKILL_STAGE_LABELS[preferredStage] : "写作"
-}
-
-export function buildUnifiedSkillEntries(
-  deAiConfig: DeAiSkillConfig,
-  writingConfig: UserSkillConfig,
-): UnifiedSkillEntry[] {
-  const disabledDeAiIds = new Set(deAiConfig.disabledSkillIds)
-  const disabledWritingIds = new Set(writingConfig.disabledSkillIds)
-
-  const deAiEntries = getAllDeAiSkills(deAiConfig).map((skill): UnifiedSkillEntry => {
-    const enabled = !disabledDeAiIds.has(skill.id)
-    const category = "去AI味"
-    const status = enabled ? "启用" : "停用"
-    return {
-      id: `de-ai:${skill.id}`,
-      skillId: skill.id,
-      library: "de-ai",
-      name: skill.name,
-      description: skill.description,
-      content: skill.content,
-      kind: DE_AI_KIND,
-      stages: DE_AI_STAGES,
-      modes: DE_AI_MODES,
-      enabled,
-      source: skill.source,
-      modified: isDeAiSkillModified(deAiConfig, skill.id),
-      defaultSkill: deAiConfig.defaultSkillId === skill.id,
-      category,
-      status,
-      searchText: createSearchText([
-        skill.name,
-        skill.description,
-        skill.content,
-        category,
-        status,
-        DE_AI_KIND,
-        DE_AI_STAGES,
-        DE_AI_MODES,
-      ]),
-    }
-  })
-
-  const writingEntries = writingConfig.skills.map((skill): UnifiedSkillEntry => {
-    const enabled = !disabledWritingIds.has(skill.id)
-    const category = getWritingSkillCategory(skill.kind, skill.stages)
-    const status = enabled ? "启用" : "停用"
-    return {
-      id: `writing:${skill.id}`,
-      skillId: skill.id,
-      library: "writing",
-      name: skill.name,
-      description: skill.description,
-      content: skill.content,
-      kind: skill.kind,
-      stages: skill.stages,
-      modes: skill.modes,
-      enabled,
-      source: skill.source,
-      modified: skill.source !== "built-in" && typeof skill.updatedAt === "number" && skill.updatedAt > (skill.createdAt ?? 0),
-      defaultSkill: writingConfig.selectedSkillId === skill.id,
-      category,
-      status,
-      searchText: createSearchText([
-        skill.name,
-        skill.description,
-        skill.content,
-        category,
-        status,
-        skill.kind,
-        skill.stages,
-        skill.modes,
-      ]),
-    }
-  })
-
-  return [...writingEntries, ...deAiEntries]
-}
-
-export function filterUnifiedSkillEntries(
-  entries: UnifiedSkillEntry[],
-  filter: UnifiedSkillFilter,
-): UnifiedSkillEntry[] {
-  const query = filter.query?.trim().toLocaleLowerCase()
-  return entries.filter((entry) => {
-    if (query && !entry.searchText.includes(query)) return false
-    if (filter.library && entry.library !== filter.library) return false
-    if (filter.category && entry.category !== filter.category) return false
-    if (filter.status === "enabled" && !entry.enabled) return false
-    if (filter.status === "disabled" && entry.enabled) return false
-    if (filter.mode && !entry.modes.includes(filter.mode)) return false
-    if (filter.stage && !entry.stages.includes(filter.stage)) return false
-    if (filter.kind && !entry.kind.includes(filter.kind)) return false
-    return true
-  })
-}
-
-export function getUnifiedSkillCategory(entry: UnifiedSkillEntry): string {
-  return entry.category
-}
-
-export function getUnifiedSkillStatus(entry: UnifiedSkillEntry): "启用" | "停用" {
-  return entry.enabled ? "启用" : "停用"
-}

+ 0 - 1
src/components/sources/outline-chat-panel.spec.tsx

@@ -812,7 +812,6 @@ describe("OutlineChatPanel controls", () => {
   })
 
   it("routes every outline generation menu item through the PRD 3.1 content workflow", () => {
-    expect(source).toContain("buildOutlineSectionGenerationPrompt")
     expect(source).toContain("## AI大纲生成工作流")
     expect(source).toContain("提取请求关键词")
     expect(source).toContain("识别用户意图")

+ 0 - 48
src/components/sources/outline-chat-panel.tsx

@@ -551,35 +551,6 @@ function getOutlineSectionOutputRules(title: string): string {
   return "按可保存的大纲正文输出:标题清楚、条目完整、能直接指导后续小说写作。";
 }
 
-export function buildOutlineSectionGenerationPrompt(
-  title: string,
-  requestHint: string,
-): string {
-  return [
-    `请按「AI大纲生成工作流」生成「${title}」。`,
-    "",
-    "## 本次意图",
-    "generate_outline",
-    "",
-    "## PRD 3.1 主流程要求",
-    "1. 提取请求关键词:确认用户要生成的大纲分项、范围和已有约束。",
-    "2. 识别用户意图:本次是生成/完善大纲正文,不是审稿报告、工具报告或分析说明。",
-    "3. 读取资料:优先读取用户 @ 引用、已有大纲、章节、记忆、推演和历史会话。",
-    "4. 提取对小说创作有用的关键内容:章节目标、冲突、伏笔、人物动机、设定限制、时间线承接和结尾钩子。",
-    "5. 结合用户要用的 skill + soul.md 约束,生成可直接保存的大纲正文。",
-    "6. 结果强约束收敛:最终回复只输出大纲标题和大纲正文。",
-    "",
-    "## 禁止输出",
-    "禁止输出工具调用报告、分析过程、完成报告、下一步行动、等待工具结果、已读取资料清单、泛泛建议。",
-    "",
-    "## 本分项内容要求",
-    requestHint,
-    getOutlineSectionOutputRules(title),
-    "",
-    "如果资料不足以完整生成,请只问一个最关键的澄清问题;如果资料足够,直接输出完整正文。",
-  ].join("\n");
-}
-
 function buildGenerationPrompt(
   title: string,
   requestHint: string,
@@ -802,25 +773,6 @@ function formatOutlineConversationDate(timestamp: number): string {
   return date.toLocaleDateString([], { month: "short", day: "numeric" });
 }
 
-export async function getUniqueOutlinePath(
-  outlinesDir: string,
-  fileName: string,
-): Promise<string> {
-  const normalizedFileName = fileName.toLowerCase().endsWith(".md")
-    ? fileName
-    : `${fileName}.md`;
-  const firstPath = `${outlinesDir}/${normalizedFileName}`;
-  if (!(await fileExists(firstPath))) return firstPath;
-  const extensionIndex = normalizedFileName.lastIndexOf(".");
-  const stem = extensionIndex > 0 ? normalizedFileName.slice(0, extensionIndex) : normalizedFileName;
-  const extension = extensionIndex > 0 ? normalizedFileName.slice(extensionIndex) : "";
-  for (let i = 2; i <= 99; i++) {
-    const candidate = `${outlinesDir}/${stem}-${i}${extension}`;
-    if (!(await fileExists(candidate))) return candidate;
-  }
-  return `${outlinesDir}/${stem}-${Date.now()}${extension}`;
-}
-
 function buildFallbackCharacterDraftsFromRequests(
   requests: OutlineSaveRequest[],
 ): CharacterSaveDraft[] {

+ 0 - 434
src/components/sources/outline-file-tree-panel.spec.tsx

@@ -1,434 +0,0 @@
-// @vitest-environment jsdom
-
-import { act } from "react"
-import { createRoot, type Root } from "react-dom/client"
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
-import type { FileNode } from "@/types/wiki"
-import { useWikiStore } from "@/stores/wiki-store"
-import { useOutlineChatStore } from "@/stores/outline-chat-store"
-import { OutlineFileTreePanel } from "./outline-file-tree-panel"
-
-;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT =
-  true
-
-const { copyFileMock, createDirectoryMock, deleteFileMock, fileExistsMock, listDirectoryMock, writeFileMock } = vi.hoisted(() => ({
-  copyFileMock: vi.fn(async () => undefined),
-  createDirectoryMock: vi.fn(async () => undefined),
-  deleteFileMock: vi.fn(async () => undefined),
-  fileExistsMock: vi.fn(async () => false),
-  listDirectoryMock: vi.fn(),
-  writeFileMock: vi.fn(async () => undefined),
-}))
-
-vi.mock("@/commands/fs", () => ({
-  createDirectory: createDirectoryMock,
-  listDirectory: listDirectoryMock,
-  copyFile: copyFileMock,
-  deleteFile: deleteFileMock,
-  fileExists: fileExistsMock,
-  writeFile: writeFileMock,
-}))
-
-const outlineNodes: FileNode[] = [
-  {
-    name: "章纲",
-    path: "C:/Book/wiki/outlines/章纲",
-    is_dir: true,
-    children: [
-      {
-        name: "1.md",
-        path: "C:/Book/wiki/outlines/章纲/1.md",
-        is_dir: false,
-      },
-    ],
-  },
-  {
-    name: "大纲",
-    path: "C:/Book/wiki/outlines/大纲",
-    is_dir: true,
-    children: [],
-  },
-]
-
-describe("OutlineFileTreePanel", () => {
-  let host: HTMLDivElement
-  let root: Root
-
-  beforeEach(() => {
-    copyFileMock.mockClear()
-    createDirectoryMock.mockClear()
-    deleteFileMock.mockClear()
-    fileExistsMock.mockClear()
-    listDirectoryMock.mockClear()
-    writeFileMock.mockClear()
-    fileExistsMock.mockResolvedValue(false)
-    listDirectoryMock.mockResolvedValue(outlineNodes)
-    useWikiStore.setState({
-      project: { id: "p1", name: "测试书", path: "C:/Book" },
-      fileTree: [],
-      selectedFile: null,
-      fileContent: "",
-      dataVersion: 0,
-    })
-    useOutlineChatStore.setState({
-      conversations: [],
-      activeConversationId: null,
-      streamingContent: "",
-      isStreaming: false,
-      loaded: false,
-      pendingReferenceTokens: [],
-    })
-    host = document.createElement("div")
-    document.body.appendChild(host)
-    root = createRoot(host)
-  })
-
-  afterEach(() => {
-    act(() => root.unmount())
-    host.remove()
-  })
-
-  it("项目数据版本变化后刷新左侧大纲文件树", async () => {
-    await act(async () => {
-      root.render(<OutlineFileTreePanel showHeader={false} />)
-    })
-    await act(async () => {
-      await Promise.resolve()
-    })
-
-    listDirectoryMock.mockClear()
-
-    await act(async () => {
-      useWikiStore.getState().bumpDataVersion()
-      await Promise.resolve()
-    })
-
-    expect(listDirectoryMock).toHaveBeenCalledWith("C:/Book/wiki/outlines")
-  })
-
-  it("重命名大纲文件后刷新左侧树并更新选中文件", async () => {
-    useWikiStore.setState({
-      selectedFile: "C:/Book/wiki/outlines/章纲/1.md",
-    })
-
-    await act(async () => {
-      root.render(<OutlineFileTreePanel showHeader={false} />)
-    })
-    await act(async () => {
-      await Promise.resolve()
-    })
-
-    const fileButton = Array.from(host.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("1.md"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      fileButton.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }))
-    })
-
-    const renameButton = Array.from(document.body.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("重命名"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      renameButton.click()
-    })
-
-    const input = host.querySelector("[data-testid='outline-rename-input']") as HTMLInputElement
-    await act(async () => {
-      input.value = "新文件.md"
-      input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }))
-      await Promise.resolve()
-    })
-
-    expect(copyFileMock).toHaveBeenCalledWith(
-      "C:/Book/wiki/outlines/章纲/1.md",
-      "C:/Book/wiki/outlines/章纲/新文件.md",
-    )
-    expect(deleteFileMock).toHaveBeenCalledWith("C:/Book/wiki/outlines/章纲/1.md")
-    expect(useWikiStore.getState().selectedFile).toBe("C:/Book/wiki/outlines/章纲/新文件.md")
-  })
-
-  it("发送到 AI 大纲会话时只加入大纲文件引用", async () => {
-    await act(async () => {
-      root.render(<OutlineFileTreePanel showHeader={false} />)
-    })
-    await act(async () => {
-      await Promise.resolve()
-    })
-
-    const fileButton = Array.from(host.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("1.md"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      fileButton.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }))
-    })
-
-    const sendButton = Array.from(document.body.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("发送到AI大纲会话"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      sendButton.click()
-    })
-
-    expect(useOutlineChatStore.getState().pendingReferenceTokens).toEqual([
-      {
-        id: "outline:C:/Book/wiki/outlines/章纲/1.md",
-        category: "outline",
-        title: "1.md",
-        path: "C:/Book/wiki/outlines/章纲/1.md",
-        displayTitle: "1.md",
-      },
-    ])
-  })
-
-  it("加载时迁移旧的带文件夹后缀目录到新目录", async () => {
-    const migratedNodes: FileNode[] = [
-      {
-        name: "人物小传文件夹",
-        path: "C:/Book/wiki/outlines/人物小传文件夹",
-        is_dir: true,
-        children: [{
-          name: "角色-男主-林辰.md",
-          path: "C:/Book/wiki/outlines/人物小传文件夹/角色-男主-林辰.md",
-          is_dir: false,
-        }],
-      },
-      {
-        name: "人物小传",
-        path: "C:/Book/wiki/outlines/人物小传",
-        is_dir: true,
-        children: [],
-      },
-    ]
-    listDirectoryMock.mockResolvedValue(migratedNodes)
-    fileExistsMock.mockResolvedValue(false)
-
-    await act(async () => {
-      root.render(<OutlineFileTreePanel showHeader={false} />)
-    })
-    await act(async () => {
-      await Promise.resolve()
-    })
-
-    expect(copyFileMock).toHaveBeenCalledWith(
-      "C:/Book/wiki/outlines/人物小传文件夹/角色-男主-林辰.md",
-      "C:/Book/wiki/outlines/人物小传/角色-男主-林辰.md",
-    )
-    expect(deleteFileMock).toHaveBeenCalledWith("C:/Book/wiki/outlines/人物小传文件夹/角色-男主-林辰.md")
-    expect(deleteFileMock).toHaveBeenCalledWith("C:/Book/wiki/outlines/人物小传文件夹")
-  })
-
-  it("迁移旧设定目录时合并同名默认子目录,不生成带序号的重复文件夹", async () => {
-    const migratedNodes: FileNode[] = [
-      {
-        name: "设定文件夹",
-        path: "C:/Book/wiki/outlines/设定文件夹",
-        is_dir: true,
-        children: [
-          {
-            name: "世界观",
-            path: "C:/Book/wiki/outlines/设定文件夹/世界观",
-            is_dir: true,
-            children: [
-              {
-                name: "规则.md",
-                path: "C:/Book/wiki/outlines/设定文件夹/世界观/规则.md",
-                is_dir: false,
-              },
-            ],
-          },
-        ],
-      },
-      {
-        name: "设定",
-        path: "C:/Book/wiki/outlines/设定",
-        is_dir: true,
-        children: [
-          {
-            name: "世界观",
-            path: "C:/Book/wiki/outlines/设定/世界观",
-            is_dir: true,
-            children: [],
-          },
-        ],
-      },
-    ]
-    listDirectoryMock.mockResolvedValue(migratedNodes)
-    fileExistsMock.mockImplementation(async (path?: unknown) => path === "C:/Book/wiki/outlines/设定/世界观")
-
-    await act(async () => {
-      root.render(<OutlineFileTreePanel showHeader={false} />)
-    })
-    await act(async () => {
-      await Promise.resolve()
-    })
-
-    expect(copyFileMock).toHaveBeenCalledWith(
-      "C:/Book/wiki/outlines/设定文件夹/世界观/规则.md",
-      "C:/Book/wiki/outlines/设定/世界观/规则.md",
-    )
-    expect(copyFileMock).not.toHaveBeenCalledWith(
-      "C:/Book/wiki/outlines/设定文件夹/世界观/规则.md",
-      "C:/Book/wiki/outlines/设定/世界观-2/规则.md",
-    )
-    expect(deleteFileMock).toHaveBeenCalledWith("C:/Book/wiki/outlines/设定文件夹/世界观")
-    expect(deleteFileMock).toHaveBeenCalledWith("C:/Book/wiki/outlines/设定文件夹")
-  })
-
-  it("加载时清理设定下带序号的重复默认子目录", async () => {
-    const duplicatedNodes: FileNode[] = [
-      {
-        name: "设定",
-        path: "C:/Book/wiki/outlines/设定",
-        is_dir: true,
-        children: [
-          {
-            name: "世界观",
-            path: "C:/Book/wiki/outlines/设定/世界观",
-            is_dir: true,
-            children: [],
-          },
-          {
-            name: "世界观-2",
-            path: "C:/Book/wiki/outlines/设定/世界观-2",
-            is_dir: true,
-            children: [
-              {
-                name: "规则.md",
-                path: "C:/Book/wiki/outlines/设定/世界观-2/规则.md",
-                is_dir: false,
-              },
-            ],
-          },
-        ],
-      },
-    ]
-    listDirectoryMock.mockResolvedValue(duplicatedNodes)
-    fileExistsMock.mockResolvedValue(false)
-
-    await act(async () => {
-      root.render(<OutlineFileTreePanel showHeader={false} />)
-    })
-    await act(async () => {
-      await Promise.resolve()
-    })
-
-    expect(copyFileMock).toHaveBeenCalledWith(
-      "C:/Book/wiki/outlines/设定/世界观-2/规则.md",
-      "C:/Book/wiki/outlines/设定/世界观/规则.md",
-    )
-    expect(deleteFileMock).toHaveBeenCalledWith("C:/Book/wiki/outlines/设定/世界观-2/规则.md")
-    expect(deleteFileMock).toHaveBeenCalledWith("C:/Book/wiki/outlines/设定/世界观-2")
-  })
-
-  it("加载时将设定下的伏笔目录合并到顶层伏笔并删除嵌套目录", async () => {
-    const duplicatedForeshadowingNodes: FileNode[] = [
-      {
-        name: "伏笔",
-        path: "C:/Book/wiki/outlines/伏笔",
-        is_dir: true,
-        children: [],
-      },
-      {
-        name: "设定",
-        path: "C:/Book/wiki/outlines/设定",
-        is_dir: true,
-        children: [
-          {
-            name: "伏笔",
-            path: "C:/Book/wiki/outlines/设定/伏笔",
-            is_dir: true,
-            children: [
-              {
-                name: "主线伏笔.md",
-                path: "C:/Book/wiki/outlines/设定/伏笔/主线伏笔.md",
-                is_dir: false,
-              },
-            ],
-          },
-        ],
-      },
-    ]
-    listDirectoryMock.mockResolvedValue(duplicatedForeshadowingNodes)
-    fileExistsMock.mockResolvedValue(false)
-
-    await act(async () => {
-      root.render(<OutlineFileTreePanel showHeader={false} />)
-    })
-    await act(async () => {
-      await Promise.resolve()
-    })
-
-    expect(createDirectoryMock).not.toHaveBeenCalledWith("C:/Book/wiki/outlines/设定/伏笔")
-    expect(copyFileMock).toHaveBeenCalledWith(
-      "C:/Book/wiki/outlines/设定/伏笔/主线伏笔.md",
-      "C:/Book/wiki/outlines/伏笔/主线伏笔.md",
-    )
-    expect(deleteFileMock).toHaveBeenCalledWith("C:/Book/wiki/outlines/设定/伏笔/主线伏笔.md")
-    expect(deleteFileMock).toHaveBeenCalledWith("C:/Book/wiki/outlines/设定/伏笔")
-  })
-
-  it("右键文件夹后可新建文档、新建文件夹和删除文件夹", async () => {
-    const promptSpy = vi.spyOn(window, "prompt")
-    const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true)
-
-    await act(async () => {
-      root.render(<OutlineFileTreePanel showHeader={false} />)
-    })
-    await act(async () => {
-      await Promise.resolve()
-    })
-
-    const folderButton = Array.from(host.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("大纲"),
-    ) as HTMLButtonElement
-
-    promptSpy.mockReturnValueOnce("新文档")
-    await act(async () => {
-      folderButton.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }))
-    })
-    let createFileButton = Array.from(document.body.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("新建文档"),
-    ) as HTMLButtonElement
-    await act(async () => {
-      createFileButton.click()
-      await Promise.resolve()
-    })
-    expect(writeFileMock).toHaveBeenCalledWith(
-      "C:/Book/wiki/outlines/大纲/新文档.md",
-      "# 新文档\n\n",
-    )
-
-    promptSpy.mockReturnValueOnce("子目录")
-    await act(async () => {
-      folderButton.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }))
-    })
-    const createFolderButton = Array.from(document.body.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("新建文件夹"),
-    ) as HTMLButtonElement
-    await act(async () => {
-      createFolderButton.click()
-      await Promise.resolve()
-    })
-    expect(createDirectoryMock).toHaveBeenCalledWith("C:/Book/wiki/outlines/大纲/子目录")
-
-    await act(async () => {
-      folderButton.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }))
-    })
-    const deleteButton = Array.from(document.body.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("删除"),
-    ) as HTMLButtonElement
-    await act(async () => {
-      deleteButton.click()
-      await Promise.resolve()
-    })
-    expect(confirmSpy).toHaveBeenCalled()
-    expect(deleteFileMock).toHaveBeenCalledWith("C:/Book/wiki/outlines/大纲")
-
-    promptSpy.mockRestore()
-    confirmSpy.mockRestore()
-  })
-})

+ 0 - 395
src/components/sources/outline-file-tree-panel.tsx

@@ -1,395 +0,0 @@
-import { useCallback, useEffect, useState } from "react"
-import type { FileNode } from "@/types/wiki"
-import { copyDirectory, copyFile, createDirectory, deleteFile, fileExists, listDirectory, writeFile } from "@/commands/fs"
-import { useWikiStore } from "@/stores/wiki-store"
-import { useOutlineChatStore } from "@/stores/outline-chat-store"
-import { OutlineFileTree } from "@/components/sources/outline-file-tree"
-import {
-  DEFAULT_OUTLINE_FOLDER_PATHS,
-  DEFAULT_SETTING_FOLDER_NAMES,
-  LEGACY_OUTLINE_FOLDER_MIGRATIONS,
-  getDefaultOutlineFolderPath,
-  getOutlineRoot,
-  planOutlineFileMove,
-} from "@/lib/novel/outline-workbench"
-import { normalizePath } from "@/lib/path-utils"
-
-interface OutlineFileTreePanelProps {
-  showHeader?: boolean
-}
-
-function getDirName(path: string): string {
-  const normalized = normalizePath(path)
-  const index = normalized.lastIndexOf("/")
-  return index >= 0 ? normalized.slice(0, index) : ""
-}
-
-function ensureMarkdownFileName(name: string): string {
-  const trimmed = name.trim()
-  return trimmed.toLowerCase().endsWith(".md") ? trimmed : `${trimmed}.md`
-}
-
-function getBaseName(path: string): string {
-  return normalizePath(path).split("/").pop() ?? ""
-}
-
-function assertSafeName(name: string, label: string) {
-  if (!name || name.includes("/") || name.includes("\\")) {
-    throw new Error(`${label}不能包含路径分隔符。`)
-  }
-}
-
-async function resolveUniquePath(path: string): Promise<string> {
-  if (!(await fileExists(path))) return path
-  const normalized = normalizePath(path)
-  const slashIndex = normalized.lastIndexOf("/")
-  const dir = slashIndex >= 0 ? normalized.slice(0, slashIndex) : ""
-  const name = slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized
-  const dotIndex = name.lastIndexOf(".")
-  const stem = dotIndex > 0 ? name.slice(0, dotIndex) : name
-  const ext = dotIndex > 0 ? name.slice(dotIndex) : ""
-  for (let index = 2; index <= 99; index++) {
-    const candidate = `${dir}/${stem}-${index}${ext}`
-    if (!(await fileExists(candidate))) return candidate
-  }
-  return `${dir}/${stem}-${Date.now()}${ext}`
-}
-
-async function migrateNodeChildren(sourceNode: FileNode, targetDir: string) {
-  await createDirectory(targetDir)
-  for (const child of sourceNode.children ?? []) {
-    if (child.is_dir) {
-      const targetPath = `${targetDir}/${child.name}`
-      await migrateNodeChildren(child, targetPath)
-      await deleteFile(child.path)
-    } else {
-      const targetPath = await resolveUniquePath(`${targetDir}/${child.name}`)
-      await copyFile(child.path, targetPath)
-      await deleteFile(child.path)
-    }
-  }
-}
-
-async function migrateLegacyOutlineFolders(projectPath: string, nodes: FileNode[]): Promise<boolean> {
-  const outlineRoot = getOutlineRoot(projectPath)
-  let migrated = false
-  for (const migration of LEGACY_OUTLINE_FOLDER_MIGRATIONS) {
-    const source = nodes.find((node) => node.is_dir && node.name === migration.from)
-    if (!source) continue
-    const targetDir = `${outlineRoot}/${migration.to}`
-    await migrateNodeChildren(source, targetDir)
-    await deleteFile(source.path)
-    migrated = true
-  }
-  return migrated
-}
-
-function getDuplicatedDefaultSettingName(name: string): string | null {
-  const match = name.match(/^(.*)-\d+$/)
-  if (!match) return null
-  const baseName = match[1]?.trim()
-  return DEFAULT_SETTING_FOLDER_NAMES.some((item) => item === baseName) ? baseName : null
-}
-
-async function cleanupDuplicatedSettingFolders(nodes: FileNode[]): Promise<boolean> {
-  const settingNode = nodes.find((node) => node.is_dir && node.name === "设定")
-  if (!settingNode?.children?.length) return false
-
-  let migrated = false
-  for (const child of settingNode.children) {
-    if (!child.is_dir) continue
-    const baseName = getDuplicatedDefaultSettingName(child.name)
-    if (!baseName) continue
-    const targetDir = `${settingNode.path}/${baseName}`
-    await migrateNodeChildren(child, targetDir)
-    await deleteFile(child.path)
-    migrated = true
-  }
-
-  return migrated
-}
-
-function isNestedForeshadowingFolder(name: string): boolean {
-  return name === "伏笔" || /^伏笔-\d+$/.test(name)
-}
-
-async function cleanupNestedForeshadowingFolders(nodes: FileNode[]): Promise<boolean> {
-  const settingNode = nodes.find((node) => node.is_dir && node.name === "设定")
-  if (!settingNode?.children?.length) return false
-
-  const outlineRoot = getDirName(settingNode.path)
-  const targetDir = `${outlineRoot}/伏笔`
-  let migrated = false
-  for (const child of settingNode.children) {
-    if (!child.is_dir || !isNestedForeshadowingFolder(child.name)) continue
-    await migrateNodeChildren(child, targetDir)
-    await deleteFile(child.path)
-    migrated = true
-  }
-
-  return migrated
-}
-
-export function OutlineFileTreePanel({ showHeader = true }: OutlineFileTreePanelProps) {
-  const project = useWikiStore((s) => s.project)
-  const selectedFile = useWikiStore((s) => s.selectedFile)
-  const setSelectedFile = useWikiStore((s) => s.setSelectedFile)
-  const setFileTree = useWikiStore((s) => s.setFileTree)
-  const bumpDataVersion = useWikiStore((s) => s.bumpDataVersion)
-  const dataVersion = useWikiStore((s) => s.dataVersion)
-  const enqueueOutlineReferenceTokens = useOutlineChatStore((s) => s.enqueueReferenceTokens)
-  const [outlineNodes, setOutlineNodes] = useState<FileNode[]>([])
-  const [loading, setLoading] = useState(false)
-  const [error, setError] = useState<string | null>(null)
-
-  const refreshTrees = useCallback(async () => {
-    if (!project) return
-    const projectPath = normalizePath(project.path)
-    const outlineRoot = getOutlineRoot(projectPath)
-    const [nextOutlineNodes, nextProjectTree] = await Promise.all([
-      listDirectory(outlineRoot),
-      listDirectory(projectPath),
-    ])
-    setOutlineNodes(nextOutlineNodes)
-    setFileTree(nextProjectTree)
-    bumpDataVersion()
-  }, [bumpDataVersion, project, setFileTree])
-
-  useEffect(() => {
-    if (!project) {
-      setOutlineNodes([])
-      return
-    }
-
-    let cancelled = false
-    async function load() {
-      if (!project) return
-      setLoading(true)
-      setError(null)
-      try {
-        const projectPath = normalizePath(project.path)
-        await Promise.all(
-          DEFAULT_OUTLINE_FOLDER_PATHS.map((folderPath) =>
-            createDirectory(getDefaultOutlineFolderPath(projectPath, folderPath)),
-          ),
-        )
-        const outlineRoot = getOutlineRoot(projectPath)
-        const initialNodes = await listDirectory(outlineRoot)
-        const migrated = await migrateLegacyOutlineFolders(projectPath, initialNodes)
-        const afterMigrationNodes = migrated ? await listDirectory(outlineRoot) : initialNodes
-        const cleaned = await cleanupDuplicatedSettingFolders(afterMigrationNodes)
-        const afterSettingCleanupNodes = cleaned ? await listDirectory(outlineRoot) : afterMigrationNodes
-        const cleanedForeshadowing = await cleanupNestedForeshadowingFolders(afterSettingCleanupNodes)
-        const nodes = cleanedForeshadowing ? await listDirectory(outlineRoot) : afterSettingCleanupNodes
-        if (cancelled) return
-        setOutlineNodes(nodes)
-      } catch (err) {
-        if (cancelled) return
-        setError(err instanceof Error ? err.message : String(err))
-      } finally {
-        if (!cancelled) setLoading(false)
-      }
-    }
-
-    void load()
-    return () => {
-      cancelled = true
-    }
-  }, [dataVersion, project])
-
-  const handleMoveFile = useCallback(
-    async (sourcePath: string, targetFolderPath: string) => {
-      if (!project) return
-      const outlineRoot = getOutlineRoot(project.path)
-      const initialPlan = planOutlineFileMove({
-        outlineRoot,
-        sourcePath,
-        targetFolderPath,
-        targetExists: false,
-      })
-      if (!initialPlan.ok) throw new Error(initialPlan.error)
-
-      const exists = await fileExists(initialPlan.targetPath)
-      const plan = planOutlineFileMove({
-        outlineRoot,
-        sourcePath,
-        targetFolderPath,
-        targetExists: exists,
-      })
-      if (!plan.ok) throw new Error(plan.error)
-
-      await copyFile(sourcePath, plan.targetPath)
-      await deleteFile(sourcePath)
-      if (selectedFile === sourcePath) {
-        setSelectedFile(plan.targetPath)
-      }
-      await refreshTrees()
-    },
-    [project, refreshTrees, selectedFile, setSelectedFile],
-  )
-
-  const handleRenameFile = useCallback(
-    async (sourcePath: string, nextName: string) => {
-      if (!project) return
-      const fileName = ensureMarkdownFileName(nextName)
-      if (!fileName || fileName.includes("/") || fileName.includes("\\")) {
-        throw new Error("文件名不能包含路径分隔符。")
-      }
-      const source = normalizePath(sourcePath)
-      const targetPath = `${getDirName(source)}/${fileName}`
-      if (targetPath === source) return
-
-      const exists = await fileExists(targetPath)
-      if (exists) throw new Error("目标文件已存在,请更换文件名。")
-
-      await copyFile(source, targetPath)
-      await deleteFile(source)
-      if (selectedFile === sourcePath || normalizePath(selectedFile ?? "") === source) {
-        setSelectedFile(targetPath)
-      }
-      await refreshTrees()
-    },
-    [project, refreshTrees, selectedFile, setSelectedFile],
-  )
-
-  const handleRenameFolder = useCallback(
-    async (sourcePath: string, nextName: string) => {
-      if (!project) return
-      const folderName = nextName.trim()
-      assertSafeName(folderName, "文件夹名称")
-      const source = normalizePath(sourcePath)
-      const targetPath = `${getDirName(source)}/${folderName}`
-      if (targetPath === source) return
-
-      const exists = await fileExists(targetPath)
-      if (exists) throw new Error("目标文件夹已存在,请更换名称。")
-
-      await copyDirectory(source, targetPath)
-      await deleteFile(source)
-      const normalizedSelected = normalizePath(selectedFile ?? "")
-      if (normalizedSelected === source || normalizedSelected.startsWith(`${source}/`)) {
-        setSelectedFile(normalizedSelected ? normalizedSelected.replace(source, targetPath) : null)
-      }
-      await refreshTrees()
-    },
-    [project, refreshTrees, selectedFile, setSelectedFile],
-  )
-
-  const handleCreateFile = useCallback(
-    async (folderPath: string) => {
-      const rawName = window.prompt("请输入新文档名称")
-      if (rawName === null) return
-      const fileName = ensureMarkdownFileName(rawName)
-      assertSafeName(fileName, "文件名")
-      const baseTitle = fileName.replace(/\.md$/i, "")
-      const targetPath = `${normalizePath(folderPath)}/${fileName}`
-      const exists = await fileExists(targetPath)
-      if (exists) throw new Error("目标文件已存在,请更换文件名。")
-      await writeFile(targetPath, `# ${baseTitle}\n\n`)
-      setSelectedFile(targetPath)
-      await refreshTrees()
-    },
-    [refreshTrees, setSelectedFile],
-  )
-
-  const handleCreateFolder = useCallback(
-    async (folderPath: string) => {
-      const rawName = window.prompt("请输入新文件夹名称")
-      if (rawName === null) return
-      const folderName = rawName.trim()
-      assertSafeName(folderName, "文件夹名称")
-      const targetPath = `${normalizePath(folderPath)}/${folderName}`
-      const exists = await fileExists(targetPath)
-      if (exists) throw new Error("目标文件夹已存在,请更换名称。")
-      await createDirectory(targetPath)
-      await refreshTrees()
-    },
-    [refreshTrees],
-  )
-
-  const handleDeleteFile = useCallback(
-    async (sourcePath: string) => {
-      const sourceName = getBaseName(sourcePath)
-      if (!window.confirm(`确认删除大纲文件“${sourceName}”吗?此操作不可撤销。`)) return
-      const source = normalizePath(sourcePath)
-      await deleteFile(source)
-      if (normalizePath(selectedFile ?? "") === source) {
-        setSelectedFile(null)
-      }
-      await refreshTrees()
-    },
-    [refreshTrees, selectedFile, setSelectedFile],
-  )
-
-  const handleDeleteFolder = useCallback(
-    async (sourcePath: string) => {
-      const sourceName = getBaseName(sourcePath)
-      if (!window.confirm(`确认删除文件夹“${sourceName}”及其全部内容吗?此操作不可撤销。`)) return
-      const source = normalizePath(sourcePath)
-      await deleteFile(source)
-      const normalizedSelected = normalizePath(selectedFile ?? "")
-      if (normalizedSelected === source || normalizedSelected.startsWith(`${source}/`)) {
-        setSelectedFile(null)
-      }
-      await refreshTrees()
-    },
-    [refreshTrees, selectedFile, setSelectedFile],
-  )
-
-  const handleSendToOutlineChat = useCallback(
-    (sourcePath: string, sourceName: string) => {
-      const normalizedPath = normalizePath(sourcePath)
-      enqueueOutlineReferenceTokens([
-        {
-          id: `outline:${normalizedPath}`,
-          category: "outline",
-          title: sourceName,
-          path: normalizedPath,
-          displayTitle: sourceName,
-        },
-      ])
-    },
-    [enqueueOutlineReferenceTokens],
-  )
-
-  if (!project) {
-    return (
-      <div className="px-3 py-4 text-xs text-muted-foreground">
-        请先打开项目
-      </div>
-    )
-  }
-
-  if (error) {
-    return (
-      <div className="m-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
-        大纲目录加载失败:{error}
-      </div>
-    )
-  }
-
-  if (loading && outlineNodes.length === 0) {
-    return (
-      <div className="px-3 py-4 text-xs text-muted-foreground">
-        正在加载大纲目录...
-      </div>
-    )
-  }
-
-  return (
-    <OutlineFileTree
-      nodes={outlineNodes}
-      selectedPath={selectedFile}
-      onSelectFile={setSelectedFile}
-      onMoveFile={handleMoveFile}
-      onRenameFile={handleRenameFile}
-      onRenameFolder={handleRenameFolder}
-      onDeleteFile={handleDeleteFile}
-      onDeleteFolder={handleDeleteFolder}
-      onCreateFile={handleCreateFile}
-      onCreateFolder={handleCreateFolder}
-      onSendToOutlineChat={handleSendToOutlineChat}
-      showHeader={showHeader}
-    />
-  )
-}

+ 0 - 340
src/components/sources/outline-file-tree.spec.tsx

@@ -1,340 +0,0 @@
-// @vitest-environment jsdom
-
-import { act } from "react"
-import { createRoot, type Root } from "react-dom/client"
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
-import type { FileNode } from "@/types/wiki"
-import { OutlineFileTree } from "./outline-file-tree"
-
-;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT =
-  true
-
-const tree: FileNode[] = [
-  {
-    name: "章纲",
-    path: "C:/Book/wiki/outlines/章纲",
-    is_dir: true,
-    children: [
-      {
-        name: "章纲-第001章.md",
-        path: "C:/Book/wiki/outlines/章纲/章纲-第001章.md",
-        is_dir: false,
-      },
-    ],
-  },
-  {
-    name: "大纲",
-    path: "C:/Book/wiki/outlines/大纲",
-    is_dir: true,
-    children: [
-      {
-        name: "总纲.md",
-        path: "C:/Book/wiki/outlines/大纲/总纲.md",
-        is_dir: false,
-      },
-    ],
-  },
-]
-
-describe("OutlineFileTree", () => {
-  let host: HTMLDivElement
-  let root: Root
-
-  beforeEach(() => {
-    host = document.createElement("div")
-    document.body.appendChild(host)
-    root = createRoot(host)
-  })
-
-  afterEach(() => {
-    act(() => root.unmount())
-    host.remove()
-  })
-
-  it("渲染大纲目录并在点击 Markdown 文件时选中文件", async () => {
-    const onSelectFile = vi.fn()
-    const onMoveFile = vi.fn(async () => {})
-
-    await act(async () => {
-      root.render(
-        <OutlineFileTree
-          nodes={tree}
-          selectedPath={null}
-          onSelectFile={onSelectFile}
-          onMoveFile={onMoveFile}
-        />,
-      )
-    })
-
-    expect(host.textContent).toContain("章纲")
-    expect(host.textContent).toContain("大纲")
-
-    const fileButton = Array.from(host.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("章纲-第001章.md"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      fileButton.click()
-    })
-
-    expect(onSelectFile).toHaveBeenCalledWith("C:/Book/wiki/outlines/章纲/章纲-第001章.md")
-    expect(host.querySelector("[data-testid='outline-file-context-menu']")).toBeNull()
-    expect(host.querySelector("[data-testid='outline-move-submenu']")).toBeNull()
-    expect(onMoveFile).not.toHaveBeenCalled()
-  })
-
-  it("右键 Markdown 文件只显示一级操作菜单,点击移动后才显示目标文件夹", async () => {
-    const onMoveFile = vi.fn(async () => {})
-
-    await act(async () => {
-      root.render(
-        <OutlineFileTree
-          nodes={tree}
-          selectedPath={null}
-          onSelectFile={() => {}}
-          onMoveFile={onMoveFile}
-        />,
-      )
-    })
-
-    const fileButton = Array.from(host.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("章纲-第001章.md"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      fileButton.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }))
-    })
-
-    const menu = document.body.querySelector("[data-testid='outline-file-context-menu']")
-    expect(menu).not.toBeNull()
-    expect(menu?.textContent ?? "").toContain("重命名")
-    expect(menu?.textContent ?? "").toContain("移动")
-    expect(menu?.textContent ?? "").toContain("发送到AI大纲会话")
-    expect(document.body.querySelector("[data-testid='outline-move-submenu']")).toBeNull()
-
-    const moveButton = Array.from(menu?.querySelectorAll("button") ?? []).find((button) =>
-      button.textContent?.includes("移动"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      moveButton.click()
-    })
-
-    const moveMenu = document.body.querySelector("[data-testid='outline-move-submenu']")
-    expect(moveMenu).not.toBeNull()
-    expect(moveMenu?.textContent ?? "").toContain("大纲")
-
-    const targetButton = Array.from(moveMenu?.querySelectorAll("button") ?? []).find((button) =>
-      button.textContent?.includes("大纲"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      targetButton.click()
-    })
-
-    expect(onMoveFile).toHaveBeenCalledWith(
-      "C:/Book/wiki/outlines/章纲/章纲-第001章.md",
-      "C:/Book/wiki/outlines/大纲",
-    )
-  })
-
-  it("右键菜单打开后点击其他区域会自动关闭", async () => {
-    await act(async () => {
-      root.render(
-        <OutlineFileTree
-          nodes={tree}
-          selectedPath={null}
-          onSelectFile={() => {}}
-          onMoveFile={async () => {}}
-        />,
-      )
-    })
-
-    const fileButton = Array.from(host.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("章纲-第001章.md"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      fileButton.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }))
-    })
-
-    expect(document.body.querySelector("[data-testid='outline-file-context-menu']")).not.toBeNull()
-
-    await act(async () => {
-      document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }))
-    })
-
-    expect(document.body.querySelector("[data-testid='outline-file-context-menu']")).toBeNull()
-  })
-
-  it("右键重命名后在文件行内编辑文件名并用回车提交", async () => {
-    const onRenameFile = vi.fn(async () => {})
-
-    await act(async () => {
-      root.render(
-        <OutlineFileTree
-          nodes={tree}
-          selectedPath={null}
-          onSelectFile={() => {}}
-          onMoveFile={async () => {}}
-          onRenameFile={onRenameFile}
-        />,
-      )
-    })
-
-    const fileButton = Array.from(host.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("章纲-第001章.md"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      fileButton.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }))
-    })
-
-    const menu = document.body.querySelector("[data-testid='outline-file-context-menu']")
-    const renameButton = Array.from(menu?.querySelectorAll("button") ?? []).find((button) =>
-      button.textContent?.includes("重命名"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      renameButton.click()
-    })
-
-    const input = host.querySelector("[data-testid='outline-rename-input']") as HTMLInputElement
-    expect(input).not.toBeNull()
-
-    await act(async () => {
-      input.value = "新章纲.md"
-      input.dispatchEvent(new Event("input", { bubbles: true }))
-      input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }))
-    })
-
-    expect(onRenameFile).toHaveBeenCalledWith(
-      "C:/Book/wiki/outlines/章纲/章纲-第001章.md",
-      "新章纲.md",
-    )
-  })
-
-  it("右键发送到 AI 大纲会话时只传递文件引用", async () => {
-    const onSendToOutlineChat = vi.fn()
-
-    await act(async () => {
-      root.render(
-        <OutlineFileTree
-          nodes={tree}
-          selectedPath={null}
-          onSelectFile={() => {}}
-          onMoveFile={async () => {}}
-          onSendToOutlineChat={onSendToOutlineChat}
-        />,
-      )
-    })
-
-    const fileButton = Array.from(host.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("章纲-第001章.md"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      fileButton.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }))
-    })
-
-    const menu = document.body.querySelector("[data-testid='outline-file-context-menu']")
-    const sendButton = Array.from(menu?.querySelectorAll("button") ?? []).find((button) =>
-      button.textContent?.includes("发送到AI大纲会话"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      sendButton.click()
-    })
-
-    expect(onSendToOutlineChat).toHaveBeenCalledWith(
-      "C:/Book/wiki/outlines/章纲/章纲-第001章.md",
-      "章纲-第001章.md",
-    )
-  })
-
-  it("拖拽 Markdown 文件到任意文件夹时移动到该文件夹", async () => {
-    const onMoveFile = vi.fn(async () => {})
-
-    await act(async () => {
-      root.render(
-        <OutlineFileTree
-          nodes={tree}
-          selectedPath={null}
-          onSelectFile={() => {}}
-          onMoveFile={onMoveFile}
-        />,
-      )
-    })
-
-    const fileButton = Array.from(host.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("章纲-第001章.md"),
-    ) as HTMLButtonElement
-    const folderButton = Array.from(host.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("大纲"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      fileButton.dispatchEvent(new Event("dragstart", { bubbles: true }))
-    })
-
-    await act(async () => {
-      folderButton.dispatchEvent(new Event("dragover", { bubbles: true }))
-      folderButton.dispatchEvent(new Event("drop", { bubbles: true }))
-    })
-
-    expect(onMoveFile).toHaveBeenCalledWith(
-      "C:/Book/wiki/outlines/章纲/章纲-第001章.md",
-      "C:/Book/wiki/outlines/大纲",
-    )
-  })
-
-  it("右键文件夹显示新建、重命名和删除操作", async () => {
-    const onCreateFile = vi.fn()
-    const onCreateFolder = vi.fn()
-    const onRenameFolder = vi.fn()
-    const onDeleteFolder = vi.fn()
-
-    await act(async () => {
-      root.render(
-        <OutlineFileTree
-          nodes={tree}
-          selectedPath={null}
-          onSelectFile={() => {}}
-          onMoveFile={async () => {}}
-          onCreateFile={onCreateFile}
-          onCreateFolder={onCreateFolder}
-          onRenameFolder={onRenameFolder}
-          onDeleteFolder={onDeleteFolder}
-        />,
-      )
-    })
-
-    const folderButton = Array.from(host.querySelectorAll("button")).find((button) =>
-      button.textContent?.includes("章纲"),
-    ) as HTMLButtonElement
-
-    await act(async () => {
-      folderButton.dispatchEvent(new MouseEvent("contextmenu", {
-        bubbles: true,
-        clientX: 24,
-        clientY: 36,
-      }))
-    })
-
-    const menu = document.body.querySelector("[data-testid='outline-file-context-menu']")
-    expect(menu).not.toBeNull()
-    expect(menu?.textContent ?? "").toContain("新建文档")
-    expect(menu?.textContent ?? "").toContain("新建文件夹")
-    expect(menu?.textContent ?? "").toContain("重命名")
-    expect(menu?.textContent ?? "").toContain("删除")
-    expect(menu?.textContent ?? "").not.toContain("发送到AI大纲会话")
-
-    const createFileButton = Array.from(menu?.querySelectorAll("button") ?? []).find((button) =>
-      button.textContent?.includes("新建文档"),
-    ) as HTMLButtonElement
-    await act(async () => {
-      createFileButton.click()
-    })
-    expect(onCreateFile).toHaveBeenCalledWith("C:/Book/wiki/outlines/章纲")
-  })
-})

+ 0 - 618
src/components/sources/outline-file-tree.tsx

@@ -1,618 +0,0 @@
-import { useEffect, useMemo, useState } from "react"
-import { createPortal } from "react-dom"
-import { ChevronDown, ChevronRight, FileText, FilePlus, Folder, FolderInput, FolderPlus, MessageCircle, Pencil, Trash2 } from "lucide-react"
-import type { FileNode } from "@/types/wiki"
-
-interface OutlineFileTreeProps {
-  nodes: FileNode[]
-  selectedPath: string | null
-  onSelectFile: (path: string) => void
-  onMoveFile: (sourcePath: string, targetFolderPath: string) => Promise<void> | void
-  onRenameFile?: (sourcePath: string, nextName: string) => Promise<void> | void
-  onRenameFolder?: (sourcePath: string, nextName: string) => Promise<void> | void
-  onDeleteFile?: (sourcePath: string) => Promise<void> | void
-  onDeleteFolder?: (sourcePath: string) => Promise<void> | void
-  onCreateFile?: (folderPath: string) => Promise<void> | void
-  onCreateFolder?: (folderPath: string) => Promise<void> | void
-  onSendToOutlineChat?: (sourcePath: string, sourceName: string) => void
-  showHeader?: boolean
-}
-
-interface ContextMenuState {
-  sourcePath: string
-  sourceName: string
-  isDir: boolean
-  x: number
-  y: number
-}
-
-interface RenameState {
-  sourcePath: string
-  isDir: boolean
-  value: string
-}
-
-function collectFolders(nodes: FileNode[]): FileNode[] {
-  const folders: FileNode[] = []
-  for (const node of nodes) {
-    if (!node.is_dir) continue
-    folders.push(node)
-    if (node.children?.length) {
-      folders.push(...collectFolders(node.children))
-    }
-  }
-  return folders
-}
-
-function collectFolderPaths(nodes: FileNode[], result = new Set<string>()): Set<string> {
-  for (const node of nodes) {
-    if (!node.is_dir) continue
-    result.add(node.path)
-    if (node.children?.length) collectFolderPaths(node.children, result)
-  }
-  return result
-}
-
-function OutlineContextMenu({
-  menu,
-  folders,
-  moveMenuOpen,
-  onOpenMoveSubmenu,
-  onMoveToFolder,
-  onCreateFile,
-  onCreateFolder,
-  onStartRename,
-  onDeleteNode,
-  onSendToOutlineChat,
-}: {
-  menu: ContextMenuState
-  folders: FileNode[]
-  moveMenuOpen: boolean
-  onOpenMoveSubmenu: (sourcePath: string) => void
-  onMoveToFolder: (targetFolderPath: string) => void
-  onCreateFile?: (folderPath: string) => void
-  onCreateFolder?: (folderPath: string) => void
-  onStartRename: (sourcePath: string, sourceName: string, isDir: boolean) => void
-  onDeleteNode?: (sourcePath: string, isDir: boolean) => void
-  onSendToOutlineChat?: (sourcePath: string, sourceName: string) => void
-}) {
-  return createPortal(
-    <div
-      className="fixed z-50 w-44 rounded-md border bg-popover p-1 text-xs text-popover-foreground shadow-md"
-      data-testid="outline-file-context-menu"
-      data-outline-context-menu="true"
-      style={{ left: menu.x, top: menu.y }}
-    >
-      {menu.isDir ? (
-        <>
-          <button
-            type="button"
-            className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-accent"
-            onClick={() => onCreateFile?.(menu.sourcePath)}
-          >
-            <FilePlus className="h-3.5 w-3.5" />
-            新建文档
-          </button>
-          <button
-            type="button"
-            className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-accent"
-            onClick={() => onCreateFolder?.(menu.sourcePath)}
-          >
-            <FolderPlus className="h-3.5 w-3.5" />
-            新建文件夹
-          </button>
-          <button
-            type="button"
-            className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-accent"
-            onClick={() => onStartRename(menu.sourcePath, menu.sourceName, true)}
-          >
-            <Pencil className="h-3.5 w-3.5" />
-            重命名
-          </button>
-          <button
-            type="button"
-            className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-destructive hover:bg-destructive/10"
-            onClick={() => onDeleteNode?.(menu.sourcePath, true)}
-          >
-            <Trash2 className="h-3.5 w-3.5" />
-            删除
-          </button>
-        </>
-      ) : (
-        <>
-          <button
-            type="button"
-            className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-accent"
-            onClick={() => onStartRename(menu.sourcePath, menu.sourceName, false)}
-          >
-            <Pencil className="h-3.5 w-3.5" />
-            重命名
-          </button>
-          <div className="relative">
-            <button
-              type="button"
-              className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-accent"
-              onClick={() => onOpenMoveSubmenu(menu.sourcePath)}
-            >
-              <FolderInput className="h-3.5 w-3.5" />
-              移动
-              <ChevronRight className="ml-auto h-3 w-3" />
-            </button>
-            {moveMenuOpen ? (
-              <div
-                className="absolute left-full top-0 z-50 flex max-h-44 w-44 flex-col gap-1 overflow-y-auto rounded-md border bg-popover p-1 shadow-md"
-                data-testid="outline-move-submenu"
-              >
-                {folders.map((folder) => (
-                  <button
-                    key={folder.path}
-                    type="button"
-                    className="flex items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-accent"
-                    onClick={() => onMoveToFolder(folder.path)}
-                    title={folder.path}
-                  >
-                    <Folder className="h-3.5 w-3.5 shrink-0 text-blue-500" />
-                    <span className="truncate">{folder.name}</span>
-                  </button>
-                ))}
-              </div>
-            ) : null}
-          </div>
-          {onSendToOutlineChat ? (
-            <button
-              type="button"
-              className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-accent"
-              onClick={() => onSendToOutlineChat(menu.sourcePath, menu.sourceName)}
-            >
-              <MessageCircle className="h-3.5 w-3.5" />
-              发送到AI大纲会话
-            </button>
-          ) : null}
-          <button
-            type="button"
-            className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-destructive hover:bg-destructive/10"
-            onClick={() => onDeleteNode?.(menu.sourcePath, false)}
-          >
-            <Trash2 className="h-3.5 w-3.5" />
-            删除
-          </button>
-        </>
-      )}
-    </div>,
-    document.body,
-  )
-}
-
-function TreeNode({
-  node,
-  depth,
-  folders,
-  expandedPaths,
-  contextMenu,
-  moveSubmenuPath,
-  renaming,
-  renameValue,
-  dragSourcePath,
-  selectedPath,
-  onToggle,
-  onSelectFile,
-  onOpenContextMenu,
-  onOpenFolderContextMenu,
-  onOpenMoveSubmenu,
-  onCreateFile,
-  onCreateFolder,
-  onMoveToFolder,
-  onStartRename,
-  onDeleteNode,
-  onRenameValueChange,
-  onSubmitRename,
-  onCancelRename,
-  onSendToOutlineChat,
-  onDragSourceChange,
-}: {
-  node: FileNode
-  depth: number
-  folders: FileNode[]
-  expandedPaths: Set<string>
-  contextMenu: ContextMenuState | null
-  moveSubmenuPath: string | null
-  renaming: RenameState | null
-  renameValue: string
-  dragSourcePath: string | null
-  selectedPath: string | null
-  onToggle: (path: string) => void
-  onSelectFile: (path: string) => void
-  onOpenContextMenu: (sourcePath: string, sourceName: string, x: number, y: number) => void
-  onOpenFolderContextMenu: (sourcePath: string, sourceName: string, x: number, y: number) => void
-  onOpenMoveSubmenu: (sourcePath: string) => void
-  onCreateFile?: (folderPath: string) => void
-  onCreateFolder?: (folderPath: string) => void
-  onMoveToFolder: (targetFolderPath: string) => void
-  onStartRename: (sourcePath: string, sourceName: string, isDir: boolean) => void
-  onDeleteNode?: (sourcePath: string, isDir: boolean) => void
-  onRenameValueChange: (value: string) => void
-  onSubmitRename: (value?: string) => void
-  onCancelRename: () => void
-  onSendToOutlineChat?: (sourcePath: string, sourceName: string) => void
-  onDragSourceChange: (sourcePath: string | null) => void
-}) {
-  const paddingLeft = 10 + depth * 14
-
-  if (node.is_dir) {
-    const expanded = expandedPaths.has(node.path)
-    const dropActive = dragSourcePath !== null
-    const contextMenuOpen = contextMenu?.sourcePath === node.path
-    const isRenaming = renaming?.sourcePath === node.path
-    return (
-      <div>
-        <button
-          type="button"
-          className={`flex w-full min-w-0 items-center gap-1 rounded px-1 py-1 text-left text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground ${
-            dropActive ? "outline outline-1 outline-primary/25" : ""
-          }`}
-          style={{ paddingLeft }}
-          onClick={() => {
-            if (isRenaming) return
-            onToggle(node.path)
-          }}
-          onContextMenu={(event) => {
-            event.preventDefault()
-            onOpenFolderContextMenu(node.path, node.name, event.clientX, event.clientY)
-          }}
-          onDragOver={(event) => {
-            if (!dragSourcePath) return
-            event.preventDefault()
-          }}
-          onDrop={(event) => {
-            event.preventDefault()
-            if (!dragSourcePath) return
-            onMoveToFolder(node.path)
-            onDragSourceChange(null)
-          }}
-          title={node.name}
-        >
-          {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-500" />
-          {isRenaming ? (
-            <input
-              data-testid="outline-rename-input"
-              className="min-w-0 flex-1 rounded border bg-background px-1 py-0.5 text-xs text-foreground outline-none focus:ring-1 focus:ring-ring"
-              value={renameValue}
-              onChange={(event) => onRenameValueChange(event.target.value)}
-              onClick={(event) => event.stopPropagation()}
-              onKeyDown={(event) => {
-                event.stopPropagation()
-                if (event.key === "Enter") {
-                  event.preventDefault()
-                  onSubmitRename(event.currentTarget.value)
-                } else if (event.key === "Escape") {
-                  event.preventDefault()
-                  onCancelRename()
-                }
-              }}
-              autoFocus
-            />
-          ) : (
-            <span className="truncate">{node.name}</span>
-          )}
-        </button>
-        {contextMenuOpen ? (
-          <OutlineContextMenu
-            menu={contextMenu}
-            folders={folders}
-            moveMenuOpen={false}
-            onOpenMoveSubmenu={onOpenMoveSubmenu}
-            onMoveToFolder={onMoveToFolder}
-            onCreateFile={onCreateFile}
-            onCreateFolder={onCreateFolder}
-            onStartRename={onStartRename}
-            onDeleteNode={onDeleteNode}
-          />
-        ) : null}
-        {expanded && node.children?.map((child) => (
-          <TreeNode
-            key={child.path}
-            node={child}
-            depth={depth + 1}
-            folders={folders}
-            expandedPaths={expandedPaths}
-            contextMenu={contextMenu}
-            moveSubmenuPath={moveSubmenuPath}
-            renaming={renaming}
-            renameValue={renameValue}
-            dragSourcePath={dragSourcePath}
-            selectedPath={selectedPath}
-            onToggle={onToggle}
-            onSelectFile={onSelectFile}
-            onOpenContextMenu={onOpenContextMenu}
-            onOpenFolderContextMenu={onOpenFolderContextMenu}
-            onOpenMoveSubmenu={onOpenMoveSubmenu}
-            onCreateFile={onCreateFile}
-            onCreateFolder={onCreateFolder}
-            onMoveToFolder={onMoveToFolder}
-            onStartRename={onStartRename}
-            onDeleteNode={onDeleteNode}
-            onRenameValueChange={onRenameValueChange}
-            onSubmitRename={onSubmitRename}
-            onCancelRename={onCancelRename}
-            onSendToOutlineChat={onSendToOutlineChat}
-            onDragSourceChange={onDragSourceChange}
-          />
-        ))}
-      </div>
-    )
-  }
-
-  const selected = selectedPath === node.path
-  const contextMenuOpen = contextMenu?.sourcePath === node.path
-  const moveMenuOpen = moveSubmenuPath === node.path
-  const isRenaming = renaming?.sourcePath === node.path
-  return (
-    <div className="relative flex min-w-0 items-start gap-1">
-      <button
-        type="button"
-        draggable={!isRenaming}
-        className={`flex min-w-0 flex-1 items-center gap-1 rounded px-1 py-1 text-left text-xs ${
-          selected ? "bg-primary/10 text-primary" : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
-        }`}
-        style={{ paddingLeft: paddingLeft + 18 }}
-        onClick={() => {
-          if (isRenaming) return
-          onSelectFile(node.path)
-        }}
-        onContextMenu={(event) => {
-          event.preventDefault()
-          onOpenContextMenu(node.path, node.name, event.clientX, event.clientY)
-        }}
-        onDragStart={() => {
-          onDragSourceChange(node.path)
-        }}
-        onDragEnd={() => {
-          onDragSourceChange(null)
-        }}
-        title={`${node.name}。右键可重命名、移动或发送到AI大纲会话。`}
-      >
-        <FileText className="h-3.5 w-3.5 shrink-0" />
-        {isRenaming ? (
-          <input
-            data-testid="outline-rename-input"
-            className="min-w-0 flex-1 rounded border bg-background px-1 py-0.5 text-xs text-foreground outline-none focus:ring-1 focus:ring-ring"
-            value={renameValue}
-            onChange={(event) => onRenameValueChange(event.target.value)}
-            onClick={(event) => event.stopPropagation()}
-            onKeyDown={(event) => {
-              event.stopPropagation()
-              if (event.key === "Enter") {
-                event.preventDefault()
-                onSubmitRename(event.currentTarget.value)
-              } else if (event.key === "Escape") {
-                event.preventDefault()
-                onCancelRename()
-              }
-            }}
-            autoFocus
-          />
-        ) : (
-          <span className="truncate">{node.name}</span>
-        )}
-      </button>
-      {contextMenuOpen ? (
-        <OutlineContextMenu
-          menu={contextMenu}
-          folders={folders}
-          moveMenuOpen={moveMenuOpen}
-          onOpenMoveSubmenu={onOpenMoveSubmenu}
-          onMoveToFolder={onMoveToFolder}
-          onStartRename={onStartRename}
-          onDeleteNode={onDeleteNode}
-          onSendToOutlineChat={onSendToOutlineChat}
-        />
-      ) : null}
-    </div>
-  )
-}
-
-export function OutlineFileTree({
-  nodes,
-  selectedPath,
-  onSelectFile,
-  onMoveFile,
-  onRenameFile,
-  onRenameFolder,
-  onDeleteFile,
-  onDeleteFolder,
-  onCreateFile,
-  onCreateFolder,
-  onSendToOutlineChat,
-  showHeader = true,
-}: OutlineFileTreeProps) {
-  const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => collectFolderPaths(nodes))
-  const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
-  const [moveSubmenuPath, setMoveSubmenuPath] = useState<string | null>(null)
-  const [renaming, setRenaming] = useState<RenameState | null>(null)
-  const [dragSourcePath, setDragSourcePath] = useState<string | null>(null)
-  const [error, setError] = useState<string | null>(null)
-  const folders = useMemo(() => collectFolders(nodes), [nodes])
-
-  useEffect(() => {
-    setExpandedPaths((current) => {
-      const next = new Set(current)
-      for (const path of collectFolderPaths(nodes)) next.add(path)
-      return next
-    })
-  }, [nodes])
-
-  useEffect(() => {
-    if (!contextMenu) return
-    function handleDocumentMouseDown(event: MouseEvent) {
-      const target = event.target
-      if (target instanceof Element && target.closest("[data-outline-context-menu='true']")) {
-        return
-      }
-      setContextMenu(null)
-      setMoveSubmenuPath(null)
-    }
-    document.addEventListener("mousedown", handleDocumentMouseDown)
-    return () => {
-      document.removeEventListener("mousedown", handleDocumentMouseDown)
-    }
-  }, [contextMenu])
-
-  function toggle(path: string) {
-    setExpandedPaths((current) => {
-      const next = new Set(current)
-      if (next.has(path)) next.delete(path)
-      else next.add(path)
-      return next
-    })
-  }
-
-  async function handleMove(targetFolderPath: string) {
-    if (!contextMenu && !dragSourcePath) return
-    const sourcePath = contextMenu?.sourcePath ?? dragSourcePath
-    if (!sourcePath) return
-    setError(null)
-    try {
-      await onMoveFile(sourcePath, targetFolderPath)
-      setContextMenu(null)
-      setMoveSubmenuPath(null)
-      setDragSourcePath(null)
-    } catch (err) {
-      setError(err instanceof Error ? err.message : String(err))
-    }
-  }
-
-  async function submitRename(valueOverride?: string) {
-    if (!renaming) return
-    const nextName = (valueOverride ?? renaming.value).trim()
-    if (!nextName) return
-    setError(null)
-    try {
-      if (renaming.isDir) {
-        if (!onRenameFolder) return
-        await onRenameFolder(renaming.sourcePath, nextName)
-      } else {
-        if (!onRenameFile) return
-        await onRenameFile(renaming.sourcePath, nextName)
-      }
-      setRenaming(null)
-      setContextMenu(null)
-    } catch (err) {
-      setError(err instanceof Error ? err.message : String(err))
-    }
-  }
-
-  async function handleCreateFile(folderPath: string) {
-    if (!onCreateFile) return
-    setError(null)
-    try {
-      await onCreateFile(folderPath)
-      setContextMenu(null)
-    } catch (err) {
-      setError(err instanceof Error ? err.message : String(err))
-    }
-  }
-
-  async function handleCreateFolder(folderPath: string) {
-    if (!onCreateFolder) return
-    setError(null)
-    try {
-      await onCreateFolder(folderPath)
-      setContextMenu(null)
-    } catch (err) {
-      setError(err instanceof Error ? err.message : String(err))
-    }
-  }
-
-  async function handleDeleteNode(sourcePath: string, isDir: boolean) {
-    setError(null)
-    try {
-      if (isDir) {
-        if (!onDeleteFolder) return
-        await onDeleteFolder(sourcePath)
-      } else {
-        if (!onDeleteFile) return
-        await onDeleteFile(sourcePath)
-      }
-      setContextMenu(null)
-      setMoveSubmenuPath(null)
-    } catch (err) {
-      setError(err instanceof Error ? err.message : String(err))
-    }
-  }
-
-  return (
-    <div className="flex h-full min-h-0 flex-col overflow-hidden" data-testid="outline-file-tree">
-      {showHeader ? (
-          <div className="shrink-0 border-b px-2 py-2">
-          <div className="text-xs font-semibold text-foreground">大纲文件树</div>
-          <div className="mt-0.5 text-[11px] text-muted-foreground">右键文件可重命名、移动或发送到AI大纲会话</div>
-        </div>
-      ) : null}
-
-      {error ? (
-        <div className="shrink-0 border-b border-destructive/30 bg-destructive/10 px-2 py-1 text-xs text-destructive">
-          {error}
-        </div>
-      ) : null}
-
-      <div className="min-h-0 flex-1 overflow-y-auto p-2">
-        {nodes.length === 0 ? (
-          <div className="px-2 py-8 text-center text-xs text-muted-foreground">
-            暂无大纲文件
-          </div>
-        ) : (
-          nodes.map((node) => (
-            <TreeNode
-              key={node.path}
-              node={node}
-              depth={0}
-              folders={folders}
-              expandedPaths={expandedPaths}
-              contextMenu={contextMenu}
-              moveSubmenuPath={moveSubmenuPath}
-              renaming={renaming}
-              renameValue={renaming?.value ?? ""}
-              dragSourcePath={dragSourcePath}
-              selectedPath={selectedPath}
-              onToggle={toggle}
-              onSelectFile={onSelectFile}
-              onOpenContextMenu={(sourcePath, sourceName, x, y) => {
-                setError(null)
-                setRenaming(null)
-                setMoveSubmenuPath(null)
-                setContextMenu({ sourcePath, sourceName, isDir: false, x, y })
-              }}
-              onOpenFolderContextMenu={(sourcePath, sourceName, x, y) => {
-                setError(null)
-                setRenaming(null)
-                setMoveSubmenuPath(null)
-                setContextMenu({ sourcePath, sourceName, isDir: true, x, y })
-              }}
-              onOpenMoveSubmenu={setMoveSubmenuPath}
-              onCreateFile={(folderPath) => void handleCreateFile(folderPath)}
-              onCreateFolder={(folderPath) => void handleCreateFolder(folderPath)}
-              onMoveToFolder={(targetFolderPath) => void handleMove(targetFolderPath)}
-              onStartRename={(sourcePath, sourceName, isDir) => {
-                setMoveSubmenuPath(null)
-                setContextMenu(null)
-                setRenaming({ sourcePath, isDir, value: sourceName })
-              }}
-              onDeleteNode={(sourcePath, isDir) => void handleDeleteNode(sourcePath, isDir)}
-              onRenameValueChange={(value) => {
-                setRenaming((current) => current ? { ...current, value } : current)
-              }}
-              onSubmitRename={(value) => void submitRename(value)}
-              onCancelRename={() => setRenaming(null)}
-              onSendToOutlineChat={(sourcePath, sourceName) => {
-                onSendToOutlineChat?.(sourcePath, sourceName)
-                setContextMenu(null)
-              }}
-              onDragSourceChange={setDragSourcePath}
-            />
-          ))
-        )}
-      </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} />
-})

+ 1 - 1
src/components/ui/button.tsx

@@ -55,4 +55,4 @@ function Button({
   )
 }
 
-export { Button, buttonVariants }
+export { Button }

+ 0 - 12
src/components/ui/dialog.tsx

@@ -11,18 +11,10 @@ function Dialog({ ...props }: DialogPrimitive.Root.Props) {
   return <DialogPrimitive.Root data-slot="dialog" {...props} />
 }
 
-function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
-  return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
-}
-
 function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
   return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
 }
 
-function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
-  return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
-}
-
 function DialogOverlay({
   className,
   ...props
@@ -148,13 +140,9 @@ function DialogDescription({
 
 export {
   Dialog,
-  DialogClose,
   DialogContent,
   DialogDescription,
   DialogFooter,
   DialogHeader,
-  DialogOverlay,
-  DialogPortal,
   DialogTitle,
-  DialogTrigger,
 }

+ 1 - 1
src/components/ui/scroll-area.tsx

@@ -51,4 +51,4 @@ function ScrollBar({
   )
 }
 
-export { ScrollArea, ScrollBar }
+export { ScrollArea }

+ 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
-}

+ 6 - 6
src/lib/agent/activity-trace.ts

@@ -11,7 +11,7 @@ const EMPTY_CONTENT = "本阶段未返回可展示内容。"
 const AGGREGATE_STAGE_IDS = new Set(["chapter_workflow", "react_tools"])
 
 /** 细粒度章节流水线阶段;存在任一此类阶段时隐藏冗余的 chapter_workflow 聚合阶段。 */
-export const DETAILED_CHAPTER_STAGE_IDS = new Set([
+const DETAILED_CHAPTER_STAGE_IDS = new Set([
   "read_context",
   "plot_analysis",
   "generate_draft",
@@ -25,7 +25,7 @@ export const DETAILED_CHAPTER_STAGE_IDS = new Set([
   "final_output",
 ])
 
-export const AGENT_STAGE_DISPLAY_ORDER = [
+const AGENT_STAGE_DISPLAY_ORDER = [
   "task_understanding",
   "capability_selection",
   "read_context",
@@ -49,7 +49,7 @@ const STAGE_DISPLAY_ORDER_INDEX = new Map<string, number>(
   AGENT_STAGE_DISPLAY_ORDER.map((id, index) => [id, index]),
 )
 
-export interface CreateAgentActivityEventInput {
+interface CreateAgentActivityEventInput {
   id: string
   stageId: string
   kind: AgentActivityKind
@@ -60,7 +60,7 @@ export interface CreateAgentActivityEventInput {
   timestamp?: number
 }
 
-export interface CreateStageStartedEventInput {
+interface CreateStageStartedEventInput {
   stageId: string
   title: string
   summary: string
@@ -129,13 +129,13 @@ export function getDefaultOpenAgentStageId(stages: AgentStageTrace[]): string |
     ?? null
 }
 
-export function filterAgentStagesForDisplay(stages: AgentStageTrace[]): AgentStageTrace[] {
+function filterAgentStagesForDisplay(stages: AgentStageTrace[]): AgentStageTrace[] {
   const hasDetailedChapterStage = stages.some((stage) => DETAILED_CHAPTER_STAGE_IDS.has(stage.id))
   if (!hasDetailedChapterStage) return stages
   return stages.filter((stage) => stage.id !== "chapter_workflow")
 }
 
-export function sortAgentStagesForDisplay(stages: AgentStageTrace[]): AgentStageTrace[] {
+function sortAgentStagesForDisplay(stages: AgentStageTrace[]): AgentStageTrace[] {
   const unknownBase = AGENT_STAGE_DISPLAY_ORDER.length
   return stages
     .map((stage, originalIndex) => ({ stage, originalIndex }))

+ 2 - 2
src/lib/agent/ai-chat-session.ts

@@ -3,7 +3,7 @@ import type { ToolRegistry } from "./registry"
 import type { AgentConfig, AgentMessage, AgentRunCallbacks, AgentRunRecord } from "./types"
 import { scopeAgentConfigTools } from "./tool-scope"
 
-export interface RunAiChatSessionCallbacks {
+interface RunAiChatSessionCallbacks {
   onText: (chunk: string) => void
   onReasoningToken?: (chunk: string) => void
   onToolEvent?: AgentRunCallbacks["onToolEvent"]
@@ -15,7 +15,7 @@ export interface RunAiChatSessionCallbacks {
   onError: (error: Error) => void
 }
 
-export interface RunAiChatSessionInput {
+interface RunAiChatSessionInput {
   userMessage: string
   projectPath?: string
   agentConfig: AgentConfig

+ 1 - 1
src/lib/agent/capabilities/registry.ts

@@ -55,7 +55,7 @@ const TOOL_LABELS: Record<string, string> = {
   run_chapter_workflow: "Chapter Workflow",
 }
 
-export interface BuildAvailableCapabilitiesOptions {
+interface BuildAvailableCapabilitiesOptions {
   toolNames?: string[]
   selectedSkills?: UserSkill[]
   mcpCapabilities?: AiCapability[]

+ 1 - 1
src/lib/agent/capabilities/selector.ts

@@ -44,7 +44,7 @@ const STRICT_EXTRA_TOOLS = new Set([
 
 const ENTITY_QUERY_INTENTS = new Set<NovelTaskIntent>(["character_query", "setting_query"])
 
-export interface SelectCapabilitiesInput {
+interface SelectCapabilitiesInput {
   capabilities: AiCapability[]
   intent: NovelTaskIntent
   mode: AiWorkflowMode

+ 2 - 2
src/lib/agent/config.ts

@@ -5,7 +5,7 @@ import { DEFAULT_MAX_ROUNDS } from "./types"
 import { registerAllBuiltInTools } from "./tools"
 import type { ToolFactoryOptions } from "./tools"
 
-export const TOOL_UNSUPPORTED_MODEL_PREFIXES: string[] = [
+const TOOL_UNSUPPORTED_MODEL_PREFIXES: string[] = [
   "o1",
   "o3-mini",
   "deepseek-reasoner",
@@ -23,7 +23,7 @@ export function providerUsesTextToolCalls(provider: LlmConfig["provider"]): bool
   return provider === "cursor-cli"
 }
 
-export interface BuildAgentConfigOptions extends ToolFactoryOptions {
+interface BuildAgentConfigOptions extends ToolFactoryOptions {
   llmConfig: LlmConfig
   requestOverrides?: AgentConfig["requestOverrides"]
 }

+ 1 - 21
src/lib/agent/context-trace.ts

@@ -6,7 +6,7 @@ import type { SkillKind, SkillMode, SkillStage } from "@/lib/novel/skill-library
 import type { CapabilityKind, CapabilityPermission } from "./capabilities/types"
 import type { ContextHubStats } from "@/lib/context-hub/types"
 
-export type TraceToolCategory = "read" | "write" | "action" | "virtual"
+type TraceToolCategory = "read" | "write" | "action" | "virtual"
 
 export interface TraceToolCall {
   id: string
@@ -151,26 +151,6 @@ export function createContextTrace(id: string): ContextTrace {
   }
 }
 
-export function addToolCallToTrace(trace: ContextTrace, toolCall: TraceToolCall): ContextTrace {
-  return {
-    ...trace,
-    toolCalls: [...trace.toolCalls, toolCall],
-  }
-}
-
-export function updateToolCallInTrace(
-  trace: ContextTrace,
-  callId: string,
-  updates: Partial<TraceToolCall>,
-): ContextTrace {
-  return {
-    ...trace,
-    toolCalls: trace.toolCalls.map((call) =>
-      call.id === callId ? { ...call, ...updates } : call,
-    ),
-  }
-}
-
 export function setContextInfo(trace: ContextTrace, info: TraceContextInfo): ContextTrace {
   return {
     ...trace,

+ 2 - 2
src/lib/agent/novel-pre-plugin-chain.ts

@@ -17,7 +17,7 @@ import { createTrimContextPlugin } from "./plugins/trim-context-plugin"
 import { createBuildSystemPromptPlugin } from "./plugins/build-system-prompt-plugin"
 import type { BuildContextPackPluginDeps } from "./plugins/build-context-pack-plugin"
 
-export interface NovelPrePluginDeps {
+interface NovelPrePluginDeps {
   selectedFile?: string | null
   lastGeneratedChapterNumber?: number
   requestSoulDialog?: (summary: string) => Promise<boolean>
@@ -80,7 +80,7 @@ export function createNovelPrePluginChain(deps: NovelPrePluginDeps = {}): PrePlu
   return plugins
 }
 
-export interface RunNovelPrePluginChainOptions {
+interface RunNovelPrePluginChainOptions {
   input: PrePluginInput
   deps?: NovelPrePluginDeps
   config?: PluginConfig

+ 1 - 1
src/lib/agent/pipeline.ts

@@ -66,7 +66,7 @@ export interface PrePluginChainResult extends PrePluginOutput {
   executedPlugins: string[]
 }
 
-export interface PrePluginChain {
+interface PrePluginChain {
   run(input: PrePluginInput, config?: PluginConfig): Promise<PrePluginChainResult>
 }
 

+ 1 - 1
src/lib/agent/plugins/build-system-prompt-plugin.ts

@@ -8,7 +8,7 @@ import {
 import { buildSelectedSkillsPrompt } from "./select-skills-plugin"
 import { getWorkflowModeLabel, type AiWorkflowMode } from "../workflow-mode"
 
-export interface BuildSystemPromptPluginDeps {
+interface BuildSystemPromptPluginDeps {
   baseSystemPrompt?: string
   buildTaskDirectiveFn?: typeof buildTaskDirective
   onError?: (error: Error) => void

+ 2 - 2
src/lib/agent/plugins/confidence-gate-plugin.ts

@@ -3,13 +3,13 @@ import type { NovelTaskIntent } from "@/lib/novel/task-router"
 
 export const DEFAULT_CONFIDENCE_THRESHOLD = 0.5
 
-export interface ClarificationCandidate {
+interface ClarificationCandidate {
   intent: NovelTaskIntent
   label: string
   confidence: number
 }
 
-export interface ConfidenceGatePluginDeps {
+interface ConfidenceGatePluginDeps {
   threshold?: number
   getCandidates?: (userMessage: string) => ClarificationCandidate[]
   onError?: (error: Error) => void

+ 1 - 1
src/lib/agent/plugins/post-write-check-ai.ts

@@ -8,7 +8,7 @@ import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { runPostWriteCheck } from "./post-write-check-plugin"
 import { useWikiStore } from "@/stores/wiki-store"
 
-export interface PostWriteCheckAIResult {
+interface PostWriteCheckAIResult {
   check: PostWriteCheck
   source: "ai" | "rule"
   fallbackReason?: string

+ 1 - 1
src/lib/agent/plugins/post-write-check-plugin.ts

@@ -1,7 +1,7 @@
 import type { PostWriteCheck, PostWriteCheckItem } from "../context-trace"
 import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
 
-export interface PostWriteCheckDeps {
+interface PostWriteCheckDeps {
   chapterContent?: string
 }
 

+ 1 - 1
src/lib/agent/plugins/resolve-chapter-plugin.ts

@@ -1,7 +1,7 @@
 import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
 import { resolveTargetChapterNumberForChat } from "@/lib/novel/chapter-utils"
 
-export interface ResolveChapterPluginDeps {
+interface ResolveChapterPluginDeps {
   selectedFile?: string | null
   lastGeneratedChapterNumber?: number
   onError?: (error: Error) => void

+ 1 - 1
src/lib/agent/plugins/route-task-plugin.ts

@@ -1,7 +1,7 @@
 import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
 import { routeTask } from "@/lib/novel/task-router"
 
-export interface RouteTaskPluginDeps {
+interface RouteTaskPluginDeps {
   onVirtualTool?: (
     event: "start" | "end",
     name: string,

+ 1 - 1
src/lib/agent/plugins/soul-dialog-plugin.ts

@@ -1,6 +1,6 @@
 import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
 
-export interface SoulDialogPluginDeps {
+interface SoulDialogPluginDeps {
   shouldRequestSoulDialog?: (contextPack: any) => boolean
   onError?: (error: Error) => void
 }

+ 1 - 1
src/lib/agent/plugins/trim-context-plugin.ts

@@ -3,7 +3,7 @@ import type { ContextPack, TrimResult } from "@/lib/novel/context-engine"
 import { resolveContextPackTokenBudget } from "@/lib/context-budget"
 import { getEffectiveMaxContextSize } from "@/lib/llm-providers"
 
-export interface TrimContextPluginDeps {
+interface TrimContextPluginDeps {
   contextPackToPromptFn?: (pack: ContextPack, tokenBudget?: number, options?: { excludeOutline?: boolean }) => string
   trimContextPackFn?: (pack: ContextPack, tokenBudget?: number, options?: { excludeOutline?: boolean }) => TrimResult
   tokenBudget?: number

+ 1 - 1
src/lib/agent/required-tool-fallback.ts

@@ -3,7 +3,7 @@ import type { AgentRunCallbacks, AgentRunRecord, Tool } from "./types"
 import { executeAgentTool, type ExecuteAgentToolResult } from "./tool-executor"
 import { RequiredToolFallbackError } from "./required-tools-gate"
 
-export interface RequiredToolFallbackResult {
+interface RequiredToolFallbackResult {
   attempted: boolean
   toolName?: string
   satisfiedTools: string[]

+ 2 - 2
src/lib/agent/required-tools-gate.ts

@@ -2,7 +2,7 @@ import type { NovelTaskIntent } from "@/lib/novel/task-router"
 import { OUTLINE_FIND_CHAPTER_INTENTS } from "@/lib/novel/outline-find-protocol"
 import type { AiWorkflowMode } from "./workflow-mode"
 
-export interface RequiredToolsGateInput {
+interface RequiredToolsGateInput {
   requiredToolsOnce?: string[] | null
   availableToolNames: Iterable<string>
   calledToolNames: Iterable<string>
@@ -57,7 +57,7 @@ export class RequiredToolFallbackError extends Error {
   }
 }
 
-export interface ResolveRequiredToolsOnceInput {
+interface ResolveRequiredToolsOnceInput {
   novelMode: boolean
   intent?: string | null
   mode?: AiWorkflowMode | null

+ 1 - 1
src/lib/agent/skills/draft-review-skill.ts

@@ -41,7 +41,7 @@ export interface Deviation {
   repairAction?: string;
 }
 
-export interface DraftReviewInput {
+interface DraftReviewInput {
   projectPath: string;
   draftChapterText: string;
   draftChapterNumber: number;

+ 2 - 2
src/lib/agent/tool-events.ts

@@ -2,7 +2,7 @@ import type { AgentRunRecord, AgentStageTrace, AgentToolEvent } from "./types"
 import { activityEventFromToolEvent, applyAgentActivityEvent } from "./activity-trace"
 import { getWorkflowToolDescription, isWebResearchToolName } from "./workflow-trace"
 
-export type ToolCallRecord = AgentRunRecord["toolCalls"][number]
+type ToolCallRecord = AgentRunRecord["toolCalls"][number]
 type SettledToolCallStatus = "done" | "error" | "cancelled"
 
 export function applyAgentToolEvent(
@@ -70,7 +70,7 @@ export function settleRunningAgentToolCalls(
 }
 
 /** chapter_* 子步骤已在工具时间线展示,且细粒度阶段由 onActivityEvent 写入,避免双轨重复。 */
-export function shouldSkipToolActivityForStages(event: AgentToolEvent): boolean {
+function shouldSkipToolActivityForStages(event: AgentToolEvent): boolean {
   return Boolean(event.parentCallId) && event.name.startsWith("chapter_")
 }
 

+ 1 - 1
src/lib/agent/tool-executor.ts

@@ -3,7 +3,7 @@ import type { AgentRunCallbacks, AgentRunRecord, ToolCall } from "./types"
 import { TOOL_EXECUTE_TIMEOUT_MS } from "./types"
 import { isToolErrorResult } from "./tool-result"
 
-export function withToolTimeout<T>(operation: Promise<T>, timeoutMs: number | undefined): Promise<T> {
+function withToolTimeout<T>(operation: Promise<T>, timeoutMs: number | undefined): Promise<T> {
   const resolvedTimeoutMs = timeoutMs ?? TOOL_EXECUTE_TIMEOUT_MS
   if (resolvedTimeoutMs <= 0) return operation
   return new Promise<T>((resolve, reject) => {

+ 1 - 1
src/lib/agent/tools/list-chapters.ts

@@ -3,7 +3,7 @@ import { listDirectory } from "@/commands/fs"
 import { flattenMdFiles } from "@/lib/novel/chapter-utils"
 
 /** 仅匹配「第N章/节/回」,避免 backup-2024 等文件名污染最新章号 */
-export function extractStrictChapterNumber(text: string): number | null {
+function extractStrictChapterNumber(text: string): number | null {
   const m = text.match(/第\s*(\d+)\s*[章节回]/)
   if (!m?.[1]) return null
   const n = Number.parseInt(m[1], 10)

+ 1 - 1
src/lib/agent/tools/list-outlines.ts

@@ -5,7 +5,7 @@ import {
   listOutlineEntries,
 } from "./outline-list-helpers"
 
-export interface ListOutlinesToolOptions {
+interface ListOutlinesToolOptions {
   readTextFile?: (path: string) => Promise<string>
   /** 模型未传 chapterNumber 时的默认目标章号(通常来自当前任务路由) */
   getDefaultChapterNumber?: () => number | undefined

+ 0 - 53
src/lib/agent/tools/mcp-tool.spec.ts

@@ -1,53 +0,0 @@
-import { describe, expect, it, vi } from "vitest"
-import { createMcpTool } from "./mcp-tool"
-import type { McpToolDescriptor } from "@/lib/mcp/types"
-
-const descriptor: McpToolDescriptor = {
-  serverId: "graph",
-  serverName: "Graph MCP",
-  name: "query",
-  description: "Query graph",
-  operation: "read",
-  inputSchema: {
-    type: "object",
-    properties: {
-      query: { type: "string", description: "Query text" },
-    },
-    required: ["query"],
-  },
-}
-
-describe("createMcpTool", () => {
-  it("wraps an MCP caller as a QMai tool", async () => {
-    const caller = vi.fn().mockResolvedValue({
-      status: "ok",
-      content: "graph result",
-      summary: "graph result",
-    })
-    const tool = createMcpTool(descriptor, caller)
-
-    expect(tool.name).toBe("mcp_graph_query")
-    expect(tool.category).toBe("read")
-    expect(tool.permission).toBe("auto")
-
-    const result = JSON.parse(await tool.execute({ query: "hero" }))
-
-    expect(caller).toHaveBeenCalledOnce()
-    expect(result).toMatchObject({
-      status: "ok",
-      serverId: "graph",
-      toolName: "query",
-      content: "graph result",
-    })
-  })
-
-  it("returns a Chinese degradation message when MCP call fails", async () => {
-    const tool = createMcpTool(descriptor, vi.fn().mockRejectedValue(new Error("offline")))
-
-    const result = JSON.parse(await tool.execute({ query: "hero" }))
-
-    expect(result.status).toBe("error")
-    expect(result.message).toContain("MCP 调用失败")
-    expect(result.message).toContain("普通 AI 会话可以继续")
-  })
-})

+ 0 - 1
src/lib/agent/tools/mcp-tool.ts

@@ -1 +0,0 @@
-export { createMcpTool } from "@/lib/mcp/adapter"

+ 0 - 9
src/lib/agent/tools/outline-list-helpers.ts

@@ -20,10 +20,6 @@ export interface OutlineListEntry {
   outlineType?: string
 }
 
-function stripMarkdownExt(name: string): string {
-  return name.replace(/\.md$/i, "")
-}
-
 function scalarFrontmatterString(value: unknown): string | undefined {
   if (typeof value === "string" && value.trim()) return value.trim()
   if (typeof value === "number" && Number.isFinite(value)) return String(value)
@@ -159,8 +155,3 @@ export function buildOutlineListToolResult(
 
   return lines.join("\n")
 }
-
-/** 供测试与调用方:从文件名推导展示名(无扩展名) */
-export function outlineDisplayName(relativePath: string): string {
-  return stripMarkdownExt(relativePath.split("/").pop() ?? relativePath)
-}

+ 2 - 2
src/lib/agent/tools/read-markdown-resource.ts

@@ -15,7 +15,7 @@ interface DirectoryCandidate {
 
 export type ReadTextFile = (path: string) => Promise<string>
 
-export type ReadMarkdownResourceOutcome =
+type ReadMarkdownResourceOutcome =
   | { kind: "file"; content: string; resolvedPath: string }
   | { kind: "directory"; content: string }
   | { kind: "error"; content: string }
@@ -172,7 +172,7 @@ export async function readMarkdownResource(
   return outcome.content
 }
 
-export async function resolveMarkdownResource(
+async function resolveMarkdownResource(
   baseDir: string,
   params: Record<string, unknown>,
   label: string,

+ 1 - 1
src/lib/agent/tools/read-web-page.ts

@@ -14,7 +14,7 @@ interface ReadWebPageDeps {
   fetchPage?: (url: string, init?: RequestInit) => Promise<FetchResponseLike>
 }
 
-export interface ReadWebPageToolResult {
+interface ReadWebPageToolResult {
   status: "ok" | "error"
   url: string
   title?: string

+ 1 - 1
src/lib/agent/tools/run-chapter-workflow.ts

@@ -16,7 +16,7 @@ export type RunDeepChapterGeneration = (
   signal?: AbortSignal,
 ) => Promise<DeepChapterGenerationResult>
 
-export interface RunChapterWorkflowToolOptions {
+interface RunChapterWorkflowToolOptions {
   projectPath: string
   llmConfig: LlmConfig
   aiWorkflowMode: AiWorkflowMode

+ 1 - 1
src/lib/agent/tools/summarize-search-results.ts

@@ -1,6 +1,6 @@
 import type { Tool } from "../types"
 
-export interface SummarizeSearchResultsResult {
+interface SummarizeSearchResultsResult {
   status: "ok" | "not_configured" | "error"
   query: string
   sourceCount: number

+ 1 - 1
src/lib/agent/tools/web-search.ts

@@ -2,7 +2,7 @@ import type { Tool } from "../types"
 import { providerRequiresApiKey, resolveSearchConfig, webSearch } from "@/lib/web-search"
 import type { SearchApiConfig } from "@/stores/wiki-store"
 
-export interface WebSearchToolResult {
+interface WebSearchToolResult {
   status: "ok" | "not_configured" | "error"
   query: string
   provider: string

+ 1 - 1
src/lib/agent/tools/write-chapter.ts

@@ -2,7 +2,7 @@ import type { Tool } from "../types"
 import { readFile, writeFile, fileExists, createDirectory } from "@/commands/fs"
 import { writeDraft } from "@/lib/novel/draft-manager"
 
-export interface WriteChapterOptions {
+interface WriteChapterOptions {
   draftMode?: boolean
   projectPath?: string
   sourceConversationId?: string

+ 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)
-    },
-  }
-}

+ 4 - 4
src/lib/agent/workflow-trace.ts

@@ -1,7 +1,7 @@
 import type { ContextTrace } from "./context-trace"
 import type { ToolCallStatus } from "./types"
 
-export type AgentWorkflowStepStatus =
+type AgentWorkflowStepStatus =
   | "pending"
   | "running"
   | "done"
@@ -9,7 +9,7 @@ export type AgentWorkflowStepStatus =
   | "error"
   | "cancelled"
 
-export type AgentWorkflowStepKind =
+type AgentWorkflowStepKind =
   | "intent"
   | "skill"
   | "context"
@@ -29,13 +29,13 @@ export interface WorkflowToolCall {
   finishedAt?: number
 }
 
-export interface AgentWorkflowDetail {
+interface AgentWorkflowDetail {
   label: string
   value: string
   tone?: "default" | "muted" | "warning" | "error" | "success"
 }
 
-export interface AgentWorkflowStep {
+interface AgentWorkflowStep {
   id: string
   kind: AgentWorkflowStepKind
   title: string

Some files were not shown because too many files changed in this diff