feat(commands): expose runtime web toggle
AC: - :web on/off updates tool exposure immediately and persists the toggle. - owlen providers web --enable/--disable reflects the same setting and reports current status. - Help/docs cover the new toggle paths and troubleshooting guidance. Tests: - cargo test -p owlen-cli - cargo test -p owlen-core toggling_web_search_updates_config_and_registry
This commit is contained in:
@@ -35,6 +35,8 @@ pub enum ProvidersCommand {
|
||||
/// Provider identifier to disable.
|
||||
provider: String,
|
||||
},
|
||||
/// Enable or disable the web.search tool exposure.
|
||||
Web(WebCommand),
|
||||
}
|
||||
|
||||
/// Arguments for the `owlen models` command.
|
||||
@@ -45,12 +47,36 @@ pub struct ModelsArgs {
|
||||
pub provider: Option<String>,
|
||||
}
|
||||
|
||||
/// Arguments for managing the web.search tool exposure.
|
||||
#[derive(Debug, Args)]
|
||||
pub struct WebCommand {
|
||||
/// Enable the web.search tool and allow remote lookups.
|
||||
#[arg(long, conflicts_with = "disable")]
|
||||
enable: bool,
|
||||
/// Disable the web.search tool to keep sessions local-only.
|
||||
#[arg(long, conflicts_with = "enable")]
|
||||
disable: bool,
|
||||
}
|
||||
|
||||
impl WebCommand {
|
||||
fn desired_state(&self) -> Option<bool> {
|
||||
if self.enable {
|
||||
Some(true)
|
||||
} else if self.disable {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_providers_command(command: ProvidersCommand) -> Result<()> {
|
||||
match command {
|
||||
ProvidersCommand::List => list_providers(),
|
||||
ProvidersCommand::Status { provider } => status_providers(provider.as_deref()).await,
|
||||
ProvidersCommand::Enable { provider } => toggle_provider(&provider, true),
|
||||
ProvidersCommand::Disable { provider } => toggle_provider(&provider, false),
|
||||
ProvidersCommand::Web(args) => handle_web_command(args),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +232,70 @@ fn verify_provider_filter(config: &Config, filter: Option<&str>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_web_command(args: WebCommand) -> Result<()> {
|
||||
let mut config = tui_config::try_load_config().unwrap_or_default();
|
||||
let initial = web_tool_enabled(&config);
|
||||
|
||||
if let Some(desired) = args.desired_state() {
|
||||
apply_web_toggle(&mut config, desired);
|
||||
tui_config::save_config(&config).map_err(|err| anyhow!(err))?;
|
||||
|
||||
if initial == desired {
|
||||
println!(
|
||||
"Web search tool already {}.",
|
||||
if desired { "enabled" } else { "disabled" }
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"Web search tool {}.",
|
||||
if desired { "enabled" } else { "disabled" }
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"Remote search is {}.",
|
||||
if config.privacy.enable_remote_search {
|
||||
"enabled"
|
||||
} else {
|
||||
"disabled"
|
||||
}
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"Web search tool is {}.",
|
||||
if initial { "enabled" } else { "disabled" }
|
||||
);
|
||||
println!(
|
||||
"Remote search is {}.",
|
||||
if config.privacy.enable_remote_search {
|
||||
"enabled"
|
||||
} else {
|
||||
"disabled"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_web_toggle(config: &mut Config, enabled: bool) {
|
||||
config.tools.web_search.enabled = enabled;
|
||||
config.privacy.enable_remote_search = enabled;
|
||||
|
||||
if enabled
|
||||
&& !config
|
||||
.security
|
||||
.allowed_tools
|
||||
.iter()
|
||||
.any(|tool| tool.eq_ignore_ascii_case("web_search"))
|
||||
{
|
||||
config.security.allowed_tools.push("web_search".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn web_tool_enabled(config: &Config) -> bool {
|
||||
config.tools.web_search.enabled && config.privacy.enable_remote_search
|
||||
}
|
||||
|
||||
fn toggle_provider(provider: &str, enable: bool) -> Result<()> {
|
||||
let mut config = tui_config::try_load_config().unwrap_or_default();
|
||||
let canonical = canonical_provider_id(provider);
|
||||
@@ -649,3 +739,56 @@ impl ProviderStatusRow {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn apply_web_toggle_updates_flags_and_allowed_tools() {
|
||||
let mut config = Config::default();
|
||||
config.privacy.enable_remote_search = false;
|
||||
config.tools.web_search.enabled = false;
|
||||
config.security.allowed_tools.clear();
|
||||
|
||||
apply_web_toggle(&mut config, true);
|
||||
assert!(config.tools.web_search.enabled);
|
||||
assert!(config.privacy.enable_remote_search);
|
||||
assert_eq!(
|
||||
1,
|
||||
config
|
||||
.security
|
||||
.allowed_tools
|
||||
.iter()
|
||||
.filter(|tool| tool.eq_ignore_ascii_case("web_search"))
|
||||
.count()
|
||||
);
|
||||
|
||||
apply_web_toggle(&mut config, false);
|
||||
assert!(!config.tools.web_search.enabled);
|
||||
assert!(!config.privacy.enable_remote_search);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_web_toggle_does_not_duplicate_allowed_entries() {
|
||||
let mut config = Config::default();
|
||||
config
|
||||
.security
|
||||
.allowed_tools
|
||||
.retain(|tool| !tool.eq_ignore_ascii_case("web_search"));
|
||||
config.security.allowed_tools.push("web_search".to_string());
|
||||
|
||||
apply_web_toggle(&mut config, true);
|
||||
apply_web_toggle(&mut config, true);
|
||||
|
||||
assert_eq!(
|
||||
1,
|
||||
config
|
||||
.security
|
||||
.allowed_tools
|
||||
.iter()
|
||||
.filter(|tool| tool.eq_ignore_ascii_case("web_search"))
|
||||
.count()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
140
crates/owlen-core/tests/web_search_toggle.rs
Normal file
140
crates/owlen-core/tests/web_search_toggle.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
use std::{any::Any, collections::HashMap, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::stream;
|
||||
use owlen_core::{
|
||||
ChatStream, Provider, Result,
|
||||
config::Config,
|
||||
llm::ProviderConfig,
|
||||
session::SessionController,
|
||||
storage::StorageManager,
|
||||
types::{ChatRequest, ChatResponse, Message, ModelInfo},
|
||||
ui::NoOpUiController,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use tempfile::tempdir;
|
||||
|
||||
struct StubCloudProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for StubCloudProvider {
|
||||
fn name(&self) -> &str {
|
||||
"ollama_cloud"
|
||||
}
|
||||
|
||||
async fn list_models(&self) -> Result<Vec<ModelInfo>> {
|
||||
Ok(vec![ModelInfo {
|
||||
id: "stub-cloud-model".to_string(),
|
||||
name: "Stub Cloud Model".to_string(),
|
||||
description: Some("Stub model for web toggle tests".to_string()),
|
||||
provider: self.name().to_string(),
|
||||
context_window: Some(8192),
|
||||
capabilities: vec!["chat".to_string(), "tools".to_string()],
|
||||
supports_tools: true,
|
||||
}])
|
||||
}
|
||||
|
||||
async fn send_prompt(&self, _request: ChatRequest) -> Result<ChatResponse> {
|
||||
Ok(ChatResponse {
|
||||
message: Message::assistant(String::new()),
|
||||
usage: None,
|
||||
is_streaming: false,
|
||||
is_final: true,
|
||||
})
|
||||
}
|
||||
|
||||
async fn stream_prompt(&self, _request: ChatRequest) -> Result<ChatStream> {
|
||||
Ok(Box::pin(stream::empty()))
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &(dyn Any + Send + Sync) {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn toggling_web_search_updates_config_and_registry() {
|
||||
let temp_dir = tempdir().expect("temp dir");
|
||||
let storage = Arc::new(
|
||||
StorageManager::with_database_path(temp_dir.path().join("owlen-tests.db"))
|
||||
.await
|
||||
.expect("storage"),
|
||||
);
|
||||
|
||||
let mut config = Config::default();
|
||||
config.privacy.encrypt_local_data = false;
|
||||
config.general.default_model = Some("stub-cloud-model".into());
|
||||
config.general.default_provider = "ollama_cloud".into();
|
||||
|
||||
let mut provider_cfg = ProviderConfig {
|
||||
enabled: true,
|
||||
provider_type: "ollama_cloud".to_string(),
|
||||
base_url: Some("https://ollama.com".to_string()),
|
||||
api_key: Some("test-key".to_string()),
|
||||
api_key_env: None,
|
||||
extra: HashMap::new(),
|
||||
};
|
||||
provider_cfg.extra.insert(
|
||||
"web_search_endpoint".into(),
|
||||
Value::String("/api/web_search".into()),
|
||||
);
|
||||
config.providers.insert("ollama_cloud".into(), provider_cfg);
|
||||
|
||||
let provider: Arc<dyn Provider> = Arc::new(StubCloudProvider);
|
||||
let ui = Arc::new(NoOpUiController);
|
||||
|
||||
let mut session = SessionController::new(provider, config, storage, ui, false, None)
|
||||
.await
|
||||
.expect("session controller");
|
||||
|
||||
assert!(
|
||||
!session
|
||||
.tool_registry()
|
||||
.tools()
|
||||
.iter()
|
||||
.any(|tool| tool == "web_search"),
|
||||
"web_search should be disabled by default"
|
||||
);
|
||||
|
||||
session
|
||||
.set_tool_enabled("web_search", true)
|
||||
.await
|
||||
.expect("enable web_search");
|
||||
|
||||
{
|
||||
let cfg = session.config_async().await;
|
||||
assert!(cfg.tools.web_search.enabled);
|
||||
assert!(cfg.privacy.enable_remote_search);
|
||||
}
|
||||
assert!(
|
||||
session
|
||||
.tool_registry()
|
||||
.tools()
|
||||
.iter()
|
||||
.any(|tool| tool == "web_search"),
|
||||
"web_search should be registered when enabled"
|
||||
);
|
||||
|
||||
session
|
||||
.set_tool_enabled("web_search", false)
|
||||
.await
|
||||
.expect("disable web_search");
|
||||
|
||||
{
|
||||
let cfg = session.config_async().await;
|
||||
assert!(!cfg.tools.web_search.enabled);
|
||||
assert!(!cfg.privacy.enable_remote_search);
|
||||
}
|
||||
assert!(
|
||||
!session
|
||||
.tool_registry()
|
||||
.tools()
|
||||
.iter()
|
||||
.any(|tool| tool == "web_search"),
|
||||
"web_search should be removed when disabled"
|
||||
);
|
||||
}
|
||||
@@ -1661,6 +1661,16 @@ impl ChatApp {
|
||||
self.error = None;
|
||||
}
|
||||
|
||||
async fn set_web_tool_enabled(&mut self, enabled: bool) -> Result<()> {
|
||||
self.controller
|
||||
.set_tool_enabled("web_search", enabled)
|
||||
.await
|
||||
.map_err(|err| anyhow!(err))?;
|
||||
config::save_config(&self.controller.config())?;
|
||||
self.refresh_usage_summary().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Override the status line with a custom message.
|
||||
pub fn set_status_message<S: Into<String>>(&mut self, status: S) {
|
||||
self.status = status.into();
|
||||
@@ -7459,6 +7469,78 @@ impl ChatApp {
|
||||
self.set_input_mode(InputMode::Normal);
|
||||
return Ok(AppState::Running);
|
||||
}
|
||||
"web" => {
|
||||
let action =
|
||||
args.get(0).map(|value| value.to_ascii_lowercase());
|
||||
match action.as_deref() {
|
||||
Some("on") | Some("enable") => {
|
||||
match self.set_web_tool_enabled(true).await {
|
||||
Ok(_) => {
|
||||
self.status = "Web search enabled; remote lookups allowed."
|
||||
.to_string();
|
||||
self.error = None;
|
||||
self.push_toast(
|
||||
ToastLevel::Info,
|
||||
"Web search enabled; remote lookups allowed.",
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
self.status =
|
||||
"Failed to enable web search".to_string();
|
||||
self.error = Some(format!(
|
||||
"Failed to enable web search: {}",
|
||||
err
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("off") | Some("disable") => {
|
||||
match self.set_web_tool_enabled(false).await {
|
||||
Ok(_) => {
|
||||
self.status =
|
||||
"Web search disabled; staying local."
|
||||
.to_string();
|
||||
self.error = None;
|
||||
self.push_toast(
|
||||
ToastLevel::Warning,
|
||||
"Web search disabled; staying local.",
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
self.status =
|
||||
"Failed to disable web search".to_string();
|
||||
self.error = Some(format!(
|
||||
"Failed to disable web search: {}",
|
||||
err
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("status") | None => {
|
||||
{
|
||||
let config = self.controller.config();
|
||||
let enabled = config.tools.web_search.enabled
|
||||
&& config.privacy.enable_remote_search;
|
||||
if enabled {
|
||||
self.status =
|
||||
"Web search is enabled.".to_string();
|
||||
} else {
|
||||
self.status =
|
||||
"Web search is disabled.".to_string();
|
||||
}
|
||||
}
|
||||
self.error = None;
|
||||
}
|
||||
_ => {
|
||||
self.status = "Usage: :web <on|off|status>".to_string();
|
||||
self.error =
|
||||
Some("Usage: :web <on|off|status>".to_string());
|
||||
}
|
||||
}
|
||||
self.command_palette.clear();
|
||||
self.set_input_mode(InputMode::Normal);
|
||||
return Ok(AppState::Running);
|
||||
}
|
||||
"privacy-enable" => {
|
||||
if let Some(tool) = args.first() {
|
||||
match self.controller.set_tool_enabled(tool, true).await {
|
||||
|
||||
@@ -4244,6 +4244,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||
Line::from(" :provider <name> [auto|local|cloud] → switch provider or set mode"),
|
||||
Line::from(" :models --local | --cloud → focus models by scope"),
|
||||
Line::from(" :cloud setup [--force-cloud-base-url] → configure Ollama Cloud"),
|
||||
Line::from(" :web on|off → expose or hide the web.search tool"),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"SESSION MANAGEMENT",
|
||||
|
||||
Reference in New Issue
Block a user