Add remote/local agent provider routing
This commit is contained in:
169
MosaicIQ/src-tauri/Cargo.lock
generated
169
MosaicIQ/src-tauri/Cargo.lock
generated
@@ -593,7 +593,7 @@ dependencies = [
|
|||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types 0.5.0",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1087,6 +1087,15 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||||
|
dependencies = [
|
||||||
|
"foreign-types-shared 0.1.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types"
|
name = "foreign-types"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -1094,7 +1103,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foreign-types-macros",
|
"foreign-types-macros",
|
||||||
"foreign-types-shared",
|
"foreign-types-shared 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1108,6 +1117,12 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types-shared"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types-shared"
|
name = "foreign-types-shared"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -1725,6 +1740,22 @@ dependencies = [
|
|||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-tls"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"native-tls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -2304,6 +2335,7 @@ name = "mosaiciq"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures",
|
"futures",
|
||||||
|
"reqwest 0.12.28",
|
||||||
"rig-core",
|
"rig-core",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -2344,6 +2376,23 @@ dependencies = [
|
|||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "native-tls"
|
||||||
|
version = "0.2.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"openssl",
|
||||||
|
"openssl-probe",
|
||||||
|
"openssl-sys",
|
||||||
|
"schannel",
|
||||||
|
"security-framework",
|
||||||
|
"security-framework-sys",
|
||||||
|
"tempfile",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -2574,12 +2623,50 @@ dependencies = [
|
|||||||
"pathdiff",
|
"pathdiff",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl"
|
||||||
|
version = "0.10.76"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
"cfg-if",
|
||||||
|
"foreign-types 0.3.2",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"openssl-macros",
|
||||||
|
"openssl-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-macros"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-probe"
|
name = "openssl-probe"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-sys"
|
||||||
|
version = "0.9.112"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -3320,6 +3407,46 @@ version = "0.8.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "reqwest"
|
||||||
|
version = "0.12.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"bytes",
|
||||||
|
"encoding_rs",
|
||||||
|
"futures-core",
|
||||||
|
"h2",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
|
"hyper-tls",
|
||||||
|
"hyper-util",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"mime",
|
||||||
|
"native-tls",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
|
"tower",
|
||||||
|
"tower-http",
|
||||||
|
"tower-service",
|
||||||
|
"url",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.2"
|
version = "0.13.2"
|
||||||
@@ -3385,7 +3512,7 @@ dependencies = [
|
|||||||
"nanoid",
|
"nanoid",
|
||||||
"ordered-float",
|
"ordered-float",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"reqwest",
|
"reqwest 0.13.2",
|
||||||
"schemars 1.2.1",
|
"schemars 1.2.1",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -3520,6 +3647,12 @@ version = "1.0.22"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "same-file"
|
name = "same-file"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
@@ -3773,6 +3906,18 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_urlencoded"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with"
|
name = "serde_with"
|
||||||
version = "3.18.0"
|
version = "3.18.0"
|
||||||
@@ -4210,7 +4355,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"plist",
|
"plist",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"reqwest",
|
"reqwest 0.13.2",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
@@ -4605,6 +4750,16 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-native-tls"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||||
|
dependencies = [
|
||||||
|
"native-tls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.4"
|
version = "0.26.4"
|
||||||
@@ -5028,6 +5183,12 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version-compare"
|
name = "version-compare"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ rig-core = "0.34.0"
|
|||||||
tauri-plugin-store = "2"
|
tauri-plugin-store = "2"
|
||||||
tokio = { version = "1", features = ["time"] }
|
tokio = { version = "1", features = ["time"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tauri = { version = "2", features = ["test"] }
|
tauri = { version = "2", features = ["test"] }
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use rig::{
|
|||||||
streaming::StreamedAssistantContent,
|
streaming::StreamedAssistantContent,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::agent::AgentRuntimeConfig;
|
use crate::agent::{AgentRuntimeConfig, ProviderMode};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
const SYSTEM_PROMPT: &str = "You are MosaicIQ's terminal chat assistant. Answer concisely in plain text. Do not claim to run tools, commands, or file operations. 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. Do not claim to run tools, commands, or file operations. If the request is unclear, ask a short clarifying question.";
|
||||||
@@ -39,8 +39,13 @@ impl ChatGateway for RigChatGateway {
|
|||||||
history: Vec<Message>,
|
history: Vec<Message>,
|
||||||
) -> BoxFuture<'static, Result<ChatGatewayStream, AppError>> {
|
) -> BoxFuture<'static, Result<ChatGatewayStream, AppError>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
|
let _task_profile = runtime.task_profile;
|
||||||
|
let api_key = match runtime.provider_mode {
|
||||||
|
ProviderMode::Remote => runtime.api_key.unwrap_or_default(),
|
||||||
|
ProviderMode::Local => "local".to_string(),
|
||||||
|
};
|
||||||
let client = openai::CompletionsClient::builder()
|
let client = openai::CompletionsClient::builder()
|
||||||
.api_key(runtime.api_key)
|
.api_key(api_key)
|
||||||
.base_url(&runtime.base_url)
|
.base_url(&runtime.base_url)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|error| AppError::ProviderInit(error.to_string()))?;
|
.map_err(|error| AppError::ProviderInit(error.to_string()))?;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Agent domain logic and request/response types.
|
//! Agent domain logic and request/response types.
|
||||||
|
|
||||||
mod gateway;
|
mod gateway;
|
||||||
|
mod routing;
|
||||||
mod service;
|
mod service;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod types;
|
mod types;
|
||||||
@@ -8,8 +9,10 @@ mod types;
|
|||||||
pub use gateway::{ChatGateway, RigChatGateway};
|
pub use gateway::{ChatGateway, RigChatGateway};
|
||||||
pub use service::AgentService;
|
pub use service::AgentService;
|
||||||
pub use types::{
|
pub use types::{
|
||||||
AgentConfigStatus, AgentDeltaEvent, AgentErrorEvent, AgentResultEvent, AgentRuntimeConfig,
|
default_task_defaults, AgentConfigStatus, AgentDeltaEvent, AgentErrorEvent, AgentResultEvent,
|
||||||
AgentStoredSettings, ChatPromptRequest, ChatStreamStart, PreparedChatTurn,
|
AgentRuntimeConfig, AgentStoredSettings, AgentTaskRoute, ChatPromptRequest, ChatStreamStart,
|
||||||
SaveAgentSettingsRequest, UpdateAgentApiKeyRequest, AGENT_SETTINGS_STORE_PATH,
|
LocalModelList, LocalProviderHealthStatus, LocalProviderSettings, PreparedChatTurn,
|
||||||
DEFAULT_AGENT_BASE_URL, DEFAULT_AGENT_MODEL,
|
ProviderMode, RemoteProviderSettings, SaveAgentRuntimeConfigRequest, TaskProfile,
|
||||||
|
UpdateRemoteApiKeyRequest, AGENT_SETTINGS_STORE_PATH, DEFAULT_LOCAL_BASE_URL,
|
||||||
|
DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL,
|
||||||
};
|
};
|
||||||
|
|||||||
213
MosaicIQ/src-tauri/src/agent/routing.rs
Normal file
213
MosaicIQ/src-tauri/src/agent/routing.rs
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
use crate::agent::{
|
||||||
|
AgentRuntimeConfig, AgentStoredSettings, AgentTaskRoute, ProviderMode, TaskProfile,
|
||||||
|
};
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
pub fn resolve_runtime(
|
||||||
|
settings: &AgentStoredSettings,
|
||||||
|
task_profile: TaskProfile,
|
||||||
|
provider_override: Option<ProviderMode>,
|
||||||
|
model_override: Option<String>,
|
||||||
|
) -> Result<AgentRuntimeConfig, AppError> {
|
||||||
|
let route = settings
|
||||||
|
.task_defaults
|
||||||
|
.get(&task_profile)
|
||||||
|
.ok_or(AppError::TaskRouteMissing(task_profile))?;
|
||||||
|
|
||||||
|
let provider_mode = provider_override.unwrap_or(route.provider_mode);
|
||||||
|
let model = resolve_model(settings, task_profile, route, provider_mode, model_override)?;
|
||||||
|
|
||||||
|
match provider_mode {
|
||||||
|
ProviderMode::Remote => {
|
||||||
|
if !settings.remote.enabled {
|
||||||
|
return Err(AppError::ProviderNotConfigured(ProviderMode::Remote));
|
||||||
|
}
|
||||||
|
|
||||||
|
let api_key = settings.remote.api_key.trim().to_string();
|
||||||
|
if api_key.is_empty() {
|
||||||
|
return Err(AppError::RemoteApiKeyMissing);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(AgentRuntimeConfig {
|
||||||
|
provider_mode,
|
||||||
|
base_url: settings.remote.base_url.clone(),
|
||||||
|
model,
|
||||||
|
api_key: Some(api_key),
|
||||||
|
task_profile,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ProviderMode::Local => {
|
||||||
|
if !settings.local.enabled {
|
||||||
|
return Err(AppError::ProviderNotConfigured(ProviderMode::Local));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(AgentRuntimeConfig {
|
||||||
|
provider_mode,
|
||||||
|
base_url: settings.local.base_url.clone(),
|
||||||
|
model,
|
||||||
|
api_key: None,
|
||||||
|
task_profile,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.local.base_url.trim().is_empty() {
|
||||||
|
return Err(AppError::InvalidSettings(
|
||||||
|
"local 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() {
|
||||||
|
let route = settings
|
||||||
|
.task_defaults
|
||||||
|
.get(&task)
|
||||||
|
.ok_or(AppError::TaskRouteMissing(task))?;
|
||||||
|
let model = normalize_route_model(settings, task, route.clone())?.model;
|
||||||
|
|
||||||
|
match route.provider_mode {
|
||||||
|
ProviderMode::Remote => {
|
||||||
|
if !settings.remote.enabled {
|
||||||
|
return Err(AppError::ProviderNotConfigured(ProviderMode::Remote));
|
||||||
|
}
|
||||||
|
if model.trim().is_empty() {
|
||||||
|
return Err(AppError::ModelMissing(task));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ProviderMode::Local => {
|
||||||
|
if !settings.local.enabled {
|
||||||
|
return Err(AppError::ProviderNotConfigured(ProviderMode::Local));
|
||||||
|
}
|
||||||
|
if model.trim().is_empty() {
|
||||||
|
return Err(AppError::ModelMissing(task));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_routes(settings: &mut AgentStoredSettings) -> Result<(), AppError> {
|
||||||
|
for task in TaskProfile::all() {
|
||||||
|
let route = settings
|
||||||
|
.task_defaults
|
||||||
|
.get(&task)
|
||||||
|
.cloned()
|
||||||
|
.ok_or(AppError::TaskRouteMissing(task))?;
|
||||||
|
let normalized = normalize_route_model(settings, task, route)?;
|
||||||
|
settings.task_defaults.insert(task, normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compute_remote_configured(settings: &AgentStoredSettings) -> bool {
|
||||||
|
settings.remote.enabled
|
||||||
|
&& !settings.remote.base_url.trim().is_empty()
|
||||||
|
&& !settings.remote.api_key.trim().is_empty()
|
||||||
|
&& !settings.default_remote_model.trim().is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compute_local_configured(settings: &AgentStoredSettings) -> bool {
|
||||||
|
settings.local.enabled && !settings.local.base_url.trim().is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compute_overall_configured(settings: &AgentStoredSettings) -> bool {
|
||||||
|
if validate_settings(settings).is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let route = match settings.task_defaults.get(&TaskProfile::InteractiveChat) {
|
||||||
|
Some(route) => route,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
match route.provider_mode {
|
||||||
|
ProviderMode::Remote => compute_remote_configured(settings),
|
||||||
|
ProviderMode::Local => compute_local_configured(settings),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_model(
|
||||||
|
settings: &AgentStoredSettings,
|
||||||
|
task_profile: TaskProfile,
|
||||||
|
route: &AgentTaskRoute,
|
||||||
|
provider_mode: ProviderMode,
|
||||||
|
model_override: Option<String>,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
if let Some(model) = model_override {
|
||||||
|
let trimmed = model.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(AppError::ModelMissing(task_profile));
|
||||||
|
}
|
||||||
|
return Ok(trimmed.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = normalize_route_model(settings, task_profile, route.clone())?;
|
||||||
|
|
||||||
|
match provider_mode {
|
||||||
|
ProviderMode::Remote if normalized.provider_mode == ProviderMode::Local => {
|
||||||
|
Ok(settings.default_remote_model.clone())
|
||||||
|
}
|
||||||
|
ProviderMode::Local if normalized.provider_mode == ProviderMode::Remote => {
|
||||||
|
let fallback = settings
|
||||||
|
.local
|
||||||
|
.available_models
|
||||||
|
.first()
|
||||||
|
.cloned()
|
||||||
|
.ok_or(AppError::ModelMissing(task_profile))?;
|
||||||
|
Ok(fallback)
|
||||||
|
}
|
||||||
|
_ => Ok(normalized.model),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_route_model(
|
||||||
|
settings: &AgentStoredSettings,
|
||||||
|
task_profile: TaskProfile,
|
||||||
|
route: AgentTaskRoute,
|
||||||
|
) -> Result<AgentTaskRoute, AppError> {
|
||||||
|
let trimmed = route.model.trim();
|
||||||
|
|
||||||
|
match route.provider_mode {
|
||||||
|
ProviderMode::Remote => Ok(AgentTaskRoute {
|
||||||
|
provider_mode: ProviderMode::Remote,
|
||||||
|
model: if trimmed.is_empty() {
|
||||||
|
settings.default_remote_model.clone()
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ProviderMode::Local => {
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Ok(AgentTaskRoute {
|
||||||
|
provider_mode: ProviderMode::Local,
|
||||||
|
model: trimmed.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(model) = settings.local.available_models.first() {
|
||||||
|
return Ok(AgentTaskRoute {
|
||||||
|
provider_mode: ProviderMode::Local,
|
||||||
|
model: model.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(AppError::ModelMissing(task_profile))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,11 +7,16 @@ use tauri::{AppHandle, Runtime};
|
|||||||
|
|
||||||
use crate::agent::{
|
use crate::agent::{
|
||||||
AgentConfigStatus, AgentRuntimeConfig, AgentStoredSettings, ChatPromptRequest,
|
AgentConfigStatus, AgentRuntimeConfig, AgentStoredSettings, ChatPromptRequest,
|
||||||
PreparedChatTurn, RigChatGateway, SaveAgentSettingsRequest, UpdateAgentApiKeyRequest,
|
LocalProviderSettings, PreparedChatTurn, ProviderMode, RemoteProviderSettings, RigChatGateway,
|
||||||
|
SaveAgentRuntimeConfigRequest, TaskProfile, UpdateRemoteApiKeyRequest,
|
||||||
};
|
};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
use super::gateway::ChatGateway;
|
use super::gateway::ChatGateway;
|
||||||
|
use super::routing::{
|
||||||
|
compute_local_configured, compute_overall_configured, compute_remote_configured,
|
||||||
|
normalize_routes, resolve_runtime, validate_settings,
|
||||||
|
};
|
||||||
use super::settings::AgentSettingsService;
|
use super::settings::AgentSettingsService;
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
@@ -95,7 +100,11 @@ impl<R: Runtime, G: ChatGateway> AgentService<R, G> {
|
|||||||
&mut self,
|
&mut self,
|
||||||
request: ChatPromptRequest,
|
request: ChatPromptRequest,
|
||||||
) -> Result<PreparedChatTurn, AppError> {
|
) -> Result<PreparedChatTurn, AppError> {
|
||||||
let runtime = self.resolve_runtime()?;
|
let runtime = self.resolve_runtime(
|
||||||
|
request.agent_profile,
|
||||||
|
request.provider_override,
|
||||||
|
request.model_override.clone(),
|
||||||
|
)?;
|
||||||
self.session_manager.prepare_turn(request, runtime)
|
self.session_manager.prepare_turn(request, runtime)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,81 +124,104 @@ impl<R: Runtime, G: ChatGateway> AgentService<R, G> {
|
|||||||
Ok(self.build_status(settings))
|
Ok(self.build_status(settings))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist the base URL and model.
|
/// Persist the provider and task routing configuration.
|
||||||
pub fn save_settings(
|
pub fn save_runtime_config(
|
||||||
&mut self,
|
&mut self,
|
||||||
request: SaveAgentSettingsRequest,
|
request: SaveAgentRuntimeConfigRequest,
|
||||||
) -> Result<AgentConfigStatus, AppError> {
|
) -> Result<AgentConfigStatus, AppError> {
|
||||||
let base_url = request.base_url.trim();
|
|
||||||
let model = request.model.trim();
|
|
||||||
|
|
||||||
if base_url.is_empty() {
|
|
||||||
return Err(AppError::InvalidSettings(
|
|
||||||
"base URL cannot be empty".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if model.is_empty() {
|
|
||||||
return Err(AppError::InvalidSettings(
|
|
||||||
"model cannot be empty".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut settings = self.settings.load()?;
|
let mut settings = self.settings.load()?;
|
||||||
settings.base_url = base_url.to_string();
|
settings.remote = RemoteProviderSettings {
|
||||||
settings.model = model.to_string();
|
enabled: request.remote_enabled,
|
||||||
|
base_url: request.remote_base_url.trim().to_string(),
|
||||||
|
api_key: settings.remote.api_key,
|
||||||
|
};
|
||||||
|
settings.local = LocalProviderSettings {
|
||||||
|
enabled: request.local_enabled,
|
||||||
|
base_url: request.local_base_url.trim().to_string(),
|
||||||
|
available_models: normalize_models(request.local_available_models),
|
||||||
|
};
|
||||||
|
settings.default_remote_model = request.default_remote_model.trim().to_string();
|
||||||
|
settings.task_defaults = request.task_defaults;
|
||||||
|
normalize_routes(&mut settings)?;
|
||||||
|
validate_settings(&settings)?;
|
||||||
|
|
||||||
let persisted = self.settings.save(settings)?;
|
let persisted = self.settings.save(settings)?;
|
||||||
Ok(self.build_status(persisted))
|
Ok(self.build_status(persisted))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save or replace the plaintext API key.
|
/// Save or replace the plaintext remote API key.
|
||||||
pub fn update_api_key(
|
pub fn update_remote_api_key(
|
||||||
&mut self,
|
&mut self,
|
||||||
request: UpdateAgentApiKeyRequest,
|
request: UpdateRemoteApiKeyRequest,
|
||||||
) -> Result<AgentConfigStatus, AppError> {
|
) -> Result<AgentConfigStatus, AppError> {
|
||||||
let api_key = request.api_key.trim().to_string();
|
let api_key = request.api_key.trim().to_string();
|
||||||
if api_key.is_empty() {
|
if api_key.is_empty() {
|
||||||
return Err(AppError::ApiKeyMissing);
|
return Err(AppError::RemoteApiKeyMissing);
|
||||||
}
|
}
|
||||||
|
|
||||||
let settings = self.settings.set_api_key(api_key)?;
|
let settings = self.settings.set_remote_api_key(api_key)?;
|
||||||
Ok(self.build_status(settings))
|
Ok(self.build_status(settings))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove the stored API key.
|
/// Remove the stored remote API key.
|
||||||
pub fn clear_api_key(&mut self) -> Result<AgentConfigStatus, AppError> {
|
pub fn clear_remote_api_key(&mut self) -> Result<AgentConfigStatus, AppError> {
|
||||||
let settings = self.settings.set_api_key(String::new())?;
|
let settings = self.settings.set_remote_api_key(String::new())?;
|
||||||
Ok(self.build_status(settings))
|
Ok(self.build_status(settings))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_status(&self, settings: AgentStoredSettings) -> AgentConfigStatus {
|
fn build_status(&self, settings: AgentStoredSettings) -> AgentConfigStatus {
|
||||||
let has_api_key = !settings.api_key.trim().is_empty();
|
|
||||||
|
|
||||||
AgentConfigStatus {
|
AgentConfigStatus {
|
||||||
configured: has_api_key,
|
configured: compute_overall_configured(&settings),
|
||||||
has_api_key,
|
remote_configured: compute_remote_configured(&settings),
|
||||||
base_url: settings.base_url,
|
local_configured: compute_local_configured(&settings),
|
||||||
model: settings.model,
|
remote_enabled: settings.remote.enabled,
|
||||||
|
local_enabled: settings.local.enabled,
|
||||||
|
has_remote_api_key: !settings.remote.api_key.trim().is_empty(),
|
||||||
|
remote_base_url: settings.remote.base_url,
|
||||||
|
local_base_url: settings.local.base_url,
|
||||||
|
default_remote_model: settings.default_remote_model,
|
||||||
|
local_available_models: settings.local.available_models,
|
||||||
|
task_defaults: settings.task_defaults,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_runtime(&self) -> Result<AgentRuntimeConfig, AppError> {
|
fn resolve_runtime(
|
||||||
|
&self,
|
||||||
|
task_profile: Option<TaskProfile>,
|
||||||
|
provider_override: Option<ProviderMode>,
|
||||||
|
model_override: Option<String>,
|
||||||
|
) -> Result<AgentRuntimeConfig, AppError> {
|
||||||
let settings = self.settings.load()?;
|
let settings = self.settings.load()?;
|
||||||
let api_key = settings.api_key.trim().to_string();
|
if !compute_overall_configured(&settings) {
|
||||||
|
|
||||||
if api_key.is_empty() {
|
|
||||||
return Err(AppError::AgentNotConfigured);
|
return Err(AppError::AgentNotConfigured);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(AgentRuntimeConfig {
|
resolve_runtime(
|
||||||
base_url: settings.base_url,
|
&settings,
|
||||||
model: settings.model,
|
task_profile.unwrap_or(TaskProfile::InteractiveChat),
|
||||||
api_key,
|
provider_override,
|
||||||
})
|
model_override,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_models(models: Vec<String>) -> Vec<String> {
|
||||||
|
let mut normalized = Vec::new();
|
||||||
|
|
||||||
|
for model in models {
|
||||||
|
let trimmed = model.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !normalized.iter().any(|existing| existing == trimmed) {
|
||||||
|
normalized.push(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::env;
|
use std::env;
|
||||||
@@ -200,8 +232,9 @@ mod tests {
|
|||||||
|
|
||||||
use super::SessionManager;
|
use super::SessionManager;
|
||||||
use crate::agent::{
|
use crate::agent::{
|
||||||
AgentRuntimeConfig, AgentService, ChatPromptRequest, SaveAgentSettingsRequest,
|
default_task_defaults, AgentRuntimeConfig, AgentService, ChatPromptRequest, ProviderMode,
|
||||||
UpdateAgentApiKeyRequest, DEFAULT_AGENT_BASE_URL, DEFAULT_AGENT_MODEL,
|
SaveAgentRuntimeConfigRequest, TaskProfile, UpdateRemoteApiKeyRequest,
|
||||||
|
DEFAULT_LOCAL_BASE_URL, DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL,
|
||||||
};
|
};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
@@ -217,12 +250,11 @@ mod tests {
|
|||||||
workspace_id: "workspace-1".to_string(),
|
workspace_id: "workspace-1".to_string(),
|
||||||
session_id: None,
|
session_id: None,
|
||||||
prompt: " ".to_string(),
|
prompt: " ".to_string(),
|
||||||
|
agent_profile: None,
|
||||||
|
model_override: None,
|
||||||
|
provider_override: None,
|
||||||
},
|
},
|
||||||
AgentRuntimeConfig {
|
sample_runtime(),
|
||||||
base_url: "https://example.com".to_string(),
|
|
||||||
model: "glm-5.1".to_string(),
|
|
||||||
api_key: "key".to_string(),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(result.unwrap_err(), AppError::EmptyPrompt);
|
assert_eq!(result.unwrap_err(), AppError::EmptyPrompt);
|
||||||
@@ -238,12 +270,11 @@ mod tests {
|
|||||||
workspace_id: "workspace-1".to_string(),
|
workspace_id: "workspace-1".to_string(),
|
||||||
session_id: None,
|
session_id: None,
|
||||||
prompt: "Summarize AAPL".to_string(),
|
prompt: "Summarize AAPL".to_string(),
|
||||||
|
agent_profile: None,
|
||||||
|
model_override: None,
|
||||||
|
provider_override: None,
|
||||||
},
|
},
|
||||||
AgentRuntimeConfig {
|
sample_runtime(),
|
||||||
base_url: "https://example.com".to_string(),
|
|
||||||
model: "glm-5.1".to_string(),
|
|
||||||
api_key: "key".to_string(),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -262,12 +293,11 @@ mod tests {
|
|||||||
workspace_id: "workspace-1".to_string(),
|
workspace_id: "workspace-1".to_string(),
|
||||||
session_id: Some(session_id.clone()),
|
session_id: Some(session_id.clone()),
|
||||||
prompt: "First prompt".to_string(),
|
prompt: "First prompt".to_string(),
|
||||||
|
agent_profile: None,
|
||||||
|
model_override: None,
|
||||||
|
provider_override: None,
|
||||||
},
|
},
|
||||||
AgentRuntimeConfig {
|
sample_runtime(),
|
||||||
base_url: "https://example.com".to_string(),
|
|
||||||
model: "glm-5.1".to_string(),
|
|
||||||
api_key: "key".to_string(),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
sessions
|
sessions
|
||||||
@@ -280,12 +310,11 @@ mod tests {
|
|||||||
workspace_id: "workspace-1".to_string(),
|
workspace_id: "workspace-1".to_string(),
|
||||||
session_id: Some(session_id),
|
session_id: Some(session_id),
|
||||||
prompt: "Second prompt".to_string(),
|
prompt: "Second prompt".to_string(),
|
||||||
|
agent_profile: None,
|
||||||
|
model_override: None,
|
||||||
|
provider_override: None,
|
||||||
},
|
},
|
||||||
AgentRuntimeConfig {
|
sample_runtime(),
|
||||||
base_url: "https://example.com".to_string(),
|
|
||||||
model: "glm-5.1".to_string(),
|
|
||||||
api_key: "key".to_string(),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -302,66 +331,290 @@ mod tests {
|
|||||||
|
|
||||||
let initial = service.get_config_status().unwrap();
|
let initial = service.get_config_status().unwrap();
|
||||||
assert!(!initial.configured);
|
assert!(!initial.configured);
|
||||||
assert!(!initial.has_api_key);
|
assert!(!initial.has_remote_api_key);
|
||||||
assert_eq!(initial.base_url, DEFAULT_AGENT_BASE_URL);
|
assert_eq!(initial.remote_base_url, DEFAULT_REMOTE_BASE_URL);
|
||||||
assert_eq!(initial.model, DEFAULT_AGENT_MODEL);
|
assert_eq!(initial.local_base_url, DEFAULT_LOCAL_BASE_URL);
|
||||||
|
assert_eq!(initial.default_remote_model, DEFAULT_REMOTE_MODEL);
|
||||||
|
|
||||||
let saved = service
|
let saved = service
|
||||||
.save_settings(SaveAgentSettingsRequest {
|
.save_runtime_config(SaveAgentRuntimeConfigRequest {
|
||||||
base_url: "https://example.test/v4".to_string(),
|
remote_enabled: true,
|
||||||
model: "glm-test".to_string(),
|
remote_base_url: "https://example.test/v4".to_string(),
|
||||||
|
default_remote_model: "glm-test".to_string(),
|
||||||
|
local_enabled: true,
|
||||||
|
local_base_url: "http://127.0.0.1:1234".to_string(),
|
||||||
|
local_available_models: vec!["qwen-small".to_string()],
|
||||||
|
task_defaults: default_task_defaults("glm-test"),
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(saved.base_url, "https://example.test/v4");
|
assert_eq!(saved.remote_base_url, "https://example.test/v4");
|
||||||
assert_eq!(saved.model, "glm-test");
|
assert_eq!(saved.default_remote_model, "glm-test");
|
||||||
assert!(!saved.has_api_key);
|
assert!(!saved.has_remote_api_key);
|
||||||
|
|
||||||
let updated = service
|
let updated = service
|
||||||
.update_api_key(UpdateAgentApiKeyRequest {
|
.update_remote_api_key(UpdateRemoteApiKeyRequest {
|
||||||
api_key: "z-ai-key-1".to_string(),
|
api_key: "z-ai-key-1".to_string(),
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(updated.configured);
|
assert!(updated.configured);
|
||||||
assert!(updated.has_api_key);
|
assert!(updated.has_remote_api_key);
|
||||||
|
|
||||||
let prepared = service
|
let prepared = service
|
||||||
.prepare_turn(ChatPromptRequest {
|
.prepare_turn(ChatPromptRequest {
|
||||||
workspace_id: "workspace-1".to_string(),
|
workspace_id: "workspace-1".to_string(),
|
||||||
session_id: None,
|
session_id: None,
|
||||||
prompt: "hello".to_string(),
|
prompt: "hello".to_string(),
|
||||||
|
agent_profile: None,
|
||||||
|
model_override: None,
|
||||||
|
provider_override: None,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(prepared.runtime.base_url, "https://example.test/v4");
|
assert_eq!(prepared.runtime.base_url, "https://example.test/v4");
|
||||||
assert_eq!(prepared.runtime.model, "glm-test");
|
assert_eq!(prepared.runtime.model, "glm-test");
|
||||||
assert_eq!(prepared.runtime.api_key, "z-ai-key-1");
|
assert_eq!(prepared.runtime.provider_mode, ProviderMode::Remote);
|
||||||
|
assert_eq!(prepared.runtime.api_key.as_deref(), Some("z-ai-key-1"));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clears_plaintext_api_key_from_store() {
|
fn clears_plaintext_remote_api_key_from_store() {
|
||||||
with_test_home("clear", || {
|
with_test_home("clear", || {
|
||||||
let app = build_test_app();
|
let app = build_test_app();
|
||||||
let mut service = AgentService::new(&app.handle()).unwrap();
|
let mut service = AgentService::new(&app.handle()).unwrap();
|
||||||
|
|
||||||
service
|
service
|
||||||
.update_api_key(UpdateAgentApiKeyRequest {
|
.save_runtime_config(SaveAgentRuntimeConfigRequest {
|
||||||
|
remote_enabled: true,
|
||||||
|
remote_base_url: "https://example.test/v4".to_string(),
|
||||||
|
default_remote_model: "glm-test".to_string(),
|
||||||
|
local_enabled: false,
|
||||||
|
local_base_url: DEFAULT_LOCAL_BASE_URL.to_string(),
|
||||||
|
local_available_models: Vec::new(),
|
||||||
|
task_defaults: default_task_defaults("glm-test"),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
service
|
||||||
|
.update_remote_api_key(UpdateRemoteApiKeyRequest {
|
||||||
api_key: "z-ai-key-1".to_string(),
|
api_key: "z-ai-key-1".to_string(),
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let cleared = service.clear_api_key().unwrap();
|
let cleared = service.clear_remote_api_key().unwrap();
|
||||||
assert!(!cleared.configured);
|
assert!(!cleared.configured);
|
||||||
assert!(!cleared.has_api_key);
|
assert!(!cleared.has_remote_api_key);
|
||||||
|
|
||||||
let result = service.prepare_turn(ChatPromptRequest {
|
let result = service.prepare_turn(ChatPromptRequest {
|
||||||
workspace_id: "workspace-1".to_string(),
|
workspace_id: "workspace-1".to_string(),
|
||||||
session_id: None,
|
session_id: None,
|
||||||
prompt: "hello".to_string(),
|
prompt: "hello".to_string(),
|
||||||
|
agent_profile: None,
|
||||||
|
model_override: None,
|
||||||
|
provider_override: None,
|
||||||
});
|
});
|
||||||
assert_eq!(result.unwrap_err(), AppError::AgentNotConfigured);
|
assert_eq!(result.unwrap_err(), AppError::AgentNotConfigured);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_task_route_resolves_without_remote_api_key() {
|
||||||
|
with_test_home("local-route", || {
|
||||||
|
let app = build_test_app();
|
||||||
|
let mut service = AgentService::new(&app.handle()).unwrap();
|
||||||
|
let mut task_defaults = default_task_defaults("glm-test");
|
||||||
|
task_defaults.insert(
|
||||||
|
TaskProfile::InteractiveChat,
|
||||||
|
crate::agent::AgentTaskRoute {
|
||||||
|
provider_mode: ProviderMode::Local,
|
||||||
|
model: "qwen-local".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
service
|
||||||
|
.save_runtime_config(SaveAgentRuntimeConfigRequest {
|
||||||
|
remote_enabled: true,
|
||||||
|
remote_base_url: "https://example.test/v4".to_string(),
|
||||||
|
default_remote_model: "glm-test".to_string(),
|
||||||
|
local_enabled: true,
|
||||||
|
local_base_url: "http://127.0.0.1:1234".to_string(),
|
||||||
|
local_available_models: vec!["qwen-local".to_string()],
|
||||||
|
task_defaults,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let prepared = service
|
||||||
|
.prepare_turn(ChatPromptRequest {
|
||||||
|
workspace_id: "workspace-1".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
prompt: "hello".to_string(),
|
||||||
|
agent_profile: Some(TaskProfile::InteractiveChat),
|
||||||
|
model_override: None,
|
||||||
|
provider_override: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(prepared.runtime.provider_mode, ProviderMode::Local);
|
||||||
|
assert_eq!(prepared.runtime.model, "qwen-local");
|
||||||
|
assert_eq!(prepared.runtime.api_key, None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_override_replaces_task_default() {
|
||||||
|
with_test_home("provider-override", || {
|
||||||
|
let app = build_test_app();
|
||||||
|
let mut service = AgentService::new(&app.handle()).unwrap();
|
||||||
|
let mut task_defaults = default_task_defaults("glm-test");
|
||||||
|
task_defaults.insert(
|
||||||
|
TaskProfile::InteractiveChat,
|
||||||
|
crate::agent::AgentTaskRoute {
|
||||||
|
provider_mode: ProviderMode::Local,
|
||||||
|
model: "qwen-local".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
service
|
||||||
|
.save_runtime_config(SaveAgentRuntimeConfigRequest {
|
||||||
|
remote_enabled: true,
|
||||||
|
remote_base_url: "https://example.test/v4".to_string(),
|
||||||
|
default_remote_model: "glm-test".to_string(),
|
||||||
|
local_enabled: true,
|
||||||
|
local_base_url: "http://127.0.0.1:1234".to_string(),
|
||||||
|
local_available_models: vec!["qwen-local".to_string()],
|
||||||
|
task_defaults,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
service
|
||||||
|
.update_remote_api_key(UpdateRemoteApiKeyRequest {
|
||||||
|
api_key: "z-ai-key-1".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let prepared = service
|
||||||
|
.prepare_turn(ChatPromptRequest {
|
||||||
|
workspace_id: "workspace-1".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
prompt: "hello".to_string(),
|
||||||
|
agent_profile: Some(TaskProfile::InteractiveChat),
|
||||||
|
model_override: None,
|
||||||
|
provider_override: Some(ProviderMode::Remote),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(prepared.runtime.provider_mode, ProviderMode::Remote);
|
||||||
|
assert_eq!(prepared.runtime.model, "glm-test");
|
||||||
|
assert_eq!(prepared.runtime.api_key.as_deref(), Some("z-ai-key-1"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_override_replaces_task_default() {
|
||||||
|
with_test_home("model-override", || {
|
||||||
|
let app = build_test_app();
|
||||||
|
let mut service = AgentService::new(&app.handle()).unwrap();
|
||||||
|
service
|
||||||
|
.save_runtime_config(SaveAgentRuntimeConfigRequest {
|
||||||
|
remote_enabled: true,
|
||||||
|
remote_base_url: "https://example.test/v4".to_string(),
|
||||||
|
default_remote_model: "glm-test".to_string(),
|
||||||
|
local_enabled: false,
|
||||||
|
local_base_url: DEFAULT_LOCAL_BASE_URL.to_string(),
|
||||||
|
local_available_models: Vec::new(),
|
||||||
|
task_defaults: default_task_defaults("glm-test"),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
service
|
||||||
|
.update_remote_api_key(UpdateRemoteApiKeyRequest {
|
||||||
|
api_key: "z-ai-key-1".to_string(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let prepared = service
|
||||||
|
.prepare_turn(ChatPromptRequest {
|
||||||
|
workspace_id: "workspace-1".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
prompt: "hello".to_string(),
|
||||||
|
agent_profile: None,
|
||||||
|
model_override: Some("glm-override".to_string()),
|
||||||
|
provider_override: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(prepared.runtime.model, "glm-override");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_task_without_model_fails_validation() {
|
||||||
|
with_test_home("local-validation", || {
|
||||||
|
let app = build_test_app();
|
||||||
|
let mut service = AgentService::new(&app.handle()).unwrap();
|
||||||
|
let mut task_defaults = default_task_defaults("glm-test");
|
||||||
|
task_defaults.insert(
|
||||||
|
TaskProfile::InteractiveChat,
|
||||||
|
crate::agent::AgentTaskRoute {
|
||||||
|
provider_mode: ProviderMode::Local,
|
||||||
|
model: String::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = service.save_runtime_config(SaveAgentRuntimeConfigRequest {
|
||||||
|
remote_enabled: true,
|
||||||
|
remote_base_url: "https://example.test/v4".to_string(),
|
||||||
|
default_remote_model: "glm-test".to_string(),
|
||||||
|
local_enabled: true,
|
||||||
|
local_base_url: "http://127.0.0.1:1234".to_string(),
|
||||||
|
local_available_models: Vec::new(),
|
||||||
|
task_defaults,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result.unwrap_err(),
|
||||||
|
AppError::ModelMissing(TaskProfile::InteractiveChat)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_task_without_api_key_fails_validation_at_prepare_time() {
|
||||||
|
with_test_home("remote-validation", || {
|
||||||
|
let app = build_test_app();
|
||||||
|
let mut service = AgentService::new(&app.handle()).unwrap();
|
||||||
|
service
|
||||||
|
.save_runtime_config(SaveAgentRuntimeConfigRequest {
|
||||||
|
remote_enabled: true,
|
||||||
|
remote_base_url: "https://example.test/v4".to_string(),
|
||||||
|
default_remote_model: "glm-test".to_string(),
|
||||||
|
local_enabled: false,
|
||||||
|
local_base_url: DEFAULT_LOCAL_BASE_URL.to_string(),
|
||||||
|
local_available_models: Vec::new(),
|
||||||
|
task_defaults: default_task_defaults("glm-test"),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = service.prepare_turn(ChatPromptRequest {
|
||||||
|
workspace_id: "workspace-1".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
prompt: "hello".to_string(),
|
||||||
|
agent_profile: None,
|
||||||
|
model_override: None,
|
||||||
|
provider_override: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(result.unwrap_err(), AppError::AgentNotConfigured);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_runtime() -> AgentRuntimeConfig {
|
||||||
|
AgentRuntimeConfig {
|
||||||
|
provider_mode: ProviderMode::Remote,
|
||||||
|
base_url: "https://example.com".to_string(),
|
||||||
|
model: "glm-5.1".to_string(),
|
||||||
|
api_key: Some("key".to_string()),
|
||||||
|
task_profile: TaskProfile::InteractiveChat,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn build_test_app() -> tauri::App<MockRuntime> {
|
fn build_test_app() -> tauri::App<MockRuntime> {
|
||||||
mock_builder()
|
mock_builder()
|
||||||
.plugin(tauri_plugin_store::Builder::new().build())
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
|
|||||||
@@ -3,13 +3,23 @@ use tauri::{AppHandle, Runtime};
|
|||||||
use tauri_plugin_store::StoreExt;
|
use tauri_plugin_store::StoreExt;
|
||||||
|
|
||||||
use crate::agent::{
|
use crate::agent::{
|
||||||
AgentStoredSettings, AGENT_SETTINGS_STORE_PATH, DEFAULT_AGENT_BASE_URL, DEFAULT_AGENT_MODEL,
|
default_task_defaults, AgentStoredSettings, LocalProviderSettings, RemoteProviderSettings,
|
||||||
|
AGENT_SETTINGS_STORE_PATH, DEFAULT_LOCAL_BASE_URL, DEFAULT_REMOTE_BASE_URL,
|
||||||
|
DEFAULT_REMOTE_MODEL,
|
||||||
};
|
};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
const BASE_URL_KEY: &str = "baseUrl";
|
const REMOTE_ENABLED_KEY: &str = "remoteEnabled";
|
||||||
const MODEL_KEY: &str = "model";
|
const REMOTE_BASE_URL_KEY: &str = "remoteBaseUrl";
|
||||||
const API_KEY_KEY: &str = "apiKey";
|
const REMOTE_API_KEY_KEY: &str = "remoteApiKey";
|
||||||
|
const DEFAULT_REMOTE_MODEL_KEY: &str = "defaultRemoteModel";
|
||||||
|
const LOCAL_ENABLED_KEY: &str = "localEnabled";
|
||||||
|
const LOCAL_BASE_URL_KEY: &str = "localBaseUrl";
|
||||||
|
const LOCAL_AVAILABLE_MODELS_KEY: &str = "localAvailableModels";
|
||||||
|
const TASK_DEFAULTS_KEY: &str = "taskDefaults";
|
||||||
|
const LEGACY_BASE_URL_KEY: &str = "baseUrl";
|
||||||
|
const LEGACY_MODEL_KEY: &str = "model";
|
||||||
|
const LEGACY_API_KEY_KEY: &str = "apiKey";
|
||||||
|
|
||||||
/// 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, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -32,19 +42,62 @@ 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
|
||||||
|
.get(DEFAULT_REMOTE_MODEL_KEY)
|
||||||
|
.and_then(|value| value.as_str().map(ToOwned::to_owned))
|
||||||
|
.or_else(|| {
|
||||||
|
store
|
||||||
|
.get(LEGACY_MODEL_KEY)
|
||||||
|
.and_then(|value| value.as_str().map(ToOwned::to_owned))
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| DEFAULT_REMOTE_MODEL.to_string());
|
||||||
|
|
||||||
|
let task_defaults = store
|
||||||
|
.get(TASK_DEFAULTS_KEY)
|
||||||
|
.and_then(|value| serde_json::from_value(value.clone()).ok())
|
||||||
|
.unwrap_or_else(|| default_task_defaults(&default_remote_model));
|
||||||
|
|
||||||
Ok(AgentStoredSettings {
|
Ok(AgentStoredSettings {
|
||||||
|
remote: RemoteProviderSettings {
|
||||||
|
enabled: store
|
||||||
|
.get(REMOTE_ENABLED_KEY)
|
||||||
|
.and_then(|value| value.as_bool())
|
||||||
|
.unwrap_or(true),
|
||||||
base_url: store
|
base_url: store
|
||||||
.get(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))
|
||||||
.unwrap_or_else(|| DEFAULT_AGENT_BASE_URL.to_string()),
|
.or_else(|| {
|
||||||
model: store
|
store
|
||||||
.get(MODEL_KEY)
|
.get(LEGACY_BASE_URL_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_AGENT_MODEL.to_string()),
|
})
|
||||||
|
.unwrap_or_else(|| DEFAULT_REMOTE_BASE_URL.to_string()),
|
||||||
api_key: store
|
api_key: store
|
||||||
.get(API_KEY_KEY)
|
.get(REMOTE_API_KEY_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(),
|
||||||
|
},
|
||||||
|
local: LocalProviderSettings {
|
||||||
|
enabled: store
|
||||||
|
.get(LOCAL_ENABLED_KEY)
|
||||||
|
.and_then(|value| value.as_bool())
|
||||||
|
.unwrap_or(false),
|
||||||
|
base_url: store
|
||||||
|
.get(LOCAL_BASE_URL_KEY)
|
||||||
|
.and_then(|value| value.as_str().map(ToOwned::to_owned))
|
||||||
|
.unwrap_or_else(|| DEFAULT_LOCAL_BASE_URL.to_string()),
|
||||||
|
available_models: store
|
||||||
|
.get(LOCAL_AVAILABLE_MODELS_KEY)
|
||||||
|
.and_then(|value| serde_json::from_value(value.clone()).ok())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
},
|
||||||
|
default_remote_model,
|
||||||
|
task_defaults,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,10 +107,10 @@ impl<R: Runtime> AgentSettingsService<R> {
|
|||||||
Ok(settings)
|
Ok(settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update only the plaintext API key.
|
/// Update only the plaintext remote API key.
|
||||||
pub fn set_api_key(&self, api_key: String) -> Result<AgentStoredSettings, AppError> {
|
pub fn set_remote_api_key(&self, api_key: String) -> Result<AgentStoredSettings, AppError> {
|
||||||
let mut settings = self.load()?;
|
let mut settings = self.load()?;
|
||||||
settings.api_key = api_key;
|
settings.remote.api_key = api_key;
|
||||||
self.save_inner(&settings)?;
|
self.save_inner(&settings)?;
|
||||||
Ok(settings)
|
Ok(settings)
|
||||||
}
|
}
|
||||||
@@ -68,11 +121,37 @@ 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()))?;
|
||||||
|
|
||||||
// The API key is intentionally persisted in plain text per the current
|
store.set(
|
||||||
// product requirement, so it lives in the same store as the runtime config.
|
REMOTE_ENABLED_KEY.to_string(),
|
||||||
store.set(BASE_URL_KEY.to_string(), json!(settings.base_url));
|
json!(settings.remote.enabled),
|
||||||
store.set(MODEL_KEY.to_string(), json!(settings.model));
|
);
|
||||||
store.set(API_KEY_KEY.to_string(), json!(settings.api_key));
|
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(LOCAL_ENABLED_KEY.to_string(), json!(settings.local.enabled));
|
||||||
|
store.set(
|
||||||
|
LOCAL_BASE_URL_KEY.to_string(),
|
||||||
|
json!(settings.local.base_url),
|
||||||
|
);
|
||||||
|
store.set(
|
||||||
|
LOCAL_AVAILABLE_MODELS_KEY.to_string(),
|
||||||
|
json!(settings.local.available_models),
|
||||||
|
);
|
||||||
|
store.set(TASK_DEFAULTS_KEY.to_string(), json!(settings.task_defaults));
|
||||||
|
|
||||||
|
// Remove legacy flat keys after writing the new schema.
|
||||||
|
store.delete(LEGACY_BASE_URL_KEY);
|
||||||
|
store.delete(LEGACY_MODEL_KEY);
|
||||||
|
store.delete(LEGACY_API_KEY_KEY);
|
||||||
store
|
store
|
||||||
.save()
|
.save()
|
||||||
.map_err(|error| AppError::SettingsStore(error.to_string()))
|
.map_err(|error| AppError::SettingsStore(error.to_string()))
|
||||||
|
|||||||
@@ -1,13 +1,34 @@
|
|||||||
use rig::completion::Message;
|
use rig::completion::Message;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// Default Z.AI coding plan endpoint used by the app.
|
/// Default Z.AI coding plan endpoint used by the app.
|
||||||
pub const DEFAULT_AGENT_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 model used for plain-text terminal chat.
|
||||||
pub const DEFAULT_AGENT_MODEL: &str = "glm-5.1";
|
pub const DEFAULT_REMOTE_MODEL: &str = "glm-5.1";
|
||||||
|
/// Default local Mistral HTTP sidecar URL.
|
||||||
|
pub const DEFAULT_LOCAL_BASE_URL: &str = "http://127.0.0.1:1234";
|
||||||
/// Store file used for agent settings and plaintext API key storage.
|
/// Store file used for agent settings and plaintext API key storage.
|
||||||
pub const AGENT_SETTINGS_STORE_PATH: &str = "agent-settings.json";
|
pub const AGENT_SETTINGS_STORE_PATH: &str = "agent-settings.json";
|
||||||
|
|
||||||
|
/// Supported runtime provider modes.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum ProviderMode {
|
||||||
|
Remote,
|
||||||
|
Local,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable harness task profiles that can be routed independently.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum TaskProfile {
|
||||||
|
InteractiveChat,
|
||||||
|
Analysis,
|
||||||
|
Summarization,
|
||||||
|
ToolUse,
|
||||||
|
}
|
||||||
|
|
||||||
/// Request payload for an interactive chat turn.
|
/// Request payload for an interactive chat turn.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -18,17 +39,27 @@ pub struct ChatPromptRequest {
|
|||||||
pub session_id: Option<String>,
|
pub session_id: Option<String>,
|
||||||
/// User-entered prompt content.
|
/// User-entered prompt content.
|
||||||
pub prompt: String,
|
pub prompt: String,
|
||||||
|
/// Optional task profile used to resolve provider and model defaults.
|
||||||
|
pub agent_profile: Option<TaskProfile>,
|
||||||
|
/// Optional one-off model override for the current request.
|
||||||
|
pub model_override: Option<String>,
|
||||||
|
/// Optional one-off provider override for the current request.
|
||||||
|
pub provider_override: Option<ProviderMode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runtime provider configuration after settings resolution.
|
/// Runtime provider configuration after settings resolution.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AgentRuntimeConfig {
|
pub struct AgentRuntimeConfig {
|
||||||
/// OpenAI-compatible base URL.
|
/// Resolved provider mode for this turn.
|
||||||
|
pub provider_mode: ProviderMode,
|
||||||
|
/// OpenAI-compatible base URL for remote or local Mistral HTTP.
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
/// Upstream model identifier.
|
/// Upstream model identifier.
|
||||||
pub model: String,
|
pub model: String,
|
||||||
/// Runtime API key loaded from plaintext application storage.
|
/// Optional runtime API key loaded from plaintext application storage.
|
||||||
pub api_key: String,
|
pub api_key: Option<String>,
|
||||||
|
/// Task profile used to resolve this target.
|
||||||
|
pub task_profile: TaskProfile,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prepared chat turn after validation and session history lookup.
|
/// Prepared chat turn after validation and session history lookup.
|
||||||
@@ -102,20 +133,23 @@ pub struct AgentErrorEvent {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct AgentStoredSettings {
|
pub struct AgentStoredSettings {
|
||||||
/// OpenAI-compatible base URL.
|
/// Remote OpenAI-compatible provider configuration.
|
||||||
pub base_url: String,
|
pub remote: RemoteProviderSettings,
|
||||||
/// Upstream model identifier.
|
/// Local Mistral HTTP provider configuration.
|
||||||
pub model: String,
|
pub local: LocalProviderSettings,
|
||||||
/// Plaintext API key saved in the application store.
|
/// Default remote model used when a task route does not override it.
|
||||||
pub api_key: String,
|
pub default_remote_model: String,
|
||||||
|
/// Default route per task profile.
|
||||||
|
pub task_defaults: HashMap<TaskProfile, AgentTaskRoute>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AgentStoredSettings {
|
impl Default for AgentStoredSettings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
base_url: DEFAULT_AGENT_BASE_URL.to_string(),
|
remote: RemoteProviderSettings::default(),
|
||||||
model: DEFAULT_AGENT_MODEL.to_string(),
|
local: LocalProviderSettings::default(),
|
||||||
api_key: String::new(),
|
default_remote_model: DEFAULT_REMOTE_MODEL.to_string(),
|
||||||
|
task_defaults: default_task_defaults(DEFAULT_REMOTE_MODEL),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,28 +160,151 @@ impl Default for AgentStoredSettings {
|
|||||||
pub struct AgentConfigStatus {
|
pub struct AgentConfigStatus {
|
||||||
/// Whether the app has everything needed to start chat immediately.
|
/// Whether the app has everything needed to start chat immediately.
|
||||||
pub configured: bool,
|
pub configured: bool,
|
||||||
/// Whether the app currently has an API key stored.
|
/// Whether the remote provider has enough config for a routed request.
|
||||||
pub has_api_key: bool,
|
pub remote_configured: bool,
|
||||||
/// Current provider base URL.
|
/// Whether the local provider has enough config for a routed request.
|
||||||
pub base_url: String,
|
pub local_configured: bool,
|
||||||
/// Current provider model.
|
/// Whether the remote provider is enabled in settings.
|
||||||
pub model: String,
|
pub remote_enabled: bool,
|
||||||
|
/// Whether the local provider is enabled in settings.
|
||||||
|
pub local_enabled: bool,
|
||||||
|
/// Whether a remote API key is currently stored.
|
||||||
|
pub has_remote_api_key: bool,
|
||||||
|
/// Current remote provider base URL.
|
||||||
|
pub remote_base_url: String,
|
||||||
|
/// Current local provider base URL.
|
||||||
|
pub local_base_url: String,
|
||||||
|
/// Current default remote model.
|
||||||
|
pub default_remote_model: String,
|
||||||
|
/// Current available local model suggestions.
|
||||||
|
pub local_available_models: Vec<String>,
|
||||||
|
/// Current route defaults per task profile.
|
||||||
|
pub task_defaults: HashMap<TaskProfile, AgentTaskRoute>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request payload for updating persisted non-secret settings.
|
/// Request payload for updating persisted non-secret settings.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SaveAgentSettingsRequest {
|
pub struct SaveAgentRuntimeConfigRequest {
|
||||||
/// OpenAI-compatible base URL.
|
/// Whether the remote provider is enabled.
|
||||||
pub base_url: String,
|
pub remote_enabled: bool,
|
||||||
/// Upstream model identifier.
|
/// Remote OpenAI-compatible base URL.
|
||||||
pub model: String,
|
pub remote_base_url: String,
|
||||||
|
/// Default model used for remote-routed tasks.
|
||||||
|
pub default_remote_model: String,
|
||||||
|
/// Whether the local provider is enabled.
|
||||||
|
pub local_enabled: bool,
|
||||||
|
/// Local Mistral HTTP base URL.
|
||||||
|
pub local_base_url: String,
|
||||||
|
/// User-provided local model suggestions.
|
||||||
|
pub local_available_models: Vec<String>,
|
||||||
|
/// Default task routes.
|
||||||
|
pub task_defaults: HashMap<TaskProfile, AgentTaskRoute>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request payload for rotating the stored 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")]
|
||||||
pub struct UpdateAgentApiKeyRequest {
|
pub struct UpdateRemoteApiKeyRequest {
|
||||||
/// Replacement plaintext API key to store.
|
/// Replacement plaintext API key to store.
|
||||||
pub api_key: String,
|
pub api_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remote provider settings persisted in the application store.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RemoteProviderSettings {
|
||||||
|
/// Whether the provider can be selected by task routing.
|
||||||
|
pub enabled: bool,
|
||||||
|
/// OpenAI-compatible base URL.
|
||||||
|
pub base_url: String,
|
||||||
|
/// Plaintext API key saved in the application store.
|
||||||
|
pub api_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RemoteProviderSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
base_url: DEFAULT_REMOTE_BASE_URL.to_string(),
|
||||||
|
api_key: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local provider settings persisted in the application store.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LocalProviderSettings {
|
||||||
|
/// Whether the local provider can be selected by task routing.
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Local Mistral HTTP base URL.
|
||||||
|
pub base_url: String,
|
||||||
|
/// User-provided local model suggestions.
|
||||||
|
pub available_models: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LocalProviderSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
base_url: DEFAULT_LOCAL_BASE_URL.to_string(),
|
||||||
|
available_models: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default provider/model assignment for a task profile.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AgentTaskRoute {
|
||||||
|
/// Provider selected for the task by default.
|
||||||
|
pub provider_mode: ProviderMode,
|
||||||
|
/// Model selected for the task by default.
|
||||||
|
pub model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response payload for local provider health checks.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LocalProviderHealthStatus {
|
||||||
|
/// Whether the local provider responded successfully.
|
||||||
|
pub reachable: bool,
|
||||||
|
/// Optional user-visible status detail.
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response payload for local model listing.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LocalModelList {
|
||||||
|
/// Whether model discovery reached the local provider.
|
||||||
|
pub reachable: bool,
|
||||||
|
/// Unique model names combined from discovery and stored settings.
|
||||||
|
pub models: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_task_defaults(default_remote_model: &str) -> HashMap<TaskProfile, AgentTaskRoute> {
|
||||||
|
let mut defaults = HashMap::new();
|
||||||
|
for task in TaskProfile::all() {
|
||||||
|
defaults.insert(
|
||||||
|
task,
|
||||||
|
AgentTaskRoute {
|
||||||
|
provider_mode: ProviderMode::Remote,
|
||||||
|
model: default_remote_model.to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskProfile {
|
||||||
|
pub const fn all() -> [TaskProfile; 4] {
|
||||||
|
[
|
||||||
|
TaskProfile::InteractiveChat,
|
||||||
|
TaskProfile::Analysis,
|
||||||
|
TaskProfile::Summarization,
|
||||||
|
TaskProfile::ToolUse,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
use crate::agent::{AgentConfigStatus, SaveAgentSettingsRequest, UpdateAgentApiKeyRequest};
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::agent::{
|
||||||
|
AgentConfigStatus, LocalModelList, LocalProviderHealthStatus, SaveAgentRuntimeConfigRequest,
|
||||||
|
UpdateRemoteApiKeyRequest,
|
||||||
|
};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
/// Return the current public configuration state for the AI chat runtime.
|
/// Return the current public configuration state for the AI chat runtime.
|
||||||
@@ -14,11 +19,11 @@ pub async fn get_agent_config_status(
|
|||||||
agent.get_config_status().map_err(|error| error.to_string())
|
agent.get_config_status().map_err(|error| error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist the non-secret base URL and model settings.
|
/// Persist the non-secret provider and task routing settings.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn save_agent_settings(
|
pub async fn save_agent_runtime_config(
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
request: SaveAgentSettingsRequest,
|
request: SaveAgentRuntimeConfigRequest,
|
||||||
) -> Result<AgentConfigStatus, String> {
|
) -> Result<AgentConfigStatus, String> {
|
||||||
let mut agent = state
|
let mut agent = state
|
||||||
.agent
|
.agent
|
||||||
@@ -26,15 +31,15 @@ pub async fn save_agent_settings(
|
|||||||
.map_err(|_| "agent state is unavailable".to_string())?;
|
.map_err(|_| "agent state is unavailable".to_string())?;
|
||||||
|
|
||||||
agent
|
agent
|
||||||
.save_settings(request)
|
.save_runtime_config(request)
|
||||||
.map_err(|error| error.to_string())
|
.map_err(|error| error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save or replace the plaintext API key.
|
/// Save or replace the plaintext remote API key.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn update_agent_api_key(
|
pub async fn update_remote_api_key(
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
request: UpdateAgentApiKeyRequest,
|
request: UpdateRemoteApiKeyRequest,
|
||||||
) -> Result<AgentConfigStatus, String> {
|
) -> Result<AgentConfigStatus, String> {
|
||||||
let mut agent = state
|
let mut agent = state
|
||||||
.agent
|
.agent
|
||||||
@@ -42,13 +47,13 @@ pub async fn update_agent_api_key(
|
|||||||
.map_err(|_| "agent state is unavailable".to_string())?;
|
.map_err(|_| "agent state is unavailable".to_string())?;
|
||||||
|
|
||||||
agent
|
agent
|
||||||
.update_api_key(request)
|
.update_remote_api_key(request)
|
||||||
.map_err(|error| error.to_string())
|
.map_err(|error| error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove the stored plaintext API key.
|
/// Remove the stored plaintext remote API key.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn clear_agent_api_key(
|
pub async fn clear_remote_api_key(
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
) -> Result<AgentConfigStatus, String> {
|
) -> Result<AgentConfigStatus, String> {
|
||||||
let mut agent = state
|
let mut agent = state
|
||||||
@@ -56,5 +61,104 @@ pub async fn clear_agent_api_key(
|
|||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| "agent state is unavailable".to_string())?;
|
.map_err(|_| "agent state is unavailable".to_string())?;
|
||||||
|
|
||||||
agent.clear_api_key().map_err(|error| error.to_string())
|
agent
|
||||||
|
.clear_remote_api_key()
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists local models from the running Mistral HTTP endpoint and stored settings.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_local_models(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<LocalModelList, String> {
|
||||||
|
let status = {
|
||||||
|
let agent = state
|
||||||
|
.agent
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "agent state is unavailable".to_string())?;
|
||||||
|
agent
|
||||||
|
.get_config_status()
|
||||||
|
.map_err(|error| error.to_string())?
|
||||||
|
};
|
||||||
|
|
||||||
|
let discovered = fetch_local_models(&status.local_base_url).await;
|
||||||
|
let mut models = status.local_available_models;
|
||||||
|
|
||||||
|
if let Ok(mut discovered_models) = discovered {
|
||||||
|
for model in discovered_models.drain(..) {
|
||||||
|
if !models.iter().any(|existing| existing == &model) {
|
||||||
|
models.push(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(LocalModelList {
|
||||||
|
reachable: true,
|
||||||
|
models,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(LocalModelList {
|
||||||
|
reachable: false,
|
||||||
|
models,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks whether the local Mistral HTTP provider is reachable.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn check_local_provider_health(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<LocalProviderHealthStatus, String> {
|
||||||
|
let local_base_url = {
|
||||||
|
let agent = state
|
||||||
|
.agent
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "agent state is unavailable".to_string())?;
|
||||||
|
agent
|
||||||
|
.get_config_status()
|
||||||
|
.map_err(|error| error.to_string())?
|
||||||
|
.local_base_url
|
||||||
|
};
|
||||||
|
|
||||||
|
match fetch_local_models(&local_base_url).await {
|
||||||
|
Ok(models) => Ok(LocalProviderHealthStatus {
|
||||||
|
reachable: true,
|
||||||
|
message: format!("Local provider reachable with {} model(s).", models.len()),
|
||||||
|
}),
|
||||||
|
Err(error) => Ok(LocalProviderHealthStatus {
|
||||||
|
reachable: false,
|
||||||
|
message: error,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OpenAiModelListResponse {
|
||||||
|
data: Vec<OpenAiModelDescriptor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OpenAiModelDescriptor {
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_local_models(base_url: &str) -> Result<Vec<String>, String> {
|
||||||
|
let base_url = base_url.trim_end_matches('/');
|
||||||
|
let endpoint = format!("{base_url}/v1/models");
|
||||||
|
let response = reqwest::get(&endpoint)
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("Failed to reach local provider: {error}"))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!(
|
||||||
|
"Local provider health check failed with status {}",
|
||||||
|
response.status()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = response
|
||||||
|
.json::<OpenAiModelListResponse>()
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("Failed to parse local provider model list: {error}"))?;
|
||||||
|
|
||||||
|
Ok(payload.data.into_iter().map(|model| model.id).collect())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::fmt::{Display, Formatter};
|
use std::fmt::{Display, Formatter};
|
||||||
|
|
||||||
|
use crate::agent::{ProviderMode, TaskProfile};
|
||||||
|
|
||||||
/// Backend error type for application-level validation and runtime failures.
|
/// Backend error type for application-level validation and runtime failures.
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
pub enum AppError {
|
pub enum AppError {
|
||||||
EmptyPrompt,
|
EmptyPrompt,
|
||||||
AgentNotConfigured,
|
AgentNotConfigured,
|
||||||
ApiKeyMissing,
|
RemoteApiKeyMissing,
|
||||||
InvalidSettings(String),
|
InvalidSettings(String),
|
||||||
UnknownSession(String),
|
UnknownSession(String),
|
||||||
SettingsStore(String),
|
SettingsStore(String),
|
||||||
ProviderInit(String),
|
ProviderInit(String),
|
||||||
ProviderRequest(String),
|
ProviderRequest(String),
|
||||||
|
ProviderNotConfigured(ProviderMode),
|
||||||
|
TaskRouteMissing(TaskProfile),
|
||||||
|
ModelMissing(TaskProfile),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for AppError {
|
impl Display for AppError {
|
||||||
@@ -19,9 +24,9 @@ impl Display for AppError {
|
|||||||
match self {
|
match self {
|
||||||
Self::EmptyPrompt => formatter.write_str("prompt cannot be empty"),
|
Self::EmptyPrompt => formatter.write_str("prompt cannot be empty"),
|
||||||
Self::AgentNotConfigured => formatter.write_str(
|
Self::AgentNotConfigured => formatter.write_str(
|
||||||
"AI chat is not configured yet. Open AI Settings to save a model and API key.",
|
"AI chat is not configured yet. Open AI Settings to configure a provider and task route.",
|
||||||
),
|
),
|
||||||
Self::ApiKeyMissing => formatter.write_str("API key cannot be empty"),
|
Self::RemoteApiKeyMissing => formatter.write_str("remote API key cannot be empty"),
|
||||||
Self::InvalidSettings(message) => formatter.write_str(message),
|
Self::InvalidSettings(message) => formatter.write_str(message),
|
||||||
Self::UnknownSession(session_id) => {
|
Self::UnknownSession(session_id) => {
|
||||||
write!(formatter, "unknown session: {session_id}")
|
write!(formatter, "unknown session: {session_id}")
|
||||||
@@ -35,6 +40,18 @@ impl Display for AppError {
|
|||||||
Self::ProviderRequest(message) => {
|
Self::ProviderRequest(message) => {
|
||||||
write!(formatter, "AI provider request failed: {message}")
|
write!(formatter, "AI provider request failed: {message}")
|
||||||
}
|
}
|
||||||
|
Self::ProviderNotConfigured(ProviderMode::Remote) => formatter.write_str(
|
||||||
|
"remote provider is not configured. Save a remote base URL, model, and API key.",
|
||||||
|
),
|
||||||
|
Self::ProviderNotConfigured(ProviderMode::Local) => formatter.write_str(
|
||||||
|
"local provider is not configured. Save a local base URL and local task model.",
|
||||||
|
),
|
||||||
|
Self::TaskRouteMissing(task) => {
|
||||||
|
write!(formatter, "task route is missing for {task:?}")
|
||||||
|
}
|
||||||
|
Self::ModelMissing(task) => {
|
||||||
|
write!(formatter, "model is missing for task {task:?}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,9 +29,11 @@ pub fn run() {
|
|||||||
commands::terminal::execute_terminal_command,
|
commands::terminal::execute_terminal_command,
|
||||||
commands::terminal::start_chat_stream,
|
commands::terminal::start_chat_stream,
|
||||||
commands::settings::get_agent_config_status,
|
commands::settings::get_agent_config_status,
|
||||||
commands::settings::save_agent_settings,
|
commands::settings::save_agent_runtime_config,
|
||||||
commands::settings::update_agent_api_key,
|
commands::settings::update_remote_api_key,
|
||||||
commands::settings::clear_agent_api_key
|
commands::settings::clear_remote_api_key,
|
||||||
|
commands::settings::list_local_models,
|
||||||
|
commands::settings::check_local_provider_health
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ function App() {
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
sessionId: currentWorkspace?.chatSessionId,
|
sessionId: currentWorkspace?.chatSessionId,
|
||||||
prompt: trimmedCommand,
|
prompt: trimmedCommand,
|
||||||
|
agentProfile: 'interactiveChat',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onDelta: (event) => {
|
onDelta: (event) => {
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { agentSettingsBridge } from '../../lib/agentSettingsBridge';
|
import { agentSettingsBridge } from '../../lib/agentSettingsBridge';
|
||||||
import { AgentConfigStatus } from '../../types/agentSettings';
|
import {
|
||||||
|
AgentConfigStatus,
|
||||||
|
AgentTaskRoute,
|
||||||
|
ProviderMode,
|
||||||
|
TASK_LABELS,
|
||||||
|
TASK_PROFILES,
|
||||||
|
TaskProfile,
|
||||||
|
} from '../../types/agentSettings';
|
||||||
|
|
||||||
interface AgentSettingsModalProps {
|
interface AgentSettingsModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -12,17 +19,56 @@ interface AgentSettingsModalProps {
|
|||||||
const inputClassName =
|
const inputClassName =
|
||||||
'w-full rounded border border-[#2a2a2a] bg-[#111111] px-3 py-2 text-sm font-mono text-[#e0e0e0] outline-none transition-colors focus:border-[#58a6ff]';
|
'w-full rounded border border-[#2a2a2a] bg-[#111111] px-3 py-2 text-sm font-mono text-[#e0e0e0] outline-none transition-colors focus:border-[#58a6ff]';
|
||||||
|
|
||||||
|
const textareaClassName = `${inputClassName} min-h-28 resize-y`;
|
||||||
|
|
||||||
|
const buttonClassName =
|
||||||
|
'rounded border border-[#2a2a2a] bg-[#151515] px-3 py-2 text-xs font-mono text-[#e0e0e0] transition-colors hover:border-[#58a6ff] hover:text-[#58a6ff] disabled:cursor-not-allowed disabled:opacity-50';
|
||||||
|
|
||||||
|
const normalizeModelList = (value: string): string[] => {
|
||||||
|
const items = value
|
||||||
|
.split(/[\n,]/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return Array.from(new Set(items));
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeTaskDefaults = (
|
||||||
|
taskDefaults: Partial<Record<TaskProfile, AgentTaskRoute>>,
|
||||||
|
defaultRemoteModel: string,
|
||||||
|
localModels: string[],
|
||||||
|
): Record<TaskProfile, AgentTaskRoute> => {
|
||||||
|
const fallbackLocalModel = localModels[0] ?? '';
|
||||||
|
|
||||||
|
return TASK_PROFILES.reduce((acc, profile) => {
|
||||||
|
const existing = taskDefaults[profile];
|
||||||
|
acc[profile] = existing ?? {
|
||||||
|
providerMode: 'remote',
|
||||||
|
model: defaultRemoteModel || fallbackLocalModel,
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<TaskProfile, AgentTaskRoute>);
|
||||||
|
};
|
||||||
|
|
||||||
export const AgentSettingsModal: React.FC<AgentSettingsModalProps> = ({
|
export const AgentSettingsModal: React.FC<AgentSettingsModalProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
status,
|
status,
|
||||||
onClose,
|
onClose,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
}) => {
|
}) => {
|
||||||
const [baseUrl, setBaseUrl] = useState('');
|
const [remoteEnabled, setRemoteEnabled] = useState(true);
|
||||||
const [model, setModel] = useState('');
|
const [remoteBaseUrl, setRemoteBaseUrl] = useState('');
|
||||||
const [apiKey, setApiKey] = useState('');
|
const [defaultRemoteModel, setDefaultRemoteModel] = useState('');
|
||||||
|
const [localEnabled, setLocalEnabled] = useState(false);
|
||||||
|
const [localBaseUrl, setLocalBaseUrl] = useState('');
|
||||||
|
const [localAvailableModelsText, setLocalAvailableModelsText] = useState('');
|
||||||
|
const [taskDefaults, setTaskDefaults] = useState<Record<TaskProfile, AgentTaskRoute>>(
|
||||||
|
mergeTaskDefaults({}, '', []),
|
||||||
|
);
|
||||||
|
const [remoteApiKey, setRemoteApiKey] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
const [localStatusMessage, setLocalStatusMessage] = useState<string | null>(null);
|
||||||
const [isBusy, setIsBusy] = useState(false);
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -30,81 +76,196 @@ export const AgentSettingsModal: React.FC<AgentSettingsModalProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setBaseUrl(status.baseUrl);
|
setRemoteEnabled(status.remoteEnabled);
|
||||||
setModel(status.model);
|
setRemoteBaseUrl(status.remoteBaseUrl);
|
||||||
setApiKey('');
|
setDefaultRemoteModel(status.defaultRemoteModel);
|
||||||
|
setLocalEnabled(status.localEnabled);
|
||||||
|
setLocalBaseUrl(status.localBaseUrl);
|
||||||
|
setLocalAvailableModelsText(status.localAvailableModels.join('\n'));
|
||||||
|
setTaskDefaults(
|
||||||
|
mergeTaskDefaults(
|
||||||
|
status.taskDefaults,
|
||||||
|
status.defaultRemoteModel,
|
||||||
|
status.localAvailableModels,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setRemoteApiKey('');
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
|
setLocalStatusMessage(null);
|
||||||
}, [isOpen, status]);
|
}, [isOpen, status]);
|
||||||
|
|
||||||
if (!isOpen || !status) {
|
if (!isOpen || !status) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveRuntimeSettings = async () => {
|
const localAvailableModels = normalizeModelList(localAvailableModelsText);
|
||||||
// Runtime config and API key are saved through separate backend commands, so
|
|
||||||
// key actions persist the latest base URL/model first to keep them in sync.
|
const runtimeRequest = {
|
||||||
const nextStatus = await agentSettingsBridge.saveSettings({ baseUrl, model });
|
remoteEnabled,
|
||||||
|
remoteBaseUrl,
|
||||||
|
defaultRemoteModel,
|
||||||
|
localEnabled,
|
||||||
|
localBaseUrl,
|
||||||
|
localAvailableModels,
|
||||||
|
taskDefaults,
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTaskRoute = (
|
||||||
|
task: TaskProfile,
|
||||||
|
updater: (route: AgentTaskRoute) => AgentTaskRoute,
|
||||||
|
) => {
|
||||||
|
setTaskDefaults((current) => ({
|
||||||
|
...current,
|
||||||
|
[task]: updater(current[task]),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveRuntimeConfig = async () => {
|
||||||
|
const nextStatus = await agentSettingsBridge.saveRuntimeConfig(runtimeRequest);
|
||||||
onStatusChange(nextStatus);
|
onStatusChange(nextStatus);
|
||||||
return nextStatus;
|
return nextStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveSettings = async () => {
|
const handleSaveRuntime = async () => {
|
||||||
setIsBusy(true);
|
setIsBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
try {
|
try {
|
||||||
await saveRuntimeSettings();
|
await saveRuntimeConfig();
|
||||||
setSuccess('Runtime settings saved.');
|
setSuccess('Provider routing saved.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to save settings.');
|
setError(err instanceof Error ? err.message : 'Failed to save runtime settings.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsBusy(false);
|
setIsBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveApiKey = async () => {
|
const handleSaveRemoteApiKey = async () => {
|
||||||
setIsBusy(true);
|
setIsBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
try {
|
try {
|
||||||
const savedStatus = await saveRuntimeSettings();
|
const savedStatus = await saveRuntimeConfig();
|
||||||
const nextStatus = await agentSettingsBridge.updateApiKey({ apiKey });
|
const nextStatus = await agentSettingsBridge.updateRemoteApiKey({ apiKey: remoteApiKey });
|
||||||
onStatusChange({ ...savedStatus, ...nextStatus });
|
onStatusChange({ ...savedStatus, ...nextStatus });
|
||||||
setApiKey('');
|
setRemoteApiKey('');
|
||||||
setSuccess(status.hasApiKey ? 'Plaintext API key updated.' : 'Plaintext API key saved.');
|
setSuccess(
|
||||||
|
status.hasRemoteApiKey
|
||||||
|
? 'Remote API key updated.'
|
||||||
|
: 'Remote API key saved.',
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to save API key.');
|
setError(err instanceof Error ? err.message : 'Failed to save remote API key.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsBusy(false);
|
setIsBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearApiKey = async () => {
|
const handleClearRemoteApiKey = async () => {
|
||||||
setIsBusy(true);
|
setIsBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
try {
|
try {
|
||||||
const savedStatus = await saveRuntimeSettings();
|
const savedStatus = await saveRuntimeConfig();
|
||||||
const nextStatus = await agentSettingsBridge.clearApiKey();
|
const nextStatus = await agentSettingsBridge.clearRemoteApiKey();
|
||||||
onStatusChange({ ...savedStatus, ...nextStatus });
|
onStatusChange({ ...savedStatus, ...nextStatus });
|
||||||
setApiKey('');
|
setRemoteApiKey('');
|
||||||
setSuccess('Plaintext API key cleared.');
|
setSuccess('Remote API key cleared.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to clear API key.');
|
setError(err instanceof Error ? err.message : 'Failed to clear remote API key.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsBusy(false);
|
setIsBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDiscoverLocalModels = async () => {
|
||||||
|
setIsBusy(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
setLocalStatusMessage(null);
|
||||||
|
try {
|
||||||
|
const result = await agentSettingsBridge.listLocalModels();
|
||||||
|
setLocalAvailableModelsText(result.models.join('\n'));
|
||||||
|
setTaskDefaults((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
for (const profile of TASK_PROFILES) {
|
||||||
|
if (
|
||||||
|
next[profile].providerMode === 'local' &&
|
||||||
|
!next[profile].model.trim() &&
|
||||||
|
result.models[0]
|
||||||
|
) {
|
||||||
|
next[profile] = { ...next[profile], model: result.models[0] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setLocalStatusMessage(
|
||||||
|
result.reachable
|
||||||
|
? `Discovered ${result.models.length} local model(s).`
|
||||||
|
: 'Loaded stored local models because the sidecar was unreachable.',
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to list local models.');
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckLocalHealth = async () => {
|
||||||
|
setIsBusy(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
try {
|
||||||
|
const result = await agentSettingsBridge.checkLocalProviderHealth();
|
||||||
|
setLocalStatusMessage(result.message);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to reach local provider.');
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTaskProviderChange = (task: TaskProfile, providerMode: ProviderMode) => {
|
||||||
|
const suggestedLocalModel = localAvailableModels[0] ?? '';
|
||||||
|
setTaskRoute(task, (route) => ({
|
||||||
|
providerMode,
|
||||||
|
model:
|
||||||
|
providerMode === 'remote'
|
||||||
|
? route.providerMode === 'remote' && route.model.trim()
|
||||||
|
? route.model
|
||||||
|
: defaultRemoteModel
|
||||||
|
: route.providerMode === 'local' && route.model.trim()
|
||||||
|
? route.model
|
||||||
|
: suggestedLocalModel,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDefaultRemoteModelChange = (nextValue: string) => {
|
||||||
|
const previousValue = defaultRemoteModel;
|
||||||
|
setDefaultRemoteModel(nextValue);
|
||||||
|
setTaskDefaults((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
for (const profile of TASK_PROFILES) {
|
||||||
|
if (
|
||||||
|
next[profile].providerMode === 'remote' &&
|
||||||
|
next[profile].model.trim() === previousValue.trim()
|
||||||
|
) {
|
||||||
|
next[profile] = { ...next[profile], model: nextValue };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/70 px-4">
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/70 px-4">
|
||||||
<div className="w-full max-w-2xl rounded-xl border border-[#2a2a2a] bg-[#0a0a0a] shadow-2xl">
|
<div className="max-h-[90vh] w-full max-w-4xl overflow-y-auto rounded-xl border border-[#2a2a2a] bg-[#0a0a0a] shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b border-[#2a2a2a] px-5 py-4">
|
<div className="flex items-center justify-between border-b border-[#2a2a2a] px-5 py-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-mono font-semibold text-[#e0e0e0]">AI Settings</h2>
|
<h2 className="text-sm font-mono font-semibold text-[#e0e0e0]">AI Settings</h2>
|
||||||
<p className="mt-1 text-xs font-mono text-[#888888]">
|
<p className="mt-1 text-xs font-mono text-[#888888]">
|
||||||
Configure the Z.AI coding endpoint and store the API key in plain text.
|
Configure remote and local providers, then assign a default provider/model per task.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -121,78 +282,232 @@ export const AgentSettingsModal: React.FC<AgentSettingsModalProps> = ({
|
|||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xs font-mono uppercase tracking-wide text-[#888888]">
|
<h3 className="text-xs font-mono uppercase tracking-wide text-[#888888]">
|
||||||
Runtime
|
Runtime Status
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-sm font-mono text-[#e0e0e0]">
|
<p className="mt-1 text-sm font-mono text-[#e0e0e0]">
|
||||||
{status.configured ? 'Configured' : 'API key required'}
|
{status.configured ? 'Configured' : 'Routing incomplete'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right text-xs font-mono text-[#888888]">
|
<div className="text-right text-xs font-mono text-[#888888]">
|
||||||
<div>Configured: {status.configured ? 'yes' : 'no'}</div>
|
<div>Remote ready: {status.remoteConfigured ? 'yes' : 'no'}</div>
|
||||||
<div>API key stored: {status.hasApiKey ? 'yes' : 'no'}</div>
|
<div>Local ready: {status.localConfigured ? 'yes' : 'no'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-[#2a2a2a] bg-[#111111] p-4">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-mono uppercase tracking-wide text-[#888888]">
|
||||||
|
Remote Provider
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-xs font-mono text-[#888888]">
|
||||||
|
OpenAI-compatible HTTP endpoint.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-xs font-mono text-[#e0e0e0]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={remoteEnabled}
|
||||||
|
onChange={(event) => setRemoteEnabled(event.target.checked)}
|
||||||
|
/>
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-xs font-mono text-[#888888]">Base URL</span>
|
<span className="mb-2 block text-xs font-mono text-[#888888]">
|
||||||
|
Remote Base URL
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
className={inputClassName}
|
className={inputClassName}
|
||||||
value={baseUrl}
|
value={remoteBaseUrl}
|
||||||
onChange={(event) => setBaseUrl(event.target.value)}
|
onChange={(event) => setRemoteBaseUrl(event.target.value)}
|
||||||
placeholder="https://api.z.ai/api/coding/paas/v4"
|
placeholder="https://api.z.ai/api/coding/paas/v4"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-xs font-mono text-[#888888]">Model</span>
|
<span className="mb-2 block text-xs font-mono text-[#888888]">
|
||||||
|
Default Remote Model
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
className={inputClassName}
|
className={inputClassName}
|
||||||
value={model}
|
value={defaultRemoteModel}
|
||||||
onChange={(event) => setModel(event.target.value)}
|
onChange={(event) => handleDefaultRemoteModelChange(event.target.value)}
|
||||||
placeholder="glm-5.1"
|
placeholder="glm-5.1"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-[#2a2a2a] bg-[#111111] p-4">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-mono uppercase tracking-wide text-[#888888]">
|
||||||
|
Local Provider
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-xs font-mono text-[#888888]">
|
||||||
|
Running Mistral HTTP sidecar on localhost.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-xs font-mono text-[#e0e0e0]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={localEnabled}
|
||||||
|
onChange={(event) => setLocalEnabled(event.target.checked)}
|
||||||
|
/>
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-mono text-[#888888]">
|
||||||
|
Local Base URL
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className={inputClassName}
|
||||||
|
value={localBaseUrl}
|
||||||
|
onChange={(event) => setLocalBaseUrl(event.target.value)}
|
||||||
|
placeholder="http://127.0.0.1:1234"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCheckLocalHealth}
|
||||||
|
disabled={isBusy}
|
||||||
|
className={buttonClassName}
|
||||||
|
>
|
||||||
|
Check Local
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDiscoverLocalModels}
|
||||||
|
disabled={isBusy}
|
||||||
|
className={buttonClassName}
|
||||||
|
>
|
||||||
|
Discover Models
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="mt-4 block">
|
||||||
|
<span className="mb-2 block text-xs font-mono text-[#888888]">
|
||||||
|
Available Local Models
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
className={textareaClassName}
|
||||||
|
value={localAvailableModelsText}
|
||||||
|
onChange={(event) => setLocalAvailableModelsText(event.target.value)}
|
||||||
|
placeholder={'qwen2.5:3b-instruct\nmistral-small'}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{localStatusMessage ? (
|
||||||
|
<div className="mt-4 rounded border border-[#2a2a2a] bg-[#0d0d0d] px-3 py-2 text-xs font-mono text-[#9fb3c8]">
|
||||||
|
{localStatusMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-[#2a2a2a] bg-[#111111] p-4">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-xs font-mono uppercase tracking-wide text-[#888888]">
|
||||||
|
Task Routing
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-xs font-mono text-[#888888]">
|
||||||
|
Choose the default provider and model for each harness task.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{TASK_PROFILES.map((task) => (
|
||||||
|
<div
|
||||||
|
key={task}
|
||||||
|
className="grid gap-3 rounded border border-[#1d1d1d] bg-[#0d0d0d] p-3 md:grid-cols-[180px_140px_minmax(0,1fr)]"
|
||||||
|
>
|
||||||
|
<div className="text-sm font-mono text-[#e0e0e0]">{TASK_LABELS[task]}</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-mono text-[#888888]">Provider</span>
|
||||||
|
<select
|
||||||
|
className={inputClassName}
|
||||||
|
value={taskDefaults[task].providerMode}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleTaskProviderChange(task, event.target.value as ProviderMode)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="remote">remote</option>
|
||||||
|
<option value="local">local</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-mono text-[#888888]">Model</span>
|
||||||
|
<input
|
||||||
|
list={taskDefaults[task].providerMode === 'local' ? `${task}-local-models` : undefined}
|
||||||
|
className={inputClassName}
|
||||||
|
value={taskDefaults[task].model}
|
||||||
|
onChange={(event) =>
|
||||||
|
setTaskRoute(task, (route) => ({ ...route, model: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
taskDefaults[task].providerMode === 'remote'
|
||||||
|
? defaultRemoteModel || 'Remote model'
|
||||||
|
: 'Local model'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{taskDefaults[task].providerMode === 'local' ? (
|
||||||
|
<datalist id={`${task}-local-models`}>
|
||||||
|
{localAvailableModels.map((model) => (
|
||||||
|
<option key={`${task}-${model}`} value={model} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex justify-end">
|
<div className="mt-4 flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSaveSettings}
|
onClick={handleSaveRuntime}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
className="rounded border border-[#2a2a2a] bg-[#151515] px-3 py-2 text-xs font-mono text-[#e0e0e0] transition-colors hover:border-[#58a6ff] hover:text-[#58a6ff] disabled:cursor-not-allowed disabled:opacity-50"
|
className={buttonClassName}
|
||||||
>
|
>
|
||||||
Save Runtime
|
Save Routing
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-lg border border-[#2a2a2a] bg-[#111111] p-4">
|
<section className="rounded-lg border border-[#2a2a2a] bg-[#111111] p-4">
|
||||||
<h3 className="text-xs font-mono uppercase tracking-wide text-[#888888]">
|
<h3 className="text-xs font-mono uppercase tracking-wide text-[#888888]">
|
||||||
{status.hasApiKey ? 'Plaintext API Key' : 'Save Plaintext API Key'}
|
Remote API Key
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-2 text-xs font-mono text-[#888888]">
|
<p className="mt-2 text-xs font-mono text-[#888888]">
|
||||||
This stores your provider key in plain text in the app settings file.
|
Stored in plain text for the remote OpenAI-compatible provider only.
|
||||||
</p>
|
</p>
|
||||||
<label className="mt-4 block">
|
<label className="mt-4 block">
|
||||||
<span className="mb-2 block text-xs font-mono text-[#888888]">
|
<span className="mb-2 block text-xs font-mono text-[#888888]">
|
||||||
{status.hasApiKey ? 'Replace API Key' : 'API Key'}
|
{status.hasRemoteApiKey ? 'Replace Remote API Key' : 'Remote API Key'}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
className={inputClassName}
|
className={inputClassName}
|
||||||
value={apiKey}
|
value={remoteApiKey}
|
||||||
onChange={(event) => setApiKey(event.target.value)}
|
onChange={(event) => setRemoteApiKey(event.target.value)}
|
||||||
placeholder="Enter API key"
|
placeholder="Enter remote API key"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="mt-4 flex justify-between">
|
<div className="mt-4 flex justify-between">
|
||||||
<div>
|
<div>
|
||||||
{status.hasApiKey ? (
|
{status.hasRemoteApiKey ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClearApiKey}
|
onClick={handleClearRemoteApiKey}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
className="rounded border border-[#2a2a2a] bg-[#151515] px-3 py-2 text-xs font-mono text-[#ff7b72] transition-colors hover:border-[#ff7b72] disabled:cursor-not-allowed disabled:opacity-50"
|
className="rounded border border-[#2a2a2a] bg-[#151515] px-3 py-2 text-xs font-mono text-[#ff7b72] transition-colors hover:border-[#ff7b72] disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
@@ -202,11 +517,11 @@ export const AgentSettingsModal: React.FC<AgentSettingsModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSaveApiKey}
|
onClick={handleSaveRemoteApiKey}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
className="rounded border border-[#2a2a2a] bg-[#151515] px-3 py-2 text-xs font-mono text-[#e0e0e0] transition-colors hover:border-[#58a6ff] hover:text-[#58a6ff] disabled:cursor-not-allowed disabled:opacity-50"
|
className={buttonClassName}
|
||||||
>
|
>
|
||||||
{status.hasApiKey ? 'Save Runtime & Update Key' : 'Save Runtime & Save Key'}
|
{status.hasRemoteApiKey ? 'Save Routing & Update Key' : 'Save Routing & Save Key'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import {
|
import {
|
||||||
AgentConfigStatus,
|
AgentConfigStatus,
|
||||||
SaveAgentSettingsRequest,
|
LocalModelList,
|
||||||
UpdateAgentApiKeyRequest,
|
LocalProviderHealthStatus,
|
||||||
|
SaveAgentRuntimeConfigRequest,
|
||||||
|
UpdateRemoteApiKeyRequest,
|
||||||
} from '../types/agentSettings';
|
} from '../types/agentSettings';
|
||||||
|
|
||||||
class AgentSettingsBridge {
|
class AgentSettingsBridge {
|
||||||
@@ -10,16 +12,24 @@ class AgentSettingsBridge {
|
|||||||
return invoke<AgentConfigStatus>('get_agent_config_status');
|
return invoke<AgentConfigStatus>('get_agent_config_status');
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveSettings(request: SaveAgentSettingsRequest): Promise<AgentConfigStatus> {
|
async saveRuntimeConfig(request: SaveAgentRuntimeConfigRequest): Promise<AgentConfigStatus> {
|
||||||
return invoke<AgentConfigStatus>('save_agent_settings', { request });
|
return invoke<AgentConfigStatus>('save_agent_runtime_config', { request });
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateApiKey(request: UpdateAgentApiKeyRequest): Promise<AgentConfigStatus> {
|
async updateRemoteApiKey(request: UpdateRemoteApiKeyRequest): Promise<AgentConfigStatus> {
|
||||||
return invoke<AgentConfigStatus>('update_agent_api_key', { request });
|
return invoke<AgentConfigStatus>('update_remote_api_key', { request });
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearApiKey(): Promise<AgentConfigStatus> {
|
async clearRemoteApiKey(): Promise<AgentConfigStatus> {
|
||||||
return invoke<AgentConfigStatus>('clear_agent_api_key');
|
return invoke<AgentConfigStatus>('clear_remote_api_key');
|
||||||
|
}
|
||||||
|
|
||||||
|
async listLocalModels(): Promise<LocalModelList> {
|
||||||
|
return invoke<LocalModelList>('list_local_models');
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkLocalProviderHealth(): Promise<LocalProviderHealthStatus> {
|
||||||
|
return invoke<LocalProviderHealthStatus>('check_local_provider_health');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,64 @@
|
|||||||
|
export type ProviderMode = 'remote' | 'local';
|
||||||
|
|
||||||
|
export type TaskProfile =
|
||||||
|
| 'interactiveChat'
|
||||||
|
| 'analysis'
|
||||||
|
| 'summarization'
|
||||||
|
| 'toolUse';
|
||||||
|
|
||||||
|
export interface AgentTaskRoute {
|
||||||
|
providerMode: ProviderMode;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentConfigStatus {
|
export interface AgentConfigStatus {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
hasApiKey: boolean;
|
remoteConfigured: boolean;
|
||||||
baseUrl: string;
|
localConfigured: boolean;
|
||||||
model: string;
|
remoteEnabled: boolean;
|
||||||
|
localEnabled: boolean;
|
||||||
|
hasRemoteApiKey: boolean;
|
||||||
|
remoteBaseUrl: string;
|
||||||
|
localBaseUrl: string;
|
||||||
|
defaultRemoteModel: string;
|
||||||
|
localAvailableModels: string[];
|
||||||
|
taskDefaults: Record<TaskProfile, AgentTaskRoute>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SaveAgentSettingsRequest {
|
export interface SaveAgentRuntimeConfigRequest {
|
||||||
baseUrl: string;
|
remoteEnabled: boolean;
|
||||||
model: string;
|
remoteBaseUrl: string;
|
||||||
|
defaultRemoteModel: string;
|
||||||
|
localEnabled: boolean;
|
||||||
|
localBaseUrl: string;
|
||||||
|
localAvailableModels: string[];
|
||||||
|
taskDefaults: Record<TaskProfile, AgentTaskRoute>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateAgentApiKeyRequest {
|
export interface UpdateRemoteApiKeyRequest {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LocalModelList {
|
||||||
|
reachable: boolean;
|
||||||
|
models: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalProviderHealthStatus {
|
||||||
|
reachable: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TASK_PROFILES: TaskProfile[] = [
|
||||||
|
'interactiveChat',
|
||||||
|
'analysis',
|
||||||
|
'summarization',
|
||||||
|
'toolUse',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TASK_LABELS: Record<TaskProfile, string> = {
|
||||||
|
interactiveChat: 'Interactive Chat',
|
||||||
|
analysis: 'Analysis',
|
||||||
|
summarization: 'Summarization',
|
||||||
|
toolUse: 'Tool Use',
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Company, NewsItem, Portfolio, SerializedNewsItem, StockAnalysis } from './financial';
|
import { Company, NewsItem, Portfolio, SerializedNewsItem, StockAnalysis } from './financial';
|
||||||
|
import { ProviderMode, TaskProfile } from './agentSettings';
|
||||||
|
|
||||||
export type PanelPayload =
|
export type PanelPayload =
|
||||||
| { type: 'company'; data: Company }
|
| { type: 'company'; data: Company }
|
||||||
@@ -29,6 +30,9 @@ export interface StartChatStreamRequest {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
|
agentProfile?: TaskProfile;
|
||||||
|
modelOverride?: string;
|
||||||
|
providerOverride?: ProviderMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatStreamStart {
|
export interface ChatStreamStart {
|
||||||
|
|||||||
Reference in New Issue
Block a user