浏览代码

fix(cursor-cli): 让 HTTP 直传 CLI model id 并关闭 ACP strict

agent --model 只认 CLI 目录,ACP catalog 对不上时 proxy 默认 strictModel 会吞错并 exit 1。
同时去掉 cursor-cli 文本 tool_calls 解析,章节提取失败带上具体错误,社区摘要按成员指纹增量重建。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 1 周之前
父节点
当前提交
cb88f76d42
共有 34 个文件被更改,包括 2394 次插入396 次删除
  1. 183 0
      src-tauri/scripts/qmai-cursor-agent.cjs
  2. 702 38
      src-tauri/src/commands/cursor_cli.rs
  3. 3 0
      src-tauri/src/lib.rs
  4. 6 2
      src/components/layout/knowledge-tree.tsx
  5. 10 3
      src/components/layout/preview-panel.tsx
  6. 7 0
      src/components/settings/llm-wiki-model-settings.spec.ts
  7. 20 0
      src/components/settings/sections/llm-provider-section.spec.ts
  8. 167 16
      src/components/settings/sections/llm-provider-section.tsx
  9. 15 2
      src/i18n/en.json
  10. 15 2
      src/i18n/zh.json
  11. 26 2
      src/lib/agent/config.spec.ts
  12. 1 12
      src/lib/agent/config.ts
  13. 0 49
      src/lib/agent/runner.spec.ts
  14. 3 18
      src/lib/agent/runner.ts
  15. 1 48
      src/lib/agent/tool-call-parser.spec.ts
  16. 0 150
      src/lib/agent/tool-call-parser.ts
  17. 156 0
      src/lib/cursor-acp-models.spec.ts
  18. 211 0
      src/lib/cursor-acp-models.ts
  19. 37 4
      src/lib/cursor-cli-provider.spec.ts
  20. 102 3
      src/lib/cursor-cli-proxy.ts
  21. 13 0
      src/lib/llm-client.ts
  22. 13 1
      src/lib/llm-providers.spec.ts
  23. 17 0
      src/lib/llm-providers.ts
  24. 2 2
      src/lib/novel/chapter-import.ts
  25. 31 0
      src/lib/novel/chapter-ingest-log.spec.ts
  26. 74 0
      src/lib/novel/chapter-ingest-log.ts
  27. 9 1
      src/lib/novel/chapter-ingest.spec.ts
  28. 57 9
      src/lib/novel/chapter-ingest.ts
  29. 257 5
      src/lib/novel/community-summary.spec.ts
  30. 134 28
      src/lib/novel/community-summary.ts
  31. 60 0
      src/lib/qmai-cursor-agent-wrapper.spec.ts
  32. 52 0
      src/lib/settings-model-list.spec.ts
  33. 7 1
      src/lib/settings-model-list.ts
  34. 3 0
      src/stores/wiki-store.ts

+ 183 - 0
src-tauri/scripts/qmai-cursor-agent.cjs

