Improve AI provider model configuration

This commit is contained in:
2026-08-09 15:48:15 -05:00
parent 603437a24e
commit 170a87e981
13 changed files with 748 additions and 142 deletions
+9 -4
View File
@@ -1,4 +1,7 @@
use galaxy_acp::{AcpAgentPreset, AcpLaunchConfig, CODEX_ACP_NPM_VERSION, OPENCODE_NPM_VERSION};
use galaxy_acp::{
resolve_known_acp_agent, AcpAgentPreset, AcpLaunchConfig, CODEX_ACP_NPM_VERSION,
OPENCODE_NPM_VERSION,
};
use sha2::{Digest as _, Sha256};
use crate::persistence::model::AcpConversationData;
@@ -215,9 +218,11 @@ pub(crate) fn resolve_acp_launch(
match agent_id.trim().to_ascii_lowercase().as_str() {
"codex" => AcpAgentPreset::Codex.resolve_launch_config(),
"opencode" => AcpAgentPreset::OpenCode.resolve_launch_config(),
unknown => Err(format!(
"Unknown ACP agent preset {unknown:?}; choose \"codex\" or \"opencode\", or configure a custom ACP executable"
)),
_ => resolve_known_acp_agent(agent_id).map_err(|error| {
format!(
"{error} Configure a custom ACP executable if this client uses a different command."
)
}),
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ use super::*;
fn unknown_builtin_agent_ids_are_rejected() {
let error = resolve_acp_launch("mystery-agent", "", &[]).unwrap_err();
assert!(error.contains("Unknown ACP agent preset"));
assert!(error.contains("Unknown ACP agent"));
}
#[test]
+8 -5
View File
@@ -509,7 +509,7 @@ fn default_computer_use_llms() -> AvailableLLMs {
},
description: None,
disable_reason: None,
vision_supported: true,
vision_supported: false,
spec: None,
provider: LLMProvider::Unknown,
host_configs: HashMap::new(),
@@ -1164,7 +1164,7 @@ impl LLMPreferences {
},
description: Some(provider_name.clone()),
disable_reason: None,
vision_supported: model.vision_supported,
vision_supported: model.effective_vision_supported(),
spec: None,
provider: LLMProvider::LiteLLM,
host_configs: HashMap::from([(
@@ -1670,13 +1670,14 @@ impl LLMPreferences {
.map(|model| OpenAIModelConfig {
model_id: model.id,
display_name: model.display_name,
vision_supported: false,
vision_supported: true,
context_size: model.context_size.unwrap_or(128_000),
max_input_tokens: model.context_size,
max_output_tokens: None,
provider: None,
use_rig: true,
supports_system_messages: Some(true),
capability_overrides: std::collections::HashMap::new(),
reasoning_efforts: Vec::new(),
enabled: true,
})
@@ -2594,7 +2595,7 @@ async fn fetch_from_litellm_model_info(
.and_then(|v| u32::try_from(v).ok());
let context_size = max_input_tokens.unwrap_or(200_000);
let vision_supported = model_info["supports_vision"].as_bool().unwrap_or(false);
let vision_supported = model_info["supports_vision"].as_bool().unwrap_or(true);
let display_name = model_name.replace(['-', '_'], " ");
let display_name = display_name
@@ -2651,6 +2652,7 @@ async fn fetch_from_litellm_model_info(
} else {
model_info["supports_system_messages"].as_bool()
},
capability_overrides: std::collections::HashMap::new(),
reasoning_efforts: Vec::new(),
enabled: true,
})
@@ -2769,7 +2771,7 @@ async fn fetch_from_openai_models(
vision_supported: m["supports_vision"]
.as_bool()
.or_else(|| m["vision_support"].as_bool())
.unwrap_or(false),
.unwrap_or(true),
context_size,
max_input_tokens,
max_output_tokens,
@@ -2780,6 +2782,7 @@ async fn fetch_from_openai_models(
} else {
m["supports_system_messages"].as_bool()
},
capability_overrides: std::collections::HashMap::new(),
reasoning_efforts: Vec::new(),
enabled: true,
})
+1
View File
@@ -152,6 +152,7 @@ fn openai_model(model_id: &str) -> OpenAIModelConfig {
provider: None,
use_rig: false,
supports_system_messages: None,
capability_overrides: std::collections::HashMap::new(),
reasoning_efforts: Vec::new(),
enabled: true,
}
+1 -1
View File
@@ -1656,7 +1656,7 @@ impl Element for EditorElement {
);
}
if size.x().is_infinite() {
unimplemented!("we don't yet handle an infinite width constraint on buffer elements");
size.set_x(0.0);
}
let top_section_height_lines = top_section_height_px / view_snapshot.line_height;
+69 -3
View File
@@ -893,6 +893,13 @@ pub struct OpenAIModelConfig {
description = "Whether this endpoint accepts system-role messages. Set false for ChatGPT-backed LiteLLM models that reject them."
)]
pub supports_system_messages: Option<bool>,
/// Per-model capability overrides. Missing entries mean Auto: use provider
/// metadata when available and allow the request path to determine support.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
#[schemars(
description = "Optional per-capability overrides: auto, supported, or unsupported."
)]
pub capability_overrides: HashMap<String, ModelCapabilityOverride>,
#[serde(default)]
#[schemars(
description = "Reasoning effort modes supported by this model when using the ChatGPT subscription provider."
@@ -906,11 +913,62 @@ pub struct OpenAIModelConfig {
impl settings_value::SettingsValue for OpenAIModelConfig {}
impl OpenAIModelConfig {
pub fn capability_override(&self, capability: &str) -> ModelCapabilityOverride {
self.capability_overrides
.get(capability)
.copied()
.unwrap_or_default()
}
pub fn effective_vision_supported(&self) -> bool {
match self.capability_override("vision") {
ModelCapabilityOverride::Auto => self.vision_supported,
ModelCapabilityOverride::Supported => true,
ModelCapabilityOverride::Unsupported => false,
}
}
pub fn supports_system_messages(&self) -> bool {
if self.capability_override("system_messages") == ModelCapabilityOverride::Unsupported {
return false;
}
if self.model_id.starts_with("codex-gpt-") {
return false;
}
self.supports_system_messages.unwrap_or(true)
match self.capability_override("system_messages") {
ModelCapabilityOverride::Supported => true,
ModelCapabilityOverride::Auto => self.supports_system_messages.unwrap_or(true),
ModelCapabilityOverride::Unsupported => false,
}
}
}
#[derive(
Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum ModelCapabilityOverride {
#[default]
Auto,
Supported,
Unsupported,
}
impl ModelCapabilityOverride {
pub fn next(self) -> Self {
match self {
Self::Auto => Self::Supported,
Self::Supported => Self::Unsupported,
Self::Unsupported => Self::Auto,
}
}
pub fn label(self) -> &'static str {
match self {
Self::Auto => "Auto",
Self::Supported => "On",
Self::Unsupported => "Off",
}
}
}
@@ -1013,13 +1071,18 @@ fn default_chatgpt_models() -> Vec<OpenAIModelConfig> {
|(model_id, display_name, reasoning_efforts)| OpenAIModelConfig {
model_id: model_id.to_string(),
display_name: display_name.to_string(),
vision_supported: false,
// ChatGPT's subscription backend accepts image input for its chat
// models, but it does not expose a public capability discovery
// endpoint. Keep this explicit catalog in sync with that contract
// so the model picker does not hide vision context.
vision_supported: true,
context_size: default_context_size(),
max_input_tokens: None,
max_output_tokens: None,
provider: Some("openai".to_string()),
use_rig: true,
supports_system_messages: Some(true),
capability_overrides: HashMap::new(),
reasoning_efforts: reasoning_efforts.into_iter().map(str::to_string).collect(),
enabled: true,
},
@@ -1055,13 +1118,16 @@ fn default_openai_providers() -> Vec<OpenAIProviderConfig> {
models: vec![OpenAIModelConfig {
model_id: INITIAL_RIG_MODEL_ID.to_string(),
display_name: "Codex GPT-5.6 SOL (xhigh)".to_string(),
vision_supported: false,
// Auto capability detection is optimistic for modern
// multimodal-compatible endpoints; users can override it per model.
vision_supported: true,
context_size: default_context_size(),
max_input_tokens: None,
max_output_tokens: None,
provider: Some("openai".to_string()),
use_rig: true,
supports_system_messages: Some(false),
capability_overrides: HashMap::new(),
reasoning_efforts: Vec::new(),
enabled: true,
}],
+27 -10
View File
@@ -707,13 +707,19 @@ impl AISettingsPageView {
draft: &AcpProviderDraft,
ctx: &mut ViewContext<Self>,
) {
let Ok(config) = crate::ai::acp::AcpRuntimeModel::discovery_config_for_values(
let config = match crate::ai::acp::AcpRuntimeModel::discovery_config_for_values(
&draft.agent_id,
&draft.command,
&draft.args,
) else {
log::warn!("Could not resolve ACP launch configuration for discovery");
return;
) {
Ok(config) => config,
Err(error) => {
log::warn!("Could not resolve ACP launch configuration for discovery: {error}");
self.provider_setup_modal_body.update(ctx, |body, ctx| {
body.finish_acp_discovery(Err(error), Vec::new(), ctx);
});
return;
}
};
self.start_acp_discovery(config, draft.agent_id.clone(), ctx);
}
@@ -739,7 +745,7 @@ impl AISettingsPageView {
log::warn!("Could not start ACP discovery: {error}");
let error_text = error.to_string();
provider_setup_modal_body.update(ctx, |body, ctx| {
body.finish_acp_discovery(Err(error_text), ctx);
body.finish_acp_discovery(Err(error_text), Vec::new(), ctx);
});
return;
}
@@ -751,6 +757,10 @@ impl AISettingsPageView {
match result {
Ok(options) => {
let option_count = options.len();
let config_options =
crate::ai::acp::AcpRuntimeModel::normalize_config_options(
options.clone(),
);
AISettings::handle(ctx).update(ctx, |settings, ctx| {
if let Err(error) =
crate::ai::acp::AcpRuntimeModel::upsert_agent_settings(
@@ -774,7 +784,7 @@ impl AISettingsPageView {
runtime.finish_discovery_success(option_count, ctx);
});
provider_setup_modal_body.update(ctx, |body, ctx| {
body.finish_acp_discovery(Ok(()), ctx);
body.finish_acp_discovery(Ok(()), config_options, ctx);
});
}
Err(error) => {
@@ -796,7 +806,7 @@ impl AISettingsPageView {
runtime.finish_discovery_failure(error_text.clone(), ctx);
});
provider_setup_modal_body.update(ctx, |body, ctx| {
body.finish_acp_discovery(Err(error_text), ctx);
body.finish_acp_discovery(Err(error_text), Vec::new(), ctx);
});
}
}
@@ -1791,12 +1801,12 @@ impl AISettingsPageView {
ctx,
)
.with_modal_style(UiComponentStyles {
width: Some(640.),
height: Some(600.),
width: Some(900.),
height: Some(700.),
..Default::default()
})
.with_body_style(UiComponentStyles {
height: Some(530.),
height: Some(630.),
..Default::default()
})
.with_dismiss_on_click()
@@ -2017,6 +2027,13 @@ impl AISettingsPageView {
agent_id: settings.acp_agent_id.value().clone(),
command: settings.acp_agent_command.value().clone(),
args: settings.acp_agent_args.value().clone(),
config_options: settings
.acp_agents
.value()
.iter()
.find(|agent| agent.id.eq_ignore_ascii_case(settings.acp_agent_id.value()))
.map(|agent| agent.config_options.clone())
.unwrap_or_default(),
};
let body = self
.provider_setup_modal_state
+467 -82
View File
@@ -5,6 +5,7 @@ use galaxyui::elements::{
MouseStateHandle, Padding, ParentElement, Radius, ScrollbarWidth, Text,
};
use galaxyui::fonts::{Properties, Weight};
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;
@@ -21,17 +22,17 @@ use crate::editor::{
};
use crate::modal::{Modal, ModalViewState};
use crate::settings::ai::{
BedrockAuthMethod, BedrockModelConfig, OpenAIModelConfig, OpenAIProviderConfig,
OpenAIProviderKind,
AcpConfigOptionSettings, BedrockAuthMethod, BedrockModelConfig, ModelCapabilityOverride,
OpenAIModelConfig, OpenAIProviderConfig, OpenAIProviderKind,
};
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{
ActionButton, NakedTheme, PrimaryTheme, SecondaryTheme,
ActionButton, ButtonSize, NakedTheme, PrimaryTheme, SecondaryTheme,
};
const MODAL_WIDTH: f32 = 640.;
const MODAL_HEIGHT: f32 = 600.;
const BODY_HEIGHT: f32 = 530.;
const MODAL_WIDTH: f32 = 900.;
const MODAL_HEIGHT: f32 = 700.;
const BODY_HEIGHT: f32 = 630.;
const INPUT_FONT_SIZE: f32 = 12.;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -111,6 +112,7 @@ pub struct AcpProviderDraft {
pub agent_id: String,
pub command: String,
pub args: Vec<String>,
pub config_options: Vec<AcpConfigOptionSettings>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -120,6 +122,45 @@ enum DiscoveryState {
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 ProviderSetupModalBodyEvent {
Close,
RequestAcpDiscovery(AcpProviderDraft),
@@ -138,12 +179,14 @@ pub enum ProviderSetupModalBodyAction {
Back,
Cancel,
ToggleModel(usize),
CycleModelCapability(usize, CapabilityKey),
ConnectChatGPT,
OpenChatGPTDevicePage,
CopyChatGPTDeviceCode,
SelectBedrockAuth(BedrockAuthMethod),
ToggleBedrockCrossRegion,
ToggleBedrockAutoLogin,
SelectAcpAgent(String),
}
pub type ProviderSetupModalState = ModalViewState<Modal<ProviderSetupModalBody>>;
@@ -162,6 +205,7 @@ pub struct ProviderSetupModalBody {
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>,
@@ -175,10 +219,16 @@ pub struct ProviderSetupModalBody {
acp_agent_id_editor: ViewHandle<EditorView>,
acp_command_editor: ViewHandle<EditorView>,
acp_args_editor: ViewHandle<EditorView>,
chatgpt_connect_mouse_state: MouseStateHandle,
chatgpt_open_mouse_state: MouseStateHandle,
chatgpt_copy_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,
back_button: ViewHandle<ActionButton>,
@@ -219,6 +269,34 @@ impl ProviderSetupModalBody {
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(
ProviderSetupModalBodyAction::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(ProviderSetupModalBodyAction::SelectAcpAgent(
"custom".to_owned(),
));
})
}));
let bedrock_auth_buttons = [
BedrockAuthMethod::Profile,
BedrockAuthMethod::Sso,
@@ -358,9 +436,11 @@ impl ProviderSetupModalBody {
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,
@@ -374,10 +454,16 @@ impl ProviderSetupModalBody {
acp_agent_id_editor,
acp_command_editor,
acp_args_editor,
chatgpt_connect_mouse_state: MouseStateHandle::default(),
chatgpt_open_mouse_state: MouseStateHandle::default(),
chatgpt_copy_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(),
back_button,
@@ -440,10 +526,12 @@ impl ProviderSetupModalBody {
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);
@@ -457,7 +545,9 @@ impl ProviderSetupModalBody {
provider: OpenAIProviderConfig,
ctx: &mut ViewContext<Self>,
) {
self.step = ProviderSetupStep::Configure;
// 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 = match provider.kind {
OpenAIProviderKind::ChatGPTSubscription => {
@@ -477,6 +567,7 @@ impl ProviderSetupModalBody {
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);
@@ -502,7 +593,11 @@ impl ProviderSetupModalBody {
}
pub fn begin_edit_acp(&mut self, draft: AcpProviderDraft, ctx: &mut ViewContext<Self>) {
self.step = ProviderSetupStep::Configure;
self.step = if draft.config_options.is_empty() {
ProviderSetupStep::Configure
} else {
ProviderSetupStep::Models
};
self.editing_index = None;
self.provider_type = ProviderSetupProviderType::Acp;
self.draft_name = draft.name.clone();
@@ -521,10 +616,12 @@ impl ProviderSetupModalBody {
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);
@@ -593,6 +690,23 @@ impl ProviderSetupModalBody {
}
}
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 {
@@ -612,6 +726,68 @@ impl ProviderSetupModalBody {
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(
ProviderSetupModalBodyAction::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>) {
@@ -628,7 +804,11 @@ impl ProviderSetupModalBody {
.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(),
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,
};
@@ -957,6 +1137,14 @@ impl ProviderSetupModalBody {
.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().into())
.soft_wrap(true)
.finish(),
);
}
if let ChatGPTAuthState::AwaitingDeviceCode {
verification_uri,
@@ -985,7 +1173,10 @@ impl ProviderSetupModalBody {
.with_child(
appearance
.ui_builder()
.button(ButtonVariant::Secondary, MouseStateHandle::default())
.button(
ButtonVariant::Secondary,
self.chatgpt_open_mouse_state.clone(),
)
.with_text_label("Open sign-in page".to_owned())
.build()
.on_click(|ctx, _, _| {
@@ -998,7 +1189,10 @@ impl ProviderSetupModalBody {
.with_child(
appearance
.ui_builder()
.button(ButtonVariant::Secondary, MouseStateHandle::default())
.button(
ButtonVariant::Secondary,
self.chatgpt_copy_mouse_state.clone(),
)
.with_text_label("Copy code".to_owned())
.build()
.on_click(|ctx, _, _| {
@@ -1029,7 +1223,10 @@ impl ProviderSetupModalBody {
children.push(
appearance
.ui_builder()
.button(ButtonVariant::Secondary, MouseStateHandle::default())
.button(
ButtonVariant::Secondary,
self.chatgpt_connect_mouse_state.clone(),
)
.with_text_label("Connect ChatGPT".to_owned())
.build()
.on_click(|ctx, _, _| {
@@ -1216,24 +1413,32 @@ impl ProviderSetupModalBody {
);
}
ProviderSetupProviderType::Acp => {
children.push(self.render_input(
appearance,
"Agent preset",
&self.acp_agent_id_editor,
));
children.push(self.render_input(
appearance,
"Custom executable (optional)",
&self.acp_command_editor,
));
children.push(self.render_input(
appearance,
"Arguments (JSON array)",
&self.acp_args_editor,
));
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(
"ACP agents own their model and authentication. Galaxy will discover the configured runtime before saving.",
"Known clients use their local executable. If the client is not installed, Galaxy will show a launch error. Choose Custom for another ACP-compatible command.",
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
@@ -1330,7 +1535,150 @@ impl ProviderSetupModalBody {
.finish(),
)
.with_width(MODAL_WIDTH - 56.)
.with_max_height(320.)
.with_max_height(430.)
.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 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(
ProviderSetupModalBodyAction::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()
}
@@ -1382,12 +1730,70 @@ impl ProviderSetupModalBody {
.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::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()
})
.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 owns model selection. The configured agent runtime was checked before this step.",
"ACP-discovered models and modes are exposed as selectable combinations in Galaxy's model picker.",
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
@@ -1395,67 +1801,25 @@ impl ProviderSetupModalBody {
.soft_wrap(true)
.finish(),
)
.with_child(catalog)
.finish();
}
let mut rows = Vec::with_capacity(self.draft_models.len());
for (index, model) in self.draft_models.iter().enumerate() {
let modes = if model.reasoning_efforts.is_empty() {
"Standard".to_string()
} else {
model.reasoning_efforts.join(", ")
};
let info = 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(
format!("{} · modes: {modes}", model.model_id),
appearance.monospace_font_family(),
10.,
)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.soft_wrap(true)
.finish(),
)
.finish();
rows.push(
Flex::row()
.with_spacing(10.)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
appearance
.ui_builder()
.switch(self.model_switches[index].clone())
.check(model.enabled)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(
ProviderSetupModalBodyAction::ToggleModel(index),
);
})
.finish(),
)
.with_child(info)
.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, 12.);
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(
"Choose which models Galaxy should make available. Reasoning modes remain selectable from the model picker.",
"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,
)
@@ -1631,6 +1995,17 @@ impl TypedActionView for ProviderSetupModalBody {
ProviderSetupModalBodyAction::Cancel => {
ctx.emit(ProviderSetupModalBodyEvent::Close);
}
ProviderSetupModalBodyAction::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();
}
ProviderSetupModalBodyAction::ToggleModel(index) => {
if let Some(model) = self.draft_models.get_mut(*index) {
model.enabled = !model.enabled;
@@ -1638,6 +2013,16 @@ impl TypedActionView for ProviderSetupModalBody {
ctx.notify();
}
}
ProviderSetupModalBodyAction::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();
}
}
ProviderSetupModalBodyAction::ConnectChatGPT => {
#[cfg(not(target_family = "wasm"))]
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx));