Support per-flow remote and Ollama routing

This commit is contained in:
2026-04-11 15:22:07 -04:00
parent 4259d54154
commit e31c7dd2e2
13 changed files with 1006 additions and 725 deletions

View File

@@ -6,20 +6,18 @@ use rig::{
client::completion::CompletionClient, client::completion::CompletionClient,
completion::{Message, Prompt}, completion::{Message, Prompt},
message::ToolChoice, message::ToolChoice,
providers::{anthropic, openai}, providers::openai,
streaming::{StreamedAssistantContent, StreamingPrompt}, streaming::{StreamedAssistantContent, StreamingPrompt},
}; };
use crate::agent::stream_events::AgentStreamEmitter; use crate::agent::stream_events::AgentStreamEmitter;
use crate::agent::tools::terminal_command::{AgentCommandExecutor, RunTerminalCommandTool}; use crate::agent::tools::terminal_command::{AgentCommandExecutor, RunTerminalCommandTool};
use crate::agent::{AgentProviderKind, AgentRuntimeConfig}; use crate::agent::AgentRuntimeConfig;
use crate::error::AppError; use crate::error::AppError;
use crate::state::PendingAgentToolApprovals; use crate::state::PendingAgentToolApprovals;
const SYSTEM_PROMPT: &str = "You are MosaicIQ's terminal chat assistant. Answer concisely in plain text. Use the available terminal command tool whenever current workspace data or live MosaicIQ terminal actions would improve the answer. Never claim to have run a command unless the tool actually ran it. If the request is unclear, ask a short clarifying question."; const SYSTEM_PROMPT: &str = "You are MosaicIQ's terminal chat assistant. Answer concisely in plain text. Use the available terminal command tool whenever current workspace data or live MosaicIQ terminal actions would improve the answer. Never claim to have run a command unless the tool actually ran it. If the request is unclear, ask a short clarifying question.";
const MAX_TOOL_TURNS: usize = 4; const MAX_TOOL_TURNS: usize = 4;
const CHAT_MAX_TOKENS: u64 = 1_024;
const SUMMARY_MAX_TOKENS: u64 = 512;
const SUMMARY_SYSTEM_PROMPT: &str = "You maintain a rolling summary for MosaicIQ terminal chats. Return plain text only. Keep the summary under 1200 characters and roughly 10 bullets. Preserve concrete facts, user preferences, decisions, portfolio actions, unresolved questions, and symbols/tickers. Use exactly these sections: User goals, Established facts, Actions taken, Open threads."; const SUMMARY_SYSTEM_PROMPT: &str = "You maintain a rolling summary for MosaicIQ terminal chats. Return plain text only. Keep the summary under 1200 characters and roughly 10 bullets. Preserve concrete facts, user preferences, decisions, portfolio actions, unresolved questions, and symbols/tickers. Use exactly these sections: User goals, Established facts, Actions taken, Open threads.";
#[derive(Clone)] #[derive(Clone)]
@@ -71,44 +69,21 @@ impl ChatGateway for RigChatGateway {
pending_approvals: tool_runtime.pending_approvals.clone(), pending_approvals: tool_runtime.pending_approvals.clone(),
workspace_id: tool_runtime.workspace_id.clone(), workspace_id: tool_runtime.workspace_id.clone(),
}; };
let client = build_openai_client(&runtime)?;
let rig_stream = client
.agent(runtime.model)
.preamble(SYSTEM_PROMPT)
.temperature(0.2)
.tool(tool)
.tool_choice(ToolChoice::Auto)
.default_max_turns(MAX_TOOL_TURNS)
.build()
.stream_prompt(prompt)
.with_history(history)
.multi_turn(MAX_TOOL_TURNS)
.await;
match runtime.provider_kind { consume_stream(rig_stream, &tool_runtime).await
AgentProviderKind::Remote | AgentProviderKind::OllamaOpenAI => {
let client = build_openai_client(&runtime)?;
let rig_stream = client
.agent(runtime.model)
.preamble(SYSTEM_PROMPT)
.temperature(0.2)
.tool(tool)
.tool_choice(ToolChoice::Auto)
.default_max_turns(MAX_TOOL_TURNS)
.build()
.stream_prompt(prompt)
.with_history(history)
.multi_turn(MAX_TOOL_TURNS)
.await;
consume_stream(rig_stream, &tool_runtime).await
}
AgentProviderKind::OllamaAnthropic => {
let client = build_anthropic_client(&runtime)?;
let rig_stream = client
.agent(runtime.model)
.preamble(SYSTEM_PROMPT)
.temperature(0.2)
.max_tokens(CHAT_MAX_TOKENS)
.tool(tool)
.tool_choice(ToolChoice::Auto)
.default_max_turns(MAX_TOOL_TURNS)
.build()
.stream_prompt(prompt)
.with_history(history)
.multi_turn(MAX_TOOL_TURNS)
.await;
consume_stream(rig_stream, &tool_runtime).await
}
}
}) })
} }
@@ -129,37 +104,18 @@ impl ChatGateway for RigChatGateway {
} }
history.extend(messages); history.extend(messages);
match runtime.provider_kind { let client = build_openai_client(&runtime)?;
AgentProviderKind::Remote | AgentProviderKind::OllamaOpenAI => { let response = client
let client = build_openai_client(&runtime)?; .agent(runtime.model)
let response = client .preamble(SUMMARY_SYSTEM_PROMPT)
.agent(runtime.model) .temperature(0.1)
.preamble(SUMMARY_SYSTEM_PROMPT) .build()
.temperature(0.1) .prompt("Update the rolling summary using the conversation history.")
.build() .with_history(history)
.prompt("Update the rolling summary using the conversation history.") .await
.with_history(history) .map_err(map_prompt_error)?;
.await
.map_err(map_prompt_error)?;
Ok(response.trim().to_string()) Ok(response.trim().to_string())
}
AgentProviderKind::OllamaAnthropic => {
let client = build_anthropic_client(&runtime)?;
let response = client
.agent(runtime.model)
.preamble(SUMMARY_SYSTEM_PROMPT)
.temperature(0.1)
.max_tokens(SUMMARY_MAX_TOKENS)
.build()
.prompt("Update the rolling summary using the conversation history.")
.with_history(history)
.await
.map_err(map_prompt_error)?;
Ok(response.trim().to_string())
}
}
}) })
} }
} }
@@ -174,16 +130,6 @@ fn build_openai_client(runtime: &AgentRuntimeConfig) -> Result<openai::Completio
.map_err(|error| AppError::ProviderInit(error.to_string())) .map_err(|error| AppError::ProviderInit(error.to_string()))
} }
fn build_anthropic_client(runtime: &AgentRuntimeConfig) -> Result<anthropic::Client, AppError> {
let api_key = runtime.api_key.clone().unwrap_or_default();
anthropic::Client::builder()
.api_key(api_key)
.base_url(&runtime.base_url)
.build()
.map_err(|error| AppError::ProviderInit(error.to_string()))
}
fn compose_request_messages(context_messages: Vec<Message>, history: Vec<Message>) -> Vec<Message> { fn compose_request_messages(context_messages: Vec<Message>, history: Vec<Message>) -> Vec<Message> {
context_messages.into_iter().chain(history).collect() context_messages.into_iter().chain(history).collect()
} }

