4 Commits

Author SHA1 Message Date
235f84fa19 Integrate core functionality for tools, MCP, and enhanced session management
Adds consent management for tool execution, input validation, sandboxed process execution, and MCP server integration. Updates session management to support tool use, conversation persistence, and streaming responses.

Major additions:
- Database migrations for conversations and secure storage
- Encryption and credential management infrastructure
- Extensible tool system with code execution and web search
- Consent management and validation systems
- Sandboxed process execution
- MCP server integration

Infrastructure changes:
- Module registration and workspace dependencies
- ToolCall type and tool-related Message methods
- Privacy, security, and tool configuration structures
- Database-backed conversation persistence
- Tool call tracking in conversations

Provider and UI updates:
- Ollama provider updates for tool support and new Role types
- TUI chat and code app updates for async initialization
- CLI updates for new SessionController API
- Configuration documentation updates
- CHANGELOG updates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 18:36:42 +02:00
9c777c8429 Add extensible tool system with code execution and web search
Introduces a tool registry architecture with sandboxed code execution, web search capabilities, and consent-based permission management. Enables safe, pluggable LLM tool integration with schema validation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 18:32:07 +02:00
0b17a0f4c8 Add encryption and credential management infrastructure
Implements AES-256-GCM encrypted storage and keyring-based credential management for securely handling API keys and sensitive data. Supports secure local storage and OS-native keychain integration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 18:31:51 +02:00
2eabe55fe6 Add database migrations for conversations and secure storage
Introduces SQL schema for persistent conversation storage and encrypted secure items, supporting the new storage architecture for managing chat history and sensitive credentials.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 18:31:26 +02:00
33 changed files with 5599 additions and 1549 deletions

View File

@@ -11,9 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Comprehensive documentation suite including guides for architecture, configuration, testing, and more. - Comprehensive documentation suite including guides for architecture, configuration, testing, and more.
- Rustdoc examples for core components like `Provider` and `SessionController`. - Rustdoc examples for core components like `Provider` and `SessionController`.
- Module-level documentation for `owlen-tui`. - Module-level documentation for `owlen-tui`.
- Ollama integration can now talk to Ollama Cloud when an API key is configured.
- Ollama provider will also read `OLLAMA_API_KEY` / `OLLAMA_CLOUD_API_KEY` environment variables when no key is stored in the config.
### Changed ### Changed
- The main `README.md` has been updated to be more concise and link to the new documentation. - The main `README.md` has been updated to be more concise and link to the new documentation.
- Default configuration now pre-populates both `providers.ollama` and `providers.ollama-cloud` entries so switching between local and cloud backends is a single setting change.
--- ---

View File

@@ -40,6 +40,17 @@ serde_json = "1.0"
uuid = { version = "1.0", features = ["v4", "serde"] } uuid = { version = "1.0", features = ["v4", "serde"] }
anyhow = "1.0" anyhow = "1.0"
thiserror = "1.0" thiserror = "1.0"
nix = "0.29"
which = "6.0"
tempfile = "3.8"
jsonschema = "0.17"
aes-gcm = "0.10"
ring = "0.17"
keyring = "3.0"
chrono = { version = "0.4", features = ["serde"] }
urlencoding = "2.1"
rpassword = "7.3"
sqlx = { version = "0.7", default-features = false, features = ["runtime-tokio-rustls", "sqlite", "macros", "uuid", "chrono", "migrate"] }
# Configuration # Configuration
toml = "0.8" toml = "0.8"
@@ -58,7 +69,6 @@ async-trait = "0.1"
clap = { version = "4.0", features = ["derive"] } clap = { version = "4.0", features = ["derive"] }
# Dev dependencies # Dev dependencies
tempfile = "3.8"
tokio-test = "0.4" tokio-test = "0.4"
# For more keys and their definitions, see https://doc.rust-lang.org/cargo/reference/manifest.html # For more keys and their definitions, see https://doc.rust-lang.org/cargo/reference/manifest.html

View File

@@ -2,7 +2,7 @@
use anyhow::Result; use anyhow::Result;
use clap::{Arg, Command}; use clap::{Arg, Command};
use owlen_core::session::SessionController; use owlen_core::{session::SessionController, storage::StorageManager};
use owlen_ollama::OllamaProvider; use owlen_ollama::OllamaProvider;
use owlen_tui::{config, ui, AppState, CodeApp, Event, EventHandler, SessionEvent}; use owlen_tui::{config, ui, AppState, CodeApp, Event, EventHandler, SessionEvent};
use std::io; use std::io;
@@ -37,14 +37,27 @@ async fn main() -> Result<()> {
config.general.default_model = Some(model.clone()); config.general.default_model = Some(model.clone());
} }
let provider_cfg = config::ensure_ollama_config(&mut config).clone(); let provider_name = config.general.default_provider.clone();
let provider_cfg = config::ensure_provider_config(&mut config, &provider_name).clone();
let provider_type = provider_cfg.provider_type.to_ascii_lowercase();
if provider_type != "ollama" && provider_type != "ollama-cloud" {
anyhow::bail!(
"Unsupported provider type '{}' configured for provider '{}'",
provider_cfg.provider_type,
provider_name
);
}
let provider = Arc::new(OllamaProvider::from_config( let provider = Arc::new(OllamaProvider::from_config(
&provider_cfg, &provider_cfg,
Some(&config.general), Some(&config.general),
)?); )?);
let controller = SessionController::new(provider, config.clone()); let storage = Arc::new(StorageManager::new().await?);
let (mut app, mut session_rx) = CodeApp::new(controller); // Code client - code execution tools enabled
let controller = SessionController::new(provider, config.clone(), storage.clone(), true)?;
let (mut app, mut session_rx) = CodeApp::new(controller).await?;
app.inner_mut().initialize_models().await?; app.inner_mut().initialize_models().await?;
let cancellation_token = CancellationToken::new(); let cancellation_token = CancellationToken::new();
@@ -87,8 +100,21 @@ async fn run_app(
session_rx: &mut mpsc::UnboundedReceiver<SessionEvent>, session_rx: &mut mpsc::UnboundedReceiver<SessionEvent>,
) -> Result<()> { ) -> Result<()> {
loop { loop {
// Advance loading animation frame
app.inner_mut().advance_loading_animation();
terminal.draw(|f| ui::render_chat(f, app.inner_mut()))?; terminal.draw(|f| ui::render_chat(f, app.inner_mut()))?;
// Process any pending LLM requests AFTER UI has been drawn
if let Err(e) = app.inner_mut().process_pending_llm_request().await {
eprintln!("Error processing LLM request: {}", e);
}
// Process any pending tool executions AFTER UI has been drawn
if let Err(e) = app.inner_mut().process_pending_tool_execution().await {
eprintln!("Error processing tool execution: {}", e);
}
tokio::select! { tokio::select! {
Some(event) = event_rx.recv() => { Some(event) = event_rx.recv() => {
if let AppState::Quit = app.handle_event(event).await? { if let AppState::Quit = app.handle_event(event).await? {
@@ -98,6 +124,10 @@ async fn run_app(
Some(session_event) = session_rx.recv() => { Some(session_event) = session_rx.recv() => {
app.handle_session_event(session_event)?; app.handle_session_event(session_event)?;
} }
// Add a timeout to keep the animation going even when there are no events
_ = tokio::time::sleep(tokio::time::Duration::from_millis(100)) => {
// This will cause the loop to continue and advance the animation
}
} }
} }
} }

View File

@@ -2,7 +2,7 @@
use anyhow::Result; use anyhow::Result;
use clap::{Arg, Command}; use clap::{Arg, Command};
use owlen_core::session::SessionController; use owlen_core::{session::SessionController, storage::StorageManager};
use owlen_ollama::OllamaProvider; use owlen_ollama::OllamaProvider;
use owlen_tui::{config, ui, AppState, ChatApp, Event, EventHandler, SessionEvent}; use owlen_tui::{config, ui, AppState, ChatApp, Event, EventHandler, SessionEvent};
use std::io; use std::io;
@@ -38,14 +38,27 @@ async fn main() -> Result<()> {
} }
// Prepare provider from configuration // Prepare provider from configuration
let provider_cfg = config::ensure_ollama_config(&mut config).clone(); let provider_name = config.general.default_provider.clone();
let provider_cfg = config::ensure_provider_config(&mut config, &provider_name).clone();
let provider_type = provider_cfg.provider_type.to_ascii_lowercase();
if provider_type != "ollama" && provider_type != "ollama-cloud" {
anyhow::bail!(
"Unsupported provider type '{}' configured for provider '{}'",
provider_cfg.provider_type,
provider_name
);
}
let provider = Arc::new(OllamaProvider::from_config( let provider = Arc::new(OllamaProvider::from_config(
&provider_cfg, &provider_cfg,
Some(&config.general), Some(&config.general),
)?); )?);
let controller = SessionController::new(provider, config.clone()); let storage = Arc::new(StorageManager::new().await?);
let (mut app, mut session_rx) = ChatApp::new(controller); // Chat client - code execution tools disabled (only available in code client)
let controller = SessionController::new(provider, config.clone(), storage.clone(), false)?;
let (mut app, mut session_rx) = ChatApp::new(controller).await?;
app.initialize_models().await?; app.initialize_models().await?;
// Event infrastructure // Event infrastructure
@@ -104,7 +117,14 @@ async fn run_app(
terminal.draw(|f| ui::render_chat(f, app))?; terminal.draw(|f| ui::render_chat(f, app))?;
// Process any pending LLM requests AFTER UI has been drawn // Process any pending LLM requests AFTER UI has been drawn
app.process_pending_llm_request().await?; if let Err(e) = app.process_pending_llm_request().await {
eprintln!("Error processing LLM request: {}", e);
}
// Process any pending tool executions AFTER UI has been drawn
if let Err(e) = app.process_pending_tool_execution().await {
eprintln!("Error processing tool execution: {}", e);
}
tokio::select! { tokio::select! {
Some(event) = event_rx.recv() => { Some(event) = event_rx.recv() => {

View File

@@ -25,7 +25,20 @@ toml = "0.8.0"
shellexpand = "3.1.0" shellexpand = "3.1.0"
dirs = "5.0" dirs = "5.0"
ratatui = { workspace = true } ratatui = { workspace = true }
tempfile = { workspace = true }
jsonschema = { workspace = true }
which = { workspace = true }
nix = { workspace = true }
aes-gcm = { workspace = true }
ring = { workspace = true }
keyring = { workspace = true }
chrono = { workspace = true }
urlencoding = { workspace = true }
rpassword = { workspace = true }
sqlx = { workspace = true }
duckduckgo = "0.2.0"
reqwest = { workspace = true, features = ["default"] }
reqwest_011 = { version = "0.11", package = "reqwest" }
[dev-dependencies] [dev-dependencies]
tokio-test = { workspace = true } tokio-test = { workspace = true }
tempfile = { workspace = true }

View File

@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
name TEXT,
description TEXT,
model TEXT NOT NULL,
message_count INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
data TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_conversations_updated_at ON conversations(updated_at DESC);

View File

@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS secure_items (
key TEXT PRIMARY KEY,
nonce BLOB NOT NULL,
ciphertext BLOB NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);

View File

@@ -26,19 +26,24 @@ pub struct Config {
/// Input handling preferences /// Input handling preferences
#[serde(default)] #[serde(default)]
pub input: InputSettings, pub input: InputSettings,
/// Privacy controls for tooling and network usage
#[serde(default)]
pub privacy: PrivacySettings,
/// Security controls for sandboxing and resource limits
#[serde(default)]
pub security: SecuritySettings,
/// Per-tool configuration toggles
#[serde(default)]
pub tools: ToolSettings,
} }
impl Default for Config { impl Default for Config {
fn default() -> Self { fn default() -> Self {
let mut providers = HashMap::new(); let mut providers = HashMap::new();
providers.insert("ollama".to_string(), default_ollama_provider_config());
providers.insert( providers.insert(
"ollama".to_string(), "ollama-cloud".to_string(),
ProviderConfig { default_ollama_cloud_provider_config(),
provider_type: "ollama".to_string(),
base_url: Some("http://localhost:11434".to_string()),
api_key: None,
extra: HashMap::new(),
},
); );
Self { Self {
@@ -47,6 +52,9 @@ impl Default for Config {
ui: UiSettings::default(), ui: UiSettings::default(),
storage: StorageSettings::default(), storage: StorageSettings::default(),
input: InputSettings::default(), input: InputSettings::default(),
privacy: PrivacySettings::default(),
security: SecuritySettings::default(),
tools: ToolSettings::default(),
} }
} }
} }
@@ -120,17 +128,26 @@ impl Config {
self.general.default_provider = "ollama".to_string(); self.general.default_provider = "ollama".to_string();
} }
if !self.providers.contains_key("ollama") { ensure_provider_config(self, "ollama");
self.providers.insert( ensure_provider_config(self, "ollama-cloud");
"ollama".to_string(), }
ProviderConfig { }
provider_type: "ollama".to_string(),
base_url: Some("http://localhost:11434".to_string()), fn default_ollama_provider_config() -> ProviderConfig {
api_key: None, ProviderConfig {
extra: HashMap::new(), provider_type: "ollama".to_string(),
}, base_url: Some("http://localhost:11434".to_string()),
); api_key: None,
} extra: HashMap::new(),
}
}
fn default_ollama_cloud_provider_config() -> ProviderConfig {
ProviderConfig {
provider_type: "ollama-cloud".to_string(),
base_url: Some("https://ollama.com".to_string()),
api_key: None,
extra: HashMap::new(),
} }
} }
@@ -185,6 +202,154 @@ impl Default for GeneralSettings {
} }
} }
/// Privacy controls governing network access and storage
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrivacySettings {
#[serde(default = "PrivacySettings::default_remote_search")]
pub enable_remote_search: bool,
#[serde(default)]
pub cache_web_results: bool,
#[serde(default)]
pub retain_history_days: u32,
#[serde(default = "PrivacySettings::default_require_consent")]
pub require_consent_per_session: bool,
#[serde(default = "PrivacySettings::default_encrypt_local_data")]
pub encrypt_local_data: bool,
}
impl PrivacySettings {
const fn default_remote_search() -> bool {
false
}
const fn default_require_consent() -> bool {
true
}
const fn default_encrypt_local_data() -> bool {
true
}
}
impl Default for PrivacySettings {
fn default() -> Self {
Self {
enable_remote_search: Self::default_remote_search(),
cache_web_results: false,
retain_history_days: 0,
require_consent_per_session: Self::default_require_consent(),
encrypt_local_data: Self::default_encrypt_local_data(),
}
}
}
/// Security settings that constrain tool execution
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecuritySettings {
#[serde(default = "SecuritySettings::default_enable_sandboxing")]
pub enable_sandboxing: bool,
#[serde(default = "SecuritySettings::default_timeout")]
pub sandbox_timeout_seconds: u64,
#[serde(default = "SecuritySettings::default_max_memory")]
pub max_memory_mb: u64,
#[serde(default = "SecuritySettings::default_allowed_tools")]
pub allowed_tools: Vec<String>,
}
impl SecuritySettings {
const fn default_enable_sandboxing() -> bool {
true
}
const fn default_timeout() -> u64 {
30
}
const fn default_max_memory() -> u64 {
512
}
fn default_allowed_tools() -> Vec<String> {
vec!["web_search".to_string(), "code_exec".to_string()]
}
}
impl Default for SecuritySettings {
fn default() -> Self {
Self {
enable_sandboxing: Self::default_enable_sandboxing(),
sandbox_timeout_seconds: Self::default_timeout(),
max_memory_mb: Self::default_max_memory(),
allowed_tools: Self::default_allowed_tools(),
}
}
}
/// Per-tool configuration toggles
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ToolSettings {
#[serde(default)]
pub web_search: WebSearchToolConfig,
#[serde(default)]
pub code_exec: CodeExecToolConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebSearchToolConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub api_key: String,
#[serde(default = "WebSearchToolConfig::default_max_results")]
pub max_results: u32,
}
impl WebSearchToolConfig {
const fn default_max_results() -> u32 {
5
}
}
impl Default for WebSearchToolConfig {
fn default() -> Self {
Self {
enabled: false,
api_key: String::new(),
max_results: Self::default_max_results(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodeExecToolConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "CodeExecToolConfig::default_allowed_languages")]
pub allowed_languages: Vec<String>,
#[serde(default = "CodeExecToolConfig::default_timeout")]
pub timeout_seconds: u64,
}
impl CodeExecToolConfig {
fn default_allowed_languages() -> Vec<String> {
vec!["python".to_string(), "javascript".to_string()]
}
const fn default_timeout() -> u64 {
30
}
}
impl Default for CodeExecToolConfig {
fn default() -> Self {
Self {
enabled: false,
allowed_languages: Self::default_allowed_languages(),
timeout_seconds: Self::default_timeout(),
}
}
}
/// UI preferences that consumers can respect as needed /// UI preferences that consumers can respect as needed
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UiSettings { pub struct UiSettings {
@@ -343,15 +508,32 @@ impl Default for InputSettings {
/// Convenience accessor for an Ollama provider entry, creating a default if missing /// Convenience accessor for an Ollama provider entry, creating a default if missing
pub fn ensure_ollama_config(config: &mut Config) -> &ProviderConfig { pub fn ensure_ollama_config(config: &mut Config) -> &ProviderConfig {
config ensure_provider_config(config, "ollama")
.providers }
.entry("ollama".to_string())
.or_insert_with(|| ProviderConfig { /// Ensure a provider configuration exists for the requested provider name
provider_type: "ollama".to_string(), pub fn ensure_provider_config<'a>(
base_url: Some("http://localhost:11434".to_string()), config: &'a mut Config,
api_key: None, provider_name: &str,
extra: HashMap::new(), ) -> &'a ProviderConfig {
}) use std::collections::hash_map::Entry;
match config.providers.entry(provider_name.to_string()) {
Entry::Occupied(entry) => entry.into_mut(),
Entry::Vacant(entry) => {
let default = match provider_name {
"ollama-cloud" => default_ollama_cloud_provider_config(),
"ollama" => default_ollama_provider_config(),
other => ProviderConfig {
provider_type: other.to_string(),
base_url: None,
api_key: None,
extra: HashMap::new(),
},
};
entry.insert(default)
}
}
} }
/// Calculate absolute timeout for session data based on configuration /// Calculate absolute timeout for session data based on configuration
@@ -404,4 +586,21 @@ mod tests {
let path = config.storage.conversation_path(); let path = config.storage.conversation_path();
assert!(path.to_string_lossy().contains("custom/path")); assert!(path.to_string_lossy().contains("custom/path"));
} }
#[test]
fn default_config_contains_local_and_cloud_providers() {
let config = Config::default();
assert!(config.providers.contains_key("ollama"));
assert!(config.providers.contains_key("ollama-cloud"));
}
#[test]
fn ensure_provider_config_backfills_cloud_defaults() {
let mut config = Config::default();
config.providers.remove("ollama-cloud");
let cloud = ensure_provider_config(&mut config, "ollama-cloud");
assert_eq!(cloud.provider_type, "ollama-cloud");
assert_eq!(cloud.base_url.as_deref(), Some("https://ollama.com"));
}
} }

