diff --git a/Cargo.lock b/Cargo.lock index 2b9260b..10f84b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9237,6 +9237,7 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2088172d00f936c348d6a72f488dc2660ab3f507263a195df308a3c2383229f6" dependencies = [ + "libc", "whisper-rs-sys", ] diff --git a/crates/voxctrl-config/src/lib.rs b/crates/voxctrl-config/src/lib.rs index cdef4e4..1c3e659 100644 --- a/crates/voxctrl-config/src/lib.rs +++ b/crates/voxctrl-config/src/lib.rs @@ -230,7 +230,11 @@ pub struct OllamaConfig { pub mode: OllamaMode, /// Used when mode == Custom. "{text}" is substituted. pub custom_prompt: Option, + /// Base URL of any OpenAI-API-compatible server (Ollama, LM Studio, OpenAI, etc.) pub endpoint: String, + /// Optional bearer token, sent as `Authorization: Bearer ` when set. + #[serde(default)] + pub api_key: Option, pub timeout_secs: u64, } @@ -242,6 +246,7 @@ impl Default for OllamaConfig { mode: OllamaMode::Clean, custom_prompt: None, endpoint: "http://localhost:11434".into(), + api_key: None, timeout_secs: 30, } } diff --git a/crates/voxctrl-inference/src/lib.rs b/crates/voxctrl-inference/src/lib.rs index 4e32c9b..d0fb22d 100644 --- a/crates/voxctrl-inference/src/lib.rs +++ b/crates/voxctrl-inference/src/lib.rs @@ -43,15 +43,25 @@ pub struct InferenceOutput { // ── Engine ──────────────────────────────────────────────────────────────────── +/// Cap on stored (role, content) pairs per binding — 10 user/assistant exchanges. +const MAX_OLLAMA_HISTORY_MESSAGES: usize = 20; + pub struct InferenceEngine { config: Arc, backend: Box, + /// Running conversation per hotkey binding, for bindings with + /// `ollama_enabled` and a Q&A-style custom prompt. Keyed by binding id. + ollama_history: std::cell::RefCell>>, } impl InferenceEngine { pub fn new(config: Arc) -> Self { let backend = build_backend(&config); - Self { config, backend } + Self { + config, + backend, + ollama_history: std::cell::RefCell::new(std::collections::HashMap::new()), + } } /// Load the selected backend model. Blocks until ready. @@ -211,22 +221,43 @@ impl InferenceEngine { } let client = voxctrl_llm::OllamaClient::new(ollama_cfg); + let history_key = req.binding_id.clone().unwrap_or_default(); + let history = self + .ollama_history + .borrow() + .get(&history_key) + .cloned() + .unwrap_or_default(); + let input_text = processed.clone(); let processed_res = match tokio::runtime::Handle::try_current() { Ok(handle) => { let c = client.clone(); - let text = processed.clone(); + let text = input_text.clone(); + let hist = history.clone(); std::thread::spawn(move || { - handle.block_on(async { c.process(&text).await }) + handle.block_on(async { c.process_with_history(&text, &hist).await }) }).join().unwrap_or(processed) } Err(_) => { if let Ok(rt) = tokio::runtime::Builder::new_current_thread().enable_all().build() { - rt.block_on(async { client.process(&processed).await }) + rt.block_on(async { client.process_with_history(&processed, &history).await }) } else { processed } } }; + + if !history_key.is_empty() { + let mut map = self.ollama_history.borrow_mut(); + let entries = map.entry(history_key).or_default(); + entries.push(("user".to_string(), input_text)); + entries.push(("assistant".to_string(), processed_res.clone())); + let len = entries.len(); + if len > MAX_OLLAMA_HISTORY_MESSAGES { + entries.drain(0..len - MAX_OLLAMA_HISTORY_MESSAGES); + } + } + processed = processed_res; } diff --git a/crates/voxctrl-llm/src/lib.rs b/crates/voxctrl-llm/src/lib.rs index da49f7d..d56aff8 100644 --- a/crates/voxctrl-llm/src/lib.rs +++ b/crates/voxctrl-llm/src/lib.rs @@ -27,18 +27,36 @@ fn mode_prompt(mode: &OllamaMode, text: &str) -> String { } } -// ── Ollama API types ────────────────────────────────────────────────────────── +// ── OpenAI-compatible API types ──────────────────────────────────────────────── #[derive(Serialize)] -struct GenerateRequest<'a> { +struct ChatMessage<'a> { + role: &'a str, + content: &'a str, +} + +#[derive(Serialize)] +struct ChatCompletionRequest<'a> { model: &'a str, - prompt: &'a str, + messages: Vec>, stream: bool, + #[serde(skip_serializing_if = "Option::is_none")] + max_tokens: Option, +} + +#[derive(Deserialize)] +struct ChatCompletionChoice { + message: ChatCompletionMessage, +} + +#[derive(Deserialize)] +struct ChatCompletionMessage { + content: String, } #[derive(Deserialize)] -struct GenerateResponse { - response: String, +struct ChatCompletionResponse { + choices: Vec, } // ── Client ──────────────────────────────────────────────────────────────────── @@ -63,7 +81,15 @@ impl OllamaClient { } } - /// Lazily probe if Ollama is reachable. Cached after first check. + fn auth_header(&self) -> Option { + self.config + .api_key + .as_ref() + .filter(|k| !k.is_empty()) + .map(|k| format!("Bearer {k}")) + } + + /// Lazily probe if the server is reachable. Cached after first check. pub async fn is_available(&self) -> bool { { let guard = self.available.lock().unwrap(); @@ -71,25 +97,26 @@ impl OllamaClient { return v; } } - let url = format!("{}/api/tags", self.config.endpoint); - let ok = self - .http - .get(&url) - .timeout(Duration::from_secs(2)) + let url = format!("{}/v1/models", self.config.endpoint); + let mut req = self.http.get(&url).timeout(Duration::from_secs(2)); + if let Some(auth) = self.auth_header() { + req = req.header("Authorization", auth); + } + let ok = req .send() .await .map(|r| r.status().is_success()) .unwrap_or(false); *self.available.lock().unwrap() = Some(ok); if ok { - info!("Ollama reachable at {}", self.config.endpoint); + info!("LLM server reachable at {}", self.config.endpoint); } else { - warn!("Ollama not reachable at {}", self.config.endpoint); + warn!("LLM server not reachable at {}", self.config.endpoint); } ok } - /// Post-process text through Ollama. Returns original text on any failure. + /// Post-process text through the configured LLM server. Returns original text on any failure. pub async fn process(&self, text: &str) -> String { if !self.config.enabled { return text.to_string(); @@ -112,58 +139,198 @@ impl OllamaClient { mode_prompt(&self.config.mode, text) }; - let url = format!("{}/api/generate", self.config.endpoint); - let req = GenerateRequest { + let url = format!("{}/v1/chat/completions", self.config.endpoint); + let req = ChatCompletionRequest { model: &self.config.model, - prompt: &prompt, + messages: vec![ChatMessage { role: "user", content: &prompt }], stream: false, + max_tokens: None, }; - match self.http.post(&url).json(&req).send().await { + let mut builder = self.http.post(&url).json(&req); + if let Some(auth) = self.auth_header() { + builder = builder.header("Authorization", auth); + } + + match builder.send().await { Ok(resp) if resp.status().is_success() => { - match resp.json::().await { - Ok(body) => { - let result = body.response.trim().to_string(); + match resp.json::().await { + Ok(mut body) => { + let result = body + .choices + .pop() + .map(|c| c.message.content.trim().to_string()) + .unwrap_or_else(|| text.to_string()); debug!( input_len = text.len(), output_len = result.len(), - "Ollama processed" + "LLM processed" ); result } Err(e) => { - warn!("Ollama response parse error: {e}"); + warn!("LLM response parse error: {e}"); text.to_string() } } } Ok(resp) => { - warn!("Ollama HTTP {}", resp.status()); + warn!("LLM HTTP {}", resp.status()); text.to_string() } Err(e) => { - warn!("Ollama request error: {e}"); + warn!("LLM request error: {e}"); text.to_string() } } } + /// Like `process`, but replays prior conversation turns before the new + /// templated prompt so hotkey bindings with `ollama_enabled` (e.g. Q&A + /// style custom prompts) can resolve follow-up questions like "what did + /// I just ask you". `history` holds `(role, content)` pairs in order. + pub async fn process_with_history(&self, text: &str, history: &[(String, String)]) -> String { + if !self.config.enabled { + return text.to_string(); + } + if !self.is_available().await { + return text.to_string(); + } + + let prompt = if self.config.mode == OllamaMode::Custom { + if let Some(tmpl) = &self.config.custom_prompt { + if tmpl.contains("{text}") { + tmpl.replace("{text}", text) + } else { + format!("{tmpl}\n\n{text}") + } + } else { + return text.to_string(); + } + } else { + mode_prompt(&self.config.mode, text) + }; + + let url = format!("{}/v1/chat/completions", self.config.endpoint); + let mut messages: Vec = Vec::new(); + for (role, content) in history { + messages.push(ChatMessage { role, content }); + } + messages.push(ChatMessage { role: "user", content: &prompt }); + + let req = ChatCompletionRequest { + model: &self.config.model, + messages, + stream: false, + max_tokens: None, + }; + + let mut builder = self.http.post(&url).json(&req); + if let Some(auth) = self.auth_header() { + builder = builder.header("Authorization", auth); + } + + match builder.send().await { + Ok(resp) if resp.status().is_success() => match resp.json::().await { + Ok(mut body) => { + let result = body + .choices + .pop() + .map(|c| c.message.content.trim().to_string()) + .unwrap_or_else(|| text.to_string()); + debug!( + input_len = text.len(), + output_len = result.len(), + "LLM processed (with history)" + ); + result + } + Err(e) => { + warn!("LLM response parse error: {e}"); + text.to_string() + } + }, + Ok(resp) => { + warn!("LLM HTTP {}", resp.status()); + text.to_string() + } + Err(e) => { + warn!("LLM request error: {e}"); + text.to_string() + } + } + } + + /// Run a chat completion with an arbitrary system prompt, prior + /// conversation history, and a new user message, bypassing the + /// `mode`-based rewrite templates. Used by the OpenAI API output + /// target, which lets each target supply its own instruction and + /// maintain its own running conversation. + pub async fn complete( + &self, + system_prompt: Option<&str>, + history: &[(String, String)], + user_text: &str, + model_override: Option<&str>, + max_tokens: Option, + timeout_secs: Option, + ) -> Result { + let model = model_override.unwrap_or(&self.config.model); + let mut messages = Vec::new(); + if let Some(sp) = system_prompt { + if !sp.is_empty() { + messages.push(ChatMessage { role: "system", content: sp }); + } + } + for (role, content) in history { + messages.push(ChatMessage { role, content }); + } + messages.push(ChatMessage { role: "user", content: user_text }); + + let url = format!("{}/v1/chat/completions", self.config.endpoint); + let req = ChatCompletionRequest { model, messages, stream: false, max_tokens }; + + let mut builder = self.http.post(&url).json(&req); + if let Some(t) = timeout_secs { + builder = builder.timeout(Duration::from_secs(t)); + } + if let Some(auth) = self.auth_header() { + builder = builder.header("Authorization", auth); + } + + let resp = builder.send().await.map_err(|e| e.to_string())?; + if resp.status().is_success() { + let mut body: ChatCompletionResponse = + resp.json().await.map_err(|e| e.to_string())?; + body.choices + .pop() + .map(|c| c.message.content.trim().to_string()) + .ok_or_else(|| "Empty response from LLM server".to_string()) + } else { + Err(format!("HTTP error: {}", resp.status())) + } + } + /// Reset the availability cache (e.g., user changed endpoint in settings). pub fn reset_availability(&self) { *self.available.lock().unwrap() = None; } - /// Retrieve the list of available local models from the Ollama instance. + /// Retrieve the list of available models from the OpenAI-API-compatible server. pub async fn list_models(&self) -> Result, String> { - let url = format!("{}/api/tags", self.config.endpoint); - let resp = self.http.get(&url).send().await.map_err(|e| e.to_string())?; + let url = format!("{}/v1/models", self.config.endpoint); + let mut builder = self.http.get(&url); + if let Some(auth) = self.auth_header() { + builder = builder.header("Authorization", auth); + } + let resp = builder.send().await.map_err(|e| e.to_string())?; if resp.status().is_success() { #[derive(serde::Deserialize)] - struct ModelItem { name: String } + struct ModelItem { id: String } #[derive(serde::Deserialize)] - struct TagsResponse { models: Vec } - let tags = resp.json::().await.map_err(|e| e.to_string())?; - Ok(tags.models.into_iter().map(|m| m.name).collect()) + struct ModelsResponse { data: Vec } + let models = resp.json::().await.map_err(|e| e.to_string())?; + Ok(models.data.into_iter().map(|m| m.id).collect()) } else { Err(format!("HTTP error: {}", resp.status())) } diff --git a/crates/voxctrl-routing/src/loader.rs b/crates/voxctrl-routing/src/loader.rs index a20460d..c9c2642 100644 --- a/crates/voxctrl-routing/src/loader.rs +++ b/crates/voxctrl-routing/src/loader.rs @@ -73,6 +73,10 @@ struct RawTarget { mcp_path: Option, mcp_tool: Option, mcp_args: Option, + openai_prompt: Option, + openai_model: Option, + openai_max_tokens: Option, + openai_timeout_secs: Option, #[serde(default = "bool_true")] send_on_release: bool, #[serde(default = "bool_true")] @@ -156,6 +160,7 @@ fn raw_to_target(r: RawTarget) -> OutputTarget { "webhook" => DeliveryType::Webhook, "mcp" => DeliveryType::Mcp, "speak" => DeliveryType::Speak, + "openai_api" => DeliveryType::OpenaiApi, _ => DeliveryType::Inject, }; @@ -219,6 +224,10 @@ fn raw_to_target(r: RawTarget) -> OutputTarget { mcp_path: r.mcp_path, mcp_tool: r.mcp_tool, mcp_args, + openai_prompt: r.openai_prompt, + openai_model: r.openai_model, + openai_max_tokens: r.openai_max_tokens, + openai_timeout_secs: r.openai_timeout_secs, send_on_release: r.send_on_release, append_newline: r.append_newline, strip_newlines: r.strip_newlines, @@ -262,12 +271,19 @@ fn migrate_legacy_pp(pp: &str) -> TargetProcessingConfig { } } +fn delivery_to_str(d: &DeliveryType) -> String { + match d { + DeliveryType::OpenaiApi => "openai_api".into(), + other => format!("{other:?}").to_lowercase(), + } +} + fn target_to_raw(t: &OutputTarget) -> RawTarget { let p = &t.processing; RawTarget { id: t.id.clone(), label: t.label.clone(), - delivery: format!("{:?}", t.delivery).to_lowercase(), + delivery: delivery_to_str(&t.delivery), command: t.command.clone(), pipe_path: t.pipe_path.clone(), socket_host: t.socket_host.clone(), @@ -297,6 +313,10 @@ fn target_to_raw(t: &OutputTarget) -> RawTarget { .mcp_args .as_ref() .and_then(|v| serde_json::from_value(v.clone()).ok()), + openai_prompt: t.openai_prompt.clone(), + openai_model: t.openai_model.clone(), + openai_max_tokens: t.openai_max_tokens, + openai_timeout_secs: t.openai_timeout_secs, send_on_release: t.send_on_release, append_newline: t.append_newline, strip_newlines: t.strip_newlines, diff --git a/crates/voxctrl-routing/src/models.rs b/crates/voxctrl-routing/src/models.rs index fedd4f8..32614a3 100644 --- a/crates/voxctrl-routing/src/models.rs +++ b/crates/voxctrl-routing/src/models.rs @@ -81,6 +81,7 @@ pub enum DeliveryType { Webhook, Mcp, Speak, + OpenaiApi, } // ── Per-target processing overrides ────────────────────────────────────────── @@ -159,6 +160,12 @@ pub struct OutputTarget { pub mcp_tool: Option, pub mcp_args: Option, + // OpenAI API call (uses the global `ollama` connection settings unless overridden) + pub openai_prompt: Option, + pub openai_model: Option, + pub openai_max_tokens: Option, + pub openai_timeout_secs: Option, + #[serde(default = "bool_true")] pub send_on_release: bool, #[serde(default = "bool_true")] @@ -210,6 +217,10 @@ impl OutputTarget { mcp_path: None, mcp_tool: None, mcp_args: None, + openai_prompt: None, + openai_model: None, + openai_max_tokens: None, + openai_timeout_secs: None, send_on_release: true, append_newline: false, strip_newlines: false, diff --git a/crates/voxctrl-routing/src/targets.rs b/crates/voxctrl-routing/src/targets.rs index d3d007e..74d7192 100644 --- a/crates/voxctrl-routing/src/targets.rs +++ b/crates/voxctrl-routing/src/targets.rs @@ -16,6 +16,40 @@ pub fn set_speak_callback(callback: SpeakCallback) { let _ = SPEAK_CALLBACK.set(callback); } +/// One prior turn of a target's running conversation, sent back to the +/// server so multi-turn context (e.g. "what did I just ask you") works. +#[derive(Clone)] +pub struct ChatTurn { + pub role: String, + pub content: String, +} + +/// Parameters for an OpenAI API output target's chat completion request. +pub struct OpenAiCallRequest { + pub system_prompt: Option, + pub history: Vec, + pub text: String, + pub model: Option, + pub max_tokens: Option, + pub timeout_secs: Option, +} + +/// Number of messages (user + assistant turns combined) kept per target +/// before older ones are dropped from the running conversation. +const MAX_HISTORY_MESSAGES: usize = 20; + +pub type OpenAiCallback = Arc< + dyn Fn(OpenAiCallRequest) -> std::pin::Pin> + Send>> + + Send + + Sync + + 'static, +>; +static OPENAI_CALLBACK: OnceLock = OnceLock::new(); + +pub fn set_openai_callback(callback: OpenAiCallback) { + let _ = OPENAI_CALLBACK.set(callback); +} + // Shared HTTP client — built once, reused for connection pooling. fn http_client() -> &'static reqwest::Client { static CLIENT: std::sync::OnceLock = std::sync::OnceLock::new(); @@ -50,6 +84,10 @@ pub fn build_target(config: OutputTarget) -> Box { DeliveryType::Webhook => Box::new(WebhookTarget(config)), DeliveryType::Mcp => Box::new(McpTarget(config)), DeliveryType::Speak => Box::new(SpeakTarget(config)), + DeliveryType::OpenaiApi => Box::new(OpenaiApiTarget { + config, + history: tokio::sync::Mutex::new(Vec::new()), + }), } } @@ -794,6 +832,54 @@ impl DeliveryTarget for SpeakTarget { } +// ── OpenaiApiTarget ─────────────────────────────────────────────────────────── + +pub struct OpenaiApiTarget { + config: OutputTarget, + /// Running conversation for this target, kept in-memory for the + /// lifetime of the built target (until targets are hot-reloaded). + history: tokio::sync::Mutex>, +} + +#[async_trait::async_trait] +impl DeliveryTarget for OpenaiApiTarget { + async fn deliver(&self, text: &str) -> DeliveryResult { + let Some(callback) = OPENAI_CALLBACK.get() else { + return DeliveryResult::err("OpenAI API integration not initialized"); + }; + let history = self.history.lock().await.clone(); + let req = OpenAiCallRequest { + system_prompt: self.config.openai_prompt.clone(), + history, + text: text.to_string(), + model: self.config.openai_model.clone(), + max_tokens: self.config.openai_max_tokens, + timeout_secs: self.config.openai_timeout_secs, + }; + match callback(req).await { + Ok(result) => { + let mut hist = self.history.lock().await; + hist.push(ChatTurn { role: "user".into(), content: text.to_string() }); + hist.push(ChatTurn { role: "assistant".into(), content: result.clone() }); + if hist.len() > MAX_HISTORY_MESSAGES { + let excess = hist.len() - MAX_HISTORY_MESSAGES; + hist.drain(0..excess); + } + DeliveryResult::ok(result) + } + Err(e) => DeliveryResult::err(e), + } + } + + async fn test(&self) -> TestResult { + if OPENAI_CALLBACK.get().is_some() { + TestResult { reachable: true, detail: "OpenAI API integration registered".into() } + } else { + TestResult { reachable: false, detail: "OpenAI API integration not initialized".into() } + } + } +} + // ── Helpers ─────────────────────────────────────────────────────────────────── fn which(bin: &str) -> bool { diff --git a/crates/voxctrl-routing/src/tests.rs b/crates/voxctrl-routing/src/tests.rs index 532c3b7..29c44fb 100644 --- a/crates/voxctrl-routing/src/tests.rs +++ b/crates/voxctrl-routing/src/tests.rs @@ -2,6 +2,11 @@ use crate::models::{DeliveryType, OutputTarget}; use crate::loader::{load_targets, save_targets}; use crate::targets::build_target; +/// Serializes tests that mutate process-global env vars (PATH, WAYLAND_DISPLAY) +/// for clipboard backend detection, since cargo test runs tests concurrently +/// and these vars are shared process-wide. +static CLIPBOARD_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[test] fn test_mcp_config_roundtrip() { let temp_dir = std::env::temp_dir().join(format!("voxctrl_test_{}", chrono::Utc::now().timestamp_millis())); @@ -31,6 +36,10 @@ fn test_mcp_config_roundtrip() { mcp_path: Some("/tmp/custom-mcp.sock".into()), mcp_tool: Some("speak_text".into()), mcp_args: Some(serde_json::json!({ "message": "{TEXT}" })), + openai_prompt: None, + openai_model: None, + openai_max_tokens: None, + openai_timeout_secs: None, send_on_release: true, append_newline: false, strip_newlines: false, @@ -138,6 +147,10 @@ async fn test_mcp_delivery_handshake() { mcp_path: Some(socket_path.clone()), mcp_tool: Some("speak_text".into()), mcp_args: Some(serde_json::json!({ "text": "{TEXT}" })), + openai_prompt: None, + openai_model: None, + openai_max_tokens: None, + openai_timeout_secs: None, send_on_release: true, append_newline: false, strip_newlines: false, @@ -259,6 +272,7 @@ async fn test_inject_target_failure_no_injection() { // 2. Clipboard Target #[tokio::test] async fn test_clipboard_target_success() { + let _guard = CLIPBOARD_ENV_LOCK.lock().unwrap(); let mut config = OutputTarget::default_inject(); config.delivery = DeliveryType::Clipboard; let target = build_target(config); @@ -275,6 +289,7 @@ async fn test_clipboard_target_success() { #[cfg(target_os = "linux")] #[tokio::test] async fn test_clipboard_target_linux_cli() { + let _guard = CLIPBOARD_ENV_LOCK.lock().unwrap(); let temp_dir = std::env::temp_dir().join(format!("voxctrl_clipboard_test_{}", chrono::Utc::now().timestamp_millis())); std::fs::create_dir_all(&temp_dir).unwrap(); @@ -1068,6 +1083,10 @@ async fn test_mcp_delivery_failure_server_error() { mcp_path: Some(socket_path.clone()), mcp_tool: Some("speak_text".into()), mcp_args: Some(serde_json::json!({ "text": "{TEXT}" })), + openai_prompt: None, + openai_model: None, + openai_max_tokens: None, + openai_timeout_secs: None, send_on_release: true, append_newline: false, strip_newlines: false, @@ -1115,6 +1134,10 @@ fn test_strip_newlines_config_roundtrip() { mcp_path: None, mcp_tool: None, mcp_args: None, + openai_prompt: None, + openai_model: None, + openai_max_tokens: None, + openai_timeout_secs: None, send_on_release: true, append_newline: false, strip_newlines: true, @@ -1134,6 +1157,111 @@ fn test_strip_newlines_config_roundtrip() { let _ = std::fs::remove_dir_all(&temp_dir); } +#[test] +fn test_openai_api_config_roundtrip() { + let temp_dir = std::env::temp_dir().join(format!("voxctrl_openai_api_test_{}", chrono::Utc::now().timestamp_millis())); + std::fs::create_dir_all(&temp_dir).unwrap(); + + let target = OutputTarget { + id: "summarize".into(), + label: "Summarize via LLM".into(), + delivery: DeliveryType::OpenaiApi, + command: None, + pipe_path: None, + socket_host: None, + socket_port: None, + socket_unix: None, + file_path: None, + file_prefix: "".into(), + file_timestamp: false, + file_mode: "append".into(), + dbus_signal: None, + http_url: None, + http_method: "POST".into(), + http_headers: None, + http_json_template: None, + webhook_url: None, + webhook_secret: None, + webhook_json_template: None, + mcp_path: None, + mcp_tool: None, + mcp_args: None, + openai_prompt: Some("Summarize in 3 bullet points.".into()), + openai_model: Some("gpt-4o-mini".into()), + openai_max_tokens: Some(500), + openai_timeout_secs: Some(30), + send_on_release: true, + append_newline: false, + strip_newlines: false, + initial_prompt: None, + processing: Default::default(), + response_pipe: None, + }; + + save_targets(&[target.clone()], &temp_dir).unwrap(); + let loaded = load_targets(&temp_dir).unwrap(); + + assert_eq!(loaded.len(), 1); + let loaded_target = &loaded[0]; + assert_eq!(loaded_target.delivery, DeliveryType::OpenaiApi); + assert_eq!(loaded_target.openai_prompt.as_deref(), Some("Summarize in 3 bullet points.")); + assert_eq!(loaded_target.openai_model.as_deref(), Some("gpt-4o-mini")); + assert_eq!(loaded_target.openai_max_tokens, Some(500)); + assert_eq!(loaded_target.openai_timeout_secs, Some(30)); + + let _ = std::fs::remove_dir_all(&temp_dir); +} + +#[tokio::test] +async fn test_openai_api_target_failure_no_callback_registered() { + // No process-wide call to set_openai_callback has happened in this test binary's + // execution of this test, so the target must fail gracefully rather than panic. + let config = OutputTarget { + id: "summarize".into(), + label: "Summarize via LLM".into(), + delivery: DeliveryType::OpenaiApi, + command: None, + pipe_path: None, + socket_host: None, + socket_port: None, + socket_unix: None, + file_path: None, + file_prefix: "".into(), + file_timestamp: false, + file_mode: "append".into(), + dbus_signal: None, + http_url: None, + http_method: "POST".into(), + http_headers: None, + http_json_template: None, + webhook_url: None, + webhook_secret: None, + webhook_json_template: None, + mcp_path: None, + mcp_tool: None, + mcp_args: None, + openai_prompt: Some("Summarize".into()), + openai_model: None, + openai_max_tokens: None, + openai_timeout_secs: None, + send_on_release: true, + append_newline: false, + strip_newlines: false, + initial_prompt: None, + processing: Default::default(), + response_pipe: None, + }; + + let target = build_target(config); + + let test_res = target.test().await; + assert!(!test_res.reachable); + + let deliver_res = target.deliver("hello").await; + assert!(!deliver_res.success); + assert!(deliver_res.error.unwrap().contains("not initialized")); +} + #[test] fn test_example_configurations_load() { use crate::loader::load_bindings; diff --git a/docs/api.md b/docs/api.md index 874a0d6..3d854d7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -243,15 +243,16 @@ interface AudioDeviceInfo { --- -### Ollama +### OpenAI API -#### `test_ollama(endpoint: string, timeoutSecs: number) → OllamaTestResult` -Pings an Ollama endpoint and lists available models. +#### `test_ollama(endpoint: string, timeoutSecs: number, apiKey?: string) → OllamaTestResult` +Pings an OpenAI-API-compatible server (Ollama, LM Studio, OpenAI, etc.) and lists available models. ```typescript const result = await invoke('test_ollama', { endpoint: 'http://localhost:11434', - timeoutSecs: 5 + timeoutSecs: 5, + apiKey: null }); ``` @@ -390,6 +391,7 @@ interface OllamaConfig { mode: "clean" | "formal" | "casual" | "bullet" | "concise" | "custom"; custom_prompt: string | null; endpoint: string; + api_key: string | null; timeout_secs: number; } @@ -428,7 +430,7 @@ interface AtspiConfig { interface OutputTarget { id: string; label: string; - delivery: "inject" | "clipboard" | "exec" | "pipe" | "socket" | "file" | "dbus" | "http" | "webhook" | "mcp" | "speak"; + delivery: "inject" | "clipboard" | "exec" | "pipe" | "socket" | "file" | "dbus" | "http" | "webhook" | "mcp" | "speak" | "openai_api"; // exec command?: string; @@ -462,6 +464,12 @@ interface OutputTarget { mcp_path?: string; mcp_tool?: string; + // openai_api (uses the global ollama endpoint/api_key/model unless overridden) + openai_prompt?: string; // system message; transcribed text is the user message + openai_model?: string; // overrides the global model for this target + openai_max_tokens?: number; + openai_timeout_secs?: number; // overrides ollama.timeout_secs for this target + send_on_release: boolean; // default: true append_newline: boolean; // default: true strip_newlines: boolean; // default: false diff --git a/docs/configuration.md b/docs/configuration.md index 4585c6f..3588cb1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -61,6 +61,7 @@ Full schema with defaults: "ollama": { "enabled": false, "endpoint": "http://localhost:11434", + "api_key": null, "model": "llama3.2:1b", "mode": "clean", "custom_prompt": null, @@ -179,10 +180,15 @@ Example with snippets: ### `ollama` section +Despite the key name (kept for config-file backward compatibility), this section configures a connection to +**any OpenAI-API-compatible server** — Ollama, LM Studio, vLLM, OpenAI itself, etc. VoxCtrl talks to the +server's `/v1/models` and `/v1/chat/completions` endpoints. + | Key | Type | Default | Description | |---|---|---|---| -| `enabled` | bool | `false` | Enable Ollama post-processing | -| `endpoint` | string | `"http://localhost:11434"` | Ollama API base URL | +| `enabled` | bool | `false` | Enable LLM post-processing | +| `endpoint` | string | `"http://localhost:11434"` | Base URL of the OpenAI-API-compatible server | +| `api_key` | string or null | `null` | Optional bearer token, sent as `Authorization: Bearer `. Required by most non-localhost servers (e.g. OpenAI). | | `model` | string | `"llama3.2:1b"` | Model name | | `mode` | string | `"clean"` | Rewrite style: `clean`/`formal`/`casual`/`bullet`/`concise`/`custom` | | `custom_prompt` | string or null | `null` | Instruction when mode is `custom`; use `{text}` as placeholder, or text is appended after the prompt | @@ -322,6 +328,20 @@ pipe_path = "/tmp/voice.fifo" delivery = "speak" ``` +**`openai_api`:** +```toml +delivery = "openai_api" +openai_prompt = "Summarize the following transcript in 3 bullet points." +openai_model = "gpt-4o-mini" # Optional: overrides ollama.model for this target +openai_max_tokens = 500 # Optional: caps response length +openai_timeout_secs = 30 # Optional: overrides ollama.timeout_secs for this target +``` + +Sends the transcribed text to the server configured in the OpenAI API settings tab +(`config.ollama.endpoint` / `api_key`), using `openai_prompt` as the system message and the +transcribed text as the user message. The delivered text is the model's response, not the +original transcription. + **TTS response pipe:** ```toml response_pipe = "/tmp/tts-response.fifo" # Optional FIFO for TTS output diff --git a/docs/routing.md b/docs/routing.md index 63fb0ca..619258f 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -211,6 +211,33 @@ delivery = "speak" --- +#### `openai_api` — OpenAI API Call +Sends the transcribed text to an OpenAI-API-compatible chat completions endpoint and delivers +the model's response (not the original transcription). Uses the server URL, API key, and +default model configured in the OpenAI API settings tab (`config.ollama`) unless overridden. + +```toml +[[target]] +id = "summarize" +label = "Summarize via LLM" +delivery = "openai_api" +openai_prompt = "Summarize the following transcript in 3 bullet points." +openai_model = "gpt-4o-mini" # Optional: overrides the global model for this target +openai_max_tokens = 500 # Optional: caps response length +openai_timeout_secs = 30 # Optional: overrides the global request timeout +``` + +| Field | Type | Default | Description | +|---|---|---|---| +| `openai_prompt` | string | null | System message instructing the model; transcribed text is sent as the user message | +| `openai_model` | string | null (inherit) | Overrides `ollama.model` for this target | +| `openai_max_tokens` | integer | null (server default) | Caps the response length | +| `openai_timeout_secs` | integer | null (inherit) | Overrides `ollama.timeout_secs` for this target | + +Each `openai_api` target keeps its own running conversation (up to the last 20 messages, i.e. 10 user/assistant exchanges) in memory and replays it with every request, so follow-up dictations like "what did I just ask you?" resolve correctly. This history lives only in the running app process — it is not persisted to disk, and resets whenever targets are hot-reloaded (e.g. after editing targets in Settings) or the app restarts. + +--- + ### Per-Target Processing Each target can override global post-processing settings. All fields are optional (`null` = inherit global config): @@ -234,6 +261,8 @@ quiet_mode = false > [!NOTE] > Ollama post-processing parameters (`ollama_enabled`, `ollama_model`, `ollama_mode`, `ollama_prompt`) are defined per-hotkey binding in `bindings.toml` (or via the Hotkeys UI tab) instead of per-target. This allows you to apply different LLM rewriting styles (e.g. formal, bullet-points) using different hotkeys targeting the same destination. +> +> When `ollama_enabled` is used with a Q&A-style custom prompt (e.g. "answer questions concisely"), each binding keeps its own running conversation (up to the last 10 exchanges) so follow-ups like "what did I just ask you" resolve correctly. This history lives only in the running inference worker — it resets on app restart and is keyed by binding id, so different bindings never share context. --- diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 6049b02..892cb5d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -548,6 +548,7 @@ pub struct OllamaTestResult { pub async fn test_ollama( endpoint: String, timeout_secs: u64, + api_key: Option, ) -> Result { use voxctrl_config::{OllamaConfig, OllamaMode}; let cfg = OllamaConfig { @@ -556,6 +557,7 @@ pub async fn test_ollama( model: String::new(), mode: OllamaMode::Clean, custom_prompt: None, + api_key, timeout_secs, }; let client = voxctrl_llm::OllamaClient::new(cfg); @@ -564,7 +566,7 @@ pub async fn test_ollama( Ok(models) => { Ok(OllamaTestResult { success: true, - message: "Successfully connected to Ollama!".to_string(), + message: "Successfully connected!".to_string(), models, }) } @@ -579,7 +581,7 @@ pub async fn test_ollama( } else { Ok(OllamaTestResult { success: false, - message: format!("Failed to connect to Ollama at '{}'. Make sure Ollama is running.", endpoint), + message: format!("Failed to connect to server at '{}'. Check the URL and API key.", endpoint), models: Vec::new(), }) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d15f7b3..235ae09 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -530,6 +530,33 @@ pub fn run() { })); } + // Register OpenAI API target callback + { + let state = app.handle().state::>().inner().clone(); + voxctrl_routing::targets::set_openai_callback(std::sync::Arc::new(move |req| { + let state = state.clone(); + Box::pin(async move { + let cfg = state.config.lock().await.data.ollama.clone(); + let client = voxctrl_llm::OllamaClient::new(cfg); + let history: Vec<(String, String)> = req + .history + .into_iter() + .map(|t| (t.role, t.content)) + .collect(); + client + .complete( + req.system_prompt.as_deref(), + &history, + &req.text, + req.model.as_deref(), + req.max_tokens, + req.timeout_secs, + ) + .await + }) + })); + } + // ── Startup udev Diagnostics ────────────────────────────────────── #[cfg(target_os = "linux")] { @@ -1300,6 +1327,10 @@ mod tests { mcp_path: None, mcp_tool: None, mcp_args: None, + openai_prompt: None, + openai_model: None, + openai_max_tokens: None, + openai_timeout_secs: None, send_on_release: true, append_newline: false, initial_prompt: None, @@ -1332,6 +1363,10 @@ mod tests { mcp_path: None, mcp_tool: None, mcp_args: None, + openai_prompt: None, + openai_model: None, + openai_max_tokens: None, + openai_timeout_secs: None, send_on_release: true, append_newline: false, initial_prompt: None, diff --git a/src/lib/Settings/OllamaTab.svelte b/src/lib/Settings/OllamaTab.svelte index b6b8b21..0b64674 100644 --- a/src/lib/Settings/OllamaTab.svelte +++ b/src/lib/Settings/OllamaTab.svelte @@ -41,6 +41,7 @@ const res = await invoke("test_ollama", { endpoint: cfg.ollama.endpoint, timeoutSecs: cfg.ollama.timeout_secs, + apiKey: cfg.ollama.api_key, }); testStatus = { success: res.success, message: res.message }; if (res.success) { @@ -65,13 +66,17 @@
-

