瀏覽代碼

Merge pull request #51 from darknessomi/fix/app-state-and-outline-skill-routing

fix: 防止 app-state 截断覆盖,并修复大纲生成与 Skill 路由断链
Mochocyang 4 周之前
父節點
當前提交
5e1454d092
共有 33 個文件被更改,包括 2164 次插入142 次删除
  1. 431 0
      src-tauri/src/app_state.rs
  2. 136 0
      src-tauri/src/atomic_file.rs
  3. 11 19
      src-tauri/src/commands/backup.rs
  4. 4 0
      src-tauri/src/lib.rs
  5. 4 0
      src-tauri/src/main.rs
  6. 18 6
      src/components/chat/chat-panel.tsx
  7. 2 0
      src/components/settings/llm-wiki-model-settings.spec.ts
  8. 2 2
      src/components/settings/sections/llm-provider-section.tsx
  9. 212 2
      src/components/sources/outline-chat-panel.spec.tsx
  10. 331 46
      src/components/sources/outline-chat-panel.tsx
  11. 0 1
      src/lib/agent/ai-chat-workflow-convergence.spec.ts
  12. 8 0
      src/lib/agent/plugins/build-system-prompt-plugin.ts
  13. 21 1
      src/lib/agent/plugins/select-capabilities-plugin.spec.ts
  14. 10 2
      src/lib/agent/plugins/select-capabilities-plugin.ts
  15. 18 3
      src/lib/agent/plugins/select-skills-plugin.spec.ts
  16. 36 10
      src/lib/agent/plugins/select-skills-plugin.ts
  17. 50 1
      src/lib/agent/runner.spec.ts
  18. 35 13
      src/lib/agent/runner.ts
  19. 6 0
      src/lib/agent/tool-result.ts
  20. 28 9
      src/lib/agent/tools/apply-skill.ts
  21. 15 0
      src/lib/agent/tools/write-tools.spec.ts
  22. 4 1
      src/lib/agent/types.ts
  23. 18 0
      src/lib/novel/novel-generation-request-package.spec.ts
  24. 19 1
      src/lib/novel/novel-generation-request-package.ts
  25. 58 0
      src/lib/novel/outline-intent-clarity.spec.ts
  26. 148 14
      src/lib/novel/outline-intent-clarity.ts
  27. 9 0
      src/lib/novel/skill-hub-seed.ts
  28. 69 0
      src/lib/novel/skill-route-registry.spec.ts
  29. 288 0
      src/lib/novel/skill-route-registry.ts
  30. 8 8
      src/lib/project-store.ts
  31. 83 0
      src/lib/web-store.spec.ts
  32. 77 3
      src/lib/web-store.ts
  33. 5 0
      src/stores/outline-chat-store.ts

+ 431 - 0
src-tauri/src/app_state.rs

