Compare commits
8 Commits
main
...
d002d35bde
| Author | SHA1 | Date | |
|---|---|---|---|
| d002d35bde | |||
| c9c3d17db0 | |||
| a909455f97 | |||
| 67381b02db | |||
| 235f84fa19 | |||
| 9c777c8429 | |||
| 0b17a0f4c8 | |||
| 2eabe55fe6 |
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
13
Cargo.toml
13
Cargo.toml
@@ -5,6 +5,7 @@ members = [
|
|||||||
"crates/owlen-tui",
|
"crates/owlen-tui",
|
||||||
"crates/owlen-cli",
|
"crates/owlen-cli",
|
||||||
"crates/owlen-ollama",
|
"crates/owlen-ollama",
|
||||||
|
"crates/owlen-mcp-server",
|
||||||
]
|
]
|
||||||
exclude = []
|
exclude = []
|
||||||
|
|
||||||
@@ -40,6 +41,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 +70,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() => {
|
||||||
|
|||||||
@@ -25,7 +25,21 @@ 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" }
|
||||||
|
path-clean = "1.0"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio-test = { workspace = true }
|
tokio-test = { workspace = true }
|
||||||
tempfile = { workspace = true }
|
|
||||||
|
|||||||
12
crates/owlen-core/migrations/0001_create_conversations.sql
Normal file
12
crates/owlen-core/migrations/0001_create_conversations.sql
Normal 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);
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -14,6 +14,9 @@ pub const DEFAULT_CONFIG_PATH: &str = "~/.config/owlen/config.toml";
|
|||||||
pub struct Config {
|
pub struct Config {
|
||||||
/// General application settings
|
/// General application settings
|
||||||
pub general: GeneralSettings,
|
pub general: GeneralSettings,
|
||||||
|
/// MCP (Multi-Client-Provider) settings
|
||||||
|
#[serde(default)]
|
||||||
|
pub mcp: McpSettings,
|
||||||
/// Provider specific configuration keyed by provider name
|
/// Provider specific configuration keyed by provider name
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub providers: HashMap<String, ProviderConfig>,
|
pub providers: HashMap<String, ProviderConfig>,
|
||||||
@@ -26,27 +29,36 @@ 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 {
|
||||||
general: GeneralSettings::default(),
|
general: GeneralSettings::default(),
|
||||||
|
mcp: McpSettings::default(),
|
||||||
providers,
|
providers,
|
||||||
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 +132,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 +206,169 @@ impl Default for GeneralSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// MCP (Multi-Client-Provider) settings
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct McpSettings {
|
||||||
|
#[serde(default)]
|
||||||
|
pub mode: McpMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum McpMode {
|
||||||
|
#[default]
|
||||||
|
Legacy,
|
||||||
|
Enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 +527,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 +605,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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
172
crates/owlen-core/src/consent.rs
Normal file
172
crates/owlen-core/src/consent.rs
Normal 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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
69
crates/owlen-core/src/credentials.rs
Normal file
69
crates/owlen-core/src/credentials.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
241
crates/owlen-core/src/encryption.rs
Normal file
241
crates/owlen-core/src/encryption.rs
Normal 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)
|
||||||
|
}
|
||||||
@@ -91,6 +91,11 @@ impl MessageFormatter {
|
|||||||
Some(thinking)
|
Some(thinking)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// If the result is empty but we have thinking content, show a placeholder
|
||||||
|
if result.trim().is_empty() && thinking_result.is_some() {
|
||||||
|
result.push_str("[Thinking...]");
|
||||||
|
}
|
||||||
|
|
||||||
(result, thinking_result)
|
(result, thinking_result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -62,4 +76,7 @@ pub enum Error {
|
|||||||
|
|
||||||
#[error("Unknown error: {0}")]
|
#[error("Unknown error: {0}")]
|
||||||
Unknown(String),
|
Unknown(String),
|
||||||
|
|
||||||
|
#[error("Not implemented: {0}")]
|
||||||
|
NotImplemented(String),
|
||||||
}
|
}
|
||||||
|
|||||||
39
crates/owlen-core/src/mcp/client.rs
Normal file
39
crates/owlen-core/src/mcp/client.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
use super::{McpToolCall, McpToolDescriptor, McpToolResponse};
|
||||||
|
use crate::{Error, Result};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
/// Trait for a client that can interact with an MCP server
|
||||||
|
#[async_trait]
|
||||||
|
pub trait McpClient: Send + Sync {
|
||||||
|
/// List the tools available on the server
|
||||||
|
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>>;
|
||||||
|
|
||||||
|
/// Call a tool on the server
|
||||||
|
async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Placeholder for a client that connects to a remote MCP server.
|
||||||
|
pub struct RemoteMcpClient;
|
||||||
|
|
||||||
|
impl RemoteMcpClient {
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
Ok(Self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl McpClient for RemoteMcpClient {
|
||||||
|
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
|
||||||
|
// TODO: Implement remote call
|
||||||
|
Err(Error::NotImplemented(
|
||||||
|
"Remote MCP client is not implemented".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call_tool(&self, _call: McpToolCall) -> Result<McpToolResponse> {
|
||||||
|
// TODO: Implement remote call
|
||||||
|
Err(Error::NotImplemented(
|
||||||
|
"Remote MCP client is not implemented".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
109
crates/owlen-core/src/mcp/mod.rs
Normal file
109
crates/owlen-core/src/mcp/mod.rs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
use crate::tools::registry::ToolRegistry;
|
||||||
|
use crate::validation::SchemaValidator;
|
||||||
|
use crate::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use client::McpClient;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub mod client;
|
||||||
|
|
||||||
|
/// 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LocalMcpClient {
|
||||||
|
server: McpServer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalMcpClient {
|
||||||
|
pub fn new(registry: Arc<ToolRegistry>, validator: Arc<SchemaValidator>) -> Self {
|
||||||
|
Self {
|
||||||
|
server: McpServer::new(registry, validator),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl McpClient for LocalMcpClient {
|
||||||
|
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
|
||||||
|
Ok(self.server.list_tools())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse> {
|
||||||
|
self.server.call_tool(call).await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ pub type ChatStream = Pin<Box<dyn Stream<Item = Result<ChatResponse>> + Send>>;
|
|||||||
/// use std::sync::Arc;
|
/// use std::sync::Arc;
|
||||||
/// use futures::Stream;
|
/// use futures::Stream;
|
||||||
/// use owlen_core::provider::{Provider, ProviderRegistry, ChatStream};
|
/// use owlen_core::provider::{Provider, ProviderRegistry, ChatStream};
|
||||||
/// use owlen_core::types::{ChatRequest, ChatResponse, ModelInfo, Message};
|
/// use owlen_core::types::{ChatRequest, ChatResponse, ModelInfo, Message, Role, ChatParameters};
|
||||||
/// use owlen_core::Result;
|
/// use owlen_core::Result;
|
||||||
///
|
///
|
||||||
/// // 1. Create a mock provider
|
/// // 1. Create a mock provider
|
||||||
@@ -31,18 +31,23 @@ pub type ChatStream = Pin<Box<dyn Stream<Item = Result<ChatResponse>> + Send>>;
|
|||||||
///
|
///
|
||||||
/// async fn list_models(&self) -> Result<Vec<ModelInfo>> {
|
/// async fn list_models(&self) -> Result<Vec<ModelInfo>> {
|
||||||
/// Ok(vec![ModelInfo {
|
/// Ok(vec![ModelInfo {
|
||||||
|
/// id: "mock-model".to_string(),
|
||||||
/// provider: "mock".to_string(),
|
/// provider: "mock".to_string(),
|
||||||
/// name: "mock-model".to_string(),
|
/// name: "mock-model".to_string(),
|
||||||
/// ..Default::default()
|
/// description: None,
|
||||||
|
/// context_window: None,
|
||||||
|
/// capabilities: vec![],
|
||||||
|
/// supports_tools: false,
|
||||||
/// }])
|
/// }])
|
||||||
/// }
|
/// }
|
||||||
///
|
///
|
||||||
/// async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> {
|
/// async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> {
|
||||||
/// let content = format!("Response to: {}", request.messages.last().unwrap().content);
|
/// let content = format!("Response to: {}", request.messages.last().unwrap().content);
|
||||||
/// Ok(ChatResponse {
|
/// Ok(ChatResponse {
|
||||||
/// model: request.model,
|
/// message: Message::new(Role::Assistant, content),
|
||||||
/// message: Message { role: "assistant".to_string(), content, ..Default::default() },
|
/// usage: None,
|
||||||
/// ..Default::default()
|
/// is_streaming: false,
|
||||||
|
/// is_final: true,
|
||||||
/// })
|
/// })
|
||||||
/// }
|
/// }
|
||||||
///
|
///
|
||||||
@@ -67,8 +72,9 @@ pub type ChatStream = Pin<Box<dyn Stream<Item = Result<ChatResponse>> + Send>>;
|
|||||||
///
|
///
|
||||||
/// let request = ChatRequest {
|
/// let request = ChatRequest {
|
||||||
/// model: "mock-model".to_string(),
|
/// model: "mock-model".to_string(),
|
||||||
/// messages: vec![Message { role: "user".to_string(), content: "Hello".to_string(), ..Default::default() }],
|
/// messages: vec![Message::new(Role::User, "Hello".to_string())],
|
||||||
/// ..Default::default()
|
/// parameters: ChatParameters::default(),
|
||||||
|
/// tools: None,
|
||||||
/// };
|
/// };
|
||||||
///
|
///
|
||||||
/// let response = provider.chat(request).await.unwrap();
|
/// let response = provider.chat(request).await.unwrap();
|
||||||
|
|||||||
212
crates/owlen-core/src/sandbox.rs
Normal file
212
crates/owlen-core/src/sandbox.rs
Normal 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,
|
||||||
|
}
|
||||||
@@ -1,12 +1,32 @@
|
|||||||
use crate::config::Config;
|
use crate::config::{Config, McpMode};
|
||||||
|
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::mcp::client::{McpClient, RemoteMcpClient};
|
||||||
|
use crate::mcp::{LocalMcpClient, McpToolCall};
|
||||||
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,
|
||||||
|
fs_tools::{ResourcesGetTool, ResourcesListTool},
|
||||||
|
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,7 +51,8 @@ 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::types::{ChatRequest, ChatResponse, ChatParameters, Message, ModelInfo};
|
/// use owlen_core::storage::StorageManager;
|
||||||
|
/// use owlen_core::types::{ChatRequest, ChatResponse, ChatParameters, Message, ModelInfo, Role};
|
||||||
/// use owlen_core::Result;
|
/// use owlen_core::Result;
|
||||||
///
|
///
|
||||||
/// // Mock provider for the example
|
/// // Mock provider for the example
|
||||||
@@ -42,9 +63,10 @@ pub enum SessionOutcome {
|
|||||||
/// async fn list_models(&self) -> Result<Vec<ModelInfo>> { Ok(vec![]) }
|
/// async fn list_models(&self) -> Result<Vec<ModelInfo>> { Ok(vec![]) }
|
||||||
/// async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> {
|
/// async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> {
|
||||||
/// Ok(ChatResponse {
|
/// Ok(ChatResponse {
|
||||||
/// model: request.model,
|
|
||||||
/// message: Message::assistant("Hello back!".to_string()),
|
/// message: Message::assistant("Hello back!".to_string()),
|
||||||
/// ..Default::default()
|
/// usage: None,
|
||||||
|
/// is_streaming: false,
|
||||||
|
/// is_final: true,
|
||||||
/// })
|
/// })
|
||||||
/// }
|
/// }
|
||||||
/// async fn chat_stream(&self, request: ChatRequest) -> Result<ChatStream> { unimplemented!() }
|
/// async fn chat_stream(&self, request: ChatRequest) -> Result<ChatStream> { unimplemented!() }
|
||||||
@@ -55,7 +77,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 +106,154 @@ 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>,
|
||||||
|
mcp_client: Arc<dyn McpClient>,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_tools(
|
||||||
|
config: &Config,
|
||||||
|
enable_code_tools: bool,
|
||||||
|
consent_manager: Arc<Mutex<ConsentManager>>,
|
||||||
|
credential_manager: Option<Arc<CredentialManager>>,
|
||||||
|
vault: Option<Arc<Mutex<VaultHandle>>>,
|
||||||
|
) -> Result<(Arc<ToolRegistry>, Arc<SchemaValidator>)> {
|
||||||
|
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 config
|
||||||
|
.security
|
||||||
|
.allowed_tools
|
||||||
|
.iter()
|
||||||
|
.any(|tool| tool == "web_search")
|
||||||
|
&& config.tools.web_search.enabled
|
||||||
|
&& config.privacy.enable_remote_search
|
||||||
|
{
|
||||||
|
let tool = WebSearchTool::new(
|
||||||
|
consent_manager.clone(),
|
||||||
|
credential_manager.clone(),
|
||||||
|
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 config
|
||||||
|
.security
|
||||||
|
.allowed_tools
|
||||||
|
.iter()
|
||||||
|
.any(|tool| tool == "web_search") // Same permission as web_search
|
||||||
|
&& config.tools.web_search.enabled
|
||||||
|
&& config.privacy.enable_remote_search
|
||||||
|
{
|
||||||
|
let tool = WebSearchDetailedTool::new(
|
||||||
|
consent_manager.clone(),
|
||||||
|
credential_manager.clone(),
|
||||||
|
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 enable_code_tools
|
||||||
|
&& config
|
||||||
|
.security
|
||||||
|
.allowed_tools
|
||||||
|
.iter()
|
||||||
|
.any(|tool| tool == "code_exec")
|
||||||
|
&& config.tools.code_exec.enabled
|
||||||
|
{
|
||||||
|
let tool = CodeExecTool::new(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resources_list_tool = ResourcesListTool;
|
||||||
|
let resources_get_tool = ResourcesGetTool;
|
||||||
|
validator.register_schema(resources_list_tool.name(), resources_list_tool.schema())?;
|
||||||
|
validator.register_schema(resources_get_tool.name(), resources_get_tool.schema())?;
|
||||||
|
registry.register(resources_list_tool);
|
||||||
|
registry.register(resources_get_tool);
|
||||||
|
|
||||||
|
Ok((Arc::new(registry), Arc::new(validator)))
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +267,41 @@ impl SessionController {
|
|||||||
|
|
||||||
let model_manager = ModelManager::new(config.general.model_cache_ttl());
|
let model_manager = ModelManager::new(config.general.model_cache_ttl());
|
||||||
|
|
||||||
Self {
|
let (tool_registry, schema_validator) = build_tools(
|
||||||
|
&config,
|
||||||
|
enable_code_tools,
|
||||||
|
consent_manager.clone(),
|
||||||
|
credential_manager.clone(),
|
||||||
|
vault_handle.clone(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mcp_client: Arc<dyn McpClient> = match config.mcp.mode {
|
||||||
|
McpMode::Legacy => Arc::new(LocalMcpClient::new(
|
||||||
|
tool_registry.clone(),
|
||||||
|
schema_validator.clone(),
|
||||||
|
)),
|
||||||
|
McpMode::Enabled => Arc::new(RemoteMcpClient::new()?),
|
||||||
|
};
|
||||||
|
|
||||||
|
let controller = Self {
|
||||||
provider,
|
provider,
|
||||||
conversation,
|
conversation,
|
||||||
model_manager,
|
model_manager,
|
||||||
input_buffer,
|
input_buffer,
|
||||||
formatter,
|
formatter,
|
||||||
config,
|
config,
|
||||||
}
|
consent_manager,
|
||||||
|
tool_registry,
|
||||||
|
schema_validator,
|
||||||
|
mcp_client,
|
||||||
|
storage,
|
||||||
|
vault: vault_handle,
|
||||||
|
master_key,
|
||||||
|
credential_manager,
|
||||||
|
enable_code_tools,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(controller)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Access the active conversation
|
/// Access the active conversation
|
||||||
@@ -156,6 +344,226 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read_file(&self, path: &str) -> Result<String> {
|
||||||
|
let call = McpToolCall {
|
||||||
|
name: "resources/get".to_string(),
|
||||||
|
arguments: serde_json::json!({ "path": path }),
|
||||||
|
};
|
||||||
|
let response = self.mcp_client.call_tool(call).await?;
|
||||||
|
let content: String = serde_json::from_value(response.output)?;
|
||||||
|
Ok(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_dir(&self, path: &str) -> Result<Vec<String>> {
|
||||||
|
let call = McpToolCall {
|
||||||
|
name: "resources/list".to_string(),
|
||||||
|
arguments: serde_json::json!({ "path": path }),
|
||||||
|
};
|
||||||
|
let response = self.mcp_client.call_tool(call).await?;
|
||||||
|
let content: Vec<String> = serde_json::from_value(response.output)?;
|
||||||
|
Ok(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rebuild_tools(&mut self) -> Result<()> {
|
||||||
|
let (registry, validator) = build_tools(
|
||||||
|
&self.config,
|
||||||
|
self.enable_code_tools,
|
||||||
|
self.consent_manager.clone(),
|
||||||
|
self.credential_manager.clone(),
|
||||||
|
self.vault.clone(),
|
||||||
|
)?;
|
||||||
|
self.tool_registry = registry;
|
||||||
|
self.schema_validator = validator;
|
||||||
|
|
||||||
|
self.mcp_client = match self.config.mcp.mode {
|
||||||
|
McpMode::Legacy => Arc::new(LocalMcpClient::new(
|
||||||
|
self.tool_registry.clone(),
|
||||||
|
self.schema_validator.clone(),
|
||||||
|
)),
|
||||||
|
McpMode::Enabled => Arc::new(RemoteMcpClient::new()?),
|
||||||
|
};
|
||||||
|
|
||||||
|
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 +595,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 +625,107 @@ 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 mcp_tool_call = McpToolCall {
|
||||||
|
name: tool_call.name.clone(),
|
||||||
|
arguments: tool_call.arguments.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let tool_result =
|
||||||
|
self.mcp_client.call_tool(mcp_tool_call).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 +738,65 @@ 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 mcp_tool_call = McpToolCall {
|
||||||
|
name: tool_call.name.clone(),
|
||||||
|
arguments: tool_call.arguments.clone(),
|
||||||
|
};
|
||||||
|
let tool_result = self.mcp_client.call_tool(mcp_tool_call).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()
|
||||||
@@ -286,7 +825,7 @@ impl SessionController {
|
|||||||
let first_msg = &conv.messages[0];
|
let first_msg = &conv.messages[0];
|
||||||
let preview = first_msg.content.chars().take(50).collect::<String>();
|
let preview = first_msg.content.chars().take(50).collect::<String>();
|
||||||
return Ok(format!(
|
return Ok(format!(
|
||||||
"{}{}",
|
"{}{} ",
|
||||||
preview,
|
preview,
|
||||||
if first_msg.content.len() > 50 {
|
if first_msg.content.len() > 50 {
|
||||||
"..."
|
"..."
|
||||||
@@ -335,6 +874,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
|
||||||
@@ -347,7 +887,7 @@ impl SessionController {
|
|||||||
let first_msg = &conv.messages[0];
|
let first_msg = &conv.messages[0];
|
||||||
let preview = first_msg.content.chars().take(50).collect::<String>();
|
let preview = first_msg.content.chars().take(50).collect::<String>();
|
||||||
return Ok(format!(
|
return Ok(format!(
|
||||||
"{}{}",
|
"{}{} ",
|
||||||
preview,
|
preview,
|
||||||
if first_msg.content.len() > 50 {
|
if first_msg.content.len() > 50 {
|
||||||
"..."
|
"..."
|
||||||
@@ -359,7 +899,8 @@ impl SessionController {
|
|||||||
|
|
||||||
// Truncate if too long
|
// Truncate if too long
|
||||||
let truncated = if description.len() > 100 {
|
let truncated = if description.len() > 100 {
|
||||||
format!("{}...", description.chars().take(97).collect::<String>())
|
description.chars().take(97).collect::<String>()
|
||||||
|
// Removed trailing '...' as it's already handled by the format! macro
|
||||||
} else {
|
} else {
|
||||||
description
|
description
|
||||||
};
|
};
|
||||||
@@ -370,7 +911,7 @@ impl SessionController {
|
|||||||
let first_msg = &conv.messages[0];
|
let first_msg = &conv.messages[0];
|
||||||
let preview = first_msg.content.chars().take(50).collect::<String>();
|
let preview = first_msg.content.chars().take(50).collect::<String>();
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"{}{}",
|
"{}{} ",
|
||||||
preview,
|
preview,
|
||||||
if first_msg.content.len() > 50 {
|
if first_msg.content.len() > 50 {
|
||||||
"..."
|
"..."
|
||||||
|
|||||||
@@ -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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ pub struct Theme {
|
|||||||
#[serde(serialize_with = "serialize_color")]
|
#[serde(serialize_with = "serialize_color")]
|
||||||
pub assistant_message_role: Color,
|
pub assistant_message_role: Color,
|
||||||
|
|
||||||
|
/// Color for tool output messages
|
||||||
|
#[serde(deserialize_with = "deserialize_color")]
|
||||||
|
#[serde(serialize_with = "serialize_color")]
|
||||||
|
pub tool_output: Color,
|
||||||
|
|
||||||
/// Color for thinking panel title
|
/// Color for thinking panel title
|
||||||
#[serde(deserialize_with = "deserialize_color")]
|
#[serde(deserialize_with = "deserialize_color")]
|
||||||
#[serde(serialize_with = "serialize_color")]
|
#[serde(serialize_with = "serialize_color")]
|
||||||
@@ -268,6 +273,7 @@ fn default_dark() -> Theme {
|
|||||||
unfocused_panel_border: Color::Rgb(95, 20, 135),
|
unfocused_panel_border: Color::Rgb(95, 20, 135),
|
||||||
user_message_role: Color::LightBlue,
|
user_message_role: Color::LightBlue,
|
||||||
assistant_message_role: Color::Yellow,
|
assistant_message_role: Color::Yellow,
|
||||||
|
tool_output: Color::Gray,
|
||||||
thinking_panel_title: Color::LightMagenta,
|
thinking_panel_title: Color::LightMagenta,
|
||||||
command_bar_background: Color::Black,
|
command_bar_background: Color::Black,
|
||||||
status_background: Color::Black,
|
status_background: Color::Black,
|
||||||
@@ -297,6 +303,7 @@ fn default_light() -> Theme {
|
|||||||
unfocused_panel_border: Color::Rgb(221, 221, 221),
|
unfocused_panel_border: Color::Rgb(221, 221, 221),
|
||||||
user_message_role: Color::Rgb(0, 85, 164),
|
user_message_role: Color::Rgb(0, 85, 164),
|
||||||
assistant_message_role: Color::Rgb(142, 68, 173),
|
assistant_message_role: Color::Rgb(142, 68, 173),
|
||||||
|
tool_output: Color::Gray,
|
||||||
thinking_panel_title: Color::Rgb(142, 68, 173),
|
thinking_panel_title: Color::Rgb(142, 68, 173),
|
||||||
command_bar_background: Color::White,
|
command_bar_background: Color::White,
|
||||||
status_background: Color::White,
|
status_background: Color::White,
|
||||||
@@ -326,8 +333,9 @@ fn gruvbox() -> Theme {
|
|||||||
unfocused_panel_border: Color::Rgb(124, 111, 100), // #7c6f64
|
unfocused_panel_border: Color::Rgb(124, 111, 100), // #7c6f64
|
||||||
user_message_role: Color::Rgb(184, 187, 38), // #b8bb26 (green)
|
user_message_role: Color::Rgb(184, 187, 38), // #b8bb26 (green)
|
||||||
assistant_message_role: Color::Rgb(131, 165, 152), // #83a598 (blue)
|
assistant_message_role: Color::Rgb(131, 165, 152), // #83a598 (blue)
|
||||||
thinking_panel_title: Color::Rgb(211, 134, 155), // #d3869b (purple)
|
tool_output: Color::Rgb(146, 131, 116),
|
||||||
command_bar_background: Color::Rgb(60, 56, 54), // #3c3836
|
thinking_panel_title: Color::Rgb(211, 134, 155), // #d3869b (purple)
|
||||||
|
command_bar_background: Color::Rgb(60, 56, 54), // #3c3836
|
||||||
status_background: Color::Rgb(60, 56, 54),
|
status_background: Color::Rgb(60, 56, 54),
|
||||||
mode_normal: Color::Rgb(131, 165, 152), // blue
|
mode_normal: Color::Rgb(131, 165, 152), // blue
|
||||||
mode_editing: Color::Rgb(184, 187, 38), // green
|
mode_editing: Color::Rgb(184, 187, 38), // green
|
||||||
@@ -355,7 +363,8 @@ fn dracula() -> Theme {
|
|||||||
unfocused_panel_border: Color::Rgb(68, 71, 90), // #44475a
|
unfocused_panel_border: Color::Rgb(68, 71, 90), // #44475a
|
||||||
user_message_role: Color::Rgb(139, 233, 253), // #8be9fd (cyan)
|
user_message_role: Color::Rgb(139, 233, 253), // #8be9fd (cyan)
|
||||||
assistant_message_role: Color::Rgb(255, 121, 198), // #ff79c6 (pink)
|
assistant_message_role: Color::Rgb(255, 121, 198), // #ff79c6 (pink)
|
||||||
thinking_panel_title: Color::Rgb(189, 147, 249), // #bd93f9 (purple)
|
tool_output: Color::Rgb(98, 114, 164),
|
||||||
|
thinking_panel_title: Color::Rgb(189, 147, 249), // #bd93f9 (purple)
|
||||||
command_bar_background: Color::Rgb(68, 71, 90),
|
command_bar_background: Color::Rgb(68, 71, 90),
|
||||||
status_background: Color::Rgb(68, 71, 90),
|
status_background: Color::Rgb(68, 71, 90),
|
||||||
mode_normal: Color::Rgb(139, 233, 253),
|
mode_normal: Color::Rgb(139, 233, 253),
|
||||||
@@ -384,6 +393,7 @@ fn solarized() -> Theme {
|
|||||||
unfocused_panel_border: Color::Rgb(7, 54, 66), // #073642 (base02)
|
unfocused_panel_border: Color::Rgb(7, 54, 66), // #073642 (base02)
|
||||||
user_message_role: Color::Rgb(42, 161, 152), // #2aa198 (cyan)
|
user_message_role: Color::Rgb(42, 161, 152), // #2aa198 (cyan)
|
||||||
assistant_message_role: Color::Rgb(203, 75, 22), // #cb4b16 (orange)
|
assistant_message_role: Color::Rgb(203, 75, 22), // #cb4b16 (orange)
|
||||||
|
tool_output: Color::Rgb(101, 123, 131),
|
||||||
thinking_panel_title: Color::Rgb(108, 113, 196), // #6c71c4 (violet)
|
thinking_panel_title: Color::Rgb(108, 113, 196), // #6c71c4 (violet)
|
||||||
command_bar_background: Color::Rgb(7, 54, 66),
|
command_bar_background: Color::Rgb(7, 54, 66),
|
||||||
status_background: Color::Rgb(7, 54, 66),
|
status_background: Color::Rgb(7, 54, 66),
|
||||||
@@ -413,6 +423,7 @@ fn midnight_ocean() -> Theme {
|
|||||||
unfocused_panel_border: Color::Rgb(48, 54, 61),
|
unfocused_panel_border: Color::Rgb(48, 54, 61),
|
||||||
user_message_role: Color::Rgb(121, 192, 255),
|
user_message_role: Color::Rgb(121, 192, 255),
|
||||||
assistant_message_role: Color::Rgb(137, 221, 255),
|
assistant_message_role: Color::Rgb(137, 221, 255),
|
||||||
|
tool_output: Color::Rgb(84, 110, 122),
|
||||||
thinking_panel_title: Color::Rgb(158, 206, 106),
|
thinking_panel_title: Color::Rgb(158, 206, 106),
|
||||||
command_bar_background: Color::Rgb(22, 27, 34),
|
command_bar_background: Color::Rgb(22, 27, 34),
|
||||||
status_background: Color::Rgb(22, 27, 34),
|
status_background: Color::Rgb(22, 27, 34),
|
||||||
@@ -442,7 +453,8 @@ fn rose_pine() -> Theme {
|
|||||||
unfocused_panel_border: Color::Rgb(38, 35, 58), // #26233a
|
unfocused_panel_border: Color::Rgb(38, 35, 58), // #26233a
|
||||||
user_message_role: Color::Rgb(49, 116, 143), // #31748f (foam)
|
user_message_role: Color::Rgb(49, 116, 143), // #31748f (foam)
|
||||||
assistant_message_role: Color::Rgb(156, 207, 216), // #9ccfd8 (foam light)
|
assistant_message_role: Color::Rgb(156, 207, 216), // #9ccfd8 (foam light)
|
||||||
thinking_panel_title: Color::Rgb(196, 167, 231), // #c4a7e7 (iris)
|
tool_output: Color::Rgb(110, 106, 134),
|
||||||
|
thinking_panel_title: Color::Rgb(196, 167, 231), // #c4a7e7 (iris)
|
||||||
command_bar_background: Color::Rgb(38, 35, 58),
|
command_bar_background: Color::Rgb(38, 35, 58),
|
||||||
status_background: Color::Rgb(38, 35, 58),
|
status_background: Color::Rgb(38, 35, 58),
|
||||||
mode_normal: Color::Rgb(156, 207, 216),
|
mode_normal: Color::Rgb(156, 207, 216),
|
||||||
@@ -471,7 +483,8 @@ fn monokai() -> Theme {
|
|||||||
unfocused_panel_border: Color::Rgb(117, 113, 94), // #75715e
|
unfocused_panel_border: Color::Rgb(117, 113, 94), // #75715e
|
||||||
user_message_role: Color::Rgb(102, 217, 239), // #66d9ef (cyan)
|
user_message_role: Color::Rgb(102, 217, 239), // #66d9ef (cyan)
|
||||||
assistant_message_role: Color::Rgb(174, 129, 255), // #ae81ff (purple)
|
assistant_message_role: Color::Rgb(174, 129, 255), // #ae81ff (purple)
|
||||||
thinking_panel_title: Color::Rgb(230, 219, 116), // #e6db74 (yellow)
|
tool_output: Color::Rgb(117, 113, 94),
|
||||||
|
thinking_panel_title: Color::Rgb(230, 219, 116), // #e6db74 (yellow)
|
||||||
command_bar_background: Color::Rgb(39, 40, 34),
|
command_bar_background: Color::Rgb(39, 40, 34),
|
||||||
status_background: Color::Rgb(39, 40, 34),
|
status_background: Color::Rgb(39, 40, 34),
|
||||||
mode_normal: Color::Rgb(102, 217, 239),
|
mode_normal: Color::Rgb(102, 217, 239),
|
||||||
@@ -500,7 +513,8 @@ fn material_dark() -> Theme {
|
|||||||
unfocused_panel_border: Color::Rgb(84, 110, 122), // #546e7a
|
unfocused_panel_border: Color::Rgb(84, 110, 122), // #546e7a
|
||||||
user_message_role: Color::Rgb(130, 170, 255), // #82aaff (blue)
|
user_message_role: Color::Rgb(130, 170, 255), // #82aaff (blue)
|
||||||
assistant_message_role: Color::Rgb(199, 146, 234), // #c792ea (purple)
|
assistant_message_role: Color::Rgb(199, 146, 234), // #c792ea (purple)
|
||||||
thinking_panel_title: Color::Rgb(255, 203, 107), // #ffcb6b (yellow)
|
tool_output: Color::Rgb(84, 110, 122),
|
||||||
|
thinking_panel_title: Color::Rgb(255, 203, 107), // #ffcb6b (yellow)
|
||||||
command_bar_background: Color::Rgb(33, 43, 48),
|
command_bar_background: Color::Rgb(33, 43, 48),
|
||||||
status_background: Color::Rgb(33, 43, 48),
|
status_background: Color::Rgb(33, 43, 48),
|
||||||
mode_normal: Color::Rgb(130, 170, 255),
|
mode_normal: Color::Rgb(130, 170, 255),
|
||||||
@@ -529,6 +543,7 @@ fn material_light() -> Theme {
|
|||||||
unfocused_panel_border: Color::Rgb(176, 190, 197),
|
unfocused_panel_border: Color::Rgb(176, 190, 197),
|
||||||
user_message_role: Color::Rgb(68, 138, 255),
|
user_message_role: Color::Rgb(68, 138, 255),
|
||||||
assistant_message_role: Color::Rgb(124, 77, 255),
|
assistant_message_role: Color::Rgb(124, 77, 255),
|
||||||
|
tool_output: Color::Rgb(144, 164, 174),
|
||||||
thinking_panel_title: Color::Rgb(245, 124, 0),
|
thinking_panel_title: Color::Rgb(245, 124, 0),
|
||||||
command_bar_background: Color::Rgb(255, 255, 255),
|
command_bar_background: Color::Rgb(255, 255, 255),
|
||||||
status_background: Color::Rgb(255, 255, 255),
|
status_background: Color::Rgb(255, 255, 255),
|
||||||
|
|||||||
147
crates/owlen-core/src/tools/code_exec.rs
Normal file
147
crates/owlen-core/src/tools/code_exec.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
111
crates/owlen-core/src/tools/fs_tools.rs
Normal file
111
crates/owlen-core/src/tools/fs_tools.rs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
use crate::tools::{Tool, ToolResult};
|
||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use path_clean::PathClean;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct FileArgs {
|
||||||
|
path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_path(path: &str, root: &Path) -> Result<PathBuf> {
|
||||||
|
let path = Path::new(path);
|
||||||
|
let path = if path.is_absolute() {
|
||||||
|
path.strip_prefix("/")
|
||||||
|
.map_err(|_| anyhow::anyhow!("Invalid path"))?
|
||||||
|
.to_path_buf()
|
||||||
|
} else {
|
||||||
|
path.to_path_buf()
|
||||||
|
};
|
||||||
|
|
||||||
|
let full_path = root.join(path).clean();
|
||||||
|
|
||||||
|
if !full_path.starts_with(root) {
|
||||||
|
return Err(anyhow::anyhow!("Path traversal detected"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(full_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ResourcesListTool;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for ResourcesListTool {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"resources/list"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &'static str {
|
||||||
|
"Lists directory contents."
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schema(&self) -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The path to the directory to list."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["path"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
|
||||||
|
let args: FileArgs = serde_json::from_value(args)?;
|
||||||
|
let root = env::current_dir()?;
|
||||||
|
let full_path = sanitize_path(&args.path, &root)?;
|
||||||
|
|
||||||
|
let entries = fs::read_dir(full_path)?;
|
||||||
|
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry?;
|
||||||
|
result.push(entry.file_name().to_string_lossy().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ToolResult::success(serde_json::to_value(result)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ResourcesGetTool;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for ResourcesGetTool {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"resources/get"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &'static str {
|
||||||
|
"Reads file content."
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schema(&self) -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The path to the file to read."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["path"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
|
||||||
|
let args: FileArgs = serde_json::from_value(args)?;
|
||||||
|
let root = env::current_dir()?;
|
||||||
|
let full_path = sanitize_path(&args.path, &root)?;
|
||||||
|
|
||||||
|
let content = fs::read_to_string(full_path)?;
|
||||||
|
|
||||||
|
Ok(ToolResult::success(serde_json::to_value(content)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
54
crates/owlen-core/src/tools/mod.rs
Normal file
54
crates/owlen-core/src/tools/mod.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
pub mod code_exec;
|
||||||
|
pub mod fs_tools;
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
crates/owlen-core/src/tools/registry.rs
Normal file
53
crates/owlen-core/src/tools/registry.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
153
crates/owlen-core/src/tools/web_search.rs
Normal file
153
crates/owlen-core/src/tools/web_search.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
130
crates/owlen-core/src/tools/web_search_detailed.rs
Normal file
130
crates/owlen-core/src/tools/web_search_detailed.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
108
crates/owlen-core/src/validation.rs
Normal file
108
crates/owlen-core/src/validation.rs
Normal 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
|
||||||
|
}
|
||||||
11
crates/owlen-mcp-server/Cargo.toml
Normal file
11
crates/owlen-mcp-server/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "owlen-mcp-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
anyhow = "1.0"
|
||||||
|
path-clean = "1.0"
|
||||||
180
crates/owlen-mcp-server/src/main.rs
Normal file
180
crates/owlen-mcp-server/src/main.rs
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
use path_clean::PathClean;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Request {
|
||||||
|
id: u64,
|
||||||
|
method: String,
|
||||||
|
params: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct Response {
|
||||||
|
id: u64,
|
||||||
|
result: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ErrorResponse {
|
||||||
|
id: u64,
|
||||||
|
error: JsonRpcError,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct JsonRpcError {
|
||||||
|
code: i64,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct FileArgs {
|
||||||
|
path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_request(req: Request, root: &Path) -> Result<serde_json::Value, JsonRpcError> {
|
||||||
|
match req.method.as_str() {
|
||||||
|
"resources/list" => {
|
||||||
|
let args: FileArgs = serde_json::from_value(req.params).map_err(|e| JsonRpcError {
|
||||||
|
code: -32602,
|
||||||
|
message: format!("Invalid params: {}", e),
|
||||||
|
})?;
|
||||||
|
resources_list(&args.path, root).await
|
||||||
|
}
|
||||||
|
"resources/get" => {
|
||||||
|
let args: FileArgs = serde_json::from_value(req.params).map_err(|e| JsonRpcError {
|
||||||
|
code: -32602,
|
||||||
|
message: format!("Invalid params: {}", e),
|
||||||
|
})?;
|
||||||
|
resources_get(&args.path, root).await
|
||||||
|
}
|
||||||
|
_ => Err(JsonRpcError {
|
||||||
|
code: -32601,
|
||||||
|
message: "Method not found".to_string(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_path(path: &str, root: &Path) -> Result<PathBuf, JsonRpcError> {
|
||||||
|
let path = Path::new(path);
|
||||||
|
let path = if path.is_absolute() {
|
||||||
|
path.strip_prefix("/")
|
||||||
|
.map_err(|_| JsonRpcError {
|
||||||
|
code: -32602,
|
||||||
|
message: "Invalid path".to_string(),
|
||||||
|
})?
|
||||||
|
.to_path_buf()
|
||||||
|
} else {
|
||||||
|
path.to_path_buf()
|
||||||
|
};
|
||||||
|
|
||||||
|
let full_path = root.join(path).clean();
|
||||||
|
|
||||||
|
if !full_path.starts_with(root) {
|
||||||
|
return Err(JsonRpcError {
|
||||||
|
code: -32602,
|
||||||
|
message: "Path traversal detected".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(full_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resources_list(path: &str, root: &Path) -> Result<serde_json::Value, JsonRpcError> {
|
||||||
|
let full_path = sanitize_path(path, root)?;
|
||||||
|
|
||||||
|
let entries = fs::read_dir(full_path).map_err(|e| JsonRpcError {
|
||||||
|
code: -32000,
|
||||||
|
message: format!("Failed to read directory: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry.map_err(|e| JsonRpcError {
|
||||||
|
code: -32000,
|
||||||
|
message: format!("Failed to read directory entry: {}", e),
|
||||||
|
})?;
|
||||||
|
result.push(entry.file_name().to_string_lossy().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(serde_json::json!(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resources_get(path: &str, root: &Path) -> Result<serde_json::Value, JsonRpcError> {
|
||||||
|
let full_path = sanitize_path(path, root)?;
|
||||||
|
|
||||||
|
let content = fs::read_to_string(full_path).map_err(|e| JsonRpcError {
|
||||||
|
code: -32000,
|
||||||
|
message: format!("Failed to read file: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(serde_json::json!(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
let root = env::current_dir()?;
|
||||||
|
let mut stdin = io::BufReader::new(io::stdin());
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let mut line = String::new();
|
||||||
|
match stdin.read_line(&mut line).await {
|
||||||
|
Ok(0) => {
|
||||||
|
// EOF
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
let req: Request = match serde_json::from_str(&line) {
|
||||||
|
Ok(req) => req,
|
||||||
|
Err(e) => {
|
||||||
|
let err_resp = ErrorResponse {
|
||||||
|
id: 0,
|
||||||
|
error: JsonRpcError {
|
||||||
|
code: -32700,
|
||||||
|
message: format!("Parse error: {}", e),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let resp_str = serde_json::to_string(&err_resp)?;
|
||||||
|
stdout.write_all(resp_str.as_bytes()).await?;
|
||||||
|
stdout.write_all(b"\n").await?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let request_id = req.id;
|
||||||
|
|
||||||
|
match handle_request(req, &root).await {
|
||||||
|
Ok(result) => {
|
||||||
|
let resp = Response {
|
||||||
|
id: request_id,
|
||||||
|
result,
|
||||||
|
};
|
||||||
|
let resp_str = serde_json::to_string(&resp)?;
|
||||||
|
stdout.write_all(resp_str.as_bytes()).await?;
|
||||||
|
stdout.write_all(b"\n").await?;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let err_resp = ErrorResponse {
|
||||||
|
id: request_id,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
let resp_str = serde_json::to_string(&err_resp)?;
|
||||||
|
stdout.write_all(resp_str.as_bytes()).await?;
|
||||||
|
stdout.write_all(b"\n").await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Handle read error
|
||||||
|
eprintln!("Error reading from stdin: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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_BRACED", "super-secret");
|
||||||
|
assert_eq!(
|
||||||
|
resolve_api_key(Some("${OWLEN_TEST_KEY_BRACED}".into())),
|
||||||
|
Some("super-secret".into())
|
||||||
|
);
|
||||||
|
std::env::remove_var("OWLEN_TEST_KEY_BRACED");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_api_key_expands_unbraced_env_reference() {
|
||||||
|
std::env::set_var("OWLEN_TEST_KEY_UNBRACED", "another-secret");
|
||||||
|
assert_eq!(
|
||||||
|
resolve_api_key(Some("$OWLEN_TEST_KEY_UNBRACED".into())),
|
||||||
|
Some("another-secret".into())
|
||||||
|
);
|
||||||
|
std::env::remove_var("OWLEN_TEST_KEY_UNBRACED");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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> {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
};
|
};
|
||||||
@@ -658,7 +670,13 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
|
|||||||
|
|
||||||
let chunks_len = chunks.len();
|
let chunks_len = chunks.len();
|
||||||
for (i, seg) in chunks.into_iter().enumerate() {
|
for (i, seg) in chunks.into_iter().enumerate() {
|
||||||
let mut spans = vec![Span::raw(format!("{indent}{}", seg))];
|
let style = if matches!(role, Role::Tool) {
|
||||||
|
Style::default().fg(theme.tool_output)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut spans = vec![Span::styled(format!("{indent}{}", seg), style)];
|
||||||
if i == chunks_len - 1 && is_streaming {
|
if i == chunks_len - 1 && is_streaming {
|
||||||
spans.push(Span::styled(" ▌", Style::default().fg(theme.cursor)));
|
spans.push(Span::styled(" ▌", Style::default().fg(theme.cursor)));
|
||||||
}
|
}
|
||||||
@@ -670,7 +688,13 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
|
|||||||
let chunks = wrap(&content, content_width as usize);
|
let chunks = wrap(&content, content_width as usize);
|
||||||
let chunks_len = chunks.len();
|
let chunks_len = chunks.len();
|
||||||
for (i, seg) in chunks.into_iter().enumerate() {
|
for (i, seg) in chunks.into_iter().enumerate() {
|
||||||
let mut spans = vec![Span::raw(seg.into_owned())];
|
let style = if matches!(role, Role::Tool) {
|
||||||
|
Style::default().fg(theme.tool_output)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut spans = vec![Span::styled(seg.into_owned(), style)];
|
||||||
if i == chunks_len - 1 && is_streaming {
|
if i == chunks_len - 1 && is_streaming {
|
||||||
spans.push(Span::styled(" ▌", Style::default().fg(theme.cursor)));
|
spans.push(Span::styled(" ▌", Style::default().fg(theme.cursor)));
|
||||||
}
|
}
|
||||||
@@ -1102,20 +1126,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 +1176,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 +1192,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 +1392,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
|
|||||||
"Commands",
|
"Commands",
|
||||||
"Sessions",
|
"Sessions",
|
||||||
"Browsers",
|
"Browsers",
|
||||||
|
"Privacy",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Build tab line
|
// Build tab line
|
||||||
@@ -1429,6 +1666,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 +1692,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 +1716,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),
|
||||||
@@ -1845,6 +2087,108 @@ fn role_color(role: &Role, theme: &owlen_core::theme::Theme) -> Style {
|
|||||||
match role {
|
match role {
|
||||||
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.unfocused_panel_border),
|
||||||
|
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();
|
||||||
|
let mut content_found = false;
|
||||||
|
|
||||||
|
// 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));
|
||||||
|
content_found = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract results array
|
||||||
|
if let Some(results) = json.get("results").and_then(|v| v.as_array()) {
|
||||||
|
content_found = true;
|
||||||
|
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("'", "'")
|
||||||
|
.replace(""", "\"");
|
||||||
|
|
||||||
|
// 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(result) = json.get("result").and_then(|v| v.as_str()) {
|
||||||
|
content_found = true;
|
||||||
|
output.push_str(result);
|
||||||
|
} else if let Some(error) = json.get("error").and_then(|v| v.as_str()) {
|
||||||
|
content_found = true;
|
||||||
|
// Handle error results
|
||||||
|
output.push_str(&format!("❌ Error: {}", error));
|
||||||
|
}
|
||||||
|
|
||||||
|
if content_found {
|
||||||
|
output
|
||||||
|
} else {
|
||||||
|
content.to_string()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If not JSON, return as-is
|
||||||
|
content.to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ focused_panel_border = "lightmagenta"
|
|||||||
unfocused_panel_border = "#5f1487"
|
unfocused_panel_border = "#5f1487"
|
||||||
user_message_role = "lightblue"
|
user_message_role = "lightblue"
|
||||||
assistant_message_role = "yellow"
|
assistant_message_role = "yellow"
|
||||||
|
tool_output = "gray"
|
||||||
thinking_panel_title = "lightmagenta"
|
thinking_panel_title = "lightmagenta"
|
||||||
command_bar_background = "black"
|
command_bar_background = "black"
|
||||||
status_background = "black"
|
status_background = "black"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ focused_panel_border = "#4a90e2"
|
|||||||
unfocused_panel_border = "#dddddd"
|
unfocused_panel_border = "#dddddd"
|
||||||
user_message_role = "#0055a4"
|
user_message_role = "#0055a4"
|
||||||
assistant_message_role = "#8e44ad"
|
assistant_message_role = "#8e44ad"
|
||||||
|
tool_output = "gray"
|
||||||
thinking_panel_title = "#8e44ad"
|
thinking_panel_title = "#8e44ad"
|
||||||
command_bar_background = "white"
|
command_bar_background = "white"
|
||||||
status_background = "white"
|
status_background = "white"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ focused_panel_border = "#ff79c6"
|
|||||||
unfocused_panel_border = "#44475a"
|
unfocused_panel_border = "#44475a"
|
||||||
user_message_role = "#8be9fd"
|
user_message_role = "#8be9fd"
|
||||||
assistant_message_role = "#ff79c6"
|
assistant_message_role = "#ff79c6"
|
||||||
|
tool_output = "#6272a4"
|
||||||
thinking_panel_title = "#bd93f9"
|
thinking_panel_title = "#bd93f9"
|
||||||
command_bar_background = "#44475a"
|
command_bar_background = "#44475a"
|
||||||
status_background = "#44475a"
|
status_background = "#44475a"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ focused_panel_border = "#fe8019"
|
|||||||
unfocused_panel_border = "#7c6f64"
|
unfocused_panel_border = "#7c6f64"
|
||||||
user_message_role = "#b8bb26"
|
user_message_role = "#b8bb26"
|
||||||
assistant_message_role = "#83a598"
|
assistant_message_role = "#83a598"
|
||||||
|
tool_output = "#928374"
|
||||||
thinking_panel_title = "#d3869b"
|
thinking_panel_title = "#d3869b"
|
||||||
command_bar_background = "#3c3836"
|
command_bar_background = "#3c3836"
|
||||||
status_background = "#3c3836"
|
status_background = "#3c3836"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ focused_panel_border = "#80cbc4"
|
|||||||
unfocused_panel_border = "#546e7a"
|
unfocused_panel_border = "#546e7a"
|
||||||
user_message_role = "#82aaff"
|
user_message_role = "#82aaff"
|
||||||
assistant_message_role = "#c792ea"
|
assistant_message_role = "#c792ea"
|
||||||
|
tool_output = "#546e7a"
|
||||||
thinking_panel_title = "#ffcb6b"
|
thinking_panel_title = "#ffcb6b"
|
||||||
command_bar_background = "#212b30"
|
command_bar_background = "#212b30"
|
||||||
status_background = "#212b30"
|
status_background = "#212b30"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ focused_panel_border = "#009688"
|
|||||||
unfocused_panel_border = "#b0bec5"
|
unfocused_panel_border = "#b0bec5"
|
||||||
user_message_role = "#448aff"
|
user_message_role = "#448aff"
|
||||||
assistant_message_role = "#7c4dff"
|
assistant_message_role = "#7c4dff"
|
||||||
|
tool_output = "#90a4ae"
|
||||||
thinking_panel_title = "#f57c00"
|
thinking_panel_title = "#f57c00"
|
||||||
command_bar_background = "#ffffff"
|
command_bar_background = "#ffffff"
|
||||||
status_background = "#ffffff"
|
status_background = "#ffffff"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ focused_panel_border = "#58a6ff"
|
|||||||
unfocused_panel_border = "#30363d"
|
unfocused_panel_border = "#30363d"
|
||||||
user_message_role = "#79c0ff"
|
user_message_role = "#79c0ff"
|
||||||
assistant_message_role = "#89ddff"
|
assistant_message_role = "#89ddff"
|
||||||
|
tool_output = "#546e7a"
|
||||||
thinking_panel_title = "#9ece6a"
|
thinking_panel_title = "#9ece6a"
|
||||||
command_bar_background = "#161b22"
|
command_bar_background = "#161b22"
|
||||||
status_background = "#161b22"
|
status_background = "#161b22"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ focused_panel_border = "#f92672"
|
|||||||
unfocused_panel_border = "#75715e"
|
unfocused_panel_border = "#75715e"
|
||||||
user_message_role = "#66d9ef"
|
user_message_role = "#66d9ef"
|
||||||
assistant_message_role = "#ae81ff"
|
assistant_message_role = "#ae81ff"
|
||||||
|
tool_output = "#75715e"
|
||||||
thinking_panel_title = "#e6db74"
|
thinking_panel_title = "#e6db74"
|
||||||
command_bar_background = "#272822"
|
command_bar_background = "#272822"
|
||||||
status_background = "#272822"
|
status_background = "#272822"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ focused_panel_border = "#eb6f92"
|
|||||||
unfocused_panel_border = "#26233a"
|
unfocused_panel_border = "#26233a"
|
||||||
user_message_role = "#31748f"
|
user_message_role = "#31748f"
|
||||||
assistant_message_role = "#9ccfd8"
|
assistant_message_role = "#9ccfd8"
|
||||||
|
tool_output = "#6e6a86"
|
||||||
thinking_panel_title = "#c4a7e7"
|
thinking_panel_title = "#c4a7e7"
|
||||||
command_bar_background = "#26233a"
|
command_bar_background = "#26233a"
|
||||||
status_background = "#26233a"
|
status_background = "#26233a"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ focused_panel_border = "#268bd2"
|
|||||||
unfocused_panel_border = "#073642"
|
unfocused_panel_border = "#073642"
|
||||||
user_message_role = "#2aa198"
|
user_message_role = "#2aa198"
|
||||||
assistant_message_role = "#cb4b16"
|
assistant_message_role = "#cb4b16"
|
||||||
|
tool_output = "#657b83"
|
||||||
thinking_panel_title = "#6c71c4"
|
thinking_panel_title = "#6c71c4"
|
||||||
command_bar_background = "#073642"
|
command_bar_background = "#073642"
|
||||||
status_background = "#073642"
|
status_background = "#073642"
|
||||||
|
|||||||
Reference in New Issue
Block a user