Ollama LLM Post-Processing

+

OpenAI API LLM Post-Processing

Connection

+ @@ -469,6 +470,65 @@
{/if} + {#if editingTarget.delivery === "openai_api"} +
+
OpenAI API Call Settings
+

Uses the server URL, API key, and default model configured in the OpenAI API settings tab unless overridden below. This target remembers the last 10 exchanges so follow-up questions like "what did I just ask you?" work.

+ +
+
+ Instruction / System Prompt +
+ +

Sent as the system message; the transcribed text is sent as the user message.

+
+ +
+
+ Model Override + Optional +
+ +
+ +
+
+ Max Response Tokens + Optional +
+ +
+ +
+
+ Request Timeout (seconds) + Optional +
+ +
+
+ {/if} +
Post-Processing & Output Tuning
diff --git a/src/lib/Settings/TargetsTab.svelte b/src/lib/Settings/TargetsTab.svelte index 7abfe6e..e19022e 100644 --- a/src/lib/Settings/TargetsTab.svelte +++ b/src/lib/Settings/TargetsTab.svelte @@ -170,6 +170,9 @@ {#if t.delivery === "speak"}
Speech: Plays transcribed text via TTS
{/if} + {#if t.delivery === "openai_api"} +
OpenAI API: {t.openai_model || "default model"}
+ {/if}
{#if confirmDeleteTargetId === t.id} diff --git a/src/lib/Settings/routing-types.ts b/src/lib/Settings/routing-types.ts index aa6a131..a29a4b0 100644 --- a/src/lib/Settings/routing-types.ts +++ b/src/lib/Settings/routing-types.ts @@ -33,6 +33,10 @@ export interface OutputTarget { mcp_path?: string; mcp_tool?: string; mcp_args?: Record; + openai_prompt?: string; + openai_model?: string; + openai_max_tokens?: number; + openai_timeout_secs?: number; send_on_release: boolean; append_newline: boolean; strip_newlines: boolean; diff --git a/src/stores/config.ts b/src/stores/config.ts index b78d9ef..598780c 100644 --- a/src/stores/config.ts +++ b/src/stores/config.ts @@ -67,6 +67,7 @@ export interface OllamaConfig { mode: "clean" | "formal" | "casual" | "bullet" | "concise" | "custom"; custom_prompt: string | null; endpoint: string; + api_key: string | null; timeout_secs: number; } @@ -146,6 +147,7 @@ const defaultConfig: AppConfig = { mode: "clean", custom_prompt: null, endpoint: "http://localhost:11434", + api_key: null, timeout_secs: 8, }, tts: { diff --git a/tests/svelte/Settings.test.ts b/tests/svelte/Settings.test.ts index 8eb2eaf..94b1c82 100644 --- a/tests/svelte/Settings.test.ts +++ b/tests/svelte/Settings.test.ts @@ -80,6 +80,7 @@ describe("Settings.svelte Startup Redirect", () => { mode: "clean", custom_prompt: null, endpoint: "http://localhost:11434", + api_key: null, timeout_secs: 8, }, tts: {