Files
galaxy/app/src/settings_view/provider_setup_view.rs
T

2274 lines
91 KiB
Rust

use galaxy_cli::agent::Harness;
use galaxy_core::ui::theme::Fill;
use galaxyui::elements::{
Border, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container,
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};
use galaxyui::ui_components::switch::SwitchStateHandle;
use galaxyui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use pathfinder_color::ColorU;
#[cfg(not(target_family = "wasm"))]
use crate::ai::chatgpt_auth::{ChatGPTAuthModel, ChatGPTAuthModelEvent, ChatGPTAuthState};
use crate::ai::harness_display;
use crate::ai::llms::{merge_discovered_provider_models, LLMPreferences};
use crate::appearance::Appearance;
use crate::editor::{
EditorView, Event as EditorEvent, SingleLineEditorOptions, TextColors, TextOptions,
};
use crate::settings::ai::{
AcpConfigOptionSettings, BedrockAuthMethod, BedrockModelConfig, ModelCapabilityOverride,
OpenAIModelConfig, OpenAIProviderConfig, OpenAIProviderKind,
};
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{
ActionButton, ButtonSize, NakedTheme, PrimaryTheme, SecondaryTheme,
};
const SETUP_WIDTH: f32 = 900.;
const INPUT_FONT_SIZE: f32 = 12.;
const MODEL_LOGO_SIZE: f32 = 20.;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ProviderSetupStep {
ProviderType,
Configure,
Discover,
Models,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProviderSetupProviderType {
OpenAI,
LiteLLM,
ChatGPTSubscription,
Anthropic,
Gemini,
VertexAI,
Bedrock,
Acp,
}
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::Bedrock,
"AWS Bedrock",
"Use the AWS Bedrock credentials and model configuration already managed by Galaxy.",
),
];
#[derive(Clone, Debug)]
pub struct BedrockProviderDraft {
pub name: String,
pub auth_method: BedrockAuthMethod,
pub profile: String,
pub region: String,
pub cross_region_inference: bool,
pub auto_login: bool,
pub auth_refresh_command: String,
pub access_key_id: String,
pub secret_access_key: String,
pub models: Vec<BedrockModelConfig>,
}
#[derive(Clone, Debug)]
pub struct AcpProviderDraft {
pub name: String,
pub agent_id: String,
pub command: String,
pub args: Vec<String>,
pub config_options: Vec<AcpConfigOptionSettings>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum DiscoveryState {
Idle,
Loading,
Failed(String),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CapabilityKey {
Vision,
Files,
Audio,
Tools,
SystemMessages,
}
impl CapabilityKey {
const ALL: [Self; 5] = [
Self::Vision,
Self::Files,
Self::Audio,
Self::Tools,
Self::SystemMessages,
];
fn label(self) -> &'static str {
match self {
Self::Vision => "Images",
Self::Files => "Files",
Self::Audio => "Audio",
Self::Tools => "Tools",
Self::SystemMessages => "System",
}
}
fn setting_key(self) -> &'static str {
match self {
Self::Vision => "vision",
Self::Files => "files",
Self::Audio => "audio",
Self::Tools => "tools",
Self::SystemMessages => "system_messages",
}
}
}
pub enum ProviderSetupViewEvent {
Close,
RequestAcpDiscovery(AcpProviderDraft),
SaveOpenAI {
editing_index: Option<usize>,
provider: OpenAIProviderConfig,
},
SaveBedrock(BedrockProviderDraft),
SaveAcp(AcpProviderDraft),
}
#[derive(Clone, Debug, PartialEq)]
pub enum ProviderSetupViewAction {
SelectProvider(ProviderSetupProviderType),
JumpToStep(ProviderSetupStep),
Next,
Back,
Cancel,
ToggleModel(usize),
CycleModelCapability(usize, CapabilityKey),
ConnectChatGPT,
OpenChatGPTDevicePage,
CopyChatGPTDeviceCode,
SelectBedrockAuth(BedrockAuthMethod),
ToggleBedrockCrossRegion,
ToggleBedrockAutoLogin,
SelectAcpAgent(String),
}
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>,
draft_project_id: String,
draft_location: String,
draft_models: Vec<OpenAIModelConfig>,
draft_bedrock: BedrockProviderDraft,
draft_acp: AcpProviderDraft,
discovery_state: DiscoveryState,
provider_type_buttons: Vec<ViewHandle<ActionButton>>,
acp_agent_buttons: Vec<ViewHandle<ActionButton>>,
name_editor: ViewHandle<EditorView>,
base_url_editor: ViewHandle<EditorView>,
api_key_editor: ViewHandle<EditorView>,
project_id_editor: ViewHandle<EditorView>,
location_editor: ViewHandle<EditorView>,
bedrock_profile_editor: ViewHandle<EditorView>,
bedrock_region_editor: ViewHandle<EditorView>,
bedrock_refresh_command_editor: ViewHandle<EditorView>,
bedrock_access_key_editor: ViewHandle<EditorView>,
bedrock_secret_key_editor: ViewHandle<EditorView>,
acp_agent_id_editor: ViewHandle<EditorView>,
acp_command_editor: ViewHandle<EditorView>,
acp_args_editor: ViewHandle<EditorView>,
chatgpt_connect_mouse_state: MouseStateHandle,
bedrock_auth_buttons: Vec<ViewHandle<ActionButton>>,
bedrock_cross_region_toggle: SwitchStateHandle,
bedrock_auto_login_toggle: SwitchStateHandle,
model_switches: Vec<SwitchStateHandle>,
model_capability_switches: Vec<[SwitchStateHandle; 2]>,
model_capability_buttons: Vec<Vec<ViewHandle<ActionButton>>>,
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 ProviderSetupView {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let provider_type_buttons = PROVIDER_TYPE_OPTIONS
.iter()
.map(|(kind, label, _)| {
let kind = *kind;
let label = *label;
ctx.add_typed_action_view(move |_| {
ActionButton::new(label, NakedTheme)
.with_full_width(true)
.on_click(move |ctx| {
ctx.dispatch_typed_action(ProviderSetupViewAction::SelectProvider(
kind,
));
})
})
})
.collect();
let name_editor = Self::create_editor("Connection name", false, ctx);
let base_url_editor = Self::create_editor("https://api.example.com/v1", false, ctx);
let api_key_editor = Self::create_editor("sk-... (optional)", true, ctx);
let project_id_editor = Self::create_editor("my-google-cloud-project", false, ctx);
let location_editor = Self::create_editor("global", false, ctx);
let bedrock_profile_editor = Self::create_editor("default", false, ctx);
let bedrock_region_editor = Self::create_editor("us-east-1", false, ctx);
let bedrock_refresh_command_editor = Self::create_editor("aws sso login", false, ctx);
let bedrock_access_key_editor = Self::create_editor("AKIA...", false, ctx);
let bedrock_secret_key_editor = Self::create_editor("Secret access key", true, ctx);
let acp_agent_id_editor = Self::create_editor("codex", false, ctx);
let acp_command_editor = Self::create_editor("Optional executable", false, ctx);
let acp_args_editor = Self::create_editor(r#"["arg1", "arg2"]"#, false, ctx);
let mut acp_agent_buttons = galaxy_acp::known_acp_agents()
.iter()
.map(|agent| {
let id = agent.id.to_owned();
ctx.add_typed_action_view(move |_| {
ActionButton::new(agent.name, NakedTheme)
.with_full_width(true)
.on_click({
let id = id.clone();
move |ctx| {
ctx.dispatch_typed_action(ProviderSetupViewAction::SelectAcpAgent(
id.clone(),
));
}
})
})
})
.collect::<Vec<_>>();
acp_agent_buttons.push(ctx.add_typed_action_view(|_| {
ActionButton::new("Custom", NakedTheme)
.with_full_width(true)
.on_click(|ctx| {
ctx.dispatch_typed_action(ProviderSetupViewAction::SelectAcpAgent(
"custom".to_owned(),
));
})
}));
let bedrock_auth_buttons = [
BedrockAuthMethod::Profile,
BedrockAuthMethod::Sso,
BedrockAuthMethod::StaticKeys,
]
.into_iter()
.map(|method| {
ctx.add_typed_action_view(move |_| {
ActionButton::new(method.display_name(), NakedTheme).on_click(move |ctx| {
ctx.dispatch_typed_action(ProviderSetupViewAction::SelectBedrockAuth(method));
})
})
})
.collect();
ctx.subscribe_to_view(&name_editor, |me, editor, event, ctx| {
if matches!(event, EditorEvent::Edited(_)) {
me.draft_name = editor.as_ref(ctx).buffer_text(ctx);
me.update_next_button(ctx);
ctx.notify();
}
});
ctx.subscribe_to_view(&base_url_editor, |me, editor, event, ctx| {
if matches!(event, EditorEvent::Edited(_)) {
me.draft_base_url = editor.as_ref(ctx).buffer_text(ctx);
me.update_next_button(ctx);
ctx.notify();
}
});
ctx.subscribe_to_view(&api_key_editor, |me, editor, event, ctx| {
if matches!(event, EditorEvent::Edited(_)) {
let value = editor.as_ref(ctx).buffer_text(ctx);
me.draft_api_key = (!value.trim().is_empty()).then_some(value);
ctx.notify();
}
});
ctx.subscribe_to_view(&project_id_editor, |me, editor, event, ctx| {
if matches!(event, EditorEvent::Edited(_)) {
me.draft_project_id = editor.as_ref(ctx).buffer_text(ctx);
me.update_next_button(ctx);
ctx.notify();
}
});
ctx.subscribe_to_view(&location_editor, |me, editor, event, ctx| {
if matches!(event, EditorEvent::Edited(_)) {
me.draft_location = editor.as_ref(ctx).buffer_text(ctx);
ctx.notify();
}
});
for (editor, update) in [
(bedrock_profile_editor.clone(), 0),
(bedrock_region_editor.clone(), 1),
(bedrock_refresh_command_editor.clone(), 2),
(bedrock_access_key_editor.clone(), 3),
(bedrock_secret_key_editor.clone(), 4),
(acp_agent_id_editor.clone(), 5),
(acp_command_editor.clone(), 6),
(acp_args_editor.clone(), 7),
] {
ctx.subscribe_to_view(&editor, move |me, editor, event, ctx| {
if matches!(event, EditorEvent::Edited(_)) {
let value = editor.as_ref(ctx).buffer_text(ctx);
match update {
0 => me.draft_bedrock.profile = value,
1 => me.draft_bedrock.region = value,
2 => me.draft_bedrock.auth_refresh_command = value,
3 => me.draft_bedrock.access_key_id = value,
4 => me.draft_bedrock.secret_access_key = value,
5 => me.draft_acp.agent_id = value,
6 => me.draft_acp.command = value,
7 => {
if let Ok(args) = serde_json::from_str::<Vec<String>>(&value) {
me.draft_acp.args = args;
}
}
_ => unreachable!(),
}
me.update_next_button(ctx);
ctx.notify();
}
});
}
#[cfg(not(target_family = "wasm"))]
ctx.subscribe_to_model(&ChatGPTAuthModel::handle(ctx), |me, _, event, ctx| {
if matches!(event, ChatGPTAuthModelEvent::StateChanged) {
if me.step == ProviderSetupStep::Discover {
me.try_discover_chatgpt(ctx);
}
me.update_next_button(ctx);
ctx.notify();
}
});
let back_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Back", NakedTheme).on_click(|ctx| {
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(ProviderSetupViewAction::Cancel);
})
});
let next_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Next", PrimaryTheme).on_click(|ctx| {
ctx.dispatch_typed_action(ProviderSetupViewAction::Next);
})
});
Self {
step: ProviderSetupStep::ProviderType,
editing_index: None,
provider_type: ProviderSetupProviderType::LiteLLM,
provider_type_locked: false,
draft_name: String::new(),
draft_base_url: String::new(),
draft_api_key: None,
draft_project_id: String::new(),
draft_location: "global".to_string(),
draft_models: Vec::new(),
draft_bedrock: BedrockProviderDraft {
name: String::new(),
auth_method: BedrockAuthMethod::default(),
profile: "default".to_string(),
region: String::new(),
cross_region_inference: true,
auto_login: true,
auth_refresh_command: "aws sso login".to_string(),
access_key_id: String::new(),
secret_access_key: String::new(),
models: Vec::new(),
},
draft_acp: AcpProviderDraft {
name: String::new(),
agent_id: "codex".to_string(),
command: String::new(),
args: Vec::new(),
config_options: Vec::new(),
},
discovery_state: DiscoveryState::Idle,
provider_type_buttons,
acp_agent_buttons,
name_editor,
base_url_editor,
api_key_editor,
project_id_editor,
location_editor,
bedrock_profile_editor,
bedrock_region_editor,
bedrock_refresh_command_editor,
bedrock_access_key_editor,
bedrock_secret_key_editor,
acp_agent_id_editor,
acp_command_editor,
acp_args_editor,
chatgpt_connect_mouse_state: MouseStateHandle::default(),
bedrock_auth_buttons,
bedrock_cross_region_toggle: SwitchStateHandle::default(),
bedrock_auto_login_toggle: SwitchStateHandle::default(),
model_switches: Vec::new(),
model_capability_switches: Vec::new(),
model_capability_buttons: Vec::new(),
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,
}
}
fn create_editor(
placeholder: &'static str,
is_password: bool,
ctx: &mut ViewContext<Self>,
) -> ViewHandle<EditorView> {
ctx.add_typed_action_view(move |ctx| {
let appearance = Appearance::as_ref(ctx);
let options = SingleLineEditorOptions {
is_password,
text: TextOptions {
font_size_override: Some(appearance.ui_font_size()),
font_family_override: Some(appearance.monospace_font_family()),
text_colors_override: Some(TextColors {
default_color: appearance.theme().active_ui_text_color(),
disabled_color: appearance.theme().disabled_ui_text_color(),
hint_color: appearance.theme().disabled_ui_text_color(),
}),
..Default::default()
},
..Default::default()
};
let mut editor = EditorView::single_line(options, ctx);
editor.set_placeholder_text(placeholder, ctx);
editor
})
}
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 = 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();
self.draft_models.clear();
self.draft_bedrock = BedrockProviderDraft {
name: String::new(),
auth_method: BedrockAuthMethod::default(),
profile: "default".to_string(),
region: String::new(),
cross_region_inference: true,
auto_login: true,
auth_refresh_command: "aws sso login".to_string(),
access_key_id: String::new(),
secret_access_key: String::new(),
models: Vec::new(),
};
self.draft_acp = AcpProviderDraft {
name: String::new(),
agent_id: "codex".to_string(),
command: String::new(),
args: Vec::new(),
config_options: Vec::new(),
};
self.discovery_state = DiscoveryState::Idle;
self.sync_editors(ctx);
self.sync_provider_type_buttons(ctx);
self.sync_acp_agent_buttons(ctx);
self.sync_bedrock_auth_buttons(ctx);
self.sync_model_switches(ctx);
self.update_next_button(ctx);
ctx.focus_self();
ctx.notify();
}
pub fn begin_edit(
&mut self,
editing_index: usize,
provider: OpenAIProviderConfig,
ctx: &mut ViewContext<Self>,
) {
// Editing an existing provider is a local catalog operation. Do not
// 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::Anthropic => ProviderSetupProviderType::Anthropic,
OpenAIProviderKind::Gemini => ProviderSetupProviderType::Gemini,
OpenAIProviderKind::VertexAI => ProviderSetupProviderType::VertexAI,
};
self.draft_name = provider.name;
self.draft_base_url = provider.base_url;
self.draft_api_key = provider.api_key;
self.draft_project_id = provider.project_id.unwrap_or_default();
self.draft_location = provider.location.unwrap_or_else(|| "global".to_string());
self.draft_models = provider.models;
self.discovery_state = DiscoveryState::Idle;
self.sync_editors(ctx);
self.sync_provider_type_buttons(ctx);
self.sync_acp_agent_buttons(ctx);
self.sync_bedrock_auth_buttons(ctx);
self.sync_model_switches(ctx);
self.update_next_button(ctx);
ctx.focus_self();
ctx.notify();
}
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;
self.draft_models.clear();
self.discovery_state = DiscoveryState::Idle;
self.sync_editors(ctx);
self.sync_provider_type_buttons(ctx);
self.sync_bedrock_auth_buttons(ctx);
self.sync_model_switches(ctx);
self.update_next_button(ctx);
ctx.focus_self();
ctx.notify();
}
pub fn begin_edit_acp(&mut self, draft: AcpProviderDraft, ctx: &mut ViewContext<Self>) {
self.step = if draft.config_options.is_empty() {
ProviderSetupStep::Configure
} else {
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;
self.draft_models.clear();
self.discovery_state = DiscoveryState::Idle;
self.sync_editors(ctx);
self.sync_provider_type_buttons(ctx);
self.sync_bedrock_auth_buttons(ctx);
self.sync_model_switches(ctx);
self.update_next_button(ctx);
ctx.focus_self();
ctx.notify();
}
pub fn finish_acp_discovery(
&mut self,
result: Result<(), String>,
config_options: Vec<AcpConfigOptionSettings>,
ctx: &mut ViewContext<Self>,
) {
match result {
Ok(()) => {
self.draft_acp.config_options = config_options;
self.discovery_state = DiscoveryState::Idle;
self.step = ProviderSetupStep::Models;
ctx.focus(&self.name_editor);
}
Err(error) => {
self.discovery_state = DiscoveryState::Failed(error);
}
}
self.update_next_button(ctx);
ctx.notify();
}
fn sync_editors(&self, ctx: &mut ViewContext<Self>) {
self.name_editor.update(ctx, |editor, ctx| {
editor.system_reset_buffer_text(&self.draft_name, ctx);
});
self.base_url_editor.update(ctx, |editor, ctx| {
editor.system_reset_buffer_text(&self.draft_base_url, ctx);
});
self.api_key_editor.update(ctx, |editor, ctx| {
editor.system_reset_buffer_text(self.draft_api_key.as_deref().unwrap_or_default(), ctx);
});
self.project_id_editor.update(ctx, |editor, ctx| {
editor.system_reset_buffer_text(&self.draft_project_id, ctx);
});
self.location_editor.update(ctx, |editor, ctx| {
editor.system_reset_buffer_text(&self.draft_location, ctx);
});
self.bedrock_profile_editor.update(ctx, |editor, ctx| {
editor.system_reset_buffer_text(&self.draft_bedrock.profile, ctx);
});
self.bedrock_region_editor.update(ctx, |editor, ctx| {
editor.system_reset_buffer_text(&self.draft_bedrock.region, ctx);
});
self.bedrock_refresh_command_editor
.update(ctx, |editor, ctx| {
editor.system_reset_buffer_text(&self.draft_bedrock.auth_refresh_command, ctx);
});
self.bedrock_access_key_editor.update(ctx, |editor, ctx| {
editor.system_reset_buffer_text(&self.draft_bedrock.access_key_id, ctx);
});
self.bedrock_secret_key_editor.update(ctx, |editor, ctx| {
editor.system_reset_buffer_text(&self.draft_bedrock.secret_access_key, ctx);
});
self.acp_agent_id_editor.update(ctx, |editor, ctx| {
editor.system_reset_buffer_text(&self.draft_acp.agent_id, ctx);
});
self.acp_command_editor.update(ctx, |editor, ctx| {
editor.system_reset_buffer_text(&self.draft_acp.command, ctx);
});
self.acp_args_editor.update(ctx, |editor, ctx| {
let args =
serde_json::to_string(&self.draft_acp.args).unwrap_or_else(|_| "[]".to_string());
editor.system_reset_buffer_text(&args, ctx);
});
}
fn sync_provider_type_buttons(&self, ctx: &mut ViewContext<Self>) {
for ((button_kind, _, _), button) in PROVIDER_TYPE_OPTIONS
.iter()
.zip(self.provider_type_buttons.iter())
{
button.update(ctx, |button, ctx| {
button.set_active(*button_kind == self.provider_type, ctx);
});
}
}
fn sync_acp_agent_buttons(&self, ctx: &mut ViewContext<Self>) {
let selected = self.draft_acp.agent_id.trim();
for (agent, button) in galaxy_acp::known_acp_agents()
.iter()
.zip(self.acp_agent_buttons.iter())
{
button.update(ctx, |button, ctx| {
button.set_active(agent.id.eq_ignore_ascii_case(selected), ctx);
});
}
if let Some(button) = self.acp_agent_buttons.last() {
button.update(ctx, |button, ctx| {
button.set_active(selected.eq_ignore_ascii_case("custom"), ctx);
});
}
}
fn sync_bedrock_auth_buttons(&self, ctx: &mut ViewContext<Self>) {
for (index, button) in self.bedrock_auth_buttons.iter().enumerate() {
let method = match index {
0 => BedrockAuthMethod::Profile,
1 => BedrockAuthMethod::Sso,
2 => BedrockAuthMethod::StaticKeys,
_ => continue,
};
button.update(ctx, |button, ctx| {
button.set_active(method == self.draft_bedrock.auth_method, ctx);
});
}
}
fn sync_model_switches(&mut self, ctx: &mut ViewContext<Self>) {
while self.model_switches.len() < self.draft_models.len() {
self.model_switches.push(SwitchStateHandle::default());
}
self.model_switches.truncate(self.draft_models.len());
while self.model_capability_switches.len() < self.draft_models.len() {
self.model_capability_switches
.push([SwitchStateHandle::default(), SwitchStateHandle::default()]);
}
self.model_capability_switches
.truncate(self.draft_models.len());
while self.model_capability_buttons.len() < self.draft_models.len() {
let index = self.model_capability_buttons.len();
let buttons = CapabilityKey::ALL
.into_iter()
.map(|key| {
ctx.add_typed_action_view(move |_| {
ActionButton::new(format!("{}: Auto", key.label()), NakedTheme)
.with_size(ButtonSize::XSmall)
.on_click(move |ctx| {
ctx.dispatch_typed_action(
ProviderSetupViewAction::CycleModelCapability(index, key),
);
})
})
})
.collect();
self.model_capability_buttons.push(buttons);
}
self.model_capability_buttons
.truncate(self.draft_models.len());
while self.model_context_editors.len() < self.draft_models.len() {
let index = self.model_context_editors.len();
let editor = Self::create_editor("Context window", false, ctx);
ctx.subscribe_to_view(&editor, move |me, editor, event, ctx| {
if matches!(event, EditorEvent::Edited(_)) {
if let Some(model) = me.draft_models.get_mut(index) {
if let Ok(context_size) = editor.as_ref(ctx).buffer_text(ctx).parse() {
model.context_size = context_size;
model.max_input_tokens = Some(context_size);
}
}
me.update_next_button(ctx);
ctx.notify();
}
});
self.model_context_editors.push(editor);
}
self.model_context_editors.truncate(self.draft_models.len());
for (index, model) in self.draft_models.iter().enumerate() {
for (button, key) in self.model_capability_buttons[index]
.iter()
.zip(CapabilityKey::ALL)
{
let state = model.capability_override(key.setting_key());
button.update(ctx, |button, ctx| {
button.set_label(format!("{}: {}", key.label(), state.label()), ctx);
});
}
self.model_context_editors[index].update(ctx, |editor, ctx| {
editor.system_reset_buffer_text(&model.context_size.to_string(), ctx);
});
}
}
fn update_next_button(&self, ctx: &mut ViewContext<Self>) {
let (label, disabled) = match self.step {
ProviderSetupStep::ProviderType => ("Next", false),
ProviderSetupStep::Configure => ("Next", !self.is_configure_valid()),
ProviderSetupStep::Discover => (
if matches!(self.discovery_state, DiscoveryState::Failed(_)) {
"Retry"
} else {
"Testing..."
},
!matches!(self.discovery_state, DiscoveryState::Failed(_)),
),
ProviderSetupStep::Models => match self.provider_type {
ProviderSetupProviderType::OpenAI
| ProviderSetupProviderType::LiteLLM
| ProviderSetupProviderType::ChatGPTSubscription
| ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
| ProviderSetupProviderType::VertexAI => (
"Save",
self.draft_name.trim().is_empty()
|| !self.draft_models.iter().any(|model| model.enabled),
),
ProviderSetupProviderType::Bedrock => (
"Save",
self.draft_name.trim().is_empty() || self.draft_bedrock.models.is_empty(),
),
ProviderSetupProviderType::Acp => ("Save", self.draft_name.trim().is_empty()),
},
};
self.next_button.update(ctx, |button, ctx| {
button.set_label(label, ctx);
button.set_disabled(disabled, ctx);
});
self.back_button.update(ctx, |button, ctx| {
button.set_disabled(self.step == ProviderSetupStep::ProviderType, ctx);
});
}
fn draft_provider(&self) -> OpenAIProviderConfig {
OpenAIProviderConfig {
kind: match self.provider_type {
ProviderSetupProviderType::OpenAI => OpenAIProviderKind::OpenAI,
ProviderSetupProviderType::LiteLLM => OpenAIProviderKind::LiteLLM,
ProviderSetupProviderType::ChatGPTSubscription => {
OpenAIProviderKind::ChatGPTSubscription
}
ProviderSetupProviderType::Bedrock | ProviderSetupProviderType::Acp => {
OpenAIProviderKind::LiteLLM
}
ProviderSetupProviderType::Anthropic => OpenAIProviderKind::Anthropic,
ProviderSetupProviderType::Gemini => OpenAIProviderKind::Gemini,
ProviderSetupProviderType::VertexAI => OpenAIProviderKind::VertexAI,
},
enabled: true,
name: self.draft_name.trim().to_string(),
base_url: if matches!(
self.provider_type,
ProviderSetupProviderType::ChatGPTSubscription
| ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
| ProviderSetupProviderType::VertexAI
) {
String::new()
} else {
self.draft_base_url.trim().trim_end_matches('/').to_string()
},
api_key: matches!(
self.provider_type,
ProviderSetupProviderType::OpenAI
| ProviderSetupProviderType::LiteLLM
| ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
)
.then(|| {
self.draft_api_key
.as_deref()
.filter(|key| !key.trim().is_empty())
.map(str::to_string)
})
.flatten(),
project_id: matches!(self.provider_type, ProviderSetupProviderType::VertexAI)
.then(|| self.draft_project_id.trim().to_string()),
location: matches!(self.provider_type, ProviderSetupProviderType::VertexAI).then(
|| {
let location = self.draft_location.trim();
if location.is_empty() {
"global".to_string()
} else {
location.to_string()
}
},
),
models: self.draft_models.clone(),
}
}
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;
self.update_next_button(ctx);
ctx.notify();
match self.provider_type {
ProviderSetupProviderType::ChatGPTSubscription => {
self.try_discover_chatgpt(ctx);
return;
}
ProviderSetupProviderType::Bedrock => {
let config = crate::ai::bedrock::client::BedrockClientConfig {
auth_method: self.draft_bedrock.auth_method,
profile: self.draft_bedrock.profile.clone(),
region: self.draft_bedrock.region.clone(),
access_key_id: self.draft_bedrock.access_key_id.clone(),
secret_access_key: self.draft_bedrock.secret_access_key.clone(),
session_token: None,
cross_region_inference: self.draft_bedrock.cross_region_inference,
use_rig: false,
};
ctx.spawn(
async move {
crate::ai::bedrock::discovery::discover_available_models(config).await
},
move |me, result, ctx| match result {
Ok(models) => {
me.draft_bedrock.models = models;
me.discovery_state = DiscoveryState::Idle;
me.step = ProviderSetupStep::Models;
me.update_next_button(ctx);
ctx.focus(&me.name_editor);
ctx.notify();
}
Err(error) => {
me.discovery_state = DiscoveryState::Failed(error);
me.update_next_button(ctx);
ctx.notify();
}
},
);
return;
}
ProviderSetupProviderType::Acp => {
ctx.emit(ProviderSetupViewEvent::RequestAcpDiscovery(
self.draft_acp.clone(),
));
return;
}
ProviderSetupProviderType::OpenAI
| ProviderSetupProviderType::LiteLLM
| ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
| ProviderSetupProviderType::VertexAI => {}
}
self.discovery_state = DiscoveryState::Loading;
self.update_next_button(ctx);
ctx.notify();
let provider = self.draft_provider();
let existing_models = self.draft_models.clone();
ctx.spawn(
async move { LLMPreferences::discover_openai_provider_models(provider).await },
move |me, result, ctx| match result {
Ok(models) => {
me.draft_models = merge_discovered_provider_models(&existing_models, models);
me.discovery_state = DiscoveryState::Idle;
me.step = ProviderSetupStep::Models;
me.sync_model_switches(ctx);
me.update_next_button(ctx);
ctx.focus(&me.name_editor);
ctx.notify();
}
Err(error) => {
me.discovery_state = DiscoveryState::Failed(error);
me.update_next_button(ctx);
ctx.notify();
}
},
);
}
#[cfg(not(target_family = "wasm"))]
fn try_discover_chatgpt(&mut self, ctx: &mut ViewContext<Self>) {
if !matches!(
ChatGPTAuthModel::as_ref(ctx).state(),
ChatGPTAuthState::Connected
) {
self.discovery_state = DiscoveryState::Failed(
"Connect your ChatGPT subscription before continuing.".to_string(),
);
self.update_next_button(ctx);
ctx.notify();
return;
}
self.discovery_state = DiscoveryState::Loading;
self.update_next_button(ctx);
ctx.notify();
let provider = self.draft_provider();
let existing_models = self.draft_models.clone();
ctx.spawn(
async move { LLMPreferences::discover_openai_provider_models(provider).await },
move |me, result, ctx| match result {
Ok(models) => {
me.draft_models = crate::ai::llms::merge_discovered_chatgpt_subscription_models(
&existing_models,
models,
);
me.discovery_state = DiscoveryState::Idle;
me.step = ProviderSetupStep::Models;
me.sync_model_switches(ctx);
me.update_next_button(ctx);
ctx.focus(&me.name_editor);
ctx.notify();
}
Err(error) => {
me.discovery_state = DiscoveryState::Failed(error);
me.update_next_button(ctx);
ctx.notify();
}
},
);
}
#[cfg(target_family = "wasm")]
fn try_discover_chatgpt(&mut self, ctx: &mut ViewContext<Self>) {
self.discovery_state = DiscoveryState::Failed(
"ChatGPT subscription providers are available in the desktop app.".to_string(),
);
self.update_next_button(ctx);
ctx.notify();
}
fn render_label(appearance: &Appearance, label: &str) -> Box<dyn galaxyui::Element> {
Text::new_inline(
label.to_string(),
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
.with_color(appearance.theme().active_ui_text_color().into())
.with_style(Properties::default().weight(Weight::Bold))
.finish()
}
fn render_input(
&self,
appearance: &Appearance,
label: &str,
editor: &ViewHandle<EditorView>,
) -> Box<dyn galaxyui::Element> {
let input = appearance
.ui_builder()
.text_input(editor.clone())
.with_style(UiComponentStyles {
padding: Some(Coords {
top: 10.,
bottom: 10.,
left: 12.,
right: 12.,
}),
background: Some(appearance.theme().surface_1().into()),
..Default::default()
})
.build()
.finish();
Flex::column()
.with_spacing(6.)
.with_child(Self::render_label(appearance, label))
.with_child(input)
.finish()
}
fn render_provider_type(&self, appearance: &Appearance) -> Box<dyn galaxyui::Element> {
let description = Text::new(
"Choose how Galaxy should connect to this provider.",
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.finish();
let cards = PROVIDER_TYPE_OPTIONS
.iter()
.enumerate()
.map(|(index, (_, _, description))| {
let button = ChildView::new(&self.provider_type_buttons[index]).finish();
Container::new(
Flex::column()
.with_spacing(8.)
.with_child(button)
.with_child(
Text::new(*description, appearance.ui_font_family(), INPUT_FONT_SIZE)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.soft_wrap(true)
.finish(),
)
.finish(),
)
.with_padding(Padding::uniform(12.))
.with_border(Border::all(1.).with_border_fill(appearance.theme().outline()))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
.finish()
})
.collect::<Vec<_>>();
let list = Flex::column()
.with_spacing(10.)
.with_children(cards)
.finish();
let scrollable = ClippedScrollable::vertical(
self.provider_type_scroll_state.clone(),
list,
ScrollbarWidth::Auto,
appearance.theme().nonactive_ui_detail().into(),
appearance.theme().active_ui_detail().into(),
appearance.theme().surface_1().into(),
)
.with_overlayed_scrollbar()
.finish();
Flex::column()
.with_spacing(16.)
.with_child(description)
.with_child(
ConstrainedBox::new(scrollable)
.with_max_height(360.)
.finish(),
)
.finish()
}
#[cfg(not(target_family = "wasm"))]
fn render_chatgpt_auth(
&self,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn galaxyui::Element> {
let state = ChatGPTAuthModel::as_ref(app).state();
let mut children = vec![Self::render_label(appearance, "ChatGPT authorization")];
let description = match &state {
ChatGPTAuthState::NotConnected => "Connect your ChatGPT subscription to continue.",
ChatGPTAuthState::AwaitingBrowser => "Waiting for ChatGPT sign-in in your browser...",
ChatGPTAuthState::ExchangingToken => "Completing sign-in...",
ChatGPTAuthState::Connected => "ChatGPT subscription connected.",
ChatGPTAuthState::Failed(_) => "ChatGPT connection failed.",
};
children.push(
Text::new(description, appearance.ui_font_family(), INPUT_FONT_SIZE)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.soft_wrap(true)
.finish(),
);
if let ChatGPTAuthState::Failed(error) = &state {
children.push(
Text::new(error.clone(), appearance.monospace_font_family(), 11.)
.with_color(appearance.theme().ui_error_color())
.soft_wrap(true)
.finish(),
);
}
if matches!(
state,
ChatGPTAuthState::NotConnected | ChatGPTAuthState::Failed(_)
) {
children.push(
appearance
.ui_builder()
.button(
ButtonVariant::Secondary,
self.chatgpt_connect_mouse_state.clone(),
)
.with_text_label("Connect ChatGPT".to_owned())
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(ProviderSetupViewAction::ConnectChatGPT);
})
.finish(),
);
}
Flex::column()
.with_spacing(10.)
.with_children(children)
.finish()
}
#[cfg(target_family = "wasm")]
fn render_chatgpt_auth(
&self,
appearance: &Appearance,
_app: &AppContext,
) -> Box<dyn galaxyui::Element> {
Flex::column()
.with_spacing(8.)
.with_child(Self::render_label(appearance, "ChatGPT authorization"))
.with_child(
Text::new(
"ChatGPT subscription providers are available in the desktop app.",
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.soft_wrap(true)
.finish(),
)
.finish()
}
fn render_configure(
&self,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn galaxyui::Element> {
let mut children = vec![Text::new(
format!("Configure {}", provider_type_label(self.provider_type)),
appearance.ui_font_family(),
appearance.header_font_size(),
)
.with_color(appearance.theme().active_ui_text_color().into())
.with_style(Properties::default().weight(Weight::Bold))
.finish()];
match self.provider_type {
ProviderSetupProviderType::ChatGPTSubscription => {
children.push(self.render_chatgpt_auth(appearance, app));
}
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. 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,
)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.soft_wrap(true)
.finish(),
);
}
ProviderSetupProviderType::Anthropic => {
children.push(self.render_input(appearance, "API key", &self.api_key_editor));
children.push(
Text::new(
"The key is stored locally and is never synced to the cloud. Models will be discovered from Anthropic after the connection test.",
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.soft_wrap(true)
.finish(),
);
}
ProviderSetupProviderType::Gemini => {
children.push(self.render_input(appearance, "API key", &self.api_key_editor));
children.push(
Text::new(
"The key is stored locally and is never synced to the cloud. Models will be discovered from Google's Gemini API after the connection test.",
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.soft_wrap(true)
.finish(),
);
}
ProviderSetupProviderType::VertexAI => {
children.push(self.render_input(
appearance,
"Google Cloud project ID",
&self.project_id_editor,
));
children.push(self.render_input(appearance, "Location", &self.location_editor));
children.push(
Text::new(
"Vertex AI uses Google Application Default Credentials. Run `gcloud auth application-default login` before testing the connection.",
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.soft_wrap(true)
.finish(),
);
}
ProviderSetupProviderType::Bedrock => {
children.push(Self::render_label(appearance, "Authentication method"));
children.push(
Flex::row()
.with_spacing(8.)
.with_children(
self.bedrock_auth_buttons
.iter()
.map(|button| ChildView::new(button).finish()),
)
.finish(),
);
match self.draft_bedrock.auth_method {
BedrockAuthMethod::Profile | BedrockAuthMethod::Sso => {
children.push(self.render_input(
appearance,
"AWS profile",
&self.bedrock_profile_editor,
));
if self.draft_bedrock.auth_method == BedrockAuthMethod::Sso {
children.push(self.render_input(
appearance,
"Login command",
&self.bedrock_refresh_command_editor,
));
children.push(
appearance
.ui_builder()
.switch(self.bedrock_auto_login_toggle.clone())
.check(self.draft_bedrock.auto_login)
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(
ProviderSetupViewAction::ToggleBedrockAutoLogin,
);
})
.finish(),
);
}
}
BedrockAuthMethod::StaticKeys => {
children.push(self.render_input(
appearance,
"Access key ID",
&self.bedrock_access_key_editor,
));
children.push(self.render_input(
appearance,
"Secret access key",
&self.bedrock_secret_key_editor,
));
}
}
children.push(self.render_input(
appearance,
"AWS region",
&self.bedrock_region_editor,
));
children.push(
appearance
.ui_builder()
.switch(self.bedrock_cross_region_toggle.clone())
.check(self.draft_bedrock.cross_region_inference)
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(
ProviderSetupViewAction::ToggleBedrockCrossRegion,
);
})
.finish(),
);
}
ProviderSetupProviderType::Acp => {
children.push(
Text::new("ACP client", appearance.ui_font_family(), INPUT_FONT_SIZE)
.with_color(appearance.theme().active_ui_text_color().into())
.with_style(Properties::default().weight(Weight::Bold))
.finish(),
);
children.extend(
self.acp_agent_buttons
.iter()
.map(|button| ChildView::new(button).finish()),
);
if self.draft_acp.agent_id.eq_ignore_ascii_case("custom") {
children.push(self.render_input(
appearance,
"Executable",
&self.acp_command_editor,
));
children.push(self.render_input(
appearance,
"Arguments (JSON array)",
&self.acp_args_editor,
));
}
children.push(
Text::new(
"Known NPM-backed clients use npx with a pinned package version. If an ACP client needs another toolchain, choose Custom and configure the executable after installing it yourself.",
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.soft_wrap(true)
.finish(),
);
}
}
Flex::column()
.with_spacing(16.)
.with_children(children)
.finish()
}
fn render_discovery(&self, appearance: &Appearance) -> Box<dyn galaxyui::Element> {
let (message, color) = match &self.discovery_state {
DiscoveryState::Idle | DiscoveryState::Loading => (
"Testing the connection and discovering available models...".to_string(),
appearance.theme().active_ui_text_color().into_solid(),
),
DiscoveryState::Failed(error) => (error.clone(), appearance.theme().ui_error_color()),
};
Flex::column()
.with_spacing(12.)
.with_child(
Text::new(
"Test connection",
appearance.ui_font_family(),
appearance.header_font_size(),
)
.with_color(appearance.theme().active_ui_text_color().into())
.with_style(Properties::default().weight(Weight::Bold))
.finish(),
)
.with_child(if matches!(self.discovery_state, DiscoveryState::Loading) {
Flex::row()
.with_spacing(8.)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
ConstrainedBox::new(
Icon::Loading
.to_galaxyui_icon(appearance.theme().active_ui_text_color())
.finish(),
)
.with_width(16.)
.with_height(16.)
.finish(),
)
.with_child(
Text::new(message, appearance.ui_font_family(), INPUT_FONT_SIZE)
.with_color(color)
.soft_wrap(true)
.finish(),
)
.finish()
} else {
Text::new(message, appearance.ui_font_family(), INPUT_FONT_SIZE)
.with_color(color)
.soft_wrap(true)
.finish()
})
.finish()
}
fn render_model_table(
&self,
appearance: &Appearance,
rows: Vec<Box<dyn galaxyui::Element>>,
spacing: f32,
) -> Box<dyn galaxyui::Element> {
let list = Flex::column()
.with_spacing(spacing)
.with_children(rows)
.finish();
let scrollable = ClippedScrollable::vertical(
self.models_scroll_state.clone(),
list,
ScrollbarWidth::Auto,
appearance.theme().nonactive_ui_detail().into(),
appearance.theme().active_ui_detail().into(),
appearance.theme().surface_1().into(),
)
.with_overlayed_scrollbar()
.finish();
ConstrainedBox::new(
Container::new(scrollable)
.with_padding(Padding::uniform(12.))
.with_background(appearance.theme().surface_1())
.with_border(Border::all(1.).with_border_fill(appearance.theme().outline()))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
.finish(),
)
.with_width(SETUP_WIDTH - 56.)
.with_max_height(430.)
.finish()
}
fn model_logo(&self) -> (Icon, ColorU) {
match self.provider_type {
ProviderSetupProviderType::ChatGPTSubscription
| ProviderSetupProviderType::OpenAI
| ProviderSetupProviderType::LiteLLM => {
(Icon::OpenAILogo, crate::terminal::cli_agent::OPENAI_COLOR)
}
ProviderSetupProviderType::Anthropic => {
(Icon::ClaudeLogo, crate::ai::blocklist::CLAUDE_ORANGE)
}
ProviderSetupProviderType::Gemini | ProviderSetupProviderType::VertexAI => {
(Icon::GeminiLogo, crate::terminal::cli_agent::GEMINI_BLUE)
}
ProviderSetupProviderType::Bedrock => {
(Icon::BedrockLogo, ColorU::new(255, 153, 0, 255))
}
ProviderSetupProviderType::Acp => {
let agent = self.draft_acp.agent_id.to_ascii_lowercase();
let harness = if agent.contains("claude") {
Harness::Claude
} else if agent.contains("gemini") {
Harness::Gemini
} else if agent.contains("codex") {
Harness::Codex
} else {
Harness::Unknown
};
(
harness_display::icon_for(harness),
harness_display::brand_color(harness)
.unwrap_or(ColorU::new(128, 128, 128, 255)),
)
}
}
}
fn render_model_logo(&self) -> Box<dyn galaxyui::Element> {
let (icon, color) = self.model_logo();
ConstrainedBox::new(icon.to_galaxyui_icon(Fill::Solid(color)).finish())
.with_width(MODEL_LOGO_SIZE)
.with_height(MODEL_LOGO_SIZE)
.finish()
}
fn render_model_table_header(&self, appearance: &Appearance) -> Box<dyn galaxyui::Element> {
let header = |label: &str| {
Text::new(label.to_owned(), appearance.ui_font_family(), 11.)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.with_style(Properties::default().weight(Weight::Semibold))
.finish()
};
Container::new(
Flex::row()
.with_spacing(12.)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(ConstrainedBox::new(header("Use")).with_width(52.).finish())
.with_child(
ConstrainedBox::new(header("Model"))
.with_width(250.)
.finish(),
)
.with_child(
ConstrainedBox::new(header("Context"))
.with_width(140.)
.finish(),
)
.with_child(
ConstrainedBox::new(header("Capabilities"))
.with_width(330.)
.finish(),
)
.finish(),
)
.with_padding(Padding::uniform(10.).with_vertical(9.))
.with_background(appearance.theme().surface_2())
.with_border(Border::bottom(1.).with_border_fill(appearance.theme().outline()))
.finish()
}
fn render_model_capabilities(
&self,
appearance: &Appearance,
index: usize,
) -> Box<dyn galaxyui::Element> {
let buttons = &self.model_capability_buttons[index];
let first_row = buttons[..3]
.iter()
.map(|button| ChildView::new(button).finish())
.collect::<Vec<_>>();
let second_row = buttons[3..]
.iter()
.map(|button| ChildView::new(button).finish())
.collect::<Vec<_>>();
Flex::column()
.with_spacing(6.)
.with_child(
Flex::row()
.with_spacing(8.)
.with_children(first_row)
.finish(),
)
.with_child(
Flex::row()
.with_spacing(8.)
.with_children(second_row)
.finish(),
)
.finish()
}
fn render_openai_model_row(
&self,
appearance: &Appearance,
index: usize,
model: &OpenAIModelConfig,
) -> Box<dyn galaxyui::Element> {
let model_info = Flex::column()
.with_spacing(4.)
.with_child(
Text::new_inline(model.display_name.clone(), appearance.ui_font_family(), 12.)
.with_color(appearance.theme().active_ui_text_color().into())
.with_clip(ClipConfig::end())
.finish(),
)
.with_child(
Text::new_inline(
model.model_id.clone(),
appearance.monospace_font_family(),
10.,
)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.with_clip(ClipConfig::end())
.finish(),
)
.finish();
let model_info = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(10.)
.with_child(self.render_model_logo())
.with_child(model_info)
.finish();
let context_input = appearance
.ui_builder()
.text_input(self.model_context_editors[index].clone())
.with_style(UiComponentStyles {
padding: Some(Coords {
top: 8.,
bottom: 8.,
left: 8.,
right: 8.,
}),
background: Some(appearance.theme().surface_1().into()),
..Default::default()
})
.build()
.finish();
Container::new(
Flex::row()
.with_spacing(12.)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
ConstrainedBox::new(
appearance
.ui_builder()
.switch(self.model_switches[index].clone())
.check(model.enabled)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ProviderSetupViewAction::ToggleModel(
index,
));
})
.finish(),
)
.with_width(52.)
.finish(),
)
.with_child(ConstrainedBox::new(model_info).with_width(250.).finish())
.with_child(ConstrainedBox::new(context_input).with_width(140.).finish())
.with_child(
ConstrainedBox::new(self.render_model_capabilities(appearance, index))
.with_width(330.)
.finish(),
)
.finish(),
)
.with_padding(Padding::uniform(12.).with_vertical(10.))
.with_border(Border::bottom(1.).with_border_fill(appearance.theme().outline()))
.finish()
}
fn render_models(&self, appearance: &Appearance) -> Box<dyn galaxyui::Element> {
if self.provider_type == ProviderSetupProviderType::Bedrock {
let rows = self
.draft_bedrock
.models
.iter()
.map(|model| {
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(10.)
.with_child(self.render_model_logo())
.with_child(
Flex::column()
.with_spacing(2.)
.with_child(
Text::new(
model.display_name.clone(),
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
.with_color(appearance.theme().active_ui_text_color().into())
.finish(),
)
.with_child(
Text::new(
model.model_id.clone(),
appearance.monospace_font_family(),
10.,
)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.soft_wrap(true)
.finish(),
)
.finish(),
)
.finish()
})
.collect::<Vec<_>>();
return Flex::column()
.with_spacing(12.)
.with_child(self.render_input(appearance, "Connection name", &self.name_editor))
.with_child(
Text::new(
"These models passed AWS Bedrock availability checks. Model selection is managed by discovery and cannot be edited manually.",
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.soft_wrap(true)
.finish(),
)
.with_child(self.render_model_table(appearance, rows, 10.))
.finish();
}
if self.provider_type == ProviderSetupProviderType::Acp {
let option_rows = self
.draft_acp
.config_options
.iter()
.filter(|option| {
matches!(
option.category.as_deref(),
Some("model") | Some("thought_level") | Some("mode")
)
})
.map(|option| {
let values = option
.options
.iter()
.map(|value| value.name.as_str())
.collect::<Vec<_>>()
.join(", ");
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(10.)
.with_child(self.render_model_logo())
.with_child(
Flex::column()
.with_spacing(2.)
.with_child(
Text::new(
option.name.clone(),
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
.with_color(appearance.theme().active_ui_text_color().into())
.with_style(Properties::default().weight(Weight::Bold))
.finish(),
)
.with_child(
Text::new(
if values.is_empty() {
option.current_value.to_string()
} else {
values
},
appearance.monospace_font_family(),
10.,
)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.soft_wrap(true)
.finish(),
)
.finish(),
)
.finish()
})
.collect::<Vec<_>>();
let catalog = if option_rows.is_empty() {
Text::new(
"No model or mode catalog has been discovered yet. Continue to test the ACP agent.",
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.soft_wrap(true)
.finish()
} else {
self.render_model_table(appearance, option_rows, 10.)
};
return Flex::column()
.with_spacing(12.)
.with_child(self.render_input(appearance, "Connection name", &self.name_editor))
.with_child(
Text::new(
"ACP-discovered models and modes are exposed as selectable combinations in Galaxy's model picker.",
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.soft_wrap(true)
.finish(),
)
.with_child(catalog)
.finish();
}
let mut rows = vec![self.render_model_table_header(appearance)];
rows.extend(
self.draft_models
.iter()
.enumerate()
.map(|(index, model)| self.render_openai_model_row(appearance, index, model)),
);
let table = self.render_model_table(appearance, rows, 0.);
Flex::column()
.with_spacing(12.)
.with_child(self.render_input(appearance, "Connection name", &self.name_editor))
.with_child(
Text::new(
"Enable the models Galaxy should offer. Context is the maximum input window. Capabilities use Auto by default and can be overridden per model.",
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.soft_wrap(true)
.finish(),
)
.with_child(table)
.finish()
}
fn render_footer(&self) -> Box<dyn galaxyui::Element> {
let mut footer = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::End)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(8.);
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());
if !matches!(self.step, ProviderSetupStep::Discover)
|| matches!(self.discovery_state, DiscoveryState::Failed(_))
{
footer = footer.with_child(ChildView::new(&self.next_button).finish());
}
footer.finish()
}
fn can_go_back(&self) -> bool {
match self.step {
ProviderSetupStep::ProviderType => false,
ProviderSetupStep::Configure => !self.provider_type_locked,
ProviderSetupStep::Discover | ProviderSetupStep::Models => true,
}
}
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 ProviderSetupView {
type Event = ProviderSetupViewEvent;
}
impl View for ProviderSetupView {
fn ui_name() -> &'static str {
"ProviderSetupView"
}
fn render(&self, app: &AppContext) -> Box<dyn galaxyui::Element> {
let appearance = Appearance::as_ref(app);
let content = match self.step {
ProviderSetupStep::ProviderType => self.render_provider_type(appearance),
ProviderSetupStep::Configure => self.render_configure(appearance, app),
ProviderSetupStep::Discover => self.render_discovery(appearance),
ProviderSetupStep::Models => self.render_models(appearance),
};
Flex::column()
.with_spacing(20.)
.with_child(self.render_step_indicator(appearance))
.with_child(content)
.with_child(self.render_footer())
.finish()
}
}
impl TypedActionView for ProviderSetupView {
type Action = ProviderSetupViewAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
ProviderSetupViewAction::SelectProvider(kind) => {
if self.provider_type != *kind {
self.draft_models.clear();
self.discovery_state = DiscoveryState::Idle;
}
self.provider_type = *kind;
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();
}
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);
ctx.notify();
}
ProviderSetupStep::Configure => self.begin_discovery(ctx),
ProviderSetupStep::Discover => {
if matches!(self.discovery_state, DiscoveryState::Failed(_)) {
self.begin_discovery(ctx);
}
}
ProviderSetupStep::Models => match self.provider_type {
ProviderSetupProviderType::OpenAI
| ProviderSetupProviderType::LiteLLM
| ProviderSetupProviderType::ChatGPTSubscription
| ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
| ProviderSetupProviderType::VertexAI => {
if self.draft_name.trim().is_empty()
|| !self.draft_models.iter().any(|model| model.enabled)
{
return;
}
ctx.emit(ProviderSetupViewEvent::SaveOpenAI {
editing_index: self.editing_index,
provider: self.draft_provider(),
});
}
ProviderSetupProviderType::Bedrock => {
if self.draft_name.trim().is_empty() || self.draft_bedrock.models.is_empty()
{
return;
}
let mut draft = self.draft_bedrock.clone();
draft.name = self.draft_name.trim().to_string();
ctx.emit(ProviderSetupViewEvent::SaveBedrock(draft));
}
ProviderSetupProviderType::Acp => {
if self.draft_name.trim().is_empty() {
return;
}
let mut draft = self.draft_acp.clone();
draft.name = self.draft_name.trim().to_string();
ctx.emit(ProviderSetupViewEvent::SaveAcp(draft));
}
},
},
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();
}
ProviderSetupStep::Discover => {
self.step = ProviderSetupStep::Configure;
self.discovery_state = DiscoveryState::Idle;
self.update_next_button(ctx);
ctx.notify();
}
ProviderSetupStep::Models => {
self.step = ProviderSetupStep::Configure;
self.update_next_button(ctx);
ctx.notify();
}
},
ProviderSetupViewAction::Cancel => {
ctx.emit(ProviderSetupViewEvent::Close);
}
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();
self.draft_acp.args.clear();
}
self.sync_editors(ctx);
self.sync_acp_agent_buttons(ctx);
self.update_next_button(ctx);
ctx.notify();
}
ProviderSetupViewAction::ToggleModel(index) => {
if let Some(model) = self.draft_models.get_mut(*index) {
model.enabled = !model.enabled;
self.update_next_button(ctx);
ctx.notify();
}
}
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();
model.capability_overrides.insert(key, next);
self.update_next_button(ctx);
self.sync_model_switches(ctx);
ctx.notify();
}
}
ProviderSetupViewAction::ConnectChatGPT => {
#[cfg(not(target_family = "wasm"))]
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx));
}
ProviderSetupViewAction::OpenChatGPTDevicePage => {
// No-op: device-code flow removed in favor of browser OAuth.
}
ProviderSetupViewAction::CopyChatGPTDeviceCode => {
// No-op: device-code flow removed in favor of browser OAuth.
}
ProviderSetupViewAction::SelectBedrockAuth(method) => {
self.draft_bedrock.auth_method = *method;
self.sync_bedrock_auth_buttons(ctx);
self.update_next_button(ctx);
ctx.notify();
}
ProviderSetupViewAction::ToggleBedrockCrossRegion => {
self.draft_bedrock.cross_region_inference =
!self.draft_bedrock.cross_region_inference;
ctx.notify();
}
ProviderSetupViewAction::ToggleBedrockAutoLogin => {
self.draft_bedrock.auto_login = !self.draft_bedrock.auto_login;
ctx.notify();
}
}
}
}
fn provider_type_label(kind: ProviderSetupProviderType) -> &'static str {
match kind {
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",
}
}