From 0d69599f3742097f574a030db433a39d2864dd7a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 18:34:08 +0000 Subject: [PATCH 1/3] Add ASR providers and GitHub PR plugin Co-authored-by: Gao Yu --- docs/plugins.md | 20 + src-tauri/src/backend.rs | 92 ++-- src-tauri/src/browser.rs | 4 +- src-tauri/src/capture.rs | 40 +- src-tauri/src/claude_history.rs | 24 +- src-tauri/src/installer.rs | 65 ++- src-tauri/src/lib.rs | 771 ++++++++++++++++++++--------- src-tauri/src/plugins/github_pr.rs | 148 ++++++ src-tauri/src/plugins/mod.rs | 1 + src-tauri/src/shell_env.rs | 39 +- src/lib/GitPanel.svelte | 142 +++--- src/lib/Settings.svelte | 96 +++- src/lib/audio.test.ts | 28 +- src/lib/audio.ts | 68 ++- src/lib/gitops.test.ts | 53 -- src/lib/gitops.ts | 43 +- src/lib/i18n/messages/chat.ts | 4 +- src/lib/i18n/messages/settings.ts | 30 +- src/lib/plugins/github-pr.test.ts | 44 ++ src/lib/plugins/github-pr.ts | 95 ++++ src/lib/plugins/registry.ts | 57 +++ src/lib/protocol.ts | 8 +- src/lib/ui/Switch.svelte | 12 +- 23 files changed, 1376 insertions(+), 508 deletions(-) create mode 100644 docs/plugins.md create mode 100644 src-tauri/src/plugins/github_pr.rs create mode 100644 src-tauri/src/plugins/mod.rs create mode 100644 src/lib/plugins/github-pr.test.ts create mode 100644 src/lib/plugins/github-pr.ts create mode 100644 src/lib/plugins/registry.ts diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..45a2adc --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,20 @@ +# First-party plugins + +JuCode Desktop uses a small built-in plugin registry. It is not a third-party JavaScript runtime: plugins are TypeScript modules shipped with the app, and native capabilities can be exposed through narrowly scoped Tauri commands. + +Each manifest in `src/lib/plugins/registry.ts` declares: + +- `id` and human-readable `name` +- the plugin-owned `commands` +- an optional required `bin` +- whether the plugin is enabled by default + +Users can enable or disable plugins in **Settings → Extensions → Plugins**. The choice is stored locally. Disabled plugins do not initialize or show their GitPanel controls. + +## GitHub Pull Requests + +`src/lib/plugins/github-pr.ts` owns GitHub CLI detection, authentication checks, PR lookup, and PR creation. Its optional binary is `gh`. + +The native bridge is isolated in `src-tauri/src/plugins/github_pr.rs`. It runs without prompts and allows only the arguments required by the plugin (`--version`, `auth status`, `pr view`, and `pr create`). It does not provide arbitrary shell or GitHub CLI execution. + +To add another first-party plugin, add its manifest and module to the registry, keep its UI behind the enabled setting, and add a focused native allowlist only when the feature needs OS-level access. diff --git a/src-tauri/src/backend.rs b/src-tauri/src/backend.rs index ada88d3..f0a0d06 100644 --- a/src-tauri/src/backend.rs +++ b/src-tauri/src/backend.rs @@ -127,8 +127,13 @@ const MAX_CUSTOM_ENV_VARS: usize = 50; const MAX_CUSTOM_ENV_VALUE_LEN: usize = 4096; /// Claude Code permission modes the desktop is allowed to request. -const CLAUDE_PERMISSION_MODES: &[&str] = - &["default", "plan", "auto", "acceptEdits", "bypassPermissions"]; +const CLAUDE_PERMISSION_MODES: &[&str] = &[ + "default", + "plan", + "auto", + "acceptEdits", + "bypassPermissions", +]; fn expect_string(key: &str, v: &serde_json::Value) -> Result { v.as_str() @@ -190,7 +195,9 @@ pub fn validate_opts( .as_object() .ok_or_else(|| "env must be an object of string values".to_string())?; if obj.len() > MAX_CUSTOM_ENV_VARS { - return Err(format!("env accepts at most {MAX_CUSTOM_ENV_VARS} variables")); + return Err(format!( + "env accepts at most {MAX_CUSTOM_ENV_VARS} variables" + )); } let mut vars = Vec::with_capacity(obj.len()); for (name, val) in obj { @@ -364,7 +371,11 @@ fn well_known_paths(kind: BackendKind) -> Vec { /// Dev fallback for the native engine only: the freshly-built binary from the /// sibling `JuCode-CLI` checkout (mirrors the pre-multi-backend behavior). fn jucode_dev_candidates() -> Vec { - let exe = if cfg!(windows) { "jucode.exe" } else { "jucode" }; + let exe = if cfg!(windows) { + "jucode.exe" + } else { + "jucode" + }; let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); // /src-tauri [ format!("../../JuCode-CLI/target/debug/{exe}"), @@ -538,7 +549,10 @@ mod tests { let args = build_args(BackendKind::Claude, &opts); let ri = args.iter().position(|a| a == "--resume").unwrap(); assert_eq!(args[ri + 1], "0f3d7a1c-9e2b-4b7e-9d4d-2a1b3c4d5e6f"); - let ai = args.iter().position(|a| a == "--resume-session-at").unwrap(); + let ai = args + .iter() + .position(|a| a == "--resume-session-at") + .unwrap(); assert_eq!(args[ai + 1], "aa11bb22-cc33-dd44-ee55-ff6677889900"); // resume_session_at without resume is rejected. assert!(validate_opts( @@ -573,7 +587,11 @@ mod tests { assert!(err.is_err(), "{kind:?} must reject permission_mode"); } assert!(validate_opts(BackendKind::Claude, Some(&json!({ "argv": ["-x"] }))).is_err()); - assert!(validate_opts(BackendKind::Claude, Some(&json!({ "extra_flag": "--yolo" }))).is_err()); + assert!(validate_opts( + BackendKind::Claude, + Some(&json!({ "extra_flag": "--yolo" })) + ) + .is_err()); } #[test] @@ -583,31 +601,29 @@ mod tests { Some(&json!({ "model": "--dangerously-skip-permissions" })) ) .is_err()); - assert!(validate_opts( - BackendKind::Claude, - Some(&json!({ "resume": "--help" })) - ) - .is_err()); - assert!(validate_opts( - BackendKind::Jucode, - Some(&json!({ "bin_override": "-rf" })) - ) - .is_err()); + assert!(validate_opts(BackendKind::Claude, Some(&json!({ "resume": "--help" }))).is_err()); + assert!( + validate_opts(BackendKind::Jucode, Some(&json!({ "bin_override": "-rf" }))).is_err() + ); } #[test] fn permission_mode_is_a_fixed_enum() { for ok in CLAUDE_PERMISSION_MODES { - assert!(validate_opts(BackendKind::Claude, Some(&json!({ "permission_mode": ok }))).is_ok()); + assert!( + validate_opts(BackendKind::Claude, Some(&json!({ "permission_mode": ok }))).is_ok() + ); } assert!(validate_opts( BackendKind::Claude, Some(&json!({ "permission_mode": "bypassPermissions --verbose" })) ) .is_err()); - assert!( - validate_opts(BackendKind::Claude, Some(&json!({ "permission_mode": "yolo" }))).is_err() - ); + assert!(validate_opts( + BackendKind::Claude, + Some(&json!({ "permission_mode": "yolo" })) + ) + .is_err()); } #[test] @@ -648,7 +664,10 @@ mod tests { #[test] fn null_or_missing_opts_mean_defaults() { - assert_eq!(validate_opts(BackendKind::Jucode, None).unwrap(), BackendOpts::default()); + assert_eq!( + validate_opts(BackendKind::Jucode, None).unwrap(), + BackendOpts::default() + ); assert_eq!( validate_opts(BackendKind::Codex, Some(&serde_json::Value::Null)).unwrap(), BackendOpts::default() @@ -665,11 +684,17 @@ mod tests { fn use_shell_env_defaults_true_and_accepts_bool_only() { for kind in [BackendKind::Jucode, BackendKind::Codex, BackendKind::Claude] { assert!(validate_opts(kind, None).unwrap().use_shell_env); - assert!(!validate_opts(kind, Some(&json!({ "use_shell_env": false }))) - .unwrap() - .use_shell_env); + assert!( + !validate_opts(kind, Some(&json!({ "use_shell_env": false }))) + .unwrap() + .use_shell_env + ); } - assert!(validate_opts(BackendKind::Jucode, Some(&json!({ "use_shell_env": "yes" }))).is_err()); + assert!(validate_opts( + BackendKind::Jucode, + Some(&json!({ "use_shell_env": "yes" })) + ) + .is_err()); } #[test] @@ -688,7 +713,10 @@ mod tests { json!({ "env": { "A": 42 } }), json!({ "env": "PATH=/x" }), ] { - assert!(validate_opts(BackendKind::Claude, Some(&bad)).is_err(), "{bad}"); + assert!( + validate_opts(BackendKind::Claude, Some(&bad)).is_err(), + "{bad}" + ); } } @@ -763,7 +791,9 @@ mod tests { assert_eq!(p, PathBuf::from("claude")); let probed = hits.lock().unwrap(); assert!(!probed.is_empty()); - assert!(probed.iter().any(|c| c.ends_with(".local/bin/claude") || c.ends_with(".local\\bin\\claude.exe"))); + assert!(probed + .iter() + .any(|c| c.ends_with(".local/bin/claude") || c.ends_with(".local\\bin\\claude.exe"))); } #[test] @@ -772,7 +802,13 @@ mod tests { c.to_string_lossy().contains("JuCode-CLI/target/debug") }); assert!(p.to_string_lossy().contains("JuCode-CLI/target/debug")); - let none = resolve_with(BackendKind::Jucode, None, &no_env, &no_which, ¬hing_exists); + let none = resolve_with( + BackendKind::Jucode, + None, + &no_env, + &no_which, + ¬hing_exists, + ); assert_eq!(none, PathBuf::from("jucode")); } } diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index fb65566..1cdd77d 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -148,7 +148,9 @@ pub fn browser_open( #[tauri::command] pub fn browser_navigate(app: AppHandle, url: String) -> Result<(), String> { let target = normalize_url(&url)?; - get_browser(&app)?.navigate(target).map_err(|e| e.to_string()) + get_browser(&app)? + .navigate(target) + .map_err(|e| e.to_string()) } #[tauri::command] diff --git a/src-tauri/src/capture.rs b/src-tauri/src/capture.rs index 2cbe18d..ea2a710 100644 --- a/src-tauri/src/capture.rs +++ b/src-tauri/src/capture.rs @@ -94,7 +94,9 @@ fn pick_shot_backend( "windows" => Ok(ShotBackend::WinSnip), "linux" => { if wayland && has("grim") { - return Ok(ShotBackend::Grim { slurp: has("slurp") }); + return Ok(ShotBackend::Grim { + slurp: has("slurp"), + }); } if has("gnome-screenshot") { return Ok(ShotBackend::GnomeScreenshot); @@ -329,7 +331,10 @@ fn win_capture_fullscreen(path: &Path) -> Result { #[tauri::command] pub fn start_screen_recording(rec: tauri::State) -> Result<(), String> { let backend = pick_rec_backend(std::env::consts::OS, is_wayland(), &has_tool)?; - let mut guard = rec.inner.lock().map_err(|e| format!("lock poisoned: {e}"))?; + let mut guard = rec + .inner + .lock() + .map_err(|e| format!("lock poisoned: {e}"))?; if guard.is_some() { return Err("已有录屏进行中 / A screen recording is already in progress".to_string()); } @@ -402,8 +407,8 @@ pub fn stop_screen_recording(rec: tauri::State<'_, Recorder>) -> Result { let _ = Command::new("kill") @@ -456,7 +461,12 @@ fn find_tool(name: &str) -> Result { let mut dirs: Vec = Vec::new(); if let Some(la) = std::env::var_os("LOCALAPPDATA") { // winget (Gyan.FFmpeg) shim directory. - dirs.push(PathBuf::from(la).join("Microsoft").join("WinGet").join("Links")); + dirs.push( + PathBuf::from(la) + .join("Microsoft") + .join("WinGet") + .join("Links"), + ); } dirs.push(PathBuf::from("C:\\ffmpeg\\bin")); for dir in dirs { @@ -466,7 +476,12 @@ fn find_tool(name: &str) -> Result { } } } else { - for dir in ["/opt/homebrew/bin", "/usr/local/bin", "/opt/local/bin", "/usr/bin"] { + for dir in [ + "/opt/homebrew/bin", + "/usr/local/bin", + "/opt/local/bin", + "/usr/bin", + ] { let candidate = Path::new(dir).join(name); if candidate.is_file() { return Ok(candidate); @@ -623,9 +638,12 @@ mod tests { #[test] fn wayland_prefers_grim_with_slurp() { - let got = - pick_shot_backend("linux", true, &avail(&["grim", "slurp", "gnome-screenshot"])) - .unwrap(); + let got = pick_shot_backend( + "linux", + true, + &avail(&["grim", "slurp", "gnome-screenshot"]), + ) + .unwrap(); assert_eq!(got, ShotBackend::Grim { slurp: true }); } @@ -645,8 +663,8 @@ mod tests { #[test] fn x11_ignores_grim_and_probes_in_order() { // grim is wayland-only: never picked on X11 even if installed. - let got = pick_shot_backend("linux", false, &avail(&["grim", "spectacle", "scrot"])) - .unwrap(); + let got = + pick_shot_backend("linux", false, &avail(&["grim", "spectacle", "scrot"])).unwrap(); assert_eq!(got, ShotBackend::Spectacle); let got = pick_shot_backend("linux", false, &avail(&["grim", "scrot"])).unwrap(); assert_eq!(got, ShotBackend::Scrot); diff --git a/src-tauri/src/claude_history.rs b/src-tauri/src/claude_history.rs index d0c3838..50b44dd 100644 --- a/src-tauri/src/claude_history.rs +++ b/src-tauri/src/claude_history.rs @@ -297,7 +297,9 @@ mod tests { &home, cwd, "aaaaaaaa-1111-4111-8111-111111111111", - &[r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"fix the login bug"}]}}"#], + &[ + r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"fix the login bug"}]}}"#, + ], ); let newer = write_session( &home, @@ -375,9 +377,18 @@ mod tests { assert_eq!( rows, vec![ - ClaudeTranscriptRow { role: "user".into(), content: "run a command".into() }, - ClaudeTranscriptRow { role: "assistant".into(), content: "on it".into() }, - ClaudeTranscriptRow { role: "assistant".into(), content: "done".into() }, + ClaudeTranscriptRow { + role: "user".into(), + content: "run a command".into() + }, + ClaudeTranscriptRow { + role: "assistant".into(), + content: "on it".into() + }, + ClaudeTranscriptRow { + role: "assistant".into(), + content: "done".into() + }, ] ); } @@ -431,6 +442,9 @@ mod tests { assert_eq!(rows.len(), MAX_TRANSCRIPT_ROWS); // The oldest rows were dropped, the newest kept. assert_eq!(rows.last().unwrap().role, "assistant"); - assert_eq!(rows.last().unwrap().content.chars().count(), MAX_ROW_CHARS + 1); // +1 = ellipsis + assert_eq!( + rows.last().unwrap().content.chars().count(), + MAX_ROW_CHARS + 1 + ); // +1 = ellipsis } } diff --git a/src-tauri/src/installer.rs b/src-tauri/src/installer.rs index 48bb2a5..f4d651f 100644 --- a/src-tauri/src/installer.rs +++ b/src-tauri/src/installer.rs @@ -142,21 +142,29 @@ fn system_plan( if has("winget") { winget(winget_id) } else { - Plan::OpenUrl { url: url.to_string() } + Plan::OpenUrl { + url: url.to_string(), + } } } "macos" => { if has("brew") { brew(brew_pkg) } else { - Plan::OpenUrl { url: url.to_string() } + Plan::OpenUrl { + url: url.to_string(), + } } } "linux" => match linux_pkg_command(linux_pkgs, has) { Some(command) => Plan::Manual { command }, - None => Plan::OpenUrl { url: url.to_string() }, + None => Plan::OpenUrl { + url: url.to_string(), + }, + }, + _ => Plan::OpenUrl { + url: url.to_string(), }, - _ => Plan::OpenUrl { url: url.to_string() }, } } @@ -169,14 +177,18 @@ pub fn plan(dep: Dep, os: &str, has: &dyn Fn(&str) -> bool) -> Plan { if has("npm") { npm_global("@openai/codex") } else { - Plan::NeedsPrereq { prereq: "node".to_string() } + Plan::NeedsPrereq { + prereq: "node".to_string(), + } } } Dep::Jucode => { if has("npm") { npm_global("@jucode/cli") } else { - Plan::NeedsPrereq { prereq: "node".to_string() } + Plan::NeedsPrereq { + prereq: "node".to_string(), + } } } Dep::Claude => { @@ -248,10 +260,15 @@ mod tests { // Linux with apt → copyable sudo command (nodejs + npm). assert_eq!( plan(Dep::Node, "linux", &avail(&["apt-get"])), - Plan::Manual { command: "sudo apt-get install -y nodejs npm".to_string() } + Plan::Manual { + command: "sudo apt-get install -y nodejs npm".to_string() + } ); // Linux without a known manager → download page. - assert!(matches!(plan(Dep::Node, "linux", &avail(&[])), Plan::OpenUrl { .. })); + assert!(matches!( + plan(Dep::Node, "linux", &avail(&[])), + Plan::OpenUrl { .. } + )); } #[test] @@ -260,10 +277,15 @@ mod tests { plan(Dep::Ffmpeg, "windows", &avail(&["winget"])), winget("Gyan.FFmpeg") ); - assert_eq!(plan(Dep::Ffmpeg, "macos", &avail(&["brew"])), brew("ffmpeg")); + assert_eq!( + plan(Dep::Ffmpeg, "macos", &avail(&["brew"])), + brew("ffmpeg") + ); assert_eq!( plan(Dep::Ffmpeg, "linux", &avail(&["dnf"])), - Plan::Manual { command: "sudo dnf install -y ffmpeg".to_string() } + Plan::Manual { + command: "sudo dnf install -y ffmpeg".to_string() + } ); } @@ -272,16 +294,29 @@ mod tests { // No npm → prereq. assert_eq!( plan(Dep::Codex, "windows", &avail(&[])), - Plan::NeedsPrereq { prereq: "node".to_string() } + Plan::NeedsPrereq { + prereq: "node".to_string() + } ); assert_eq!( plan(Dep::Jucode, "linux", &avail(&[])), - Plan::NeedsPrereq { prereq: "node".to_string() } + Plan::NeedsPrereq { + prereq: "node".to_string() + } ); // With npm → npm i -g , identical across platforms. - assert_eq!(plan(Dep::Codex, "windows", &avail(&["npm"])), npm_global("@openai/codex")); - assert_eq!(plan(Dep::Codex, "macos", &avail(&["npm"])), npm_global("@openai/codex")); - assert_eq!(plan(Dep::Jucode, "linux", &avail(&["npm"])), npm_global("@jucode/cli")); + assert_eq!( + plan(Dep::Codex, "windows", &avail(&["npm"])), + npm_global("@openai/codex") + ); + assert_eq!( + plan(Dep::Codex, "macos", &avail(&["npm"])), + npm_global("@openai/codex") + ); + assert_eq!( + plan(Dep::Jucode, "linux", &avail(&["npm"])), + npm_global("@jucode/cli") + ); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bcf9a24..315a2dc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,10 +1,10 @@ +use serde::Serialize; use std::collections::HashMap; use std::io::{BufRead, BufReader, Read, Write}; use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; -use serde::Serialize; use tauri::{AppHandle, Emitter, Manager}; mod backend; @@ -12,6 +12,7 @@ mod browser; mod capture; mod claude_history; mod installer; +mod plugins; mod shell_env; use backend::BackendKind; @@ -265,11 +266,7 @@ fn send_op( /// own protocol frame (JSON-RPC for codex, stream-json for claude) and sends it /// as one line. Embedded newlines are rejected — one call, one frame. #[tauri::command] -fn send_line( - session: String, - line: String, - engines: tauri::State, -) -> Result<(), String> { +fn send_line(session: String, line: String, engines: tauri::State) -> Result<(), String> { if line.contains('\n') || line.contains('\r') { return Err("line must be a single frame (no embedded newlines)".to_string()); } @@ -341,8 +338,12 @@ fn read_json(path: &std::path::Path) -> serde_json::Value { fn read_json_strict(path: &std::path::Path) -> Result { match std::fs::read_to_string(path) { Ok(text) if text.trim().is_empty() => Ok(serde_json::json!({})), - Ok(text) => serde_json::from_str(&text) - .map_err(|e| format!("{} 解析失败,已中止写入以免覆盖现有内容:{e}", path.display())), + Ok(text) => serde_json::from_str(&text).map_err(|e| { + format!( + "{} 解析失败,已中止写入以免覆盖现有内容:{e}", + path.display() + ) + }), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(serde_json::json!({})), Err(e) => Err(format!("读取 {} 失败:{e}", path.display())), } @@ -427,10 +428,7 @@ fn remove_auth_key(provider: String) -> Result<(), String> { root.remove("jucode"); } } - if let Some(map) = current - .get_mut("providers") - .and_then(|v| v.as_object_mut()) - { + if let Some(map) = current.get_mut("providers").and_then(|v| v.as_object_mut()) { map.remove(&provider); } write_json(&path, ¤t) @@ -461,7 +459,10 @@ fn unix_now() -> u64 { fn jucode_access_token() -> Result { let path = jucode_dir().join("auth.json"); let auth = read_json(&path); - let jucode = auth.get("jucode").cloned().unwrap_or_else(|| serde_json::json!({})); + let jucode = auth + .get("jucode") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); let access = jucode .get("access_token") .and_then(|v| v.as_str()) @@ -472,7 +473,10 @@ fn jucode_access_token() -> Result { .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); - let access_exp = jucode.get("access_expires_at").and_then(|v| v.as_u64()).unwrap_or(0); + let access_exp = jucode + .get("access_expires_at") + .and_then(|v| v.as_u64()) + .unwrap_or(0); if refresh.is_empty() { return Err("not logged in to JuCode".to_string()); } @@ -489,7 +493,10 @@ fn jucode_access_token() -> Result { .map_err(|e| format!("lock poisoned: {e}"))?; // Re-read after acquiring the lock: another thread may have just refreshed. let fresh = read_json(&path); - let fresh_jucode = fresh.get("jucode").cloned().unwrap_or_else(|| serde_json::json!({})); + let fresh_jucode = fresh + .get("jucode") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); let fresh_access = fresh_jucode .get("access_token") .and_then(|v| v.as_str()) @@ -524,12 +531,23 @@ fn jucode_access_token() -> Result { .map_err(|e| e.to_string())? .into_json() .map_err(|e| e.to_string())?; - let new_access = resp.get("access_token").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let new_refresh = resp.get("refresh_token").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let new_access = resp + .get("access_token") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let new_refresh = resp + .get("refresh_token") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); if new_access.is_empty() || new_refresh.is_empty() { return Err("JuCode session expired; please sign in again".to_string()); } - let expires_in = resp.get("expires_in").and_then(|v| v.as_u64()).unwrap_or(3600); + let expires_in = resp + .get("expires_in") + .and_then(|v| v.as_u64()) + .unwrap_or(3600); let refresh_expires_in = resp .get("refresh_expires_in") .and_then(|v| v.as_u64()) @@ -620,55 +638,294 @@ fn fetch_deepseek_balance() -> Result { .map_err(|e| e.to_string()) } -/// 语音转写:调用小米 MiMo ASR(OpenAI 兼容 chat/completions 端点,模型 -/// mimo-v2.5-asr),API key 存于 auth.json 的 providers.mimo。音频为 base64 -/// 编码的 WAV/MP3(编码后 ≤10MB),返回转写文本。 +#[derive(Clone, Copy, Debug, PartialEq)] +enum AsrProtocol { + Mimo, + OpenAiWhisper, + Deepgram, +} + +#[derive(Clone, Copy, Debug)] +struct AsrProvider { + id: &'static str, + name: &'static str, + base_url: &'static str, + model: &'static str, + auth_key: &'static str, + protocol: AsrProtocol, +} + +const ASR_PROVIDERS: &[AsrProvider] = &[ + AsrProvider { + id: "mimo", + name: "Xiaomi MiMo", + base_url: "https://api.xiaomimimo.com/v1", + model: "mimo-v2.5-asr", + auth_key: "mimo", + protocol: AsrProtocol::Mimo, + }, + AsrProvider { + id: "openai", + name: "OpenAI-compatible Whisper", + base_url: "https://api.openai.com/v1", + model: "whisper-1", + auth_key: "asr-openai", + protocol: AsrProtocol::OpenAiWhisper, + }, + AsrProvider { + id: "groq", + name: "Groq Whisper", + base_url: "https://api.groq.com/openai/v1", + model: "whisper-large-v3-turbo", + auth_key: "asr-groq", + protocol: AsrProtocol::OpenAiWhisper, + }, + AsrProvider { + id: "deepgram", + name: "Deepgram", + base_url: "https://api.deepgram.com/v1", + model: "nova-3", + auth_key: "asr-deepgram", + protocol: AsrProtocol::Deepgram, + }, +]; + +#[derive(Debug)] +struct AsrConfig { + provider: &'static AsrProvider, + base_url: String, + model: String, +} + +#[derive(Debug)] +struct AsrHttpRequest { + url: String, + headers: Vec<(&'static str, String)>, + body: Vec, +} + +fn asr_provider(id: &str) -> Option<&'static AsrProvider> { + ASR_PROVIDERS.iter().find(|provider| provider.id == id) +} + +fn resolve_asr_config(config: &serde_json::Value) -> Result { + let raw = config.get("asr").and_then(|value| value.as_object()); + let id = raw + .and_then(|value| value.get("provider")) + .and_then(|value| value.as_str()) + .unwrap_or("mimo"); + let provider = asr_provider(id).ok_or_else(|| format!("Unsupported ASR provider: {id}"))?; + let base_url = raw + .and_then(|value| value.get("base_url")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(provider.base_url) + .trim_end_matches('/') + .to_string(); + if !(base_url.starts_with("https://") || base_url.starts_with("http://")) { + return Err("ASR base URL must start with http:// or https://".to_string()); + } + let model = raw + .and_then(|value| value.get("model")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(provider.model) + .to_string(); + Ok(AsrConfig { + provider, + base_url, + model, + }) +} + +fn append_multipart_field(body: &mut Vec, boundary: &str, name: &str, value: &str) { + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice( + format!("Content-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n").as_bytes(), + ); +} + +fn query_component(value: &str) -> String { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + let mut out = String::new(); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { + out.push(byte as char); + } else { + out.push('%'); + out.push(HEX[(byte >> 4) as usize] as char); + out.push(HEX[(byte & 0xf) as usize] as char); + } + } + out +} + +fn build_asr_request( + config: &AsrConfig, + key: &str, + audio: &[u8], + mime: &str, + language: &str, + boundary: &str, +) -> Result { + match config.provider.protocol { + AsrProtocol::Mimo => { + let body = serde_json::to_vec(&serde_json::json!({ + "model": config.model, + "messages": [{ + "role": "user", + "content": [{ + "type": "input_audio", + "input_audio": { + "data": format!( + "data:{mime};base64,{}", + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + audio + ) + ) + } + }] + }], + "asr_options": { "language": language } + })) + .map_err(|error| error.to_string())?; + Ok(AsrHttpRequest { + url: format!("{}/chat/completions", config.base_url), + headers: vec![ + ("api-key", key.to_string()), + ("Content-Type", "application/json".to_string()), + ], + body, + }) + } + AsrProtocol::OpenAiWhisper => { + let mut body = Vec::new(); + append_multipart_field(&mut body, boundary, "model", &config.model); + if language != "auto" && !language.is_empty() { + append_multipart_field(&mut body, boundary, "language", language); + } + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice( + format!( + "Content-Disposition: form-data; name=\"file\"; filename=\"recording.wav\"\r\nContent-Type: {mime}\r\n\r\n" + ) + .as_bytes(), + ); + body.extend_from_slice(audio); + body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); + Ok(AsrHttpRequest { + url: format!("{}/audio/transcriptions", config.base_url), + headers: vec![ + ("Authorization", format!("Bearer {key}")), + ( + "Content-Type", + format!("multipart/form-data; boundary={boundary}"), + ), + ], + body, + }) + } + AsrProtocol::Deepgram => { + let mut url = format!( + "{}/listen?model={}&smart_format=true", + config.base_url, + query_component(&config.model) + ); + if language != "auto" && !language.is_empty() { + url.push_str("&language="); + url.push_str(&query_component(language)); + } + Ok(AsrHttpRequest { + url, + headers: vec![ + ("Authorization", format!("Token {key}")), + ("Content-Type", mime.to_string()), + ], + body: audio.to_vec(), + }) + } + } +} + +fn parse_asr_response(protocol: AsrProtocol, response: &serde_json::Value) -> Option { + let text = match protocol { + AsrProtocol::Mimo => response + .get("choices") + .and_then(|value| value.get(0)) + .and_then(|value| value.get("message")) + .and_then(|value| value.get("content")), + AsrProtocol::OpenAiWhisper => response.get("text"), + AsrProtocol::Deepgram => response + .get("results") + .and_then(|value| value.get("channels")) + .and_then(|value| value.get(0)) + .and_then(|value| value.get("alternatives")) + .and_then(|value| value.get(0)) + .and_then(|value| value.get("transcript")), + }; + text.and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +/// Transcribes the composer's WAV payload using the ASR provider selected in +/// config.json. Provider API keys remain in auth.json. #[tauri::command(async)] fn transcribe_audio( audio_base64: String, mime: Option, language: Option, ) -> Result { + use base64::Engine as _; + + if audio_base64.len() > 14_000_000 { + return Err("Audio recording is larger than 10 MB".to_string()); + } + let config = resolve_asr_config(&read_json(&jucode_dir().join("config.json")))?; let key = read_json(&jucode_dir().join("auth.json")) .get("providers") - .and_then(|p| p.get("mimo")) - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .filter(|k| !k.is_empty()) - .ok_or_else(|| "未配置 MiMo API key(设置 → 账户 → 语音识别)".to_string())?; + .and_then(|providers| providers.get(config.provider.auth_key)) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + format!( + "No API key configured for {} (Settings → Account → Speech recognition)", + config.provider.name + ) + })?; + let audio = base64::engine::general_purpose::STANDARD + .decode(audio_base64) + .map_err(|error| format!("Invalid base64 audio: {error}"))?; + if audio.len() > 10 * 1024 * 1024 { + return Err("Audio recording is larger than 10 MB".to_string()); + } let mime = mime.unwrap_or_else(|| "audio/wav".to_string()); let language = language.unwrap_or_else(|| "auto".to_string()); - let body = serde_json::json!({ - "model": "mimo-v2.5-asr", - "messages": [{ - "role": "user", - "content": [{ - "type": "input_audio", - "input_audio": { "data": format!("data:{mime};base64,{audio_base64}") } - }] - }], - "asr_options": { "language": language } - }); - let resp = ureq::post("https://api.xiaomimimo.com/v1/chat/completions") - .timeout(std::time::Duration::from_secs(120)) - .set("api-key", &key) - .send_json(body) + let boundary = format!("jucode-asr-{}", std::process::id()); + let request = build_asr_request(&config, key, &audio, &mime, &language, &boundary)?; + let mut http = ureq::post(&request.url).timeout(std::time::Duration::from_secs(120)); + for (name, value) in &request.headers { + http = http.set(name, value); + } + let response = http + .send_bytes(&request.body) .map_err(|e| match e { ureq::Error::Status(code, r) => format!( - "MiMo ASR 请求失败(HTTP {code}):{}", + "{} transcription failed (HTTP {code}): {}", + config.provider.name, r.into_string().unwrap_or_default() ), other => other.to_string(), })? .into_json::() .map_err(|e| e.to_string())?; - resp.get("choices") - .and_then(|c| c.get(0)) - .and_then(|c| c.get("message")) - .and_then(|m| m.get("content")) - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .ok_or_else(|| format!("无法解析转写结果:{resp}")) + parse_asr_response(config.provider.protocol, &response) + .ok_or_else(|| format!("Could not parse transcription response: {response}")) } /// One-shot LLM text generation (no agent, no chat pollution) — used for AI @@ -735,8 +992,8 @@ fn generate_text( ], "temperature": 0.3, }); - let mut req = ureq::post(&format!("{base}/chat/completions")) - .timeout(std::time::Duration::from_secs(90)); + let mut req = + ureq::post(&format!("{base}/chat/completions")).timeout(std::time::Duration::from_secs(90)); if !key.is_empty() { req = req.set("Authorization", &format!("Bearer {key}")); } @@ -977,7 +1234,9 @@ fn check_environment() -> EnvReport { }; let engine = DepStatus { present: engine_path.is_some(), - detail: engine_path.map(|p| p.display().to_string()).unwrap_or_default(), + detail: engine_path + .map(|p| p.display().to_string()) + .unwrap_or_default(), }; EnvReport { @@ -1192,9 +1451,7 @@ fn run_install(name: String, app: AppHandle) -> Result { let (program, args) = match plan { installer::Plan::Manual { command } => return Ok(InstallStart::ManualCommand { command }), installer::Plan::OpenUrl { url } => return Ok(InstallStart::OpenUrl { url }), - installer::Plan::NeedsPrereq { prereq } => { - return Ok(InstallStart::NeedsPrereq { prereq }) - } + installer::Plan::NeedsPrereq { prereq } => return Ok(InstallStart::NeedsPrereq { prereq }), installer::Plan::Run { program, args } => (program, args), }; // Resolve the logical program name through PATH (e.g. `npm` → `npm.cmd`). @@ -1258,7 +1515,11 @@ fn list_dir(path: Option, root: Option) -> Result, is_dir, }); } - entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + entries.sort_by(|a, b| { + b.is_dir + .cmp(&a.is_dir) + .then(a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); Ok(entries) } @@ -1267,10 +1528,7 @@ fn list_dir(path: Option, root: Option) -> Result, fn list_providers() -> Result { let mut cmd = Command::new(resolve_bin()); no_window(&mut cmd); - let out = cmd - .arg("providers") - .output() - .map_err(|e| e.to_string())?; + let out = cmd.arg("providers").output().map_err(|e| e.to_string())?; if !out.status.success() { return Err(String::from_utf8_lossy(&out.stderr).to_string()); } @@ -1519,7 +1777,10 @@ fn git_head_text(path: String, cwd: Option) -> Result { return Err(String::from_utf8_lossy(&out.stderr).into_owned()); } if out.stdout.len() as u64 > MAX_TEXT_READ { - return Err(format!("file too large to diff ({} bytes)", out.stdout.len())); + return Err(format!( + "file too large to diff ({} bytes)", + out.stdout.len() + )); } String::from_utf8(out.stdout).map_err(|_| "not a UTF-8 text file".to_string()) } @@ -1529,9 +1790,27 @@ fn git_head_text(path: String, cwd: Option) -> Result { /// argument-validated set of remote operations (fetch / pull / push / remote -v). /// Anything that can run arbitrary programs is intentionally excluded. const GIT_SUBCOMMANDS: &[&str] = &[ - "status", "log", "diff", "add", "reset", "restore", "commit", "stash", "show", - "rev-parse", "branch", "checkout", "switch", "ls-files", "clean", "fetch", "pull", - "push", "remote", "merge", "rev-list", + "status", + "log", + "diff", + "add", + "reset", + "restore", + "commit", + "stash", + "show", + "rev-parse", + "branch", + "checkout", + "switch", + "ls-files", + "clean", + "fetch", + "pull", + "push", + "remote", + "merge", + "rev-list", ]; /// 需要访问网络的子命令:禁用凭据交互提示、限时执行(防止卡在等待输入上)。 @@ -1577,16 +1856,52 @@ fn is_valid_ref_name(s: &str) -> bool { fn git_flag_allowed(sub: &str, arg: &str) -> bool { let base = arg.split_once('=').map(|(k, _)| k).unwrap_or(arg); let allowed: &[&str] = match sub { - "status" => &["--porcelain", "-s", "-b", "-sb", "--short", "--branch", "--no-color"], - "log" => &["--oneline", "-n", "-1", "--no-color", "--pretty", "--format", "--max-count"], - "diff" => &["--cached", "--staged", "--no-color", "--stat", "--numstat", "--name-only"], + "status" => &[ + "--porcelain", + "-s", + "-b", + "-sb", + "--short", + "--branch", + "--no-color", + ], + "log" => &[ + "--oneline", + "-n", + "-1", + "--no-color", + "--pretty", + "--format", + "--max-count", + ], + "diff" => &[ + "--cached", + "--staged", + "--no-color", + "--stat", + "--numstat", + "--name-only", + ], "add" => &["-A", "--all"], "restore" => &["--staged", "--worktree"], "commit" => &["-m"], "stash" => &["-m", "-u", "--include-untracked"], "show" => &["--no-color", "--stat", "--pretty", "--format", "-s"], - "rev-parse" => &["--abbrev-ref", "--symbolic-full-name", "--short", "--verify"], - "branch" => &["--show-current", "--format", "--list", "--no-color", "-d", "-D", "--delete"], + "rev-parse" => &[ + "--abbrev-ref", + "--symbolic-full-name", + "--short", + "--verify", + ], + "branch" => &[ + "--show-current", + "--format", + "--list", + "--no-color", + "-d", + "-D", + "--delete", + ], "checkout" => &["-b"], // 注意:不放行 `-c`(与全局禁用的 config 短参撞名),创建分支用 // `switch --create` 或 `checkout -b`。 @@ -1904,7 +2219,11 @@ fn run_with_timeout( }; let stdout = out_thread.join().unwrap_or_default(); let stderr = err_thread.join().unwrap_or_default(); - Ok(std::process::Output { status, stdout, stderr }) + Ok(std::process::Output { + status, + stdout, + stderr, + }) } /// Runs a git command in the project root and returns stdout (or stderr on failure). @@ -1935,7 +2254,8 @@ fn git(args: Vec, cwd: Option) -> Result { let output = if is_remote { run_with_timeout(cmd, REMOTE_OP_TIMEOUT)? } else { - cmd.output().map_err(|e| format!("failed to run git: {e}"))? + cmd.output() + .map_err(|e| format!("failed to run git: {e}"))? }; if output.status.success() { Ok(String::from_utf8_lossy(&output.stdout).into_owned()) @@ -2005,7 +2325,11 @@ fn git_checkpoint_capture(cwd: String) -> Result { &["commit-tree", &tree, "-p", &head, "-m", "jucode-checkpoint"], )? } else { - git_plumb(&dir, None, &["commit-tree", &tree, "-m", "jucode-checkpoint"])? + git_plumb( + &dir, + None, + &["commit-tree", &tree, "-m", "jucode-checkpoint"], + )? }; Ok(commit) } @@ -2030,120 +2354,6 @@ fn git_checkpoint_restore(cwd: String, checkpoint: String) -> Result PathBuf { - if let Some(found) = which("gh") { - return found; - } - for candidate in ["/opt/homebrew/bin/gh", "/usr/local/bin/gh", "/usr/bin/gh"] { - let p = PathBuf::from(candidate); - if p.is_file() { - return p; - } - } - PathBuf::from("gh") -} - -/// gh CLI 桥的参数白名单:只放行 PR 工作流需要的四种调用 —— -/// `--version`(可用性检测)、`auth status`(登录检测)、 -/// `pr view --json …`(查询当前分支已有 PR)、`pr create`(创建 PR)。 -fn validate_gh_args(args: &[String]) -> Result<(), String> { - match args.first().map(String::as_str) { - Some("--version") if args.len() == 1 => Ok(()), - Some("auth") if args.len() == 2 && args[1] == "status" => Ok(()), - Some("pr") => validate_gh_pr_args(&args[1..]), - _ => Err(format!("gh arguments not allowed: {}", args.join(" "))), - } -} - -fn validate_gh_pr_args(rest: &[String]) -> Result<(), String> { - match rest.first().map(String::as_str) { - // gh pr view --json url,title,state,isDraft - Some("view") => { - let mut i = 1; - while i < rest.len() { - if rest[i] != "--json" { - return Err(format!("gh argument not allowed: {}", rest[i])); - } - let v = rest - .get(i + 1) - .ok_or_else(|| "--json requires a value".to_string())?; - if v.is_empty() || !v.chars().all(|c| c.is_ascii_alphanumeric() || c == ',') { - return Err(format!("gh --json fields not allowed: {v}")); - } - i += 2; - } - Ok(()) - } - // gh pr create --title … --body … [--base ] [--draft] - Some("create") => { - let mut i = 1; - let mut has_title = false; - while i < rest.len() { - match rest[i].as_str() { - "--title" | "--body" => { - // 值是自由文本(作为独立 argv 传给 gh,无 shell 解释)。 - if rest.get(i + 1).is_none() { - return Err(format!("{} requires a value", rest[i])); - } - has_title |= rest[i] == "--title"; - i += 2; - } - "--base" | "--head" => { - let v = rest - .get(i + 1) - .ok_or_else(|| format!("{} requires a value", rest[i]))?; - if !is_valid_ref_name(v) { - return Err(format!("invalid ref name: {v}")); - } - i += 2; - } - "--draft" => i += 1, - other => return Err(format!("gh argument not allowed: {other}")), - } - } - if !has_title { - return Err("gh pr create requires --title".to_string()); - } - Ok(()) - } - _ => Err(format!("gh pr subcommand not allowed: {}", rest.join(" "))), - } -} - -/// GitHub CLI bridge (separate from the git whitelist). Fully non-interactive: -/// prompts are disabled so a missing login fails fast with gh's stderr, and the -/// whole call is killed after a bounded timeout. -#[tauri::command(async)] -fn gh(args: Vec, cwd: Option) -> Result { - validate_gh_args(&args)?; - let dir = cwd.map(PathBuf::from).unwrap_or_else(resolve_cwd); - let mut cmd = Command::new(resolve_gh()); - no_window(&mut cmd); - // gh 的登录态/配置常依赖终端环境(GH_CONFIG_DIR、代理等)。 - shell_env::merge_into(&mut cmd); - cmd.args(&args) - .current_dir(dir) - // 全程非交互:未登录 / 缺配置时立即报错返回,绝不挂起等输入。 - .env("GH_PROMPT_DISABLED", "1") - .env("GH_NO_UPDATE_NOTIFIER", "1") - .env("GH_PAGER", "cat") - .env("NO_COLOR", "1") - .env("GIT_TERMINAL_PROMPT", "0"); - let output = run_with_timeout(cmd, REMOTE_OP_TIMEOUT)?; - if output.status.success() { - Ok(String::from_utf8_lossy(&output.stdout).into_owned()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); - if stderr.trim().is_empty() { - Err(String::from_utf8_lossy(&output.stdout).into_owned()) - } else { - Err(stderr) - } - } -} - // --- terminal (real PTY) --- struct Pty { @@ -2275,7 +2485,10 @@ fn pty_write(id: String, data: String, ptys: tauri::State) -> Result<(), S .get(&id) .cloned(); let pty = pty.ok_or_else(|| "unknown terminal".to_string())?; - let mut writer = pty.writer.lock().map_err(|e| format!("lock poisoned: {e}"))?; + let mut writer = pty + .writer + .lock() + .map_err(|e| format!("lock poisoned: {e}"))?; writer.write_all(data.as_bytes()).map_err(|e| e.to_string()) } @@ -2289,7 +2502,10 @@ fn pty_resize(id: String, cols: u16, rows: u16, ptys: tauri::State) -> Res .get(&id) .cloned(); let pty = pty.ok_or_else(|| "unknown terminal".to_string())?; - let master = pty.master.lock().map_err(|e| format!("lock poisoned: {e}"))?; + let master = pty + .master + .lock() + .map_err(|e| format!("lock poisoned: {e}"))?; master .resize(PtySize { rows, @@ -2397,7 +2613,9 @@ pub fn run() { // 不透明、只让侧栏半透明,于是磨砂只在侧栏透出(见 app.css 的 [data-vibrancy])。 #[cfg(target_os = "macos")] if let Some(win) = app.get_webview_window("main") { - use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial, NSVisualEffectState}; + use window_vibrancy::{ + apply_vibrancy, NSVisualEffectMaterial, NSVisualEffectState, + }; let _ = apply_vibrancy( &win, NSVisualEffectMaterial::Sidebar, @@ -2415,7 +2633,8 @@ pub fn run() { let _ = app.deep_link().register_all(); // 深链到达时先把窗口带到前台,具体路由由前端解析处理。 let handle = app.handle().clone(); - app.deep_link().on_open_url(move |_| show_main_window(&handle)); + app.deep_link() + .on_open_url(move |_| show_main_window(&handle)); } Ok(()) }) @@ -2468,7 +2687,7 @@ pub fn run() { check_dependencies, run_install, git, - gh, + plugins::github_pr::gh, worktree_base, claude_history::claude_sessions, claude_history::claude_session_transcript, @@ -2516,6 +2735,91 @@ mod tests { assert_eq!(read_json_strict(&p).unwrap(), serde_json::json!({})); } + #[test] + fn asr_provider_switch_uses_provider_defaults_and_overrides() { + use super::{resolve_asr_config, AsrProtocol}; + + let mimo = resolve_asr_config(&serde_json::json!({})).unwrap(); + assert_eq!(mimo.provider.id, "mimo"); + assert_eq!(mimo.provider.protocol, AsrProtocol::Mimo); + assert_eq!(mimo.model, "mimo-v2.5-asr"); + + let groq = + resolve_asr_config(&serde_json::json!({ "asr": { "provider": "groq" } })).unwrap(); + assert_eq!(groq.provider.protocol, AsrProtocol::OpenAiWhisper); + assert_eq!(groq.base_url, "https://api.groq.com/openai/v1"); + assert_eq!(groq.model, "whisper-large-v3-turbo"); + assert_eq!(groq.provider.auth_key, "asr-groq"); + + let deepgram = resolve_asr_config(&serde_json::json!({ + "asr": { + "provider": "deepgram", + "base_url": "https://speech.example/v1/", + "model": "custom-model" + } + })) + .unwrap(); + assert_eq!(deepgram.provider.protocol, AsrProtocol::Deepgram); + assert_eq!(deepgram.base_url, "https://speech.example/v1"); + assert_eq!(deepgram.model, "custom-model"); + } + + #[test] + fn asr_request_assembly_matches_each_protocol() { + use super::{build_asr_request, resolve_asr_config}; + + let audio = b"wav bytes"; + let mimo = resolve_asr_config(&serde_json::json!({})).unwrap(); + let request = + build_asr_request(&mimo, "mimo-key", audio, "audio/wav", "auto", "boundary").unwrap(); + assert_eq!( + request.url, + "https://api.xiaomimimo.com/v1/chat/completions" + ); + assert!(request + .headers + .contains(&("api-key", "mimo-key".to_string()))); + let json: serde_json::Value = serde_json::from_slice(&request.body).unwrap(); + assert_eq!(json["model"], "mimo-v2.5-asr"); + assert!(json["messages"][0]["content"][0]["input_audio"]["data"] + .as_str() + .unwrap() + .starts_with("data:audio/wav;base64,")); + + let openai = + resolve_asr_config(&serde_json::json!({ "asr": { "provider": "openai" } })).unwrap(); + let request = + build_asr_request(&openai, "openai-key", audio, "audio/wav", "en", "boundary").unwrap(); + assert_eq!( + request.url, + "https://api.openai.com/v1/audio/transcriptions" + ); + assert!(request + .headers + .contains(&("Authorization", "Bearer openai-key".to_string()))); + let body = String::from_utf8_lossy(&request.body); + assert!(body.contains("name=\"model\"\r\n\r\nwhisper-1")); + assert!(body.contains("name=\"language\"\r\n\r\nen")); + assert!(body.contains("filename=\"recording.wav\"")); + assert!(request + .body + .windows(audio.len()) + .any(|window| window == audio)); + + let deepgram = + resolve_asr_config(&serde_json::json!({ "asr": { "provider": "deepgram" } })).unwrap(); + let request = + build_asr_request(&deepgram, "dg-key", audio, "audio/wav", "zh", "boundary").unwrap(); + assert_eq!( + request.url, + "https://api.deepgram.com/v1/listen?model=nova-3&smart_format=true&language=zh" + ); + assert!(request + .headers + .contains(&("Authorization", "Token dg-key".to_string()))); + assert_eq!(request.body, audio); + } + // On Windows, npm installs a binary as both an extensionless POSIX-shell shim // and a `.cmd`. Only the `.cmd` is launchable, so resolution must prefer it and // never return the extensionless file (which CreateProcess cannot execute). @@ -2647,7 +2951,10 @@ mod tests { fn windows_advice_depends_on_winget() { let advice = git_install_advice("windows", &avail(&["winget"])); assert_eq!(advice.kind, "auto"); - assert!(advice.command.unwrap().contains("winget install --id Git.Git")); + assert!(advice + .command + .unwrap() + .contains("winget install --id Git.Git")); let advice = git_install_advice("windows", &avail(&[])); assert_eq!(advice.kind, "open-url"); assert_eq!(advice.url, "https://git-scm.com/download/win"); @@ -2664,7 +2971,7 @@ mod tests { // --- git bridge argument validation --- - use super::{is_valid_ref_name, is_valid_remote_name, validate_gh_args, validate_git_args}; + use super::{is_valid_ref_name, is_valid_remote_name, validate_git_args}; fn args(v: &[&str]) -> Vec { v.iter().map(|s| s.to_string()).collect() @@ -2692,7 +2999,10 @@ mod tests { vec!["checkout", "main"], vec!["remote", "-v"], ] { - assert!(validate_git_args(&args(&cmd)).is_ok(), "should allow: {cmd:?}"); + assert!( + validate_git_args(&args(&cmd)).is_ok(), + "should allow: {cmd:?}" + ); } } @@ -2704,7 +3014,10 @@ mod tests { vec!["push"], vec!["push", "-u", "origin", "feature/new-ui"], ] { - assert!(validate_git_args(&args(&cmd)).is_ok(), "should allow: {cmd:?}"); + assert!( + validate_git_args(&args(&cmd)).is_ok(), + "should allow: {cmd:?}" + ); } } @@ -2737,7 +3050,10 @@ mod tests { // 远端子命令禁止 `--` 逃逸 vec!["push", "--", "origin"], ] { - assert!(validate_git_args(&args(&cmd)).is_err(), "should reject: {cmd:?}"); + assert!( + validate_git_args(&args(&cmd)).is_err(), + "should reject: {cmd:?}" + ); } } @@ -2809,11 +3125,8 @@ mod tests { /// Creates //repo and returns (tmp_root, repo_root). fn tmp_repo(name: &str) -> (std::path::PathBuf, std::path::PathBuf) { - let root = std::env::temp_dir().join(format!( - "jucode-wt-test-{}-{}", - std::process::id(), - name - )); + let root = + std::env::temp_dir().join(format!("jucode-wt-test-{}-{}", std::process::id(), name)); let repo = root.join("repo"); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&repo).unwrap(); @@ -2826,7 +3139,10 @@ mod tests { let base = worktree_base_dir(&repo).unwrap(); assert_eq!( base, - root.canonicalize().unwrap().join(".jucode-worktrees").join("repo") + root.canonicalize() + .unwrap() + .join(".jucode-worktrees") + .join("repo") ); let _ = std::fs::remove_dir_all(&root); } @@ -2854,8 +3170,14 @@ mod tests { &repo ) .is_ok()); - assert!(validate_worktree_args(&args(&["worktree", "add", &ok, "-b", "task/my-task"]), &repo).is_ok()); - assert!(validate_worktree_args(&args(&["worktree", "add", &ok, "task/my-task"]), &repo).is_ok()); + assert!(validate_worktree_args( + &args(&["worktree", "add", &ok, "-b", "task/my-task"]), + &repo + ) + .is_ok()); + assert!( + validate_worktree_args(&args(&["worktree", "add", &ok, "task/my-task"]), &repo).is_ok() + ); // 容器外 / 穿越 / 非法 slug / 非法分支名 / 非法 flag 一律拒绝。 let escape = base.join("../evil").display().to_string(); @@ -2888,7 +3210,9 @@ mod tests { std::fs::create_dir_all(base.join("done-task")).unwrap(); let ok = base.join("done-task").display().to_string(); assert!(validate_worktree_args(&args(&["worktree", "remove", &ok]), &repo).is_ok()); - assert!(validate_worktree_args(&args(&["worktree", "remove", &ok, "--force"]), &repo).is_ok()); + assert!( + validate_worktree_args(&args(&["worktree", "remove", &ok, "--force"]), &repo).is_ok() + ); assert!(validate_worktree_args(&args(&["worktree", "list", "--porcelain"]), &repo).is_ok()); assert!(validate_worktree_args(&args(&["worktree", "prune"]), &repo).is_ok()); @@ -2928,15 +3252,26 @@ mod tests { assert!(in_root_or_task_container(&inside_repo, &canon_repo)); assert!(in_root_or_task_container(&inside_container, &canon_repo)); assert!(!in_root_or_task_container(&outside, &canon_repo)); - assert!(!in_root_or_task_container(std::path::Path::new("/etc"), &canon_repo)); + assert!(!in_root_or_task_container( + std::path::Path::new("/etc"), + &canon_repo + )); let _ = std::fs::remove_dir_all(&root); } #[test] fn merge_and_revlist_whitelist() { - assert!(validate_git_args(&args(&["merge", "--no-ff", "--no-edit", "task/fix-login"])).is_ok()); + assert!( + validate_git_args(&args(&["merge", "--no-ff", "--no-edit", "task/fix-login"])).is_ok() + ); assert!(validate_git_args(&args(&["merge", "--abort"])).is_ok()); - assert!(validate_git_args(&args(&["rev-list", "--left-right", "--count", "main...task/x"])).is_ok()); + assert!(validate_git_args(&args(&[ + "rev-list", + "--left-right", + "--count", + "main...task/x" + ])) + .is_ok()); assert!(validate_git_args(&args(&["merge", "--squash", "task/x"])).is_err()); assert!(validate_git_args(&args(&["merge", "--no-ff", "-evil"])).is_err()); @@ -2944,24 +3279,4 @@ mod tests { // worktree 不走通用校验入口。 assert!(validate_git_args(&args(&["worktree", "list", "--porcelain"])).is_err()); } - - #[test] - fn gh_whitelist_allows_pr_workflow_only() { - assert!(validate_gh_args(&args(&["--version"])).is_ok()); - assert!(validate_gh_args(&args(&["auth", "status"])).is_ok()); - assert!(validate_gh_args(&args(&["pr", "view", "--json", "url,title,state,isDraft"])).is_ok()); - assert!(validate_gh_args(&args(&[ - "pr", "create", "--title", "feat: x", "--body", "", "--base", "main", "--draft" - ])) - .is_ok()); - - assert!(validate_gh_args(&args(&["repo", "clone", "x/y"])).is_err()); - assert!(validate_gh_args(&args(&["auth", "login"])).is_err()); - assert!(validate_gh_args(&args(&["pr", "merge"])).is_err()); - assert!(validate_gh_args(&args(&["pr", "create", "--body", "no title"])).is_err()); - assert!(validate_gh_args(&args(&["pr", "create", "--title", "t", "--base", "-evil"])).is_err()); - assert!(validate_gh_args(&args(&["pr", "create", "--title", "t", "--web"])).is_err()); - assert!(validate_gh_args(&args(&["pr", "view", "--json", "url;rm -rf"])).is_err()); - assert!(validate_gh_args(&args(&["--version", "extra"])).is_err()); - } } diff --git a/src-tauri/src/plugins/github_pr.rs b/src-tauri/src/plugins/github_pr.rs new file mode 100644 index 0000000..72c7b15 --- /dev/null +++ b/src-tauri/src/plugins/github_pr.rs @@ -0,0 +1,148 @@ +use std::path::PathBuf; +use std::process::Command; + +use super::super::{ + is_valid_ref_name, no_window, resolve_cwd, run_with_timeout, shell_env, which, + REMOTE_OP_TIMEOUT, +}; + +/// Resolves the plugin's optional `gh` binary. Packaged apps inherit a minimal +/// PATH, so common install locations are checked after the captured shell PATH. +fn resolve_gh() -> PathBuf { + if let Some(found) = which("gh") { + return found; + } + for candidate in ["/opt/homebrew/bin/gh", "/usr/local/bin/gh", "/usr/bin/gh"] { + let path = PathBuf::from(candidate); + if path.is_file() { + return path; + } + } + PathBuf::from("gh") +} + +/// Rust-side command allowlist for the first-party GitHub PR plugin. +pub(crate) fn validate_args(args: &[String]) -> Result<(), String> { + match args.first().map(String::as_str) { + Some("--version") if args.len() == 1 => Ok(()), + Some("auth") if args.len() == 2 && args[1] == "status" => Ok(()), + Some("pr") => validate_pr_args(&args[1..]), + _ => Err(format!("gh arguments not allowed: {}", args.join(" "))), + } +} + +fn validate_pr_args(rest: &[String]) -> Result<(), String> { + match rest.first().map(String::as_str) { + Some("view") => { + let mut index = 1; + while index < rest.len() { + if rest[index] != "--json" { + return Err(format!("gh argument not allowed: {}", rest[index])); + } + let value = rest + .get(index + 1) + .ok_or_else(|| "--json requires a value".to_string())?; + if value.is_empty() + || !value + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == ',') + { + return Err(format!("gh --json fields not allowed: {value}")); + } + index += 2; + } + Ok(()) + } + Some("create") => { + let mut index = 1; + let mut has_title = false; + while index < rest.len() { + match rest[index].as_str() { + "--title" | "--body" => { + if rest.get(index + 1).is_none() { + return Err(format!("{} requires a value", rest[index])); + } + has_title |= rest[index] == "--title"; + index += 2; + } + "--base" | "--head" => { + let value = rest + .get(index + 1) + .ok_or_else(|| format!("{} requires a value", rest[index]))?; + if !is_valid_ref_name(value) { + return Err(format!("invalid ref name: {value}")); + } + index += 2; + } + "--draft" => index += 1, + other => return Err(format!("gh argument not allowed: {other}")), + } + } + if !has_title { + return Err("gh pr create requires --title".to_string()); + } + Ok(()) + } + _ => Err(format!("gh pr subcommand not allowed: {}", rest.join(" "))), + } +} + +/// Non-interactive bridge used only by the GitHub PR plugin. The allowlist +/// above keeps the exposed Tauri command narrower than arbitrary `gh` access. +#[tauri::command(async)] +pub(crate) fn gh(args: Vec, cwd: Option) -> Result { + validate_args(&args)?; + let dir = cwd.map(PathBuf::from).unwrap_or_else(resolve_cwd); + let mut command = Command::new(resolve_gh()); + no_window(&mut command); + shell_env::merge_into(&mut command); + command + .args(&args) + .current_dir(dir) + .env("GH_PROMPT_DISABLED", "1") + .env("GH_NO_UPDATE_NOTIFIER", "1") + .env("GH_PAGER", "cat") + .env("NO_COLOR", "1") + .env("GIT_TERMINAL_PROMPT", "0"); + let output = run_with_timeout(command, REMOTE_OP_TIMEOUT)?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + if stderr.trim().is_empty() { + Err(String::from_utf8_lossy(&output.stdout).into_owned()) + } else { + Err(stderr) + } + } +} + +#[cfg(test)] +mod tests { + use super::validate_args; + + fn args(values: &[&str]) -> Vec { + values.iter().map(|value| value.to_string()).collect() + } + + #[test] + fn allowlist_matches_manifest_commands() { + assert!(validate_args(&args(&["--version"])).is_ok()); + assert!(validate_args(&args(&["auth", "status"])).is_ok()); + assert!(validate_args(&args(&["pr", "view", "--json", "url,title,state,isDraft"])).is_ok()); + assert!(validate_args(&args(&[ + "pr", "create", "--title", "feat: x", "--body", "", "--base", "main", "--draft" + ])) + .is_ok()); + assert!(validate_args(&args(&["repo", "clone", "x/y"])).is_err()); + assert!(validate_args(&args(&["auth", "login"])).is_err()); + assert!(validate_args(&args(&["pr", "merge"])).is_err()); + assert!(validate_args(&args(&["pr", "create", "--body", "no title"])).is_err()); + assert!( + validate_args(&args(&["pr", "create", "--title", "t", "--base", "-evil"])).is_err() + ); + assert!(validate_args(&args(&["pr", "create", "--title", "t", "--web"])).is_err()); + assert!(validate_args(&args(&["pr", "view", "--json", "url;rm -rf"])).is_err()); + assert!(validate_args(&args(&["--version", "extra"])).is_err()); + } +} diff --git a/src-tauri/src/plugins/mod.rs b/src-tauri/src/plugins/mod.rs new file mode 100644 index 0000000..5a96923 --- /dev/null +++ b/src-tauri/src/plugins/mod.rs @@ -0,0 +1 @@ +pub(crate) mod github_pr; diff --git a/src-tauri/src/shell_env.rs b/src-tauri/src/shell_env.rs index 6b55876..e42bec9 100644 --- a/src-tauri/src/shell_env.rs +++ b/src-tauri/src/shell_env.rs @@ -53,9 +53,7 @@ fn is_denied(name: &str) -> bool { /// 从捕获输出里解析 NUL 分隔的 KEY=VALUE(值可含换行)。 /// 若整段找不到 NUL 分隔(env 不支持 -0 的降级情形),退回按行解析。 pub(crate) fn parse_env_output(bytes: &[u8]) -> Option> { - let pos = bytes - .windows(MARKER.len()) - .position(|w| w == MARKER)?; + let pos = bytes.windows(MARKER.len()).position(|w| w == MARKER)?; let rest = &bytes[pos + MARKER.len()..]; let mut vars = parse_entries(rest.split(|b| *b == 0)); if vars.len() <= 1 { @@ -84,13 +82,16 @@ fn parse_entries<'a>(entries: impl Iterator) -> HashMap String { - std::env::var("SHELL").ok().filter(|s| !s.trim().is_empty()).unwrap_or_else(|| { - if cfg!(target_os = "macos") { - "/bin/zsh".to_string() - } else { - "/bin/bash".to_string() - } - }) + std::env::var("SHELL") + .ok() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| { + if cfg!(target_os = "macos") { + "/bin/zsh".to_string() + } else { + "/bin/bash".to_string() + } + }) } /// 用指定 shell 捕获一次环境。测试用假 shell 脚本注入即可覆盖全链路。 @@ -178,7 +179,11 @@ pub fn status() -> ShellEnvStatus { /// 快照的 PATH(供二进制解析优先使用)。 pub fn snapshot_path() -> Option { - state().read().unwrap().as_ref().and_then(|e| e.vars.get("PATH").cloned()) + state() + .read() + .unwrap() + .as_ref() + .and_then(|e| e.vars.get("PATH").cloned()) } /// 在不清空现有环境的前提下合并快照(git/gh 这类命令用:既要终端 PATH/ @@ -304,7 +309,10 @@ mod tests { std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap(); let env = capture_with(fake.to_str().unwrap()).expect("capture should succeed"); - assert_eq!(env.vars.get("SNAP_TEST_VAR").map(String::as_str), Some("hello")); + assert_eq!( + env.vars.get("SNAP_TEST_VAR").map(String::as_str), + Some("hello") + ); assert!(env.vars.contains_key("PATH")); let _ = std::fs::remove_dir_all(&dir); } @@ -336,7 +344,12 @@ mod tests { ); let envs: HashMap<_, _> = cmd .get_envs() - .filter_map(|(k, v)| Some((k.to_string_lossy().to_string(), v?.to_string_lossy().to_string()))) + .filter_map(|(k, v)| { + Some(( + k.to_string_lossy().to_string(), + v?.to_string_lossy().to_string(), + )) + }) .collect(); assert_eq!(envs["PATH"], "/snap/bin"); assert_eq!(envs["SHARED"], "from-custom"); diff --git a/src/lib/GitPanel.svelte b/src/lib/GitPanel.svelte index 25a54ae..60d85c2 100644 --- a/src/lib/GitPanel.svelte +++ b/src/lib/GitPanel.svelte @@ -5,21 +5,24 @@ import { openUrl } from '@tauri-apps/plugin-opener'; import IconButton from '$lib/ui/IconButton.svelte'; import Button from '$lib/ui/Button.svelte'; - import { git, gh, generateText } from '$lib/protocol'; + import { git, generateText } from '$lib/protocol'; import { isValidBranchName, parseBranches, parseSyncStatus, - parseGhVersion, - hasGitHubRemote, - parsePrView, - extractPrUrl, defaultBaseBranch, parseNumstat, type SyncInfo, - type PrInfo, type RangeFile } from '$lib/gitops'; + import { + checkGitHubPr, + createGitHubPr, + viewGitHubPr, + type GitHubPrState, + type PrInfo + } from '$lib/plugins/github-pr'; + import { isPluginEnabled, PLUGIN_SETTINGS_EVENT } from '$lib/plugins/registry'; import { t } from '$lib/i18n'; import ParallelTasks from '$lib/ParallelTasks.svelte'; import type { WorktreeMeta } from '$lib/types'; @@ -59,8 +62,8 @@ let syncBusy = $state<'' | 'pull' | 'push' | 'fetch'>(''); // GitHub PR(通过 gh CLI) - type GhState = 'checking' | 'missing' | 'unauthed' | 'noRemote' | 'ready'; - let ghState = $state('checking'); + let pluginEnabled = $state(isPluginEnabled('github-pr')); + let ghState = $state('checking'); let pr = $state(null); let prForm = $state(false); let prTitle = $state(''); @@ -107,7 +110,20 @@ } onMount(() => { refresh(); - checkGh(); + if (pluginEnabled) checkGh(); + const updatePlugin = () => { + const enabled = isPluginEnabled('github-pr'); + if (enabled === pluginEnabled) return; + pluginEnabled = enabled; + pr = null; + prForm = false; + if (enabled) { + ghState = 'checking'; + checkGh(); + } + }; + window.addEventListener(PLUGIN_SETTINGS_EVENT, updatePlugin); + return () => window.removeEventListener(PLUGIN_SETTINGS_EVENT, updatePlugin); }); async function run(args: string[]) { @@ -281,39 +297,11 @@ // --- GitHub PR --- async function checkGh() { - try { - if (!parseGhVersion(await gh(['--version'], dir()))) { - ghState = 'missing'; - return; - } - } catch { - ghState = 'missing'; - return; - } - try { - if (!hasGitHubRemote(await git(['remote', '-v'], dir()))) { - ghState = 'noRemote'; - return; - } - } catch { - ghState = 'noRemote'; - return; - } - try { - await gh(['auth', 'status'], dir()); - } catch { - ghState = 'unauthed'; - return; - } - ghState = 'ready'; - await loadPr(); + ghState = await checkGitHubPr(dir()); + if (ghState === 'ready') await loadPr(); } async function loadPr() { - try { - pr = parsePrView(await gh(['pr', 'view', '--json', 'url,title,state,isDraft'], dir())); - } catch { - pr = null; // 当前分支还没有 PR - } + pr = await viewGitHubPr(dir()); } async function openPrForm() { prError = ''; @@ -332,12 +320,10 @@ prBusy = true; prError = ''; try { - const args = ['pr', 'create', '--title', prTitle.trim(), '--body', prBody]; - if (prBase) args.push('--base', prBase); - if (prDraft) args.push('--draft'); - const out = await gh(args, dir()); - const url = extractPrUrl(out); - const created: PrInfo | null = url ? { url, title: prTitle.trim(), state: 'OPEN', isDraft: prDraft } : null; + const created = await createGitHubPr( + { title: prTitle, body: prBody, base: prBase || undefined, draft: prDraft }, + dir() + ); prForm = false; await loadPr(); if (!pr && created) pr = created; @@ -468,37 +454,39 @@ {#if compareLoaded && compareFiles.length === 0 && !compareBusy}
{t('dock.git.reviewEmpty')}
{/if} {/if} -
{t('dock.git.pr')}
- {#if ghState === 'checking'} -
{t('dock.git.ghChecking')}
- {:else if ghState === 'missing'} -
- {t('dock.git.ghMissing')} - -
- {:else if ghState === 'unauthed'} -
- {t('dock.git.ghUnauthed')} - -
- {:else if ghState === 'noRemote'} -
{t('dock.git.noGithubRemote')}
- {:else if pr} -
- {pr.isDraft ? 'DRAFT' : pr.state} - -
- {:else} -
- -
+ {#if pluginEnabled} +
{t('dock.git.pr')}
+ {#if ghState === 'checking'} +
{t('dock.git.ghChecking')}
+ {:else if ghState === 'missing'} +
+ {t('dock.git.ghMissing')} + +
+ {:else if ghState === 'unauthed'} +
+ {t('dock.git.ghUnauthed')} + +
+ {:else if ghState === 'noRemote'} +
{t('dock.git.noGithubRemote')}
+ {:else if pr} +
+ {pr.isDraft ? 'DRAFT' : pr.state} + +
+ {:else} +
+ +
+ {/if} {/if} {#if !worktree} diff --git a/src/lib/Settings.svelte b/src/lib/Settings.svelte index d35cb51..c8cf06a 100644 --- a/src/lib/Settings.svelte +++ b/src/lib/Settings.svelte @@ -22,6 +22,8 @@ import Segmented from '$lib/ui/Segmented.svelte'; import { focusTrap } from '$lib/focusTrap'; import { t, setLocale, getLocale, LOCALES, LOCALE_LABELS } from '$lib/i18n'; + import { ASR_PROVIDERS, asrProvider, resolveAsrSettings, type AsrSettings } from '$lib/audio'; + import { PLUGINS, loadPluginSettings, setPluginEnabled } from '$lib/plugins/registry'; let { sessionId, @@ -66,6 +68,9 @@ let builtin = $state<{ id: string; base_url: string; protocol: string; models: ModelCfg[] }[]>([]); let custom = $state([]); let saved = $state(false); + let asr = $state(resolveAsrSettings(null)); + let asrKey = $state(''); + let pluginSettings = $state(loadPluginSettings()); // Capture the opening section once; the prop doesn't change during the modal's life. let section = $state<'overview' | 'account' | 'behavior' | 'extensions'>(untrack(() => initialSection)); @@ -124,6 +129,7 @@ onMount(async () => { cfg = await readConfig(); + asr = resolveAsrSettings(cfg.asr); if (cfg.compaction_threshold_percent == null) cfg.compaction_threshold_percent = 75; keyed = await readAuthProviders(); loadBalances(); @@ -216,7 +222,8 @@ retry_attempts: Number(cfg.retry_attempts) || 0, connect_timeout_seconds: Number(cfg.connect_timeout_seconds) || 0, read_timeout_seconds: Number(cfg.read_timeout_seconds) || 0, - include_project_instructions: !!cfg.include_project_instructions + include_project_instructions: !!cfg.include_project_instructions, + asr }); saved = true; setTimeout(() => (saved = false), 1500); @@ -261,15 +268,24 @@ // Card click: not-logged-in jucode kicks off OAuth directly (no expand); other // (key-based) providers expand to reveal the key input. Logged-in cards expand // to show details. - // MiMo ASR key for composer voice input (stored as providers.mimo — a plain - // keyed provider from auth.json's perspective, but not a chat provider, so - // it gets its own group instead of a provider card). - let mimoKey = $state(''); - async function saveMimoKey() { - if (!mimoKey.trim()) return; - await setAuthKey('mimo', mimoKey.trim()); + // ASR keys are separate from chat-provider keys, except the historical MiMo + // key which stays at providers.mimo for backward compatibility. + const selectedAsr = $derived(asrProvider(asr.provider)); + const asrOptions = ASR_PROVIDERS.map((provider) => ({ value: provider.id, label: provider.name })); + function selectAsr(id: string) { + const provider = asrProvider(id); + asr = { provider: provider.id, base_url: provider.baseUrl, model: provider.model }; + asrKey = ''; + } + async function saveAsrKey() { + if (!asrKey.trim()) return; + await setAuthKey(selectedAsr.authKey, asrKey.trim()); keyed = await readAuthProviders(); - mimoKey = ''; + asrKey = ''; + } + function togglePlugin(id: string, enabled: boolean) { + pluginSettings = { ...pluginSettings, [id]: enabled }; + setPluginEnabled(id, enabled); } function cardClick(p: Provider, authed: boolean) { @@ -361,16 +377,52 @@
{t('settings.voice.groupLabel')}

{t('settings.voice.hint')}

+
+