View File

@@ -0,0 +1,172 @@
use std::collections::HashMap;
use std::io::{self, Write};
use std::sync::Arc;
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::encryption::VaultHandle;
#[derive(Clone, Debug)]
pub struct ConsentRequest {
pub tool_name: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ConsentRecord {
pub tool_name: String,
pub granted: bool,
pub timestamp: DateTime<Utc>,
pub data_types: Vec<String>,
pub external_endpoints: Vec<String>,
}
#[derive(Serialize, Deserialize, Default)]
pub struct ConsentManager {
records: HashMap<String, ConsentRecord>,
}
impl ConsentManager {
pub fn new() -> Self {
Self::default()
}
/// Load consent records from vault storage
pub fn from_vault(vault: &Arc<std::sync::Mutex<VaultHandle>>) -> Self {
let guard = vault.lock().expect("Vault mutex poisoned");
if let Some(consent_data) = guard.settings().get("consent_records") {
if let Ok(records) =
serde_json::from_value::<HashMap<String, ConsentRecord>>(consent_data.clone())
{
return Self { records };
}
}
Self::default()
}
/// Persist consent records to vault storage
pub fn persist_to_vault(&self, vault: &Arc<std::sync::Mutex<VaultHandle>>) -> Result<()> {
let mut guard = vault.lock().expect("Vault mutex poisoned");
let consent_json = serde_json::to_value(&self.records)?;
guard
.settings_mut()
.insert("consent_records".to_string(), consent_json);
guard.persist()?;
Ok(())
}
pub fn request_consent(
&mut self,
tool_name: &str,
data_types: Vec<String>,
endpoints: Vec<String>,
) -> Result<bool> {
if let Some(existing) = self.records.get(tool_name) {
return Ok(existing.granted);
}
let consent = self.show_consent_dialog(tool_name, &data_types, &endpoints)?;
let record = ConsentRecord {
tool_name: tool_name.to_string(),
granted: consent,
timestamp: Utc::now(),
data_types,
external_endpoints: endpoints,
};
self.records.insert(tool_name.to_string(), record);
// Note: Caller should persist to vault after this call
Ok(consent)
}
/// Grant consent programmatically (for TUI or automated flows)
pub fn grant_consent(
&mut self,
tool_name: &str,
data_types: Vec<String>,
endpoints: Vec<String>,
) {
let record = ConsentRecord {
tool_name: tool_name.to_string(),
granted: true,
timestamp: Utc::now(),
data_types,
external_endpoints: endpoints,
};
self.records.insert(tool_name.to_string(), record);
}
/// Check if consent is needed (returns None if already granted, Some(info) if needed)
pub fn check_consent_needed(&self, tool_name: &str) -> Option<ConsentRequest> {
if self.has_consent(tool_name) {
None
} else {
Some(ConsentRequest {
tool_name: tool_name.to_string(),
})
}
}
pub fn has_consent(&self, tool_name: &str) -> bool {
self.records
.get(tool_name)
.map(|record| record.granted)
.unwrap_or(false)
}
pub fn revoke_consent(&mut self, tool_name: &str) {
if let Some(record) = self.records.get_mut(tool_name) {
record.granted = false;
record.timestamp = Utc::now();
}
}
pub fn clear_all_consent(&mut self) {
self.records.clear();
}
/// Check if consent is needed for a tool (non-blocking)
/// Returns Some with consent details if needed, None if already granted
pub fn check_if_consent_needed(
&self,
tool_name: &str,
data_types: Vec<String>,
endpoints: Vec<String>,
) -> Option<(String, Vec<String>, Vec<String>)> {
if self.has_consent(tool_name) {
return None;
}
Some((tool_name.to_string(), data_types, endpoints))
}
fn show_consent_dialog(
&self,
tool_name: &str,
data_types: &[String],
endpoints: &[String],
) -> Result<bool> {
// TEMPORARY: Auto-grant consent when not in a proper terminal (TUI mode)
// TODO: Integrate consent UI into the TUI event loop
use std::io::IsTerminal;
if !io::stdin().is_terminal() || std::env::var("OWLEN_AUTO_CONSENT").is_ok() {
eprintln!("Auto-granting consent for {} (TUI mode)", tool_name);
return Ok(true);
}
println!("=== PRIVACY CONSENT REQUIRED ===");
println!("Tool: {}", tool_name);
println!("Data to be sent: {}", data_types.join(", "));
println!("External endpoints: {}", endpoints.join(", "));
println!("Do you consent to this data transmission? (y/N)");
print!("> ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
Ok(matches!(input.trim().to_lowercase().as_str(), "y" | "yes"))
}
}

View File

@@ -3,7 +3,6 @@ use crate::types::{Conversation, Message};
use crate::Result; use crate::Result;
use serde_json::{Number, Value}; use serde_json::{Number, Value};
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use uuid::Uuid; use uuid::Uuid;
@@ -214,6 +213,25 @@ impl ConversationManager {
Ok(()) Ok(())
} }
/// Set tool calls on a streaming message
pub fn set_tool_calls_on_message(
&mut self,
message_id: Uuid,
tool_calls: Vec<crate::types::ToolCall>,
) -> Result<()> {
let index = self
.message_index
.get(&message_id)
.copied()
.ok_or_else(|| crate::Error::Unknown(format!("Unknown message id: {message_id}")))?;
if let Some(message) = self.active_mut().messages.get_mut(index) {
message.tool_calls = Some(tool_calls);
}
Ok(())
}
/// Update the active model (used when user changes model mid session) /// Update the active model (used when user changes model mid session)
pub fn set_model(&mut self, model: impl Into<String>) { pub fn set_model(&mut self, model: impl Into<String>) {
self.active.model = model.into(); self.active.model = model.into();
@@ -268,36 +286,40 @@ impl ConversationManager {
} }
/// Save the active conversation to disk /// Save the active conversation to disk
pub fn save_active(&self, storage: &StorageManager, name: Option<String>) -> Result<PathBuf> { pub async fn save_active(
storage.save_conversation(&self.active, name) &self,
storage: &StorageManager,
name: Option<String>,
) -> Result<Uuid> {
storage.save_conversation(&self.active, name).await?;
Ok(self.active.id)
} }
/// Save the active conversation to disk with a description /// Save the active conversation to disk with a description
pub fn save_active_with_description( pub async fn save_active_with_description(
&self, &self,
storage: &StorageManager, storage: &StorageManager,
name: Option<String>, name: Option<String>,
description: Option<String>, description: Option<String>,
) -> Result<PathBuf> { ) -> Result<Uuid> {
storage.save_conversation_with_description(&self.active, name, description) storage
.save_conversation_with_description(&self.active, name, description)
.await?;
Ok(self.active.id)
} }
/// Load a conversation from disk and make it active /// Load a conversation from storage and make it active
pub fn load_from_disk( pub async fn load_saved(&mut self, storage: &StorageManager, id: Uuid) -> Result<()> {
&mut self, let conversation = storage.load_conversation(id).await?;
storage: &StorageManager,
path: impl AsRef<Path>,
) -> Result<()> {
let conversation = storage.load_conversation(path)?;
self.load(conversation); self.load(conversation);
Ok(()) Ok(())
} }
/// List all saved sessions /// List all saved sessions
pub fn list_saved_sessions( pub async fn list_saved_sessions(
storage: &StorageManager, storage: &StorageManager,
) -> Result<Vec<crate::storage::SessionMeta>> { ) -> Result<Vec<crate::storage::SessionMeta>> {
storage.list_sessions() storage.list_sessions().await
} }
} }

View File

@@ -0,0 +1,69 @@
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::{storage::StorageManager, Error, Result};
#[derive(Serialize, Deserialize, Debug)]
pub struct ApiCredentials {
pub api_key: String,
pub endpoint: String,
}
pub struct CredentialManager {
storage: Arc<StorageManager>,
master_key: Arc<Vec<u8>>,
namespace: String,
}
impl CredentialManager {
pub fn new(storage: Arc<StorageManager>, master_key: Arc<Vec<u8>>) -> Self {
Self {
storage,
master_key,
namespace: "owlen".to_string(),
}
}
fn namespaced_key(&self, tool_name: &str) -> String {
format!("{}_{}", self.namespace, tool_name)
}
pub async fn store_credentials(
&self,
tool_name: &str,
credentials: &ApiCredentials,
) -> Result<()> {
let key = self.namespaced_key(tool_name);
let payload = serde_json::to_vec(credentials).map_err(|e| {
Error::Storage(format!(
"Failed to serialize credentials for secure storage: {e}"
))
})?;
self.storage
.store_secure_item(&key, &payload, &self.master_key)
.await
}
pub async fn get_credentials(&self, tool_name: &str) -> Result<Option<ApiCredentials>> {
let key = self.namespaced_key(tool_name);
match self
.storage
.load_secure_item(&key, &self.master_key)
.await?
{
Some(bytes) => {
let creds = serde_json::from_slice(&bytes).map_err(|e| {
Error::Storage(format!("Failed to deserialize stored credentials: {e}"))
})?;
Ok(Some(creds))
}
None => Ok(None),
}
}
pub async fn delete_credentials(&self, tool_name: &str) -> Result<()> {
let key = self.namespaced_key(tool_name);
self.storage.delete_secure_item(&key).await
}
}

View File

@@ -0,0 +1,241 @@
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Nonce,
};
use anyhow::{bail, Context, Result};
use ring::digest;
use ring::rand::{SecureRandom, SystemRandom};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
pub struct EncryptedStorage {
cipher: Aes256Gcm,
storage_path: PathBuf,
}
#[derive(Serialize, Deserialize)]
struct EncryptedData {
nonce: [u8; 12],
ciphertext: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct VaultData {
pub master_key: Vec<u8>,
#[serde(default)]
pub settings: HashMap<String, JsonValue>,
}
pub struct VaultHandle {
storage: EncryptedStorage,
pub data: VaultData,
}
impl VaultHandle {
pub fn master_key(&self) -> &[u8] {
&self.data.master_key
}
pub fn settings(&self) -> &HashMap<String, JsonValue> {
&self.data.settings
}
pub fn settings_mut(&mut self) -> &mut HashMap<String, JsonValue> {
&mut self.data.settings
}
pub fn persist(&self) -> Result<()> {
self.storage.store(&self.data)
}
}
impl EncryptedStorage {
pub fn new(storage_path: PathBuf, password: &str) -> Result<Self> {
let digest = digest::digest(&digest::SHA256, password.as_bytes());
let cipher = Aes256Gcm::new_from_slice(digest.as_ref())
.map_err(|_| anyhow::anyhow!("Invalid key length for AES-256"))?;
if let Some(parent) = storage_path.parent() {
fs::create_dir_all(parent).context("Failed to ensure storage directory exists")?;
}
Ok(Self {
cipher,
storage_path,
})
}
pub fn store<T: Serialize>(&self, data: &T) -> Result<()> {
let json = serde_json::to_vec(data).context("Failed to serialize data")?;
let nonce = generate_nonce()?;
let nonce_ref = Nonce::from_slice(&nonce);
let ciphertext = self
.cipher
.encrypt(nonce_ref, json.as_ref())
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
let encrypted_data = EncryptedData { nonce, ciphertext };
let encrypted_json = serde_json::to_vec(&encrypted_data)?;
fs::write(&self.storage_path, encrypted_json).context("Failed to write encrypted data")?;
Ok(())
}
pub fn load<T: for<'de> Deserialize<'de>>(&self) -> Result<T> {
let encrypted_json =
fs::read(&self.storage_path).context("Failed to read encrypted data")?;
let encrypted_data: EncryptedData =
serde_json::from_slice(&encrypted_json).context("Failed to parse encrypted data")?;
let nonce_ref = Nonce::from_slice(&encrypted_data.nonce);
let plaintext = self
.cipher
.decrypt(nonce_ref, encrypted_data.ciphertext.as_ref())
.map_err(|e| anyhow::anyhow!("Decryption failed: {}", e))?;
let data: T =
serde_json::from_slice(&plaintext).context("Failed to deserialize decrypted data")?;
Ok(data)
}
pub fn exists(&self) -> bool {
self.storage_path.exists()
}
pub fn delete(&self) -> Result<()> {
if self.exists() {
fs::remove_file(&self.storage_path).context("Failed to delete encrypted storage")?;
}
Ok(())
}
pub fn verify_password(&self) -> Result<()> {
if !self.exists() {
return Ok(());
}
let encrypted_json =
fs::read(&self.storage_path).context("Failed to read encrypted data")?;
if encrypted_json.is_empty() {
return Ok(());
}
let encrypted_data: EncryptedData =
serde_json::from_slice(&encrypted_json).context("Failed to parse encrypted data")?;
let nonce_ref = Nonce::from_slice(&encrypted_data.nonce);
self.cipher
.decrypt(nonce_ref, encrypted_data.ciphertext.as_ref())
.map(|_| ())
.map_err(|e| anyhow::anyhow!("Decryption failed: {}", e))
}
}
pub fn prompt_password(prompt: &str) -> Result<String> {
let password = rpassword::prompt_password(prompt)
.map_err(|e| anyhow::anyhow!("Failed to read password: {e}"))?;
if password.is_empty() {
bail!("Password cannot be empty");
}
Ok(password)
}
pub fn prompt_new_password() -> Result<String> {
loop {
let first = prompt_password("Enter new master password: ")?;
let confirm = prompt_password("Confirm master password: ")?;
if first == confirm {
return Ok(first);
}
println!("Passwords did not match. Please try again.");
}
}
pub fn unlock_with_password(storage_path: PathBuf, password: &str) -> Result<VaultHandle> {
let storage = EncryptedStorage::new(storage_path, password)?;
let data = load_or_initialize_vault(&storage)?;
Ok(VaultHandle { storage, data })
}
pub fn unlock_interactive(storage_path: PathBuf) -> Result<VaultHandle> {
if storage_path.exists() {
for attempt in 0..3 {
let password = prompt_password("Enter master password: ")?;
match unlock_with_password(storage_path.clone(), &password) {
Ok(handle) => return Ok(handle),
Err(err) => {
println!("Failed to unlock vault: {err}");
if attempt == 2 {
return Err(err);
}
}
}
}
bail!("Failed to unlock encrypted storage after multiple attempts");
} else {
println!(
"No encrypted storage found at {}. Initializing a new vault.",
storage_path.display()
);
let password = prompt_new_password()?;
let storage = EncryptedStorage::new(storage_path, &password)?;
let data = VaultData {
master_key: generate_master_key()?,
..Default::default()
};
storage.store(&data)?;
Ok(VaultHandle { storage, data })
}
}
fn load_or_initialize_vault(storage: &EncryptedStorage) -> Result<VaultData> {
match storage.load::<VaultData>() {
Ok(data) => {
if data.master_key.len() != 32 {
bail!(
"Corrupted vault: master key has invalid length ({}). \
Expected 32 bytes for AES-256. Vault cannot be recovered.",
data.master_key.len()
);
}
Ok(data)
}
Err(err) => {
if storage.exists() {
return Err(err);
}
let data = VaultData {
master_key: generate_master_key()?,
..Default::default()
};
storage.store(&data)?;
Ok(data)
}
}
}
fn generate_master_key() -> Result<Vec<u8>> {
let mut key = vec![0u8; 32];
SystemRandom::new()
.fill(&mut key)
.map_err(|_| anyhow::anyhow!("Failed to generate master key"))?;
Ok(key)
}
fn generate_nonce() -> Result<[u8; 12]> {
let mut nonce = [0u8; 12];
let rng = SystemRandom::new();
rng.fill(&mut nonce)
.map_err(|_| anyhow::anyhow!("Failed to generate nonce"))?;
Ok(nonce)
}

View File

@@ -4,28 +4,42 @@
//! LLM providers, routers, and MCP (Model Context Protocol) adapters. //! LLM providers, routers, and MCP (Model Context Protocol) adapters.
pub mod config; pub mod config;
pub mod consent;
pub mod conversation; pub mod conversation;
pub mod credentials;
pub mod encryption;
pub mod formatting; pub mod formatting;
pub mod input; pub mod input;
pub mod mcp;
pub mod model; pub mod model;
pub mod provider; pub mod provider;
pub mod router; pub mod router;
pub mod sandbox;
pub mod session; pub mod session;
pub mod storage; pub mod storage;
pub mod theme; pub mod theme;
pub mod tools;
pub mod types; pub mod types;
pub mod ui; pub mod ui;
pub mod validation;
pub mod wrap_cursor; pub mod wrap_cursor;
pub use config::*; pub use config::*;
pub use consent::*;
pub use conversation::*; pub use conversation::*;
pub use credentials::*;
pub use encryption::*;
pub use formatting::*; pub use formatting::*;
pub use input::*; pub use input::*;
pub use mcp::*;
pub use model::*; pub use model::*;
pub use provider::*; pub use provider::*;
pub use router::*; pub use router::*;
pub use sandbox::*;
pub use session::*; pub use session::*;
pub use theme::*; pub use theme::*;
pub use tools::*;
pub use validation::*;
/// Result type used throughout the OWLEN ecosystem /// Result type used throughout the OWLEN ecosystem
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T> = std::result::Result<T, Error>;

View File

@@ -0,0 +1,82 @@
use crate::tools::registry::ToolRegistry;
use crate::validation::SchemaValidator;
use crate::Result;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
/// Descriptor for a tool exposed over MCP
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolDescriptor {
pub name: String,
pub description: String,
pub input_schema: Value,
pub requires_network: bool,
pub requires_filesystem: Vec<String>,
}
/// Invocation payload for a tool call
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolCall {
pub name: String,
pub arguments: Value,
}
/// Result returned by a tool invocation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolResponse {
pub name: String,
pub success: bool,
pub output: Value,
pub metadata: HashMap<String, String>,
pub duration_ms: u128,
}
/// Thin MCP server facade over the tool registry
pub struct McpServer {
registry: Arc<ToolRegistry>,
validator: Arc<SchemaValidator>,
}
impl McpServer {
pub fn new(registry: Arc<ToolRegistry>, validator: Arc<SchemaValidator>) -> Self {
Self {
registry,
validator,
}
}
/// Enumerate the registered tools as MCP descriptors
pub fn list_tools(&self) -> Vec<McpToolDescriptor> {
self.registry
.all()
.into_iter()
.map(|tool| McpToolDescriptor {
name: tool.name().to_string(),
description: tool.description().to_string(),
input_schema: tool.schema(),
requires_network: tool.requires_network(),
requires_filesystem: tool.requires_filesystem(),
})
.collect()
}
/// Execute a tool call after validating inputs against the registered schema
pub async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse> {
self.validator.validate(&call.name, &call.arguments)?;
let result = self.registry.execute(&call.name, call.arguments).await?;
Ok(McpToolResponse {
name: call.name,
success: result.success,
output: result.output,
metadata: result.metadata,
duration_ms: duration_to_millis(result.duration),
})
}
}
fn duration_to_millis(duration: Duration) -> u128 {
duration.as_secs() as u128 * 1_000 + u128::from(duration.subsec_millis())
}