@@ -0,0 +1,431 @@
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use serde_json::{Map, Value};
+use tauri::{AppHandle, Manager, Runtime};
+use tauri_plugin_store::StoreExt;
+
+use crate::atomic_file::write_bytes_atomically;
+
+pub const PRIMARY_FILE_NAME: &str = "app-state.json";
+pub const BAK_FILE_NAME: &str = "app-state.json.bak";
+
+const SUBSTANTIVE_KEYS: &[&str] = &[
+    "llmConfig",
+    "providerConfigs",
+    "recentProjects",
+    "lastProject",
+];
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RecoverAction {
+    Unchanged,
+    RefreshedBak,
+    RestoredFromBak,
+    Quarantined,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RecoverReport {
+    pub action: RecoverAction,
+    pub message: String,
+}
+
+enum Classified {
+    Missing,
+    Unusable,
+    Object { value: Value, has_substance: bool },
+}
+
+fn value_has_substance(value: &Value) -> bool {
+    let Some(object) = value.as_object() else {
+        return false;
+    };
+    SUBSTANTIVE_KEYS.iter().any(|key| match object.get(*key) {
+        None | Some(Value::Null) => false,
+        Some(Value::String(text)) => !text.is_empty(),
+        Some(Value::Array(items)) => !items.is_empty(),
+        Some(Value::Object(nested)) => !nested.is_empty(),
+        Some(_) => true,
+    })
+}
+
+fn classify(path: &Path) -> Classified {
+    if !path.exists() {
+        return Classified::Missing;
+    }
+    let Ok(bytes) = fs::read(path) else {
+        return Classified::Unusable;
+    };
+    if bytes.iter().all(u8::is_ascii_whitespace) {
+        return Classified::Unusable;
+    }
+    match serde_json::from_slice::<Value>(&bytes) {
+        Ok(value) if value.is_object() => {
+            let has_substance = value_has_substance(&value);
+            Classified::Object {
+                value,
+                has_substance,
+            }
+        }
+        _ => Classified::Unusable,
+    }
+}
+
+fn object_bytes(value: &Value) -> Result<Vec<u8>, String> {
+    serde_json::to_vec_pretty(value).map_err(|error| format!("序列化 app-state 失败: {error}"))
+}
+
+fn quarantine_primary(path: &Path) -> Result<(), String> {
+    if !path.exists() {
+        return Ok(());
+    }
+    let stamp = SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map(|duration| duration.as_millis())
+        .unwrap_or(0);
+    let dest = path.with_file_name(format!("app-state.json.corrupt-{stamp}"));
+    fs::rename(path, &dest)
+        .or_else(|_| {
+            fs::copy(path, &dest)
+                .and_then(|_| fs::remove_file(path))
+                .map(|_| ())
+        })
+        .map_err(|error| {
+            format!(
+                "隔离损坏的 app-state 失败 '{}' -> '{}': {error}",
+                path.display(),
+                dest.display()
+            )
+        })
+}
+
+fn write_value(path: &Path, value: &Value) -> Result<(), String> {
+    write_bytes_atomically(path, &object_bytes(value)?)
+}
+
+pub fn recover_app_state_file(dir: &Path) -> RecoverReport {
+    let primary_path = dir.join(PRIMARY_FILE_NAME);
+    let bak_path = dir.join(BAK_FILE_NAME);
+    let primary = classify(&primary_path);
+    let bak = classify(&bak_path);
+
+    let primary_unusable = matches!(primary, Classified::Missing | Classified::Unusable);
+    let primary_has_substance = matches!(
+        primary,
+        Classified::Object {
+            has_substance: true,
+            ..
+        }
+    );
+    let bak_has_substance = matches!(
+        bak,
+        Classified::Object {
+            has_substance: true,
+            ..
+        }
+    );
+    let bak_usable_value = match &bak {
+        Classified::Object {
+            has_substance: true,
+            value,
+        } => Some(value.clone()),
+        _ => None,
+    };
+
+    if (!primary_has_substance && bak_has_substance) || (primary_unusable && bak_has_substance) {
+        if let Some(value) = bak_usable_value {
+            if let Err(error) = quarantine_primary(&primary_path) {
+                return RecoverReport {
+                    action: RecoverAction::Unchanged,
+                    message: format!("无法隔离损坏的 app-state.json: {error}"),
+                };
+            }
+            return match write_value(&primary_path, &value) {
+                Ok(()) => RecoverReport {
+                    action: RecoverAction::RestoredFromBak,
+                    message: "已从 app-state.json.bak 恢复全局配置".to_string(),
+                },
+                Err(error) => RecoverReport {
+                    action: RecoverAction::Unchanged,
+                    message: format!("从 bak 恢复 app-state.json 失败: {error}"),
+                },
+            };
+        }
+    }
+
+    if primary_has_substance {
+        let bak_needs_refresh = !matches!(
+            bak,
+            Classified::Object {
+                has_substance: true,
+                ..
+            }
+        );
+        if bak_needs_refresh {
+            if let Classified::Object { value, .. } = primary {
+                return match write_value(&bak_path, &value) {
+                    Ok(()) => RecoverReport {
+                        action: RecoverAction::RefreshedBak,
+                        message: "已用当前配置刷新 app-state.json.bak".to_string(),
+                    },
+                    Err(error) => RecoverReport {
+                        action: RecoverAction::Unchanged,
+                        message: format!("刷新 app-state.json.bak 失败: {error}"),
+                    },
+                };
+            }
+        }
+        return RecoverReport {
+            action: RecoverAction::Unchanged,
+            message: "app-state.json 完好".to_string(),
+        };
+    }
+
+    if matches!(primary, Classified::Unusable) {
+        return match quarantine_primary(&primary_path) {
+            Ok(()) => RecoverReport {
+                action: RecoverAction::Quarantined,
+                message: "已隔离损坏的 app-state.json,未写入空配置".to_string(),
+            },
+            Err(error) => RecoverReport {
+                action: RecoverAction::Unchanged,
+                message: format!("隔离损坏的 app-state.json 失败: {error}"),
+            },
+        };
+    }
+
+    RecoverReport {
+        action: RecoverAction::Unchanged,
+        message: "app-state.json 不存在或尚无实质配置".to_string(),
+    }
+}
+
+pub fn persist_app_state_object(dir: &Path, value: &Value) -> Result<(), String> {
+    if !value.is_object() {
+        return Err("app-state 必须是 JSON 对象".to_string());
+    }
+    let primary_path = dir.join(PRIMARY_FILE_NAME);
+    let incoming_has_substance = value_has_substance(value);
+    if let Classified::Object {
+        has_substance: true,
+        ..
+    } = classify(&primary_path)
+    {
+        if !incoming_has_substance {
+            eprintln!("[app-state] 拒绝用无实质配置的内容覆盖现有 app-state.json");
+            return Err("拒绝用空配置覆盖现有应用配置".to_string());
+        }
+    }
+
+    let bytes = object_bytes(value)?;
+    write_bytes_atomically(&primary_path, &bytes)?;
+    if incoming_has_substance {
+        write_bytes_atomically(&dir.join(BAK_FILE_NAME), &bytes)?;
+    }
+    Ok(())
+}
+
+fn open_app_state_store<R: Runtime>(
+    app: &AppHandle<R>,
+) -> Result<std::sync::Arc<tauri_plugin_store::Store<R>>, String> {
+    app.store_builder(PRIMARY_FILE_NAME)
+        .disable_auto_save()
+        .build()
+        .map_err(|error| format!("无法打开应用状态存储: {error}"))
+}
+
+pub fn persist_plugin_store<R: Runtime>(app: &AppHandle<R>) -> Result<PathBuf, String> {
+    let dir = app
+        .path()
+        .app_data_dir()
+        .map_err(|error| format!("无法获取 app_data_dir: {error}"))?;
+    let path = dir.join(PRIMARY_FILE_NAME);
+    let store = open_app_state_store(app)?;
+    let mut map = Map::new();
+    for (key, value) in store.entries() {
+        map.insert(key, value);
+    }
+    persist_app_state_object(&dir, &Value::Object(map))?;
+    Ok(path)
+}
+
+pub fn prepare_app_state_store<R: Runtime>(app: &AppHandle<R>) {
+    let Ok(dir) = app.path().app_data_dir() else {
+        eprintln!("[app-state] could not resolve app_data_dir");
+        return;
+    };
+    let report = recover_app_state_file(&dir);
+    eprintln!("[app-state] {}", report.message);
+    if let Err(error) = open_app_state_store(app) {
+        eprintln!("[app-state] 打开存储失败: {error}");
+    }
+}
+
+#[tauri::command]
+pub async fn write_app_state_atomic(
+    app: AppHandle,
+    entries: Value,
+) -> Result<(), String> {
+    let dir = app
+        .path()
+        .app_data_dir()
+        .map_err(|error| format!("无法获取 app_data_dir: {error}"))?;
+    tauri::async_runtime::spawn_blocking(move || persist_app_state_object(&dir, &entries))
+        .await
+        .map_err(|error| format!("write_app_state_atomic join error: {error}"))?
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::atomic_file::write_bytes_atomically_with_replace;
+
+    fn unique_test_dir(name: &str) -> PathBuf {
+        let dir = std::env::temp_dir().join(format!(
+            "qmai_app_state_{name}_{}",
+            uuid::Uuid::new_v4()
+        ));
+        fs::create_dir_all(&dir).unwrap();
+        dir
+    }
+
+    fn write_raw(dir: &Path, name: &str, contents: &str) {
+        fs::write(dir.join(name), contents).unwrap();
+    }
+
+    fn read(dir: &Path, name: &str) -> String {
+        fs::read_to_string(dir.join(name)).unwrap()
+    }
+
+    fn corrupt_names(dir: &Path) -> Vec<String> {
+        fs::read_dir(dir)
+            .unwrap()
+            .filter_map(|entry| entry.ok())
+            .map(|entry| entry.file_name().to_string_lossy().into_owned())
+            .filter(|name| name.starts_with("app-state.json.corrupt-"))
+            .collect()
+    }
+
+    #[test]
+    fn restores_truncated_primary_from_bak() {
+        let dir = unique_test_dir("truncated");
+        write_raw(&dir, PRIMARY_FILE_NAME, r#"{"llmConfig":{"model":""#);
+        write_raw(
+            &dir,
+            BAK_FILE_NAME,
+            r#"{"llmConfig":{"model":"kept"},"recentProjects":[{"path":"/novel"}]}"#,
+        );
+
+        let report = recover_app_state_file(&dir);
+        assert_eq!(report.action, RecoverAction::RestoredFromBak);
+        let restored: Value = serde_json::from_str(&read(&dir, PRIMARY_FILE_NAME)).unwrap();
+        assert_eq!(restored["llmConfig"]["model"], "kept");
+        assert_eq!(corrupt_names(&dir).len(), 1);
+        let _ = fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn restores_when_primary_lost_substantive_keys() {
+        let dir = unique_test_dir("shrink");
+        write_raw(
+            &dir,
+            PRIMARY_FILE_NAME,
+            r#"{"analytics_device_uuid":"abc"}"#,
+        );
+        write_raw(
+            &dir,
+            BAK_FILE_NAME,
+            r#"{"llmConfig":{"provider":"openai","model":"gpt"},"providerConfigs":{"openai":{}}}"#,
+        );
+
+        let report = recover_app_state_file(&dir);
+        assert_eq!(report.action, RecoverAction::RestoredFromBak);
+        let restored: Value = serde_json::from_str(&read(&dir, PRIMARY_FILE_NAME)).unwrap();
+        assert!(restored.get("llmConfig").is_some());
+        assert!(restored.get("analytics_device_uuid").is_none());
+        let _ = fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn quarantines_unusable_primary_without_writing_empty_json() {
+        let dir = unique_test_dir("quarantine");
+        write_raw(&dir, PRIMARY_FILE_NAME, "{not-json");
+
+        let report = recover_app_state_file(&dir);
+        assert_eq!(report.action, RecoverAction::Quarantined);
+        assert!(!dir.join(PRIMARY_FILE_NAME).exists());
+        assert_eq!(corrupt_names(&dir).len(), 1);
+        let _ = fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn refreshes_missing_bak_from_healthy_primary() {
+        let dir = unique_test_dir("bak");
+        write_raw(
+            &dir,
+            PRIMARY_FILE_NAME,
+            r#"{"lastProject":{"path":"/novel","name":"n"}}"#,
+        );
+
+        let report = recover_app_state_file(&dir);
+        assert_eq!(report.action, RecoverAction::RefreshedBak);
+        let bak: Value = serde_json::from_str(&read(&dir, BAK_FILE_NAME)).unwrap();
+        assert_eq!(bak["lastProject"]["path"], "/novel");
+        let _ = fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn refuses_to_overwrite_substantive_state_with_empty_payload() {
+        let dir = unique_test_dir("refuse");
+        write_raw(
+            &dir,
+            PRIMARY_FILE_NAME,
+            r#"{"llmConfig":{"model":"keep"},"recentProjects":[{"path":"/a"}]}"#,
+        );
+
+        let error = persist_app_state_object(
+            &dir,
+            &serde_json::json!({"analytics_device_uuid":"x"}),
+        )
+        .unwrap_err();
+        assert!(error.contains("拒绝"));
+        let kept: Value = serde_json::from_str(&read(&dir, PRIMARY_FILE_NAME)).unwrap();
+        assert_eq!(kept["llmConfig"]["model"], "keep");
+        let _ = fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn persist_writes_primary_and_bak() {
+        let dir = unique_test_dir("persist");
+        persist_app_state_object(
+            &dir,
+            &serde_json::json!({"providerConfigs":{"openai":{"apiKey":"k"}}}),
+        )
+        .unwrap();
+        let primary: Value = serde_json::from_str(&read(&dir, PRIMARY_FILE_NAME)).unwrap();
+        let bak: Value = serde_json::from_str(&read(&dir, BAK_FILE_NAME)).unwrap();
+        assert_eq!(primary, bak);
+        assert_eq!(primary["providerConfigs"]["openai"]["apiKey"], "k");
+        let _ = fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn failed_atomic_replace_keeps_original_app_state() {
+        let dir = unique_test_dir("persist_fail");
+        let path = dir.join(PRIMARY_FILE_NAME);
+        fs::write(&path, r#"{"llmConfig":{"model":"keep"}}"#).unwrap();
+        let result = write_bytes_atomically_with_replace(
+            &path,
+            br#"{"llmConfig":{"model":"new"}}"#,
+            |_temp, _dest| Err("injected".to_string()),
+        );
+        assert!(result.is_err());
+        assert_eq!(
+            fs::read_to_string(&path).unwrap(),
+            r#"{"llmConfig":{"model":"keep"}}"#
+        );
+        let _ = fs::remove_dir_all(&dir);
+    }
+}

+ 136 - 0
src-tauri/src/atomic_file.rs

@@ -0,0 +1,136 @@
+use std::fs::{self, OpenOptions};
+use std::io::Write;
+use std::path::Path;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+/// Atomically replace `destination` with a sibling temporary file.
+#[cfg(windows)]
+pub fn replace_file_atomically(temp: &Path, destination: &Path) -> Result<(), String> {
+    use std::os::windows::ffi::OsStrExt;
+
+    #[link(name = "Kernel32")]
+    extern "system" {
+        fn MoveFileExW(existing: *const u16, new: *const u16, flags: u32) -> i32;
+    }
+
+    const MOVEFILE_REPLACE_EXISTING: u32 = 0x1;
+    const MOVEFILE_WRITE_THROUGH: u32 = 0x8;
+    let existing: Vec<u16> = temp.as_os_str().encode_wide().chain(Some(0)).collect();
+    let new: Vec<u16> = destination.as_os_str().encode_wide().chain(Some(0)).collect();
+    let result = unsafe {
+        MoveFileExW(
+            existing.as_ptr(),
+            new.as_ptr(),
+            MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
+        )
+    };
+    if result == 0 {
+        Err(format!(
+            "原子替换文件失败: {}",
+            std::io::Error::last_os_error()
+        ))
+    } else {
+        Ok(())
+    }
+}
+
+#[cfg(not(windows))]
+pub fn replace_file_atomically(temp: &Path, destination: &Path) -> Result<(), String> {
+    fs::rename(temp, destination).map_err(|e| format!("原子替换文件失败: {e}"))
+}
+
+pub fn write_bytes_atomically(path: &Path, bytes: &[u8]) -> Result<(), String> {
+    write_bytes_atomically_with_replace(path, bytes, replace_file_atomically)
+}
+
+pub fn write_bytes_atomically_with_replace<F>(
+    path: &Path,
+    bytes: &[u8],
+    replace: F,
+) -> Result<(), String>
+where
+    F: FnOnce(&Path, &Path) -> Result<(), String>,
+{
+    let parent = path
+        .parent()
+        .ok_or_else(|| "路径缺少父目录".to_string())?;
+    fs::create_dir_all(parent).map_err(|e| format!("创建目录失败 '{}': {e}", parent.display()))?;
+    let file_name = path
+        .file_name()
+        .map(|name| name.to_string_lossy().to_string())
+        .unwrap_or_else(|| "app-state.json".to_string());
+    let stamp = SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map(|duration| duration.as_nanos())
+        .unwrap_or(0);
+    let temp = parent.join(format!(".{file_name}.{stamp}.tmp"));
+    let write_result = (|| {
+        let mut file = OpenOptions::new()
+            .write(true)
+            .create_new(true)
+            .open(&temp)
+            .map_err(|e| format!("创建临时文件失败 '{}': {e}", temp.display()))?;
+        file.write_all(bytes)
+            .map_err(|e| format!("写入临时文件失败 '{}': {e}", temp.display()))?;
+        file.sync_all()
+            .map_err(|e| format!("同步临时文件失败 '{}': {e}", temp.display()))?;
+        drop(file);
+        replace(&temp, path)
+    })();
+    if write_result.is_err() {
+        let _ = fs::remove_file(&temp);
+    }
+    write_result
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::path::PathBuf;
+
+    fn unique_test_dir(name: &str) -> PathBuf {
+        let dir = std::env::temp_dir().join(format!(
+            "qmai_atomic_file_{name}_{}",
+            uuid::Uuid::new_v4()
+        ));
+        fs::create_dir_all(&dir).unwrap();
+        dir
+    }
+
+    #[test]
+    fn failed_replace_leaves_the_original_file() {
+        let dir = unique_test_dir("replace_fail");
+        let path = dir.join("app-state.json");
+        fs::write(&path, r#"{"llmConfig":{"model":"keep"}}"#).unwrap();
+
+        let result = write_bytes_atomically_with_replace(
+            &path,
+            br#"{"llmConfig":{"model":"new"}}"#,
+            |_temp, _dest| Err("injected replace failure".to_string()),
+        );
+
+        assert!(result.is_err());
+        assert_eq!(
+            fs::read_to_string(&path).unwrap(),
+            r#"{"llmConfig":{"model":"keep"}}"#
+        );
+        let leftovers: Vec<_> = fs::read_dir(&dir)
+            .unwrap()
+            .filter_map(|entry| entry.ok())
+            .map(|entry| entry.file_name().to_string_lossy().into_owned())
+            .filter(|name| name.ends_with(".tmp"))
+            .collect();
+        assert!(leftovers.is_empty(), "temp files leftover: {leftovers:?}");
+        let _ = fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn atomic_write_replaces_existing_content() {
+        let dir = unique_test_dir("replace_ok");
+        let path = dir.join("app-state.json");
+        fs::write(&path, "{}").unwrap();
+        write_bytes_atomically(&path, br#"{"ok":true}"#).unwrap();
+        assert_eq!(fs::read_to_string(&path).unwrap(), r#"{"ok":true}"#);
+        let _ = fs::remove_dir_all(&dir);
+    }
+}

+ 11 - 19
src-tauri/src/commands/backup.rs

@@ -10,6 +10,7 @@ use walkdir::WalkDir;
 use zip::write::ZipWriter;
 use zip::CompressionMethod;
 
+use crate::app_state;
 use crate::panic_guard::run_guarded;
 
 #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -295,7 +296,9 @@ fn restore_app_state_via_store(
     app_state_json: &serde_json::Value,
 ) -> Result<(), String> {
     let store = app
-        .store("app-state.json")
+        .store_builder("app-state.json")
+        .disable_auto_save()
+        .build()
         .map_err(|e| format!("无法加载应用状态存储: {e}"))?;
 
     store.clear();
@@ -308,8 +311,7 @@ fn restore_app_state_via_store(
         store.set(key.clone(), value.clone());
     }
 
-    store
-        .save()
+    crate::app_state::persist_plugin_store(app)
         .map_err(|e| format!("保存应用状态存储失败: {e}"))?;
 
     Ok(())
@@ -1011,9 +1013,7 @@ pub fn do_import_backup<F: Fn(&BackupProgressPayload)>(
                     && manifest_contents.credentials);
             let merged_app_state =
                 merge_app_state_file(&app_state_path, app_state_json, replace_app_state)?;
-            let app_state_str = serde_json::to_string_pretty(&merged_app_state)
-                .map_err(|e| format!("序列化 app-state 失败: {e}"))?;
-            fs::write(&app_state_path, app_state_str.as_bytes())
+            crate::app_state::persist_app_state_object(app_state_dir, &merged_app_state)
                 .map_err(|e| format!("写入 app-state.json 失败: {e}"))?;
 
             app_state = Some(merged_app_state);
@@ -1203,15 +1203,10 @@ pub async fn export_backup(
             .app_data_dir()
             .map_err(|err| format!("无法获取 app_data_dir: {err}"))?;
 
-        let app_state_path = match app.store("app-state.json") {
-            Ok(store) => {
-                if let Err(e) = store.save() {
-                    eprintln!("保存 app-state 存储失败: {e}");
-                }
-                app_data_dir.join("app-state.json")
-            }
+        let app_state_path = match app_state::persist_plugin_store(&app) {
+            Ok(path) => path,
             Err(e) => {
-                eprintln!("无法获取 app-state 存储句柄: {e}");
+                eprintln!("保存 app-state 存储失败: {e}");
                 app_data_dir.join("app-state.json")
             }
         };
@@ -1241,11 +1236,8 @@ pub async fn import_backup(
             .app_data_dir()
             .map_err(|err| format!("无法获取 app_data_dir: {err}"))?;
 
-        if let Ok(store) = app.store("app-state.json") {
-            store
-                .save()
-                .map_err(|error| format!("导入前保存当前应用状态失败: {error}"))?;
-        }
+        app_state::persist_plugin_store(&app)
+            .map_err(|error| format!("导入前保存当前应用状态失败: {error}"))?;
 
         let app_clone = app.clone();
         let result = do_import_backup(params, &app_data_dir, move |payload| {

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

@@ -1,3 +1,5 @@
+mod app_state;
+mod atomic_file;
 mod commands;
 mod panic_guard;
 mod proxy;
@@ -46,6 +48,7 @@ pub fn run() {
             if let Ok(dir) = app.path().resource_dir() {
                 commands::fs::set_resource_dir_hint(dir);
             }
+            app_state::prepare_app_state_store(app.handle());
             if let Ok(dir) = app.path().app_data_dir() {
                 let store_path = dir.join("app-state.json");
                 eprintln!("[proxy] reading from {}", store_path.display());
@@ -121,6 +124,7 @@ pub fn run() {
             commands::backup::export_backup,
             commands::backup::import_backup,
             commands::backup::read_backup_manifest,
+            app_state::write_app_state_atomic,
             commands::writing_wake_lock::acquire_writing_wake_lock,
             commands::writing_wake_lock::release_writing_wake_lock,
             set_proxy_env,

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

@@ -1,5 +1,7 @@
 #![cfg_attr(target_os = "windows", windows_subsystem = "windows")]
 
+mod app_state;
+mod atomic_file;
 mod commands;
 mod panic_guard;
 mod proxy;
@@ -48,6 +50,7 @@ fn main() {
             if let Ok(dir) = app.path().resource_dir() {
                 commands::fs::set_resource_dir_hint(dir);
             }
+            app_state::prepare_app_state_store(app.handle());
             if let Ok(dir) = app.path().app_data_dir() {
                 let store_path = dir.join("app-state.json");
                 eprintln!("[proxy] reading from {}", store_path.display());
@@ -123,6 +126,7 @@ fn main() {
             commands::backup::export_backup,
             commands::backup::import_backup,
             commands::backup::read_backup_manifest,
+            app_state::write_app_state_atomic,
             commands::writing_wake_lock::acquire_writing_wake_lock,
             commands::writing_wake_lock::release_writing_wake_lock,
             set_proxy_env,

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

@@ -283,9 +283,15 @@ function buildWorkflowRouteActivityContent(
   ].join("\n")
 }
 
-function buildSelectedSkillsActivityContent(skills: UserSkill[] | undefined): string {
+function buildSelectedSkillsActivityContent(
+  skills: UserSkill[] | undefined,
+  missingSkillNames: string[] = [],
+): string {
+  const missingText = missingSkillNames.length > 0
+    ? `\n缺失或已禁用:${missingSkillNames.join("、")}。未强制启用。`
+    : ""
   if (!skills || skills.length === 0) {
-    return "本次未启用 Skill:当前任务、模式或阶段没有匹配到可用技能。"
+    return `本次未启用 Skill:当前任务、模式或阶段没有匹配到可用技能。${missingText}`
   }
   return skills
     .map((skill, index) => {
@@ -293,7 +299,7 @@ function buildSelectedSkillsActivityContent(skills: UserSkill[] | undefined): st
       const kindText = skill.kind.length > 0 ? skill.kind.join("、") : "未标注类型"
       return `${index + 1}. ${skill.name}|阶段:${stageText}|类型:${kindText}|优先级:${skill.priority ?? 50}`
     })
-    .join("\n")
+    .join("\n") + missingText
 }
 
 function buildChatAgentSystemPrompt(options: {
@@ -1667,7 +1673,10 @@ export function ChatPanel() {
           stageId: "capability_selection",
           kind: "skill_used",
           title: "本次启用 Skill",
-          content: buildSelectedSkillsActivityContent(prePluginResult?.selectedSkills),
+          content: buildSelectedSkillsActivityContent(
+            prePluginResult?.selectedSkills,
+            (prePluginResult?.missingSkillNames as string[] | undefined) ?? [],
+          ),
           timestamp: now + 1,
         })
         updateAgentAssistantMessage(assistantMessage.id, (message) => ({
@@ -1959,7 +1968,10 @@ export function ChatPanel() {
         if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
         useChatStore.getState().setConversationContextUsage(
           capturedConvId,
-          calibrateContextUsageSnapshot(usageSnapshotBase, record.usage),
+          calibrateContextUsageSnapshot(
+            usageSnapshotBase,
+            record.lastRequestUsage ?? record.usage,
+          ),
         )
         if (contextHubResult && record.usage) {
           try {
@@ -1967,7 +1979,7 @@ export function ChatPanel() {
               getContextHub(pp),
               assistantMessage.id,
               contextHubResult,
-              record.usage,
+              record.lastRequestUsage ?? record.usage,
               {
                 memoryDecision: record.userMemoryDecision,
                 requestDiagnostics: buildLlmRequestDiagnostics(

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

@@ -34,6 +34,8 @@ describe("QMAI model settings", () => {
     expect(source).toContain("settings.sections.llm.longWritingContextHint")
     expect(source).toContain("settings.sections.llm.longWritingContextDocs")
     expect(source).toContain("https://global.modelmesh.info/model")
+    expect(source).toContain("text-emerald-800 dark:text-emerald-200")
+    expect(source).not.toContain("bg-emerald-500/10 px-3 py-2 text-sm text-white")
   })
 
   it("keeps every built-in provider and gives each one at least 204800", () => {

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

@@ -121,13 +121,13 @@ export function LlmProviderSection() {
         </p>
       </div>
 
-      <div className="flex gap-2 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-white">
+      <div className="flex gap-2 rounded-md border border-emerald-500/40 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-800 dark:text-emerald-200">
         <AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
         <div className="min-w-0 space-y-1.5">
           <div className="font-medium">
             {t("settings.sections.llm.longWritingContextTitle")}
           </div>
-          <p className="text-xs leading-relaxed">
+          <p className="text-xs leading-relaxed text-emerald-800/80 dark:text-emerald-200/75">
             {t("settings.sections.llm.longWritingContextHint")}
           </p>
           <ResourceLink

+ 212 - 2
src/components/sources/outline-chat-panel.spec.tsx

@@ -119,6 +119,43 @@ afterEach(async () => {
 
 describe("OutlineChatPanel controls", () => {
 
+  it("上下文圆环使用 AI 大纲选中模型的窗口而不是全局模型窗口", async () => {
+    useWikiStore.setState({
+      llmConfig: {
+        ...useWikiStore.getState().llmConfig,
+        provider: "openai",
+        apiKey: "test-key",
+        model: "gpt-4o",
+        maxContextSize: 204_800,
+      },
+      aiOutlineModel: "openai/gpt-4o",
+      providerConfigs: {
+        openai: {
+          apiKey: "test-key",
+          enabled: true,
+          maxContextSize: 409_600,
+          savedModels: [{ id: "gpt-4o", model: "gpt-4o", name: "GPT-4o", createdAt: 1 }],
+        },
+      },
+    })
+    setOutlineConversations([{
+      ...conversation(),
+      lastContextUsage: {
+        windowTokens: 204_800,
+        totalTokens: 100_000,
+        measuredAt: 1,
+        estimated: false,
+        segments: [{ key: "dynamicContext", tokens: 100_000 }],
+      },
+    }], "outline-active")
+
+    const container = await renderOutlineChatPanel()
+    const ring = container.querySelector<HTMLButtonElement>('[aria-label="上下文用量"]')
+
+    expect(ring).not.toBeNull()
+    expect(ring?.textContent).toBe("24")
+  })
+
   it("在 AI 大纲回复下方独立显示上下文中控摘要", async () => {
     const contextHubSnapshot: ContextHubSnapshotRef = {
       id: "outline-assistant-1",
@@ -440,7 +477,7 @@ describe("OutlineChatPanel controls", () => {
     expect(source).toContain("InsertReferenceTokens")
     expect(source).toContain("outlineReferenceTokens")
     expect(source).toContain("onAtTrigger={() => setReferencePickerOpen(true)}")
-    expect(source).toContain("onSubmit={handleSend}")
+    expect(source).toContain("onSubmit={handleDirectSubmit}")
     expect(source).not.toContain("<ChatInput")
     expect(source).not.toContain('from "@/components/chat/chat-input"')
   })
@@ -479,6 +516,26 @@ describe("OutlineChatPanel controls", () => {
     expect(source).toContain("请优先使用工具读取引用内容")
   })
 
+  it("hides internal prompts and legacy intent handoff bubbles without removing model history", async () => {
+    setOutlineConversations([conversation([
+      { id: "u1", role: "user", content: "把236章大纲补充详细" },
+      { id: "a1", role: "assistant", content: "意图明确" },
+      {
+        id: "u2",
+        role: "user",
+        content: "请按「AI大纲生成工作流」生成「章节细纲」。\n## PRD 3.1 主流程要求\n禁止再次输出 intent_clarity",
+      },
+      { id: "u3", role: "user", content: "✓ 意图明确(章节细纲),开始生成..." },
+      { id: "a2", role: "assistant", content: "# 第236章章纲" },
+    ])], "outline-active")
+
+    const container = await renderOutlineChatPanel()
+    expect(container.textContent).toContain("把236章大纲补充详细")
+    expect(container.textContent).toContain("第236章章纲")
+    expect(container.textContent).not.toContain("PRD 3.1 主流程要求")
+    expect(container.textContent).not.toContain("✓ 意图明确")
+  })
+
   it("routes outline chat sends through AgentRunner with built-in tools", () => {
     expect(source).toContain("AgentRunner")
     expect(source).toContain("buildAgentConfig")
@@ -502,7 +559,8 @@ describe("OutlineChatPanel controls", () => {
     expect(source).toContain('"write_chapter"')
     expect(source).toContain('"write_memory"')
     expect(source).toContain('"write_outline_node"')
-    expect(source).toContain("disabledTools: OUTLINE_CHAT_DISABLED_TOOLS")
+    expect(source).toContain("disabledTools: mergeDisabledTools(")
+    expect(source).toContain("OUTLINE_CHAT_DISABLED_TOOLS,")
     expect(source).toContain("禁止调用 write_outline_node")
     expect(source).toContain("用户确认后才写入文件")
     expect(source).toContain("content 字段强制要求")
@@ -579,6 +637,129 @@ describe("OutlineChatPanel controls", () => {
     expect(source).toContain("最后再生成大纲建议")
   })
 
+  it("直接章纲完善请求按意图分析和正文生成两阶段执行,并保留原请求与引用", async () => {
+    const reference = {
+      id: "chapter-outline-236",
+      category: "outline" as const,
+      title: "第236章-远洋投送",
+      displayTitle: "第236章-远洋投送",
+      path: "章纲/第236章-远洋投送.md",
+    }
+    const calls: Array<{ system: string; user: string }> = []
+    vi.spyOn(AgentRunner.prototype, "run").mockImplementation(async (_config, _registry, messages, callbacks) => {
+      const system = agentMessageContentText(messages.find((message) => message.role === "system")?.content ?? "")
+      const user = agentMessageContentText(messages.findLast((message) => message.role === "user")?.content ?? "")
+      calls.push({ system, user })
+      const text = system.includes("本轮阶段:意图分析")
+        ? `<!-- intent_clarity -->\n{"clarity":"clear","module":"章节细纲","analysis":"范围明确","detectedScope":"第236章","missingItems":[],"options":[],"question":""}\n<!-- /intent_clarity -->`
+        : "# 第236章 远洋投送\n\n## 本章目标\n完善远洋投送细节。"
+      callbacks.onText(text)
+      callbacks.onDone()
+      return { toolCalls: [], roundsUsed: 1, finalText: text }
+    })
+    setOutlineConversations([conversation()], "outline-active", { pendingReferenceTokens: [reference] })
+    const container = await renderOutlineChatPanel()
+    await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) })
+    const input = container.querySelector<HTMLTextAreaElement>('[aria-label="引用输入框"]')
+    expect(input).not.toBeNull()
+    await act(async () => {
+      const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set
+      setValue?.call(input, "把236章大纲补充详细")
+      input?.dispatchEvent(new Event("input", { bubbles: true }))
+      input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }))
+      for (let attempt = 0; attempt < 200; attempt += 1) {
+        if (calls.length >= 2 && useOutlineChatStore.getState().runStates["outline-active"]?.status !== "running") break
+        await new Promise((resolve) => setTimeout(resolve, 5))
+      }
+    })
+
+    expect(calls).toHaveLength(2)
+    expect(calls[0].system).toContain("本轮阶段:意图分析")
+    expect(calls[0].system).toContain("<!-- /intent_clarity -->")
+    expect(calls[1].system).toContain("本轮阶段:正文生成")
+    expect(calls[1].system).toContain("禁止再次输出 intent_clarity")
+    expect(calls[1].user).toContain("把236章大纲补充详细")
+    expect(calls[1].user).toContain("第236章-远洋投送")
+    const current = useOutlineChatStore.getState().conversations[0]
+    expect(current.messages.findLast((message) => message.role === "assistant")?.content).toContain("完善远洋投送细节")
+  })
+
+  it("needs_input 停在推荐选项,不自动进入正文生成", async () => {
+    const protocolText = `<!-- intent_clarity -->\n{"clarity":"needs_input","module":"章节细纲","analysis":"范围不足","detectedScope":"","missingItems":["章节范围"],"options":[{"id":"A","label":"生成最近章节","description":"最近5章"},{"id":"D","label":"自定义","description":"自行说明"}],"question":"请确认章节范围"}\n<!-- /intent_clarity -->`
+    const runSpy = vi.spyOn(AgentRunner.prototype, "run").mockImplementation(async (_config, _registry, _messages, callbacks) => {
+      callbacks.onText(protocolText)
+      callbacks.onDone()
+      return { toolCalls: [], roundsUsed: 1, finalText: protocolText }
+    })
+    setOutlineConversations([conversation()], "outline-active")
+    const container = await renderOutlineChatPanel()
+    const input = container.querySelector<HTMLTextAreaElement>('[aria-label="引用输入框"]')
+    await act(async () => {
+      const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set
+      setValue?.call(input, "补充章节大纲")
+      input?.dispatchEvent(new Event("input", { bubbles: true }))
+      input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }))
+      for (let attempt = 0; attempt < 100; attempt += 1) {
+        if (runSpy.mock.calls.length === 1 && useOutlineChatStore.getState().runStates["outline-active"]?.status !== "running") break
+        await new Promise((resolve) => setTimeout(resolve, 5))
+      }
+    })
+
+    expect(runSpy).toHaveBeenCalledTimes(1)
+    expect(container.textContent).toContain("请确认章节范围")
+    expect(container.textContent).toContain("生成最近章节")
+    expect(useOutlineChatStore.getState().conversations[0].messages.findLast((message) => message.role === "assistant")?.intentClarityResult?.clarity).toBe("needs_input")
+  })
+
+  it("历史未闭合 status clear 消息只显示手动继续生成,不在加载时调用模型", async () => {
+    let sentSystem = ""
+    let sentUser = ""
+    const runSpy = vi.spyOn(AgentRunner.prototype, "run").mockImplementation(async (_config, _registry, messages, callbacks) => {
+      sentSystem = agentMessageContentText(messages.find((message) => message.role === "system")?.content ?? "")
+      sentUser = agentMessageContentText(messages.findLast((message) => message.role === "user")?.content ?? "")
+      const text = "# 第236章\n\n## 本章目标\n补全远洋投送。"
+      callbacks.onText(text)
+      callbacks.onDone()
+      return { toolCalls: [], roundsUsed: 1, finalText: text }
+    })
+    setOutlineConversations([conversation([
+      { id: "u236", role: "user", content: "把236章大纲补充详细" },
+      { id: "a236", role: "assistant", content: '<!-- intent_clarity -->\n{"status":"clear","intent":"完善第236章章纲","target":"章纲/第236章.md","scope":"第236章"}' },
+    ])], "outline-active")
+    const container = await renderOutlineChatPanel()
+
+    expect(container.textContent).toContain("继续生成")
+    expect(container.textContent).not.toContain('"status":"clear"')
+    expect(Array.from(container.querySelectorAll("button")).some((button) => button.textContent?.includes("保存为大纲"))).toBe(false)
+    expect(runSpy).not.toHaveBeenCalled()
+
+    const continueButton = Array.from(container.querySelectorAll<HTMLButtonElement>("button"))
+      .find((button) => button.textContent?.includes("继续生成"))
+    await act(async () => {
+      continueButton?.click()
+      for (let attempt = 0; attempt < 100; attempt += 1) {
+        if (runSpy.mock.calls.length === 1 && useOutlineChatStore.getState().runStates["outline-active"]?.status !== "running") break
+        await new Promise((resolve) => setTimeout(resolve, 5))
+      }
+    })
+    expect(runSpy).toHaveBeenCalledTimes(1)
+    expect(sentSystem).toContain("本轮阶段:正文生成")
+    expect(sentUser).toContain("把236章大纲补充详细")
+    expect(useOutlineChatStore.getState().conversations[0].messages.at(-1)?.content).toContain("补全远洋投送")
+  })
+
+  it("无效意图 JSON 显示协议错误且不提供保存入口", async () => {
+    setOutlineConversations([conversation([
+      { id: "u-invalid", role: "user", content: "完善章纲" },
+      { id: "a-invalid", role: "assistant", content: '<!-- intent_clarity -->\n{"clarity":"clear"' },
+    ])], "outline-active")
+    const container = await renderOutlineChatPanel()
+
+    expect(container.textContent).toContain("意图分析格式无效,尚未开始生成")
+    expect(container.querySelector('[role="alert"]')).not.toBeNull()
+    expect(Array.from(container.querySelectorAll("button")).some((button) => button.textContent?.includes("保存为大纲"))).toBe(false)
+  })
+
   it("routes every outline generation menu item through the PRD 3.1 content workflow", () => {
     expect(source).toContain("buildOutlineSectionGenerationPrompt")
     expect(source).toContain("## AI大纲生成工作流")
@@ -1137,6 +1318,35 @@ describe("OutlineChatPanel controls", () => {
     expect(answer).not.toContain("```markdown")
   })
 
+  it("生成阶段重新生成若再次返回意图标记则报错并阻止循环", async () => {
+    const protocolText = `<!-- intent_clarity -->\n{"clarity":"clear","module":"章节细纲","analysis":"重复分析","detectedScope":"第236章","missingItems":[],"options":[],"question":""}\n<!-- /intent_clarity -->`
+    const runSpy = vi.spyOn(AgentRunner.prototype, "run").mockImplementation(async (_config, _registry, _messages, callbacks) => {
+      callbacks.onText(protocolText)
+      callbacks.onDone()
+      return { toolCalls: [], roundsUsed: 1, finalText: protocolText }
+    })
+    setOutlineConversations([conversation([
+      { id: "u-generation", role: "user", content: "直接生成第236章章纲" },
+      { id: "a-generation", role: "assistant", content: "# 旧章纲", intentPhase: "generation" },
+    ])], "outline-active")
+    const container = await renderOutlineChatPanel()
+    const button = Array.from(container.querySelectorAll<HTMLButtonElement>("button"))
+      .find((item) => item.textContent?.includes("重新生成"))
+    await act(async () => {
+      button?.click()
+      for (let attempt = 0; attempt < 100; attempt += 1) {
+        if (useOutlineChatStore.getState().runStates["outline-active"]?.status !== "running") break
+        await new Promise((resolve) => setTimeout(resolve, 5))
+      }
+    })
+
+    expect(runSpy).toHaveBeenCalledTimes(1)
+    const assistant = useOutlineChatStore.getState().conversations[0].messages.at(-1)
+    expect(assistant?.intentProtocolError).toContain("已阻止重复意图分析和自动循环")
+    expect(container.textContent).toContain("已阻止重复意图分析和自动循环")
+    expect(container.textContent).not.toContain("继续生成")
+  })
+
   it.each(["\u751f\u6210\u4eba\u7269\u8bbe\u5b9a", "\u751f\u6210\u4e16\u754c\u89c2", "\u7ee7\u7eed\u5b8c\u5584\u4eba\u7269\u5173\u7cfb", "\u7ee7\u7eed\u8865\u5145\u4e16\u754c\u89c2", "\u7ec6\u5316\u5f53\u524d\u5927\u7eb2", "\u7ee7\u7eed\u5b8c\u5584\u5f53\u524d\u6a21\u5757"])("structured next step triggers Markdown finalization: %s", async (label) => {
     vi.spyOn(AgentRunner.prototype, "run").mockImplementation(async (_config, _registry, _messages, callbacks) => {
       const text = "```markdown\n# \u8bbe\u5b9a\n\n## \u7ed3\u679c\n\u5185\u5bb9\n```"

+ 331 - 46
src/components/sources/outline-chat-panel.tsx

@@ -65,12 +65,19 @@ import {
 } from "@/lib/novel/outline-wizard";
 import {
   createNovelGenerationRequestPackage,
+  getOutlineMessageModelContent,
   mapOutlineMessagesForModel,
   buildOutlineRegenerationInput,
   isExplicitStructuredGenerationFollowUp,
+  isInternalOutlineMessage,
   mapOutlineConversationsForModel,
   type NovelGenerationRequestPackage,
 } from "@/lib/novel/novel-generation-request-package";
+import { buildSelectedSkillsPrompt } from "@/lib/agent/plugins/select-skills-plugin";
+import {
+  getOutlineSkillNames,
+  resolveAvailableSkillsByNames,
+} from "@/lib/novel/skill-route-registry";
 import {
   buildBoundedSubAgentMergePayload,
   type OutlineSubAgentPlan,
@@ -205,8 +212,11 @@ import {
 import { createWriteOutlineNodeTool } from "@/lib/agent/tools/write-outline-node";
 import {
   buildIntentAnalysisPrompt,
-  parseIntentClarity,
+  buildIntentPhaseSystemRules,
+  classifyDirectOutlineGenerationRequest,
+  parseIntentClarityProtocol,
   shouldAutoFollowUpGeneration,
+  stripStructuredMarkers,
   type IntentClarityResult,
 } from "@/lib/novel/outline-intent-clarity";
 import {
@@ -260,6 +270,15 @@ function messageContentToText(content: AgentMessage["content"]): string {
   return content.map((block) => (block.type === "text" ? block.text : "")).join("");
 }
 
+function appendSystemRules(
+  content: AgentMessage["content"],
+  rules: string,
+): AgentMessage["content"] {
+  if (!rules.trim()) return content;
+  if (typeof content === "string") return [content, rules].filter(Boolean).join("\n\n");
+  return [...content, { type: "text", text: rules }];
+}
+
 function persistOutlineConversationContextUsage(input: {
   conversationId: string
   windowTokens: number
@@ -395,12 +414,17 @@ export function buildOutlineAgentSystemPrompt(options: {
     "章纲采用滚动章纲方式:优先生成前 10 章或用户指定范围,后续依据已确认章纲继续补齐,避免一次性生成整本导致承接断裂。",
     "生成章纲后必须列出新增设定写回清单,包含新增角色、势力、世界观规则、伏笔、地图地点和状态变化;用户确认前不得写入设定文件。",
     "## 意图清晰度分析阶段",
-    "当用户请求生成大纲分项时,必须先进行意图清晰度分析:",
+    "仅当系统明确标记本轮为“意图分析”时,才输出 intent_clarity;正文生成阶段严禁再次输出该标记。",
+    "当本轮为意图分析时:",
     "1. 调用 list_outlines、list_chapters、read_outline 读取已有资料",
     "2. 判断用户意图是否清晰(能否确定具体生成范围)",
-    "3. 输出 <!-- intent_clarity --> JSON 标记块",
+    "3. 严格输出以下完整协议块:",
+    "<!-- intent_clarity -->",
+    '{"clarity":"clear|needs_input","module":"模块名","analysis":"判断依据","detectedScope":"明确范围","missingItems":[],"options":[],"question":""}',
+    "<!-- /intent_clarity -->",
+    "开闭标记必须成对出现;字段名必须使用 clarity,禁止使用 status。JSON 必须完整且可解析。",
     "4. clear 时:只输出 JSON,不生成正文,等待系统自动注入生成指令",
-    "5. needs_input 时:输出 JSON 后用自然语言提出澄清问题 + 4个推荐选项",
+    "5. needs_input 时:只输出 JSON,在 question 和 options 中提供澄清问题与4个推荐选项",
     "推荐选项必须包含:A.全部缺失项 B.基于已有内容推断 C.最近范围 D.自定义",
     "用户选择或回复后,直接进入生成流程,不再二次分析。",
     "",
@@ -506,6 +530,7 @@ function buildGenerationPrompt(
   requestHint: string,
   scope?: string,
   outputMode?: "per_chapter" | "per_item" | "single",
+  originalRequest?: string,
 ): string {
   const outputModeInstruction = outputMode === "per_chapter"
     ? "每个章节必须输出独立的 outlineSaveRequest,每个对应一个独立 .md 文件,文件名格式:第N章-章节标题.md。禁止将多个章节写入同一文件。"
@@ -515,6 +540,7 @@ function buildGenerationPrompt(
 
   return [
     `请按「AI大纲生成工作流」生成「${title}」。`,
+    originalRequest ? `\n## 原始用户请求\n${originalRequest}\n` : "",
     scope ? `\n## 已确认范围\n${scope}\n` : "",
     "## PRD 3.1 主流程要求",
     "本轮意图分析已经完成,直接使用已确认范围生成完整大纲正文;禁止再次输出 intent_clarity 标记,也不要重新进入意图分析。",
@@ -886,6 +912,7 @@ function OutlineAssistantMessage({
   onConfirmToolSave,
   onRejectTool,
   onSendMessage,
+  onContinueIntentGeneration,
   onResumeMultiAgent,
   resumeMultiAgentDisabled,
   nextStepDisabled,
@@ -906,6 +933,7 @@ function OutlineAssistantMessage({
   onConfirmToolSave: (call: ToolCallRecord & { preview?: string }) => void;
   onRejectTool: (call: ToolCallRecord & { preview?: string }) => void;
   onSendMessage: (text: string, options?: { intentPhase?: "intent_analysis" | "generation" | "waiting_user_input"; scope?: string }) => Promise<boolean>;
+  onContinueIntentGeneration: (messageId: string, result: IntentClarityResult) => Promise<void>;
   onResumeMultiAgent: (messageId: string) => Promise<void>;
   resumeMultiAgentDisabled: boolean;
   nextStepDisabled: boolean;
@@ -927,6 +955,23 @@ function OutlineAssistantMessage({
   );
   const actionContent = answer || displayContent;
   const messageIsStreaming = isStreaming && index === activeMessagesLength - 1;
+  const intentProtocol = useMemo(
+    () => parseIntentClarityProtocol(answer || displayContent),
+    [answer, displayContent],
+  );
+  const intentProtocolError = !messageIsStreaming
+    ? msg.intentProtocolError ?? (intentProtocol.kind === "invalid"
+      ? `意图分析格式无效,尚未开始生成:${intentProtocol.error}`
+      : undefined)
+    : undefined;
+  const canUseAsOutlineContent = intentProtocol.kind === "none" && !intentProtocolError;
+  const historicalClearIntent = !msg.intentClarityResult
+    && !msg.intentProtocolError
+    && msg.intentPhase !== "generation"
+    && intentProtocol.kind === "valid"
+    && intentProtocol.result.clarity === "clear"
+    ? intentProtocol.result
+    : null;
 
   // Parse for file edits
   const [parsed, setParsed] = useState<{
@@ -936,8 +981,10 @@ function OutlineAssistantMessage({
   }>({ textContent: "", edits: [], hasEdits: false });
   const renderedMarkdownContent = useMemo(() => {
     const rawContent = parsed.textContent || answer;
+    if (intentProtocol.kind === "valid") return stripStructuredMarkers(rawContent);
+    if (intentProtocol.kind === "invalid" || msg.intentProtocolError) return "";
     return prepareOutlineSaveSourceContent(rawContent);
-  }, [answer, parsed.textContent]);
+  }, [answer, intentProtocol, msg.intentProtocolError, parsed.textContent]);
   useEffect(() => {
     if (!answer) {
       setParsed({ textContent: "", edits: [], hasEdits: false });
@@ -984,6 +1031,11 @@ function OutlineAssistantMessage({
           {runStatusText}
         </div>
       ) : null}
+      {intentProtocolError ? (
+        <div role="alert" className="mb-2 rounded border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
+          {intentProtocolError}
+        </div>
+      ) : null}
       <StreamingMarkdown
         content={renderedMarkdownContent}
         isStreaming={messageIsStreaming}
@@ -1022,7 +1074,7 @@ function OutlineAssistantMessage({
         </details>
       ) : null}
       {/* Action buttons */}
-      {actionContent && !isStreaming ? (
+      {actionContent && canUseAsOutlineContent && !isStreaming ? (
         <div className="mt-2 flex gap-2 border-t pt-2">
           <button
             onClick={() => void onSaveAsOutline(actionContent)}
@@ -1045,6 +1097,17 @@ function OutlineAssistantMessage({
           </button>
         </div>
       ) : null}
+      {historicalClearIntent && !isStreaming ? (
+        <div className="mt-2 border-t pt-2">
+          <button
+            type="button"
+            onClick={() => void onContinueIntentGeneration(msg.id, historicalClearIntent)}
+            className="inline-flex items-center gap-1 rounded border px-2 py-1 text-xs hover:bg-accent"
+          >
+            继续生成
+          </button>
+        </div>
+      ) : null}
       {/* 意图不清晰时的推荐选项 */}
       {msg.intentClarityResult?.clarity === "needs_input" && !isStreaming ? (
         <IntentOptionsCard
@@ -1266,6 +1329,17 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     providerConfigs,
   ]);
   const effectiveOutlineModelId = storedOutlineModelId || fallbackOutlineModelId;
+  const effectiveOutlineContextWindow = useMemo(() => {
+    let config = resolveNovelModel(llmConfig, novelConfig, "writing");
+    if (effectiveOutlineModelId) {
+      config = resolveModelConfig(
+        effectiveOutlineModelId,
+        config,
+        providerConfigs,
+      );
+    }
+    return getEffectiveMaxContextSize(config);
+  }, [effectiveOutlineModelId, llmConfig, novelConfig, providerConfigs]);
 
   const [inputValue, setInputValue] = useState("");
   const deferredInputValue = useDeferredValue(inputValue);
@@ -1275,7 +1349,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       activeConv?.contextSummary?.text,
     );
     return composeLiveContextUsage(activeConv?.lastContextUsage, {
-      windowTokens: getEffectiveMaxContextSize(llmConfig),
+      windowTokens: effectiveOutlineContextWindow,
       sessionSummaryText: activeConv?.contextSummary?.text ?? "",
       historyTexts: historyMessages.map((message) => message.content),
       currentInput: deferredInputValue,
@@ -1285,7 +1359,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     activeConv?.lastContextUsage,
     activeMessages,
     deferredInputValue,
-    llmConfig,
+    effectiveOutlineContextWindow,
   ]);
   const [outlineReferenceTokens, setOutlineReferenceTokens] = useState<
     ReferenceToken[]
@@ -1311,6 +1385,9 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     title: string;
     hint: string;
     outputMode?: "per_chapter" | "per_item" | "single";
+    originalRequest?: string;
+    references?: ReferenceToken[];
+    skillNames?: string[];
   }>>({});
   const [outlineWorkflowStages, setOutlineWorkflowStages] = useState<Record<string, OutlineWorkflowStage>>({});
   const outlineWorkflowStage = activeConversationId
@@ -1755,6 +1832,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         intentPhase?: "intent_analysis" | "generation" | "waiting_user_input";
         novelGenerationRequest?: NovelGenerationRequestPackage;
         systemGenerated?: boolean;
+        userMessageVisibility?: "visible" | "internal";
+        userDisplayText?: string;
       } = {},
     ): Promise<OutlineSendResult> => {
       const prompt = inputText.trim();
@@ -1860,7 +1939,11 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       const userMsg: OutlineChatMessage = {
         id: crypto.randomUUID(),
         role: "user",
-        content: options.novelGenerationRequest?.summary ?? prompt,
+        content: options.userDisplayText ?? options.novelGenerationRequest?.summary ?? prompt,
+        ...(options.userDisplayText || options.userMessageVisibility === "internal"
+          ? { modelContent: prompt }
+          : {}),
+        visibility: options.userMessageVisibility ?? "visible",
         novelGenerationRequest: options.novelGenerationRequest,
         attachedReferences: tokens,
       };
@@ -1889,11 +1972,14 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       userScrolledUpRef.current = false;
       let hiddenToolCalls: AgentRunRecord["toolCalls"] = [];
       let followUpGenerationPrompt: string | null = null;
+      let followUpReferences: ReferenceToken[] = [];
       let contextHubResult: ContextHubResult | null = null;
       let providerUsage: LlmUsage | undefined;
+      let lastProviderUsage: LlmUsage | undefined;
       let memoryDecision: UserMemoryDecision | null | undefined;
       let llmRequestCount = 0;
       let accumulatedReasoningContent = "";
+      const missingSkillNames = new Set<string>();
       // 已生成的用户可见文本。streamingContents 只承载状态提示不存内容,
       // 出错/中断时必须依靠这个变量判断有没有可保留的内容,
       // 避免整段结果被静默丢弃。
@@ -2002,7 +2088,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             },
           ];
         };
-        const primarySystemContent = buildOutlineRunSystemContent();
+        const intentPhaseRules = buildIntentPhaseSystemRules(options.intentPhase);
+        const primarySystemContent = buildOutlineRunSystemContent(intentPhaseRules);
         const systemPrompt = typeof primarySystemContent === "string"
           ? primarySystemContent
           : flattenContextHubSystemContent(primarySystemContent);
@@ -2021,6 +2108,11 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           budgetStage: OutlineBudgetStage = outlineBudgetStage,
         ) => {
           const registry = new ToolRegistry();
+          const skillResolution = resolveAvailableSkillsByNames(
+            outlineWritingSkills,
+            skillNames ?? [],
+          );
+          for (const name of skillResolution.missingNames) missingSkillNames.add(name);
           const effectiveOutlineWritingSkills = prioritizeOutlineSkills(
             outlineWritingSkills,
             skillNames,
@@ -2059,6 +2151,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                   ? OUTLINE_CHAT_WIZARD_DISABLED_TOOLS
                   : OUTLINE_CHAT_DISABLED_TOOLS,
                 contextDecision.disabledTools,
+                (skillNames?.length ?? 0) > 0 ? ["apply_skill"] : [],
               ),
               ...(contextHubResult
                 ? { readTextFile: contextHubResult.readFile }
@@ -2087,6 +2180,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               },
             },
             registry,
+            selectedSkills: skillResolution.skills,
           };
         };
 
@@ -2100,11 +2194,17 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             budgetStage?: OutlineBudgetStage;
           } = {},
         ): Promise<{ text: string; record: AgentRunRecord; error?: Error; reasoning_content: string }> => {
-          const { agentConfig, registry } = buildConfigForSkillNames(
+          const { agentConfig, registry, selectedSkills } = buildConfigForSkillNames(
             optionsForRun.skillNames,
             optionsForRun.disableWriteTools,
             optionsForRun.budgetStage,
           );
+          const selectedSkillsPrompt = buildSelectedSkillsPrompt(selectedSkills);
+          const runMessages = selectedSkillsPrompt
+            ? messages.map((message, index) => index === 0 && message.role === "system"
+              ? { ...message, content: appendSystemRules(message.content, selectedSkillsPrompt) }
+              : message)
+            : messages;
           let runText = "";
           let runReasoningContent = "";
           const agentErrorBox: { current: Error | null } = { current: null };
@@ -2114,7 +2214,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           const record = await new AgentRunner().run(
             agentConfig,
             registry,
-            messages,
+            runMessages,
             {
               onText: (chunk) => {
                 runText += chunk;
@@ -2158,6 +2258,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             controller.signal,
           );
           providerUsage = addLlmUsage(providerUsage, record.usage);
+          lastProviderUsage = record.lastRequestUsage ?? record.usage ?? lastProviderUsage;
           llmRequestCount += Math.max(1, record.roundsUsed || 1);
           if (memoryDecision === undefined && record.userMemoryDecision !== undefined) {
             memoryDecision = record.userMemoryDecision;
@@ -2592,7 +2693,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               getContextHub(normalizePath(project.path)),
               assistantId,
               contextHubResult,
-              providerUsage,
+              lastProviderUsage ?? providerUsage,
               {
                 memoryDecision: memoryDecision ?? null,
                 requestDiagnostics: buildLlmRequestDiagnostics(
@@ -2622,7 +2723,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               content: messageContentToText(message.content),
             })),
             currentInput: userMessage ? messageContentToText(userMessage.content) : prompt,
-            usage: providerUsage,
+            usage: lastProviderUsage ?? providerUsage,
           });
         }
 
@@ -2631,16 +2732,19 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             ...contextSources,
             ...outlineSources,
             ...outlineToolCallsToSources(allToolCalls),
+            ...[...missingSkillNames].map((name) => `Skill 缺失(未强制启用): ${name}`),
           ]),
         );
         const rawFinalContent = finalText || result || "AI大纲未返回内容。";
+        const rawIntentProtocol = parseIntentClarityProtocol(rawFinalContent);
         const nextStepExtraction = extractNextStep(rawFinalContent, {
           allowFallback: options.intentPhase === "generation",
           completedModule: intentContextsRef.current[capturedConvId]?.title || "当前模块",
         });
         const cleanFinalContent = nextStepExtraction.cleanText || "AI大纲未返回内容。";
-        const structuredMarkdownEnabled = options.intentPhase === "generation"
-          || options.novelGenerationRequest !== undefined;
+        const structuredMarkdownEnabled = (
+          options.intentPhase === "generation" && rawIntentProtocol.kind === "none"
+        ) || options.novelGenerationRequest !== undefined;
         const finalContent = await finalizeStructuredMarkdownMessage(
           cleanFinalContent,
           {
@@ -2660,12 +2764,24 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           },
         );
         if (finalContent.trim()) bestGeneratedText = finalContent;
+        const intentProtocol = rawIntentProtocol.kind !== "none"
+          ? rawIntentProtocol
+          : parseIntentClarityProtocol(finalContent);
+        const intentProtocolError = options.intentPhase === "intent_analysis"
+          ? intentProtocol.kind === "invalid"
+            ? `意图分析格式无效,尚未开始生成:${intentProtocol.error}`
+            : intentProtocol.kind === "none"
+              ? "意图分析格式无效,尚未开始生成:模型未返回 intent_clarity 协议块"
+              : undefined
+          : options.intentPhase === "generation" && intentProtocol.kind !== "none"
+            ? "正文生成阶段返回了 intent_clarity,已阻止重复意图分析和自动循环。"
+            : undefined;
         // 内容已直接写入消息,这里只需清掉运行状态提示
         if (isCurrentRun()) clearStreamingContent(capturedConvId);
         const visibleToolCalls = allToolCalls.length ? allToolCalls : [];
         const shouldShowToolProcess =
           historyPlan.showToolProcess ||
-          visibleToolCalls.some((call) => call.status === "approval_required");
+          visibleToolCalls.some((call) => call.status === "approval_required" || call.status === "error");
         // 最终内容提交不受 run 状态闸门限制:即使运行状态已被切换/停止,
         // 已生成的结果也必须写入消息,只有后续 UI 副作用才需要闸门。
         updateOutlineAssistantMessage(convId, assistantId, (message) => ({
@@ -2680,7 +2796,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               )
             : [],
           isAgentRunning: false,
-          nextStepRecommendation: nextStepExtraction.recommendation,
+          nextStepRecommendation: intentProtocolError ? null : nextStepExtraction.recommendation,
+          intentProtocolError,
         }));
         if (!isCurrentRun()) {
           void useOutlineChatStore.getState().saveToDisk();
@@ -2688,14 +2805,21 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         }
 
         // 解析意图清晰度结果
-        const intentResult = parseIntentClarity(finalContent);
+        const intentResult = intentProtocol.kind === "valid" && !intentProtocolError
+          ? intentProtocol.result
+          : null;
         if (intentResult) {
           const existingContext = intentContextsRef.current[capturedConvId] ?? { title: "", hint: "" };
           const matchedConfig = !existingContext.title
             ? OUTLINE_SECTION_GENERATION_CONFIGS.find((c) => c.title === intentResult.module)
             : null;
           const updatedContext = matchedConfig
-            ? { title: matchedConfig.title, hint: matchedConfig.requestHint, outputMode: matchedConfig.outputMode }
+            ? {
+                title: matchedConfig.title,
+                hint: matchedConfig.requestHint,
+                outputMode: matchedConfig.outputMode,
+                skillNames: getOutlineSkillNames(matchedConfig.title),
+              }
             : existingContext.title
               ? existingContext
               : { ...existingContext, title: intentResult.module };
@@ -2715,11 +2839,6 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             if (canTransitionOutlineWorkflow(capturedStage, "sufficiency_check")) {
               setCapturedWorkflowStage("sufficiency_check");
             }
-            addMessage(convId, {
-              id: crypto.randomUUID(),
-              role: "user",
-              content: `✓ 意图明确(${intentResult.module}${intentResult.detectedScope ? `:${intentResult.detectedScope}` : ""}),开始生成...`,
-            });
             const scope = intentResult.detectedScope;
             const capturedIntentContext = intentContextsRef.current[capturedConvId] ?? { title: "", hint: "" };
             followUpGenerationPrompt = buildGenerationPrompt(
@@ -2727,7 +2846,9 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               capturedIntentContext.hint,
               scope,
               capturedIntentContext.outputMode,
+              capturedIntentContext.originalRequest,
             );
+            followUpReferences = capturedIntentContext.references ?? tokens;
           } else if (intentResult.clarity === "needs_input") {
             const capturedStage = outlineWorkflowStages[capturedConvId] ?? "idle";
             if (canTransitionOutlineWorkflow(capturedStage, "waiting_user_input")) {
@@ -2757,12 +2878,14 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             sessionKey: capturedConvId,
           });
         }
-        await handleAutoSaveOutlineRequests(capturedConvId, finalContent, isCurrentRun);
+        if (intentProtocol.kind === "none" && !intentProtocolError) {
+          await handleAutoSaveOutlineRequests(capturedConvId, finalContent, isCurrentRun);
+        }
         if (!isCurrentRun()) return { started: true, sent: false };
         const firstUser = useOutlineChatStore
           .getState()
           .conversations.find((conversation) => conversation.id === convId)
-          ?.messages.find((message) => message.role === "user");
+          ?.messages.find((message) => message.role === "user" && !isInternalOutlineMessage(message));
         if (firstUser) {
           useOutlineChatStore.setState((state) => ({
             conversations: state.conversations.map((conversation) =>
@@ -2785,11 +2908,13 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           runId,
         );
         if (followUpGenerationPrompt) {
-          void handleSend(followUpGenerationPrompt, [], {
+          void handleSend(followUpGenerationPrompt, followUpReferences, {
             conversationId: capturedConvId,
             clearDraft: false,
             intentPhase: "generation",
             systemGenerated: true,
+            userMessageVisibility: "internal",
+            preferredSkillNames: intentContextsRef.current[capturedConvId]?.skillNames,
             forceRefresh: true,
           });
         }
@@ -2877,16 +3002,86 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         title,
         hint: requestHint,
         outputMode: config?.outputMode,
+        skillNames: getOutlineSkillNames(title),
       });
       if (canTransitionOutlineWorkflow(outlineWorkflowStages[capturedConvId] ?? "idle", "intent_analysis")) {
         setOutlineWorkflowStages((stages) => setOutlineSessionValue(stages, capturedConvId, "intent_analysis"));
       }
       const intentPrompt = buildIntentAnalysisPrompt(title, requestHint);
-      void handleSend(intentPrompt, [], { conversationId: capturedConvId, intentPhase: "intent_analysis", systemGenerated: true });
+      void handleSend(intentPrompt, [], {
+        conversationId: capturedConvId,
+        intentPhase: "intent_analysis",
+        systemGenerated: true,
+        userDisplayText: `生成${title}`,
+      });
+    },
+    [activeConversationId, createConversation, handleSend, outlineWorkflowStages],
+  );
+
+  const handleDirectSubmit = useCallback(
+    async (text: string, references: ReferenceToken[] = []) => {
+      const directRequest = classifyDirectOutlineGenerationRequest(text);
+      if (!directRequest) return handleSend(text, references);
+
+      const capturedConvId = activeConversationId ?? createConversation();
+      intentContextsRef.current = setOutlineSessionValue(intentContextsRef.current, capturedConvId, {
+        title: directRequest.module,
+        hint: text.trim(),
+        originalRequest: text.trim(),
+        references: [...references],
+        skillNames: getOutlineSkillNames(directRequest.module || text),
+      });
+      if (canTransitionOutlineWorkflow(outlineWorkflowStages[capturedConvId] ?? "idle", "intent_analysis")) {
+        setOutlineWorkflowStages((stages) => setOutlineSessionValue(stages, capturedConvId, "intent_analysis"));
+      }
+      return handleSend(text, references, {
+        conversationId: capturedConvId,
+        intentPhase: "intent_analysis",
+      });
     },
     [activeConversationId, createConversation, handleSend, outlineWorkflowStages],
   );
 
+  const handleContinueIntentGeneration = useCallback(
+    async (messageId: string, result: IntentClarityResult) => {
+      if (!activeConversationId || !canStartConversationRun(activeConversationId)) return;
+      const conversation = useOutlineChatStore.getState().conversations
+        .find((item) => item.id === activeConversationId);
+      const messageIndex = conversation?.messages.findIndex((message) => message.id === messageId) ?? -1;
+      if (!conversation || messageIndex < 0) return;
+      const originalUserMessage = [...conversation.messages.slice(0, messageIndex)]
+        .reverse()
+        .find((message) => message.role === "user" && !isInternalOutlineMessage(message));
+      if (!originalUserMessage) return;
+
+      const directRequest = classifyDirectOutlineGenerationRequest(originalUserMessage.content);
+      const context = {
+        title: result.module || directRequest?.module || "大纲",
+        hint: originalUserMessage.content,
+        originalRequest: originalUserMessage.content,
+        references: originalUserMessage.attachedReferences ?? [],
+        skillNames: getOutlineSkillNames(result.module || directRequest?.module || originalUserMessage.content),
+        result,
+      };
+      intentContextsRef.current = setOutlineSessionValue(intentContextsRef.current, activeConversationId, context);
+      setOutlineWorkflowStages((stages) => setOutlineSessionValue(stages, activeConversationId, "sufficiency_check"));
+      await handleSend(
+        buildGenerationPrompt(context.title, context.hint, result.detectedScope, undefined, context.originalRequest),
+        context.references,
+        {
+          conversationId: activeConversationId,
+          clearDraft: false,
+          intentPhase: "generation",
+          systemGenerated: true,
+          userMessageVisibility: "internal",
+          preferredSkillNames: context.skillNames,
+          forceRefresh: true,
+        },
+      );
+    },
+    [activeConversationId, canStartConversationRun, handleSend],
+  );
+
   const handleSendMessage = useCallback(
     async (text: string, options?: { intentPhase?: "intent_analysis" | "generation" | "waiting_user_input"; scope?: string }) => {
       const capturedConvId = activeConversationId;
@@ -2910,9 +3105,26 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           }
           const scope = options.scope || text;
           const intentContext = intentContextsRef.current[capturedConvId] ?? { title: "", hint: "" };
-          const generationPrompt = buildGenerationPrompt(intentContext.title, intentContext.hint, scope, intentContext.outputMode);
-          const references = outlineReferenceTokens;
-          const result = await handleSend(generationPrompt, references, { conversationId: capturedConvId, intentPhase: "generation", clearDraft: false, systemGenerated: true, forceRefresh: true });
+          const generationPrompt = buildGenerationPrompt(
+            intentContext.title,
+            intentContext.hint,
+            scope,
+            intentContext.outputMode,
+            intentContext.originalRequest,
+          );
+          const references = Array.from(new Map([
+            ...(intentContext.references ?? []),
+            ...outlineReferenceTokens,
+          ].map((reference) => [reference.id, reference])).values());
+          const result = await handleSend(generationPrompt, references, {
+            conversationId: capturedConvId,
+            intentPhase: "generation",
+            clearDraft: false,
+            systemGenerated: true,
+            userMessageVisibility: "internal",
+            preferredSkillNames: intentContext.skillNames ?? getOutlineSkillNames(intentContext.title || scope),
+            forceRefresh: true,
+          });
           if (result.sent) {
             if (shouldClearOutlineReferences({
               invocationConversationId: capturedConvId,
@@ -2994,6 +3206,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       try {
         let contextHubResult: ContextHubResult | null = null;
         let providerUsage: LlmUsage | undefined;
+        let lastProviderUsage: LlmUsage | undefined;
         let memoryDecision: UserMemoryDecision | null | undefined;
         let llmRequestCount = 0;
         try {
@@ -3132,6 +3345,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               onError: (error) => { agentError = error; },
             }, controller.signal);
             providerUsage = addLlmUsage(providerUsage, record.usage);
+            lastProviderUsage = record.lastRequestUsage ?? record.usage ?? lastProviderUsage;
             llmRequestCount += Math.max(1, record.roundsUsed || 1);
             if (memoryDecision === undefined && record.userMemoryDecision !== undefined) {
               memoryDecision = record.userMemoryDecision;
@@ -3170,6 +3384,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               onError: (error) => { mergeError = error; },
             }, controller.signal);
             providerUsage = addLlmUsage(providerUsage, record.usage);
+            lastProviderUsage = record.lastRequestUsage ?? record.usage ?? lastProviderUsage;
             llmRequestCount += Math.max(1, record.roundsUsed || 1);
             if (memoryDecision === undefined && record.userMemoryDecision !== undefined) {
               memoryDecision = record.userMemoryDecision;
@@ -3198,7 +3413,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               getContextHub(normalizePath(project.path)),
               `${messageId}:${runId}`,
               contextHubResult,
-              providerUsage,
+              lastProviderUsage ?? providerUsage,
               {
                 memoryDecision: memoryDecision ?? null,
                 requestDiagnostics: buildLlmRequestDiagnostics(
@@ -3222,7 +3437,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           windowTokens: getEffectiveMaxContextSize(effectiveLlmConfig),
           systemPrompt: contextHubResult ? baseSystemPrompt : systemPrompt,
           contextHubResult,
-          usage: providerUsage,
+          usage: lastProviderUsage ?? providerUsage,
         });
 
         // 更新最终状态
@@ -3369,6 +3584,44 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         .conversations.find((c) => c.id === activeConversationId);
       if (!conv) return;
       const capturedConvId = activeConversationId;
+      const targetAssistantMessage = conv.messages[msgIndex];
+      let precedingUserIndex = msgIndex - 1;
+      while (precedingUserIndex >= 0 && conv.messages[precedingUserIndex]?.role !== "user") {
+        precedingUserIndex -= 1;
+      }
+      const precedingUserMessage = precedingUserIndex >= 0 ? conv.messages[precedingUserIndex] : undefined;
+      const historicalIntent = targetAssistantMessage?.role === "assistant"
+        ? parseIntentClarityProtocol(targetAssistantMessage.content)
+        : { kind: "none" as const };
+      const regenerateAsIntentAnalysis = targetAssistantMessage?.intentPhase === "intent_analysis"
+        || (targetAssistantMessage?.intentPhase == null
+          && historicalIntent.kind !== "none"
+          && Boolean(precedingUserMessage && classifyDirectOutlineGenerationRequest(precedingUserMessage.content)));
+      if (regenerateAsIntentAnalysis && precedingUserMessage) {
+        const precedingUserContent = getOutlineMessageModelContent(precedingUserMessage);
+        const directRequest = classifyDirectOutlineGenerationRequest(precedingUserContent);
+        intentContextsRef.current = setOutlineSessionValue(intentContextsRef.current, capturedConvId, {
+          title: directRequest?.module || (historicalIntent.kind === "valid" ? historicalIntent.result.module : "大纲"),
+          hint: precedingUserContent,
+          originalRequest: precedingUserContent,
+          references: precedingUserMessage.attachedReferences ?? [],
+          skillNames: getOutlineSkillNames(directRequest?.module || precedingUserContent),
+        });
+        useOutlineChatStore.setState((state) => ({
+          conversations: state.conversations.map((conversation) => conversation.id === capturedConvId
+            ? { ...conversation, messages: conversation.messages.slice(0, precedingUserIndex) }
+            : conversation),
+        }));
+        setOutlineWorkflowStages((stages) => setOutlineSessionValue(stages, capturedConvId, "intent_analysis"));
+        await handleSend(precedingUserContent, precedingUserMessage.attachedReferences ?? [], {
+          conversationId: capturedConvId,
+          clearDraft: false,
+          intentPhase: "intent_analysis",
+          forceRefresh: true,
+        });
+        return;
+      }
+      const regenerationIntentPhase = targetAssistantMessage?.intentPhase;
       const runId = crypto.randomUUID();
       if (!startConversationRun(capturedConvId, runId)) return;
       const controller = new AbortController();
@@ -3442,6 +3695,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           agentToolCalls: [],
           isAgentRunning: true,
           contextHubSnapshot,
+          intentPhase: regenerationIntentPhase,
         });
         assistantAdded = true;
 
@@ -3457,9 +3711,19 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           projectName: project.name,
           soulDoc,
         });
-        const systemContent: AgentMessage["content"] = contextHubResult
-          ? buildContextHubSystemContent(baseSystemPrompt, contextHubResult)
-          : legacySystemPrompt;
+        const regenerationPhaseRules = buildIntentPhaseSystemRules(regenerationIntentPhase);
+        const regenerationContext = intentContextsRef.current[capturedConvId];
+        const regenerationSkillNames = regenerationContext?.skillNames
+          ?? getOutlineSkillNames(regenerationContext?.title || lastUserRequest);
+        const regenerationSkills = resolveAvailableSkillsByNames(
+          outlineWritingSkills,
+          regenerationSkillNames,
+        );
+        const regenerationSkillPrompt = buildSelectedSkillsPrompt(regenerationSkills.skills);
+        const baseSystemContent: AgentMessage["content"] = contextHubResult
+          ? buildContextHubSystemContent(baseSystemPrompt, contextHubResult, [regenerationPhaseRules])
+          : [legacySystemPrompt, regenerationPhaseRules].filter(Boolean).join("\n\n");
+        const systemContent = appendSystemRules(baseSystemContent, regenerationSkillPrompt);
         const systemPrompt = typeof systemContent === "string"
           ? systemContent
           : flattenContextHubSystemContent(systemContent);
@@ -3492,7 +3756,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                 useOutlineChatStore.getState().conversations,
               ),
             llmConfig: effectiveLlmConfig,
-            disabledTools: OUTLINE_CHAT_DISABLED_TOOLS,
+            disabledTools: mergeDisabledTools(
+              OUTLINE_CHAT_DISABLED_TOOLS,
+              regenerationSkillNames.length > 0 ? ["apply_skill"] : [],
+            ),
             ...(contextHubResult
               ? { readTextFile: contextHubResult.readFile }
               : {}),
@@ -3577,7 +3844,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               getContextHub(normalizePath(project.path)),
               assistantId,
               contextHubResult,
-              record.usage,
+              record.lastRequestUsage ?? record.usage,
               {
                 memoryDecision: record.userMemoryDecision,
                 requestDiagnostics: buildLlmRequestDiagnostics(
@@ -3605,19 +3872,22 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             content: messageContentToText(message.content),
           })),
           currentInput: lastUserRequest,
-          usage: record.usage,
+          usage: record.lastRequestUsage ?? record.usage,
         });
 
         const sources = [
           ...outlineToolCallsToSources(record.toolCalls),
+          ...regenerationSkills.missingNames.map((name) => `Skill 缺失(未强制启用): ${name}`),
         ];
+        const rawRegenerationContent = result || record.finalText || "AI大纲未返回内容。";
+        const rawRegenerationIntentProtocol = parseIntentClarityProtocol(rawRegenerationContent);
         const nextStepExtraction = extractNextStep(
-          result || record.finalText || "AI大纲未返回内容。",
+          rawRegenerationContent,
           { allowFallback: true, completedModule: "当前模块" },
         );
         const cleanFinalContent = nextStepExtraction.cleanText || "AI大纲未返回内容。";
         const finalContent = await finalizeStructuredMarkdownMessage(cleanFinalContent, {
-          enabled: regenerationInput.structuredGeneration,
+          enabled: regenerationInput.structuredGeneration && rawRegenerationIntentProtocol.kind === "none",
           repairWithAi: ({ content, maxTokens }) => repairMarkdownFormatWithAi({
             content,
             llmConfig: effectiveLlmConfig,
@@ -3629,6 +3899,16 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           }),
         });
         if (!isCurrentRun()) return;
+        const regenerationIntentProtocol = rawRegenerationIntentProtocol.kind !== "none"
+          ? rawRegenerationIntentProtocol
+          : parseIntentClarityProtocol(finalContent);
+        const regenerationIntentProtocolError = regenerationIntentPhase === "generation"
+          && regenerationIntentProtocol.kind !== "none"
+          ? "正文生成阶段返回了 intent_clarity,已阻止重复意图分析和自动循环。"
+          : regenerationIntentPhase === "intent_analysis"
+            && regenerationIntentProtocol.kind !== "valid"
+            ? `意图分析格式无效,尚未开始生成:${regenerationIntentProtocol.kind === "invalid" ? regenerationIntentProtocol.error : "模型未返回 intent_clarity 协议块"}`
+            : undefined;
         updateOutlineAssistantMessage(
           capturedConvId,
           assistantId,
@@ -3639,7 +3919,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             sources,
             agentToolCalls: settleRunningAgentToolCalls(record.toolCalls.length ? record.toolCalls : message.agentToolCalls),
             isAgentRunning: false,
-            nextStepRecommendation: nextStepExtraction.recommendation,
+            nextStepRecommendation: regenerationIntentProtocolError ? null : nextStepExtraction.recommendation,
+            intentProtocolError: regenerationIntentProtocolError,
           }),
         );
         setConversationContextSummary(capturedConvId, buildSessionContextSummary({
@@ -3651,7 +3932,9 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           dependencyFingerprint: contextHubResult?.dependencyStamp.fingerprint ?? "",
         }));
         if (!isCurrentRun()) return;
-        await handleAutoSaveOutlineRequests(capturedConvId, finalContent, isCurrentRun);
+        if (regenerationIntentProtocol.kind === "none" && !regenerationIntentProtocolError) {
+          await handleAutoSaveOutlineRequests(capturedConvId, finalContent, isCurrentRun);
+        }
         if (!isCurrentRun()) return;
         clearStreamingContent(capturedConvId);
         finishConversationRun(
@@ -3716,6 +3999,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       activeConv,
       activeConversationId,
       addMessage,
+      handleSend,
       handleAutoSaveOutlineRequests,
       outlineWritingSkills,
       clearStreamingContent,
@@ -4106,7 +4390,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             会基于当前大纲和章节内容进行回答和创作。
           </p>
         ) : null}
-        {activeMessages.map((msg, i) => (
+        {activeMessages.map((msg, i) => isInternalOutlineMessage(msg) ? null : (
           <div
             key={msg.id}
             className={`flex w-full min-w-0 max-w-full ${msg.role === "user" ? "justify-end" : "justify-start"}`}
@@ -4133,6 +4417,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                   onConfirmToolSave={handleConfirmToolSave}
                   onRejectTool={handleRejectTool}
                   onSendMessage={handleSendMessage}
+                  onContinueIntentGeneration={handleContinueIntentGeneration}
                   onResumeMultiAgent={handleResumeMultiAgent}
                   resumeMultiAgentDisabled={isStreaming}
                   nextStepDisabled={submitDisabled}
@@ -4211,7 +4496,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             outlineReferenceTokensRef.current = tokens;
             setOutlineReferenceTokens(tokens);
           }}
-          onSubmit={handleSend}
+          onSubmit={handleDirectSubmit}
           onAtTrigger={() => setReferencePickerOpen(true)}
           insertTokensRef={insertReferenceTokensRef}
           leftFooterControls={

+ 0 - 1
src/lib/agent/ai-chat-workflow-convergence.spec.ts

@@ -196,7 +196,6 @@ describe("AI chat workflow convergence", () => {
       "read_memory",
       "search_chapters",
       "write_chapter",
-      "apply_skill",
     ]))
     expect(result.selectedCapabilities).toContainEqual(expect.objectContaining({ kind: "user_skill", skillId: "three-turns" }))
     expect(result.selectedCapabilities).toContainEqual(expect.objectContaining({ toolName: "write_chapter", permission: "confirm" }))

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

@@ -47,6 +47,14 @@ export function createBuildSystemPromptPlugin(deps: BuildSystemPromptPluginDeps
           parts.push(selectedSkillsPrompt)
           rulesParts.push(selectedSkillsPrompt)
         }
+        const missingSkillNames = Array.isArray(input.missingSkillNames)
+          ? input.missingSkillNames.filter((name): name is string => typeof name === "string")
+          : []
+        if (missingSkillNames.length > 0) {
+          const diagnostic = `## Skill 路由诊断\n以下确定性 Skill 缺失或已被用户禁用,禁止通过 apply_skill 强制启用:${missingSkillNames.join("、")}。请按已加载规则继续,并向用户保留该诊断。`
+          parts.push(diagnostic)
+          rulesParts.push(diagnostic)
+        }
 
         const routeForWriting = input.effectiveTaskRoute || input.taskRoute
 

+ 21 - 1
src/lib/agent/plugins/select-capabilities-plugin.spec.ts

@@ -252,8 +252,28 @@ describe("SelectCapabilitiesPlugin", () => {
       .filter((n): n is string => Boolean(n))
 
     expect(toolNames).toContain("write_chapter")
-    expect(toolNames).toContain("apply_skill")
+    expect(toolNames).not.toContain("apply_skill")
     expect(toolNames).toContain("run_chapter_workflow")
     expect(toolNames).toContain("read_chapter")
   })
+
+  it("removes apply_skill when deterministic skills are already injected", async () => {
+    const plugin = createSelectCapabilitiesPlugin()
+    const availableCapabilities = buildAvailableCapabilities({
+      toolNames: ["read_chapter", "apply_skill", "run_chapter_workflow"],
+    })
+    const result = await plugin.run({
+      userMessage: "写第一章",
+      projectPath: "/project",
+      agentConfig: {} as any,
+      novelMode: true,
+      aiWorkflowMode: "strict",
+      availableCapabilities,
+      selectedSkills: [{ id: "skillhub:long-form-drafting" }] as any,
+      taskRoute: { intent: "write_chapter", confidence: 0.9, extractedParams: {} },
+    })
+
+    expect(result.enabledToolNames).not.toContain("apply_skill")
+    expect(result.enabledToolNames).toContain("run_chapter_workflow")
+  })
 })

+ 10 - 2
src/lib/agent/plugins/select-capabilities-plugin.ts

@@ -3,6 +3,7 @@ import { buildAvailableCapabilities } from "../capabilities/registry"
 import { selectCapabilities } from "../capabilities/selector"
 import { resolveAiWorkflowMode } from "../workflow-mode"
 import { detectLocalEntityMiss } from "@/lib/novel/local-entity-names"
+import { getOutlineSkillNames, getWritingSkillNames } from "@/lib/novel/skill-route-registry"
 
 const PLAN_PHASE_ALLOWED_TOOLS = new Set([
   "read_chapter",
@@ -61,10 +62,17 @@ export function createSelectCapabilitiesPlugin(): PrePlugin {
             (cap) => cap.toolName && PLAN_PHASE_ALLOWED_TOOLS.has(cap.toolName),
           )
         : selectedCapabilities
+      const injectedSkillIds = new Set((input.selectedSkills ?? []).map((skill) => skill.id))
+      const deterministicSkillNames = route.intent === "generate_outline"
+        ? getOutlineSkillNames(input.userMessage)
+        : getWritingSkillNames(route.intent, input.userMessage)
+      const capabilitiesWithoutDuplicateSkillTool = injectedSkillIds.size > 0 || deterministicSkillNames.length > 0
+        ? filteredCapabilities.filter((capability) => capability.toolName !== "apply_skill")
+        : filteredCapabilities
 
       return {
-        selectedCapabilities: filteredCapabilities,
-        enabledToolNames: filteredCapabilities
+        selectedCapabilities: capabilitiesWithoutDuplicateSkillTool,
+        enabledToolNames: capabilitiesWithoutDuplicateSkillTool
           .map((capability) => capability.toolName)
           .filter((name): name is string => Boolean(name)),
       }

+ 18 - 3
src/lib/agent/plugins/select-skills-plugin.spec.ts

@@ -23,7 +23,7 @@ const availableSkills = [
   skill({ id: "conflict", name: "冲突升级", kind: ["structure"], stages: ["drafting"], modes: ["standard", "strict"] }),
   skill({ id: "plot-review", name: "剧情自检", kind: ["review"], stages: ["review"], modes: ["standard", "strict"] }),
   skill({ id: "output-protocol", name: "正文输出协议", kind: ["output"], stages: ["output"], modes: ["fast", "standard", "strict"] }),
-  skill({ id: "de-ai", name: "去AI味", kind: ["style"], stages: ["rewrite", "output"], modes: ["fast", "standard", "strict"] }),
+  skill({ id: "de-ai", name: "基础去AI味", kind: ["style"], stages: ["rewrite", "output"], modes: ["fast", "standard", "strict"] }),
   skill({ id: "mainline", name: "主线检查", kind: ["review"], stages: ["review"], modes: ["strict"] }),
   skill({ id: "foreshadow", name: "伏笔管理", kind: ["structure", "review"], stages: ["planning", "review"], modes: ["strict"] }),
   skill({ id: "pace", name: "节奏检查", kind: ["review"], stages: ["review"], modes: ["strict"] }),
@@ -47,7 +47,7 @@ describe("SelectSkillsPlugin", () => {
 
     expect(result.selectedSkills?.map((item) => item.name)).toEqual([
       "正文输出协议",
-      "去AI味",
+      "基础去AI味",
     ])
   })
 
@@ -78,7 +78,7 @@ describe("SelectSkillsPlugin", () => {
 
     expect(result.selectedSkills?.map((item) => item.name)).toEqual([
       "正文输出协议",
-      "去AI味",
+      "基础去AI味",
     ])
   })
 
@@ -113,6 +113,7 @@ describe("SelectSkillsPlugin", () => {
 
     expect(result.selectedSkills?.map((item) => item.name)).toEqual([
       "正文输出协议",
+      "基础去AI味",
     ])
   })
 
@@ -203,4 +204,18 @@ describe("SelectSkillsPlugin", () => {
 
     expect(selected.map((item) => item.name)).toEqual(["正文输出协议", "场景描写"])
   })
+
+  it("injects canonical drafting and scene skills before Chinese helpers", () => {
+    const selected = selectSkillsForRoute([
+      skill({ id: "long", name: "long-form-drafting", kind: ["output"], stages: ["drafting"], modes: ["standard", "strict"], categoryId: SKILL_ROUTE_CATEGORY_IDS.writing }),
+      skill({ id: "combat", name: "combat-action", kind: ["style"], stages: ["drafting"], modes: ["standard", "strict"], categoryId: SKILL_ROUTE_CATEGORY_IDS.writing }),
+      skill({ id: "protocol", name: "正文输出协议", kind: ["output"], stages: ["output"], modes: ["standard", "strict"], categoryId: SKILL_ROUTE_CATEGORY_IDS.writing }),
+    ], "write_chapter", "standard", "编写一章战斗场景")
+
+    expect(selected.map((item) => item.name)).toEqual([
+      "long-form-drafting",
+      "combat-action",
+      "正文输出协议",
+    ])
+  })
 })

+ 36 - 10
src/lib/agent/plugins/select-skills-plugin.ts

@@ -3,6 +3,11 @@ import { resolveAiWorkflowMode, type AiWorkflowMode } from "../workflow-mode"
 import type { NovelTaskIntent } from "@/lib/novel/task-router"
 import type { SkillKind, SkillStage, UserSkill } from "@/lib/novel/skill-library"
 import { filterSkillsForSkillRoute, filterSkillsForSkillRoutes, inferSkillRoute, type SkillRoute } from "@/lib/novel/skill-route"
+import {
+  getOutlineSkillNames,
+  getWritingSkillNames,
+  resolveAvailableSkillsByNames,
+} from "@/lib/novel/skill-route-registry"
 
 const WRITING_INTENTS = new Set<NovelTaskIntent>([
   "write_chapter",
@@ -37,7 +42,7 @@ const STRICT_WRITING_SKILL_NAMES = [
   "结尾钩子",
 ]
 
-const FAST_WRITING_SKILL_NAMES = ["正文输出协议", "去AI味"]
+const FAST_WRITING_SKILL_NAMES = ["正文输出协议", "基础去AI味"]
 
 const EXCLUDED_FROM_FALLBACK = ["去AI味"]
 const OUTLINE_SUPPORT_ROUTES: SkillRoute[] = [
@@ -61,11 +66,18 @@ export function createSelectSkillsPlugin(): PrePlugin {
       if (!route) return { selectedSkills: [] }
 
       const availableSkills = input.availableSkills ?? []
-      if (availableSkills.length === 0) return { selectedSkills: [] }
-
       const mode = resolveAiWorkflowMode(input.aiWorkflowMode)
+      const deterministicNames = route.intent === "generate_outline"
+        ? getOutlineSkillNames(input.userMessage)
+        : getWritingSkillNames(route.intent, input.userMessage)
+      const selectedSkills = selectSkillsForRoute(availableSkills, route.intent, mode, input.userMessage)
       return {
-        selectedSkills: selectSkillsForRoute(availableSkills, route.intent, mode),
+        selectedSkills,
+        missingSkillNames: deterministicNames.length > 0
+          ? resolveAvailableSkillsByNames(mode === "fast"
+              ? availableSkills
+              : availableSkills.filter((skill) => skill.modes.includes(mode)), deterministicNames).missingNames
+          : [],
       }
     },
   }
@@ -75,6 +87,7 @@ export function selectSkillsForRoute(
   skills: UserSkill[],
   intent: NovelTaskIntent,
   mode: AiWorkflowMode,
+  requestText = "",
 ): UserSkill[] {
   if (mode === "fast") return []
 
@@ -82,10 +95,15 @@ export function selectSkillsForRoute(
   if (modeSkills.length === 0) return []
 
   if (WRITING_INTENTS.has(intent)) {
-    return selectWritingSkills(modeSkills, mode)
+    return selectWritingSkills(modeSkills, mode, intent, requestText)
   }
 
   if (intent === "generate_outline") {
+    const routedNames = getOutlineSkillNames(requestText)
+    if (routedNames.length > 0) {
+      const routed = resolveAvailableSkillsByNames(modeSkills, routedNames).skills
+      if (routed.length > 0) return routed
+    }
     return selectOutlineSkills(modeSkills, mode)
   }
 
@@ -130,17 +148,25 @@ function selectOutlineSkills(skills: UserSkill[], mode: Exclude<AiWorkflowMode,
   return selectByShape(skills, mode, options)
 }
 
-function selectWritingSkills(skills: UserSkill[], mode: Exclude<AiWorkflowMode, "fast">): UserSkill[] {
+function selectWritingSkills(
+  skills: UserSkill[],
+  mode: Exclude<AiWorkflowMode, "fast">,
+  intent: NovelTaskIntent,
+  requestText: string,
+): UserSkill[] {
   const writingSkills = skills.filter((skill) => {
     const route = inferSkillRoute(skill)
     return route === "writing" || route === null
   })
   const scopedSkills = writingSkills.length > 0 ? writingSkills : skills
+  const primaryNames = getWritingSkillNames(intent, requestText)
+  const supportNames = mode === "strict" ? STRICT_WRITING_SKILL_NAMES : FAST_WRITING_SKILL_NAMES
+  const preferredNames = [...primaryNames, ...supportNames]
   if (mode === "standard") {
-    return selectPreferredNames(scopedSkills, FAST_WRITING_SKILL_NAMES, 3, false)
+    return selectPreferredNames(scopedSkills, preferredNames, Math.max(3, primaryNames.length + 2), false)
   }
   if (mode === "strict") {
-    return selectPreferredNames(scopedSkills, STRICT_WRITING_SKILL_NAMES, 12)
+    return selectPreferredNames(scopedSkills, preferredNames, 14)
   }
   return []
 }
@@ -148,7 +174,7 @@ function selectWritingSkills(skills: UserSkill[], mode: Exclude<AiWorkflowMode,
 function selectPreferredNames(skills: UserSkill[], names: string[], limit: number, fillWithRelevant = true): UserSkill[] {
   const selected: UserSkill[] = []
   for (const name of names) {
-    const skill = skills.find((item) => item.name.includes(name))
+    const skill = skills.find((item) => item.name === name || item.id === name)
     if (skill && !selected.some((item) => item.id === skill.id)) {
       selected.push(skill)
     }
@@ -156,7 +182,7 @@ function selectPreferredNames(skills: UserSkill[], names: string[], limit: numbe
 
   const fallback = skills
     .filter((skill) => isWritingSkill(skill))
-    .filter((skill) => !EXCLUDED_FROM_FALLBACK.some((name) => skill.name.includes(name)))
+    .filter((skill) => !EXCLUDED_FROM_FALLBACK.some((name) => skill.name === name))
     .sort((a, b) => (a.priority ?? 50) - (b.priority ?? 50))
 
   if (selected.length > 0) {

+ 50 - 1
src/lib/agent/runner.spec.ts

@@ -399,6 +399,7 @@ describe("AgentRunner", () => {
 
   it("aggregates provider usage across agent rounds", async () => {
     let round = 0
+    const onUsage = vi.fn()
     mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
       round += 1
       if (round === 1) {
@@ -415,7 +416,7 @@ describe("AgentRunner", () => {
       { maxRounds: 2, tools: [], systemPrompt: "", llmConfig: mockLlmConfig },
       registry,
       [systemMsg, userMsg],
-      { onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
+      { onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError: vi.fn(), onUsage, onDone: vi.fn(), onError: vi.fn() },
     )
 
     expect(result.usage).toEqual({
@@ -423,6 +424,12 @@ describe("AgentRunner", () => {
       outputTokens: 100,
       cachedInputTokens: 1100,
     })
+    expect(result.lastRequestUsage).toEqual({
+      inputTokens: 700,
+      outputTokens: 80,
+      cachedInputTokens: 500,
+    })
+    expect(onUsage).toHaveBeenLastCalledWith(result.lastRequestUsage)
   })
 
   it("executes tool calls and continues the loop", async () => {
@@ -773,6 +780,48 @@ describe("AgentRunner", () => {
     expect(onToolError).toHaveBeenCalledOnce()
   })
 
+  it.each(["错误:未找到 Skill", "错误: read failed"])(
+    "treats an error-prefixed tool result as an error and still lets the model recover: %s",
+    async (toolResult) => {
+      const tool: Tool = {
+        name: "soft_error_tool",
+        description: "",
+        category: "read",
+        parameters: {},
+        execute: vi.fn().mockResolvedValue(toolResult),
+      }
+      registry.register(tool)
+      mockStreamChat
+        .mockImplementationOnce(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+          cb.onToolCallDelta?.({ index: 0, id: "soft_error_1", name: "soft_error_tool" })
+          cb.onToolCallDelta?.({ index: 0, arguments: "{}" })
+          cb.onDone()
+        })
+        .mockImplementationOnce(async (_config: unknown, messages: unknown[], cb: StreamCallbacks) => {
+          expect(JSON.stringify(messages)).toContain(toolResult)
+          cb.onToken("已改用其他方法恢复")
+          cb.onDone()
+        })
+
+      const onToolError = vi.fn()
+      const onToolResult = vi.fn()
+      const onToolEvent = vi.fn()
+      const result = await runner.run(
+        { maxRounds: 3, tools: [tool], systemPrompt: "", llmConfig: mockLlmConfig },
+        registry,
+        [systemMsg, userMsg],
+        { onText: vi.fn(), onToolCall: vi.fn(), onToolResult, onToolError, onToolEvent, onDone: vi.fn(), onError: vi.fn() },
+        undefined,
+      )
+
+      expect(onToolResult).not.toHaveBeenCalled()
+      expect(onToolError).toHaveBeenCalledWith("soft_error_1", toolResult)
+      expect(onToolEvent).toHaveBeenCalledWith(expect.objectContaining({ type: "error", result: toolResult }))
+      expect(result.toolCalls[0].status).toBe("error")
+      expect(result.finalText).toBe("已改用其他方法恢复")
+    },
+  )
+
   it("allows long-running workflow tools to opt out of the generic 30 second timeout", async () => {
     vi.useFakeTimers()
     try {

+ 35 - 13
src/lib/agent/runner.ts

@@ -15,7 +15,7 @@ import {
 } from "./task-breakpoint"
 import { getEffectiveMaxContextSize, type ChatMessage } from "../llm-providers"
 import { isReasoningDisabled, isReasoningOnlyResponseError, withReasoningDisabled } from "../reasoning-retry"
-import { addLlmUsage } from "../llm-usage"
+import { addLlmUsage, mergeLlmUsageSnapshot, type LlmUsage } from "../llm-usage"
 import { trimChatMessagesToTokenBudget } from "../chat-request-budget"
 import { logReasoningReplay } from "../reasoning-replay-debug"
 import { ToolEvidenceLedger } from "./tool-evidence-ledger"
@@ -24,6 +24,7 @@ import {
   buildRequiredToolNudgeMessage,
   missingRequiredToolsOnce,
 } from "./required-tools-gate"
+import { isToolErrorResult } from "./tool-result"
 
 export class ModelDoesNotSupportToolsError extends Error {
   constructor() {
@@ -128,6 +129,7 @@ export class AgentRunner {
       let roundText = ""
       let roundReasoningContent = ""
       let streamError: Error | undefined
+      let roundUsage: LlmUsage | undefined
 
       const streamCallbacks: StreamCallbacks = {
         onToken: (t: string) => {
@@ -141,8 +143,8 @@ export class AgentRunner {
           toolCallDeltas.push(delta)
         },
         onUsage: (usage) => {
-          record.usage = addLlmUsage(record.usage, usage)
-          if (record.usage) callbacks.onUsage?.(record.usage)
+          roundUsage = mergeLlmUsageSnapshot(roundUsage, usage)
+          if (roundUsage) callbacks.onUsage?.(roundUsage)
         },
         onUserMemoryDecision: (decision) => {
           if (record.userMemoryDecision === undefined) {
@@ -208,6 +210,7 @@ export class AgentRunner {
         roundReasoningContent = ""
         toolCallDeltas.length = 0
         streamError = undefined
+        roundUsage = undefined
         requestOverrides = buildRequestOverrides(config.requestOverrides)
         await streamRound()
       }
@@ -247,6 +250,7 @@ export class AgentRunner {
         roundReasoningContent = ""
         toolCallDeltas.length = 0
         streamError = undefined
+        roundUsage = undefined
         requestOverrides = buildRequestOverrides(withReasoningDisabled(config.requestOverrides))
         try {
           await streamRound()
@@ -256,6 +260,11 @@ export class AgentRunner {
         }
       }
 
+      if (roundUsage) {
+        record.lastRequestUsage = { ...roundUsage }
+        record.usage = addLlmUsage(record.usage, roundUsage)
+      }
+
       if (streamError) {
         if (attemptedToolsFallback) {
           return failToolsUnsupported()
@@ -478,17 +487,30 @@ export class AgentRunner {
         try {
           const result = await withToolTimeout(tool.execute(params, signal, executionContext), tool.executeTimeoutMs)
           toolCallRecord.result = result
-          toolCallRecord.status = "done"
           toolCallRecord.finishedAt = Date.now()
-          callbacks.onToolResult(tc.id, result)
-          callbacks.onToolEvent?.({
-            type: "result",
-            callId: tc.id,
-            name: toolName,
-            params,
-            result,
-            timestamp: toolCallRecord.finishedAt,
-          })
+          if (isToolErrorResult(result)) {
+            toolCallRecord.status = "error"
+            callbacks.onToolError(tc.id, result)
+            callbacks.onToolEvent?.({
+              type: "error",
+              callId: tc.id,
+              name: toolName,
+              params,
+              result,
+              timestamp: toolCallRecord.finishedAt,
+            })
+          } else {
+            toolCallRecord.status = "done"
+            callbacks.onToolResult(tc.id, result)
+            callbacks.onToolEvent?.({
+              type: "result",
+              callId: tc.id,
+              name: toolName,
+              params,
+              result,
+              timestamp: toolCallRecord.finishedAt,
+            })
+          }
         } catch (err) {
           toolCallRecord.status = "error"
           toolCallRecord.result = `错误: ${err instanceof Error ? err.message : String(err)}`

+ 6 - 0
src/lib/agent/tool-result.ts

@@ -1,5 +1,11 @@
+const TOOL_ERROR_PREFIX = /^\s*错误\s*[::]/
+
 export const DEFAULT_TOOL_RESULT_CONTEXT_LIMIT = 6000
 
+export function isToolErrorResult(result: string): boolean {
+  return TOOL_ERROR_PREFIX.test(result)
+}
+
 export function formatToolResultForModel(
   toolName: string,
   result: string,

+ 28 - 9
src/lib/agent/tools/apply-skill.ts

@@ -2,37 +2,56 @@ import type { Tool } from "../types"
 import { getAllDeAiSkills } from "@/lib/novel/de-ai-skill-library"
 import type { DeAiSkillConfig } from "@/lib/novel/de-ai-skill-library"
 import type { UserSkill } from "@/lib/novel/skill-library"
+import { resolveSkillReference } from "@/lib/novel/skill-route-registry"
 
 export function createApplySkillTool(
   getConfig: () => DeAiSkillConfig | null,
   getUserSkills?: () => UserSkill[] | null,
 ): Tool {
+  const getAvailableSkills = () => {
+    const config = getConfig()
+    let deAiSkills: ReturnType<typeof getAllDeAiSkills> = []
+    if (config) {
+      try {
+        deAiSkills = getAllDeAiSkills(config)
+      } catch {
+        // 某些宿主只加载通用 Skill,不提供去 AI 味技能库;工具仍可正常列出通用 Skill。
+      }
+    }
+    return [
+      ...(getUserSkills?.() ?? []),
+      ...deAiSkills,
+    ]
+  }
+  const availableNames = [...new Set(getAvailableSkills().map((skill) => skill.name))].sort()
+  const availableNamesText = availableNames.length > 0
+    ? `本轮可用名称:${availableNames.join("、")}。`
+    : "本轮没有已加载的 Skill。"
   return {
     name: "apply_skill",
-    description: "应用写作 Skill。参数 skillName 为技能名称,或 skillId 为技能 ID。返回 Skill 的 prompt 模板内容,AI 可据此调整写作流程或输出风格。",
+    description: `应用写作 Skill。仅支持 Skill ID、完整名称或受控 UI 别名,不支持模糊子串。${availableNamesText}`,
     category: "action",
     parameters: {
-      skillName: { type: "string", description: "Skill 名称,例如「三翻四抖」或「去AI味」" },
+      skillName: { type: "string", description: `Skill 完整名称或受控 UI 别名。${availableNamesText}` },
       skillId: { type: "string", description: "Skill ID,可选,与 skillName 二选一" },
     },
     execute: async (params) => {
       const name = params.skillName as string | undefined
       const id = params.skillId as string | undefined
-      const userSkill = getUserSkills?.()?.find((skill) => matchesSkill(skill, id, name))
+      const userSkills = getUserSkills?.() ?? []
+      const userSkill = resolveSkillReference(userSkills, { id, name })
       if (userSkill) {
         return `Skill「${userSkill.name}」的写作模板:\n\n${userSkill.content}`
       }
 
       const config = getConfig()
-      if (!config) return "错误:技能库配置未加载"
+      if (!config && userSkills.length === 0) return "错误:技能库配置未加载"
 
-      const skill = getAllDeAiSkills(config).find((item) => matchesSkill(item, id, name))
+      const skill = config
+        ? resolveSkillReference(getAllDeAiSkills(config), { id, name })
+        : undefined
       if (!skill) return `错误:未找到 Skill「${name || id}」`
       return `Skill「${skill.name}」的写作模板:\n\n${skill.content}`
     },
   }
 }
-
-function matchesSkill(skill: { id: string; name: string }, id?: string, name?: string): boolean {
-  return Boolean((id && skill.id === id) || (name && skill.name.includes(name)))
-}

+ 15 - 0
src/lib/agent/tools/write-tools.spec.ts

@@ -92,4 +92,19 @@ describe("write tools", () => {
 
     expect(result).toContain("三次转折,四次震惊。")
   })
+
+  it("apply_skill rejects unsafe partial names", async () => {
+    vi.mocked(getAllDeAiSkills).mockReturnValue([])
+    const tool = createApplySkillTool(
+      () => null,
+      () => [{
+        id: "skillhub:long-form-drafting",
+        name: "long-form-drafting",
+        content: "long form rules",
+      }] as any,
+    )
+
+    expect(await tool.execute({ skillName: "long-form" })).toBe("错误:未找到 Skill「long-form」")
+    expect(await tool.execute({ skillName: "long-form-drafting" })).toContain("long form rules")
+  })
 })

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

@@ -134,7 +134,7 @@ export interface AgentRunCallbacks {
   onToolError: (callId: string, error: string) => void
   onToolEvent?: (event: AgentToolEvent) => void
   onActivityEvent?: (event: AgentActivityEvent) => void
-  /** Cumulative prompt/usage so far across agent rounds. */
+  /** Usage for the current/latest provider request. */
   onUsage?: (usage: LlmUsage) => void
   onUserMemoryDecision?: (decision: import("@/lib/user-memory/decision-trace").UserMemoryDecision | null) => void
   onDone: () => void
@@ -164,7 +164,10 @@ export interface AgentRunRecord {
   }[]
   roundsUsed: number
   finalText: string
+  /** Cumulative provider usage across all requests in this agent run. */
   usage?: LlmUsage
+  /** Provider usage for the final request only; used for context-window UI. */
+  lastRequestUsage?: LlmUsage
   /** Memory decision from the first LLM round that applied user memory. */
   userMemoryDecision?: import("@/lib/user-memory/decision-trace").UserMemoryDecision | null
 }

+ 18 - 0
src/lib/novel/novel-generation-request-package.spec.ts

@@ -5,6 +5,7 @@ import {
   getNovelGenerationModelContent,
   getOutlineMessageModelContent,
   isExplicitStructuredGenerationFollowUp,
+  isInternalOutlineMessage,
   mapOutlineConversationsForModel,
   mapOutlineMessagesForModel,
 } from "./novel-generation-request-package"
@@ -47,6 +48,23 @@ describe("???????", () => {
     ])
   })
 
+  it("keeps hidden internal prompts in model history while identifying legacy UI leaks", () => {
+    const internal = {
+      role: "user" as const,
+      content: "继续生成",
+      modelContent: "请按内部完整工作流生成正文",
+      visibility: "internal" as const,
+    }
+    expect(isInternalOutlineMessage(internal)).toBe(true)
+    expect(mapOutlineMessagesForModel([internal])).toEqual([
+      { role: "user", content: "请按内部完整工作流生成正文" },
+    ])
+    expect(isInternalOutlineMessage({
+      role: "user",
+      content: "请按「AI大纲生成工作流」生成「章节细纲」。\n## PRD 3.1 主流程要求\n禁止再次输出 intent_clarity",
+    })).toBe(true)
+  })
+
   it("????????????", () => {
     const value = createNovelGenerationRequestPackage(request, "??????")
     const messages = [

+ 19 - 1
src/lib/novel/novel-generation-request-package.ts

@@ -19,6 +19,8 @@ export interface NovelGenerationRequestPackage {
 type OutlineModelMessage = {
   role: "user" | "assistant"
   content: string
+  modelContent?: string
+  visibility?: "visible" | "internal"
   novelGenerationRequest?: NovelGenerationRequestPackage
   isAgentRunning?: boolean
   reasoning_content?: string
@@ -75,8 +77,10 @@ export function getNovelGenerationModelContent(request: NovelGenerationRequestPa
 
 export function getOutlineMessageModelContent(message: {
   content: string
+  modelContent?: string
   novelGenerationRequest?: NovelGenerationRequestPackage
 }): string {
+  if (message.modelContent?.trim()) return message.modelContent
   return message.novelGenerationRequest
     ? getNovelGenerationModelContent(message.novelGenerationRequest)
     : message.content
@@ -88,7 +92,7 @@ export function mapOutlineMessagesForModel(messages: OutlineModelMessage[]): Arr
   reasoning_content?: string
 }> {
   return messages
-    .filter((message) => message.content.trim() && !message.isAgentRunning)
+    .filter((message) => getOutlineMessageModelContent(message).trim() && !message.isAgentRunning)
     .map((message) => ({
       role: message.role,
       content: getOutlineMessageModelContent(message),
@@ -98,6 +102,20 @@ export function mapOutlineMessagesForModel(messages: OutlineModelMessage[]): Arr
     }))
 }
 
+export function isInternalOutlineMessage(message: {
+  role: "user" | "assistant"
+  content: string
+  visibility?: "visible" | "internal"
+}): boolean {
+  if (message.visibility === "internal") return true
+  if (message.role !== "user") return false
+  const content = message.content.trim()
+  if (content.startsWith("✓ 意图明确(") && content.includes("开始生成")) return true
+  return content.startsWith("请按「AI大纲生成工作流」生成「")
+    && content.includes("## PRD 3.1 主流程要求")
+    && content.includes("禁止再次输出 intent_clarity")
+}
+
 export function buildOutlineRegenerationInput(messages: OutlineModelMessage[]): {
   request: string
   history: Array<{ role: "user" | "assistant"; content: string; reasoning_content?: string }>

+ 58 - 0
src/lib/novel/outline-intent-clarity.spec.ts

@@ -1,6 +1,9 @@
 import { describe, it, expect } from "vitest"
 import {
+  buildIntentPhaseSystemRules,
+  classifyDirectOutlineGenerationRequest,
   parseIntentClarity,
+  parseIntentClarityProtocol,
   shouldAutoFollowUpGeneration,
   stripStructuredMarkers,
 } from "./outline-intent-clarity"
@@ -39,6 +42,61 @@ describe("parseIntentClarity", () => {
 <!-- /intent_clarity -->`
     expect(parseIntentClarity(text)).toBeNull()
   })
+
+  it("兼容现场 status clear 且缺少闭合标记的完整 JSON", () => {
+    const text = `<!-- intent_clarity -->
+{"status":"clear","intent":"完善既有第236章章纲","target":"章纲/第236章-远洋投送.md","scope":"补充细节","basis":["第235章"],"writeMode":"replace"}`
+    const outcome = parseIntentClarityProtocol(text)
+    expect(outcome.kind).toBe("valid")
+    if (outcome.kind !== "valid") return
+    expect(outcome.result.clarity).toBe("clear")
+    expect(outcome.result.module).toBe("章节细纲")
+    expect(outcome.result.detectedScope).toBe("补充细节")
+    expect(outcome.result.analysis).toBe("完善既有第236章章纲")
+    expect(outcome.result.normalizationSource).toBe("legacy_status_unclosed")
+  })
+
+  it("未闭合且 JSON 截断时返回明确协议错误", () => {
+    const outcome = parseIntentClarityProtocol(
+      '<!-- intent_clarity -->\n{"status":"clear","scope":"第236章"',
+    )
+    expect(outcome).toEqual({
+      kind: "invalid",
+      error: "意图分析 JSON 不完整或缺失",
+    })
+  })
+})
+
+describe("classifyDirectOutlineGenerationRequest", () => {
+  it("识别直接章纲完善请求", () => {
+    expect(classifyDirectOutlineGenerationRequest("把236章大纲补充详细")).toEqual({
+      module: "章节细纲",
+    })
+  })
+
+  it("不把普通冲突问答误判为生成请求", () => {
+    expect(classifyDirectOutlineGenerationRequest("第236章有哪些冲突")).toBeNull()
+  })
+
+  it("识别人物、设定和伏笔的修改请求", () => {
+    expect(classifyDirectOutlineGenerationRequest("完善人物小传")?.module).toBe("人物小传")
+    expect(classifyDirectOutlineGenerationRequest("重写世界观设定")?.module).toBe("背景设定")
+    expect(classifyDirectOutlineGenerationRequest("补充伏笔计划")?.module).toBe("伏笔计划")
+    expect(classifyDirectOutlineGenerationRequest("生成故事大纲")?.module).toBe("故事大纲")
+    expect(classifyDirectOutlineGenerationRequest("完善分卷大纲")?.module).toBe("卷纲")
+    expect(classifyDirectOutlineGenerationRequest("补充力量体系")?.module).toBe("力量体系")
+    expect(classifyDirectOutlineGenerationRequest("细化地点设定")?.module).toBe("地理设定")
+  })
+})
+
+describe("buildIntentPhaseSystemRules", () => {
+  it("意图分析阶段要求完整协议,生成阶段禁止再次输出", () => {
+    const analysis = buildIntentPhaseSystemRules("intent_analysis")
+    expect(analysis).toContain('"clarity":"clear|needs_input"')
+    expect(analysis).toContain("<!-- /intent_clarity -->")
+    expect(analysis).toContain("禁止使用 status")
+    expect(buildIntentPhaseSystemRules("generation")).toContain("禁止再次输出 intent_clarity")
+  })
 })
 
 describe("shouldAutoFollowUpGeneration", () => {

+ 148 - 14
src/lib/novel/outline-intent-clarity.ts

@@ -14,9 +14,86 @@ export interface IntentClarityResult {
   missingItems: string[]
   options: IntentClarityOption[]
   question: string
+  normalizationSource?: "canonical" | "legacy_status" | "legacy_unclosed" | "legacy_status_unclosed"
 }
 
-const CLARITY_PATTERN = /<!--\s*intent_clarity\s*-->([\s\S]*?)<!--\s*\/intent_clarity\s*-->/i
+export type IntentClarityParseOutcome =
+  | { kind: "none" }
+  | { kind: "valid"; result: IntentClarityResult }
+  | { kind: "invalid"; error: string }
+
+export interface DirectOutlineGenerationRequest {
+  module: string
+}
+
+const INTENT_OPEN_PATTERN = /<!--\s*intent_clarity\s*-->/i
+const INTENT_CLOSE_PATTERN = /<!--\s*\/intent_clarity\s*-->/i
+const OUTLINE_GENERATION_VERB_PATTERN = /生成|编写|完善|补充|细化|扩写|修改|重写|续写/
+const OUTLINE_GENERATION_TARGETS: Array<{ pattern: RegExp; module: string }> = [
+  { pattern: /(?:第?\s*\d+\s*章[^\n]{0,12}大纲)|章纲|章节细纲|章节大纲/, module: "章节细纲" },
+  { pattern: /卷纲|分卷大纲/, module: "卷纲" },
+  { pattern: /人物|角色/, module: "人物小传" },
+  { pattern: /组织势力|势力设定/, module: "组织势力设定" },
+  { pattern: /力量体系|能力体系/, module: "力量体系" },
+  { pattern: /金手指|系统设定/, module: "金手指设定" },
+  { pattern: /地理设定|地点设定|地图/, module: "地理设定" },
+  { pattern: /背景设定|世界观/, module: "背景设定" },
+  { pattern: /伏笔/, module: "伏笔计划" },
+  { pattern: /大纲质量/, module: "大纲质量检查" },
+  { pattern: /故事大纲|总纲|(?:^|[^章节卷])大纲/, module: "故事大纲" },
+]
+
+function extractCompleteJsonObject(text: string): { json: string; remainder: string } | null {
+  const start = text.indexOf("{")
+  if (start < 0) return null
+  let depth = 0
+  let inString = false
+  let escaped = false
+  for (let index = start; index < text.length; index += 1) {
+    const character = text[index]
+    if (inString) {
+      if (escaped) {
+        escaped = false
+      } else if (character === "\\") {
+        escaped = true
+      } else if (character === '"') {
+        inString = false
+      }
+      continue
+    }
+    if (character === '"') {
+      inString = true
+    } else if (character === "{") {
+      depth += 1
+    } else if (character === "}") {
+      depth -= 1
+      if (depth === 0) {
+        return {
+          json: text.slice(start, index + 1),
+          remainder: text.slice(index + 1),
+        }
+      }
+    }
+  }
+  return null
+}
+
+function inferModule(raw: Record<string, unknown>): string {
+  const explicitModule = String(raw.module ?? "").trim()
+  if (explicitModule) return explicitModule
+  const legacyDescription = [raw.target, raw.scope, raw.intent]
+    .map((value) => String(value ?? ""))
+    .join(" ")
+  return OUTLINE_GENERATION_TARGETS.find(({ pattern }) => pattern.test(legacyDescription))?.module ?? "大纲"
+}
+
+export function classifyDirectOutlineGenerationRequest(
+  text: string,
+): DirectOutlineGenerationRequest | null {
+  if (!OUTLINE_GENERATION_VERB_PATTERN.test(text)) return null
+  const target = OUTLINE_GENERATION_TARGETS.find(({ pattern }) => pattern.test(text))
+  return target ? { module: target.module } : null
+}
 
 export function shouldAutoFollowUpGeneration(
   intentPhase: "intent_analysis" | "generation" | "waiting_user_input" | undefined,
@@ -25,21 +102,46 @@ export function shouldAutoFollowUpGeneration(
 }
 
 export function parseIntentClarity(text: string): IntentClarityResult | null {
-  const match = text.match(CLARITY_PATTERN)
-  if (!match) return null
+  const outcome = parseIntentClarityProtocol(text)
+  return outcome.kind === "valid" ? outcome.result : null
+}
+
+export function parseIntentClarityProtocol(text: string): IntentClarityParseOutcome {
+  const openMatch = INTENT_OPEN_PATTERN.exec(text)
+  if (!openMatch) return { kind: "none" }
+
+  const payloadStart = openMatch.index + openMatch[0].length
+  const afterOpen = text.slice(payloadStart)
+  const closeMatch = INTENT_CLOSE_PATTERN.exec(afterOpen)
+  const hasClosingMarker = Boolean(closeMatch)
+  const unclosedPayload = hasClosingMarker ? null : extractCompleteJsonObject(afterOpen)
+  const payloadText = hasClosingMarker
+    ? afterOpen.slice(0, closeMatch!.index).trim()
+    : unclosedPayload?.json
+  if (!payloadText) {
+    return { kind: "invalid", error: "意图分析 JSON 不完整或缺失" }
+  }
+  if (!hasClosingMarker && unclosedPayload?.remainder.trim()) {
+    return { kind: "invalid", error: "意图分析缺少闭合标记且 JSON 后仍有额外内容" }
+  }
 
   let payload: unknown
   try {
-    payload = JSON.parse(match[1].trim())
+    payload = JSON.parse(payloadText)
   } catch {
-    return null
+    return { kind: "invalid", error: "意图分析 JSON 无法解析" }
   }
 
-  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null
+  if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
+    return { kind: "invalid", error: "意图分析结果必须是 JSON 对象" }
+  }
 
   const raw = payload as Record<string, unknown>
-  const clarity = String(raw.clarity ?? "")
-  if (clarity !== "clear" && clarity !== "needs_input") return null
+  const usedLegacyStatus = raw.clarity == null && raw.status != null
+  const clarity = String(raw.clarity ?? raw.status ?? "")
+  if (clarity !== "clear" && clarity !== "needs_input") {
+    return { kind: "invalid", error: "意图分析缺少有效的 clarity 字段" }
+  }
 
   const options: IntentClarityOption[] = Array.isArray(raw.options)
     ? raw.options
@@ -53,17 +155,25 @@ export function parseIntentClarity(text: string): IntentClarityResult | null {
         .filter((item) => item.id && item.label)
     : []
 
-  return {
+  const result: IntentClarityResult = {
     clarity,
-    module: String(raw.module ?? ""),
-    analysis: String(raw.analysis ?? ""),
-    detectedScope: String(raw.detectedScope ?? ""),
+    module: inferModule(raw),
+    analysis: String(raw.analysis ?? raw.intent ?? ""),
+    detectedScope: String(raw.detectedScope ?? raw.scope ?? raw.target ?? ""),
     missingItems: Array.isArray(raw.missingItems)
       ? raw.missingItems.filter((item): item is string => typeof item === "string")
       : [],
     options,
     question: String(raw.question ?? ""),
+    normalizationSource: !hasClosingMarker && usedLegacyStatus
+      ? "legacy_status_unclosed"
+      : !hasClosingMarker
+        ? "legacy_unclosed"
+        : usedLegacyStatus
+          ? "legacy_status"
+          : "canonical",
   }
+  return { kind: "valid", result }
 }
 
 export function buildIntentAnalysisPrompt(title: string, requestHint: string): string {
@@ -97,14 +207,38 @@ export function buildIntentAnalysisPrompt(title: string, requestHint: string): s
     "",
     "## 输出格式(必须严格遵守)",
     "<!-- intent_clarity -->",
-    '按 JSON 输出,字段:clarity("clear"|"needs_input")、module、analysis、detectedScope(clear时填写)、missingItems(数组)、options(needs_input时填4个选项,clear时为空数组)、question(needs_input时填写自然语言提问)',
+    '{"clarity":"clear|needs_input","module":"模块名","analysis":"判断依据","detectedScope":"明确范围","missingItems":[],"options":[],"question":""}',
     "<!-- /intent_clarity -->",
+    "开闭标记必须成对出现;字段名必须使用 clarity,禁止使用 status。JSON 必须完整且可解析。",
     "",
     "clear 时:只输出上述 JSON,不生成正文。",
-    "needs_input 时:输出 JSON 后,用自然语言在会话中提出澄清问题 + 推荐选项。",
+    "needs_input 时:只输出上述 JSON,在 question 和 options 中提供澄清问题与推荐选项。",
   ].join("\n")
 }
 
+export function buildIntentPhaseSystemRules(
+  intentPhase: "intent_analysis" | "generation" | "waiting_user_input" | undefined,
+): string {
+  if (intentPhase === "intent_analysis") {
+    return [
+      "## 本轮阶段:意图分析",
+      "本轮只判断生成范围,不生成大纲正文。最终输出必须包含且只包含一个完整协议块:",
+      "<!-- intent_clarity -->",
+      '{"clarity":"clear|needs_input","module":"模块名","analysis":"判断依据","detectedScope":"明确范围","missingItems":[],"options":[],"question":""}',
+      "<!-- /intent_clarity -->",
+      "开闭标记必须成对出现;字段名必须使用 clarity,禁止使用 status。JSON 必须完整且可解析。",
+    ].join("\n")
+  }
+  if (intentPhase === "generation") {
+    return [
+      "## 本轮阶段:正文生成",
+      "意图分析已经完成。直接生成可保存的大纲正文。",
+      "禁止再次输出 intent_clarity 标记,禁止重新进入意图分析。",
+    ].join("\n")
+  }
+  return ""
+}
+
 export function stripStructuredMarkers(text: string): string {
   return text
     // 1. 移除完整的标记对(现有逻辑)

+ 9 - 0
src/lib/novel/skill-hub-seed.ts

@@ -1,5 +1,6 @@
 import { normalizeUserSkill, type SkillKind, type SkillStage, type UserSkill } from "./skill-library"
 import { SKILL_ROUTE_CATEGORY_IDS } from "./skill-route"
+import { validateSkillRouteRegistry } from "./skill-route-registry"
 
 const skillHubModules = import.meta.glob("../../../skills/SkillHub/**/SKILL.md", {
   eager: true,
@@ -87,3 +88,11 @@ export const DEFAULT_SKILL_HUB_SKILLS: UserSkill[] = Object.entries(skillHubModu
   })
   .filter((skill): skill is UserSkill => Boolean(skill))
   .sort((left, right) => left.id.localeCompare(right.id))
+
+export const SKILL_ROUTE_REGISTRY_MISSING_NAMES = validateSkillRouteRegistry(
+  DEFAULT_SKILL_HUB_SKILLS.map((skill) => skill.name),
+)
+
+if (SKILL_ROUTE_REGISTRY_MISSING_NAMES.length > 0) {
+  console.error("Skill 路由注册表包含不存在的 SkillHub 名称:", SKILL_ROUTE_REGISTRY_MISSING_NAMES)
+}

+ 69 - 0
src/lib/novel/skill-route-registry.spec.ts

@@ -0,0 +1,69 @@
+import { describe, expect, it } from "vitest"
+import { DEFAULT_SKILL_HUB_SKILLS } from "./skill-hub-seed"
+import {
+  findSkillRouteByAlias,
+  getOutlineSkillNames,
+  getSkillRouteSkillNames,
+  getWritingSkillNames,
+  resolveAvailableSkillsByNames,
+  resolveSkillReference,
+  validateSkillRouteRegistry,
+} from "./skill-route-registry"
+
+describe("skill route registry", () => {
+  it("keeps every canonical route name backed by SkillHub", () => {
+    expect(validateSkillRouteRegistry(DEFAULT_SKILL_HUB_SKILLS.map((skill) => skill.name))).toEqual([])
+  })
+
+  it("routes chapter outline through the complete eight-skill chain", () => {
+    expect(getOutlineSkillNames("把第236章章纲补充详细")).toEqual([
+      "chapter-outline-builder",
+      "chapter-attribute-positioning",
+      "chapter-keyword-conditions",
+      "chapter-four-beat-flow",
+      "chapter-emotion-curve",
+      "chapter-visual-detail",
+      "chapter-foreshadow-hook",
+      "chapter-outline-assembler",
+    ])
+  })
+
+  it.each([
+    ["人物小传", ["character-design", "supporting-cast", "relationship-emotion"]],
+    ["组织势力设定", ["faction-system"]],
+    ["力量体系", ["power-system"]],
+    ["金手指设定", ["idea-market-positioning", "power-system"]],
+    ["背景设定", ["world-rules"]],
+    ["地点设定", ["world-rules", "map-progression"]],
+    ["伏笔计划", ["foreshadowing-suspense"]],
+    ["故事大纲", ["outline-master-builder", "outline-final-assembler"]],
+    ["分卷大纲", ["story-goal-ladder", "outline-master-builder", "outline-final-assembler"]],
+    ["大纲质量检查", ["outline-quality-check"]],
+  ])("routes %s to canonical SkillHub names", (alias, expected) => {
+    expect(getSkillRouteSkillNames(findSkillRouteByAlias(alias)!)).toEqual(expected)
+  })
+
+  it("routes long, short, combat, dialogue and anti-ai writing tasks", () => {
+    expect(getWritingSkillNames("write_chapter", "写一个长篇战斗章,对话要有情绪,最后去AI味")).toEqual([
+      "long-form-drafting",
+      "combat-action",
+      "dialogue-emotion",
+      "anti-ai-polish",
+    ])
+    expect(getWritingSkillNames("write_chapter", "写知乎短篇正文")).toEqual(["short-form-drafting"])
+  })
+
+  it("uses exact id, exact name and controlled aliases without arbitrary substrings", () => {
+    const skills = DEFAULT_SKILL_HUB_SKILLS
+    expect(resolveSkillReference(skills, { name: "章节细纲" })?.name).toBe("chapter-outline-builder")
+    expect(resolveSkillReference(skills, { name: "请应用章节细纲技能" })).toBeUndefined()
+    expect(resolveSkillReference(skills, { name: "chapter-outline" })).toBeUndefined()
+    expect(resolveSkillReference(skills, { id: "skillhub:long-form-drafting" })?.name).toBe("long-form-drafting")
+  })
+
+  it("reports disabled or missing required skills instead of forcing them", () => {
+    const result = resolveAvailableSkillsByNames([], ["chapter-outline-builder"])
+    expect(result.skills).toEqual([])
+    expect(result.missingNames).toEqual(["chapter-outline-builder"])
+  })
+})

+ 288 - 0
src/lib/novel/skill-route-registry.ts

@@ -0,0 +1,288 @@
+import type { NovelTaskIntent } from "./task-router"
+import type { UserSkill } from "./skill-library"
+
+export type SkillRouteStage = "outline" | "drafting" | "review"
+export type SkillRouteMissingPolicy = "diagnose_and_continue" | "stop"
+
+export type SkillRouteTask =
+  | "chapter_outline"
+  | "character_design"
+  | "faction_setting"
+  | "power_setting"
+  | "golden_finger"
+  | "world_setting"
+  | "map_setting"
+  | "foreshadowing"
+  | "master_outline"
+  | "volume_outline"
+  | "outline_quality"
+  | "long_form_drafting"
+  | "short_form_drafting"
+  | "combat_scene"
+  | "dialogue_scene"
+  | "anti_ai_polish"
+
+export interface SkillRouteDefinition {
+  task: SkillRouteTask
+  aliases: string[]
+  primarySkills: string[]
+  supportingSkills: string[]
+  stage: SkillRouteStage
+  missingPolicy: SkillRouteMissingPolicy
+}
+
+const CHAPTER_OUTLINE_SUPPORT_SKILLS = [
+  "chapter-attribute-positioning",
+  "chapter-keyword-conditions",
+  "chapter-four-beat-flow",
+  "chapter-emotion-curve",
+  "chapter-visual-detail",
+  "chapter-foreshadow-hook",
+  "chapter-outline-assembler",
+]
+
+export const SKILL_ROUTE_DEFINITIONS: readonly SkillRouteDefinition[] = [
+  {
+    task: "chapter_outline",
+    aliases: ["章节细纲", "章纲", "章纲完善"],
+    primarySkills: ["chapter-outline-builder"],
+    supportingSkills: CHAPTER_OUTLINE_SUPPORT_SKILLS,
+    stage: "outline",
+    missingPolicy: "diagnose_and_continue",
+  },
+  {
+    task: "character_design",
+    aliases: ["人物小传", "人物设定"],
+    primarySkills: ["character-design"],
+    supportingSkills: ["supporting-cast", "relationship-emotion"],
+    stage: "outline",
+    missingPolicy: "diagnose_and_continue",
+  },
+  {
+    task: "faction_setting",
+    aliases: ["组织势力设定", "势力设定"],
+    primarySkills: ["faction-system"],
+    supportingSkills: [],
+    stage: "outline",
+    missingPolicy: "diagnose_and_continue",
+  },
+  {
+    task: "power_setting",
+    aliases: ["力量体系", "能力体系"],
+    primarySkills: ["power-system"],
+    supportingSkills: [],
+    stage: "outline",
+    missingPolicy: "diagnose_and_continue",
+  },
+  {
+    task: "golden_finger",
+    aliases: ["金手指设定", "系统设定"],
+    primarySkills: ["idea-market-positioning", "power-system"],
+    supportingSkills: [],
+    stage: "outline",
+    missingPolicy: "diagnose_and_continue",
+  },
+  {
+    task: "world_setting",
+    aliases: ["背景设定", "世界观设定"],
+    primarySkills: ["world-rules"],
+    supportingSkills: [],
+    stage: "outline",
+    missingPolicy: "diagnose_and_continue",
+  },
+  {
+    task: "map_setting",
+    aliases: ["地理设定", "地点设定", "地图"],
+    primarySkills: ["world-rules", "map-progression"],
+    supportingSkills: [],
+    stage: "outline",
+    missingPolicy: "diagnose_and_continue",
+  },
+  {
+    task: "foreshadowing",
+    aliases: ["伏笔计划", "伏笔审查"],
+    primarySkills: ["foreshadowing-suspense"],
+    supportingSkills: [],
+    stage: "outline",
+    missingPolicy: "diagnose_and_continue",
+  },
+  {
+    task: "master_outline",
+    aliases: ["故事大纲", "总纲"],
+    primarySkills: ["outline-master-builder"],
+    supportingSkills: ["outline-final-assembler"],
+    stage: "outline",
+    missingPolicy: "diagnose_and_continue",
+  },
+  {
+    task: "volume_outline",
+    aliases: ["卷纲", "分卷大纲"],
+    primarySkills: ["story-goal-ladder", "outline-master-builder"],
+    supportingSkills: ["outline-final-assembler"],
+    stage: "outline",
+    missingPolicy: "diagnose_and_continue",
+  },
+  {
+    task: "outline_quality",
+    aliases: ["大纲质量检查"],
+    primarySkills: ["outline-quality-check"],
+    supportingSkills: [],
+    stage: "review",
+    missingPolicy: "diagnose_and_continue",
+  },
+  {
+    task: "long_form_drafting",
+    aliases: ["编写章节", "续写章节", "长篇正文"],
+    primarySkills: ["long-form-drafting"],
+    supportingSkills: [],
+    stage: "drafting",
+    missingPolicy: "diagnose_and_continue",
+  },
+  {
+    task: "short_form_drafting",
+    aliases: ["短篇正文", "知乎短篇", "世情短篇"],
+    primarySkills: ["short-form-drafting"],
+    supportingSkills: [],
+    stage: "drafting",
+    missingPolicy: "diagnose_and_continue",
+  },
+  {
+    task: "combat_scene",
+    aliases: ["战斗场景", "动作场景"],
+    primarySkills: ["combat-action"],
+    supportingSkills: [],
+    stage: "drafting",
+    missingPolicy: "diagnose_and_continue",
+  },
+  {
+    task: "dialogue_scene",
+    aliases: ["对话场景", "情绪对话"],
+    primarySkills: ["dialogue-emotion"],
+    supportingSkills: [],
+    stage: "drafting",
+    missingPolicy: "diagnose_and_continue",
+  },
+  {
+    task: "anti_ai_polish",
+    aliases: ["正文去 AI 味", "正文去AI味", "最终去 AI 味", "最终去AI味"],
+    primarySkills: ["anti-ai-polish"],
+    supportingSkills: [],
+    stage: "review",
+    missingPolicy: "diagnose_and_continue",
+  },
+] as const
+
+export const WRITING_SUPPORT_SKILL_NAMES = [
+  "章节承接",
+  "下一章计划",
+  "主线检查",
+  "人物动机",
+  "冲突升级",
+  "伏笔管理",
+  "节奏检查",
+  "结尾钩子",
+  "剧情自检",
+  "正文输出协议",
+  "基础去AI味",
+] as const
+
+export function getSkillRouteSkillNames(definition: SkillRouteDefinition): string[] {
+  return [...definition.primarySkills, ...definition.supportingSkills]
+}
+
+export function findSkillRouteByTask(task: SkillRouteTask): SkillRouteDefinition | undefined {
+  return SKILL_ROUTE_DEFINITIONS.find((definition) => definition.task === task)
+}
+
+export function findSkillRouteByAlias(value: string): SkillRouteDefinition | undefined {
+  const normalized = normalizeAlias(value)
+  if (!normalized) return undefined
+  const candidates = SKILL_ROUTE_DEFINITIONS.flatMap((definition) =>
+    definition.aliases.map((alias) => ({ definition, alias: normalizeAlias(alias) })),
+  ).sort((left, right) => right.alias.length - left.alias.length)
+  return candidates.find(({ alias }) => normalized === alias || normalized.includes(alias))?.definition
+}
+
+export function findSkillRouteByExactAlias(value: string): SkillRouteDefinition | undefined {
+  const normalized = normalizeAlias(value)
+  if (!normalized) return undefined
+  return SKILL_ROUTE_DEFINITIONS.find((definition) =>
+    definition.aliases.some((alias) => normalizeAlias(alias) === normalized),
+  )
+}
+
+export function getOutlineSkillNames(value: string): string[] {
+  const definition = findSkillRouteByAlias(value)
+  if (!definition || (definition.stage !== "outline" && definition.stage !== "review")) return []
+  return getSkillRouteSkillNames(definition)
+}
+
+export function getWritingSkillNames(intent: NovelTaskIntent, requestText: string): string[] {
+  if (!new Set<NovelTaskIntent>([
+    "write_chapter",
+    "continue_chapter",
+    "rewrite_chapter",
+    "polish_chapter",
+  ]).has(intent)) return []
+
+  const normalized = normalizeAlias(requestText)
+  const names: string[] = []
+  const addRoute = (task: SkillRouteTask) => {
+    const definition = findSkillRouteByTask(task)
+    if (definition) names.push(...getSkillRouteSkillNames(definition))
+  }
+
+  if (/(短篇|知乎|世情)/.test(normalized)) addRoute("short_form_drafting")
+  else addRoute("long_form_drafting")
+  if (/(战斗|动作|打斗|追逐|战争|交火)/.test(normalized)) addRoute("combat_scene")
+  if (/(对话|对白|情绪对话)/.test(normalized)) addRoute("dialogue_scene")
+  if (/(去ai味|反ai|降低ai|消除ai|最终润色)/.test(normalized)) addRoute("anti_ai_polish")
+  return unique(names)
+}
+
+export function resolveAvailableSkillsByNames(
+  skills: UserSkill[],
+  names: readonly string[],
+): { skills: UserSkill[]; missingNames: string[] } {
+  const availableByName = new Map(skills.map((skill) => [skill.name, skill]))
+  const resolved: UserSkill[] = []
+  const missingNames: string[] = []
+  for (const name of unique(names)) {
+    const skill = availableByName.get(name)
+    if (!skill) {
+      missingNames.push(name)
+      continue
+    }
+    if (!resolved.some((item) => item.id === skill.id)) resolved.push(skill)
+  }
+  return { skills: resolved, missingNames }
+}
+
+export function validateSkillRouteRegistry(availableNames: Iterable<string>): string[] {
+  const available = new Set(availableNames)
+  return unique(SKILL_ROUTE_DEFINITIONS.flatMap(getSkillRouteSkillNames))
+    .filter((name) => !available.has(name))
+}
+
+export function resolveSkillReference<T extends { id: string; name: string }>(
+  skills: readonly T[],
+  reference: { id?: string; name?: string },
+): T | undefined {
+  const id = reference.id?.trim()
+  if (id) return skills.find((skill) => skill.id === id)
+  const name = reference.name?.trim()
+  if (!name) return undefined
+  const exact = skills.find((skill) => skill.name === name)
+  if (exact) return exact
+  const route = findSkillRouteByExactAlias(name)
+  const canonicalName = route?.primarySkills[0]
+  return canonicalName ? skills.find((skill) => skill.name === canonicalName) : undefined
+}
+
+function normalizeAlias(value: string): string {
+  return value.toLowerCase().replace(/[\s「」『』【】]/g, "").trim()
+}
+
+function unique<T>(values: readonly T[]): T[] {
+  return [...new Set(values)]
+}

+ 8 - 8
src/lib/project-store.ts

@@ -260,14 +260,14 @@ const PROXY_CONFIG_KEY = "proxyConfig"
 export async function saveProxyConfig(config: ProxyConfig): Promise<void> {
   const store = await getStore()
   await store.set(PROXY_CONFIG_KEY, config)
-  // Force-flush to disk. The store is opened with `autoSave: true`,
-  // which is a 100ms debounce — not an immediate write. For most
-  // settings that's fine, but the proxy config is on the startup
-  // critical path: the Rust setup hook reads `app-state.json` on
-  // launch to apply HTTP_PROXY / HTTPS_PROXY / NO_PROXY. If the
-  // user saves and quits within the debounce window the disk
-  // value would lag behind in-memory, and the next launch would
-  // boot with the wrong proxy.
+  // Force-flush to disk. `set()` only schedules a 100ms debounce
+  // persist — not an immediate write. For most settings that's
+  // fine, but the proxy config is on the startup critical path:
+  // the Rust setup hook reads `app-state.json` on launch to apply
+  // HTTP_PROXY / HTTPS_PROXY / NO_PROXY. If the user saves and
+  // quits within the debounce window the disk value would lag
+  // behind in-memory, and the next launch would boot with the
+  // wrong proxy.
   await store.save()
 }
 

+ 83 - 0
src/lib/web-store.spec.ts

@@ -0,0 +1,83 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import {
+  APP_STATE_ATOMIC_WRITE_COMMAND,
+  wrapStoreForAtomicPersist,
+} from "./web-store"
+
+describe("wrapStoreForAtomicPersist", () => {
+  beforeEach(() => {
+    vi.useFakeTimers()
+  })
+
+  afterEach(() => {
+    vi.useRealTimers()
+  })
+
+  function createInner(initial: Record<string, unknown> = {}) {
+    const values = new Map(Object.entries(initial))
+    return {
+      values,
+      save: vi.fn(async () => {
+        throw new Error("plugin save must not be called")
+      }),
+      store: {
+        get: vi.fn(async <T>(key: string) => values.get(key) as T | undefined),
+        set: vi.fn(async (key: string, value: unknown) => {
+          values.set(key, value)
+        }),
+        delete: vi.fn(async (key: string) => values.delete(key)),
+        entries: vi.fn(async () => [...values.entries()] as Array<[string, unknown]>),
+      },
+    }
+  }
+
+  it("persists full entries through the atomic command and never calls plugin save", async () => {
+    const persist = vi.fn(async () => {})
+    const inner = createInner({ llmConfig: { model: "keep" } })
+    const store = wrapStoreForAtomicPersist(inner.store, persist, 100)
+
+    await store.set("theme", "dark")
+    expect(persist).not.toHaveBeenCalled()
+    await vi.advanceTimersByTimeAsync(100)
+
+    expect(inner.save).not.toHaveBeenCalled()
+    expect(persist).toHaveBeenCalledTimes(1)
+    expect(persist).toHaveBeenCalledWith({
+      llmConfig: { model: "keep" },
+      theme: "dark",
+    })
+  })
+
+  it("save flushes immediately with the latest entries", async () => {
+    const persist = vi.fn(async () => {})
+    const inner = createInner()
+    const store = wrapStoreForAtomicPersist(inner.store, persist, 100)
+
+    await store.set("language", "zh")
+    await store.save()
+
+    expect(persist).toHaveBeenCalledTimes(1)
+    expect(persist).toHaveBeenCalledWith({ language: "zh" })
+    await vi.advanceTimersByTimeAsync(100)
+    expect(persist).toHaveBeenCalledTimes(1)
+  })
+
+  it("keeps the latest value when overlapping writes resolve out of order", async () => {
+    const persist = vi.fn(async () => {})
+    const inner = createInner()
+    const store = wrapStoreForAtomicPersist(inner.store, persist, 100)
+
+    await store.set("aiOutlineModel", "first")
+    await store.set("aiOutlineModel", "second")
+    await store.save()
+
+    expect(persist).toHaveBeenCalledTimes(1)
+    expect(persist).toHaveBeenCalledWith({ aiOutlineModel: "second" })
+  })
+})
+
+describe("atomic write command name", () => {
+  it("matches the Rust command", () => {
+    expect(APP_STATE_ATOMIC_WRITE_COMMAND).toBe("write_app_state_atomic")
+  })
+})

+ 77 - 3
src/lib/web-store.ts

@@ -1,4 +1,78 @@
-export async function getStore() {
-  const { load } = await import("@tauri-apps/plugin-store")
-  return load("app-state.json", { autoSave: true, defaults: {} })
+import { invoke } from "@tauri-apps/api/core"
+
+export const APP_STATE_ATOMIC_WRITE_COMMAND = "write_app_state_atomic"
+export const APP_STATE_PERSIST_DEBOUNCE_MS = 100
+
+export interface AtomicPersistStore {
+  get: <T>(key: string) => Promise<T | undefined>
+  set: (key: string, value: unknown) => Promise<void>
+  delete: (key: string) => Promise<boolean>
+  entries: <T>() => Promise<Array<[string, T]>>
+}
+
+export interface AtomicAppStateStore {
+  get: <T>(key: string) => Promise<T | undefined>
+  set: (key: string, value: unknown) => Promise<void>
+  delete: (key: string) => Promise<boolean>
+  save: () => Promise<void>
+}
+
+export function wrapStoreForAtomicPersist(
+  inner: AtomicPersistStore,
+  persistEntries: (entries: Record<string, unknown>) => Promise<void>,
+  debounceMs = APP_STATE_PERSIST_DEBOUNCE_MS,
+): AtomicAppStateStore {
+  let persistTimer: ReturnType<typeof setTimeout> | null = null
+  let persistChain = Promise.resolve()
+
+  const flushPersist = () => {
+    if (persistTimer) {
+      clearTimeout(persistTimer)
+      persistTimer = null
+    }
+    persistChain = persistChain
+      .catch(() => undefined)
+      .then(async () => {
+        const pairs = await inner.entries()
+        await persistEntries(Object.fromEntries(pairs))
+      })
+    return persistChain
+  }
+
+  const schedulePersist = () => {
+    if (persistTimer) clearTimeout(persistTimer)
+    persistTimer = setTimeout(() => {
+      persistTimer = null
+      void flushPersist()
+    }, debounceMs)
+  }
+
+  return {
+    get: (key) => inner.get(key),
+    set: async (key, value) => {
+      await inner.set(key, value)
+      schedulePersist()
+    },
+    delete: async (key) => {
+      const deleted = await inner.delete(key)
+      schedulePersist()
+      return deleted
+    },
+    save: () => flushPersist(),
+  }
+}
+
+let storePromise: Promise<AtomicAppStateStore> | null = null
+
+export async function getStore(): Promise<AtomicAppStateStore> {
+  if (!storePromise) {
+    storePromise = (async () => {
+      const { load } = await import("@tauri-apps/plugin-store")
+      const inner = await load("app-state.json", { autoSave: false })
+      return wrapStoreForAtomicPersist(inner, async (entries) => {
+        await invoke(APP_STATE_ATOMIC_WRITE_COMMAND, { entries })
+      })
+    })()
+  }
+  return storePromise
 }

+ 5 - 0
src/stores/outline-chat-store.ts

@@ -87,6 +87,10 @@ export interface OutlineChatMessage {
   id: string
   role: "user" | "assistant"
   content: string
+  /** 发给模型的完整内容;与用户界面展示内容分离。 */
+  modelContent?: string
+  /** internal 消息参与模型历史,但不渲染为用户气泡。 */
+  visibility?: "visible" | "internal"
   sources?: string[]
   agentToolCalls?: AgentRunRecord["toolCalls"]
   multiAgentRun?: OutlineMultiAgentRunState
@@ -96,6 +100,7 @@ export interface OutlineChatMessage {
   attachedReferences?: ReferenceToken[]
   intentPhase?: "intent_analysis" | "generation" | "waiting_user_input"
   intentClarityResult?: IntentClarityResult | null
+  intentProtocolError?: string
   nextStepRecommendation?: NextStepRecommendation | null
   novelGenerationRequest?: NovelGenerationRequestPackage
   contextHubSnapshot?: ContextHubSnapshotRef