View File

@@ -14,10 +14,11 @@ pub use service::AgentService;
pub(crate) use settings::AgentSettingsService; pub(crate) use settings::AgentSettingsService;
pub use stream_events::AgentStreamEmitter; pub use stream_events::AgentStreamEmitter;
pub use types::{ pub use types::{
default_task_defaults, AgentConfigStatus, AgentProviderKind, AgentRuntimeConfig, default_task_defaults, AgentConfigStatus, AgentProviderKind, AgentProviderStatuses,
AgentStoredSettings, AgentStreamItemEvent, AgentStreamItemKind, AgentTaskRoute, AgentRuntimeConfig, AgentStoredSettings, AgentStreamItemEvent, AgentStreamItemKind,
ChatPanelContext, ChatPromptRequest, ChatStreamStart, PreparedChatTurn, AgentTaskRoute, ChatPanelContext, ChatPromptRequest, ChatStreamStart,
RemoteProviderSettings, ResolveAgentToolApprovalRequest, SaveAgentRuntimeConfigRequest, OllamaProviderSettings, PreparedChatTurn, ProviderConfigStatus, RemoteProviderSettings,
TaskProfile, UpdateRemoteApiKeyRequest, AGENT_SETTINGS_STORE_PATH, ResolveAgentToolApprovalRequest, SaveAgentRuntimeConfigRequest, TaskProfile,
UpdateRemoteApiKeyRequest, AGENT_SETTINGS_STORE_PATH, DEFAULT_OLLAMA_BASE_URL,
DEFAULT_OLLAMA_COMPAT_API_KEY, DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL, DEFAULT_OLLAMA_COMPAT_API_KEY, DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL,
}; };

View File

@@ -13,55 +13,61 @@ pub fn resolve_runtime(
.task_defaults .task_defaults
.get(&task_profile) .get(&task_profile)
.ok_or(AppError::TaskRouteMissing(task_profile))?; .ok_or(AppError::TaskRouteMissing(task_profile))?;
let provider = provider_settings(settings, route.provider);
if !settings.remote.enabled { if !provider.enabled() {
return Err(AppError::ProviderNotConfigured); return Err(AppError::ProviderNotConfigured);
} }
let api_key = settings.remote.api_key.trim().to_string(); let model = resolve_model(settings, task_profile, route, model_override)?;
if settings.remote.provider_kind.uses_api_key() && api_key.is_empty() { let api_key = match route.provider {
return Err(AppError::RemoteApiKeyMissing); AgentProviderKind::Remote => {
} let api_key = settings.remote.api_key.trim().to_string();
if api_key.is_empty() {
return Err(AppError::RemoteApiKeyMissing);
}
Some(api_key)
}
AgentProviderKind::Ollama => Some(DEFAULT_OLLAMA_COMPAT_API_KEY.to_string()),
};
Ok(AgentRuntimeConfig { Ok(AgentRuntimeConfig {
provider_kind: settings.remote.provider_kind, provider_kind: route.provider,
base_url: settings.remote.base_url.clone(), base_url: provider.base_url().to_string(),
model: resolve_model(settings, task_profile, route, model_override)?, model,
api_key: Some(match settings.remote.provider_kind { api_key,
AgentProviderKind::Remote => api_key,
AgentProviderKind::OllamaOpenAI | AgentProviderKind::OllamaAnthropic => {
if api_key.is_empty() {
DEFAULT_OLLAMA_COMPAT_API_KEY.to_string()
} else {
api_key
}
}
}),
}) })
} }
pub fn validate_settings(settings: &AgentStoredSettings) -> Result<(), AppError> { pub fn validate_settings(settings: &AgentStoredSettings) -> Result<(), AppError> {
if settings.remote.base_url.trim().is_empty() {
return Err(AppError::InvalidSettings(
"remote base URL cannot be empty".to_string(),
));
}
if settings.default_remote_model.trim().is_empty() {
return Err(AppError::InvalidSettings(
"default remote model cannot be empty".to_string(),
));
}
for task in TaskProfile::all() { for task in TaskProfile::all() {
let route = settings let route = settings
.task_defaults .task_defaults
.get(&task) .get(&task)
.ok_or(AppError::TaskRouteMissing(task))?; .ok_or(AppError::TaskRouteMissing(task))?;
let model = normalize_route_model(settings, task, route.clone())?.model; let provider = provider_settings(settings, route.provider);
if model.trim().is_empty() { if !provider.enabled() {
return Err(AppError::ModelMissing(task)); return Err(AppError::InvalidSettings(format!(
"{} is routed to disabled provider {}",
task_label(task),
provider.label()
)));
}
if provider.default_model().trim().is_empty() && route.model.trim().is_empty() {
return Err(AppError::InvalidSettings(format!(
"{} is missing a model. Set a task model or a default model for {}.",
task_label(task),
provider.label()
)));
}
if route.provider == AgentProviderKind::Remote && settings.remote.api_key.trim().is_empty() {
return Err(AppError::InvalidSettings(format!(
"{} is routed to Remote, but the Remote API key is missing.",
task_label(task)
)));
} }
} }
@@ -69,29 +75,46 @@ pub fn validate_settings(settings: &AgentStoredSettings) -> Result<(), AppError>
} }
pub fn normalize_routes(settings: &mut AgentStoredSettings) -> Result<(), AppError> { pub fn normalize_routes(settings: &mut AgentStoredSettings) -> Result<(), AppError> {
if settings.task_defaults.is_empty() {
settings.task_defaults = crate::agent::default_task_defaults();
}
for task in TaskProfile::all() { for task in TaskProfile::all() {
let route = settings let route = settings
.task_defaults .task_defaults
.get(&task) .entry(task)
.cloned() .or_insert_with(|| AgentTaskRoute {
.ok_or(AppError::TaskRouteMissing(task))?; provider: AgentProviderKind::Remote,
let normalized = normalize_route_model(settings, task, route)?; model: String::new(),
settings.task_defaults.insert(task, normalized); });
route.model = route.model.trim().to_string();
} }
settings.remote.base_url = settings.remote.base_url.trim().to_string();
settings.remote.default_model = settings.remote.default_model.trim().to_string();
settings.ollama.base_url = settings.ollama.base_url.trim().to_string();
settings.ollama.default_model = settings.ollama.default_model.trim().to_string();
Ok(()) Ok(())
} }
pub fn compute_remote_configured(settings: &AgentStoredSettings) -> bool { pub fn compute_provider_configured(
settings.remote.enabled settings: &AgentStoredSettings,
&& !settings.remote.base_url.trim().is_empty() provider: AgentProviderKind,
&& !settings.default_remote_model.trim().is_empty() ) -> bool {
&& (!settings.remote.provider_kind.uses_api_key() let provider = provider_settings(settings, provider);
|| !settings.remote.api_key.trim().is_empty())
provider.enabled()
&& !provider.base_url().trim().is_empty()
&& !provider.default_model().trim().is_empty()
&& (!provider.uses_api_key() || !settings.remote.api_key.trim().is_empty())
} }
pub fn compute_overall_configured(settings: &AgentStoredSettings) -> bool { pub fn compute_overall_configured(settings: &AgentStoredSettings) -> bool {
validate_settings(settings).is_ok() && compute_remote_configured(settings) TaskProfile::all()
.iter()
.copied()
.all(|task| resolve_runtime(settings, task, None).is_ok())
} }
fn resolve_model( fn resolve_model(
@@ -108,27 +131,78 @@ fn resolve_model(
return Ok(trimmed.to_string()); return Ok(trimmed.to_string());
} }
Ok(normalize_route_model(settings, task_profile, route.clone())?.model) let route_model = route.model.trim();
} if !route_model.is_empty() {
return Ok(route_model.to_string());
fn normalize_route_model(
settings: &AgentStoredSettings,
task_profile: TaskProfile,
route: AgentTaskRoute,
) -> Result<AgentTaskRoute, AppError> {
let trimmed = route.model.trim();
if trimmed.is_empty() {
if settings.default_remote_model.trim().is_empty() {
return Err(AppError::ModelMissing(task_profile));
}
return Ok(AgentTaskRoute {
model: settings.default_remote_model.clone(),
});
} }
Ok(AgentTaskRoute { let provider = provider_settings(settings, route.provider);
model: trimmed.to_string(), let provider_model = provider.default_model().trim();
}) if provider_model.is_empty() {
return Err(AppError::ModelMissing(task_profile));
}
Ok(provider_model.to_string())
}
fn task_label(task: TaskProfile) -> &'static str {
match task {
TaskProfile::InteractiveChat => "Interactive Chat",
TaskProfile::Analysis => "Analysis",
TaskProfile::Summarization => "Summarization",
TaskProfile::ToolUse => "Tool Use",
TaskProfile::NoteEnrichment => "Note Enrichment",
TaskProfile::RelationshipInference => "Relationship Inference",
TaskProfile::GhostSynthesis => "Ghost Synthesis",
TaskProfile::MemoStructuring => "Memo Structuring",
}
}
enum ProviderSettingsRef<'a> {
Remote(&'a crate::agent::RemoteProviderSettings),
Ollama(&'a crate::agent::OllamaProviderSettings),
}
impl ProviderSettingsRef<'_> {
fn enabled(&self) -> bool {
match self {
Self::Remote(settings) => settings.enabled,
Self::Ollama(settings) => settings.enabled,
}
}
fn base_url(&self) -> &str {
match self {
Self::Remote(settings) => &settings.base_url,
Self::Ollama(settings) => &settings.base_url,
}
}
fn default_model(&self) -> &str {
match self {
Self::Remote(settings) => &settings.default_model,
Self::Ollama(settings) => &settings.default_model,
}
}
fn uses_api_key(&self) -> bool {
matches!(self, Self::Remote(_))
}
fn label(&self) -> &'static str {
match self {
Self::Remote(_) => "Remote",
Self::Ollama(_) => "Local (Ollama)",
}
}
}
fn provider_settings<'a>(
settings: &'a AgentStoredSettings,
provider: AgentProviderKind,
) -> ProviderSettingsRef<'a> {
match provider {
AgentProviderKind::Remote => ProviderSettingsRef::Remote(&settings.remote),
AgentProviderKind::Ollama => ProviderSettingsRef::Ollama(&settings.ollama),
}
} }