View File

@@ -0,0 +1,212 @@
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use anyhow::{bail, Context, Result};
use tempfile::TempDir;
/// Configuration options for sandboxed process execution.
#[derive(Clone, Debug)]
pub struct SandboxConfig {
pub allow_network: bool,
pub allow_paths: Vec<PathBuf>,
pub readonly_paths: Vec<PathBuf>,
pub timeout_seconds: u64,
pub max_memory_mb: u64,
}
impl Default for SandboxConfig {
fn default() -> Self {
Self {
allow_network: false,
allow_paths: Vec::new(),
readonly_paths: Vec::new(),
timeout_seconds: 30,
max_memory_mb: 512,
}
}
}
/// Wrapper around a bubblewrap sandbox instance.
///
/// Memory limits are enforced via:
/// - bwrap's --rlimit-as (version >= 0.12.0)
/// - prlimit wrapper (fallback for older bwrap versions)
/// - timeout mechanism (always enforced as last resort)
pub struct SandboxedProcess {
temp_dir: TempDir,
config: SandboxConfig,
}
impl SandboxedProcess {
pub fn new(config: SandboxConfig) -> Result<Self> {
let temp_dir = TempDir::new().context("Failed to create temp directory")?;
which::which("bwrap")
.context("bubblewrap not found. Install with: sudo apt install bubblewrap")?;
Ok(Self { temp_dir, config })
}
pub fn execute(&self, command: &str, args: &[&str]) -> Result<SandboxResult> {
let supports_rlimit = self.supports_rlimit_as();
let use_prlimit = !supports_rlimit && which::which("prlimit").is_ok();
let mut cmd = if use_prlimit {
// Use prlimit wrapper for older bwrap versions
let mut prlimit_cmd = Command::new("prlimit");
let memory_limit_bytes = self
.config
.max_memory_mb
.saturating_mul(1024)
.saturating_mul(1024);
prlimit_cmd.arg(format!("--as={}", memory_limit_bytes));
prlimit_cmd.arg("bwrap");
prlimit_cmd
} else {
Command::new("bwrap")
};
cmd.args(["--unshare-all", "--die-with-parent", "--new-session"]);
if self.config.allow_network {
cmd.arg("--share-net");
} else {
cmd.arg("--unshare-net");
}
cmd.args(["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp"]);
// Bind essential system paths readonly for executables and libraries
let system_paths = ["/usr", "/bin", "/lib", "/lib64", "/etc"];
for sys_path in &system_paths {
let path = std::path::Path::new(sys_path);
if path.exists() {
cmd.arg("--ro-bind").arg(sys_path).arg(sys_path);
}
}
// Bind /run for DNS resolution (resolv.conf may be a symlink to /run/systemd/resolve/*)
if std::path::Path::new("/run").exists() {
cmd.arg("--ro-bind").arg("/run").arg("/run");
}
for path in &self.config.allow_paths {
let path_host = path.to_string_lossy().into_owned();
let path_guest = path_host.clone();
cmd.arg("--bind").arg(&path_host).arg(&path_guest);
}
for path in &self.config.readonly_paths {
let path_host = path.to_string_lossy().into_owned();
let path_guest = path_host.clone();
cmd.arg("--ro-bind").arg(&path_host).arg(&path_guest);
}
let work_dir = self.temp_dir.path().to_string_lossy().into_owned();
cmd.arg("--bind").arg(&work_dir).arg("/work");
cmd.arg("--chdir").arg("/work");
// Add memory limits via bwrap's --rlimit-as if supported (version >= 0.12.0)
// If not supported, we use prlimit wrapper (set earlier)
if supports_rlimit && !use_prlimit {
let memory_limit_bytes = self
.config
.max_memory_mb
.saturating_mul(1024)
.saturating_mul(1024);
let memory_soft = memory_limit_bytes.to_string();
let memory_hard = memory_limit_bytes.to_string();
cmd.arg("--rlimit-as").arg(&memory_soft).arg(&memory_hard);
}
cmd.arg(command);
cmd.args(args);
let start = Instant::now();
let timeout = Duration::from_secs(self.config.timeout_seconds);
// Spawn the process instead of waiting immediately
let mut child = cmd
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("Failed to spawn sandboxed command")?;
let mut was_timeout = false;
// Wait for the child with timeout
let output = loop {
match child.try_wait() {
Ok(Some(_status)) => {
// Process exited
let output = child
.wait_with_output()
.context("Failed to collect process output")?;
break output;
}
Ok(None) => {
// Process still running, check timeout
if start.elapsed() >= timeout {
// Timeout exceeded, kill the process
was_timeout = true;
child.kill().context("Failed to kill timed-out process")?;
// Wait for the killed process to exit
let output = child
.wait_with_output()
.context("Failed to collect output from killed process")?;
break output;
}
// Sleep briefly before checking again
std::thread::sleep(Duration::from_millis(50));
}
Err(e) => {
bail!("Failed to check process status: {}", e);
}
}
};
let duration = start.elapsed();
Ok(SandboxResult {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
exit_code: output.status.code().unwrap_or(-1),
duration,
was_timeout,
})
}
/// Check if bubblewrap supports --rlimit-as option (version >= 0.12.0)
fn supports_rlimit_as(&self) -> bool {
// Try to get bwrap version
let output = Command::new("bwrap").arg("--version").output();
if let Ok(output) = output {
let version_str = String::from_utf8_lossy(&output.stdout);
// Parse version like "bubblewrap 0.11.0" or "0.11.0"
if let Some(version_part) = version_str.split_whitespace().last() {
if let Some((major, rest)) = version_part.split_once('.') {
if let Some((minor, _patch)) = rest.split_once('.') {
if let (Ok(maj), Ok(min)) = (major.parse::<u32>(), minor.parse::<u32>()) {
// --rlimit-as was added in 0.12.0
return maj > 0 || (maj == 0 && min >= 12);
}
}
}
}
}
// If we can't determine the version, assume it doesn't support it (safer default)
false
}
}
#[derive(Debug, Clone)]
pub struct SandboxResult {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
pub duration: Duration,
pub was_timeout: bool,
}

View File