@@ -0,0 +1,183 @@
+#!/usr/bin/env node
+const { spawn } = require("child_process");
+const fs = require("fs");
+const path = require("path");
+
+const ACP_ALIASES = {
+  auto: "default",
+  "composer-2": "composer-2.5",
+};
+const EFFORTS = [
+  "extra-high",
+  "xhigh",
+  "minimal",
+  "medium",
+  "high",
+  "none",
+  "low",
+  "max",
+];
+
+function bakedAgent() {
+  try {
+    return JSON.parse(String.raw`__QMAI_BAKED_AGENT__`);
+  } catch {
+    return "";
+  }
+}
+
+function normalizeEffort(raw) {
+  const value = String(raw).trim().toLowerCase();
+  if (value === "extra-high" || value === "max") return "xhigh";
+  return value;
+}
+
+function toCursorAcpModelId(raw) {
+  const trimmed = String(raw || "").trim();
+  if (!trimmed) return "";
+  if (trimmed === "default") return "default";
+  const parameterized = trimmed.includes("[")
+    ? trimmed.replace(/\[.*$/, "").trim()
+    : trimmed;
+  let id = parameterized;
+  if (id.toLowerCase().startsWith("cursor-")) id = id.slice("cursor-".length);
+  if (/-fast$/i.test(id)) id = id.replace(/-fast$/i, "");
+  for (const effort of EFFORTS) {
+    const re = new RegExp(`[-_]${effort}$`, "i");
+    if (re.test(id)) {
+      id = id.replace(re, "");
+      break;
+    }
+  }
+  id = id.replace(/[-_]thinking$/i, "");
+  return ACP_ALIASES[id.toLowerCase()] ?? ACP_ALIASES[trimmed.toLowerCase()] ?? id;
+}
+
+function inferFast(model) {
+  if (/(?:^|[\[,])\s*fast\s*=\s*true(?:[,\]]|$)/i.test(model)) return true;
+  if (/(?:^|[\[,])\s*fast\s*=\s*false(?:[,\]]|$)/i.test(model)) return false;
+  return /-fast$/i.test(model.replace(/\[.*$/, ""));
+}
+
+function inferEffort(model) {
+  const param = /(?:^|[\[,])\s*effort\s*=\s*([^,\]]+)/i.exec(model);
+  if (param) return normalizeEffort(param[1]);
+  const id = model.replace(/\[.*$/, "").replace(/-fast$/i, "");
+  for (const effort of EFFORTS) {
+    if (new RegExp(`[-_]${effort}$`, "i").test(id)) return normalizeEffort(effort);
+  }
+  return "";
+}
+
+function toCursorAcpModelValue(base, fast, effort) {
+  const params = [];
+  if (effort) params.push(`effort=${effort}`);
+  if (fast === true) params.push("fast=true");
+  else if (fast === false) params.push("fast=false");
+  return params.length === 0 ? base : `${base}[${params.join(",")}]`;
+}
+
+function isDefaultModel(model) {
+  const trimmed = String(model || "").trim();
+  return !trimmed || trimmed === "default" || trimmed === "auto";
+}
+
+function asCliModel(model) {
+  const trimmed = String(model || "").trim();
+  if (isDefaultModel(trimmed) || trimmed.includes("[")) return "";
+  return trimmed;
+}
+
+function cliModelToAcpValue(model) {
+  const trimmed = String(model || "").trim();
+  if (isDefaultModel(trimmed)) return "";
+  const acp = toCursorAcpModelId(trimmed);
+  if (!acp || acp === "default") return "";
+  const baseId = trimmed.replace(/\[.*$/, "");
+  const hasFastSuffix = /-fast$/i.test(baseId);
+  const hasCursorPrefix = baseId.toLowerCase().startsWith("cursor-");
+  const effort = inferEffort(trimmed);
+  if (trimmed.includes("[")) {
+    return toCursorAcpModelValue(acp, inferFast(trimmed), effort || undefined);
+  }
+  if (!hasFastSuffix && !hasCursorPrefix && !effort) return acp;
+  return toCursorAcpModelValue(
+    acp,
+    hasFastSuffix ? true : hasCursorPrefix ? false : undefined,
+    effort || undefined,
+  );
+}
+
+function resolveAcpArgvModel(argvModel, pinned) {
+  const passed = asCliModel(argvModel);
+  if (passed) return passed;
+  return asCliModel(pinned);
+}
+
+function readPinnedModel() {
+  const files = [];
+  if (process.env.CURSOR_CONFIG_DIR) {
+    files.push(path.join(process.env.CURSOR_CONFIG_DIR, "qmai-acp-model"));
+  }
+  files.push(path.join(__dirname, "qmai-acp-model"));
+  for (const file of files) {
+    try {
+      const pinned = fs.readFileSync(file, "utf8").trim();
+      if (pinned && pinned !== "default" && pinned !== "auto") return pinned;
+    } catch {
+      /* ignore */
+    }
+  }
+  return "";
+}
+
+function withModel(args, model) {
+  const next = args.slice();
+  const i = next.indexOf("--model");
+  if (i >= 0) {
+    if (i + 1 < next.length) next[i + 1] = model;
+    else next.push(model);
+    return next;
+  }
+  const acp = next.indexOf("acp");
+  if (acp >= 0) {
+    next.splice(acp + 1, 0, "--model", model);
+  }
+  return next;
+}
+
+function rewriteAcpArgs(args, pinned) {
+  if (!args.includes("acp")) return args;
+  const i = args.indexOf("--model");
+  const argvModel = i >= 0 && i + 1 < args.length ? args[i + 1] : "";
+  const next = resolveAcpArgvModel(argvModel, pinned);
+  return next ? withModel(args, next) : args;
+}
+
+function run(argv) {
+  const real = process.env.QMAI_CURSOR_AGENT_REAL || bakedAgent();
+  if (!real) {
+    console.error("QMAI cursor agent wrapper: no real agent path");
+    process.exit(1);
+  }
+  const args = rewriteAcpArgs(argv, readPinnedModel());
+  const child = spawn(real, args, { stdio: "inherit" });
+  child.on("error", (error) => {
+    console.error(error);
+    process.exit(1);
+  });
+  child.on("exit", (code, signal) => {
+    if (signal) process.kill(process.pid, signal);
+    process.exit(code ?? 1);
+  });
+}
+
+if (require.main === module) {
+  run(process.argv.slice(2));
+}
+
+module.exports = {
+  cliModelToAcpValue,
+  resolveAcpArgvModel,
+  rewriteAcpArgs,
+};

+ 702 - 38
src-tauri/src/commands/cursor_cli.rs

@@ -4,6 +4,8 @@
 //! frontend can talk OpenAI-compatible HTTP. Port is chosen dynamically
 //! (prefer 8765, else an ephemeral free port) via `CURSOR_BRIDGE_PORT`.
 
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicBool, Ordering};
 use std::sync::Arc;
 use std::time::Duration;
 
@@ -19,14 +21,32 @@ use super::local_cli_config::{apply_local_cli_environment, resolve_home_dir};
 
 const PREFERRED_PROXY_PORT: u16 = 8765;
 const DEFAULT_PROXY_BASE: &str = "http://127.0.0.1:8765";
-const PROXY_START_TIMEOUT_MS: u64 = 15_000;
+const PROXY_START_TIMEOUT_MS: u64 = 90_000;
 const PROXY_POLL_MS: u64 = 200;
+/// Align parked ACP tool turns with the frontend LLM backstop (30 minutes).
+const PROXY_TIMEOUT_MS: u64 = 30 * 60 * 1000;
+const AGENT_ABOUT_TIMEOUT: Duration = Duration::from_secs(20);
+const AGENT_UPDATE_TIMEOUT: Duration = Duration::from_secs(10 * 60);
+const AGENT_TRUST_TIMEOUT: Duration = Duration::from_secs(45);
+const QMAI_WORKSPACE_TRUST_MARKER: &str = ".qmai-workspace-trusted";
+const QMAI_ACP_MODEL_FILE: &str = "qmai-acp-model";
+const QMAI_CURSOR_AGENT_WRAPPER: &str = include_str!("../../scripts/qmai-cursor-agent.cjs");
+static AGENT_UPDATE_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
+
+struct AgentUpdateGuard;
+
+impl Drop for AgentUpdateGuard {
+    fn drop(&mut self) {
+        AGENT_UPDATE_IN_FLIGHT.store(false, Ordering::SeqCst);
+    }
+}
 
 #[derive(Default)]
 struct ManagedProxy {
     child: Option<Child>,
     /// e.g. http://127.0.0.1:8765 — the port this managed child actually bound.
     base_url: Option<String>,
+    launch_fingerprint: Option<String>,
 }
 
 #[derive(Default)]
@@ -43,6 +63,24 @@ pub struct DetectResult {
     error: Option<String>,
 }
 
+#[derive(Serialize)]
+pub struct AgentAboutResult {
+    installed: bool,
+    version: Option<String>,
+    latest_status: Option<String>,
+    latest_version: Option<String>,
+    path: Option<String>,
+    error: Option<String>,
+}
+
+#[derive(Serialize)]
+pub struct AgentUpdateResult {
+    ok: bool,
+    version: Option<String>,
+    output: String,
+    error: Option<String>,
+}
+
 #[derive(Serialize)]
 pub struct ProxyStatus {
     healthy: bool,
@@ -66,29 +104,356 @@ async fn find_agent_command() -> Result<std::path::PathBuf, String> {
     find_cli_command("agent", &["agent.cmd", "agent.exe"]).await
 }
 
-async fn find_proxy_launcher() -> Result<(std::path::PathBuf, Vec<String>), String> {
-    if let Ok(bin) = find_cli_command(
-        "cursor-api-proxy",
-        &["cursor-api-proxy.cmd", "cursor-api-proxy.exe"],
+fn npx_proxy_args() -> Vec<String> {
+    vec!["--yes".to_string(), "cursor-api-proxy@latest".to_string()]
+}
+
+fn agent_trust_args(workspace: &Path) -> Vec<String> {
+    vec![
+        "--trust".to_string(),
+        "--workspace".to_string(),
+        workspace.to_string_lossy().into_owned(),
+        "--mode".to_string(),
+        "ask".to_string(),
+        "--output-format".to_string(),
+        "text".to_string(),
+        "-p".to_string(),
+        "ok".to_string(),
+    ]
+}
+
+fn qmai_workspace_trust_marker(config_dir: &Path) -> PathBuf {
+    config_dir.join(QMAI_WORKSPACE_TRUST_MARKER)
+}
+
+async fn ensure_qmai_workspace_trusted(workspace: &Path, config_dir: &Path) {
+    let marker = qmai_workspace_trust_marker(config_dir);
+    if marker.is_file() {
+        return;
+    }
+    let args = agent_trust_args(workspace);
+    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
+    match run_agent_command(&arg_refs, AGENT_TRUST_TIMEOUT, Some(config_dir)).await {
+        Ok((_path, true, _output)) => {
+            if let Err(error) = std::fs::write(&marker, "") {
+                eprintln!(
+                    "[cursor-cli] trusted workspace but failed to write {}: {error}",
+                    marker.display()
+                );
+            }
+        }
+        Ok((_path, false, output)) => {
+            eprintln!(
+                "[cursor-cli] `agent --trust -p` failed for {}: {output}",
+                workspace.display()
+            );
+        }
+        Err(error) => {
+            eprintln!("[cursor-cli] workspace trust skipped: {error}");
+        }
+    }
+}
+
+fn npx_missing_error() -> String {
+    "`npx` not found on PATH. Install Node.js 18+ or set CURSOR_API_PROXY_BIN to a cursor-api-proxy binary."
+        .to_string()
+}
+
+fn resolve_explicit_proxy_bin(path: &Path) -> Result<(PathBuf, Vec<String>), String> {
+    if path.is_file() {
+        Ok((path.to_path_buf(), vec![]))
+    } else {
+        Err(format!(
+            "CURSOR_API_PROXY_BIN is set but is not a file: {}",
+            path.display()
+        ))
+    }
+}
+
+async fn find_proxy_launcher() -> Result<(PathBuf, Vec<String>), String> {
+    if let Some(bin) = read_nonempty_env(&["CURSOR_API_PROXY_BIN"]) {
+        return resolve_explicit_proxy_bin(Path::new(&bin));
+    }
+
+    find_cli_command("npx", &["npx.cmd", "npx.exe"])
+        .await
+        .map(|bin| (bin, npx_proxy_args()))
+        .map_err(|_| npx_missing_error())
+}
+
+fn qmai_proxy_home_dirs() -> Result<(PathBuf, PathBuf), String> {
+    let home = resolve_home_dir().ok_or_else(|| "Cannot resolve home directory for cursor-api-proxy".to_string())?;
+    let root = home.join(".cursor-api-proxy");
+    let config_dir = root.join("qmai-agent");
+    let workspace = root.join("qmai-workspace");
+    std::fs::create_dir_all(&config_dir)
+        .map_err(|e| format!("Failed to create {}: {e}", config_dir.display()))?;
+    std::fs::create_dir_all(&workspace)
+        .map_err(|e| format!("Failed to create {}: {e}", workspace.display()))?;
+    Ok((config_dir, workspace))
+}
+
+fn ensure_qmai_cli_config(config_dir: &Path) -> Result<PathBuf, String> {
+    let path = config_dir.join("cli-config.json");
+    let mut value = if path.exists() {
+        std::fs::read_to_string(&path)
+            .ok()
+            .and_then(|raw| serde_json::from_str(&raw).ok())
+            .unwrap_or_else(|| serde_json::json!({}))
+    } else {
+        serde_json::json!({
+            "version": 1,
+            "editor": { "vimMode": false },
+            "permissions": { "allow": [], "deny": [] }
+        })
+    };
+    if !value.is_object() {
+        value = serde_json::json!({});
+    }
+    value["disableAutoUpdate"] = serde_json::json!(true);
+    std::fs::write(
+        &path,
+        serde_json::to_string_pretty(&value)
+            .map_err(|e| format!("Failed to serialize {}: {e}", path.display()))?,
     )
-    .await
+    .map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
+    Ok(path)
+}
+
+fn apply_qmai_acp_model(
+    config_dir: &Path,
+    model: &str,
+    fast: Option<bool>,
+    effort: Option<&str>,
+    cli_model: Option<&str>,
+) -> Result<(), String> {
+    let model = model.trim();
+    if model.is_empty() {
+        return Err("ACP model id is empty".to_string());
+    }
+    let path = ensure_qmai_cli_config(config_dir)?;
+    let mut value = std::fs::read_to_string(&path)
+        .ok()
+        .and_then(|raw| serde_json::from_str(&raw).ok())
+        .unwrap_or_else(|| serde_json::json!({}));
+    if !value.is_object() {
+        value = serde_json::json!({});
+    }
+    let mut parameters = Vec::new();
+    if let Some(effort) = effort.map(str::trim).filter(|value| !value.is_empty()) {
+        parameters.push(serde_json::json!({ "id": "effort", "value": effort }));
+    }
+    if let Some(fast) = fast {
+        parameters.push(serde_json::json!({
+            "id": "fast",
+            "value": if fast { "true" } else { "false" }
+        }));
+    }
+    value["selectedModel"] = serde_json::json!({
+        "modelId": model,
+        "parameters": parameters
+    });
+    if let Some(obj) = value.get_mut("model").and_then(|item| item.as_object_mut()) {
+        obj.insert("modelId".to_string(), serde_json::json!(model));
+        obj.insert("displayModelId".to_string(), serde_json::json!(model));
+    } else {
+        value["model"] = serde_json::json!({
+            "modelId": model,
+            "displayModelId": model
+        });
+    }
+    value["hasChangedDefaultModel"] = serde_json::json!(true);
+    std::fs::write(
+        &path,
+        serde_json::to_string_pretty(&value)
+            .map_err(|e| format!("Failed to serialize {}: {e}", path.display()))?,
+    )
+    .map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
+    let pin = cli_model
+        .map(str::trim)
+        .filter(|value| !value.is_empty() && *value != "default" && *value != "auto")
+        .unwrap_or(model);
+    std::fs::write(config_dir.join(QMAI_ACP_MODEL_FILE), pin)
+    .map_err(|e| {
+        format!(
+            "Failed to write {}: {e}",
+            config_dir.join(QMAI_ACP_MODEL_FILE).display()
+        )
+    })?;
+    Ok(())
+}
+
+#[allow(dead_code)]
+fn qmai_acp_model_value(model: &str, fast: Option<bool>, effort: Option<&str>) -> String {
+    let mut params = Vec::new();
+    if let Some(effort) = effort.map(str::trim).filter(|value| !value.is_empty()) {
+        params.push(format!("effort={effort}"));
+    }
+    if let Some(fast) = fast {
+        params.push(format!("fast={}", if fast { "true" } else { "false" }));
+    }
+    if params.is_empty() {
+        model.to_string()
+    } else {
+        format!("{model}[{}]", params.join(","))
+    }
+}
+
+#[tauri::command]
+pub async fn cursor_cli_apply_acp_model(
+    model: String,
+    fast: Option<bool>,
+    effort: Option<String>,
+    cli_model: Option<String>,
+) -> Result<(), String> {
+    let (config_dir, _) = qmai_proxy_home_dirs()?;
+    apply_qmai_acp_model(
+        &config_dir,
+        &model,
+        fast,
+        effort.as_deref(),
+        cli_model.as_deref(),
+    )
+}
+
+fn ensure_qmai_agent_wrapper(
+    config_dir: &Path,
+    agent_bin: Option<&Path>,
+) -> Result<PathBuf, String> {
+    let baked = agent_bin
+        .map(|path| serde_json::to_string(&path.to_string_lossy().as_ref()).unwrap_or_else(|_| "\"\"".into()))
+        .unwrap_or_else(|| "\"\"".to_string());
+    let source = QMAI_CURSOR_AGENT_WRAPPER.replace("__QMAI_BAKED_AGENT__", &baked);
+    let script = config_dir.join("qmai-cursor-agent.cjs");
+    std::fs::write(&script, source)
+        .map_err(|e| format!("Failed to write {}: {e}", script.display()))?;
+    #[cfg(unix)]
+    {
+        use std::os::unix::fs::PermissionsExt;
+        let _ = std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755));
+        Ok(script)
+    }
+    #[cfg(windows)]
     {
-        return Ok((bin, vec![]));
+        let cmd_path = config_dir.join("qmai-cursor-agent.cmd");
+        std::fs::write(
+            &cmd_path,
+            "@echo off\r\nnode \"%~dp0qmai-cursor-agent.cjs\" %*\r\n",
+        )
+        .map_err(|e| format!("Failed to write {}: {e}", cmd_path.display()))?;
+        Ok(cmd_path)
+    }
+}
+
+fn proxy_launch_fingerprint(
+    launcher: &Path,
+    extra_args: &[String],
+    workspace: &Path,
+    config_dir: &Path,
+) -> String {
+    format!(
+        "launcher={}|args={}|chat_only=false|acp=true|mode=ask|ws={}|cfg={}|timeout={}|agent_wrap=4|strict=off",
+        launcher.display(),
+        extra_args.join("\0"),
+        workspace.display(),
+        config_dir.display(),
+        PROXY_TIMEOUT_MS
+    )
+}
+
+fn extract_json_object(raw: &str) -> Option<&str> {
+    let start = raw.find('{')?;
+    let end = raw.rfind('}')?;
+    if end < start {
+        return None;
+    }
+    Some(&raw[start..=end])
+}
+
+fn parse_agent_about_json(raw: &str) -> Option<(String, Option<String>, Option<String>)> {
+    let json: serde_json::Value = serde_json::from_str(extract_json_object(raw)?.trim()).ok()?;
+    let version = json
+        .get("cliVersion")
+        .and_then(|v| v.as_str())
+        .map(str::trim)
+        .filter(|v| !v.is_empty())?
+        .to_string();
+    let latest_status = json
+        .get("latestStatus")
+        .and_then(|v| v.as_str())
+        .map(str::trim)
+        .filter(|v| !v.is_empty())
+        .map(ToOwned::to_owned);
+    let latest_version = json
+        .get("latestVersion")
+        .and_then(|v| v.as_str())
+        .map(str::trim)
+        .filter(|v| !v.is_empty())
+        .map(ToOwned::to_owned);
+    Some((version, latest_status, latest_version))
+}
+
+async fn run_agent_command(
+    args: &[&str],
+    timeout: Duration,
+    config_dir: Option<&Path>,
+) -> Result<(PathBuf, bool, String), String> {
+    let path = find_agent_command().await?;
+    let mut cmd = Command::new(&path);
+    suppress_windows_console(&mut cmd);
+    apply_local_cli_environment(&mut cmd);
+    if let Some(path_env) = child_path_env().await {
+        cmd.env("PATH", path_env);
     }
+    apply_cursor_auth_env(&mut cmd);
+    if let Some(dir) = config_dir {
+        cmd.env("CURSOR_CONFIG_DIR", dir);
+    } else {
+        cmd.env_remove("CURSOR_CONFIG_DIR");
+    }
+    cmd.env_remove("CURSOR_BRIDGE_CHAT_ONLY_WORKSPACE");
+    cmd.args(args);
 
-    let npx = find_cli_command("npx", &["npx.cmd", "npx.exe"])
+    let output = tokio::time::timeout(timeout, cmd.output())
         .await
-        .map_err(|_| {
-            "`cursor-api-proxy` and `npx` not found on PATH. Install Node.js 18+ and `npm i -g cursor-api-proxy`, or ensure `npx` works."
-                .to_string()
-        })?;
-    Ok((
-        npx,
-        vec![
-            "--yes".to_string(),
-            "cursor-api-proxy".to_string(),
-        ],
-    ))
+        .map_err(|_| format!("`agent {}` timed out", args.join(" ")))?
+        .map_err(|e| format!("Failed to spawn `agent {}`: {e}", args.join(" ")))?;
+    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
+    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+    let combined = if stderr.is_empty() {
+        stdout
+    } else if stdout.is_empty() {
+        stderr
+    } else {
+        format!("{stdout}\n{stderr}")
+    };
+    Ok((path, output.status.success(), combined))
+}
+
+fn apply_qmai_proxy_env(
+    cmd: &mut Command,
+    port: u16,
+    workspace: &Path,
+    config_dir: &Path,
+    agent_bin: Option<&Path>,
+    wrapper: Option<&Path>,
+) {
+    cmd.env("CURSOR_BRIDGE_HOST", "127.0.0.1");
+    cmd.env("CURSOR_BRIDGE_PORT", port.to_string());
+    cmd.env("CURSOR_BRIDGE_CHAT_ONLY_WORKSPACE", "false");
+    cmd.env("CURSOR_BRIDGE_USE_ACP", "true");
+    cmd.env("CURSOR_BRIDGE_MODE", "ask");
+    // CLI catalog ids (cursor-grok-4.6-medium-fast) are valid for `agent --model`
+    // but do not appear in ACP availableModels. Proxy defaults strictModel=true
+    // and treats that mismatch as fatal (empty stderr, exit 1).
+    cmd.env("CURSOR_BRIDGE_STRICT_MODEL", "false");
+    cmd.env("CURSOR_BRIDGE_WORKSPACE", workspace);
+    cmd.env("CURSOR_CONFIG_DIR", config_dir);
+    cmd.env("CURSOR_BRIDGE_TIMEOUT_MS", PROXY_TIMEOUT_MS.to_string());
+    if let (Some(agent_bin), Some(wrapper)) = (agent_bin, wrapper) {
+        cmd.env("CURSOR_AGENT_BIN", wrapper);
+        cmd.env("QMAI_CURSOR_AGENT_REAL", agent_bin);
+    }
 }
 
 fn normalize_proxy_base(base_url: Option<String>) -> String {
@@ -154,7 +519,7 @@ fn base_url_for_port(port: u16) -> String {
     format!("http://127.0.0.1:{port}")
 }
 
-async fn http_get_status(base: &str, path: &str) -> Result<u16, String> {
+async fn http_get(base: &str, path: &str) -> Result<(u16, String), String> {
     let (host, port, _) = parse_http_url(base)?;
     let request_path = if path.starts_with('/') {
         path.to_string()
@@ -178,20 +543,39 @@ async fn http_get_status(base: &str, path: &str) -> Result<u16, String> {
         .await
         .map_err(|e| format!("health check write failed: {e}"))?;
 
-    let mut buf = vec![0u8; 1024];
-    let n = tokio::time::timeout(Duration::from_secs(2), stream.read(&mut buf))
-        .await
-        .map_err(|_| "health check timed out reading".to_string())?
-        .map_err(|e| format!("health check read failed: {e}"))?;
+    let mut buf = Vec::new();
+    let mut chunk = vec![0u8; 4096];
+    loop {
+        let n = tokio::time::timeout(Duration::from_secs(2), stream.read(&mut chunk))
+            .await
+            .map_err(|_| "health check timed out reading".to_string())?
+            .map_err(|e| format!("health check read failed: {e}"))?;
+        if n == 0 {
+            break;
+        }
+        buf.extend_from_slice(&chunk[..n]);
+        if buf.len() > 64 * 1024 {
+            break;
+        }
+    }
 
-    let text = String::from_utf8_lossy(&buf[..n]);
+    let text = String::from_utf8_lossy(&buf);
     let status_line = text.lines().next().unwrap_or("");
     let code = status_line
         .split_whitespace()
         .nth(1)
         .and_then(|s| s.parse::<u16>().ok())
         .ok_or_else(|| format!("unexpected health response: {status_line}"))?;
-    Ok(code)
+    let header_end = text
+        .find("\r\n\r\n")
+        .map(|i| i + 4)
+        .or_else(|| text.find("\n\n").map(|i| i + 2))
+        .unwrap_or(0);
+    Ok((code, text[header_end..].to_string()))
+}
+
+async fn http_get_status(base: &str, path: &str) -> Result<u16, String> {
+    http_get(base, path).await.map(|(status, _)| status)
 }
 
 async fn ping_health(base: &str) -> bool {
@@ -278,6 +662,99 @@ pub async fn cursor_cli_detect() -> Result<DetectResult, String> {
     do_cursor_cli_detect().await
 }
 
+#[tauri::command]
+pub async fn cursor_cli_about() -> Result<AgentAboutResult, String> {
+    let (path, ok, output) = match run_agent_command(
+        &["about", "--format", "json"],
+        AGENT_ABOUT_TIMEOUT,
+        None,
+    )
+    .await
+    {
+        Ok(result) => result,
+        Err(error) => {
+            return Ok(AgentAboutResult {
+                installed: false,
+                version: None,
+                latest_status: None,
+                latest_version: None,
+                path: None,
+                error: Some(error),
+            });
+        }
+    };
+    let path_str = path.to_string_lossy().to_string();
+    if let Some((version, latest_status, latest_version)) = parse_agent_about_json(&output) {
+        return Ok(AgentAboutResult {
+            installed: true,
+            version: Some(version),
+            latest_status,
+            latest_version,
+            path: Some(path_str),
+            error: if ok {
+                None
+            } else {
+                Some(output)
+            },
+        });
+    }
+    Ok(AgentAboutResult {
+        installed: true,
+        version: None,
+        latest_status: None,
+        latest_version: None,
+        path: Some(path_str),
+        error: Some(if output.is_empty() {
+            "`agent about --format json` returned no version".to_string()
+        } else {
+            output
+        }),
+    })
+}
+
+#[tauri::command]
+pub async fn cursor_cli_update() -> Result<AgentUpdateResult, String> {
+    if AGENT_UPDATE_IN_FLIGHT
+        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
+        .is_err()
+    {
+        return Ok(AgentUpdateResult {
+            ok: false,
+            version: None,
+            output: String::new(),
+            error: Some("cursor-agent update is already running".to_string()),
+        });
+    }
+
+    let _guard = AgentUpdateGuard;
+
+    match run_agent_command(&["update"], AGENT_UPDATE_TIMEOUT, None).await {
+        Ok((_path, ok, output)) => {
+            let version = do_cursor_cli_detect().await.ok().and_then(|detected| detected.version);
+            Ok(AgentUpdateResult {
+                ok,
+                version,
+                output: output.clone(),
+                error: if ok {
+                    None
+                } else {
+                    Some(if output.is_empty() {
+                        "`agent update` failed".to_string()
+                    } else {
+                        output
+                    })
+                },
+            })
+        }
+        Err(error) => Ok(AgentUpdateResult {
+            ok: false,
+            version: None,
+            output: String::new(),
+            error: Some(error),
+        }),
+    }
+}
+
 #[tauri::command]
 pub async fn cursor_proxy_status(state: State<'_, CursorProxyState>) -> Result<ProxyStatus, String> {
     let (base, managed) = {
@@ -393,16 +870,34 @@ fn shell_quote(value: &str) -> String {
     format!("'{}'", value.replace('\'', "'\"'\"'"))
 }
 
-async fn spawn_proxy_process(port: u16) -> Result<Child, String> {
-    let (launcher, extra_args) = find_proxy_launcher().await?;
+/// Windows: wrap with `cmd /c` so `.cmd`/`.bat` shims like npx resolve.
+#[cfg(any(test, windows))]
+fn should_wrap_windows_launcher(launcher: &str) -> bool {
+    let name = launcher
+        .rsplit(['/', '\\'])
+        .next()
+        .unwrap_or(launcher)
+        .to_ascii_lowercase();
+    name != "cmd" && name != "cmd.exe"
+}
+
+async fn spawn_proxy_process(
+    port: u16,
+    launcher: &Path,
+    extra_args: &[String],
+    workspace: &Path,
+    config_dir: &Path,
+) -> Result<Child, String> {
     let path_env = child_path_env().await;
+    let agent_bin = find_agent_command().await.ok();
+    let wrapper = ensure_qmai_agent_wrapper(config_dir, agent_bin.as_deref()).ok();
 
     #[cfg(unix)]
     {
         let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
         let mut parts = Vec::with_capacity(1 + extra_args.len());
         parts.push(shell_quote(&launcher.to_string_lossy()));
-        for arg in &extra_args {
+        for arg in extra_args {
             parts.push(shell_quote(arg));
         }
         let cmdline = format!("exec {}", parts.join(" "));
@@ -414,8 +909,14 @@ async fn spawn_proxy_process(port: u16) -> Result<Child, String> {
             cmd.env("PATH", path_env);
         }
         apply_cursor_auth_env(&mut cmd);
-        cmd.env("CURSOR_BRIDGE_HOST", "127.0.0.1");
-        cmd.env("CURSOR_BRIDGE_PORT", port.to_string());
+        apply_qmai_proxy_env(
+            &mut cmd,
+            port,
+            workspace,
+            config_dir,
+            agent_bin.as_deref(),
+            wrapper.as_deref(),
+        );
         cmd.args(["-l", "-c", &cmdline]);
         cmd.stdin(std::process::Stdio::null())
             .stdout(std::process::Stdio::null())
@@ -429,16 +930,31 @@ async fn spawn_proxy_process(port: u16) -> Result<Child, String> {
 
     #[cfg(windows)]
     {
-        let mut cmd = Command::new(&launcher);
+        let launcher_str = launcher.to_string_lossy();
+        let mut cmd = if should_wrap_windows_launcher(&launcher_str) {
+            let mut wrapped = Command::new("cmd");
+            wrapped.arg("/c").arg(launcher.as_os_str());
+            wrapped.args(extra_args);
+            wrapped
+        } else {
+            let mut direct = Command::new(launcher);
+            direct.args(extra_args);
+            direct
+        };
         suppress_windows_console(&mut cmd);
         apply_local_cli_environment(&mut cmd);
         if let Some(path_env) = path_env {
             cmd.env("PATH", path_env);
         }
         apply_cursor_auth_env(&mut cmd);
-        cmd.env("CURSOR_BRIDGE_HOST", "127.0.0.1");
-        cmd.env("CURSOR_BRIDGE_PORT", port.to_string());
-        cmd.args(&extra_args);
+        apply_qmai_proxy_env(
+            &mut cmd,
+            port,
+            workspace,
+            config_dir,
+            agent_bin.as_deref(),
+            wrapper.as_deref(),
+        );
         cmd.stdin(std::process::Stdio::null())
             .stdout(std::process::Stdio::null())
             .stderr(std::process::Stdio::null())
@@ -456,6 +972,7 @@ async fn stop_managed_child(state: &CursorProxyState) {
         let _ = tokio::time::timeout(Duration::from_secs(3), child.wait()).await;
     }
     guard.base_url = None;
+    guard.launch_fingerprint = None;
 }
 
 async fn wait_until_healthy(base: &str) -> bool {
@@ -476,13 +993,19 @@ pub async fn cursor_proxy_ensure(
     force_restart: Option<bool>,
 ) -> Result<ProxyStatus, String> {
     let force = force_restart.unwrap_or(false);
+    let (config_dir, workspace) = qmai_proxy_home_dirs()?;
+    ensure_qmai_cli_config(&config_dir)?;
+    ensure_qmai_workspace_trusted(&workspace, &config_dir).await;
+    let (launcher, extra_args) = find_proxy_launcher().await?;
+    let fingerprint = proxy_launch_fingerprint(&launcher, &extra_args, &workspace, &config_dir);
 
     {
         let mut guard = state.managed.lock().await;
         if let Some(child) = guard.child.as_mut() {
             match child.try_wait() {
                 Ok(None) => {
-                    if !force {
+                    let env_matches = guard.launch_fingerprint.as_deref() == Some(fingerprint.as_str());
+                    if !force && env_matches {
                         if let Some(base) = guard.base_url.clone() {
                             drop(guard);
                             if ping_health(&base).await {
@@ -499,6 +1022,7 @@ pub async fn cursor_proxy_ensure(
                 _ => {
                     guard.child = None;
                     guard.base_url = None;
+                    guard.launch_fingerprint = None;
                 }
             }
         }
@@ -508,11 +1032,12 @@ pub async fn cursor_proxy_ensure(
 
     let port = allocate_proxy_port()?;
     let base = base_url_for_port(port);
-    let child = spawn_proxy_process(port).await?;
+    let child = spawn_proxy_process(port, &launcher, &extra_args, &workspace, &config_dir).await?;
     {
         let mut guard = state.managed.lock().await;
         guard.child = Some(child);
         guard.base_url = Some(base.clone());
+        guard.launch_fingerprint = Some(fingerprint);
     }
 
     if wait_until_healthy(&base).await {
@@ -602,4 +1127,143 @@ mod tests {
         );
         let _ = std::fs::remove_dir_all(&dir);
     }
+
+    #[test]
+    fn trusts_qmai_workspace_with_official_flag() {
+        let ws = PathBuf::from("/Users/omi/.cursor-api-proxy/qmai-workspace");
+        assert_eq!(
+            agent_trust_args(&ws),
+            vec![
+                "--trust".to_string(),
+                "--workspace".to_string(),
+                ws.to_string_lossy().into_owned(),
+                "--mode".to_string(),
+                "ask".to_string(),
+                "--output-format".to_string(),
+                "text".to_string(),
+                "-p".to_string(),
+                "ok".to_string(),
+            ]
+        );
+        let cfg = PathBuf::from("/Users/omi/.cursor-api-proxy/qmai-agent");
+        assert_eq!(
+            qmai_workspace_trust_marker(&cfg),
+            cfg.join(".qmai-workspace-trusted")
+        );
+    }
+
+    #[test]
+    fn proxy_fingerprint_enables_acp() {
+        let fp = proxy_launch_fingerprint(
+            Path::new("/usr/bin/npx"),
+            &["--yes".to_string(), "cursor-api-proxy@latest".to_string()],
+            Path::new("/tmp/ws"),
+            Path::new("/tmp/cfg"),
+        );
+        assert!(fp.contains("acp=true"));
+        assert!(fp.contains("agent_wrap=4"));
+        assert!(fp.contains("strict=off"));
+        assert!(!fp.contains("acp=false"));
+    }
+
+    #[test]
+    fn default_npx_proxy_args_pin_latest() {
+        assert_eq!(
+            npx_proxy_args(),
+            vec!["--yes".to_string(), "cursor-api-proxy@latest".to_string()]
+        );
+        assert!(!npx_missing_error().contains("npm i -g"));
+    }
+
+    #[test]
+    fn wraps_npx_but_not_cmd_on_windows() {
+        assert!(should_wrap_windows_launcher(r"C:\Program Files\nodejs\npx.cmd"));
+        assert!(should_wrap_windows_launcher("/usr/local/bin/npx"));
+        assert!(!should_wrap_windows_launcher(r"C:\Windows\System32\cmd.exe"));
+        assert!(!should_wrap_windows_launcher("cmd"));
+    }
+
+    #[test]
+    fn explicit_proxy_bin_must_be_a_file() {
+        let missing = std::env::temp_dir().join(format!(
+            "qmai-missing-proxy-bin-{}",
+            std::process::id()
+        ));
+        let err = resolve_explicit_proxy_bin(&missing).unwrap_err();
+        assert!(err.contains("CURSOR_API_PROXY_BIN"));
+
+        let file = std::env::temp_dir().join(format!("qmai-proxy-bin-{}", std::process::id()));
+        std::fs::write(&file, "x").unwrap();
+        let (path, args) = resolve_explicit_proxy_bin(&file).unwrap();
+        assert_eq!(path, file);
+        assert!(args.is_empty());
+        let _ = std::fs::remove_file(&file);
+    }
+
+    #[test]
+    fn writes_disable_auto_update_into_qmai_cli_config() {
+        let dir = std::env::temp_dir().join(format!("qmai-agent-config-{}", std::process::id()));
+        let _ = std::fs::create_dir_all(&dir);
+        let path = ensure_qmai_cli_config(&dir).unwrap();
+        let raw = std::fs::read_to_string(&path).unwrap();
+        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
+        assert_eq!(json["disableAutoUpdate"], true);
+        apply_qmai_acp_model(
+            &dir,
+            "grok-4.6",
+            Some(true),
+            Some("medium"),
+            Some("cursor-grok-4.6-medium-fast"),
+        )
+        .unwrap();
+        let raw = std::fs::read_to_string(&path).unwrap();
+        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
+        assert_eq!(json["selectedModel"]["modelId"], "grok-4.6");
+        assert_eq!(json["selectedModel"]["parameters"][0]["id"], "effort");
+        assert_eq!(json["selectedModel"]["parameters"][0]["value"], "medium");
+        assert_eq!(json["selectedModel"]["parameters"][1]["id"], "fast");
+        assert_eq!(json["selectedModel"]["parameters"][1]["value"], "true");
+        assert_eq!(json["model"]["modelId"], "grok-4.6");
+        assert_eq!(json["disableAutoUpdate"], true);
+        assert_eq!(
+            std::fs::read_to_string(dir.join("qmai-acp-model")).unwrap(),
+            "cursor-grok-4.6-medium-fast"
+        );
+        let wrapper = ensure_qmai_agent_wrapper(&dir, Some(Path::new("/usr/local/bin/agent"))).unwrap();
+        assert!(wrapper.exists());
+        let script = std::fs::read_to_string(dir.join("qmai-cursor-agent.cjs")).unwrap();
+        assert!(script.contains("qmai-acp-model"));
+        assert!(script.contains("resolveAcpArgvModel"));
+        assert!(script.contains("QMAI_CURSOR_AGENT_REAL"));
+        assert!(script.contains("/usr/local/bin/agent"));
+        assert!(!script.contains("__QMAI_BAKED_AGENT__"));
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn formats_acp_model_pin() {
+        assert_eq!(qmai_acp_model_value("grok-4.6", None, None), "grok-4.6");
+        assert_eq!(
+            qmai_acp_model_value("grok-4.6", Some(true), Some("high")),
+            "grok-4.6[effort=high,fast=true]"
+        );
+        assert_eq!(
+            qmai_acp_model_value("composer-2.5", Some(false), None),
+            "composer-2.5[fast=false]"
+        );
+    }
+
+    #[test]
+    fn parses_agent_about_json() {
+        let (version, status, latest) = parse_agent_about_json(
+            r#"noise
+{"cliVersion":"2026.08.31-4057e58","latestStatus":"update_available","latestVersion":"2026.09.01-aaaaaaa"}
+tail"#,
+        )
+        .unwrap();
+        assert_eq!(version, "2026.08.31-4057e58");
+        assert_eq!(status.as_deref(), Some("update_available"));
+        assert_eq!(latest.as_deref(), Some("2026.09.01-aaaaaaa"));
+        assert!(parse_agent_about_json(r#"{"latestStatus":"up_to_date"}"#).is_none());
+    }
 }

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

@@ -108,6 +108,9 @@ pub fn run() {
             commands::codex_cli::codex_app_server_write,
             commands::codex_cli::codex_app_server_stop,
             commands::cursor_cli::cursor_cli_detect,
+            commands::cursor_cli::cursor_cli_about,
+            commands::cursor_cli::cursor_cli_update,
+            commands::cursor_cli::cursor_cli_apply_acp_model,
             commands::cursor_cli::cursor_proxy_status,
             commands::cursor_cli::cursor_proxy_ensure,
             commands::cursor_cli::cursor_proxy_stop,

+ 6 - 2
src/components/layout/knowledge-tree.tsx

@@ -738,6 +738,7 @@ export function KnowledgeTree({
 
     let completed = 0
     let failed = 0
+    let lastError = ""
     const titleByPath = new Map(sortedChapterPages.map((page) => [page.path, page.title]))
 
     try {
@@ -772,6 +773,7 @@ export function KnowledgeTree({
           completed += 1
         } else {
           failed += 1
+          lastError = result.error ?? result.failReason ?? lastError
         }
       }
 
@@ -780,7 +782,7 @@ export function KnowledgeTree({
         total: chapterPaths.length,
         currentTitle: "",
         message: failed > 0
-          ? `章节记忆提取完成:成功 ${completed} 个,失败 ${failed} 个。`
+          ? `章节记忆提取完成:成功 ${completed} 个,失败 ${failed} 个。${lastError ? ` 最近错误:${lastError}` : ""}`
           : `章节记忆提取完成:成功 ${completed} 个章节。`,
       })
       await loadPages()
@@ -802,7 +804,9 @@ export function KnowledgeTree({
         completed,
         total: chapterPaths.length,
         currentTitle: "",
-        message: `章节记忆提取失败:已完成 ${completed}/${chapterPaths.length} 个章节。`,
+        message: `章节记忆提取失败:已完成 ${completed}/${chapterPaths.length} 个章节。${
+          error instanceof Error ? ` ${error.message}` : ` ${String(error)}`
+        }`,
       })
     } finally {
       setPageMenu(null)

+ 10 - 3
src/components/layout/preview-panel.tsx

@@ -889,7 +889,10 @@ export function PreviewPanel() {
             completed: result.snapshot ? 1 : 0,
             total: 1,
             currentTitle: "",
-            message: result.snapshot ? `${chapterTitle} 提取完成` : `${chapterTitle} 提取失败`,
+            message: result.snapshot
+              ? `${chapterTitle} 提取完成`
+              : `${chapterTitle} 提取失败:${result.error ?? result.failReason ?? "未知错误"}`,
+            error: result.error,
           })
           if (result.snapshot) {
             updatePhase(false, "ingested", { chapter: result.snapshot.chapterNumber })
@@ -947,7 +950,10 @@ export function PreviewPanel() {
         completed: result.snapshot ? 1 : 0,
         total: 1,
         currentTitle: "",
-        message: result.snapshot ? `${chapterTitle} 提取完成` : `${chapterTitle} 提取失败`,
+        message: result.snapshot
+          ? `${chapterTitle} 提取完成`
+          : `${chapterTitle} 提取失败:${result.error ?? result.failReason ?? "未知错误"}`,
+        error: result.error,
       })
       if (result.snapshot) {
         updatePhase(false, "ingested", { chapter: result.snapshot.chapterNumber })
@@ -962,7 +968,8 @@ export function PreviewPanel() {
         completed: 0,
         total: 1,
         currentTitle: "",
-        message: `${chapterTitle} 提取失败`,
+        message: `${chapterTitle} 提取失败:${message}`,
+        error: message,
       })
       updatePhase(false, "ingest_failed", { message: message.slice(0, 100) })
     } finally {

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

@@ -156,6 +156,13 @@ describe("QMAI model settings", () => {
     expect(cursor.model).toBe("composer-2-fast")
     expect(cursor.apiKey).toBe("")
 
+    const cursorMapped = resolveConfig(
+      preset("cursor-cli"),
+      { model: "cursor-grok-4.6-medium-fast" },
+      fallback,
+    )
+    expect(cursorMapped.model).toBe("cursor-grok-4.6-medium-fast")
+
     const cursorProvider = getProviderConfig(cursor)
     expect(cursorProvider.url).toBe("http://127.0.0.1:8765/v1/chat/completions")
     expect(cursorProvider.headers.Authorization).toBe("Bearer unused")

+ 20 - 0
src/components/settings/sections/llm-provider-section.spec.ts

@@ -32,6 +32,19 @@ describe("LLM provider model controls", () => {
     expect(source).toContain('settings.sections.llm.functionCalling.label')
   })
 
+  it("starts cursor-api-proxy via npx @latest instead of a global install", () => {
+    expect(source).toContain('t("settings.sections.llm.cliStatus.cursorProxyNpxHint")')
+    expect(source).not.toContain("npm i -g cursor-api-proxy")
+  })
+
+  it("exposes Cursor agent version check and update", () => {
+    expect(source).toContain("checkCursorAgentUpdate")
+    expect(source).toContain("updateCursorAgent")
+    expect(source).toContain('t("settings.sections.llm.cliStatus.cursorCheckUpdate")')
+    expect(source).toContain('t("settings.sections.llm.cliStatus.cursorUpdateNow")')
+    expect(source).toContain("await detect({ silent: true })")
+  })
+
   it("keeps Codex CLI isolated and requires app-server dynamic tools", () => {
     expect(source).toContain('const showLocalCliIsolation = preset.provider === "claude-code"')
     expect(source).toContain("r.installed && r.appServerReady === true && r.dynamicToolsReady === true")
@@ -44,4 +57,11 @@ describe("LLM provider model controls", () => {
     expect(source).toContain('codexSpeedMode: codexFastEnabled ? "standard" : "fast"')
     expect(source).toContain('settings.sections.llm.codexSpeedFast')
   })
+
+  it("does not expose Cursor Fast or reasoning controls", () => {
+    expect(source).not.toContain("resolveCursorSpeedMode")
+    expect(source).not.toContain("cursorSpeedMode")
+    expect(source).toContain('{preset.provider !== "cursor-cli" && (')
+    expect(source).toContain("<ReasoningControls")
+  })
 })

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

@@ -798,10 +798,12 @@ function PresetRow({
             />
           </div>
 
-          <ReasoningControls
-            value={reasoning}
-            onChange={(next) => onChange(withOutputRoomForReasoning(next, maxOutputTokens))}
-          />
+          {preset.provider !== "cursor-cli" && (
+            <ReasoningControls
+              value={reasoning}
+              onChange={(next) => onChange(withOutputRoomForReasoning(next, maxOutputTokens))}
+            />
+          )}
 
           <FunctionCallingControls
             enabled={ov.functionCallingEnabled !== false}
@@ -1365,10 +1367,19 @@ function CursorCliStatusPill() {
   const [proxyError, setProxyError] = useState<string | null>(null)
   const [proxyHealthy, setProxyHealthy] = useState(false)
   const [proxyBase, setProxyBase] = useState<string | null>(null)
-
-  async function detect() {
-    setState("loading")
-    setProxyError(null)
+  const [agentAction, setAgentAction] = useState<"idle" | "checking" | "updating">("idle")
+  const [updateNote, setUpdateNote] = useState<{
+    kind: "ok" | "info" | "err"
+    text: string
+  } | null>(null)
+
+  const actionBusy = agentAction !== "idle"
+
+  async function detect(opts?: { silent?: boolean }) {
+    if (!opts?.silent) {
+      setState("loading")
+      setProxyError(null)
+    }
     if (!isTauri()) {
       setAgent({
         installed: false,
@@ -1416,24 +1427,136 @@ function CursorCliStatusPill() {
     }
   }
 
+  async function checkAgentUpdate() {
+    if (actionBusy) return
+    setAgentAction("checking")
+    setUpdateNote(null)
+    try {
+      const { checkCursorAgentUpdate } = await import("@/lib/cursor-cli-proxy")
+      const about = await checkCursorAgentUpdate()
+      if (about.version) {
+        setAgent((prev) => ({
+          installed: true,
+          version: about.version,
+          path: about.path ?? prev?.path ?? null,
+          error: about.error,
+        }))
+      }
+      if (!about.installed) {
+        setUpdateNote({
+          kind: "err",
+          text: about.error ?? t("settings.sections.llm.cliStatus.cursorAgentUnavailable"),
+        })
+        return
+      }
+      if (about.latest_status === "up_to_date") {
+        setUpdateNote({
+          kind: "ok",
+          text: t("settings.sections.llm.cliStatus.cursorLatestUpToDate"),
+        })
+        return
+      }
+      if (about.latest_status === "update_available") {
+        setUpdateNote({
+          kind: "info",
+          text: t("settings.sections.llm.cliStatus.cursorLatestAvailable", {
+            version: about.latest_version ?? "?",
+          }),
+        })
+        return
+      }
+      setUpdateNote({
+        kind: "err",
+        text: about.error ?? t("settings.sections.llm.cliStatus.cursorLatestUnknown"),
+      })
+    } catch (e) {
+      setUpdateNote({
+        kind: "err",
+        text: e instanceof Error ? e.message : String(e),
+      })
+    } finally {
+      setAgentAction("idle")
+    }
+  }
+
+  async function runAgentUpdate() {
+    if (actionBusy) return
+    setAgentAction("updating")
+    setUpdateNote(null)
+    try {
+      const { updateCursorAgent } = await import("@/lib/cursor-cli-proxy")
+      const result = await updateCursorAgent()
+      if (result.ok) {
+        setUpdateNote({
+          kind: "ok",
+          text: t("settings.sections.llm.cliStatus.cursorUpdateOk", {
+            version: result.version ?? "?",
+          }),
+        })
+        await detect({ silent: true })
+        return
+      }
+      const busy = /already running/i.test(result.error ?? "")
+      setUpdateNote({
+        kind: "err",
+        text: busy
+          ? t("settings.sections.llm.cliStatus.cursorUpdateBusy")
+          : t("settings.sections.llm.cliStatus.cursorUpdateFailed", {
+              message: result.error ?? result.output,
+            }),
+      })
+    } catch (e) {
+      setUpdateNote({
+        kind: "err",
+        text: t("settings.sections.llm.cliStatus.cursorUpdateFailed", {
+          message: e instanceof Error ? e.message : String(e),
+        }),
+      })
+    } finally {
+      setAgentAction("idle")
+    }
+  }
+
   useEffect(() => {
     void detect()
   }, [])
 
+  const pillBusy = state === "loading" || actionBusy
+
   return (
     <div className="space-y-1.5">
-      <div className="flex items-center gap-2">
+      <div className="flex flex-wrap items-center gap-2">
         <Label className="m-0">{t("settings.sections.llm.cliStatus.title")}</Label>
         <button
           type="button"
           onClick={() => void detect()}
-          className="rounded border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground"
-          disabled={state === "loading"}
+          className="rounded border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
+          disabled={pillBusy}
         >
           {state === "loading"
             ? t("settings.sections.llm.cliStatus.checking")
             : t("settings.sections.llm.cliStatus.recheck")}
         </button>
+        {agent?.installed && (
+          <>
+            <button
+              type="button"
+              onClick={() => void checkAgentUpdate()}
+              className="rounded border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
+              disabled={pillBusy}
+            >
+              {t("settings.sections.llm.cliStatus.cursorCheckUpdate")}
+            </button>
+            <button
+              type="button"
+              onClick={() => void runAgentUpdate()}
+              className="rounded border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
+              disabled={pillBusy}
+            >
+              {t("settings.sections.llm.cliStatus.cursorUpdateNow")}
+            </button>
+          </>
+        )}
       </div>
       <div
         className={`flex items-start gap-1.5 rounded-md border px-2 py-1.5 text-xs ${
@@ -1477,6 +1600,38 @@ function CursorCliStatusPill() {
                 </code>{" "}
                 {t("settings.sections.llm.cliStatus.cursorAuthErrorSuffix")}
               </div>
+              {agent?.installed && (
+                <div className="text-muted-foreground">
+                  {t("settings.sections.llm.cliStatus.cursorUpdatePrefix")}{" "}
+                  <code className="rounded bg-background/60 px-1 py-0.5 font-mono text-[10px]">
+                    agent update
+                  </code>{" "}
+                  {t("settings.sections.llm.cliStatus.cursorUpdateSuffix")}
+                </div>
+              )}
+              {agentAction === "checking" && (
+                <div className="text-muted-foreground">
+                  {t("settings.sections.llm.cliStatus.cursorUpdateChecking")}
+                </div>
+              )}
+              {agentAction === "updating" && (
+                <div className="text-muted-foreground">
+                  {t("settings.sections.llm.cliStatus.cursorUpdating")}
+                </div>
+              )}
+              {updateNote && (
+                <div
+                  className={
+                    updateNote.kind === "err"
+                      ? "text-rose-700 dark:text-rose-400"
+                      : updateNote.kind === "ok"
+                        ? "text-emerald-700 dark:text-emerald-400"
+                        : "text-muted-foreground"
+                  }
+                >
+                  {updateNote.text}
+                </div>
+              )}
               {!agent?.installed && (
                 <div className="text-muted-foreground">
                   {t("settings.sections.llm.cliStatus.installPrefix")}{" "}
@@ -1488,11 +1643,7 @@ function CursorCliStatusPill() {
               )}
               {agent?.installed && !proxyHealthy && (
                 <div className="text-muted-foreground">
-                  {t("settings.sections.llm.cliStatus.installPrefix")}{" "}
-                  <code className="rounded bg-background/60 px-1 py-0.5 font-mono text-[10px]">
-                    npx cursor-api-proxy
-                  </code>{" "}
-                  {t("settings.sections.llm.cliStatus.installSuffix")}
+                  {t("settings.sections.llm.cliStatus.cursorProxyNpxHint")}
                 </div>
               )}
             </>

+ 15 - 2
src/i18n/en.json

@@ -844,6 +844,18 @@
           "codexReady": "Detected{{versionSuffix}}. Ready to use your local login — no API key needed.",
           "cursorAgentReady": "Cursor agent detected{{versionSuffix}}.",
           "cursorProxyReady": "cursor-api-proxy is ready ({{baseUrl}}).",
+          "cursorUpdatePrefix": "QMAI will not auto-update the agent. You can also run",
+          "cursorUpdateSuffix": "in a terminal.",
+          "cursorCheckUpdate": "Check for updates",
+          "cursorUpdateNow": "Update agent",
+          "cursorUpdateChecking": "Checking the latest agent version…",
+          "cursorUpdating": "Updating Cursor agent…",
+          "cursorLatestUpToDate": "Already up to date.",
+          "cursorLatestAvailable": "Update available: {{version}}.",
+          "cursorLatestUnknown": "Could not check the latest version (login or network).",
+          "cursorUpdateOk": "Updated to {{version}}.",
+          "cursorUpdateFailed": "Update failed: {{message}}",
+          "cursorUpdateBusy": "An update is already running.",
           "authErrorPrefix": "If chat fails with an authentication error, run",
           "claudeAuthErrorSuffix": "in a terminal to refresh the OAuth login.",
           "codexAuthErrorSuffix": "in a terminal to refresh the login.",
@@ -852,6 +864,7 @@
           "codexUnavailable": "Codex CLI not available.",
           "cursorAgentUnavailable": "Cursor agent CLI not available.",
           "cursorProxyUnavailable": "cursor-api-proxy is not reachable.",
+          "cursorProxyNpxHint": "QMAI starts it with npx --yes cursor-api-proxy@latest. Requires Node.js 18+, or set CURSOR_API_PROXY_BIN.",
           "desktopOnly": "Desktop only",
           "installPrefix": "Install with",
           "installSuffix": "then re-check."
@@ -1417,9 +1430,9 @@
       "testUsingDefaultMainModel": "Using main model: {{model}}",
       "testUsingCurrentModel": "Testing model: {{model}}",
       "communitySummaryEnabled": "Auto Community Summary",
-      "communitySummaryEnabledHint": "When enabled, an LLM generates narrative summaries for graph communities every N chapters, used to answer global questions (e.g., character factions, main relationship networks). Requires vector model to be enabled.",
+      "communitySummaryEnabledHint": "When enabled, every N chapters QMAI checks graph communities and only summarizes those whose membership changed and that have at least 5 nodes. Used for global questions (e.g. factions, relationship networks). Requires the vector model.",
       "communitySummaryInterval": "Community Summary Interval",
-      "communitySummaryIntervalHint": "How many chapters to ingest before auto-rebuilding community summaries. Smaller values mean fresher summaries but more frequent LLM calls.",
+      "communitySummaryIntervalHint": "How many ingested chapters between community-summary checks. Only communities whose membership changed and that meet the size floor are rebuilt — not a full rerun.",
       "communitySummaryAsync": "Background Async Execution",
       "communitySummaryAsyncHint": "When enabled, community summaries are generated in the background without blocking chapter ingestion. Disable to wait synchronously.",
       "communitySummaryFailed": "Community summary generation failed: {{message}}. Will retry on next trigger."

+ 15 - 2
src/i18n/zh.json

@@ -550,6 +550,18 @@
           "codexReady": "Codex CLI 可用{{versionSuffix}},无需填写 API Key。",
           "cursorAgentReady": "Cursor agent 可用{{versionSuffix}}。",
           "cursorProxyReady": "cursor-api-proxy 已就绪({{baseUrl}})。",
+          "cursorUpdatePrefix": "QMAI 调用时不会自动更新 Agent。也可在终端执行",
+          "cursorUpdateSuffix": "。",
+          "cursorCheckUpdate": "检查更新",
+          "cursorUpdateNow": "更新 Agent",
+          "cursorUpdateChecking": "正在查询最新版本…",
+          "cursorUpdating": "正在更新 Agent…",
+          "cursorLatestUpToDate": "已是最新版。",
+          "cursorLatestAvailable": "可更新到 {{version}}。",
+          "cursorLatestUnknown": "未能查询最新版(未登录或网络失败)。",
+          "cursorUpdateOk": "已更新到 {{version}}。",
+          "cursorUpdateFailed": "更新失败:{{message}}",
+          "cursorUpdateBusy": "已有更新在进行。",
           "authErrorPrefix": "如果运行时报认证错误,请先在终端执行",
           "claudeAuthErrorSuffix": "完成 Claude OAuth 登录。",
           "codexAuthErrorSuffix": "完成 Codex 登录。",
@@ -558,6 +570,7 @@
           "codexUnavailable": "未检测到 Codex CLI。",
           "cursorAgentUnavailable": "未检测到 Cursor agent CLI。",
           "cursorProxyUnavailable": "cursor-api-proxy 未就绪。",
+          "cursorProxyNpxHint": "QMAI 通过 npx --yes cursor-api-proxy@latest 启动,需要 Node.js 18+,或设置 CURSOR_API_PROXY_BIN。",
           "desktopOnly": "仅桌面应用可检测 CLI 状态。",
           "installPrefix": "可执行",
           "installSuffix": "安装后再重新检查。"
@@ -1289,9 +1302,9 @@
       "feedbackWindowPreviousChapterCarryEnabledHelp": "开启后,上一章审稿时标记的「延续事项」(下一章需要继续处理的情节线索、伏笔等)会注入当前章节的写作上下文,确保章节间衔接连贯。关闭后,上一章的延续事项不会注入,每章更独立地写作,适合各章相对独立的故事结构。",
       "feedbackWindowLookbackIncludeMustFixOnlyHelp": "开启后,回溯的那些历史章节只保留「必须修复」级别的反馈(如设定冲突、逻辑错误等严重问题),忽略改进建议,减少旧反馈对当前写作的干扰。关闭后,回溯章节不仅保留必须修复项,也会贡献改进建议,让 AI 参考更多历史意见。",
       "communitySummaryEnabled": "社区摘要自动提取",
-      "communitySummaryEnabledHint": "开启后,每 N 章自动用 LLM 为图谱社区生成叙事摘要,用于回答全局性问题(如角色阵营、主要关系网络等)。需要先启用向量模型。",
+      "communitySummaryEnabledHint": "开启后,每 N 章检查一次图谱社区:只为成员集变化且规模达标(至少 5 个节点)的社区生成叙事摘要,用于回答全局性问题(如角色阵营、主要关系网络等)。需要先启用向量模型。",
       "communitySummaryInterval": "社区摘要提取间隔",
-      "communitySummaryIntervalHint": "每摄取多少章后自动重建一次社区摘要。数值越小,摘要越新鲜,但 LLM 调用越频繁。",
+      "communitySummaryIntervalHint": "每摄取多少章后检查一次社区摘要。只重建成员集变化且规模达标的社区,不会全量重跑。",
       "communitySummaryAsync": "后台异步执行",
       "communitySummaryAsyncHint": "开启后,社区摘要在后台生成,不阻塞章节摄取。关闭则同步等待生成完成。",
       "communitySummaryFailed": "社区摘要生成失败:{{message}}。下次触发时会自动重试。"

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

@@ -33,9 +33,19 @@ describe("function calling helpers", () => {
     expect(modelSupportsTools("gpt-4o")).toBe(true)
   })
 
-  it("blocks local CLI providers from agent tools", () => {
+  it("blocks Claude Code CLI from agent tools", () => {
     expect(modelSupportsTools("opus", "claude-code")).toBe(false)
-    expect(modelSupportsTools("composer-2-fast", "cursor-cli")).toBe(false)
+  })
+
+  it("allows cursor-cli native tools", () => {
+    expect(modelSupportsTools("composer-2-fast", "cursor-cli")).toBe(true)
+    expect(effectiveToolsEnabled("composer-2-fast", { ...baseLlm, provider: "cursor-cli", model: "composer-2-fast" })).toBe(true)
+    expect(effectiveToolsEnabled("composer-2-fast", {
+      ...baseLlm,
+      provider: "cursor-cli",
+      model: "composer-2-fast",
+      functionCallingEnabled: false,
+    })).toBe(false)
   })
 
   it("allows Codex app-server dynamic tools", () => {
@@ -57,4 +67,18 @@ describe("buildAgentConfig functionCallingEnabled", () => {
     expect(config.tools).toEqual([])
     expect(registry.list()).toEqual([])
   })
+
+  it("keeps cursor-cli on native tools without a text-JSON prompt", () => {
+    const registry = new ToolRegistry()
+    const config = buildAgentConfig("composer-2-fast", "You are helpful", registry, {
+      wikiPath: "/tmp/wiki",
+      getSkillConfig: () => null,
+      getChatConversations: () => [],
+      getOutlineConversations: () => [],
+      llmConfig: { ...baseLlm, provider: "cursor-cli", model: "composer-2-fast" },
+    })
+
+    expect(config.systemPrompt).toBe("You are helpful")
+    expect(config.systemPrompt).not.toContain("只输出一个 JSON")
+  })
 })

+ 1 - 12
src/lib/agent/config.ts

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

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

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

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

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

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

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

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

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

+ 156 - 0
src/lib/cursor-acp-models.spec.ts

@@ -0,0 +1,156 @@
+import { afterEach, describe, expect, it } from "vitest"
+import {
+  cliModelToAcpValue,
+  inferCursorEffortFromModel,
+  inferCursorSpeedModeFromModel,
+  pickCursorCliCatalogId,
+  rememberCursorCliCatalog,
+  resolveCursorSpeedMode,
+  toCursorAcpEffort,
+  toCursorAcpModelId,
+  toCursorAcpModelIds,
+  toCursorAcpModelValue,
+  toCursorHttpModel,
+} from "./cursor-acp-models"
+
+afterEach(() => {
+  rememberCursorCliCatalog([])
+})
+
+describe("toCursorAcpModelId", () => {
+  it("maps the extract CLI id to the ACP catalog name", () => {
+    expect(toCursorAcpModelId("cursor-grok-4.6-medium-fast")).toBe("grok-4.6")
+  })
+
+  it("collapses CLI effort/fast variants onto one ACP name", () => {
+    expect(toCursorAcpModelId("cursor-grok-4.6-high-fast")).toBe("grok-4.6")
+    expect(toCursorAcpModelId("cursor-grok-4.6-low")).toBe("grok-4.6")
+    expect(toCursorAcpModelId("claude-opus-4-7-medium-fast")).toBe("claude-opus-4-7")
+    expect(toCursorAcpModelId("claude-opus-4-7-thinking-max")).toBe("claude-opus-4-7")
+    expect(toCursorAcpModelId("gpt-5.3-codex-high")).toBe("gpt-5.3-codex")
+  })
+
+  it("aliases leftover print-mode ids", () => {
+    expect(toCursorAcpModelId("composer-2-fast")).toBe("composer-2.5")
+    expect(toCursorAcpModelId("auto")).toBe("default")
+  })
+
+  it("keeps ACP names and parameterized ids", () => {
+    expect(toCursorAcpModelId("grok-4.6")).toBe("grok-4.6")
+    expect(toCursorAcpModelId("grok-4.6[effort=high,fast=true]")).toBe("grok-4.6")
+    expect(toCursorAcpModelId("default")).toBe("default")
+  })
+})
+
+describe("toCursorAcpModelIds", () => {
+  it("dedupes CLI variants", () => {
+    expect(
+      toCursorAcpModelIds([
+        "cursor-grok-4.6-medium-fast",
+        "cursor-grok-4.6-high",
+        "composer-2-fast",
+        "grok-4.6",
+      ]),
+    ).toEqual(["grok-4.6", "composer-2.5"])
+  })
+})
+
+describe("resolveCursorSpeedMode", () => {
+  it("infers Fast from old CLI ids and ACP parameters", () => {
+    expect(inferCursorSpeedModeFromModel("cursor-grok-4.6-medium-fast")).toBe("fast")
+    expect(inferCursorSpeedModeFromModel("cursor-grok-4.6-high")).toBe("standard")
+    expect(inferCursorSpeedModeFromModel("grok-4.6[effort=high,fast=true]")).toBe("fast")
+    expect(inferCursorSpeedModeFromModel("grok-4.6")).toBe("fast")
+  })
+
+  it("prefers the saved switch over model-id inference", () => {
+    expect(resolveCursorSpeedMode("standard", "cursor-grok-4.6-medium-fast")).toBe("standard")
+    expect(resolveCursorSpeedMode("fast", "cursor-grok-4.6-high")).toBe("fast")
+  })
+
+  it("maps reasoning mode onto ACP effort", () => {
+    expect(toCursorAcpEffort({ mode: "high" })).toBe("high")
+    expect(toCursorAcpEffort({ mode: "max" })).toBe("xhigh")
+    expect(toCursorAcpEffort({ mode: "auto" })).toBeUndefined()
+  })
+
+  it("reads effort from CLI ids and ACP parameters", () => {
+    expect(inferCursorEffortFromModel("cursor-grok-4.6-medium-fast")).toBe("medium")
+    expect(inferCursorEffortFromModel("cursor-grok-4.6-high")).toBe("high")
+    expect(inferCursorEffortFromModel("grok-4.6[effort=high,fast=true]")).toBe("high")
+    expect(inferCursorEffortFromModel("composer-2-fast")).toBeUndefined()
+  })
+})
+
+const CATALOG = [
+  "cursor-grok-4.6-medium-fast",
+  "cursor-grok-4.6-high",
+  "composer-2-fast",
+]
+
+describe("pickCursorCliCatalogId", () => {
+  it("picks the grok CLI id that matches Fast and effort", () => {
+    expect(pickCursorCliCatalogId("grok-4.6", true, "medium", CATALOG)).toBe(
+      "cursor-grok-4.6-medium-fast",
+    )
+  })
+
+  it("picks composer-2-fast for composer-2.5 and does not use grok", () => {
+    expect(pickCursorCliCatalogId("composer-2.5", true, undefined, CATALOG)).toBe("composer-2-fast")
+  })
+
+  it("picks a real CLI id for gemini, not an ACP parameterized value", () => {
+    expect(
+      pickCursorCliCatalogId("gemini-3.7-flash", true, "high", [
+        "gemini-3.7-flash-low",
+        "gemini-3.7-flash-medium",
+        "gemini-3.7-flash-high",
+        "cursor-grok-4.6-medium-fast",
+      ]),
+    ).toBe("gemini-3.7-flash-high")
+  })
+
+  it("falls back to a cursor-prefixed heuristic when the catalog is empty", () => {
+    expect(pickCursorCliCatalogId("grok-4.6", true, "medium", [])).toBe(
+      "cursor-grok-4.6-medium-fast",
+    )
+    expect(pickCursorCliCatalogId("composer-2.5", true, undefined, [])).toBe("composer-2-fast")
+  })
+
+  it("keeps Auto as default", () => {
+    expect(pickCursorCliCatalogId("auto", true, undefined, CATALOG)).toBe("default")
+    expect(pickCursorCliCatalogId("default", true, undefined, CATALOG)).toBe("default")
+  })
+})
+
+describe("toCursorAcpModelValue", () => {
+  it("matches the rust pin format", () => {
+    expect(toCursorAcpModelValue("grok-4.6")).toBe("grok-4.6")
+    expect(toCursorAcpModelValue("grok-4.6", true, "medium")).toBe(
+      "grok-4.6[effort=medium,fast=true]",
+    )
+    expect(toCursorAcpModelValue("composer-2.5", false)).toBe("composer-2.5[fast=false]")
+  })
+})
+
+describe("cliModelToAcpValue", () => {
+  it("rewrites CLI ids from the argv string itself", () => {
+    expect(cliModelToAcpValue("cursor-grok-4.6-medium-fast")).toBe(
+      "grok-4.6[effort=medium,fast=true]",
+    )
+    expect(cliModelToAcpValue("cursor-grok-4.6-high")).toBe("grok-4.6[effort=high,fast=false]")
+    expect(cliModelToAcpValue("composer-2-fast")).toBe("composer-2.5[fast=true]")
+    expect(cliModelToAcpValue("grok-4.6")).toBe("grok-4.6")
+    expect(cliModelToAcpValue("default")).toBe("")
+  })
+})
+
+describe("toCursorHttpModel", () => {
+  it("passes the saved id through and does not remap ACP names", () => {
+    expect(toCursorHttpModel("cursor-grok-4.6-medium-fast")).toBe("cursor-grok-4.6-medium-fast")
+    expect(toCursorHttpModel("cursor-grok-4.6-high")).toBe("cursor-grok-4.6-high")
+    expect(toCursorHttpModel("grok-4.6")).toBe("grok-4.6")
+    expect(toCursorHttpModel("composer-2-fast")).toBe("composer-2-fast")
+    expect(toCursorHttpModel("auto")).toBe("default")
+  })
+})

+ 211 - 0
src/lib/cursor-acp-models.ts

@@ -0,0 +1,211 @@
+import type { CodexSpeedMode } from "@/lib/codex-cli-speed"
+import type { ReasoningConfig } from "@/stores/wiki-store"
+
+const ACP_MODEL_ALIASES: Record<string, string> = {
+  auto: "default",
+  "composer-2": "composer-2.5",
+}
+
+const EFFORT_SUFFIX = /[-_](extra-high|xhigh|minimal|medium|high|none|low|max)$/i
+const FAST_SUFFIX = /[-_]fast$/i
+const THINKING_SUFFIX = /[-_]thinking$/i
+const ACP_FAST_PARAM = /(?:^|[\[,])\s*fast\s*=\s*(true|false)(?:[,\]]|$)/i
+
+export type CursorSpeedMode = CodexSpeedMode
+export const DEFAULT_CURSOR_SPEED_MODE: CursorSpeedMode = "fast"
+
+export function inferCursorSpeedModeFromModel(model: string): CursorSpeedMode {
+  const trimmed = model.trim()
+  if (!trimmed) return DEFAULT_CURSOR_SPEED_MODE
+  const parameterized = ACP_FAST_PARAM.exec(trimmed)
+  if (parameterized) {
+    return parameterized[1].toLowerCase() === "true" ? "fast" : "standard"
+  }
+  const id = trimmed.replace(/\[.*$/, "")
+  if (FAST_SUFFIX.test(id)) return "fast"
+  if (id.toLowerCase().startsWith("cursor-")) return "standard"
+  return DEFAULT_CURSOR_SPEED_MODE
+}
+
+export function resolveCursorSpeedMode(
+  value: unknown,
+  model?: string,
+): CursorSpeedMode {
+  if (value === "fast" || value === "standard") return value
+  return inferCursorSpeedModeFromModel(model ?? "")
+}
+
+export function toCursorAcpEffort(reasoning?: Pick<ReasoningConfig, "mode">): string | undefined {
+  switch (reasoning?.mode) {
+    case "low":
+    case "medium":
+    case "high":
+      return reasoning.mode
+    case "max":
+      return "xhigh"
+    default:
+      return undefined
+  }
+}
+
+/** CLI `--list-models` id → ACP `availableModels[].name`. */
+export function toCursorAcpModelId(raw: string): string {
+  const trimmed = raw.trim()
+  if (!trimmed) return ""
+  if (trimmed === "default") return "default"
+
+  const parameterized = trimmed.includes("[") ? trimmed.replace(/\[.*$/, "").trim() : trimmed
+  let id = parameterized
+  if (id.toLowerCase().startsWith("cursor-")) {
+    id = id.slice("cursor-".length)
+  }
+  id = id.replace(FAST_SUFFIX, "")
+  id = id.replace(EFFORT_SUFFIX, "")
+  id = id.replace(THINKING_SUFFIX, "")
+
+  const alias = ACP_MODEL_ALIASES[id.toLowerCase()] ?? ACP_MODEL_ALIASES[trimmed.toLowerCase()]
+  return alias ?? id
+}
+
+export function toCursorAcpModelIds(ids: readonly string[]): string[] {
+  const seen = new Set<string>()
+  const mapped: string[] = []
+  for (const id of ids) {
+    const acpId = toCursorAcpModelId(id)
+    if (!acpId || seen.has(acpId)) continue
+    seen.add(acpId)
+    mapped.push(acpId)
+  }
+  return mapped
+}
+
+const ACP_REVERSE_ALIASES: Record<string, string[]> = {
+  "composer-2.5": ["composer-2"],
+}
+
+let cachedCursorCliCatalog: string[] = []
+
+export function rememberCursorCliCatalog(ids: readonly string[]): void {
+  cachedCursorCliCatalog = ids.map((id) => id.trim()).filter(Boolean)
+}
+
+export function getCursorCliCatalog(): readonly string[] {
+  return cachedCursorCliCatalog
+}
+
+function normalizeEffortToken(raw: string): string {
+  const value = raw.trim().toLowerCase()
+  if (value === "extra-high" || value === "max") return "xhigh"
+  return value
+}
+
+export function inferCursorEffortFromModel(model: string): string | undefined {
+  const trimmed = model.trim()
+  if (!trimmed) return undefined
+  const parameterized = /(?:^|[\[,])\s*effort\s*=\s*([^,\]]+)/i.exec(trimmed)
+  if (parameterized?.[1]) return normalizeEffortToken(parameterized[1])
+  const id = trimmed.replace(/\[.*$/, "").replace(FAST_SUFFIX, "")
+  const match = id.match(/[-_](extra-high|xhigh|minimal|medium|high|none|low|max)$/i)
+  return match?.[1] ? normalizeEffortToken(match[1]) : undefined
+}
+
+export function toCursorAcpModelValue(
+  model: string,
+  fast?: boolean,
+  effort?: string,
+): string {
+  const acp = toCursorAcpModelId(model)
+  if (!acp) return ""
+  const params: string[] = []
+  const effortValue = effort?.trim()
+  if (effortValue) params.push(`effort=${effortValue}`)
+  if (fast === true) params.push("fast=true")
+  else if (fast === false) params.push("fast=false")
+  return params.length === 0 ? acp : `${acp}[${params.join(",")}]`
+}
+
+export function cliModelToAcpValue(model: string): string {
+  const trimmed = model.trim()
+  if (!trimmed || trimmed === "default" || trimmed === "auto") return ""
+  const acp = toCursorAcpModelId(trimmed)
+  if (!acp || acp === "default") return ""
+  const baseId = trimmed.replace(/\[.*$/, "")
+  const hasFastSuffix = FAST_SUFFIX.test(baseId)
+  const hasCursorPrefix = baseId.toLowerCase().startsWith("cursor-")
+  const effort = inferCursorEffortFromModel(trimmed)
+  if (trimmed.includes("[")) {
+    return toCursorAcpModelValue(acp, inferCursorSpeedModeFromModel(trimmed) === "fast", effort)
+  }
+  if (!hasFastSuffix && !hasCursorPrefix && !effort) return acp
+  return toCursorAcpModelValue(acp, hasFastSuffix ? true : hasCursorPrefix ? false : undefined, effort)
+}
+
+function scoreCursorCliId(id: string, fast: boolean, effort?: string): number {
+  const idFast = inferCursorSpeedModeFromModel(id) === "fast"
+  const idEffort = inferCursorEffortFromModel(id)
+  let score = 0
+  if (idFast === fast) score += 4
+  else score -= 3
+  if (effort) {
+    if (idEffort === effort) score += 4
+    else if (idEffort) score -= 2
+  } else if (!idEffort) {
+    score += 2
+  }
+  return score
+}
+
+function heuristicCursorCliIds(acpId: string, fast: boolean, effort?: string): string[] {
+  const aliases = ACP_REVERSE_ALIASES[acpId] ?? []
+  const out: string[] = []
+  const push = (value: string) => {
+    if (value && !out.includes(value)) out.push(value)
+  }
+  const decorate = (stem: string) => {
+    if (effort && fast) push(`${stem}-${effort}-fast`)
+    if (effort) push(`${stem}-${effort}`)
+    if (fast) push(`${stem}-fast`)
+    push(stem)
+  }
+  for (const alias of aliases) {
+    decorate(alias)
+    decorate(`cursor-${alias}`)
+  }
+  decorate(acpId.toLowerCase().startsWith("cursor-") ? acpId : `cursor-${acpId}`)
+  decorate(acpId)
+  return out
+}
+
+export function pickCursorCliCatalogId(
+  acpId: string,
+  fast: boolean,
+  effort?: string,
+  catalog: readonly string[] = getCursorCliCatalog(),
+): string {
+  const target = toCursorAcpModelId(acpId)
+  if (!target || target === "default") return "default"
+
+  const matches = catalog.filter((id) => toCursorAcpModelId(id) === target)
+  if (matches.length > 0) {
+    return matches.slice().sort((left, right) => {
+      const delta = scoreCursorCliId(right, fast, effort) - scoreCursorCliId(left, fast, effort)
+      return delta !== 0 ? delta : left.localeCompare(right)
+    })[0]!
+  }
+
+  const heuristics = heuristicCursorCliIds(target, fast, effort)
+  if (catalog.length > 0) {
+    const byLower = new Map(catalog.map((id) => [id.toLowerCase(), id]))
+    for (const candidate of heuristics) {
+      const hit = byLower.get(candidate.toLowerCase())
+      if (hit) return hit
+    }
+  }
+  return heuristics[0] ?? target
+}
+
+export function toCursorHttpModel(model: string): string {
+  const trimmed = model.trim()
+  if (!trimmed || trimmed === "auto" || trimmed === "default") return "default"
+  return trimmed
+}

+ 37 - 4
src/lib/cursor-cli-provider.spec.ts

@@ -1,9 +1,10 @@
-import { describe, expect, it } from "vitest"
+import { afterEach, describe, expect, it } from "vitest"
+import { rememberCursorCliCatalog } from "@/lib/cursor-acp-models"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { modelSupportsTools } from "@/lib/agent/config"
 import { getProviderConfig } from "@/lib/llm-providers"
 import type { LlmConfig } from "@/stores/wiki-store"
-import { toCursorProxyV1Endpoint } from "@/lib/cursor-cli-proxy"
+import { toCursorProxyV1Endpoint, withCursorProxyEndpoint } from "@/lib/cursor-cli-proxy"
 
 const base: LlmConfig = {
   provider: "cursor-cli",
@@ -22,8 +23,8 @@ describe("cursor-cli provider", () => {
     expect(hasUsableLlm(base, { "cursor-cli": { enabled: true } })).toBe(true)
   })
 
-  it("is blocked from agent tools like other local CLI providers", () => {
-    expect(modelSupportsTools("composer-2-fast", "cursor-cli")).toBe(false)
+  it("supports native agent tools over the proxy", () => {
+    expect(modelSupportsTools("composer-2-fast", "cursor-cli")).toBe(true)
     expect(modelSupportsTools("gpt-4o", "openai")).toBe(true)
   })
 
@@ -42,4 +43,36 @@ describe("cursor-cli provider", () => {
     expect(toCursorProxyV1Endpoint("http://127.0.0.1:9123")).toBe("http://127.0.0.1:9123/v1")
     expect(toCursorProxyV1Endpoint("http://127.0.0.1:9123/v1")).toBe("http://127.0.0.1:9123/v1")
   })
+
+  it("sends the saved CLI id so overlapping requests keep their own model", () => {
+    rememberCursorCliCatalog([
+      "cursor-grok-4.6-medium-fast",
+      "cursor-grok-4.6-high",
+      "composer-2-fast",
+    ])
+    const grok = withCursorProxyEndpoint(
+      { ...base, model: "cursor-grok-4.6-medium-fast" },
+      "http://127.0.0.1:9123/v1",
+    )
+    const composer = withCursorProxyEndpoint(
+      { ...base, model: "composer-2-fast" },
+      "http://127.0.0.1:9123/v1",
+    )
+    expect(grok.model).toBe("cursor-grok-4.6-medium-fast")
+    expect(composer.model).toBe("composer-2-fast")
+    expect(grok.model).not.toBe(composer.model)
+    expect(grok.customEndpoint).toBe("http://127.0.0.1:9123/v1")
+  })
+
+  it("keeps HTTP model=default for Auto", () => {
+    const cfg = withCursorProxyEndpoint(
+      { ...base, model: "auto" },
+      "http://127.0.0.1:9123/v1",
+    )
+    expect(cfg.model).toBe("default")
+  })
+})
+
+afterEach(() => {
+  rememberCursorCliCatalog([])
 })

+ 102 - 3
src/lib/cursor-cli-proxy.ts

@@ -8,6 +8,14 @@
 
 import { invoke } from "@tauri-apps/api/core"
 import type { LlmConfig } from "@/stores/wiki-store"
+import {
+  getCursorCliCatalog,
+  inferCursorEffortFromModel,
+  inferCursorSpeedModeFromModel,
+  rememberCursorCliCatalog,
+  toCursorAcpModelId,
+  toCursorHttpModel,
+} from "@/lib/cursor-acp-models"
 import { isTauri } from "@/lib/platform"
 import type { LocalCliDetectResult } from "./local-cli-config"
 
@@ -27,6 +35,48 @@ export function toCursorProxyV1Endpoint(baseUrl: string): string {
   return `${trimmed}/v1`
 }
 
+export interface CursorAgentAboutResult {
+  installed: boolean
+  version: string | null
+  latest_status: string | null
+  latest_version: string | null
+  path: string | null
+  error: string | null
+}
+
+export interface CursorAgentUpdateResult {
+  ok: boolean
+  version: string | null
+  output: string
+  error: string | null
+}
+
+export async function checkCursorAgentUpdate(): Promise<CursorAgentAboutResult> {
+  if (!isTauri()) {
+    return {
+      installed: false,
+      version: null,
+      latest_status: null,
+      latest_version: null,
+      path: null,
+      error: "仅桌面端支持本地 CLI 检测",
+    }
+  }
+  return invoke<CursorAgentAboutResult>("cursor_cli_about")
+}
+
+export async function updateCursorAgent(): Promise<CursorAgentUpdateResult> {
+  if (!isTauri()) {
+    return {
+      ok: false,
+      version: null,
+      output: "",
+      error: "仅桌面端可更新 Cursor agent",
+    }
+  }
+  return invoke<CursorAgentUpdateResult>("cursor_cli_update")
+}
+
 export async function detectCursorCli(): Promise<LocalCliDetectResult> {
   if (!isTauri()) {
     return {
@@ -55,7 +105,7 @@ export async function getCursorProxyStatus(): Promise<CursorProxyStatus> {
  * Ensure proxy is up; returns the live OpenAI-compatible base (`…/v1`).
  */
 export async function ensureCursorProxyRunning(
-  config: Pick<LlmConfig, "provider">,
+  config: Pick<LlmConfig, "provider"> & Partial<Pick<LlmConfig, "model" | "apiKey">>,
   options?: { forceRestart?: boolean },
 ): Promise<string> {
   if (config.provider !== "cursor-cli") {
@@ -70,7 +120,49 @@ export async function ensureCursorProxyRunning(
   if (!status.healthy) {
     throw new Error(status.error ?? `cursor-api-proxy 未就绪:${status.base_url}`)
   }
-  return toCursorProxyV1Endpoint(status.base_url)
+  const endpoint = toCursorProxyV1Endpoint(status.base_url)
+  await refreshCursorCliCatalogIfEmpty(endpoint, config.apiKey)
+  const acpModel = toCursorAcpModelId(config.model ?? "")
+  if (acpModel) {
+    const cliModel = toCursorHttpModel(config.model ?? "")
+    await invoke("cursor_cli_apply_acp_model", {
+      model: acpModel,
+      cliModel,
+      fast: inferCursorSpeedModeFromModel(cliModel) === "fast",
+      effort: inferCursorEffortFromModel(cliModel),
+    })
+  }
+  return endpoint
+}
+
+async function refreshCursorCliCatalogIfEmpty(
+  v1Endpoint: string,
+  apiKey?: string,
+): Promise<void> {
+  if (getCursorCliCatalog().length > 0) return
+  try {
+    const { getHttpFetch } = await import("@/lib/tauri-fetch")
+    const httpFetch = await getHttpFetch()
+    const response = await httpFetch(`${v1Endpoint.replace(/\/+$/, "")}/models`, {
+      method: "GET",
+      headers: {
+        Authorization: `Bearer ${apiKey?.trim() || "unused"}`,
+      },
+    })
+    if (!response.ok) return
+    const raw = await response.json() as { data?: unknown[] }
+    const ids: string[] = []
+    for (const item of raw.data ?? []) {
+      if (typeof item === "string" && item.trim()) ids.push(item.trim())
+      else if (item && typeof item === "object") {
+        const id = (item as { id?: unknown }).id
+        if (typeof id === "string" && id.trim()) ids.push(id.trim())
+      }
+    }
+    if (ids.length > 0) rememberCursorCliCatalog(ids)
+  } catch {
+    /* catalog stays empty; HTTP falls back to heuristics */
+  }
 }
 
 /** After Authentication required, kill managed proxy and respawn with zshrc credentials. */
@@ -82,5 +174,12 @@ export async function restartCursorProxyWithAuth(
 
 /** Apply the live proxy `/v1` endpoint onto an LlmConfig for HTTP dispatch. */
 export function withCursorProxyEndpoint(config: LlmConfig, v1Endpoint: string): LlmConfig {
-  return { ...config, customEndpoint: v1Endpoint, apiMode: "chat_completions" }
+  return {
+    ...config,
+    customEndpoint: v1Endpoint,
+    apiMode: "chat_completions",
+    // Send a CLI catalog id so overlapping requests keep their own --model.
+    // ACP names are remapped to auto; default/auto stay default.
+    model: toCursorHttpModel(config.model ?? ""),
+  }
 }

+ 13 - 0
src/lib/llm-client.ts

@@ -5,6 +5,7 @@ import {
   getEffectiveMaxOutputTokens,
   getProviderConfig,
   isTruncationFinishReason,
+  parseOpenAiSseError,
   thinkingMinMaxTokens,
   type RequestOverrides,
 } from "./llm-providers"
@@ -780,6 +781,12 @@ async function streamChatHeld(
         if (done) {
           if (lineBuffer.trim()) {
             const trimmed = lineBuffer.trim()
+            const sseError = parseOpenAiSseError(trimmed)
+            if (sseError) {
+              finishRequestTrace(activeRequestTrace, "error", streamUsage)
+              onError(new Error(sseError))
+              return
+            }
             recordUsage(trimmed)
             recordFinishReason(trimmed)
             // Always harvest reasoning first: some gateways emit
@@ -807,6 +814,12 @@ async function streamChatHeld(
         for (const line of lines) {
           const trimmed = line.trim()
           if (!trimmed) continue
+          const sseError = parseOpenAiSseError(trimmed)
+          if (sseError) {
+            finishRequestTrace(activeRequestTrace, "error", streamUsage)
+            onError(new Error(sseError))
+            return
+          }
           recordUsage(trimmed)
           recordFinishReason(trimmed)
           // Always harvest reasoning first: some gateways emit

+ 13 - 1
src/lib/llm-providers.spec.ts

@@ -1,5 +1,5 @@
 import { describe, expect, it } from "vitest"
-import { getCustomCompatibleHeaders, getProviderConfig, parseGoogleLine, withCustomOriginHeader } from "./llm-providers"
+import { getCustomCompatibleHeaders, getProviderConfig, parseGoogleLine, parseOpenAiSseError, withCustomOriginHeader } from "./llm-providers"
 import { filterDeAiOutput } from "./novel/de-ai-output"
 import type { LlmConfig, ReasoningMode } from "@/stores/wiki-store"
 
@@ -23,6 +23,18 @@ function requestBody(config: LlmConfig): Record<string, unknown> {
   ]) as Record<string, unknown>
 }
 
+describe("OpenAI SSE error lines", () => {
+  it("reads cursor-api-proxy agent_exit errors instead of treating them as empty content", () => {
+    expect(
+      parseOpenAiSseError(
+        'data: {"error":{"message":"The Cursor agent process exited with code 1. See server logs for details.","code":"cursor_cli_error"}}',
+      ),
+    ).toBe("The Cursor agent process exited with code 1. See server logs for details.")
+    expect(parseOpenAiSseError("data: [DONE]")).toBeNull()
+    expect(parseOpenAiSseError('data: {"choices":[{"delta":{"content":"hi"}}]}')).toBeNull()
+  })
+})
+
 describe("llm provider reasoning options", () => {
   it("keeps Anthropic thinking inside the caller's max_tokens budget", () => {
     const body = getProviderConfig(customConfig({

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

@@ -225,6 +225,23 @@ function parseOpenAiLine(line: string): string | null {
   }
 }
 
+/** SSE error objects (cursor-api-proxy agent_exit_*, OpenAI-style `{error:{message}}`). */
+export function parseOpenAiSseError(line: string): string | null {
+  const data = line.startsWith("data: ") ? line.slice(6).trim() : line.trim()
+  if (!data || data === "[DONE]") return null
+  try {
+    const parsed = JSON.parse(data) as { error?: { message?: string } | string }
+    if (typeof parsed.error === "string" && parsed.error.trim()) return parsed.error.trim()
+    if (parsed.error && typeof parsed.error === "object") {
+      const message = parsed.error.message?.trim()
+      if (message) return message
+    }
+  } catch {
+    return null
+  }
+  return null
+}
+
 function tokenCount(value: unknown): number | undefined {
   return typeof value === "number" && Number.isFinite(value) && value >= 0
     ? Math.floor(value)

+ 2 - 2
src/lib/novel/chapter-import.ts

@@ -41,7 +41,7 @@ type IngestChapterDependency = (
   projectPath: string,
   chapterPath: string,
   reviewModel?: string,
-) => Promise<{ snapshot: unknown | null; failReason?: string }>
+) => Promise<{ snapshot: unknown | null; failReason?: string; error?: string }>
 
 function yamlEscape(value: string): string {
   return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
@@ -338,7 +338,7 @@ export async function runImportedChapterMemoryExtraction({
         completed += 1
       } else {
         failed += 1
-        errors.push(`${getFileName(chapterPath)}:${result.failReason ?? "提取失败"}`)
+        errors.push(`${getFileName(chapterPath)}:${result.error ?? result.failReason ?? "提取失败"}`)
       }
     } catch (error) {
       failed += 1

+ 31 - 0
src/lib/novel/chapter-ingest-log.spec.ts

@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest"
+import {
+  CHAPTER_INGEST_LOG_REL,
+  chapterIngestLogPath,
+  formatChapterIngestLogLine,
+  previewLlmOutput,
+} from "./chapter-ingest-log"
+
+describe("chapter ingest log", () => {
+  it("writes under the project .qmai directory", () => {
+    expect(CHAPTER_INGEST_LOG_REL).toBe(".qmai/chapter-ingest.log")
+    expect(chapterIngestLogPath("/Users/omi/book")).toBe("/Users/omi/book/.qmai/chapter-ingest.log")
+  })
+
+  it("formats one JSON line and truncates long model output", () => {
+    const line = formatChapterIngestLogLine({
+      at: "2026-09-02T04:00:00.000Z",
+      event: "fail",
+      chapterNumber: 298,
+      failReason: "extract_failed",
+      error: "cursor-api-proxy is not reachable",
+    })
+    expect(line.endsWith("\n")).toBe(true)
+    expect(JSON.parse(line)).toMatchObject({
+      event: "fail",
+      chapterNumber: 298,
+      error: "cursor-api-proxy is not reachable",
+    })
+    expect(previewLlmOutput("a".repeat(10), 4)).toBe("aaaa…[truncated 6 chars]")
+  })
+})

+ 74 - 0
src/lib/novel/chapter-ingest-log.ts

@@ -0,0 +1,74 @@
+import { createDirectory, fileExists, readFile, writeFileAtomic } from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
+
+export const CHAPTER_INGEST_LOG_REL = ".qmai/chapter-ingest.log"
+const OUTPUT_PREVIEW_CHARS = 4_000
+const MAX_LOG_BYTES = 512 * 1024
+
+export type ChapterIngestLogEvent = "start" | "ok" | "fail"
+
+export interface ChapterIngestLogEntry {
+  at: string
+  event: ChapterIngestLogEvent
+  chapterNumber?: number
+  chapterPath?: string
+  failReason?: string
+  error?: string
+  model?: string
+  provider?: string
+  elapsedMs?: number
+  outputChars?: number
+  outputPreview?: string
+}
+
+let writeQueue: Promise<void> = Promise.resolve()
+
+export function chapterIngestLogPath(projectPath: string): string {
+  return `${normalizePath(projectPath)}/${CHAPTER_INGEST_LOG_REL}`
+}
+
+export function previewLlmOutput(raw: string, maxChars = OUTPUT_PREVIEW_CHARS): string {
+  const trimmed = raw.trim()
+  if (trimmed.length <= maxChars) return trimmed
+  return `${trimmed.slice(0, maxChars)}…[truncated ${trimmed.length - maxChars} chars]`
+}
+
+export function formatChapterIngestLogLine(entry: ChapterIngestLogEntry): string {
+  return `${JSON.stringify(entry)}\n`
+}
+
+async function writeLogFile(projectPath: string, line: string): Promise<void> {
+  const dir = `${normalizePath(projectPath)}/.qmai`
+  const path = chapterIngestLogPath(projectPath)
+  await createDirectory(dir).catch(() => {})
+  let prev = ""
+  if (await fileExists(path)) {
+    prev = await readFile(path)
+  }
+  if (prev.length > MAX_LOG_BYTES) {
+    prev = prev.slice(prev.length - MAX_LOG_BYTES / 2)
+    const cut = prev.indexOf("\n")
+    if (cut >= 0) prev = prev.slice(cut + 1)
+  }
+  await writeFileAtomic(path, `${prev}${line}`)
+}
+
+export async function appendChapterIngestLog(
+  projectPath: string,
+  entry: Omit<ChapterIngestLogEntry, "at"> & { at?: string },
+): Promise<void> {
+  const line = formatChapterIngestLogLine({
+    at: entry.at ?? new Date().toISOString(),
+    ...entry,
+  })
+  const next = writeQueue.then(() => writeLogFile(projectPath, line))
+  writeQueue = next.then(
+    () => undefined,
+    () => undefined,
+  )
+  try {
+    await next
+  } catch (error) {
+    console.error("[Chapter Ingest] failed to write log:", error)
+  }
+}

+ 9 - 1
src/lib/novel/chapter-ingest.spec.ts

@@ -10,7 +10,15 @@ describe("chapter ingest draft boundary", () => {
     expect(source).toContain("allowDraft?: boolean")
     expect(source).toContain("options: IngestChapterOptions = {}")
     expect(source).toContain("if (!options.allowDraft && !isFinalChapter(fm))")
-    expect(source).toContain('failReason: "not_final"')
+    expect(source).toContain('return logFail("not_final", "章节不是正式稿")')
+  })
+
+  it("writes extract failures to .qmai/chapter-ingest.log and returns error", () => {
+    expect(source).toContain("appendChapterIngestLog")
+    expect(source).toContain('event: "fail"')
+    expect(source).toContain('event: "start"')
+    expect(source).toContain('event: "ok"')
+    expect(source).toContain("failReason, error: message")
   })
 
   it("does not persist a snapshot before syncSnapshotToMemory", () => {

+ 57 - 9
src/lib/novel/chapter-ingest.ts

@@ -41,6 +41,7 @@ import {
   CHAPTER_EXTRACT_REQUEST_OVERRIDES,
   resolveChapterExtractMaxTokens,
 } from "./chapter-ingest-extract"
+import { appendChapterIngestLog, previewLlmOutput } from "./chapter-ingest-log"
 
 export interface ValidationWarning {
   type: "entity_new" | "canon_conflict"
@@ -313,6 +314,7 @@ type IngestFailReason = "no_llm" | "not_chapter" | "not_final" | "invalid_chapte
 interface IngestResult {
   snapshot: ChapterSnapshot | null
   failReason?: IngestFailReason
+  error?: string
 }
 
 interface IngestChapterOptions {
@@ -335,31 +337,64 @@ export async function ingestChapter(
   const novelConfig = state.novelConfig
   // 使用 resolveNovelModel 正确解析提取模型(含供应商配置切换)
   const runtimeLlmConfig = resolveNovelModel(llmConfig, novelConfig, "extract")
-  if (!hasUsableLlm(runtimeLlmConfig, state.providerConfigs)) return { snapshot: null, failReason: "no_llm" }
+  const startedAt = Date.now()
+  let resolvedChapterNumber = chapterNumberOverride
+  const logFail = async (failReason: IngestFailReason, error?: string) => {
+    const message = error ?? failReason
+    console.error(`[Chapter Ingest] ${failReason}:`, message)
+    await appendChapterIngestLog(pp, {
+      event: "fail",
+      chapterNumber: resolvedChapterNumber,
+      chapterPath,
+      failReason,
+      error: message,
+      model: runtimeLlmConfig.model,
+      provider: runtimeLlmConfig.provider,
+      elapsedMs: Date.now() - startedAt,
+    })
+    return { snapshot: null, failReason, error: message } satisfies IngestResult
+  }
+
+  if (!hasUsableLlm(runtimeLlmConfig, state.providerConfigs)) {
+    return logFail("no_llm", "提取模型不可用")
+  }
 
   const content = await readFile(chapterPath)
   const parsed = parseFrontmatter(content)
   const fm = parsed.frontmatter as Record<string, unknown> | null
-  if (!fm || !isChapterPage(fm)) return { snapshot: null, failReason: "not_chapter" }
+  if (!fm || !isChapterPage(fm)) return logFail("not_chapter", "不是章节页")
   if (!options.allowDraft && !isFinalChapter(fm)) {
     console.warn(`[Chapter Ingest] Chapter status is not final, skipping ingest.`)
-    return { snapshot: null, failReason: "not_final" }
+    return logFail("not_final", "章节不是正式稿")
   }
 
   const chapterNumber = chapterNumberOverride ?? parseChapterNumber(fm.chapter_number) ?? 0
+  resolvedChapterNumber = chapterNumber
   if (chapterNumber <= 0) {
     console.warn("[Chapter Ingest] Invalid chapter number, skipping ingest.")
-    return { snapshot: null, failReason: "invalid_chapter_number" }
+    return logFail("invalid_chapter_number", "章节号无效")
   }
   const body = parsed.body
 
-  if (signal?.aborted) return { snapshot: null, failReason: "cancelled" }
+  if (signal?.aborted) return logFail("cancelled", "已取消")
+  await appendChapterIngestLog(pp, {
+    event: "start",
+    chapterNumber,
+    chapterPath,
+    model: runtimeLlmConfig.model,
+    provider: runtimeLlmConfig.provider,
+  })
   const existingSnapshotPromise = readCurrentSnapshot(pp, chapterNumber)
-  const extractedSnapshot = await extractSnapshotWithLLM(chapterNumber, body, runtimeLlmConfig, signal)
+  let extractedSnapshot: ChapterSnapshot | null
+  try {
+    extractedSnapshot = await extractSnapshotWithLLM(chapterNumber, body, runtimeLlmConfig, signal)
+  } catch (err) {
+    return logFail("extract_failed", err instanceof Error ? err.message : String(err))
+  }
   const snapshot = extractedSnapshot ? canonicalizeSnapshotCharacters(extractedSnapshot) : null
 
   if (!snapshot) {
-    return { snapshot: null, failReason: "extract_failed" as IngestFailReason }
+    return logFail("extract_failed", "模型没有返回快照")
   }
 
   const existingSnapshot = await existingSnapshotPromise
@@ -420,7 +455,7 @@ export async function ingestChapter(
   if (!isReingest && shouldRebuildCommunitySummaries(snapshot.chapterNumber, novelConfig)) {
     const rebuildCommunitySummaries = async () => {
       try {
-        await generateCommunitySummaries(pp, llmConfig, novelConfig)
+        await generateCommunitySummaries(pp, llmConfig, novelConfig, signal)
       } catch (err) {
         const message = err instanceof Error ? err.message : String(err)
         console.warn("[Chapter Ingest] 社区摘要生成失败:", message)
@@ -466,6 +501,14 @@ export async function ingestChapter(
     }
   }
 
+  await appendChapterIngestLog(pp, {
+    event: "ok",
+    chapterNumber,
+    chapterPath,
+    model: runtimeLlmConfig.model,
+    provider: runtimeLlmConfig.provider,
+    elapsedMs: Date.now() - startedAt,
+  })
   return { snapshot: { ...snapshot, memorySyncedAt: syncResult.memorySyncedAt } }
 }
 
@@ -553,10 +596,15 @@ async function extractSnapshotWithLLM(
       max_tokens: resolveChapterExtractMaxTokens(llmConfig.maxContextSize),
     })
     if (streamError) throw streamError
+    if (!result.trim()) {
+      throw new Error("章节快照提取失败:模型返回空内容(流结束但没有任何正文)")
+    }
 
     const parsed = parseLlmJsonObject(result)
     if (!parsed) {
-      throw new Error("章节快照提取失败:模型没有返回可解析的 JSON")
+      throw new Error(
+        `章节快照提取失败:模型没有返回可解析的 JSON(${result.trim().length} chars) ${previewLlmOutput(result, 400)}`,
+      )
     }
     return normalizeChapterSnapshot({
       ...parsed,

+ 257 - 5
src/lib/novel/community-summary.spec.ts

@@ -1,12 +1,24 @@
 import { beforeEach, describe, expect, it, vi } from "vitest"
-import { searchByEmbedding } from "@/lib/embedding"
-import { useWikiStore } from "@/stores/wiki-store"
-import { searchCommunitySummaries } from "./community-summary"
+import { readFile, writeFile, createDirectory, listDirectory } from "@/commands/fs"
+import { buildWikiGraph } from "@/lib/wiki-graph"
+import { streamChat } from "@/lib/llm-client"
+import { resolveNovelModel } from "@/lib/novel/model-resolver"
+import { embedPage, searchByEmbedding } from "@/lib/embedding"
+import { useWikiStore, type LlmConfig, type NovelConfig } from "@/stores/wiki-store"
+import {
+  MIN_COMMUNITY_SUMMARY_NODE_COUNT,
+  __resetCommunitySummaryLocksForTesting,
+  communityMemberFingerprint,
+  generateCommunitySummaries,
+  searchCommunitySummaries,
+  shouldRebuildCommunitySummaries,
+} from "./community-summary"
 
 vi.mock("@/commands/fs", () => ({
   readFile: vi.fn(),
   writeFile: vi.fn(),
   createDirectory: vi.fn(),
+  listDirectory: vi.fn(),
 }))
 vi.mock("@/lib/wiki-graph", () => ({ buildWikiGraph: vi.fn() }))
 vi.mock("@/lib/llm-client", () => ({
@@ -19,8 +31,249 @@ vi.mock("@/lib/embedding", () => ({
   searchByEmbedding: vi.fn(),
 }))
 
+const mockReadFile = vi.mocked(readFile)
+const mockWriteFile = vi.mocked(writeFile)
+const mockCreateDirectory = vi.mocked(createDirectory)
+const mockListDirectory = vi.mocked(listDirectory)
+const mockBuildWikiGraph = vi.mocked(buildWikiGraph)
+const mockStreamChat = vi.mocked(streamChat)
+const mockResolveNovelModel = vi.mocked(resolveNovelModel)
+const mockEmbedPage = vi.mocked(embedPage)
 const mockSearchByEmbedding = vi.mocked(searchByEmbedding)
 
+const llmConfig: LlmConfig = {
+  provider: "openai",
+  apiKey: "sk",
+  model: "gpt-4o",
+  ollamaUrl: "",
+  customEndpoint: "",
+  maxContextSize: 8192,
+}
+
+const novelConfig: NovelConfig = {
+  contextTokenBudget: 0,
+  recentSummaryWindow: 8,
+  searchTopK: 5,
+  chapterTargetChars: 3000,
+  autoIngestOnSave: true,
+  autoExtractOnImport: true,
+  deepPreviousChaptersAnalysis: false,
+  reviewReasoningEffort: "high",
+  defaultLlmModel: "",
+  writingModel: "",
+  reviewModel: "",
+  summaryModel: "",
+  extractModel: "",
+  deAiModel: "",
+  deAiBatchConcurrency: 3,
+  communitySummaryEnabled: true,
+  communitySummaryInterval: 5,
+  communitySummaryAsync: true,
+  autoGenerateChapterTitle: true,
+}
+
+function node(id: string, community: number) {
+  return {
+    id,
+    label: id,
+    type: "character",
+    path: `/project/wiki/${id}.md`,
+    linkCount: 2,
+    community,
+  }
+}
+
+function community(id: number, memberIds: string[]) {
+  return {
+    id,
+    nodeCount: memberIds.length,
+    cohesion: 1,
+    topNodes: memberIds.slice(0, 3),
+  }
+}
+
+function graphWithCommunity(id: number, memberIds: string[]) {
+  return {
+    nodes: memberIds.map((memberId) => node(memberId, id)),
+    communities: [community(id, memberIds)],
+  }
+}
+
+describe("community member fingerprint", () => {
+  it("is stable regardless of member order", () => {
+    expect(communityMemberFingerprint(["b", "a", "c"])).toBe(
+      communityMemberFingerprint(["c", "a", "b"]),
+    )
+  })
+
+  it("changes when membership changes", () => {
+    expect(communityMemberFingerprint(["a", "b", "c", "d", "e"])).not.toBe(
+      communityMemberFingerprint(["a", "b", "c", "d", "f"]),
+    )
+  })
+})
+
+describe("shouldRebuildCommunitySummaries", () => {
+  it("only triggers on interval chapter numbers", () => {
+    expect(shouldRebuildCommunitySummaries(5, novelConfig)).toBe(true)
+    expect(shouldRebuildCommunitySummaries(6, novelConfig)).toBe(false)
+    expect(shouldRebuildCommunitySummaries(5, { ...novelConfig, communitySummaryEnabled: false })).toBe(false)
+  })
+})
+
+describe("community summary incremental rebuild", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    __resetCommunitySummaryLocksForTesting()
+    mockCreateDirectory.mockResolvedValue(undefined)
+    mockWriteFile.mockResolvedValue(undefined)
+    mockListDirectory.mockResolvedValue([])
+    mockReadFile.mockImplementation(async (path: string) => `body of ${path}`)
+    mockResolveNovelModel.mockImplementation((config) => config)
+    mockStreamChat.mockImplementation(async (_config, _messages, cb) => {
+      cb.onToken("A community summary.")
+      cb.onDone()
+    })
+    mockEmbedPage.mockResolvedValue(undefined)
+    useWikiStore.setState({
+      embeddingConfig: {
+        enabled: true,
+        endpoint: "http://localhost:11434/api/embeddings",
+        apiKey: "",
+        model: "nomic-embed-text",
+      },
+    })
+  })
+
+  it("locks the minimum community size at 5", () => {
+    expect(MIN_COMMUNITY_SUMMARY_NODE_COUNT).toBe(5)
+  })
+
+  it("skips communities smaller than the minimum size", async () => {
+    mockBuildWikiGraph.mockResolvedValue(graphWithCommunity(0, ["a", "b", "c", "d"]))
+
+    await generateCommunitySummaries("/project", llmConfig, novelConfig)
+
+    expect(mockStreamChat).not.toHaveBeenCalled()
+    expect(mockWriteFile).not.toHaveBeenCalled()
+  })
+
+  it("skips LLM and embed when the member fingerprint already exists", async () => {
+    const members = ["a", "b", "c", "d", "e"]
+    const fingerprint = communityMemberFingerprint(members)
+    mockBuildWikiGraph.mockResolvedValue(graphWithCommunity(7, members))
+    mockListDirectory.mockResolvedValue([{
+      name: `${fingerprint}.json`,
+      path: `/project/.novel/community-summaries/${fingerprint}.json`,
+      is_dir: false,
+    }])
+    mockReadFile.mockImplementation(async (path: string) => {
+      if (path.endsWith(".json")) {
+        return JSON.stringify({
+          communityId: 0,
+          memberFingerprint: fingerprint,
+          memberIds: members,
+          summary: "cached",
+          nodeCount: 5,
+          topNodes: members.slice(0, 3),
+          generatedAt: "2026-01-01T00:00:00.000Z",
+        })
+      }
+      return `body of ${path}`
+    })
+
+    await generateCommunitySummaries("/project", llmConfig, novelConfig)
+
+    expect(mockStreamChat).not.toHaveBeenCalled()
+    expect(mockEmbedPage).not.toHaveBeenCalled()
+    expect(mockWriteFile).not.toHaveBeenCalled()
+  })
+
+  it("reuses a fingerprint after Louvain renumbers the community id", async () => {
+    const members = ["north", "seal", "lin", "guard", "tower"]
+    const fingerprint = communityMemberFingerprint([...members].reverse())
+    mockBuildWikiGraph.mockResolvedValue(graphWithCommunity(99, members))
+    mockListDirectory.mockResolvedValue([{
+      name: "0.json",
+      path: "/project/.novel/community-summaries/0.json",
+      is_dir: false,
+    }])
+    mockReadFile.mockImplementation(async (path: string) => {
+      if (path.endsWith("0.json")) {
+        return JSON.stringify({
+          communityId: 0,
+          memberFingerprint: fingerprint,
+          memberIds: members,
+          summary: "old id, same members",
+          nodeCount: 5,
+          topNodes: members,
+          generatedAt: "2026-01-01T00:00:00.000Z",
+        })
+      }
+      return `body of ${path}`
+    })
+
+    await generateCommunitySummaries("/project", llmConfig, novelConfig)
+
+    expect(mockStreamChat).not.toHaveBeenCalled()
+  })
+
+  it("calls LLM once for a large community without a stored fingerprint", async () => {
+    const members = ["a", "b", "c", "d", "e"]
+    const fingerprint = communityMemberFingerprint(members)
+    mockBuildWikiGraph.mockResolvedValue(graphWithCommunity(1, members))
+
+    await generateCommunitySummaries("/project", llmConfig, novelConfig)
+
+    expect(mockStreamChat).toHaveBeenCalledTimes(1)
+    expect(mockWriteFile).toHaveBeenCalledWith(
+      `/project/.novel/community-summaries/${fingerprint}.json`,
+      expect.stringContaining(fingerprint),
+    )
+    expect(mockEmbedPage).toHaveBeenCalledWith(
+      "/project",
+      `community:${fingerprint}`,
+      expect.stringContaining("社区摘要"),
+      "A community summary.",
+      expect.anything(),
+    )
+  })
+
+  it("coalesces concurrent rebuilds for the same project", async () => {
+    const members = ["a", "b", "c", "d", "e"]
+    mockBuildWikiGraph.mockResolvedValue(graphWithCommunity(1, members))
+    let release!: () => void
+    mockStreamChat.mockImplementation(
+      () => new Promise((resolve) => {
+        release = () => {
+          resolve(undefined)
+        }
+      }),
+    )
+
+    const first = generateCommunitySummaries("/project", llmConfig, novelConfig)
+    const second = generateCommunitySummaries("/project", llmConfig, novelConfig)
+    await vi.waitFor(() => {
+      expect(mockStreamChat).toHaveBeenCalledTimes(1)
+    })
+    release()
+    await Promise.all([first, second])
+    expect(mockStreamChat).toHaveBeenCalledTimes(1)
+  })
+
+  it("stops the loop when the ingest signal aborts", async () => {
+    const members = ["a", "b", "c", "d", "e"]
+    mockBuildWikiGraph.mockResolvedValue(graphWithCommunity(1, members))
+    const controller = new AbortController()
+    controller.abort()
+
+    await expect(
+      generateCommunitySummaries("/project", llmConfig, novelConfig, controller.signal),
+    ).rejects.toMatchObject({ name: "AbortError" })
+    expect(mockStreamChat).not.toHaveBeenCalled()
+  })
+})
+
 describe("community summary vector noise control", () => {
   beforeEach(() => {
     vi.clearAllMocks()
@@ -54,9 +307,8 @@ describe("community summary vector noise control", () => {
 
     const output = await searchCommunitySummaries("/project", "who controls the seal", 3)
 
-    expect(output).toContain("社区2")
+    expect(output).toContain("社区摘要")
     expect(output).toContain("Northern faction")
-    expect(output).not.toContain("社区1")
     expect(output).not.toContain("Weak neighboring faction")
   })
 

+ 134 - 28
src/lib/novel/community-summary.ts

@@ -1,4 +1,4 @@
-import { readFile, writeFile, createDirectory } from "@/commands/fs"
+import { readFile, writeFile, createDirectory, listDirectory } from "@/commands/fs"
 import { buildWikiGraph, type GraphNode, type CommunityInfo } from "@/lib/wiki-graph"
 import { streamChat, DEFAULT_LLM_REQUEST_TIMEOUT_MS, type StreamCallbacks } from "@/lib/llm-client"
 import type { ChatMessage } from "@/lib/llm-providers"
@@ -6,20 +6,99 @@ import { resolveNovelModel } from "@/lib/novel/model-resolver"
 import { embedPage, searchByEmbedding } from "@/lib/embedding"
 import { useWikiStore, type NovelConfig, type LlmConfig } from "@/stores/wiki-store"
 import { normalizePath } from "@/lib/path-utils"
+import { fingerprintText } from "@/lib/novel/book-analysis/content-fingerprint"
 import {
   buildNovelVectorSnippet,
   selectRelevantNovelVectorResults,
 } from "./vector-relevance"
 
+/** 小于该规模的社区不调 LLM(孤立节点 / 噪声簇)。 */
+export const MIN_COMMUNITY_SUMMARY_NODE_COUNT = 5
+
 /** 社区摘要持久化结构 */
 interface CommunitySummaryRecord {
   communityId: number
+  memberFingerprint: string
+  memberIds: string[]
   summary: string
   nodeCount: number
   topNodes: string[]
   generatedAt: string
 }
 
+const inFlight = new Map<string, Promise<void>>()
+
+export function __resetCommunitySummaryLocksForTesting(): void {
+  inFlight.clear()
+}
+
+/** 成员 ID 排序后的稳定指纹。Louvain 重编号不应改变结果。 */
+export function communityMemberFingerprint(memberIds: readonly string[]): string {
+  return fingerprintText(
+    [...memberIds]
+      .map((id) => id.trim())
+      .filter(Boolean)
+      .sort()
+      .join("\0"),
+  )
+}
+
+function throwIfAborted(signal?: AbortSignal): void {
+  if (!signal?.aborted) return
+  if (signal.reason instanceof Error) throw signal.reason
+  throw new DOMException("The operation was aborted.", "AbortError")
+}
+
+function combineAbortSignals(signal?: AbortSignal): AbortSignal {
+  const timeout = AbortSignal.timeout(DEFAULT_LLM_REQUEST_TIMEOUT_MS)
+  if (!signal) return timeout
+  if (typeof AbortSignal.any === "function") return AbortSignal.any([signal, timeout])
+  return signal
+}
+
+function parseStoredRecord(raw: unknown): CommunitySummaryRecord | null {
+  if (!raw || typeof raw !== "object") return null
+  const rec = raw as Record<string, unknown>
+  if (typeof rec.memberFingerprint !== "string" || !rec.memberFingerprint.trim()) return null
+  if (typeof rec.summary !== "string" || !rec.summary.trim()) return null
+  const memberIds = Array.isArray(rec.memberIds)
+    ? rec.memberIds.filter((id): id is string => typeof id === "string")
+    : []
+  return {
+    communityId: typeof rec.communityId === "number" ? rec.communityId : -1,
+    memberFingerprint: rec.memberFingerprint,
+    memberIds,
+    summary: rec.summary,
+    nodeCount: typeof rec.nodeCount === "number" ? rec.nodeCount : memberIds.length,
+    topNodes: Array.isArray(rec.topNodes)
+      ? rec.topNodes.filter((name): name is string => typeof name === "string")
+      : [],
+    generatedAt: typeof rec.generatedAt === "string" ? rec.generatedAt : "",
+  }
+}
+
+async function loadExistingByFingerprint(
+  summaryDir: string,
+): Promise<Map<string, CommunitySummaryRecord>> {
+  const map = new Map<string, CommunitySummaryRecord>()
+  let files
+  try {
+    files = await listDirectory(summaryDir, { includeHidden: false, maxDepth: 1 })
+  } catch {
+    return map
+  }
+  for (const file of files) {
+    if (file.is_dir || !file.name.endsWith(".json")) continue
+    try {
+      const record = parseStoredRecord(JSON.parse(await readFile(file.path)))
+      if (record) map.set(record.memberFingerprint, record)
+    } catch {
+      // 跳过坏文件
+    }
+  }
+  return map
+}
+
 /** 判断当前章节是否应该触发社区摘要重建 */
 export function shouldRebuildCommunitySummaries(
   chapterNumber: number,
@@ -31,17 +110,17 @@ export function shouldRebuildCommunitySummaries(
   return chapterNumber % interval === 0
 }
 
-/** 生成所有社区的叙事摘要并持久化 + 向量化 */
-export async function generateCommunitySummaries(
+async function generateCommunitySummariesUnlocked(
   projectPath: string,
   llmConfig: LlmConfig,
   novelConfig: NovelConfig,
+  signal?: AbortSignal,
 ): Promise<void> {
+  throwIfAborted(signal)
   const pp = normalizePath(projectPath)
   const { nodes, communities } = await buildWikiGraph(pp)
   if (communities.length === 0) return
 
-  // 按社区 ID 分组节点
   const nodesByCommunity = new Map<number, GraphNode[]>()
   for (const node of nodes) {
     const bucket = nodesByCommunity.get(node.community) ?? []
@@ -49,67 +128,96 @@ export async function generateCommunitySummaries(
     nodesByCommunity.set(node.community, bucket)
   }
 
-  // 准备持久化目录
   const summaryDir = `${pp}/.novel/community-summaries`
   await createDirectory(summaryDir)
+  const existing = await loadExistingByFingerprint(summaryDir)
 
-  // 解析摘要模型
   const summaryLlmConfig = resolveNovelModel(llmConfig, novelConfig, "summary")
   const embCfg = useWikiStore.getState().embeddingConfig
 
-  // 逐个社区生成摘要
   for (const community of communities) {
+    throwIfAborted(signal)
     const members = nodesByCommunity.get(community.id) ?? []
-    if (members.length === 0) continue
+    if (members.length < MIN_COMMUNITY_SUMMARY_NODE_COUNT) continue
+    if (community.nodeCount < MIN_COMMUNITY_SUMMARY_NODE_COUNT) continue
+
+    const memberIds = members.map((member) => member.id)
+    const fingerprint = communityMemberFingerprint(memberIds)
+    if (existing.get(fingerprint)?.summary.trim()) continue
 
     try {
-      const summary = await generateSingleCommunitySummary(community, members, summaryLlmConfig)
+      const summary = await generateSingleCommunitySummary(
+        community,
+        members,
+        summaryLlmConfig,
+        signal,
+      )
       const record: CommunitySummaryRecord = {
         communityId: community.id,
+        memberFingerprint: fingerprint,
+        memberIds,
         summary,
-        nodeCount: community.nodeCount,
+        nodeCount: members.length,
         topNodes: community.topNodes,
         generatedAt: new Date().toISOString(),
       }
 
-      // 持久化到 JSON
-      const summaryPath = `${summaryDir}/${community.id}.json`
-      await writeFile(summaryPath, JSON.stringify(record, null, 2))
+      await writeFile(`${summaryDir}/${fingerprint}.json`, JSON.stringify(record, null, 2))
+      existing.set(fingerprint, record)
 
-      // 向量化写入 LanceDB(page_id = community:xxx)
       if (embCfg.enabled && embCfg.model) {
         try {
-          const pageId = `community:${community.id}`
-          const title = `社区 ${community.id} 摘要(${community.topNodes[0] ?? ""})`
-          await embedPage(pp, pageId, title, summary, embCfg)
+          const title = `社区摘要(${community.topNodes[0] ?? fingerprint.slice(0, 8)})`
+          await embedPage(pp, `community:${fingerprint}`, title, summary, embCfg)
         } catch (err) {
           console.warn(
-            `[CommunitySummary] 向量化社区 ${community.id} 失败:`,
+            `[CommunitySummary] 向量化社区 ${fingerprint} 失败:`,
             err instanceof Error ? err.message : err,
           )
         }
       }
     } catch (err) {
+      if (signal?.aborted) throw err
       console.warn(
-        `[CommunitySummary] 生成社区 ${community.id} 摘要失败:`,
+        `[CommunitySummary] 生成社区 ${fingerprint} 摘要失败:`,
         err instanceof Error ? err.message : err,
       )
     }
   }
 }
 
+/** 生成规模达标且成员集变化过的社区叙事摘要并持久化 + 向量化 */
+export async function generateCommunitySummaries(
+  projectPath: string,
+  llmConfig: LlmConfig,
+  novelConfig: NovelConfig,
+  signal?: AbortSignal,
+): Promise<void> {
+  const key = normalizePath(projectPath)
+  const pending = inFlight.get(key)
+  if (pending) return pending
+
+  const run = generateCommunitySummariesUnlocked(projectPath, llmConfig, novelConfig, signal)
+    .finally(() => {
+      if (inFlight.get(key) === run) inFlight.delete(key)
+    })
+  inFlight.set(key, run)
+  return run
+}
+
 /** 为单个社区生成叙事摘要(200-400 字) */
 async function generateSingleCommunitySummary(
   community: CommunityInfo,
   members: GraphNode[],
   llmConfig: LlmConfig,
+  signal?: AbortSignal,
 ): Promise<string> {
-  // 收集成员节点内容(前 500 字/节点,最多 10 个节点)
   const topMembers = members
     .sort((a, b) => b.linkCount - a.linkCount)
     .slice(0, 10)
   const memberContents: string[] = []
   for (const member of topMembers) {
+    throwIfAborted(signal)
     try {
       const content = await readFile(member.path)
       const truncated = content.slice(0, 500).replace(/\s+/g, " ").trim()
@@ -120,7 +228,7 @@ async function generateSingleCommunitySummary(
   }
 
   if (memberContents.length === 0) {
-    return `社区 ${community.id}:包含 ${community.nodeCount} 个节点(${community.topNodes.join("、")}),但无法读取节点内容。`
+    return `社区:包含 ${community.nodeCount} 个节点(${community.topNodes.join("、")}),但无法读取节点内容。`
   }
 
   const systemPrompt = `你是一位小说编辑助手,擅长分析角色阵营、关系网络和故事结构。请根据给定的图谱社区成员信息,生成一段 200-400 字的叙事摘要,描述这个社区的核心主题、阵营特征、关键关系和重要事件。
@@ -132,8 +240,7 @@ async function generateSingleCommunitySummary(
 4. 涵盖重要事件和冲突
 5. 200-400 字,不要超过 400 字`
 
-  const userPrompt = `社区 ID: ${community.id}
-社区规模: ${community.nodeCount} 个节点
+  const userPrompt = `社区规模: ${community.nodeCount} 个节点
 核心成员: ${community.topNodes.join("、")}
 
 成员详情:
@@ -158,10 +265,10 @@ ${memberContents.join("\n\n")}
     },
   }
 
-  await streamChat(llmConfig, messages, callbacks, AbortSignal.timeout(DEFAULT_LLM_REQUEST_TIMEOUT_MS))
+  await streamChat(llmConfig, messages, callbacks, combineAbortSignals(signal))
   if (streamError) throw streamError
 
-  return result.trim() || `社区 ${community.id}:包含 ${community.nodeCount} 个节点(${community.topNodes.join("、")})。`
+  return result.trim() || `社区:包含 ${community.nodeCount} 个节点(${community.topNodes.join("、")})。`
 }
 
 /** 检索与查询相关的社区摘要(用于注入上下文) */
@@ -176,7 +283,6 @@ export async function searchCommunitySummaries(
 
   try {
     const results = await searchByEmbedding(pp, query, embCfg, topK * 3)
-    // 只保留 community: 前缀的结果
     const communityResults = selectRelevantNovelVectorResults(
       results.filter(r => r.id.startsWith("community:")),
       topK,
@@ -184,9 +290,9 @@ export async function searchCommunitySummaries(
     if (communityResults.length === 0) return ""
 
     return communityResults.map(r => {
-      const communityId = r.id.replace("community:", "")
+      const heading = r.matchedChunks?.[0]?.headingPath
       const snippet = buildNovelVectorSnippet(r, 400)
-      return `- 【社区摘要·社区${communityId}】: ${snippet}`
+      return `- 【社区摘要${heading ? `·${heading}` : ""}】: ${snippet}`
     }).join("\n")
   } catch {
     return ""

+ 60 - 0
src/lib/qmai-cursor-agent-wrapper.spec.ts

@@ -0,0 +1,60 @@
+import { createRequire } from "node:module"
+import { fileURLToPath } from "node:url"
+import { describe, expect, it } from "vitest"
+
+const require = createRequire(import.meta.url)
+const wrapperPath = fileURLToPath(
+  new URL("../../src-tauri/scripts/qmai-cursor-agent.cjs", import.meta.url),
+)
+const {
+  cliModelToAcpValue,
+  resolveAcpArgvModel,
+  rewriteAcpArgs,
+} = require(wrapperPath) as {
+  cliModelToAcpValue: (model: string) => string
+  resolveAcpArgvModel: (argvModel: string, pinned: string) => string
+  rewriteAcpArgs: (args: string[], pinned: string) => string[]
+}
+
+describe("qmai-cursor-agent wrapper", () => {
+  it("keeps the CLI catalog id on argv and ignores the shared pin", () => {
+    expect(
+      rewriteAcpArgs(
+        ["acp", "--model", "cursor-grok-4.6-medium-fast"],
+        "composer-2.5[fast=true]",
+      ),
+    ).toEqual(["acp", "--model", "cursor-grok-4.6-medium-fast"])
+    expect(
+      rewriteAcpArgs(["acp", "--model", "gemini-3.7-flash-high"], "cursor-grok-4.6-high-fast"),
+    ).toEqual(["acp", "--model", "gemini-3.7-flash-high"])
+  })
+
+  it("reads a CLI pin only for default or missing --model", () => {
+    expect(resolveAcpArgvModel("default", "cursor-grok-4.6-medium-fast")).toBe(
+      "cursor-grok-4.6-medium-fast",
+    )
+    expect(resolveAcpArgvModel("auto", "gemini-3.7-flash[effort=high,fast=true]")).toBe("")
+    expect(rewriteAcpArgs(["acp", "--model", "default"], "gemini-3.7-flash-high")).toEqual([
+      "acp",
+      "--model",
+      "gemini-3.7-flash-high",
+    ])
+    expect(rewriteAcpArgs(["acp"], "composer-2.5-fast")).toEqual([
+      "acp",
+      "--model",
+      "composer-2.5-fast",
+    ])
+  })
+
+  it("does not use a parameterized ACP pin", () => {
+    expect(cliModelToAcpValue("cursor-grok-4.6-medium-fast")).toBe(
+      "grok-4.6[effort=medium,fast=true]",
+    )
+    expect(resolveAcpArgvModel("default", "gemini-3.7-flash[effort=high,fast=true]")).toBe("")
+    expect(rewriteAcpArgs(["acp", "--model", "default"], "grok-4.6[effort=high,fast=true]")).toEqual([
+      "acp",
+      "--model",
+      "default",
+    ])
+  })
+})

+ 52 - 0
src/lib/settings-model-list.spec.ts

@@ -1,6 +1,7 @@
 import { afterEach, describe, expect, it, vi } from "vitest"
 import type { LlmConfig } from "@/stores/wiki-store"
 import { invoke } from "@tauri-apps/api/core"
+import { getCursorCliCatalog, rememberCursorCliCatalog } from "@/lib/cursor-acp-models"
 
 const fetchMock = vi.fn()
 
@@ -33,6 +34,7 @@ function customConfig(overrides: Partial<LlmConfig> = {}): LlmConfig {
 afterEach(() => {
   fetchMock.mockReset()
   vi.mocked(invoke).mockReset()
+  rememberCursorCliCatalog([])
 })
 
 describe("settings model list", () => {
@@ -140,6 +142,56 @@ describe("settings model list", () => {
     expect(result.models).toEqual(["gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"])
   })
 
+  it("rewrites cursor-cli model list to ACP catalog names", async () => {
+    vi.mocked(invoke)
+      .mockResolvedValueOnce({
+        healthy: true,
+        base_url: "http://127.0.0.1:8765",
+        managed: true,
+        error: null,
+      })
+      .mockResolvedValueOnce(undefined)
+    fetchMock.mockImplementation(() =>
+      Promise.resolve(
+        new Response(
+          JSON.stringify({
+            data: [
+              { id: "cursor-grok-4.6-medium-fast" },
+              { id: "cursor-grok-4.6-high" },
+              { id: "composer-2-fast" },
+            ],
+          }),
+          { status: 200, headers: { "Content-Type": "application/json" } },
+        ),
+      ),
+    )
+
+    const { fetchLlmModelList } = await import("./settings-model-list")
+    const result = await fetchLlmModelList(customConfig({
+      provider: "cursor-cli",
+      apiKey: "",
+      model: "cursor-grok-4.6-medium-fast",
+      customEndpoint: "http://127.0.0.1:8765/v1",
+    }))
+
+    expect(invoke).toHaveBeenCalledWith("cursor_cli_apply_acp_model", {
+      model: "grok-4.6",
+      cliModel: "cursor-grok-4.6-medium-fast",
+      fast: true,
+      effort: "medium",
+    })
+    expect(result.models).toEqual([
+      "composer-2-fast",
+      "cursor-grok-4.6-high",
+      "cursor-grok-4.6-medium-fast",
+    ])
+    expect(getCursorCliCatalog()).toEqual([
+      "composer-2-fast",
+      "cursor-grok-4.6-high",
+      "cursor-grok-4.6-medium-fast",
+    ])
+  })
+
   it("rejects a Codex CLI without app-server dynamic tools", async () => {
     vi.mocked(invoke).mockResolvedValueOnce({
       installed: true,

+ 7 - 1
src/lib/settings-model-list.ts

@@ -1,5 +1,6 @@
 import { getProviderConfig, withCustomOriginHeader } from "@/lib/llm-providers"
 import { detectLocalCliConfig } from "@/lib/local-cli-config"
+import { rememberCursorCliCatalog } from "@/lib/cursor-acp-models"
 import { ensureCursorProxyRunning, restartCursorProxyWithAuth, withCursorProxyEndpoint } from "@/lib/cursor-cli-proxy"
 import { isDirectRerankEndpoint } from "@/lib/rerank-api"
 import { getHttpFetch } from "@/lib/tauri-fetch"
@@ -209,6 +210,10 @@ export async function fetchLlmModelList(config: LlmConfig): Promise<LlmModelList
     if (runtimeConfig.provider === "google") {
       return toModelListResult(result.models.map((model) => model.replace(/^models\//, "")))
     }
+    if (runtimeConfig.provider === "cursor-cli") {
+      rememberCursorCliCatalog(result.models)
+      return toModelListResult(result.models)
+    }
     return result
   }
 
@@ -224,7 +229,8 @@ export async function fetchLlmModelList(config: LlmConfig): Promise<LlmModelList
     runtimeConfig = withCursorProxyEndpoint(runtimeConfig, endpoint)
     const retry = buildModelsUrl(runtimeConfig)
     const result = await fetchModelList(retry.url, retry.headers, runtimeConfig.model)
-    return result
+    rememberCursorCliCatalog(result.models)
+    return toModelListResult(result.models)
   }
 }
 

+ 3 - 0
src/stores/wiki-store.ts

@@ -158,6 +158,8 @@ interface LlmConfig {
   localCliIsolation?: boolean
   codexCliTimeoutMinutes?: number
   codexSpeedMode?: CodexSpeedMode
+  /** Cursor ACP `fast=` parameter. Independent of Codex Fast. */
+  cursorSpeedMode?: CodexSpeedMode
   /** When false, Agent requests omit tools/tool_choice. Default/undefined = enabled. */
   functionCallingEnabled?: boolean
 }
@@ -459,6 +461,7 @@ export interface ProviderOverride {
   localCliIsolation?: boolean
   codexCliTimeoutMinutes?: number
   codexSpeedMode?: CodexSpeedMode
+  cursorSpeedMode?: CodexSpeedMode
   /** 是否在 AI 会话中显示该 provider 下的模型(仅用于自定义供应商)。默认 true。 */
   enabled?: boolean
   /** 已保存的模型列表(仅用于自定义供应商) */