diff --git a/openless-all/app/src-tauri/build.rs b/openless-all/app/src-tauri/build.rs index 925bf8b0..fef616ba 100644 --- a/openless-all/app/src-tauri/build.rs +++ b/openless-all/app/src-tauri/build.rs @@ -24,7 +24,9 @@ int openless_common_controls_v6_manifest_dependency_anchor = 0; cc::Build::new() .file(&source_path) .compile("openless_common_controls_v6_manifest_dependency"); - println!("cargo:rustc-link-arg=/INCLUDE:openless_common_controls_v6_manifest_dependency_anchor"); + println!( + "cargo:rustc-link-arg=/INCLUDE:openless_common_controls_v6_manifest_dependency_anchor" + ); } /// 编译 vendored Open-Less/qwen-asr 的 C 源(仅 macOS)。 diff --git a/openless-all/app/src-tauri/capabilities/default.json b/openless-all/app/src-tauri/capabilities/default.json index e629bf7b..760d74ed 100644 --- a/openless-all/app/src-tauri/capabilities/default.json +++ b/openless-all/app/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Default capabilities for OpenLess windows", - "windows": ["main", "capsule", "qa"], + "windows": ["main", "capsule", "qa", "less-computer", "less-computer-glow"], "permissions": [ "core:default", "core:window:default", diff --git a/openless-all/app/src-tauri/src/asr/local/cache.rs b/openless-all/app/src-tauri/src/asr/local/cache.rs index 7076fd1e..43c043b5 100644 --- a/openless-all/app/src-tauri/src/asr/local/cache.rs +++ b/openless-all/app/src-tauri/src/asr/local/cache.rs @@ -1,4 +1,7 @@ -#![cfg_attr(target_os = "linux", allow(dead_code, unused_imports, unused_variables))] +#![cfg_attr( + target_os = "linux", + allow(dead_code, unused_imports, unused_variables) +)] //! 本地 Qwen3-ASR 引擎缓存。 //! //! 用途:避免每次 dictation 都重加载 1.2GB+ 模型。引擎一次 load 后驻留在内存, diff --git a/openless-all/app/src-tauri/src/asr/local/test_run.rs b/openless-all/app/src-tauri/src/asr/local/test_run.rs index 322aa49c..e14735af 100644 --- a/openless-all/app/src-tauri/src/asr/local/test_run.rs +++ b/openless-all/app/src-tauri/src/asr/local/test_run.rs @@ -18,9 +18,9 @@ use std::time::Instant; use anyhow::Result; use serde::Serialize; -use super::models::ModelId; #[cfg(target_os = "macos")] use super::models::model_dir; +use super::models::ModelId; /// 内嵌测试音频。原始文件 `vendor/qwen-asr/samples/test_speech.wav` /// 内容:"Hello. This is a test of the Voxtrail speech-to-text system." diff --git a/openless-all/app/src-tauri/src/asr/volcengine.rs b/openless-all/app/src-tauri/src/asr/volcengine.rs index 101cd15a..6457a1e6 100644 --- a/openless-all/app/src-tauri/src/asr/volcengine.rs +++ b/openless-all/app/src-tauri/src/asr/volcengine.rs @@ -162,7 +162,9 @@ impl VolcengineStreamingASR { .map_err(|e| VolcengineASRError::ConnectionFailed(e.to_string()))?, ); - let (ws, _resp) = connect_async(request).await.map_err(classify_connect_error)?; + let (ws, _resp) = connect_async(request) + .await + .map_err(classify_connect_error)?; let (write, read) = ws.split(); let (tx, rx) = oneshot::channel(); diff --git a/openless-all/app/src-tauri/src/asr/whisper.rs b/openless-all/app/src-tauri/src/asr/whisper.rs index cc3120d2..788e5b9a 100644 --- a/openless-all/app/src-tauri/src/asr/whisper.rs +++ b/openless-all/app/src-tauri/src/asr/whisper.rs @@ -260,9 +260,8 @@ fn extract_confident_text(json: &serde_json::Value) -> String { .and_then(|v| v.as_f64()) .unwrap_or(1.0); - let is_hallucination = (no_speech > 0.6 && avg_logprob < -0.5) - || compression > 2.4 - || avg_logprob < -1.0; + let is_hallucination = + (no_speech > 0.6 && avg_logprob < -0.5) || compression > 2.4 || avg_logprob < -1.0; if is_hallucination { log::warn!( "[whisper] 丢弃疑似幻听段落: no_speech={:.2} avg_logprob={:.2} compression={:.2} text={:?}", diff --git a/openless-all/app/src-tauri/src/coding_agent/args.rs b/openless-all/app/src-tauri/src/coding_agent/args.rs new file mode 100644 index 00000000..ecaed77b --- /dev/null +++ b/openless-all/app/src-tauri/src/coding_agent/args.rs @@ -0,0 +1,233 @@ +//! 无头 Claude Code(`claude -p`)调用参数构造。 +//! +//! 纯逻辑:把一个 [`CodingAgentRequest`] 翻译成 `claude` 的命令行参数列表。 +//! prompt 本身**不**进 argv(避免出现在进程列表里泄露),由运行器写进 stdin。 + +use std::path::PathBuf; + +/// Claude Code 权限模式,对应 CLI `--permission-mode` 的取值(已对本机 v2.1.161 核实)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CodingAgentPermissionMode { + /// 只读/计划模式:不改文件。 + Plan, + /// 默认:每个动作都要确认(无头下等于大多被拒,少用)。 + Default, + /// 放行可恢复的编辑/动作(本项目「放行 + 护栏」的默认)。 + AcceptEdits, + /// 跳过所有权限检查——仅高级区,绝不做默认。 + BypassPermissions, +} + +impl CodingAgentPermissionMode { + /// 传给 `--permission-mode` 的字符串。 + pub fn as_cli_arg(self) -> &'static str { + match self { + Self::Plan => "plan", + Self::Default => "default", + Self::AcceptEdits => "acceptEdits", + Self::BypassPermissions => "bypassPermissions", + } + } +} + +impl Default for CodingAgentPermissionMode { + fn default() -> Self { + Self::AcceptEdits + } +} + +/// 一次无头 agent 运行的完整请求。 +#[derive(Debug, Clone)] +pub struct CodingAgentRequest { + /// 会话标识,用于丢弃迟到事件。 + pub session_id: String, + /// 最终发给 Claude 的指令(写入 stdin,不进 argv)。 + pub prompt: String, + /// 工作目录;同时作为 `--add-dir` 限定文件作用域。 + pub cwd: Option, + pub model: Option, + pub fallback_model: Option, + pub permission_mode: CodingAgentPermissionMode, + pub allowed_tools: Vec, + pub disallowed_tools: Vec, + /// 单次运行成本硬上限(`--max-budget-usd`)。 + pub max_budget_usd: Option, + /// 运行超时(秒)。 + pub timeout_secs: u64, + /// 额外系统提示词(`--append-system-prompt`)。 + pub extra_system_prompt: Option, + /// 护栏 settings JSON 文件路径(`--settings`)。 + pub settings_json_path: Option, + /// 是否保留会话(false 时加 `--no-session-persistence`,快取用走 false 更快)。 + pub session_persistence: bool, + /// 续接最近一次会话(`--continue`)。Less Computer 连续对话用:第二轮起带上下文。 + pub continue_session: bool, +} + +impl CodingAgentRequest { + /// 最小化构造:只给会话 id 和 prompt,其余取保守默认。 + pub fn new(session_id: impl Into, prompt: impl Into) -> Self { + Self { + session_id: session_id.into(), + prompt: prompt.into(), + cwd: None, + model: None, + fallback_model: None, + permission_mode: CodingAgentPermissionMode::default(), + allowed_tools: Vec::new(), + disallowed_tools: Vec::new(), + max_budget_usd: None, + timeout_secs: 300, + extra_system_prompt: None, + settings_json_path: None, + session_persistence: true, + continue_session: false, + } + } +} + +/// 构造 `claude` 的命令行参数(不含可执行文件本身,也不含 prompt)。 +/// +/// 固定使用无头流式:`-p --output-format stream-json --verbose --include-partial-messages`, +/// 这样前端能拿到逐字 delta。 +pub fn build_claude_args(req: &CodingAgentRequest) -> Vec { + let mut args: Vec = vec![ + "-p".into(), + "--output-format".into(), + "stream-json".into(), + "--verbose".into(), + "--include-partial-messages".into(), + "--permission-mode".into(), + req.permission_mode.as_cli_arg().into(), + ]; + + if let Some(model) = &req.model { + args.push("--model".into()); + args.push(model.clone()); + } + if let Some(fm) = &req.fallback_model { + args.push("--fallback-model".into()); + args.push(fm.clone()); + } + if let Some(cwd) = &req.cwd { + args.push("--add-dir".into()); + args.push(cwd.to_string_lossy().into_owned()); + } + if !req.allowed_tools.is_empty() { + args.push("--allowedTools".into()); + args.push(req.allowed_tools.join(",")); + } + if !req.disallowed_tools.is_empty() { + args.push("--disallowedTools".into()); + args.push(req.disallowed_tools.join(",")); + } + if let Some(budget) = req.max_budget_usd { + args.push("--max-budget-usd".into()); + args.push(format!("{budget}")); + } + if let Some(path) = &req.settings_json_path { + args.push("--settings".into()); + args.push(path.to_string_lossy().into_owned()); + } + if let Some(sp) = &req.extra_system_prompt { + args.push("--append-system-prompt".into()); + args.push(sp.clone()); + } + if !req.session_persistence { + args.push("--no-session-persistence".into()); + } + if req.continue_session { + args.push("--continue".into()); + } + + args +} + +#[cfg(test)] +mod tests { + use super::*; + + fn arg_value<'a>(args: &'a [String], flag: &str) -> Option<&'a str> { + args.iter() + .position(|a| a == flag) + .and_then(|i| args.get(i + 1)) + .map(|s| s.as_str()) + } + + #[test] + fn default_args_are_headless_streaming() { + let req = CodingAgentRequest::new("s1", "hello"); + let args = build_claude_args(&req); + assert!(args.contains(&"-p".to_string())); + assert_eq!(arg_value(&args, "--output-format"), Some("stream-json")); + assert!(args.contains(&"--verbose".to_string())); + assert!(args.contains(&"--include-partial-messages".to_string())); + // prompt 不能出现在 argv 里 + assert!(!args.iter().any(|a| a.contains("hello"))); + } + + #[test] + fn permission_mode_maps_to_cli_string() { + assert_eq!(CodingAgentPermissionMode::Plan.as_cli_arg(), "plan"); + assert_eq!( + CodingAgentPermissionMode::AcceptEdits.as_cli_arg(), + "acceptEdits" + ); + assert_eq!( + CodingAgentPermissionMode::BypassPermissions.as_cli_arg(), + "bypassPermissions" + ); + let mut req = CodingAgentRequest::new("s", "p"); + req.permission_mode = CodingAgentPermissionMode::Plan; + assert_eq!( + arg_value(&build_claude_args(&req), "--permission-mode"), + Some("plan") + ); + } + + #[test] + fn default_permission_mode_is_accept_edits() { + assert_eq!( + CodingAgentPermissionMode::default(), + CodingAgentPermissionMode::AcceptEdits + ); + } + + #[test] + fn optional_flags_are_emitted_when_set() { + let mut req = CodingAgentRequest::new("s", "p"); + req.model = Some("sonnet".into()); + req.fallback_model = Some("haiku".into()); + req.max_budget_usd = Some(0.5); + req.cwd = Some(PathBuf::from("/tmp/work")); + req.allowed_tools = vec!["Bash(git *)".into(), "Edit".into()]; + req.disallowed_tools = vec!["Bash(rm -rf:*)".into()]; + req.settings_json_path = Some(PathBuf::from("/tmp/guard.json")); + req.extra_system_prompt = Some("be terse".into()); + req.session_persistence = false; + + let args = build_claude_args(&req); + assert_eq!(arg_value(&args, "--model"), Some("sonnet")); + assert_eq!(arg_value(&args, "--fallback-model"), Some("haiku")); + assert_eq!(arg_value(&args, "--max-budget-usd"), Some("0.5")); + assert_eq!(arg_value(&args, "--add-dir"), Some("/tmp/work")); + assert_eq!(arg_value(&args, "--allowedTools"), Some("Bash(git *),Edit")); + assert_eq!( + arg_value(&args, "--disallowedTools"), + Some("Bash(rm -rf:*)") + ); + assert_eq!(arg_value(&args, "--settings"), Some("/tmp/guard.json")); + assert_eq!(arg_value(&args, "--append-system-prompt"), Some("be terse")); + assert!(args.contains(&"--no-session-persistence".to_string())); + } + + #[test] + fn optional_flags_absent_by_default() { + let req = CodingAgentRequest::new("s", "p"); + let args = build_claude_args(&req); + assert!(arg_value(&args, "--model").is_none()); + assert!(arg_value(&args, "--max-budget-usd").is_none()); + assert!(!args.contains(&"--no-session-persistence".to_string())); + } +} diff --git a/openless-all/app/src-tauri/src/coding_agent/commands.rs b/openless-all/app/src-tauri/src/coding_agent/commands.rs new file mode 100644 index 00000000..94d1dc42 --- /dev/null +++ b/openless-all/app/src-tauri/src/coding_agent/commands.rs @@ -0,0 +1,179 @@ +//! 「Claude 控制台」用到的 Tauri 命令:检测安装 / MCP 列表、护栏化流式测试运行、取消。 +//! +//! 这些命令不碰录音 / coordinator,是「快速 Agent」引擎最小可用的垂直切片。 + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use once_cell::sync::Lazy; +use parking_lot::Mutex; +use serde::Serialize; +use tauri::{AppHandle, Emitter}; + +use super::detect::{has_computer_use_mcp, McpServerStatus}; +use super::guard::build_guard_settings_json; +use super::{ + claude_mcp_list, create_git_snapshot, detect_claude, run_claude_agent, + CodingAgentPermissionMode, CodingAgentRequest, +}; + +/// 当前测试运行的取消标志(一次只跑一个)。 +static TEST_CANCEL: Lazy>>> = Lazy::new(|| Mutex::new(None)); + +/// 测试运行计数器,给每次运行一个唯一 session id(避免依赖时间戳)。 +static TEST_COUNTER: Lazy> = Lazy::new(|| Mutex::new(0)); + +fn next_session_id() -> String { + let mut c = TEST_COUNTER.lock(); + *c = c.wrapping_add(1); + format!("console-{}", *c) +} + +fn normalize_exe(exe: Option) -> String { + exe.map(|e| e.trim().to_string()) + .filter(|e| !e.is_empty()) + .unwrap_or_else(|| "claude".to_string()) +} + +/// Claude Code 检测结果(回前端,camelCase)。 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaudeDetectionWire { + /// 是否检测到可运行的 claude。 + pub installed: bool, + /// 版本号(如 "2.1.161")。 + pub version: Option, + /// 实际使用的可执行文件名/路径。 + pub exe: String, + /// 已配置的 MCP server 列表(含健康状态)。 + pub mcp_servers: Vec, + /// 是否检测到桌面控制类(computer use)MCP。 + pub has_computer_use: bool, +} + +/// 检测 claude 是否安装、版本、已配置的 MCP server(即「computer use 技能」检测口径)。 +#[tauri::command] +pub async fn coding_agent_detect(exe: Option) -> ClaudeDetectionWire { + let exe = normalize_exe(exe); + let version = detect_claude(&exe).await; + let mcp_servers = if version.is_some() { + claude_mcp_list(&exe).await + } else { + Vec::new() + }; + let has_computer_use = has_computer_use_mcp(&mcp_servers); + ClaudeDetectionWire { + installed: version.is_some(), + version, + exe, + mcp_servers, + has_computer_use, + } +} + +/// 护栏化地无头跑一次 claude,事件流式 emit 到前端 `coding-agent:test`。 +/// +/// 安全:附 `--settings`(acceptEdits + 高风险 deny)、`--max-budget-usd` 成本上限; +/// 若 workdir 是 git 仓库,运行前做一次 `git stash create` 快照(可回滚)。 +#[tauri::command] +pub async fn coding_agent_run_test( + app: AppHandle, + prompt: String, + exe: Option, + permission_mode: Option, + workdir: Option, + model: Option, + max_budget_usd: Option, +) -> Result<(), String> { + let prompt = prompt.trim().to_string(); + if prompt.is_empty() { + return Err("指令为空".into()); + } + let exe = normalize_exe(exe); + let mode = permission_mode.unwrap_or_default(); + + let cwd = workdir + .map(|w| w.trim().to_string()) + .filter(|w| !w.is_empty()) + .map(std::path::PathBuf::from); + + // 运行前 git 快照(仅当是 git 仓库;非仓库返回 None,无副作用)。 + if let Some(dir) = &cwd { + if let Some(sha) = create_git_snapshot(dir) { + log::info!("[coding-agent] 运行前已生成 git 快照 {sha}(git stash apply 可回滚)"); + } + } + + // 写护栏 settings 到临时文件。 + let settings_json = build_guard_settings_json(mode.as_cli_arg(), &[]); + let settings_path = std::env::temp_dir().join(format!( + "openless-claude-guard-{}.json", + uuid::Uuid::new_v4() + )); + std::fs::write( + &settings_path, + serde_json::to_vec_pretty(&settings_json).map_err(|e| e.to_string())?, + ) + .map_err(|e| format!("写护栏配置失败: {e}"))?; + + let mut req = CodingAgentRequest::new(next_session_id(), prompt); + req.cwd = cwd; + // 控制台测试默认走 sonnet:比用户默认的 Opus 便宜约一个数量级,足够验证连通与流式。 + req.model = model + .filter(|m| !m.trim().is_empty()) + .or_else(|| Some("sonnet".to_string())); + req.permission_mode = mode; + req.max_budget_usd = max_budget_usd.or(Some(0.5)); + req.timeout_secs = 120; + req.settings_json_path = Some(settings_path.clone()); + req.session_persistence = false; + // 「放行 + 护栏」:允许轻动作与可恢复编辑;高风险由 deny 清单拦截。 + req.allowed_tools = vec![ + "Bash".into(), + "Read".into(), + "Edit".into(), + "Write".into(), + "Glob".into(), + "Grep".into(), + "WebFetch".into(), + "WebSearch".into(), + ]; + + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let cancel = Arc::new(AtomicBool::new(false)); + *TEST_CANCEL.lock() = Some(cancel.clone()); + + let exe_for_task = exe.clone(); + let handle = tauri::async_runtime::spawn(async move { + run_claude_agent(&exe_for_task, req, tx, cancel).await + }); + + // 边收边发:runner 结束会 drop sink,rx 收到 None 退出。 + while let Some(ev) = rx.recv().await { + let _ = app.emit("coding-agent:test", &ev); + } + + let run_result = handle.await; + *TEST_CANCEL.lock() = None; + let _ = std::fs::remove_file(&settings_path); + + match run_result { + Ok(Ok(())) => Ok(()), + Ok(Err(e)) => Err(e.to_string()), + Err(join_err) => Err(format!("agent 任务异常: {join_err}")), + } +} + +/// 取消当前正在跑的测试运行。 +#[tauri::command] +pub fn coding_agent_cancel_test() { + if let Some(flag) = TEST_CANCEL.lock().clone() { + flag.store(true, Ordering::Relaxed); + } +} + +/// 本地预检一条命令是否高风险,返回原因(控制台在运行前给用户警示用)。 +#[tauri::command] +pub fn coding_agent_command_risk(command: String) -> Option { + super::guard::is_high_risk_command(&command).map(|r| r.to_string()) +} diff --git a/openless-all/app/src-tauri/src/coding_agent/detect.rs b/openless-all/app/src-tauri/src/coding_agent/detect.rs new file mode 100644 index 00000000..07100fb7 --- /dev/null +++ b/openless-all/app/src-tauri/src/coding_agent/detect.rs @@ -0,0 +1,138 @@ +//! 解析 `claude --version` 与 `claude mcp list` 的输出(纯逻辑,便于单测)。 + +/// 从 `claude --version` 输出里提取版本号。 +/// +/// 兼容 `"2.1.161 (Claude Code)"` 与 `"Claude Code version 2.1.161"` 两种排版: +/// 取第一个形如 `x.y.z` 的 token。 +pub fn parse_claude_version(stdout: &str) -> Option { + for raw in stdout.split_whitespace() { + let token = raw.trim_matches(|c: char| !c.is_ascii_digit() && c != '.'); + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() == 3 + && parts + .iter() + .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) + { + return Some(token.to_string()); + } + } + None +} + +/// MCP server 健康状态。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum McpHealth { + Connected, + Failed, + NeedsAuth, + Unknown, +} + +/// `claude mcp list` 里的一项。 +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct McpServerStatus { + pub name: String, + pub detail: String, + pub health: McpHealth, +} + +/// 解析 `claude mcp list` 输出。忽略 "Checking..." 等噪声行。 +/// +/// 行格式约为:`: - <✓|✗|!> `。 +pub fn parse_mcp_list(stdout: &str) -> Vec { + let mut out = Vec::new(); + for line in stdout.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with("Checking") { + continue; + } + let Some((name, rest)) = line.split_once(": ") else { + continue; + }; + // 用最后一个 " - " 分隔 detail 与状态,避免 URL 里的连字符误伤。 + let (detail, status) = match rest.rfind(" - ") { + Some(idx) => (rest[..idx].trim(), rest[idx + 3..].trim()), + None => (rest.trim(), ""), + }; + let health = if status.contains("Connected") { + McpHealth::Connected + } else if status.contains("Failed") { + McpHealth::Failed + } else if status.contains("authentication") || status.contains("Needs") { + McpHealth::NeedsAuth + } else { + McpHealth::Unknown + }; + out.push(McpServerStatus { + name: name.trim().to_string(), + detail: detail.to_string(), + health, + }); + } + out +} + +/// 是否存在桌面控制类(computer use)MCP server。 +/// +/// 这是 OpenLess 对「computer use 技能是否安装」的检测口径:Claude Code 本身无原生 +/// computer use,桌面 GUI 控制只能通过挂载相应 MCP server 获得。 +pub fn has_computer_use_mcp(servers: &[McpServerStatus]) -> bool { + servers.iter().any(|s| { + let n = s.name.to_lowercase(); + n.contains("computer") || n.contains("desktop") || n.contains("screen") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_version_from_both_layouts() { + assert_eq!( + parse_claude_version("2.1.161 (Claude Code)").as_deref(), + Some("2.1.161") + ); + assert_eq!( + parse_claude_version("Claude Code version 2.1.161").as_deref(), + Some("2.1.161") + ); + assert_eq!(parse_claude_version("no version here"), None); + } + + #[test] + fn parses_mcp_list_health() { + let stdout = "Checking MCP server health…\n\ +memory: npx -y @modelcontextprotocol/server-memory - ✓ Connected\n\ +railway: npx -y @railway/mcp-server - ✗ Failed to connect\n\ +cloudflare-observability: https://observability.mcp.cloudflare.com/mcp (HTTP) - ! Needs authentication\n"; + let servers = parse_mcp_list(stdout); + assert_eq!(servers.len(), 3); + assert_eq!(servers[0].name, "memory"); + assert_eq!(servers[0].health, McpHealth::Connected); + assert_eq!(servers[1].health, McpHealth::Failed); + assert_eq!(servers[2].name, "cloudflare-observability"); + assert_eq!(servers[2].health, McpHealth::NeedsAuth); + // URL 里的 "-" 不应把状态切错 + assert!(servers[2] + .detail + .contains("observability.mcp.cloudflare.com")); + } + + #[test] + fn detects_computer_use_mcp_by_name() { + let with = vec![McpServerStatus { + name: "computer-use".into(), + detail: String::new(), + health: McpHealth::Connected, + }]; + let without = vec![McpServerStatus { + name: "playwright".into(), + detail: String::new(), + health: McpHealth::Connected, + }]; + assert!(has_computer_use_mcp(&with)); + assert!(!has_computer_use_mcp(&without)); + } +} diff --git a/openless-all/app/src-tauri/src/coding_agent/guard.rs b/openless-all/app/src-tauri/src/coding_agent/guard.rs new file mode 100644 index 00000000..634524e5 --- /dev/null +++ b/openless-all/app/src-tauri/src/coding_agent/guard.rs @@ -0,0 +1,117 @@ +//! 护栏:高风险命令分类 + 生成传给 `claude --settings` 的权限 JSON。 +//! +//! 「放行 + 护栏」策略: +//! - `permissions.defaultMode = acceptEdits`(放行可恢复/轻动作)。 +//! - `permissions.deny` 声明式拦截高风险工具调用(跨平台、稳)。 +//! - 运行级 git 快照由运行器在启动前做(见 `mod.rs::create_git_snapshot`)。 +//! +//! [`is_high_risk_command`] 供「Claude 控制台」等场景对**单条命令**做本地预检/展示用, +//! 与 CLI 侧的 deny 规则互为补充。 + +/// 高风险子串(已小写)→ 原因。命中任一即判为高风险。 +pub const HIGH_RISK_PATTERNS: &[(&str, &str)] = &[ + ("rm -rf", "递归强制删除"), + ("rm -fr", "递归强制删除"), + ("sudo ", "提权执行"), + ("git push --force", "强制推送会覆盖远端历史"), + ("git push -f", "强制推送会覆盖远端历史"), + ("git reset --hard", "硬重置会丢弃未提交改动"), + ("git clean -fd", "强制清理未跟踪文件"), + ("mkfs", "格式化文件系统"), + ("dd if=", "裸盘写入"), + (":(){", "fork 炸弹"), + ("shutdown", "关机"), + ("reboot", "重启"), + ("> /dev/sd", "直接写入块设备"), + ("| sh", "管道执行远程脚本"), + ("|sh", "管道执行远程脚本"), + ("| bash", "管道执行远程脚本"), + ("|bash", "管道执行远程脚本"), + ("chmod -r 777 /", "危险的全局权限修改"), + ("chown -r", "递归改所有权"), +]; + +/// 若命令命中高风险模式,返回原因;否则 `None`。 +pub fn is_high_risk_command(command: &str) -> Option<&'static str> { + let lowered = command.to_lowercase(); + HIGH_RISK_PATTERNS + .iter() + .find(|(pat, _)| lowered.contains(pat)) + .map(|(_, reason)| *reason) +} + +/// CLI `--settings` 默认的 `permissions.deny` 规则(Claude Code 工具说明符语法)。 +pub fn default_deny_rules() -> Vec { + vec![ + "Bash(rm -rf:*)".into(), + "Bash(rm -fr:*)".into(), + "Bash(sudo:*)".into(), + "Bash(git push --force:*)".into(), + "Bash(git push -f:*)".into(), + "Bash(git reset --hard:*)".into(), + "Bash(git clean -fd:*)".into(), + "Bash(mkfs:*)".into(), + "Bash(dd:*)".into(), + "Bash(shutdown:*)".into(), + "Bash(reboot:*)".into(), + "Edit(.env)".into(), + "Edit(.git/**)".into(), + ] +} + +/// 生成护栏 settings JSON。`mode` 为 `--permission-mode` 同名取值; +/// `extra_deny` 追加在默认 deny 之后。 +pub fn build_guard_settings_json(mode: &str, extra_deny: &[String]) -> serde_json::Value { + let mut deny = default_deny_rules(); + deny.extend(extra_deny.iter().cloned()); + serde_json::json!({ + "permissions": { + "defaultMode": mode, + "deny": deny, + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flags_rm_rf_regardless_of_case_and_spacing() { + assert!(is_high_risk_command("rm -rf /tmp/x").is_some()); + assert!(is_high_risk_command("RM -RF /").is_some()); + assert!(is_high_risk_command("sudo apt install").is_some()); + assert!(is_high_risk_command("git push --force origin main").is_some()); + } + + #[test] + fn flags_pipe_to_shell() { + assert!(is_high_risk_command("curl http://x | sh").is_some()); + assert!(is_high_risk_command("wget -qO- x|bash").is_some()); + } + + #[test] + fn allows_ordinary_reversible_commands() { + assert!(is_high_risk_command("ls -la").is_none()); + assert!(is_high_risk_command("git status").is_none()); + assert!(is_high_risk_command("pbcopy < file.txt").is_none()); + assert!(is_high_risk_command("echo hi").is_none()); + } + + #[test] + fn guard_settings_has_accept_edits_and_deny_list() { + let v = build_guard_settings_json("acceptEdits", &[]); + assert_eq!(v["permissions"]["defaultMode"], "acceptEdits"); + let deny = v["permissions"]["deny"].as_array().unwrap(); + assert!(deny.iter().any(|d| d == "Bash(rm -rf:*)")); + assert!(deny.iter().any(|d| d == "Bash(sudo:*)")); + } + + #[test] + fn guard_settings_appends_extra_deny() { + let extra = vec!["Bash(npm publish:*)".to_string()]; + let v = build_guard_settings_json("acceptEdits", &extra); + let deny = v["permissions"]["deny"].as_array().unwrap(); + assert!(deny.iter().any(|d| d == "Bash(npm publish:*)")); + } +} diff --git a/openless-all/app/src-tauri/src/coding_agent/mod.rs b/openless-all/app/src-tauri/src/coding_agent/mod.rs new file mode 100644 index 00000000..f98a1c2d --- /dev/null +++ b/openless-all/app/src-tauri/src/coding_agent/mod.rs @@ -0,0 +1,292 @@ +//! 无头 Claude Code 调用子系统(「快速 Agent」后端)。 +//! +//! - [`args`]:`claude -p` 参数构造。 +//! - [`stream`]:stream-json 输出解析为 [`stream::CodingAgentEvent`]。 +//! - [`guard`]:高风险命令分类 + `--settings` 护栏 JSON。 +//! - [`detect`]:解析 `claude --version` / `claude mcp list`。 +//! +//! 本模块只负责「跑无头 Claude 并把事件抛出来」,不碰录音 / ASR / 前端—— +//! 那些由 coordinator 串联(镜像现有 QA 链路)。 + +pub mod args; +pub mod commands; +pub mod detect; +pub mod guard; +pub mod stream; + +use std::path::Path; +use std::process::Stdio; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::process::Command; + +pub use args::{build_claude_args, CodingAgentPermissionMode, CodingAgentRequest}; +pub use detect::McpServerStatus; +pub use stream::{parse_stream_json_line, CodingAgentEvent}; + +/// 无头 Claude 的「自动化前置说明」。 +/// +/// 无头 `claude -p` 是单次运行、没有多轮对话兜底:模型若中途提问、只给计划 / 半成品, +/// 这一轮就废了。所以在把用户的真实需求交给它之前,统一包一层目标驱动(/goal 式)的 +/// 自动化指令,要求它一口气把任务彻底做完、只回最终结果。所有走 [`run_claude_agent`] +/// 的「让 Claude 干活」入口都应该用它来构造 prompt。 +pub fn autonomous_prompt(task: &str) -> String { + format!( + "【自动化任务 · 一次性完成】这是一次无人值守的单次无头运行,没有多轮对话机会,\ +你无法事后追问或补充。请把下面的需求当成一个必须在本次运行内彻底达成的目标(等价于先 /goal \ +设定目标与完成标准,再自主执行直到达成):\n\ +- 先想清楚目标和「完成」的判定标准,再开始动手;\n\ +- 自主、连续地一口气执行到完全完成,不要中途停下来提问或等待确认;遇到歧义按最合理的方式继续;\n\ +- 不要只给计划、思路或半成品,也不要留「后续步骤」给别人——要交付最终可用的结果;\n\ +- 任务较长也要想办法在这一次运行内拆解并跑完;\n\ +- 全部完成后,只输出最终结果本身,不要解释过程、不要前后缀、不要引号。\n\n\ +需求:\n{task}" + ) +} + +/// 运行器把事件投递到这个 sink(coordinator / 命令层再转成 Tauri event)。 +pub type CodingAgentEventSink = tokio::sync::mpsc::UnboundedSender; + +#[derive(Debug, thiserror::Error)] +pub enum CodingAgentError { + #[error("找不到可执行文件: {0}")] + ExecutableNotFound(String), + #[error("启动 agent 进程失败: {0}")] + Spawn(String), + #[error("agent 进程异常退出 (code={0:?})")] + ProcessExit(Option), + #[error("agent 运行超时 ({0}s)")] + Timeout(u64), + #[error("已取消")] + Cancelled, + #[error("IO 错误: {0}")] + Io(String), +} + +/// 给 GUI 进程补 PATH / HOME:macOS 从 Finder 启动的进程不继承登录 shell 环境, +/// `claude` 常装在 `~/.local/bin`、Homebrew 在 `/opt/homebrew/bin`。 +fn augment_env(cmd: &mut Command) { + let mut path = std::env::var("PATH").unwrap_or_default(); + if let Some(home_os) = std::env::var_os("HOME") { + let home = home_os.to_string_lossy().to_string(); + let extras = [ + format!("{home}/.local/bin"), + "/opt/homebrew/bin".to_string(), + "/usr/local/bin".to_string(), + ]; + for extra in extras { + if !path.split(':').any(|p| p == extra) { + path = if path.is_empty() { + extra + } else { + format!("{extra}:{path}") + }; + } + } + cmd.env("HOME", home); + } + cmd.env("PATH", path); +} + +fn augmented_command(exe: &str) -> Command { + let mut cmd = Command::new(exe); + augment_env(&mut cmd); + cmd +} + +/// `git stash create`:生成一个表示当前工作区的提交对象,**不改动工作区、也不进 stash 列表**。 +/// 返回该快照的 commit SHA,供出问题时 `git stash apply ` 回滚。无改动时返回 `None`。 +pub fn create_git_snapshot(cwd: &Path) -> Option { + let out = std::process::Command::new("git") + .arg("-C") + .arg(cwd) + .args(["stash", "create", "openless-agent-pre-run"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let sha = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if sha.is_empty() { + None + } else { + Some(sha) + } +} + +/// 探测 `claude` 版本(`None` 表示未安装或无法运行)。 +pub async fn detect_claude(exe: &str) -> Option { + let out = augmented_command(exe) + .arg("--version") + .output() + .await + .ok()?; + if !out.status.success() { + return None; + } + detect::parse_claude_version(&String::from_utf8_lossy(&out.stdout)) +} + +/// 列出 Claude Code 已配置的 MCP server(含健康状态)。 +pub async fn claude_mcp_list(exe: &str) -> Vec { + match augmented_command(exe).args(["mcp", "list"]).output().await { + Ok(out) => detect::parse_mcp_list(&String::from_utf8_lossy(&out.stdout)), + Err(_) => Vec::new(), + } +} + +async fn wait_cancel(cancel: &Arc) { + loop { + if cancel.load(Ordering::Relaxed) { + return; + } + tokio::time::sleep(Duration::from_millis(150)).await; + } +} + +/// 无头跑一次 Claude:写 prompt 到 stdin,逐行解析 stream-json,把事件投到 `sink`。 +/// 支持取消(`cancel` 置 true)与超时(`req.timeout_secs`),两者都会 kill 子进程。 +pub async fn run_claude_agent( + exe: &str, + req: CodingAgentRequest, + sink: CodingAgentEventSink, + cancel: Arc, +) -> Result<(), CodingAgentError> { + let args = build_claude_args(&req); + let mut cmd = augmented_command(exe); + cmd.args(&args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + if let Some(cwd) = &req.cwd { + cmd.current_dir(cwd); + } + + let mut child = cmd.spawn().map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + CodingAgentError::ExecutableNotFound(exe.to_string()) + } else { + CodingAgentError::Spawn(e.to_string()) + } + })?; + + // 写入 prompt 后立即关闭 stdin,触发 claude 开始处理。 + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(req.prompt.as_bytes()).await; + let _ = stdin.shutdown().await; + } + + // 后台排空 stderr,避免管道写满导致子进程阻塞;出错时用作摘要。 + let stderr_task = child.stderr.take().map(|s| { + tokio::spawn(async move { + let mut buf = String::new(); + let _ = BufReader::new(s).read_to_string(&mut buf).await; + buf + }) + }); + + let _ = sink.send(CodingAgentEvent::Started { + session_id: req.session_id.clone(), + }); + + let stdout = child + .stdout + .take() + .ok_or_else(|| CodingAgentError::Io("子进程无 stdout".into()))?; + let mut lines = BufReader::new(stdout).lines(); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(req.timeout_secs.max(1)); + let mut got_terminal = false; + let mut outcome: Result<(), CodingAgentError> = Ok(()); + + loop { + tokio::select! { + biased; + _ = wait_cancel(&cancel) => { + let _ = child.start_kill(); + let _ = sink.send(CodingAgentEvent::Cancelled { session_id: req.session_id.clone() }); + got_terminal = true; + outcome = Err(CodingAgentError::Cancelled); + break; + } + _ = tokio::time::sleep_until(deadline) => { + let _ = child.start_kill(); + let _ = sink.send(CodingAgentEvent::Error { + session_id: req.session_id.clone(), + message: format!("运行超时({}s)", req.timeout_secs), + }); + got_terminal = true; + outcome = Err(CodingAgentError::Timeout(req.timeout_secs)); + break; + } + line = lines.next_line() => { + match line { + Ok(Some(l)) => { + if let Some(ev) = parse_stream_json_line(&req.session_id, &l) { + if matches!(ev, CodingAgentEvent::Completed { .. } | CodingAgentEvent::Error { .. }) { + got_terminal = true; + } + let _ = sink.send(ev); + } + } + Ok(None) => break, // EOF + Err(e) => { + outcome = Err(CodingAgentError::Io(e.to_string())); + break; + } + } + } + } + } + + let status = child + .wait() + .await + .map_err(|e| CodingAgentError::Io(e.to_string()))?; + if !status.success() && outcome.is_ok() { + // 进程非 0 退出且我们还没判终局:补一条 Error。 + if !got_terminal { + let stderr = match stderr_task { + Some(t) => t.await.unwrap_or_default(), + None => String::new(), + }; + let summary = stderr.lines().last().unwrap_or("").trim().to_string(); + let _ = sink.send(CodingAgentEvent::Error { + session_id: req.session_id.clone(), + message: if summary.is_empty() { + format!("agent 异常退出 (code={:?})", status.code()) + } else { + summary + }, + }); + } + return Err(CodingAgentError::ProcessExit(status.code())); + } + + outcome +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn autonomous_prompt_wraps_task_with_oneshot_directive() { + let p = autonomous_prompt("把这段话翻译成英文:你好"); + // 原始需求必须原样带上。 + assert!(p.contains("把这段话翻译成英文:你好")); + // 必含「一次性完成 / 单次无头运行 / 不要提问 / 只输出最终结果」这些核心约束。 + assert!(p.contains("一次性完成")); + assert!(p.contains("无头")); + assert!(p.contains("不要中途停下来提问")); + assert!(p.contains("只输出最终结果")); + // 需求要排在自动化说明之后(前置说明在前)。 + let directive_idx = p.find("自动化任务").unwrap(); + let task_idx = p.find("把这段话翻译成英文").unwrap(); + assert!(directive_idx < task_idx, "自动化前置说明必须在需求之前"); + } +} diff --git a/openless-all/app/src-tauri/src/coding_agent/stream.rs b/openless-all/app/src-tauri/src/coding_agent/stream.rs new file mode 100644 index 00000000..d1349eb0 --- /dev/null +++ b/openless-all/app/src-tauri/src/coding_agent/stream.rs @@ -0,0 +1,166 @@ +//! 解析 `claude -p --output-format stream-json` 的逐行 JSON 输出。 +//! +//! 关注的几类行(其余忽略): +//! - `stream_event` → `content_block_delta` → `text_delta`:逐字增量。 +//! - `assistant` 消息里的 `tool_use` 块:工具调用提示。 +//! - `result`:终局,带 `result` 文本、`total_cost_usd`、`duration_ms`、`is_error`。 + +/// 转发给前端的 agent 事件。`kind` 作为 tag,前端按 `kind` 分发。 +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum CodingAgentEvent { + /// 进程已启动。 + Started { session_id: String }, + /// 逐字增量文本。 + Delta { session_id: String, text: String }, + /// agent 触发了某个工具(如 Bash / Edit)。 + ToolUse { session_id: String, name: String }, + /// 运行完成的最终结果。 + Completed { + session_id: String, + text: String, + cost_usd: Option, + duration_ms: Option, + }, + /// 用户取消。 + Cancelled { session_id: String }, + /// 运行出错(超时、进程异常、解析失败等)。 + Error { session_id: String, message: String }, +} + +/// 解析一行 stream-json。无关行返回 `None`(防御式:解析失败也返回 `None`,不 panic)。 +pub fn parse_stream_json_line(session_id: &str, line: &str) -> Option { + let line = line.trim(); + if line.is_empty() { + return None; + } + let v: serde_json::Value = serde_json::from_str(line).ok()?; + match v.get("type")?.as_str()? { + "stream_event" => { + let event = v.get("event")?; + if event.get("type")?.as_str()? != "content_block_delta" { + return None; + } + let delta = event.get("delta")?; + if delta.get("type")?.as_str()? != "text_delta" { + return None; + } + let text = delta.get("text")?.as_str()?.to_string(); + Some(CodingAgentEvent::Delta { + session_id: session_id.to_string(), + text, + }) + } + "assistant" => { + let content = v.get("message")?.get("content")?.as_array()?; + for block in content { + if block.get("type").and_then(|t| t.as_str()) == Some("tool_use") { + if let Some(name) = block.get("name").and_then(|n| n.as_str()) { + return Some(CodingAgentEvent::ToolUse { + session_id: session_id.to_string(), + name: name.to_string(), + }); + } + } + } + None + } + "result" => { + let is_error = v.get("is_error").and_then(|b| b.as_bool()).unwrap_or(false); + let text = v + .get("result") + .and_then(|r| r.as_str()) + .unwrap_or_default() + .to_string(); + if is_error { + Some(CodingAgentEvent::Error { + session_id: session_id.to_string(), + message: if text.is_empty() { + "agent 返回错误".to_string() + } else { + text + }, + }) + } else { + Some(CodingAgentEvent::Completed { + session_id: session_id.to_string(), + text, + cost_usd: v.get("total_cost_usd").and_then(|c| c.as_f64()), + duration_ms: v.get("duration_ms").and_then(|d| d.as_u64()), + }) + } + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_text_delta() { + let line = r#"{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"你好"}}}"#; + assert_eq!( + parse_stream_json_line("s1", line), + Some(CodingAgentEvent::Delta { + session_id: "s1".into(), + text: "你好".into() + }) + ); + } + + #[test] + fn ignores_non_text_deltas() { + let line = r#"{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"input_json_delta","partial_json":"{"}}}"#; + assert_eq!(parse_stream_json_line("s1", line), None); + } + + #[test] + fn parses_tool_use() { + let line = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{}}]}}"#; + assert_eq!( + parse_stream_json_line("s1", line), + Some(CodingAgentEvent::ToolUse { + session_id: "s1".into(), + name: "Bash".into() + }) + ); + } + + #[test] + fn parses_successful_result_with_cost() { + let line = r#"{"type":"result","subtype":"success","is_error":false,"result":"done","total_cost_usd":0.0123,"duration_ms":1500,"session_id":"abc"}"#; + assert_eq!( + parse_stream_json_line("s1", line), + Some(CodingAgentEvent::Completed { + session_id: "s1".into(), + text: "done".into(), + cost_usd: Some(0.0123), + duration_ms: Some(1500), + }) + ); + } + + #[test] + fn parses_error_result() { + let line = r#"{"type":"result","is_error":true,"result":"boom"}"#; + assert_eq!( + parse_stream_json_line("s1", line), + Some(CodingAgentEvent::Error { + session_id: "s1".into(), + message: "boom".into() + }) + ); + } + + #[test] + fn ignores_system_init_and_garbage() { + assert_eq!( + parse_stream_json_line("s1", r#"{"type":"system","subtype":"init"}"#), + None + ); + assert_eq!(parse_stream_json_line("s1", "not json"), None); + assert_eq!(parse_stream_json_line("s1", ""), None); + } +} diff --git a/openless-all/app/src-tauri/src/coding_agent_hotkey.rs b/openless-all/app/src-tauri/src/coding_agent_hotkey.rs new file mode 100644 index 00000000..d28d8635 --- /dev/null +++ b/openless-all/app/src-tauri/src/coding_agent_hotkey.rs @@ -0,0 +1,187 @@ +//! 快速 Agent 的两个全局组合键监听器(面板键 + 快取用键)。 +//! +//! 镜像 `qa_hotkey.rs`,但同时注册两个 binding,事件区分来源 +//! ([`CodingAgentHotkeyEvent::PanelPressed`] / [`QuickPressed`])。 +//! toggle / 生命周期由 coordinator 解释。 + +use std::sync::mpsc::{Receiver, Sender}; +use std::sync::Arc; + +use global_hotkey::{GlobalHotKeyEvent, HotKeyState}; +use parking_lot::Mutex; + +use crate::global_hotkey_runtime::{GlobalHotkeyRuntime, RegisteredHotkey}; +use crate::shortcut_binding::{parse_global_hotkey, ShortcutBindingError}; +use crate::types::ShortcutBinding; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CodingAgentHotkeyEvent { + /// 面板键:语音 Agent(录音→ASR→Claude→面板)。 + PanelPressed, + /// 快取用键:选中文本→Claude→回插光标。 + QuickPressed, +} + +#[derive(Debug, thiserror::Error)] +pub enum CodingAgentHotkeyError { + #[error("不支持的修饰键: {0}")] + UnsupportedModifier(String), + #[error("不支持的主键: {0}")] + UnsupportedKey(String), + #[error("注册全局快捷键失败: {0}")] + RegisterFailed(String), + #[error("初始化全局快捷键管理器失败: {0}")] + ManagerInitFailed(String), +} + +/// 同时持有面板键与快取用键的注册句柄;`Drop` 时全部反注册。 +pub struct CodingAgentHotkeyMonitor { + inner: Arc, +} + +struct Inner { + registered: Mutex>, +} + +// 与 qa_hotkey.rs::Inner 同款理由:global-hotkey 句柄是 OS 进程级资源, +// coordinator 需要把它放进 async 上下文,手动标记 Send/Sync。 +unsafe impl Send for Inner {} +unsafe impl Sync for Inner {} + +impl CodingAgentHotkeyMonitor { + /// 注册 panel / quick 两个组合键(任一为 `None` 即跳过)。每次按下边沿向 `tx` + /// 投递对应事件。需从主线程调用(macOS 的 global-hotkey 要求)。 + pub fn start( + panel: Option, + quick: Option, + tx: Sender, + ) -> Result { + let runtime = GlobalHotkeyRuntime::shared() + .map_err(|e| CodingAgentHotkeyError::ManagerInitFailed(e.to_string()))?; + let mut registered = Vec::new(); + + for (binding, event) in [ + (panel, CodingAgentHotkeyEvent::PanelPressed), + (quick, CodingAgentHotkeyEvent::QuickPressed), + ] { + let Some(binding) = binding else { continue }; + // 单个 binding 无效(如单修饰键 Right ⌘)只跳过它,绝不让一个坏键拖垮 + // 另一个有效键的注册,也避免 supervisor 因为 Err 而陷入无限重试。 + let hotkey = match parse_binding(&binding) { + Ok(h) => h, + Err(e) => { + log::warn!("[coding-agent] 跳过无法解析的快捷键 ({event:?}): {e}"); + continue; + } + }; + let (reg, rx) = match runtime.register(hotkey) { + Ok(v) => v, + Err(e) => { + log::warn!("[coding-agent] 注册快捷键失败 ({event:?}): {e}"); + continue; + } + }; + spawn_forward(reg.hotkey().id(), rx, tx.clone(), event); + registered.push(reg); + } + + Ok(Self { + inner: Arc::new(Inner { + registered: Mutex::new(registered), + }), + }) + } +} + +impl Drop for CodingAgentHotkeyMonitor { + fn drop(&mut self) { + self.inner.registered.lock().clear(); + } +} + +fn spawn_forward( + id: u32, + rx: Receiver, + tx: Sender, + event: CodingAgentHotkeyEvent, +) { + let _ = std::thread::Builder::new() + .name("openless-coding-agent-hotkey-forward".into()) + .spawn(move || forward_loop(id, rx, tx, event)); +} + +fn forward_loop( + hotkey_id: u32, + rx: Receiver, + tx: Sender, + event: CodingAgentHotkeyEvent, +) { + while let Ok(global_event) = rx.recv() { + if global_event.id() != hotkey_id { + continue; + } + if !matches!(global_event.state(), HotKeyState::Pressed) { + continue; + } + if tx.send(event).is_err() { + break; + } + } +} + +fn parse_binding( + binding: &ShortcutBinding, +) -> Result { + parse_global_hotkey(binding).map_err(|e| match e { + ShortcutBindingError::UnsupportedModifier(m) => { + CodingAgentHotkeyError::UnsupportedModifier(m) + } + ShortcutBindingError::UnsupportedKey(k) => CodingAgentHotkeyError::UnsupportedKey(k), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use global_hotkey::hotkey::Code; + + #[test] + fn parses_default_panel_binding() { + let binding = ShortcutBinding { + primary: "Enter".into(), + modifiers: vec!["cmd".into(), "shift".into()], + }; + let parsed = parse_binding(&binding).expect("Enter binding parses"); + assert_eq!(parsed.key, Code::Enter); + } + + #[test] + fn parses_default_quick_binding() { + let binding = ShortcutBinding { + primary: "J".into(), + modifiers: vec!["cmd".into(), "shift".into()], + }; + let parsed = parse_binding(&binding).expect("J binding parses"); + assert_eq!(parsed.key, Code::KeyJ); + } + + #[test] + fn forward_loop_filters_other_ids_and_releases() { + let (event_tx, event_rx) = std::sync::mpsc::channel(); + let (out_tx, out_rx) = std::sync::mpsc::channel(); + event_tx + .send(GlobalHotKeyEvent { id: 1, state: HotKeyState::Pressed }) + .unwrap(); + event_tx + .send(GlobalHotKeyEvent { id: 7, state: HotKeyState::Released }) + .unwrap(); + event_tx + .send(GlobalHotKeyEvent { id: 7, state: HotKeyState::Pressed }) + .unwrap(); + drop(event_tx); + forward_loop(7, event_rx, out_tx, CodingAgentHotkeyEvent::QuickPressed); + assert_eq!(out_rx.recv().unwrap(), CodingAgentHotkeyEvent::QuickPressed); + assert!(out_rx.try_recv().is_err()); + } + +} diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 79a8f5b4..037bc105 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -86,6 +86,7 @@ trait SettingsWriter { fn refresh_translation_hotkey(&self); fn refresh_switch_style_hotkey(&self); fn refresh_open_app_hotkey(&self); + fn refresh_coding_agent_hotkey(&self); } impl SettingsWriter for Coordinator { @@ -124,6 +125,10 @@ impl SettingsWriter for Coordinator { fn refresh_open_app_hotkey(&self) { self.update_open_app_hotkey_binding(); } + + fn refresh_coding_agent_hotkey(&self) { + self.update_coding_agent_hotkey_binding(); + } } impl SettingsWriter for Arc { @@ -162,6 +167,10 @@ impl SettingsWriter for Arc { fn refresh_open_app_hotkey(&self) { (**self).refresh_open_app_hotkey(); } + + fn refresh_coding_agent_hotkey(&self) { + (**self).refresh_coding_agent_hotkey(); + } } fn persist_settings( @@ -178,6 +187,8 @@ fn persist_settings( let translation_changed = previous.translation_hotkey != prefs.translation_hotkey; let switch_style_changed = previous.switch_style_hotkey != prefs.switch_style_hotkey; let open_app_changed = previous.open_app_hotkey != prefs.open_app_hotkey; + let coding_agent_changed = previous.coding_agent_enabled != prefs.coding_agent_enabled + || previous.coding_agent_voice_hotkey != prefs.coding_agent_voice_hotkey; let active_asr_provider_changed = previous.active_asr_provider != prefs.active_asr_provider; let active_asr_provider = prefs.active_asr_provider.clone(); if active_asr_provider_changed { @@ -218,6 +229,9 @@ fn persist_settings( if open_app_changed { coord.refresh_open_app_hotkey(); } + if coding_agent_changed { + coord.refresh_coding_agent_hotkey(); + } Ok(()) } @@ -536,10 +550,8 @@ async fn resolve_beta_manifest_endpoints() -> Result, String> { let direct = format!( "https://github.com/appergb/openless/releases/download/{tag}/latest-{{{{target}}}}-{{{{arch}}}}-beta.json" ); - let mirror_url = - url::Url::parse(&mirror).map_err(|e| format!("parse beta mirror url: {e}"))?; - let direct_url = - url::Url::parse(&direct).map_err(|e| format!("parse beta direct url: {e}"))?; + let mirror_url = url::Url::parse(&mirror).map_err(|e| format!("parse beta mirror url: {e}"))?; + let direct_url = url::Url::parse(&direct).map_err(|e| format!("parse beta direct url: {e}"))?; Ok(vec![mirror_url, direct_url]) } @@ -1415,7 +1427,8 @@ fn is_valid_local_pack_id(s: &str) -> bool { if s.is_empty() || s.len() > 128 { return false; } - s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-' || b == b'_') + s.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-' || b == b'_') } // ─────────────────────────── vocab ─────────────────────────── @@ -1909,6 +1922,24 @@ pub fn qa_window_pin(coord: CoordinatorState<'_>, pinned: bool) { coord.qa_window_pin(pinned); } +/// 用户点 ✕ / 按 Esc 关 Less Computer 浮窗。 +#[tauri::command] +pub fn less_computer_window_dismiss(coord: CoordinatorState<'_>) { + coord.less_computer_window_dismiss(); +} + +/// 前端按内容测高后回传高度,后端 clamp + bottom-anchored 重新摆放浮窗。 +#[tauri::command] +pub fn less_computer_window_resize(coord: CoordinatorState<'_>, height: f64) { + coord.less_computer_window_resize(height); +} + +/// 内联审批卡的 Approve / Deny 回执。token 关联到等待中的拦截动作。 +#[tauri::command] +pub fn less_computer_approve(coord: CoordinatorState<'_>, token: String, approved: bool) { + coord.less_computer_approve(&token, approved); +} + // ─────────────────────────── 自定义组合键 ─────────────────────────── /// 测试一个组合键是否可以注册(验证格式,不实际注册)。 @@ -2127,9 +2158,13 @@ fn reject_hotkey_collisions(prefs: &UserPreferences) -> Result<(), String> { // 停用(None)的 action 快捷键不参与任何冲突检测。 let switch_style = prefs.switch_style_hotkey.as_ref(); let open_app = prefs.open_app_hotkey.as_ref(); + let less_computer = prefs.coding_agent_voice_hotkey.as_ref(); if let Some(qa_hotkey) = prefs.qa_hotkey.as_ref() { reject_dictation_qa_hotkey_overlap(&prefs.dictation_hotkey, qa_hotkey)?; reject_qa_translation_hotkey_overlap(qa_hotkey, &prefs.translation_hotkey)?; + if let Some(less_computer) = less_computer { + reject_qa_less_computer_hotkey_overlap(qa_hotkey, less_computer)?; + } if let Some(switch_style) = switch_style { reject_qa_switch_style_hotkey_overlap(qa_hotkey, switch_style)?; } @@ -2141,13 +2176,23 @@ fn reject_hotkey_collisions(prefs: &UserPreferences) -> Result<(), String> { &prefs.dictation_hotkey, &prefs.translation_hotkey, )?; + if let Some(less_computer) = less_computer { + reject_dictation_less_computer_hotkey_overlap(&prefs.dictation_hotkey, less_computer)?; + reject_translation_less_computer_hotkey_overlap(&prefs.translation_hotkey, less_computer)?; + } if let Some(switch_style) = switch_style { reject_dictation_switch_style_hotkey_overlap(&prefs.dictation_hotkey, switch_style)?; reject_translation_switch_style_hotkey_overlap(&prefs.translation_hotkey, switch_style)?; + if let Some(less_computer) = less_computer { + reject_less_computer_switch_style_hotkey_overlap(less_computer, switch_style)?; + } } if let Some(open_app) = open_app { reject_dictation_open_app_hotkey_overlap(&prefs.dictation_hotkey, open_app)?; reject_translation_open_app_hotkey_overlap(&prefs.translation_hotkey, open_app)?; + if let Some(less_computer) = less_computer { + reject_less_computer_open_app_hotkey_overlap(less_computer, open_app)?; + } } if let (Some(switch_style), Some(open_app)) = (switch_style, open_app) { reject_switch_style_open_app_hotkey_overlap(switch_style, open_app)?; @@ -2180,6 +2225,17 @@ fn reject_dictation_open_app_hotkey_overlap( reject_hotkey_overlap(dictation, open_app, "打开应用快捷键不能和听写快捷键相同") } +fn reject_dictation_less_computer_hotkey_overlap( + dictation: &ShortcutBinding, + less_computer: &ShortcutBinding, +) -> Result<(), String> { + reject_hotkey_overlap( + dictation, + less_computer, + "Less Computer 快捷键不能和听写快捷键相同", + ) +} + fn reject_qa_translation_hotkey_overlap( qa: &ShortcutBinding, translation: &ShortcutBinding, @@ -2201,6 +2257,17 @@ fn reject_qa_open_app_hotkey_overlap( reject_hotkey_overlap(qa, open_app, "打开应用快捷键不能和 QA 快捷键相同") } +fn reject_qa_less_computer_hotkey_overlap( + qa: &ShortcutBinding, + less_computer: &ShortcutBinding, +) -> Result<(), String> { + reject_hotkey_overlap( + qa, + less_computer, + "Less Computer 快捷键不能和 QA 快捷键相同", + ) +} + fn reject_translation_switch_style_hotkey_overlap( translation: &ShortcutBinding, switch_style: &ShortcutBinding, @@ -2219,6 +2286,17 @@ fn reject_translation_open_app_hotkey_overlap( reject_hotkey_overlap(translation, open_app, "打开应用快捷键不能和翻译快捷键相同") } +fn reject_translation_less_computer_hotkey_overlap( + translation: &ShortcutBinding, + less_computer: &ShortcutBinding, +) -> Result<(), String> { + reject_hotkey_overlap( + translation, + less_computer, + "Less Computer 快捷键不能和翻译快捷键相同", + ) +} + fn reject_switch_style_open_app_hotkey_overlap( switch_style: &ShortcutBinding, open_app: &ShortcutBinding, @@ -2230,6 +2308,28 @@ fn reject_switch_style_open_app_hotkey_overlap( ) } +fn reject_less_computer_switch_style_hotkey_overlap( + less_computer: &ShortcutBinding, + switch_style: &ShortcutBinding, +) -> Result<(), String> { + reject_hotkey_overlap( + less_computer, + switch_style, + "Less Computer 快捷键不能和切换风格快捷键相同", + ) +} + +fn reject_less_computer_open_app_hotkey_overlap( + less_computer: &ShortcutBinding, + open_app: &ShortcutBinding, +) -> Result<(), String> { + reject_hotkey_overlap( + less_computer, + open_app, + "Less Computer 快捷键不能和打开应用快捷键相同", + ) +} + fn shortcut_bindings_overlap(left: &ShortcutBinding, right: &ShortcutBinding) -> bool { let left_legacy = crate::shortcut_binding::legacy_modifier_trigger(left); let right_legacy = crate::shortcut_binding::legacy_modifier_trigger(right); @@ -3115,8 +3215,10 @@ pub async fn marketplace_list( let body = resp.text().await.unwrap_or_default(); return Err(format!("marketplace HTTP {status}: {body}")); } - let items: Vec = - resp.json().await.map_err(|e| format!("parse failed: {e}"))?; + let items: Vec = resp + .json() + .await + .map_err(|e| format!("parse failed: {e}"))?; Ok(items) } @@ -3443,11 +3545,13 @@ fn get_github_oauth_client_id() -> Result { if !GITHUB_OAUTH_CLIENT_ID.is_empty() { return Ok(GITHUB_OAUTH_CLIENT_ID.to_string()); } - Err("GitHub OAuth 未配置。请去 https://github.com/settings/applications/new 注册一个 OAuth App\ + Err( + "GitHub OAuth 未配置。请去 https://github.com/settings/applications/new 注册一个 OAuth App\ (必须勾 Enable Device Flow),把 client_id 填到 \ openless-all/app/src-tauri/src/commands.rs 的 GITHUB_OAUTH_CLIENT_ID 常量,\ 或在启动前设置环境变量 GITHUB_OAUTH_CLIENT_ID=。" - .to_string()) + .to_string(), + ) } #[derive(Debug, serde::Serialize)] @@ -3605,6 +3709,7 @@ mod tests { translation_refreshes: Mutex, switch_style_refreshes: Mutex, open_app_refreshes: Mutex, + coding_agent_refreshes: Mutex, } fn snapshot() -> CredentialsSnapshot { @@ -3941,12 +4046,7 @@ mod tests { } { return Err(error); } - if let Some(error) = self - .active_asr_provider_sync_error - .lock() - .unwrap() - .clone() - { + if let Some(error) = self.active_asr_provider_sync_error.lock().unwrap().clone() { return Err(error); } Ok(()) @@ -3975,6 +4075,10 @@ mod tests { fn refresh_open_app_hotkey(&self) { *self.open_app_refreshes.lock().unwrap() += 1; } + + fn refresh_coding_agent_hotkey(&self) { + *self.coding_agent_refreshes.lock().unwrap() += 1; + } } #[test] @@ -4112,6 +4216,10 @@ mod tests { primary: "O".to_string(), modifiers: vec!["ctrl".to_string(), "alt".to_string()], }), + coding_agent_voice_hotkey: Some(ShortcutBinding { + primary: "RightControl".to_string(), + modifiers: vec![], + }), hotkey: HotkeyBinding { trigger: HotkeyTrigger::Custom, mode: HotkeyMode::Hold, @@ -4141,6 +4249,27 @@ mod tests { assert_eq!(*writer.translation_refreshes.lock().unwrap(), 1); assert_eq!(*writer.switch_style_refreshes.lock().unwrap(), 1); assert_eq!(*writer.open_app_refreshes.lock().unwrap(), 1); + assert_eq!(*writer.coding_agent_refreshes.lock().unwrap(), 1); + } + + #[test] + fn persist_settings_rejects_less_computer_dictation_overlap() { + let writer = FakeSettingsWriter::default(); + let binding = ShortcutBinding { + primary: "LeftControl".into(), + modifiers: vec![], + }; + let prefs = UserPreferences { + dictation_hotkey: binding.clone(), + coding_agent_voice_hotkey: Some(binding), + ..Default::default() + }; + + assert_eq!( + persist_settings(&writer, prefs), + Err("Less Computer 快捷键不能和听写快捷键相同".into()) + ); + assert!(writer.saved.lock().unwrap().is_none()); } #[test] @@ -4180,6 +4309,7 @@ mod tests { assert_eq!(*writer.translation_refreshes.lock().unwrap(), 0); assert_eq!(*writer.switch_style_refreshes.lock().unwrap(), 0); assert_eq!(*writer.open_app_refreshes.lock().unwrap(), 0); + assert_eq!(*writer.coding_agent_refreshes.lock().unwrap(), 0); } #[test] @@ -4210,7 +4340,10 @@ mod tests { .clone() .expect("previous settings remain saved"); assert_eq!(saved.active_asr_provider, previous.active_asr_provider); - assert_eq!(saved.microphone_device_name, previous.microphone_device_name); + assert_eq!( + saved.microphone_device_name, + previous.microphone_device_name + ); assert_eq!( writer.active_asr_provider_syncs.lock().unwrap().clone(), vec!["whisper".to_string()] @@ -4221,6 +4354,7 @@ mod tests { assert_eq!(*writer.translation_refreshes.lock().unwrap(), 0); assert_eq!(*writer.switch_style_refreshes.lock().unwrap(), 0); assert_eq!(*writer.open_app_refreshes.lock().unwrap(), 0); + assert_eq!(*writer.coding_agent_refreshes.lock().unwrap(), 0); } #[test] @@ -4251,13 +4385,13 @@ mod tests { .clone() .expect("previous settings remain saved"); assert_eq!(saved.active_asr_provider, previous.active_asr_provider); - assert_eq!(saved.microphone_device_name, previous.microphone_device_name); + assert_eq!( + saved.microphone_device_name, + previous.microphone_device_name + ); assert_eq!( writer.active_asr_provider_syncs.lock().unwrap().clone(), - vec![ - "whisper".to_string(), - previous.active_asr_provider.clone() - ] + vec!["whisper".to_string(), previous.active_asr_provider.clone()] ); assert_eq!(*writer.dictation_refreshes.lock().unwrap(), 0); assert_eq!(*writer.combo_refreshes.lock().unwrap(), 0); @@ -4265,6 +4399,7 @@ mod tests { assert_eq!(*writer.translation_refreshes.lock().unwrap(), 0); assert_eq!(*writer.switch_style_refreshes.lock().unwrap(), 0); assert_eq!(*writer.open_app_refreshes.lock().unwrap(), 0); + assert_eq!(*writer.coding_agent_refreshes.lock().unwrap(), 0); } #[test] @@ -4272,8 +4407,7 @@ mod tests { let writer = FakeSettingsWriter::default(); let previous = UserPreferences::default(); *writer.saved.lock().unwrap() = Some(previous.clone()); - *writer.write_settings_errors.lock().unwrap() = - vec![Some("save failed".to_string()), None]; + *writer.write_settings_errors.lock().unwrap() = vec![Some("save failed".to_string()), None]; *writer.active_asr_provider_sync_errors.lock().unwrap() = vec![None, Some("rollback failed".to_string())]; let prefs = UserPreferences { @@ -4308,6 +4442,7 @@ mod tests { assert_eq!(*writer.translation_refreshes.lock().unwrap(), 0); assert_eq!(*writer.switch_style_refreshes.lock().unwrap(), 0); assert_eq!(*writer.open_app_refreshes.lock().unwrap(), 0); + assert_eq!(*writer.coding_agent_refreshes.lock().unwrap(), 0); } #[test] @@ -4656,13 +4791,17 @@ mod tests { // 长度对但含 `/`:dash 位置错或非 hex 字符都不通过 assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-44665544/000")); assert!(!is_valid_session_id("550e8400_e29b_41d4_a716_446655440000")); // 用 _ 代 - - // 非 hex 字符 + // 非 hex 字符 assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-44665544000g")); // 长度不对(35 / 37) assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-44665544000")); - assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-4466554400000")); + assert!(!is_valid_session_id( + "550e8400-e29b-41d4-a716-4466554400000" + )); // NUL 字节 - assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-44665544\x00000")); + assert!(!is_valid_session_id( + "550e8400-e29b-41d4-a716-44665544\x00000" + )); // 百分号编码与绝对路径 assert!(!is_valid_session_id("%2e%2e/recordings/x")); assert!(!is_valid_session_id("/Users/attacker/secret.wav")); @@ -4673,7 +4812,9 @@ mod tests { assert!(is_valid_local_pack_id("builtin.light")); assert!(is_valid_local_pack_id("builtin.structured")); assert!(is_valid_local_pack_id("custom.meeting")); - assert!(is_valid_local_pack_id("550e8400-e29b-41d4-a716-446655440000")); + assert!(is_valid_local_pack_id( + "550e8400-e29b-41d4-a716-446655440000" + )); assert!(is_valid_local_pack_id("my_pack_v2")); assert!(is_valid_local_pack_id("Pack-2026.05")); } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index f8c58e6c..48dfffcc 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1,4 +1,7 @@ -#![cfg_attr(target_os = "linux", allow(dead_code, unused_imports, unused_variables))] +#![cfg_attr( + target_os = "linux", + allow(dead_code, unused_imports, unused_variables) +)] //! Dictation coordinator. //! //! Mirrors the Swift `DictationCoordinator` state machine. Single owner of @@ -65,9 +68,11 @@ mod resources; #[cfg(test)] use dictation::dictation_error_code; use dictation::{ - begin_session, cancel_session, end_session, handle_pressed, handle_pressed_edge, - handle_released, handle_released_edge, request_stop_during_starting, + begin_session, cancel_session, end_session, handle_pressed_edge, handle_released_edge, + request_stop_during_starting, }; +#[cfg(any(debug_assertions, test))] +use dictation::{handle_pressed, handle_released}; use qa::{close_qa_panel, handle_qa_hotkey_pressed, QaPhase, QaSessionState}; #[cfg(test)] use resources::discard_startup_resources_for_session; @@ -255,6 +260,12 @@ struct Inner { /// 划词语音问答(issue #118):与 dictation hotkey 平行的全局快捷键 /// 监听器(global-hotkey crate)。`None` 表示功能关闭或还没成功安装。 qa_hotkey: Mutex>, + coding_agent_modifier_hotkey: Mutex>, + coding_agent_combo_hotkey: Mutex>, + /// 最近一次 emit_capsule 下发的 state,纯内省/测试用途(在 app 句柄校验之前写入, + /// 因此无 GUI 的测试环境也能断言「按下热键 → 弹了哪种胶囊」)。写入是单次廉价 + /// 加锁,对 ~30Hz 录音回调可忽略。 + last_capsule_state: Mutex>, /// QA 单独的 session 状态,与 dictation 的 SessionPhase 不冲突。 qa_state: Mutex, /// 最近一次应用到 capsule 窗口的几何状态。避免录音 level tick 反复触发 @@ -273,6 +284,9 @@ struct Inner { /// supervisor 线程,但 integration test 和未来 RunEvent::Exit 钩子需要这条 /// 显式退出路径。审计 3.1.2。 shutdown: AtomicBool, + /// Less Computer 连续对话:true=浮窗里已有进行中的会话,下一轮 `claude --continue` 续上下文; + /// 关闭浮窗(dismiss)复位为 false,下次说话开新会话。 + less_computer_conversation: AtomicBool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -335,6 +349,9 @@ impl Coordinator { open_app_hotkey: Mutex::new(None), translation_modifier_seen: AtomicBool::new(false), qa_hotkey: Mutex::new(None), + coding_agent_modifier_hotkey: Mutex::new(None), + coding_agent_combo_hotkey: Mutex::new(None), + last_capsule_state: Mutex::new(None), qa_state: Mutex::new(QaSessionState::default()), capsule_layout: Mutex::new(None), qa_asr: Mutex::new(None), @@ -342,6 +359,7 @@ impl Coordinator { qa_stream_cancelled: Arc::new(AtomicBool::new(false)), local_asr_cache: Arc::new(crate::asr::local::LocalAsrCache::new()), shutdown: AtomicBool::new(false), + less_computer_conversation: AtomicBool::new(false), }), } } @@ -397,6 +415,9 @@ impl Coordinator { open_app_hotkey: Mutex::new(None), translation_modifier_seen: AtomicBool::new(false), qa_hotkey: Mutex::new(None), + coding_agent_modifier_hotkey: Mutex::new(None), + coding_agent_combo_hotkey: Mutex::new(None), + last_capsule_state: Mutex::new(None), qa_state: Mutex::new(QaSessionState::default()), capsule_layout: Mutex::new(None), qa_asr: Mutex::new(None), @@ -406,6 +427,7 @@ impl Coordinator { foundry_local_runtime, sherpa_onnx_runtime, shutdown: AtomicBool::new(false), + less_computer_conversation: AtomicBool::new(false), }), } } @@ -497,6 +519,24 @@ impl Coordinator { .ok(); } + /// 启动「快速 Agent」双热键 supervisor。与 QA hotkey 平行;功能默认关闭, + /// 仅在 `coding_agent_enabled` 时注册。 + pub fn start_coding_agent_hotkey_listener(&self) { + let inner = Arc::clone(&self.inner); + std::thread::Builder::new() + .name("openless-coding-agent-hotkey-supervisor".into()) + .spawn(move || coding_agent_hotkey_supervisor_loop(inner)) + .ok(); + } + + pub fn stop_coding_agent_hotkey_listener(&self) { + take_coding_agent_hotkeys_on_main_thread(&self.inner); + } + + pub fn update_coding_agent_hotkey_binding(&self) { + update_coding_agent_hotkey_binding_now(&self.inner); + } + pub fn stop_qa_hotkey_listener(&self) { // QaHotkeyMonitor::drop 在 macOS 底层是 Carbon RemoveEventHotKey,要求主线程。 // RunEvent::Exit 回调不保证在 AppKit 主线程跑,drop 漏到 tokio worker 上会 @@ -796,6 +836,30 @@ impl Coordinator { log::info!("[coord] QA window pinned={pinned}"); } + /// 用户点 ✕ / 按 Esc 关 Less Computer 浮窗:隐藏窗口 + 结束连续对话 + /// (下次说话开新会话,不再 --continue 续旧上下文)。 + pub fn less_computer_window_dismiss(&self) { + self.inner + .less_computer_conversation + .store(false, Ordering::SeqCst); + if let Some(app) = self.inner.app.lock().clone() { + crate::hide_less_computer_window(&app); + crate::hide_less_computer_glow(&app); + } + } + + /// 前端按内容测高后回传,后端 clamp + bottom-anchored 重新摆放 Less Computer 浮窗。 + pub fn less_computer_window_resize(&self, height: f64) { + if let Some(app) = self.inner.app.lock().clone() { + crate::resize_less_computer_window(&app, height); + } + } + + /// 内联审批卡的 Approve / Deny 回执:解析等待中的 token。 + pub fn less_computer_approve(&self, token: &str, approved: bool) { + dictation::resolve_less_computer_approval(token, approved); + } + pub fn history(&self) -> &HistoryStore { &self.inner.history } @@ -1311,6 +1375,341 @@ fn qa_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { // ─────────────────────────── combo hotkey supervisor ─────────────────────────── +// ─────────────────────── coding agent hotkey supervisor ─────────────────────── + +fn coding_agent_hotkey_supervisor_loop(inner: Arc) { + loop { + if inner.shutdown.load(Ordering::SeqCst) { + return; + } + update_coding_agent_hotkey_binding_now(&inner); + std::thread::sleep(std::time::Duration::from_secs(5)); + } +} + +fn update_coding_agent_hotkey_binding_now(inner: &Arc) { + #[cfg(not(target_os = "macos"))] + { + // Less Computer is intentionally macOS-only for now; keep Windows/Linux hidden and inert. + take_coding_agent_hotkeys_on_main_thread(inner); + return; + } + + #[cfg(target_os = "macos")] + { + let prefs = inner.prefs.get(); + let Some(binding) = prefs.coding_agent_voice_hotkey.clone() else { + take_coding_agent_hotkeys_on_main_thread(inner); + log::info!("[less-computer] hotkey disabled"); + return; + }; + if !prefs.coding_agent_enabled || is_unconfigured_shortcut(&binding) { + take_coding_agent_hotkeys_on_main_thread(inner); + return; + } + + if let Some(modifier_binding) = less_computer_modifier_binding(&binding) { + take_coding_agent_combo_hotkey_on_main_thread(inner); + if let Some(monitor) = inner.coding_agent_modifier_hotkey.lock().as_ref() { + monitor.update_binding(modifier_binding); + return; + } + let (tx, rx) = mpsc::channel::(); + match HotkeyMonitor::start(modifier_binding, tx) { + Ok(monitor) => { + *inner.coding_agent_modifier_hotkey.lock() = Some(monitor); + log::info!( + "[less-computer] modifier hotkey installed ({})", + binding.display_label() + ); + let bridge_inner = Arc::clone(inner); + std::thread::Builder::new() + .name("openless-less-computer-modifier-bridge".into()) + .spawn(move || less_computer_modifier_bridge_loop(bridge_inner, rx)) + .ok(); + } + Err(e) => log::warn!("[less-computer] modifier hotkey install failed: {e}"), + } + return; + } + + inner.coding_agent_modifier_hotkey.lock().take(); + let app = match inner.app.lock().clone() { + Some(app) => app, + None => { + log::warn!("[less-computer] AppHandle 未 bind,跳过组合键注册"); + return; + } + }; + let inner_clone = Arc::clone(inner); + let binding_for_main = binding.clone(); + let _ = app.run_on_main_thread(move || { + if let Some(monitor) = inner_clone.coding_agent_combo_hotkey.lock().as_ref() { + if let Err(e) = monitor.update_binding(binding_for_main.clone()) { + log::warn!("[less-computer] combo hotkey update failed: {e}"); + } + return; + } + let (tx, rx) = mpsc::channel::(); + match ComboHotkeyMonitor::start(binding_for_main.clone(), tx) { + Ok(monitor) => { + *inner_clone.coding_agent_combo_hotkey.lock() = Some(monitor); + log::info!( + "[less-computer] combo hotkey installed ({})", + binding_for_main.display_label() + ); + let bridge_inner = Arc::clone(&inner_clone); + std::thread::Builder::new() + .name("openless-less-computer-combo-bridge".into()) + .spawn(move || less_computer_combo_bridge_loop(bridge_inner, rx)) + .ok(); + } + Err(e) => log::warn!("[less-computer] combo hotkey install failed: {e}"), + } + }); + } +} + +#[cfg(target_os = "macos")] +fn less_computer_modifier_binding( + binding: &crate::types::ShortcutBinding, +) -> Option { + let trigger = crate::shortcut_binding::legacy_modifier_trigger(binding)?; + Some(crate::types::HotkeyBinding { + trigger, + mode: crate::types::HotkeyMode::Hold, + keys: None, + }) +} + +fn less_computer_modifier_bridge_loop(inner: Arc, rx: mpsc::Receiver) { + while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } + let inner_cloned = Arc::clone(&inner); + match evt { + HotkeyEvent::Pressed => { + async_runtime::block_on(async { + handle_less_computer_pressed(&inner_cloned).await + }); + } + HotkeyEvent::Released => { + async_runtime::block_on(async { + handle_less_computer_released(&inner_cloned).await + }); + } + HotkeyEvent::Cancelled => cancel_session(&inner_cloned), + HotkeyEvent::TranslationModifierPressed | HotkeyEvent::QaShortcutPressed => {} + } + } +} + +fn less_computer_combo_bridge_loop(inner: Arc, rx: mpsc::Receiver) { + while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } + let inner_cloned = Arc::clone(&inner); + match evt { + ComboHotkeyEvent::Pressed => { + async_runtime::block_on(async { + handle_less_computer_pressed(&inner_cloned).await + }); + } + ComboHotkeyEvent::Released => { + async_runtime::block_on(async { + handle_less_computer_released(&inner_cloned).await + }); + } + } + } +} + +async fn handle_less_computer_pressed(inner: &Arc) { + let prefs = inner.prefs.get(); + if !prefs.coding_agent_enabled { + return; + } + if !matches!(inner.state.lock().phase, SessionPhase::Idle) { + log::info!("[less-computer] press ignored: dictation session already active"); + return; + } + if !matches!(inner.qa_state.lock().phase, QaPhase::Idle) { + log::info!("[less-computer] press ignored: QA session active"); + return; + } + + if begin_session(inner).await.is_err() { + return; + } + let started = { + let mut state = inner.state.lock(); + if matches!( + state.phase, + SessionPhase::Starting | SessionPhase::Listening + ) { + state.voice_agent = true; + log::info!( + "[less-computer] voice session started (session={:?})", + state.session_id + ); + true + } else { + false + } + }; + // 一按下键(开始录音)就点亮整屏彩虹描边,贯穿 录音 → 处理 → 出结果,完成/关闭才熄灭。 + if started { + if let Some(app) = inner.app.lock().clone() { + crate::show_less_computer_glow(&app); + } + } +} + +async fn handle_less_computer_released(inner: &Arc) { + let (phase, voice_agent) = { + let state = inner.state.lock(); + (state.phase, state.voice_agent) + }; + if !voice_agent { + return; + } + match phase { + SessionPhase::Listening => { + let _ = end_session(inner).await; + // 收尾后熄灭整屏描边。正常路径 run_voice_agent_transcript 已熄过、这里兜底; + // 空转写/出错路径不进 run_voice_agent_transcript,全靠这里熄,否则描边卡住不灭。 + if let Some(app) = inner.app.lock().clone() { + crate::hide_less_computer_glow(&app); + } + } + SessionPhase::Starting => { + // 握手中松手:排队;真正收尾在 begin 续流的 end_session → run_voice_agent_transcript 熄灭。 + request_stop_during_starting(inner, "less-computer release edge"); + } + _ => {} + } +} + +fn take_coding_agent_hotkeys_on_main_thread(inner: &Arc) { + inner.coding_agent_modifier_hotkey.lock().take(); + take_coding_agent_combo_hotkey_on_main_thread(inner); +} + +fn take_coding_agent_combo_hotkey_on_main_thread(inner: &Arc) { + let app = inner.app.lock().clone(); + if let Some(app) = app { + let inner = Arc::clone(inner); + let _ = app.run_on_main_thread(move || { + inner.coding_agent_combo_hotkey.lock().take(); + }); + } else { + inner.coding_agent_combo_hotkey.lock().take(); + } +} + +/// 快取用:抓当前选中文本 → Claude 润色 → 回插(替换选区)。全程胶囊反馈。 +async fn handle_coding_agent_quick(inner: &Arc) { + let prefs = inner.prefs.get(); + if !prefs.coding_agent_enabled { + return; + } + let selection = tauri::async_runtime::spawn_blocking(crate::selection::capture_selection) + .await + .ok() + .flatten(); + let source_text = match selection { + Some(ctx) => ctx.text, + None => { + log::info!("[coding-agent] 快取用:没有选中文本"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + 0, + Some("请先选中文本,再按快捷键".to_string()), + None, + ); + return; + } + }; + + log::info!( + "[coding-agent] 快取用:润色 {} 字", + source_text.chars().count() + ); + emit_capsule( + inner, + CapsuleState::Polishing, + 0.0, + 0, + Some("Claude 润色中…".to_string()), + None, + ); + + let prompt = format!( + "请润色下面这段文字,使其更通顺自然、表达更清晰,保持原意、语言和事实不变。\ + 直接输出润色后的文本,不要加任何解释、前缀或引号:\n\n{source_text}" + ); + + // 纯文本润色:不需要任何工具 → plan 只读、无 guard、便宜快、最可靠。 + let mut req = crate::coding_agent::CodingAgentRequest::new("quick-polish", prompt); + req.model = prefs + .coding_agent_model + .clone() + .filter(|m| !m.trim().is_empty()) + .or_else(|| Some("sonnet".to_string())); + req.permission_mode = crate::coding_agent::CodingAgentPermissionMode::Plan; + req.allowed_tools = Vec::new(); + req.max_budget_usd = Some(0.2); + req.timeout_secs = 60; + req.session_persistence = false; + + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let cancel = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let run = async_runtime::spawn(async move { + crate::coding_agent::run_claude_agent("claude", req, tx, cancel).await + }); + + let mut final_text = String::new(); + let mut error_msg: Option = None; + while let Some(ev) = rx.recv().await { + match ev { + crate::coding_agent::CodingAgentEvent::Completed { text, .. } => final_text = text, + crate::coding_agent::CodingAgentEvent::Error { message, .. } => { + error_msg = Some(message) + } + _ => {} + } + } + let run_result = run.await; + + let final_text = final_text.trim().to_string(); + if final_text.is_empty() { + let msg = error_msg + .or_else(|| match run_result { + Ok(Err(e)) => Some(e.to_string()), + _ => None, + }) + .unwrap_or_else(|| "Claude 无结果(确认已登录 claude 且额度充足)".to_string()); + log::warn!("[coding-agent] 快取用失败: {msg}"); + emit_capsule(inner, CapsuleState::Error, 0.0, 0, Some(msg), None); + return; + } + + let inserted = final_text.chars().count() as u32; + let inner2 = Arc::clone(inner); + let restore = prefs.restore_clipboard_after_paste; + let paste_shortcut = prefs.paste_shortcut; + let _ = tauri::async_runtime::spawn_blocking(move || { + inner2.inserter.insert(&final_text, restore, paste_shortcut) + }) + .await; + log::info!("[coding-agent] 快取用:已回插 {inserted} 字"); + emit_capsule(inner, CapsuleState::Done, 0.0, 0, None, Some(inserted)); +} + fn combo_hotkey_supervisor_loop(inner: Arc) { let mut attempts: u32 = 0; loop { @@ -2507,7 +2906,10 @@ async fn build_local_qwen3( /// (messages=[{content:[{audio:...}]}]) 协议,不是 Whisper multipart,需要 /// 单独 ASR 客户端,留给 V2。 fn is_whisper_compatible_provider(id: &str) -> bool { - matches!(id, "whisper" | "siliconflow" | "zhipu" | "groq" | "openrouter") + matches!( + id, + "whisper" | "siliconflow" | "zhipu" | "groq" | "openrouter" + ) } /// 该 provider 的请求体编码方式。OpenRouter 的 `/audio/transcriptions` 是 @@ -3973,6 +4375,33 @@ mod tests { std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); } + /// 复现并验证目标 2(a):按下 Less Computer 键必须弹出可见胶囊。 + /// 这里直接驱动 bridge 会调用的 handler,断言 begin_session 确实下发了可见胶囊。 + #[tokio::test] + async fn less_computer_press_emits_visible_capsule() { + let _guard = ENV_LOCK.lock().await; + std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1"); + + let coordinator = Coordinator::new(); + { + let mut prefs = coordinator.inner.prefs.get(); + prefs.coding_agent_enabled = true; + coordinator.inner.prefs.set(prefs).unwrap(); + } + // 前置:还没弹过任何胶囊。 + assert!(coordinator.inner.last_capsule_state.lock().is_none()); + + // 等价于「按下 Less Computer 键」:bridge_loop 收到 Pressed 后就是调这个 handler。 + super::handle_less_computer_pressed(&coordinator.inner).await; + + assert_eq!( + *coordinator.inner.last_capsule_state.lock(), + Some(CapsuleState::Recording), + "按下 Less Computer 键必须进入录音并弹出可见胶囊" + ); + std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); + } + #[tokio::test] async fn begin_session_dry_run_enters_listening_and_clears_stale_edges() { let _guard = ENV_LOCK.lock().await; @@ -5042,7 +5471,9 @@ fn show_capsule_window_no_activate( let Ok(handle) = window.window_handle() else { // #470 诊断 v2:Win32 show 路径最可能的暗点之一。此前静默 return, // 无法观测「胶囊完全不显示」是否卡在这里。 - log::warn!("[capsule] no_activate failed: window_handle() unavailable — Win32 show skipped"); + log::warn!( + "[capsule] no_activate failed: window_handle() unavailable — Win32 show skipped" + ); return false; }; let RawWindowHandle::Win32(raw) = handle.as_raw() else { @@ -5163,9 +5594,12 @@ fn emit_capsule( message: Option, inserted_chars: Option, ) { + // 在 app 句柄校验之前记录,便于无 GUI 的测试断言「按下热键 → 弹了哪种胶囊」。 + *inner.last_capsule_state.lock() = Some(state); let app_opt = inner.app.lock().clone(); let Some(app) = app_opt else { return }; let translation = inner.translation_modifier_seen.load(Ordering::SeqCst); + let operating = inner.state.lock().voice_agent; let payload = CapsulePayload { state, level, @@ -5173,6 +5607,7 @@ fn emit_capsule( message, inserted_chars, translation, + operating, }; // visible / translation 是「这一帧 capsule:state event 的 payload」内容 —— diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index dfbbe3a5..7d13df9f 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -1,4 +1,4 @@ -use std::sync::atomic::Ordering; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use crate::coordinator_state::request_stop_during_starting_state; @@ -14,6 +14,47 @@ use super::*; const HOTKEY_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(250); const STREAMING_INSERT_FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(12); +/// Less Computer 浮窗的 Tauri 事件名(前端 LessComputerPanel 订阅)。 +const LESS_COMPUTER_EVENT: &str = "less-computer:event"; + +/// Less Computer 内联审批:等待用户决断的 token → oneshot sender 注册表。 +/// +/// 无头 `claude -p` 没有 mid-run 的 `--permission-prompt-tool` 通道(v2.1.165 不支持), +/// 所以护栏拦截发生在「整轮跑完、护栏 deny 生效」之后。这个注册表是审批 UI 的实回路: +/// 后端发 `approval` 事件后把一个 oneshot 接收端挂在这里,等前端 `less_computer_approve` +/// 命令按 token 解析出用户决断(true=Approve / false=Deny)。 +static LESS_COMPUTER_APPROVALS: std::sync::OnceLock< + std::sync::Mutex>>, +> = std::sync::OnceLock::new(); + +fn less_computer_approvals( +) -> &'static std::sync::Mutex>> +{ + LESS_COMPUTER_APPROVALS.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) +} + +/// 前端 `less_computer_approve` 命令调到这里:按 token 解析等待中的审批。 +/// token 不存在(已超时 / 已解析)时静默忽略。 +pub(super) fn resolve_less_computer_approval(token: &str, approved: bool) { + let sender = less_computer_approvals() + .lock() + .ok() + .and_then(|mut m| m.remove(token)); + if let Some(tx) = sender { + let _ = tx.send(approved); + log::info!("[less-computer] 审批 token={token} approved={approved}"); + } else { + log::info!("[less-computer] 审批 token={token} 已失效(超时/重复)"); + } +} + +/// 往 Less Computer 浮窗发一条事件(macOS only;前端按 `kind` 渲染聊天结构)。 +fn emit_less_computer(inner: &Arc, payload: serde_json::Value) { + if let Some(app) = inner.app.lock().clone() { + let _ = app.emit_to("less-computer", LESS_COMPUTER_EVENT, payload); + } +} + /// 跑流式润色路径(opt-in,跨平台)。 /// /// 平台差异: @@ -511,6 +552,10 @@ pub(super) async fn handle_released(inner: &Arc) { let mode = inner.prefs.get().hotkey.mode; let phase = inner.state.lock().phase; log::info!("[coord] hotkey released (mode={mode:?}, phase={phase:?})"); + if mode == HotkeyMode::Toggle { + // Toggle 听写松手不做事(点一下停)。Less Computer 走独立专用键监听器。 + return; + } if mode == HotkeyMode::Hold { match phase { SessionPhase::Listening => { @@ -525,6 +570,380 @@ pub(super) async fn handle_released(inner: &Arc) { } } +/// Less Computer 收尾:把转写当作指令交给无头 Claude,结果以胶囊展示(不插入到光标)。 +async fn run_voice_agent_transcript( + inner: &Arc, + _session_id: SessionId, + transcript: String, + elapsed: u64, +) -> Result<(), String> { + log::info!( + "[coord] Cloud Agent 语音:指令 {} 字", + transcript.chars().count() + ); + // 胶囊保留「处理中」反馈(用户熟悉的小录音条状态机);聊天浮窗承载完整对话。 + emit_capsule( + inner, + CapsuleState::Polishing, + 0.0, + elapsed, + Some("Claude 处理中…".to_string()), + None, + ); + + // 聊天浮窗:显示窗口 + 落用户气泡(语音指令转写)。macOS only(helper 内部 gating)。 + if let Some(app) = inner.app.lock().clone() { + crate::show_less_computer_window(&app); + // 全屏彩虹描边已在按下键时(handle_less_computer_pressed)点亮,这里不重复。 + } + // 连续对话:浮窗里已有进行中的会话 → 本轮 `claude --continue` 续上下文;否则是新会话(fresh)。 + // dismiss 关窗会把标志复位为 false。 + let continue_session = inner + .less_computer_conversation + .swap(true, Ordering::SeqCst); + emit_less_computer( + inner, + serde_json::json!({ "kind": "user", "text": transcript, "fresh": !continue_session }), + ); + + let prefs = inner.prefs.get(); + // 工作目录:用户设的 workdir,否则 $HOME。--add-dir 把文件作用域限定在此。 + let cwd = prefs + .coding_agent_workdir + .clone() + .filter(|d| !d.trim().is_empty()) + .map(std::path::PathBuf::from) + .or_else(|| std::env::var("HOME").ok().map(std::path::PathBuf::from)); + // 运行前 git 快照(cwd 是 git 仓库才有效;非仓库无副作用),便于回滚文件改动。 + if let Some(dir) = &cwd { + if let Some(sha) = crate::coding_agent::create_git_snapshot(dir) { + log::info!("[less-computer] 运行前 git 快照 {sha}(git stash apply 可回滚)"); + } + } + + let mode = coding_agent_mode_from_pref(&prefs.coding_agent_permission_mode); + let model = prefs + .coding_agent_model + .clone() + .filter(|m| !m.trim().is_empty()) + .or_else(|| Some("sonnet".to_string())); + let prompt = crate::coding_agent::autonomous_prompt(&transcript); + + // 第一轮:默认护栏(高风险全 deny)。运行后若检测到护栏拦截,弹审批卡; + // 用户 Approve 则在第二轮把该高风险模式从 deny 移除 + 加进 allowed,重跑一次。 + let outcome = run_less_computer_once( + inner, + &prompt, + cwd.as_deref(), + mode, + model.as_deref(), + &[], + continue_session, + ) + .await; + + let final_outcome = match maybe_request_approval(inner, &outcome).await { + Some(approved_pattern) => { + log::info!("[less-computer] 审批通过,放行高风险模式后重跑:{approved_pattern}"); + run_less_computer_once( + inner, + &prompt, + cwd.as_deref(), + mode, + model.as_deref(), + &[approved_pattern], + continue_session, + ) + .await + } + None => outcome, + }; + + inner.state.lock().phase = SessionPhase::Idle; + // 工作结束:熄灭全屏彩虹描边(聊天浮窗保留,等用户读完/关闭)。 + if let Some(app) = inner.app.lock().clone() { + crate::hide_less_computer_glow(&app); + } + + match final_outcome { + LessComputerOutcome::Done { text, cost_usd } => { + let text = text.trim().to_string(); + if text.is_empty() { + let msg = "Claude 无结果(确认已登录 claude 且额度充足)".to_string(); + emit_less_computer( + inner, + serde_json::json!({ "kind": "error", "message": msg }), + ); + emit_capsule(inner, CapsuleState::Error, 0.0, elapsed, Some(msg), None); + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err("voice agent empty".to_string()); + } + log::info!("[coord] Cloud Agent 语音:返回 {} 字", text.chars().count()); + emit_less_computer( + inner, + serde_json::json!({ "kind": "completed", "text": text, "costUsd": cost_usd }), + ); + emit_capsule(inner, CapsuleState::Done, 0.0, elapsed, Some(text), None); + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + Ok(()) + } + LessComputerOutcome::Failed { message } => { + log::warn!("[coord] Cloud Agent 语音失败: {message}"); + emit_less_computer( + inner, + serde_json::json!({ "kind": "error", "message": message }), + ); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some(message), + None, + ); + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + Err("voice agent failed".to_string()) + } + LessComputerOutcome::Cancelled => { + log::info!("[coord] Cloud Agent 语音已取消"); + emit_less_computer(inner, serde_json::json!({ "kind": "cancelled" })); + emit_capsule(inner, CapsuleState::Cancelled, 0.0, elapsed, None, None); + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + Err("voice agent cancelled".to_string()) + } + } +} + +/// 一轮无头 Less Computer 运行的结果。 +enum LessComputerOutcome { + Done { text: String, cost_usd: Option }, + Failed { message: String }, + Cancelled, +} + +/// 跑一轮无头 Claude(「放行 + 护栏」),把 Delta/ToolUse 实时 stream 到聊天浮窗, +/// 终局收敛为 [`LessComputerOutcome`]。`extra_allow_patterns` 为审批通过后放行的 +/// 高风险子串(如 "git push --force"):从 deny 清单剔除 + 作为 `Bash(:*)` 加进 allowed。 +async fn run_less_computer_once( + inner: &Arc, + prompt: &str, + cwd: Option<&std::path::Path>, + mode: crate::coding_agent::CodingAgentPermissionMode, + model: Option<&str>, + extra_allow_patterns: &[String], + continue_session: bool, +) -> LessComputerOutcome { + // 护栏 deny:默认全量;审批放行的模式从 deny 中剔除。 + let mut deny = crate::coding_agent::guard::default_deny_rules(); + let allow_rules: Vec = extra_allow_patterns + .iter() + .map(|p| format!("Bash({p}:*)")) + .collect(); + if !allow_rules.is_empty() { + deny.retain(|d| !allow_rules.iter().any(|a| a == d)); + } + let settings_json = serde_json::json!({ + "permissions": { "defaultMode": mode.as_cli_arg(), "deny": deny } + }); + let settings_path = std::env::temp_dir().join(format!( + "openless-less-computer-guard-{}.json", + uuid::Uuid::new_v4() + )); + if let Err(e) = std::fs::write( + &settings_path, + serde_json::to_vec_pretty(&settings_json).unwrap_or_default(), + ) { + log::warn!("[less-computer] 写护栏配置失败: {e}"); + } + + let mut req = crate::coding_agent::CodingAgentRequest::new("less-computer", prompt.to_string()); + req.cwd = cwd.map(|p| p.to_path_buf()); + req.model = model.map(|m| m.to_string()); + req.permission_mode = mode; + req.settings_json_path = Some(settings_path.clone()); + req.allowed_tools = vec![ + "Bash".into(), + "Read".into(), + "Edit".into(), + "Write".into(), + "Glob".into(), + "Grep".into(), + "WebFetch".into(), + "WebSearch".into(), + ]; + req.allowed_tools.extend(allow_rules); + // 真实任务(开应用、多步操作、读写文件)常超过 120s/0.5$ → 老是「运行超时」。放宽到 + // 5 分钟 / 2$,给多步任务足够空间;仍有硬上限兜底,不会无限跑/烧钱。 + req.max_budget_usd = Some(2.0); + req.timeout_secs = 300; + // 连续对话需要保留会话:本轮保存(供下轮 --continue),第二轮起带 --continue 续上下文。 + req.session_persistence = true; + req.continue_session = continue_session; + + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let cancel = Arc::new(AtomicBool::new(false)); + let cancel_for_runner = Arc::clone(&cancel); + let run = async_runtime::spawn(async move { + crate::coding_agent::run_claude_agent("claude", req, tx, cancel_for_runner).await + }); + let cancel_for_watcher = Arc::clone(&cancel); + let inner_for_cancel = Arc::clone(inner); + let cancel_watcher = async_runtime::spawn(async move { + loop { + if cancel_for_watcher.load(Ordering::Relaxed) { + return; + } + if inner_for_cancel.state.lock().cancelled { + cancel_for_watcher.store(true, Ordering::Relaxed); + return; + } + tokio::time::sleep(std::time::Duration::from_millis(120)).await; + } + }); + + let mut final_text = String::new(); + let mut cost_usd: Option = None; + let mut error_msg: Option = None; + let mut cancelled = false; + while let Some(ev) = rx.recv().await { + use crate::coding_agent::CodingAgentEvent as E; + match ev { + E::Started { .. } => { + emit_less_computer(inner, serde_json::json!({ "kind": "started" })); + } + E::Delta { text, .. } => { + emit_less_computer(inner, serde_json::json!({ "kind": "delta", "text": text })); + } + E::ToolUse { name, .. } => { + emit_less_computer(inner, serde_json::json!({ "kind": "tool", "name": name })); + } + E::Completed { + text, cost_usd: c, .. + } => { + final_text = text; + cost_usd = c; + } + E::Error { message, .. } => error_msg = Some(message), + E::Cancelled { .. } => cancelled = true, + } + } + let run_result = run.await; + cancel.store(true, Ordering::Relaxed); + let _ = cancel_watcher.await; + let _ = std::fs::remove_file(&settings_path); + + if cancelled + || matches!( + &run_result, + Ok(Err(crate::coding_agent::CodingAgentError::Cancelled)) + ) + { + return LessComputerOutcome::Cancelled; + } + + let trimmed = final_text.trim().to_string(); + if !trimmed.is_empty() { + LessComputerOutcome::Done { + text: trimmed, + cost_usd, + } + } else { + let message = error_msg + .or_else(|| match run_result { + Ok(Err(e)) => Some(e.to_string()), + _ => None, + }) + .unwrap_or_else(|| "Claude 无结果(确认已登录 claude 且额度充足)".to_string()); + LessComputerOutcome::Failed { message } + } +} + +/// 护栏拦截探测 + 内联审批(best-effort)。 +/// +/// 无头 `claude -p`(v2.1.165)没有 mid-run 的 `--permission-prompt-tool` 通道,所以 +/// 我们只能在「一轮跑完」后判断护栏是否拦了高风险动作:扫描终局文本里是否提到某个 +/// 高风险模式 + 权限/拒绝/blocked 关键词。命中则发 `approval` 事件、挂一个 oneshot 等 +/// 用户决断(前端 Approve/Deny → `less_computer_approve` 命令解析)。 +/// +/// 返回 `Some(pattern)` 表示用户 Approve 了某高风险模式 → 调用方应放行该模式重跑一轮; +/// `None` 表示无需审批 / 用户 Deny / 超时。**注意**这是「重跑放行」而非真正的 mid-run +/// 续跑——headless 下没有干净的 mid-run round-trip,详见 report。 +async fn maybe_request_approval( + inner: &Arc, + outcome: &LessComputerOutcome, +) -> Option { + let text = match outcome { + LessComputerOutcome::Done { text, .. } => text.as_str(), + LessComputerOutcome::Failed { message } => message.as_str(), + LessComputerOutcome::Cancelled => return None, + }; + let lowered = text.to_lowercase(); + // 必须同时出现「拒绝/权限/blocked」语义 + 某个已知高风险模式,才认为是护栏拦截, + // 避免把正常提到 "rm" 的回答误判成审批请求。 + let mentions_block = [ + "denied", + "permission", + "not allowed", + "blocked", + "拒绝", + "权限", + "被拦", + ] + .iter() + .any(|kw| lowered.contains(kw)); + if !mentions_block { + return None; + } + let hit = crate::coding_agent::guard::HIGH_RISK_PATTERNS + .iter() + .find(|(pat, _)| lowered.contains(*pat))?; + let (pattern, reason) = (hit.0.to_string(), hit.1.to_string()); + + // 挂 oneshot 等用户决断。 + let token = uuid::Uuid::new_v4().to_string(); + let (tx, rx) = tokio::sync::oneshot::channel::(); + if let Ok(mut map) = less_computer_approvals().lock() { + map.insert(token.clone(), tx); + } + emit_less_computer( + inner, + serde_json::json!({ + "kind": "approval", + "token": token, + "command": pattern, + "reason": reason, + }), + ); + + // 等用户点 Approve/Deny;90s 无响应按 Deny 处理并清理注册表项。 + let approved = match tokio::time::timeout(std::time::Duration::from_secs(90), rx).await { + Ok(Ok(v)) => v, + _ => { + less_computer_approvals() + .lock() + .ok() + .map(|mut m| m.remove(&token)); + false + } + }; + if approved { + Some(pattern) + } else { + None + } +} + +/// 把 prefs 里的权限模式字符串映射成枚举;未知值回落到 acceptEdits(放行+护栏的默认)。 +fn coding_agent_mode_from_pref(s: &str) -> crate::coding_agent::CodingAgentPermissionMode { + use crate::coding_agent::CodingAgentPermissionMode as M; + match s.trim() { + "plan" => M::Plan, + "default" => M::Default, + "bypassPermissions" => M::BypassPermissions, + _ => M::AcceptEdits, + } +} + pub(super) fn request_stop_during_starting(inner: &Arc, reason: &str) { { let mut state = inner.state.lock(); @@ -1531,6 +1950,13 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { raw.text = corrected; } } + + // Cloud Agent 语音分流:长按升级的会话不走润色/插入,转写交给 Claude 跑任务、结果弹胶囊。 + if inner.state.lock().voice_agent { + return run_voice_agent_transcript(inner, current_session_id, raw.text.clone(), elapsed) + .await; + } + emit_capsule(inner, CapsuleState::Polishing, 0.0, elapsed, None, None); let prefs = inner.prefs.get(); @@ -1903,6 +2329,10 @@ pub(super) fn cancel_session(inner: &Arc) { emit_capsule(inner, CapsuleState::Cancelled, 0.0, 0, None, None); log::info!("[coord] session cancelled (was {:?})", decision.phase); schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + // 取消时也熄灭整屏彩虹描边(dictation session 没开描边,hide 是无害 no-op)。 + if let Some(app) = inner.app.lock().clone() { + crate::hide_less_computer_glow(&app); + } } fn append_typed_prefix(target: &mut String, delta: &str, typed_chars: usize) -> usize { diff --git a/openless-all/app/src-tauri/src/coordinator_state.rs b/openless-all/app/src-tauri/src/coordinator_state.rs index 82ce5672..bdb59081 100644 --- a/openless-all/app/src-tauri/src/coordinator_state.rs +++ b/openless-all/app/src-tauri/src/coordinator_state.rs @@ -48,6 +48,9 @@ pub(crate) struct SessionState { /// 用户开始 dictation 时所处的前台 app 标签("Mail (com.apple.mail)" / Windows 窗口标题)。 /// 用作 LLM polish/translate 的上下文前提,让模型按 app 调风格。详见 issue #116。 pub(crate) front_app: Option, + /// Less Computer 语音模式:专用 Agent 键按下后置 true。end_session 在拿到转写后 + /// 据此分流——不走润色插入,转而把转写交给 Claude 跑任务、结果弹胶囊。默认 false。 + pub(crate) voice_agent: bool, } impl Default for SessionState { @@ -60,6 +63,7 @@ impl Default for SessionState { focus_target: None, session_id: initial_session_id(), front_app: None, + voice_agent: false, } } } @@ -80,6 +84,8 @@ pub(crate) fn begin_session_state( state.focus_target = focus_target; state.session_id = new_session_id(); state.front_app = front_app; + // 每个新会话默认是普通听写;Less Computer 专用入口会显式把它标为语音 Agent。 + state.voice_agent = false; Some(state.session_id) } @@ -247,6 +253,18 @@ mod tests { assert_ne!(id, initial_session_id()); } + #[test] + fn begin_session_resets_voice_agent_flag() { + // 安全护栏:上一会话残留的 voice_agent=true 绝不能让下一次普通听写被误判成 + // Cloud Agent(否则听写内容会被发去跑 Claude 而不是插入光标)。 + let mut state = SessionState { + voice_agent: true, + ..Default::default() + }; + begin_session_state(&mut state, None, None).unwrap(); + assert!(!state.voice_agent, "新会话必须从普通听写开始"); + } + #[test] fn begin_session_ignores_non_idle_phase() { let mut state = SessionState { diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index eaeb675e..7fa0eaea 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -228,7 +228,17 @@ where } fn update_shared_binding(shared: &Shared, binding: HotkeyBinding) { - *shared.binding.write() = binding; + { + let mut current = shared.binding.write(); + if *current == binding { + // 绑定未变化(如 supervisor 每 5s 周期性重新应用同一绑定):不要碰 held latch。 + // 否则会在长按期间把「已按住」清成 false,松手时 `!is_active && was_held` 不成立、 + // 不再发 Released —— hold 模式(Less Computer 按住说话)录音停不下来、要再按一次。 + // 复现:长按 >5s 跨过一次 supervisor 轮询即触发。 + return; + } + *current = binding; + } shared .trigger_held .store(false, std::sync::atomic::Ordering::SeqCst); @@ -1200,9 +1210,7 @@ mod platform { _binding: HotkeyBinding, _tx: Sender, ) -> Result, HotkeyInstallError> { - log::info!( - "[hotkey] Linux — fcitx5 plugin handles hotkeys" - ); + log::info!("[hotkey] Linux — fcitx5 plugin handles hotkeys"); Ok(Box::new(PlaceholderAdapter { _tx })) } diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index 2d8c454b..c767437c 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -50,7 +50,9 @@ impl TextInserter { match crate::linux_fcitx::commit_text(text) { Ok(()) => return InsertStatus::Inserted, Err(e) => { - log::warn!("[insertion] fcitx commit_text failed: {e}, fallback to clipboard only"); + log::warn!( + "[insertion] fcitx commit_text failed: {e}, fallback to clipboard only" + ); if copy_to_clipboard(text) { return InsertStatus::CopiedFallback; } diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 4b65b3d8..9b5c2da9 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -1,4 +1,7 @@ -#![cfg_attr(target_os = "linux", allow(dead_code, unused_imports, unused_variables))] +#![cfg_attr( + target_os = "linux", + allow(dead_code, unused_imports, unused_variables) +)] //! OpenLess Tauri backend. //! //! Modules mirror the original Swift libraries (one purpose per file): @@ -14,6 +17,7 @@ mod asr; mod audio_mute; mod cli; +mod coding_agent; mod combo_hotkey; mod commands; mod coordinator; @@ -164,6 +168,13 @@ pub fn run() { log::info!("[qa] qa 窗口未在 tauri.conf.json 中声明,前端 agent 会补上"); } + // Less Computer 语音 Agent 浮窗(macOS only)。启动时隐藏;coordinator + // 在 Less Computer 会话开始时再 show + 定位。非 macOS 上该窗口虽在 + // tauri.conf.json 声明,但前端不渲染入口、后端不 emit,保持隐藏惰性。 + if let Some(lc) = app.get_webview_window("less-computer") { + let _ = lc.hide(); + } + // 主窗口磨砂:macOS 用 NSVisualEffectView,Windows 用 Mica。 // 没这一层的话 transparent: true 让窗口透明 → 背后只是空,不是磨砂。 // @@ -377,6 +388,10 @@ pub fn run() { commands::start_dictation, commands::stop_dictation, commands::cancel_dictation, + coding_agent::commands::coding_agent_detect, + coding_agent::commands::coding_agent_run_test, + coding_agent::commands::coding_agent_cancel_test, + coding_agent::commands::coding_agent_command_risk, commands::handle_window_hotkey_event, #[cfg(debug_assertions)] commands::inject_hotkey_click_for_dev, @@ -411,6 +426,9 @@ pub fn run() { commands::set_open_app_hotkey, commands::qa_window_dismiss, commands::qa_window_pin, + commands::less_computer_window_dismiss, + commands::less_computer_window_resize, + commands::less_computer_approve, commands::validate_combo_hotkey, commands::set_combo_hotkey, commands::validate_provider_credentials, @@ -481,6 +499,8 @@ pub fn run() { let coordinator = app.state::>(); // 同步启动 QA hotkey listener。和 dictation hotkey 平行,互不抢状态。 coordinator.start_qa_hotkey_listener(); + // 启动「快速 Agent」双热键监听(功能默认关闭,启用后才注册)。 + coordinator.start_coding_agent_hotkey_listener(); // 启动自定义组合键监听器。当 trigger == Custom 时替代 modifier-only 监听器。 coordinator.start_combo_hotkey_listener(); coordinator.start_translation_hotkey_listener(); @@ -502,6 +522,7 @@ pub fn run() { let coordinator = app.state::>(); coordinator.stop_hotkey_listener(); coordinator.stop_qa_hotkey_listener(); + coordinator.stop_coding_agent_hotkey_listener(); coordinator.stop_combo_hotkey_listener(); coordinator.stop_translation_hotkey_listener(); coordinator.stop_switch_style_hotkey_listener(); @@ -855,9 +876,7 @@ fn reset_tcc_service_for_beta_restart(service: &str) { log::info!("[updater] reset TCC {service} before beta restart"); } Ok(status) => { - log::warn!( - "[updater] reset TCC {service} before beta restart exited with {status}" - ); + log::warn!("[updater] reset TCC {service} before beta restart exited with {status}"); } Err(e) => { log::warn!("[updater] reset TCC {service} before beta restart failed: {e}"); @@ -1142,6 +1161,53 @@ const QA_WINDOW_GAP_TO_CAPSULE: f64 = 8.0; /// 给 macOS Dock 留的下边距(与 capsule 同源)。 const DOCK_BOTTOM_PADDING_FOR_QA: f64 = 80.0; +#[derive(Clone, Copy, Debug, PartialEq)] +struct LogicalMonitorFrame { + x: f64, + y: f64, + width: f64, + height: f64, +} + +fn logical_monitor_frame( + physical_x: i32, + physical_y: i32, + physical_width: u32, + physical_height: u32, + scale: f64, +) -> LogicalMonitorFrame { + let scale = scale.max(0.1); + LogicalMonitorFrame { + x: physical_x as f64 / scale, + y: physical_y as f64 / scale, + width: physical_width as f64 / scale, + height: physical_height as f64 / scale, + } +} + +fn bottom_center_position( + frame: LogicalMonitorFrame, + window_width: f64, + window_height: f64, + bottom_offset: f64, +) -> (f64, f64) { + let x = frame.x + ((frame.width - window_width) / 2.0).max(0.0); + let y = frame.y + (frame.height - bottom_offset - window_height).max(0.0); + (x, y) +} + +fn bottom_visual_position( + frame: LogicalMonitorFrame, + window_width: f64, + visual_height: f64, + bottom_padding: f64, + bottom_inset: f64, +) -> (f64, f64) { + let x = frame.x + ((frame.width - window_width) / 2.0).max(0.0); + let y = frame.y + (frame.height - visual_height - bottom_padding - bottom_inset).max(0.0); + (x, y) +} + /// 把 QA 浮窗放到屏幕底部居中、紧贴胶囊上方。tauri 启动期 + show 之前都会调一次, /// 防止用户切换显示器后位置错乱。 fn position_qa_window(window: &tauri::WebviewWindow) -> tauri::Result<()> { @@ -1151,16 +1217,15 @@ fn position_qa_window(window: &tauri::WebviewWindow) -> ta }; let scale = monitor.scale_factor(); let size = monitor.size(); - let logical_w = size.width as f64 / scale; - let logical_h = size.height as f64 / scale; + let pos = monitor.position(); + let frame = logical_monitor_frame(pos.x, pos.y, size.width, size.height, scale); let capsule_height = capsule_height_for_qa(); - let x = ((logical_w - QA_WINDOW_WIDTH) / 2.0).max(0.0); - let y = (logical_h - - DOCK_BOTTOM_PADDING_FOR_QA - - capsule_height - - QA_WINDOW_GAP_TO_CAPSULE - - QA_WINDOW_HEIGHT) - .max(0.0); + let (x, y) = bottom_center_position( + frame, + QA_WINDOW_WIDTH, + QA_WINDOW_HEIGHT, + DOCK_BOTTOM_PADDING_FOR_QA + capsule_height + QA_WINDOW_GAP_TO_CAPSULE, + ); window.set_size(tauri::LogicalSize::new(QA_WINDOW_WIDTH, QA_WINDOW_HEIGHT))?; window.set_position(LogicalPosition::new(x, y))?; Ok(()) @@ -1268,6 +1333,214 @@ pub(crate) fn hide_qa_window(app: &AppHandle) { } } +// ───────────────────────── Less Computer 浮窗 ───────────────────────── +// +// Less Computer 语音 Agent 的聊天浮窗(窗口 label = "less-computer")。 +// 仅 macOS:和 coordinator / 前端对 Less Computer 的 gating 一致(Windows/Linux +// 不注册热键、前端 detectOS 不渲染入口),所以这些窗口操作全部 `#[cfg(macos)]`, +// 其它平台是 no-op,避免在非目标平台动 NSWindow / 弹一个空浮窗。 + +/// Less Computer 浮窗宽度(高度由前端按内容自适应,经 `less_computer_window_resize` +/// 回传,Rust 端按 bottom-anchored 重新摆放,让内容增长向上撑开)。 +#[cfg(target_os = "macos")] +const LESS_COMPUTER_WINDOW_WIDTH: f64 = 400.0; +#[cfg(target_os = "macos")] +const LESS_COMPUTER_WINDOW_MIN_HEIGHT: f64 = 120.0; +#[cfg(target_os = "macos")] +const LESS_COMPUTER_WINDOW_MAX_HEIGHT: f64 = 520.0; + +/// 把 Less Computer 浮窗按给定高度(clamp 到 [min,max])摆到屏幕底部居中、 +/// 紧贴胶囊上方。bottom 对齐胶囊顶部,所以高度变化时窗口向上生长。 +#[cfg(target_os = "macos")] +fn position_less_computer_window( + window: &tauri::WebviewWindow, + height: f64, +) -> tauri::Result<()> { + let monitor = match window.current_monitor()? { + Some(m) => m, + None => return Ok(()), + }; + let scale = monitor.scale_factor(); + let size = monitor.size(); + let pos = monitor.position(); + let frame = logical_monitor_frame(pos.x, pos.y, size.width, size.height, scale); + let height = height.clamp( + LESS_COMPUTER_WINDOW_MIN_HEIGHT, + LESS_COMPUTER_WINDOW_MAX_HEIGHT, + ); + let capsule_height = capsule_height_for_qa(); + let (x, y) = bottom_center_position( + frame, + LESS_COMPUTER_WINDOW_WIDTH, + height, + DOCK_BOTTOM_PADDING_FOR_QA + capsule_height + QA_WINDOW_GAP_TO_CAPSULE, + ); + window.set_size(tauri::LogicalSize::new(LESS_COMPUTER_WINDOW_WIDTH, height))?; + window.set_position(LogicalPosition::new(x, y))?; + Ok(()) +} + +/// 显示 Less Computer 浮窗(不抢前台 app 焦点,与 QA 同手法)。`macos` 专用。 +#[cfg(target_os = "macos")] +pub(crate) fn show_less_computer_window(app: &AppHandle) { + let Some(window) = app.get_webview_window("less-computer") else { + log::info!("[less-computer] show 跳过:窗口不存在"); + return; + }; + if let Err(e) = position_less_computer_window(&window, LESS_COMPUTER_WINDOW_MIN_HEIGHT) { + log::warn!("[less-computer] position before show failed: {e}"); + } + let window_clone = window.clone(); + let _ = app.run_on_main_thread(move || { + use objc2::msg_send; + use objc2::runtime::AnyObject; + match window_clone.ns_window() { + Ok(handle) => { + let ns = handle as *mut AnyObject; + if ns.is_null() { + log::warn!("[less-computer] ns_window null; falling back to window.show()"); + let _ = window_clone.show(); + } else { + unsafe { + let _: () = msg_send![ns, orderFrontRegardless]; + } + } + } + Err(e) => { + log::warn!("[less-computer] ns_window unavailable: {e}; falling back to show()"); + let _ = window_clone.show(); + } + } + }); +} + +#[cfg(not(target_os = "macos"))] +pub(crate) fn show_less_computer_window(_app: &AppHandle) {} + +/// 隐藏 Less Computer 浮窗。供 dismiss 命令 / session 收尾共用。 +#[cfg(target_os = "macos")] +pub(crate) fn hide_less_computer_window(app: &AppHandle) { + if let Some(window) = app.get_webview_window("less-computer") { + let _ = window.hide(); + } +} + +#[cfg(not(target_os = "macos"))] +pub(crate) fn hide_less_computer_window(_app: &AppHandle) {} + +/// 显示全屏彩虹描边浮层:盖满当前显示器、点击穿透、置顶。Agent 工作时点亮整屏边缘。 +#[cfg(target_os = "macos")] +pub(crate) fn show_less_computer_glow(app: &AppHandle) { + let Some(window) = app.get_webview_window("less-computer-glow") else { + return; + }; + // 盖满当前(否则主)显示器,含菜单栏/Dock 区域。关键:用「逻辑坐标」(物理/缩放) —— + // Retina 上 monitor.size() 是物理像素(2x),直接 set_size 会把窗口铺成两倍、错位、不贴边。 + let monitor = window + .current_monitor() + .ok() + .flatten() + .or_else(|| app.primary_monitor().ok().flatten()); + if let Some(monitor) = monitor { + let scale = monitor.scale_factor(); + let size = monitor.size(); + let pos = monitor.position(); + let _ = window.set_position(tauri::LogicalPosition::new( + pos.x as f64 / scale, + pos.y as f64 / scale, + )); + let _ = window.set_size(tauri::LogicalSize::new( + size.width as f64 / scale, + size.height as f64 / scale, + )); + } + // 点击穿透:纯视觉浮层,绝不拦截鼠标。 + let _ = window.set_ignore_cursor_events(true); + let window_clone = window.clone(); + let _ = app.run_on_main_thread(move || { + use objc2::msg_send; + use objc2::runtime::AnyObject; + match window_clone.ns_window() { + Ok(handle) => { + let ns = handle as *mut AnyObject; + if ns.is_null() { + let _ = window_clone.show(); + } else { + unsafe { + // 抬到菜单栏(24)/Dock 之上,让描边能真正贴到屏幕最外缘(含顶部菜单栏区域)。 + let _: () = msg_send![ns, setLevel: 25i64]; + // 所有 Space 都显示、不参与窗口循环、全屏 app 上也叠加。 + let _: () = msg_send![ns, setCollectionBehavior: 273u64]; + let _: () = msg_send![ns, setIgnoresMouseEvents: true]; + let _: () = msg_send![ns, orderFrontRegardless]; + } + } + } + Err(_) => { + let _ = window_clone.show(); + } + } + }); +} + +#[cfg(not(target_os = "macos"))] +pub(crate) fn show_less_computer_glow(_app: &AppHandle) {} + +/// 隐藏全屏彩虹描边浮层。 +#[cfg(target_os = "macos")] +pub(crate) fn hide_less_computer_glow(app: &AppHandle) { + if let Some(window) = app.get_webview_window("less-computer-glow") { + let _ = window.hide(); + } +} + +#[cfg(not(target_os = "macos"))] +pub(crate) fn hide_less_computer_glow(_app: &AppHandle) {} + +/// 前端按内容测高后回传。以「当前窗口底边」为锚向上生长——只改高度、保住用户拖动后的位置, +/// 不再重新居中(否则一改内容就把拖走的框拉回屏幕底部中间)。`macos` 专用。 +#[cfg(target_os = "macos")] +pub(crate) fn resize_less_computer_window(app: &AppHandle, height: f64) { + let Some(window) = app.get_webview_window("less-computer") else { + return; + }; + let height = height.clamp( + LESS_COMPUTER_WINDOW_MIN_HEIGHT, + LESS_COMPUTER_WINDOW_MAX_HEIGHT, + ); + let scale = window.scale_factor().unwrap_or(1.0); + match (window.outer_position(), window.outer_size()) { + (Ok(pos), Ok(size)) => { + let x = pos.x as f64 / scale; + let cur_top = pos.y as f64 / scale; + let cur_h = size.height as f64 / scale; + let bottom = cur_top + cur_h; + let monitor_top = window + .current_monitor() + .ok() + .flatten() + .map(|m| { + let p = m.position(); + let s = m.size(); + logical_monitor_frame(p.x, p.y, s.width, s.height, m.scale_factor()).y + }) + .unwrap_or(f64::NEG_INFINITY); + let new_y = (bottom - height).max(monitor_top); + let _ = window.set_size(tauri::LogicalSize::new(LESS_COMPUTER_WINDOW_WIDTH, height)); + let _ = window.set_position(tauri::LogicalPosition::new(x, new_y)); + } + // 拿不到当前位置(极少见)→ 退回首屏居中摆放。 + _ => { + if let Err(e) = position_less_computer_window(&window, height) { + log::warn!("[less-computer] resize fallback failed: {e}"); + } + } + } +} + +#[cfg(not(target_os = "macos"))] +pub(crate) fn resize_less_computer_window(_app: &AppHandle, _height: f64) {} + /// 抓完选区后把焦点重新交回 QA 浮窗(Windows focus-dance 下半场)。begin_qa_session /// 在 capture_selection 跑完时调;非 Windows 平台是 no-op。issue #466。 #[cfg(target_os = "windows")] @@ -1368,7 +1641,10 @@ pub(crate) fn position_capsule_bottom_center( let scale = mon.scale; let phys_w = (bounds.width * scale).round() as i32; let phys_h = (bounds.height * scale).round() as i32; - window.set_size(PhysicalSize::new(phys_w.max(1) as u32, phys_h.max(1) as u32))?; + window.set_size(PhysicalSize::new( + phys_w.max(1) as u32, + phys_h.max(1) as u32, + ))?; let mon_w = mon.right - mon.left; let x = mon.left + ((mon_w - phys_w) / 2).max(0); @@ -1398,11 +1674,15 @@ pub(crate) fn position_capsule_bottom_center( let scale = monitor.scale_factor(); let size = monitor.size(); - let logical_w = size.width as f64 / scale; - let logical_h = size.height as f64 / scale; - let x = ((logical_w - bounds.width) / 2.0).max(0.0); - let y = (logical_h - capsule_visual_height(translation_active) - 80.0 - bounds.bottom_inset) - .max(0.0); + let pos = monitor.position(); + let frame = logical_monitor_frame(pos.x, pos.y, size.width, size.height, scale); + let (x, y) = bottom_visual_position( + frame, + bounds.width, + capsule_visual_height(translation_active), + 80.0, + bounds.bottom_inset, + ); window.set_position(LogicalPosition::new(x, y))?; Ok(()) } @@ -1460,9 +1740,10 @@ fn capsule_height_for_qa() -> f64 { #[cfg(test)] mod tests { use super::{ - capsule_height_for_qa, capsule_visual_height, capsule_window_bounds, + bottom_center_position, bottom_visual_position, capsule_height_for_qa, + capsule_visual_height, capsule_window_bounds, logical_monitor_frame, parse_tray_polish_mode_id, rotate_log_if_too_large, tray_polish_mode_menu_entries, - tray_style_menu_enabled, LOG_ROTATE_LIMIT_BYTES, + tray_style_menu_enabled, LogicalMonitorFrame, LOG_ROTATE_LIMIT_BYTES, }; use crate::types::PolishMode; use std::io::Write; @@ -1566,6 +1847,49 @@ mod tests { assert_eq!(capsule_height_for_qa(), 96.0); } + #[test] + fn logical_monitor_frame_preserves_negative_origin() { + let frame = logical_monitor_frame(-2560, 720, 5120, 2880, 2.0); + + assert_eq!( + frame, + LogicalMonitorFrame { + x: -1280.0, + y: 360.0, + width: 2560.0, + height: 1440.0, + } + ); + } + + #[test] + fn bottom_center_position_keeps_window_on_left_monitor() { + let frame = LogicalMonitorFrame { + x: -1440.0, + y: 0.0, + width: 1440.0, + height: 900.0, + }; + + let pos = bottom_center_position(frame, 380.0, 440.0, 184.0); + + assert_eq!(pos, (-910.0, 276.0)); + } + + #[test] + fn bottom_visual_position_keeps_capsule_on_upper_monitor() { + let frame = LogicalMonitorFrame { + x: 0.0, + y: -900.0, + width: 1440.0, + height: 900.0, + }; + + let pos = bottom_visual_position(frame, 220.0, 96.0, 80.0, 0.0); + + assert_eq!(pos, (610.0, -176.0)); + } + #[test] fn oversized_log_rotates_to_single_archive() { let dir = std::env::temp_dir().join(format!("openless-log-rotate-{}", std::process::id())); diff --git a/openless-all/app/src-tauri/src/linux_fcitx.rs b/openless-all/app/src-tauri/src/linux_fcitx.rs index c99faa41..16069c6a 100644 --- a/openless-all/app/src-tauri/src/linux_fcitx.rs +++ b/openless-all/app/src-tauri/src/linux_fcitx.rs @@ -20,8 +20,8 @@ const TIMEOUT: Duration = Duration::from_secs(3); /// /// 返回 `Ok(())` 表示文字已提交,`Err` 表示调用失败(插件未加载 / DBus 不通等)。 pub fn commit_text(text: &str) -> Result<(), String> { - let conn = dbus::blocking::Connection::new_session() - .map_err(|e| format!("dbus session: {e}"))?; + let conn = + dbus::blocking::Connection::new_session().map_err(|e| format!("dbus session: {e}"))?; let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "CommitText") .map_err(|e| format!("build msg: {e}"))? .append1(text); @@ -34,8 +34,8 @@ pub fn commit_text(text: &str) -> Result<(), String> { /// /// `keys` 为 Key::parse 格式的字符串数组,例如 `["Control+space"]`。 pub fn set_hotkey(keys: &[&str]) -> Result<(), String> { - let conn = dbus::blocking::Connection::new_session() - .map_err(|e| format!("dbus session: {e}"))?; + let conn = + dbus::blocking::Connection::new_session().map_err(|e| format!("dbus session: {e}"))?; let list: Vec = keys.iter().map(|s| s.to_string()).collect(); let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetHotkey") .map_err(|e| format!("build msg: {e}"))? @@ -47,8 +47,8 @@ pub fn set_hotkey(keys: &[&str]) -> Result<(), String> { /// 通过 fcitx5 插件直接设置 sym + states 作为触发键。 pub fn set_hotkey_raw(sym: u32, states: u32) -> Result<(), String> { - let conn = dbus::blocking::Connection::new_session() - .map_err(|e| format!("dbus session: {e}"))?; + let conn = + dbus::blocking::Connection::new_session().map_err(|e| format!("dbus session: {e}"))?; let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetHotkeyRaw") .map_err(|e| format!("build msg: {e}"))? .append2(sym, states); @@ -59,8 +59,8 @@ pub fn set_hotkey_raw(sym: u32, states: u32) -> Result<(), String> { /// 通过 fcitx5 插件设置 QA 面板快捷键 sym + states。 pub fn set_qa_hotkey_raw(sym: u32, states: u32) -> Result<(), String> { - let conn = dbus::blocking::Connection::new_session() - .map_err(|e| format!("dbus session: {e}"))?; + let conn = + dbus::blocking::Connection::new_session().map_err(|e| format!("dbus session: {e}"))?; let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetQaHotkeyRaw") .map_err(|e| format!("build msg: {e}"))? .append2(sym, states); @@ -71,8 +71,8 @@ pub fn set_qa_hotkey_raw(sym: u32, states: u32) -> Result<(), String> { /// 通过 fcitx5 插件设置翻译模式修饰键 sym + states。 pub fn set_translation_hotkey_raw(sym: u32, states: u32) -> Result<(), String> { - let conn = dbus::blocking::Connection::new_session() - .map_err(|e| format!("dbus session: {e}"))?; + let conn = + dbus::blocking::Connection::new_session().map_err(|e| format!("dbus session: {e}"))?; let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetTranslationHotkeyRaw") .map_err(|e| format!("build msg: {e}"))? .append2(sym, states); @@ -97,7 +97,9 @@ fn trigger_to_keysym(trigger: crate::types::HotkeyTrigger) -> u32 { match trigger { crate::types::HotkeyTrigger::RightControl => KEYSYM_CONTROL_R, crate::types::HotkeyTrigger::LeftControl => KEYSYM_CONTROL_L, - crate::types::HotkeyTrigger::RightOption | crate::types::HotkeyTrigger::RightAlt => KEYSYM_ALT_R, + crate::types::HotkeyTrigger::RightOption | crate::types::HotkeyTrigger::RightAlt => { + KEYSYM_ALT_R + } crate::types::HotkeyTrigger::LeftOption => KEYSYM_ALT_L, crate::types::HotkeyTrigger::RightCommand => KEYSYM_SUPER_R, crate::types::HotkeyTrigger::Fn => KEYSYM_CONTROL_R, @@ -171,13 +173,11 @@ pub fn binding_to_fcitx_key_string(binding: &crate::types::ShortcutBinding) -> S /// 通过 fcitx5 插件的 SetCustomDictationTrigger 方法设置自定义组合键。 pub fn set_custom_dictation_trigger(key_string: &str) -> Result<(), String> { - let conn = dbus::blocking::Connection::new_session() - .map_err(|e| format!("dbus session: {e}"))?; - let msg = dbus::Message::new_method_call( - DEST, PATH, IFACE, "SetCustomDictationTrigger", - ) - .map_err(|e| format!("build msg: {e}"))? - .append1(key_string); + let conn = + dbus::blocking::Connection::new_session().map_err(|e| format!("dbus session: {e}"))?; + let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetCustomDictationTrigger") + .map_err(|e| format!("build msg: {e}"))? + .append1(key_string); conn.send_with_reply_and_block(msg, TIMEOUT) .map_err(|e| format!("SetCustomDictationTrigger: {e}"))?; Ok(()) @@ -196,7 +196,9 @@ pub fn sync_qa_binding(trigger: Option) { let sym = trigger_to_keysym(trigger); let name = trigger_name(trigger); match set_qa_hotkey_raw(sym, 0) { - Ok(()) => log::info!("[fcitx] Synced QA hotkey {name} (sym={sym}) to plugin via SetQaHotkeyRaw"), + Ok(()) => { + log::info!("[fcitx] Synced QA hotkey {name} (sym={sym}) to plugin via SetQaHotkeyRaw") + } Err(e) => log::warn!("[fcitx] Failed to sync QA hotkey to plugin: {e}"), } } @@ -220,8 +222,8 @@ pub fn sync_translation_binding(trigger: Option) { /// 通过 fcitx5 插件在候选词列表下方显示状态文本(不干扰输入法预编辑)。 pub fn set_aux_down(text: &str) -> Result<(), String> { - let conn = dbus::blocking::Connection::new_session() - .map_err(|e| format!("dbus session: {e}"))?; + let conn = + dbus::blocking::Connection::new_session().map_err(|e| format!("dbus session: {e}"))?; let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetAuxDown") .map_err(|e| format!("build msg: {e}"))? .append1(text); @@ -232,8 +234,8 @@ pub fn set_aux_down(text: &str) -> Result<(), String> { /// 清除 fcitx5 插件候选词列表下方状态文本。 pub fn clear_aux_down() -> Result<(), String> { - let conn = dbus::blocking::Connection::new_session() - .map_err(|e| format!("dbus session: {e}"))?; + let conn = + dbus::blocking::Connection::new_session().map_err(|e| format!("dbus session: {e}"))?; let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "ClearAuxDown") .map_err(|e| format!("build msg: {e}"))?; conn.send_with_reply_and_block(msg, TIMEOUT) @@ -425,8 +427,8 @@ pub fn ensure_plugin_installed(_app: &tauri::AppHandle) { // fcitx5 在不同发行版的 lib 路径不同,同时支持用户 XDG 安装 let lib_dirs = [ "/usr/lib/x86_64-linux-gnu/fcitx5", // Debian multiarch - "/usr/lib64/fcitx5", // RPM 64-bit - "/usr/lib/fcitx5", // 通用回退 + "/usr/lib64/fcitx5", // RPM 64-bit + "/usr/lib/fcitx5", // 通用回退 ]; let system_conf = std::path::Path::new("/usr/share/fcitx5/addon/openless.conf"); @@ -442,9 +444,9 @@ pub fn ensure_plugin_installed(_app: &tauri::AppHandle) { }; let conf_ok = user_conf.exists() || system_conf.exists(); - let system_so_found = lib_dirs.iter().find(|dir| { - std::path::Path::new(dir).join("libopenless.so").exists() - }); + let system_so_found = lib_dirs + .iter() + .find(|dir| std::path::Path::new(dir).join("libopenless.so").exists()); let so_ok = user_so.exists() || system_so_found.is_some(); // 用户手动安装过 ~/.local/ 版本,同时系统路径也有(deb 注入的)→ @@ -471,7 +473,8 @@ pub fn ensure_plugin_installed(_app: &tauri::AppHandle) { log::warn!( "[fcitx] fcitx5 plugin .so not found in {:?} or {:?}. \ The OpenLess package may be incomplete.", - lib_dirs, user_so + lib_dirs, + user_so ); } } diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index df4ba8eb..e4557472 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -423,10 +423,7 @@ fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> { .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_default(); - let tmp_path = path.with_file_name(format!( - "{file_name}.tmp-{}", - Uuid::new_v4().simple() - )); + let tmp_path = path.with_file_name(format!("{file_name}.tmp-{}", Uuid::new_v4().simple())); fs::write(&tmp_path, contents) .with_context(|| format!("write tmp failed: {}", tmp_path.display()))?; if let Err(err) = fs::rename(&tmp_path, path) { @@ -1217,9 +1214,19 @@ struct StylePackArchiveManifest { compatible_app_version: Option, /// Marketplace 上游关系。旧 ZIP 没有此字段时自动为 None; /// 兼容早期口误/拼写包里可能出现的 `orion*` 字段名。 - #[serde(default, alias = "orionPackId", alias = "orion_pack_id", alias = "origin_pack_id")] + #[serde( + default, + alias = "orionPackId", + alias = "orion_pack_id", + alias = "origin_pack_id" + )] origin_pack_id: Option, - #[serde(default, alias = "orionAuthorLogin", alias = "orion_author_login", alias = "origin_author_login")] + #[serde( + default, + alias = "orionAuthorLogin", + alias = "orion_author_login", + alias = "origin_author_login" + )] origin_author_login: Option, } @@ -2413,7 +2420,8 @@ impl CredentialsVault { mod tests { use super::{ chunk_json_payload, list_vocab_presets, read_preferences, save_vocab_presets, - sync_style_pack_preferences, validate_correction_rule_syntax, KEYRING_CHUNK_MAX_UTF16_UNITS, + sync_style_pack_preferences, validate_correction_rule_syntax, + KEYRING_CHUNK_MAX_UTF16_UNITS, }; use crate::types::{builtin_style_packs, CustomStylePrompts, VocabPreset, VocabPresetStore}; use std::fs; diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index 05c87fa9..9c47b268 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -2967,11 +2967,19 @@ mod tests { ); // v2 PRO 自带 prompt 必须共享:四/五、ASR 纠错段 + 高/低置信度分级 + 根目录词条。 - for mode in [PolishMode::Light, PolishMode::Structured, PolishMode::Formal] { + for mode in [ + PolishMode::Light, + PolishMode::Structured, + PolishMode::Formal, + ] { let prompt = prompts::system_prompt(mode); - let has_asr_heading = prompt.contains("# 四、ASR 纠错") || prompt.contains("# 五、ASR 纠错"); + let has_asr_heading = + prompt.contains("# 四、ASR 纠错") || prompt.contains("# 五、ASR 纠错"); assert!(has_asr_heading, "{mode:?} prompt 缺少 v2 自带 ASR 纠错段落"); - assert!(prompt.contains("根目录"), "{mode:?} prompt 缺少根目录纠错示例"); + assert!( + prompt.contains("根目录"), + "{mode:?} prompt 缺少根目录纠错示例" + ); assert!( prompt.contains("**高置信度**") && prompt.contains("**低置信度**"), "{mode:?} prompt 缺少分级置信度策略" @@ -2983,8 +2991,14 @@ mod tests { fn translate_prompt_swaps_to_en_dedicated_when_target_is_english() { // 英文目标:整段切到 EN_TRANSLATE_SYSTEM_PROMPT,不再带通用 base 的 \"# 任务(翻译输出)\" 标题。 let en = prompts::translate_system_prompt("English"); - assert!(en.contains("# 任务(中文转写 → 英文翻译)"), "English target 必须使用 EN 专用 prompt"); - assert!(!en.contains("# 任务(翻译输出)"), "English target 不应再带通用 base 标题"); + assert!( + en.contains("# 任务(中文转写 → 英文翻译)"), + "English target 必须使用 EN 专用 prompt" + ); + assert!( + !en.contains("# 任务(翻译输出)"), + "English target 不应再带通用 base 标题" + ); assert!(en.contains("# 工作流程")); assert!(en.contains("# 中→英术语规范化")); assert!(en.contains("# 翻译要求")); @@ -3005,8 +3019,7 @@ mod tests { // 别名容忍:'美式英文' / '英文' / 'english' / 'British English' 都走 EN 专用 prompt。 for alias in ["美式英文", "英文", "english", "British English"] { assert!( - prompts::translate_system_prompt(alias) - .contains("# 任务(中文转写 → 英文翻译)"), + prompts::translate_system_prompt(alias).contains("# 任务(中文转写 → 英文翻译)"), "alias '{alias}' should resolve to English target" ); } diff --git a/openless-all/app/src-tauri/src/selection.rs b/openless-all/app/src-tauri/src/selection.rs index 0c4da6c1..00584de8 100644 --- a/openless-all/app/src-tauri/src/selection.rs +++ b/openless-all/app/src-tauri/src/selection.rs @@ -1,4 +1,7 @@ -#![cfg_attr(target_os = "linux", allow(dead_code, unused_imports, unused_variables))] +#![cfg_attr( + target_os = "linux", + allow(dead_code, unused_imports, unused_variables) +)] //! 跨平台「划词捕获」工具:在用户触发 QA 快捷键时尝试拿到当前前台 app 的选区文本。 //! //! 三级 fallback: diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 5f8c16a3..7528cfd4 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -611,6 +611,31 @@ pub struct UserPreferences { /// 「唤起 App」全局快捷键。`None` = 停用;`Some(...)` = 注册。默认 `Some(默认键)`。 #[serde(default = "default_open_app_hotkey")] pub open_app_hotkey: Option, + /// Less Computer:是否启用。默认关闭,需用户在高级设置开启。 + #[serde(default)] + pub coding_agent_enabled: bool, + /// Agent 后端:`claude-code-cli`(默认)或 `opencode-cli`。 + #[serde(default = "default_coding_agent_provider")] + pub coding_agent_provider: String, + /// Agent 模型(`None` = 运行时取便宜默认 sonnet)。 + #[serde(default)] + pub coding_agent_model: Option, + /// 权限模式:plan/default/acceptEdits/bypassPermissions。默认 acceptEdits(放行+护栏)。 + #[serde(default = "default_coding_agent_permission_mode")] + pub coding_agent_permission_mode: String, + /// Agent 工作目录(`None` = 临时目录)。 + #[serde(default)] + pub coding_agent_workdir: Option, + /// Less Computer 语音触发键。macOS 生效;支持单修饰键(左/右 Control、左/右 Option、Fn) + /// 和普通组合键。`None` = 停用。 + #[serde(default = "default_coding_agent_voice_hotkey")] + pub coding_agent_voice_hotkey: Option, + /// 热键 1:语音 Agent 面板键。默认 Cmd/Ctrl+Shift+Enter。`None` = 停用。 + #[serde(default = "default_coding_agent_panel_hotkey")] + pub coding_agent_panel_hotkey: Option, + /// 热键 2:快取用键(选中→Claude→回插)。默认 `None`(用户自配)。 + #[serde(default)] + pub coding_agent_quick_hotkey: Option, /// 本地 Qwen3-ASR 当前激活的模型 id("qwen3-asr-0.6b" / "qwen3-asr-1.7b")。 /// 仅在 active_asr_provider == "local-qwen3" 时有意义。 #[serde(default = "default_local_asr_model")] @@ -809,6 +834,22 @@ struct UserPreferencesWire { translation_hotkey: Option, switch_style_hotkey: Option, open_app_hotkey: Option, + #[serde(default)] + coding_agent_enabled: bool, + #[serde(default = "default_coding_agent_provider")] + coding_agent_provider: String, + #[serde(default)] + coding_agent_model: Option, + #[serde(default = "default_coding_agent_permission_mode")] + coding_agent_permission_mode: String, + #[serde(default)] + coding_agent_workdir: Option, + #[serde(default = "default_coding_agent_voice_hotkey")] + coding_agent_voice_hotkey: Option, + #[serde(default = "default_coding_agent_panel_hotkey")] + coding_agent_panel_hotkey: Option, + #[serde(default)] + coding_agent_quick_hotkey: Option, #[serde(default = "default_local_asr_model")] local_asr_active_model: String, #[serde(default = "default_local_asr_mirror")] @@ -892,6 +933,14 @@ impl Default for UserPreferencesWire { // 默认携带默认键(Some),保证缺字段时仍是启用状态;None 专表「用户主动停用」。 switch_style_hotkey: prefs.switch_style_hotkey, open_app_hotkey: prefs.open_app_hotkey, + coding_agent_enabled: prefs.coding_agent_enabled, + coding_agent_provider: prefs.coding_agent_provider, + coding_agent_model: prefs.coding_agent_model, + coding_agent_permission_mode: prefs.coding_agent_permission_mode, + coding_agent_workdir: prefs.coding_agent_workdir, + coding_agent_voice_hotkey: prefs.coding_agent_voice_hotkey, + coding_agent_panel_hotkey: prefs.coding_agent_panel_hotkey, + coding_agent_quick_hotkey: prefs.coding_agent_quick_hotkey, local_asr_active_model: prefs.local_asr_active_model, local_asr_mirror: prefs.local_asr_mirror, local_asr_keep_loaded_secs: prefs.local_asr_keep_loaded_secs, @@ -968,6 +1017,14 @@ impl<'de> Deserialize<'de> for UserPreferences { output_language_preference: wire.output_language_preference, qa_hotkey: wire.qa_hotkey, qa_save_history: wire.qa_save_history, + coding_agent_enabled: wire.coding_agent_enabled, + coding_agent_provider: wire.coding_agent_provider, + coding_agent_model: wire.coding_agent_model, + coding_agent_permission_mode: wire.coding_agent_permission_mode, + coding_agent_workdir: wire.coding_agent_workdir, + coding_agent_voice_hotkey: wire.coding_agent_voice_hotkey, + coding_agent_panel_hotkey: wire.coding_agent_panel_hotkey, + coding_agent_quick_hotkey: wire.coding_agent_quick_hotkey, custom_combo_hotkey: wire.custom_combo_hotkey, translation_hotkey: wire .translation_hotkey @@ -1012,6 +1069,28 @@ fn default_qa_hotkey() -> Option { Some(ShortcutBinding::default_qa()) } +fn default_coding_agent_provider() -> String { + "claude-code-cli".to_string() +} + +fn default_coding_agent_permission_mode() -> String { + "acceptEdits".to_string() +} + +pub(crate) fn default_coding_agent_voice_hotkey() -> Option { + Some(ShortcutBinding { + primary: "LeftControl".into(), + modifiers: Vec::new(), + }) +} + +pub(crate) fn default_coding_agent_panel_hotkey() -> Option { + Some(ShortcutBinding { + primary: "Enter".into(), + modifiers: vec!["cmd".into(), "shift".into()], + }) +} + fn default_translation_hotkey() -> ShortcutBinding { ShortcutBinding { primary: "Shift".into(), @@ -1670,6 +1749,14 @@ impl Default for UserPreferences { translation_hotkey: default_translation_hotkey(), switch_style_hotkey: default_switch_style_hotkey(), open_app_hotkey: default_open_app_hotkey(), + coding_agent_enabled: false, + coding_agent_provider: default_coding_agent_provider(), + coding_agent_model: None, + coding_agent_permission_mode: default_coding_agent_permission_mode(), + coding_agent_workdir: None, + coding_agent_voice_hotkey: default_coding_agent_voice_hotkey(), + coding_agent_panel_hotkey: default_coding_agent_panel_hotkey(), + coding_agent_quick_hotkey: None, local_asr_active_model: default_local_asr_model(), local_asr_mirror: default_local_asr_mirror(), local_asr_keep_loaded_secs: default_local_asr_keep_loaded_secs(), @@ -2245,6 +2332,10 @@ pub struct CapsulePayload { /// 当前 session 是否处于翻译模式(用户按过 Shift)。前端用它在胶囊顶部 /// 渲染"正在翻译"标签,让用户立刻知道这次输出会走翻译管线。详见 issue #4。 pub translation: bool, + /// 当前是否是 Less Computer(语音 Agent 操控电脑)会话。前端据此把处理态文案 + /// 从 "thinking" 换成 "using"——告诉用户 Agent 正在操作电脑而非单纯思考。 + #[serde(default)] + pub operating: bool, } /// Snapshot of credentials read from vault — only what the UI needs to know @@ -2369,7 +2460,10 @@ mod tests { .unwrap(); let binding = prefs.switch_style_hotkey.expect("应保留为 Some"); assert_eq!(binding.primary, "S"); - assert_eq!(binding.modifiers, vec!["cmd".to_string(), "shift".to_string()]); + assert_eq!( + binding.modifiers, + vec!["cmd".to_string(), "shift".to_string()] + ); } #[test] diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index 285280ae..5e79db90 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -63,6 +63,39 @@ "visible": false, "center": false, "acceptFirstMouse": true + }, + { + "label": "less-computer", + "url": "index.html?window=less-computer", + "title": "OpenLess Less Computer", + "width": 400, + "height": 200, + "decorations": false, + "transparent": true, + "shadow": true, + "alwaysOnTop": true, + "skipTaskbar": true, + "resizable": false, + "focus": false, + "visible": false, + "center": false, + "acceptFirstMouse": true + }, + { + "label": "less-computer-glow", + "url": "index.html?window=less-computer-glow", + "title": "OpenLess Less Computer Glow", + "width": 800, + "height": 600, + "decorations": false, + "transparent": true, + "shadow": false, + "alwaysOnTop": true, + "skipTaskbar": true, + "resizable": false, + "focus": false, + "visible": false, + "center": false } ], "security": { diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index ff5dd5fc..1953cf97 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -17,24 +17,34 @@ import { windowMouseHotkeyCode, } from './lib/windowHotkeyFallback'; import { QaPanel } from './pages/QaPanel'; +import { LessComputerPanel } from './pages/LessComputerPanel'; +import { LessComputerGlow } from './pages/LessComputerGlow'; import { invoke } from '@tauri-apps/api/core'; import { HotkeySettingsProvider } from './state/HotkeySettingsContext'; interface AppProps { isCapsule: boolean; isQa: boolean; + isLessComputer: boolean; + isLessComputerGlow: boolean; forcedOs?: OS | null; } type Gate = 'onboarding' | 'ready'; -export function App({ isCapsule, isQa, forcedOs }: AppProps) { +export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, forcedOs }: AppProps) { if (isCapsule) { return ; } if (isQa) { return ; } + if (isLessComputer) { + return ; + } + if (isLessComputerGlow) { + return ; + } const os = forcedOs ?? detectOS(); // Windows 启动不应被权限探测阻塞首屏。 diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index 48065a57..10caaf90 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -105,6 +105,10 @@ function CircleButton({ variant, enabled, onClick }: CircleButtonProps) { return ( +
+ {onDisable && ( + + )} + +
{recording && (
{ return invokeOrMock("qa_window_pin", { pinned }, () => undefined) } +// ── Less Computer 浮窗 ──────────────────────────────────────────────── +/** 用户点 ✕ / 按 Esc 关闭 Less Computer 浮窗(隐藏窗口)。 */ +export function lessComputerWindowDismiss(): Promise { + return invokeOrMock("less_computer_window_dismiss", undefined, () => undefined) +} + +/** 内联审批卡的 Approve / Deny 回执。token 关联到等待中的拦截动作。 */ +export function lessComputerApprove( + token: string, + approved: boolean, +): Promise { + return invokeOrMock( + "less_computer_approve", + { token, approved }, + () => undefined, + ) +} + +/** 前端按内容测高后回传,后端 clamp + bottom-anchored 重新摆放浮窗。 */ +export function lessComputerWindowResize(height: number): Promise { + return invokeOrMock( + "less_computer_window_resize", + { height }, + () => undefined, + ) +} + // ── Combo Hotkey (自定义录音组合键) ─────────────────────────────────── export function validateComboHotkey(binding: ComboBinding): Promise { return invokeOrMock("validate_combo_hotkey", { binding }, () => undefined) @@ -1152,6 +1188,75 @@ export async function exportErrorLog( export { isTauri } +// ── Coding Agent / Claude 控制台 ─────────────────────────────────────── +export type { CodingAgentPermissionMode } + +export type McpHealth = "connected" | "failed" | "needs_auth" | "unknown" + +export interface McpServerStatus { + name: string + detail: string + health: McpHealth +} + +export interface ClaudeDetection { + installed: boolean + version: string | null + exe: string + mcpServers: McpServerStatus[] + hasComputerUse: boolean +} + +/** 无头 Claude 运行事件,由后端 `coding-agent:test` 流式推送(tag 为 `kind`)。 */ +export type CodingAgentEvent = + | { kind: "started"; session_id: string } + | { kind: "delta"; session_id: string; text: string } + | { kind: "tool_use"; session_id: string; name: string } + | { + kind: "completed" + session_id: string + text: string + cost_usd: number | null + duration_ms: number | null + } + | { kind: "cancelled"; session_id: string } + | { kind: "error"; session_id: string; message: string } + +export function codingAgentDetect(exe?: string): Promise { + return invokeOrMock( + "coding_agent_detect", + { exe }, + () => ({ + installed: false, + version: null, + exe: exe || "claude", + mcpServers: [], + hasComputerUse: false, + }), + ) +} + +export interface CodingAgentRunTestArgs { + prompt: string + exe?: string + permissionMode?: CodingAgentPermissionMode + workdir?: string + model?: string + maxBudgetUsd?: number +} + +export function codingAgentRunTest(args: CodingAgentRunTestArgs): Promise { + return invokeOrMock("coding_agent_run_test", { ...args }, () => undefined) +} + +export function codingAgentCancelTest(): Promise { + return invokeOrMock("coding_agent_cancel_test", undefined, () => undefined) +} + +export function codingAgentCommandRisk(command: string): Promise { + return invokeOrMock("coding_agent_command_risk", { command }, () => null) +} + // ── Marketplace (Phase A) ───────────────────────────────────────────── // 5 个 IPC wrapper —— marketplace-backend HTTP 通过 Rust IPC 转发。Mock fallback // 让 vite dev 在浏览器里也能预览 UI(返回空列表 / 假数据)。 diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index 66d08b68..dc4d43f1 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -49,6 +49,14 @@ const previousPrefs: UserPreferences = { translationHotkey: { primary: 'Shift', modifiers: [] }, switchStyleHotkey: { primary: 'S', modifiers: ['alt'] }, openAppHotkey: { primary: 'O', modifiers: ['alt'] }, + codingAgentEnabled: false, + codingAgentProvider: 'claude-code-cli', + codingAgentModel: null, + codingAgentPermissionMode: 'acceptEdits', + codingAgentWorkdir: null, + codingAgentVoiceHotkey: { primary: 'LeftControl', modifiers: [] }, + codingAgentPanelHotkey: { primary: 'Enter', modifiers: ['cmd', 'shift'] }, + codingAgentQuickHotkey: null, localAsrActiveModel: '', localAsrMirror: 'huggingface', localAsrKeepLoadedSecs: 300, diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 9e1e660c..343c54de 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -117,6 +117,13 @@ export type QaHotkeyBinding = ShortcutBinding; /** 自定义录音组合键绑定。当 hotkey.trigger == 'custom' 时使用。 */ export type ComboBinding = ShortcutBinding; +export type CodingAgentProviderId = "claude-code-cli" | "opencode-cli"; +export type CodingAgentPermissionMode = + | "plan" + | "default" + | "acceptEdits" + | "bypassPermissions"; + /** 模拟粘贴时按下的快捷键。仅 Windows/Linux 生效;macOS 走 AX 直写。 * - ctrlV : 标准粘贴(默认;大多数编辑器、浏览器、IDE) * - ctrlShiftV : kitty / alacritty / wezterm / gnome-terminal / foot 等终端 @@ -259,6 +266,22 @@ export interface UserPreferences { switchStyleHotkey: ShortcutBinding | null; /** 打开 OpenLess 主窗口的全局快捷键。null = 用户已停用(issue #576)。 */ openAppHotkey: ShortcutBinding | null; + /** Less Computer:是否启用。默认关闭。 */ + codingAgentEnabled: boolean; + /** Agent 后端:claude-code-cli(默认)/ opencode-cli。 */ + codingAgentProvider: CodingAgentProviderId; + /** Agent 模型,null = 运行时取便宜默认(sonnet)。 */ + codingAgentModel: string | null; + /** 权限模式:plan/default/acceptEdits/bypassPermissions。 */ + codingAgentPermissionMode: CodingAgentPermissionMode; + /** Agent 工作目录,null = 临时目录。 */ + codingAgentWorkdir: string | null; + /** Less Computer 按住说话快捷键。null = 停用;目前仅 macOS 显示/生效。 */ + codingAgentVoiceHotkey: ShortcutBinding | null; + /** 热键 1:语音 Agent 面板键。null = 停用。 */ + codingAgentPanelHotkey: ShortcutBinding | null; + /** 热键 2:快取用键(选中→Claude→回插)。null = 未配置。 */ + codingAgentQuickHotkey: ShortcutBinding | null; /** 本地 Qwen3-ASR 当前激活的模型 id。仅在 activeAsrProvider === 'local-qwen3' 时有意义。 */ localAsrActiveModel: string; /** 本地模型下载源镜像('huggingface' / 'hf-mirror')。 */ @@ -378,6 +401,28 @@ export interface QaStatePayload { chunk?: string; } +/** + * Less Computer 语音 Agent 浮窗事件(窗口 label = "less-computer",事件名 + * `less-computer:event`)。后端按 `kind` 标记,前端据此把交互渲染成聊天结构。 + */ +export type LessComputerEvent = + /** 一轮用户气泡(语音指令转写)。fresh=true 表示新会话(清空历史);否则追加为后续轮次。 */ + | { kind: 'user'; text: string; fresh?: boolean } + /** Agent 启动,进入运行态。 */ + | { kind: 'started' } + /** 流式回复增量(来自 CodingAgentEvent::Delta)。 */ + | { kind: 'delta'; text: string } + /** 工具调用提示(来自 CodingAgentEvent::ToolUse,如 "Bash")。 */ + | { kind: 'tool'; name: string } + /** 内联审批卡:高风险动作被护栏拦下,等用户 Approve / Deny。 */ + | { kind: 'approval'; token: string; command: string; reason: string } + /** 运行完成:最终结果 + 成本(美元)。 */ + | { kind: 'completed'; text: string; costUsd?: number | null } + /** 用户从胶囊取消正在运行的 Agent。 */ + | { kind: 'cancelled' } + /** 运行出错。 */ + | { kind: 'error'; message: string }; + /** 内置语言列表 — 前端 Settings UI 用,后端只接收原生名字符串拼 prompt。 * 添加新语言时直接在这里加一项(原生名),无需修改后端。 */ export const SUPPORTED_LANGUAGES: readonly string[] = [ @@ -415,6 +460,8 @@ export interface CapsulePayload { insertedChars: number | null; /** 当前 session 是否处于翻译模式(用户已按过 Shift)。详见 issue #4。 */ translation: boolean; + /** 当前是否是 Less Computer 会话:处理态文案显示 "using" 而非 "thinking"。 */ + operating?: boolean; } export interface CredentialsStatus { diff --git a/openless-all/app/src/main.tsx b/openless-all/app/src/main.tsx index 9ce653e1..f835aa19 100644 --- a/openless-all/app/src/main.tsx +++ b/openless-all/app/src/main.tsx @@ -11,6 +11,8 @@ const params = new URLSearchParams(window.location.search); const windowKind = params.get("window"); const isCapsule = windowKind === "capsule"; const isQa = windowKind === "qa"; +const isLessComputer = windowKind === "less-computer"; +const isLessComputerGlow = windowKind === "less-computer-glow"; const osQuery = params.get("os") as OS | null; const root = ReactDOM.createRoot(document.getElementById("root")!); @@ -18,7 +20,13 @@ const root = ReactDOM.createRoot(document.getElementById("root")!); const renderApp = () => { root.render( - + , ); }; diff --git a/openless-all/app/src/pages/LessComputerGlow.tsx b/openless-all/app/src/pages/LessComputerGlow.tsx new file mode 100644 index 00000000..2214c319 --- /dev/null +++ b/openless-all/app/src/pages/LessComputerGlow.tsx @@ -0,0 +1,102 @@ +// Less Computer 全屏彩虹边缘亮条(独立窗口 window=less-computer-glow)。 +// 只画贴边光带,不铺暗场;彩色弧段沿边缘流动,模拟 Apple Intelligence 的粗细变化。 +// 纯视觉:pointer-events:none,后端再 set_ignore_cursor_events(true)。仅 macOS 显示。 + +const glowCss = ` +@property --lcg-angle { syntax: ''; initial-value: 0deg; inherits: false; } +@keyframes lcg-spin { to { --lcg-angle: 360deg; } } +@keyframes lcg-breathe { 0%, 100% { opacity: .72; } 50% { opacity: .92; } } +@keyframes lcg-flow { 0%, 100% { opacity: .44; } 48% { opacity: .74; } } + +html, body, #root { background: transparent !important; margin: 0; height: 100%; overflow: hidden; } + +/* 全屏裁剪容器:圆角贴合屏幕物理圆角;overflow:hidden 把外溢模糊裁在屏幕边缘。 */ +.lcg-root { + --lcg-spectrum: conic-gradient(from calc(var(--lcg-angle) - 74deg), + #4e9dff 0deg, + #6cc9ff 40deg, + #9d82ff 82deg, + #e77dff 124deg, + #ff7aa8 162deg, + #ff9765 198deg, + #ffe070 236deg, + #bff47a 266deg, + #63e8a2 304deg, + #63d4ff 334deg, + #4e9dff 360deg); + position: fixed; + inset: 0; + pointer-events: none; + overflow: hidden; + border-radius: var(--lcg-radius, 42px); +} + +.lcg-edge, +.lcg-flow { + position: absolute; + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; + will-change: transform, filter, opacity, --lcg-angle; +} + +/* 贴边主光带:只保留边缘亮条,避免在屏幕中央铺暗场或彩雾。 */ +.lcg-edge { + inset: -4px; + border-radius: calc(var(--lcg-radius, 42px) + 4px); + padding: 12px; + background: var(--lcg-spectrum); + filter: blur(1.1px) saturate(1.36) brightness(1.08) + drop-shadow(0 0 7px rgba(95, 185, 255, .44)) + drop-shadow(0 0 10px rgba(255, 126, 168, .30)); + opacity: .84; + animation: lcg-spin 7.5s linear infinite, lcg-breathe 4.8s ease-in-out infinite; +} + +/* 彩色粗细流动层:仍然是边缘 ring,不向中间铺开。 */ +.lcg-flow { + inset: -7px; + border-radius: calc(var(--lcg-radius, 42px) + 7px); + padding: 18px; + background: conic-gradient(from calc(var(--lcg-angle) + 28deg), + rgba(31,140,255,0) 0deg, + rgba(91,166,255,.74) 28deg, + rgba(167,134,255,.58) 54deg, + rgba(240,92,255,0) 82deg, + rgba(240,92,255,0) 132deg, + rgba(255,138,94,.70) 164deg, + rgba(255,220,103,.52) 192deg, + rgba(217,255,63,0) 222deg, + rgba(217,255,63,0) 266deg, + rgba(100,232,164,.68) 294deg, + rgba(93,210,255,.56) 326deg, + rgba(31,140,255,0) 360deg); + filter: blur(4.5px) saturate(1.42) brightness(1.08); + opacity: .58; + mix-blend-mode: screen; + animation: lcg-spin 6.8s linear infinite reverse, lcg-flow 3.8s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .lcg-edge, + .lcg-flow { animation: none; } +} +`; + +if (typeof document !== 'undefined' && !document.getElementById('less-computer-glow-style')) { + const tag = document.createElement('style'); + tag.id = 'less-computer-glow-style'; + tag.textContent = glowCss; + document.head.appendChild(tag); +} + +export function LessComputerGlow() { + return ( +
+ + +
+ ); +} diff --git a/openless-all/app/src/pages/LessComputerPanel.tsx b/openless-all/app/src/pages/LessComputerPanel.tsx new file mode 100644 index 00000000..c271976d --- /dev/null +++ b/openless-all/app/src/pages/LessComputerPanel.tsx @@ -0,0 +1,627 @@ +// LessComputerPanel.tsx — Less Computer 语音 Agent 浮窗(窗口 label = "less-computer")。 +// +// 把「按住专用键说话 → Agent 操控电脑」的交互渲染成聊天结构: +// - 用户气泡:语音指令转写(`user` 事件,开启新会话并清空旧内容)。 +// - 助手气泡:流式回复(`delta` 累积)+ 工具调用 chip(`tool`)。 +// - 内联审批卡(`approval`):高风险动作被护栏拦下时弹 Approve / Deny。 +// - 完成(`completed`)落最终结果 + 成本;出错(`error`)红色样式。 +// +// 窗口随内容自适应高度(measure content → setSize),不可拖动、置顶、磨砂。 +// 仅 macOS 实际触发(后端只在 macOS 注册 Less Computer 热键并 emit 事件)。 +// 关闭:Esc / ✕ → less_computer_window_dismiss → 后端隐藏窗口。 + +import { + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { + isTauri, + lessComputerApprove, + lessComputerWindowDismiss, + lessComputerWindowResize, +} from '../lib/ipc'; +import type { LessComputerEvent } from '../lib/types'; +import { renderQaMarkdown, renderQaPlainText } from '../lib/qaMarkdown'; + +type RunStatus = 'idle' | 'working' | 'done' | 'error' | 'cancelled'; + +interface ToolChip { + kind: 'tool'; + name: string; +} + +interface ApprovalCard { + kind: 'approval'; + token: string; + command: string; + reason: string; + /** 用户已点过的结果,决定按钮禁用态。undefined = 待处理。 */ + decision?: 'approved' | 'denied'; +} + +/** 助手回复流里穿插的工具 chip / 审批卡,按到达顺序排列。 */ +type Activity = ToolChip | ApprovalCard; + +/** 一轮对话:用户一句 + 助手流式回复 + 其间的工具/审批 + 本轮收尾态。连续对话累积成数组。 */ +interface Turn { + user: string; + answer: string; + activities: Activity[]; + status: RunStatus; + errorMsg: string; + costUsd: number | null; +} + +/** 对 turns 数组「最后一轮」做不可变更新。 */ +function updateLastTurn(turns: Turn[], fn: (t: Turn) => Turn): Turn[] { + if (turns.length === 0) return turns; + return [...turns.slice(0, -1), fn(turns[turns.length - 1])]; +} + +const WINDOW_MIN_HEIGHT = 120; +const WINDOW_MAX_HEIGHT = 520; +const TOOLBAR_HEIGHT = 28; + +export function LessComputerPanel() { + const { t } = useTranslation(); + // 连续对话:每按一次说话键追加一轮(除非后端标记 fresh=新会话则清空重开)。 + const [turns, setTurns] = useState([]); + + // ── 后端事件订阅(mount 一次)──────────────────────────────────────── + useEffect(() => { + if (!isTauri) return; + let unlisten: (() => void) | undefined; + let cancelled = false; + (async () => { + try { + const { listen } = await import('@tauri-apps/api/event'); + const handle = await listen( + 'less-computer:event', + event => applyEvent(event.payload), + ); + if (cancelled) handle(); + else unlisten = handle; + } catch (error) { + console.error('[LessComputer] listener setup failed', error); + } + })(); + return () => { + cancelled = true; + unlisten?.(); + }; + }, []); + + const applyEvent = (ev: LessComputerEvent) => { + switch (ev.kind) { + case 'user': { + // 一轮新对话。fresh=true(后端无可续会话→新会话)则清空历史重开;否则追加为后续轮次。 + const fresh: Turn = { + user: ev.text, + answer: '', + activities: [], + status: 'working', + errorMsg: '', + costUsd: null, + }; + setTurns(prev => (ev.fresh ? [fresh] : [...prev, fresh])); + break; + } + case 'started': + setTurns(prev => updateLastTurn(prev, tn => ({ ...tn, status: 'working' }))); + break; + case 'delta': + setTurns(prev => updateLastTurn(prev, tn => ({ ...tn, answer: tn.answer + ev.text }))); + break; + case 'tool': + setTurns(prev => + updateLastTurn(prev, tn => ({ + ...tn, + activities: [...tn.activities, { kind: 'tool', name: ev.name }], + })), + ); + break; + case 'approval': + setTurns(prev => + updateLastTurn(prev, tn => ({ + ...tn, + activities: [ + ...tn.activities, + { kind: 'approval', token: ev.token, command: ev.command, reason: ev.reason }, + ], + })), + ); + break; + case 'completed': + setTurns(prev => + updateLastTurn(prev, tn => ({ + ...tn, + answer: ev.text || tn.answer, + costUsd: ev.costUsd ?? null, + status: 'done', + })), + ); + break; + case 'error': + setTurns(prev => updateLastTurn(prev, tn => ({ ...tn, errorMsg: ev.message, status: 'error' }))); + break; + case 'cancelled': + setTurns(prev => updateLastTurn(prev, tn => ({ ...tn, status: 'cancelled' }))); + break; + } + }; + + const onApproval = (token: string, approved: boolean) => { + setTurns(prev => + prev.map(tn => ({ + ...tn, + activities: tn.activities.map(a => + a.kind === 'approval' && a.token === token + ? { ...a, decision: approved ? 'approved' : 'denied' } + : a, + ), + })), + ); + void lessComputerApprove(token, approved); + }; + + const onClose = () => void lessComputerWindowDismiss(); + + // ── Esc 关闭 ──────────────────────────────────────────────────────── + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + void lessComputerWindowDismiss(); + } + }; + window.addEventListener('keydown', onKey, true); + return () => window.removeEventListener('keydown', onKey, true); + }, []); + + // ── 内容自适应:measure 内容高 + toolbar → 回传后端 clamp + bottom-anchored 摆放, + // 让内容增长向上撑开。超出 max 则窗口内部滚动。 + const contentRef = useRef(null); + useEffect(() => { + if (!isTauri) return; + const el = contentRef.current; + if (!el) return; + const measured = Math.ceil(el.scrollHeight) + TOOLBAR_HEIGHT; + const target = Math.min(WINDOW_MAX_HEIGHT, Math.max(WINDOW_MIN_HEIGHT, measured)); + void lessComputerWindowResize(target); + }, [turns]); + + // 自动滚动到底 + const scrollRef = useRef(null); + useEffect(() => { + const el = scrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [turns]); + + return ( +
+
+ +
+
+ {turns.map((turn, ti) => ( + + ))} +
+
+
+ ); +} + +// ── 子组件 ──────────────────────────────────────────────────────────── + +/** 渲染单轮对话:用户气泡 → 工具/审批 → 助手流式回复 → 收尾(错误/花费)。 */ +function TurnView({ + turn, + onApproval, + t, +}: { + turn: Turn; + onApproval: (token: string, approved: boolean) => void; + t: ReturnType['t']; +}) { + return ( + <> + + {turn.activities.map((a, i) => + a.kind === 'tool' ? ( + + ) : ( + + ), + )} + {turn.answer && } + {turn.status === 'working' && !turn.answer && } + {turn.status === 'error' && } + {turn.status === 'cancelled' && } + {turn.status === 'done' && turn.costUsd != null && ( + + )} + + ); +} + +function Toolbar({ label, onClose }: { label: string; onClose: () => void }) { + return ( + // 顶栏作为拖动把手:按住空白处可把整个聊天框拖到屏幕任意位置(resize 会保住拖后的位置)。 +
+
+ +
+ ); +} + +function UserBubble({ text, label }: { text: string; label: string }) { + return ( +
+ {label} +
{text}
+
+ ); +} + +function AssistantBubble({ markdown, working }: { markdown: string; working: boolean }) { + const html = useMemo(() => { + try { + return renderQaMarkdown(markdown); + } catch (error) { + console.error('[LessComputer] markdown render failed', error); + return renderQaPlainText(String(markdown ?? '')); + } + }, [markdown]); + return ( +
+
+ {working && } +
+ ); +} + +function ToolChipRow({ + name, + t, +}: { + name: string; + t: ReturnType['t']; +}) { + return ( +
+ + + {'\u{1F6E0}'} + + {t('lessComputer.tool', { name })} + +
+ ); +} + +function WorkingRow({ label }: { label: string }) { + return ( +
+ + {label} +
+ ); +} + +function ApprovalRow({ + card, + onDecide, + t, +}: { + card: ApprovalCard; + onDecide: (token: string, approved: boolean) => void; + t: ReturnType['t']; +}) { + const decided = card.decision != null; + return ( +
+
+ {t('lessComputer.approvalTitle')} +
+ {card.command} +
{card.reason}
+ {decided ? ( +
+ {card.decision === 'approved' + ? t('lessComputer.approved') + : t('lessComputer.denied')} +
+ ) : ( +
+ + +
+ )} +
+ ); +} + +function ErrorRow({ message }: { message: string }) { + return ( +
+ {message} +
+ ); +} + +function CostRow({ label }: { label: string }) { + return ( +
+ {label} +
+ ); +} + +// ── 样式 ────────────────────────────────────────────────────────────── + +const shellStyle: CSSProperties = { + width: '100%', + height: '100vh', + display: 'flex', + flexDirection: 'column', + borderRadius: 14, + overflow: 'hidden', + border: '0.5px solid rgba(0, 0, 0, 0.12)', + background: 'rgba(246, 247, 250, 0.88)', + boxShadow: '0 18px 44px -18px rgba(15,17,22,.28), 0 0 0 0.5px rgba(255,255,255,.7) inset', + fontFamily: 'var(--ol-font-sans)', + color: 'var(--ol-ink)', + isolation: 'isolate', +}; + +const toolbarStyle: CSSProperties = { + height: 28, + display: 'flex', + alignItems: 'center', + padding: '0 8px', + borderBottom: '0.5px solid rgba(0, 0, 0, 0.08)', + background: + 'linear-gradient(180deg, rgba(255,255,255,0.74), rgba(238,240,245,0.58))', + boxShadow: '0 1px 0 rgba(255,255,255,.55) inset', + backdropFilter: 'blur(18px) saturate(150%)', + WebkitBackdropFilter: 'blur(18px) saturate(150%)', + flexShrink: 0, + position: 'relative', + zIndex: 1, +}; + +const closeBtnStyle: CSSProperties = { + width: 22, + height: 22, + border: 0, + borderRadius: 6, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'default', + padding: 0, + background: 'transparent', + color: 'var(--ol-ink-3)', + transition: 'background 0.16s var(--ol-motion-quick)', +}; + +const scrollStyle: CSSProperties = { + flex: 1, + minHeight: 0, + overflow: 'auto', + position: 'relative', + zIndex: 1, +}; + +const contentStyle: CSSProperties = { + padding: 14, + display: 'flex', + flexDirection: 'column', + gap: 10, +}; + +const roleLabelStyle: CSSProperties = { + fontSize: 10.5, + color: 'var(--ol-ink-4)', + fontWeight: 600, +}; + +const userBubbleStyle: CSSProperties = { + maxWidth: '85%', + padding: '8px 12px', + borderRadius: 14, + borderBottomRightRadius: 4, + background: 'var(--ol-blue)', + color: '#fff', + fontSize: 13, + lineHeight: 1.55, + wordBreak: 'break-word', +}; + +const assistantBubbleStyle: CSSProperties = { + maxWidth: '92%', + padding: '8px 12px', + borderRadius: 14, + borderBottomLeftRadius: 4, + background: 'rgba(0,0,0,0.04)', + fontSize: 13, + lineHeight: 1.6, + color: 'var(--ol-ink)', + wordBreak: 'break-word', + alignSelf: 'flex-start', +}; + +const caretStyle: CSSProperties = { + display: 'inline-block', + width: 6, + height: 12, + background: 'var(--ol-blue)', + marginLeft: 12, + animation: 'lc-pulse 0.9s var(--ol-motion-soft) infinite', + borderRadius: 1, +}; + +const toolChipStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + fontSize: 11.5, + fontWeight: 500, + color: 'var(--ol-ink-2)', + background: 'rgba(0,0,0,0.045)', + border: '0.5px solid rgba(0,0,0,0.06)', + borderRadius: 8, + padding: '4px 8px', +}; + +const costChipStyle: CSSProperties = { + fontSize: 11, + fontWeight: 500, + color: 'var(--ol-ink-4)', + fontFamily: 'var(--ol-font-mono)', +}; + +const dotStyle: CSSProperties = { + width: 8, + height: 8, + borderRadius: '50%', + background: 'var(--ol-blue)', + animation: 'lc-pulse 1.2s var(--ol-motion-soft) infinite', +}; + +const approvalCardStyle: CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: 6, + padding: '10px 12px', + borderRadius: 12, + background: 'rgba(220,38,38,0.05)', + border: '0.5px solid rgba(220,38,38,0.20)', +}; + +const approvalCmdStyle: CSSProperties = { + fontFamily: 'var(--ol-font-mono)', + fontSize: 11.5, + color: 'var(--ol-ink)', + background: 'rgba(0,0,0,0.05)', + borderRadius: 6, + padding: '5px 8px', + wordBreak: 'break-all', +}; + +const approveBtnStyle: CSSProperties = { + flex: 1, + border: 0, + borderRadius: 8, + padding: '6px 10px', + fontSize: 12, + fontWeight: 600, + cursor: 'default', + background: 'var(--ol-blue)', + color: '#fff', +}; + +const denyBtnStyle: CSSProperties = { + flex: 1, + borderRadius: 8, + padding: '6px 10px', + fontSize: 12, + fontWeight: 600, + cursor: 'default', + background: 'transparent', + border: '0.5px solid rgba(0,0,0,0.14)', + color: 'var(--ol-ink-2)', +}; + +const errorRowStyle: CSSProperties = { + padding: '8px 12px', + borderRadius: 10, + background: 'rgba(220,38,38,0.06)', + border: '0.5px solid rgba(220,38,38,0.18)', +}; + +const globalCss = ` +@keyframes lc-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.35; transform: scale(.94); } +} +/* 内容进场:工具芯片 / 气泡 / 审批卡出现时柔和淡入上滑,而不是直接闪出。 */ +@keyframes lc-enter { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} +.lc-shell { position: relative; } +.lc-bg { + position: absolute; + inset: -1px; + border-radius: inherit; + background: + radial-gradient(120% 80% at 18% 0%, rgba(255,255,255,.72), rgba(255,255,255,0) 58%), + linear-gradient(180deg, rgba(248,249,252,.78), rgba(235,238,244,.72)); + pointer-events: none; + z-index: 0; +} +.lc-enter { animation: lc-enter 0.30s var(--ol-motion-soft, cubic-bezier(.16,1,.3,1)) both; } +@media (prefers-reduced-motion: reduce) { + .lc-enter { animation: none; } +} +.lc-answer p { margin: 0 0 6px; } +.lc-answer p:last-child { margin-bottom: 0; } +.lc-answer ul, +.lc-answer ol { margin: 0 0 6px; padding-left: 18px; } +.lc-answer li { margin: 2px 0; } +.lc-answer code { font-family: var(--ol-font-mono); font-size: 12px; + padding: 1px 5px; border-radius: 4px; + background: rgba(0,0,0,0.05); } +.lc-answer pre { margin: 0 0 6px; padding: 8px 10px; + border-radius: 8px; background: rgba(0,0,0,0.05); + overflow-x: auto; } +.lc-answer pre code { padding: 0; background: transparent; } +.lc-answer a { color: var(--ol-blue); text-decoration: none; } +`; + +if (typeof document !== 'undefined' && !document.getElementById('less-computer-panel-style')) { + const tag = document.createElement('style'); + tag.id = 'less-computer-panel-style'; + tag.textContent = globalCss; + document.head.appendChild(tag); +} diff --git a/openless-all/app/src/pages/settings/ClaudeConsoleSection.tsx b/openless-all/app/src/pages/settings/ClaudeConsoleSection.tsx new file mode 100644 index 00000000..ee0ef786 --- /dev/null +++ b/openless-all/app/src/pages/settings/ClaudeConsoleSection.tsx @@ -0,0 +1,292 @@ +// 高级 → Claude 控制台:检测 claude 安装 / MCP(computer use)状态, +// 并护栏化地无头跑一次 claude、流式查看输出与用量。这是「快速 Agent」引擎的 +// 最小可用垂直切片,不依赖录音 / coordinator。 + +import { useEffect, useRef, useState, type CSSProperties } from 'react' +import { useTranslation } from 'react-i18next' +import { + codingAgentCancelTest, + codingAgentCommandRisk, + codingAgentDetect, + codingAgentRunTest, + isTauri, + type ClaudeDetection, + type CodingAgentEvent, + type CodingAgentPermissionMode, +} from '../../lib/ipc' +import { Btn, Card } from '../_atoms' +import { SectionDesc, SectionTitle, SettingRow, inputStyle } from './shared' + +const PERMISSION_MODES: CodingAgentPermissionMode[] = [ + 'acceptEdits', + 'plan', + 'default', + 'bypassPermissions', +] + +const consoleStyle: CSSProperties = { + margin: 0, + marginTop: 10, + padding: '12px 14px', + minHeight: 96, + maxHeight: 260, + overflow: 'auto', + borderRadius: 10, + background: '#0f1117', + color: '#d7dbe0', + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', + fontSize: 12, + lineHeight: 1.6, + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', +} + +export function ClaudeConsoleSection() { + const { t } = useTranslation() + const [detection, setDetection] = useState(null) + const [detecting, setDetecting] = useState(false) + const [exe, setExe] = useState('claude') + const [workdir, setWorkdir] = useState('') + const [permMode, setPermMode] = useState('acceptEdits') + const [prompt, setPrompt] = useState('') + const [running, setRunning] = useState(false) + const [output, setOutput] = useState('') + const [summary, setSummary] = useState(null) + const [risk, setRisk] = useState(null) + // 控制台默认折叠:测试用的重型 UI(检测/输入/输出)平时不展开,保持设置页清爽。 + const [expanded, setExpanded] = useState(false) + const outRef = useRef(null) + + async function runDetect() { + setDetecting(true) + try { + setDetection(await codingAgentDetect(exe.trim() || undefined)) + } finally { + setDetecting(false) + } + } + + // 订阅后端流式事件。 + useEffect(() => { + if (!isTauri) return + let unlisten: (() => void) | undefined + let alive = true + void (async () => { + const { listen } = await import('@tauri-apps/api/event') + const un = await listen('coding-agent:test', e => { + const ev = e.payload + switch (ev.kind) { + case 'started': + setOutput('') + setSummary(null) + break + case 'delta': + setOutput(prev => prev + ev.text) + break + case 'tool_use': + setOutput(prev => `${prev}\n· ${t('settings.codingConsole.toolUse', { name: ev.name })}\n`) + break + case 'completed': + setRunning(false) + setSummary( + ev.cost_usd != null + ? t('settings.codingConsole.doneCost', { cost: ev.cost_usd.toFixed(4) }) + : t('settings.codingConsole.done'), + ) + if (ev.text.trim()) setOutput(prev => (prev.trim() ? prev : ev.text)) + break + case 'cancelled': + setRunning(false) + setSummary(t('settings.codingConsole.cancelled')) + break + case 'error': + setRunning(false) + setSummary(`✗ ${ev.message}`) + break + } + }) + if (alive) unlisten = un + else un() + })() + return () => { + alive = false + unlisten?.() + } + }, [t]) + + // 首次自动检测。 + useEffect(() => { + void runDetect() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // 输出自动滚到底。 + useEffect(() => { + if (outRef.current) outRef.current.scrollTop = outRef.current.scrollHeight + }, [output]) + + const onRun = async () => { + const p = prompt.trim() + if (!p || running) return + setRisk(await codingAgentCommandRisk(p)) + setRunning(true) + setOutput('') + setSummary(null) + try { + await codingAgentRunTest({ + prompt: p, + exe: exe.trim() || undefined, + permissionMode: permMode, + workdir: workdir.trim() || undefined, + maxBudgetUsd: 0.5, + }) + } catch (err) { + setRunning(false) + setSummary(`✗ ${err instanceof Error ? err.message : String(err)}`) + } + } + + const installed = detection?.installed === true + + return ( + + + {t('settings.codingConsole.desc')} + + {expanded && ( + <> + +
+
+ void runDetect()}> + {detecting ? t('settings.codingConsole.detecting') : t('settings.codingConsole.detect')} + + {detection && ( + + {installed + ? `${t('settings.codingConsole.installed')} · v${detection.version ?? '?'}` + : t('settings.codingConsole.notInstalled')} + + )} +
+ {detection && !installed && ( + + {t('settings.codingConsole.notInstalledHint')} + + )} + {detection && installed && ( + + {t('settings.codingConsole.mcpServers', { count: detection.mcpServers.length })} + {' · '} + {detection.hasComputerUse + ? t('settings.codingConsole.computerUsePresent') + : t('settings.codingConsole.computerUseAbsent')} + + )} +
+
+ + + setExe(e.target.value)} + style={inputStyle} + /> + + + + setWorkdir(e.target.value)} + style={inputStyle} + /> + + + + + + +
+