@@ -1,12 +1,26 @@
use crate::config::Config; use crate::config::Config;
use crate::consent::ConsentManager;
use crate::conversation::ConversationManager; use crate::conversation::ConversationManager;
use crate::credentials::CredentialManager;
use crate::encryption::{self, VaultHandle};
use crate::formatting::MessageFormatter; use crate::formatting::MessageFormatter;
use crate::input::InputBuffer; use crate::input::InputBuffer;
use crate::model::ModelManager; use crate::model::ModelManager;
use crate::provider::{ChatStream, Provider}; use crate::provider::{ChatStream, Provider};
use crate::types::{ChatParameters, ChatRequest, ChatResponse, Conversation, ModelInfo}; use crate::storage::{SessionMeta, StorageManager};
use crate::Result; use crate::tools::{
use std::sync::Arc; code_exec::CodeExecTool, registry::ToolRegistry, web_search::WebSearchTool,
web_search_detailed::WebSearchDetailedTool, Tool,
};
use crate::types::{
ChatParameters, ChatRequest, ChatResponse, Conversation, Message, ModelInfo, ToolCall,
};
use crate::validation::{get_builtin_schemas, SchemaValidator};
use crate::{Error, Result};
use log::warn;
use std::env;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use uuid::Uuid; use uuid::Uuid;
/// Outcome of submitting a chat request /// Outcome of submitting a chat request
@@ -31,6 +45,7 @@ pub enum SessionOutcome {
/// use owlen_core::config::Config; /// use owlen_core::config::Config;
/// use owlen_core::provider::{Provider, ChatStream}; /// use owlen_core::provider::{Provider, ChatStream};
/// use owlen_core::session::{SessionController, SessionOutcome}; /// use owlen_core::session::{SessionController, SessionOutcome};
/// use owlen_core::storage::StorageManager;
/// use owlen_core::types::{ChatRequest, ChatResponse, ChatParameters, Message, ModelInfo}; /// use owlen_core::types::{ChatRequest, ChatResponse, ChatParameters, Message, ModelInfo};
/// use owlen_core::Result; /// use owlen_core::Result;
/// ///
@@ -55,7 +70,9 @@ pub enum SessionOutcome {
/// async fn main() { /// async fn main() {
/// let provider = Arc::new(MockProvider); /// let provider = Arc::new(MockProvider);
/// let config = Config::default(); /// let config = Config::default();
/// let mut session_controller = SessionController::new(provider, config); /// let storage = Arc::new(StorageManager::new().await.unwrap());
/// let enable_code_tools = false; // Set to true for code client
/// let mut session_controller = SessionController::new(provider, config, storage, enable_code_tools).unwrap();
/// ///
/// // Send a message /// // Send a message
/// let outcome = session_controller.send_message( /// let outcome = session_controller.send_message(
@@ -82,17 +99,69 @@ pub struct SessionController {
input_buffer: InputBuffer, input_buffer: InputBuffer,
formatter: MessageFormatter, formatter: MessageFormatter,
config: Config, config: Config,
consent_manager: Arc<Mutex<ConsentManager>>,
tool_registry: Arc<ToolRegistry>,
schema_validator: Arc<SchemaValidator>,
storage: Arc<StorageManager>,
vault: Option<Arc<Mutex<VaultHandle>>>,
master_key: Option<Arc<Vec<u8>>>,
credential_manager: Option<Arc<CredentialManager>>,
enable_code_tools: bool, // Whether to enable code execution tools (code client only)
} }
impl SessionController { impl SessionController {
/// Create a new controller with the given provider and configuration /// Create a new controller with the given provider and configuration
pub fn new(provider: Arc<dyn Provider>, config: Config) -> Self { ///
/// # Arguments
/// * `provider` - The LLM provider to use
/// * `config` - Application configuration
/// * `storage` - Storage manager for persistence
/// * `enable_code_tools` - Whether to enable code execution tools (should only be true for code client)
pub fn new(
provider: Arc<dyn Provider>,
config: Config,
storage: Arc<StorageManager>,
enable_code_tools: bool,
) -> Result<Self> {
let model = config let model = config
.general .general
.default_model .default_model
.clone() .clone()
.unwrap_or_else(|| "ollama/default".to_string()); .unwrap_or_else(|| "ollama/default".to_string());
let mut vault_handle: Option<Arc<Mutex<VaultHandle>>> = None;
let mut master_key: Option<Arc<Vec<u8>>> = None;
let mut credential_manager: Option<Arc<CredentialManager>> = None;
if config.privacy.encrypt_local_data {
let base_dir = storage
.database_path()
.parent()
.map(|p| p.to_path_buf())
.or_else(dirs::data_local_dir)
.unwrap_or_else(|| PathBuf::from("."));
let secure_path = base_dir.join("encrypted_data.json");
let handle = match env::var("OWLEN_MASTER_PASSWORD") {
Ok(password) if !password.is_empty() => {
encryption::unlock_with_password(secure_path, &password)?
}
_ => encryption::unlock_interactive(secure_path)?,
};
let master = Arc::new(handle.data.master_key.clone());
master_key = Some(master.clone());
vault_handle = Some(Arc::new(Mutex::new(handle)));
credential_manager = Some(Arc::new(CredentialManager::new(storage.clone(), master)));
}
// Load consent manager from vault if available, otherwise create new
let consent_manager = if let Some(ref vault) = vault_handle {
Arc::new(Mutex::new(ConsentManager::from_vault(vault)))
} else {
Arc::new(Mutex::new(ConsentManager::new()))
};
let conversation = let conversation =
ConversationManager::with_history_capacity(model, config.storage.max_saved_sessions); ConversationManager::with_history_capacity(model, config.storage.max_saved_sessions);
let formatter = let formatter =
@@ -106,14 +175,26 @@ impl SessionController {
let model_manager = ModelManager::new(config.general.model_cache_ttl()); let model_manager = ModelManager::new(config.general.model_cache_ttl());
Self { let mut controller = Self {
provider, provider,
conversation, conversation,
model_manager, model_manager,
input_buffer, input_buffer,
formatter, formatter,
config, config,
} consent_manager,
tool_registry: Arc::new(ToolRegistry::new()),
schema_validator: Arc::new(SchemaValidator::new()),
storage,
vault: vault_handle,
master_key,
credential_manager,
enable_code_tools,
};
controller.rebuild_tools()?;
Ok(controller)
} }
/// Access the active conversation /// Access the active conversation
@@ -156,6 +237,260 @@ impl SessionController {
&mut self.config &mut self.config
} }
/// Grant consent programmatically for a tool (for TUI consent dialog)
pub fn grant_consent(&self, tool_name: &str, data_types: Vec<String>, endpoints: Vec<String>) {
let mut consent = self
.consent_manager
.lock()
.expect("Consent manager mutex poisoned");
consent.grant_consent(tool_name, data_types, endpoints);
// Persist to vault if available
if let Some(vault) = &self.vault {
if let Err(e) = consent.persist_to_vault(vault) {
eprintln!("Warning: Failed to persist consent to vault: {}", e);
}
}
}
/// Check if consent is needed for tool calls (non-blocking check)
/// Returns a list of (tool_name, data_types, endpoints) tuples for tools that need consent
pub fn check_tools_consent_needed(
&self,
tool_calls: &[ToolCall],
) -> Vec<(String, Vec<String>, Vec<String>)> {
let consent = self
.consent_manager
.lock()
.expect("Consent manager mutex poisoned");
let mut needs_consent = Vec::new();
let mut seen_tools = std::collections::HashSet::new();
for tool_call in tool_calls {
// Skip if we already checked this tool
if seen_tools.contains(&tool_call.name) {
continue;
}
seen_tools.insert(tool_call.name.clone());
// Get tool metadata (data types and endpoints) based on tool name
let (data_types, endpoints) = match tool_call.name.as_str() {
"web_search" | "web_search_detailed" => (
vec!["search query".to_string()],
vec!["duckduckgo.com".to_string()],
),
"code_exec" => (
vec!["code to execute".to_string()],
vec!["local sandbox".to_string()],
),
_ => (vec![], vec![]),
};
if let Some((tool_name, dt, ep)) =
consent.check_if_consent_needed(&tool_call.name, data_types, endpoints)
{
needs_consent.push((tool_name, dt, ep));
}
}
needs_consent
}
/// Persist the active conversation to storage
pub async fn save_active_session(
&self,
name: Option<String>,
description: Option<String>,
) -> Result<Uuid> {
self.conversation
.save_active_with_description(&self.storage, name, description)
.await
}
/// Persist the active conversation without description override
pub async fn save_active_session_simple(&self, name: Option<String>) -> Result<Uuid> {
self.conversation.save_active(&self.storage, name).await
}
/// Load a saved conversation by ID and make it active
pub async fn load_saved_session(&mut self, id: Uuid) -> Result<()> {
self.conversation.load_saved(&self.storage, id).await
}
/// Retrieve session metadata from storage
pub async fn list_saved_sessions(&self) -> Result<Vec<SessionMeta>> {
ConversationManager::list_saved_sessions(&self.storage).await
}
pub async fn delete_session(&self, id: Uuid) -> Result<()> {
self.storage.delete_session(id).await
}
pub async fn clear_secure_data(&self) -> Result<()> {
self.storage.clear_secure_items().await?;
if let Some(vault) = &self.vault {
let mut guard = vault.lock().expect("Vault mutex poisoned");
guard.data.settings.clear();
guard.persist()?;
}
// Also clear consent records
{
let mut consent = self
.consent_manager
.lock()
.expect("Consent manager mutex poisoned");
consent.clear_all_consent();
}
self.persist_consent()?;
Ok(())
}
/// Persist current consent state to vault (if encryption is enabled)
pub fn persist_consent(&self) -> Result<()> {
if let Some(vault) = &self.vault {
let consent = self
.consent_manager
.lock()
.expect("Consent manager mutex poisoned");
consent.persist_to_vault(vault)?;
}
Ok(())
}
pub async fn set_tool_enabled(&mut self, tool: &str, enabled: bool) -> Result<()> {
match tool {
"web_search" => {
self.config.tools.web_search.enabled = enabled;
self.config.privacy.enable_remote_search = enabled;
}
"code_exec" => {
self.config.tools.code_exec.enabled = enabled;
}
other => {
return Err(Error::InvalidInput(format!("Unknown tool: {other}")));
}
}
self.rebuild_tools()?;
Ok(())
}
/// Access the consent manager shared across tools
pub fn consent_manager(&self) -> Arc<Mutex<ConsentManager>> {
self.consent_manager.clone()
}
/// Access the tool registry for executing registered tools
pub fn tool_registry(&self) -> Arc<ToolRegistry> {
Arc::clone(&self.tool_registry)
}
/// Access the schema validator used for tool input validation
pub fn schema_validator(&self) -> Arc<SchemaValidator> {
Arc::clone(&self.schema_validator)
}
/// Construct an MCP server facade for the active tool registry
pub fn mcp_server(&self) -> crate::mcp::McpServer {
crate::mcp::McpServer::new(self.tool_registry(), self.schema_validator())
}
/// Access the underlying storage manager
pub fn storage(&self) -> Arc<StorageManager> {
Arc::clone(&self.storage)
}
/// Retrieve the active master key if encryption is enabled
pub fn master_key(&self) -> Option<Arc<Vec<u8>>> {
self.master_key.as_ref().map(Arc::clone)
}
/// Access the vault handle for managing secure settings
pub fn vault(&self) -> Option<Arc<Mutex<VaultHandle>>> {
self.vault.as_ref().map(Arc::clone)
}
/// Access the credential manager if available
pub fn credential_manager(&self) -> Option<Arc<CredentialManager>> {
self.credential_manager.as_ref().map(Arc::clone)
}
fn rebuild_tools(&mut self) -> Result<()> {
let mut registry = ToolRegistry::new();
let mut validator = SchemaValidator::new();
for (name, schema) in get_builtin_schemas() {
if let Err(err) = validator.register_schema(&name, schema) {
warn!("Failed to register built-in schema {name}: {err}");
}
}
if self
.config
.security
.allowed_tools
.iter()
.any(|tool| tool == "web_search")
&& self.config.tools.web_search.enabled
&& self.config.privacy.enable_remote_search
{
let tool = WebSearchTool::new(
self.consent_manager.clone(),
self.credential_manager.clone(),
self.vault.clone(),
);
let schema = tool.schema();
if let Err(err) = validator.register_schema(tool.name(), schema) {
warn!("Failed to register schema for {}: {err}", tool.name());
}
registry.register(tool);
}
// Register web_search_detailed tool (provides snippets)
if self
.config
.security
.allowed_tools
.iter()
.any(|tool| tool == "web_search") // Same permission as web_search
&& self.config.tools.web_search.enabled
&& self.config.privacy.enable_remote_search
{
let tool = WebSearchDetailedTool::new(
self.consent_manager.clone(),
self.credential_manager.clone(),
self.vault.clone(),
);
let schema = tool.schema();
if let Err(err) = validator.register_schema(tool.name(), schema) {
warn!("Failed to register schema for {}: {err}", tool.name());
}
registry.register(tool);
}
// Code execution tool - only available in code client
if self.enable_code_tools
&& self
.config
.security
.allowed_tools
.iter()
.any(|tool| tool == "code_exec")
&& self.config.tools.code_exec.enabled
{
let tool = CodeExecTool::new(self.config.tools.code_exec.allowed_languages.clone());
let schema = tool.schema();
if let Err(err) = validator.register_schema(tool.name(), schema) {
warn!("Failed to register schema for {}: {err}", tool.name());
}
registry.register(tool);
}
self.tool_registry = Arc::new(registry);
self.schema_validator = Arc::new(validator);
Ok(())
}
/// Currently selected model identifier /// Currently selected model identifier
pub fn selected_model(&self) -> &str { pub fn selected_model(&self) -> &str {
&self.conversation.active().model &self.conversation.active().model
@@ -187,6 +522,13 @@ impl SessionController {
} }
} }
/// Replace the active provider at runtime and invalidate cached model listings
pub async fn switch_provider(&mut self, provider: Arc<dyn Provider>) -> Result<()> {
self.provider = provider;
self.model_manager.invalidate().await;
Ok(())
}
/// Submit a user message; optionally stream the response /// Submit a user message; optionally stream the response
pub async fn send_message( pub async fn send_message(
&mut self, &mut self,
@@ -210,38 +552,104 @@ impl SessionController {
let streaming = parameters.stream || self.config.general.enable_streaming; let streaming = parameters.stream || self.config.general.enable_streaming;
parameters.stream = streaming; parameters.stream = streaming;
let request = ChatRequest { // Get available tools
model: self.conversation.active().model.clone(), let tools = if !self.tool_registry.all().is_empty() {
messages: self.conversation.active().messages.clone(), Some(
parameters, self.tool_registry
.all()
.into_iter()
.map(|tool| crate::mcp::McpToolDescriptor {
name: tool.name().to_string(),
description: tool.description().to_string(),
input_schema: tool.schema(),
requires_network: tool.requires_network(),
requires_filesystem: tool.requires_filesystem(),
})
.collect(),
)
} else {
None
}; };
if streaming { let mut request = ChatRequest {
match self.provider.chat_stream(request).await { model: self.conversation.active().model.clone(),
Ok(stream) => { messages: self.conversation.active().messages.clone(),
let response_id = self.conversation.start_streaming_response(); parameters: parameters.clone(),
Ok(SessionOutcome::Streaming { tools: tools.clone(),
response_id, };
stream,
}) // Tool execution loop (non-streaming only for now)
} if !streaming {
Err(err) => { const MAX_TOOL_ITERATIONS: usize = 5;
self.conversation for _iteration in 0..MAX_TOOL_ITERATIONS {
.push_assistant_message(format!("Error starting stream: {}", err)); match self.provider.chat(request.clone()).await {
Err(err) Ok(response) => {
// Check if the response has tool calls
if response.message.has_tool_calls() {
// Add assistant's tool call message to conversation
self.conversation.push_message(response.message.clone());
// Execute each tool call
if let Some(tool_calls) = &response.message.tool_calls {
for tool_call in tool_calls {
let tool_result = self
.tool_registry
.execute(&tool_call.name, tool_call.arguments.clone())
.await;
let tool_response_content = match tool_result {
Ok(result) => serde_json::to_string_pretty(&result.output)
.unwrap_or_else(|_| {
"Tool execution succeeded".to_string()
}),
Err(e) => format!("Tool execution failed: {}", e),
};
// Add tool response to conversation
let tool_msg =
Message::tool(tool_call.id.clone(), tool_response_content);
self.conversation.push_message(tool_msg);
}
}
// Update request with new messages for next iteration
request.messages = self.conversation.active().messages.clone();
continue;
} else {
// No more tool calls, return final response
self.conversation.push_message(response.message.clone());
return Ok(SessionOutcome::Complete(response));
}
}
Err(err) => {
self.conversation
.push_assistant_message(format!("Error: {}", err));
return Err(err);
}
} }
} }
} else {
match self.provider.chat(request).await { // Max iterations reached
Ok(response) => { self.conversation
self.conversation.push_message(response.message.clone()); .push_assistant_message("Maximum tool execution iterations reached".to_string());
Ok(SessionOutcome::Complete(response)) return Err(crate::Error::Provider(anyhow::anyhow!(
} "Maximum tool execution iterations reached"
Err(err) => { )));
self.conversation }
.push_assistant_message(format!("Error: {}", err));
Err(err) // Streaming mode with tool support
} match self.provider.chat_stream(request).await {
Ok(stream) => {
let response_id = self.conversation.start_streaming_response();
Ok(SessionOutcome::Streaming {
response_id,
stream,
})
}
Err(err) => {
self.conversation
.push_assistant_message(format!("Error starting stream: {}", err));
Err(err)
} }
} }
} }
@@ -254,10 +662,64 @@ impl SessionController {
/// Apply streaming chunk to the conversation /// Apply streaming chunk to the conversation
pub fn apply_stream_chunk(&mut self, message_id: Uuid, chunk: &ChatResponse) -> Result<()> { pub fn apply_stream_chunk(&mut self, message_id: Uuid, chunk: &ChatResponse) -> Result<()> {
// Check if this chunk contains tool calls
if chunk.message.has_tool_calls() {
// This is a tool call chunk - store the tool calls on the message
self.conversation.set_tool_calls_on_message(
message_id,
chunk.message.tool_calls.clone().unwrap_or_default(),
)?;
}
self.conversation self.conversation
.append_stream_chunk(message_id, &chunk.message.content, chunk.is_final) .append_stream_chunk(message_id, &chunk.message.content, chunk.is_final)
} }
/// Check if a streaming message has complete tool calls that need execution
pub fn check_streaming_tool_calls(&self, message_id: Uuid) -> Option<Vec<ToolCall>> {
self.conversation
.active()
.messages
.iter()
.find(|m| m.id == message_id)
.and_then(|m| m.tool_calls.clone())
.filter(|calls| !calls.is_empty())
}
/// Execute tools for a streaming response and continue conversation
pub async fn execute_streaming_tools(
&mut self,
_message_id: Uuid,
tool_calls: Vec<ToolCall>,
) -> Result<SessionOutcome> {
// Execute each tool call
for tool_call in &tool_calls {
let tool_result = self
.tool_registry
.execute(&tool_call.name, tool_call.arguments.clone())
.await;
let tool_response_content = match tool_result {
Ok(result) => serde_json::to_string_pretty(&result.output)
.unwrap_or_else(|_| "Tool execution succeeded".to_string()),
Err(e) => format!("Tool execution failed: {}", e),
};
// Add tool response to conversation
let tool_msg = Message::tool(tool_call.id.clone(), tool_response_content);
self.conversation.push_message(tool_msg);
}
// Continue the conversation with tool results
let parameters = ChatParameters {
stream: self.config.general.enable_streaming,
..Default::default()
};
self.send_request_with_current_conversation(parameters)
.await
}
/// Access conversation history /// Access conversation history
pub fn history(&self) -> Vec<Conversation> { pub fn history(&self) -> Vec<Conversation> {
self.conversation.history().cloned().collect() self.conversation.history().cloned().collect()
@@ -335,6 +797,7 @@ impl SessionController {
stream: false, stream: false,
extra: std::collections::HashMap::new(), extra: std::collections::HashMap::new(),
}, },
tools: None,
}; };
// Get the summary from the provider // Get the summary from the provider

View File

@@ -1,19 +1,26 @@
//! Session persistence and storage management //! Session persistence and storage management backed by SQLite
use crate::types::Conversation; use crate::types::Conversation;
use crate::{Error, Result}; use crate::{Error, Result};
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
use ring::rand::{SecureRandom, SystemRandom};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
use sqlx::{Pool, Row, Sqlite};
use std::fs; use std::fs;
use std::io::IsTerminal;
use std::io::{self, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::SystemTime; use std::str::FromStr;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use uuid::Uuid;
/// Metadata about a saved session /// Metadata about a saved session
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMeta { pub struct SessionMeta {
/// Session file path
pub path: PathBuf,
/// Conversation ID /// Conversation ID
pub id: uuid::Uuid, pub id: Uuid,
/// Optional session name /// Optional session name
pub name: Option<String>, pub name: Option<String>,
/// Optional AI-generated description /// Optional AI-generated description
@@ -28,282 +35,525 @@ pub struct SessionMeta {
pub updated_at: SystemTime, pub updated_at: SystemTime,
} }
/// Storage manager for persisting conversations /// Storage manager for persisting conversations in SQLite
pub struct StorageManager { pub struct StorageManager {
sessions_dir: PathBuf, pool: Pool<Sqlite>,
database_path: PathBuf,
} }
impl StorageManager { impl StorageManager {
/// Create a new storage manager with the default sessions directory /// Create a new storage manager using the default database path
pub fn new() -> Result<Self> { pub async fn new() -> Result<Self> {
let sessions_dir = Self::default_sessions_dir()?; let db_path = Self::default_database_path()?;
Self::with_directory(sessions_dir) Self::with_database_path(db_path).await
} }
/// Create a storage manager with a custom sessions directory /// Create a storage manager using the provided database path
pub fn with_directory(sessions_dir: PathBuf) -> Result<Self> { pub async fn with_database_path(database_path: PathBuf) -> Result<Self> {
// Ensure the directory exists if let Some(parent) = database_path.parent() {
if !sessions_dir.exists() { if !parent.exists() {
fs::create_dir_all(&sessions_dir).map_err(|e| { std::fs::create_dir_all(parent).map_err(|e| {
Error::Storage(format!("Failed to create sessions directory: {}", e)) Error::Storage(format!(
})?; "Failed to create database directory {parent:?}: {e}"
))
})?;
}
} }
Ok(Self { sessions_dir }) let options = SqliteConnectOptions::from_str(&format!(
"sqlite://{}",
database_path
.to_str()
.ok_or_else(|| Error::Storage("Invalid database path".to_string()))?
))
.map_err(|e| Error::Storage(format!("Invalid database URL: {e}")))?
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Normal);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(options)
.await
.map_err(|e| Error::Storage(format!("Failed to connect to database: {e}")))?;
sqlx::migrate!("./migrations")
.run(&pool)
.await
.map_err(|e| Error::Storage(format!("Failed to run database migrations: {e}")))?;
let storage = Self {
pool,
database_path,
};
storage.try_migrate_legacy_sessions().await?;
Ok(storage)
} }
/// Get the default sessions directory /// Save a conversation. Existing entries are updated in-place.
/// - Linux: ~/.local/share/owlen/sessions pub async fn save_conversation(
/// - Windows: %APPDATA%\owlen\sessions &self,
/// - macOS: ~/Library/Application Support/owlen/sessions conversation: &Conversation,
pub fn default_sessions_dir() -> Result<PathBuf> { name: Option<String>,
) -> Result<()> {
self.save_conversation_with_description(conversation, name, None)
.await
}
/// Save a conversation with an optional description override
pub async fn save_conversation_with_description(
&self,
conversation: &Conversation,
name: Option<String>,
description: Option<String>,
) -> Result<()> {
let mut serialized = conversation.clone();
if name.is_some() {
serialized.name = name.clone();
}
if description.is_some() {
serialized.description = description.clone();
}
let data = serde_json::to_string(&serialized)
.map_err(|e| Error::Storage(format!("Failed to serialize conversation: {e}")))?;
let created_at = to_epoch_seconds(serialized.created_at);
let updated_at = to_epoch_seconds(serialized.updated_at);
let message_count = serialized.messages.len() as i64;
sqlx::query(
r#"
INSERT INTO conversations (
id,
name,
description,
model,
message_count,
created_at,
updated_at,
data
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
model = excluded.model,
message_count = excluded.message_count,
created_at = excluded.created_at,
updated_at = excluded.updated_at,
data = excluded.data
"#,
)
.bind(serialized.id.to_string())
.bind(name.or(serialized.name.clone()))
.bind(description.or(serialized.description.clone()))
.bind(&serialized.model)
.bind(message_count)
.bind(created_at)
.bind(updated_at)
.bind(data)
.execute(&self.pool)
.await
.map_err(|e| Error::Storage(format!("Failed to save conversation: {e}")))?;
Ok(())
}
/// Load a conversation by ID
pub async fn load_conversation(&self, id: Uuid) -> Result<Conversation> {
let record = sqlx::query(r#"SELECT data FROM conversations WHERE id = ?1"#)
.bind(id.to_string())
.fetch_optional(&self.pool)
.await
.map_err(|e| Error::Storage(format!("Failed to load conversation: {e}")))?;
let row =
record.ok_or_else(|| Error::Storage(format!("No conversation found with id {id}")))?;
let data: String = row
.try_get("data")
.map_err(|e| Error::Storage(format!("Failed to read conversation payload: {e}")))?;
serde_json::from_str(&data)
.map_err(|e| Error::Storage(format!("Failed to deserialize conversation: {e}")))
}
/// List metadata for all saved conversations ordered by most recent update
pub async fn list_sessions(&self) -> Result<Vec<SessionMeta>> {
let rows = sqlx::query(
r#"
SELECT id, name, description, model, message_count, created_at, updated_at
FROM conversations
ORDER BY updated_at DESC
"#,
)
.fetch_all(&self.pool)
.await
.map_err(|e| Error::Storage(format!("Failed to list sessions: {e}")))?;
let mut sessions = Vec::with_capacity(rows.len());
for row in rows {
let id_text: String = row
.try_get("id")
.map_err(|e| Error::Storage(format!("Failed to read id column: {e}")))?;
let id = Uuid::parse_str(&id_text)
.map_err(|e| Error::Storage(format!("Invalid UUID in storage: {e}")))?;
let message_count: i64 = row
.try_get("message_count")
.map_err(|e| Error::Storage(format!("Failed to read message count: {e}")))?;
let created_at: i64 = row
.try_get("created_at")
.map_err(|e| Error::Storage(format!("Failed to read created_at: {e}")))?;
let updated_at: i64 = row
.try_get("updated_at")
.map_err(|e| Error::Storage(format!("Failed to read updated_at: {e}")))?;
sessions.push(SessionMeta {
id,
name: row
.try_get("name")
.map_err(|e| Error::Storage(format!("Failed to read name: {e}")))?,
description: row
.try_get("description")
.map_err(|e| Error::Storage(format!("Failed to read description: {e}")))?,
model: row
.try_get("model")
.map_err(|e| Error::Storage(format!("Failed to read model: {e}")))?,
message_count: message_count as usize,
created_at: from_epoch_seconds(created_at),
updated_at: from_epoch_seconds(updated_at),
});
}
Ok(sessions)
}
/// Delete a conversation by ID
pub async fn delete_session(&self, id: Uuid) -> Result<()> {
sqlx::query("DELETE FROM conversations WHERE id = ?1")
.bind(id.to_string())
.execute(&self.pool)
.await
.map_err(|e| Error::Storage(format!("Failed to delete conversation: {e}")))?;
Ok(())
}
pub async fn store_secure_item(
&self,
key: &str,
plaintext: &[u8],
master_key: &[u8],
) -> Result<()> {
let cipher = create_cipher(master_key)?;
let nonce_bytes = generate_nonce()?;
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext)
.map_err(|e| Error::Storage(format!("Failed to encrypt secure item: {e}")))?;
let now = to_epoch_seconds(SystemTime::now());
sqlx::query(
r#"
INSERT INTO secure_items (key, nonce, ciphertext, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(key) DO UPDATE SET
nonce = excluded.nonce,
ciphertext = excluded.ciphertext,
updated_at = excluded.updated_at
"#,
)
.bind(key)
.bind(&nonce_bytes[..])
.bind(&ciphertext[..])
.bind(now)
.bind(now)
.execute(&self.pool)
.await
.map_err(|e| Error::Storage(format!("Failed to store secure item: {e}")))?;
Ok(())
}
pub async fn load_secure_item(&self, key: &str, master_key: &[u8]) -> Result<Option<Vec<u8>>> {
let record = sqlx::query("SELECT nonce, ciphertext FROM secure_items WHERE key = ?1")
.bind(key)
.fetch_optional(&self.pool)
.await
.map_err(|e| Error::Storage(format!("Failed to load secure item: {e}")))?;
let Some(row) = record else {
return Ok(None);
};
let nonce_bytes: Vec<u8> = row
.try_get("nonce")
.map_err(|e| Error::Storage(format!("Failed to read secure item nonce: {e}")))?;
let ciphertext: Vec<u8> = row
.try_get("ciphertext")
.map_err(|e| Error::Storage(format!("Failed to read secure item ciphertext: {e}")))?;
if nonce_bytes.len() != 12 {
return Err(Error::Storage(
"Invalid nonce length for secure item".to_string(),
));
}
let cipher = create_cipher(master_key)?;
let nonce = Nonce::from_slice(&nonce_bytes);
let plaintext = cipher
.decrypt(nonce, ciphertext.as_ref())
.map_err(|e| Error::Storage(format!("Failed to decrypt secure item: {e}")))?;
Ok(Some(plaintext))
}
pub async fn delete_secure_item(&self, key: &str) -> Result<()> {
sqlx::query("DELETE FROM secure_items WHERE key = ?1")
.bind(key)
.execute(&self.pool)
.await
.map_err(|e| Error::Storage(format!("Failed to delete secure item: {e}")))?;
Ok(())
}
pub async fn clear_secure_items(&self) -> Result<()> {
sqlx::query("DELETE FROM secure_items")
.execute(&self.pool)
.await
.map_err(|e| Error::Storage(format!("Failed to clear secure items: {e}")))?;
Ok(())
}
/// Database location used by this storage manager
pub fn database_path(&self) -> &Path {
&self.database_path
}
/// Determine default database path (platform specific)
pub fn default_database_path() -> Result<PathBuf> {
let data_dir = dirs::data_local_dir()
.ok_or_else(|| Error::Storage("Could not determine data directory".to_string()))?;
Ok(data_dir.join("owlen").join("owlen.db"))
}
fn legacy_sessions_dir() -> Result<PathBuf> {
let data_dir = dirs::data_local_dir() let data_dir = dirs::data_local_dir()
.ok_or_else(|| Error::Storage("Could not determine data directory".to_string()))?; .ok_or_else(|| Error::Storage("Could not determine data directory".to_string()))?;
Ok(data_dir.join("owlen").join("sessions")) Ok(data_dir.join("owlen").join("sessions"))
} }
/// Save a conversation to disk async fn database_has_records(&self) -> Result<bool> {
pub fn save_conversation( let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM conversations")
&self, .fetch_one(&self.pool)
conversation: &Conversation, .await
name: Option<String>, .map_err(|e| Error::Storage(format!("Failed to inspect database: {e}")))?;
) -> Result<PathBuf> { Ok(count > 0)
self.save_conversation_with_description(conversation, name, None)
} }
/// Save a conversation to disk with an optional description async fn try_migrate_legacy_sessions(&self) -> Result<()> {
pub fn save_conversation_with_description( if self.database_has_records().await? {
&self, return Ok(());
conversation: &Conversation, }
name: Option<String>,
description: Option<String>, let legacy_dir = match Self::legacy_sessions_dir() {
) -> Result<PathBuf> { Ok(dir) => dir,
let filename = if let Some(ref session_name) = name { Err(_) => return Ok(()),
// Use provided name, sanitized
let sanitized = sanitize_filename(session_name);
format!("{}_{}.json", conversation.id, sanitized)
} else {
// Use conversation ID and timestamp
let timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("{}_{}.json", conversation.id, timestamp)
}; };
let path = self.sessions_dir.join(filename); if !legacy_dir.exists() {
return Ok(());
// Create a saveable version with the name and description
let mut save_conv = conversation.clone();
if name.is_some() {
save_conv.name = name;
}
if description.is_some() {
save_conv.description = description;
} }
let json = serde_json::to_string_pretty(&save_conv) let entries = fs::read_dir(&legacy_dir).map_err(|e| {
.map_err(|e| Error::Storage(format!("Failed to serialize conversation: {}", e)))?; Error::Storage(format!("Failed to read legacy sessions directory: {e}"))
})?;
fs::write(&path, json)
.map_err(|e| Error::Storage(format!("Failed to write session file: {}", e)))?;
Ok(path)
}
/// Load a conversation from disk
pub fn load_conversation(&self, path: impl AsRef<Path>) -> Result<Conversation> {
let content = fs::read_to_string(path.as_ref())
.map_err(|e| Error::Storage(format!("Failed to read session file: {}", e)))?;
let conversation: Conversation = serde_json::from_str(&content)
.map_err(|e| Error::Storage(format!("Failed to parse session file: {}", e)))?;
Ok(conversation)
}
/// List all saved sessions with metadata
pub fn list_sessions(&self) -> Result<Vec<SessionMeta>> {
let mut sessions = Vec::new();
let entries = fs::read_dir(&self.sessions_dir)
.map_err(|e| Error::Storage(format!("Failed to read sessions directory: {}", e)))?;
for entry in entries {
let entry = entry
.map_err(|e| Error::Storage(format!("Failed to read directory entry: {}", e)))?;
let mut json_files = Vec::new();
for entry in entries.flatten() {
let path = entry.path(); let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") { if path.extension().and_then(|s| s.to_str()) == Some("json") {
continue; json_files.push(path);
} }
}
// Try to load the conversation to extract metadata if json_files.is_empty() {
match self.load_conversation(&path) { return Ok(());
Ok(conv) => { }
sessions.push(SessionMeta {
path: path.clone(), if !io::stdin().is_terminal() {
id: conv.id, return Ok(());
name: conv.name.clone(), }
description: conv.description.clone(),
message_count: conv.messages.len(), println!(
model: conv.model.clone(), "Legacy OWLEN session files were found in {}.",
created_at: conv.created_at, legacy_dir.display()
updated_at: conv.updated_at, );
}); if !prompt_yes_no("Migrate them to the new SQLite storage? (y/N) ")? {
} println!("Skipping legacy session migration.");
Err(_) => { return Ok(());
// Skip files that can't be parsed }
continue;
println!("Migrating legacy sessions...");
let mut migrated = 0usize;
for path in &json_files {
match fs::read_to_string(path) {
Ok(content) => match serde_json::from_str::<Conversation>(&content) {
Ok(conversation) => {
if let Err(err) = self
.save_conversation_with_description(
&conversation,
conversation.name.clone(),
conversation.description.clone(),
)
.await
{
println!(" • Failed to migrate {}: {}", path.display(), err);
} else {
migrated += 1;
}
}
Err(err) => {
println!(
" • Failed to parse conversation {}: {}",
path.display(),
err
);
}
},
Err(err) => {
println!(" • Failed to read {}: {}", path.display(), err);
} }
} }
} }
// Sort by updated_at, most recent first if migrated > 0 {
sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); if let Err(err) = archive_legacy_directory(&legacy_dir) {
println!(
Ok(sessions) "Warning: migrated sessions but failed to archive legacy directory: {}",
} err
);
/// Delete a saved session
pub fn delete_session(&self, path: impl AsRef<Path>) -> Result<()> {
fs::remove_file(path.as_ref())
.map_err(|e| Error::Storage(format!("Failed to delete session file: {}", e)))
}
/// Get the sessions directory path
pub fn sessions_dir(&self) -> &Path {
&self.sessions_dir
}
}
impl Default for StorageManager {
fn default() -> Self {
Self::new().expect("Failed to create default storage manager")
}
}
/// Sanitize a filename by removing invalid characters
fn sanitize_filename(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_alphanumeric() || c == '_' || c == '-' {
c
} else if c.is_whitespace() {
'_'
} else {
'-'
} }
}) }
.collect::<String>()
.chars() println!("Migrated {} legacy sessions.", migrated);
.take(50) // Limit length Ok(())
.collect() }
}
fn to_epoch_seconds(time: SystemTime) -> i64 {
match time.duration_since(UNIX_EPOCH) {
Ok(duration) => duration.as_secs() as i64,
Err(_) => 0,
}
}
fn from_epoch_seconds(seconds: i64) -> SystemTime {
UNIX_EPOCH + Duration::from_secs(seconds.max(0) as u64)
}
fn prompt_yes_no(prompt: &str) -> Result<bool> {
print!("{}", prompt);
io::stdout()
.flush()
.map_err(|e| Error::Storage(format!("Failed to flush stdout: {e}")))?;
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.map_err(|e| Error::Storage(format!("Failed to read input: {e}")))?;
let trimmed = input.trim().to_lowercase();
Ok(matches!(trimmed.as_str(), "y" | "yes"))
}
fn archive_legacy_directory(legacy_dir: &Path) -> Result<()> {
let mut backup_dir = legacy_dir.with_file_name("sessions_legacy_backup");
let mut counter = 1;
while backup_dir.exists() {
backup_dir = legacy_dir.with_file_name(format!("sessions_legacy_backup_{}", counter));
counter += 1;
}
fs::rename(legacy_dir, &backup_dir).map_err(|e| {
Error::Storage(format!(
"Failed to archive legacy sessions directory {}: {}",
legacy_dir.display(),
e
))
})?;
println!("Legacy session files archived to {}", backup_dir.display());
Ok(())
}
fn create_cipher(master_key: &[u8]) -> Result<Aes256Gcm> {
if master_key.len() != 32 {
return Err(Error::Storage(
"Master key must be 32 bytes for AES-256-GCM".to_string(),
));
}
Aes256Gcm::new_from_slice(master_key).map_err(|_| {
Error::Storage("Failed to initialize cipher with provided master key".to_string())
})
}
fn generate_nonce() -> Result<[u8; 12]> {
let mut nonce = [0u8; 12];
SystemRandom::new()
.fill(&mut nonce)
.map_err(|_| Error::Storage("Failed to generate nonce".to_string()))?;
Ok(nonce)
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::types::Message; use crate::types::{Conversation, Message};
use tempfile::TempDir; use tempfile::tempdir;
#[test] fn sample_conversation() -> Conversation {
fn test_platform_specific_default_path() { Conversation {
let path = StorageManager::default_sessions_dir().unwrap(); id: Uuid::new_v4(),
name: Some("Test conversation".to_string()),
// Verify it contains owlen/sessions description: Some("A sample conversation".to_string()),
assert!(path.to_string_lossy().contains("owlen")); messages: vec![
assert!(path.to_string_lossy().contains("sessions")); Message::user("Hello".to_string()),
Message::assistant("Hi".to_string()),
// Platform-specific checks ],
#[cfg(target_os = "linux")] model: "test-model".to_string(),
{ created_at: SystemTime::now(),
// Linux should use ~/.local/share/owlen/sessions updated_at: SystemTime::now(),
assert!(path.to_string_lossy().contains(".local/share"));
} }
#[cfg(target_os = "windows")]
{
// Windows should use AppData
assert!(path.to_string_lossy().contains("AppData"));
}
#[cfg(target_os = "macos")]
{
// macOS should use ~/Library/Application Support
assert!(path
.to_string_lossy()
.contains("Library/Application Support"));
}
println!("Default sessions directory: {}", path.display());
} }
#[test] #[tokio::test]
fn test_sanitize_filename() { async fn test_storage_lifecycle() {
assert_eq!(sanitize_filename("Hello World"), "Hello_World"); let temp_dir = tempdir().expect("failed to create temp dir");
assert_eq!(sanitize_filename("test/path\\file"), "test-path-file"); let db_path = temp_dir.path().join("owlen.db");
assert_eq!(sanitize_filename("file:name?"), "file-name-"); let storage = StorageManager::with_database_path(db_path).await.unwrap();
}
#[test] let conversation = sample_conversation();
fn test_save_and_load_conversation() { storage
let temp_dir = TempDir::new().unwrap(); .save_conversation(&conversation, None)
let storage = StorageManager::with_directory(temp_dir.path().to_path_buf()).unwrap(); .await
.expect("failed to save conversation");
let mut conv = Conversation::new("test-model".to_string()); let sessions = storage.list_sessions().await.unwrap();
conv.messages.push(Message::user("Hello".to_string())); assert_eq!(sessions.len(), 1);
conv.messages assert_eq!(sessions[0].id, conversation.id);
.push(Message::assistant("Hi there!".to_string()));
// Save conversation let loaded = storage.load_conversation(conversation.id).await.unwrap();
let path = storage
.save_conversation(&conv, Some("test_session".to_string()))
.unwrap();
assert!(path.exists());
// Load conversation
let loaded = storage.load_conversation(&path).unwrap();
assert_eq!(loaded.id, conv.id);
assert_eq!(loaded.model, conv.model);
assert_eq!(loaded.messages.len(), 2); assert_eq!(loaded.messages.len(), 2);
assert_eq!(loaded.name, Some("test_session".to_string()));
}
#[test] storage
fn test_list_sessions() { .delete_session(conversation.id)
let temp_dir = TempDir::new().unwrap(); .await
let storage = StorageManager::with_directory(temp_dir.path().to_path_buf()).unwrap(); .expect("failed to delete conversation");
let sessions = storage.list_sessions().await.unwrap();
// Create multiple sessions assert!(sessions.is_empty());
for i in 0..3 {
let mut conv = Conversation::new("test-model".to_string());
conv.messages.push(Message::user(format!("Message {}", i)));
storage
.save_conversation(&conv, Some(format!("session_{}", i)))
.unwrap();
}
// List sessions
let sessions = storage.list_sessions().unwrap();
assert_eq!(sessions.len(), 3);
// Check that sessions are sorted by updated_at (most recent first)
for i in 0..sessions.len() - 1 {
assert!(sessions[i].updated_at >= sessions[i + 1].updated_at);
}
}
#[test]
fn test_delete_session() {
let temp_dir = TempDir::new().unwrap();
let storage = StorageManager::with_directory(temp_dir.path().to_path_buf()).unwrap();
let conv = Conversation::new("test-model".to_string());
let path = storage.save_conversation(&conv, None).unwrap();
assert!(path.exists());
storage.delete_session(&path).unwrap();
assert!(!path.exists());
} }
} }

