Explorar o código

feat(writing): 正文生成期间自动防止系统休眠

正文生成、续写、改写和润色期间保持亮屏与系统唤醒。

通过并发令牌和专用线程确保任务完成、失败或取消后安全释放。
darknessomi hai 1 mes
pai
achega
7104a982a8

+ 166 - 17
src-tauri/Cargo.lock

@@ -683,7 +683,7 @@ version = "3.9.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c"
 dependencies = [
- "darling",
+ "darling 0.23.0",
  "ident_case",
  "prettyplease",
  "proc-macro2",
@@ -1312,14 +1312,38 @@ version = "0.0.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 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]]
 name = "darling"
 version = "0.23.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
 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]]
@@ -1335,13 +1359,24 @@ dependencies = [
  "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]]
 name = "darling_macro"
 version = "0.23.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
 dependencies = [
- "darling_core",
+ "darling_core 0.23.0",
  "quote",
  "syn 2.0.117",
 ]
@@ -2034,6 +2069,37 @@ dependencies = [
  "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]]
 name = "derive_more"
 version = "2.1.1"
@@ -3680,6 +3746,21 @@ dependencies = [
  "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]]
 name = "keyboard-types"
 version = "0.7.0"
@@ -4966,7 +5047,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
 dependencies = [
  "bitflags 2.11.1",
+ "block2",
  "dispatch2",
+ "libc",
  "objc2",
 ]
 
@@ -5043,6 +5126,20 @@ dependencies = [
  "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]]
 name = "objc2-io-surface"
 version = "0.3.2"
@@ -5740,6 +5837,7 @@ dependencies = [
  "encoding_rs",
  "futures",
  "image",
+ "keepawake",
  "lancedb",
  "md-5",
  "notify",
@@ -6623,7 +6721,7 @@ version = "3.20.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac"
 dependencies = [
- "darling",
+ "darling 0.23.0",
  "proc-macro2",
  "quote",
  "syn 2.0.117",
@@ -7226,7 +7324,7 @@ dependencies = [
  "tao-macros",
  "unicode-segmentation",
  "url",
- "windows",
+ "windows 0.61.3",
  "windows-core 0.61.2",
  "windows-version",
  "x11-dl",
@@ -7315,7 +7413,7 @@ dependencies = [
  "webkit2gtk",
  "webview2-com",
  "window-vibrancy",
- "windows",
+ "windows 0.61.3",
 ]
 
 [[package]]
@@ -7480,7 +7578,7 @@ dependencies = [
  "tauri-plugin",
  "thiserror 2.0.18",
  "url",
- "windows",
+ "windows 0.61.3",
  "zbus",
 ]
 
@@ -7570,7 +7668,7 @@ dependencies = [
  "url",
  "webkit2gtk",
  "webview2-com",
- "windows",
+ "windows 0.61.3",
 ]
 
 [[package]]
@@ -7595,7 +7693,7 @@ dependencies = [
  "url",
  "webkit2gtk",
  "webview2-com",
- "windows",
+ "windows 0.61.3",
  "wry",
 ]
 
@@ -8615,7 +8713,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
 dependencies = [
  "webview2-com-macros",
  "webview2-com-sys",
- "windows",
+ "windows 0.61.3",
  "windows-core 0.61.2",
  "windows-implement",
  "windows-interface",
@@ -8639,7 +8737,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
 dependencies = [
  "thiserror 2.0.18",
- "windows",
+ "windows 0.61.3",
  "windows-core 0.61.2",
 ]
 
@@ -8713,11 +8811,23 @@ version = "0.61.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
 dependencies = [
- "windows-collections",
+ "windows-collections 0.2.0",
  "windows-core 0.61.2",
- "windows-future",
+ "windows-future 0.2.1",
  "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]]
@@ -8729,6 +8839,15 @@ dependencies = [
  "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]]
 name = "windows-core"
 version = "0.61.2"
@@ -8763,7 +8882,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
 dependencies = [
  "windows-core 0.61.2",
  "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]]
@@ -8810,6 +8940,16 @@ dependencies = [
  "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]]
 name = "windows-registry"
 version = "0.6.1"
@@ -8959,6 +9099,15 @@ dependencies = [
  "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]]
 name = "windows-version"
 version = "0.1.7"
@@ -9284,7 +9433,7 @@ dependencies = [
  "webkit2gtk",
  "webkit2gtk-sys",
  "webview2-com",
- "windows",
+ "windows 0.61.3",
  "windows-core 0.61.2",
  "windows-version",
  "x11-dl",

+ 1 - 0
src-tauri/Cargo.toml

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

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

@@ -10,3 +10,4 @@ pub mod local_cli_config;
 pub mod mcp_stdio;
 pub mod project;
 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);
+    }
+}

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

@@ -43,6 +43,7 @@ pub fn run() {
             app.manage(commands::cursor_cli::CursorProxyState::default());
             app.manage(commands::file_sync::FileSyncState::default());
             app.manage(commands::mcp_stdio::McpStdioState::default());
+            app.manage(commands::writing_wake_lock::WritingWakeLockManager::default());
             Ok(())
         })
         .invoke_handler(tauri::generate_handler![
@@ -106,6 +107,8 @@ pub fn run() {
             commands::backup::export_backup,
             commands::backup::import_backup,
             commands::backup::read_backup_manifest,
+            commands::writing_wake_lock::acquire_writing_wake_lock,
+            commands::writing_wake_lock::release_writing_wake_lock,
             set_proxy_env,
         ])
         .on_window_event(|window, event| {

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

@@ -56,6 +56,7 @@ fn main() {
             app.manage(commands::cursor_cli::CursorProxyState::default());
             app.manage(commands::file_sync::FileSyncState::default());
             app.manage(commands::mcp_stdio::McpStdioState::default());
+            app.manage(commands::writing_wake_lock::WritingWakeLockManager::default());
             Ok(())
         })
         .invoke_handler(tauri::generate_handler![
@@ -118,6 +119,8 @@ fn main() {
             commands::backup::export_backup,
             commands::backup::import_backup,
             commands::backup::read_backup_manifest,
+            commands::writing_wake_lock::acquire_writing_wake_lock,
+            commands::writing_wake_lock::release_writing_wake_lock,
             set_proxy_env,
             log_error,
         ])

+ 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", () => {
-    const runnerIndex = source.indexOf("const record = await runAiChatSession({")
+    const runnerIndex = source.indexOf("const record = await withWritingWakeLock(")
     const afterRunner = source.slice(runnerIndex)
     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", () => {
-    const runnerIndex = source.indexOf("const record = await runAiChatSession({")
+    const runnerIndex = source.indexOf("const record = await withWritingWakeLock(")
     const finishIndex = source.indexOf("finishAgentSession(() => {", runnerIndex)
     const planConfirmIndex = source.indexOf("await requestChapterPlanConfirm(", runnerIndex)
     const followupIndex = source.indexOf("await handleSendRef.current(followupText", runnerIndex)

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

@@ -39,6 +39,7 @@ import {
 } from "@/lib/reference/providers"
 import type { ReferenceToken } from "@/lib/reference/types"
 import { runAiChatSession } from "@/lib/agent/ai-chat-session"
+import { shouldKeepAwakeForWriting, withWritingWakeLock } from "@/lib/writing-wake-lock"
 import { ToolRegistry } from "@/lib/agent/registry"
 import { registerAllBuiltInTools } from "@/lib/agent/tools"
 import {
@@ -1720,7 +1721,12 @@ export function ChatPanel() {
       }
 
       try {
-        const record = await runAiChatSession({
+        const keepAwake = shouldKeepAwakeForWriting({
+          novelMode,
+          intent: effectiveTaskRoute?.intent,
+          planExecuteActive,
+        })
+        const record = await withWritingWakeLock(keepAwake, () => runAiChatSession({
           userMessage: plainText,
           projectPath,
           agentConfig: {
@@ -1779,7 +1785,7 @@ export function ChatPanel() {
               markError(error)
             },
           },
-        })
+        }))
 
         if (controller.signal.aborted) return
         if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return

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