Просмотр исходного кода

merge: 合并 PR #30 Cursor CLI 本地 proxy 预置与写作流程优化

# Conflicts:
#	src/components/chat/chat-panel.tsx
Mochocyang 2 месяцев назад
Родитель
Сommit
69449b37ee
95 измененных файлов с 3614 добавлено и 327 удалено
  1. 166 17
      src-tauri/Cargo.lock
  2. 1 0
      src-tauri/Cargo.toml
  3. 608 0
      src-tauri/src/commands/cursor_cli.rs
  4. 2 0
      src-tauri/src/commands/mod.rs
  5. 253 0
      src-tauri/src/commands/writing_wake_lock.rs
  6. 8 0
      src-tauri/src/lib.rs
  7. 8 0
      src-tauri/src/main.rs
  8. 48 0
      src/components/chat/agent-stage-stream.spec.tsx
  9. 6 2
      src/components/chat/agent-stage-stream.tsx
  10. 45 0
      src/components/chat/agent-tool-call-message.spec.tsx
  11. 2 2
      src/components/chat/agent-workflow-panel.tsx
  12. 2 2
      src/components/chat/chat-panel.spec.tsx
  13. 45 18
      src/components/chat/chat-panel.tsx
  14. 1 1
      src/components/chat/tool-call-timeline.tsx
  15. 59 0
      src/components/common/timeline-types.spec.ts
  16. 25 0
      src/components/common/timeline-types.ts
  17. 6 2
      src/components/layout/preview-panel.tsx
  18. 6 2
      src/components/novel/character-aura-view.tsx
  19. 1 1
      src/components/novel/de-ai-batch-entry.spec.ts
  20. 22 0
      src/components/settings/llm-presets.ts
  21. 15 0
      src/components/settings/llm-wiki-model-settings.spec.ts
  22. 16 0
      src/components/settings/preset-resolver.ts
  23. 179 4
      src/components/settings/sections/llm-provider-section.tsx
  24. 3 1
      src/components/settings/sections/rerank-section.tsx
  25. 56 62
      src/components/settings/sections/saved-models-manager.tsx
  26. 2 2
      src/components/settings/settings-types.ts
  27. 8 11
      src/components/sources/outline-chat-panel.tsx
  28. 2 6
      src/components/sources/outline-workflow-stages.tsx
  29. 2 1
      src/hooks/use-agent-config.ts
  30. 11 2
      src/i18n/en.json
  31. 11 2
      src/i18n/zh.json
  32. 89 0
      src/lib/agent/activity-trace.spec.ts
  33. 77 2
      src/lib/agent/activity-trace.ts
  34. 21 2
      src/lib/agent/config.ts
  35. 1 1
      src/lib/agent/plan-execute-policy.ts
  36. 74 1
      src/lib/agent/plugins/build-system-prompt-plugin.spec.ts
  37. 22 6
      src/lib/agent/plugins/build-system-prompt-plugin.ts
  38. 3 2
      src/lib/agent/plugins/post-write-check-ai.ts
  39. 3 3
      src/lib/agent/plugins/trim-context-plugin.spec.ts
  40. 11 12
      src/lib/agent/plugins/trim-context-plugin.ts
  41. 49 0
      src/lib/agent/runner.spec.ts
  42. 18 3
      src/lib/agent/runner.ts
  43. 48 1
      src/lib/agent/tool-call-parser.spec.ts
  44. 150 0
      src/lib/agent/tool-call-parser.ts
  45. 41 2
      src/lib/agent/tool-events.spec.ts
  46. 10 1
      src/lib/agent/tool-events.ts
  47. 22 3
      src/lib/agent/tools/index.ts
  48. 27 5
      src/lib/agent/tools/list-chapters.ts
  49. 44 10
      src/lib/agent/tools/list-outlines.ts
  50. 58 4
      src/lib/agent/tools/list-tools.spec.ts
  51. 74 0
      src/lib/agent/tools/outline-list-helpers.spec.ts
  52. 144 0
      src/lib/agent/tools/outline-list-helpers.ts
  53. 8 5
      src/lib/agent/tools/trim-context.ts
  54. 83 0
      src/lib/context-budget.contract.spec.ts
  55. 91 0
      src/lib/context-budget.test.ts
  56. 76 0
      src/lib/context-budget.ts
  57. 3 5
      src/lib/context-hub/ai-chat-integration.spec.ts
  58. 6 0
      src/lib/context-hub/ai-outline-integration.spec.ts
  59. 21 0
      src/lib/context-hub/composer.spec.ts
  60. 8 1
      src/lib/context-hub/composer.ts
  61. 2 0
      src/lib/context-hub/context-hub.ts
  62. 3 0
      src/lib/context-hub/types.ts
  63. 45 0
      src/lib/cursor-cli-provider.spec.ts
  64. 86 0
      src/lib/cursor-cli-proxy.ts
  65. 26 0
      src/lib/has-usable-llm.test.ts
  66. 2 1
      src/lib/has-usable-llm.ts
  67. 5 3
      src/lib/lint.ts
  68. 7 1
      src/lib/llm-client.ts
  69. 23 0
      src/lib/llm-providers.ts
  70. 2 1
      src/lib/novel/book-analysis/character-extraction-engine.ts
  71. 3 2
      src/lib/novel/book-analysis/story-framework-extraction.ts
  72. 2 1
      src/lib/novel/book-analysis/style-analysis-adapter.ts
  73. 41 0
      src/lib/novel/chapter-content-cleanup.spec.ts
  74. 4 65
      src/lib/novel/chapter-content-cleanup.ts
  75. 2 0
      src/lib/novel/chapter-excerpts.ts
  76. 2 1
      src/lib/novel/chapter-ingest.ts
  77. 2 1
      src/lib/novel/chapter-plan-compliance.ts
  78. 11 3
      src/lib/novel/context-engine.ts
  79. 4 5
      src/lib/novel/de-ai-batch/chapter-apply.spec.ts
  80. 9 3
      src/lib/novel/de-ai-batch/chapter-apply.ts
  81. 20 10
      src/lib/novel/deep-chapter-generation.ts
  82. 10 3
      src/lib/novel/dimension-review-adapter.ts
  83. 9 4
      src/lib/novel/lint.ts
  84. 55 0
      src/lib/novel/outline-find-protocol.spec.ts
  85. 52 0
      src/lib/novel/outline-find-protocol.ts
  86. 31 1
      src/lib/novel/previous-chapters-analysis.ts
  87. 3 1
      src/lib/novel/prompt-templates.ts
  88. 1 1
      src/lib/novel/review-adapter.spec.ts
  89. 14 6
      src/lib/novel/review-adapter.ts
  90. 27 1
      src/lib/novel/task-router.ts
  91. 30 5
      src/lib/settings-model-list.ts
  92. 6 0
      src/lib/settings-model-test.ts
  93. 107 0
      src/lib/writing-wake-lock.spec.ts
  94. 66 0
      src/lib/writing-wake-lock.ts
  95. 1 1
      src/stores/wiki-store.ts

+ 166 - 17
src-tauri/Cargo.lock

@@ -683,7 +683,7 @@ version = "3.9.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c"
 checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c"
 dependencies = [
 dependencies = [
- "darling",
+ "darling 0.23.0",
  "ident_case",
  "ident_case",
  "prettyplease",
  "prettyplease",
  "proc-macro2",
  "proc-macro2",
@@ -1312,14 +1312,38 @@ version = "0.0.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
 checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
 
 
+[[package]]
+name = "darling"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
+dependencies = [
+ "darling_core 0.20.11",
+ "darling_macro 0.20.11",
+]
+
 [[package]]
 [[package]]
 name = "darling"
 name = "darling"
 version = "0.23.0"
 version = "0.23.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
 checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
 dependencies = [
 dependencies = [
- "darling_core",
- "darling_macro",
+ "darling_core 0.23.0",
+ "darling_macro 0.23.0",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 2.0.117",
 ]
 ]
 
 
 [[package]]
 [[package]]
@@ -1335,13 +1359,24 @@ dependencies = [
  "syn 2.0.117",
  "syn 2.0.117",
 ]
 ]
 
 
+[[package]]
+name = "darling_macro"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
+dependencies = [
+ "darling_core 0.20.11",
+ "quote",
+ "syn 2.0.117",
+]
+
 [[package]]
 [[package]]
 name = "darling_macro"
 name = "darling_macro"
 version = "0.23.0"
 version = "0.23.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
 checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
 dependencies = [
 dependencies = [
- "darling_core",
+ "darling_core 0.23.0",
  "quote",
  "quote",
  "syn 2.0.117",
  "syn 2.0.117",
 ]
 ]
@@ -2034,6 +2069,37 @@ dependencies = [
  "syn 2.0.117",
  "syn 2.0.117",
 ]
 ]
 
 
+[[package]]
+name = "derive_builder"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
+dependencies = [
+ "derive_builder_macro",
+]
+
+[[package]]
+name = "derive_builder_core"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
+dependencies = [
+ "darling 0.20.11",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "derive_builder_macro"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
+dependencies = [
+ "derive_builder_core",
+ "syn 2.0.117",
+]
+
 [[package]]
 [[package]]
 name = "derive_more"
 name = "derive_more"
 version = "2.1.1"
 version = "2.1.1"
@@ -3680,6 +3746,21 @@ dependencies = [
  "serde_json",
  "serde_json",
 ]
 ]
 
 
+[[package]]
+name = "keepawake"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5521b450ec179362595d5cbda7c3abd5da3af3dff58456434ad3ca33c95226b7"
+dependencies = [
+ "cfg-if",
+ "derive_builder",
+ "objc2-core-foundation",
+ "objc2-io-kit",
+ "thiserror 2.0.18",
+ "windows 0.62.2",
+ "zbus",
+]
+
 [[package]]
 [[package]]
 name = "keyboard-types"
 name = "keyboard-types"
 version = "0.7.0"
 version = "0.7.0"
@@ -4966,7 +5047,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
 checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
 dependencies = [
 dependencies = [
  "bitflags 2.11.1",
  "bitflags 2.11.1",
+ "block2",
  "dispatch2",
  "dispatch2",
+ "libc",
  "objc2",
  "objc2",
 ]
 ]
 
 
@@ -5043,6 +5126,20 @@ dependencies = [
  "objc2-core-foundation",
  "objc2-core-foundation",
 ]
 ]
 
 
+[[package]]
+name = "objc2-io-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
+dependencies = [
+ "bitflags 2.11.1",
+ "block2",
+ "dispatch2",
+ "libc",
+ "objc2",
+ "objc2-core-foundation",
+]
+
 [[package]]
 [[package]]
 name = "objc2-io-surface"
 name = "objc2-io-surface"
 version = "0.3.2"
 version = "0.3.2"
@@ -5740,6 +5837,7 @@ dependencies = [
  "encoding_rs",
  "encoding_rs",
  "futures",
  "futures",
  "image",
  "image",
+ "keepawake",
  "lancedb",
  "lancedb",
  "md-5",
  "md-5",
  "notify",
  "notify",
@@ -6623,7 +6721,7 @@ version = "3.20.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac"
 checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac"
 dependencies = [
 dependencies = [
- "darling",
+ "darling 0.23.0",
  "proc-macro2",
  "proc-macro2",
  "quote",
  "quote",
  "syn 2.0.117",
  "syn 2.0.117",
@@ -7226,7 +7324,7 @@ dependencies = [
  "tao-macros",
  "tao-macros",
  "unicode-segmentation",
  "unicode-segmentation",
  "url",
  "url",
- "windows",
+ "windows 0.61.3",
  "windows-core 0.61.2",
  "windows-core 0.61.2",
  "windows-version",
  "windows-version",
  "x11-dl",
  "x11-dl",
@@ -7315,7 +7413,7 @@ dependencies = [
  "webkit2gtk",
  "webkit2gtk",
  "webview2-com",
  "webview2-com",
  "window-vibrancy",
  "window-vibrancy",
- "windows",
+ "windows 0.61.3",
 ]
 ]
 
 
 [[package]]
 [[package]]
@@ -7480,7 +7578,7 @@ dependencies = [
  "tauri-plugin",
  "tauri-plugin",
  "thiserror 2.0.18",
  "thiserror 2.0.18",
  "url",
  "url",
- "windows",
+ "windows 0.61.3",
  "zbus",
  "zbus",
 ]
 ]
 
 
@@ -7570,7 +7668,7 @@ dependencies = [
  "url",
  "url",
  "webkit2gtk",
  "webkit2gtk",
  "webview2-com",
  "webview2-com",
- "windows",
+ "windows 0.61.3",
 ]
 ]
 
 
 [[package]]
 [[package]]
@@ -7595,7 +7693,7 @@ dependencies = [
  "url",
  "url",
  "webkit2gtk",
  "webkit2gtk",
  "webview2-com",
  "webview2-com",
- "windows",
+ "windows 0.61.3",
  "wry",
  "wry",
 ]
 ]
 
 
@@ -8615,7 +8713,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
 dependencies = [
 dependencies = [
  "webview2-com-macros",
  "webview2-com-macros",
  "webview2-com-sys",
  "webview2-com-sys",
- "windows",
+ "windows 0.61.3",
  "windows-core 0.61.2",
  "windows-core 0.61.2",
  "windows-implement",
  "windows-implement",
  "windows-interface",
  "windows-interface",
@@ -8639,7 +8737,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
 checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
 dependencies = [
 dependencies = [
  "thiserror 2.0.18",
  "thiserror 2.0.18",
- "windows",
+ "windows 0.61.3",
  "windows-core 0.61.2",
  "windows-core 0.61.2",
 ]
 ]
 
 
@@ -8713,11 +8811,23 @@ version = "0.61.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
 checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
 dependencies = [
 dependencies = [
- "windows-collections",
+ "windows-collections 0.2.0",
  "windows-core 0.61.2",
  "windows-core 0.61.2",
- "windows-future",
+ "windows-future 0.2.1",
  "windows-link 0.1.3",
  "windows-link 0.1.3",
- "windows-numerics",
+ "windows-numerics 0.2.0",
+]
+
+[[package]]
+name = "windows"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
+dependencies = [
+ "windows-collections 0.3.2",
+ "windows-core 0.62.2",
+ "windows-future 0.3.2",
+ "windows-numerics 0.3.1",
 ]
 ]
 
 
 [[package]]
 [[package]]
@@ -8729,6 +8839,15 @@ dependencies = [
  "windows-core 0.61.2",
  "windows-core 0.61.2",
 ]
 ]
 
 
+[[package]]
+name = "windows-collections"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
+dependencies = [
+ "windows-core 0.62.2",
+]
+
 [[package]]
 [[package]]
 name = "windows-core"
 name = "windows-core"
 version = "0.61.2"
 version = "0.61.2"
@@ -8763,7 +8882,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
 dependencies = [
 dependencies = [
  "windows-core 0.61.2",
  "windows-core 0.61.2",
  "windows-link 0.1.3",
  "windows-link 0.1.3",
- "windows-threading",
+ "windows-threading 0.1.0",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
+dependencies = [
+ "windows-core 0.62.2",
+ "windows-link 0.2.1",
+ "windows-threading 0.2.1",
 ]
 ]
 
 
 [[package]]
 [[package]]
@@ -8810,6 +8940,16 @@ dependencies = [
  "windows-link 0.1.3",
  "windows-link 0.1.3",
 ]
 ]
 
 
+[[package]]
+name = "windows-numerics"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
+dependencies = [
+ "windows-core 0.62.2",
+ "windows-link 0.2.1",
+]
+
 [[package]]
 [[package]]
 name = "windows-registry"
 name = "windows-registry"
 version = "0.6.1"
 version = "0.6.1"
@@ -8959,6 +9099,15 @@ dependencies = [
  "windows-link 0.1.3",
  "windows-link 0.1.3",
 ]
 ]
 
 
+[[package]]
+name = "windows-threading"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
+dependencies = [
+ "windows-link 0.2.1",
+]
+
 [[package]]
 [[package]]
 name = "windows-version"
 name = "windows-version"
 version = "0.1.7"
 version = "0.1.7"
@@ -9284,7 +9433,7 @@ dependencies = [
  "webkit2gtk",
  "webkit2gtk",
  "webkit2gtk-sys",
  "webkit2gtk-sys",
  "webview2-com",
  "webview2-com",
- "windows",
+ "windows 0.61.3",
  "windows-core 0.61.2",
  "windows-core 0.61.2",
  "windows-version",
  "windows-version",
  "x11-dl",
  "x11-dl",

+ 1 - 0
src-tauri/Cargo.toml

@@ -60,6 +60,7 @@ md-5 = "0.10"
 notify = "8"
 notify = "8"
 walkdir = "2"
 walkdir = "2"
 tauri-plugin-single-instance = "2"
 tauri-plugin-single-instance = "2"
+keepawake = "0.6"
 
 
 [dev-dependencies]
 [dev-dependencies]
 tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] }
 tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] }

+ 608 - 0
src-tauri/src/commands/cursor_cli.rs

@@ -0,0 +1,608 @@
+//! Cursor CLI + cursor-api-proxy management.
+//!
+//! Detects the local `agent` binary and can start `cursor-api-proxy` so the
+//! frontend can talk OpenAI-compatible HTTP. Port is chosen dynamically
+//! (prefer 8765, else an ephemeral free port) via `CURSOR_BRIDGE_PORT`.
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use serde::Serialize;
+use tauri::State;
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::TcpStream;
+use tokio::process::{Child, Command};
+use tokio::sync::Mutex;
+
+use super::cli_resolver::{child_path_env, find_cli_command};
+use super::local_cli_config::{apply_local_cli_environment, resolve_home_dir};
+
+const PREFERRED_PROXY_PORT: u16 = 8765;
+const DEFAULT_PROXY_BASE: &str = "http://127.0.0.1:8765";
+const PROXY_START_TIMEOUT_MS: u64 = 15_000;
+const PROXY_POLL_MS: u64 = 200;
+
+#[derive(Default)]
+struct ManagedProxy {
+    child: Option<Child>,
+    /// e.g. http://127.0.0.1:8765 — the port this managed child actually bound.
+    base_url: Option<String>,
+}
+
+#[derive(Default)]
+pub struct CursorProxyState {
+    managed: Arc<Mutex<ManagedProxy>>,
+}
+
+#[derive(Serialize)]
+pub struct DetectResult {
+    installed: bool,
+    version: Option<String>,
+    path: Option<String>,
+    model: Option<String>,
+    error: Option<String>,
+}
+
+#[derive(Serialize)]
+pub struct ProxyStatus {
+    healthy: bool,
+    base_url: String,
+    managed: bool,
+    error: Option<String>,
+}
+
+fn suppress_windows_console(_cmd: &mut Command) {
+    #[cfg(windows)]
+    {
+        #[allow(unused_imports)]
+        use std::os::windows::process::CommandExt;
+
+        const CREATE_NO_WINDOW: u32 = 0x08000000;
+        _cmd.creation_flags(CREATE_NO_WINDOW);
+    }
+}
+
+async fn find_agent_command() -> Result<std::path::PathBuf, String> {
+    find_cli_command("agent", &["agent.cmd", "agent.exe"]).await
+}
+
+async fn find_proxy_launcher() -> Result<(std::path::PathBuf, Vec<String>), String> {
+    if let Ok(bin) = find_cli_command(
+        "cursor-api-proxy",
+        &["cursor-api-proxy.cmd", "cursor-api-proxy.exe"],
+    )
+    .await
+    {
+        return Ok((bin, vec![]));
+    }
+
+    let npx = find_cli_command("npx", &["npx.cmd", "npx.exe"])
+        .await
+        .map_err(|_| {
+            "`cursor-api-proxy` and `npx` not found on PATH. Install Node.js 18+ and `npm i -g cursor-api-proxy`, or ensure `npx` works."
+                .to_string()
+        })?;
+    Ok((
+        npx,
+        vec![
+            "--yes".to_string(),
+            "cursor-api-proxy".to_string(),
+        ],
+    ))
+}
+
+fn normalize_proxy_base(base_url: Option<String>) -> String {
+    let raw = base_url
+        .unwrap_or_else(|| DEFAULT_PROXY_BASE.to_string())
+        .trim()
+        .to_string();
+    let trimmed = raw.trim_end_matches('/').to_string();
+    if trimmed.to_lowercase().ends_with("/v1") {
+        trimmed[..trimmed.len() - 3].trim_end_matches('/').to_string()
+    } else {
+        trimmed
+    }
+}
+
+fn parse_http_url(base: &str) -> Result<(String, u16, String), String> {
+    let url = base.trim();
+    let without_scheme = if let Some(rest) = url.strip_prefix("http://") {
+        rest
+    } else if url.starts_with("https://") {
+        return Err("cursor-api-proxy health check only supports http:// localhost URLs".to_string());
+    } else {
+        return Err(format!("Invalid proxy base URL: {base}"));
+    };
+
+    let (host_port, path) = match without_scheme.split_once('/') {
+        Some((hp, p)) => (hp, format!("/{p}")),
+        None => (without_scheme, "/".to_string()),
+    };
+
+    let (host, port) = if let Some((h, p)) = host_port.rsplit_once(':') {
+        let port: u16 = p
+            .parse()
+            .map_err(|_| format!("Invalid port in proxy URL: {base}"))?;
+        (h.to_string(), port)
+    } else {
+        (host_port.to_string(), 80)
+    };
+
+    Ok((host, port, path))
+}
+
+fn port_available(port: u16) -> bool {
+    std::net::TcpListener::bind(("127.0.0.1", port)).is_ok()
+}
+
+/// Prefer 8765; if taken, bind `:0` once to learn a free ephemeral port.
+fn allocate_proxy_port() -> Result<u16, String> {
+    if port_available(PREFERRED_PROXY_PORT) {
+        return Ok(PREFERRED_PROXY_PORT);
+    }
+    let listener = std::net::TcpListener::bind(("127.0.0.1", 0))
+        .map_err(|e| format!("Failed to allocate free localhost port: {e}"))?;
+    let port = listener
+        .local_addr()
+        .map_err(|e| format!("Failed to read allocated port: {e}"))?
+        .port();
+    drop(listener);
+    Ok(port)
+}
+
+fn base_url_for_port(port: u16) -> String {
+    format!("http://127.0.0.1:{port}")
+}
+
+async fn http_get_status(base: &str, path: &str) -> Result<u16, String> {
+    let (host, port, _) = parse_http_url(base)?;
+    let request_path = if path.starts_with('/') {
+        path.to_string()
+    } else {
+        format!("/{path}")
+    };
+
+    let mut stream = tokio::time::timeout(
+        Duration::from_secs(2),
+        TcpStream::connect((host.as_str(), port)),
+    )
+    .await
+    .map_err(|_| "health check timed out connecting".to_string())?
+    .map_err(|e| format!("health check connect failed: {e}"))?;
+
+    let req = format!(
+        "GET {request_path} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n"
+    );
+    stream
+        .write_all(req.as_bytes())
+        .await
+        .map_err(|e| format!("health check write failed: {e}"))?;
+
+    let mut buf = vec![0u8; 1024];
+    let n = tokio::time::timeout(Duration::from_secs(2), stream.read(&mut buf))
+        .await
+        .map_err(|_| "health check timed out reading".to_string())?
+        .map_err(|e| format!("health check read failed: {e}"))?;
+
+    let text = String::from_utf8_lossy(&buf[..n]);
+    let status_line = text.lines().next().unwrap_or("");
+    let code = status_line
+        .split_whitespace()
+        .nth(1)
+        .and_then(|s| s.parse::<u16>().ok())
+        .ok_or_else(|| format!("unexpected health response: {status_line}"))?;
+    Ok(code)
+}
+
+async fn ping_health(base: &str) -> bool {
+    matches!(http_get_status(base, "/health").await, Ok(200))
+}
+
+/// Detect whether Cursor `agent` CLI is installed on PATH.
+pub async fn do_cursor_cli_detect() -> Result<DetectResult, String> {
+    let path = match find_agent_command().await {
+        Ok(p) => p,
+        Err(error) => {
+            return Ok(DetectResult {
+                installed: false,
+                version: None,
+                path: None,
+                model: None,
+                error: Some(error),
+            });
+        }
+    };
+
+    let path_str = path.to_string_lossy().to_string();
+    let mut cmd = Command::new(&path);
+    suppress_windows_console(&mut cmd);
+    apply_local_cli_environment(&mut cmd);
+    if let Some(path_env) = child_path_env().await {
+        cmd.env("PATH", path_env);
+    }
+
+    let output = tokio::time::timeout(Duration::from_secs(5), cmd.arg("--version").output()).await;
+
+    match output {
+        Ok(Ok(out)) if out.status.success() => {
+            let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
+            let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
+            let version = if !stdout.is_empty() {
+                stdout
+            } else if !stderr.is_empty() {
+                stderr
+            } else {
+                "agent".to_string()
+            };
+            Ok(DetectResult {
+                installed: true,
+                version: Some(version),
+                path: Some(path_str),
+                model: None,
+                error: None,
+            })
+        }
+        Ok(Ok(out)) => {
+            let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
+            Ok(DetectResult {
+                installed: true,
+                version: None,
+                path: Some(path_str),
+                model: None,
+                error: Some(if stderr.is_empty() {
+                    format!("`agent --version` exited with {}", out.status)
+                } else {
+                    stderr
+                }),
+            })
+        }
+        Ok(Err(e)) => Ok(DetectResult {
+            installed: false,
+            version: None,
+            path: Some(path_str),
+            model: None,
+            error: Some(format!("Failed to spawn `agent`: {e}")),
+        }),
+        Err(_) => Ok(DetectResult {
+            installed: true,
+            version: None,
+            path: Some(path_str),
+            model: None,
+            error: Some("`agent --version` timed out after 5s".to_string()),
+        }),
+    }
+}
+
+#[tauri::command]
+pub async fn cursor_cli_detect() -> Result<DetectResult, String> {
+    do_cursor_cli_detect().await
+}
+
+#[tauri::command]
+pub async fn cursor_proxy_status(state: State<'_, CursorProxyState>) -> Result<ProxyStatus, String> {
+    let (base, managed) = {
+        let guard = state.managed.lock().await;
+        (
+            guard
+                .base_url
+                .clone()
+                .unwrap_or_else(|| DEFAULT_PROXY_BASE.to_string()),
+            guard.child.is_some(),
+        )
+    };
+    let healthy = ping_health(&base).await;
+    Ok(ProxyStatus {
+        healthy,
+        base_url: base,
+        managed,
+        error: if healthy {
+            None
+        } else {
+            Some("cursor-api-proxy is not reachable".to_string())
+        },
+    })
+}
+
+fn read_nonempty_env(keys: &[&str]) -> Option<String> {
+    keys.iter().find_map(|key| {
+        std::env::var(key)
+            .ok()
+            .map(|v| v.trim().to_string())
+            .filter(|v| !v.is_empty())
+    })
+}
+
+/// Parse `export NAME=value` / `NAME=value` from a shell rc file. No secret logging.
+fn read_export_from_rc(rc_path: &std::path::Path, name: &str) -> Option<String> {
+    let content = std::fs::read_to_string(rc_path).ok()?;
+    let prefix = format!("{name}=");
+    for raw in content.lines() {
+        let line = raw.trim();
+        if line.is_empty() || line.starts_with('#') {
+            continue;
+        }
+        let line = line.strip_prefix("export ").unwrap_or(line).trim();
+        if let Some(rest) = line.strip_prefix(&prefix) {
+            let value = rest
+                .trim()
+                .trim_matches(|c| c == '\'' || c == '"')
+                .trim()
+                .to_string();
+            if !value.is_empty() {
+                return Some(value);
+            }
+        }
+    }
+    None
+}
+
+fn read_cursor_api_key_from_user_files() -> Option<String> {
+    let home = resolve_home_dir()?;
+    for rel in [".zshrc", ".zprofile", ".bashrc", ".bash_profile", ".profile"] {
+        if let Some(v) = read_export_from_rc(&home.join(rel), "CURSOR_API_KEY") {
+            return Some(v);
+        }
+    }
+    let auth_path = home.join(".cursor").join("auth.json");
+    let content = std::fs::read_to_string(auth_path).ok()?;
+    let json: serde_json::Value = serde_json::from_str(&content).ok()?;
+    json.get("apiKey")
+        .and_then(|v| v.as_str())
+        .map(str::trim)
+        .filter(|v| !v.is_empty())
+        .map(ToOwned::to_owned)
+}
+
+fn read_agent_credential_store_from_user_files() -> Option<String> {
+    let home = resolve_home_dir()?;
+    for rel in [".zshrc", ".zprofile", ".bashrc", ".bash_profile", ".profile"] {
+        if let Some(v) = read_export_from_rc(&home.join(rel), "AGENT_CLI_CREDENTIAL_STORE") {
+            return Some(v);
+        }
+    }
+    None
+}
+
+/// GUI apps do not load ~/.zshrc. Inject the same Cursor CLI auth the user
+/// exports in shell: CURSOR_API_KEY + AGENT_CLI_CREDENTIAL_STORE.
+fn apply_cursor_auth_env(cmd: &mut Command) {
+    let api_key = read_nonempty_env(&["CURSOR_API_KEY"]).or_else(read_cursor_api_key_from_user_files);
+    if let Some(api_key) = api_key {
+        cmd.env("CURSOR_API_KEY", api_key);
+    }
+
+    let store = read_nonempty_env(&["AGENT_CLI_CREDENTIAL_STORE"])
+        .or_else(read_agent_credential_store_from_user_files)
+        .unwrap_or_else(|| "file".to_string());
+    cmd.env("AGENT_CLI_CREDENTIAL_STORE", store);
+
+    if let Some(token) = read_nonempty_env(&["CURSOR_AUTH_TOKEN"]) {
+        cmd.env("CURSOR_AUTH_TOKEN", token);
+    }
+
+    for key in [
+        "HTTP_PROXY",
+        "HTTPS_PROXY",
+        "ALL_PROXY",
+        "http_proxy",
+        "https_proxy",
+        "all_proxy",
+    ] {
+        cmd.env_remove(key);
+    }
+}
+
+fn shell_quote(value: &str) -> String {
+    format!("'{}'", value.replace('\'', "'\"'\"'"))
+}
+
+async fn spawn_proxy_process(port: u16) -> Result<Child, String> {
+    let (launcher, extra_args) = find_proxy_launcher().await?;
+    let path_env = child_path_env().await;
+
+    #[cfg(unix)]
+    {
+        let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
+        let mut parts = Vec::with_capacity(1 + extra_args.len());
+        parts.push(shell_quote(&launcher.to_string_lossy()));
+        for arg in &extra_args {
+            parts.push(shell_quote(arg));
+        }
+        let cmdline = format!("exec {}", parts.join(" "));
+
+        let mut cmd = Command::new(&shell);
+        suppress_windows_console(&mut cmd);
+        apply_local_cli_environment(&mut cmd);
+        if let Some(path_env) = path_env {
+            cmd.env("PATH", path_env);
+        }
+        apply_cursor_auth_env(&mut cmd);
+        cmd.env("CURSOR_BRIDGE_HOST", "127.0.0.1");
+        cmd.env("CURSOR_BRIDGE_PORT", port.to_string());
+        cmd.args(["-l", "-c", &cmdline]);
+        cmd.stdin(std::process::Stdio::null())
+            .stdout(std::process::Stdio::null())
+            .stderr(std::process::Stdio::null())
+            .kill_on_drop(true);
+
+        return cmd
+            .spawn()
+            .map_err(|e| format!("Failed to start cursor-api-proxy: {e}"));
+    }
+
+    #[cfg(windows)]
+    {
+        let mut cmd = Command::new(&launcher);
+        suppress_windows_console(&mut cmd);
+        apply_local_cli_environment(&mut cmd);
+        if let Some(path_env) = path_env {
+            cmd.env("PATH", path_env);
+        }
+        apply_cursor_auth_env(&mut cmd);
+        cmd.env("CURSOR_BRIDGE_HOST", "127.0.0.1");
+        cmd.env("CURSOR_BRIDGE_PORT", port.to_string());
+        cmd.args(&extra_args);
+        cmd.stdin(std::process::Stdio::null())
+            .stdout(std::process::Stdio::null())
+            .stderr(std::process::Stdio::null())
+            .kill_on_drop(true);
+
+        cmd.spawn()
+            .map_err(|e| format!("Failed to start cursor-api-proxy: {e}"))
+    }
+}
+
+async fn stop_managed_child(state: &CursorProxyState) {
+    let mut guard = state.managed.lock().await;
+    if let Some(mut child) = guard.child.take() {
+        let _ = child.start_kill();
+        let _ = tokio::time::timeout(Duration::from_secs(3), child.wait()).await;
+    }
+    guard.base_url = None;
+}
+
+async fn wait_until_healthy(base: &str) -> bool {
+    let deadline = tokio::time::Instant::now() + Duration::from_millis(PROXY_START_TIMEOUT_MS);
+    while tokio::time::Instant::now() < deadline {
+        if ping_health(base).await {
+            return true;
+        }
+        tokio::time::sleep(Duration::from_millis(PROXY_POLL_MS)).await;
+    }
+    false
+}
+
+/// Ensure cursor-api-proxy is healthy. Starts (or force-restarts) on a free port.
+#[tauri::command]
+pub async fn cursor_proxy_ensure(
+    state: State<'_, CursorProxyState>,
+    force_restart: Option<bool>,
+) -> Result<ProxyStatus, String> {
+    let force = force_restart.unwrap_or(false);
+
+    {
+        let mut guard = state.managed.lock().await;
+        if let Some(child) = guard.child.as_mut() {
+            match child.try_wait() {
+                Ok(None) => {
+                    if !force {
+                        if let Some(base) = guard.base_url.clone() {
+                            drop(guard);
+                            if ping_health(&base).await {
+                                return Ok(ProxyStatus {
+                                    healthy: true,
+                                    base_url: base,
+                                    managed: true,
+                                    error: None,
+                                });
+                            }
+                        }
+                    }
+                }
+                _ => {
+                    guard.child = None;
+                    guard.base_url = None;
+                }
+            }
+        }
+    }
+
+    stop_managed_child(&state).await;
+
+    let port = allocate_proxy_port()?;
+    let base = base_url_for_port(port);
+    let child = spawn_proxy_process(port).await?;
+    {
+        let mut guard = state.managed.lock().await;
+        guard.child = Some(child);
+        guard.base_url = Some(base.clone());
+    }
+
+    if wait_until_healthy(&base).await {
+        return Ok(ProxyStatus {
+            healthy: true,
+            base_url: base,
+            managed: true,
+            error: None,
+        });
+    }
+
+    stop_managed_child(&state).await;
+    Err(format!(
+        "Started cursor-api-proxy on {base} but /health did not become ready within {PROXY_START_TIMEOUT_MS}ms. Ensure Node.js 18+, `agent` CLI, and that CURSOR_API_KEY / AGENT_CLI_CREDENTIAL_STORE are set in ~/.zshrc (or auth.json)."
+    ))
+}
+
+/// Stop the proxy process if this app started it.
+#[tauri::command]
+pub async fn cursor_proxy_stop(state: State<'_, CursorProxyState>) -> Result<(), String> {
+    stop_managed_child(&state).await;
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn normalize_strips_v1_suffix() {
+        assert_eq!(
+            normalize_proxy_base(Some("http://127.0.0.1:8765/v1".into())),
+            "http://127.0.0.1:8765"
+        );
+        assert_eq!(
+            normalize_proxy_base(Some("http://127.0.0.1:8765/".into())),
+            "http://127.0.0.1:8765"
+        );
+        assert_eq!(normalize_proxy_base(None), DEFAULT_PROXY_BASE);
+    }
+
+    #[test]
+    fn parse_localhost_url() {
+        let (host, port, path) = parse_http_url("http://127.0.0.1:8765").unwrap();
+        assert_eq!(host, "127.0.0.1");
+        assert_eq!(port, 8765);
+        assert_eq!(path, "/");
+    }
+
+    #[test]
+    fn allocate_prefers_8765_when_free() {
+        if port_available(PREFERRED_PROXY_PORT) {
+            assert_eq!(allocate_proxy_port().unwrap(), PREFERRED_PROXY_PORT);
+        }
+    }
+
+    #[test]
+    fn allocate_returns_nonzero_when_preferred_taken() {
+        let _hold = std::net::TcpListener::bind(("127.0.0.1", PREFERRED_PROXY_PORT));
+        if _hold.is_err() {
+            let port = allocate_proxy_port().unwrap();
+            assert!(port > 0);
+            return;
+        }
+        let port = allocate_proxy_port().unwrap();
+        assert_ne!(port, PREFERRED_PROXY_PORT);
+        assert!(port > 0);
+    }
+
+    #[test]
+    fn reads_export_lines_from_rc() {
+        let dir = std::env::temp_dir().join(format!("qmai-cursor-rc-{}", std::process::id()));
+        let _ = std::fs::create_dir_all(&dir);
+        let rc = dir.join(".zshrc");
+        std::fs::write(
+            &rc,
+            "# comment\nexport CURSOR_API_KEY=crsr_test_key\nexport AGENT_CLI_CREDENTIAL_STORE=file\n",
+        )
+        .unwrap();
+        assert_eq!(
+            read_export_from_rc(&rc, "CURSOR_API_KEY").as_deref(),
+            Some("crsr_test_key")
+        );
+        assert_eq!(
+            read_export_from_rc(&rc, "AGENT_CLI_CREDENTIAL_STORE").as_deref(),
+            Some("file")
+        );
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+}

+ 2 - 0
src-tauri/src/commands/mod.rs

@@ -2,6 +2,7 @@ pub mod backup;
 pub mod claude_cli;
 pub mod claude_cli;
 mod cli_resolver;
 mod cli_resolver;
 pub mod codex_cli;
 pub mod codex_cli;
+pub mod cursor_cli;
 pub mod extract_images;
 pub mod extract_images;
 pub mod file_sync;
 pub mod file_sync;
 pub mod fs;
 pub mod fs;
@@ -9,3 +10,4 @@ pub mod local_cli_config;
 pub mod mcp_stdio;
 pub mod mcp_stdio;
 pub mod project;
 pub mod project;
 pub mod vectorstore;
 pub mod vectorstore;
+pub mod writing_wake_lock;

+ 253 - 0
src-tauri/src/commands/writing_wake_lock.rs

@@ -0,0 +1,253 @@
+use std::{
+    collections::HashSet,
+    sync::mpsc,
+    thread::{self, JoinHandle},
+};
+
+use tauri::State;
+use tokio::sync::oneshot;
+
+type WorkerResult<T> = Result<T, String>;
+
+enum WorkerCommand {
+    Acquire {
+        reply: oneshot::Sender<WorkerResult<String>>,
+    },
+    Release {
+        token: String,
+        reply: oneshot::Sender<WorkerResult<()>>,
+    },
+}
+
+struct WakeLockRegistry<T> {
+    tokens: HashSet<String>,
+    assertion: Option<T>,
+}
+
+impl<T> Default for WakeLockRegistry<T> {
+    fn default() -> Self {
+        Self {
+            tokens: HashSet::new(),
+            assertion: None,
+        }
+    }
+}
+
+impl<T> WakeLockRegistry<T> {
+    fn acquire<E>(&mut self, create_assertion: impl FnOnce() -> Result<T, E>) -> Result<String, E> {
+        if self.assertion.is_none() {
+            self.assertion = Some(create_assertion()?);
+        }
+
+        let token = uuid::Uuid::new_v4().to_string();
+        self.tokens.insert(token.clone());
+        Ok(token)
+    }
+
+    fn release(&mut self, token: &str) {
+        if !self.tokens.remove(token) {
+            return;
+        }
+        if self.tokens.is_empty() {
+            self.assertion.take();
+        }
+    }
+}
+
+fn create_system_wake_lock() -> keepawake::Result<keepawake::KeepAwake> {
+    keepawake::Builder::default()
+        .display(true)
+        .idle(true)
+        .sleep(false)
+        .reason("QMaiWrite 正在生成小说正文")
+        .app_name("QMaiWrite")
+        .app_reverse_domain("com.qingmuai.writer")
+        .create()
+}
+
+fn run_worker(receiver: mpsc::Receiver<WorkerCommand>) {
+    // keepawake 在 Windows 上使用线程级 SetThreadExecutionState。
+    // 创建、持有和释放必须始终发生在这个专用线程,不能交给 Tauri IPC 线程池。
+    let mut registry = WakeLockRegistry::default();
+
+    while let Ok(command) = receiver.recv() {
+        match command {
+            WorkerCommand::Acquire { reply } => {
+                let result = registry
+                    .acquire(create_system_wake_lock)
+                    .map_err(|error| format!("无法启用写作防休眠:{error}"));
+                let _ = reply.send(result);
+            }
+            WorkerCommand::Release { token, reply } => {
+                registry.release(&token);
+                let _ = reply.send(Ok(()));
+            }
+        }
+    }
+    // receiver 断开时 registry 在此线程销毁,确保系统断言也在原线程释放。
+}
+
+pub struct WritingWakeLockManager {
+    sender: Option<mpsc::Sender<WorkerCommand>>,
+    worker: Option<JoinHandle<()>>,
+}
+
+impl Default for WritingWakeLockManager {
+    fn default() -> Self {
+        let (sender, receiver) = mpsc::channel();
+        let worker = thread::Builder::new()
+            .name("qmai-writing-wake-lock".to_string())
+            .spawn(move || run_worker(receiver));
+
+        match worker {
+            Ok(worker) => Self {
+                sender: Some(sender),
+                worker: Some(worker),
+            },
+            Err(error) => {
+                eprintln!("[writing-wake-lock] 无法启动防休眠线程:{error}");
+                Self {
+                    sender: None,
+                    worker: None,
+                }
+            }
+        }
+    }
+}
+
+impl WritingWakeLockManager {
+    async fn acquire(&self) -> WorkerResult<String> {
+        let (reply, response) = oneshot::channel();
+        self.sender
+            .as_ref()
+            .ok_or_else(|| "写作防休眠管理器已关闭".to_string())?
+            .send(WorkerCommand::Acquire { reply })
+            .map_err(|_| "写作防休眠线程不可用".to_string())?;
+        response
+            .await
+            .map_err(|_| "写作防休眠线程未返回结果".to_string())?
+    }
+
+    async fn release(&self, token: String) -> WorkerResult<()> {
+        let (reply, response) = oneshot::channel();
+        self.sender
+            .as_ref()
+            .ok_or_else(|| "写作防休眠管理器已关闭".to_string())?
+            .send(WorkerCommand::Release { token, reply })
+            .map_err(|_| "写作防休眠线程不可用".to_string())?;
+        response
+            .await
+            .map_err(|_| "写作防休眠线程未返回结果".to_string())?
+    }
+}
+
+impl Drop for WritingWakeLockManager {
+    fn drop(&mut self) {
+        self.sender.take();
+        if let Some(worker) = self.worker.take() {
+            let _ = worker.join();
+        }
+    }
+}
+
+#[tauri::command]
+pub async fn acquire_writing_wake_lock(
+    state: State<'_, WritingWakeLockManager>,
+) -> WorkerResult<String> {
+    state.acquire().await
+}
+
+#[tauri::command]
+pub async fn release_writing_wake_lock(
+    token: String,
+    state: State<'_, WritingWakeLockManager>,
+) -> WorkerResult<()> {
+    state.release(token).await
+}
+
+#[cfg(test)]
+mod tests {
+    use super::WakeLockRegistry;
+    use std::sync::{
+        atomic::{AtomicUsize, Ordering},
+        Arc,
+    };
+
+    struct DropProbe(Arc<AtomicUsize>);
+
+    impl Drop for DropProbe {
+        fn drop(&mut self) {
+            self.0.fetch_add(1, Ordering::SeqCst);
+        }
+    }
+
+    #[test]
+    fn concurrent_tokens_share_one_assertion_until_the_last_release() {
+        let creates = Arc::new(AtomicUsize::new(0));
+        let drops = Arc::new(AtomicUsize::new(0));
+        let mut registry = WakeLockRegistry::default();
+
+        let first = registry
+            .acquire(|| {
+                creates.fetch_add(1, Ordering::SeqCst);
+                Ok::<_, ()>(DropProbe(drops.clone()))
+            })
+            .unwrap();
+        let second = registry
+            .acquire(|| {
+                creates.fetch_add(1, Ordering::SeqCst);
+                Ok::<_, ()>(DropProbe(drops.clone()))
+            })
+            .unwrap();
+
+        assert_eq!(creates.load(Ordering::SeqCst), 1);
+        registry.release(&first);
+        assert_eq!(drops.load(Ordering::SeqCst), 0);
+        registry.release(&second);
+        assert_eq!(drops.load(Ordering::SeqCst), 1);
+    }
+
+    #[test]
+    fn duplicate_or_unknown_release_is_idempotent() {
+        let drops = Arc::new(AtomicUsize::new(0));
+        let mut registry = WakeLockRegistry::default();
+        let token = registry
+            .acquire(|| Ok::<_, ()>(DropProbe(drops.clone())))
+            .unwrap();
+
+        registry.release("unknown");
+        assert_eq!(drops.load(Ordering::SeqCst), 0);
+        registry.release(&token);
+        registry.release(&token);
+        assert_eq!(drops.load(Ordering::SeqCst), 1);
+    }
+
+    #[test]
+    fn failed_creation_does_not_register_a_token_and_can_retry() {
+        let drops = Arc::new(AtomicUsize::new(0));
+        let mut registry: WakeLockRegistry<DropProbe> = WakeLockRegistry::default();
+
+        assert!(registry
+            .acquire(|| Err::<DropProbe, _>("unavailable"))
+            .is_err());
+        assert!(registry.tokens.is_empty());
+
+        let token = registry
+            .acquire(|| Ok::<_, &str>(DropProbe(drops.clone())))
+            .unwrap();
+        registry.release(&token);
+        assert_eq!(drops.load(Ordering::SeqCst), 1);
+    }
+
+    #[test]
+    fn dropping_registry_releases_an_active_assertion() {
+        let drops = Arc::new(AtomicUsize::new(0));
+        {
+            let mut registry = WakeLockRegistry::default();
+            registry
+                .acquire(|| Ok::<_, ()>(DropProbe(drops.clone())))
+                .unwrap();
+        }
+        assert_eq!(drops.load(Ordering::SeqCst), 1);
+    }
+}

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

@@ -40,8 +40,10 @@ pub fn run() {
             }
             }
             app.manage(commands::claude_cli::ClaudeCliState::default());
             app.manage(commands::claude_cli::ClaudeCliState::default());
             app.manage(commands::codex_cli::CodexCliState::default());
             app.manage(commands::codex_cli::CodexCliState::default());