View File

@@ -6,15 +6,16 @@ use rig::completion::Message;
use tauri::{AppHandle, Runtime}; use tauri::{AppHandle, Runtime};
use crate::agent::{ use crate::agent::{
panel_context::build_panel_context_message, AgentConfigStatus, AgentRuntimeConfig, panel_context::build_panel_context_message, AgentConfigStatus, AgentProviderKind,
AgentStoredSettings, ChatPromptRequest, PreparedChatTurn, RemoteProviderSettings, AgentProviderStatuses, AgentRuntimeConfig, AgentStoredSettings, ChatPromptRequest,
OllamaProviderSettings, PreparedChatTurn, ProviderConfigStatus, RemoteProviderSettings,
RigChatGateway, SaveAgentRuntimeConfigRequest, TaskProfile, UpdateRemoteApiKeyRequest, RigChatGateway, SaveAgentRuntimeConfigRequest, TaskProfile, UpdateRemoteApiKeyRequest,
}; };
use crate::error::AppError; use crate::error::AppError;
use super::gateway::ChatGateway; use super::gateway::ChatGateway;
use super::routing::{ use super::routing::{
compute_overall_configured, compute_remote_configured, normalize_routes, resolve_runtime, compute_overall_configured, compute_provider_configured, normalize_routes, resolve_runtime,
validate_settings, validate_settings,
}; };
use super::settings::AgentSettingsService; use super::settings::AgentSettingsService;
@@ -291,12 +292,16 @@ impl<R: Runtime, G: ChatGateway> AgentService<R, G> {
) -> Result<AgentConfigStatus, AppError> { ) -> Result<AgentConfigStatus, AppError> {
let mut settings = self.settings.load()?; let mut settings = self.settings.load()?;
settings.remote = RemoteProviderSettings { settings.remote = RemoteProviderSettings {
enabled: request.remote_enabled, enabled: request.remote.enabled,
provider_kind: request.provider_kind, base_url: request.remote.base_url.trim().to_string(),
base_url: request.remote_base_url.trim().to_string(),
api_key: settings.remote.api_key, api_key: settings.remote.api_key,
default_model: request.remote.default_model.trim().to_string(),
};
settings.ollama = OllamaProviderSettings {
enabled: request.ollama.enabled,
base_url: request.ollama.base_url.trim().to_string(),
default_model: request.ollama.default_model.trim().to_string(),
}; };
settings.default_remote_model = request.default_remote_model.trim().to_string();
settings.task_defaults = request.task_defaults; settings.task_defaults = request.task_defaults;
settings.sec_edgar_user_agent = request.sec_edgar_user_agent.trim().to_string(); settings.sec_edgar_user_agent = request.sec_edgar_user_agent.trim().to_string();
normalize_routes(&mut settings)?; normalize_routes(&mut settings)?;
@@ -327,14 +332,24 @@ impl<R: Runtime, G: ChatGateway> AgentService<R, G> {
fn build_status(&self, settings: AgentStoredSettings) -> AgentConfigStatus { fn build_status(&self, settings: AgentStoredSettings) -> AgentConfigStatus {
AgentConfigStatus { AgentConfigStatus {
configured: compute_overall_configured(&settings), configured: compute_overall_configured(&settings),
remote_configured: compute_remote_configured(&settings), providers: AgentProviderStatuses {
remote_enabled: settings.remote.enabled, remote: ProviderConfigStatus {
provider_kind: settings.remote.provider_kind, enabled: settings.remote.enabled,
has_remote_api_key: !settings.remote.api_key.trim().is_empty(), configured: compute_provider_configured(&settings, AgentProviderKind::Remote),
has_sec_edgar_user_agent: !settings.sec_edgar_user_agent.trim().is_empty(), base_url: settings.remote.base_url.clone(),
remote_base_url: settings.remote.base_url, default_model: settings.remote.default_model.clone(),
default_remote_model: settings.default_remote_model, has_api_key: !settings.remote.api_key.trim().is_empty(),
},
ollama: ProviderConfigStatus {
enabled: settings.ollama.enabled,
configured: compute_provider_configured(&settings, AgentProviderKind::Ollama),
base_url: settings.ollama.base_url.clone(),
default_model: settings.ollama.default_model.clone(),
has_api_key: false,
},
},
task_defaults: settings.task_defaults, task_defaults: settings.task_defaults,
has_sec_edgar_user_agent: !settings.sec_edgar_user_agent.trim().is_empty(),
sec_edgar_user_agent: settings.sec_edgar_user_agent, sec_edgar_user_agent: settings.sec_edgar_user_agent,
} }
} }
@@ -345,10 +360,6 @@ impl<R: Runtime, G: ChatGateway> AgentService<R, G> {
model_override: Option<String>, model_override: Option<String>,
) -> Result<AgentRuntimeConfig, AppError> { ) -> Result<AgentRuntimeConfig, AppError> {
let settings = self.settings.load()?; let settings = self.settings.load()?;
if !compute_overall_configured(&settings) {
return Err(AppError::AgentNotConfigured);
}
resolve_runtime( resolve_runtime(
&settings, &settings,
task_profile.unwrap_or(TaskProfile::InteractiveChat), task_profile.unwrap_or(TaskProfile::InteractiveChat),

View File

@@ -1,20 +1,29 @@
use std::collections::HashMap;
use serde::Deserialize;
use serde_json::json; use serde_json::json;
use tauri::{AppHandle, Runtime}; use tauri::{AppHandle, Runtime};
use tauri_plugin_store::StoreExt; use tauri_plugin_store::StoreExt;
use crate::agent::{ use crate::agent::{
default_task_defaults, AgentProviderKind, AgentStoredSettings, RemoteProviderSettings, default_task_defaults, AgentProviderKind, AgentStoredSettings, AgentTaskRoute,
AGENT_SETTINGS_STORE_PATH, DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL, OllamaProviderSettings, RemoteProviderSettings, AGENT_SETTINGS_STORE_PATH,
DEFAULT_OLLAMA_BASE_URL, DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL,
}; };
use crate::error::AppError; use crate::error::AppError;
const REMOTE_ENABLED_KEY: &str = "remoteEnabled"; const REMOTE_ENABLED_KEY: &str = "remoteEnabled";
const PROVIDER_KIND_KEY: &str = "providerKind";
const REMOTE_BASE_URL_KEY: &str = "remoteBaseUrl"; const REMOTE_BASE_URL_KEY: &str = "remoteBaseUrl";
const REMOTE_API_KEY_KEY: &str = "remoteApiKey"; const REMOTE_API_KEY_KEY: &str = "remoteApiKey";
const DEFAULT_REMOTE_MODEL_KEY: &str = "defaultRemoteModel"; const REMOTE_DEFAULT_MODEL_KEY: &str = "remoteDefaultModel";
const OLLAMA_ENABLED_KEY: &str = "ollamaEnabled";
const OLLAMA_BASE_URL_KEY: &str = "ollamaBaseUrl";
const OLLAMA_DEFAULT_MODEL_KEY: &str = "ollamaDefaultModel";
const TASK_DEFAULTS_KEY: &str = "taskDefaults"; const TASK_DEFAULTS_KEY: &str = "taskDefaults";
const SEC_EDGAR_USER_AGENT_KEY: &str = "secEdgarUserAgent"; const SEC_EDGAR_USER_AGENT_KEY: &str = "secEdgarUserAgent";
const LEGACY_PROVIDER_KIND_KEY: &str = "providerKind";
const LEGACY_DEFAULT_REMOTE_MODEL_KEY: &str = "defaultRemoteModel";
const LEGACY_BASE_URL_KEY: &str = "baseUrl"; const LEGACY_BASE_URL_KEY: &str = "baseUrl";
const LEGACY_MODEL_KEY: &str = "model"; const LEGACY_MODEL_KEY: &str = "model";
const LEGACY_API_KEY_KEY: &str = "apiKey"; const LEGACY_API_KEY_KEY: &str = "apiKey";
@@ -22,6 +31,12 @@ const LOCAL_ENABLED_KEY: &str = "localEnabled";
const LOCAL_BASE_URL_KEY: &str = "localBaseUrl"; const LOCAL_BASE_URL_KEY: &str = "localBaseUrl";
const LOCAL_AVAILABLE_MODELS_KEY: &str = "localAvailableModels"; const LOCAL_AVAILABLE_MODELS_KEY: &str = "localAvailableModels";
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LegacyAgentTaskRoute {
model: String,
}
/// Manages the provider settings and plaintext API key stored through the Tauri store plugin. /// Manages the provider settings and plaintext API key stored through the Tauri store plugin.
#[derive(Debug)] #[derive(Debug)]
pub struct AgentSettingsService<R: Runtime> { pub struct AgentSettingsService<R: Runtime> {
@@ -49,51 +64,120 @@ impl<R: Runtime> AgentSettingsService<R> {
.store(AGENT_SETTINGS_STORE_PATH) .store(AGENT_SETTINGS_STORE_PATH)
.map_err(|error| AppError::SettingsStore(error.to_string()))?; .map_err(|error| AppError::SettingsStore(error.to_string()))?;
let default_remote_model = store let has_new_schema = store.get(REMOTE_DEFAULT_MODEL_KEY).is_some()
.get(DEFAULT_REMOTE_MODEL_KEY) || store.get(OLLAMA_ENABLED_KEY).is_some()
|| store.get(OLLAMA_BASE_URL_KEY).is_some()
|| store.get(OLLAMA_DEFAULT_MODEL_KEY).is_some();
let legacy_provider = store
.get(LEGACY_PROVIDER_KIND_KEY)
.and_then(|value| serde_json::from_value(value.clone()).ok())
.unwrap_or(AgentProviderKind::Remote);
let remote_api_key = store
.get(REMOTE_API_KEY_KEY)
.and_then(|value| value.as_str().map(ToOwned::to_owned))
.or_else(|| {
store
.get(LEGACY_API_KEY_KEY)
.and_then(|value| value.as_str().map(ToOwned::to_owned))
})
.unwrap_or_default();
let legacy_base_url = store
.get(REMOTE_BASE_URL_KEY)
.and_then(|value| value.as_str().map(ToOwned::to_owned))
.or_else(|| {
store
.get(LEGACY_BASE_URL_KEY)
.and_then(|value| value.as_str().map(ToOwned::to_owned))
});
let legacy_default_model = store
.get(LEGACY_DEFAULT_REMOTE_MODEL_KEY)
.and_then(|value| value.as_str().map(ToOwned::to_owned)) .and_then(|value| value.as_str().map(ToOwned::to_owned))
.or_else(|| { .or_else(|| {
store store
.get(LEGACY_MODEL_KEY) .get(LEGACY_MODEL_KEY)
.and_then(|value| value.as_str().map(ToOwned::to_owned)) .and_then(|value| value.as_str().map(ToOwned::to_owned))
}) });
.unwrap_or_else(|| DEFAULT_REMOTE_MODEL.to_string());
let task_defaults = store let (remote, ollama, migrated_provider) = if has_new_schema {
.get(TASK_DEFAULTS_KEY) let remote = RemoteProviderSettings {
.and_then(|value| serde_json::from_value(value.clone()).ok())
.unwrap_or_else(|| default_task_defaults(&default_remote_model));
Ok(AgentStoredSettings {
remote: RemoteProviderSettings {
enabled: store enabled: store
.get(REMOTE_ENABLED_KEY) .get(REMOTE_ENABLED_KEY)
.and_then(|value| value.as_bool()) .and_then(|value| value.as_bool())
.unwrap_or(true), .unwrap_or(true),
provider_kind: store
.get(PROVIDER_KIND_KEY)
.and_then(|value| serde_json::from_value(value.clone()).ok())
.unwrap_or(AgentProviderKind::Remote),
base_url: store base_url: store
.get(REMOTE_BASE_URL_KEY) .get(REMOTE_BASE_URL_KEY)
.and_then(|value| value.as_str().map(ToOwned::to_owned)) .and_then(|value| value.as_str().map(ToOwned::to_owned))
.or_else(|| {
store
.get(LEGACY_BASE_URL_KEY)
.and_then(|value| value.as_str().map(ToOwned::to_owned))
})
.unwrap_or_else(|| DEFAULT_REMOTE_BASE_URL.to_string()), .unwrap_or_else(|| DEFAULT_REMOTE_BASE_URL.to_string()),
api_key: store api_key: remote_api_key,
.get(REMOTE_API_KEY_KEY) default_model: store
.get(REMOTE_DEFAULT_MODEL_KEY)
.and_then(|value| value.as_str().map(ToOwned::to_owned))
.unwrap_or_else(|| DEFAULT_REMOTE_MODEL.to_string()),
};
let ollama = OllamaProviderSettings {
enabled: store
.get(OLLAMA_ENABLED_KEY)
.and_then(|value| value.as_bool())
.unwrap_or(false),
base_url: store
.get(OLLAMA_BASE_URL_KEY)
.and_then(|value| value.as_str().map(ToOwned::to_owned))
.unwrap_or_else(|| DEFAULT_OLLAMA_BASE_URL.to_string()),
default_model: store
.get(OLLAMA_DEFAULT_MODEL_KEY)
.and_then(|value| value.as_str().map(ToOwned::to_owned)) .and_then(|value| value.as_str().map(ToOwned::to_owned))
.or_else(|| {
store
.get(LEGACY_API_KEY_KEY)
.and_then(|value| value.as_str().map(ToOwned::to_owned))
})
.unwrap_or_default(), .unwrap_or_default(),
}, };
default_remote_model, (remote, ollama, AgentProviderKind::Remote)
} else {
let legacy_enabled = store
.get(REMOTE_ENABLED_KEY)
.and_then(|value| value.as_bool())
.unwrap_or(true);
let legacy_base_url =
legacy_base_url.unwrap_or_else(|| DEFAULT_REMOTE_BASE_URL.to_string());
let legacy_default_model =
legacy_default_model.unwrap_or_else(|| DEFAULT_REMOTE_MODEL.to_string());
match legacy_provider {
AgentProviderKind::Remote => (
RemoteProviderSettings {
enabled: legacy_enabled,
base_url: legacy_base_url,
api_key: remote_api_key,
default_model: legacy_default_model,
},
OllamaProviderSettings::default(),
AgentProviderKind::Remote,
),
AgentProviderKind::Ollama => (
RemoteProviderSettings {
api_key: remote_api_key,
..RemoteProviderSettings::default()
},
OllamaProviderSettings {
enabled: legacy_enabled,
base_url: normalize_legacy_ollama_base_url(&legacy_base_url),
default_model: legacy_default_model,
},
AgentProviderKind::Ollama,
),
}
};
let task_defaults = load_task_defaults(
store.get(TASK_DEFAULTS_KEY).as_ref(),
migrated_provider,
has_new_schema,
);
Ok(AgentStoredSettings {
remote,
ollama,
task_defaults, task_defaults,
sec_edgar_user_agent: store sec_edgar_user_agent: store
.get(SEC_EDGAR_USER_AGENT_KEY) .get(SEC_EDGAR_USER_AGENT_KEY)
@@ -120,41 +204,107 @@ impl<R: Runtime> AgentSettingsService<R> {
.store(AGENT_SETTINGS_STORE_PATH) .store(AGENT_SETTINGS_STORE_PATH)
.map_err(|error| AppError::SettingsStore(error.to_string()))?; .map_err(|error| AppError::SettingsStore(error.to_string()))?;
store.set(REMOTE_ENABLED_KEY.to_string(), json!(settings.remote.enabled));
store.set(REMOTE_BASE_URL_KEY.to_string(), json!(settings.remote.base_url));
store.set(REMOTE_API_KEY_KEY.to_string(), json!(settings.remote.api_key));
store.set( store.set(
REMOTE_ENABLED_KEY.to_string(), REMOTE_DEFAULT_MODEL_KEY.to_string(),
json!(settings.remote.enabled), json!(settings.remote.default_model),
); );
store.set(OLLAMA_ENABLED_KEY.to_string(), json!(settings.ollama.enabled));
store.set(OLLAMA_BASE_URL_KEY.to_string(), json!(settings.ollama.base_url));
store.set( store.set(
PROVIDER_KIND_KEY.to_string(), OLLAMA_DEFAULT_MODEL_KEY.to_string(),
json!(settings.remote.provider_kind), json!(settings.ollama.default_model),
);
store.set(
REMOTE_BASE_URL_KEY.to_string(),
json!(settings.remote.base_url),
);
store.set(
REMOTE_API_KEY_KEY.to_string(),
json!(settings.remote.api_key),
);
store.set(
DEFAULT_REMOTE_MODEL_KEY.to_string(),
json!(settings.default_remote_model),
); );
store.set(TASK_DEFAULTS_KEY.to_string(), json!(settings.task_defaults)); store.set(TASK_DEFAULTS_KEY.to_string(), json!(settings.task_defaults));
store.set( store.set(
SEC_EDGAR_USER_AGENT_KEY.to_string(), SEC_EDGAR_USER_AGENT_KEY.to_string(),
json!(settings.sec_edgar_user_agent), json!(settings.sec_edgar_user_agent),
); );
store.delete(LOCAL_ENABLED_KEY); store.delete(LEGACY_PROVIDER_KIND_KEY);
store.delete(LOCAL_BASE_URL_KEY);
store.delete(LOCAL_AVAILABLE_MODELS_KEY);
store.delete(LEGACY_BASE_URL_KEY); store.delete(LEGACY_BASE_URL_KEY);
store.delete(LEGACY_MODEL_KEY); store.delete(LEGACY_MODEL_KEY);
store.delete(LEGACY_API_KEY_KEY); store.delete(LEGACY_API_KEY_KEY);
store.delete(LEGACY_DEFAULT_REMOTE_MODEL_KEY);
store.delete(LOCAL_ENABLED_KEY);
store.delete(LOCAL_BASE_URL_KEY);
store.delete(LOCAL_AVAILABLE_MODELS_KEY);
store store
.save() .save()
.map_err(|error| AppError::SettingsStore(error.to_string())) .map_err(|error| AppError::SettingsStore(error.to_string()))
} }
} }
fn load_task_defaults(
raw_value: Option<&serde_json::Value>,
migrated_provider: AgentProviderKind,
has_new_schema: bool,
) -> HashMap<crate::agent::TaskProfile, AgentTaskRoute> {
let Some(value) = raw_value else {
return if has_new_schema {
default_task_defaults()
} else {
super::types::default_task_defaults_for(migrated_provider)
};
};
if let Ok(routes) =
serde_json::from_value::<HashMap<crate::agent::TaskProfile, AgentTaskRoute>>(value.clone())
{
return merge_task_defaults(default_task_defaults(), routes);
}
if let Ok(legacy_routes) = serde_json::from_value::<
HashMap<crate::agent::TaskProfile, LegacyAgentTaskRoute>,
>(value.clone())
{
let routes = legacy_routes
.into_iter()
.map(|(task, route)| {
(
task,
AgentTaskRoute {
provider: migrated_provider,
model: route.model,
},
)
})
.collect::<HashMap<_, _>>();
return merge_task_defaults(
super::types::default_task_defaults_for(migrated_provider),
routes,
);
}
if has_new_schema {
default_task_defaults()
} else {
super::types::default_task_defaults_for(migrated_provider)
}
}
fn merge_task_defaults(
mut defaults: HashMap<crate::agent::TaskProfile, AgentTaskRoute>,
overrides: HashMap<crate::agent::TaskProfile, AgentTaskRoute>,
) -> HashMap<crate::agent::TaskProfile, AgentTaskRoute> {
defaults.extend(overrides);
defaults
}
fn normalize_legacy_ollama_base_url(base_url: &str) -> String {
let trimmed = base_url.trim();
if trimmed.is_empty() {
return DEFAULT_OLLAMA_BASE_URL.to_string();
}
if trimmed.ends_with("/v1") {
trimmed.to_string()
} else {
format!("{}/v1", trimmed.trim_end_matches('/'))
}
}

View File

@@ -6,14 +6,10 @@ use crate::terminal::{PanelPayload, TerminalCommandResponse};
/// Default Z.AI coding plan endpoint used by the app. /// Default Z.AI coding plan endpoint used by the app.
pub const DEFAULT_REMOTE_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4"; pub const DEFAULT_REMOTE_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
/// Default model used for plain-text terminal chat. /// Default remote model used for plain-text terminal chat.
pub const DEFAULT_REMOTE_MODEL: &str = "glm-5.1"; pub const DEFAULT_REMOTE_MODEL: &str = "glm-5.1";
/// Default Ollama OpenAI compatibility endpoint used by the app. /// Default Ollama OpenAI compatibility endpoint used by the app.
#[cfg_attr(not(test), allow(dead_code))] pub const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434/v1";
pub const DEFAULT_OLLAMA_OPENAI_BASE_URL: &str = "http://localhost:11434/v1";
/// Default Ollama Anthropic compatibility endpoint used by the app.
#[allow(dead_code)]
pub const DEFAULT_OLLAMA_ANTHROPIC_BASE_URL: &str = "http://localhost:11434";
/// Placeholder token required by Ollama compatibility endpoints but ignored by Ollama. /// Placeholder token required by Ollama compatibility endpoints but ignored by Ollama.
pub const DEFAULT_OLLAMA_COMPAT_API_KEY: &str = "ollama"; pub const DEFAULT_OLLAMA_COMPAT_API_KEY: &str = "ollama";
/// Store file used for agent settings and plaintext API key storage. /// Store file used for agent settings and plaintext API key storage.
@@ -23,8 +19,7 @@ pub const AGENT_SETTINGS_STORE_PATH: &str = "agent-settings.json";
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum AgentProviderKind { pub enum AgentProviderKind {
Remote, Remote,
OllamaOpenAI, Ollama,
OllamaAnthropic,
} }
impl AgentProviderKind { impl AgentProviderKind {
@@ -145,7 +140,7 @@ pub struct ResolveAgentToolApprovalRequest {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct AgentStoredSettings { pub struct AgentStoredSettings {
pub remote: RemoteProviderSettings, pub remote: RemoteProviderSettings,
pub default_remote_model: String, pub ollama: OllamaProviderSettings,
pub task_defaults: HashMap<TaskProfile, AgentTaskRoute>, pub task_defaults: HashMap<TaskProfile, AgentTaskRoute>,
pub sec_edgar_user_agent: String, pub sec_edgar_user_agent: String,
} }
@@ -154,26 +149,40 @@ impl Default for AgentStoredSettings {
fn default() -> Self { fn default() -> Self {
Self { Self {
remote: RemoteProviderSettings::default(), remote: RemoteProviderSettings::default(),
default_remote_model: DEFAULT_REMOTE_MODEL.to_string(), ollama: OllamaProviderSettings::default(),
task_defaults: default_task_defaults(DEFAULT_REMOTE_MODEL), task_defaults: default_task_defaults(),
sec_edgar_user_agent: String::new(), sec_edgar_user_agent: String::new(),
} }
} }
} }
/// Public provider configuration status returned to the webview.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderConfigStatus {
pub enabled: bool,
pub configured: bool,
pub base_url: String,
pub default_model: String,
pub has_api_key: bool,
}
/// Public AI provider status returned to the webview.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentProviderStatuses {
pub remote: ProviderConfigStatus,
pub ollama: ProviderConfigStatus,
}
/// Public configuration status returned to the webview. /// Public configuration status returned to the webview.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct AgentConfigStatus { pub struct AgentConfigStatus {
pub configured: bool, pub configured: bool,
pub remote_configured: bool, pub providers: AgentProviderStatuses,
pub remote_enabled: bool,
pub provider_kind: AgentProviderKind,
pub has_remote_api_key: bool,
pub has_sec_edgar_user_agent: bool,
pub remote_base_url: String,
pub default_remote_model: String,
pub task_defaults: HashMap<TaskProfile, AgentTaskRoute>, pub task_defaults: HashMap<TaskProfile, AgentTaskRoute>,
pub has_sec_edgar_user_agent: bool,
pub sec_edgar_user_agent: String, pub sec_edgar_user_agent: String,
} }
@@ -181,14 +190,28 @@ pub struct AgentConfigStatus {
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SaveAgentRuntimeConfigRequest { pub struct SaveAgentRuntimeConfigRequest {
pub remote_enabled: bool, pub remote: SaveRemoteProviderSettings,
pub provider_kind: AgentProviderKind, pub ollama: SaveOllamaProviderSettings,
pub remote_base_url: String,
pub default_remote_model: String,
pub task_defaults: HashMap<TaskProfile, AgentTaskRoute>, pub task_defaults: HashMap<TaskProfile, AgentTaskRoute>,
pub sec_edgar_user_agent: String, pub sec_edgar_user_agent: String,
} }
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveRemoteProviderSettings {
pub enabled: bool,
pub base_url: String,
pub default_model: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveOllamaProviderSettings {
pub enabled: bool,
pub base_url: String,
pub default_model: String,
}
/// Request payload for rotating the stored remote API key. /// Request payload for rotating the stored remote API key.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -201,18 +224,37 @@ pub struct UpdateRemoteApiKeyRequest {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct RemoteProviderSettings { pub struct RemoteProviderSettings {
pub enabled: bool, pub enabled: bool,
pub provider_kind: AgentProviderKind,
pub base_url: String, pub base_url: String,
pub api_key: String, pub api_key: String,
pub default_model: String,
} }
impl Default for RemoteProviderSettings { impl Default for RemoteProviderSettings {
fn default() -> Self { fn default() -> Self {
Self { Self {
enabled: true, enabled: true,
provider_kind: AgentProviderKind::Remote,
base_url: DEFAULT_REMOTE_BASE_URL.to_string(), base_url: DEFAULT_REMOTE_BASE_URL.to_string(),
api_key: String::new(), api_key: String::new(),
default_model: DEFAULT_REMOTE_MODEL.to_string(),
}
}
}
/// Local Ollama provider settings persisted in the application store.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct OllamaProviderSettings {
pub enabled: bool,
pub base_url: String,
pub default_model: String,
}
impl Default for OllamaProviderSettings {
fn default() -> Self {
Self {
enabled: false,
base_url: DEFAULT_OLLAMA_BASE_URL.to_string(),
default_model: String::new(),
} }
} }
} }
@@ -221,16 +263,24 @@ impl Default for RemoteProviderSettings {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct AgentTaskRoute { pub struct AgentTaskRoute {
pub provider: AgentProviderKind,
pub model: String, pub model: String,
} }
pub fn default_task_defaults(default_remote_model: &str) -> HashMap<TaskProfile, AgentTaskRoute> { pub fn default_task_defaults() -> HashMap<TaskProfile, AgentTaskRoute> {
default_task_defaults_for(AgentProviderKind::Remote)
}
pub(crate) fn default_task_defaults_for(
provider: AgentProviderKind,
) -> HashMap<TaskProfile, AgentTaskRoute> {
let mut defaults = HashMap::new(); let mut defaults = HashMap::new();
for task in TaskProfile::all() { for task in TaskProfile::all() {
defaults.insert( defaults.insert(
task, task,
AgentTaskRoute { AgentTaskRoute {
model: default_remote_model.to_string(), provider,
model: String::new(),
}, },
); );
} }

View File

@@ -77,7 +77,7 @@ fn build_annotation(note: &ResearchNote) -> String {
} }
} }
pub(crate) fn build_model_info(model: &str, task_profile: &str) -> Option<ModelInfo> { pub(crate) fn build_model_info(model: &str, task_profile: &str, provider: &str) -> Option<ModelInfo> {
if model.trim().is_empty() { if model.trim().is_empty() {
return None; return None;
} }
@@ -85,7 +85,7 @@ pub(crate) fn build_model_info(model: &str, task_profile: &str) -> Option<ModelI
Some(ModelInfo { Some(ModelInfo {
task_profile: task_profile.to_string(), task_profile: task_profile.to_string(),
model: model.to_string(), model: model.to_string(),
provider: Some("remote".to_string()), provider: Some(provider.to_string()),
}) })
} }

View File

@@ -910,14 +910,26 @@ impl<R: Runtime + 'static> ResearchService<R> {
return None; return None;
}; };
let defaults = if settings.task_defaults.is_empty() { let defaults = if settings.task_defaults.is_empty() {
default_task_defaults(&settings.default_remote_model) default_task_defaults()
} else { } else {
settings.task_defaults settings.task_defaults
}; };
defaults defaults.get(&task_profile).and_then(|route| {
.get(&task_profile) let model = if route.model.trim().is_empty() {
.map(|route| build_model_info(&route.model, &format!("{task_profile:?}"))) match route.provider {
.flatten() crate::agent::AgentProviderKind::Remote => settings.remote.default_model.as_str(),
crate::agent::AgentProviderKind::Ollama => settings.ollama.default_model.as_str(),
}
} else {
route.model.as_str()
};
let provider = match route.provider {
crate::agent::AgentProviderKind::Remote => "remote",
crate::agent::AgentProviderKind::Ollama => "ollama",
};
build_model_info(model, &format!("{task_profile:?}"), provider)
})
} }
async fn record_audit( async fn record_audit(

File diff suppressed because it is too large Load Diff

View File

@@ -62,6 +62,15 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
}); });
const selectedOption = options.find((opt) => opt.value === value); const selectedOption = options.find((opt) => opt.value === value);
const displayOption =
selectedOption ??
(value.trim()
? {
value,
label: value,
provider: 'Custom',
}
: null);
const handleSelect = (optionValue: string) => { const handleSelect = (optionValue: string) => {
onChange(optionValue); onChange(optionValue);
@@ -116,11 +125,11 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
> >
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<span className="flex-1 min-w-0 truncate"> <span className="flex-1 min-w-0 truncate">
{selectedOption ? ( {displayOption ? (
<span> <span>
{selectedOption.label} {displayOption.label}
{selectedOption.provider && ( {displayOption.provider && (
<span className="ml-2 text-term-text-tertiary">{selectedOption.provider}</span> <span className="ml-2 text-term-text-tertiary">{displayOption.provider}</span>
)} )}
</span> </span>
) : ( ) : (

View File

@@ -90,10 +90,16 @@ export const SecEdgarSettingsCard: React.FC<SecEdgarSettingsCardProps> = ({
try { try {
const nextStatus = await agentSettingsBridge.saveRuntimeConfig({ const nextStatus = await agentSettingsBridge.saveRuntimeConfig({
remoteEnabled: status.remoteEnabled, remote: {
providerKind: status.providerKind, enabled: status.providers.remote.enabled,
remoteBaseUrl: status.remoteBaseUrl, baseUrl: status.providers.remote.baseUrl,
defaultRemoteModel: status.defaultRemoteModel, defaultModel: status.providers.remote.defaultModel,
},
ollama: {
enabled: status.providers.ollama.enabled,
baseUrl: status.providers.ollama.baseUrl,
defaultModel: status.providers.ollama.defaultModel,
},
taskDefaults: status.taskDefaults, taskDefaults: status.taskDefaults,
secEdgarUserAgent: value, secEdgarUserAgent: value,
}); });

View File

@@ -132,13 +132,26 @@ export const SettingsPage: React.FC<SettingsPageProps> = ({
), ),
}, },
{ {
label: "AI provider", label: "Remote provider",
value: status?.remoteEnabled value: status?.providers.remote.enabled
? status ? status.providers.remote.configured
? AGENT_PROVIDER_LABELS[status.providerKind] ? "Ready"
: "Enabled" : "Needs setup"
: "Disabled", : "Disabled",
icon: status?.remoteEnabled ? ( icon: status?.providers.remote.enabled ? (
<Wifi className="h-4 w-4" />
) : (
<WifiOff className="h-4 w-4" />
),
},
{
label: "Local provider",
value: status?.providers.ollama.enabled
? status.providers.ollama.configured
? "Ready"
: "Needs setup"
: "Disabled",
icon: status?.providers.ollama.enabled ? (
<Wifi className="h-4 w-4" /> <Wifi className="h-4 w-4" />
) : ( ) : (
<WifiOff className="h-4 w-4" /> <WifiOff className="h-4 w-4" />
@@ -147,15 +160,11 @@ export const SettingsPage: React.FC<SettingsPageProps> = ({
{ {
label: "API key", label: "API key",
value: status value: status
? status.providerKind === "remote" ? status.providers.remote.hasApiKey
? status.hasRemoteApiKey ? "Stored"
? "Stored" : "Missing"
: "Missing"
: status.hasRemoteApiKey
? "Unused"
: "Optional"
: "Missing", : "Missing",
icon: status?.hasRemoteApiKey ? ( icon: status?.providers.remote.hasApiKey ? (
<KeyRound className="h-4 w-4" /> <KeyRound className="h-4 w-4" />
) : ( ) : (
<Key className="h-4 w-4" /> <Key className="h-4 w-4" />

View File

@@ -1,4 +1,4 @@
export type AgentProviderKind = 'remote' | 'ollama_openai' | 'ollama_anthropic'; export type AgentProviderKind = 'remote' | 'ollama';
export type TaskProfile = export type TaskProfile =
| 'interactiveChat' | 'interactiveChat'
@@ -11,27 +11,46 @@ export type TaskProfile =
| 'memoStructuring'; | 'memoStructuring';
export interface AgentTaskRoute { export interface AgentTaskRoute {
provider: AgentProviderKind;
model: string; model: string;
} }
export interface ProviderConfigStatus {
enabled: boolean;
configured: boolean;
baseUrl: string;
defaultModel: string;
hasApiKey: boolean;
}
export interface AgentProviderStatuses {
remote: ProviderConfigStatus;
ollama: ProviderConfigStatus;
}
export interface AgentConfigStatus { export interface AgentConfigStatus {
configured: boolean; configured: boolean;
remoteConfigured: boolean; providers: AgentProviderStatuses;
remoteEnabled: boolean;
providerKind: AgentProviderKind;
hasRemoteApiKey: boolean;
hasSecEdgarUserAgent: boolean;
remoteBaseUrl: string;
defaultRemoteModel: string;
taskDefaults: Record<TaskProfile, AgentTaskRoute>; taskDefaults: Record<TaskProfile, AgentTaskRoute>;
hasSecEdgarUserAgent: boolean;
secEdgarUserAgent: string; secEdgarUserAgent: string;
} }
export interface SaveRemoteProviderSettings {
enabled: boolean;
baseUrl: string;
defaultModel: string;
}
export interface SaveOllamaProviderSettings {
enabled: boolean;
baseUrl: string;
defaultModel: string;
}
export interface SaveAgentRuntimeConfigRequest { export interface SaveAgentRuntimeConfigRequest {
remoteEnabled: boolean; remote: SaveRemoteProviderSettings;
providerKind: AgentProviderKind; ollama: SaveOllamaProviderSettings;
remoteBaseUrl: string;
defaultRemoteModel: string;
taskDefaults: Record<TaskProfile, AgentTaskRoute>; taskDefaults: Record<TaskProfile, AgentTaskRoute>;
secEdgarUserAgent: string; secEdgarUserAgent: string;
} }
@@ -53,8 +72,7 @@ export const TASK_PROFILES: TaskProfile[] = [
export const AGENT_PROVIDER_LABELS: Record<AgentProviderKind, string> = { export const AGENT_PROVIDER_LABELS: Record<AgentProviderKind, string> = {
remote: 'Remote', remote: 'Remote',
ollama_openai: 'Ollama (OpenAI Compat)', ollama: 'Local (Ollama)',
ollama_anthropic: 'Ollama (Anthropic Compat)',
}; };
export const TASK_LABELS: Record<TaskProfile, string> = { export const TASK_LABELS: Record<TaskProfile, string> = {