Simplify provider settings pages
This commit is contained in:
+33
-12
@@ -1040,7 +1040,7 @@ impl LLMPreferences {
|
||||
};
|
||||
provider_entries.push((
|
||||
name,
|
||||
OpenAIProviderKind::OpenAICompatible,
|
||||
OpenAIProviderKind::LiteLLM,
|
||||
true,
|
||||
base_url,
|
||||
api_key,
|
||||
@@ -1057,7 +1057,14 @@ impl LLMPreferences {
|
||||
.iter()
|
||||
.filter_map(|provider| {
|
||||
let missing_credentials = match provider.kind {
|
||||
OpenAIProviderKind::OpenAICompatible => provider.base_url.trim().is_empty(),
|
||||
OpenAIProviderKind::OpenAI => {
|
||||
provider.base_url.trim().is_empty()
|
||||
|| provider
|
||||
.api_key
|
||||
.as_deref()
|
||||
.is_none_or(|key| key.trim().is_empty())
|
||||
}
|
||||
OpenAIProviderKind::LiteLLM => provider.base_url.trim().is_empty(),
|
||||
OpenAIProviderKind::Anthropic | OpenAIProviderKind::Gemini => provider
|
||||
.api_key
|
||||
.as_deref()
|
||||
@@ -1143,7 +1150,10 @@ impl LLMPreferences {
|
||||
max_input_tokens: Some(openai_model_context_size(model)),
|
||||
max_output_tokens: model.max_output_tokens,
|
||||
use_rig: model.use_rig
|
||||
|| !matches!(provider_kind, OpenAIProviderKind::OpenAICompatible),
|
||||
|| !matches!(
|
||||
provider_kind,
|
||||
OpenAIProviderKind::OpenAI | OpenAIProviderKind::LiteLLM
|
||||
),
|
||||
supports_system_messages: model.supports_system_messages(),
|
||||
};
|
||||
self.openai_provider_routing
|
||||
@@ -1531,6 +1541,7 @@ impl LLMPreferences {
|
||||
return;
|
||||
}
|
||||
|
||||
let provider_kind = provider.kind;
|
||||
let requested_base_url = provider.base_url;
|
||||
let api_key = provider.api_key.filter(|key| !key.is_empty());
|
||||
let request_base_url = requested_base_url.clone();
|
||||
@@ -1543,11 +1554,13 @@ impl LLMPreferences {
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
|
||||
if provider_kind == OpenAIProviderKind::LiteLLM {
|
||||
if let Some(models) =
|
||||
fetch_from_litellm_model_info(base, api_key.as_deref(), &client).await
|
||||
{
|
||||
return models;
|
||||
}
|
||||
}
|
||||
|
||||
fetch_from_openai_models(base, api_key.as_deref(), &client).await
|
||||
},
|
||||
@@ -1580,7 +1593,7 @@ impl LLMPreferences {
|
||||
|
||||
/// Discovers models for a provider draft without persisting or injecting it.
|
||||
///
|
||||
/// The provider setup modal uses this to keep configuration changes atomic
|
||||
/// The provider setup view uses this to keep configuration changes atomic
|
||||
/// until the user clicks Save.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub(crate) async fn discover_openai_provider_models(
|
||||
@@ -1624,7 +1637,9 @@ impl LLMPreferences {
|
||||
)?;
|
||||
Some(vertex_ai_model_catalog())
|
||||
}
|
||||
OpenAIProviderKind::OpenAICompatible | OpenAIProviderKind::ChatGPTSubscription => None,
|
||||
OpenAIProviderKind::OpenAI
|
||||
| OpenAIProviderKind::LiteLLM
|
||||
| OpenAIProviderKind::ChatGPTSubscription => None,
|
||||
};
|
||||
|
||||
if let Some(models) = native_models {
|
||||
@@ -1645,19 +1660,25 @@ impl LLMPreferences {
|
||||
.map_err(|error| format!("Could not create the provider client: {error}"))?;
|
||||
let api_key = provider.api_key.as_deref().filter(|key| !key.is_empty());
|
||||
|
||||
let models = if let Some(models) =
|
||||
fetch_from_litellm_model_info(&base_url, api_key, &client).await
|
||||
{
|
||||
let models = if provider.kind == OpenAIProviderKind::LiteLLM {
|
||||
if let Some(models) = fetch_from_litellm_model_info(&base_url, api_key, &client).await {
|
||||
models
|
||||
} else {
|
||||
fetch_from_openai_models(&base_url, api_key, &client).await
|
||||
}
|
||||
} else {
|
||||
fetch_from_openai_models(&base_url, api_key, &client).await
|
||||
};
|
||||
|
||||
if models.is_empty() {
|
||||
return Err(
|
||||
"The provider responded, but no models were found at /model/info or /models."
|
||||
.to_string(),
|
||||
);
|
||||
let endpoint_description = if provider.kind == OpenAIProviderKind::LiteLLM {
|
||||
"/model/info or /models"
|
||||
} else {
|
||||
"/models"
|
||||
};
|
||||
return Err(format!(
|
||||
"The provider responded, but no models were found at {endpoint_description}."
|
||||
));
|
||||
}
|
||||
|
||||
Ok(models)
|
||||
|
||||
@@ -41,7 +41,7 @@ pub(crate) fn rig_openai_response_stream(
|
||||
let prepared = prepare_rig_turn(&config, params, supported_tools, supported_cli_agent_tools);
|
||||
let model_id = prepared.request.model.as_str().to_string();
|
||||
match config.kind {
|
||||
OpenAIProviderKind::OpenAICompatible => {
|
||||
OpenAIProviderKind::OpenAI | OpenAIProviderKind::LiteLLM => {
|
||||
let runtime = OpenAICompatibleRuntime::new(OpenAICompatibleRuntimeConfig {
|
||||
base_url: config.base_url,
|
||||
api_key: config.api_key,
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::ai::skills::SkillDescriptor;
|
||||
|
||||
fn config() -> OpenAIClientConfig {
|
||||
OpenAIClientConfig {
|
||||
kind: crate::settings::OpenAIProviderKind::OpenAICompatible,
|
||||
kind: crate::settings::OpenAIProviderKind::LiteLLM,
|
||||
base_url: "http://localhost:4000/v1".to_string(),
|
||||
api_key: None,
|
||||
project_id: None,
|
||||
|
||||
+11
-10
@@ -978,10 +978,13 @@ impl ModelCapabilityOverride {
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OpenAIProviderKind {
|
||||
/// A regular OpenAI-compatible `/chat/completions` endpoint.
|
||||
#[serde(alias = "openai")]
|
||||
/// OpenAI's native Chat Completions API.
|
||||
#[serde(rename = "openai", alias = "open_ai")]
|
||||
OpenAI,
|
||||
/// A LiteLLM endpoint using the OpenAI-compatible API plus LiteLLM metadata APIs.
|
||||
#[serde(rename = "litellm", alias = "openai_compatible")]
|
||||
#[default]
|
||||
OpenAICompatible,
|
||||
LiteLLM,
|
||||
/// The ChatGPT subscription backend, authenticated with ChatGPT OAuth.
|
||||
ChatGPTSubscription,
|
||||
/// Anthropic's native Messages API.
|
||||
@@ -993,14 +996,12 @@ pub enum OpenAIProviderKind {
|
||||
VertexAI,
|
||||
}
|
||||
|
||||
/// Configuration for a single OpenAI-compatible provider endpoint.
|
||||
/// Configuration for a single direct model provider endpoint.
|
||||
///
|
||||
/// Multiple providers can be configured simultaneously (e.g. LiteLLM for cloud models,
|
||||
/// Ollama for local models, etc.). Each provider has its own endpoint, credentials, and model list.
|
||||
/// Multiple providers can be configured simultaneously. Each provider has its own endpoint,
|
||||
/// credentials, and model list.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
|
||||
#[schemars(
|
||||
description = "Configuration for an OpenAI-compatible provider endpoint (e.g. LiteLLM, Ollama, vLLM)."
|
||||
)]
|
||||
#[schemars(description = "Configuration for a direct model provider endpoint.")]
|
||||
pub struct OpenAIProviderConfig {
|
||||
#[serde(default)]
|
||||
#[schemars(description = "Provider protocol and authentication kind.")]
|
||||
@@ -1114,7 +1115,7 @@ pub(crate) fn default_chatgpt_provider() -> OpenAIProviderConfig {
|
||||
fn default_openai_providers() -> Vec<OpenAIProviderConfig> {
|
||||
vec![
|
||||
OpenAIProviderConfig {
|
||||
kind: OpenAIProviderKind::OpenAICompatible,
|
||||
kind: OpenAIProviderKind::LiteLLM,
|
||||
enabled: true,
|
||||
name: "LiteLLM (ai.ryserve.net)".to_string(),
|
||||
base_url: INITIAL_LITELLM_BASE_URL.to_string(),
|
||||
|
||||
@@ -351,7 +351,7 @@ fn initial_litellm_provider_maps_codex_model_to_rig_without_a_committed_key() {
|
||||
|
||||
assert_eq!(providers.len(), 2);
|
||||
let provider = &providers[0];
|
||||
assert_eq!(provider.kind, OpenAIProviderKind::OpenAICompatible);
|
||||
assert_eq!(provider.kind, OpenAIProviderKind::LiteLLM);
|
||||
assert_eq!(provider.base_url, INITIAL_LITELLM_BASE_URL);
|
||||
assert_eq!(provider.api_key, None);
|
||||
assert_eq!(provider.models.len(), 1);
|
||||
@@ -414,13 +414,6 @@ fn initial_litellm_provider_maps_codex_model_to_rig_without_a_committed_key() {
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
let instant = chatgpt
|
||||
.models
|
||||
.iter()
|
||||
.find(|model| model.model_id == "gpt-5.3-instant")
|
||||
.expect("GPT-5.3 Instant should be in the ChatGPT catalog");
|
||||
assert!(instant.reasoning_efforts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -446,9 +439,29 @@ fn native_provider_settings_roundtrip_with_vertex_configuration() {
|
||||
"models": []
|
||||
}))
|
||||
.expect("Legacy provider settings should remain compatible");
|
||||
assert_eq!(legacy.kind, OpenAIProviderKind::OpenAICompatible);
|
||||
assert_eq!(legacy.kind, OpenAIProviderKind::LiteLLM);
|
||||
assert_eq!(legacy.project_id, None);
|
||||
assert_eq!(legacy.location, None);
|
||||
|
||||
let legacy_openai_compatible: OpenAIProviderConfig =
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"kind": "openai_compatible",
|
||||
"name": "Legacy LiteLLM provider",
|
||||
"base_url": "http://localhost:4000/v1",
|
||||
"models": []
|
||||
}))
|
||||
.expect("Legacy OpenAI-compatible provider settings should deserialize");
|
||||
assert_eq!(legacy_openai_compatible.kind, OpenAIProviderKind::LiteLLM);
|
||||
|
||||
let native_openai: OpenAIProviderConfig = serde_json::from_value(serde_json::json!({
|
||||
"kind": "openai",
|
||||
"name": "OpenAI",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"api_key": "sk-test",
|
||||
"models": []
|
||||
}))
|
||||
.expect("Native OpenAI provider settings should deserialize");
|
||||
assert_eq!(native_openai.kind, OpenAIProviderKind::OpenAI);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+794
-322
File diff suppressed because it is too large
Load Diff
@@ -86,9 +86,8 @@ mod platform;
|
||||
mod platform_page;
|
||||
mod privacy;
|
||||
mod privacy_page;
|
||||
mod provider_setup_modal;
|
||||
mod provider_setup_view;
|
||||
mod scripting_page;
|
||||
mod set_default_model_modal;
|
||||
mod settings_file_footer;
|
||||
pub(crate) mod settings_page;
|
||||
mod tab_menu;
|
||||
@@ -248,6 +247,12 @@ pub enum SettingsSection {
|
||||
ThirdPartyCLIAgents,
|
||||
Models,
|
||||
Experiments,
|
||||
// ── Providers umbrella subpages ──
|
||||
ProviderOpenAI,
|
||||
ProviderLiteLLM,
|
||||
ProviderChatGPTSubscription,
|
||||
ProviderBedrock,
|
||||
ProviderACP,
|
||||
/// Internal backing-page identifier for CodeSettingsPageView. Multiple subpages
|
||||
/// (CodeIndexing, EditorAndCodeReview) share this single backing page,
|
||||
/// so this variant is needed as the key in `settings_pages`.
|
||||
@@ -276,6 +281,11 @@ impl Display for SettingsSection {
|
||||
SettingsSection::ThirdPartyCLIAgents => write!(f, "Third party CLI agents"),
|
||||
SettingsSection::Models => write!(f, "Models"),
|
||||
SettingsSection::Experiments => write!(f, "Experiments"),
|
||||
SettingsSection::ProviderOpenAI => write!(f, "OpenAI"),
|
||||
SettingsSection::ProviderLiteLLM => write!(f, "LiteLLM"),
|
||||
SettingsSection::ProviderChatGPTSubscription => write!(f, "ChatGPT Subscription"),
|
||||
SettingsSection::ProviderBedrock => write!(f, "Bedrock"),
|
||||
SettingsSection::ProviderACP => write!(f, "ACP"),
|
||||
SettingsSection::Warpify => write!(f, "Wormhole"),
|
||||
SettingsSection::CodeIndexing => write!(f, "Indexing and projects"),
|
||||
SettingsSection::EditorAndCodeReview => write!(f, "Editor and Code Review"),
|
||||
@@ -287,7 +297,7 @@ impl Display for SettingsSection {
|
||||
impl SettingsSection {
|
||||
/// Returns true if this section is a subpage under any umbrella.
|
||||
pub fn is_subpage(&self) -> bool {
|
||||
self.is_ai_subpage() || self.is_code_subpage()
|
||||
self.is_ai_subpage() || self.is_provider_subpage() || self.is_code_subpage()
|
||||
}
|
||||
|
||||
/// Returns true if this section is a subpage under the "Agents" umbrella.
|
||||
@@ -304,6 +314,36 @@ impl SettingsSection {
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns true if this section is a subpage under the "Providers" umbrella.
|
||||
pub fn is_provider_subpage(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::ProviderOpenAI
|
||||
| Self::ProviderLiteLLM
|
||||
| Self::ProviderChatGPTSubscription
|
||||
| Self::ProviderBedrock
|
||||
| Self::ProviderACP
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns true if this section renders through the AI settings backing page.
|
||||
pub fn is_ai_backed_subpage(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::WarpAgent
|
||||
| Self::AgentProfiles
|
||||
| Self::Knowledge
|
||||
| Self::ThirdPartyCLIAgents
|
||||
| Self::Models
|
||||
| Self::Experiments
|
||||
| Self::ProviderOpenAI
|
||||
| Self::ProviderLiteLLM
|
||||
| Self::ProviderChatGPTSubscription
|
||||
| Self::ProviderBedrock
|
||||
| Self::ProviderACP
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns true if this section is a subpage under the "Code" umbrella.
|
||||
pub fn is_code_subpage(&self) -> bool {
|
||||
matches!(self, Self::CodeIndexing | Self::EditorAndCodeReview)
|
||||
@@ -315,8 +355,8 @@ impl SettingsSection {
|
||||
match self {
|
||||
// AgentMCPServers renders the standalone MCPServers page directly.
|
||||
Self::AgentMCPServers => Self::MCPServers,
|
||||
// All other AI subpages render within the AI page.
|
||||
s if s.is_ai_subpage() => Self::AI,
|
||||
// AI and provider subpages render within the AI page.
|
||||
s if s.is_ai_backed_subpage() => Self::AI,
|
||||
// Code subpages render within the Code page.
|
||||
s if s.is_code_subpage() => Self::Code,
|
||||
other => *other,
|
||||
@@ -327,7 +367,6 @@ impl SettingsSection {
|
||||
pub fn ai_subpages() -> &'static [Self] {
|
||||
&[
|
||||
Self::WarpAgent,
|
||||
Self::Models,
|
||||
Self::AgentProfiles,
|
||||
Self::AgentMCPServers,
|
||||
Self::Knowledge,
|
||||
@@ -336,6 +375,17 @@ impl SettingsSection {
|
||||
]
|
||||
}
|
||||
|
||||
/// The ordered list of provider subpage sections shown under the Providers umbrella.
|
||||
pub fn provider_subpages() -> &'static [Self] {
|
||||
&[
|
||||
Self::ProviderOpenAI,
|
||||
Self::ProviderLiteLLM,
|
||||
Self::ProviderChatGPTSubscription,
|
||||
Self::ProviderBedrock,
|
||||
Self::ProviderACP,
|
||||
]
|
||||
}
|
||||
|
||||
/// The ordered list of Code subpage sections shown under the Code umbrella.
|
||||
pub fn code_subpages() -> &'static [Self] {
|
||||
&[Self::CodeIndexing, Self::EditorAndCodeReview]
|
||||
@@ -364,14 +414,12 @@ impl FromStr for SettingsSection {
|
||||
"MCP servers" | "AgentMCPServers" => Ok(Self::AgentMCPServers),
|
||||
"Knowledge" => Ok(Self::Knowledge),
|
||||
"Third party CLI agents" | "ThirdPartyCLIAgents" => Ok(Self::ThirdPartyCLIAgents),
|
||||
"Models"
|
||||
| "AWS Bedrock"
|
||||
| "Bedrock"
|
||||
| "OpenAI / LiteLLM"
|
||||
| "OpenAI"
|
||||
| "Agent runtimes"
|
||||
| "Agent Client Protocol"
|
||||
| "ACP" => Ok(Self::Models),
|
||||
"Models" | "Agent runtimes" => Ok(Self::ProviderOpenAI),
|
||||
"AWS Bedrock" | "Bedrock" => Ok(Self::ProviderBedrock),
|
||||
"OpenAI / LiteLLM" | "LiteLLM" => Ok(Self::ProviderLiteLLM),
|
||||
"OpenAI" => Ok(Self::ProviderOpenAI),
|
||||
"ChatGPT Subscription" | "ChatGPTSubscription" => Ok(Self::ProviderChatGPTSubscription),
|
||||
"Agent Client Protocol" | "ACP" => Ok(Self::ProviderACP),
|
||||
"Indexing and projects" | "CodeIndexing" => Ok(Self::CodeIndexing),
|
||||
"Editor and Code Review" | "EditorAndCodeReview" => Ok(Self::EditorAndCodeReview),
|
||||
"Experiments" => Ok(Self::Experiments),
|
||||
@@ -1235,6 +1283,10 @@ impl SettingsView {
|
||||
"Agents",
|
||||
SettingsSection::ai_subpages().to_vec(),
|
||||
)),
|
||||
SettingsNavItem::Umbrella(SettingsUmbrella::new(
|
||||
"Providers",
|
||||
SettingsSection::provider_subpages().to_vec(),
|
||||
)),
|
||||
SettingsNavItem::Umbrella(SettingsUmbrella::new(
|
||||
"Code",
|
||||
vec![
|
||||
@@ -1367,7 +1419,10 @@ impl SettingsView {
|
||||
// For each AI subpage, temporarily switch to that subpage's
|
||||
// widget set and run the filter to get a subpage-specific result.
|
||||
self.subpage_filter.clear();
|
||||
for &subpage_section in SettingsSection::ai_subpages() {
|
||||
for &subpage_section in SettingsSection::ai_subpages()
|
||||
.iter()
|
||||
.chain(SettingsSection::provider_subpages().iter())
|
||||
{
|
||||
if subpage_section == SettingsSection::AgentMCPServers {
|
||||
// AgentMCPServers has its own backing page; handled below.
|
||||
continue;
|
||||
@@ -1434,7 +1489,7 @@ impl SettingsView {
|
||||
// Restore the active subpage after filtering.
|
||||
if is_search_active {
|
||||
let current = self.current_settings_page;
|
||||
if current.is_ai_subpage() && current != SettingsSection::AgentMCPServers {
|
||||
if current.is_ai_backed_subpage() {
|
||||
if let Some(subpage) = AISubpage::from_section(current) {
|
||||
self.ai_page_handle.update(ctx, |view, ctx| {
|
||||
view.set_active_subpage(Some(subpage), ctx);
|
||||
@@ -1737,7 +1792,7 @@ impl SettingsView {
|
||||
|
||||
fn handle_ai_page_event(&mut self, event: &AISettingsPageEvent, ctx: &mut ViewContext<Self>) {
|
||||
match event {
|
||||
AISettingsPageEvent::FocusModal => ctx.focus(&self.search_editor),
|
||||
AISettingsPageEvent::FocusSearch => ctx.focus(&self.search_editor),
|
||||
AISettingsPageEvent::OpenAIFactCollection => {
|
||||
ctx.emit(SettingsViewEvent::OpenAIFactCollection)
|
||||
}
|
||||
@@ -1758,10 +1813,6 @@ impl SettingsView {
|
||||
AISettingsPageEvent::SignupAnonymousUser => {
|
||||
ctx.emit(SettingsViewEvent::SignupAnonymousUser)
|
||||
}
|
||||
AISettingsPageEvent::ShowModal | AISettingsPageEvent::HideModal => {
|
||||
// Modal rendering is handled in get_modal_content_for_page
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1853,8 +1904,8 @@ impl SettingsView {
|
||||
// When navigating to a subpage, update the backing page's active subpage mode
|
||||
// and auto-expand the umbrella containing it.
|
||||
if section.is_subpage() {
|
||||
// AI subpages: update the AI page's subpage mode.
|
||||
if section.is_ai_subpage() && section != SettingsSection::AgentMCPServers {
|
||||
// AI-backed subpages: update the AI page's subpage mode.
|
||||
if section.is_ai_backed_subpage() {
|
||||
let subpage = AISubpage::from_section(section);
|
||||
self.ai_page_handle.update(ctx, |view, ctx| {
|
||||
view.set_active_subpage(subpage, ctx);
|
||||
@@ -2113,9 +2164,6 @@ impl SettingsView {
|
||||
SettingsPageViewHandle::MCPServers(view) => {
|
||||
view.read(app, |view, _| view.get_modal_content(app))
|
||||
}
|
||||
SettingsPageViewHandle::AI(view) => {
|
||||
view.read(app, |view, _| view.get_modal_content(app))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,30 @@ fn ai_subpages_are_classified_and_map_to_their_backing_pages() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_subpages_are_classified_and_map_to_ai_backing_page() {
|
||||
assert_eq!(
|
||||
SettingsSection::provider_subpages(),
|
||||
&[
|
||||
SettingsSection::ProviderOpenAI,
|
||||
SettingsSection::ProviderLiteLLM,
|
||||
SettingsSection::ProviderChatGPTSubscription,
|
||||
SettingsSection::ProviderBedrock,
|
||||
SettingsSection::ProviderACP,
|
||||
]
|
||||
);
|
||||
for section in SettingsSection::provider_subpages() {
|
||||
assert!(section.is_provider_subpage());
|
||||
assert!(section.is_subpage());
|
||||
assert_eq!(
|
||||
section.parent_page_section(),
|
||||
SettingsSection::AI,
|
||||
"{section:?} should use the AI backing page"
|
||||
);
|
||||
}
|
||||
assert!(!SettingsSection::AI.is_provider_subpage());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_subpages_are_classified_and_map_to_code() {
|
||||
assert_eq!(
|
||||
@@ -86,8 +110,15 @@ fn current_settings_display_names_round_trip() {
|
||||
SettingsSection::ThirdPartyCLIAgents,
|
||||
"Third party CLI agents",
|
||||
),
|
||||
(SettingsSection::Models, "Models"),
|
||||
(SettingsSection::Experiments, "Experiments"),
|
||||
(SettingsSection::ProviderOpenAI, "OpenAI"),
|
||||
(SettingsSection::ProviderLiteLLM, "LiteLLM"),
|
||||
(
|
||||
SettingsSection::ProviderChatGPTSubscription,
|
||||
"ChatGPT Subscription",
|
||||
),
|
||||
(SettingsSection::ProviderBedrock, "Bedrock"),
|
||||
(SettingsSection::ProviderACP, "ACP"),
|
||||
(SettingsSection::CodeIndexing, "Indexing and projects"),
|
||||
(
|
||||
SettingsSection::EditorAndCodeReview,
|
||||
@@ -110,13 +141,23 @@ fn legacy_settings_names_remain_parseable() {
|
||||
("AgentProfiles", SettingsSection::AgentProfiles),
|
||||
("AgentMCPServers", SettingsSection::AgentMCPServers),
|
||||
("ThirdPartyCLIAgents", SettingsSection::ThirdPartyCLIAgents),
|
||||
("AWS Bedrock", SettingsSection::Models),
|
||||
("Bedrock", SettingsSection::Models),
|
||||
("OpenAI / LiteLLM", SettingsSection::Models),
|
||||
("OpenAI", SettingsSection::Models),
|
||||
("Agent runtimes", SettingsSection::Models),
|
||||
("Agent Client Protocol", SettingsSection::Models),
|
||||
("ACP", SettingsSection::Models),
|
||||
("AWS Bedrock", SettingsSection::ProviderBedrock),
|
||||
("Bedrock", SettingsSection::ProviderBedrock),
|
||||
("OpenAI / LiteLLM", SettingsSection::ProviderLiteLLM),
|
||||
("LiteLLM", SettingsSection::ProviderLiteLLM),
|
||||
("OpenAI", SettingsSection::ProviderOpenAI),
|
||||
("Models", SettingsSection::ProviderOpenAI),
|
||||
("Agent runtimes", SettingsSection::ProviderOpenAI),
|
||||
(
|
||||
"ChatGPTSubscription",
|
||||
SettingsSection::ProviderChatGPTSubscription,
|
||||
),
|
||||
(
|
||||
"ChatGPT Subscription",
|
||||
SettingsSection::ProviderChatGPTSubscription,
|
||||
),
|
||||
("Agent Client Protocol", SettingsSection::ProviderACP),
|
||||
("ACP", SettingsSection::ProviderACP),
|
||||
("CodeIndexing", SettingsSection::CodeIndexing),
|
||||
("EditorAndCodeReview", SettingsSection::EditorAndCodeReview),
|
||||
] {
|
||||
@@ -139,6 +180,10 @@ fn realistic_nav_items() -> Vec<SettingsNavItem> {
|
||||
"Agents",
|
||||
SettingsSection::ai_subpages().to_vec(),
|
||||
)),
|
||||
SettingsNavItem::Umbrella(SettingsUmbrella::new(
|
||||
"Providers",
|
||||
SettingsSection::provider_subpages().to_vec(),
|
||||
)),
|
||||
SettingsNavItem::Umbrella(SettingsUmbrella::new(
|
||||
"Code",
|
||||
SettingsSection::code_subpages().to_vec(),
|
||||
@@ -166,7 +211,7 @@ fn collapsed_umbrellas_each_form_one_navigation_stop() {
|
||||
let nav_items = realistic_nav_items();
|
||||
let stops = build_nav_stops(&nav_items, |_| true);
|
||||
|
||||
assert_eq!(stops.len(), 10);
|
||||
assert_eq!(stops.len(), 11);
|
||||
assert_eq!(
|
||||
stops[0],
|
||||
NavStop::CollapsedUmbrella {
|
||||
@@ -179,12 +224,20 @@ fn collapsed_umbrellas_each_form_one_navigation_stop() {
|
||||
stops[1],
|
||||
NavStop::CollapsedUmbrella {
|
||||
nav_index: 1,
|
||||
first_subpage: SettingsSection::ProviderOpenAI,
|
||||
last_subpage: SettingsSection::ProviderACP,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
stops[2],
|
||||
NavStop::CollapsedUmbrella {
|
||||
nav_index: 2,
|
||||
first_subpage: SettingsSection::CodeIndexing,
|
||||
last_subpage: SettingsSection::EditorAndCodeReview,
|
||||
}
|
||||
);
|
||||
assert_eq!(stops[2], NavStop::Section(SettingsSection::Appearance));
|
||||
assert_eq!(stops[9], NavStop::Section(SettingsSection::Scripting));
|
||||
assert_eq!(stops[3], NavStop::Section(SettingsSection::Appearance));
|
||||
assert_eq!(stops[10], NavStop::Section(SettingsSection::Scripting));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -207,8 +260,8 @@ fn expanded_umbrella_has_one_stop_per_visible_subpage() {
|
||||
stops[expected_ai_sections.len()],
|
||||
NavStop::CollapsedUmbrella {
|
||||
nav_index: 1,
|
||||
first_subpage: SettingsSection::CodeIndexing,
|
||||
last_subpage: SettingsSection::EditorAndCodeReview,
|
||||
first_subpage: SettingsSection::ProviderOpenAI,
|
||||
last_subpage: SettingsSection::ProviderACP,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -244,6 +297,9 @@ fn umbrella_without_visible_subpages_is_skipped() {
|
||||
assert!(stops
|
||||
.iter()
|
||||
.any(|stop| matches!(stop, NavStop::CollapsedUmbrella { nav_index: 1, .. })));
|
||||
assert!(stops
|
||||
.iter()
|
||||
.any(|stop| matches!(stop, NavStop::CollapsedUmbrella { nav_index: 2, .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -263,16 +319,20 @@ fn current_stop_matches_sections_and_collapsed_umbrella_children() {
|
||||
|
||||
assert_eq!(
|
||||
current_stop_index(&stops, &nav_items, SettingsSection::Appearance),
|
||||
Some(2)
|
||||
Some(3)
|
||||
);
|
||||
assert_eq!(
|
||||
current_stop_index(&stops, &nav_items, SettingsSection::Knowledge),
|
||||
Some(0)
|
||||
);
|
||||
assert_eq!(
|
||||
current_stop_index(&stops, &nav_items, SettingsSection::CodeIndexing),
|
||||
current_stop_index(&stops, &nav_items, SettingsSection::ProviderLiteLLM),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(
|
||||
current_stop_index(&stops, &nav_items, SettingsSection::CodeIndexing),
|
||||
Some(2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -355,6 +415,6 @@ fn cycling_leaves_an_expanded_umbrella_after_its_last_subpage() {
|
||||
SettingsSection::Experiments,
|
||||
CycleDirection::Down,
|
||||
),
|
||||
SettingsSection::CodeIndexing
|
||||
SettingsSection::ProviderOpenAI
|
||||
);
|
||||
}
|
||||
|
||||
+339
-145
@@ -2,10 +2,11 @@ use galaxy_cli::agent::Harness;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxyui::elements::{
|
||||
Border, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container,
|
||||
CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement, MainAxisAlignment, MainAxisSize,
|
||||
MouseStateHandle, Padding, ParentElement, Radius, ScrollbarWidth, Text,
|
||||
CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement, Hoverable, MainAxisAlignment,
|
||||
MainAxisSize, MouseStateHandle, Padding, ParentElement, Radius, ScrollbarWidth, Text,
|
||||
};
|
||||
use galaxyui::fonts::{Properties, Weight};
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::text_layout::ClipConfig;
|
||||
use galaxyui::ui_components::button::ButtonVariant;
|
||||
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
@@ -23,7 +24,6 @@ use crate::appearance::Appearance;
|
||||
use crate::editor::{
|
||||
EditorView, Event as EditorEvent, SingleLineEditorOptions, TextColors, TextOptions,
|
||||
};
|
||||
use crate::modal::{Modal, ModalViewState};
|
||||
use crate::settings::ai::{
|
||||
AcpConfigOptionSettings, BedrockAuthMethod, BedrockModelConfig, ModelCapabilityOverride,
|
||||
OpenAIModelConfig, OpenAIProviderConfig, OpenAIProviderKind,
|
||||
@@ -33,14 +33,12 @@ use crate::view_components::action_button::{
|
||||
ActionButton, ButtonSize, NakedTheme, PrimaryTheme, SecondaryTheme,
|
||||
};
|
||||
|
||||
const MODAL_WIDTH: f32 = 900.;
|
||||
const MODAL_HEIGHT: f32 = 700.;
|
||||
const BODY_HEIGHT: f32 = 630.;
|
||||
const SETUP_WIDTH: f32 = 900.;
|
||||
const INPUT_FONT_SIZE: f32 = 12.;
|
||||
const MODEL_LOGO_SIZE: f32 = 20.;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum ProviderSetupStep {
|
||||
pub(crate) enum ProviderSetupStep {
|
||||
ProviderType,
|
||||
Configure,
|
||||
Discover,
|
||||
@@ -49,8 +47,9 @@ enum ProviderSetupStep {
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ProviderSetupProviderType {
|
||||
OpenAI,
|
||||
LiteLLM,
|
||||
ChatGPTSubscription,
|
||||
OpenAICompatible,
|
||||
Anthropic,
|
||||
Gemini,
|
||||
VertexAI,
|
||||
@@ -59,41 +58,26 @@ pub enum ProviderSetupProviderType {
|
||||
}
|
||||
|
||||
const PROVIDER_TYPE_OPTIONS: &[(ProviderSetupProviderType, &str, &str)] = &[
|
||||
(
|
||||
ProviderSetupProviderType::OpenAI,
|
||||
"OpenAI",
|
||||
"Connect directly to OpenAI's API with an API key.",
|
||||
),
|
||||
(
|
||||
ProviderSetupProviderType::LiteLLM,
|
||||
"LiteLLM",
|
||||
"Connect LiteLLM and use its richer model metadata APIs during discovery.",
|
||||
),
|
||||
(
|
||||
ProviderSetupProviderType::ChatGPTSubscription,
|
||||
"ChatGPT subscription",
|
||||
"Use your ChatGPT Plus or Pro subscription with native OAuth.",
|
||||
),
|
||||
(
|
||||
ProviderSetupProviderType::OpenAICompatible,
|
||||
"OpenAI-compatible API",
|
||||
"Connect LiteLLM, Ollama, vLLM, or another compatible endpoint.",
|
||||
),
|
||||
(
|
||||
ProviderSetupProviderType::Anthropic,
|
||||
"Anthropic",
|
||||
"Connect directly to Anthropic's native Messages API with an API key.",
|
||||
),
|
||||
(
|
||||
ProviderSetupProviderType::Gemini,
|
||||
"Google Gemini",
|
||||
"Connect directly to Google's Gemini API with an API key.",
|
||||
),
|
||||
(
|
||||
ProviderSetupProviderType::VertexAI,
|
||||
"Google Vertex AI",
|
||||
"Use Google Cloud Application Default Credentials for Vertex-hosted Gemini models.",
|
||||
),
|
||||
(
|
||||
ProviderSetupProviderType::Bedrock,
|
||||
"AWS Bedrock",
|
||||
"Use the AWS Bedrock credentials and model configuration already managed by Galaxy.",
|
||||
),
|
||||
(
|
||||
ProviderSetupProviderType::Acp,
|
||||
"ACP agent runtime",
|
||||
"Use a session-oriented ACP agent that owns its model and authentication.",
|
||||
),
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -165,7 +149,7 @@ impl CapabilityKey {
|
||||
}
|
||||
}
|
||||
|
||||
pub enum ProviderSetupModalBodyEvent {
|
||||
pub enum ProviderSetupViewEvent {
|
||||
Close,
|
||||
RequestAcpDiscovery(AcpProviderDraft),
|
||||
SaveOpenAI {
|
||||
@@ -177,8 +161,9 @@ pub enum ProviderSetupModalBodyEvent {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ProviderSetupModalBodyAction {
|
||||
pub enum ProviderSetupViewAction {
|
||||
SelectProvider(ProviderSetupProviderType),
|
||||
JumpToStep(ProviderSetupStep),
|
||||
Next,
|
||||
Back,
|
||||
Cancel,
|
||||
@@ -193,12 +178,11 @@ pub enum ProviderSetupModalBodyAction {
|
||||
SelectAcpAgent(String),
|
||||
}
|
||||
|
||||
pub type ProviderSetupModalState = ModalViewState<Modal<ProviderSetupModalBody>>;
|
||||
|
||||
pub struct ProviderSetupModalBody {
|
||||
pub struct ProviderSetupView {
|
||||
step: ProviderSetupStep,
|
||||
editing_index: Option<usize>,
|
||||
provider_type: ProviderSetupProviderType,
|
||||
provider_type_locked: bool,
|
||||
draft_name: String,
|
||||
draft_base_url: String,
|
||||
draft_api_key: Option<String>,
|
||||
@@ -233,12 +217,13 @@ pub struct ProviderSetupModalBody {
|
||||
model_context_editors: Vec<ViewHandle<EditorView>>,
|
||||
provider_type_scroll_state: ClippedScrollStateHandle,
|
||||
models_scroll_state: ClippedScrollStateHandle,
|
||||
step_tab_mouse_states: Vec<MouseStateHandle>,
|
||||
back_button: ViewHandle<ActionButton>,
|
||||
cancel_button: ViewHandle<ActionButton>,
|
||||
next_button: ViewHandle<ActionButton>,
|
||||
}
|
||||
|
||||
impl ProviderSetupModalBody {
|
||||
impl ProviderSetupView {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let provider_type_buttons = PROVIDER_TYPE_OPTIONS
|
||||
.iter()
|
||||
@@ -249,9 +234,9 @@ impl ProviderSetupModalBody {
|
||||
ActionButton::new(label, NakedTheme)
|
||||
.with_full_width(true)
|
||||
.on_click(move |ctx| {
|
||||
ctx.dispatch_typed_action(
|
||||
ProviderSetupModalBodyAction::SelectProvider(kind),
|
||||
);
|
||||
ctx.dispatch_typed_action(ProviderSetupViewAction::SelectProvider(
|
||||
kind,
|
||||
));
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -281,9 +266,9 @@ impl ProviderSetupModalBody {
|
||||
.on_click({
|
||||
let id = id.clone();
|
||||
move |ctx| {
|
||||
ctx.dispatch_typed_action(
|
||||
ProviderSetupModalBodyAction::SelectAcpAgent(id.clone()),
|
||||
);
|
||||
ctx.dispatch_typed_action(ProviderSetupViewAction::SelectAcpAgent(
|
||||
id.clone(),
|
||||
));
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -293,7 +278,7 @@ impl ProviderSetupModalBody {
|
||||
ActionButton::new("Custom", NakedTheme)
|
||||
.with_full_width(true)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(ProviderSetupModalBodyAction::SelectAcpAgent(
|
||||
ctx.dispatch_typed_action(ProviderSetupViewAction::SelectAcpAgent(
|
||||
"custom".to_owned(),
|
||||
));
|
||||
})
|
||||
@@ -308,9 +293,7 @@ impl ProviderSetupModalBody {
|
||||
.map(|method| {
|
||||
ctx.add_typed_action_view(move |_| {
|
||||
ActionButton::new(method.display_name(), NakedTheme).on_click(move |ctx| {
|
||||
ctx.dispatch_typed_action(ProviderSetupModalBodyAction::SelectBedrockAuth(
|
||||
method,
|
||||
));
|
||||
ctx.dispatch_typed_action(ProviderSetupViewAction::SelectBedrockAuth(method));
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -397,24 +380,25 @@ impl ProviderSetupModalBody {
|
||||
|
||||
let back_button = ctx.add_typed_action_view(|_| {
|
||||
ActionButton::new("Back", NakedTheme).on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(ProviderSetupModalBodyAction::Back);
|
||||
ctx.dispatch_typed_action(ProviderSetupViewAction::Back);
|
||||
})
|
||||
});
|
||||
let cancel_button = ctx.add_typed_action_view(|_| {
|
||||
ActionButton::new("Cancel", NakedTheme).on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(ProviderSetupModalBodyAction::Cancel);
|
||||
ctx.dispatch_typed_action(ProviderSetupViewAction::Cancel);
|
||||
})
|
||||
});
|
||||
let next_button = ctx.add_typed_action_view(|_| {
|
||||
ActionButton::new("Next", PrimaryTheme).on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(ProviderSetupModalBodyAction::Next);
|
||||
ctx.dispatch_typed_action(ProviderSetupViewAction::Next);
|
||||
})
|
||||
});
|
||||
|
||||
Self {
|
||||
step: ProviderSetupStep::ProviderType,
|
||||
editing_index: None,
|
||||
provider_type: ProviderSetupProviderType::OpenAICompatible,
|
||||
provider_type: ProviderSetupProviderType::LiteLLM,
|
||||
provider_type_locked: false,
|
||||
draft_name: String::new(),
|
||||
draft_base_url: String::new(),
|
||||
draft_api_key: None,
|
||||
@@ -466,6 +450,7 @@ impl ProviderSetupModalBody {
|
||||
model_context_editors: Vec::new(),
|
||||
provider_type_scroll_state: ClippedScrollStateHandle::default(),
|
||||
models_scroll_state: ClippedScrollStateHandle::default(),
|
||||
step_tab_mouse_states: (0..4).map(|_| MouseStateHandle::default()).collect(),
|
||||
back_button,
|
||||
cancel_button,
|
||||
next_button,
|
||||
@@ -499,12 +484,43 @@ impl ProviderSetupModalBody {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn begin_create(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.step = ProviderSetupStep::ProviderType;
|
||||
fn default_name(provider_type: ProviderSetupProviderType) -> &'static str {
|
||||
match provider_type {
|
||||
ProviderSetupProviderType::OpenAI => "OpenAI",
|
||||
ProviderSetupProviderType::LiteLLM => "LiteLLM",
|
||||
ProviderSetupProviderType::ChatGPTSubscription => "ChatGPT Subscription",
|
||||
ProviderSetupProviderType::Anthropic => "Anthropic",
|
||||
ProviderSetupProviderType::Gemini => "Google Gemini",
|
||||
ProviderSetupProviderType::VertexAI => "Google Vertex AI",
|
||||
ProviderSetupProviderType::Bedrock => "AWS Bedrock",
|
||||
ProviderSetupProviderType::Acp => "ACP agent runtime",
|
||||
}
|
||||
}
|
||||
|
||||
fn default_base_url(provider_type: ProviderSetupProviderType) -> &'static str {
|
||||
match provider_type {
|
||||
ProviderSetupProviderType::OpenAI => "https://api.openai.com/v1",
|
||||
ProviderSetupProviderType::LiteLLM
|
||||
| ProviderSetupProviderType::ChatGPTSubscription
|
||||
| ProviderSetupProviderType::Anthropic
|
||||
| ProviderSetupProviderType::Gemini
|
||||
| ProviderSetupProviderType::VertexAI
|
||||
| ProviderSetupProviderType::Bedrock
|
||||
| ProviderSetupProviderType::Acp => "",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn begin_create(
|
||||
&mut self,
|
||||
provider_type: ProviderSetupProviderType,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.step = ProviderSetupStep::Configure;
|
||||
self.editing_index = None;
|
||||
self.provider_type = ProviderSetupProviderType::OpenAICompatible;
|
||||
self.draft_name.clear();
|
||||
self.draft_base_url.clear();
|
||||
self.provider_type = provider_type;
|
||||
self.provider_type_locked = true;
|
||||
self.draft_name = Self::default_name(provider_type).to_string();
|
||||
self.draft_base_url = Self::default_base_url(provider_type).to_string();
|
||||
self.draft_api_key = None;
|
||||
self.draft_project_id.clear();
|
||||
self.draft_location = "global".to_string();
|
||||
@@ -549,11 +565,13 @@ impl ProviderSetupModalBody {
|
||||
// send the user through credentials or model discovery again.
|
||||
self.step = ProviderSetupStep::Models;
|
||||
self.editing_index = Some(editing_index);
|
||||
self.provider_type_locked = true;
|
||||
self.provider_type = match provider.kind {
|
||||
OpenAIProviderKind::OpenAI => ProviderSetupProviderType::OpenAI,
|
||||
OpenAIProviderKind::LiteLLM => ProviderSetupProviderType::LiteLLM,
|
||||
OpenAIProviderKind::ChatGPTSubscription => {
|
||||
ProviderSetupProviderType::ChatGPTSubscription
|
||||
}
|
||||
OpenAIProviderKind::OpenAICompatible => ProviderSetupProviderType::OpenAICompatible,
|
||||
OpenAIProviderKind::Anthropic => ProviderSetupProviderType::Anthropic,
|
||||
OpenAIProviderKind::Gemini => ProviderSetupProviderType::Gemini,
|
||||
OpenAIProviderKind::VertexAI => ProviderSetupProviderType::VertexAI,
|
||||
@@ -578,6 +596,7 @@ impl ProviderSetupModalBody {
|
||||
pub fn begin_edit_bedrock(&mut self, draft: BedrockProviderDraft, ctx: &mut ViewContext<Self>) {
|
||||
self.step = ProviderSetupStep::Configure;
|
||||
self.editing_index = None;
|
||||
self.provider_type_locked = true;
|
||||
self.provider_type = ProviderSetupProviderType::Bedrock;
|
||||
self.draft_name = draft.name.clone();
|
||||
self.draft_bedrock = draft;
|
||||
@@ -599,6 +618,7 @@ impl ProviderSetupModalBody {
|
||||
ProviderSetupStep::Models
|
||||
};
|
||||
self.editing_index = None;
|
||||
self.provider_type_locked = true;
|
||||
self.provider_type = ProviderSetupProviderType::Acp;
|
||||
self.draft_name = draft.name.clone();
|
||||
self.draft_acp = draft;
|
||||
@@ -744,7 +764,7 @@ impl ProviderSetupModalBody {
|
||||
.with_size(ButtonSize::XSmall)
|
||||
.on_click(move |ctx| {
|
||||
ctx.dispatch_typed_action(
|
||||
ProviderSetupModalBodyAction::CycleModelCapability(index, key),
|
||||
ProviderSetupViewAction::CycleModelCapability(index, key),
|
||||
);
|
||||
})
|
||||
})
|
||||
@@ -793,27 +813,7 @@ impl ProviderSetupModalBody {
|
||||
fn update_next_button(&self, ctx: &mut ViewContext<Self>) {
|
||||
let (label, disabled) = match self.step {
|
||||
ProviderSetupStep::ProviderType => ("Next", false),
|
||||
ProviderSetupStep::Configure => {
|
||||
let disabled = match self.provider_type {
|
||||
ProviderSetupProviderType::OpenAICompatible => {
|
||||
self.draft_base_url.trim().is_empty()
|
||||
}
|
||||
ProviderSetupProviderType::Anthropic | ProviderSetupProviderType::Gemini => {
|
||||
self.draft_api_key
|
||||
.as_deref()
|
||||
.is_none_or(|key| key.trim().is_empty())
|
||||
}
|
||||
ProviderSetupProviderType::VertexAI => self.draft_project_id.trim().is_empty(),
|
||||
ProviderSetupProviderType::Acp => {
|
||||
self.draft_acp.agent_id.trim().is_empty()
|
||||
|| (self.draft_acp.agent_id.eq_ignore_ascii_case("custom")
|
||||
&& self.draft_acp.command.trim().is_empty())
|
||||
}
|
||||
ProviderSetupProviderType::ChatGPTSubscription
|
||||
| ProviderSetupProviderType::Bedrock => false,
|
||||
};
|
||||
("Next", disabled)
|
||||
}
|
||||
ProviderSetupStep::Configure => ("Next", !self.is_configure_valid()),
|
||||
ProviderSetupStep::Discover => (
|
||||
if matches!(self.discovery_state, DiscoveryState::Failed(_)) {
|
||||
"Retry"
|
||||
@@ -823,7 +823,8 @@ impl ProviderSetupModalBody {
|
||||
!matches!(self.discovery_state, DiscoveryState::Failed(_)),
|
||||
),
|
||||
ProviderSetupStep::Models => match self.provider_type {
|
||||
ProviderSetupProviderType::OpenAICompatible
|
||||
ProviderSetupProviderType::OpenAI
|
||||
| ProviderSetupProviderType::LiteLLM
|
||||
| ProviderSetupProviderType::ChatGPTSubscription
|
||||
| ProviderSetupProviderType::Anthropic
|
||||
| ProviderSetupProviderType::Gemini
|
||||
@@ -851,12 +852,14 @@ impl ProviderSetupModalBody {
|
||||
fn draft_provider(&self) -> OpenAIProviderConfig {
|
||||
OpenAIProviderConfig {
|
||||
kind: match self.provider_type {
|
||||
ProviderSetupProviderType::OpenAI => OpenAIProviderKind::OpenAI,
|
||||
ProviderSetupProviderType::LiteLLM => OpenAIProviderKind::LiteLLM,
|
||||
ProviderSetupProviderType::ChatGPTSubscription => {
|
||||
OpenAIProviderKind::ChatGPTSubscription
|
||||
}
|
||||
ProviderSetupProviderType::OpenAICompatible
|
||||
| ProviderSetupProviderType::Bedrock
|
||||
| ProviderSetupProviderType::Acp => OpenAIProviderKind::OpenAICompatible,
|
||||
ProviderSetupProviderType::Bedrock | ProviderSetupProviderType::Acp => {
|
||||
OpenAIProviderKind::LiteLLM
|
||||
}
|
||||
ProviderSetupProviderType::Anthropic => OpenAIProviderKind::Anthropic,
|
||||
ProviderSetupProviderType::Gemini => OpenAIProviderKind::Gemini,
|
||||
ProviderSetupProviderType::VertexAI => OpenAIProviderKind::VertexAI,
|
||||
@@ -876,7 +879,8 @@ impl ProviderSetupModalBody {
|
||||
},
|
||||
api_key: matches!(
|
||||
self.provider_type,
|
||||
ProviderSetupProviderType::OpenAICompatible
|
||||
ProviderSetupProviderType::OpenAI
|
||||
| ProviderSetupProviderType::LiteLLM
|
||||
| ProviderSetupProviderType::Anthropic
|
||||
| ProviderSetupProviderType::Gemini
|
||||
)
|
||||
@@ -903,6 +907,38 @@ impl ProviderSetupModalBody {
|
||||
}
|
||||
}
|
||||
|
||||
fn jump_to_step(&mut self, step: ProviderSetupStep, ctx: &mut ViewContext<Self>) {
|
||||
if !self.can_jump_to_step(step) {
|
||||
return;
|
||||
}
|
||||
|
||||
match step {
|
||||
ProviderSetupStep::ProviderType => {
|
||||
self.step = ProviderSetupStep::ProviderType;
|
||||
self.discovery_state = DiscoveryState::Idle;
|
||||
self.update_next_button(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
ProviderSetupStep::Configure => {
|
||||
self.step = ProviderSetupStep::Configure;
|
||||
self.discovery_state = DiscoveryState::Idle;
|
||||
self.update_next_button(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
ProviderSetupStep::Discover => {
|
||||
self.begin_discovery(ctx);
|
||||
}
|
||||
ProviderSetupStep::Models => {
|
||||
self.step = ProviderSetupStep::Models;
|
||||
self.discovery_state = DiscoveryState::Idle;
|
||||
self.sync_model_switches(ctx);
|
||||
self.update_next_button(ctx);
|
||||
ctx.focus(&self.name_editor);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_discovery(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.step = ProviderSetupStep::Discover;
|
||||
self.discovery_state = DiscoveryState::Loading;
|
||||
@@ -948,12 +984,13 @@ impl ProviderSetupModalBody {
|
||||
return;
|
||||
}
|
||||
ProviderSetupProviderType::Acp => {
|
||||
ctx.emit(ProviderSetupModalBodyEvent::RequestAcpDiscovery(
|
||||
ctx.emit(ProviderSetupViewEvent::RequestAcpDiscovery(
|
||||
self.draft_acp.clone(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
ProviderSetupProviderType::OpenAICompatible
|
||||
ProviderSetupProviderType::OpenAI
|
||||
| ProviderSetupProviderType::LiteLLM
|
||||
| ProviderSetupProviderType::Anthropic
|
||||
| ProviderSetupProviderType::Gemini
|
||||
| ProviderSetupProviderType::VertexAI => {}
|
||||
@@ -1158,7 +1195,7 @@ impl ProviderSetupModalBody {
|
||||
.with_text_label("Connect ChatGPT".to_owned())
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ProviderSetupModalBodyAction::ConnectChatGPT);
|
||||
ctx.dispatch_typed_action(ProviderSetupViewAction::ConnectChatGPT);
|
||||
})
|
||||
.finish(),
|
||||
);
|
||||
@@ -1210,12 +1247,26 @@ impl ProviderSetupModalBody {
|
||||
ProviderSetupProviderType::ChatGPTSubscription => {
|
||||
children.push(self.render_chatgpt_auth(appearance, app));
|
||||
}
|
||||
ProviderSetupProviderType::OpenAICompatible => {
|
||||
ProviderSetupProviderType::OpenAI => {
|
||||
children.push(self.render_input(appearance, "Base URL", &self.base_url_editor));
|
||||
children.push(self.render_input(appearance, "API key", &self.api_key_editor));
|
||||
children.push(
|
||||
Text::new(
|
||||
"The API key is stored locally and is never synced to the cloud.",
|
||||
"The API key is stored locally and is never synced to the cloud. Models will be discovered from OpenAI's /models endpoint.",
|
||||
appearance.ui_font_family(),
|
||||
INPUT_FONT_SIZE,
|
||||
)
|
||||
.with_color(appearance.theme().nonactive_ui_text_color().into())
|
||||
.soft_wrap(true)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
ProviderSetupProviderType::LiteLLM => {
|
||||
children.push(self.render_input(appearance, "Base URL", &self.base_url_editor));
|
||||
children.push(self.render_input(appearance, "API key", &self.api_key_editor));
|
||||
children.push(
|
||||
Text::new(
|
||||
"The API key is stored locally and is never synced to the cloud. LiteLLM model discovery uses /model/info for rich metadata, then falls back to /models.",
|
||||
appearance.ui_font_family(),
|
||||
INPUT_FONT_SIZE,
|
||||
)
|
||||
@@ -1301,7 +1352,7 @@ impl ProviderSetupModalBody {
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(
|
||||
ProviderSetupModalBodyAction::ToggleBedrockAutoLogin,
|
||||
ProviderSetupViewAction::ToggleBedrockAutoLogin,
|
||||
);
|
||||
})
|
||||
.finish(),
|
||||
@@ -1334,7 +1385,7 @@ impl ProviderSetupModalBody {
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(
|
||||
ProviderSetupModalBodyAction::ToggleBedrockCrossRegion,
|
||||
ProviderSetupViewAction::ToggleBedrockCrossRegion,
|
||||
);
|
||||
})
|
||||
.finish(),
|
||||
@@ -1462,7 +1513,7 @@ impl ProviderSetupModalBody {
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(MODAL_WIDTH - 56.)
|
||||
.with_width(SETUP_WIDTH - 56.)
|
||||
.with_max_height(430.)
|
||||
.finish()
|
||||
}
|
||||
@@ -1470,7 +1521,8 @@ impl ProviderSetupModalBody {
|
||||
fn model_logo(&self) -> (Icon, ColorU) {
|
||||
match self.provider_type {
|
||||
ProviderSetupProviderType::ChatGPTSubscription
|
||||
| ProviderSetupProviderType::OpenAICompatible => {
|
||||
| ProviderSetupProviderType::OpenAI
|
||||
| ProviderSetupProviderType::LiteLLM => {
|
||||
(Icon::OpenAILogo, crate::terminal::cli_agent::OPENAI_COLOR)
|
||||
}
|
||||
ProviderSetupProviderType::Anthropic => {
|
||||
@@ -1636,9 +1688,9 @@ impl ProviderSetupModalBody {
|
||||
.check(model.enabled)
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(
|
||||
ProviderSetupModalBodyAction::ToggleModel(index),
|
||||
);
|
||||
ctx.dispatch_typed_action(ProviderSetupViewAction::ToggleModel(
|
||||
index,
|
||||
));
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
@@ -1828,7 +1880,7 @@ impl ProviderSetupModalBody {
|
||||
.with_main_axis_alignment(MainAxisAlignment::End)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_spacing(8.);
|
||||
if self.step != ProviderSetupStep::ProviderType {
|
||||
if self.can_go_back() {
|
||||
footer = footer.with_child(ChildView::new(&self.back_button).finish());
|
||||
}
|
||||
footer = footer.with_child(ChildView::new(&self.cancel_button).finish());
|
||||
@@ -1840,44 +1892,178 @@ impl ProviderSetupModalBody {
|
||||
footer.finish()
|
||||
}
|
||||
|
||||
fn render_step_indicator(&self, appearance: &Appearance) -> Box<dyn galaxyui::Element> {
|
||||
let steps = [
|
||||
(ProviderSetupStep::ProviderType, "Provider"),
|
||||
(ProviderSetupStep::Configure, "Configure"),
|
||||
(ProviderSetupStep::Discover, "Test"),
|
||||
(ProviderSetupStep::Models, "Models"),
|
||||
];
|
||||
Flex::row()
|
||||
.with_spacing(10.)
|
||||
.with_children(steps.into_iter().map(|(step, label)| {
|
||||
let active = self.step == step;
|
||||
Text::new(label, appearance.ui_font_family(), INPUT_FONT_SIZE)
|
||||
.with_color(
|
||||
if active {
|
||||
appearance.theme().accent()
|
||||
} else {
|
||||
appearance.theme().nonactive_ui_text_color()
|
||||
fn can_go_back(&self) -> bool {
|
||||
match self.step {
|
||||
ProviderSetupStep::ProviderType => false,
|
||||
ProviderSetupStep::Configure => !self.provider_type_locked,
|
||||
ProviderSetupStep::Discover | ProviderSetupStep::Models => true,
|
||||
}
|
||||
.into(),
|
||||
}
|
||||
|
||||
fn is_configure_valid(&self) -> bool {
|
||||
match self.provider_type {
|
||||
ProviderSetupProviderType::OpenAI => {
|
||||
!self.draft_base_url.trim().is_empty()
|
||||
&& self
|
||||
.draft_api_key
|
||||
.as_deref()
|
||||
.is_some_and(|key| !key.trim().is_empty())
|
||||
}
|
||||
ProviderSetupProviderType::LiteLLM => !self.draft_base_url.trim().is_empty(),
|
||||
ProviderSetupProviderType::Anthropic | ProviderSetupProviderType::Gemini => self
|
||||
.draft_api_key
|
||||
.as_deref()
|
||||
.is_some_and(|key| !key.trim().is_empty()),
|
||||
ProviderSetupProviderType::VertexAI => !self.draft_project_id.trim().is_empty(),
|
||||
ProviderSetupProviderType::Acp => {
|
||||
!self.draft_acp.agent_id.trim().is_empty()
|
||||
&& (!self.draft_acp.agent_id.eq_ignore_ascii_case("custom")
|
||||
|| !self.draft_acp.command.trim().is_empty())
|
||||
}
|
||||
ProviderSetupProviderType::ChatGPTSubscription | ProviderSetupProviderType::Bedrock => {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn has_model_catalog(&self) -> bool {
|
||||
match self.provider_type {
|
||||
ProviderSetupProviderType::OpenAI
|
||||
| ProviderSetupProviderType::LiteLLM
|
||||
| ProviderSetupProviderType::ChatGPTSubscription
|
||||
| ProviderSetupProviderType::Anthropic
|
||||
| ProviderSetupProviderType::Gemini
|
||||
| ProviderSetupProviderType::VertexAI => !self.draft_models.is_empty(),
|
||||
ProviderSetupProviderType::Bedrock => !self.draft_bedrock.models.is_empty(),
|
||||
ProviderSetupProviderType::Acp => !self.draft_acp.config_options.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn step_tab_index(step: ProviderSetupStep) -> usize {
|
||||
match step {
|
||||
ProviderSetupStep::ProviderType => 0,
|
||||
ProviderSetupStep::Configure => 1,
|
||||
ProviderSetupStep::Discover => 2,
|
||||
ProviderSetupStep::Models => 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn can_jump_to_step(&self, step: ProviderSetupStep) -> bool {
|
||||
if self.step == step {
|
||||
return false;
|
||||
}
|
||||
|
||||
match step {
|
||||
ProviderSetupStep::ProviderType => !self.provider_type_locked,
|
||||
ProviderSetupStep::Configure => true,
|
||||
ProviderSetupStep::Discover => {
|
||||
!matches!(self.discovery_state, DiscoveryState::Loading)
|
||||
&& self.is_configure_valid()
|
||||
}
|
||||
ProviderSetupStep::Models => self.has_model_catalog(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_step_tab(
|
||||
&self,
|
||||
step: ProviderSetupStep,
|
||||
label: &'static str,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn galaxyui::Element> {
|
||||
let active = self.step == step;
|
||||
let enabled = self.can_jump_to_step(step);
|
||||
let Some(mouse_state) = self
|
||||
.step_tab_mouse_states
|
||||
.get(Self::step_tab_index(step))
|
||||
.cloned()
|
||||
else {
|
||||
return Text::new(label, appearance.ui_font_family(), INPUT_FONT_SIZE)
|
||||
.with_color(appearance.theme().disabled_ui_text_color().into())
|
||||
.finish();
|
||||
};
|
||||
|
||||
let tab = Hoverable::new(mouse_state, move |mouse_state| {
|
||||
let theme = appearance.theme();
|
||||
let text_color = if active {
|
||||
theme.accent()
|
||||
} else if enabled {
|
||||
theme.nonactive_ui_text_color()
|
||||
} else {
|
||||
theme.disabled_ui_text_color()
|
||||
};
|
||||
let mut container = Container::new(
|
||||
Text::new_inline(
|
||||
label.to_string(),
|
||||
appearance.ui_font_family(),
|
||||
INPUT_FONT_SIZE,
|
||||
)
|
||||
.with_color(text_color.into())
|
||||
.with_style(Properties::default().weight(if active {
|
||||
Weight::Bold
|
||||
} else {
|
||||
Weight::Normal
|
||||
}))
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_padding(10.)
|
||||
.with_vertical_padding(6.)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)));
|
||||
|
||||
if active {
|
||||
container = container.with_background(theme.surface_overlay_1());
|
||||
} else if enabled && mouse_state.is_hovered() {
|
||||
container = container.with_background(theme.surface_overlay_2());
|
||||
}
|
||||
|
||||
container.finish()
|
||||
});
|
||||
|
||||
if enabled {
|
||||
tab.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ProviderSetupViewAction::JumpToStep(step));
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish()
|
||||
}))
|
||||
} else {
|
||||
tab.finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn render_step_indicator(&self, appearance: &Appearance) -> Box<dyn galaxyui::Element> {
|
||||
let locked_steps = [
|
||||
(ProviderSetupStep::Configure, "Configure"),
|
||||
(ProviderSetupStep::Discover, "Test"),
|
||||
(ProviderSetupStep::Models, "Models"),
|
||||
];
|
||||
let selectable_steps = [
|
||||
(ProviderSetupStep::ProviderType, "Provider"),
|
||||
(ProviderSetupStep::Configure, "Configure"),
|
||||
(ProviderSetupStep::Discover, "Test"),
|
||||
(ProviderSetupStep::Models, "Models"),
|
||||
];
|
||||
let steps: &[(ProviderSetupStep, &str)] = if self.provider_type_locked {
|
||||
&locked_steps
|
||||
} else {
|
||||
&selectable_steps
|
||||
};
|
||||
Flex::row()
|
||||
.with_spacing(10.)
|
||||
.with_children(
|
||||
steps
|
||||
.iter()
|
||||
.map(|(step, label)| self.render_step_tab(*step, label, appearance)),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ProviderSetupModalBody {
|
||||
type Event = ProviderSetupModalBodyEvent;
|
||||
impl Entity for ProviderSetupView {
|
||||
type Event = ProviderSetupViewEvent;
|
||||
}
|
||||
|
||||
impl View for ProviderSetupModalBody {
|
||||
impl View for ProviderSetupView {
|
||||
fn ui_name() -> &'static str {
|
||||
"ProviderSetupModalBody"
|
||||
"ProviderSetupView"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn galaxyui::Element> {
|
||||
@@ -1897,27 +2083,30 @@ impl View for ProviderSetupModalBody {
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for ProviderSetupModalBody {
|
||||
type Action = ProviderSetupModalBodyAction;
|
||||
impl TypedActionView for ProviderSetupView {
|
||||
type Action = ProviderSetupViewAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
ProviderSetupModalBodyAction::SelectProvider(kind) => {
|
||||
ProviderSetupViewAction::SelectProvider(kind) => {
|
||||
if self.provider_type != *kind {
|
||||
self.draft_models.clear();
|
||||
self.discovery_state = DiscoveryState::Idle;
|
||||
}
|
||||
self.provider_type = *kind;
|
||||
if *kind == ProviderSetupProviderType::ChatGPTSubscription {
|
||||
self.draft_base_url.clear();
|
||||
self.draft_name = Self::default_name(*kind).to_string();
|
||||
self.draft_base_url = Self::default_base_url(*kind).to_string();
|
||||
if matches!(*kind, ProviderSetupProviderType::ChatGPTSubscription) {
|
||||
self.draft_api_key = None;
|
||||
}
|
||||
self.sync_editors(ctx);
|
||||
self.sync_provider_type_buttons(ctx);
|
||||
self.sync_bedrock_auth_buttons(ctx);
|
||||
self.update_next_button(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
ProviderSetupModalBodyAction::Next => match self.step {
|
||||
ProviderSetupViewAction::JumpToStep(step) => self.jump_to_step(*step, ctx),
|
||||
ProviderSetupViewAction::Next => match self.step {
|
||||
ProviderSetupStep::ProviderType => {
|
||||
self.step = ProviderSetupStep::Configure;
|
||||
self.update_next_button(ctx);
|
||||
@@ -1930,7 +2119,8 @@ impl TypedActionView for ProviderSetupModalBody {
|
||||
}
|
||||
}
|
||||
ProviderSetupStep::Models => match self.provider_type {
|
||||
ProviderSetupProviderType::OpenAICompatible
|
||||
ProviderSetupProviderType::OpenAI
|
||||
| ProviderSetupProviderType::LiteLLM
|
||||
| ProviderSetupProviderType::ChatGPTSubscription
|
||||
| ProviderSetupProviderType::Anthropic
|
||||
| ProviderSetupProviderType::Gemini
|
||||
@@ -1940,7 +2130,7 @@ impl TypedActionView for ProviderSetupModalBody {
|
||||
{
|
||||
return;
|
||||
}
|
||||
ctx.emit(ProviderSetupModalBodyEvent::SaveOpenAI {
|
||||
ctx.emit(ProviderSetupViewEvent::SaveOpenAI {
|
||||
editing_index: self.editing_index,
|
||||
provider: self.draft_provider(),
|
||||
});
|
||||
@@ -1952,7 +2142,7 @@ impl TypedActionView for ProviderSetupModalBody {
|
||||
}
|
||||
let mut draft = self.draft_bedrock.clone();
|
||||
draft.name = self.draft_name.trim().to_string();
|
||||
ctx.emit(ProviderSetupModalBodyEvent::SaveBedrock(draft));
|
||||
ctx.emit(ProviderSetupViewEvent::SaveBedrock(draft));
|
||||
}
|
||||
ProviderSetupProviderType::Acp => {
|
||||
if self.draft_name.trim().is_empty() {
|
||||
@@ -1960,13 +2150,16 @@ impl TypedActionView for ProviderSetupModalBody {
|
||||
}
|
||||
let mut draft = self.draft_acp.clone();
|
||||
draft.name = self.draft_name.trim().to_string();
|
||||
ctx.emit(ProviderSetupModalBodyEvent::SaveAcp(draft));
|
||||
ctx.emit(ProviderSetupViewEvent::SaveAcp(draft));
|
||||
}
|
||||
},
|
||||
},
|
||||
ProviderSetupModalBodyAction::Back => match self.step {
|
||||
ProviderSetupViewAction::Back => match self.step {
|
||||
ProviderSetupStep::ProviderType => {}
|
||||
ProviderSetupStep::Configure => {
|
||||
if self.provider_type_locked {
|
||||
return;
|
||||
}
|
||||
self.step = ProviderSetupStep::ProviderType;
|
||||
self.update_next_button(ctx);
|
||||
ctx.notify();
|
||||
@@ -1983,10 +2176,10 @@ impl TypedActionView for ProviderSetupModalBody {
|
||||
ctx.notify();
|
||||
}
|
||||
},
|
||||
ProviderSetupModalBodyAction::Cancel => {
|
||||
ctx.emit(ProviderSetupModalBodyEvent::Close);
|
||||
ProviderSetupViewAction::Cancel => {
|
||||
ctx.emit(ProviderSetupViewEvent::Close);
|
||||
}
|
||||
ProviderSetupModalBodyAction::SelectAcpAgent(agent_id) => {
|
||||
ProviderSetupViewAction::SelectAcpAgent(agent_id) => {
|
||||
self.draft_acp.agent_id = agent_id.clone();
|
||||
if !agent_id.eq_ignore_ascii_case("custom") {
|
||||
self.draft_acp.command.clear();
|
||||
@@ -1997,14 +2190,14 @@ impl TypedActionView for ProviderSetupModalBody {
|
||||
self.update_next_button(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
ProviderSetupModalBodyAction::ToggleModel(index) => {
|
||||
ProviderSetupViewAction::ToggleModel(index) => {
|
||||
if let Some(model) = self.draft_models.get_mut(*index) {
|
||||
model.enabled = !model.enabled;
|
||||
self.update_next_button(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
ProviderSetupModalBodyAction::CycleModelCapability(index, capability) => {
|
||||
ProviderSetupViewAction::CycleModelCapability(index, capability) => {
|
||||
if let Some(model) = self.draft_models.get_mut(*index) {
|
||||
let key = capability.setting_key().to_string();
|
||||
let next = model.capability_override(&key).next();
|
||||
@@ -2014,28 +2207,28 @@ impl TypedActionView for ProviderSetupModalBody {
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
ProviderSetupModalBodyAction::ConnectChatGPT => {
|
||||
ProviderSetupViewAction::ConnectChatGPT => {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx));
|
||||
}
|
||||
ProviderSetupModalBodyAction::OpenChatGPTDevicePage => {
|
||||
ProviderSetupViewAction::OpenChatGPTDevicePage => {
|
||||
// No-op: device-code flow removed in favor of browser OAuth.
|
||||
}
|
||||
ProviderSetupModalBodyAction::CopyChatGPTDeviceCode => {
|
||||
ProviderSetupViewAction::CopyChatGPTDeviceCode => {
|
||||
// No-op: device-code flow removed in favor of browser OAuth.
|
||||
}
|
||||
ProviderSetupModalBodyAction::SelectBedrockAuth(method) => {
|
||||
ProviderSetupViewAction::SelectBedrockAuth(method) => {
|
||||
self.draft_bedrock.auth_method = *method;
|
||||
self.sync_bedrock_auth_buttons(ctx);
|
||||
self.update_next_button(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
ProviderSetupModalBodyAction::ToggleBedrockCrossRegion => {
|
||||
ProviderSetupViewAction::ToggleBedrockCrossRegion => {
|
||||
self.draft_bedrock.cross_region_inference =
|
||||
!self.draft_bedrock.cross_region_inference;
|
||||
ctx.notify();
|
||||
}
|
||||
ProviderSetupModalBodyAction::ToggleBedrockAutoLogin => {
|
||||
ProviderSetupViewAction::ToggleBedrockAutoLogin => {
|
||||
self.draft_bedrock.auto_login = !self.draft_bedrock.auto_login;
|
||||
ctx.notify();
|
||||
}
|
||||
@@ -2045,7 +2238,8 @@ impl TypedActionView for ProviderSetupModalBody {
|
||||
|
||||
fn provider_type_label(kind: ProviderSetupProviderType) -> &'static str {
|
||||
match kind {
|
||||
ProviderSetupProviderType::OpenAICompatible => "OpenAI-compatible API",
|
||||
ProviderSetupProviderType::OpenAI => "OpenAI",
|
||||
ProviderSetupProviderType::LiteLLM => "LiteLLM",
|
||||
ProviderSetupProviderType::ChatGPTSubscription => "ChatGPT subscription",
|
||||
ProviderSetupProviderType::Anthropic => "Anthropic",
|
||||
ProviderSetupProviderType::Gemini => "Google Gemini",
|
||||
@@ -1,213 +0,0 @@
|
||||
use warpui::elements::{
|
||||
ChildView, Container, CrossAxisAlignment, DispatchEventResult, Element, EventHandler, Flex,
|
||||
MainAxisAlignment, MainAxisSize, ParentElement, Text,
|
||||
};
|
||||
use warpui::{
|
||||
AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
WeakViewHandle,
|
||||
};
|
||||
|
||||
use crate::ai::llms::LLMId;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::view_components::action_button::{ActionButton, NakedTheme, PrimaryTheme};
|
||||
use crate::view_components::{DropdownItem, FilterableDropdown, FilterableDropdownEvent};
|
||||
|
||||
/// Width shared by the model dropdown's top bar and open menu so long model
|
||||
/// names stay readable inside the modal.
|
||||
const MODEL_DROPDOWN_WIDTH: f32 = 400.;
|
||||
/// The body's `ui_font_size`-based default reads too small in the modal, so the
|
||||
/// description uses an explicit, slightly larger size.
|
||||
const DESCRIPTION_FONT_SIZE: f32 = 14.;
|
||||
|
||||
pub enum SetDefaultModelModalBodyEvent {
|
||||
/// The user dismissed the prompt without choosing a model.
|
||||
Close,
|
||||
/// The user committed `LLMId` as their new default Agent Mode model.
|
||||
SetDefault(LLMId),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum SetDefaultModelModalBodyAction {
|
||||
/// Carries the index into `model_choices` of the picked model.
|
||||
SelectModel(usize),
|
||||
Save,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// Body of the "change your default model" prompt that appears after a BYO API
|
||||
/// key or custom endpoint is saved. It is hosted inside a [`crate::modal::Modal`],
|
||||
/// which supplies the title, close button, and backdrop.
|
||||
pub struct SetDefaultModelModalBody {
|
||||
description: String,
|
||||
/// `(model id, label)` pairs offered in the dropdown. The id flows back out
|
||||
/// through [`SetDefaultModelModalBodyEvent::SetDefault`] on save.
|
||||
model_choices: Vec<(LLMId, String)>,
|
||||
selected_index: usize,
|
||||
model_dropdown: ViewHandle<FilterableDropdown<SetDefaultModelModalBodyAction>>,
|
||||
cancel_button: ViewHandle<ActionButton>,
|
||||
save_button: ViewHandle<ActionButton>,
|
||||
self_handle: WeakViewHandle<Self>,
|
||||
}
|
||||
|
||||
impl SetDefaultModelModalBody {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let model_dropdown = ctx.add_typed_action_view(|ctx| {
|
||||
let mut dropdown = FilterableDropdown::new(ctx);
|
||||
dropdown.set_top_bar_max_width(MODEL_DROPDOWN_WIDTH);
|
||||
dropdown.set_menu_width(MODEL_DROPDOWN_WIDTH, ctx);
|
||||
dropdown
|
||||
});
|
||||
// When the dropdown closes (selection or dismiss), return focus to the
|
||||
// body so Escape closes the modal rather than no-op'ing on the hidden
|
||||
// filter input.
|
||||
ctx.subscribe_to_view(&model_dropdown, |_, _, event, ctx| {
|
||||
if let FilterableDropdownEvent::Close = event {
|
||||
ctx.focus_self();
|
||||
}
|
||||
});
|
||||
|
||||
let cancel_button = ctx.add_typed_action_view(|_| {
|
||||
ActionButton::new("Not now", NakedTheme).on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(SetDefaultModelModalBodyAction::Cancel);
|
||||
})
|
||||
});
|
||||
|
||||
let save_button = ctx.add_typed_action_view(|_| {
|
||||
ActionButton::new("Change default model", PrimaryTheme).on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(SetDefaultModelModalBodyAction::Save);
|
||||
})
|
||||
});
|
||||
|
||||
Self {
|
||||
description: String::new(),
|
||||
model_choices: Vec::new(),
|
||||
selected_index: 0,
|
||||
model_dropdown,
|
||||
cancel_button,
|
||||
save_button,
|
||||
self_handle: ctx.handle(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Populates the prompt for a freshly added credential and focuses the body
|
||||
/// so Escape closes the modal. The first model is pre-selected so the user
|
||||
/// can accept without opening the dropdown.
|
||||
pub fn set_choices(
|
||||
&mut self,
|
||||
description: String,
|
||||
model_choices: Vec<(LLMId, String)>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.description = description;
|
||||
self.model_choices = model_choices;
|
||||
self.selected_index = 0;
|
||||
|
||||
let items = self
|
||||
.model_choices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (_, label))| {
|
||||
DropdownItem::new(
|
||||
label.clone(),
|
||||
SetDefaultModelModalBodyAction::SelectModel(index),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
self.model_dropdown.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_items(items, ctx);
|
||||
dropdown.set_selected_by_index(0, ctx);
|
||||
});
|
||||
ctx.focus_self();
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for SetDefaultModelModalBody {
|
||||
type Event = SetDefaultModelModalBodyEvent;
|
||||
}
|
||||
|
||||
impl View for SetDefaultModelModalBody {
|
||||
fn ui_name() -> &'static str {
|
||||
"SetDefaultModelModalBody"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let description = Container::new(
|
||||
Text::new(
|
||||
self.description.clone(),
|
||||
appearance.ui_font_family(),
|
||||
DESCRIPTION_FONT_SIZE,
|
||||
)
|
||||
.with_color(theme.nonactive_ui_text_color().into())
|
||||
.soft_wrap(true)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_bottom(20.)
|
||||
.finish();
|
||||
|
||||
let dropdown = Container::new(ChildView::new(&self.model_dropdown).finish())
|
||||
.with_margin_bottom(24.)
|
||||
.finish();
|
||||
|
||||
let buttons_row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::End)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(ChildView::new(&self.cancel_button).finish())
|
||||
.with_child(
|
||||
Container::new(ChildView::new(&self.save_button).finish())
|
||||
.with_margin_left(12.)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let content = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(description)
|
||||
.with_child(dropdown)
|
||||
.with_child(buttons_row)
|
||||
.finish();
|
||||
|
||||
// Close the modal on Escape when the body itself is focused. While the
|
||||
// dropdown is open it owns Escape (to close itself); on close it hands
|
||||
// focus back to the body via the `Close` subscription above.
|
||||
let self_handle = self.self_handle.clone();
|
||||
EventHandler::new(content)
|
||||
.on_keydown(move |ctx, app, keystroke| {
|
||||
let body_focused = self_handle
|
||||
.upgrade(app)
|
||||
.is_some_and(|handle| handle.is_focused(app));
|
||||
if body_focused && keystroke.is_unmodified_key("escape") {
|
||||
ctx.dispatch_typed_action(SetDefaultModelModalBodyAction::Cancel);
|
||||
DispatchEventResult::StopPropagation
|
||||
} else {
|
||||
DispatchEventResult::PropagateToParent
|
||||
}
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for SetDefaultModelModalBody {
|
||||
type Action = SetDefaultModelModalBodyAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
SetDefaultModelModalBodyAction::SelectModel(index) => {
|
||||
self.selected_index = *index;
|
||||
ctx.notify();
|
||||
}
|
||||
SetDefaultModelModalBodyAction::Save => {
|
||||
if let Some((id, _)) = self.model_choices.get(self.selected_index) {
|
||||
ctx.emit(SetDefaultModelModalBodyEvent::SetDefault(id.clone()));
|
||||
}
|
||||
}
|
||||
SetDefaultModelModalBodyAction::Cancel => {
|
||||
ctx.emit(SetDefaultModelModalBodyEvent::Close);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user