+            app.manage(commands::cursor_cli::CursorProxyState::default());
             app.manage(commands::file_sync::FileSyncState::default());
             app.manage(commands::file_sync::FileSyncState::default());
             app.manage(commands::mcp_stdio::McpStdioState::default());
             app.manage(commands::mcp_stdio::McpStdioState::default());
+            app.manage(commands::writing_wake_lock::WritingWakeLockManager::default());
             Ok(())
             Ok(())
         })
         })
         .invoke_handler(tauri::generate_handler![
         .invoke_handler(tauri::generate_handler![
@@ -84,6 +86,10 @@ pub fn run() {
             commands::codex_cli::codex_cli_detect,
             commands::codex_cli::codex_cli_detect,
             commands::codex_cli::codex_cli_spawn,
             commands::codex_cli::codex_cli_spawn,
             commands::codex_cli::codex_cli_kill,
             commands::codex_cli::codex_cli_kill,
+            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_pdf_images_cmd,
             commands::extract_images::extract_pdf_images_cmd,
             commands::extract_images::extract_office_images_cmd,
             commands::extract_images::extract_office_images_cmd,
             commands::extract_images::extract_and_save_pdf_images_cmd,
             commands::extract_images::extract_and_save_pdf_images_cmd,
@@ -101,6 +107,8 @@ pub fn run() {
             commands::backup::export_backup,
             commands::backup::export_backup,
             commands::backup::import_backup,
             commands::backup::import_backup,
             commands::backup::read_backup_manifest,
             commands::backup::read_backup_manifest,
+            commands::writing_wake_lock::acquire_writing_wake_lock,
+            commands::writing_wake_lock::release_writing_wake_lock,
             set_proxy_env,
             set_proxy_env,
         ])
         ])
         .on_window_event(|window, event| {
         .on_window_event(|window, event| {

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

@@ -53,8 +53,10 @@ fn main() {
             }
             }
             app.manage(commands::claude_cli::ClaudeCliState::default());
             app.manage(commands::claude_cli::ClaudeCliState::default());
             app.manage(commands::codex_cli::CodexCliState::default());
             app.manage(commands::codex_cli::CodexCliState::default());
+            app.manage(commands::cursor_cli::CursorProxyState::default());
             app.manage(commands::file_sync::FileSyncState::default());
             app.manage(commands::file_sync::FileSyncState::default());
             app.manage(commands::mcp_stdio::McpStdioState::default());
             app.manage(commands::mcp_stdio::McpStdioState::default());
+            app.manage(commands::writing_wake_lock::WritingWakeLockManager::default());
             Ok(())
             Ok(())
         })
         })
         .invoke_handler(tauri::generate_handler![
         .invoke_handler(tauri::generate_handler![
@@ -96,6 +98,10 @@ fn main() {
             commands::codex_cli::codex_cli_detect,
             commands::codex_cli::codex_cli_detect,
             commands::codex_cli::codex_cli_spawn,
             commands::codex_cli::codex_cli_spawn,
             commands::codex_cli::codex_cli_kill,
             commands::codex_cli::codex_cli_kill,
+            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_pdf_images_cmd,
             commands::extract_images::extract_pdf_images_cmd,
             commands::extract_images::extract_office_images_cmd,
             commands::extract_images::extract_office_images_cmd,
             commands::extract_images::extract_and_save_pdf_images_cmd,
             commands::extract_images::extract_and_save_pdf_images_cmd,
@@ -113,6 +119,8 @@ fn main() {
             commands::backup::export_backup,
             commands::backup::export_backup,
             commands::backup::import_backup,
             commands::backup::import_backup,
             commands::backup::read_backup_manifest,
             commands::backup::read_backup_manifest,
+            commands::writing_wake_lock::acquire_writing_wake_lock,
+            commands::writing_wake_lock::release_writing_wake_lock,
             set_proxy_env,
             set_proxy_env,
             log_error,
             log_error,
         ])
         ])

+ 48 - 0
src/components/chat/agent-stage-stream.spec.tsx

@@ -68,4 +68,52 @@ describe("AgentStageStream", () => {
     expect(html).toContain("min-w-0")
     expect(html).toContain("min-w-0")
     expect(html).toContain("max-w-full")
     expect(html).toContain("max-w-full")
   })
   })
+
+  it("renders stages in canonical order and hides redundant chapter_workflow", () => {
+    const disordered: AgentStageTrace[] = [
+      {
+        id: "final_output",
+        title: "最终输出",
+        status: "done",
+        summary: "正文已生成",
+        events: [],
+        startedAt: 400,
+      },
+      {
+        id: "chapter_workflow",
+        title: "多任务写作循环",
+        status: "running",
+        summary: "不应展示",
+        events: [],
+        startedAt: 50,
+      },
+      {
+        id: "read_context",
+        title: "读取上下文",
+        status: "done",
+        summary: "已读",
+        events: [],
+        startedAt: 100,
+      },
+      {
+        id: "generate_draft",
+        title: "生成章节草稿",
+        status: "done",
+        summary: "草稿完成",
+        events: [],
+        startedAt: 200,
+      },
+    ]
+
+    const html = renderToStaticMarkup(<AgentStageStream stages={disordered} />)
+    const readIndex = html.indexOf("读取上下文")
+    const draftIndex = html.indexOf("生成章节草稿")
+    const finalIndex = html.indexOf("最终输出")
+
+    expect(readIndex).toBeGreaterThan(-1)
+    expect(draftIndex).toBeGreaterThan(readIndex)
+    expect(finalIndex).toBeGreaterThan(draftIndex)
+    expect(html).not.toContain("多任务写作循环")
+    expect(html).toContain("(3)")
+  })
 })
 })

+ 6 - 2
src/components/chat/agent-stage-stream.tsx

@@ -11,7 +11,11 @@ import {
   Wrench,
   Wrench,
   XCircle,
   XCircle,
 } from "lucide-react"
 } from "lucide-react"
-import { getDefaultOpenAgentStageId, summarizeAgentStage } from "@/lib/agent/activity-trace"
+import {
+  getDefaultOpenAgentStageId,
+  prepareAgentStagesForDisplay,
+  summarizeAgentStage,
+} from "@/lib/agent/activity-trace"
 import type { AgentActivityEvent, AgentActivityKind, AgentStageStatus, AgentStageTrace } from "@/lib/agent/types"
 import type { AgentActivityEvent, AgentActivityKind, AgentStageStatus, AgentStageTrace } from "@/lib/agent/types"
 import { cn } from "@/lib/utils"
 import { cn } from "@/lib/utils"
 
 
@@ -20,7 +24,7 @@ interface AgentStageStreamProps {
 }
 }
 
 
 export function AgentStageStream({ stages }: AgentStageStreamProps) {
 export function AgentStageStream({ stages }: AgentStageStreamProps) {
-  const visibleStages = stages ?? []
+  const visibleStages = useMemo(() => prepareAgentStagesForDisplay(stages), [stages])
   const defaultOpenStageId = useMemo(() => getDefaultOpenAgentStageId(visibleStages), [visibleStages])
   const defaultOpenStageId = useMemo(() => getDefaultOpenAgentStageId(visibleStages), [visibleStages])
   const [openMap, setOpenMap] = useState<Record<string, boolean>>({})
   const [openMap, setOpenMap] = useState<Record<string, boolean>>({})
   const [allOpen, setAllOpen] = useState(false)
   const [allOpen, setAllOpen] = useState(false)

+ 45 - 0
src/components/chat/agent-tool-call-message.spec.tsx

@@ -99,6 +99,51 @@ describe("AgentToolCallMessage", () => {
     expect(host.textContent).not.toContain("apply_skill")
     expect(host.textContent).not.toContain("apply_skill")
   })
   })
 
 
