diff --git a/openless-all/app/package-lock.json b/openless-all/app/package-lock.json index 9d43a18b2..0b20efc4d 100644 --- a/openless-all/app/package-lock.json +++ b/openless-all/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "openless-app", - "version": "1.3.15-Beta.2", + "version": "1.3.15-Beta.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openless-app", - "version": "1.3.15-Beta.2", + "version": "1.3.15-Beta.3", "dependencies": { "@base-ui/react": "^1.6.0", "@formkit/auto-animate": "^0.9.0", diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 144dff099..55ad9c471 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -1,7 +1,7 @@ { "name": "openless-app", "private": true, - "version": "1.3.15-Beta.2", + "version": "1.3.15-Beta.3", "type": "module", "scripts": { "pretest": "npm run build", diff --git a/openless-all/app/scripts/frontend-test-runner.test.mjs b/openless-all/app/scripts/frontend-test-runner.test.mjs index c33ef2fa7..97e4f0bf3 100644 --- a/openless-all/app/scripts/frontend-test-runner.test.mjs +++ b/openless-all/app/scripts/frontend-test-runner.test.mjs @@ -8,6 +8,7 @@ const discovered = discoverTestFiles(); for (const expected of [ 'scripts/check-android-updater-pubkey.mjs', 'scripts/check-hotkey-injection.mjs', + 'scripts/less-computer-opencode-contract.test.mjs', 'scripts/macos-capsule-spaces-contract.test.mjs', 'scripts/macos-speech-usage-description-contract.test.mjs', 'scripts/repository-owner-contract.test.mjs', diff --git a/openless-all/app/scripts/less-computer-opencode-contract.test.mjs b/openless-all/app/scripts/less-computer-opencode-contract.test.mjs new file mode 100644 index 000000000..91b97fa91 --- /dev/null +++ b/openless-all/app/scripts/less-computer-opencode-contract.test.mjs @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const appRoot = fileURLToPath(new URL('..', import.meta.url)); +const read = (relativePath) => readFile(join(appRoot, relativePath), 'utf8'); + +const [settings, ipc, opencode, dictation, coordinator, lib, onboarding, lessComputerIpc, qaCommands, credentialCommands, miscCommands] = await Promise.all([ + read('src/pages/settings/CodingAgentSection.tsx'), + read('src/lib/ipc/coding-agent.ts'), + read('src-tauri/src/coding_agent/opencode.rs'), + read('src-tauri/src/coordinator/dictation.rs'), + read('src-tauri/src/coordinator.rs'), + read('src-tauri/src/lib.rs'), + read('src/components/Onboarding.tsx'), + read('src/lib/ipc/less-computer.ts'), + read('src-tauri/src/commands/qa.rs'), + read('src-tauri/src/commands/credentials.rs'), + read('src-tauri/src/commands/misc.rs'), +]); + +assert( + settings.includes('codingAgentListOpencodeModels(exe, true)'), + 'OpenCode settings must automatically refresh the available models after detection', +); +assert( + settings.includes('settings.codingAgent.opencodeModelsRefresh'), + 'OpenCode settings must expose the localized manual refresh action', +); +assert( + ipc.includes('"coding_agent_list_opencode_models"'), + 'frontend IPC must call the OpenCode model-list command', +); +assert(opencode.includes('"--refresh"'), 'OpenCode model discovery must refresh its model cache'); +assert(opencode.includes('"--auto"'), 'OpenCode runs must use the current automatic permission flag'); +assert(opencode.includes('"--continue"'), 'OpenCode runs must preserve follow-up session context'); +assert( + !opencode.includes('--dangerously-skip-permissions'), + 'OpenCode adapter must not use the removed Claude-style permission flag', +); +assert( + dictation.includes('resolve_coding_agent_model(provider'), + 'Less Computer must resolve model defaults per provider', +); + +const microphoneMenuStart = lib.indexOf('fn build_microphone_tray_menu'); +const microphoneMenuEnd = lib.indexOf('pub(crate) fn refresh_tray_microphone_menu'); +const microphoneMenu = lib.slice(microphoneMenuStart, microphoneMenuEnd); +assert( + microphoneMenu.includes('TrayMicrophoneDeviceCache'), + 'tray menu construction must read the asynchronous microphone-device cache', +); +assert( + !microphoneMenu.includes('recorder::list_input_devices'), + 'tray menu construction must never enumerate CoreAudio devices on the AppKit main thread', +); +assert( + lib.includes('.name("openless-tray-mic-event".into())'), + 'native microphone notifications must dispatch enumeration to a background thread', +); +assert( + credentialCommands.includes('pub async fn get_credentials()') && + credentialCommands.includes('tauri::async_runtime::spawn_blocking'), + 'Keychain reads must not block the AppKit main thread while settings load', +); +assert( + miscCommands.includes('pub async fn list_microphone_devices()') && + miscCommands.includes('microphone device worker failed'), + 'settings microphone enumeration must not block the AppKit main thread', +); +assert( + onboarding.includes("t('onboarding.continueToSettings')"), + 'users must be able to configure OpenCode without granting unrelated system permissions first', +); +assert( + settings.includes('lessComputerWindowOpen()') && + lessComputerIpc.includes('"less_computer_window_open"') && + qaCommands.includes('window.label() != "main"'), + 'Advanced settings must expose a main-window-only text entry point for Less Computer', +); +const showLessComputerStart = lib.indexOf('pub(crate) fn show_less_computer_window'); +const showLessComputerEnd = lib.indexOf('pub(crate) fn hide_less_computer_window'); +const showLessComputer = lib.slice(showLessComputerStart, showLessComputerEnd); +assert( + showLessComputer.includes('window_clone.show()'), + 'the first lazy Less Computer window must clear Tauri visible=false before ordering its NSPanel', +); +assert( + showLessComputer.indexOf('run_on_main_thread') < + showLessComputer.indexOf('position_less_computer_window(&window_clone)'), + 'Less Computer NSPanel positioning must run on the AppKit main thread', +); +const submitTextStart = coordinator.indexOf('pub fn less_computer_submit_text'); +const submitTextEnd = coordinator.indexOf('pub fn history', submitTextStart); +const submitText = coordinator.slice(submitTextStart, submitTextEnd); +assert( + submitText.includes('tauri::async_runtime::spawn') && !submitText.includes('tokio::spawn'), + 'Less Computer text submit must spawn through the Tauri runtime from the WebKit IPC thread', +); + +const localeFiles = ['zh-CN.ts', 'zh-TW.ts', 'en.ts', 'ja.ts', 'ko.ts']; +const localizedKeys = [ + 'opencodeModelDefault', + 'opencodeModelHint', + 'opencodeModelsRefresh', + 'opencodeModelsRefreshing', + 'opencodeModelsLoaded', + 'opencodeModelsEmpty', + 'opencodeModelsError', + 'openPanel', + 'openPanelHint', + 'openPanelAction', +]; +for (const localeFile of localeFiles) { + const locale = await read(`src/i18n/${localeFile}`); + assert(locale.includes('continueToSettings:'), `${localeFile} is missing continueToSettings`); + for (const key of localizedKeys) { + assert(locale.includes(`${key}:`), `${localeFile} is missing ${key}`); + } +} + +console.log('less-computer-opencode-contract.test.mjs passed'); diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 995bd6174..0a4a2d1bc 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -3909,7 +3909,7 @@ dependencies = [ [[package]] name = "openless" -version = "1.3.15-Beta.2" +version = "1.3.15-Beta.3" dependencies = [ "anyhow", "arboard", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index f25b039aa..f100b4183 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openless" -version = "1.3.15-Beta.2" +version = "1.3.15-Beta.3" description = "OpenLess — local voice input that types where your cursor is" authors = ["OpenLess"] edition = "2021" diff --git a/openless-all/app/src-tauri/src/coding_agent/args.rs b/openless-all/app/src-tauri/src/coding_agent/args.rs index 2a7f3ea02..8005b85c5 100644 --- a/openless-all/app/src-tauri/src/coding_agent/args.rs +++ b/openless-all/app/src-tauri/src/coding_agent/args.rs @@ -33,6 +33,21 @@ impl CodingAgentProvider { } } +/// 按后端解析用户选择的模型。Claude 保持既有的 sonnet 默认;OpenCode 只接受 +/// `provider/model`,未选择或遗留的 Claude 别名均交给 OpenCode 自己的默认配置。 +pub fn resolve_coding_agent_model( + provider: CodingAgentProvider, + configured: Option, +) -> Option { + let configured = configured + .map(|model| model.trim().to_string()) + .filter(|model| !model.is_empty()); + match provider { + CodingAgentProvider::ClaudeCodeCli => configured.or_else(|| Some("sonnet".to_string())), + CodingAgentProvider::OpenCodeCli => configured.filter(|model| model.contains('/')), + } +} + /// Claude Code 权限模式,对应 CLI `--permission-mode` 的取值(已对本机 v2.1.161 核实)。 #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] @@ -285,4 +300,30 @@ mod tests { assert_eq!(CodingAgentProvider::ClaudeCodeCli.default_exe(), "claude"); assert_eq!(CodingAgentProvider::OpenCodeCli.default_exe(), "opencode"); } + + #[test] + fn provider_specific_model_defaults_do_not_leak_sonnet_into_opencode() { + assert_eq!( + resolve_coding_agent_model(CodingAgentProvider::ClaudeCodeCli, None), + Some("sonnet".to_string()) + ); + assert_eq!( + resolve_coding_agent_model(CodingAgentProvider::OpenCodeCli, None), + None + ); + assert_eq!( + resolve_coding_agent_model( + CodingAgentProvider::OpenCodeCli, + Some("sonnet".to_string()) + ), + None + ); + assert_eq!( + resolve_coding_agent_model( + CodingAgentProvider::OpenCodeCli, + Some("opencode/deepseek-v4-flash-free".to_string()) + ), + Some("opencode/deepseek-v4-flash-free".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 index 689289812..0ec50c7ab 100644 --- a/openless-all/app/src-tauri/src/coding_agent/commands.rs +++ b/openless-all/app/src-tauri/src/coding_agent/commands.rs @@ -12,7 +12,7 @@ use tauri::{AppHandle, Emitter, Window}; use super::detect::{has_computer_use_mcp, McpServerStatus}; use super::guard::build_guard_settings_json; -use super::opencode::detect_opencode; +use super::opencode::{detect_opencode, list_opencode_models}; use super::{ claude_mcp_list, create_git_snapshot, detect_claude, run_claude_agent, CodingAgentPermissionMode, CodingAgentRequest, @@ -38,7 +38,9 @@ fn validate_exe(exe: &str) -> Result<(), String> { if exe == "claude" { return Ok(()); } - return Err(format!("不允许的可执行文件名: {exe}(只接受 'claude' 或已知安装目录下的绝对路径)")); + return Err(format!( + "不允许的可执行文件名: {exe}(只接受 'claude' 或已知安装目录下的绝对路径)" + )); } // 绝对路径:必须规范化到已知 claude 安装目录之一 let path = std::path::Path::new(exe); @@ -46,11 +48,7 @@ fn validate_exe(exe: &str) -> Result<(), String> { return Err(format!("不允许的相对路径: {exe}")); } // 已知 claude 安装目录前缀 - let known_prefixes: &[&str] = &[ - "/usr/local/bin/", - "/usr/bin/", - "/opt/homebrew/bin/", - ]; + let known_prefixes: &[&str] = &["/usr/local/bin/", "/usr/bin/", "/opt/homebrew/bin/"]; // 也允许 ~/.local/bin/claude(用户目录绝对路径,动态计算) let home_prefix = std::env::var("HOME") .ok() @@ -58,11 +56,15 @@ fn validate_exe(exe: &str) -> Result<(), String> { let exe_norm = exe.replace('\\', "/"); let allowed = known_prefixes.iter().any(|p| exe_norm.starts_with(p)) - || home_prefix.as_deref().map_or(false, |p| exe_norm.starts_with(p)); + || home_prefix + .as_deref() + .map_or(false, |p| exe_norm.starts_with(p)); if allowed { Ok(()) } else { - Err(format!("不允许的 claude 路径: {exe}(必须位于已知安装目录)")) + Err(format!( + "不允许的 claude 路径: {exe}(必须位于已知安装目录)" + )) } } @@ -135,25 +137,29 @@ pub struct OpenCodeDetectionWire { pub exe: String, } -/// 检测 `opencode` 是否安装、版本。语音 Agent 选了 OpenCode 后端时,设置页据此提示 -/// 用户是否需要先 `npm i -g opencode-ai` / 登录。 -#[tauri::command] -pub async fn coding_agent_detect_opencode( - window: Window, - exe: Option, -) -> Result { - ensure_main_window(&window)?; +fn normalize_opencode_exe(exe: Option) -> Result { let exe = exe .map(|e| e.trim().to_string()) .filter(|e| !e.is_empty()) .unwrap_or_else(|| "opencode".to_string()); - // 拒绝路径遍历和相对路径(--version 探测仅允许裸名或绝对路径)。 if exe.contains("..") { return Err("不允许的可执行文件路径: 包含 '..'".into()); } if (exe.contains('/') || exe.contains('\\')) && !std::path::Path::new(&exe).is_absolute() { return Err("不允许的相对路径,仅接受裸可执行文件名或绝对路径".into()); } + Ok(exe) +} + +/// 检测 `opencode` 是否安装、版本。语音 Agent 选了 OpenCode 后端时,设置页据此提示 +/// 用户是否需要先 `npm i -g opencode-ai` / 登录。 +#[tauri::command] +pub async fn coding_agent_detect_opencode( + window: Window, + exe: Option, +) -> Result { + ensure_main_window(&window)?; + let exe = normalize_opencode_exe(exe)?; let version = detect_opencode(&exe).await; Ok(OpenCodeDetectionWire { installed: version.is_some(), @@ -162,6 +168,18 @@ pub async fn coding_agent_detect_opencode( }) } +/// 拉取 OpenCode 当前账号可用模型,供 Less Computer 设置页自动填充模型选择器。 +#[tauri::command] +pub async fn coding_agent_list_opencode_models( + window: Window, + exe: Option, + refresh: Option, +) -> Result, String> { + ensure_main_window(&window)?; + let exe = normalize_opencode_exe(exe)?; + list_opencode_models(&exe, refresh.unwrap_or(true)).await +} + /// 护栏化地无头跑一次 claude,事件流式 emit 到前端 `coding-agent:test`。 /// /// 安全:附 `--settings`(acceptEdits + 高风险 deny)、`--max-budget-usd` 成本上限; diff --git a/openless-all/app/src-tauri/src/coding_agent/mod.rs b/openless-all/app/src-tauri/src/coding_agent/mod.rs index 8cce6188c..b831cf60d 100644 --- a/openless-all/app/src-tauri/src/coding_agent/mod.rs +++ b/openless-all/app/src-tauri/src/coding_agent/mod.rs @@ -25,7 +25,8 @@ use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::process::Command; pub use args::{ - build_claude_args, CodingAgentPermissionMode, CodingAgentProvider, CodingAgentRequest, + build_claude_args, resolve_coding_agent_model, CodingAgentPermissionMode, CodingAgentProvider, + CodingAgentRequest, }; pub use detect::McpServerStatus; pub use opencode::run_opencode_agent; diff --git a/openless-all/app/src-tauri/src/coding_agent/opencode.rs b/openless-all/app/src-tauri/src/coding_agent/opencode.rs index ec00a2ded..a9f156ebc 100644 --- a/openless-all/app/src-tauri/src/coding_agent/opencode.rs +++ b/openless-all/app/src-tauri/src/coding_agent/opencode.rs @@ -5,8 +5,9 @@ //! OpenCode CLI([opencode.ai](https://opencode.ai)): //! //! - 检测:`opencode --version` → 复用 [`super::detect::parse_claude_version`] 取 `x.y.z`。 +//! - 模型:`opencode models --refresh` → 拉取当前账号可用的 `provider/model` 列表。 //! - 运行:`opencode run --format json --model --dir -//! [--dangerously-skip-permissions]`,**prompt 作为命令行参数**(OpenCode 从 argv 读, +//! [--auto]`,**prompt 作为命令行参数**(OpenCode 从 argv 读, //! 不像 claude 从 stdin),逐行解析 JSON 事件。 //! - 护栏:OpenCode 无 `--settings`,但支持 `permission` 配置;用 //! `OPENCODE_CONFIG_CONTENT` 环境变量注入 inline 护栏 JSON(precedence 高于项目 @@ -15,6 +16,7 @@ //! 且**没有**带 cost 的终局 `result` 事件——本模块在 EOF 处合成一条 //! [`CodingAgentEvent::Completed`],`cost_usd = None`(OpenCode CLI 不在 JSON 里给单次成本)。 +use std::collections::HashSet; use std::process::Stdio; use std::sync::atomic::AtomicBool; use std::sync::Arc; @@ -28,15 +30,84 @@ use super::{CodingAgentError, CodingAgentPermissionMode}; /// 探测 `opencode` 版本(`None` 表示未安装或无法运行)。复用 claude 的 `x.y.z` 解析。 pub async fn detect_opencode(exe: &str) -> Option { - let out = augmented_command(exe).arg("--version").output().await.ok()?; + let out = augmented_command(exe) + .arg("--version") + .output() + .await + .ok()?; if !out.status.success() { return None; } super::detect::parse_claude_version(&String::from_utf8_lossy(&out.stdout)) } -/// OpenCode 权限模式到 CLI 的映射。OpenCode 只有一个二元的 -/// `--dangerously-skip-permissions`(绕过所有非 deny 规则),没有 claude 的四档模式。 +/// 去掉 OpenCode CLI 状态行里的 ANSI CSI 转义序列,保留普通文本。 +fn strip_ansi_csi(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + while let Some(ch) = chars.next() { + if ch != '\u{1b}' || chars.peek() != Some(&'[') { + out.push(ch); + continue; + } + let _ = chars.next(); + for code in chars.by_ref() { + if ('@'..='~').contains(&code) { + break; + } + } + } + out +} + +/// 解析 `opencode models` 输出,只保留合法的 `provider/model`,保持 CLI 顺序并去重。 +pub fn parse_opencode_models_output(stdout: &str) -> Vec { + let mut seen = HashSet::new(); + strip_ansi_csi(stdout) + .lines() + .map(str::trim) + .filter(|line| { + !line.is_empty() + && line.contains('/') + && !line.chars().any(char::is_whitespace) + && line.len() <= 256 + }) + .filter(|line| seen.insert((*line).to_string())) + .map(str::to_string) + .collect() +} + +/// 从 OpenCode 拉取当前账号可用模型。`refresh=true` 时同步刷新 models.dev 缓存。 +pub async fn list_opencode_models(exe: &str, refresh: bool) -> Result, String> { + let mut cmd = augmented_command(exe); + cmd.arg("models").kill_on_drop(true); + if refresh { + cmd.arg("--refresh"); + } + let output = tokio::time::timeout(Duration::from_secs(45), cmd.output()) + .await + .map_err(|_| "OpenCode 模型拉取超时(45 秒)".to_string())? + .map_err(|e| format!("无法运行 OpenCode 模型命令: {e}"))?; + if !output.status.success() { + let stderr = strip_ansi_csi(&String::from_utf8_lossy(&output.stderr)); + let summary = stderr + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("OpenCode 模型命令执行失败") + .trim(); + return Err(summary.chars().take(512).collect()); + } + let models = parse_opencode_models_output(&String::from_utf8_lossy(&output.stdout)); + if models.is_empty() { + Err("OpenCode 没有返回可用模型,请先完成登录或配置模型提供商".to_string()) + } else { + Ok(models) + } +} + +/// OpenCode 权限模式到 CLI 的映射。OpenCode 1.17 使用 `--auto` 自动批准未被配置显式 +/// deny 的权限,没有 claude 的四档模式。 /// 我们靠注入的 `permission` 护栏配置(deny 高风险)做真正的拦截,因此除 `Plan` 外都需要 /// 加 skip 标志,否则无头下「ask」会卡死或被默认拒。`Plan` 不传 skip(OpenCode 自身只读)。 fn skip_permissions_flag(mode: CodingAgentPermissionMode) -> bool { @@ -62,7 +133,10 @@ pub fn build_opencode_args(req: &CodingAgentRequest) -> Vec { args.push(cwd.to_string_lossy().into_owned()); } if skip_permissions_flag(req.permission_mode) { - args.push("--dangerously-skip-permissions".into()); + args.push("--auto".into()); + } + if req.continue_session { + args.push("--continue".into()); } // end-of-options:其后由运行器追加的 prompt 一律按位置参数处理,不再解析成 flag。 args.push("--".into()); @@ -103,11 +177,16 @@ pub fn parse_opencode_json_line(session_id: &str, line: &str) -> Option { - let message = match v.get("error") { - Some(serde_json::Value::String(s)) => s.clone(), - Some(other) => other.to_string(), - None => "agent 返回错误".to_string(), - }; + let message = v + .pointer("/error/data/message") + .and_then(serde_json::Value::as_str) + .or_else(|| { + v.pointer("/error/message") + .and_then(serde_json::Value::as_str) + }) + .or_else(|| v.get("error").and_then(serde_json::Value::as_str)) + .map(str::to_string) + .unwrap_or_else(|| "OpenCode 返回了未知错误".to_string()); Some(CodingAgentEvent::Error { session_id: session_id.to_string(), message, @@ -282,7 +361,7 @@ mod tests { #[test] fn end_of_options_marker_is_last_even_with_flags() { - // 即便带 --model / --dir / --dangerously-skip-permissions,`--` 仍在最末, + // 即便带 --model / --dir / --auto,`--` 仍在最末, // 保证以 `-` 开头的 prompt 不会被解析成 flag。 let mut req = CodingAgentRequest::new("s", "p"); req.model = Some("anthropic/claude-sonnet-4".into()); @@ -290,20 +369,56 @@ mod tests { req.permission_mode = CodingAgentPermissionMode::AcceptEdits; let args = build_opencode_args(&req); assert_eq!(args.last().map(|s| s.as_str()), Some("--")); - // `--` 之前才是各 flag,`--dangerously-skip-permissions` 不在 `--` 之后。 + // `--` 之前才是各 flag,`--auto` 不在 `--` 之后。 let dd = args.iter().rposition(|a| a == "--").unwrap(); - assert!(args[..dd].iter().any(|a| a == "--dangerously-skip-permissions")); + assert!(args[..dd].iter().any(|a| a == "--auto")); } #[test] fn plan_mode_omits_skip_permissions_other_modes_add_it() { let mut req = CodingAgentRequest::new("s", "p"); req.permission_mode = CodingAgentPermissionMode::Plan; - assert!(!build_opencode_args(&req).contains(&"--dangerously-skip-permissions".to_string())); + assert!(!build_opencode_args(&req).contains(&"--auto".to_string())); req.permission_mode = CodingAgentPermissionMode::AcceptEdits; - assert!(build_opencode_args(&req).contains(&"--dangerously-skip-permissions".to_string())); + assert!(build_opencode_args(&req).contains(&"--auto".to_string())); req.permission_mode = CodingAgentPermissionMode::BypassPermissions; - assert!(build_opencode_args(&req).contains(&"--dangerously-skip-permissions".to_string())); + assert!(build_opencode_args(&req).contains(&"--auto".to_string())); + } + + #[test] + fn continue_session_uses_opencode_continue_flag() { + let mut req = CodingAgentRequest::new("s", "p"); + req.continue_session = true; + let args = build_opencode_args(&req); + assert!(args.contains(&"--continue".to_string())); + assert_eq!(args.last().map(|s| s.as_str()), Some("--")); + } + + #[test] + fn parses_refreshed_models_and_removes_ansi_status() { + let stdout = "\u{1b}[92m\u{1b}[1mModels cache refreshed\u{1b}[0m\n\ +opencode/deepseek-v4-flash-free\n\ +minimax/MiniMax-M2.7\n\ +opencode/deepseek-v4-flash-free\n"; + assert_eq!( + parse_opencode_models_output(stdout), + vec![ + "opencode/deepseek-v4-flash-free".to_string(), + "minimax/MiniMax-M2.7".to_string(), + ] + ); + } + + #[test] + fn parses_nested_opencode_error_message() { + let line = r#"{"type":"error","error":{"name":"UnknownError","data":{"message":"Rate limit exceeded"}}}"#; + assert_eq!( + parse_opencode_json_line("s1", line), + Some(CodingAgentEvent::Error { + session_id: "s1".into(), + message: "Rate limit exceeded".into(), + }) + ); } #[test] @@ -312,7 +427,10 @@ mod tests { req.model = Some("anthropic/claude-sonnet-4".into()); req.cwd = Some(PathBuf::from("/tmp/work")); let args = build_opencode_args(&req); - assert_eq!(arg_value(&args, "--model"), Some("anthropic/claude-sonnet-4")); + assert_eq!( + arg_value(&args, "--model"), + Some("anthropic/claude-sonnet-4") + ); assert_eq!(arg_value(&args, "--dir"), Some("/tmp/work")); } diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index 7ed34ba18..338f1c1b3 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -3,21 +3,25 @@ use super::*; const LLM_EXTRA_HEADERS_ACCOUNT: &str = "ark.extra_headers"; #[tauri::command] -pub fn get_credentials() -> CredentialsStatus { - let snap = CredentialsVault::snapshot(); - let active_asr_provider = CredentialsVault::get_active_asr(); - let active_llm_provider = CredentialsVault::get_active_llm(); - let volcengine_configured = volcengine_configured(&snap); - let asr_configured = asr_configured_for_provider(&active_asr_provider, &snap); - let llm_configured = llm_configured_for_provider(&active_llm_provider, &snap); - CredentialsStatus { - active_asr_provider, - active_llm_provider, - asr_configured, - llm_configured, - volcengine_configured, - ark_configured: llm_configured, - } +pub async fn get_credentials() -> Result { + tauri::async_runtime::spawn_blocking(|| { + let snap = CredentialsVault::snapshot(); + let active_asr_provider = CredentialsVault::get_active_asr(); + let active_llm_provider = CredentialsVault::get_active_llm(); + let volcengine_configured = volcengine_configured(&snap); + let asr_configured = asr_configured_for_provider(&active_asr_provider, &snap); + let llm_configured = llm_configured_for_provider(&active_llm_provider, &snap); + CredentialsStatus { + active_asr_provider, + active_llm_provider, + asr_configured, + llm_configured, + volcengine_configured, + ark_configured: llm_configured, + } + }) + .await + .map_err(|e| format!("credential status worker failed: {e}")) } fn volcengine_configured(snap: &CredentialsSnapshot) -> bool { @@ -159,39 +163,48 @@ pub(crate) async fn release_sherpa_runtime_if_inactive( } #[tauri::command] -pub fn set_credential( +pub async fn set_credential( window: Window, account: String, value: String, provider: Option, ) -> Result<(), String> { ensure_main_window(&window)?; - if account == LLM_EXTRA_HEADERS_ACCOUNT { - CredentialsVault::set_active_llm_extra_headers_json(&value).map_err(|e| e.to_string())?; - let _ = window.emit("credentials:changed", ()); - return Ok(()); - } - let acc = parse_account(&account)?; - if let Some(provider) = provider { - if !matches!( - acc, - CredentialAccount::VolcengineAppKey - | CredentialAccount::VolcengineAccessKey - | CredentialAccount::VolcengineResourceId - | CredentialAccount::AsrApiKey - | CredentialAccount::AsrEndpoint - | CredentialAccount::AsrModel - | CredentialAccount::AsrVocabularyId - ) { - return Err("provider-scoped credential must be an ASR account".to_string()); - } - CredentialsVault::set_for_asr_provider(&provider, acc, &value) - .map_err(|e| e.to_string())?; - } else if value.is_empty() { - CredentialsVault::remove(acc).map_err(|e| e.to_string())?; + let extra_headers = account == LLM_EXTRA_HEADERS_ACCOUNT; + let parsed = if extra_headers { + None } else { - CredentialsVault::set(acc, &value).map_err(|e| e.to_string())?; - } + Some(parse_account(&account)?) + }; + tauri::async_runtime::spawn_blocking(move || { + if extra_headers { + return CredentialsVault::set_active_llm_extra_headers_json(&value) + .map_err(|e| e.to_string()); + } + let acc = parsed.expect("non-extra credential account must be parsed"); + if let Some(provider) = provider { + if !matches!( + acc, + CredentialAccount::VolcengineAppKey + | CredentialAccount::VolcengineAccessKey + | CredentialAccount::VolcengineResourceId + | CredentialAccount::AsrApiKey + | CredentialAccount::AsrEndpoint + | CredentialAccount::AsrModel + | CredentialAccount::AsrVocabularyId + ) { + return Err("provider-scoped credential must be an ASR account".to_string()); + } + CredentialsVault::set_for_asr_provider(&provider, acc, &value) + .map_err(|e| e.to_string()) + } else if value.is_empty() { + CredentialsVault::remove(acc).map_err(|e| e.to_string()) + } else { + CredentialsVault::set(acc, &value).map_err(|e| e.to_string()) + } + }) + .await + .map_err(|e| format!("credential write worker failed: {e}"))??; // 通知前端凭据已变更(如 Overview 页需要刷新 asrConfigured 状态)。 // issue #532 / #573:在 Settings 填写凭据但不切换提供商时,Overview 不会重拉状态, // 仍显示「未配置」。该修复曾随 #538 合入 main,但被 beta→main 合并覆盖,beta 上缺失。 @@ -267,21 +280,32 @@ pub fn set_active_llm_provider(provider: String) -> Result<(), String> { /// 读出某个账号的实际值(用于设置页预填表单)。 /// 凭据来自系统凭据库;只允许主设置窗口读取 raw secret,避免胶囊 / QA 等辅助窗口默认暴露。 #[tauri::command] -pub fn read_credential( +pub async fn read_credential( window: Window, account: String, provider: Option, ) -> Result, String> { ensure_main_window(&window)?; - if account == LLM_EXTRA_HEADERS_ACCOUNT { - return CredentialsVault::get_active_llm_extra_headers_json().map_err(|e| e.to_string()); - } - let acc = parse_account(&account)?; - if let Some(provider) = provider { - CredentialsVault::get_for_asr_provider(&provider, acc).map_err(|e| e.to_string()) + let extra_headers = account == LLM_EXTRA_HEADERS_ACCOUNT; + let parsed = if extra_headers { + None } else { - CredentialsVault::get(acc).map_err(|e| e.to_string()) - } + Some(parse_account(&account)?) + }; + tauri::async_runtime::spawn_blocking(move || { + if extra_headers { + return CredentialsVault::get_active_llm_extra_headers_json() + .map_err(|e| e.to_string()); + } + let acc = parsed.expect("non-extra credential account must be parsed"); + if let Some(provider) = provider { + CredentialsVault::get_for_asr_provider(&provider, acc).map_err(|e| e.to_string()) + } else { + CredentialsVault::get(acc).map_err(|e| e.to_string()) + } + }) + .await + .map_err(|e| format!("credential read worker failed: {e}"))? } fn ensure_main_window(window: &Window) -> Result<(), String> { diff --git a/openless-all/app/src-tauri/src/commands/misc.rs b/openless-all/app/src-tauri/src/commands/misc.rs index 83506a6aa..d4dda6c63 100644 --- a/openless-all/app/src-tauri/src/commands/misc.rs +++ b/openless-all/app/src-tauri/src/commands/misc.rs @@ -83,14 +83,18 @@ pub fn get_windows_ime_status() -> WindowsImeStatus { #[tauri::command] #[cfg(mobile)] -pub fn list_microphone_devices() -> Result, String> { +pub async fn list_microphone_devices() -> Result, String> { Ok(Vec::new()) } #[tauri::command] #[cfg(not(mobile))] -pub fn list_microphone_devices() -> Result, String> { - crate::recorder::list_input_devices().map_err(|e| e.to_string()) +pub async fn list_microphone_devices() -> Result, String> { + tauri::async_runtime::spawn_blocking(|| { + crate::recorder::list_input_devices().map_err(|e| e.to_string()) + }) + .await + .map_err(|e| format!("microphone device worker failed: {e}"))? } #[tauri::command] diff --git a/openless-all/app/src-tauri/src/commands/qa.rs b/openless-all/app/src-tauri/src/commands/qa.rs index 222413c61..56fb8be84 100644 --- a/openless-all/app/src-tauri/src/commands/qa.rs +++ b/openless-all/app/src-tauri/src/commands/qa.rs @@ -127,6 +127,19 @@ pub fn less_computer_submit_text(coord: CoordinatorState<'_>, text: String) { coord.less_computer_submit_text(text); } +/// 主设置页的文字测试入口。浮窗自身无需也不允许反向调用这个命令。 +#[tauri::command] +pub fn less_computer_window_open( + window: Window, + coord: CoordinatorState<'_>, +) -> Result<(), String> { + if window.label() != "main" { + return Err("Less Computer can only be opened from the main window".to_string()); + } + coord.less_computer_window_open(); + Ok(()) +} + /// 浮窗 mount 时拉取当前会话的事件缓冲(seq 升序)。 /// /// 浮窗首次创建时 webview 冷加载,后端事件(尤其第一条 `user` —— 用户说的话) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index fc5a2736b..eccf069cc 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1354,6 +1354,14 @@ impl Coordinator { } } + /// 从主设置页打开 Less Computer 浮窗,允许用户在没有麦克风/全局快捷键权限时 + /// 先用文字测试已配置的 Coding Agent 后端。 + pub fn less_computer_window_open(&self) { + if let Some(app) = self.inner.app.lock().clone() { + crate::show_less_computer_window(&app); + } + } + /// 内联审批卡的 Approve / Deny 回执:解析等待中的 token。 pub fn less_computer_approve(&self, token: &str, approved: bool) { dictation::resolve_less_computer_approval(token, approved); @@ -1367,7 +1375,11 @@ impl Coordinator { return; } let inner = Arc::clone(&self.inner); - tokio::spawn(async move { + // This method is entered by a synchronous Tauri command on WebKit's custom + // protocol callback. Direct Tokio spawning panics there because that AppKit thread + // has no entered Tokio runtime, and the panic cannot unwind across the ObjC + // callback (SIGABRT). Tauri's runtime handle is safe from either thread. + tauri::async_runtime::spawn(async move { let session_id = crate::coordinator_state::new_session_id(); if let Err(e) = dictation::run_voice_agent_transcript(&inner, session_id, text, 0).await diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index f396df076..48b67e68f 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -969,11 +969,10 @@ pub(super) async fn run_voice_agent_transcript( } other => other, }; - let model = prefs - .coding_agent_model - .clone() - .filter(|m| !m.trim().is_empty()) - .or_else(|| Some("sonnet".to_string())); + let provider = + crate::coding_agent::CodingAgentProvider::from_pref(&prefs.coding_agent_provider); + let model = + crate::coding_agent::resolve_coding_agent_model(provider, prefs.coding_agent_model.clone()); let prompt = crate::coding_agent::autonomous_prompt(&transcript); // 第一轮:默认护栏(高风险全 deny)。运行后若检测到护栏拦截,弹审批卡; diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 18f61a3f5..130cce62f 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -114,6 +114,8 @@ static LESS_COMPUTER_PANEL_EPOCH: std::sync::atomic::AtomicU64 = #[cfg(not(mobile))] static TRAY_MICROPHONE_WATCHER_STOPPING: AtomicBool = AtomicBool::new(false); #[cfg(not(mobile))] +struct TrayMicrophoneDeviceCache(parking_lot::Mutex>); +#[cfg(not(mobile))] use tauri::menu::{ CheckMenuItemBuilder, Menu, MenuBuilder, MenuItemBuilder, Submenu, SubmenuBuilder, }; @@ -207,6 +209,7 @@ macro_rules! app_invoke_handler_desktop { commands::cancel_dictation, coding_agent::commands::coding_agent_detect, coding_agent::commands::coding_agent_detect_opencode, + coding_agent::commands::coding_agent_list_opencode_models, coding_agent::commands::coding_agent_run_test, coding_agent::commands::coding_agent_cancel_test, coding_agent::commands::coding_agent_command_risk, @@ -246,6 +249,7 @@ macro_rules! app_invoke_handler_desktop { commands::qa_toggle_recording, commands::qa_submit_text, commands::less_computer_window_dismiss, + commands::less_computer_window_open, commands::chat_panel_focus_keyboard, commands::less_computer_submit_text, commands::less_computer_sync, @@ -490,6 +494,7 @@ fn run_desktop() { .manage(sherpa_onnx_runtime.clone()) .manage(commands::MicrophoneMonitorState::new(None)) .manage(commands::TrayMicrophoneMenuState::new(Vec::new())) + .manage(TrayMicrophoneDeviceCache(parking_lot::Mutex::new(Vec::new()))) .setup(move |app| { init_file_logger(); log::info!("=== OpenLess 启动 ==="); @@ -897,13 +902,14 @@ fn build_microphone_tray_menu>( let selected = coordinator.prefs().get().microphone_device_name; let mut items = Vec::new(); let mut submenu = SubmenuBuilder::with_id(app, "microphone", "选择麦克风"); - let devices = match recorder::list_input_devices() { - Ok(devices) => devices, - Err(err) => { - log::warn!("[tray] list microphone devices failed: {err}"); - Vec::new() - } - }; + // CoreAudio device enumeration can block inside AudioUnitSetProperty while AppKit is + // finishing launch. Tray menus must be built on the main thread, so only consume the + // cache here; the watcher below owns every potentially blocking enumeration. + let devices = app + .state::() + .0 + .lock() + .clone(); let selected_available = selected.trim().is_empty() || devices.iter().any(|device| device.name == selected); @@ -961,14 +967,16 @@ pub(crate) fn refresh_tray_microphone_menu(app: &AppHandle) -> tauri::Result<()> } #[cfg(not(mobile))] -fn microphone_device_signature() -> Option> { +fn microphone_devices_with_signature( +) -> Option<(Vec, Vec<(String, bool)>)> { match recorder::list_input_devices() { - Ok(devices) => Some( - devices - .into_iter() - .map(|device| (device.name, device.is_default)) - .collect(), - ), + Ok(devices) => { + let signature = devices + .iter() + .map(|device| (device.name.clone(), device.is_default)) + .collect(); + Some((devices, signature)) + } Err(err) => { log::warn!("[tray] watch microphone devices failed: {err}"); None @@ -976,6 +984,30 @@ fn microphone_device_signature() -> Option> { } } +/// Enumerate devices off the main thread, update the shared cache, then rebuild the tray on +/// AppKit's main thread only when the device signature changed. +#[cfg(not(mobile))] +fn refresh_microphone_cache_if_changed( + app: &AppHandle, + last_signature: &parking_lot::Mutex>>, +) { + let Some((devices, signature)) = microphone_devices_with_signature() else { + return; + }; + { + let mut guard = last_signature.lock(); + if guard.as_ref() == Some(&signature) { + return; + } + *guard = Some(signature); + } + *app.state::().0.lock() = devices; + let refresh_app = app.clone(); + if let Err(err) = app.run_on_main_thread(move || refresh_microphone_on_main(&refresh_app)) { + log::warn!("[tray] dispatch microphone cache refresh failed: {err}"); + } +} + /// 在主线程上刷新托盘麦克风子菜单并通知前端。供 OS 原生设备变更回调与慢速兜底轮询 /// 共用同一条收尾路径。已在主线程或被 `run_on_main_thread` 派发后调用。 #[cfg(not(mobile))] @@ -987,24 +1019,29 @@ fn refresh_microphone_on_main(app: &AppHandle) { } /// 设备变更去抖闭包:被 OS 原生通知回调(macOS CoreAudio / Windows MMDevice)调用。 -/// 复用 `microphone_device_signature()` 去抖——签名没变就零副作用直接返回;变了才 -/// `run_on_main_thread` 派发刷新+emit。OS 通知可能合并/重复触发,去抖确保只在真正 -/// 变化时刷新。`last_signature` 用 `Mutex` 保护,因为回调可能从不同的 CoreAudio/COM -/// 线程并发进入。 +/// OS 回调仅调度一个后台枚举任务,绝不在 AppKit 主线程或 CoreAudio/COM 通知线程中 +/// 直接枚举设备。并发通知通过 `refresh_in_flight` 合并,签名去抖避免重复刷新菜单。 #[cfg(not(mobile))] fn make_microphone_change_handler(app: AppHandle) -> impl Fn() + Send + Sync + 'static { - let last_signature = parking_lot::Mutex::new(microphone_device_signature()); + let last_signature = Arc::new(parking_lot::Mutex::new(None)); + let refresh_in_flight = Arc::new(AtomicBool::new(false)); move || { - let signature = microphone_device_signature(); - { - let mut guard = last_signature.lock(); - if signature == *guard { - return; - } - *guard = signature; + if refresh_in_flight.swap(true, Ordering::AcqRel) { + return; } let refresh_app = app.clone(); - let _ = app.run_on_main_thread(move || refresh_microphone_on_main(&refresh_app)); + let refresh_signature = Arc::clone(&last_signature); + let refresh_flag = Arc::clone(&refresh_in_flight); + if let Err(err) = std::thread::Builder::new() + .name("openless-tray-mic-event".into()) + .spawn(move || { + refresh_microphone_cache_if_changed(&refresh_app, &refresh_signature); + refresh_flag.store(false, Ordering::Release); + }) + { + refresh_in_flight.store(false, Ordering::Release); + log::warn!("[tray] start microphone event refresh failed: {err}"); + } } } @@ -1033,7 +1070,9 @@ fn start_tray_microphone_watcher(app: AppHandle) { if let Err(err) = std::thread::Builder::new() .name("openless-tray-mic-poll".into()) .spawn(move || { - let mut last_signature = microphone_device_signature(); + let last_signature = parking_lot::Mutex::new(None); + // Populate the initially empty tray cache without blocking AppKit startup. + refresh_microphone_cache_if_changed(&app, &last_signature); while !TRAY_MICROPHONE_WATCHER_STOPPING.load(Ordering::Relaxed) { // 60s(而非 10s):原生通知承担实时检测,这条线程只是兜底,把它拉到 60s // 进一步压低空闲唤醒。1s 一片的睡眠让退出 flag 最多 1s 内生效,避免退出时 @@ -1047,13 +1086,7 @@ fn start_tray_microphone_watcher(app: AppHandle) { if TRAY_MICROPHONE_WATCHER_STOPPING.load(Ordering::Relaxed) { break; } - let signature = microphone_device_signature(); - if signature == last_signature { - continue; - } - last_signature = signature; - let refresh_app = app.clone(); - let _ = app.run_on_main_thread(move || refresh_microphone_on_main(&refresh_app)); + refresh_microphone_cache_if_changed(&app, &last_signature); } }) { @@ -2391,13 +2424,23 @@ pub(crate) fn show_less_computer_window(app: &AppHandle) { log::info!("[less-computer] show 跳过:窗口不存在"); return; }; - if let Err(e) = position_less_computer_window(&window) { - 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; + // This helper is also called from the Tokio worker that executes a text or + // voice Agent turn. Keep every AppKit-backed window mutation on the main + // thread; macOS aborts the process if a converted NSPanel is resized or moved + // from that worker while WebKit is servicing its custom URL scheme. + if let Err(e) = position_less_computer_window(&window_clone) { + log::warn!("[less-computer] position before show failed: {e}"); + } + // A lazily-created window starts with Tauri's visible=false state. Cocoa's + // orderFrontRegardless alone does not always clear that state, leaving the first + // text-only launch invisible even though the NSPanel was created successfully. + if let Err(e) = window_clone.show() { + log::warn!("[less-computer] window.show before orderFront failed: {e}"); + } match window_clone.ns_window() { Ok(handle) => { let ns = handle as *mut AnyObject; diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index cbea55eb7..11b1e8360 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenLess", - "version": "1.3.15-Beta.2", + "version": "1.3.15-Beta.3", "identifier": "com.openless.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/openless-all/app/src/components/Onboarding.tsx b/openless-all/app/src/components/Onboarding.tsx index 195682302..ff879ed5d 100644 --- a/openless-all/app/src/components/Onboarding.tsx +++ b/openless-all/app/src/components/Onboarding.tsx @@ -440,6 +440,25 @@ function DesktopOnboarding({
{t('onboarding.footerHint')}
+ ); diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index d12da35e9..69c39b83c 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -257,6 +257,7 @@ export const en: typeof zhCN = { actionRequestMic: 'Request access', accessibilityHint: 'After granting, you must **fully quit OpenLess** and reopen it (a macOS TCC requirement).', footerHint: 'This onboarding closes automatically once both permissions are granted. If it persists, quit OpenLess from the menu bar and relaunch.', + continueToSettings: 'Open settings only (voice and global shortcuts unavailable)', androidContinue: 'Continue to app', androidFooterHint: 'Microphone access is required for dictation. Tap Request access above, or continue and grant it later from Overview.', androidTitle: 'Set up OpenLess', @@ -637,10 +638,10 @@ export const en: typeof zhCN = { }, codingAgent: { title: 'Less Computer', - desc: 'Hold a key, speak, and Claude operates your computer. macOS only.', + desc: 'Hold a key, speak, and your selected agent operates your computer. macOS only.', enable: 'Enable Less Computer', comingSoonNote: 'Config is saved now; hotkey triggering and the execution flow land in a later version.', - hotkeyHint: 'When enabled, hold the shortcut below to talk; release it and Claude shows the result in the capsule.', + hotkeyHint: 'When enabled, hold the shortcut to talk; release it and the selected agent shows the result in the capsule.', voiceHotkey: 'Hold-to-talk key', voiceHotkeyDesc: 'Hold to talk, release to run. Supports Ctrl/Option/Fn single keys. See the Advanced settings page for what it does.', provider: 'Agent backend', @@ -654,7 +655,17 @@ export const en: typeof zhCN = { modelPlaceholder: 'Default: sonnet', modelDefault: 'Default (auto sonnet)', modelHint: 'Haiku = fastest · Sonnet = balanced · Opus = strongest', + opencodeModelDefault: 'Use OpenCode default model', + opencodeModelHint: 'Automatically fetches provider/model choices available to the current OpenCode account and saves your selection immediately.', + opencodeModelsRefresh: 'Refresh models', + opencodeModelsRefreshing: 'Fetching OpenCode models…', + opencodeModelsLoaded: 'Fetched {{count}} models.', + opencodeModelsEmpty: 'No models were returned. Sign in to OpenCode or configure a model provider first.', + opencodeModelsError: 'Failed to fetch models: {{message}}', exe: 'Executable path', + openPanel: 'Text test', + openPanelHint: 'Open the Less Computer panel and verify the current agent and model with text.', + openPanelAction: 'Open Less Computer', }, debug: { title: 'Debug tools', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index b747c4356..ad2941a97 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -259,6 +259,7 @@ export const ja: typeof zhCN = { actionRequestMic: '許可ダイアログを表示', accessibilityHint: '許可後は **OpenLess を完全に終了** してから再起動してください(macOS TCC の仕様)。', footerHint: 'すべての権限が揃うとこのガイドは自動で閉じます。閉じない場合はメニューバーの OpenLess → 終了 から再起動してください。', + continueToSettings: '設定のみ開く(音声とグローバルショートカットは利用不可)', androidContinue: 'アプリに進む', androidFooterHint: '音声入力にはマイク権限が必要です。上の「許可ダイアログを表示」をタップするか、先にアプリへ進み、概要ページで後から許可してください。', androidTitle: 'OpenLess を設定', @@ -639,10 +640,10 @@ export const ja: typeof zhCN = { }, codingAgent: { title: 'Less Computer', - desc: 'キーを押して話すと Claude が PC を操作します。macOS のみ。', + desc: 'キーを押して話すと、選択した Agent が PC を操作します。macOS のみ。', enable: 'Less Computer を有効化', comingSoonNote: '設定はすぐ保存されます。ホットキー起動と実行フローは今後のバージョンで対応。', - hotkeyHint: '有効にすると、下のショートカットを押しながら話し、離すと Claude の結果がカプセルに表示されます。', + hotkeyHint: '有効にすると、ショートカットを押しながら話し、離すと選択した Agent の結果がカプセルに表示されます。', voiceHotkey: '押しながら話すキー', voiceHotkeyDesc: '押して話す、離して実行。Ctrl/Option/Fn などの単キー対応。機能の説明は「詳細」設定ページを参照。', provider: 'Agent バックエンド', @@ -656,7 +657,17 @@ export const ja: typeof zhCN = { modelPlaceholder: 'デフォルト: sonnet', modelDefault: 'デフォルト(自動 sonnet)', modelHint: 'Haiku = 最速 · Sonnet = バランス · Opus = 最強', + opencodeModelDefault: 'OpenCode のデフォルトモデルを使用', + opencodeModelHint: '現在の OpenCode アカウントで利用できる provider/model を自動取得し、選択内容をすぐ保存します。', + opencodeModelsRefresh: 'モデルを再取得', + opencodeModelsRefreshing: 'OpenCode モデルを取得中…', + opencodeModelsLoaded: '{{count}} 個のモデルを取得しました。', + opencodeModelsEmpty: '利用可能なモデルが返されませんでした。OpenCode にログインするか、モデルプロバイダーを設定してください。', + opencodeModelsError: 'モデルの取得に失敗しました:{{message}}', exe: '実行ファイルのパス', + openPanel: 'テキストテスト', + openPanelHint: 'Less Computer パネルを直接開き、現在の Agent とモデル設定をテキストで確認します。', + openPanelAction: 'Less Computer を開く', }, debug: { title: 'デバッグツール', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index c0a7acd9d..18938920f 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -259,6 +259,7 @@ export const ko: typeof zhCN = { actionRequestMic: '권한 대화상자 표시', accessibilityHint: '허용 후에는 **OpenLess 를 완전히 종료** 한 다음 다시 실행해야 합니다(macOS TCC 규칙).', footerHint: '모든 권한이 부여되면 이 가이드는 자동으로 닫힙니다. 닫히지 않으면 메뉴 막대의 OpenLess → 종료 후 앱을 다시 실행해 주세요.', + continueToSettings: '설정만 열기(음성 및 전역 단축키는 사용 불가)', androidContinue: '앱으로 계속', androidFooterHint: '받아쓰기에는 마이크 권한이 필요합니다. 위의 권한 요청을 탭하거나, 앱으로 먼저 들어가 개요 페이지에서 나중에 허용할 수 있습니다.', androidTitle: 'OpenLess 설정', @@ -639,10 +640,10 @@ export const ko: typeof zhCN = { }, codingAgent: { title: 'Less Computer', - desc: '키를 누르고 말하면 Claude가 PC를 조작합니다. macOS 전용.', + desc: '키를 누르고 말하면 선택한 Agent가 PC를 조작합니다. macOS 전용.', enable: 'Less Computer 켜기', comingSoonNote: '설정은 즉시 저장됩니다. 단축키 트리거와 실행 흐름은 이후 버전에서 제공됩니다.', - hotkeyHint: '켜면 아래 단축키를 누른 채 말하고, 놓으면 Claude 결과가 캡슐에 표시됩니다.', + hotkeyHint: '켜면 단축키를 누른 채 말하고, 놓으면 선택한 Agent 결과가 캡슐에 표시됩니다.', voiceHotkey: '누르고 말하기 키', voiceHotkeyDesc: '누르고 말하고 놓으면 실행. Ctrl/Option/Fn 단일 키 지원. 기능 설명은 「고급」 설정 페이지 참조.', provider: 'Agent 백엔드', @@ -656,7 +657,17 @@ export const ko: typeof zhCN = { modelPlaceholder: '기본: sonnet', modelDefault: '기본(자동 sonnet)', modelHint: 'Haiku = 가장 빠름 · Sonnet = 균형 · Opus = 최강', + opencodeModelDefault: 'OpenCode 기본 모델 사용', + opencodeModelHint: '현재 OpenCode 계정에서 사용 가능한 provider/model을 자동으로 가져오고 선택 즉시 저장합니다.', + opencodeModelsRefresh: '모델 다시 가져오기', + opencodeModelsRefreshing: 'OpenCode 모델을 가져오는 중…', + opencodeModelsLoaded: '모델 {{count}}개를 가져왔습니다.', + opencodeModelsEmpty: '사용 가능한 모델이 반환되지 않았습니다. OpenCode에 로그인하거나 모델 제공자를 설정하세요.', + opencodeModelsError: '모델 가져오기 실패: {{message}}', exe: '실행 파일 경로', + openPanel: '텍스트 테스트', + openPanelHint: 'Less Computer 패널을 열어 현재 Agent와 모델 설정을 텍스트로 확인합니다.', + openPanelAction: 'Less Computer 열기', }, debug: { title: '디버그 도구', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index bfe6011c0..1da63cad9 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -255,6 +255,7 @@ export const zhCN = { actionRequestMic: '弹出授权', accessibilityHint: '授权后必须**完全退出 OpenLess** 再重新打开(macOS TCC 规则)。', footerHint: '授权全部完成后此引导自动关闭。如果一直不消失,从菜单栏 OpenLess → 退出,重新打开 App。', + continueToSettings: '仅进入设置(语音与全局快捷键暂不可用)', androidContinue: '先进入应用', androidFooterHint: '听写需要麦克风权限。可点击上方「弹出授权」,或先进入应用后在概览页继续授权。', androidTitle: '配置 OpenLess', @@ -635,10 +636,10 @@ export const zhCN = { }, codingAgent: { title: 'Less Computer', - desc: '按住一个键说话,Claude 帮你操作电脑。仅 macOS。', + desc: '按住一个键说话,由所选 Agent 帮你操作电脑。仅 macOS。', enable: '启用 Less Computer', comingSoonNote: '配置即时保存;热键触发与执行链路随后续版本生效。', - hotkeyHint: '开启后,按住下方快捷键说话,松开后 Claude 处理并把结果显示在胶囊里。', + hotkeyHint: '开启后,按住快捷键说话,松开后由所选 Agent 处理并把结果显示在胶囊里。', voiceHotkey: '按住说话键', voiceHotkeyDesc: '按住说话、松开执行。支持 Ctrl/Option/Fn 等单键。功能说明参见「高级」设置页。', provider: 'Agent 后端', @@ -652,7 +653,17 @@ export const zhCN = { modelPlaceholder: '默认 sonnet', modelDefault: '默认(自动 sonnet)', modelHint: 'Haiku 最快 · Sonnet 均衡 · Opus 最强', + opencodeModelDefault: '使用 OpenCode 默认模型', + opencodeModelHint: '自动拉取 OpenCode 当前账号可用的 provider/model;选择后立即保存。', + opencodeModelsRefresh: '重新拉取模型', + opencodeModelsRefreshing: '正在拉取 OpenCode 模型…', + opencodeModelsLoaded: '已拉取 {{count}} 个模型。', + opencodeModelsEmpty: '没有返回可用模型,请先完成 OpenCode 登录或配置模型提供商。', + opencodeModelsError: '拉取模型失败:{{message}}', exe: '可执行文件路径', + openPanel: '文字测试', + openPanelHint: '直接打开 Less Computer 浮窗,用文字验证当前 Agent 与模型配置。', + openPanelAction: '打开 Less Computer', }, debug: { title: '调试工具', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 88e7c8a5a..8734b1be0 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -257,6 +257,7 @@ export const zhTW: typeof zhCN = { actionRequestMic: '彈出授權', accessibilityHint: '授權後必須**完全退出 OpenLess** 再重新打開(macOS TCC 規則)。', footerHint: '授權全部完成後此引導自動關閉。如果一直不消失,從菜單欄 OpenLess → 退出,重新打開 App。', + continueToSettings: '僅進入設定(語音與全域快速鍵暫不可用)', androidContinue: '先進入應用', androidFooterHint: '聽寫需要麥克風權限。可點擊上方「彈出授權」,或先進入應用後在概覽頁繼續授權。', androidTitle: '配置 OpenLess', @@ -637,10 +638,10 @@ export const zhTW: typeof zhCN = { }, codingAgent: { title: 'Less Computer', - desc: '按住一個鍵說話,Claude 幫你操作電腦。僅 macOS。', + desc: '按住一個鍵說話,由所選 Agent 幫你操作電腦。僅 macOS。', enable: '啟用 Less Computer', comingSoonNote: '設定即時儲存;熱鍵觸發與執行鏈路隨後續版本生效。', - hotkeyHint: '開啟後,按住下方快捷鍵說話,放開後 Claude 處理並把結果顯示在膠囊裡。', + hotkeyHint: '開啟後,按住快捷鍵說話,放開後由所選 Agent 處理並把結果顯示在膠囊裡。', voiceHotkey: '按住說話鍵', voiceHotkeyDesc: '按住說話、放開執行。支援 Ctrl/Option/Fn 等單鍵。功能說明參見「進階」設定頁。', provider: 'Agent 後端', @@ -654,7 +655,17 @@ export const zhTW: typeof zhCN = { modelPlaceholder: '預設 sonnet', modelDefault: '預設(自動 sonnet)', modelHint: 'Haiku 最快 · Sonnet 均衡 · Opus 最強', + opencodeModelDefault: '使用 OpenCode 預設模型', + opencodeModelHint: '自動拉取 OpenCode 目前帳號可用的 provider/model;選取後立即儲存。', + opencodeModelsRefresh: '重新拉取模型', + opencodeModelsRefreshing: '正在拉取 OpenCode 模型…', + opencodeModelsLoaded: '已拉取 {{count}} 個模型。', + opencodeModelsEmpty: '沒有回傳可用模型,請先完成 OpenCode 登入或設定模型提供商。', + opencodeModelsError: '拉取模型失敗:{{message}}', exe: '可執行檔路徑', + openPanel: '文字測試', + openPanelHint: '直接開啟 Less Computer 浮窗,以文字驗證目前的 Agent 與模型設定。', + openPanelAction: '開啟 Less Computer', }, debug: { title: '除錯工具', diff --git a/openless-all/app/src/lib/ipc/coding-agent.ts b/openless-all/app/src/lib/ipc/coding-agent.ts index c4d44eeea..396d6ab33 100644 --- a/openless-all/app/src/lib/ipc/coding-agent.ts +++ b/openless-all/app/src/lib/ipc/coding-agent.ts @@ -38,6 +38,18 @@ export function codingAgentDetectOpencode(exe?: string): Promise { + return invokeOrMock( + "coding_agent_list_opencode_models", + { exe, refresh }, + () => [], + ) +} + /** 无头 Claude 运行事件,由后端 `coding-agent:test` 流式推送(tag 为 `kind`)。 */ export type CodingAgentEvent = | { kind: "started"; session_id: string } diff --git a/openless-all/app/src/lib/ipc/index.ts b/openless-all/app/src/lib/ipc/index.ts index ff4f838a0..743501e5c 100644 --- a/openless-all/app/src/lib/ipc/index.ts +++ b/openless-all/app/src/lib/ipc/index.ts @@ -130,6 +130,7 @@ export { // less-computer export { lessComputerWindowDismiss, + lessComputerWindowOpen, lessComputerApprove, lessComputerSubmitText, lessComputerSync, @@ -170,6 +171,7 @@ export type { export { codingAgentDetect, codingAgentDetectOpencode, + codingAgentListOpencodeModels, codingAgentRunTest, codingAgentCancelTest, codingAgentCommandRisk, diff --git a/openless-all/app/src/lib/ipc/less-computer.ts b/openless-all/app/src/lib/ipc/less-computer.ts index f298215a2..a340e43ef 100644 --- a/openless-all/app/src/lib/ipc/less-computer.ts +++ b/openless-all/app/src/lib/ipc/less-computer.ts @@ -6,6 +6,11 @@ export function lessComputerWindowDismiss(): Promise { return invokeOrMock("less_computer_window_dismiss", undefined, () => undefined) } +/** 从主设置页打开文字交互浮窗,无需先触发麦克风或全局快捷键。 */ +export function lessComputerWindowOpen(): Promise { + return invokeOrMock("less_computer_window_open", undefined, () => undefined) +} + /** 内联审批卡的 Approve / Deny 回执。token 关联到等待中的拦截动作。 */ export function lessComputerApprove( token: string, diff --git a/openless-all/app/src/pages/settings/CodingAgentSection.tsx b/openless-all/app/src/pages/settings/CodingAgentSection.tsx index 0414a04ed..9304c36b1 100644 --- a/openless-all/app/src/pages/settings/CodingAgentSection.tsx +++ b/openless-all/app/src/pages/settings/CodingAgentSection.tsx @@ -5,7 +5,12 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { detectOS } from '../../components/WindowChrome' -import { codingAgentDetectOpencode, type OpenCodeDetection } from '../../lib/ipc' +import { + codingAgentDetectOpencode, + codingAgentListOpencodeModels, + lessComputerWindowOpen, + type OpenCodeDetection, +} from '../../lib/ipc' import type { CodingAgentPermissionMode, CodingAgentProviderId } from '../../lib/types' import { useHotkeySettings } from '../../state/HotkeySettingsContext' import { Card } from '../_atoms' @@ -18,6 +23,8 @@ const PERMISSION_MODES: CodingAgentPermissionMode[] = [ 'bypassPermissions', ] +type OpenCodeModelsStatus = 'idle' | 'loading' | 'loaded' | 'error' + export function CodingAgentSection() { const { t } = useTranslation() const { prefs, updatePrefs: savePrefs } = useHotkeySettings() @@ -25,22 +32,62 @@ export function CodingAgentSection() { // OpenCode 安装检测:仅当启用 + 选了 OpenCode 后端时探测一次,用于提示是否需先安装。 const [opencode, setOpencode] = useState(null) + const [opencodeModels, setOpencodeModels] = useState([]) + const [opencodeModelsStatus, setOpencodeModelsStatus] = useState('idle') + const [opencodeModelsError, setOpencodeModelsError] = useState('') const useOpencode = prefs?.codingAgentEnabled && prefs?.codingAgentProvider === 'opencode-cli' useEffect(() => { if (!useOpencode) { setOpencode(null) + setOpencodeModels([]) + setOpencodeModelsStatus('idle') + setOpencodeModelsError('') return } let alive = true - // 把配置的可执行路径传给检测,用户改了路径后能重新探测对应二进制。 - void codingAgentDetectOpencode(prefs?.codingAgentExe ?? undefined).then(d => { - if (alive) setOpencode(d) - }) + setOpencode(null) + setOpencodeModels([]) + setOpencodeModelsStatus('loading') + setOpencodeModelsError('') + // 先探测用户配置的二进制,再自动刷新当前 OpenCode 账号可用的模型。 + void (async () => { + try { + const exe = prefs?.codingAgentExe ?? undefined + const detection = await codingAgentDetectOpencode(exe) + if (!alive) return + setOpencode(detection) + if (!detection.installed) { + setOpencodeModelsStatus('idle') + return + } + const models = await codingAgentListOpencodeModels(exe, true) + if (!alive) return + setOpencodeModels(models) + setOpencodeModelsStatus('loaded') + } catch (error) { + if (!alive) return + setOpencodeModelsError(error instanceof Error ? error.message : String(error)) + setOpencodeModelsStatus('error') + } + })() return () => { alive = false } }, [useOpencode, prefs?.codingAgentExe]) + const refreshOpencodeModels = async () => { + setOpencodeModelsStatus('loading') + setOpencodeModelsError('') + try { + const models = await codingAgentListOpencodeModels(prefs?.codingAgentExe ?? undefined, true) + setOpencodeModels(models) + setOpencodeModelsStatus('loaded') + } catch (error) { + setOpencodeModelsError(error instanceof Error ? error.message : String(error)) + setOpencodeModelsStatus('error') + } + } + if (os === 'win') return null if (!prefs) { @@ -75,6 +122,7 @@ export function CodingAgentSection() { void savePrefs({ ...prefs, codingAgentProvider: e.target.value as CodingAgentProviderId, + codingAgentModel: null, }) } style={{ ...inputStyle, maxWidth: 240, cursor: 'pointer' }} @@ -119,21 +167,99 @@ export function CodingAgentSection() { - - { + const v = e.target.value + void savePrefs({ ...prefs, codingAgentModel: v === '' ? null : v }) + }} + style={{ ...inputStyle, maxWidth: 300, cursor: 'pointer' }} + > + {useOpencode ? ( + <> + + {prefs.codingAgentModel?.includes('/') && + !opencodeModels.includes(prefs.codingAgentModel) && ( + + )} + {opencodeModels.map(model => ( + + ))} + + ) : ( + <> + + + + + + )} + + {useOpencode && opencode?.installed && ( + + )} + + + + {useOpencode && opencode?.installed && opencodeModelsStatus !== 'idle' && ( +
- - - - - - + {opencodeModelsStatus === 'loading' + ? t('settings.codingAgent.opencodeModelsRefreshing') + : opencodeModelsStatus === 'error' + ? t('settings.codingAgent.opencodeModelsError', { + message: opencodeModelsError, + }) + : opencodeModels.length > 0 + ? t('settings.codingAgent.opencodeModelsLoaded', { + count: opencodeModels.length, + }) + : t('settings.codingAgent.opencodeModelsEmpty')} +
+ )} + + + + )}