View File

@@ -0,0 +1,147 @@
use std::sync::Arc;
use std::time::Instant;
use anyhow::{anyhow, Context, Result};
use async_trait::async_trait;
use serde_json::{json, Value};
use super::{Tool, ToolResult};
use crate::sandbox::{SandboxConfig, SandboxedProcess};
pub struct CodeExecTool {
allowed_languages: Arc<Vec<String>>,
}
impl CodeExecTool {
pub fn new(allowed_languages: Vec<String>) -> Self {
Self {
allowed_languages: Arc::new(allowed_languages),
}
}
}
#[async_trait]
impl Tool for CodeExecTool {
fn name(&self) -> &'static str {
"code_exec"
}
fn description(&self) -> &'static str {
"Execute code snippets within a sandboxed environment"
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"language": {
"type": "string",
"enum": self.allowed_languages.as_slice(),
"description": "Language of the code block"
},
"code": {
"type": "string",
"minLength": 1,
"maxLength": 10000,
"description": "Code to execute"
},
"timeout": {
"type": "integer",
"minimum": 1,
"maximum": 300,
"default": 30,
"description": "Execution timeout in seconds"
}
},
"required": ["language", "code"],
"additionalProperties": false
})
}
async fn execute(&self, args: Value) -> Result<ToolResult> {
let start = Instant::now();
let language = args
.get("language")
.and_then(Value::as_str)
.context("Missing language parameter")?;
let code = args
.get("code")
.and_then(Value::as_str)
.context("Missing code parameter")?;
let timeout = args.get("timeout").and_then(Value::as_u64).unwrap_or(30);
if !self.allowed_languages.iter().any(|lang| lang == language) {
return Err(anyhow!("Language '{}' not permitted", language));
}
let (command, command_args) = match language {
"python" => (
"python3".to_string(),
vec!["-c".to_string(), code.to_string()],
),
"javascript" => ("node".to_string(), vec!["-e".to_string(), code.to_string()]),
"bash" => ("bash".to_string(), vec!["-c".to_string(), code.to_string()]),
"rust" => {
let mut result =
ToolResult::error("Rust execution is not yet supported in the sandbox");
result.duration = start.elapsed();
return Ok(result);
}
other => return Err(anyhow!("Unsupported language: {}", other)),
};
let sandbox_config = SandboxConfig {
allow_network: false,
timeout_seconds: timeout,
..Default::default()
};
let sandbox_result = tokio::task::spawn_blocking(move || -> Result<_> {
let sandbox = SandboxedProcess::new(sandbox_config)?;
let arg_refs: Vec<&str> = command_args.iter().map(|s| s.as_str()).collect();
sandbox.execute(&command, &arg_refs)
})
.await
.context("Sandbox execution task failed")??;
let mut result = if sandbox_result.exit_code == 0 {
ToolResult::success(json!({
"stdout": sandbox_result.stdout,
"stderr": sandbox_result.stderr,
"exit_code": sandbox_result.exit_code,
"timed_out": sandbox_result.was_timeout,
}))
} else {
let error_msg = if sandbox_result.was_timeout {
format!(
"Execution timed out after {} seconds (exit code {}): {}",
timeout, sandbox_result.exit_code, sandbox_result.stderr
)
} else {
format!(
"Execution failed with status {}: {}",
sandbox_result.exit_code, sandbox_result.stderr
)
};
let mut err_result = ToolResult::error(&error_msg);
err_result.output = json!({
"stdout": sandbox_result.stdout,
"stderr": sandbox_result.stderr,
"exit_code": sandbox_result.exit_code,
"timed_out": sandbox_result.was_timeout,
});
err_result
};
result.duration = start.elapsed();
result
.metadata
.insert("language".to_string(), language.to_string());
result
.metadata
.insert("timeout_seconds".to_string(), timeout.to_string());
Ok(result)
}
}

View File

@@ -0,0 +1,53 @@
use std::collections::HashMap;
use anyhow::Result;
use async_trait::async_trait;
use serde_json::Value;
pub mod code_exec;
pub mod registry;
pub mod web_search;
pub mod web_search_detailed;
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &'static str;
fn description(&self) -> &'static str;
fn schema(&self) -> Value;
fn requires_network(&self) -> bool {
false
}
fn requires_filesystem(&self) -> Vec<String> {
Vec::new()
}
async fn execute(&self, args: Value) -> Result<ToolResult>;
}
#[derive(Debug, Clone)]
pub struct ToolResult {
pub success: bool,
pub output: Value,
pub duration: std::time::Duration,
pub metadata: HashMap<String, String>,
}
impl ToolResult {
pub fn success(output: Value) -> Self {
Self {
success: true,
output,
duration: std::time::Duration::from_millis(0),
metadata: HashMap::new(),
}
}
pub fn error(message: &str) -> Self {
Self {
success: false,
output: serde_json::json!({ "error": message }),
duration: std::time::Duration::from_millis(0),
metadata: HashMap::new(),
}
}
}

View File