+  it("hides parent run_chapter_workflow once child chapter steps exist", async () => {
+    await act(async () => {
+      root.render(
+        <AgentToolCallMessage
+          toolCalls={[
+            {
+              id: "workflow-1",
+              name: "run_chapter_workflow",
+              params: {},
+              result: "",
+              status: "running",
+              startedAt: 100,
+              finishedAt: 0,
+            },
+            {
+              id: "workflow-1:chapter_context",
+              parentCallId: "workflow-1",
+              name: "chapter_context",
+              params: { title: "读取上下文" },
+              result: "上下文完成",
+              status: "done",
+              startedAt: 110,
+              finishedAt: 150,
+            },
+            {
+              id: "workflow-1:chapter_execution_repair",
+              parentCallId: "workflow-1",
+              name: "chapter_execution_repair",
+              params: { title: "返修执行失败项" },
+              result: "",
+              status: "running",
+              startedAt: 200,
+              finishedAt: 0,
+            },
+          ]}
+        />,
+      )
+    })
+
+    expect(host.textContent).not.toContain("运行章节工作流")
+    expect(host.textContent).toContain("读取章节上下文")
+    expect(host.textContent).toContain("返修执行清单失败项")
+    expect(host.textContent).toContain("运行中")
+  })
+
   it("shows error style for failed tool calls", async () => {
   it("shows error style for failed tool calls", async () => {
     await act(async () => {
     await act(async () => {
       root.render(<AgentToolCallMessage toolCalls={[sampleCalls[4]]} />)
       root.render(<AgentToolCallMessage toolCalls={[sampleCalls[4]]} />)

+ 2 - 2
src/components/chat/agent-workflow-panel.tsx

@@ -2,7 +2,7 @@ import { useMemo, useRef, useEffect } from "react"
 import { getWorkflowToolDescription } from "@/lib/agent/workflow-trace"
 import { getWorkflowToolDescription } from "@/lib/agent/workflow-trace"
 import type { AgentRunRecord } from "@/lib/agent/types"
 import type { AgentRunRecord } from "@/lib/agent/types"
 import type { ContextTrace } from "@/lib/agent/context-trace"
 import type { ContextTrace } from "@/lib/agent/context-trace"
-import { createStreamingEventBuilder } from "@/components/common/timeline-types"
+import { createStreamingEventBuilder, compareToolCallsByStartedAt, filterToolCallsForDisplay } from "@/components/common/timeline-types"
 import type { ToolCallEventItem, TimelineToolCategory } from "@/components/common/timeline-types"
 import type { ToolCallEventItem, TimelineToolCategory } from "@/components/common/timeline-types"
 import { EventStream } from "@/components/common/event-stream"
 import { EventStream } from "@/components/common/event-stream"
 
 
@@ -62,7 +62,7 @@ export function AgentWorkflowPanel({
   const wasStreamingRef = useRef(false)
   const wasStreamingRef = useRef(false)
 
 
   const sortedCalls = useMemo(
   const sortedCalls = useMemo(
-    () => [...safeToolCalls].sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0)),
+    () => filterToolCallsForDisplay([...safeToolCalls]).sort(compareToolCallsByStartedAt),
     [safeToolCalls],
     [safeToolCalls],
   )
   )
 
 

+ 2 - 2
src/components/chat/chat-panel.spec.tsx

@@ -375,7 +375,7 @@ describe("chat-panel chapter plan confirm integration (Stage C)", () => {
   })
   })
 
 
   it("skips chapter plan confirmation when Plan Execute is disabled", () => {
   it("skips chapter plan confirmation when Plan Execute is disabled", () => {
-    const runnerIndex = source.indexOf("const record = await runAiChatSession({")
+    const runnerIndex = source.indexOf("const record = await withWritingWakeLock(")
     const afterRunner = source.slice(runnerIndex)
     const afterRunner = source.slice(runnerIndex)
     expect(afterRunner).toContain("if (!hasAgentError && planExecuteActive)")
     expect(afterRunner).toContain("if (!hasAgentError && planExecuteActive)")
   })
   })
@@ -394,7 +394,7 @@ describe("chat-panel chapter plan confirm integration (Stage C)", () => {
   })
   })
 
 
   it("waits for chapter plan confirmation outside the completed stream guard before follow-up execution", () => {
   it("waits for chapter plan confirmation outside the completed stream guard before follow-up execution", () => {
-    const runnerIndex = source.indexOf("const record = await runAiChatSession({")
+    const runnerIndex = source.indexOf("const record = await withWritingWakeLock(")
     const finishIndex = source.indexOf("finishAgentSession(() => {", runnerIndex)
     const finishIndex = source.indexOf("finishAgentSession(() => {", runnerIndex)
     const planConfirmIndex = source.indexOf("await requestChapterPlanConfirm(", runnerIndex)
     const planConfirmIndex = source.indexOf("await requestChapterPlanConfirm(", runnerIndex)
     const followupIndex = source.indexOf("await handleSendRef.current(followupText", runnerIndex)
     const followupIndex = source.indexOf("await handleSendRef.current(followupText", runnerIndex)

+ 45 - 18
src/components/chat/chat-panel.tsx

@@ -41,6 +41,7 @@ import type { ReferenceToken } from "@/lib/reference/types"
 import { runAiChatSession } from "@/lib/agent/ai-chat-session"
 import { runAiChatSession } from "@/lib/agent/ai-chat-session"
 import { runDraftReviewSkill } from "@/lib/agent/skills/draft-review-skill"
 import { runDraftReviewSkill } from "@/lib/agent/skills/draft-review-skill"
 import { useDraftReviewStore } from "@/stores/draft-review-store"
 import { useDraftReviewStore } from "@/stores/draft-review-store"
+import { shouldKeepAwakeForWriting, withWritingWakeLock } from "@/lib/writing-wake-lock"
 import { ToolRegistry } from "@/lib/agent/registry"
 import { ToolRegistry } from "@/lib/agent/registry"
 import { registerAllBuiltInTools } from "@/lib/agent/tools"
 import { registerAllBuiltInTools } from "@/lib/agent/tools"
 import {
 import {
@@ -56,6 +57,7 @@ import type { PrePluginChainResult } from "@/lib/agent/pipeline"
 import { applyAgentToolActivityEvent, applyAgentToolEvent } from "@/lib/agent/tool-events"
 import { applyAgentToolActivityEvent, applyAgentToolEvent } from "@/lib/agent/tool-events"
 import { applyAgentActivityEvent, createAgentActivityEvent, settleRunningAgentStages } from "@/lib/agent/activity-trace"
 import { applyAgentActivityEvent, createAgentActivityEvent, settleRunningAgentStages } from "@/lib/agent/activity-trace"
 import { useAgentConfig } from "@/hooks/use-agent-config"
 import { useAgentConfig } from "@/hooks/use-agent-config"
+import { resolveContextPackTokenBudget } from "@/lib/context-budget"
 import { resolveChapterLengthSpec } from "@/lib/novel/deep-chapter-prompts"
 import { resolveChapterLengthSpec } from "@/lib/novel/deep-chapter-prompts"
 import { executeIngestWrites } from "@/lib/ingest"
 import { executeIngestWrites } from "@/lib/ingest"
 import { routeTask, buildTaskDirective, type TaskRouteResult } from "@/lib/novel/task-router"
 import { routeTask, buildTaskDirective, type TaskRouteResult } from "@/lib/novel/task-router"
@@ -105,6 +107,10 @@ import type { FrameworkBinding, StoryFramework } from "@/lib/novel/story-simulat
 
 
 import type { AiWorkflowMode } from "@/lib/agent/workflow-mode"
 import type { AiWorkflowMode } from "@/lib/agent/workflow-mode"
 import { buildPlanExecutePolicyPrompt, WRITING_INTENTS } from "@/lib/agent/plan-execute-policy"
 import { buildPlanExecutePolicyPrompt, WRITING_INTENTS } from "@/lib/agent/plan-execute-policy"
+import {
+  buildOutlineFindProtocol,
+  shouldIncludeOutlineFindProtocol,
+} from "@/lib/novel/outline-find-protocol"
 import { createContextTrace, finishTrace, setContextInfo, type ContextTrace } from "@/lib/agent/context-trace"
 import { createContextTrace, finishTrace, setContextInfo, type ContextTrace } from "@/lib/agent/context-trace"
 import { settleRunningAgentToolCalls } from "@/lib/agent/tool-events"
 import { settleRunningAgentToolCalls } from "@/lib/agent/tool-events"
 import { appendMcpCallTrace } from "@/lib/agent/mcp-trace"
 import { appendMcpCallTrace } from "@/lib/agent/mcp-trace"
@@ -286,6 +292,9 @@ function buildChatAgentSystemPrompt(options: {
   agentWritingSkills?: UserSkill[]
   agentWritingSkills?: UserSkill[]
   projectName?: string
   projectName?: string
   bindingTitle?: string
   bindingTitle?: string
+  targetChapterNumber?: number
+  /** 仅章节写作且不会走 pre-plugin 时注入,避免与 build_system_prompt plugin 重复 */
+  includeOutlineFindProtocol?: boolean
 }): string {
 }): string {
   const lines = [
   const lines = [
     options.novelMode
     options.novelMode
@@ -306,6 +315,9 @@ function buildChatAgentSystemPrompt(options: {
     lines.push("小说模式下,如果用户要求生成、续写或改写章节,只输出可直接放入章节库的正文。")
     lines.push("小说模式下,如果用户要求生成、续写或改写章节,只输出可直接放入章节库的正文。")
     lines.push("章节生成、续写或改写任务的最终回复必须只包含章节正文,不要把工具读取过程、写作计划或执行过程展示给用户。")
     lines.push("章节生成、续写或改写任务的最终回复必须只包含章节正文,不要把工具读取过程、写作计划或执行过程展示给用户。")
     lines.push("不要输出读取说明、执行总结、完成目标表格、章节结构、后续建议、引用来源或 Markdown 表格;章节标题和正文以外的内容都不要输出。")
     lines.push("不要输出读取说明、执行总结、完成目标表格、章节结构、后续建议、引用来源或 Markdown 表格;章节标题和正文以外的内容都不要输出。")
+    if (options.includeOutlineFindProtocol) {
+      lines.push(buildOutlineFindProtocol(options.targetChapterNumber))
+    }
     if (options.aiWorkflowMode === "fast") {
     if (options.aiWorkflowMode === "fast") {
       lines.push("快速模式下可以读取必要上下文;除非用户明确要求使用工作流或 Skill,否则不要主动调用 run_chapter_workflow。")
       lines.push("快速模式下可以读取必要上下文;除非用户明确要求使用工作流或 Skill,否则不要主动调用 run_chapter_workflow。")
     } else {
     } else {
@@ -1099,7 +1111,7 @@ export function ChatPanel() {
         getCopyableAssistantContent(content),
         getCopyableAssistantContent(content),
       )
       )
       const selectedChapterNumber = await readSelectedChapterNumberForFile(selectedFile)
       const selectedChapterNumber = await readSelectedChapterNumberForFile(selectedFile)
-      const generatedTargetChapterNumber = detectGeneratedTargetChapterNumber(cleanedContent)
+      const generatedTargetChapterNumber = detectGeneratedTargetChapterNumber(extractedTitle ?? cleanedContent)
       const explicitTargetPath = generatedTargetChapterNumber ? await findChapterFileByNumber(pp, generatedTargetChapterNumber) : null
       const explicitTargetPath = generatedTargetChapterNumber ? await findChapterFileByNumber(pp, generatedTargetChapterNumber) : null
       const strategy = decideChapterSaveStrategy({
       const strategy = decideChapterSaveStrategy({
         selectedChapterNumber: selectedChapterNumber ?? null,
         selectedChapterNumber: selectedChapterNumber ?? null,
@@ -1128,7 +1140,7 @@ export function ChatPanel() {
           "---",
           "---",
           "",
           "",
         ].join("\n")
         ].join("\n")
-        // 正文内容已经包含标题行,直接拼接即可
+        // 标题只写入 frontmatter,正文只保存实际章节内容,避免重复。
         return `${frontmatter}${bodyContent}\n`
         return `${frontmatter}${bodyContent}\n`
       }
       }
 
 
@@ -1351,16 +1363,6 @@ export function ChatPanel() {
         return
         return
       }
       }
 
 
-      const sessionAgentSystemPrompt = buildChatAgentSystemPrompt({
-        novelMode,
-        mode,
-        deepChapterEnabled,
-        chatEditModeEnabled,
-        aiWorkflowMode,
-        planExecuteEnabled: planExecuteActive,
-        projectName: project?.name,
-        bindingTitle: activeBinding?.framework.title,
-      })
       const lastGeneratedChapterNumber = novelMode
       const lastGeneratedChapterNumber = novelMode
         ? detectLastGeneratedChapterNumber(
         ? detectLastGeneratedChapterNumber(
             activeConvMessages
             activeConvMessages
@@ -1469,6 +1471,22 @@ export function ChatPanel() {
           }
           }
         : taskRoute
         : taskRoute
 
 
+      const sessionAgentSystemPrompt = buildChatAgentSystemPrompt({
+        novelMode,
+        mode,
+        deepChapterEnabled,
+        chatEditModeEnabled,
+        aiWorkflowMode,
+        planExecuteEnabled: planExecuteActive,
+        projectName: project?.name,
+        bindingTitle: activeBinding?.framework.title,
+        targetChapterNumber,
+        // pre-plugin 会注入找纲协议;仅在不会跑 pre-plugin 的章节写作路径由这里注入一次
+        includeOutlineFindProtocol:
+          shouldIncludeOutlineFindProtocol(effectiveTaskRoute?.intent) &&
+          !(novelMode && (aiWorkflowMode !== "fast" || planExecuteActive)),
+      })
+
       if (novelMode && effectiveTaskRoute) {
       if (novelMode && effectiveTaskRoute) {
         const contextHub = getContextHub(pp)
         const contextHub = getContextHub(pp)
         const novelConfig = useWikiStore.getState().novelConfig
         const novelConfig = useWikiStore.getState().novelConfig
@@ -1489,9 +1507,8 @@ export function ChatPanel() {
               content: message.content,
               content: message.content,
             })),
             })),
             existingSummary: activeConv?.contextSummary,
             existingSummary: activeConv?.contextSummary,
-            tokenBudget: novelConfig.contextTokenBudget > 0
-              ? novelConfig.contextTokenBudget
-              : undefined,
+            tokenBudget: novelConfig.contextTokenBudget,
+            maxContextSize: agentConfig.llmConfig.maxContextSize,
           })
           })
           if (contextHubResult) {
           if (contextHubResult) {
             try {
             try {
@@ -1612,7 +1629,10 @@ export function ChatPanel() {
             revisionDirectives: "",
             revisionDirectives: "",
             }))
             }))
             const novelConfig = useWikiStore.getState().novelConfig
             const novelConfig = useWikiStore.getState().novelConfig
-            const budget = novelConfig.contextTokenBudget > 0 ? novelConfig.contextTokenBudget : undefined
+            const budget = resolveContextPackTokenBudget({
+              maxContextSize: agentConfig.llmConfig.maxContextSize,
+              contextTokenBudget: novelConfig.contextTokenBudget,
+            })
             novelContextPrompt = [
             novelContextPrompt = [
               taskDirective,
               taskDirective,
               goldenDirective,
               goldenDirective,
@@ -1697,6 +1717,8 @@ export function ChatPanel() {
           getChatConversations: () => [],
           getChatConversations: () => [],
           getOutlineConversations: () => [],
           getOutlineConversations: () => [],
           readTextFile: contextHubResult.readFile,
           readTextFile: contextHubResult.readFile,
+          llmConfig: agentConfig.llmConfig,
+          maxContextSize: agentConfig.llmConfig.maxContextSize,
           enabledToolNames: [
           enabledToolNames: [
             "read_chapter",
             "read_chapter",
             "read_outline",
             "read_outline",
@@ -1722,7 +1744,12 @@ export function ChatPanel() {
       }
       }
 
 
       try {
       try {
-        const record = await runAiChatSession({
+        const keepAwake = shouldKeepAwakeForWriting({
+          novelMode,
+          intent: effectiveTaskRoute?.intent,
+          planExecuteActive,
+        })
+        const record = await withWritingWakeLock(keepAwake, () => runAiChatSession({
           userMessage: plainText,
           userMessage: plainText,
           projectPath,
           projectPath,
           agentConfig: {
           agentConfig: {
@@ -1781,7 +1808,7 @@ export function ChatPanel() {
               markError(error)
               markError(error)
             },
             },
           },
           },
-        })
+        }))
 
 
         if (controller.signal.aborted) return
         if (controller.signal.aborted) return
         if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
         if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return

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

@@ -101,7 +101,7 @@ export function getToolCallDescription(name: string, params: Record<string, unkn
     case "load_context":
     case "load_context":
       return `加载小说上下文${params.chapterNumber ? `(第${params.chapterNumber}章)` : ""}`
       return `加载小说上下文${params.chapterNumber ? `(第${params.chapterNumber}章)` : ""}`
     case "trim_context":
     case "trim_context":
-      return `裁剪上下文至 ${params.targetChars || "默认"} 字符`
+      return `裁剪上下文至 ${params.tokenBudget || params.targetChars || "默认"} Token`
     default:
     default:
       return name
       return name
   }
   }

+ 59 - 0
src/components/common/timeline-types.spec.ts

@@ -1,6 +1,8 @@
 import { describe, expect, it } from "vitest"
 import { describe, expect, it } from "vitest"
 import {
 import {
+  compareToolCallsByStartedAt,
   createStreamingEventBuilder,
   createStreamingEventBuilder,
+  filterToolCallsForDisplay,
   interleaveThinkingWithToolCalls,
   interleaveThinkingWithToolCalls,
   type ToolCallEventItem,
   type ToolCallEventItem,
 } from "./timeline-types"
 } from "./timeline-types"
@@ -18,6 +20,63 @@ function createToolCall(
   }
   }
 }
 }
 
 
+describe("compareToolCallsByStartedAt", () => {
+  it("sorts missing startedAt after known timestamps and breaks ties by id", () => {
+    const calls = [
+      { id: "b", startedAt: undefined },
+      { id: "a", startedAt: 200 },
+      { id: "c", startedAt: undefined },
+      { id: "d", startedAt: 100 },
+    ]
+
+    expect([...calls].sort(compareToolCallsByStartedAt).map((call) => call.id)).toEqual([
+      "d",
+      "a",
+      "b",
+      "c",
+    ])
+  })
+})
+
+describe("filterToolCallsForDisplay", () => {
+  it("hides parent tools once child steps exist", () => {
+    const calls = [
+      { id: "workflow-1", name: "run_chapter_workflow", startedAt: 100 },
+      {
+        id: "workflow-1:chapter_context",
+        parentCallId: "workflow-1",
+        name: "chapter_context",
+        startedAt: 110,
+      },
+      {
+        id: "workflow-1:chapter_draft",
+        parentCallId: "workflow-1",
+        name: "chapter_draft",
+        startedAt: 200,
+      },
+      { id: "write-1", name: "write_chapter", startedAt: 300 },
+    ]
+
+    expect(filterToolCallsForDisplay(calls).map((call) => call.id)).toEqual([
+      "workflow-1:chapter_context",
+      "workflow-1:chapter_draft",
+      "write-1",
+    ])
+  })
+
+  it("keeps parent tools when they have no children yet", () => {
+    const calls = [
+      { id: "workflow-1", name: "run_chapter_workflow", startedAt: 100 },
+      { id: "read-1", name: "read_chapter", startedAt: 50 },
+    ]
+
+    expect(filterToolCallsForDisplay(calls).map((call) => call.id)).toEqual([
+      "workflow-1",
+      "read-1",
+    ])
+  })
+})
+
 describe("createStreamingEventBuilder", () => {
 describe("createStreamingEventBuilder", () => {
   it("keeps both tool ids in order while replacing the second running call with done", () => {
   it("keeps both tool ids in order while replacing the second running call with done", () => {
     const builder = createStreamingEventBuilder("thinking")
     const builder = createStreamingEventBuilder("thinking")

+ 25 - 0
src/components/common/timeline-types.ts

@@ -2,6 +2,31 @@ import type { ToolCallStatus } from "@/lib/agent/types"
 
 
 export type TimelineToolCategory = "read" | "write" | "action" | "virtual"
 export type TimelineToolCategory = "read" | "write" | "action" | "virtual"
 
 
+export function compareToolCallsByStartedAt(
+  a: { id: string; startedAt?: number },
+  b: { id: string; startedAt?: number },
+): number {
+  const aStart = a.startedAt ?? Number.MAX_SAFE_INTEGER
+  const bStart = b.startedAt ?? Number.MAX_SAFE_INTEGER
+  if (aStart !== bStart) return aStart - bStart
+  return a.id.localeCompare(b.id)
+}
+
+/**
+ * 隐藏已有子步骤的父工具(如 run_chapter_workflow),避免父级一直「运行中」置顶、与子步骤双轨重复。
+ * 尚无子步骤时仍展示父级,作为工作流刚启动的占位。
+ */
+export function filterToolCallsForDisplay<T extends { id: string; parentCallId?: string }>(
+  calls: T[],
+): T[] {
+  const parentIdsWithChildren = new Set<string>()
+  for (const call of calls) {
+    if (call.parentCallId) parentIdsWithChildren.add(call.parentCallId)
+  }
+  if (parentIdsWithChildren.size === 0) return calls
+  return calls.filter((call) => !parentIdsWithChildren.has(call.id))
+}
+
 export interface ToolCallEventItem {
 export interface ToolCallEventItem {
   id: string
   id: string
   name: string
   name: string

+ 6 - 2
src/components/layout/preview-panel.tsx

@@ -1245,7 +1245,10 @@ export function PreviewPanel() {
       return
       return
     }
     }
 
 
-    handleSave(rawBlock + rebuildChapterBody(heading, replaced.body))
+    const replacedMarkdown = rawBlock + rebuildChapterBody(heading, replaced.body)
+    handleSave(selectionTransformAction === "de-ai"
+      ? normalizeChapterWriting(replacedMarkdown)
+      : replacedMarkdown)
     setSelectionTransformOpen(false)
     setSelectionTransformOpen(false)
     setSelectionTransformAction(null)
     setSelectionTransformAction(null)
     setSelectionTransformSelection(null)
     setSelectionTransformSelection(null)
@@ -1253,7 +1256,7 @@ export function PreviewPanel() {
     setSelectionTransformCandidateContent("")
     setSelectionTransformCandidateContent("")
     setSelectionTransformSkillName("")
     setSelectionTransformSkillName("")
     setSaveStatus("")
     setSaveStatus("")
-  }, [fileContent, handleSave, selectionTransformCandidateContent, selectionTransformSelection])
+  }, [fileContent, handleSave, selectionTransformAction, selectionTransformCandidateContent, selectionTransformSelection])
 
 
   const handleCloseSelectionTransform = useCallback(() => {
   const handleCloseSelectionTransform = useCallback(() => {
     setSelectionTransformOpen(false)
     setSelectionTransformOpen(false)
@@ -1774,6 +1777,7 @@ export function PreviewPanel() {
               try {
               try {
                 await applyDeAiBatchChapter(task.chapterPath, task.candidateContent)
                 await applyDeAiBatchChapter(task.chapterPath, task.candidateContent)
                 useDeAiTaskStore.getState().confirmTask(chapterId)
                 useDeAiTaskStore.getState().confirmTask(chapterId)
+                useDeAiTaskStore.getState().closeReview(project.path)
                 toast.success(`${task.chapterTitle} 去AI味结果已保存`)
                 toast.success(`${task.chapterTitle} 去AI味结果已保存`)
               } catch (err) {
               } catch (err) {
                 console.error("保存去AI味结果失败:", err)
                 console.error("保存去AI味结果失败:", err)

+ 6 - 2
src/components/novel/character-aura-view.tsx

@@ -6,7 +6,7 @@ import { Input } from "@/components/ui/input"
 import { Label } from "@/components/ui/label"
 import { Label } from "@/components/ui/label"
 import { streamChat, type ChatMessage } from "@/lib/llm-client"
 import { streamChat, type ChatMessage } from "@/lib/llm-client"
 import { buildContextPack, contextPackToPrompt } from "@/lib/novel/context-engine"
 import { buildContextPack, contextPackToPrompt } from "@/lib/novel/context-engine"
-import { computeNovelContextTokenBudget } from "@/lib/context-budget"
+import { resolveContextPackTokenBudget } from "@/lib/context-budget"
 import { resolveNovelModel } from "@/lib/novel/model-resolver"
 import { resolveNovelModel } from "@/lib/novel/model-resolver"
 import { useWikiStore } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import {
 import {
@@ -357,8 +357,12 @@ export function CharacterAuraView({ hideSidebar = false }: { hideSidebar?: boole
       }
       }
       const contextPack = await buildContextPack(project.path, auraPreviewTask)
       const contextPack = await buildContextPack(project.path, auraPreviewTask)
       const previewPack = { ...contextPack, characterAuras: characterAuraPreview }
       const previewPack = { ...contextPack, characterAuras: characterAuraPreview }
-      const contextPrompt = contextPackToPrompt(previewPack, computeNovelContextTokenBudget(llmConfig.maxContextSize, novelConfig.contextTokenBudget))
+      // 预算须绑定实际发起调用的模型窗口,而非 store 里的基础 llmConfig。
       const effectiveConfig = resolveNovelModel(llmConfig, novelConfig, "writing")
       const effectiveConfig = resolveNovelModel(llmConfig, novelConfig, "writing")
+      const contextPrompt = contextPackToPrompt(previewPack, resolveContextPackTokenBudget({
+        maxContextSize: effectiveConfig.maxContextSize,
+        contextTokenBudget: novelConfig.contextTokenBudget,
+      }))
       const messages: ChatMessage[] = [
       const messages: ChatMessage[] = [
         {
         {
           role: "system",
           role: "system",

+ 1 - 1
src/components/novel/de-ai-batch-entry.spec.ts

@@ -15,6 +15,7 @@ describe("de-ai batch entry and settings", () => {
     expect(previewPanel).toContain("registerEditorExternalUpdateHandler")
     expect(previewPanel).toContain("registerEditorExternalUpdateHandler")
     expect(previewPanel).toContain("createDeAiBatchChapterApplier")
     expect(previewPanel).toContain("createDeAiBatchChapterApplier")
     expect(previewPanel).toContain("await applyDeAiBatchChapter(task.chapterPath, task.candidateContent)")
     expect(previewPanel).toContain("await applyDeAiBatchChapter(task.chapterPath, task.candidateContent)")
+    expect(previewPanel).toContain("useDeAiTaskStore.getState().closeReview(project.path)")
     expect(previewPanel).toContain("saveDeAiDraftWithoutOverwrite")
     expect(previewPanel).toContain("saveDeAiDraftWithoutOverwrite")
     expect(previewPanel).toContain("writeFileIfAbsent")
     expect(previewPanel).toContain("writeFileIfAbsent")
     expect(previewPanel).toContain("deAiDraftSaving")
     expect(previewPanel).toContain("deAiDraftSaving")
@@ -45,4 +46,3 @@ describe("de-ai batch entry and settings", () => {
   })
   })
 })
 })
 
 
-

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

@@ -20,6 +20,7 @@ export type Provider =
   | "minimax"
   | "minimax"
   | "claude-code"
   | "claude-code"
   | "codex-cli"
   | "codex-cli"
+  | "cursor-cli"
 
 
 export interface LlmPreset {
 export interface LlmPreset {
   /** Stable id used as the dropdown value. */
   /** Stable id used as the dropdown value. */
@@ -121,6 +122,27 @@ const RAW_LLM_PRESETS: LlmPreset[] = [
     ],
     ],
     suggestedContextSize: 200000,
     suggestedContextSize: 200000,
   },
   },
+  {
+    id: "cursor-cli",
+    label: "Cursor CLI (local)",
+    hint: "Local `agent` via cursor-api-proxy — no official API key needed",
+    provider: "cursor-cli",
+    baseUrl: "http://127.0.0.1:8765/v1",
+    defaultModel: "composer-2-fast",
+    apiMode: "chat_completions",
+    // Picks aligned with cursor-api-proxy docs/cursor-models-picks.md.
+    suggestedModels: [
+      "auto",
+      "composer-2-fast",
+      "claude-opus-4-7-medium-fast",
+      "claude-opus-4-7-high",
+      "gpt-5.3-codex-high",
+      "gpt-5.3-codex-xhigh",
+      "gpt-5.5-medium",
+      "claude-opus-4-7-thinking-max",
+    ],
+    suggestedContextSize: 200000,
+  },
   {
   {
     id: "openai",
     id: "openai",
     label: "OpenAI (GPT)",
     label: "OpenAI (GPT)",

+ 15 - 0
src/components/settings/llm-wiki-model-settings.spec.ts

@@ -31,6 +31,7 @@ describe("QMAI model settings", () => {
       "anthropic",
       "anthropic",
       "claude-code-cli",
       "claude-code-cli",
       "codex-cli",
       "codex-cli",
+      "cursor-cli",
       "openai",
       "openai",
       "google",
       "google",
       "azure",
       "azure",
@@ -106,6 +107,20 @@ describe("QMAI model settings", () => {
     expect(codex.localCliIsolation).toBe(true)
     expect(codex.localCliIsolation).toBe(true)
     expect(codex.model).toBe("")
     expect(codex.model).toBe("")
     expect(codex.codexCliTimeoutMinutes).toBe(45)
     expect(codex.codexCliTimeoutMinutes).toBe(45)
+
+    const cursor = resolveConfig(
+      preset("cursor-cli"),
+      { baseUrl: "http://127.0.0.1:8765/v1" },
+      fallback,
+    )
+    expect(cursor.provider).toBe("cursor-cli")
+    expect(cursor.customEndpoint).toBe("http://127.0.0.1:8765/v1")
+    expect(cursor.model).toBe("composer-2-fast")
+    expect(cursor.apiKey).toBe("")
+
+    const cursorProvider = getProviderConfig(cursor)
+    expect(cursorProvider.url).toBe("http://127.0.0.1:8765/v1/chat/completions")
+    expect(cursorProvider.headers.Authorization).toBe("Bearer unused")
   })
   })
 
 
   it("has Chinese labels for the built-in model settings instead of placeholder question marks", () => {
   it("has Chinese labels for the built-in model settings instead of placeholder question marks", () => {

+ 16 - 0
src/components/settings/preset-resolver.ts

@@ -85,6 +85,22 @@ export function resolveConfig(
     }
     }
   }
   }
 
 
+  if (preset.provider === "cursor-cli") {
+    // HTTP bridge via cursor-api-proxy. Optional apiKey only if the
+    // proxy was started with CURSOR_BRIDGE_API_KEY.
+    return {
+      provider: "cursor-cli",
+      apiKey,
+      model: ov.model?.trim() || preset.defaultModel || "",
+      ollamaUrl: fallback.ollamaUrl,
+      customEndpoint: ov.baseUrl ?? preset.baseUrl ?? "http://127.0.0.1:8765/v1",
+      maxContextSize,
+      apiMode: "chat_completions",
+      reasoning,
+      localCliIsolation: false,
+    }
+  }
+
   // openai / anthropic / google / minimax — use fixed endpoint baked into the
   // openai / anthropic / google / minimax — use fixed endpoint baked into the
   // provider dispatch. We still let users override baseUrl via apiKey env if
   // provider dispatch. We still let users override baseUrl via apiKey env if
   // needed by editing manually, but presets for these don't expose it.
   // needed by editing manually, but presets for these don't expose it.

+ 179 - 4
src/components/settings/sections/llm-provider-section.tsx

@@ -163,6 +163,7 @@ function PresetRow({
   const localCliIsolation = ov.localCliIsolation === true
   const localCliIsolation = ov.localCliIsolation === true
   const codexCliTimeoutMinutes = Math.max(1, Math.min(240, ov.codexCliTimeoutMinutes ?? 10))
   const codexCliTimeoutMinutes = Math.max(1, Math.min(240, ov.codexCliTimeoutMinutes ?? 10))
   const isLocalCliProvider = preset.provider === "claude-code" || preset.provider === "codex-cli"
   const isLocalCliProvider = preset.provider === "claude-code" || preset.provider === "codex-cli"
+  const isCursorCliProvider = preset.provider === "cursor-cli"
   const [testState, setTestState] = useState<ProviderTestState>({ kind: "idle" })
   const [testState, setTestState] = useState<ProviderTestState>({ kind: "idle" })
   const [modelOptions, setModelOptions] = useState<string[]>([])
   const [modelOptions, setModelOptions] = useState<string[]>([])
   const [modelListState, setModelListState] = useState<ModelActionState>(null)
   const [modelListState, setModelListState] = useState<ModelActionState>(null)
@@ -172,11 +173,13 @@ function PresetRow({
   const hasConfig = !!apiKey || !!ov.baseUrl || !!ov.model || !!ov.azureApiVersion || !!ov.azureModelFamily
   const hasConfig = !!apiKey || !!ov.baseUrl || !!ov.model || !!ov.azureApiVersion || !!ov.azureModelFamily
   // Local CLI providers authenticate via their own existing login state
   // Local CLI providers authenticate via their own existing login state
   // (inherited by the spawned subprocess), so no API key field is shown.
   // (inherited by the spawned subprocess), so no API key field is shown.
-  // Ollama ditto for its local-only model.
+  // Ollama ditto for its local-only model. Cursor CLI uses cursor-api-proxy;
+  // bridge auth key is optional (only if CURSOR_BRIDGE_API_KEY is set).
   const needsApiKey =
   const needsApiKey =
     preset.provider !== "ollama" &&
     preset.provider !== "ollama" &&
     preset.provider !== "claude-code" &&
     preset.provider !== "claude-code" &&
-    preset.provider !== "codex-cli"
+    preset.provider !== "codex-cli" &&
+    preset.provider !== "cursor-cli"
 
 
   const resolvedConfig = useMemo(
   const resolvedConfig = useMemo(
     () => resolveConfig(preset, ov, useWikiStore.getState().llmConfig),
     () => resolveConfig(preset, ov, useWikiStore.getState().llmConfig),
@@ -244,8 +247,9 @@ function PresetRow({
         id: `model-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
         id: `model-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
         name: modelId,
         name: modelId,
         model: modelId,
         model: modelId,
-        apiKey: apiKey,
-        customEndpoint: baseUrl,
+        apiKey: apiKey || undefined,
+        // Cursor CLI endpoint is managed dynamically — never bake 8765 into saved models.
+        customEndpoint: isCursorCliProvider ? undefined : (baseUrl || undefined),
         createdAt: Date.now(),
         createdAt: Date.now(),
       }
       }
       updatedModels = [...currentSaved, newModel]
       updatedModels = [...currentSaved, newModel]
@@ -416,6 +420,22 @@ function PresetRow({
 
 
           {preset.provider === "claude-code" && <ClaudeCliStatusPill />}
           {preset.provider === "claude-code" && <ClaudeCliStatusPill />}
           {preset.provider === "codex-cli" && <CodexCliStatusPill />}
           {preset.provider === "codex-cli" && <CodexCliStatusPill />}
+          {isCursorCliProvider && <CursorCliStatusPill />}
+
+          {isCursorCliProvider && (
+            <div className="space-y-2">
+              <Label>{t("settings.sections.llm.cursorBridgeApiKey")}</Label>
+              <Input
+                type="password"
+                value={apiKey}
+                onChange={(e) => onChange({ apiKey: e.target.value })}
+                placeholder={t("settings.sections.llm.cursorBridgeApiKeyPlaceholder")}
+              />
+              <p className="text-xs text-muted-foreground">
+                {t("settings.sections.llm.cursorBridgeApiKeyHint")}
+              </p>
+            </div>
+          )}
 
 
           {isLocalCliProvider && (
           {isLocalCliProvider && (
             <div className="space-y-2 rounded-md border p-3">
             <div className="space-y-2 rounded-md border p-3">
@@ -582,6 +602,16 @@ function PresetRow({
           <SavedModelsManager
           <SavedModelsManager
             savedModels={ov.savedModels ?? []}
             savedModels={ov.savedModels ?? []}
             onChange={(models) => onChange({ savedModels: models })}
             onChange={(models) => onChange({ savedModels: models })}
+            hideEndpoint={isCursorCliProvider}
+            buildTestConfig={(saved) => ({
+              ...resolvedConfig,
+              model: saved.model,
+              apiKey: saved.apiKey?.trim() || resolvedConfig.apiKey,
+              // Cursor CLI ignores saved endpoints; other providers may override.
+              customEndpoint: isCursorCliProvider
+                ? resolvedConfig.customEndpoint
+                : (saved.customEndpoint?.trim() || resolvedConfig.customEndpoint),
+            })}
           />
           />
 
 
           <div className="space-y-2">
           <div className="space-y-2">
@@ -1141,3 +1171,148 @@ function CodexCliStatusPill() {
     </div>
     </div>
   )
   )
 }
 }
+
+function CursorCliStatusPill() {
+  const { t } = useTranslation()
+  const [state, setState] = useState<"loading" | "ok" | "err">("loading")
+  const [agent, setAgent] = useState<DetectResult | null>(null)
+  const [proxyError, setProxyError] = useState<string | null>(null)
+  const [proxyHealthy, setProxyHealthy] = useState(false)
+  const [proxyBase, setProxyBase] = useState<string | null>(null)
+
+  async function detect() {
+    setState("loading")
+    setProxyError(null)
+    if (!isTauri()) {
+      setAgent({
+        installed: false,
+        version: null,
+        path: null,
+        error: t("settings.sections.llm.cliStatus.desktopOnly"),
+      })
+      setProxyHealthy(false)
+      setState("err")
+      return
+    }
+    try {
+      const { detectCursorCli, getCursorProxyStatus, ensureCursorProxyRunning } =
+        await import("@/lib/cursor-cli-proxy")
+      const agentResult = await detectCursorCli()
+      setAgent(agentResult)
+
+      let status = await getCursorProxyStatus()
+      let ensureError: string | null = null
+      try {
+        const endpoint = await ensureCursorProxyRunning(
+          { provider: "cursor-cli" },
+          { forceRestart: true },
+        )
+        status = await getCursorProxyStatus()
+        setProxyBase(status.base_url || endpoint.replace(/\/v1$/i, ""))
+      } catch (e) {
+        ensureError = e instanceof Error ? e.message : String(e)
+      }
+      setProxyHealthy(status.healthy)
+      setProxyError(ensureError ?? (status.healthy ? null : status.error))
+      if (status.base_url) setProxyBase(status.base_url)
+
+      const ok = agentResult.installed && status.healthy
+      setState(ok ? "ok" : "err")
+    } catch (e) {
+      setAgent({
+        installed: false,
+        version: null,
+        path: null,
+        error: e instanceof Error ? e.message : String(e),
+      })
+      setProxyHealthy(false)
+      setState("err")
+    }
+  }
+
+  useEffect(() => {
+    void detect()
+  }, [])
+
+  return (
+    <div className="space-y-1.5">
+      <div className="flex items-center gap-2">
+        <Label className="m-0">{t("settings.sections.llm.cliStatus.title")}</Label>
+        <button
+          type="button"
+          onClick={() => void detect()}
+          className="rounded border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground"
+          disabled={state === "loading"}
+        >
+          {state === "loading"
+            ? t("settings.sections.llm.cliStatus.checking")
+            : t("settings.sections.llm.cliStatus.recheck")}
+        </button>
+      </div>
+      <div
+        className={`flex items-start gap-1.5 rounded-md border px-2 py-1.5 text-xs ${
+          state === "ok"
+            ? "border-emerald-500/40 bg-emerald-500/5 text-emerald-700 dark:text-emerald-400"
+            : state === "err"
+              ? "border-rose-500/40 bg-rose-500/5 text-rose-700 dark:text-rose-400"
+              : "border-border bg-background/50 text-muted-foreground"
+        }`}
+      >
+        {state === "loading" && <Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin" />}
+        {state === "ok" && <CheckCircle2 className="mt-0.5 h-3.5 w-3.5 shrink-0" />}
+        {state === "err" && <XCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />}
+        <div className="min-w-0 flex-1 space-y-0.5">
+          {state === "loading" && <div>{t("settings.sections.llm.cliStatus.cursorDetecting")}</div>}
+          {state !== "loading" && (
+            <>
+              <div>
+                {agent?.installed
+                  ? t("settings.sections.llm.cliStatus.cursorAgentReady", {
+                      versionSuffix: agent.version ? ` ${agent.version}` : "",
+                    })
+                  : (agent?.error ?? t("settings.sections.llm.cliStatus.cursorAgentUnavailable"))}
+              </div>
+              {agent?.path && (
+                <div className="truncate font-mono text-[10px] text-muted-foreground">
+                  {agent.path}
+                </div>
+              )}
+              <div>
+                {proxyHealthy
+                  ? t("settings.sections.llm.cliStatus.cursorProxyReady", {
+                      baseUrl: proxyBase ?? "",
+                    })
+                  : (proxyError ?? t("settings.sections.llm.cliStatus.cursorProxyUnavailable"))}
+              </div>
+              <div className="text-muted-foreground">
+                {t("settings.sections.llm.cliStatus.authErrorPrefix")}{" "}
+                <code className="rounded bg-background/60 px-1 py-0.5 font-mono text-[10px]">
+                  agent login
+                </code>{" "}
+                {t("settings.sections.llm.cliStatus.cursorAuthErrorSuffix")}
+              </div>
+              {!agent?.installed && (
+                <div className="text-muted-foreground">
+                  {t("settings.sections.llm.cliStatus.installPrefix")}{" "}
+                  <code className="rounded bg-background/60 px-1 py-0.5 font-mono text-[10px]">
+                    curl https://cursor.com/install -fsS | bash
+                  </code>{" "}
+                  {t("settings.sections.llm.cliStatus.installSuffix")}
+                </div>
+              )}
+              {agent?.installed && !proxyHealthy && (
+                <div className="text-muted-foreground">
+                  {t("settings.sections.llm.cliStatus.installPrefix")}{" "}
+                  <code className="rounded bg-background/60 px-1 py-0.5 font-mono text-[10px]">
+                    npx cursor-api-proxy
+                  </code>{" "}
+                  {t("settings.sections.llm.cliStatus.installSuffix")}
+                </div>
+              )}
+            </>
+          )}
+        </div>
+      </div>
+    </div>
+  )
+}

+ 3 - 1
src/components/settings/sections/rerank-section.tsx

@@ -42,6 +42,7 @@ const PROVIDER_OPTIONS: Array<{ value: LlmConfig["provider"]; label: string }> =
   { value: "minimax", label: "MiniMax" },
   { value: "minimax", label: "MiniMax" },
   { value: "claude-code", label: "Claude Code CLI" },
   { value: "claude-code", label: "Claude Code CLI" },
   { value: "codex-cli", label: "Codex CLI" },
   { value: "codex-cli", label: "Codex CLI" },
+  { value: "cursor-cli", label: "Cursor CLI" },
 ]
 ]
 
 
 export function RerankSection({ draft, setDraft }: Props) {
 export function RerankSection({ draft, setDraft }: Props) {
@@ -68,7 +69,8 @@ export function RerankSection({ draft, setDraft }: Props) {
   const needsApiKey =
   const needsApiKey =
     config.provider !== "ollama" &&
     config.provider !== "ollama" &&
     config.provider !== "claude-code" &&
     config.provider !== "claude-code" &&
-    config.provider !== "codex-cli"
+    config.provider !== "codex-cli" &&
+    config.provider !== "cursor-cli"
   const hasConfig = config.useMainLlm || Boolean(config.model || config.customEndpoint || config.ollamaUrl)
   const hasConfig = config.useMainLlm || Boolean(config.model || config.customEndpoint || config.ollamaUrl)
 
 
   function handleOpenPanel() {
   function handleOpenPanel() {

+ 56 - 62
src/components/settings/sections/saved-models-manager.tsx

@@ -1,16 +1,21 @@
 import { useState } from "react"
 import { useState } from "react"
 import { useTranslation } from "react-i18next"
 import { useTranslation } from "react-i18next"
-import { Plus, Edit, Trash2, Check, X, Download, TestTube } from "lucide-react"
+import { Plus, Edit, Trash2, Download, TestTube, Check, X } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import { Button } from "@/components/ui/button"
 import { Input } from "@/components/ui/input"
 import { Input } from "@/components/ui/input"
 import { Label } from "@/components/ui/label"
 import { Label } from "@/components/ui/label"
 import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
 import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
 import { toast } from "@/lib/toast"
 import { toast } from "@/lib/toast"
-import type { SavedModel } from "@/stores/wiki-store"
+import { testSettingsLlmModel, normalizeModelTestError } from "@/lib/settings-model-test"
+import type { LlmConfig, SavedModel } from "@/stores/wiki-store"
 
 
 interface SavedModelsManagerProps {
 interface SavedModelsManagerProps {
   savedModels: SavedModel[]
   savedModels: SavedModel[]
   onChange: (models: SavedModel[]) => void
   onChange: (models: SavedModel[]) => void
+  /** Build a full LlmConfig for connectivity tests (provider + live endpoint). */
+  buildTestConfig: (model: SavedModel) => LlmConfig
+  /** Hide per-model endpoint UI (e.g. Cursor CLI uses a managed local proxy). */
+  hideEndpoint?: boolean
 }
 }
 
 
 interface ModelFormData {
 interface ModelFormData {
@@ -21,7 +26,12 @@ interface ModelFormData {
   description: string
   description: string
 }
 }
 
 
-export function SavedModelsManager({ savedModels, onChange }: SavedModelsManagerProps) {
+export function SavedModelsManager({
+  savedModels,
+  onChange,
+  buildTestConfig,
+  hideEndpoint = false,
+}: SavedModelsManagerProps) {
   const { t } = useTranslation()
   const { t } = useTranslation()
   const [dialogOpen, setDialogOpen] = useState(false)
   const [dialogOpen, setDialogOpen] = useState(false)
   const [editingId, setEditingId] = useState<string | null>(null)
   const [editingId, setEditingId] = useState<string | null>(null)
@@ -69,7 +79,9 @@ export function SavedModelsManager({ savedModels, onChange }: SavedModelsManager
       name: formData.name.trim(),
       name: formData.name.trim(),
       model: formData.model.trim(),
       model: formData.model.trim(),
       apiKey: formData.apiKey.trim() || undefined,
       apiKey: formData.apiKey.trim() || undefined,
-      customEndpoint: formData.customEndpoint.trim() || undefined,
+      customEndpoint: hideEndpoint
+        ? undefined
+        : (formData.customEndpoint.trim() || undefined),
       description: formData.description.trim() || undefined,
       description: formData.description.trim() || undefined,
       createdAt: editingId
       createdAt: editingId
         ? savedModels.find((m) => m.id === editingId)?.createdAt || Date.now()
         ? savedModels.find((m) => m.id === editingId)?.createdAt || Date.now()
@@ -128,37 +140,13 @@ export function SavedModelsManager({ savedModels, onChange }: SavedModelsManager
   async function handleTestModel(model: SavedModel) {
   async function handleTestModel(model: SavedModel) {
     setTestingModel(model.id)
     setTestingModel(model.id)
     try {
     try {
-      const endpoint = model.customEndpoint || ""
-      const apiKey = model.apiKey || ""
-
-      if (!endpoint) {
-        toast.error("该模型未配置接口地址")
-        return
-      }
-
-      const response = await fetch(`${endpoint}/chat/completions`, {
-        method: "POST",
-        headers: {
-          "Authorization": `Bearer ${apiKey}`,
-          "Content-Type": "application/json",
-        },
-        body: JSON.stringify({
-          model: model.model,
-          messages: [{ role: "user", content: "Hi" }],
-          max_tokens: 10,
-        }),
-      })
-
-      if (!response.ok) {
-        throw new Error(`HTTP ${response.status}`)
-      }
-
-      const data = await response.json()
-
-      toast.success(`模型 ${model.name} 可正常使用`)
-      console.log("Test response:", data)
+      const result = await testSettingsLlmModel(buildTestConfig(model))
+      toast.success(`模型 ${model.name} 可正常使用:${result.content.slice(0, 40)}`)
     } catch (error) {
     } catch (error) {
-      toast.error(error instanceof Error ? error.message : "未知错误")
+      const normalized = error instanceof Error
+        ? normalizeModelTestError(error)
+        : new Error(String(error))
+      toast.error(normalized.message || "未知错误")
     } finally {
     } finally {
       setTestingModel(null)
       setTestingModel(null)
     }
     }
@@ -226,7 +214,7 @@ export function SavedModelsManager({ savedModels, onChange }: SavedModelsManager
                 <p className="text-xs text-muted-foreground line-clamp-2">{model.description}</p>
                 <p className="text-xs text-muted-foreground line-clamp-2">{model.description}</p>
               )}
               )}
 
 
-              {model.customEndpoint && (
+              {!hideEndpoint && model.customEndpoint && (
                 <p className="truncate text-xs text-muted-foreground">
                 <p className="truncate text-xs text-muted-foreground">
                   <span className="font-medium">接口:</span>
                   <span className="font-medium">接口:</span>
                   {model.customEndpoint}
                   {model.customEndpoint}
@@ -307,20 +295,22 @@ export function SavedModelsManager({ savedModels, onChange }: SavedModelsManager
               </p>
               </p>
             </div>
             </div>
 
 
-            <div className="space-y-2">
-              <Label htmlFor="model-endpoint">
-                {t("settings.sections.llm.savedModels.customEndpoint")}
-              </Label>
-              <Input
-                id="model-endpoint"
-                value={formData.customEndpoint}
-                onChange={(e) => setFormData({ ...formData, customEndpoint: e.target.value })}
-                placeholder={t("settings.sections.llm.savedModels.customEndpointPlaceholder")}
-              />
-              <p className="text-xs text-muted-foreground">
-                {t("settings.sections.llm.savedModels.customEndpointHint")}
-              </p>
-            </div>
+            {!hideEndpoint && (
+              <div className="space-y-2">
+                <Label htmlFor="model-endpoint">
+                  {t("settings.sections.llm.savedModels.customEndpoint")}
+                </Label>
+                <Input
+                  id="model-endpoint"
+                  value={formData.customEndpoint}
+                  onChange={(e) => setFormData({ ...formData, customEndpoint: e.target.value })}
+                  placeholder={t("settings.sections.llm.savedModels.customEndpointPlaceholder")}
+                />
+                <p className="text-xs text-muted-foreground">
+                  {t("settings.sections.llm.savedModels.customEndpointHint")}
+                </p>
+              </div>
+            )}
 
 
             <div className="space-y-2">
             <div className="space-y-2">
               <Label htmlFor="model-description">
               <Label htmlFor="model-description">
@@ -335,27 +325,31 @@ export function SavedModelsManager({ savedModels, onChange }: SavedModelsManager
             </div>
             </div>
 
 
             <div className="flex gap-2">
             <div className="flex gap-2">
-              <Button
-                type="button"
-                variant="outline"
-                onClick={handleFetchModels}
-                disabled={fetchingModels || !formData.customEndpoint.trim()}
-                className="flex-1"
-              >
-                <Download className="mr-2 h-4 w-4" />
-                {fetchingModels ? "拉取中..." : "拉取模型"}
-              </Button>
+              {!hideEndpoint && (
+                <Button
+                  type="button"
+                  variant="outline"
+                  onClick={handleFetchModels}
+                  disabled={fetchingModels || !formData.customEndpoint.trim()}
+                  className="flex-1"
+                >
+                  <Download className="mr-2 h-4 w-4" />
+                  {fetchingModels ? "拉取中..." : "拉取模型"}
+                </Button>
+              )}
               <Button
               <Button
                 type="button"
                 type="button"
                 variant="outline"
                 variant="outline"
                 onClick={() => {
                 onClick={() => {
                   if (formData.model.trim()) {
                   if (formData.model.trim()) {
-                    handleTestModel({
+                    void handleTestModel({
                       id: "temp",
                       id: "temp",
-                      name: formData.name,
+                      name: formData.name || formData.model,
                       model: formData.model,
                       model: formData.model,
                       apiKey: formData.apiKey || undefined,
                       apiKey: formData.apiKey || undefined,
-                      customEndpoint: formData.customEndpoint || undefined,
+                      customEndpoint: hideEndpoint
+                        ? undefined
+                        : (formData.customEndpoint || undefined),
                       createdAt: Date.now(),
                       createdAt: Date.now(),
                     })
                     })
                   }
                   }

+ 2 - 2
src/components/settings/settings-types.ts

@@ -12,7 +12,7 @@ import type { VisualStyle } from "@/lib/visual-style-settings"
  */
  */
 export interface SettingsDraft {
 export interface SettingsDraft {
   // LLM provider
   // LLM provider
-  provider: "openai" | "anthropic" | "google" | "azure" | "ollama" | "custom" | "minimax" | "claude-code" | "codex-cli"
+  provider: "openai" | "anthropic" | "google" | "azure" | "ollama" | "custom" | "minimax" | "claude-code" | "codex-cli" | "cursor-cli"
   apiKey: string
   apiKey: string
   model: string
   model: string
   ollamaUrl: string
   ollamaUrl: string
@@ -39,7 +39,7 @@ export interface SettingsDraft {
   // Multimodal (image captioning at ingest time)
   // Multimodal (image captioning at ingest time)
   multimodalEnabled: boolean
   multimodalEnabled: boolean
   multimodalUseMainLlm: boolean
   multimodalUseMainLlm: boolean
-  multimodalProvider: "openai" | "anthropic" | "google" | "azure" | "ollama" | "custom" | "minimax" | "claude-code" | "codex-cli"
+  multimodalProvider: "openai" | "anthropic" | "google" | "azure" | "ollama" | "custom" | "minimax" | "claude-code" | "codex-cli" | "cursor-cli"
   multimodalApiKey: string
   multimodalApiKey: string
   multimodalModel: string
   multimodalModel: string
   multimodalOllamaUrl: string
   multimodalOllamaUrl: string

+ 8 - 11
src/components/sources/outline-chat-panel.tsx

@@ -1774,7 +1774,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         });
         });
         return { started: false, sent: false };
         return { started: false, sent: false };
       }
       }
-      if (!modelSupportsTools(effectiveModelId)) {
+      if (!modelSupportsTools(effectiveModelId, effectiveLlmConfig.provider)) {
         addMessage(convId, {
         addMessage(convId, {
           id: crypto.randomUUID(),
           id: crypto.randomUUID(),
           role: "assistant",
           role: "assistant",
@@ -1886,9 +1886,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           references: tokens.map(describeReferenceForOutlineAgent),
           references: tokens.map(describeReferenceForOutlineAgent),
           messages: historyBeforeSend,
           messages: historyBeforeSend,
           existingSummary: forceRefresh ? undefined : targetConversation?.contextSummary,
           existingSummary: forceRefresh ? undefined : targetConversation?.contextSummary,
-          tokenBudget: novelConfig.contextTokenBudget > 0
-            ? novelConfig.contextTokenBudget
-            : undefined,
+          tokenBudget: novelConfig.contextTokenBudget,
+          maxContextSize: effectiveLlmConfig.maxContextSize,
           forceRefresh,
           forceRefresh,
         });
         });
         if (contextHubResult) {
         if (contextHubResult) {
@@ -2753,9 +2752,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               content: message.content,
               content: message.content,
             })),
             })),
             existingSummary: conv.contextSummary,
             existingSummary: conv.contextSummary,
-            tokenBudget: novelConfig.contextTokenBudget > 0
-              ? novelConfig.contextTokenBudget
-              : undefined,
+            tokenBudget: novelConfig.contextTokenBudget,
+            maxContextSize: effectiveLlmConfig.maxContextSize,
           });
           });
           if (contextHubResult) {
           if (contextHubResult) {
             try {
             try {
@@ -3084,7 +3082,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         });
         });
         return;
         return;
       }
       }
-      if (!modelSupportsTools(effectiveModelId)) {
+      if (!modelSupportsTools(effectiveModelId, effectiveLlmConfig.provider)) {
         addMessage(activeConversationId, {
         addMessage(activeConversationId, {
           id: crypto.randomUUID(),
           id: crypto.randomUUID(),
           role: "assistant",
           role: "assistant",
@@ -3140,9 +3138,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             intent: "generate",
             intent: "generate",
             messages: historyMessages,
             messages: historyMessages,
             existingSummary: undefined,
             existingSummary: undefined,
-            tokenBudget: novelConfig.contextTokenBudget > 0
-              ? novelConfig.contextTokenBudget
-              : undefined,
+            tokenBudget: novelConfig.contextTokenBudget,
+            maxContextSize: effectiveLlmConfig.maxContextSize,
           });
           });
           if (contextHubResult && isCurrentRun()) {
           if (contextHubResult && isCurrentRun()) {
             try {
             try {

+ 2 - 6
src/components/sources/outline-workflow-stages.tsx

@@ -1,7 +1,7 @@
 import React, { useMemo, useRef, useEffect } from "react"
 import React, { useMemo, useRef, useEffect } from "react"
 import type { ToolCallRecord } from "@/lib/agent/tool-events"
 import type { ToolCallRecord } from "@/lib/agent/tool-events"
 import type { ToolCallEventItem, TimelineToolCategory } from "@/components/common/timeline-types"
 import type { ToolCallEventItem, TimelineToolCategory } from "@/components/common/timeline-types"
-import { createStreamingEventBuilder } from "@/components/common/timeline-types"
+import { compareToolCallsByStartedAt, createStreamingEventBuilder, filterToolCallsForDisplay } from "@/components/common/timeline-types"
 import { EventStream } from "@/components/common/event-stream"
 import { EventStream } from "@/components/common/event-stream"
 import { extractThinkingContent } from "@/lib/novel/outline-stage-trace"
 import { extractThinkingContent } from "@/lib/novel/outline-stage-trace"
 import { getWorkflowToolDescription } from "@/lib/agent/workflow-trace"
 import { getWorkflowToolDescription } from "@/lib/agent/workflow-trace"
@@ -62,11 +62,7 @@ export const OutlineWorkflowStages = React.memo(function OutlineWorkflowStages(
   const thinkingStreaming = thinkingExtract.streaming || isStreaming
   const thinkingStreaming = thinkingExtract.streaming || isStreaming
 
 
   const sortedCalls = useMemo(() => {
   const sortedCalls = useMemo(() => {
-    return [...toolCalls].sort((a, b) => {
-      const aStart = (a as { startedAt?: number }).startedAt ?? 0
-      const bStart = (b as { startedAt?: number }).startedAt ?? 0
-      return aStart - bStart
-    })
+    return filterToolCallsForDisplay([...toolCalls]).sort(compareToolCallsByStartedAt)
   }, [toolCalls])
   }, [toolCalls])
 
 
   const adaptedCalls = useMemo(
   const adaptedCalls = useMemo(

+ 2 - 1
src/hooks/use-agent-config.ts

@@ -111,7 +111,7 @@ export function useAgentConfig(systemPrompt: string, getPlanBlueprint?: () => st
 
 
   return useMemo(() => {
   return useMemo(() => {
     const agentLlmConfig = resolveDefaultModel(baseLlmConfig)
     const agentLlmConfig = resolveDefaultModel(baseLlmConfig)
-    const supportsTools = modelSupportsTools(agentLlmConfig.model)
+    const supportsTools = modelSupportsTools(agentLlmConfig.model, agentLlmConfig.provider)
 
 
     if (!supportsTools || !projectPath || !skillConfigLoaded) {
     if (!supportsTools || !projectPath || !skillConfigLoaded) {
       return {
       return {
@@ -143,6 +143,7 @@ export function useAgentConfig(systemPrompt: string, getPlanBlueprint?: () => st
       getOutlineConversations,
       getOutlineConversations,
       mcpTools: mcpRuntime.mcpTools,
       mcpTools: mcpRuntime.mcpTools,
       llmConfig: agentLlmConfig,
       llmConfig: agentLlmConfig,
+      maxContextSize: agentLlmConfig.maxContextSize,
       chapterWritingLlmConfig,
       chapterWritingLlmConfig,
       aiWorkflowMode,
       aiWorkflowMode,
       runDeepChapterGeneration,
       runDeepChapterGeneration,

+ 11 - 2
src/i18n/en.json

@@ -804,13 +804,19 @@
           "recheck": "Re-check",
           "recheck": "Re-check",
           "claudeDetecting": "Detecting local Claude binary…",
           "claudeDetecting": "Detecting local Claude binary…",
           "codexDetecting": "Detecting local Codex binary…",
           "codexDetecting": "Detecting local Codex binary…",
+          "cursorDetecting": "Detecting agent and cursor-api-proxy…",
           "claudeReady": "Detected{{versionSuffix}}. Ready to use your local subscription — no API key needed.",
           "claudeReady": "Detected{{versionSuffix}}. Ready to use your local subscription — no API key needed.",
           "codexReady": "Detected{{versionSuffix}}. Ready to use your local login — no API key needed.",
           "codexReady": "Detected{{versionSuffix}}. Ready to use your local login — no API key needed.",
+          "cursorAgentReady": "Cursor agent detected{{versionSuffix}}.",
+          "cursorProxyReady": "cursor-api-proxy is ready ({{baseUrl}}).",
           "authErrorPrefix": "If chat fails with an authentication error, run",
           "authErrorPrefix": "If chat fails with an authentication error, run",
           "claudeAuthErrorSuffix": "in a terminal to refresh the OAuth login.",
           "claudeAuthErrorSuffix": "in a terminal to refresh the OAuth login.",
           "codexAuthErrorSuffix": "in a terminal to refresh the login.",
           "codexAuthErrorSuffix": "in a terminal to refresh the login.",
+          "cursorAuthErrorSuffix": "in a terminal to refresh the Cursor login.",
           "claudeUnavailable": "Claude CLI not available.",
           "claudeUnavailable": "Claude CLI not available.",
           "codexUnavailable": "Codex CLI not available.",
           "codexUnavailable": "Codex CLI not available.",
+          "cursorAgentUnavailable": "Cursor agent CLI not available.",
+          "cursorProxyUnavailable": "cursor-api-proxy is not reachable.",
           "desktopOnly": "Desktop only",
           "desktopOnly": "Desktop only",
           "installPrefix": "Install with",
           "installPrefix": "Install with",
           "installSuffix": "then re-check."
           "installSuffix": "then re-check."
@@ -843,6 +849,9 @@
         "codexCliTimeout": "Codex CLI timeout",
         "codexCliTimeout": "Codex CLI timeout",
         "codexCliTimeoutUnit": "minutes",
         "codexCliTimeoutUnit": "minutes",
         "codexCliTimeoutHint": "Overall subprocess timeout for longer generations, from 1 to 240 minutes.",
         "codexCliTimeoutHint": "Overall subprocess timeout for longer generations, from 1 to 240 minutes.",
+        "cursorBridgeApiKey": "Proxy API key (optional)",
+        "cursorBridgeApiKeyPlaceholder": "Only if the proxy was started with CURSOR_BRIDGE_API_KEY",
+        "cursorBridgeApiKeyHint": "Leave blank by default. Fill in the same value if you started the proxy with CURSOR_BRIDGE_API_KEY.",
         "providerTests": "Model tests",
         "providerTests": "Model tests",
         "providerTestsHint": "Connection test checks reachability. Functional test asks the model to return a specific token, confirming it can generate usable output.",
         "providerTestsHint": "Connection test checks reachability. Functional test asks the model to return a specific token, confirming it can generate usable output.",
         "testConnection": "Test connection",
         "testConnection": "Test connection",
@@ -1255,8 +1264,8 @@
       "searchTopK": "Search Result Count (Retrieval TOPK)",
       "searchTopK": "Search Result Count (Retrieval TOPK)",
       "searchTopKHint": "Controls how many relevant memory records are retrieved and injected into the writing context. Higher values add more references, but can add noise and consume context.",
       "searchTopKHint": "Controls how many relevant memory records are retrieved and injected into the writing context. Higher values add more references, but can add noise and consume context.",
       "contextTokenBudget": "Context Token Budget",
       "contextTokenBudget": "Context Token Budget",
-      "contextTokenBudgetHint": "0 means unlimited",
-      "contextTokenBudgetHelp": "Limits the number of context tokens injected into one novel writing or extraction call. Use 0 for no extra limit.",
+      "contextTokenBudgetHint": "0 = auto from model window",
+      "contextTokenBudgetHelp": "Limits context tokens injected into one novel writing or extraction call. 0 means no extra limit: the budget is derived from a safe fraction of the model context window. Deep chapter writing first reserves output headroom as max(2× chapter target at CJK density, chapter maxOutputTokens), then allocates the context pack.",
       "chatHistoryLength": "Chat History Length",
       "chatHistoryLength": "Chat History Length",
       "chatHistoryLengthHint": "Number of previous messages sent to the AI with each request. More history gives fuller context but uses more tokens.",
       "chatHistoryLengthHint": "Number of previous messages sent to the AI with each request. More history gives fuller context but uses more tokens.",
       "chatHistoryLengthHelp": "Controls how many AI chat messages are included in each request. Higher values preserve more conversation context and consume more tokens.",
       "chatHistoryLengthHelp": "Controls how many AI chat messages are included in each request. Higher values preserve more conversation context and consume more tokens.",

+ 11 - 2
src/i18n/zh.json

@@ -511,13 +511,19 @@
           "recheck": "重新检查",
           "recheck": "重新检查",
           "claudeDetecting": "正在检查 claude 命令...",
           "claudeDetecting": "正在检查 claude 命令...",
           "codexDetecting": "正在检查 codex 命令...",
           "codexDetecting": "正在检查 codex 命令...",
+          "cursorDetecting": "正在检查 agent 与 cursor-api-proxy...",
           "claudeReady": "Claude CLI 可用{{versionSuffix}},无需填写 API Key。",
           "claudeReady": "Claude CLI 可用{{versionSuffix}},无需填写 API Key。",
           "codexReady": "Codex CLI 可用{{versionSuffix}},无需填写 API Key。",
           "codexReady": "Codex CLI 可用{{versionSuffix}},无需填写 API Key。",
+          "cursorAgentReady": "Cursor agent 可用{{versionSuffix}}。",
+          "cursorProxyReady": "cursor-api-proxy 已就绪({{baseUrl}})。",
           "authErrorPrefix": "如果运行时报认证错误,请先在终端执行",
           "authErrorPrefix": "如果运行时报认证错误,请先在终端执行",
           "claudeAuthErrorSuffix": "完成 Claude OAuth 登录。",
           "claudeAuthErrorSuffix": "完成 Claude OAuth 登录。",
           "codexAuthErrorSuffix": "完成 Codex 登录。",
           "codexAuthErrorSuffix": "完成 Codex 登录。",
+          "cursorAuthErrorSuffix": "完成 Cursor 登录。",
           "claudeUnavailable": "未检测到 Claude CLI。",
           "claudeUnavailable": "未检测到 Claude CLI。",
           "codexUnavailable": "未检测到 Codex CLI。",
           "codexUnavailable": "未检测到 Codex CLI。",
+          "cursorAgentUnavailable": "未检测到 Cursor agent CLI。",
+          "cursorProxyUnavailable": "cursor-api-proxy 未就绪。",
           "desktopOnly": "仅桌面应用可检测 CLI 状态。",
           "desktopOnly": "仅桌面应用可检测 CLI 状态。",
           "installPrefix": "可执行",
           "installPrefix": "可执行",
           "installSuffix": "安装后再重新检查。"
           "installSuffix": "安装后再重新检查。"
@@ -550,6 +556,9 @@
         "codexCliTimeout": "Codex CLI 超时",
         "codexCliTimeout": "Codex CLI 超时",
         "codexCliTimeoutUnit": "分钟",
         "codexCliTimeoutUnit": "分钟",
         "codexCliTimeoutHint": "长文本生成的子进程总超时时间,可设置 1-240 分钟。",
         "codexCliTimeoutHint": "长文本生成的子进程总超时时间,可设置 1-240 分钟。",
+        "cursorBridgeApiKey": "Proxy API Key(可选)",
+        "cursorBridgeApiKeyPlaceholder": "仅当 proxy 设置了 CURSOR_BRIDGE_API_KEY 时填写",
+        "cursorBridgeApiKeyHint": "默认无需填写。若手动启动 proxy 时设置了 CURSOR_BRIDGE_API_KEY,在此填入相同值。",
         "providerTests": "模型测试",
         "providerTests": "模型测试",
         "providerTestsHint": "连接测试用于检查是否可访问;功能测试会要求模型返回指定标记,确认模型能正常生成可用内容。",
         "providerTestsHint": "连接测试用于检查是否可访问;功能测试会要求模型返回指定标记,确认模型能正常生成可用内容。",
         "testConnection": "测试连接",
         "testConnection": "测试连接",
@@ -1124,8 +1133,8 @@
       "searchTopK": "检索结果数量(检索 TOPK)",
       "searchTopK": "检索结果数量(检索 TOPK)",
       "searchTopKHint": "控制从小说记忆库中检索并注入上下文的相关资料条数。数值越大,参考资料越多,但噪音和上下文占用也会增加。",
       "searchTopKHint": "控制从小说记忆库中检索并注入上下文的相关资料条数。数值越大,参考资料越多,但噪音和上下文占用也会增加。",
       "contextTokenBudget": "上下文 Token 预算",
       "contextTokenBudget": "上下文 Token 预算",
-      "contextTokenBudgetHint": "0 表示无限制",
-      "contextTokenBudgetHelp": "限制一次小说写作或资料提取时可注入的上下文 Token 数量,避免提示词过长。设置为 0 时不做额外限制。",
+      "contextTokenBudgetHint": "0 表示按模型窗口自动计算",
+      "contextTokenBudgetHelp": "限制一次小说写作或资料提取时可注入的上下文 Token 数量,避免提示词过长。设置为 0 时不额外限制,按模型上下文窗口的安全比例自动计算。深度写作会先按「单章目标字数×2(按中文密度折算)与章节 maxOutputTokens 取较大值」预留正文输出,再分配资料包。",
       "chatHistoryLength": "对话历史长度",
       "chatHistoryLength": "对话历史长度",
       "chatHistoryLengthHint": "每次请求发给 AI 的历史消息条数。多 = 上下文更完整但更费 token。",
       "chatHistoryLengthHint": "每次请求发给 AI 的历史消息条数。多 = 上下文更完整但更费 token。",
       "chatHistoryLengthHelp": "控制 AI 会话中每次请求携带多少条历史消息。数量越多,AI 记得的上下文越完整,但消耗的 token 也越多。",
       "chatHistoryLengthHelp": "控制 AI 会话中每次请求携带多少条历史消息。数量越多,AI 记得的上下文越完整,但消耗的 token 也越多。",

+ 89 - 0
src/lib/agent/activity-trace.spec.ts

@@ -5,6 +5,8 @@ import {
   createAgentActivityEvent,
   createAgentActivityEvent,
   createStageStartedEvent,
   createStageStartedEvent,
   getDefaultOpenAgentStageId,
   getDefaultOpenAgentStageId,
+  prepareAgentStagesForDisplay,
+  resolveAgentStageTitle,
   settleRunningAgentStages,
   settleRunningAgentStages,
   summarizeAgentStage,
   summarizeAgentStage,
 } from "./activity-trace"
 } from "./activity-trace"
@@ -186,4 +188,91 @@ describe("activity trace", () => {
       finishedAt: 320,
       finishedAt: 320,
     })
     })
   })
   })
+
+  it("sorts stages by canonical display order and hides redundant chapter_workflow", () => {
+    const stages: AgentStageTrace[] = [
+      {
+        id: "final_output",
+        title: "最终输出",
+        status: "done",
+        summary: "完成",
+        events: [],
+        startedAt: 400,
+      },
+      {
+        id: "chapter_workflow",
+        title: "多任务写作循环",
+        status: "running",
+        summary: "聚合",
+        events: [],
+        startedAt: 50,
+      },
+      {
+        id: "generate_draft",
+        title: "生成章节草稿",
+        status: "done",
+        summary: "草稿",
+        events: [],
+        startedAt: 200,
+      },
+      {
+        id: "read_context",
+        title: "读取上下文",
+        status: "done",
+        summary: "上下文",
+        events: [],
+        startedAt: 100,
+      },
+      {
+        id: "write_confirmation",
+        title: "写入确认",
+        status: "approval_required",
+        summary: "待确认",
+        events: [],
+        startedAt: 500,
+      },
+    ]
+
+    const prepared = prepareAgentStagesForDisplay(stages)
+    expect(prepared.map((stage) => stage.id)).toEqual([
+      "read_context",
+      "generate_draft",
+      "final_output",
+      "write_confirmation",
+    ])
+  })
+
+  it("keeps chapter_workflow when no detailed chapter stages exist", () => {
+    const stages: AgentStageTrace[] = [
+      {
+        id: "chapter_workflow",
+        title: "多任务写作循环",
+        status: "running",
+        summary: "运行中",
+        events: [],
+        startedAt: 100,
+      },
+      {
+        id: "write_confirmation",
+        title: "写入确认",
+        status: "approval_required",
+        summary: "待确认",
+        events: [],
+        startedAt: 200,
+      },
+    ]
+
+    expect(prepareAgentStagesForDisplay(stages).map((stage) => stage.id)).toEqual([
+      "write_confirmation",
+      "chapter_workflow",
+    ])
+  })
+
+  it("resolves titles for post-draft strict stages", () => {
+    expect(resolveAgentStageTitle("execution_report")).toBe("执行报告")
+    expect(resolveAgentStageTitle("execution_recheck")).toBe("执行复检")
+    expect(resolveAgentStageTitle("plan_compliance")).toBe("计划履约")
+    expect(resolveAgentStageTitle("plan_deviation_repair")).toBe("计划偏离返修")
+    expect(resolveAgentStageTitle("plan_deviation_recheck")).toBe("计划偏离复检")
+  })
 })
 })

+ 77 - 2
src/lib/agent/activity-trace.ts

@@ -9,6 +9,43 @@ import type {
 const EMPTY_CONTENT = "本阶段未返回可展示内容。"
 const EMPTY_CONTENT = "本阶段未返回可展示内容。"
 const AGGREGATE_STAGE_IDS = new Set(["chapter_workflow", "react_tools"])
 const AGGREGATE_STAGE_IDS = new Set(["chapter_workflow", "react_tools"])
 
 
+/** 细粒度章节流水线阶段;存在任一此类阶段时隐藏冗余的 chapter_workflow 聚合阶段。 */
+export const DETAILED_CHAPTER_STAGE_IDS = new Set([
+  "read_context",
+  "plot_analysis",
+  "generate_draft",
+  "validate_revision",
+  "execution_report",
+  "execution_recheck",
+  "plan_compliance",
+  "plan_deviation_repair",
+  "plan_deviation_recheck",
+  "final_output",
+])
+
+export const AGENT_STAGE_DISPLAY_ORDER = [
+  "task_understanding",
+  "capability_selection",
+  "read_context",
+  "plot_analysis",
+  "generate_draft",
+  "validate_revision",
+  "execution_report",
+  "execution_recheck",
+  "plan_compliance",
+  "plan_deviation_repair",
+  "plan_deviation_recheck",
+  "final_output",
+  "external_search",
+  "write_confirmation",
+  "react_tools",
+  "chapter_workflow",
+] as const
+
+const STAGE_DISPLAY_ORDER_INDEX = new Map<string, number>(
+  AGENT_STAGE_DISPLAY_ORDER.map((id, index) => [id, index]),
+)
+
 export interface CreateAgentActivityEventInput {
 export interface CreateAgentActivityEventInput {
   id: string
   id: string
   stageId: string
   stageId: string
@@ -89,6 +126,35 @@ export function getDefaultOpenAgentStageId(stages: AgentStageTrace[]): string |
     ?? null
     ?? null
 }
 }
 
 
+export 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[] {
+  const unknownBase = AGENT_STAGE_DISPLAY_ORDER.length
+  return stages
+    .map((stage, originalIndex) => ({ stage, originalIndex }))
+    .sort((a, b) => {
+      const orderA = STAGE_DISPLAY_ORDER_INDEX.get(a.stage.id) ?? unknownBase
+      const orderB = STAGE_DISPLAY_ORDER_INDEX.get(b.stage.id) ?? unknownBase
+      if (orderA !== orderB) return orderA - orderB
+
+      const startedA = a.stage.startedAt ?? Number.MAX_SAFE_INTEGER
+      const startedB = b.stage.startedAt ?? Number.MAX_SAFE_INTEGER
+      if (startedA !== startedB) return startedA - startedB
+
+      return a.originalIndex - b.originalIndex
+    })
+    .map(({ stage }) => stage)
+}
+
+export function prepareAgentStagesForDisplay(stages: AgentStageTrace[] | undefined): AgentStageTrace[] {
+  const visible = filterAgentStagesForDisplay(stages ?? [])
+  return sortAgentStagesForDisplay(visible)
+}
+
 export function settleRunningAgentStages(
 export function settleRunningAgentStages(
   stages: AgentStageTrace[] | undefined,
   stages: AgentStageTrace[] | undefined,
   status: Extract<AgentStageStatus, "done" | "error" | "cancelled"> = "done",
   status: Extract<AgentStageStatus, "done" | "error" | "cancelled"> = "done",
@@ -221,7 +287,7 @@ function inferStageIdFromToolName(name: string): string {
   return "react_tools"
   return "react_tools"
 }
 }
 
 
-function inferStageTitle(event: AgentActivityEvent): string {
+export function resolveAgentStageTitle(stageId: string, fallbackTitle?: string): string {
   const titles: Record<string, string> = {
   const titles: Record<string, string> = {
     task_understanding: "任务理解",
     task_understanding: "任务理解",
     capability_selection: "能力选择",
     capability_selection: "能力选择",
@@ -230,12 +296,21 @@ function inferStageTitle(event: AgentActivityEvent): string {
     chapter_workflow: "多任务写作循环",
     chapter_workflow: "多任务写作循环",
     generate_draft: "生成章节草稿",
     generate_draft: "生成章节草稿",
     validate_revision: "校验与修正",
     validate_revision: "校验与修正",
+    execution_report: "执行报告",
+    execution_recheck: "执行复检",
+    plan_compliance: "计划履约",
+    plan_deviation_repair: "计划偏离返修",
+    plan_deviation_recheck: "计划偏离复检",
     final_output: "最终输出",
     final_output: "最终输出",
     external_search: "外部检索",
     external_search: "外部检索",
     write_confirmation: "写入确认",
     write_confirmation: "写入确认",
     react_tools: "工具调用",
     react_tools: "工具调用",
   }
   }
-  return titles[event.stageId] ?? event.title
+  return titles[stageId] ?? fallbackTitle ?? stageId
+}
+
+function inferStageTitle(event: AgentActivityEvent): string {
+  return resolveAgentStageTitle(event.stageId, event.title)
 }
 }
 
 
 function inferActivityKindFromToolName(name: string): AgentActivityKind {
 function inferActivityKindFromToolName(name: string): AgentActivityKind {

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

@@ -13,12 +13,27 @@ export const TOOL_UNSUPPORTED_MODEL_PREFIXES: string[] = [
   "codex-cli",
   "codex-cli",
 ]
 ]
 
 
+const TOOL_UNSUPPORTED_PROVIDERS = new Set<LlmConfig["provider"]>([
+  "claude-code",
+  "codex-cli",
+])
+
+/** cursor-api-proxy 无法返回原生 tool_calls delta,需从文本中解析工具调用。 */
+export function providerUsesTextToolCalls(provider: LlmConfig["provider"]): boolean {
+  return provider === "cursor-cli"
+}
+
 export interface BuildAgentConfigOptions extends ToolFactoryOptions {
 export interface BuildAgentConfigOptions extends ToolFactoryOptions {
   llmConfig: LlmConfig
   llmConfig: LlmConfig
   requestOverrides?: AgentConfig["requestOverrides"]
   requestOverrides?: AgentConfig["requestOverrides"]
 }
 }
 
 
-export function modelSupportsTools(modelId: string): boolean {
+export function modelSupportsTools(
+  modelId: string,
+  provider?: LlmConfig["provider"],
+): boolean {
+  if (provider && TOOL_UNSUPPORTED_PROVIDERS.has(provider)) return false
+
   const id = modelId.trim().toLowerCase()
   const id = modelId.trim().toLowerCase()
   if (!id) return false
   if (!id) return false
 
 
@@ -39,10 +54,14 @@ export function buildAgentConfig(
   registry.clear()
   registry.clear()
   registerAllBuiltInTools(registry, options)
   registerAllBuiltInTools(registry, options)
 
 
+  const prompt = providerUsesTextToolCalls(options.llmConfig.provider)
+    ? `${systemPrompt}\n\n当需要调用工具时,请只输出一个 JSON 对象,格式为 {"name":"工具名","arguments":{...}},不要附加其他说明文字。收到工具结果后继续推理;若无需工具则直接回答。`
+    : systemPrompt
+
   return {
   return {
     maxRounds: DEFAULT_MAX_ROUNDS,
     maxRounds: DEFAULT_MAX_ROUNDS,
     tools: registry.list(),
     tools: registry.list(),
-    systemPrompt,
+    systemPrompt: prompt,
     llmConfig: options.llmConfig,
     llmConfig: options.llmConfig,
     modelId,
     modelId,
     projectPath: options.projectPath,
     projectPath: options.projectPath,

+ 1 - 1
src/lib/agent/plan-execute-policy.ts

@@ -27,7 +27,7 @@ export function buildPlanExecutePolicyPrompt(mode: LegacyAiWorkflowMode): string
     "计划必须整体包裹在 `<!-- chapter_plan -->` 和 `<!-- /chapter_plan -->` 标记中。",
     "计划必须整体包裹在 `<!-- chapter_plan -->` 和 `<!-- /chapter_plan -->` 标记中。",
     "输出计划后必须暂停,等待用户确认后再进入正文或执行阶段。",
     "输出计划后必须暂停,等待用户确认后再进入正文或执行阶段。",
     "计划必须包含:任务目标、已读取依据、缺失资料、执行步骤、确认后动作。",
     "计划必须包含:任务目标、已读取依据、缺失资料、执行步骤、确认后动作。",
-    "读取资料前先用 list_chapters、list_outlines、list_memories 确认可用文件;不要凭空编造章节、大纲或记忆条目名称。",
+    "读取资料前先用 list_chapters、list_outlines、list_memories 确认可用文件;不要凭空编造章节、大纲或记忆条目名称。list_outlines 后按 type 分流:优先关注 overview(索引)与 concept(硬约束),对 outline 类必须读正文确认对应该章后再写。",
     "如果资料缺失,必须在“缺失资料”里说明,并基于已读取内容继续制定可执行方案。",
     "如果资料缺失,必须在“缺失资料”里说明,并基于已读取内容继续制定可执行方案。",
   ].join("\n")
   ].join("\n")
 
 

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

@@ -1,6 +1,7 @@
 import { describe, expect, it } from "vitest"
 import { describe, expect, it } from "vitest"
 import { createBuildSystemPromptPlugin } from "./build-system-prompt-plugin"
 import { createBuildSystemPromptPlugin } from "./build-system-prompt-plugin"
 import { normalizeUserSkill } from "@/lib/novel/skill-library"
 import { normalizeUserSkill } from "@/lib/novel/skill-library"
+import { buildOutlineFindProtocol } from "@/lib/novel/outline-find-protocol"
 
 
 describe("BuildSystemPromptPlugin selected skills", () => {
 describe("BuildSystemPromptPlugin selected skills", () => {
   it("injects selected skill prompt before final model execution", async () => {
   it("injects selected skill prompt before final model execution", async () => {
@@ -103,10 +104,82 @@ describe("BuildSystemPromptPlugin selected skills", () => {
     expect(result.finalSystemPrompt).toContain("禁止违背")
     expect(result.finalSystemPrompt).toContain("禁止违背")
     expect(result.finalSystemPrompt).toContain("可自由发挥")
     expect(result.finalSystemPrompt).toContain("可自由发挥")
     expect(result.finalSystemPrompt).toContain("planBlueprint")
     expect(result.finalSystemPrompt).toContain("planBlueprint")
+    expect(result.finalSystemPrompt).toContain("大纲定位协议")
+    expect(result.finalSystemPrompt).toContain("list_outlines")
+    expect(result.finalSystemPrompt).toContain("按 type 分流")
     expect(result.finalSystemRulesPrompt).toContain("章节主编策划协议")
     expect(result.finalSystemRulesPrompt).toContain("章节主编策划协议")
     expect(result.finalSystemRulesPrompt).not.toContain("context prompt")
     expect(result.finalSystemRulesPrompt).not.toContain("context prompt")
     const finalPrompt = result.finalSystemPrompt ?? ""
     const finalPrompt = result.finalSystemPrompt ?? ""
-    expect(finalPrompt.length).toBeLessThan(3000)
+    expect(finalPrompt.length).toBeLessThan(4500)
+  })
+
+  it("injects outline find protocol for chapter writing even without Plan Execute", async () => {
+    const plugin = createBuildSystemPromptPlugin({
+      baseSystemPrompt: "base prompt",
+      buildTaskDirectiveFn: () => "task directive",
+    })
+
+    const result = await plugin.run({
+      userMessage: "写第167章",
+      projectPath: "/project",
+      agentConfig: {} as any,
+      novelMode: true,
+      aiWorkflowMode: "strict",
+      planExecuteEnabled: false,
+      taskRoute: {
+        intent: "write_chapter",
+        confidence: 0.95,
+        chapterNumber: 167,
+        extractedParams: { chapterNumber: "167" },
+      },
+    })
+
+    expect(result.finalSystemPrompt).toContain("大纲定位协议")
+    expect(result.finalSystemPrompt).toContain("本次写作目标:第 167 章")
+    expect(result.finalSystemPrompt).toContain("overview")
+    expect(result.finalSystemPrompt).not.toContain("章节主编策划协议")
+    const occurrences = (result.finalSystemPrompt?.match(/大纲定位协议/g) ?? []).length
+    expect(occurrences).toBe(1)
+  })
+
+  it("does not inject outline find protocol for non-writing intents", async () => {
+    const plugin = createBuildSystemPromptPlugin({
+      baseSystemPrompt: "base prompt",
+    })
+
+    const result = await plugin.run({
+      userMessage: "陈远现在知道什么",
+      projectPath: "/project",
+      agentConfig: {} as any,
+      novelMode: true,
+      aiWorkflowMode: "strict",
+      taskRoute: { intent: "character_query", confidence: 0.9, extractedParams: {} },
+    })
+
+    expect(result.finalSystemPrompt).not.toContain("大纲定位协议")
+  })
+
+  it("dedupes outline find protocol already present in base system prompt", async () => {
+    const plugin = createBuildSystemPromptPlugin({
+      baseSystemPrompt: ["base", buildOutlineFindProtocol(1), "tail"].join("\n\n"),
+    })
+
+    const result = await plugin.run({
+      userMessage: "写下一章",
+      projectPath: "/project",
+      agentConfig: {} as any,
+      novelMode: true,
+      taskRoute: {
+        intent: "write_chapter",
+        confidence: 0.9,
+        chapterNumber: 167,
+        extractedParams: {},
+      },
+    })
+
+    const occurrences = (result.finalSystemPrompt?.match(/大纲定位协议/g) ?? []).length
+    expect(occurrences).toBe(1)
+    expect(result.finalSystemPrompt).toContain("本次写作目标:第 167 章")
   })
   })
 
 
   it.each(["fast", "standard", "strict"] as const)(
   it.each(["fast", "standard", "strict"] as const)(

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

@@ -1,10 +1,15 @@
-  import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
+import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
 import { buildTaskDirective } from "@/lib/novel/task-router"
 import { buildTaskDirective } from "@/lib/novel/task-router"
+import {
+  buildOutlineFindProtocol,
+  shouldIncludeOutlineFindProtocol,
+  stripOutlineFindProtocol,
+} from "@/lib/novel/outline-find-protocol"
 import { buildSelectedSkillsPrompt } from "./select-skills-plugin"
 import { buildSelectedSkillsPrompt } from "./select-skills-plugin"
 import { getWorkflowModeLabel, resolveAiWorkflowMode, type LegacyAiWorkflowMode } from "../workflow-mode"
 import { getWorkflowModeLabel, resolveAiWorkflowMode, type LegacyAiWorkflowMode } from "../workflow-mode"
 import { WRITING_INTENTS } from "../plan-execute-policy"
 import { WRITING_INTENTS } from "../plan-execute-policy"
 
 
-  export interface BuildSystemPromptPluginDeps {
+export interface BuildSystemPromptPluginDeps {
   baseSystemPrompt?: string
   baseSystemPrompt?: string
   buildTaskDirectiveFn?: typeof buildTaskDirective
   buildTaskDirectiveFn?: typeof buildTaskDirective
   onError?: (error: Error) => void
   onError?: (error: Error) => void
@@ -26,7 +31,9 @@ export function createBuildSystemPromptPlugin(deps: BuildSystemPromptPluginDeps
         const parts: string[] = []
         const parts: string[] = []
         const rulesParts: string[] = []
         const rulesParts: string[] = []
 
 
-        const base = baseSystemPrompt || (input.agentConfig as any)?.systemPrompt || ""
+        // 去掉 base 里可能已有的找纲协议,统一由本 plugin 注入一次,避免重复。
+        const rawBase = baseSystemPrompt || (input.agentConfig as any)?.systemPrompt || ""
+        const base = rawBase ? stripOutlineFindProtocol(rawBase) : ""
         if (base) {
         if (base) {
           parts.push(base)
           parts.push(base)
           rulesParts.push(base)
           rulesParts.push(base)
@@ -42,9 +49,18 @@ export function createBuildSystemPromptPlugin(deps: BuildSystemPromptPluginDeps
           rulesParts.push(selectedSkillsPrompt)
           rulesParts.push(selectedSkillsPrompt)
         }
         }
 
 
+        const routeForWriting = input.effectiveTaskRoute || input.taskRoute
+        const isWritingTask = Boolean(
+          routeForWriting?.intent && WRITING_INTENTS.has(routeForWriting.intent),
+        )
+
+        if (shouldIncludeOutlineFindProtocol(routeForWriting?.intent)) {
+          const outlineProtocol = buildOutlineFindProtocol(routeForWriting?.chapterNumber)
+          parts.push(outlineProtocol)
+          rulesParts.push(outlineProtocol)
+        }
+
         if (input.planExecuteEnabled && input.aiWorkflowMode) {
         if (input.planExecuteEnabled && input.aiWorkflowMode) {
-          const routeForPlan = input.effectiveTaskRoute || input.taskRoute
-          const isWritingTask = routeForPlan?.intent && WRITING_INTENTS.has(routeForPlan.intent)
           if (isWritingTask) {
           if (isWritingTask) {
             const planProtocol = buildChapterPlanProtocol(input.aiWorkflowMode)
             const planProtocol = buildChapterPlanProtocol(input.aiWorkflowMode)
             parts.push(planProtocol)
             parts.push(planProtocol)
@@ -83,7 +99,7 @@ function buildChapterPlanProtocol(mode: LegacyAiWorkflowMode): string {
     "输出规范:",
     "输出规范:",
     "1. 计划必须整体包裹在 `<!-- chapter_plan -->` 和 `<!-- /chapter_plan -->` 标记中。",
     "1. 计划必须整体包裹在 `<!-- chapter_plan -->` 和 `<!-- /chapter_plan -->` 标记中。",
     "2. 计划只供用户确认,正文生成必须等待用户确认后再开始。当前阶段禁用正文生成类工具,只能使用读取类工具收集资料。",
     "2. 计划只供用户确认,正文生成必须等待用户确认后再开始。当前阶段禁用正文生成类工具,只能使用读取类工具收集资料。",
-    "3. 计划必须基于会话上下文包;读取资料前先用 list_chapters、list_outlines、list_memories 确认可用文件名,绝不编造资料名称。",
+    "3. 计划必须基于会话上下文包;读取资料前先用 list_chapters、list_outlines、list_memories 确认可用文件名,绝不编造资料名称。list_outlines 后按 type 分流:优先关注 overview(索引)与 concept(硬约束),对 outline 类必须 read_outline 读正文确认对应该章后再写。",
     "4. 计划总长控制在 1200-1800字,避免堆砌分析维度;用结论和执行项表达。",
     "4. 计划总长控制在 1200-1800字,避免堆砌分析维度;用结论和执行项表达。",
     "5. 场景必须用 S1/S2/S3 编号,后续正文会按编号执行。",
     "5. 场景必须用 S1/S2/S3 编号,后续正文会按编号执行。",
     "6. 不要使用旧版分析报告式编号标题。",
     "6. 不要使用旧版分析报告式编号标题。",

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

@@ -1,5 +1,6 @@
 import type { PostWriteCheck, PostWriteCheckItem } from "../context-trace"
 import type { PostWriteCheck, PostWriteCheckItem } from "../context-trace"
 import type { ContextPack } from "@/lib/novel/context-engine"
 import type { ContextPack } from "@/lib/novel/context-engine"
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "@/lib/novel/chapter-excerpts"
 import type { LlmConfig } from "@/stores/wiki-store"
 import type { LlmConfig } from "@/stores/wiki-store"
 import { streamChat } from "@/lib/llm-client"
 import { streamChat } from "@/lib/llm-client"
 import { resolveNovelModel, type NovelTaskType } from "@/lib/novel/model-resolver"
 import { resolveNovelModel, type NovelTaskType } from "@/lib/novel/model-resolver"
@@ -28,8 +29,8 @@ const AI_TIMEOUT_MS = 30_000
 const VALID_SEVERITIES = ["info", "warning", "error"] as const
 const VALID_SEVERITIES = ["info", "warning", "error"] as const
 
 
 function buildPostWriteCheckPrompt(chapterContent: string, contextPack?: ContextPack): string {
 function buildPostWriteCheckPrompt(chapterContent: string, contextPack?: ContextPack): string {
-  const truncated = chapterContent.length > 8000
-    ? chapterContent.slice(0, 8000) + "\n\n[正文已截断]"
+  const truncated = chapterContent.length > CHAPTER_BODY_EXCERPT_MAX_CHARS
+    ? chapterContent.slice(0, CHAPTER_BODY_EXCERPT_MAX_CHARS) + "\n\n[正文已截断]"
     : chapterContent
     : chapterContent
   const chapterGoal = contextPack?.chapterGoal || "未提供"
   const chapterGoal = contextPack?.chapterGoal || "未提供"
   const previousEnding = contextPack?.previousChapterEnding || "未提供"
   const previousEnding = contextPack?.previousChapterEnding || "未提供"

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

@@ -52,7 +52,7 @@ describe("TrimContextPlugin", () => {
       userMessage: "写第5章",
       userMessage: "写第5章",
       projectPath: "/test-project",
       projectPath: "/test-project",
       agentConfig: {
       agentConfig: {
-        llmConfig: { maxContextSize: 4000 },
+        llmConfig: { maxContextSize: 204_800 },
       } as any,
       } as any,
       novelMode: true,
       novelMode: true,
       contextPack: mockContextPack,
       contextPack: mockContextPack,
@@ -62,8 +62,8 @@ describe("TrimContextPlugin", () => {
     expect(mockToPrompt).toHaveBeenCalled()
     expect(mockToPrompt).toHaveBeenCalled()
     const budgetArg = mockToPrompt.mock.calls[0][1]
     const budgetArg = mockToPrompt.mock.calls[0][1]
     expect(typeof budgetArg).toBe("number")
     expect(typeof budgetArg).toBe("number")
-    expect(budgetArg).toBeGreaterThan(500)
-    expect(budgetArg).toBeLessThan(4000)
+    expect(budgetArg).toBeGreaterThan(0)
+    expect(Number.isFinite(budgetArg)).toBe(true)
   })
   })
 
 
   it("supports custom token budget", async () => {
   it("supports custom token budget", async () => {

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

@@ -1,5 +1,7 @@
 import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
 import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
 import type { ContextPack, TrimResult } from "@/lib/novel/context-engine"
 import type { ContextPack, TrimResult } from "@/lib/novel/context-engine"
+import { resolveContextPackTokenBudget } from "@/lib/context-budget"
+import { useWikiStore } from "@/stores/wiki-store"
 
 
 export interface TrimContextPluginDeps {
 export interface TrimContextPluginDeps {
   contextPackToPromptFn?: (pack: ContextPack, tokenBudget?: number, options?: { excludeOutline?: boolean }) => string
   contextPackToPromptFn?: (pack: ContextPack, tokenBudget?: number, options?: { excludeOutline?: boolean }) => string
@@ -26,7 +28,7 @@ export function createTrimContextPlugin(deps: TrimContextPluginDeps = {}): PrePl
       let callId: string | undefined
       let callId: string | undefined
       if (onVirtualTool) {
       if (onVirtualTool) {
         callId = `trim_context_${Date.now()}`
         callId = `trim_context_${Date.now()}`
-        const budget = tokenBudget ?? computeTokenBudget(input)
+        const budget = tokenBudget ?? resolveTokenBudget(input)
         onVirtualTool("start", "trim_context", {
         onVirtualTool("start", "trim_context", {
           callId,
           callId,
           params: {
           params: {
@@ -37,7 +39,7 @@ export function createTrimContextPlugin(deps: TrimContextPluginDeps = {}): PrePl
       }
       }
 
 
       try {
       try {
-        const budget = tokenBudget ?? computeTokenBudget(input)
+        const budget = tokenBudget ?? resolveTokenBudget(input)
         let trimmedPrompt: string
         let trimmedPrompt: string
         let trimResult: TrimResult | null = null
         let trimResult: TrimResult | null = null
 
 
@@ -89,14 +91,11 @@ export function createTrimContextPlugin(deps: TrimContextPluginDeps = {}): PrePl
   }
   }
 }
 }
 
 
-function computeTokenBudget(input: PrePluginInput): number | undefined {
-  const maxContextSize = (input.agentConfig as any)?.llmConfig?.maxContextSize
-  if (!maxContextSize) return undefined
-  const estimatedUserTokens = Math.ceil(input.userMessage.length / 4)
-  const estimatedHistoryTokens = input.historyMessages
-    ? input.historyMessages.reduce((acc, msg) => acc + Math.ceil(msg.content.length / 4), 0)
-    : 0
-  const reservedTokens = 2000
-  const budget = maxContextSize - estimatedUserTokens - estimatedHistoryTokens - reservedTokens
-  return budget > 500 ? budget : undefined
+function resolveTokenBudget(input: PrePluginInput): number {
+  const maxContextSize = input.agentConfig?.llmConfig?.maxContextSize
+  const contextTokenBudget = useWikiStore.getState().novelConfig?.contextTokenBudget
+  return resolveContextPackTokenBudget({
+    maxContextSize,
+    contextTokenBudget,
+  })
 }
 }

+ 49 - 0
src/lib/agent/runner.spec.ts

@@ -188,6 +188,55 @@ describe("AgentRunner", () => {
     expect(result.roundsUsed).toBe(2)
     expect(result.roundsUsed).toBe(2)
   })
   })
 
 
+  it("parses text JSON tool calls for cursor-cli providers", async () => {
+    const tool: Tool = {
+      name: "read_chapter",
+      description: "read",
+      category: "read",
+      parameters: { name: { type: "string", description: "name" } },
+      execute: vi.fn().mockResolvedValue("Chapter content"),
+    }
+    registry.register(tool)
+
+    let callCount = 0
+    mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+      callCount++
+      if (callCount === 1) {
+        cb.onToken('{"name":"read_chapter","arguments":{"name":"ch1"}}')
+        cb.onDone()
+      } else {
+        cb.onToken("完成")
+        cb.onDone()
+      }
+    })
+
+    const callbacks = {
+      onText: vi.fn(),
+      onToolCall: vi.fn(),
+      onToolResult: vi.fn(),
+      onToolError: vi.fn(),
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    }
+
+    const config: AgentConfig = {
+      maxRounds: 3,
+      tools: [tool],
+      systemPrompt: "You are helpful",
+      llmConfig: { ...mockLlmConfig, provider: "cursor-cli" },
+    }
+    const result = await runner.run(config, registry, [systemMsg, userMsg], callbacks, undefined)
+
+    expect(tool.execute).toHaveBeenCalledWith(
+      { name: "ch1" },
+      undefined,
+      expect.objectContaining({ toolName: "read_chapter" }),
+    )
+    expect(callbacks.onToolCall).toHaveBeenCalledOnce()
+    expect(result.finalText).toBe("完成")
+    expect(result.roundsUsed).toBe(2)
+  })
+
   it("passes the real tool call id into tool execution context", async () => {
   it("passes the real tool call id into tool execution context", async () => {
     const tool: Tool = {
     const tool: Tool = {
       name: "read_chapter",
       name: "read_chapter",

+ 18 - 3
src/lib/agent/runner.ts

@@ -1,6 +1,7 @@
 import { streamChat } from "../llm-client"
 import { streamChat } from "../llm-client"
 import type { StreamCallbacks } from "../llm-client"
 import type { StreamCallbacks } from "../llm-client"
-import { accumulateToolCalls } from "./tool-call-parser"
+import { providerUsesTextToolCalls } from "./config"
+import { accumulateToolCalls, parseTextToolCalls } from "./tool-call-parser"
 import { toOpenAITools } from "./tools-schema"
 import { toOpenAITools } from "./tools-schema"
 import type { ToolRegistry } from "./registry"
 import type { ToolRegistry } from "./registry"
 import type { AgentConfig, AgentMessage, AgentRunCallbacks, AgentRunRecord, ToolCall, ToolCallDelta } from "./types"
 import type { AgentConfig, AgentMessage, AgentRunCallbacks, AgentRunRecord, ToolCall, ToolCallDelta } from "./types"
@@ -192,8 +193,22 @@ export class AgentRunner {
         return record
         return record
       }
       }
 
 
-      // Check for tool calls
-      const toolCalls = accumulateToolCalls(toolCallDeltas)
+      // Check for tool calls (native deltas, or text JSON for cursor-cli bridge)
+      let toolCalls = accumulateToolCalls(toolCallDeltas)
+      if (
+        toolCalls.length === 0 &&
+        openaiTools &&
+        providerUsesTextToolCalls(config.llmConfig.provider)
+      ) {
+        const parsed = parseTextToolCalls(
+          roundText,
+          new Set(config.tools.map((tool) => tool.name)),
+        )
+        if (parsed.toolCalls.length > 0) {
+          toolCalls = parsed.toolCalls
+          roundText = parsed.residualText
+        }
+      }
 
 
       if (toolCalls.length === 0) {
       if (toolCalls.length === 0) {
         finalText = roundText
         finalText = roundText

+ 48 - 1
src/lib/agent/tool-call-parser.spec.ts

@@ -1,5 +1,5 @@
 import { describe, expect, it } from "vitest"
 import { describe, expect, it } from "vitest"
-import { accumulateToolCalls } from "./tool-call-parser"
+import { accumulateToolCalls, parseTextToolCalls } from "./tool-call-parser"
 import type { ToolCallDelta } from "./types"
 import type { ToolCallDelta } from "./types"
 
 
 describe("accumulateToolCalls", () => {
 describe("accumulateToolCalls", () => {
@@ -49,3 +49,50 @@ describe("accumulateToolCalls", () => {
     expect(result[0].function.arguments).toBe("not json")
     expect(result[0].function.arguments).toBe("not json")
   })
   })
 })
 })
+
+describe("parseTextToolCalls", () => {
+  const allowed = new Set(["read_chapter", "route_task"])
+
+  it("parses name/arguments JSON", () => {
+    const result = parseTextToolCalls(
+      '{"name":"read_chapter","arguments":{"name":"第1章"}}',
+      allowed,
+    )
+    expect(result.toolCalls).toHaveLength(1)
+    expect(result.toolCalls[0].function.name).toBe("read_chapter")
+    expect(result.toolCalls[0].function.arguments).toBe('{"name":"第1章"}')
+    expect(result.residualText).toBe("")
+  })
+
+  it("parses fenced JSON and keeps residual prose", () => {
+    const result = parseTextToolCalls(
+      '先查一下章节。\n```json\n{"name":"read_chapter","parameters":{"name":"第1章"}}\n```\n',
+      allowed,
+    )
+    expect(result.toolCalls[0].function.name).toBe("read_chapter")
+    expect(result.residualText).toContain("先查一下章节")
+  })
+
+  it("parses OpenAI-shaped tool_calls wrapper", () => {
+    const result = parseTextToolCalls(
+      JSON.stringify({
+        tool_calls: [
+          {
+            id: "call_1",
+            type: "function",
+            function: { name: "route_task", arguments: '{"intent":"write_chapter"}' },
+          },
+        ],
+      }),
+      allowed,
+    )
+    expect(result.toolCalls[0].id).toBe("call_1")
+    expect(result.toolCalls[0].function.name).toBe("route_task")
+  })
+
+  it("ignores JSON that is not an allowed tool", () => {
+    const result = parseTextToolCalls('{"name":"unknown_tool","arguments":{}}', allowed)
+    expect(result.toolCalls).toEqual([])
+    expect(result.residualText).toContain("unknown_tool")
+  })
+})

+ 150 - 0
src/lib/agent/tool-call-parser.ts

@@ -24,3 +24,153 @@ export function accumulateToolCalls(deltas: ToolCallDelta[]): ToolCall[] {
     }
     }
   })
   })
 }
 }
+
+function extractJsonCandidates(text: string): string[] {
+  const trimmed = text.trim()
+  if (!trimmed) return []
+
+  const candidates: string[] = []
+  const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)
+  if (fenced?.[1]?.trim()) {
+    candidates.push(fenced[1].trim())
+  }
+
+  const firstObj = trimmed.indexOf("{")
+  const lastObj = trimmed.lastIndexOf("}")
+  if (firstObj >= 0 && lastObj > firstObj) {
+    candidates.push(trimmed.slice(firstObj, lastObj + 1))
+  }
+
+  const firstArr = trimmed.indexOf("[")
+  const lastArr = trimmed.lastIndexOf("]")
+  if (firstArr >= 0 && lastArr > firstArr) {
+    candidates.push(trimmed.slice(firstArr, lastArr + 1))
+  }
+
+  if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
+    candidates.unshift(trimmed)
+  }
+
+  return [...new Set(candidates)]
+}
+
+function stringifyArguments(value: unknown): string {
+  if (typeof value === "string") {
+    const trimmed = value.trim()
+    if (!trimmed) return "{}"
+    try {
+      JSON.parse(trimmed)
+      return trimmed
+    } catch {
+      return JSON.stringify(value)
+    }
+  }
+  if (value && typeof value === "object") {
+    return JSON.stringify(value)
+  }
+  return "{}"
+}
+
+function toolCallFromObject(
+  raw: Record<string, unknown>,
+  allowedToolNames: ReadonlySet<string>,
+  index: number,
+): ToolCall | null {
+  const nestedFunction =
+    raw.function && typeof raw.function === "object"
+      ? (raw.function as Record<string, unknown>)
+      : null
+
+  const nameCandidate = [
+    raw.name,
+    raw.tool,
+    raw.tool_name,
+    nestedFunction?.name,
+  ].find((value): value is string => typeof value === "string" && value.trim().length > 0)
+
+  if (!nameCandidate) return null
+  const name = nameCandidate.trim()
+  if (!allowedToolNames.has(name)) return null
+
+  const args =
+    raw.arguments ??
+    raw.parameters ??
+    raw.input ??
+    nestedFunction?.arguments ??
+    nestedFunction?.parameters ??
+    {}
+
+  const id =
+    (typeof raw.id === "string" && raw.id.trim()) ||
+    (typeof nestedFunction?.id === "string" && nestedFunction.id.trim()) ||
+    `text_call_${index}_${Date.now()}`
+
+  return {
+    id,
+    type: "function",
+    function: {
+      name,
+      arguments: stringifyArguments(args),
+    },
+  }
+}
+
+function collectToolCallsFromParsed(
+  parsed: unknown,
+  allowedToolNames: ReadonlySet<string>,
+): ToolCall[] {
+  if (Array.isArray(parsed)) {
+    return parsed.flatMap((item, index) => {
+      if (!item || typeof item !== "object") return []
+      const call = toolCallFromObject(item as Record<string, unknown>, allowedToolNames, index)
+      return call ? [call] : []
+    })
+  }
+
+  if (!parsed || typeof parsed !== "object") return []
+  const obj = parsed as Record<string, unknown>
+
+  if (Array.isArray(obj.tool_calls)) {
+    return collectToolCallsFromParsed(obj.tool_calls, allowedToolNames)
+  }
+
+  if (Array.isArray(obj.tools)) {
+    return collectToolCallsFromParsed(obj.tools, allowedToolNames)
+  }
+
+  const single = toolCallFromObject(obj, allowedToolNames, 0)
+  return single ? [single] : []
+}
+
+/**
+ * cursor-api-proxy 等桥接层只能把 tools schema 注入 prompt,无法返回原生
+ * tool_calls delta。从模型文本里解析 JSON 工具调用。
+ */
+export function parseTextToolCalls(
+  text: string,
+  allowedToolNames: ReadonlySet<string>,
+): { toolCalls: ToolCall[]; residualText: string } {
+  if (!text.trim() || allowedToolNames.size === 0) {
+    return { toolCalls: [], residualText: text }
+  }
+
+  for (const candidate of extractJsonCandidates(text)) {
+    let parsed: unknown
+    try {
+      parsed = JSON.parse(candidate)
+    } catch {
+      continue
+    }
+
+    const toolCalls = collectToolCallsFromParsed(parsed, allowedToolNames)
+    if (toolCalls.length === 0) continue
+
+    const residualText = text.includes(candidate)
+      ? text.replace(candidate, "").replace(/```(?:json)?\s*```/gi, "").trim()
+      : ""
+
+    return { toolCalls, residualText }
+  }
+
+  return { toolCalls: [], residualText: text }
+}

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

@@ -128,7 +128,7 @@ describe("applyAgentToolEvent", () => {
     expect(event.content).toContain("铜铃线索")
     expect(event.content).toContain("铜铃线索")
   })
   })
 
 
-  it("converts workflow child events into chapter workflow activities", () => {
+  it("skips writing chapter_* child tool events into agent stages", () => {
     const event = activityEventFromAgentToolEvent({
     const event = activityEventFromAgentToolEvent({
       type: "result",
       type: "result",
       callId: "workflow-1:chapter_task_brief",
       callId: "workflow-1:chapter_task_brief",
@@ -139,10 +139,49 @@ describe("applyAgentToolEvent", () => {
       timestamp: 200,
       timestamp: 200,
     })
     })
 
 
+    expect(event).toBeNull()
+
+    const stages = applyAgentToolActivityEvent(
+      [
+        {
+          id: "read_context",
+          title: "读取上下文",
+          status: "done",
+          summary: "已完成",
+          events: [],
+          startedAt: 100,
+          finishedAt: 150,
+        },
+      ],
+      {
+        type: "result",
+        callId: "workflow-1:chapter_task_brief",
+        parentCallId: "workflow-1",
+        name: "chapter_task_brief",
+        params: { title: "生成写作任务书" },
+        result: "写作任务书完成。",
+        timestamp: 200,
+      },
+    )
+
+    expect(stages).toHaveLength(1)
+    expect(stages[0].id).toBe("read_context")
+  })
+
+  it("still maps orphan chapter_* events without parentCallId into chapter_workflow", () => {
+    const event = activityEventFromAgentToolEvent({
+      type: "result",
+      callId: "orphan-chapter-context",
+      name: "chapter_context",
+      params: { title: "读取上下文" },
+      result: "上下文完成。",
+      timestamp: 200,
+    })
+
     expect(event).toMatchObject({
     expect(event).toMatchObject({
       stageId: "chapter_workflow",
       stageId: "chapter_workflow",
       kind: "stage_output",
       kind: "stage_output",
-      title: "生成写作任务书",
+      title: "读取上下文",
     })
     })
   })
   })
 
 

+ 10 - 1
src/lib/agent/tool-events.ts

@@ -68,7 +68,14 @@ export function settleRunningAgentToolCalls(
   })
   })
 }
 }
 
 
+/** chapter_* 子步骤已在工具时间线展示,且细粒度阶段由 onActivityEvent 写入,避免双轨重复。 */
+export function shouldSkipToolActivityForStages(event: AgentToolEvent): boolean {
+  return Boolean(event.parentCallId) && event.name.startsWith("chapter_")
+}
+
 export function activityEventFromAgentToolEvent(event: AgentToolEvent) {
 export function activityEventFromAgentToolEvent(event: AgentToolEvent) {
+  if (shouldSkipToolActivityForStages(event)) return null
+
   const activity = activityEventFromToolEvent(event)
   const activity = activityEventFromToolEvent(event)
   const titleFromParams = typeof event.params.title === "string" ? event.params.title : ""
   const titleFromParams = typeof event.params.title === "string" ? event.params.title : ""
 
 
@@ -88,5 +95,7 @@ export function applyAgentToolActivityEvent(
   stages: AgentStageTrace[] | undefined,
   stages: AgentStageTrace[] | undefined,
   event: AgentToolEvent,
   event: AgentToolEvent,
 ): AgentStageTrace[] {
 ): AgentStageTrace[] {
-  return applyAgentActivityEvent(stages, activityEventFromAgentToolEvent(event))
+  const activity = activityEventFromAgentToolEvent(event)
+  if (!activity) return stages ?? []
+  return applyAgentActivityEvent(stages, activity)
 }
 }

+ 22 - 3
src/lib/agent/tools/index.ts

@@ -28,13 +28,17 @@ import type { UserSkill } from "@/lib/novel/skill-library"
 import type { LlmConfig, SearchApiConfig } from "@/stores/wiki-store"
 import type { LlmConfig, SearchApiConfig } from "@/stores/wiki-store"
 import type { TaskRouteResult } from "@/lib/novel/task-router"
 import type { TaskRouteResult } from "@/lib/novel/task-router"
 import type { ContextPack } from "@/lib/novel/context-engine"
 import type { ContextPack } from "@/lib/novel/context-engine"
+import { resolveContextPackTokenBudget } from "@/lib/context-budget"
 
 
 export interface VirtualToolContext {
 export interface VirtualToolContext {
   userMessage: string
   userMessage: string
   projectPath: string
   projectPath: string
   taskRoute?: TaskRouteResult
   taskRoute?: TaskRouteResult
   contextPack?: ContextPack
   contextPack?: ContextPack
-  targetChars?: number
+  /** ContextPack token budget; resolved from model window when omitted. */
+  tokenBudget?: number
+  /** Session model context window in characters. */
+  maxContextSize?: number
 }
 }
 
 
 export interface ToolFactoryOptions {
 export interface ToolFactoryOptions {
@@ -48,6 +52,8 @@ export interface ToolFactoryOptions {
   mcpTools?: Tool[]
   mcpTools?: Tool[]
   draftMode?: boolean
   draftMode?: boolean
   projectPath?: string
   projectPath?: string
+  /** Session model context window in characters (for trim_context defaults). */
+  maxContextSize?: number
   sourceConversationId?: string
   sourceConversationId?: string
   sourceMessageId?: string
   sourceMessageId?: string
   enabledToolNames?: string[]
   enabledToolNames?: string[]
@@ -80,7 +86,14 @@ export function registerAllBuiltInTools(registry: ToolRegistry, options: ToolFac
   if (shouldRegister("read_outline_history")) registry.register(createReadOutlineHistoryTool(options.getOutlineConversations()))
   if (shouldRegister("read_outline_history")) registry.register(createReadOutlineHistoryTool(options.getOutlineConversations()))
   if (shouldRegister("search_chapters")) registry.register(createSearchChaptersTool(chaptersDir, options.readTextFile))
   if (shouldRegister("search_chapters")) registry.register(createSearchChaptersTool(chaptersDir, options.readTextFile))
   if (shouldRegister("list_chapters")) registry.register(createListChaptersTool(chaptersDir))
   if (shouldRegister("list_chapters")) registry.register(createListChaptersTool(chaptersDir))
-  if (shouldRegister("list_outlines")) registry.register(createListOutlinesTool(outlinesDir))
+  if (shouldRegister("list_outlines")) {
+    registry.register(
+      createListOutlinesTool(outlinesDir, {
+        readTextFile: options.readTextFile,
+        getDefaultChapterNumber: () => options.virtualToolContext?.taskRoute?.chapterNumber,
+      }),
+    )
+  }
   if (shouldRegister("list_memories")) registry.register(createListMemoriesTool(memoryDir))
   if (shouldRegister("list_memories")) registry.register(createListMemoriesTool(memoryDir))
   if (shouldRegister("list_deductions")) registry.register(createListDeductionsTool(simDir))
   if (shouldRegister("list_deductions")) registry.register(createListDeductionsTool(simDir))
   if (shouldRegister("write_chapter")) {
   if (shouldRegister("write_chapter")) {
@@ -124,7 +137,13 @@ export function registerAllBuiltInTools(registry: ToolRegistry, options: ToolFac
       registry.register(createLoadContextTool(vtc.projectPath, vtc.userMessage, vtc.taskRoute))
       registry.register(createLoadContextTool(vtc.projectPath, vtc.userMessage, vtc.taskRoute))
     }
     }
     if (vtc.contextPack && shouldRegister("trim_context")) {
     if (vtc.contextPack && shouldRegister("trim_context")) {
-      registry.register(createTrimContextTool(vtc.contextPack, vtc.targetChars ?? 8000))
+      const tokenBudget = vtc.tokenBudget
+        ?? resolveContextPackTokenBudget({
+          maxContextSize: vtc.maxContextSize
+            ?? options.maxContextSize
+            ?? options.llmConfig?.maxContextSize,
+        })
+      registry.register(createTrimContextTool(vtc.contextPack, tokenBudget))
     }
     }
   }
   }
 }
 }

+ 27 - 5
src/lib/agent/tools/list-chapters.ts

@@ -1,19 +1,41 @@
 import type { Tool } from "../types"
 import type { Tool } from "../types"
 import { listDirectory } from "@/commands/fs"
 import { listDirectory } from "@/commands/fs"
+import { flattenMdFiles } from "@/lib/novel/chapter-utils"
+
+/** 仅匹配「第N章/节/回」,避免 backup-2024 等文件名污染最新章号 */
+export 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)
+  return Number.isFinite(n) && n > 0 ? n : null
+}
 
 
 export function createListChaptersTool(chaptersDir: string): Tool {
 export function createListChaptersTool(chaptersDir: string): Tool {
   return {
   return {
     name: "list_chapters",
     name: "list_chapters",
-    description: "列出所有章节文件的名称列表。无需参数。",
+    description:
+      "列出所有章节文件的名称列表,并标注最新已写章节号。续写「下一章」时可用最新章号+1 作为目标章号。无需参数。",
     category: "read",
     category: "read",
     parameters: {},
     parameters: {},
     execute: async () => {
     execute: async () => {
       try {
       try {
         const files = await listDirectory(chaptersDir)
         const files = await listDirectory(chaptersDir)
-        const chapters = files
-          .filter((f) => !f.is_dir && f.name.endsWith(".md"))
-          .map((f) => f.name.replace(/\.md$/, ""))
-        return `可用章节列表:\n${chapters.map((c, i) => `${i + 1}. ${c}`).join("\n")}`
+        const chapters = flattenMdFiles(files).map((f) => f.name.replace(/\.md$/i, ""))
+        const numbers = chapters
+          .map((name) => extractStrictChapterNumber(name))
+          .filter((n): n is number => n !== null)
+        const latest = numbers.length > 0 ? Math.max(...numbers) : null
+
+        const lines = [
+          "可用章节列表:",
+          ...chapters.map((c, i) => `${i + 1}. ${c}`),
+        ]
+        if (latest !== null) {
+          lines.push("")
+          lines.push(`最新已写章节:第 ${latest} 章。若任务是续写下一章,可推断目标为第 ${latest + 1} 章。`)
+          lines.push("找大纲时以「本次要写的目标章号」为准,不要用最新已写章号直接当卷纲匹配锚点。")
+        }
+        return lines.join("\n")
       } catch {
       } catch {
         return "错误:无法列出章节目录"
         return "错误:无法列出章节目录"
       }
       }

+ 44 - 10
src/lib/agent/tools/list-outlines.ts

@@ -1,19 +1,53 @@
 import type { Tool } from "../types"
 import type { Tool } from "../types"
-import { listDirectory } from "@/commands/fs"
+import { readFile } from "@/commands/fs"
+import {
+  buildOutlineListToolResult,
+  listOutlineEntries,
+} from "./outline-list-helpers"
+
+export interface ListOutlinesToolOptions {
+  readTextFile?: (path: string) => Promise<string>
+  /** 模型未传 chapterNumber 时的默认目标章号(通常来自当前任务路由) */
+  getDefaultChapterNumber?: () => number | undefined
+}
+
+function parseChapterNumberParam(raw: unknown): number | undefined {
+  if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) return Math.floor(raw)
+  if (typeof raw === "string" && /^\d+$/.test(raw.trim())) {
+    const n = Number.parseInt(raw.trim(), 10)
+    return n > 0 ? n : undefined
+  }
+  return undefined
+}
+
+export function createListOutlinesTool(
+  outlinesDir: string,
+  readTextFileOrOptions: ((path: string) => Promise<string>) | ListOutlinesToolOptions = readFile,
+): Tool {
+  const options: ListOutlinesToolOptions =
+    typeof readTextFileOrOptions === "function"
+      ? { readTextFile: readTextFileOrOptions }
+      : readTextFileOrOptions
+  const readTextFile = options.readTextFile ?? readFile
 
 
-export function createListOutlinesTool(outlinesDir: string): Tool {
   return {
   return {
     name: "list_outlines",
     name: "list_outlines",
-    description: "列出所有大纲文件的名称列表。无需参数。",
+    description:
+      "列出大纲目录下全部 Markdown 文件(含子目录),并标注 frontmatter 的 type / outline_type。可选参数 chapterNumber 用于在结果中标注本次目标章号。写章节前应先调用本工具,再按 type 分流并用 read_outline 读正文判断哪份对应该章。",
     category: "read",
     category: "read",
-    parameters: {},
-    execute: async () => {
+    parameters: {
+      chapterNumber: {
+        type: "number",
+        description: "本次要写的目标章号(可选)。传入后会在列表末尾标注,便于对照正文找纲。",
+      },
+    },
+    execute: async (params) => {
       try {
       try {
-        const files = await listDirectory(outlinesDir)
-        const outlines = files
-          .filter((f) => !f.is_dir && f.name.endsWith(".md"))
-          .map((f) => f.name.replace(/\.md$/, ""))
-        return `可用大纲列表:\n${outlines.map((o, i) => `${i + 1}. ${o}`).join("\n")}`
+        const chapterNumber =
+          parseChapterNumberParam(params.chapterNumber) ?? options.getDefaultChapterNumber?.()
+
+        const entries = await listOutlineEntries(outlinesDir, readTextFile)
+        return buildOutlineListToolResult(entries, chapterNumber)
       } catch {
       } catch {
         return "错误:无法列出大纲目录"
         return "错误:无法列出大纲目录"
       }
       }

+ 58 - 4
src/lib/agent/tools/list-tools.spec.ts

@@ -1,24 +1,78 @@
 import { describe, expect, it, vi, beforeEach } from "vitest"
 import { describe, expect, it, vi, beforeEach } from "vitest"
 import { createListChaptersTool } from "./list-chapters"
 import { createListChaptersTool } from "./list-chapters"
+import { createListOutlinesTool } from "./list-outlines"
 import { createListMemoriesTool } from "./list-memories"
 import { createListMemoriesTool } from "./list-memories"
 
 
-vi.mock("@/commands/fs", () => ({ listDirectory: vi.fn() }))
-import { listDirectory } from "@/commands/fs"
+vi.mock("@/commands/fs", () => ({
+  listDirectory: vi.fn(),
+  readFile: vi.fn(),
+}))
+import { listDirectory, readFile } from "@/commands/fs"
 
 
 describe("list tools", () => {
 describe("list tools", () => {
   beforeEach(() => {
   beforeEach(() => {
     vi.clearAllMocks()
     vi.clearAllMocks()
   })
   })
 
 
-  it("list_chapters returns file list from chapters dir", async () => {
+  it("list_chapters returns file list and latest chapter number", async () => {
     vi.mocked(listDirectory).mockResolvedValue([
     vi.mocked(listDirectory).mockResolvedValue([
       { name: "第1章-无我绝响.md", path: "/p/第1章-无我绝响.md", is_dir: false },
       { name: "第1章-无我绝响.md", path: "/p/第1章-无我绝响.md", is_dir: false },
+      { name: "第166章-账本.md", path: "/p/第166章-账本.md", is_dir: false },
       { name: "第2章.md", path: "/p/第2章.md", is_dir: false },
       { name: "第2章.md", path: "/p/第2章.md", is_dir: false },
+      { name: "backup-2024.md", path: "/p/backup-2024.md", is_dir: false },
     ])
     ])
     const tool = createListChaptersTool("/project/wiki/chapters")
     const tool = createListChaptersTool("/project/wiki/chapters")
     const result = await tool.execute({})
     const result = await tool.execute({})
     expect(result).toContain("第1章-无我绝响")
     expect(result).toContain("第1章-无我绝响")
-    expect(result).toContain("第2章")
+    expect(result).toContain("第166章-账本")
+    expect(result).toContain("最新已写章节:第 166 章")
+    expect(result).toContain("目标为第 167 章")
+    expect(result).not.toContain("最新已写章节:第 2024 章")
+  })
+
+  it("list_outlines uses default chapterNumber when param omitted", async () => {
+    vi.mocked(listDirectory).mockResolvedValue([
+      { name: "总纲.md", path: "/project/wiki/outlines/总纲.md", is_dir: false },
+    ])
+    vi.mocked(readFile).mockResolvedValue(`---\ntype: outline\n---\n`)
+    const tool = createListOutlinesTool("/project/wiki/outlines", {
+      readTextFile: readFile,
+      getDefaultChapterNumber: () => 167,
+    })
+    const result = await tool.execute({})
+    expect(result).toContain("本次目标章号:第 167 章")
+  })
+
+  it("list_outlines recursively lists files with type annotations", async () => {
+    vi.mocked(listDirectory).mockImplementation(async (path: string) => {
+      if (path.endsWith("/outlines")) {
+        return [
+          { name: "全局设定.md", path: `${path}/全局设定.md`, is_dir: false },
+          { name: "卷纲", path: `${path}/卷纲`, is_dir: true },
+        ]
+      }
+      if (path.endsWith("/卷纲")) {
+        return [
+          { name: "第三卷大纲.md", path: `${path}/第三卷大纲.md`, is_dir: false },
+        ]
+      }
+      return []
+    })
+    vi.mocked(readFile).mockImplementation(async (path: string) => {
+      if (path.includes("全局设定")) {
+        return `---\ntype: overview\ntitle: "全局设定"\n---\n`
+      }
+      if (path.includes("第三卷大纲")) {
+        return `---\ntype: outline\ntitle: "第三卷"\n---\n`
+      }
+      return ""
+    })
+
+    const tool = createListOutlinesTool("/project/wiki/outlines")
+    const result = await tool.execute({ chapterNumber: 167 })
+    expect(result).toContain("全局设定.md  type=overview")
+    expect(result).toContain("卷纲/第三卷大纲.md  type=outline")
+    expect(result).toContain("本次目标章号:第 167 章")
   })
   })
 
 
   it("list_memories returns file list from memory dir", async () => {
   it("list_memories returns file list from memory dir", async () => {

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

@@ -0,0 +1,74 @@
+import { describe, expect, it } from "vitest"
+import {
+  buildOutlineListToolResult,
+  extractOutlineTypeFields,
+  formatOutlineListLine,
+  type OutlineListEntry,
+} from "./outline-list-helpers"
+
+describe("outline-list-helpers", () => {
+  it("extracts type and outline_type from frontmatter", () => {
+    expect(
+      extractOutlineTypeFields(`---
+type: outline
+title: "第三卷大纲"
+---
+
+正文`),
+    ).toEqual({ type: "outline", outlineType: undefined })
+
+    expect(
+      extractOutlineTypeFields(`---
+type: concept
+outline_type: volume-outline
+title: "规则"
+---
+`),
+    ).toEqual({ type: "concept", outlineType: "volume-outline" })
+
+    expect(extractOutlineTypeFields("# 无 frontmatter")).toEqual({})
+  })
+
+  it("formats list lines with type fields", () => {
+    const entry: OutlineListEntry = {
+      relativePath: "卷纲/第三卷.md",
+      absolutePath: "/p/wiki/outlines/卷纲/第三卷.md",
+      type: "outline",
+      outlineType: "volume-outline",
+    }
+    expect(formatOutlineListLine(entry, 0)).toBe(
+      "1. 卷纲/第三卷.md  type=outline  outline_type=volume-outline",
+    )
+  })
+
+  it("builds tool result with mixed types and optional target chapter", () => {
+    const result = buildOutlineListToolResult(
+      [
+        {
+          relativePath: "第三卷大纲.md",
+          absolutePath: "/o/第三卷大纲.md",
+          type: "outline",
+        },
+        {
+          relativePath: "全局设定.md",
+          absolutePath: "/o/全局设定.md",
+          type: "overview",
+        },
+        {
+          relativePath: "钢铁洪流系统规则.md",
+          absolutePath: "/o/钢铁洪流系统规则.md",
+          type: "concept",
+        },
+      ],
+      167,
+    )
+
+    expect(result).toContain("1. 第三卷大纲.md  type=outline")
+    expect(result).toContain("2. 全局设定.md  type=overview")
+    expect(result).toContain("3. 钢铁洪流系统规则.md  type=concept")
+    expect(result).toContain("本次目标章号:第 167 章")
+    expect(result).toContain("overview:优先当索引读")
+    expect(result).toContain("concept:全书硬约束")
+    expect(result).toContain("不要跳过 overview/concept")
+  })
+})

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

@@ -0,0 +1,144 @@
+import { listDirectory, readFile } from "@/commands/fs"
+import { parseFrontmatter } from "@/lib/frontmatter"
+import { normalizePath } from "@/lib/path-utils"
+import type { FileNode } from "@/types/wiki"
+
+export interface OutlineListEntry {
+  /** 相对 outlines 根目录的路径,含 .md */
+  relativePath: string
+  absolutePath: string
+  type?: string
+  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)
+  return undefined
+}
+
+export function extractOutlineTypeFields(content: string): {
+  type?: string
+  outlineType?: string
+} {
+  const { frontmatter } = parseFrontmatter(content)
+  if (!frontmatter) return {}
+  return {
+    type: scalarFrontmatterString(frontmatter.type),
+    outlineType: scalarFrontmatterString(frontmatter.outline_type),
+  }
+}
+
+async function collectOutlineMarkdownFiles(
+  rootDir: string,
+  depth = 0,
+  maxDepth = 4,
+): Promise<Array<{ name: string; path: string }>> {
+  let entries: FileNode[] = []
+  try {
+    entries = await listDirectory(rootDir)
+  } catch {
+    return []
+  }
+
+  const files: Array<{ name: string; path: string }> = []
+  for (const entry of entries) {
+    if (!entry.is_dir) {
+      if (entry.name.toLowerCase().endsWith(".md")) {
+        files.push({ name: entry.name, path: entry.path })
+      }
+      continue
+    }
+    if (depth >= maxDepth) continue
+    files.push(...(await collectOutlineMarkdownFiles(entry.path, depth + 1, maxDepth)))
+  }
+  return files
+}
+
+function toRelativePath(outlinesDir: string, absolutePath: string): string {
+  const root = normalizePath(outlinesDir).replace(/\/$/, "")
+  const full = normalizePath(absolutePath)
+  if (full === root) return ""
+  if (full.startsWith(`${root}/`)) return full.slice(root.length + 1)
+  return full.split("/").pop() ?? full
+}
+
+export function formatOutlineListLine(entry: OutlineListEntry, index: number): string {
+  const parts = [`${index + 1}. ${entry.relativePath}`]
+  if (entry.type) parts.push(`type=${entry.type}`)
+  if (entry.outlineType) parts.push(`outline_type=${entry.outlineType}`)
+  return parts.join("  ")
+}
+
+const FRONTMATTER_READ_CHARS = 8192
+
+export async function listOutlineEntries(
+  outlinesDir: string,
+  readTextFile: (path: string) => Promise<string> = readFile,
+): Promise<OutlineListEntry[]> {
+  const files = await collectOutlineMarkdownFiles(outlinesDir)
+  const entries: OutlineListEntry[] = []
+
+  for (const file of files) {
+    const relativePath = toRelativePath(outlinesDir, file.path) || file.name
+    let type: string | undefined
+    let outlineType: string | undefined
+    try {
+      const content = await readTextFile(file.path)
+      // 只需 frontmatter;大卷纲全文可达数百 KB,避免 list 时整文件读入。
+      const fields = extractOutlineTypeFields(content.slice(0, FRONTMATTER_READ_CHARS))
+      type = fields.type
+      outlineType = fields.outlineType
+    } catch {
+      // 单个文件读失败仍列入清单
+    }
+    entries.push({
+      relativePath,
+      absolutePath: file.path,
+      type,
+      outlineType,
+    })
+  }
+
+  entries.sort((a, b) =>
+    a.relativePath.localeCompare(b.relativePath, "zh-Hans-CN", { numeric: true }),
+  )
+  return entries
+}
+
+export function buildOutlineListToolResult(
+  entries: OutlineListEntry[],
+  targetChapterNumber?: number,
+): string {
+  if (entries.length === 0) {
+    return "可用大纲列表:\n(空)"
+  }
+
+  const lines = [
+    "可用大纲列表:",
+    ...entries.map((entry, index) => formatOutlineListLine(entry, index)),
+    "",
+    "说明:请根据 frontmatter 的 type / outline_type 分流。",
+    "- overview:优先当索引读,用来发现规则与卷纲入口。",
+    "- concept:全书硬约束/机制,写正文前关注并读取相关项;不要用章号匹配。",
+    "- outline:读正文判断是否对目标章;同为 outline 也可能不是卷纲。",
+    "不要只凭文件名选择;不要跳过 overview/concept 只读一份卷纲。",
+  ]
+
+  if (typeof targetChapterNumber === "number" && targetChapterNumber > 0) {
+    lines.push(
+      `本次目标章号:第 ${targetChapterNumber} 章。请结合 overview/concept 约束,为该章找到正文内容对应的大纲。`,
+    )
+  }
+
+  return lines.join("\n")
+}
+
+/** 供测试与调用方:从文件名推导展示名(无扩展名) */
+export function outlineDisplayName(relativePath: string): string {
+  return stripMarkdownExt(relativePath.split("/").pop() ?? relativePath)
+}

+ 8 - 5
src/lib/agent/tools/trim-context.ts

@@ -2,19 +2,22 @@ import type { Tool } from "../types"
 import { contextPackToPrompt } from "@/lib/novel/context-engine"
 import { contextPackToPrompt } from "@/lib/novel/context-engine"
 import type { ContextPack } from "@/lib/novel/context-engine"
 import type { ContextPack } from "@/lib/novel/context-engine"
 
 
-export function createTrimContextTool(contextPack: ContextPack, targetChars: number): Tool {
+/**
+ * @param tokenBudget ContextPack token budget (not characters).
+ */
+export function createTrimContextTool(contextPack: ContextPack, tokenBudget: number): Tool {
   return {
   return {
     name: "trim_context",
     name: "trim_context",
     description:
     description:
-      "虚拟工具:将 ContextPack 按 targetChars 预算裁剪为最终提示字符串。M1 阶段包装现有头尾切,M4 升级为两级裁剪。由管道前置链自动执行,LLM 不直接调用。",
+      "虚拟工具:将 ContextPack 按 tokenBudget 预算裁剪为最终提示字符串。由管道前置链自动执行,LLM 不直接调用。",
     category: "virtual",
     category: "virtual",
     parameters: {},
     parameters: {},
     execute: async () => {
     execute: async () => {
-      if (targetChars <= 0) {
-        return "【上下文为空】targetChars 为 0,已跳过上下文加载"
+      if (tokenBudget <= 0) {
+        return "【上下文为空】tokenBudget 为 0,已跳过上下文加载"
       }
       }
       try {
       try {
-        return contextPackToPrompt(contextPack, targetChars)
+        return contextPackToPrompt(contextPack, tokenBudget)
       } catch (e) {
       } catch (e) {
         return `错误:裁剪上下文失败 - ${e instanceof Error ? e.message : String(e)}`
         return `错误:裁剪上下文失败 - ${e instanceof Error ? e.message : String(e)}`
       }
       }

+ 83 - 0
src/lib/context-budget.contract.spec.ts

@@ -0,0 +1,83 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+import {
+  computeContextBudget,
+  computeNovelContextTokenBudget,
+  computeWritingContextPackTokenBudget,
+  resolveContextPackTokenBudget,
+  WRITING_OUTPUT_RESERVE_MULTIPLIER,
+} from "./context-budget"
+
+describe("context pack budget contracts", () => {
+  it("auto mode is always finite and bounded by the window-derived cap", () => {
+    for (const maxContextSize of [64_000, 204_800, 1_000_000]) {
+      const general = resolveContextPackTokenBudget({
+        maxContextSize,
+        contextTokenBudget: 0,
+        langScale: 1,
+      })
+      const writing = computeWritingContextPackTokenBudget({
+        maxContextSize,
+        contextTokenBudget: 0,
+        chapterTargetChars: 3_000,
+        langScale: 1,
+      })
+      expect(Number.isFinite(general)).toBe(true)
+      expect(Number.isFinite(writing)).toBe(true)
+      expect(general).toBeGreaterThan(0)
+      expect(writing).toBeGreaterThan(0)
+      expect(general).toBeLessThanOrEqual(computeNovelContextTokenBudget(maxContextSize, 0, 1))
+      expect(writing).toBeLessThanOrEqual(general)
+    }
+  })
+
+  it("writing budget collapses to zero when the window cannot fit output reserve", () => {
+    const writing = computeWritingContextPackTokenBudget({
+      maxContextSize: 32_000,
+      chapterTargetChars: 3_000,
+      langScale: 1,
+    })
+    expect(writing).toBe(0)
+  })
+
+  it("writing pack leaves room for output-token reserve plus scaffold", () => {
+    for (const chapterTargetChars of [2_000, 3_000, 6_000]) {
+      for (const maxContextSize of [64_000, 204_800]) {
+        const { maxCtx } = computeContextBudget(maxContextSize, 1)
+        const packTokens = computeWritingContextPackTokenBudget({
+          maxContextSize,
+          chapterTargetChars,
+          langScale: 1,
+        })
+        const maxOutputTokens = chapterTargetChars === 3_000
+          ? 8_000
+          : Math.max(8_000, Math.ceil((chapterTargetChars + 500) * 2))
+        const targetReserveTokens = Math.ceil(
+          (chapterTargetChars * WRITING_OUTPUT_RESERVE_MULTIPLIER) / 1.7,
+        )
+        const outputReserveChars = Math.max(targetReserveTokens, maxOutputTokens) * 4
+        const scaffold = Math.max(8_000, Math.floor(maxCtx * 0.08))
+        expect(packTokens * 4 + outputReserveChars + scaffold).toBeLessThanOrEqual(maxCtx)
+      }
+    }
+  })
+
+  it("chat-panel fallback resolves budget instead of passing undefined", () => {
+    const source = readFileSync(resolve(__dirname, "../components/chat/chat-panel.tsx"), "utf8")
+    expect(source).toContain("resolveContextPackTokenBudget({")
+    expect(source).not.toContain("contextTokenBudget > 0 ? novelConfig.contextTokenBudget : undefined")
+  })
+
+  it("context-engine never uses Infinity for pack trimming", () => {
+    const source = readFileSync(resolve(__dirname, "./novel/context-engine.ts"), "utf8")
+    expect(source).not.toMatch(/tokenBudget \? tokenBudget \* 4 : Infinity/)
+    expect(source).toContain("resolveContextPackTokenBudget({ maxContextSize: options?.maxContextSize })")
+  })
+
+  it("deep chapter no longer hard-codes a 32000 context budget", () => {
+    const source = readFileSync(resolve(__dirname, "./novel/deep-chapter-generation.ts"), "utf8")
+    expect(source).toContain("computeWritingContextPackTokenBudget({")
+    expect(source).not.toContain("DEEP_CHAPTER_CONTEXT_TOKEN_BUDGET")
+  })
+})

+ 91 - 0
src/lib/context-budget.test.ts

@@ -2,7 +2,10 @@ import { describe, it, expect } from "vitest"
 import {
 import {
   computeContextBudget,
   computeContextBudget,
   computeNovelContextTokenBudget,
   computeNovelContextTokenBudget,
+  computeWritingContextPackTokenBudget,
   contextScaleForLanguage,
   contextScaleForLanguage,
+  resolveContextPackTokenBudget,
+  WRITING_OUTPUT_RESERVE_MULTIPLIER,
 } from "./context-budget"
 } from "./context-budget"
 
 
 // The base-math tests pin langScale=1 so they stay deterministic
 // The base-math tests pin langScale=1 so they stay deterministic
@@ -86,3 +89,91 @@ describe("computeNovelContextTokenBudget", () => {
     expect(computeNovelContextTokenBudget(204_800, 0, zh)).toBe(14_144)
     expect(computeNovelContextTokenBudget(204_800, 0, zh)).toBe(14_144)
   })
   })
 })
 })
+
+describe("resolveContextPackTokenBudget", () => {
+  it("always returns a positive finite budget for auto mode", () => {
+    const budget = resolveContextPackTokenBudget({ maxContextSize: 204_800, contextTokenBudget: 0, langScale: 1 })
+    expect(budget).toBe(33_280)
+    expect(Number.isFinite(budget)).toBe(true)
+  })
+
+  it("honors an explicit user budget within the window cap", () => {
+    expect(resolveContextPackTokenBudget({
+      maxContextSize: 204_800,
+      contextTokenBudget: 10_000,
+      langScale: 1,
+    })).toBe(10_000)
+  })
+})
+
+describe("computeWritingContextPackTokenBudget", () => {
+  it("reserves at least maxOutputTokens (as chars) before allocating the pack", () => {
+    const maxContextSize = 204_800
+    const chapterTargetChars = 3_000
+    const langScale = 1
+    const { maxCtx } = computeContextBudget(maxContextSize, langScale)
+    const budget = computeWritingContextPackTokenBudget({
+      maxContextSize,
+      contextTokenBudget: 0,
+      chapterTargetChars,
+      langScale,
+    })
+    const maxOutputTokens = 8_000
+    const targetReserveTokens = Math.ceil((chapterTargetChars * WRITING_OUTPUT_RESERVE_MULTIPLIER) / 1.7)
+    const outputReserveChars = Math.max(targetReserveTokens, maxOutputTokens) * 4
+    const scaffold = Math.max(8_000, Math.floor(maxCtx * 0.08))
+    expect(budget * 4 + outputReserveChars + scaffold).toBeLessThanOrEqual(maxCtx)
+    expect(outputReserveChars).toBeGreaterThanOrEqual(maxOutputTokens * 4)
+  })
+
+  it("shrinks when chapter target grows", () => {
+    const smallTarget = computeWritingContextPackTokenBudget({
+      maxContextSize: 204_800,
+      chapterTargetChars: 3_000,
+      langScale: 1,
+    })
+    const largeTarget = computeWritingContextPackTokenBudget({
+      maxContextSize: 204_800,
+      chapterTargetChars: 6_000,
+      langScale: 1,
+    })
+    expect(largeTarget).toBeLessThanOrEqual(smallTarget)
+  })
+
+  it("grows with a larger window within the general cap", () => {
+    const smallWindow = computeWritingContextPackTokenBudget({
+      maxContextSize: 64_000,
+      chapterTargetChars: 3_000,
+      langScale: 1,
+    })
+    const largeWindow = computeWritingContextPackTokenBudget({
+      maxContextSize: 204_800,
+      chapterTargetChars: 3_000,
+      langScale: 1,
+    })
+    expect(largeWindow).toBeGreaterThanOrEqual(smallWindow)
+  })
+
+  it("never exceeds the general window cap", () => {
+    const budget = computeWritingContextPackTokenBudget({
+      maxContextSize: 204_800,
+      chapterTargetChars: 3_000,
+      langScale: 1,
+    })
+    expect(budget).toBeLessThanOrEqual(computeNovelContextTokenBudget(204_800, 0, 1))
+  })
+
+  it("clamps an explicit user budget to the writing-derived auto budget", () => {
+    const auto = computeWritingContextPackTokenBudget({
+      maxContextSize: 204_800,
+      chapterTargetChars: 3_000,
+      langScale: 1,
+    })
+    expect(computeWritingContextPackTokenBudget({
+      maxContextSize: 204_800,
+      contextTokenBudget: 100_000,
+      chapterTargetChars: 3_000,
+      langScale: 1,
+    })).toBe(auto)
+  })
+})

+ 76 - 0
src/lib/context-budget.ts

@@ -175,6 +175,82 @@ export function computeNovelContextTokenBudget(
   return cap
   return cap
 }
 }
 
 
+export interface ResolveContextPackTokenBudgetInput {
+  maxContextSize?: number
+  /** User setting; 0 / undefined = auto from window. */
+  contextTokenBudget?: number
+  langScale?: number
+}
+
+/**
+ * Canonical resolver for chat / context-hub / trim-plugin ContextPack budgets.
+ * Always returns a positive finite token budget (never "unbounded").
+ */
+export function resolveContextPackTokenBudget(
+  input: ResolveContextPackTokenBudgetInput = {},
+): number {
+  return computeNovelContextTokenBudget(
+    input.maxContextSize,
+    input.contextTokenBudget,
+    input.langScale,
+  )
+}
+
+/** Output reserve multiplier: chapter target chars × this factor. */
+export const WRITING_OUTPUT_RESERVE_MULTIPLIER = 2
+/** Minimum scaffold reserve for writing prompts (instructions / outline shell). */
+const WRITING_SCAFFOLD_RESERVE_FLOOR = 8_000
+const WRITING_SCAFFOLD_RESERVE_FRAC = 0.08
+
+export interface ComputeWritingContextPackTokenBudgetInput {
+  maxContextSize?: number
+  contextTokenBudget?: number
+  chapterTargetChars?: number
+  langScale?: number
+}
+
+/**
+ * Deep-chapter ContextPack budget: window minus output reserve (target×2)
+ * and scaffold, then clamped by the general window cap / user budget.
+ */
+export function computeWritingContextPackTokenBudget(
+  input: ComputeWritingContextPackTokenBudgetInput,
+): number {
+  const langScale = input.langScale
+  const { maxCtx } = computeContextBudget(input.maxContextSize, langScale)
+  // Inline clamp mirrors resolveChapterLengthSpec without importing deep-chapter-prompts
+  // (avoids circular deps). Keep in sync with DEEP_CHAPTER 2000–6000 bounds.
+  const rawTarget = input.chapterTargetChars
+  const target = Number.isFinite(rawTarget) && (rawTarget as number) > 0
+    ? Math.max(2_000, Math.min(6_000, Math.round(rawTarget as number)))
+    : 3_000
+  // Same formula as resolveChapterLengthSpec.maxOutputTokens.
+  const maxOutputTokens = target === 3_000
+    ? 8_000
+    : Math.max(8_000, Math.ceil((target + 500) * 2))
+  // User redundancy: 2× target chars at CJK density (~1.7 chars/token), then take the
+  // larger of that vs the chapter maxOutputTokens so generation headroom is real.
+  const targetReserveTokens = Math.ceil(
+    (target * WRITING_OUTPUT_RESERVE_MULTIPLIER) / CHARS_PER_TOKEN_CJK,
+  )
+  const outputReserveTokens = Math.max(targetReserveTokens, maxOutputTokens)
+  const outputReserveChars = outputReserveTokens * CHARS_PER_TOKEN
+  const scaffoldReserveChars = Math.max(
+    WRITING_SCAFFOLD_RESERVE_FLOOR,
+    Math.floor(maxCtx * WRITING_SCAFFOLD_RESERVE_FRAC),
+  )
+  const availableChars = Math.max(0, maxCtx - outputReserveChars - scaffoldReserveChars)
+  // Do not inflate with NOVEL_CONTEXT_TOKEN_FLOOR: that would steal the output reserve
+  // on small windows. Prefer leaving room for chapter generation.
+  const derivedTokens = Math.max(0, Math.floor(availableChars / CHARS_PER_TOKEN))
+  const windowCap = computeNovelContextTokenBudget(input.maxContextSize, 0, langScale)
+  const autoTokens = Math.min(derivedTokens, windowCap)
+  if (input.contextTokenBudget && input.contextTokenBudget > 0) {
+    return Math.min(input.contextTokenBudget, autoTokens)
+  }
+  return autoTokens
+}
+
 /** Legacy single-pass outline ingest floor; kept so small windows still behave predictably. */
 /** Legacy single-pass outline ingest floor; kept so small windows still behave predictably. */
 export const OUTLINE_INGEST_MIN_BODY_BUDGET = 8_000
 export const OUTLINE_INGEST_MIN_BODY_BUDGET = 8_000
 /** Upper cap aligned with wiki long-source ingest. */
 /** Upper cap aligned with wiki long-source ingest. */

+ 3 - 5
src/lib/context-hub/ai-chat-integration.spec.ts

@@ -38,10 +38,8 @@ describe("AI chat context hub integration", () => {
     )
     )
   })
   })
 
 
-  it("passes project and conversation scope into global user memory", () => {
-    expect(source).toContain("userMemoryProjectKey: projectPath")
-    expect(source).toContain("userMemorySessionKey: capturedConvId")
-    expect(source).toContain("projectKey: projectPath")
-    expect(source).toContain("sessionKey: capturedConvId")
+  it("passes resolved context budget from the model window when novel budget is unlimited", () => {
+    expect(source).toContain("tokenBudget: novelConfig.contextTokenBudget,")
+    expect(source).toContain("maxContextSize: agentConfig.llmConfig.maxContextSize,")
   })
   })
 })
 })

+ 6 - 0
src/lib/context-hub/ai-outline-integration.spec.ts

@@ -33,4 +33,10 @@ describe("AI outline context hub integration", () => {
     expect(source).not.toContain("formatContextHubStatsForDetails")
     expect(source).not.toContain("formatContextHubStatsForDetails")
     expect(source).toContain("contextHub.saveSnapshot(`${messageId}:${runId}`, contextHubResult)")
     expect(source).toContain("contextHub.saveSnapshot(`${messageId}:${runId}`, contextHubResult)")
   })
   })
+
+  it("passes model window size so unlimited token budget scales safely", () => {
+    expect(source).toContain("tokenBudget: novelConfig.contextTokenBudget,")
+    expect(source).toContain("maxContextSize: effectiveLlmConfig.maxContextSize,")
+    expect(source).not.toContain("contextTokenBudget > 0")
+  })
 })
 })

+ 21 - 0
src/lib/context-hub/composer.spec.ts

@@ -1,4 +1,5 @@
 import { describe, expect, it } from "vitest"
 import { describe, expect, it } from "vitest"
+import { computeNovelContextTokenBudget } from "@/lib/context-budget"
 import type { ContextPack } from "@/lib/novel/context-engine"
 import type { ContextPack } from "@/lib/novel/context-engine"
 import { composeContext } from "./composer"
 import { composeContext } from "./composer"
 import { estimateContextTokens } from "./token-estimator"
 import { estimateContextTokens } from "./token-estimator"
@@ -141,4 +142,24 @@ describe("composeContext", () => {
     expect(result.stats.composedTokens).toBeLessThanOrEqual(800)
     expect(result.stats.composedTokens).toBeLessThanOrEqual(800)
     expect(result.stats.utilizationPercent).toBeLessThanOrEqual(100)
     expect(result.stats.utilizationPercent).toBeLessThanOrEqual(100)
   })
   })
+
+  it("无显式预算时按模型上下文窗口安全比例计算,而不是写死上限", () => {
+    const large = composeContext({
+      contextPack: pack(),
+      dependencies: {},
+      maxContextSize: 204_800,
+      tokenBudget: 0,
+    })
+    const small = composeContext({
+      contextPack: pack(),
+      dependencies: {},
+      maxContextSize: 32_000,
+      tokenBudget: 0,
+    })
+
+    expect(large.stats.budgetTokens).toBe(computeNovelContextTokenBudget(204_800, 0))
+    expect(small.stats.budgetTokens).toBe(computeNovelContextTokenBudget(32_000, 0))
+    expect(large.stats.budgetTokens).toBeGreaterThan(small.stats.budgetTokens)
+    expect(large.stats.budgetTokens).not.toBe(16_000)
+  })
 })
 })

+ 8 - 1
src/lib/context-hub/composer.ts

@@ -1,3 +1,4 @@
+import { resolveContextPackTokenBudget } from "@/lib/context-budget"
 import { contextPackToPrompt, type ContextPack } from "@/lib/novel/context-engine"
 import { contextPackToPrompt, type ContextPack } from "@/lib/novel/context-engine"
 import { estimateContextTokens } from "./token-estimator"
 import { estimateContextTokens } from "./token-estimator"
 import type { ContextHubStats } from "./types"
 import type { ContextHubStats } from "./types"
@@ -8,7 +9,10 @@ export interface ComposeContextInput {
   dependencies: Record<string, number>
   dependencies: Record<string, number>
   referenceContext?: string[]
   referenceContext?: string[]
   confidence?: number
   confidence?: number
+  /** Explicit token budget; 0 / undefined = window-derived safe cap. */
   tokenBudget?: number
   tokenBudget?: number
+  /** Model context window in characters (wiki-store `maxContextSize`). */
+  maxContextSize?: number
 }
 }
 export interface ComposedContext {
 export interface ComposedContext {
   stableCore: string
   stableCore: string
@@ -158,7 +162,10 @@ function fitFragmentsProportionally(fragments: ContextFragment[], tokenBudget: n
 
 
 export function composeContext(input: ComposeContextInput): ComposedContext {
 export function composeContext(input: ComposeContextInput): ComposedContext {
   const expanded = (input.confidence ?? 0.8) < 0.6
   const expanded = (input.confidence ?? 0.8) < 0.6
-  const tokenBudget = Math.max(0, input.tokenBudget ?? 16_000)
+  const tokenBudget = resolveContextPackTokenBudget({
+    maxContextSize: input.maxContextSize,
+    contextTokenBudget: input.tokenBudget,
+  })
   const stableBudget = Math.floor(tokenBudget * 0.4)
   const stableBudget = Math.floor(tokenBudget * 0.4)
   const summaryBudget = Math.floor(tokenBudget * 0.15)
   const summaryBudget = Math.floor(tokenBudget * 0.15)
   const stableCore = joinSections(fitFragmentsProportionally(stableFragments(input.contextPack), stableBudget))
   const stableCore = joinSections(fitFragmentsProportionally(stableFragments(input.contextPack), stableBudget))

+ 2 - 0
src/lib/context-hub/context-hub.ts

@@ -78,6 +78,7 @@ function prepareKey(request: ContextHubRequest): string {
     references: request.references ?? [],
     references: request.references ?? [],
     summary: request.existingSummary ?? null,
     summary: request.existingSummary ?? null,
     tokenBudget: request.tokenBudget ?? null,
     tokenBudget: request.tokenBudget ?? null,
+    maxContextSize: request.maxContextSize ?? null,
     forceRefresh: request.forceRefresh ?? false,
     forceRefresh: request.forceRefresh ?? false,
   })
   })
 }
 }
@@ -236,6 +237,7 @@ export class ContextHubController implements ContextHub {
       referenceContext: request.references,
       referenceContext: request.references,
       confidence: confidenceFor(request, contextPack),
       confidence: confidenceFor(request, contextPack),
       tokenBudget: request.tokenBudget,
       tokenBudget: request.tokenBudget,
+      maxContextSize: request.maxContextSize,
     })
     })
     const cacheStats = cacheAdapter.getStats()
     const cacheStats = cacheAdapter.getStats()
     const cacheItems = cacheAdapter.getTraceItems()
     const cacheItems = cacheAdapter.getTraceItems()

+ 3 - 0
src/lib/context-hub/types.ts

@@ -117,7 +117,10 @@ export interface ContextHubRequest {
   references?: string[]
   references?: string[]
   messages?: AgentMessage[]
   messages?: AgentMessage[]
   existingSummary?: SessionContextSummary
   existingSummary?: SessionContextSummary
+  /** Explicit token budget; 0 / undefined = window-derived safe cap. */
   tokenBudget?: number
   tokenBudget?: number
+  /** Model context window in characters (wiki-store `maxContextSize`). */
+  maxContextSize?: number
   forceRefresh?: boolean
   forceRefresh?: boolean
 }
 }
 
 

+ 45 - 0
src/lib/cursor-cli-provider.spec.ts

@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest"
+import { hasUsableLlm } from "@/lib/has-usable-llm"
+import { modelSupportsTools } from "@/lib/agent/config"
+import { getProviderConfig } from "@/lib/llm-providers"
+import type { LlmConfig } from "@/stores/wiki-store"
+import { toCursorProxyV1Endpoint } from "@/lib/cursor-cli-proxy"
+
+const base: LlmConfig = {
+  provider: "cursor-cli",
+  apiKey: "",
+  model: "composer-2-fast",
+  ollamaUrl: "http://localhost:11434",
+  customEndpoint: "http://127.0.0.1:8765/v1",
+  maxContextSize: 200000,
+  apiMode: "chat_completions",
+  reasoning: { mode: "auto" },
+}
+
+describe("cursor-cli provider", () => {
+  it("is usable when the preset is enabled without an API key", () => {
+    expect(hasUsableLlm(base, {})).toBe(false)
+    expect(hasUsableLlm(base, { "cursor-cli": { enabled: true } })).toBe(true)
+  })
+
+  it("supports agent tools via text tool-call parsing", () => {
+    expect(modelSupportsTools("composer-2-fast", "cursor-cli")).toBe(true)
+    expect(modelSupportsTools("gpt-4o", "openai")).toBe(true)
+  })
+
+  it("builds an OpenAI-compatible chat completions URL", () => {
+    const cfg = getProviderConfig(base)
+    expect(cfg.url).toBe("http://127.0.0.1:8765/v1/chat/completions")
+    expect(cfg.headers.Authorization).toBe("Bearer unused")
+  })
+
+  it("uses an optional bridge API key when provided", () => {
+    const cfg = getProviderConfig({ ...base, apiKey: "secret" })
+    expect(cfg.headers.Authorization).toBe("Bearer secret")
+  })
+
+  it("normalizes dynamic proxy bases to /v1", () => {
+    expect(toCursorProxyV1Endpoint("http://127.0.0.1:9123")).toBe("http://127.0.0.1:9123/v1")
+    expect(toCursorProxyV1Endpoint("http://127.0.0.1:9123/v1")).toBe("http://127.0.0.1:9123/v1")
+  })
+})

+ 86 - 0
src/lib/cursor-cli-proxy.ts

@@ -0,0 +1,86 @@
+/**
+ * Cursor CLI local provider helpers.
+ *
+ * Ensures cursor-api-proxy is reachable before HTTP chat / model-list calls.
+ * The listening port is chosen by Tauri (prefer 8765, else a free port) and
+ * returned so callers can point OpenAI-compatible requests at the live URL.
+ */
+
+import { invoke } from "@tauri-apps/api/core"
+import type { LlmConfig } from "@/stores/wiki-store"
+import { isTauri } from "@/lib/platform"
+import type { LocalCliDetectResult } from "./local-cli-config"
+
+export const DEFAULT_CURSOR_PROXY_BASE = "http://127.0.0.1:8765"
+export const DEFAULT_CURSOR_PROXY_V1 = "http://127.0.0.1:8765/v1"
+
+export interface CursorProxyStatus {
+  healthy: boolean
+  base_url: string
+  managed: boolean
+  error: string | null
+}
+
+export function toCursorProxyV1Endpoint(baseUrl: string): string {
+  const trimmed = baseUrl.replace(/\/+$/, "")
+  if (/\/v1$/i.test(trimmed)) return trimmed
+  return `${trimmed}/v1`
+}
+
+export async function detectCursorCli(): Promise<LocalCliDetectResult> {
+  if (!isTauri()) {
+    return {
+      installed: false,
+      version: null,
+      path: null,
+      error: "仅桌面端支持本地 CLI 检测",
+    }
+  }
+  return invoke<LocalCliDetectResult>("cursor_cli_detect")
+}
+
+export async function getCursorProxyStatus(): Promise<CursorProxyStatus> {
+  if (!isTauri()) {
+    return {
+      healthy: false,
+      base_url: DEFAULT_CURSOR_PROXY_BASE,
+      managed: false,
+      error: "仅桌面端可托管 cursor-api-proxy",
+    }
+  }
+  return invoke<CursorProxyStatus>("cursor_proxy_status")
+}
+
+/**
+ * Ensure proxy is up; returns the live OpenAI-compatible base (`…/v1`).
+ */
+export async function ensureCursorProxyRunning(
+  config: Pick<LlmConfig, "provider">,
+  options?: { forceRestart?: boolean },
+): Promise<string> {
+  if (config.provider !== "cursor-cli") {
+    return DEFAULT_CURSOR_PROXY_V1
+  }
+  if (!isTauri()) {
+    throw new Error("Cursor CLI 仅桌面端可用。请在 Tauri 应用中使用,或手动启动 cursor-api-proxy。")
+  }
+  const status = await invoke<CursorProxyStatus>("cursor_proxy_ensure", {
+    forceRestart: options?.forceRestart ?? false,
+  })
+  if (!status.healthy) {
+    throw new Error(status.error ?? `cursor-api-proxy 未就绪:${status.base_url}`)
+  }
+  return toCursorProxyV1Endpoint(status.base_url)
+}
+
+/** After Authentication required, kill managed proxy and respawn with zshrc credentials. */
+export async function restartCursorProxyWithAuth(
+  config: Pick<LlmConfig, "provider">,
+): Promise<string> {
+  return ensureCursorProxyRunning(config, { forceRestart: true })
+}
+
+/** Apply the live proxy `/v1` endpoint onto an LlmConfig for HTTP dispatch. */
+export function withCursorProxyEndpoint(config: LlmConfig, v1Endpoint: string): LlmConfig {
+  return { ...config, customEndpoint: v1Endpoint, apiMode: "chat_completions" }
+}

+ 26 - 0
src/lib/has-usable-llm.test.ts

@@ -71,6 +71,32 @@ describe("hasUsableLlm", () => {
     expect(hasUsableLlm(cfg, providers)).toBe(true)
     expect(hasUsableLlm(cfg, providers)).toBe(true)
   })
   })
 
 
+  it("accepts cursor-cli when the preset is enabled without an API key", () => {
+    const providers: ProviderConfigs = {
+      "cursor-cli": { enabled: true },
+    }
+    const cfg: LlmConfig = {
+      ...baseCfg,
+      provider: "cursor-cli",
+      apiKey: "",
+      model: "composer-2-fast",
+      customEndpoint: "http://127.0.0.1:8765/v1",
+    }
+    expect(hasUsableLlm(cfg, providers)).toBe(true)
+  })
+
+  it("rejects cursor-cli when the preset is not enabled", () => {
+    const providers: ProviderConfigs = {}
+    const cfg: LlmConfig = {
+      ...baseCfg,
+      provider: "cursor-cli",
+      apiKey: "",
+      model: "composer-2-fast",
+      customEndpoint: "http://127.0.0.1:8765/v1",
+    }
+    expect(hasUsableLlm(cfg, providers)).toBe(false)
+  })
+
   it("accepts ollama without apiKey when enabled", () => {
   it("accepts ollama without apiKey when enabled", () => {
     const providers: ProviderConfigs = {
     const providers: ProviderConfigs = {
       "ollama-local": { enabled: true, model: "qwen2.5" },
       "ollama-local": { enabled: true, model: "qwen2.5" },

+ 2 - 1
src/lib/has-usable-llm.ts

@@ -9,6 +9,7 @@ export type LlmProvider = LlmConfig["provider"]
 const PRESET_ID_BY_PROVIDER: Partial<Record<LlmProvider, string>> = {
 const PRESET_ID_BY_PROVIDER: Partial<Record<LlmProvider, string>> = {
   "claude-code": "claude-code-cli",
   "claude-code": "claude-code-cli",
   "codex-cli": "codex-cli",
   "codex-cli": "codex-cli",
+  "cursor-cli": "cursor-cli",
   "ollama": "ollama-local",
   "ollama": "ollama-local",
 }
 }
 
 
@@ -46,7 +47,7 @@ export function hasUsableLlm(
   const hasKey = cfg.apiKey.trim().length > 0
   const hasKey = cfg.apiKey.trim().length > 0
   const hasModel = cfg.model.trim().length > 0
   const hasModel = cfg.model.trim().length > 0
 
 
-  if (cfg.provider === "claude-code" || cfg.provider === "codex-cli") {
+  if (cfg.provider === "claude-code" || cfg.provider === "codex-cli" || cfg.provider === "cursor-cli") {
     const presetId = PRESET_ID_BY_PROVIDER[cfg.provider]!
     const presetId = PRESET_ID_BY_PROVIDER[cfg.provider]!
     return isPresetEnabled(providerConfigs, presetId)
     return isPresetEnabled(providerConfigs, presetId)
   }
   }

+ 5 - 3
src/lib/lint.ts

@@ -7,6 +7,7 @@ import { getFileName, getRelativePath, normalizePath } from "@/lib/path-utils"
 import { buildLanguageDirective } from "@/lib/output-language"
 import { buildLanguageDirective } from "@/lib/output-language"
 import { useWikiStore } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { buildContextPack, contextPackToPrompt } from "@/lib/novel/context-engine"
 import { buildContextPack, contextPackToPrompt } from "@/lib/novel/context-engine"
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "@/lib/novel/chapter-excerpts"
 import i18n from "@/i18n"
 import i18n from "@/i18n"
 
 
 export interface LintResult {
 export interface LintResult {
@@ -182,6 +183,7 @@ async function buildSemanticNovelPrompt(
   projectPath: string,
   projectPath: string,
   chapterContent: string,
   chapterContent: string,
   chapterNumber?: number,
   chapterNumber?: number,
+  maxContextSize?: number,
 ): Promise<string> {
 ): Promise<string> {
   const contextPack = await buildContextPack(
   const contextPack = await buildContextPack(
     projectPath,
     projectPath,
@@ -192,7 +194,7 @@ async function buildSemanticNovelPrompt(
   return [
   return [
     "你是一个小说连贯性检查编辑。请根据小说上下文包检查本章是否存在连贯性和执行偏差问题。",
     "你是一个小说连贯性检查编辑。请根据小说上下文包检查本章是否存在连贯性和执行偏差问题。",
     "",
     "",
-    contextPackToPrompt(contextPack),
+    contextPackToPrompt(contextPack, undefined, { maxContextSize }),
     "",
     "",
     "请重点检查:",
     "请重点检查:",
     "1. 本章必须完成:是否已完成,若未完成请指出缺失推进。",
     "1. 本章必须完成:是否已完成,若未完成请指出缺失推进。",
@@ -218,7 +220,7 @@ async function buildSemanticNovelPrompt(
     "- info: 建议优化",
     "- info: 建议优化",
     "",
     "",
     "章节正文:",
     "章节正文:",
-    chapterContent.slice(0, 8000),
+    chapterContent.slice(0, CHAPTER_BODY_EXCERPT_MAX_CHARS),
   ].join("\n")
   ].join("\n")
 }
 }
 
 
@@ -270,7 +272,7 @@ export async function runSemanticLint(
 
 
   const novelMode = useWikiStore.getState().novelMode
   const novelMode = useWikiStore.getState().novelMode
   const prompt = novelMode && options.chapterContent?.trim()
   const prompt = novelMode && options.chapterContent?.trim()
-    ? await buildSemanticNovelPrompt(pp, options.chapterContent, options.chapterNumber)
+    ? await buildSemanticNovelPrompt(pp, options.chapterContent, options.chapterNumber, llmConfig.maxContextSize)
     : buildSemanticWikiPrompt(summaries)
     : buildSemanticWikiPrompt(summaries)
 
 
   let raw = ""
   let raw = ""

+ 7 - 1
src/lib/llm-client.ts

@@ -4,6 +4,7 @@ import { getProviderConfig, type RequestOverrides } from "./llm-providers"
 import { getHttpFetch, isFetchNetworkError } from "./tauri-fetch"
 import { getHttpFetch, isFetchNetworkError } from "./tauri-fetch"
 import { countReasoningCharsInLine, extractReasoningTextFromLine } from "./reasoning-detector"
 import { countReasoningCharsInLine, extractReasoningTextFromLine } from "./reasoning-detector"
 import { resolveRuntimeLocalCliConfig } from "./local-cli-config"
 import { resolveRuntimeLocalCliConfig } from "./local-cli-config"
+import { ensureCursorProxyRunning, withCursorProxyEndpoint } from "./cursor-cli-proxy"
 import { trimChatMessagesToBudget } from "./chat-request-budget"
 import { trimChatMessagesToBudget } from "./chat-request-budget"
 import { mergeLlmUsageSnapshot, type LlmUsage } from "./llm-usage"
 import { mergeLlmUsageSnapshot, type LlmUsage } from "./llm-usage"
 import { applyGlobalUserMemoryToMessages } from "./user-memory/request-integration"
 import { applyGlobalUserMemoryToMessages } from "./user-memory/request-integration"
@@ -134,7 +135,7 @@ export async function streamChat(
    */
    */
   requestOverrides?: RequestOverrides,
   requestOverrides?: RequestOverrides,
 ): Promise<void> {
 ): Promise<void> {
-  const runtimeConfig = await resolveRuntimeLocalCliConfig(config)
+  let runtimeConfig = await resolveRuntimeLocalCliConfig(config)
   const preparedMessages = applyGlobalUserMemoryToMessages(messages, requestOverrides)
   const preparedMessages = applyGlobalUserMemoryToMessages(messages, requestOverrides)
   const configuredWindow = Number.isFinite(runtimeConfig.maxContextSize) && runtimeConfig.maxContextSize > 0
   const configuredWindow = Number.isFinite(runtimeConfig.maxContextSize) && runtimeConfig.maxContextSize > 0
     ? runtimeConfig.maxContextSize
     ? runtimeConfig.maxContextSize
@@ -161,6 +162,11 @@ export async function streamChat(
     return streamViaCodexCli(runtimeConfig, budgetedMessages, callbacks, signal, requestOverrides)
     return streamViaCodexCli(runtimeConfig, budgetedMessages, callbacks, signal, requestOverrides)
   }
   }
 
 
+  if (runtimeConfig.provider === "cursor-cli") {
+    const endpoint = await ensureCursorProxyRunning(runtimeConfig)
+    runtimeConfig = withCursorProxyEndpoint(runtimeConfig, endpoint)
+  }
+
   const providerConfig = getProviderConfig(runtimeConfig)
   const providerConfig = getProviderConfig(runtimeConfig)
 
 
   // Combined abort: (a) user cancel, (b) our long-horizon timeout.
   // Combined abort: (a) user cancel, (b) our long-horizon timeout.

+ 23 - 0
src/lib/llm-providers.ts

@@ -1007,6 +1007,29 @@ export function getProviderConfig(config: LlmConfig): ProviderConfig {
         `${provider} provider uses subprocess transport; getProviderConfig should not be called for it`,
         `${provider} provider uses subprocess transport; getProviderConfig should not be called for it`,
       )
       )
 
 
+    case "cursor-cli": {
+      // OpenAI-compatible HTTP via local cursor-api-proxy.
+      const endpoint = (customEndpoint || "http://127.0.0.1:8765/v1").replace(/\/+$/, "")
+      const base = normalizeEndpoint(endpoint, "chat_completions").normalized.replace(/\/+$/, "")
+      const url = /\/chat\/completions$/i.test(base)
+        ? base
+        : `${base}/chat/completions`
+      const key = apiKey.trim() || "unused"
+      return {
+        url,
+        headers: withCustomOriginHeader({
+          "Content-Type": JSON_CONTENT_TYPE,
+          Authorization: `Bearer ${key}`,
+        }, url),
+        buildBody: (messages, overrides) => ({
+          ...buildOpenAiCompatibleBody(config, messages, overrides),
+          model,
+        }),
+        parseStream: parseOpenAiLine,
+        parseUsage: parseOpenAiUsage,
+      }
+    }
+
     case "custom": {
     case "custom": {
       // Custom endpoints can speak either OpenAI's /chat/completions
       // Custom endpoints can speak either OpenAI's /chat/completions
       // wire or Anthropic's /v1/messages wire. The field `apiMode` on
       // wire or Anthropic's /v1/messages wire. The field `apiMode` on

+ 2 - 1
src/lib/novel/book-analysis/character-extraction-engine.ts

@@ -20,6 +20,7 @@ import { joinPath } from "@/lib/path-utils"
 import { streamChat, type ChatMessage } from "@/lib/llm-client"
 import { streamChat, type ChatMessage } from "@/lib/llm-client"
 import { analyzeSixDimensions, DEPTH_DESCRIPTIONS } from "./six-dimension-engine"
 import { analyzeSixDimensions, DEPTH_DESCRIPTIONS } from "./six-dimension-engine"
 import { stableCharacterId } from "./character-recognition-engine"
 import { stableCharacterId } from "./character-recognition-engine"
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "@/lib/novel/chapter-excerpts"
 
 
 export interface CharacterExtractionInput {
 export interface CharacterExtractionInput {
   bookPath: string
   bookPath: string
@@ -72,7 +73,7 @@ async function identifyCharactersInChapter(
 章节:${chapterTitle}
 章节:${chapterTitle}
 
 
 内容:
 内容:
-${chapterContent.substring(0, 8000)} ${chapterContent.length > 8000 ? "...(内容过长已截断)" : ""}
+${chapterContent.substring(0, CHAPTER_BODY_EXCERPT_MAX_CHARS)} ${chapterContent.length > CHAPTER_BODY_EXCERPT_MAX_CHARS ? "...(内容过长已截断)" : ""}
 
 
 请以JSON格式返回角色列表,格式如下:
 请以JSON格式返回角色列表,格式如下:
 {
 {

+ 3 - 2
src/lib/novel/book-analysis/story-framework-extraction.ts

@@ -5,6 +5,7 @@ import {
   extractPlotFrameworkLineageFromAnalysis,
   extractPlotFrameworkLineageFromAnalysis,
 } from "@/lib/novel/dismantling"
 } from "@/lib/novel/dismantling"
 import type { PlotFramework } from "@/lib/novel/plot-framework"
 import type { PlotFramework } from "@/lib/novel/plot-framework"
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "@/lib/novel/chapter-excerpts"
 
 
 export interface BookStoryFrameworkChapter {
 export interface BookStoryFrameworkChapter {
   id: string
   id: string
@@ -148,6 +149,6 @@ function readFrontmatterValue(frontmatter: string, key: string): string {
 
 
 function trimChapterContentForPrompt(content: string): string {
 function trimChapterContentForPrompt(content: string): string {
   const trimmed = content.trim()
   const trimmed = content.trim()
-  if (trimmed.length <= 8000) return trimmed
-  return `${trimmed.slice(0, 8000)}\n\n[本章内容过长,已截断用于故事框架提取]`
+  if (trimmed.length <= CHAPTER_BODY_EXCERPT_MAX_CHARS) return trimmed
+  return `${trimmed.slice(0, CHAPTER_BODY_EXCERPT_MAX_CHARS)}\n\n[本章内容过长,已截断用于故事框架提取]`
 }
 }

+ 2 - 1
src/lib/novel/book-analysis/style-analysis-adapter.ts

@@ -15,6 +15,7 @@ import {
 } from "./style-prompts"
 } from "./style-prompts"
 import { styleProfileToMarkdown } from "./style-extraction-engine"
 import { styleProfileToMarkdown } from "./style-extraction-engine"
 import type { BookStyleProfile } from "./types"
 import type { BookStyleProfile } from "./types"
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "@/lib/novel/chapter-excerpts"
 
 
 export interface StyleAnalysisChunkResult {
 export interface StyleAnalysisChunkResult {
   raw: string
   raw: string
@@ -128,7 +129,7 @@ export function createStyleAnalysisAdapter(
       const blocks: string[] = []
       const blocks: string[] = []
       for (const chapterId of chunk.chapterIds) {
       for (const chapterId of chunk.chapterIds) {
         const raw = await dependencies.readFile(joinPath(bookPath, "chapters", `${chapterId}.md`))
         const raw = await dependencies.readFile(joinPath(bookPath, "chapters", `${chapterId}.md`))
-        const body = stripFrontmatter(raw).slice(0, 8000)
+        const body = stripFrontmatter(raw).slice(0, CHAPTER_BODY_EXCERPT_MAX_CHARS)
         if (body) blocks.push(`【章节ID:${chapterId}】\n${body}`)
         if (body) blocks.push(`【章节ID:${chapterId}】\n${body}`)
       }
       }
       if (blocks.length !== chunk.chapterIds.length) throw new Error("所选文风章节正文为空,请检查后重试")
       if (blocks.length !== chunk.chapterIds.length) throw new Error("所选文风章节正文为空,请检查后重试")

+ 41 - 0
src/lib/novel/chapter-content-cleanup.spec.ts

@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest"
+
+import {
+  cleanGeneratedChapterContentForSave,
+  cleanGeneratedChapterContentWithTitle,
+} from "./chapter-content-cleanup"
+
+describe("cleanGeneratedChapterContentWithTitle", () => {
+  it("提取 Markdown 章节标题,但不把标题重复保存在正文中", () => {
+    expect(cleanGeneratedChapterContentWithTitle("# 第12章 夜雨归人\n\n雨落在旧宅门前。\n\n他推门而入。"))
+      .toEqual({
+        title: "第12章 夜雨归人",
+        content: "雨落在旧宅门前。\n\n他推门而入。",
+      })
+  })
+
+  it("提取纯文本章节标题,但不把标题重复保存在正文中", () => {
+    expect(cleanGeneratedChapterContentWithTitle("第13章 风雪来客\n\n风雪压住了脚步声。"))
+      .toEqual({
+        title: "第13章 风雪来客",
+        content: "风雪压住了脚步声。",
+      })
+  })
+
+  it("没有章节标题时保留正文首行", () => {
+    expect(cleanGeneratedChapterContentWithTitle("雨落在旧宅门前。\n\n他推门而入。"))
+      .toEqual({
+        title: null,
+        content: "雨落在旧宅门前。\n\n他推门而入。",
+      })
+  })
+})
+
+describe("cleanGeneratedChapterContentForSave", () => {
+  it.each([
+    "# 第12章 夜雨归人\n\n正文内容。",
+    "第12章 夜雨归人\n\n正文内容。",
+  ])("保存时移除章节标题:%s", (content) => {
+    expect(cleanGeneratedChapterContentForSave(content)).toBe("正文内容。")
+  })
+})

+ 4 - 65
src/lib/novel/chapter-content-cleanup.ts

@@ -106,57 +106,15 @@ export interface CleanedChapterContent {
 /**
 /**
  * 清理生成的章节内容,同时提取标题。
  * 清理生成的章节内容,同时提取标题。
  * 返回对象包含:
  * 返回对象包含:
- * - content: 清理后的正文(保留标题行)
+ * - content: 清理后的纯正文(移除已提取的标题行)
  * - title: 提取到的标题文字(如 "第3章 初入江湖"),如果没有则为 null
  * - title: 提取到的标题文字(如 "第3章 初入江湖"),如果没有则为 null
  */
  */
 export function cleanGeneratedChapterContentWithTitle(content: string): CleanedChapterContent {
 export function cleanGeneratedChapterContentWithTitle(content: string): CleanedChapterContent {
   const withoutThinking = stripThinkingBlocks(content).replace(/\r\n?/g, "\n")
   const withoutThinking = stripThinkingBlocks(content).replace(/\r\n?/g, "\n")
   const withoutCitations = stripCitationSyntax(withoutThinking)
   const withoutCitations = stripCitationSyntax(withoutThinking)
-
   const allLines = withoutCitations.split("\n")
   const allLines = withoutCitations.split("\n")
-
-  // 先提取标题(但不从正文中移除)
-  const { title } = extractLeadingTitle(allLines)
-
-  // 清理其他元信息(引用块、分隔线等)
-  // 注意:使用 stripLeadingMeta,但它会删除标题行。我们需要保留标题。
-  // 所以我们先手动清理非标题的元信息
-  let index = 0
-  const lines = allLines
-
-  // 跳过开头空行
-  while (index < lines.length && !lines[index].trim()) index += 1
-
-  // 如果第一行是标题,跳过它继续清理后面的内容
-  const firstLine = lines[index]?.trim() ?? ""
-  const hasTitleLine = /^#{1,6}\s*第\s*\d+\s*章/.test(firstLine) || /^第\s*\d+\s*章/.test(firstLine)
-  if (hasTitleLine) {
-    index += 1
-    // 跳过标题后的空行
-    while (index < lines.length && !lines[index].trim()) index += 1
-  }
-
-  // 清理引用块
-  while (/^>\s*/.test(lines[index]?.trim() ?? "")) {
-    index += 1
-  }
-
-  // 跳过分隔线前的空行
-  while (index < lines.length && !lines[index].trim()) index += 1
-
-  // 跳过分隔线
-  if (/^[-*_]{3,}$/.test(lines[index]?.trim() ?? "")) {
-    index += 1
-  }
-
-  // 构建清理后的内容:如果有标题,把标题加回去
-  const cleanedLinesAfterMeta = lines.slice(index)
-  const finalLines = hasTitleLine
-    ? [allLines.find((l) => l.trim()) ?? "", "", ...cleanedLinesAfterMeta]
-    : cleanedLinesAfterMeta
-
-  // 清理结尾助手提议
-  const cleanedLines = stripTrailingAssistantOffer(finalLines)
+  const { lines: linesWithoutTitle, title } = extractLeadingTitle(allLines)
+  const cleanedLines = stripTrailingAssistantOffer(stripLeadingMeta(linesWithoutTitle))
 
 
   const cleanedContent = cleanedLines
   const cleanedContent = cleanedLines
     .join("\n")
     .join("\n")
@@ -186,24 +144,5 @@ export function cleanGeneratedChapterContentWithTitle(content: string): CleanedC
  * 保持向后兼容:返回纯字符串(去掉标题行)。
  * 保持向后兼容:返回纯字符串(去掉标题行)。
  */
  */
 export function cleanGeneratedChapterContentForSave(content: string): string {
 export function cleanGeneratedChapterContentForSave(content: string): string {
-  const withoutThinking = stripThinkingBlocks(content).replace(/\r\n?/g, "\n")
-  const withoutCitations = stripCitationSyntax(withoutThinking)
-  const lines = stripTrailingAssistantOffer(stripLeadingMeta(withoutCitations.split("\n")))
-
-  const cleaned = lines
-    .join("\n")
-    .replace(/^\s*[-*_]{3,}\s*$/gm, "")
-    .replace(/\s+([,。!?;:、,.!?;:])/g, "$1")
-    .replace(/[ \t]+$/gm, "")
-    .replace(/\n{3,}/g, "\n\n")
-
-  return cleaned
-    .split("\n")
-    .filter((line, index, all) => {
-      if (line.trim()) return true
-      const hasBefore = all.slice(0, index).some((item) => item.trim())
-      const hasAfter = all.slice(index + 1).some((item) => item.trim())
-      return hasBefore && hasAfter
-    })
-    .join("\n")
+  return cleanGeneratedChapterContentWithTitle(content).content
 }
 }

+ 2 - 0
src/lib/novel/chapter-excerpts.ts

@@ -0,0 +1,2 @@
+/** Max characters of chapter body injected into a single review / lint / check prompt. */
+export const CHAPTER_BODY_EXCERPT_MAX_CHARS = 12_000

+ 2 - 1
src/lib/novel/chapter-ingest.ts

@@ -22,6 +22,7 @@ import { buildStructuredMemoryDocuments, isValidMemorySnapshot } from "./memory-
 import { clearGraphCache } from "@/lib/graph-relevance"
 import { clearGraphCache } from "@/lib/graph-relevance"
 import { RetrievalStore } from "./retrieval"
 import { RetrievalStore } from "./retrieval"
 import { computeOutlineIngestBodyBudget } from "@/lib/context-budget"
 import { computeOutlineIngestBodyBudget } from "@/lib/context-budget"
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "./chapter-excerpts"
 
 
 export interface ValidationWarning {
 export interface ValidationWarning {
   type: "entity_new" | "canon_conflict"
   type: "entity_new" | "canon_conflict"
@@ -737,7 +738,7 @@ ${langReminder}`
 章节编号:第${chapterNumber}章
 章节编号:第${chapterNumber}章
 
 
 章节正文:
 章节正文:
-${chapterBody.slice(0, 8000)}
+${chapterBody.slice(0, CHAPTER_BODY_EXCERPT_MAX_CHARS)}
 
 
 请输出以下格式的 JSON:
 请输出以下格式的 JSON:
 {
 {

+ 2 - 1
src/lib/novel/chapter-plan-compliance.ts

@@ -1,7 +1,8 @@
 import { streamChat } from "@/lib/llm-client"
 import { streamChat } from "@/lib/llm-client"
 import type { LlmConfig } from "@/stores/wiki-store"
 import type { LlmConfig } from "@/stores/wiki-store"
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "./chapter-excerpts"
 
 
-const FINAL_CONTENT_EXCERPT_MAX_CHARS = 12000
+const FINAL_CONTENT_EXCERPT_MAX_CHARS = CHAPTER_BODY_EXCERPT_MAX_CHARS
 const FINAL_CONTENT_EXCERPT_MARKER = "(正文中段已截断,保留开头与结尾用于检查承接和章末钩子。)"
 const FINAL_CONTENT_EXCERPT_MARKER = "(正文中段已截断,保留开头与结尾用于检查承接和章末钩子。)"
 
 
 export type ChapterPlanComplianceStatus =
 export type ChapterPlanComplianceStatus =

+ 11 - 3
src/lib/novel/context-engine.ts

@@ -1,3 +1,4 @@
+import { resolveContextPackTokenBudget } from "@/lib/context-budget"
 import { listDirectory, readFile } from "@/commands/fs"
 import { listDirectory, readFile } from "@/commands/fs"
 import i18n from "@/i18n"
 import i18n from "@/i18n"
 import { searchWiki, tokenizeQuery } from "@/lib/search"
 import { searchWiki, tokenizeQuery } from "@/lib/search"
@@ -1104,7 +1105,11 @@ const FIELD_CONFIGS: FieldConfig[] = [
   { titleKey: "novel.contextPack.graphSearchResults", fieldKey: "graphSearchResults" },
   { titleKey: "novel.contextPack.graphSearchResults", fieldKey: "graphSearchResults" },
 ]
 ]
 
 
-export function contextPackToPrompt(pack: ContextPack, tokenBudget?: number, options?: { excludeOutline?: boolean }): string {
+export function contextPackToPrompt(
+  pack: ContextPack,
+  tokenBudget?: number,
+  options?: { excludeOutline?: boolean; maxContextSize?: number },
+): string {
   const result = trimContextPack(pack, tokenBudget, options)
   const result = trimContextPack(pack, tokenBudget, options)
   return result.prompt
   return result.prompt
 }
 }
@@ -1140,7 +1145,7 @@ function trimFieldContent(content: string | string[], maxChars: number): string
 export function trimContextPack(
 export function trimContextPack(
   pack: ContextPack,
   pack: ContextPack,
   tokenBudget?: number,
   tokenBudget?: number,
-  options?: { excludeOutline?: boolean }
+  options?: { excludeOutline?: boolean; maxContextSize?: number }
 ): TrimResult {
 ): TrimResult {
   const sections: string[] = []
   const sections: string[] = []
 
 
@@ -1181,7 +1186,10 @@ export function trimContextPack(
   const originalChars = totalChars
   const originalChars = totalChars
   const trimmedFields: string[] = []
   const trimmedFields: string[] = []
 
 
-  const targetChars = tokenBudget ? tokenBudget * 4 : Infinity
+  const resolvedTokenBudget = tokenBudget && tokenBudget > 0
+    ? tokenBudget
+    : resolveContextPackTokenBudget({ maxContextSize: options?.maxContextSize })
+  const targetChars = resolvedTokenBudget * 4
 
 
   if (totalChars <= targetChars) {
   if (totalChars <= targetChars) {
     for (const { title, content } of fieldData) {
     for (const { title, content } of fieldData) {

+ 4 - 5
src/lib/novel/de-ai-batch/chapter-apply.spec.ts

@@ -41,7 +41,7 @@ describe("de-ai batch chapter apply", () => {
     const written = writeFileAtomic.mock.calls[0][1]
     const written = writeFileAtomic.mock.calls[0][1]
     expect(written).toContain("custom: keep-me")
     expect(written).toContain("custom: keep-me")
     expect(written).toContain("# 第一章 原标题")
     expect(written).toContain("# 第一章 原标题")
-    expect(written).toContain("批量去 AI 味候选正文")
+    expect(written).toContain("  批量去 AI 味候选正文")
     expect(written).not.toContain("status: malicious")
     expect(written).not.toContain("status: malicious")
     expect(written).not.toContain("# 不应替换原标题")
     expect(written).not.toContain("# 不应替换原标题")
   })
   })
@@ -75,7 +75,7 @@ describe("de-ai batch chapter apply", () => {
     expect(editorContent).toBe(diskContent)
     expect(editorContent).toBe(diskContent)
     expect(diskContent).toContain("custom: keep-me")
     expect(diskContent).toContain("custom: keep-me")
     expect(diskContent).toContain("# 第一章 原标题")
     expect(diskContent).toContain("# 第一章 原标题")
-    expect(diskContent).toContain("批量去 AI 味候选正文")
+    expect(diskContent).toContain("  批量去 AI 味候选正文")
     expect(diskContent).not.toContain("编辑器中的旧正文")
     expect(diskContent).not.toContain("编辑器中的旧正文")
     expect(dataVersion).toBe(1)
     expect(dataVersion).toBe(1)
   })
   })
@@ -139,7 +139,7 @@ describe("de-ai batch chapter apply", () => {
     await expect(applying).resolves.toBe(true)
     await expect(applying).resolves.toBe(true)
     oldAutoSaveA()
     oldAutoSaveA()
 
 
-    expect(diskA).toContain("批量去 AI 味候选正文")
+    expect(diskA).toContain("  批量去 AI 味候选正文")
     expect(diskB).toBe("B 编辑器正文")
     expect(diskB).toBe("B 编辑器正文")
     expect(commitEditor).not.toHaveBeenCalled()
     expect(commitEditor).not.toHaveBeenCalled()
     expect(bumpDataVersion).toHaveBeenCalledTimes(1)
     expect(bumpDataVersion).toHaveBeenCalledTimes(1)
@@ -184,7 +184,7 @@ describe("de-ai batch chapter apply", () => {
     releaseExternalWrite()
     releaseExternalWrite()
     await Promise.all([applying, flushing])
     await Promise.all([applying, flushing])
 
 
-    expect(diskA).toContain("批量去 AI 味候选正文")
+    expect(diskA).toContain("  批量去 AI 味候选正文")
     expect(flushWrite).not.toHaveBeenCalled()
     expect(flushWrite).not.toHaveBeenCalled()
     expect(editorB).toBe("B 编辑器正文")
     expect(editorB).toBe("B 编辑器正文")
     expect(commitEditor).not.toHaveBeenCalled()
     expect(commitEditor).not.toHaveBeenCalled()
@@ -201,4 +201,3 @@ describe("de-ai batch chapter apply", () => {
     expect(flushWrite).toHaveBeenCalledTimes(1)
     expect(flushWrite).toHaveBeenCalledTimes(1)
   })
   })
 })
 })
-

+ 9 - 3
src/lib/novel/de-ai-batch/chapter-apply.ts

@@ -1,8 +1,15 @@
 import { readFile, writeFileAtomic } from "@/commands/fs"
 import { readFile, writeFileAtomic } from "@/commands/fs"
+import { formatChapterWriting } from "@/lib/chapter-formatting"
 import { replaceWholeChapterBody } from "@/lib/chapter-selection"
 import { replaceWholeChapterBody } from "@/lib/chapter-selection"
 import { requestEditorExternalChapterBodyUpdate } from "@/lib/editor-external-update-session"
 import { requestEditorExternalChapterBodyUpdate } from "@/lib/editor-external-update-session"
+import { syncChapterFrontmatterFromBody } from "@/lib/novel/chapter-meta"
 import { normalizePath } from "@/lib/path-utils"
 import { normalizePath } from "@/lib/path-utils"
 
 
+function mergeAndFormatDeAiResult(currentMarkdown: string, candidateContent: string): string {
+  const merged = replaceWholeChapterBody(currentMarkdown, candidateContent)
+  return formatChapterWriting(syncChapterFrontmatterFromBody(merged))
+}
+
 export interface OpenChapterBodyUpdateInput {
 export interface OpenChapterBodyUpdateInput {
   path: string
   path: string
   candidateContent: string
   candidateContent: string
@@ -20,7 +27,7 @@ export async function applyOpenChapterBodyUpdate(input: OpenChapterBodyUpdateInp
   const targetPath = normalizePath(input.path)
   const targetPath = normalizePath(input.path)
   const initialOpenPath = input.currentOpenPath()
   const initialOpenPath = input.currentOpenPath()
   if (!initialOpenPath || normalizePath(initialOpenPath) !== targetPath) return false
   if (!initialOpenPath || normalizePath(initialOpenPath) !== targetPath) return false
-  const merged = replaceWholeChapterBody(input.currentMarkdown(), input.candidateContent)
+  const merged = mergeAndFormatDeAiResult(input.currentMarkdown(), input.candidateContent)
   input.invalidatePendingSave()
   input.invalidatePendingSave()
   const externalVersion = await input.runExternalUpdate(input.path, () =>
   const externalVersion = await input.runExternalUpdate(input.path, () =>
     input.writeFileAtomic(input.path, merged),
     input.writeFileAtomic(input.path, merged),
@@ -50,8 +57,7 @@ export function createDeAiBatchChapterApplier(
   return async (path, candidateContent) => {
   return async (path, candidateContent) => {
     if (await options.requestOpenUpdate(path, candidateContent)) return
     if (await options.requestOpenUpdate(path, candidateContent)) return
     const currentMarkdown = await options.readFile(path)
     const currentMarkdown = await options.readFile(path)
-    const merged = replaceWholeChapterBody(currentMarkdown, candidateContent)
+    const merged = mergeAndFormatDeAiResult(currentMarkdown, candidateContent)
     await options.writeFileAtomic(path, merged)
     await options.writeFileAtomic(path, merged)
   }
   }
 }
 }
-

+ 20 - 10
src/lib/novel/deep-chapter-generation.ts

@@ -13,7 +13,7 @@ import {
   isReasoningOnlyResponseError,
   isReasoningOnlyResponseError,
   withReasoningDisabled,
   withReasoningDisabled,
 } from "@/lib/reasoning-retry";
 } from "@/lib/reasoning-retry";
-import { computeNovelContextTokenBudget } from "@/lib/context-budget";
+import { computeWritingContextPackTokenBudget } from "@/lib/context-budget";
 import { USER_ABORT_MESSAGE, rethrowIfUserAbort, throwIfAborted } from "@/lib/user-abort";
 import { USER_ABORT_MESSAGE, rethrowIfUserAbort, throwIfAborted } from "@/lib/user-abort";
 import {
 import {
   buildContextPack,
   buildContextPack,
@@ -146,9 +146,6 @@ const defaultDeps: DeepChapterGenerationDeps = {
 const REPEAT_CHECK_MIN_CHARS = 600;
 const REPEAT_CHECK_MIN_CHARS = 600;
 const REPEAT_WINDOW_CHARS = 120;
 const REPEAT_WINDOW_CHARS = 120;
 const REPEAT_HIT_LIMIT = 3;
 const REPEAT_HIT_LIMIT = 3;
-/** Legacy deep-chapter context budget (tokens). Kept as the upper bound;
- *  computeNovelContextTokenBudget clamps it down for small context windows. */
-const DEEP_CHAPTER_CONTEXT_TOKEN_BUDGET = 32000;
 /** chars/token approximation used to convert the token budget to characters
 /** chars/token approximation used to convert the token budget to characters
  *  for the outline cap (mirrors context-budget.ts / contextPackToPrompt). */
  *  for the outline cap (mirrors context-budget.ts / contextPackToPrompt). */
 const DEEP_CHAPTER_CHARS_PER_TOKEN = 4;
 const DEEP_CHAPTER_CHARS_PER_TOKEN = 4;
@@ -656,12 +653,25 @@ export async function runDeepChapterGeneration(
   );
   );
   throwIfAborted(signal);
   throwIfAborted(signal);
 
 
-  // 大纲与其余上下文共用同一窗口预算(派生自 maxContextSize)。大纲优先,
-  // 但设有上限占比,避免其独占整个窗口;剩余额度再分给记忆/设定/检索上下文。
-  const totalContextTokenBudget = computeNovelContextTokenBudget(
-    input.llmConfig.maxContextSize,
-    DEEP_CHAPTER_CONTEXT_TOKEN_BUDGET,
-  );
+  // 任务书(workflowConfig)、初稿/返修(writingConfig)、去AI味(deAiConfig)复用同一份
+  // outlinePrompt + contextPrompt。这三个环节可能用不同模型、各有独立上下文窗口,
+  // 因此预算取三者窗口的最小值:确保任一环节都不必依赖 llm-client 末级无差别截断
+  // (末级截断按字符砍,会绕过 ContextPack 的字段优先级),并让各环节看到一致的上下文。
+  const sharedContextWindows = [
+    writingConfig.maxContextSize,
+    workflowConfig.maxContextSize,
+    deAiConfig.maxContextSize,
+  ].filter((size): size is number => typeof size === "number" && size > 0);
+  const sharedContextWindow = sharedContextWindows.length > 0
+    ? Math.min(...sharedContextWindows)
+    : input.llmConfig.maxContextSize;
+
+  // 大纲与其余上下文共用同一窗口预算:按单章目标字数×2预留输出,再分配资料包。
+  const totalContextTokenBudget = computeWritingContextPackTokenBudget({
+    maxContextSize: sharedContextWindow,
+    contextTokenBudget: novelConfig.contextTokenBudget,
+    chapterTargetChars: novelConfig.chapterTargetChars,
+  });
   const totalContextCharBudget =
   const totalContextCharBudget =
     totalContextTokenBudget * DEEP_CHAPTER_CHARS_PER_TOKEN;
     totalContextTokenBudget * DEEP_CHAPTER_CHARS_PER_TOKEN;
   const outlineCharCap = Math.floor(
   const outlineCharCap = Math.floor(

+ 10 - 3
src/lib/novel/dimension-review-adapter.ts

@@ -3,6 +3,7 @@ import type { ChatMessage } from "@/lib/llm-providers"
 import type { LlmConfig } from "@/stores/wiki-store"
 import type { LlmConfig } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { buildContextPack, contextPackToPrompt, type ContextPack } from "./context-engine"
 import { buildContextPack, contextPackToPrompt, type ContextPack } from "./context-engine"
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "./chapter-excerpts"
 import { resolveNovelModel } from "./model-resolver"
 import { resolveNovelModel } from "./model-resolver"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import type { NovelReviewResult } from "./review-adapter"
 import type { NovelReviewResult } from "./review-adapter"
@@ -101,8 +102,9 @@ export function buildDimensionReviewPrompt(
   pack: ContextPack,
   pack: ContextPack,
   chapterContent: string,
   chapterContent: string,
   dimension: SixReviewDimensionDefinition,
   dimension: SixReviewDimensionDefinition,
+  maxContextSize?: number,
 ): string {
 ): string {
-  return `${contextPackToPrompt(pack)}
+  return `${contextPackToPrompt(pack, undefined, { maxContextSize })}
 
 
 六维独立审查维度:${dimension.label}
 六维独立审查维度:${dimension.label}
 审查目标:${dimension.objective}
 审查目标:${dimension.objective}
@@ -136,7 +138,7 @@ ${dimension.checks.map((check) => `- ${check}`).join("\n")}
 }
 }
 
 
 章节正文:
 章节正文:
-${chapterContent.slice(0, 8000)}`
+${chapterContent.slice(0, CHAPTER_BODY_EXCERPT_MAX_CHARS)}`
 }
 }
 
 
 export async function reviewChapterDimension({
 export async function reviewChapterDimension({
@@ -153,7 +155,12 @@ export async function reviewChapterDimension({
   callbacks?: DimensionReviewCallbacks
   callbacks?: DimensionReviewCallbacks
 }): Promise<DimensionReviewResult> {
 }): Promise<DimensionReviewResult> {
   callbacks.onThinking?.(dimension.key, formatDimensionThinking(dimension, "正在读取上下文..."))
   callbacks.onThinking?.(dimension.key, formatDimensionThinking(dimension, "正在读取上下文..."))
-  const analysisPrompt = buildDimensionReviewPrompt(contextPack, chapterContent, dimension)
+  const analysisPrompt = buildDimensionReviewPrompt(
+    contextPack,
+    chapterContent,
+    dimension,
+    llmConfig.maxContextSize,
+  )
   const analysis = await runDimensionStage(
   const analysis = await runDimensionStage(
     llmConfig,
     llmConfig,
     dimension,
     dimension,

+ 9 - 4
src/lib/novel/lint.ts

@@ -4,6 +4,7 @@ import type { ChatMessage } from "@/lib/llm-providers"
 import { useWikiStore } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { getOutputLanguage, buildLanguageReminder } from "@/lib/output-language"
 import { getOutputLanguage, buildLanguageReminder } from "@/lib/output-language"
 import { contextPackToPrompt, buildContextPack, type ContextPack } from "./context-engine"
 import { contextPackToPrompt, buildContextPack, type ContextPack } from "./context-engine"
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "./chapter-excerpts"
 import { resolveNovelModel } from "./model-resolver"
 import { resolveNovelModel } from "./model-resolver"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 
 
@@ -36,8 +37,12 @@ const NOVEL_LINT_DIMENSIONS = [
   "是否缺少章节钩子",
   "是否缺少章节钩子",
 ]
 ]
 
 
-export function buildNovelLintPrompt(pack: ContextPack, chapterContent: string): string {
-  return `${contextPackToPrompt(pack)}
+export function buildNovelLintPrompt(
+  pack: ContextPack,
+  chapterContent: string,
+  maxContextSize?: number,
+): string {
+  return `${contextPackToPrompt(pack, undefined, { maxContextSize })}
 
 
 ${i18n.t("novel.lint.lintInstruction", { defaultValue: "请对以下章节进行连贯性检查,逐一核对以下维度:" })}
 ${i18n.t("novel.lint.lintInstruction", { defaultValue: "请对以下章节进行连贯性检查,逐一核对以下维度:" })}
 ${NOVEL_LINT_DIMENSIONS.map((key, i) => `${i + 1}. ${key}`).join("\n")}
 ${NOVEL_LINT_DIMENSIONS.map((key, i) => `${i + 1}. ${key}`).join("\n")}
@@ -45,7 +50,7 @@ ${NOVEL_LINT_DIMENSIONS.map((key, i) => `${i + 1}. ${key}`).join("\n")}
 ${i18n.t("novel.lint.lintOutputFormat", { defaultValue: "请严格按照 JSON 数组格式输出检查结果。每个问题包含以下字段:severity(error/warning/info)、type(问题类型)、message(问题描述)、evidence(正文证据)、relatedMemory(相关记忆引用)、suggestion(修改建议)。如果没有发现问题,输出空数组 []。不要输出任何其他内容。" })}
 ${i18n.t("novel.lint.lintOutputFormat", { defaultValue: "请严格按照 JSON 数组格式输出检查结果。每个问题包含以下字段:severity(error/warning/info)、type(问题类型)、message(问题描述)、evidence(正文证据)、relatedMemory(相关记忆引用)、suggestion(修改建议)。如果没有发现问题,输出空数组 []。不要输出任何其他内容。" })}
 
 
 ${i18n.t("novel.lint.chapterContent", { defaultValue: "章节正文:" })}
 ${i18n.t("novel.lint.chapterContent", { defaultValue: "章节正文:" })}
-${chapterContent.slice(0, 8000)}`
+${chapterContent.slice(0, CHAPTER_BODY_EXCERPT_MAX_CHARS)}`
 }
 }
 
 
 export async function runNovelLint(
 export async function runNovelLint(
@@ -77,7 +82,7 @@ export async function runNovelLint(
 请严格按照 JSON 数组格式输出检查结果,不要输出任何其他内容。
 请严格按照 JSON 数组格式输出检查结果,不要输出任何其他内容。
 ${langReminder}`
 ${langReminder}`
 
 
-  const userPrompt = buildNovelLintPrompt(contextPack, chapterContent)
+  const userPrompt = buildNovelLintPrompt(contextPack, chapterContent, llmConfig.maxContextSize)
 
 
   try {
   try {
     const messages: ChatMessage[] = [
     const messages: ChatMessage[] = [

+ 55 - 0
src/lib/novel/outline-find-protocol.spec.ts

@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest"
+import {
+  buildOutlineFindProtocol,
+  formatTargetChapterLine,
+  shouldIncludeOutlineFindProtocol,
+  stripOutlineFindProtocol,
+} from "./outline-find-protocol"
+
+describe("outline-find-protocol", () => {
+  it("includes explicit target chapter when provided", () => {
+    const text = buildOutlineFindProtocol(167)
+    expect(text).toContain("本次写作目标:第 167 章")
+    expect(text).toContain("list_outlines")
+    expect(text).toContain("overview(高优先级入口)")
+    expect(text).toContain("concept / 设定类(写作硬约束)")
+    expect(text).toContain("禁止在未查看 overview/concept")
+    expect(text).toContain("read_outline")
+    expect(text).toContain("禁止只凭文件名")
+  })
+
+  it("asks model to resolve chapter number when missing", () => {
+    const text = buildOutlineFindProtocol()
+    expect(text).toContain("必须先明确本次要写的目标章号")
+    expect(text).not.toContain("本次写作目标:第")
+  })
+
+  it("formats target chapter line", () => {
+    expect(formatTargetChapterLine(104)).toBe("本次写作目标:第 104 章。")
+  })
+
+  it("only enables protocol for chapter writing intents", () => {
+    expect(shouldIncludeOutlineFindProtocol("write_chapter")).toBe(true)
+    expect(shouldIncludeOutlineFindProtocol("polish_chapter")).toBe(true)
+    expect(shouldIncludeOutlineFindProtocol("generate_outline")).toBe(false)
+    expect(shouldIncludeOutlineFindProtocol("character_query")).toBe(false)
+    expect(shouldIncludeOutlineFindProtocol(undefined)).toBe(false)
+  })
+
+  it("strips outline find protocol block without removing following sections", () => {
+    const prompt = [
+      "base rules",
+      "",
+      buildOutlineFindProtocol(10),
+      "",
+      "## 其它规则",
+      "keep me",
+    ].join("\n")
+    const stripped = stripOutlineFindProtocol(prompt)
+    expect(stripped).toContain("base rules")
+    expect(stripped).toContain("## 其它规则")
+    expect(stripped).toContain("keep me")
+    expect(stripped).not.toContain("大纲定位协议")
+    expect(stripped).not.toContain("list_outlines")
+  })
+})

+ 52 - 0
src/lib/novel/outline-find-protocol.ts

@@ -0,0 +1,52 @@
+/**
+ * 写作前找大纲协议:先钉目标章号,再按 type 分流,最后读正文判断归属。
+ * 不依赖文件名规范,也不要求大纲正文写法统一。
+ */
+
+import type { NovelTaskIntent } from "./task-router"
+
+/** 需要「按目标章找纲」协议的章节写作意图(不含大纲生成) */
+export const OUTLINE_FIND_CHAPTER_INTENTS = new Set<NovelTaskIntent>([
+  "write_chapter",
+  "continue_chapter",
+  "rewrite_chapter",
+  "polish_chapter",
+])
+
+export function shouldIncludeOutlineFindProtocol(intent?: string | null): boolean {
+  return Boolean(intent && OUTLINE_FIND_CHAPTER_INTENTS.has(intent as NovelTaskIntent))
+}
+
+export function buildOutlineFindProtocol(targetChapterNumber?: number): string {
+  const targetLine =
+    typeof targetChapterNumber === "number" && targetChapterNumber > 0
+      ? `本次写作目标:第 ${targetChapterNumber} 章。后续找大纲必须以该章号为准。`
+      : "写作前必须先明确本次要写的目标章号 N(可从任务路由、list_chapters 的最新章+1,或用户明示获得)。"
+
+  return [
+    "## 大纲定位协议(写章节前必须遵守)",
+    "",
+    targetLine,
+    "1. 调用 list_outlines 查看全部大纲候选及其 type / outline_type;先扫一遍有哪些 overview / concept / outline,不要只盯卷纲文件名。",
+    "2. 按 type 分流处理(有 type 用 type;无 type 则读正文判断用途):",
+    "   - overview(高优先级入口):应优先 read_outline 读索引,按 related / 文档表发现必须遵守的规则文档与卷纲入口;不要跳过 overview 直接瞎点卷纲。overview 本身不是本章剧情大纲。",
+    "   - concept / 设定类(写作硬约束):列表中出现的 concept 默认视为全书机制/叙述禁则;写正文前应至少读与本次任务相关的 concept(有 overview 指引时按指引读;无指引时对列出的 concept 做必要性判断并读取关键项)。不要用章号去「匹配」concept,也不要把它们当成卷纲。",
+    "   - outline(及 outline_type 为 story/volume/chapter-outline 等):主候选;必须 read_outline 读正文,判断是否对应该章 / 当前阶段。",
+    "   - 未知 type:必须读正文判断是卷纲、章纲、设定还是清单。",
+    "3. 同为 outline 也可能不是卷纲(如资产明细、人物表);禁止只看 type 或文件名就选定。",
+    "4. 确认对应该章的大纲,并已知相关 overview/concept 约束后,再写作或调用 run_chapter_workflow。",
+    "禁止只凭文件名猜测分卷;禁止把 concept/overview 当成章节剧情大纲;禁止在未查看 overview/concept 的情况下只读一份卷纲就开写。",
+  ].join("\n")
+}
+
+export function formatTargetChapterLine(chapterNumber: number): string {
+  return `本次写作目标:第 ${chapterNumber} 章。`
+}
+
+/** 从已拼接的系统提示中移除找纲协议块,避免与 plugin 重复注入。 */
+export function stripOutlineFindProtocol(prompt: string): string {
+  return prompt
+    .replace(/\n*## 大纲定位协议(写章节前必须遵守)\n[\s\S]*?(?=\n## |\n*$)/, "")
+    .replace(/\n{3,}/g, "\n\n")
+    .trim()
+}

+ 31 - 1
src/lib/novel/previous-chapters-analysis.ts

@@ -1,6 +1,23 @@
 import type { LlmConfig } from "@/stores/wiki-store"
 import type { LlmConfig } from "@/stores/wiki-store"
 import { readFile } from "@/commands/fs"
 import { readFile } from "@/commands/fs"
 import { searchWiki } from "@/lib/search"
 import { searchWiki } from "@/lib/search"
+import { computeContextBudget } from "@/lib/context-budget"
+
+/** 前情正文占分析模型窗口的比例;其余留给分析指令与模型输出。 */
+const PREVIOUS_BODY_WINDOW_FRAC = 0.5
+/** 单章正文最低保留字符数,避免窗口很小时被裁到无信息量。 */
+const PREVIOUS_PER_CHAPTER_FLOOR = 800
+
+/** 章节正文超预算时保留首尾、省略中段,避免尾部(结尾/最新状态)被整段丢弃。 */
+function clampChapterBody(body: string, maxChars: number): string {
+  const normalized = body.trim()
+  if (normalized.length <= maxChars) return normalized
+  const marker = "\n\n[中间内容已省略,保留首尾]\n\n"
+  const available = Math.max(200, maxChars - marker.length)
+  const head = Math.ceil(available * 0.6)
+  const tail = available - head
+  return `${normalized.slice(0, head).trimEnd()}${marker}${tail > 0 ? normalized.slice(-tail).trimStart() : ""}`
+}
 
 
 export interface PreviousChapterAnalysis {
 export interface PreviousChapterAnalysis {
   chapterNumber: number
   chapterNumber: number
@@ -43,8 +60,21 @@ export async function analyzePreviousChapters(
 
 
   if (previousChapters.length === 0) return ""
   if (previousChapters.length === 0) return ""
 
 
+  // 按分析模型自身的上下文窗口分配前情正文预算,均分到各章后保留首尾。
+  // 不再无界拼接全文、依赖 llm-client 末级截断。
+  const { maxCtx } = computeContextBudget(llmConfig.maxContextSize)
+  const bodyBudget = Math.floor(maxCtx * PREVIOUS_BODY_WINDOW_FRAC)
+  const perChapterBudget = Math.max(
+    PREVIOUS_PER_CHAPTER_FLOOR,
+    Math.floor(bodyBudget / previousChapters.length),
+  )
+  const budgetedChapters = previousChapters.map((ch) => ({
+    number: ch.number,
+    content: clampChapterBody(ch.content, perChapterBudget),
+  }))
+
   // 构建分析prompt
   // 构建分析prompt
-  const analysisPrompt = buildPreviousChaptersAnalysisPrompt(previousChapters, currentChapterNumber)
+  const analysisPrompt = buildPreviousChaptersAnalysisPrompt(budgetedChapters, currentChapterNumber)
 
 
   // 调用LLM分析
   // 调用LLM分析
   const { streamChat } = await import("@/lib/llm-client")
   const { streamChat } = await import("@/lib/llm-client")

+ 3 - 1
src/lib/novel/prompt-templates.ts

@@ -1,3 +1,5 @@
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "./chapter-excerpts"
+
 export const PROMPTS = {
 export const PROMPTS = {
   chapterGeneration: (contextPack: string, chapterGoal: string) =>
   chapterGeneration: (contextPack: string, chapterGoal: string) =>
     [
     [
@@ -43,7 +45,7 @@ export const PROMPTS = {
       contextPack,
       contextPack,
       "",
       "",
       "请检查以下章节的连贯性:",
       "请检查以下章节的连贯性:",
-      chapterContent.slice(0, 8000),
+      chapterContent.slice(0, CHAPTER_BODY_EXCERPT_MAX_CHARS),
       "",
       "",
       "检查维度:",
       "检查维度:",
       "1. 人设一致性",
       "1. 人设一致性",

+ 1 - 1
src/lib/novel/review-adapter.spec.ts

@@ -344,7 +344,7 @@ describe("review-adapter staged review", () => {
       callbacks.onDone()
       callbacks.onDone()
     })
     })
 
 
-    await expect(reviewChapter("E:/Novel", "正".repeat(9000), 8, {
+    await expect(reviewChapter("E:/Novel", "正".repeat(13000), 8, {
       contextPack,
       contextPack,
       throwOnFailure: true,
       throwOnFailure: true,
     })).rejects.toThrow()
     })).rejects.toThrow()

+ 14 - 6
src/lib/novel/review-adapter.ts

@@ -4,6 +4,7 @@ import type { ChatMessage } from "@/lib/llm-providers"
 import { useWikiStore } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { getOutputLanguage, buildLanguageReminder } from "@/lib/output-language"
 import { getOutputLanguage, buildLanguageReminder } from "@/lib/output-language"
 import { contextPackToPrompt, buildContextPack, type ContextPack } from "./context-engine"
 import { contextPackToPrompt, buildContextPack, type ContextPack } from "./context-engine"
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "./chapter-excerpts"
 import { resolveNovelModel } from "./model-resolver"
 import { resolveNovelModel } from "./model-resolver"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { rethrowIfUserAbort } from "@/lib/user-abort"
 import { rethrowIfUserAbort } from "@/lib/user-abort"
@@ -88,12 +89,12 @@ const REVIEW_STAGES = [
   "阶段7:二次复核",
   "阶段7:二次复核",
 ]
 ]
 
 
-const REVIEW_CHUNK_SIZE = 8000
+const REVIEW_CHUNK_SIZE = CHAPTER_BODY_EXCERPT_MAX_CHARS
 const REVIEW_MAX_CHUNKS = 3
 const REVIEW_MAX_CHUNKS = 3
 
 
 /**
 /**
- * 把超长章节分段用于审查。章节 ≤ 8000 字时返回单段;
- * 超过时按 8000 字一段切分,最多 3 段(覆盖 24000 字),超出部分追加到最后一段。
+ * 把超长章节分段用于审查。章节 ≤ 12000 字时返回单段;
+ * 超过时按 12000 字一段切分,最多 3 段(覆盖 36000 字),超出部分追加到最后一段。
  */
  */
 function splitChapterForReview(content: string): string[] {
 function splitChapterForReview(content: string): string[] {
   if (content.length <= REVIEW_CHUNK_SIZE) return [content]
   if (content.length <= REVIEW_CHUNK_SIZE) return [content]
@@ -113,6 +114,7 @@ export function buildReviewPrompt(
   chapterContent: string,
   chapterContent: string,
   characterOnly = false,
   characterOnly = false,
   planBlueprint?: string,
   planBlueprint?: string,
+  maxContextSize?: number,
 ): string {
 ): string {
   const baseDimensions = characterOnly ? CHARACTER_REVIEW_DIMENSIONS : REVIEW_DIMENSIONS
   const baseDimensions = characterOnly ? CHARACTER_REVIEW_DIMENSIONS : REVIEW_DIMENSIONS
   // 当传入用户确认的计划时,追加计划偏离维度(characterOnly 模式下不追加,保持轻量)
   // 当传入用户确认的计划时,追加计划偏离维度(characterOnly 模式下不追加,保持轻量)
@@ -135,7 +137,7 @@ export function buildReviewPrompt(
         planBlueprint.trim(),
         planBlueprint.trim(),
       ].join("\n")
       ].join("\n")
     : ""
     : ""
-  return `${contextPackToPrompt(pack)}
+  return `${contextPackToPrompt(pack, undefined, { maxContextSize })}
 
 
 ${modeTitle}:
 ${modeTitle}:
 ${modeStages.map((stage) => `- ${stage}:必须使用高级 thinking,先分析证据,再给结论。`).join("\n")}
 ${modeStages.map((stage) => `- ${stage}:必须使用高级 thinking,先分析证据,再给结论。`).join("\n")}
@@ -180,7 +182,7 @@ ${blueprintSection}
 4. 输出要求:在审查 JSON 中,角色相关问题 type 使用 "character_consistency",relatedMemory 必须引用对应的光环/状态/认知/大纲原文。
 4. 输出要求:在审查 JSON 中,角色相关问题 type 使用 "character_consistency",relatedMemory 必须引用对应的光环/状态/认知/大纲原文。
 
 
 ${i18n.t("novel.reviewPrompt.chapterContent")}
 ${i18n.t("novel.reviewPrompt.chapterContent")}
-${chapterContent.slice(0, 8000)}
+${chapterContent.slice(0, CHAPTER_BODY_EXCERPT_MAX_CHARS)}
 
 
 ${i18n.t("novel.reviewPrompt.outputFormat")}
 ${i18n.t("novel.reviewPrompt.outputFormat")}
 [
 [
@@ -277,7 +279,13 @@ ${langReminder}`
       const chunkContent = chunks.length > 1
       const chunkContent = chunks.length > 1
         ? `【第${i + 1}段/共${chunks.length}段】\n${chunk}`
         ? `【第${i + 1}段/共${chunks.length}段】\n${chunk}`
         : chunk
         : chunk
-      const userPrompt = buildReviewPrompt(contextPack, chunkContent, options.characterOnly, options.planBlueprint)
+      const userPrompt = buildReviewPrompt(
+        contextPack,
+        chunkContent,
+        options.characterOnly,
+        options.planBlueprint,
+        llmConfig.maxContextSize,
+      )
       const stageTitle = chunks.length > 1
       const stageTitle = chunks.length > 1
         ? (options.characterOnly ? `角色一致性审查(第${i + 1}/${chunks.length}段)` : `深度审查(第${i + 1}/${chunks.length}段)`)
         ? (options.characterOnly ? `角色一致性审查(第${i + 1}/${chunks.length}段)` : `深度审查(第${i + 1}/${chunks.length}段)`)
         : (options.characterOnly ? "角色一致性审查" : "深度审查")
         : (options.characterOnly ? "角色一致性审查" : "深度审查")

+ 27 - 1
src/lib/novel/task-router.ts

@@ -413,6 +413,13 @@ function parseChineseChapterNumber(text: string): number {
 /**
 /**
  * 根据任务路由结果生成对 AI 的系统提示增强
  * 根据任务路由结果生成对 AI 的系统提示增强
  */
  */
+const CHAPTER_WRITING_INTENTS = new Set<NovelTaskIntent>([
+  "write_chapter",
+  "continue_chapter",
+  "rewrite_chapter",
+  "polish_chapter",
+])
+
 export function buildTaskDirective(route: TaskRouteResult): string {
 export function buildTaskDirective(route: TaskRouteResult): string {
   const directives: Record<NovelTaskIntent, string> = {
   const directives: Record<NovelTaskIntent, string> = {
     write_chapter: "用户要求生成新章节。请根据上下文包中的大纲、人物状态和伏笔状态,生成完整的章节正文。注意保持人设一致,结尾留有钩子。",
     write_chapter: "用户要求生成新章节。请根据上下文包中的大纲、人物状态和伏笔状态,生成完整的章节正文。注意保持人设一致,结尾留有钩子。",
@@ -437,7 +444,26 @@ export function buildTaskDirective(route: TaskRouteResult): string {
   const directive = directives[route.intent]
   const directive = directives[route.intent]
   if (!directive) return ""
   if (!directive) return ""
 
 
-  return `\n## 任务类型识别\n意图:${intentToLabel(route.intent)}(置信度 ${Math.round(route.confidence * 100)}%)\n指令:${directive}\n`
+  const lines = [
+    "",
+    "## 任务类型识别",
+    `意图:${intentToLabel(route.intent)}(置信度 ${Math.round(route.confidence * 100)}%)`,
+    `指令:${directive}`,
+  ]
+
+  if (
+    CHAPTER_WRITING_INTENTS.has(route.intent) &&
+    typeof route.chapterNumber === "number" &&
+    route.chapterNumber > 0
+  ) {
+    lines.push(`本次写作目标:第 ${route.chapterNumber} 章。`)
+    lines.push(
+      "写正文前先 list_outlines(可传 chapterNumber),再按 type 分流并用 read_outline 读正文,确认对应该章的大纲后再写。",
+    )
+  }
+
+  lines.push("")
+  return lines.join("\n")
 }
 }
 
 
 function intentToLabel(intent: NovelTaskIntent): string {
 function intentToLabel(intent: NovelTaskIntent): string {

+ 30 - 5
src/lib/settings-model-list.ts

@@ -1,5 +1,6 @@
 import { getProviderConfig, withCustomOriginHeader } from "@/lib/llm-providers"
 import { getProviderConfig, withCustomOriginHeader } from "@/lib/llm-providers"
 import { detectLocalCliConfig } from "@/lib/local-cli-config"
 import { detectLocalCliConfig } from "@/lib/local-cli-config"
+import { ensureCursorProxyRunning, restartCursorProxyWithAuth, withCursorProxyEndpoint } from "@/lib/cursor-cli-proxy"
 import { isDirectRerankEndpoint } from "@/lib/rerank-api"
 import { isDirectRerankEndpoint } from "@/lib/rerank-api"
 import { getHttpFetch } from "@/lib/tauri-fetch"
 import { getHttpFetch } from "@/lib/tauri-fetch"
 import type { EmbeddingConfig, LlmConfig, RerankConfig } from "@/stores/wiki-store"
 import type { EmbeddingConfig, LlmConfig, RerankConfig } from "@/stores/wiki-store"
@@ -182,12 +183,36 @@ export async function fetchLlmModelList(config: LlmConfig): Promise<LlmModelList
     return fetchLocalCliModel(config)
     return fetchLocalCliModel(config)
   }
   }
 
 
-  const { url, headers } = buildModelsUrl(config)
-  const result = await fetchModelList(url, headers, config.model)
-  if (config.provider === "google") {
-    return toModelListResult(result.models.map((model) => model.replace(/^models\//, "")))
+  let runtimeConfig = config
+  if (config.provider === "cursor-cli") {
+    const endpoint = await ensureCursorProxyRunning(config)
+    runtimeConfig = withCursorProxyEndpoint(config, endpoint)
+  }
+
+  const { url, headers } = buildModelsUrl(runtimeConfig)
+
+  const load = async () => {
+    const result = await fetchModelList(url, headers, runtimeConfig.model)
+    if (runtimeConfig.provider === "google") {
+      return toModelListResult(result.models.map((model) => model.replace(/^models\//, "")))
+    }
+    return result
+  }
+
+  try {
+    return await load()
+  } catch (error) {
+    if (runtimeConfig.provider !== "cursor-cli") throw error
+    const message = error instanceof Error ? error.message : String(error)
+    if (!/authentication required|CURSOR_API_KEY|CURSOR_AUTH_TOKEN/i.test(message)) {
+      throw error
+    }
+    const endpoint = await restartCursorProxyWithAuth(runtimeConfig)
+    runtimeConfig = withCursorProxyEndpoint(runtimeConfig, endpoint)
+    const retry = buildModelsUrl(runtimeConfig)
+    const result = await fetchModelList(retry.url, retry.headers, runtimeConfig.model)
+    return result
   }
   }
-  return result
 }
 }
 
 
 export async function fetchEmbeddingModelList(config: EmbeddingConfig): Promise<LlmModelListResult> {
 export async function fetchEmbeddingModelList(config: EmbeddingConfig): Promise<LlmModelListResult> {

+ 6 - 0
src/lib/settings-model-test.ts

@@ -33,6 +33,12 @@ function ensureModel(model: string, emptyMessage: string): string {
 export function normalizeModelTestError(error: Error): Error {
 export function normalizeModelTestError(error: Error): Error {
   const message = error.message
   const message = error.message
 
 
+  if (message === "Load failed" || /failed to fetch|networkerror|load failed/i.test(message)) {
+    return new Error(
+      "无法连接模型接口(Load failed)。若使用 Cursor CLI,请先在设置中重新检查 CLI 状态,确认 proxy 已拉起后再测。",
+    )
+  }
+
   if (/insufficient account balance/i.test(message)) {
   if (/insufficient account balance/i.test(message)) {
     return new Error("当前中转站账户余额不足,或该模型没有可用额度,请先充值或切换可用模型。")
     return new Error("当前中转站账户余额不足,或该模型没有可用额度,请先充值或切换可用模型。")
   }
   }

+ 107 - 0
src/lib/writing-wake-lock.spec.ts

@@ -0,0 +1,107 @@
+import { describe, expect, it, vi } from "vitest"
+import {
+  shouldKeepAwakeForWriting,
+  withWritingWakeLock,
+  type WritingWakeLockBindings,
+} from "./writing-wake-lock"
+
+function bindings(invoke: WritingWakeLockBindings["invoke"], tauri = true) {
+  return {
+    isTauri: () => tauri,
+    invoke,
+    warn: vi.fn(),
+  } satisfies WritingWakeLockBindings
+}
+
+describe("shouldKeepAwakeForWriting", () => {
+  it.each(["write_chapter", "continue_chapter", "rewrite_chapter", "polish_chapter"] as const)(
+    "enables the wake lock for %s",
+    (intent) => {
+      expect(shouldKeepAwakeForWriting({ novelMode: true, intent, planExecuteActive: false })).toBe(true)
+    },
+  )
+
+  it.each(["general_chat", "generate_outline", "review_chapter", "lint_chapter"] as const)(
+    "does not enable the wake lock for %s",
+    (intent) => {
+      expect(shouldKeepAwakeForWriting({ novelMode: true, intent, planExecuteActive: false })).toBe(false)
+    },
+  )
+
+  it("does not enable the wake lock while waiting for plan execution", () => {
+    expect(shouldKeepAwakeForWriting({
+      novelMode: true,
+      intent: "write_chapter",
+      planExecuteActive: true,
+    })).toBe(false)
+  })
+})
+
+describe("withWritingWakeLock", () => {
+  it("acquires before the operation and releases after it completes", async () => {
+    const events: string[] = []
+    const invoke = vi.fn(async <T>(command: string) => {
+      events.push(command)
+      return (command === "acquire_writing_wake_lock" ? "token-1" : undefined) as T
+    })
+
+    const result = await withWritingWakeLock(true, async () => {
+      events.push("operation")
+      return "正文"
+    }, bindings(invoke))
+
+    expect(result).toBe("正文")
+    expect(events).toEqual([
+      "acquire_writing_wake_lock",
+      "operation",
+      "release_writing_wake_lock",
+    ])
+    expect(invoke).toHaveBeenLastCalledWith("release_writing_wake_lock", { token: "token-1" })
+  })
+
+  it("releases after an aborted or failed operation and preserves the original error", async () => {
+    const abortError = new DOMException("cancelled", "AbortError")
+    const invoke = vi.fn(async <T>(command: string) => {
+      if (command === "release_writing_wake_lock") throw new Error("release failed")
+      return "token-abort" as T
+    })
+    const testBindings = bindings(invoke)
+
+    await expect(withWritingWakeLock(true, async () => {
+      throw abortError
+    }, testBindings)).rejects.toBe(abortError)
+
+    expect(invoke).toHaveBeenLastCalledWith("release_writing_wake_lock", { token: "token-abort" })
+    expect(testBindings.warn).toHaveBeenCalledTimes(1)
+  })
+
+  it("continues generation when acquisition fails", async () => {
+    const invoke = vi.fn(async <T>() => {
+      throw new Error("unsupported")
+    })
+    const testBindings = bindings(invoke)
+
+    await expect(withWritingWakeLock(true, async () => "正文", testBindings)).resolves.toBe("正文")
+    expect(invoke).toHaveBeenCalledTimes(1)
+    expect(testBindings.warn).toHaveBeenCalledTimes(1)
+  })
+
+  it("does not let a release failure mask the operation result", async () => {
+    const invoke = vi.fn(async <T>(command: string) => {
+      if (command === "release_writing_wake_lock") throw new Error("release failed")
+      return "token-release" as T
+    })
+    const testBindings = bindings(invoke)
+
+    await expect(withWritingWakeLock(true, async () => "正文", testBindings)).resolves.toBe("正文")
+    expect(testBindings.warn).toHaveBeenCalledTimes(1)
+  })
+
+  it("is a no-op outside Tauri or when disabled", async () => {
+    const invoke = vi.fn()
+
+    await expect(withWritingWakeLock(true, async () => "browser", bindings(invoke, false))).resolves.toBe("browser")
+    await expect(withWritingWakeLock(false, async () => "disabled", bindings(invoke))).resolves.toBe("disabled")
+    expect(invoke).not.toHaveBeenCalled()
+  })
+})

+ 66 - 0
src/lib/writing-wake-lock.ts

@@ -0,0 +1,66 @@
+import { isTauri } from "@/lib/platform"
+import type { NovelTaskIntent } from "@/lib/novel/task-router"
+
+type TauriInvoke = <T>(command: string, args?: Record<string, unknown>) => Promise<T>
+
+export interface WritingWakeLockBindings {
+  isTauri: () => boolean
+  invoke: TauriInvoke
+  warn: (message: string, error: unknown) => void
+}
+
+const WRITING_WAKE_LOCK_INTENTS = new Set<NovelTaskIntent>([
+  "write_chapter",
+  "continue_chapter",
+  "rewrite_chapter",
+  "polish_chapter",
+])
+
+const defaultBindings: WritingWakeLockBindings = {
+  isTauri,
+  invoke: async <T>(command: string, args?: Record<string, unknown>) => {
+    const { invoke } = await import("@tauri-apps/api/core")
+    return invoke<T>(command, args)
+  },
+  warn: (message, error) => console.warn(message, error),
+}
+
+export function shouldKeepAwakeForWriting(options: {
+  novelMode: boolean
+  intent?: NovelTaskIntent | null
+  planExecuteActive: boolean
+}): boolean {
+  return options.novelMode
+    && !options.planExecuteActive
+    && !!options.intent
+    && WRITING_WAKE_LOCK_INTENTS.has(options.intent)
+}
+
+export async function withWritingWakeLock<T>(
+  enabled: boolean,
+  operation: () => Promise<T>,
+  bindings: WritingWakeLockBindings = defaultBindings,
+): Promise<T> {
+  if (!enabled || !bindings.isTauri()) {
+    return operation()
+  }
+
+  let token: string | null = null
+  try {
+    token = await bindings.invoke<string>("acquire_writing_wake_lock")
+  } catch (error) {
+    bindings.warn("[writing-wake-lock] 启用失败,继续执行正文生成", error)
+  }
+
+  try {
+    return await operation()
+  } finally {
+    if (token) {
+      try {
+        await bindings.invoke<void>("release_writing_wake_lock", { token })
+      } catch (error) {
+        bindings.warn("[writing-wake-lock] 释放失败,将在应用退出时清理", error)
+      }
+    }
+  }
+}

+ 1 - 1
src/stores/wiki-store.ts

@@ -133,7 +133,7 @@ export interface ReasoningConfig {
 }
 }
 
 
 interface LlmConfig {
 interface LlmConfig {
-  provider: "openai" | "anthropic" | "google" | "azure" | "ollama" | "custom" | "minimax" | "claude-code" | "codex-cli"
+  provider: "openai" | "anthropic" | "google" | "azure" | "ollama" | "custom" | "minimax" | "claude-code" | "codex-cli" | "cursor-cli"
   apiKey: string
   apiKey: string
   model: string
   model: string
   ollamaUrl: string
   ollamaUrl: string