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:
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` —— 用户说的话)
|
||||
|
||||
@@ -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)。运行后若检测到护栏拦截,弹审批卡;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'デバッグツール',
|
||||
|
||||
@@ -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: '디버그 도구',
|
||||
|
||||
@@ -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: '调试工具',
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user