@@ -0,0 +1,53 @@
use std::collections::HashMap;
use std::sync::Arc;
use anyhow::{Context, Result};
use serde_json::Value;
use super::Tool;
pub struct ToolRegistry {
tools: HashMap<String, Arc<dyn Tool>>,
}
impl Default for ToolRegistry {
fn default() -> Self {
Self::new()
}
}
impl ToolRegistry {
pub fn new() -> Self {
Self {
tools: HashMap::new(),
}
}
pub fn register<T>(&mut self, tool: T)
where
T: Tool + 'static,
{
let tool: Arc<dyn Tool> = Arc::new(tool);
let name = tool.name().to_string();
self.tools.insert(name, tool);
}
pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
self.tools.get(name).cloned()
}
pub fn all(&self) -> Vec<Arc<dyn Tool>> {
self.tools.values().cloned().collect()
}
pub async fn execute(&self, name: &str, args: Value) -> Result<super::ToolResult> {
let tool = self
.get(name)
.with_context(|| format!("Tool not registered: {}", name))?;
tool.execute(args).await
}
pub fn tools(&self) -> Vec<String> {
self.tools.keys().cloned().collect()
}
}

View File

@@ -0,0 +1,153 @@
use std::sync::{Arc, Mutex};
use std::time::Instant;
use anyhow::{Context, Result};
use async_trait::async_trait;
use serde_json::{json, Value};
use super::{Tool, ToolResult};
use crate::consent::ConsentManager;
use crate::credentials::CredentialManager;
use crate::encryption::VaultHandle;
pub struct WebSearchTool {
consent_manager: Arc<Mutex<ConsentManager>>,
_credential_manager: Option<Arc<CredentialManager>>,
browser: duckduckgo::browser::Browser,
}
impl WebSearchTool {
pub fn new(
consent_manager: Arc<Mutex<ConsentManager>>,
credential_manager: Option<Arc<CredentialManager>>,
_vault: Option<Arc<Mutex<VaultHandle>>>,
) -> Self {
// Create a reqwest client compatible with duckduckgo crate (v0.11)
let client = reqwest_011::Client::new();
let browser = duckduckgo::browser::Browser::new(client);
Self {
consent_manager,
_credential_manager: credential_manager,
browser,
}
}
}
#[async_trait]
impl Tool for WebSearchTool {
fn name(&self) -> &'static str {
"web_search"
}
fn description(&self) -> &'static str {
"Search the web for information using DuckDuckGo API"
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"minLength": 1,
"maxLength": 500,
"description": "Search query"
},
"max_results": {
"type": "integer",
"minimum": 1,
"maximum": 10,
"default": 5,
"description": "Maximum number of results"
}
},
"required": ["query"],
"additionalProperties": false
})
}
fn requires_network(&self) -> bool {
true
}
async fn execute(&self, args: Value) -> Result<ToolResult> {
let start = Instant::now();
// Check if consent has been granted (non-blocking check)
// Consent should have been granted via TUI dialog before tool execution
{
let consent = self
.consent_manager
.lock()
.expect("Consent manager mutex poisoned");
if !consent.has_consent(self.name()) {
return Ok(ToolResult::error(
"Consent not granted for web search. This should have been handled by the TUI.",
));
}
}
let query = args
.get("query")
.and_then(Value::as_str)
.context("Missing query parameter")?;
let max_results = args.get("max_results").and_then(Value::as_u64).unwrap_or(5) as usize;
let user_agent = duckduckgo::user_agents::get("firefox").unwrap_or(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:91.0) Gecko/20100101 Firefox/91.0",
);
// Detect if this is a news query - use news endpoint for better snippets
let is_news_query = query.to_lowercase().contains("news")
|| query.to_lowercase().contains("latest")
|| query.to_lowercase().contains("today")
|| query.to_lowercase().contains("recent");
let mut formatted_results = Vec::new();
if is_news_query {
// Use news endpoint which returns excerpts/snippets
let news_results = self
.browser
.news(query, "wt-wt", false, Some(max_results), user_agent)
.await
.context("DuckDuckGo news search failed")?;
for result in news_results {
formatted_results.push(json!({
"title": result.title,
"url": result.url,
"snippet": result.body, // news has body/excerpt
"source": result.source,
"date": result.date
}));
}
} else {
// Use lite search for general queries (fast but no snippets)
let search_results = self
.browser
.lite_search(query, "wt-wt", Some(max_results), user_agent)
.await
.context("DuckDuckGo search failed")?;
for result in search_results {
formatted_results.push(json!({
"title": result.title,
"url": result.url,
"snippet": result.snippet
}));
}
}
let mut result = ToolResult::success(json!({
"query": query,
"results": formatted_results,
"total_found": formatted_results.len()
}));
result.duration = start.elapsed();
Ok(result)
}
}

View File

@@ -0,0 +1,130 @@
use std::sync::{Arc, Mutex};
use std::time::Instant;
use anyhow::{Context, Result};
use async_trait::async_trait;
use serde_json::{json, Value};
use super::{Tool, ToolResult};
use crate::consent::ConsentManager;
use crate::credentials::CredentialManager;
use crate::encryption::VaultHandle;
pub struct WebSearchDetailedTool {
consent_manager: Arc<Mutex<ConsentManager>>,
_credential_manager: Option<Arc<CredentialManager>>,
browser: duckduckgo::browser::Browser,
}
impl WebSearchDetailedTool {
pub fn new(
consent_manager: Arc<Mutex<ConsentManager>>,
credential_manager: Option<Arc<CredentialManager>>,
_vault: Option<Arc<Mutex<VaultHandle>>>,
) -> Self {
// Create a reqwest client compatible with duckduckgo crate (v0.11)
let client = reqwest_011::Client::new();
let browser = duckduckgo::browser::Browser::new(client);
Self {
consent_manager,
_credential_manager: credential_manager,
browser,
}
}
}
#[async_trait]
impl Tool for WebSearchDetailedTool {
fn name(&self) -> &'static str {
"web_search_detailed"
}
fn description(&self) -> &'static str {
"Search for recent articles and web content with detailed snippets and descriptions. \
Returns results with publication dates, sources, and full text excerpts. \
Best for finding recent information, articles, and detailed context about topics."
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"minLength": 1,
"maxLength": 500,
"description": "Search query"
},
"max_results": {
"type": "integer",
"minimum": 1,
"maximum": 10,
"default": 5,
"description": "Maximum number of results"
}
},
"required": ["query"],
"additionalProperties": false
})
}
fn requires_network(&self) -> bool {
true
}
async fn execute(&self, args: Value) -> Result<ToolResult> {
let start = Instant::now();
// Check if consent has been granted (non-blocking check)
// Consent should have been granted via TUI dialog before tool execution
{
let consent = self
.consent_manager
.lock()
.expect("Consent manager mutex poisoned");
if !consent.has_consent(self.name()) {
return Ok(ToolResult::error("Consent not granted for detailed web search. This should have been handled by the TUI."));
}
}
let query = args
.get("query")
.and_then(Value::as_str)
.context("Missing query parameter")?;
let max_results = args.get("max_results").and_then(Value::as_u64).unwrap_or(5) as usize;
let user_agent = duckduckgo::user_agents::get("firefox").unwrap_or(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:91.0) Gecko/20100101 Firefox/91.0",
);
// Use news endpoint which provides detailed results with full snippets
// Even for non-news queries, this often returns recent articles and content with good descriptions
let news_results = self
.browser
.news(query, "wt-wt", false, Some(max_results), user_agent)
.await
.context("DuckDuckGo detailed search failed")?;
let mut formatted_results = Vec::new();
for result in news_results {
formatted_results.push(json!({
"title": result.title,
"url": result.url,
"snippet": result.body, // news endpoint includes full excerpts
"source": result.source,
"date": result.date
}));
}
let mut result = ToolResult::success(json!({
"query": query,
"results": formatted_results,
"total_found": formatted_results.len()
}));
result.duration = start.elapsed();
Ok(result)
}
}

View File

