fix(less-computer): restore OpenCode model discovery and execution (#840)

* fix(less-computer): restore OpenCode model execution

* fix(capsule): gate set_ignore_cursor_events behind #[cfg(not(mobile))]

#887 introduced desktop-only capsule cursor-passthrough logic into the
unconditionally-compiled capsule_focus module, breaking Android cargo check
(E0599: no method set_ignore_cursor_events on tauri::WebviewWindow). Gate the
block to desktop; variables it consumes (classic_style, selection_polish,
visible) are still used on mobile outside the block.

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
authored and GitHub committed 2026-08-03 16:03:41 +08:00
1 parent 0063497c6e
commit cd48a037a4
23 files changed
+824 -189

No files matched your search

@@ -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',
@@ -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');
@@ -33,6 +33,21 @@ impl CodingAgentProvider {
}
}
/// 按后端解析用户选择的模型。Claude 保持既有的 sonnet 默认;OpenCode 只接受
/// `provider/model`,未选择或遗留的 Claude 别名均交给 OpenCode 自己的默认配置。
pub fn resolve_coding_agent_model(
provider: CodingAgentProvider,
configured: Option<String>,
) -> Option<String> {
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())
);
}
}
@@ -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,6 +137,20 @@ pub struct OpenCodeDetectionWire {
pub exe: String,
}
fn normalize_opencode_exe(exe: Option<String>) -> Result<String, String> {
let exe = exe
.map(|e| e.trim().to_string())
.filter(|e| !e.is_empty())
.unwrap_or_else(|| "opencode".to_string());
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]
@@ -143,17 +159,7 @@ pub async fn coding_agent_detect_opencode(
exe: Option<String>,
) -> Result<OpenCodeDetectionWire, String> {
ensure_main_window(&window)?;
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());
}
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<String>,
refresh: Option<bool>,
) -> Result<Vec<String>, 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` 成本上限;
@@ -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;
@@ -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 <provider/model> --dir <cwd>
//! [--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<String> {
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<String> {
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<Vec<String>, 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<String> {
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<CodingAg
})
}
"error" => {
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"));
}
@@ -4,21 +4,25 @@ const LLM_EXTRA_HEADERS_ACCOUNT: &str = "ark.extra_headers";
const LLM_TEMPERATURE_ACCOUNT: &str = "ark.temperature";
#[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<CredentialsStatus, String> {
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 {
@@ -173,46 +177,55 @@ 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<String>,
) -> 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(());
}
if account == LLM_TEMPERATURE_ACCOUNT {
CredentialsVault::set_active_llm_temperature(&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::VolcengineAuthMode
| CredentialAccount::VolcengineApiKey
| 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 temperature = account == LLM_TEMPERATURE_ACCOUNT;
let parsed = if extra_headers || temperature {
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());
}
if temperature {
return CredentialsVault::set_active_llm_temperature(&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::VolcengineAuthMode
| CredentialAccount::VolcengineApiKey
| 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 上缺失。
@@ -288,24 +301,36 @@ 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<String>,
) -> Result<Option<String>, 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());
}
if account == LLM_TEMPERATURE_ACCOUNT {
return Ok(CredentialsVault::get_active_llm_temperature_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 temperature = account == LLM_TEMPERATURE_ACCOUNT;
let parsed = if extra_headers || temperature {
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());
}
if temperature {
return Ok(CredentialsVault::get_active_llm_temperature_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> {
@@ -83,14 +83,18 @@ pub fn get_windows_ime_status() -> WindowsImeStatus {
#[tauri::command]
#[cfg(mobile)]
pub fn list_microphone_devices() -> Result<Vec<crate::recorder::MicrophoneDevice>, String> {
pub async fn list_microphone_devices() -> Result<Vec<crate::recorder::MicrophoneDevice>, String> {
Ok(Vec::new())
}
#[tauri::command]
#[cfg(not(mobile))]
pub fn list_microphone_devices() -> Result<Vec<crate::recorder::MicrophoneDevice>, String> {
crate::recorder::list_input_devices().map_err(|e| e.to_string())
pub async fn list_microphone_devices() -> Result<Vec<crate::recorder::MicrophoneDevice>, 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]
@@ -128,6 +128,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` —— 用户说的话)
+13 -1
View File
@@ -1466,6 +1466,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);
@@ -1479,7 +1487,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
@@ -709,21 +709,25 @@ fn emit_capsule_with_context_locked(
// 关掉鼠标穿透(按钮可点,代价是窗口底部 460×180 区域在这几秒内拦截点击——
// 与 1.3.x 经典胶囊同款取舍);终态 toast、隐藏、Siri 光效、选区润色提示一律
// 保持穿透,不遮挡底层 app。set_ignore_cursor_events 只在值变化时调用一次。
let interactive = classic_style
&& visible
&& !selection_polish
&& matches!(
state,
CapsuleState::Recording | CapsuleState::Transcribing | CapsuleState::Polishing
);
let want_passthrough = !interactive;
if inner_for_main
.capsule_cursor_passthrough
.swap(want_passthrough, Ordering::SeqCst)
!= want_passthrough
// Android 没有胶囊窗口,tauri 的 set_ignore_cursor_events 在其上不可用。
#[cfg(not(mobile))]
{
if let Err(e) = window.set_ignore_cursor_events(want_passthrough) {
log::warn!("[capsule] set_ignore_cursor_events failed: {e}");
let interactive = classic_style
&& visible
&& !selection_polish
&& matches!(
state,
CapsuleState::Recording | CapsuleState::Transcribing | CapsuleState::Polishing
);
let want_passthrough = !interactive;
if inner_for_main
.capsule_cursor_passthrough
.swap(want_passthrough, Ordering::SeqCst)
!= want_passthrough
{
if let Err(e) = window.set_ignore_cursor_events(want_passthrough) {
log::warn!("[capsule] set_ignore_cursor_events failed: {e}");
}
}
}
if show_capsule && visible {
@@ -1166,11 +1166,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)。运行后若检测到护栏拦截,弹审批卡;
+81 -38
View File
@@ -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<Vec<recorder::MicrophoneDevice>>);
#[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,
@@ -255,6 +258,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,
@@ -499,6 +503,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 启动 ===");
@@ -908,13 +913,14 @@ fn build_microphone_tray_menu<M: Manager<tauri::Wry>>(
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::<TrayMicrophoneDeviceCache>()
.0
.lock()
.clone();
let selected_available =
selected.trim().is_empty() || devices.iter().any(|device| device.name == selected);
@@ -972,14 +978,16 @@ pub(crate) fn refresh_tray_microphone_menu(app: &AppHandle) -> tauri::Result<()>
}
#[cfg(not(mobile))]
fn microphone_device_signature() -> Option<Vec<(String, bool)>> {
fn microphone_devices_with_signature(
) -> Option<(Vec<recorder::MicrophoneDevice>, 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
@@ -987,6 +995,30 @@ fn microphone_device_signature() -> Option<Vec<(String, bool)>> {
}
}
/// 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<Option<Vec<(String, bool)>>>,
) {
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::<TrayMicrophoneDeviceCache>().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))]
@@ -998,24 +1030,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}");
}
}
}
@@ -1044,7 +1081,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 内生效,避免退出时
@@ -1058,13 +1097,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);
}
})
{
@@ -2473,13 +2506,23 @@ pub(crate) fn show_less_computer_window<R: tauri::Runtime>(app: &AppHandle<R>) {
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;
@@ -440,6 +440,25 @@ function DesktopOnboarding({
<div style={footerHintStyle}>
{t('onboarding.footerHint')}
</div>
<button
type="button"
onClick={onComplete}
style={{
width: '100%',
marginTop: 12,
padding: '9px 14px',
fontSize: 12.5,
fontWeight: 500,
fontFamily: 'inherit',
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 8,
background: 'var(--ol-surface)',
color: 'var(--ol-ink-2)',
cursor: 'pointer',
}}
>
{t('onboarding.continueToSettings')}
</button>
</div>
</OnboardingSurface>
);
+13 -2
View File
@@ -273,6 +273,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',
@@ -681,10 +682,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',
@@ -698,7 +699,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',
+13 -2
View File
@@ -275,6 +275,7 @@ export const ja: typeof zhCN = {
actionRequestMic: '許可ダイアログを表示',
accessibilityHint: '許可後は **OpenLess を完全に終了** してから再起動してください(macOS TCC の仕様)。',
footerHint: 'すべての権限が揃うとこのガイドは自動で閉じます。閉じない場合はメニューバーの OpenLess → 終了 から再起動してください。',
continueToSettings: '設定のみ開く(音声とグローバルショートカットは利用不可)',
androidContinue: 'アプリに進む',
androidFooterHint: '音声入力にはマイク権限が必要です。上の「許可ダイアログを表示」をタップするか、先にアプリへ進み、概要ページで後から許可してください。',
androidTitle: 'OpenLess を設定',
@@ -683,10 +684,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 バックエンド',
@@ -700,7 +701,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: 'デバッグツール',
+13 -2
View File
@@ -275,6 +275,7 @@ export const ko: typeof zhCN = {
actionRequestMic: '권한 대화상자 표시',
accessibilityHint: '허용 후에는 **OpenLess 를 완전히 종료** 한 다음 다시 실행해야 합니다(macOS TCC 규칙).',
footerHint: '모든 권한이 부여되면 이 가이드는 자동으로 닫힙니다. 닫히지 않으면 메뉴 막대의 OpenLess → 종료 후 앱을 다시 실행해 주세요.',
continueToSettings: '설정만 열기(음성 및 전역 단축키는 사용 불가)',
androidContinue: '앱으로 계속',
androidFooterHint: '받아쓰기에는 마이크 권한이 필요합니다. 위의 권한 요청을 탭하거나, 앱으로 먼저 들어가 개요 페이지에서 나중에 허용할 수 있습니다.',
androidTitle: 'OpenLess 설정',
@@ -683,10 +684,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 백엔드',
@@ -700,7 +701,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: '디버그 도구',
+13 -2
View File
@@ -271,6 +271,7 @@ export const zhCN = {
actionRequestMic: '弹出授权',
accessibilityHint: '授权后必须**完全退出 OpenLess** 再重新打开(macOS TCC 规则)。',
footerHint: '授权全部完成后此引导自动关闭。如果一直不消失,从菜单栏 OpenLess → 退出,重新打开 App。',
continueToSettings: '仅进入设置(语音与全局快捷键暂不可用)',
androidContinue: '先进入应用',
androidFooterHint: '听写需要麦克风权限。可点击上方「弹出授权」,或先进入应用后在概览页继续授权。',
androidTitle: '配置 OpenLess',
@@ -679,10 +680,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 后端',
@@ -696,7 +697,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: '调试工具',
+13 -2
View File
@@ -273,6 +273,7 @@ export const zhTW: typeof zhCN = {
actionRequestMic: '彈出授權',
accessibilityHint: '授權後必須**完全退出 OpenLess** 再重新打開(macOS TCC 規則)。',
footerHint: '授權全部完成後此引導自動關閉。如果一直不消失,從菜單欄 OpenLess → 退出,重新打開 App。',
continueToSettings: '僅進入設定(語音與全域快速鍵暫不可用)',
androidContinue: '先進入應用',
androidFooterHint: '聽寫需要麥克風權限。可點擊上方「彈出授權」,或先進入應用後在概覽頁繼續授權。',
androidTitle: '配置 OpenLess',
@@ -681,10 +682,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 後端',
@@ -698,7 +699,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: '除錯工具',
@@ -38,6 +38,18 @@ export function codingAgentDetectOpencode(exe?: string): Promise<OpenCodeDetecti
)
}
/** 拉取当前 OpenCode 配置可用的 `provider/model` 列表。 */
export function codingAgentListOpencodeModels(
exe?: string,
refresh = true,
): Promise<string[]> {
return invokeOrMock(
"coding_agent_list_opencode_models",
{ exe, refresh },
() => [],
)
}
/** 无头 Claude 运行事件,由后端 `coding-agent:test` 流式推送(tag 为 `kind`)。 */
export type CodingAgentEvent =
| { kind: "started"; session_id: string }
+2
View File
@@ -137,6 +137,7 @@ export {
// less-computer
export {
lessComputerWindowDismiss,
lessComputerWindowOpen,
lessComputerApprove,
lessComputerSubmitText,
lessComputerSync,
@@ -177,6 +178,7 @@ export type {
export {
codingAgentDetect,
codingAgentDetectOpencode,
codingAgentListOpencodeModels,
codingAgentRunTest,
codingAgentCancelTest,
codingAgentCommandRisk,
@@ -6,6 +6,11 @@ export function lessComputerWindowDismiss(): Promise<void> {
return invokeOrMock("less_computer_window_dismiss", undefined, () => undefined)
}
/** 从主设置页打开文字交互浮窗,无需先触发麦克风或全局快捷键。 */
export function lessComputerWindowOpen(): Promise<void> {
return invokeOrMock("less_computer_window_open", undefined, () => undefined)
}
/** 内联审批卡的 Approve / Deny 回执。token 关联到等待中的拦截动作。 */
export function lessComputerApprove(
token: string,
@@ -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 { SelectLite } from '../../components/ui/SelectLite'
@@ -19,6 +24,8 @@ const PERMISSION_MODES: CodingAgentPermissionMode[] = [
'bypassPermissions',
]
type OpenCodeModelsStatus = 'idle' | 'loading' | 'loaded' | 'error'
export function CodingAgentSection() {
const { t } = useTranslation()
const { prefs, updatePrefs: savePrefs } = useHotkeySettings()
@@ -26,22 +33,62 @@ export function CodingAgentSection() {
// OpenCode 安装检测:仅当启用 + 选了 OpenCode 后端时探测一次,用于提示是否需先安装。
const [opencode, setOpencode] = useState<OpenCodeDetection | null>(null)
const [opencodeModels, setOpencodeModels] = useState<string[]>([])
const [opencodeModelsStatus, setOpencodeModelsStatus] = useState<OpenCodeModelsStatus>('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) {
@@ -72,7 +119,13 @@ export function CodingAgentSection() {
<SettingRow label={t('settings.codingAgent.provider')}>
<SelectLite
value={prefs.codingAgentProvider}
onChange={v => void savePrefs({ ...prefs, codingAgentProvider: v as CodingAgentProviderId })}
onChange={v =>
void savePrefs({
...prefs,
codingAgentProvider: v as CodingAgentProviderId,
codingAgentModel: null,
})
}
options={[
{ value: 'claude-code-cli', label: 'Claude Code' },
{ value: 'opencode-cli', label: 'OpenCode' },
@@ -111,22 +164,96 @@ export function CodingAgentSection() {
/>
</SettingRow>
<SettingRow label={t('settings.codingAgent.model')} desc={t('settings.codingAgent.modelHint')}>
<SelectLite
value={prefs.codingAgentModel ?? ''}
onChange={v => void savePrefs({ ...prefs, codingAgentModel: v === '' ? null : v })}
options={[
// 空值 = 使用 CLI 默认模型;放回选项里,避免选了具体模型后回不去默认。
{ value: '', label: t('settings.codingAgent.modelDefault') },
{ value: 'haiku', label: 'Haiku' },
{ value: 'sonnet', label: 'Sonnet' },
{ value: 'opus', label: 'Opus' },
]}
ariaLabel={t('settings.codingAgent.model')}
style={{ ...inputStyle, maxWidth: 240 }}
/>
<SettingRow
label={t('settings.codingAgent.model')}
desc={t(
useOpencode
? 'settings.codingAgent.opencodeModelHint'
: 'settings.codingAgent.modelHint',
)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<SelectLite
value={
useOpencode
? prefs.codingAgentModel?.includes('/')
? prefs.codingAgentModel
: ''
: (prefs.codingAgentModel ?? '')
}
onChange={v => void savePrefs({ ...prefs, codingAgentModel: v === '' ? null : v })}
options={
useOpencode
? [
// 空值 = 使用 OpenCode CLI 默认模型。
{ value: '', label: t('settings.codingAgent.opencodeModelDefault') },
// 已选但不在拉取结果里的模型仍保留,避免选中项凭空消失。
...(prefs.codingAgentModel?.includes('/') &&
!opencodeModels.includes(prefs.codingAgentModel)
? [{ value: prefs.codingAgentModel, label: prefs.codingAgentModel }]
: []),
...opencodeModels.map(model => ({ value: model, label: model })),
]
: [
// 空值 = 使用 CLI 默认模型;放回选项里,避免选了具体模型后回不去默认。
{ value: '', label: t('settings.codingAgent.modelDefault') },
{ value: 'haiku', label: 'Haiku' },
{ value: 'sonnet', label: 'Sonnet' },
{ value: 'opus', label: 'Opus' },
]
}
ariaLabel={t('settings.codingAgent.model')}
style={{ ...inputStyle, maxWidth: 300 }}
/>
{useOpencode && opencode?.installed && (
<button
type="button"
disabled={opencodeModelsStatus === 'loading'}
onClick={() => void refreshOpencodeModels()}
style={{
...inputStyle,
width: 'auto',
cursor: opencodeModelsStatus === 'loading' ? 'default' : 'pointer',
opacity: opencodeModelsStatus === 'loading' ? 0.65 : 1,
}}
>
{t(
opencodeModelsStatus === 'loading'
? 'settings.codingAgent.opencodeModelsRefreshing'
: 'settings.codingAgent.opencodeModelsRefresh',
)}
</button>
)}
</div>
</SettingRow>
{useOpencode && opencode?.installed && opencodeModelsStatus !== 'idle' && (
<div
role={opencodeModelsStatus === 'error' ? 'alert' : 'status'}
style={{
fontSize: 12,
lineHeight: 1.6,
color:
opencodeModelsStatus === 'error'
? 'var(--ol-warn, #b8860b)'
: 'var(--ol-ink-4)',
margin: '-4px 0 8px',
}}
>
{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')}
</div>
)}
<SettingRow label={t('settings.codingConsole.workdir')} desc={t('settings.codingConsole.workdirDesc')}>
<input
type="text"
@@ -154,6 +281,19 @@ export function CodingAgentSection() {
style={inputStyle}
/>
</SettingRow>
<SettingRow
label={t('settings.codingAgent.openPanel')}
desc={t('settings.codingAgent.openPanelHint')}
>
<button
type="button"
onClick={() => void lessComputerWindowOpen()}
style={{ ...inputStyle, width: 'auto', cursor: 'pointer' }}
>
{t('settings.codingAgent.openPanelAction')}
</button>
</SettingRow>
</>
)}
</Card>