@@ -18,6 +18,9 @@ pub struct Message {
pub metadata: HashMap<String, serde_json::Value>, pub metadata: HashMap<String, serde_json::Value>,
/// Timestamp when the message was created /// Timestamp when the message was created
pub timestamp: std::time::SystemTime, pub timestamp: std::time::SystemTime,
/// Tool calls requested by the assistant
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
} }
/// Role of a message sender /// Role of a message sender
@@ -30,6 +33,19 @@ pub enum Role {
Assistant, Assistant,
/// System message (prompts, context, etc.) /// System message (prompts, context, etc.)
System, System,
/// Tool response message
Tool,
}
/// A tool call requested by the assistant
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ToolCall {
/// Unique identifier for this tool call
pub id: String,
/// Name of the tool to call
pub name: String,
/// Arguments for the tool (JSON object)
pub arguments: serde_json::Value,
} }
impl fmt::Display for Role { impl fmt::Display for Role {
@@ -38,6 +54,7 @@ impl fmt::Display for Role {
Role::User => "user", Role::User => "user",
Role::Assistant => "assistant", Role::Assistant => "assistant",
Role::System => "system", Role::System => "system",
Role::Tool => "tool",
}; };
f.write_str(label) f.write_str(label)
} }
@@ -72,6 +89,9 @@ pub struct ChatRequest {
pub messages: Vec<Message>, pub messages: Vec<Message>,
/// Optional parameters for the request /// Optional parameters for the request
pub parameters: ChatParameters, pub parameters: ChatParameters,
/// Optional tools available for the model to use
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<crate::mcp::McpToolDescriptor>>,
} }
/// Parameters for chat completion /// Parameters for chat completion
@@ -133,6 +153,9 @@ pub struct ModelInfo {
pub context_window: Option<u32>, pub context_window: Option<u32>,
/// Additional capabilities /// Additional capabilities
pub capabilities: Vec<String>, pub capabilities: Vec<String>,
/// Whether this model supports tool/function calling
#[serde(default)]
pub supports_tools: bool,
} }
impl Message { impl Message {
@@ -144,6 +167,7 @@ impl Message {
content, content,
metadata: HashMap::new(), metadata: HashMap::new(),
timestamp: std::time::SystemTime::now(), timestamp: std::time::SystemTime::now(),
tool_calls: None,
} }
} }
@@ -161,6 +185,24 @@ impl Message {
pub fn system(content: String) -> Self { pub fn system(content: String) -> Self {
Self::new(Role::System, content) Self::new(Role::System, content)
} }
/// Create a tool response message
pub fn tool(tool_call_id: String, content: String) -> Self {
let mut msg = Self::new(Role::Tool, content);
msg.metadata.insert(
"tool_call_id".to_string(),
serde_json::Value::String(tool_call_id),
);
msg
}
/// Check if this message has tool calls
pub fn has_tool_calls(&self) -> bool {
self.tool_calls
.as_ref()
.map(|tc| !tc.is_empty())
.unwrap_or(false)
}
} }
impl Conversation { impl Conversation {

View File

@@ -357,8 +357,10 @@ mod tests {
#[test] #[test]
fn test_auto_scroll() { fn test_auto_scroll() {
let mut scroll = AutoScroll::default(); let mut scroll = AutoScroll {
scroll.content_len = 100; content_len: 100,
..Default::default()
};
// Test on_viewport with stick_to_bottom // Test on_viewport with stick_to_bottom
scroll.on_viewport(10); scroll.on_viewport(10);

View File

@@ -0,0 +1,108 @@
use std::collections::HashMap;
use anyhow::{Context, Result};
use jsonschema::{JSONSchema, ValidationError};
use serde_json::{json, Value};
pub struct SchemaValidator {
schemas: HashMap<String, JSONSchema>,
}
impl Default for SchemaValidator {
fn default() -> Self {
Self::new()
}
}
impl SchemaValidator {
pub fn new() -> Self {
Self {
schemas: HashMap::new(),
}
}
pub fn register_schema(&mut self, tool_name: &str, schema: Value) -> Result<()> {
let compiled = JSONSchema::compile(&schema)
.map_err(|e| anyhow::anyhow!("Invalid schema for {}: {}", tool_name, e))?;
self.schemas.insert(tool_name.to_string(), compiled);
Ok(())
}
pub fn validate(&self, tool_name: &str, input: &Value) -> Result<()> {
let schema = self
.schemas
.get(tool_name)
.with_context(|| format!("No schema registered for tool: {}", tool_name))?;
if let Err(errors) = schema.validate(input) {
let error_messages: Vec<String> = errors.map(format_validation_error).collect();
return Err(anyhow::anyhow!(
"Input validation failed for {}: {}",
tool_name,
error_messages.join(", ")
));
}
Ok(())
}
}
fn format_validation_error(error: ValidationError) -> String {
format!("Validation error at {}: {}", error.instance_path, error)
}
pub fn get_builtin_schemas() -> HashMap<String, Value> {
let mut schemas = HashMap::new();
schemas.insert(
"web_search".to_string(),
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"minLength": 1,
"maxLength": 500
},
"max_results": {
"type": "integer",
"minimum": 1,
"maximum": 10,
"default": 5
}
},
"required": ["query"],
"additionalProperties": false
}),
);
schemas.insert(
"code_exec".to_string(),
json!({
"type": "object",
"properties": {
"language": {
"type": "string",
"enum": ["python", "javascript", "bash", "rust"]
},
"code": {
"type": "string",
"minLength": 1,
"maxLength": 10000
},
"timeout": {
"type": "integer",
"minimum": 1,
"maximum": 300,
"default": 30
}
},
"required": ["language", "code"],
"additionalProperties": false
}),
);
schemas
}

View File

@@ -2,7 +2,7 @@
This crate provides an implementation of the `owlen-core::Provider` trait for the [Ollama](https://ollama.ai) backend. This crate provides an implementation of the `owlen-core::Provider` trait for the [Ollama](https://ollama.ai) backend.
It allows Owlen to communicate with a local Ollama instance, sending requests and receiving responses from locally-run large language models. It allows Owlen to communicate with a local Ollama instance, sending requests and receiving responses from locally-run large language models. You can also target [Ollama Cloud](https://docs.ollama.com/cloud) by pointing the provider at `https://ollama.com` (or `https://api.ollama.com`) and providing an API key through your Owlen configuration (or the `OLLAMA_API_KEY` / `OLLAMA_CLOUD_API_KEY` environment variables). The client automatically adds the required Bearer authorization header when a key is supplied, accepts either host without rewriting, and expands inline environment references like `$OLLAMA_API_KEY` if you prefer not to check the secret into your config file. The generated configuration now includes both `providers.ollama` and `providers.ollama-cloud` entries—switch between them by updating `general.default_provider`.
## Configuration ## Configuration

View File

@@ -5,13 +5,16 @@ use owlen_core::{
config::GeneralSettings, config::GeneralSettings,
model::ModelManager, model::ModelManager,
provider::{ChatStream, Provider, ProviderConfig}, provider::{ChatStream, Provider, ProviderConfig},
types::{ChatParameters, ChatRequest, ChatResponse, Message, ModelInfo, Role, TokenUsage}, types::{
ChatParameters, ChatRequest, ChatResponse, Message, ModelInfo, Role, TokenUsage, ToolCall,
},
Result, Result,
}; };
use reqwest::Client; use reqwest::{header, Client, Url};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::collections::HashMap; use std::collections::HashMap;
use std::env;
use std::io; use std::io;
use std::time::Duration; use std::time::Duration;
use tokio::sync::mpsc; use tokio::sync::mpsc;
@@ -20,26 +23,195 @@ use tokio_stream::wrappers::UnboundedReceiverStream;
const DEFAULT_TIMEOUT_SECS: u64 = 120; const DEFAULT_TIMEOUT_SECS: u64 = 120;
const DEFAULT_MODEL_CACHE_TTL_SECS: u64 = 60; const DEFAULT_MODEL_CACHE_TTL_SECS: u64 = 60;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OllamaMode {
Local,
Cloud,
}
impl OllamaMode {
fn from_provider_type(provider_type: &str) -> Self {
if provider_type.eq_ignore_ascii_case("ollama-cloud") {
Self::Cloud
} else {
Self::Local
}
}
fn default_base_url(self) -> &'static str {
match self {
Self::Local => "http://localhost:11434",
Self::Cloud => "https://ollama.com",
}
}
fn default_scheme(self) -> &'static str {
match self {
Self::Local => "http",
Self::Cloud => "https",
}
}
}
fn is_ollama_host(host: &str) -> bool {
host.eq_ignore_ascii_case("ollama.com")
|| host.eq_ignore_ascii_case("www.ollama.com")
|| host.eq_ignore_ascii_case("api.ollama.com")
|| host.ends_with(".ollama.com")
}
fn normalize_base_url(
input: Option<&str>,
mode_hint: OllamaMode,
) -> std::result::Result<String, String> {
let mut candidate = input
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.to_string())
.unwrap_or_else(|| mode_hint.default_base_url().to_string());
if !candidate.contains("://") {
candidate = format!("{}://{}", mode_hint.default_scheme(), candidate);
}
let mut url =
Url::parse(&candidate).map_err(|err| format!("Invalid base_url '{candidate}': {err}"))?;
let mut is_cloud = matches!(mode_hint, OllamaMode::Cloud);
if let Some(host) = url.host_str() {
if is_ollama_host(host) {
is_cloud = true;
}
}
if is_cloud {
if url.scheme() != "https" {
url.set_scheme("https")
.map_err(|_| "Ollama Cloud requires an https URL".to_string())?;
}
match url.host_str() {
Some(host) => {
if host.eq_ignore_ascii_case("www.ollama.com") {
url.set_host(Some("ollama.com"))
.map_err(|_| "Failed to normalize Ollama Cloud host".to_string())?;
}
}
None => {
return Err("Ollama Cloud base_url must include a hostname".to_string());
}
}
}
// Remove trailing slash and discard query/fragment segments
let current_path = url.path().to_string();
let trimmed_path = current_path.trim_end_matches('/');
if trimmed_path.is_empty() {
url.set_path("");
} else {
url.set_path(trimmed_path);
}
url.set_query(None);
url.set_fragment(None);
Ok(url.to_string().trim_end_matches('/').to_string())
}
fn build_api_endpoint(base_url: &str, endpoint: &str) -> String {
let trimmed_base = base_url.trim_end_matches('/');
let trimmed_endpoint = endpoint.trim_start_matches('/');
if trimmed_base.ends_with("/api") {
format!("{trimmed_base}/{trimmed_endpoint}")
} else {
format!("{trimmed_base}/api/{trimmed_endpoint}")
}
}
fn env_var_non_empty(name: &str) -> Option<String> {
env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn resolve_api_key(configured: Option<String>) -> Option<String> {
let raw = configured?.trim().to_string();
if raw.is_empty() {
return None;
}
if let Some(variable) = raw
.strip_prefix("${")
.and_then(|value| value.strip_suffix('}'))
.or_else(|| raw.strip_prefix('$'))
{
let var_name = variable.trim();
if var_name.is_empty() {
return None;
}
return env_var_non_empty(var_name);
}
Some(raw)
}
fn debug_requests_enabled() -> bool {
std::env::var("OWLEN_DEBUG_OLLAMA")
.ok()
.map(|value| {
matches!(
value.trim(),
"1" | "true" | "TRUE" | "True" | "yes" | "YES" | "Yes"
)
})
.unwrap_or(false)
}
fn mask_token(token: &str) -> String {
if token.len() <= 8 {
return "***".to_string();
}
let head = &token[..4];
let tail = &token[token.len() - 4..];
format!("{head}***{tail}")
}
fn mask_authorization(value: &str) -> String {
if let Some(token) = value.strip_prefix("Bearer ") {
format!("Bearer {}", mask_token(token))
} else {
"***".to_string()
}
}
/// Ollama provider implementation with enhanced configuration and caching /// Ollama provider implementation with enhanced configuration and caching
#[derive(Debug)]
pub struct OllamaProvider { pub struct OllamaProvider {
client: Client, client: Client,
base_url: String, base_url: String,
api_key: Option<String>,
model_manager: ModelManager, model_manager: ModelManager,
} }
/// Options for configuring the Ollama provider /// Options for configuring the Ollama provider
pub struct OllamaOptions { pub(crate) struct OllamaOptions {
pub base_url: String, base_url: String,
pub request_timeout: Duration, request_timeout: Duration,
pub model_cache_ttl: Duration, model_cache_ttl: Duration,
api_key: Option<String>,
} }
impl OllamaOptions { impl OllamaOptions {
pub fn new(base_url: impl Into<String>) -> Self { pub(crate) fn new(base_url: impl Into<String>) -> Self {
Self { Self {
base_url: base_url.into(), base_url: base_url.into(),
request_timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS), request_timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
model_cache_ttl: Duration::from_secs(DEFAULT_MODEL_CACHE_TTL_SECS), model_cache_ttl: Duration::from_secs(DEFAULT_MODEL_CACHE_TTL_SECS),
api_key: None,
} }
} }
@@ -54,6 +226,20 @@ impl OllamaOptions {
struct OllamaMessage { struct OllamaMessage {
role: String, role: String,
content: String, content: String,
#[serde(skip_serializing_if = "Option::is_none")]
tool_calls: Option<Vec<OllamaToolCall>>,
}
/// Ollama tool call format
#[derive(Debug, Clone, Serialize, Deserialize)]
struct OllamaToolCall {
function: OllamaToolCallFunction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct OllamaToolCallFunction {
name: String,
arguments: serde_json::Value,
} }
/// Ollama chat request format /// Ollama chat request format
@@ -62,10 +248,27 @@ struct OllamaChatRequest {
model: String, model: String,
messages: Vec<OllamaMessage>, messages: Vec<OllamaMessage>,
stream: bool, stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<OllamaTool>>,
#[serde(flatten)] #[serde(flatten)]
options: HashMap<String, Value>, options: HashMap<String, Value>,
} }
/// Ollama tool definition
#[derive(Debug, Clone, Serialize, Deserialize)]
struct OllamaTool {
#[serde(rename = "type")]
tool_type: String,
function: OllamaToolFunction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct OllamaToolFunction {
name: String,
description: String,
parameters: serde_json::Value,
}
/// Ollama chat response format /// Ollama chat response format
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct OllamaChatResponse { struct OllamaChatResponse {
@@ -107,17 +310,60 @@ struct OllamaModelDetails {
impl OllamaProvider { impl OllamaProvider {
/// Create a new Ollama provider with sensible defaults /// Create a new Ollama provider with sensible defaults
pub fn new(base_url: impl Into<String>) -> Result<Self> { pub fn new(base_url: impl Into<String>) -> Result<Self> {
Self::with_options(OllamaOptions::new(base_url)) let mode = OllamaMode::Local;
let supplied = base_url.into();
let normalized =
normalize_base_url(Some(&supplied), mode).map_err(owlen_core::Error::Config)?;
Self::with_options(OllamaOptions::new(normalized))
}
fn debug_log_request(&self, label: &str, request: &reqwest::Request, body_json: Option<&str>) {
if !debug_requests_enabled() {
return;
}
eprintln!("--- OWLEN Ollama request ({label}) ---");
eprintln!("{} {}", request.method(), request.url());
match request
.headers()
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
{
Some(value) => eprintln!("Authorization: {}", mask_authorization(value)),
None => eprintln!("Authorization: <none>"),
}
if let Some(body) = body_json {
eprintln!("Body:\n{body}");
}
eprintln!("---------------------------------------");
}
/// Convert MCP tool descriptors to Ollama tool format
fn convert_tools_to_ollama(tools: &[owlen_core::mcp::McpToolDescriptor]) -> Vec<OllamaTool> {
tools
.iter()
.map(|tool| OllamaTool {
tool_type: "function".to_string(),
function: OllamaToolFunction {
name: tool.name.clone(),
description: tool.description.clone(),
parameters: tool.input_schema.clone(),
},
})
.collect()
} }
/// Create a provider from configuration settings /// Create a provider from configuration settings
pub fn from_config(config: &ProviderConfig, general: Option<&GeneralSettings>) -> Result<Self> { pub fn from_config(config: &ProviderConfig, general: Option<&GeneralSettings>) -> Result<Self> {
let mut options = OllamaOptions::new( let mode = OllamaMode::from_provider_type(&config.provider_type);
config let normalized_base_url = normalize_base_url(config.base_url.as_deref(), mode)
.base_url .map_err(owlen_core::Error::Config)?;
.clone()
.unwrap_or_else(|| "http://localhost:11434".to_string()), let mut options = OllamaOptions::new(normalized_base_url);
);
if let Some(timeout) = config if let Some(timeout) = config
.extra .extra
@@ -135,6 +381,10 @@ impl OllamaProvider {
options.model_cache_ttl = Duration::from_secs(cache_ttl.max(5)); options.model_cache_ttl = Duration::from_secs(cache_ttl.max(5));
} }
options.api_key = resolve_api_key(config.api_key.clone())
.or_else(|| env_var_non_empty("OLLAMA_API_KEY"))
.or_else(|| env_var_non_empty("OLLAMA_CLOUD_API_KEY"));
if let Some(general) = general { if let Some(general) = general {
options = options.with_general(general); options = options.with_general(general);
} }
@@ -143,16 +393,24 @@ impl OllamaProvider {
} }
/// Create a provider from explicit options /// Create a provider from explicit options
pub fn with_options(options: OllamaOptions) -> Result<Self> { pub(crate) fn with_options(options: OllamaOptions) -> Result<Self> {
let OllamaOptions {
base_url,
request_timeout,
model_cache_ttl,
api_key,
} = options;
let client = Client::builder() let client = Client::builder()
.timeout(options.request_timeout) .timeout(request_timeout)
.build() .build()
.map_err(|e| owlen_core::Error::Config(format!("Failed to build HTTP client: {e}")))?; .map_err(|e| owlen_core::Error::Config(format!("Failed to build HTTP client: {e}")))?;
Ok(Self { Ok(Self {
client, client,
base_url: options.base_url.trim_end_matches('/').to_string(), base_url: base_url.trim_end_matches('/').to_string(),
model_manager: ModelManager::new(options.model_cache_ttl), api_key,
model_manager: ModelManager::new(model_cache_ttl),
}) })
} }
@@ -161,14 +419,42 @@ impl OllamaProvider {
&self.model_manager &self.model_manager
} }
fn api_url(&self, endpoint: &str) -> String {
build_api_endpoint(&self.base_url, endpoint)
}
fn apply_auth(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some(api_key) = &self.api_key {
request.bearer_auth(api_key)
} else {
request
}
}
fn convert_message(message: &Message) -> OllamaMessage { fn convert_message(message: &Message) -> OllamaMessage {
let role = match message.role {
Role::User => "user".to_string(),
Role::Assistant => "assistant".to_string(),
Role::System => "system".to_string(),
Role::Tool => "tool".to_string(),
};
let tool_calls = message.tool_calls.as_ref().map(|calls| {
calls
.iter()
.map(|tc| OllamaToolCall {
function: OllamaToolCallFunction {
name: tc.name.clone(),
arguments: tc.arguments.clone(),
},
})
.collect()
});
OllamaMessage { OllamaMessage {
role: match message.role { role,
Role::User => "user".to_string(),
Role::Assistant => "assistant".to_string(),
Role::System => "system".to_string(),
},
content: message.content.clone(), content: message.content.clone(),
tool_calls,
} }
} }
@@ -177,10 +463,27 @@ impl OllamaProvider {
"user" => Role::User, "user" => Role::User,
"assistant" => Role::Assistant, "assistant" => Role::Assistant,
"system" => Role::System, "system" => Role::System,
"tool" => Role::Tool,
_ => Role::Assistant, _ => Role::Assistant,
}; };
Message::new(role, message.content.clone()) let mut msg = Message::new(role, message.content.clone());
// Convert tool calls if present
if let Some(ollama_tool_calls) = &message.tool_calls {
let tool_calls: Vec<ToolCall> = ollama_tool_calls
.iter()
.enumerate()
.map(|(idx, tc)| ToolCall {
id: format!("call_{}", idx),
name: tc.function.name.clone(),
arguments: tc.function.arguments.clone(),
})
.collect();
msg.tool_calls = Some(tool_calls);
}
msg
} }
fn build_options(parameters: ChatParameters) -> HashMap<String, Value> { fn build_options(parameters: ChatParameters) -> HashMap<String, Value> {
@@ -202,11 +505,10 @@ impl OllamaProvider {
} }
async fn fetch_models(&self) -> Result<Vec<ModelInfo>> { async fn fetch_models(&self) -> Result<Vec<ModelInfo>> {
let url = format!("{}/api/tags", self.base_url); let url = self.api_url("tags");
let response = self let response = self
.client .apply_auth(self.client.get(&url))
.get(&url)
.send() .send()
.await .await
.map_err(|e| owlen_core::Error::Network(format!("Failed to fetch models: {e}")))?; .map_err(|e| owlen_core::Error::Network(format!("Failed to fetch models: {e}")))?;
@@ -229,21 +531,51 @@ impl OllamaProvider {
let models = ollama_response let models = ollama_response
.models .models
.into_iter() .into_iter()
.map(|model| ModelInfo { .map(|model| {
id: model.name.clone(), // Check if model supports tool calling based on known models
name: model.name.clone(), let supports_tools = Self::check_tool_support(&model.name);
description: model
.details ModelInfo {
.as_ref() id: model.name.clone(),
.and_then(|d| d.family.as_ref().map(|f| format!("Ollama {f} model"))), name: model.name.clone(),
provider: "ollama".to_string(), description: model
context_window: None, .details
capabilities: vec!["chat".to_string()], .as_ref()
.and_then(|d| d.family.as_ref().map(|f| format!("Ollama {f} model"))),
provider: "ollama".to_string(),
context_window: None,
capabilities: vec!["chat".to_string()],
supports_tools,
}
}) })
.collect(); .collect();
Ok(models) Ok(models)
} }
/// Check if a model supports tool calling based on its name
fn check_tool_support(model_name: &str) -> bool {
let name_lower = model_name.to_lowercase();
// Known models with tool calling support
let tool_supporting_models = [
"qwen",
"llama3.1",
"llama3.2",
"llama3.3",
"mistral-nemo",
"mistral:7b-instruct",
"command-r",
"firefunction",
"hermes",
"nexusraven",
"granite-code",
];
tool_supporting_models
.iter()
.any(|&supported| name_lower.contains(supported))
}
} }
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -263,25 +595,42 @@ impl Provider for OllamaProvider {
model, model,
messages, messages,
parameters, parameters,
tools,
} = request; } = request;
let messages: Vec<OllamaMessage> = messages.iter().map(Self::convert_message).collect(); let messages: Vec<OllamaMessage> = messages.iter().map(Self::convert_message).collect();
let options = Self::build_options(parameters); let options = Self::build_options(parameters);
let ollama_tools = tools.as_ref().map(|t| Self::convert_tools_to_ollama(t));
let ollama_request = OllamaChatRequest { let ollama_request = OllamaChatRequest {
model, model,
messages, messages,
stream: false, stream: false,
tools: ollama_tools,
options, options,
}; };
let url = format!("{}/api/chat", self.base_url); let url = self.api_url("chat");
let debug_body = if debug_requests_enabled() {
serde_json::to_string_pretty(&ollama_request).ok()
} else {
None
};
let mut request_builder = self.client.post(&url).json(&ollama_request);
request_builder = self.apply_auth(request_builder);
let request = request_builder.build().map_err(|e| {
owlen_core::Error::Network(format!("Failed to build chat request: {e}"))
})?;
self.debug_log_request("chat", &request, debug_body.as_deref());
let response = self let response = self
.client .client
.post(&url) .execute(request)
.json(&ollama_request)
.send()
.await .await
.map_err(|e| owlen_core::Error::Network(format!("Chat request failed: {e}")))?; .map_err(|e| owlen_core::Error::Network(format!("Chat request failed: {e}")))?;
@@ -339,28 +688,43 @@ impl Provider for OllamaProvider {
model, model,
messages, messages,
parameters, parameters,
tools,
} = request; } = request;
let messages: Vec<OllamaMessage> = messages.iter().map(Self::convert_message).collect(); let messages: Vec<OllamaMessage> = messages.iter().map(Self::convert_message).collect();
let options = Self::build_options(parameters); let options = Self::build_options(parameters);
let ollama_tools = tools.as_ref().map(|t| Self::convert_tools_to_ollama(t));
let ollama_request = OllamaChatRequest { let ollama_request = OllamaChatRequest {
model, model,
messages, messages,
stream: true, stream: true,
tools: ollama_tools,
options, options,
}; };
let url = format!("{}/api/chat", self.base_url); let url = self.api_url("chat");
let debug_body = if debug_requests_enabled() {
serde_json::to_string_pretty(&ollama_request).ok()
} else {
None
};
let response = self let mut request_builder = self.client.post(&url).json(&ollama_request);
.client request_builder = self.apply_auth(request_builder);
.post(&url)
.json(&ollama_request) let request = request_builder.build().map_err(|e| {
.send() owlen_core::Error::Network(format!("Failed to build streaming request: {e}"))
.await })?;
.map_err(|e| owlen_core::Error::Network(format!("Streaming request failed: {e}")))?;
self.debug_log_request("chat_stream", &request, debug_body.as_deref());
let response =
self.client.execute(request).await.map_err(|e| {
owlen_core::Error::Network(format!("Streaming request failed: {e}"))
})?;
if !response.status().is_success() { if !response.status().is_success() {
let code = response.status(); let code = response.status();
@@ -462,11 +826,10 @@ impl Provider for OllamaProvider {
} }
async fn health_check(&self) -> Result<()> { async fn health_check(&self) -> Result<()> {
let url = format!("{}/api/version", self.base_url); let url = self.api_url("version");
let response = self let response = self
.client .apply_auth(self.client.get(&url))
.get(&url)
.send() .send()
.await .await
.map_err(|e| owlen_core::Error::Network(format!("Health check failed: {e}")))?; .map_err(|e| owlen_core::Error::Network(format!("Health check failed: {e}")))?;
@@ -528,3 +891,86 @@ async fn parse_error_body(response: reqwest::Response) -> String {
Err(_) => "unknown error".to_string(), Err(_) => "unknown error".to_string(),
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalizes_local_base_url_and_infers_scheme() {
let normalized =
normalize_base_url(Some("localhost:11434"), OllamaMode::Local).expect("valid URL");
assert_eq!(normalized, "http://localhost:11434");
}
#[test]
fn normalizes_cloud_base_url_and_host() {
let normalized =
normalize_base_url(Some("https://ollama.com"), OllamaMode::Cloud).expect("valid URL");
assert_eq!(normalized, "https://ollama.com");
}
#[test]
fn infers_scheme_for_cloud_hosts() {
let normalized =
normalize_base_url(Some("ollama.com"), OllamaMode::Cloud).expect("valid URL");
assert_eq!(normalized, "https://ollama.com");
}
#[test]
fn rewrites_www_cloud_host() {
let normalized = normalize_base_url(Some("https://www.ollama.com"), OllamaMode::Cloud)
.expect("valid URL");
assert_eq!(normalized, "https://ollama.com");
}
#[test]
fn retains_explicit_api_suffix() {
let normalized = normalize_base_url(Some("https://api.ollama.com/api"), OllamaMode::Cloud)
.expect("valid URL");
assert_eq!(normalized, "https://api.ollama.com/api");
}
#[test]
fn builds_api_endpoint_without_duplicate_segments() {
let base = "http://localhost:11434";
assert_eq!(
build_api_endpoint(base, "chat"),
"http://localhost:11434/api/chat"
);
let base_with_api = "http://localhost:11434/api";
assert_eq!(
build_api_endpoint(base_with_api, "chat"),
"http://localhost:11434/api/chat"
);
}
#[test]
fn resolve_api_key_prefers_literal_value() {
assert_eq!(
resolve_api_key(Some("direct-key".into())),
Some("direct-key".into())
);
}
#[test]
fn resolve_api_key_expands_braced_env_reference() {
std::env::set_var("OWLEN_TEST_KEY", "super-secret");
assert_eq!(
resolve_api_key(Some("${OWLEN_TEST_KEY}".into())),
Some("super-secret".into())
);
std::env::remove_var("OWLEN_TEST_KEY");
}
#[test]
fn resolve_api_key_expands_unbraced_env_reference() {
std::env::set_var("OWLEN_TEST_KEY", "another-secret");
assert_eq!(
resolve_api_key(Some("$OWLEN_TEST_KEY".into())),
Some("another-secret".into())
);
std::env::remove_var("OWLEN_TEST_KEY");
}
}

View File

@@ -10,6 +10,7 @@ description = "Terminal User Interface for OWLEN LLM client"
[dependencies] [dependencies]
owlen-core = { path = "../owlen-core" } owlen-core = { path = "../owlen-core" }
owlen-ollama = { path = "../owlen-ollama" }
# TUI framework # TUI framework
ratatui = { workspace = true } ratatui = { workspace = true }
@@ -26,6 +27,7 @@ futures-util = { workspace = true }
# Utilities # Utilities
anyhow = { workspace = true } anyhow = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
serde_json.workspace = true
[dev-dependencies] [dev-dependencies]
tokio-test = { workspace = true } tokio-test = { workspace = true }

File diff suppressed because it is too large Load Diff

View File

@@ -14,12 +14,14 @@ pub struct CodeApp {
} }
impl CodeApp { impl CodeApp {
pub fn new(mut controller: SessionController) -> (Self, mpsc::UnboundedReceiver<SessionEvent>) { pub async fn new(
mut controller: SessionController,
) -> Result<(Self, mpsc::UnboundedReceiver<SessionEvent>)> {
controller controller
.conversation_mut() .conversation_mut()
.push_system_message(DEFAULT_SYSTEM_PROMPT.to_string()); .push_system_message(DEFAULT_SYSTEM_PROMPT.to_string());
let (inner, rx) = ChatApp::new(controller); let (inner, rx) = ChatApp::new(controller).await?;
(Self { inner }, rx) Ok((Self { inner }, rx))
} }
pub async fn handle_event(&mut self, event: Event) -> Result<AppState> { pub async fn handle_event(&mut self, event: Event) -> Result<AppState> {

View File

@@ -1,6 +1,6 @@
pub use owlen_core::config::{ pub use owlen_core::config::{
default_config_path, ensure_ollama_config, session_timeout, Config, GeneralSettings, default_config_path, ensure_ollama_config, ensure_provider_config, session_timeout, Config,
InputSettings, StorageSettings, UiSettings, DEFAULT_CONFIG_PATH, GeneralSettings, InputSettings, StorageSettings, UiSettings, DEFAULT_CONFIG_PATH,
}; };
/// Attempt to load configuration from default location /// Attempt to load configuration from default location

View File

@@ -3,14 +3,17 @@ use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
use ratatui::Frame; use ratatui::Frame;
use serde_json;
use textwrap::{wrap, Options}; use textwrap::{wrap, Options};
use tui_textarea::TextArea; use tui_textarea::TextArea;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
use crate::chat_app::ChatApp; use crate::chat_app::{ChatApp, ModelSelectorItemKind, HELP_TAB_COUNT};
use owlen_core::types::Role; use owlen_core::types::Role;
use owlen_core::ui::{FocusedPanel, InputMode}; use owlen_core::ui::{FocusedPanel, InputMode};
const PRIVACY_TAB_INDEX: usize = HELP_TAB_COUNT - 1;
pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
// Update thinking content from last message // Update thinking content from last message
app.update_thinking_from_last_message(); app.update_thinking_from_last_message();
@@ -82,14 +85,19 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
render_status(frame, layout[idx], app); render_status(frame, layout[idx], app);
match app.mode() { // Render consent dialog with highest priority (always on top)
InputMode::ProviderSelection => render_provider_selector(frame, app), if app.has_pending_consent() {
InputMode::ModelSelection => render_model_selector(frame, app), render_consent_dialog(frame, app);
InputMode::Help => render_help(frame, app), } else {
InputMode::SessionBrowser => render_session_browser(frame, app), match app.mode() {
InputMode::ThemeBrowser => render_theme_browser(frame, app), InputMode::ProviderSelection => render_provider_selector(frame, app),
InputMode::Command => render_command_suggestions(frame, app), InputMode::ModelSelection => render_model_selector(frame, app),
_ => {} InputMode::Help => render_help(frame, app),
InputMode::SessionBrowser => render_session_browser(frame, app),
InputMode::ThemeBrowser => render_theme_browser(frame, app),
InputMode::Command => render_command_suggestions(frame, app),
_ => {}
}
} }
} }
@@ -600,12 +608,16 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
Role::User => ("👤 ", "You: "), Role::User => ("👤 ", "You: "),
Role::Assistant => ("🤖 ", "Assistant: "), Role::Assistant => ("🤖 ", "Assistant: "),
Role::System => ("⚙️ ", "System: "), Role::System => ("⚙️ ", "System: "),
Role::Tool => ("🔧 ", "Tool: "),
}; };
// Extract content without thinking tags for assistant messages // Extract content without thinking tags for assistant messages
let content_to_display = if matches!(role, Role::Assistant) { let content_to_display = if matches!(role, Role::Assistant) {
let (content_without_think, _) = formatter.extract_thinking(&message.content); let (content_without_think, _) = formatter.extract_thinking(&message.content);
content_without_think content_without_think
} else if matches!(role, Role::Tool) {
// Format tool results nicely
format_tool_output(&message.content)
} else { } else {
message.content.clone() message.content.clone()
}; };
@@ -1102,20 +1114,49 @@ fn render_model_selector(frame: &mut Frame<'_>, app: &ChatApp) {
frame.render_widget(Clear, area); frame.render_widget(Clear, area);
let items: Vec<ListItem> = app let items: Vec<ListItem> = app
.models() .model_selector_items()
.iter() .iter()
.map(|model| { .map(|item| match item.kind() {
let label = if model.name.is_empty() { ModelSelectorItemKind::Header { provider, expanded } => {
model.id.clone() let marker = if *expanded { "" } else { "" };
} else { let label = format!("{} {}", marker, provider);
format!("{}{}", model.id, model.name) ListItem::new(Span::styled(
}; label,
ListItem::new(Span::styled( Style::default()
label, .fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
))
}
ModelSelectorItemKind::Model {
provider: _,
model_index,
} => {
if let Some(model) = app.model_info_by_index(*model_index) {
let tool_indicator = if model.supports_tools { "🔧 " } else { " " };
let label = if model.name.is_empty() {
format!(" {}{}", tool_indicator, model.id)
} else {
format!(" {}{}{}", tool_indicator, model.id, model.name)
};
ListItem::new(Span::styled(
label,
Style::default()
.fg(theme.user_message_role)
.add_modifier(Modifier::BOLD),
))
} else {
ListItem::new(Span::styled(
" <model unavailable>",
Style::default().fg(theme.error),
))
}
}
ModelSelectorItemKind::Empty { provider } => ListItem::new(Span::styled(
format!(" (no models configured for {provider})"),
Style::default() Style::default()
.fg(theme.user_message_role) .fg(theme.unfocused_panel_border)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::ITALIC),
)) )),
}) })
.collect(); .collect();
@@ -1123,7 +1164,7 @@ fn render_model_selector(frame: &mut Frame<'_>, app: &ChatApp) {
.block( .block(
Block::default() Block::default()
.title(Span::styled( .title(Span::styled(
format!("Select Model ({})", app.selected_provider), "Select Model — 🔧 = Tool Support",
Style::default() Style::default()
.fg(theme.focused_panel_border) .fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
@@ -1139,10 +1180,193 @@ fn render_model_selector(frame: &mut Frame<'_>, app: &ChatApp) {
.highlight_symbol(""); .highlight_symbol("");
let mut state = ListState::default(); let mut state = ListState::default();
state.select(app.selected_model_index()); state.select(app.selected_model_item());
frame.render_stateful_widget(list, area, &mut state); frame.render_stateful_widget(list, area, &mut state);
} }
fn render_consent_dialog(frame: &mut Frame<'_>, app: &ChatApp) {
let theme = app.theme();
// Get consent dialog state
let consent_state = match app.consent_dialog() {
Some(state) => state,
None => return,
};
// Create centered modal area
let area = centered_rect(70, 50, frame.area());
frame.render_widget(Clear, area);
// Build consent dialog content
let mut lines = vec![
Line::from(vec![
Span::styled("🔒 ", Style::default().fg(theme.focused_panel_border)),
Span::styled(
"Consent Required",
Style::default()
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(""),
Line::from(vec![
Span::styled("Tool: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(
consent_state.tool_name.clone(),
Style::default().fg(theme.user_message_role),
),
]),
Line::from(""),
];
// Add data types if any
if !consent_state.data_types.is_empty() {
lines.push(Line::from(Span::styled(
"Data Access:",
Style::default().add_modifier(Modifier::BOLD),
)));
for data_type in &consent_state.data_types {
lines.push(Line::from(vec![
Span::raw(""),
Span::styled(data_type, Style::default().fg(theme.text)),
]));
}
lines.push(Line::from(""));
}
// Add endpoints if any
if !consent_state.endpoints.is_empty() {
lines.push(Line::from(Span::styled(
"Endpoints:",
Style::default().add_modifier(Modifier::BOLD),
)));
for endpoint in &consent_state.endpoints {
lines.push(Line::from(vec![
Span::raw(""),
Span::styled(endpoint, Style::default().fg(theme.text)),
]));
}
lines.push(Line::from(""));
}
// Add prompt
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"Allow this tool to execute?",
Style::default()
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
)]));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(
"[Y] ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw("Allow "),
Span::styled(
"[N] ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::raw("Deny "),
Span::styled(
"[Esc] ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw("Cancel"),
]));
let paragraph = Paragraph::new(lines)
.block(
Block::default()
.title(Span::styled(
" Consent Dialog ",
Style::default()
.fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.focused_panel_border))
.style(Style::default().bg(theme.background)),
)
.alignment(Alignment::Left)
.wrap(Wrap { trim: true });
frame.render_widget(paragraph, area);
}
fn render_privacy_settings(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
let theme = app.theme();
let config = app.config();
let block = Block::default()
.title("Privacy Settings")
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.unfocused_panel_border))
.style(Style::default().bg(theme.background).fg(theme.text));
let inner = block.inner(area);
frame.render_widget(block, area);
let remote_search_enabled =
config.privacy.enable_remote_search && config.tools.web_search.enabled;
let code_exec_enabled = config.tools.code_exec.enabled;
let history_days = config.privacy.retain_history_days;
let cache_results = config.privacy.cache_web_results;
let consent_required = config.privacy.require_consent_per_session;
let encryption_enabled = config.privacy.encrypt_local_data;
let status_line = |label: &str, enabled: bool| {
let status_text = if enabled { "Enabled" } else { "Disabled" };
let status_style = if enabled {
Style::default().fg(theme.selection_fg)
} else {
Style::default().fg(theme.error)
};
Line::from(vec![
Span::raw(format!(" {label}: ")),
Span::styled(status_text, status_style),
])
};
let mut lines = Vec::new();
lines.push(Line::from(vec![Span::styled(
"Privacy Configuration",
Style::default().fg(theme.info).add_modifier(Modifier::BOLD),
)]));
lines.push(Line::raw(""));
lines.push(Line::from("Network Access:"));
lines.push(status_line("Web Search", remote_search_enabled));
lines.push(status_line("Code Execution", code_exec_enabled));
lines.push(Line::raw(""));
lines.push(Line::from("Data Retention:"));
lines.push(Line::from(format!(
" History retention: {} day(s)",
history_days
)));
lines.push(Line::from(format!(
" Cache web results: {}",
if cache_results { "Yes" } else { "No" }
)));
lines.push(Line::raw(""));
lines.push(Line::from("Safeguards:"));
lines.push(status_line("Consent required", consent_required));
lines.push(status_line("Encrypted storage", encryption_enabled));
lines.push(Line::raw(""));
lines.push(Line::from("Commands:"));
lines.push(Line::from(" :privacy-enable <tool> - Enable tool"));
lines.push(Line::from(" :privacy-disable <tool> - Disable tool"));
lines.push(Line::from(" :privacy-clear - Clear all data"));
let paragraph = Paragraph::new(lines)
.wrap(Wrap { trim: true })
.style(Style::default().bg(theme.background).fg(theme.text));
frame.render_widget(paragraph, inner);
}
fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
let theme = app.theme(); let theme = app.theme();
let area = centered_rect(75, 70, frame.area()); let area = centered_rect(75, 70, frame.area());
@@ -1156,6 +1380,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
"Commands", "Commands",
"Sessions", "Sessions",
"Browsers", "Browsers",
"Privacy",
]; ];
// Build tab line // Build tab line
@@ -1429,6 +1654,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
Line::from(" g / Home → jump to top"), Line::from(" g / Home → jump to top"),
Line::from(" G / End → jump to bottom"), Line::from(" G / End → jump to bottom"),
], ],
6 => vec![],
_ => vec![], _ => vec![],
}; };
@@ -1454,14 +1680,18 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
frame.render_widget(tabs_para, layout[0]); frame.render_widget(tabs_para, layout[0]);
// Render content // Render content
let content_block = Block::default() if tab_index == PRIVACY_TAB_INDEX {
.borders(Borders::LEFT | Borders::RIGHT) render_privacy_settings(frame, layout[1], app);
.border_style(Style::default().fg(theme.unfocused_panel_border)) } else {
.style(Style::default().bg(theme.background).fg(theme.text)); let content_block = Block::default()
let content_para = Paragraph::new(help_text) .borders(Borders::LEFT | Borders::RIGHT)
.style(Style::default().bg(theme.background).fg(theme.text)) .border_style(Style::default().fg(theme.unfocused_panel_border))
.block(content_block); .style(Style::default().bg(theme.background).fg(theme.text));
frame.render_widget(content_para, layout[1]); let content_para = Paragraph::new(help_text)
.style(Style::default().bg(theme.background).fg(theme.text))
.block(content_block);
frame.render_widget(content_para, layout[1]);
}
// Render navigation hint // Render navigation hint
let nav_hint = Line::from(vec![ let nav_hint = Line::from(vec![
@@ -1474,7 +1704,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
), ),
Span::raw(":Switch "), Span::raw(":Switch "),
Span::styled( Span::styled(
"1-6", format!("1-{}", HELP_TAB_COUNT),
Style::default() Style::default()
.fg(theme.focused_panel_border) .fg(theme.focused_panel_border)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
@@ -1846,5 +2076,96 @@ fn role_color(role: &Role, theme: &owlen_core::theme::Theme) -> Style {
Role::User => Style::default().fg(theme.user_message_role), Role::User => Style::default().fg(theme.user_message_role),
Role::Assistant => Style::default().fg(theme.assistant_message_role), Role::Assistant => Style::default().fg(theme.assistant_message_role),
Role::System => Style::default().fg(theme.info), Role::System => Style::default().fg(theme.info),
Role::Tool => Style::default().fg(theme.info),
}
}
/// Format tool output JSON into a nice human-readable format
fn format_tool_output(content: &str) -> String {
// Try to parse as JSON
if let Ok(json) = serde_json::from_str::<serde_json::Value>(content) {
let mut output = String::new();
// Extract query if present
if let Some(query) = json.get("query").and_then(|v| v.as_str()) {
output.push_str(&format!("Query: \"{}\"\n\n", query));
}
// Extract results array
if let Some(results) = json.get("results").and_then(|v| v.as_array()) {
if results.is_empty() {
output.push_str("No results found");
return output;
}
for (i, result) in results.iter().enumerate() {
// Title
if let Some(title) = result.get("title").and_then(|v| v.as_str()) {
// Strip HTML tags from title
let clean_title = title.replace("<b>", "").replace("</b>", "");
output.push_str(&format!("{}. {}\n", i + 1, clean_title));
}
// Source and date (if available)
let mut meta = Vec::new();
if let Some(source) = result.get("source").and_then(|v| v.as_str()) {
meta.push(format!("📰 {}", source));
}
if let Some(date) = result.get("date").and_then(|v| v.as_str()) {
// Simplify date format
if let Some(simple_date) = date.split('T').next() {
meta.push(format!("📅 {}", simple_date));
}
}
if !meta.is_empty() {
output.push_str(&format!(" {}\n", meta.join("")));
}
// Snippet (truncated if too long)
if let Some(snippet) = result.get("snippet").and_then(|v| v.as_str()) {
if !snippet.is_empty() {
// Strip HTML tags
let clean_snippet = snippet
.replace("<b>", "")
.replace("</b>", "")
.replace("&#x27;", "'")
.replace("&quot;", "\"");
// Truncate if too long
let truncated = if clean_snippet.len() > 200 {
format!("{}...", &clean_snippet[..197])
} else {
clean_snippet
};
output.push_str(&format!(" {}\n", truncated));
}
}
// URL (shortened if too long)
if let Some(url) = result.get("url").and_then(|v| v.as_str()) {
let display_url = if url.len() > 80 {
format!("{}...", &url[..77])
} else {
url.to_string()
};
output.push_str(&format!(" 🔗 {}\n", display_url));
}
output.push('\n');
}
// Add total count
if let Some(total) = json.get("total_found").and_then(|v| v.as_u64()) {
output.push_str(&format!("Found {} result(s)", total));
}
} else if let Some(error) = json.get("error").and_then(|v| v.as_str()) {
// Handle error results
output.push_str(&format!("❌ Error: {}", error));
}
output
} else {
// If not JSON, return as-is
content.to_string()
} }
} }

View File

@@ -96,17 +96,22 @@ These settings control the behavior of the text input area.
## Provider Settings (`[providers]`) ## Provider Settings (`[providers]`)
This section contains a table for each provider you want to configure. The key is the provider name (e.g., `ollama`). This section contains a table for each provider you want to configure. Owlen ships with two entries pre-populated: `ollama` for a local daemon and `ollama-cloud` for the hosted API. You can switch between them by changing `general.default_provider`.
```toml ```toml
[providers.ollama] [providers.ollama]
provider_type = "ollama" provider_type = "ollama"
base_url = "http://localhost:11434" base_url = "http://localhost:11434"
# api_key = "..." # api_key = "..."
[providers.ollama-cloud]
provider_type = "ollama-cloud"
base_url = "https://ollama.com"
# api_key = "${OLLAMA_API_KEY}"
``` ```
- `provider_type` (string, required) - `provider_type` (string, required)
The type of the provider. Currently, only `"ollama"` is built-in. The type of the provider. The built-in options are `"ollama"` (local daemon) and `"ollama-cloud"` (hosted service).
- `base_url` (string, optional) - `base_url` (string, optional)
The base URL of the provider's API. The base URL of the provider's API.
@@ -116,3 +121,16 @@ base_url = "http://localhost:11434"
- `extra` (table, optional) - `extra` (table, optional)
Any additional, provider-specific parameters can be added here. Any additional, provider-specific parameters can be added here.
### Using Ollama Cloud
To talk to [Ollama Cloud](https://docs.ollama.com/cloud), point the base URL at the hosted endpoint and supply your API key:
```toml
[providers.ollama-cloud]
provider_type = "ollama-cloud"
base_url = "https://ollama.com"
api_key = "${OLLAMA_API_KEY}"
```
Requests target the same `/api/chat` endpoint documented by Ollama and automatically include the API key using a `Bearer` authorization header. If you prefer not to store the key in the config file, you can leave `api_key` unset and provide it via the `OLLAMA_API_KEY` (or `OLLAMA_CLOUD_API_KEY`) environment variable instead. You can also reference an environment variable inline (for example `api_key = "$OLLAMA_API_KEY"` or `api_key = "${OLLAMA_API_KEY}"`), which Owlen expands when the configuration is loaded. The base URL is normalised automatically—Owlen enforces HTTPS, trims trailing slashes, and accepts both `https://ollama.com` and `https://api.ollama.com` without rewriting the host.