use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use ::ai::api_keys::CustomEndpoint; use url::Url; use warp_editor::editor::NavigationKey; use warpui::elements::{ Border, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Expanded, Flex, MainAxisSize, MouseStateHandle, ParentElement, Radius, SavePosition, ScrollTarget, ScrollToPositionMode, ScrollbarWidth, Shrinkable, Text, }; use warpui::fonts::FamilyId; use warpui::ui_components::button::ButtonVariant; use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles}; use warpui::units::Pixels; use warpui::{ AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, }; use crate::appearance::{Appearance, AppearanceEvent}; use crate::editor::{ EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions, TextOptions, }; use crate::modal::{Modal, ModalViewState}; use crate::ui_components::icons::Icon; use crate::view_components::action_button::{ActionButton, DangerSecondaryTheme}; const LABEL_FONT_SIZE: f32 = 12.; const INPUT_WIDTH: f32 = 480.; const ENDPOINT_NAME_SCROLL_POSITION_ID: &str = "custom_endpoint_name"; const ENDPOINT_URL_SCROLL_POSITION_ID: &str = "custom_endpoint_url"; const API_KEY_SCROLL_POSITION_ID: &str = "custom_endpoint_api_key"; const ACTIONS_POSITION_ID: &str = "custom_endpoint_actions"; const MODEL_ROW_SPACING: f32 = 16.; const REMOVE_MODEL_BUTTON_SPACING: f32 = 8.; const REMOVE_MODEL_BUTTON_COL_WIDTH: f32 = 20.; const MODAL_SCROLLBAR_WIDTH: f32 = 4.; // Right margin shared by the scrollable content and the (non-scrollable) actions // row. It includes the overlayed scrollbar width so the scrollable content's right // edge (e.g. the remove-model "X" buttons) lines up with the Save/Cancel buttons, // which have no scrollbar. const SCROLL_CONTENT_RIGHT_MARGIN: f32 = 24. + MODAL_SCROLLBAR_WIDTH; const MODEL_INPUT_WIDTH: f32 = (INPUT_WIDTH - MODEL_ROW_SPACING) / 2.; fn model_row_scroll_position_id(index: usize) -> String { format!("custom_endpoint_model_row_{index}") } #[derive(Debug, Clone, PartialEq, Eq)] pub enum CustomEndpointModalEvent { Close, AddEndpoint { name: String, url: String, api_key: String, models: Vec<(String, Option, Option)>, }, SaveEndpoint { index: usize, name: String, url: String, api_key: String, models: Vec<(String, Option, Option)>, }, RemoveEndpoint { index: usize, }, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum CustomEndpointModalAction { Cancel, Save, AddModel, RemoveModel(usize), RemoveEndpoint, } struct ModelRow { name_editor: ViewHandle, alias_editor: ViewHandle, remove_mouse_state: MouseStateHandle, config_key: Option, } pub struct CustomEndpointModal { endpoint_name_editor: ViewHandle, endpoint_url_editor: ViewHandle, api_key_editor: ViewHandle, model_rows: Vec, cancel_button_mouse_state: MouseStateHandle, save_button_mouse_state: MouseStateHandle, add_model_button_mouse_state: MouseStateHandle, remove_endpoint_button: ViewHandle, editing_index: Option, url_has_error: bool, scroll_state: ClippedScrollStateHandle, } impl CustomEndpointModal { pub fn new( endpoint: Option<&CustomEndpoint>, editing_index: Option, ctx: &mut ViewContext, ) -> Self { // Editor text colors are snapshotted at construction via // `text_colors_override`, so refresh them whenever the theme changes. ctx.subscribe_to_model(&Appearance::handle(ctx), |me, _, event, ctx| { if let AppearanceEvent::ThemeChanged = event { me.update_editor_text_colors(ctx); } }); let font_family = Appearance::as_ref(ctx).ui_font_family(); let text_colors = crate::settings_view::editor_text_colors(Appearance::as_ref(ctx)); let endpoint_name_text_colors = text_colors.clone(); let endpoint_name_editor = ctx.add_typed_action_view(move |ctx| { let options = SingleLineEditorOptions { text: TextOptions { font_family_override: Some(font_family), text_colors_override: Some(endpoint_name_text_colors.clone()), ..Default::default() }, propagate_and_no_op_vertical_navigation_keys: PropagateAndNoOpNavigationKeys::Always, ..Default::default() }; let mut editor = EditorView::single_line(options, ctx); editor.set_placeholder_text("e.g., Zach's external models", ctx); if let Some(ep) = endpoint { editor.set_buffer_text(&ep.name, ctx); } editor }); let endpoint_url_text_colors = text_colors.clone(); let endpoint_url_editor = ctx.add_typed_action_view(move |ctx| { let options = SingleLineEditorOptions { text: TextOptions { font_family_override: Some(font_family), text_colors_override: Some(endpoint_url_text_colors.clone()), ..Default::default() }, propagate_and_no_op_vertical_navigation_keys: PropagateAndNoOpNavigationKeys::Always, ..Default::default() }; let mut editor = EditorView::single_line(options, ctx); editor.set_placeholder_text("Please include 'https://'", ctx); if let Some(ep) = endpoint { editor.set_buffer_text(&ep.url, ctx); } editor }); let api_key_text_colors = text_colors.clone(); let api_key_editor = ctx.add_typed_action_view(move |ctx| { let options = SingleLineEditorOptions { is_password: true, text: TextOptions { font_family_override: Some(font_family), text_colors_override: Some(api_key_text_colors.clone()), ..Default::default() }, propagate_and_no_op_vertical_navigation_keys: PropagateAndNoOpNavigationKeys::Always, ..Default::default() }; let mut editor = EditorView::single_line(options, ctx); editor.set_placeholder_text("e.g., sk-...", ctx); if let Some(ep) = endpoint { editor.set_buffer_text(&ep.api_key, ctx); } editor }); let mut model_rows = Vec::new(); if let Some(ep) = endpoint { for model in &ep.models { model_rows.push(Self::create_model_row( Some(&model.name), model.alias.as_deref(), Some(model.config_key.clone()), font_family, &text_colors, ctx, )); } } if model_rows.is_empty() { model_rows.push(Self::create_model_row( None, None, None, font_family, &text_colors, ctx, )); } ctx.subscribe_to_view(&endpoint_name_editor, |me, _, event, ctx| { me.handle_endpoint_name_event(event, ctx); }); ctx.subscribe_to_view(&endpoint_url_editor, |me, _, event, ctx| { me.handle_endpoint_url_event(event, ctx); }); // Validate initial URL (if any) so the error state is accurate on open. let initial_url = endpoint_url_editor.as_ref(ctx).buffer_text(ctx); let url_has_error = !initial_url.trim().is_empty() && validate_url(&initial_url).is_err(); ctx.subscribe_to_view(&api_key_editor, |me, _, event, ctx| { me.handle_api_key_event(event, ctx); }); for row in &model_rows { let name_editor = row.name_editor.clone(); ctx.subscribe_to_view(&name_editor, |me, editor, event, ctx| { me.handle_model_editor_event(&editor, event, ctx); }); let alias_editor = row.alias_editor.clone(); ctx.subscribe_to_view(&alias_editor, |me, editor, event, ctx| { me.handle_model_editor_event(&editor, event, ctx); }); } let remove_endpoint_button = ctx.add_typed_action_view(|_| { ActionButton::new("Remove", DangerSecondaryTheme) .with_icon(Icon::Trash) .on_click(|ctx| { ctx.dispatch_typed_action(CustomEndpointModalAction::RemoveEndpoint); }) }); Self { endpoint_name_editor, endpoint_url_editor, api_key_editor, model_rows, cancel_button_mouse_state: Default::default(), save_button_mouse_state: Default::default(), add_model_button_mouse_state: Default::default(), remove_endpoint_button, editing_index, url_has_error, scroll_state: Default::default(), } } fn create_model_row( name: Option<&str>, alias: Option<&str>, config_key: Option, font_family: FamilyId, text_colors: &crate::editor::TextColors, ctx: &mut ViewContext, ) -> ModelRow { let tc = text_colors.clone(); let name_editor = ctx.add_typed_action_view(move |ctx| { let options = SingleLineEditorOptions { text: TextOptions { font_family_override: Some(font_family), text_colors_override: Some(tc.clone()), ..Default::default() }, propagate_and_no_op_vertical_navigation_keys: PropagateAndNoOpNavigationKeys::Always, ..Default::default() }; let mut editor = EditorView::single_line(options, ctx); editor.set_placeholder_text("e.g., GLM-5-FP8", ctx); if let Some(n) = name { editor.set_buffer_text(n, ctx); } editor }); let tc = text_colors.clone(); let alias_editor = ctx.add_typed_action_view(move |ctx| { let options = SingleLineEditorOptions { text: TextOptions { font_family_override: Some(font_family), text_colors_override: Some(tc.clone()), ..Default::default() }, propagate_and_no_op_vertical_navigation_keys: PropagateAndNoOpNavigationKeys::Always, ..Default::default() }; let mut editor = EditorView::single_line(options, ctx); editor.set_placeholder_text("e.g., GLM-5", ctx); if let Some(a) = alias { editor.set_buffer_text(a, ctx); } editor }); ModelRow { name_editor, alias_editor, remove_mouse_state: Default::default(), config_key, } } pub fn prefill( &mut self, endpoint: Option<&CustomEndpoint>, editing_index: Option, ctx: &mut ViewContext, ) { self.editing_index = editing_index; self.scroll_state = Default::default(); self.endpoint_name_editor.update(ctx, |editor, ctx| { editor.set_buffer_text(endpoint.map(|e| e.name.as_str()).unwrap_or(""), ctx); }); self.endpoint_url_editor.update(ctx, |editor, ctx| { editor.set_buffer_text(endpoint.map(|e| e.url.as_str()).unwrap_or(""), ctx); }); let url = self.endpoint_url_editor.as_ref(ctx).buffer_text(ctx); self.url_has_error = !url.trim().is_empty() && validate_url(&url).is_err(); self.api_key_editor.update(ctx, |editor, ctx| { editor.set_buffer_text(endpoint.map(|e| e.api_key.as_str()).unwrap_or(""), ctx); }); // Rebuild model rows // Old model row editors will be dropped with the modal body self.model_rows.clear(); let font_family = Appearance::as_ref(ctx).ui_font_family(); let text_colors = crate::settings_view::editor_text_colors(Appearance::as_ref(ctx)); if let Some(ep) = endpoint { for model in &ep.models { self.model_rows.push(Self::create_model_row( Some(&model.name), model.alias.as_deref(), Some(model.config_key.clone()), font_family, &text_colors, ctx, )); } } if self.model_rows.is_empty() { self.model_rows.push(Self::create_model_row( None, None, None, font_family, &text_colors, ctx, )); } for row in &self.model_rows { let name_editor = row.name_editor.clone(); ctx.subscribe_to_view(&name_editor, |me, editor, event, ctx| { me.handle_model_editor_event(&editor, event, ctx); }); let alias_editor = row.alias_editor.clone(); ctx.subscribe_to_view(&alias_editor, |me, editor, event, ctx| { me.handle_model_editor_event(&editor, event, ctx); }); } } pub fn on_open(&mut self, ctx: &mut ViewContext) { self.scroll_state.scroll_to(Pixels::zero()); self.focus_editor(&self.endpoint_name_editor, ctx); } pub fn on_close(&mut self, ctx: &mut ViewContext) { self.endpoint_name_editor.update(ctx, |editor, ctx| { editor.clear_buffer_and_reset_undo_stack(ctx); }); self.endpoint_url_editor.update(ctx, |editor, ctx| { editor.clear_buffer_and_reset_undo_stack(ctx); }); self.api_key_editor.update(ctx, |editor, ctx| { editor.clear_buffer_and_reset_undo_stack(ctx); }); for row in &self.model_rows { row.name_editor.update(ctx, |editor, ctx| { editor.clear_buffer_and_reset_undo_stack(ctx); }); row.alias_editor.update(ctx, |editor, ctx| { editor.clear_buffer_and_reset_undo_stack(ctx); }); } } /// Re-applies theme-derived text colors to every editor in the modal. /// Called on appearance changes since editors only snapshot their text /// colors at construction. fn update_editor_text_colors(&mut self, ctx: &mut ViewContext) { let text_colors = crate::settings_view::editor_text_colors(Appearance::as_ref(ctx)); let mut editors = vec![ self.endpoint_name_editor.clone(), self.endpoint_url_editor.clone(), self.api_key_editor.clone(), ]; for row in &self.model_rows { editors.push(row.name_editor.clone()); editors.push(row.alias_editor.clone()); } for editor in editors { let colors = text_colors.clone(); editor.update(ctx, move |editor, ctx| { editor.set_text_colors(colors, ctx); }); } } fn save(&mut self, ctx: &mut ViewContext) { self.validate_url_field(ctx); if !self.is_valid(ctx) { return; } let name = self.endpoint_name_editor.as_ref(ctx).buffer_text(ctx); let url = self.endpoint_url_editor.as_ref(ctx).buffer_text(ctx); let api_key = self.api_key_editor.as_ref(ctx).buffer_text(ctx); let models: Vec<(String, Option, Option)> = self .model_rows .iter() .map(|row| { let name = row.name_editor.as_ref(ctx).buffer_text(ctx); let alias = row.alias_editor.as_ref(ctx).buffer_text(ctx); let alias_opt = if alias.trim().is_empty() { None } else { Some(alias) }; (name, alias_opt, row.config_key.clone()) }) .filter(|(name, _, _)| !name.trim().is_empty()) .collect(); if let Some(index) = self.editing_index { ctx.emit(CustomEndpointModalEvent::SaveEndpoint { index, name, url, api_key, models, }); } else { ctx.emit(CustomEndpointModalEvent::AddEndpoint { name, url, api_key, models, }); } } fn cancel(&mut self, ctx: &mut ViewContext) { ctx.emit(CustomEndpointModalEvent::Close); } fn add_model(&mut self, ctx: &mut ViewContext) { let font_family = Appearance::as_ref(ctx).ui_font_family(); let text_colors = crate::settings_view::editor_text_colors(Appearance::as_ref(ctx)); let row = Self::create_model_row(None, None, None, font_family, &text_colors, ctx); // Subscribe to the new editors let name_editor = row.name_editor.clone(); ctx.subscribe_to_view(&name_editor, |me, editor, event, ctx| { me.handle_model_editor_event(&editor, event, ctx); }); let alias_editor = row.alias_editor.clone(); ctx.subscribe_to_view(&alias_editor, |me, editor, event, ctx| { me.handle_model_editor_event(&editor, event, ctx); }); self.model_rows.push(row); // ClippedScrollable clamps this to its true maximum after laying out the new row. // Until the form overflows, that maximum remains zero and the modal grows naturally. self.scroll_state.scroll_to(Pixels::new(f32::MAX)); ctx.notify(); } fn remove_model(&mut self, index: usize, ctx: &mut ViewContext) { if index < self.model_rows.len() { let _row = self.model_rows.remove(index); ctx.notify(); } } fn is_valid(&self, app: &AppContext) -> bool { let name = self.endpoint_name_editor.as_ref(app).buffer_text(app); let url = self.endpoint_url_editor.as_ref(app).buffer_text(app); let api_key = self.api_key_editor.as_ref(app).buffer_text(app); let has_models = self.model_rows.iter().any(|row| { !row.name_editor .as_ref(app) .buffer_text(app) .trim() .is_empty() }); is_endpoint_form_valid(&name, &url, &api_key, has_models) } fn focus_editor(&self, editor: &ViewHandle, ctx: &mut ViewContext) { ctx.focus(editor); let position_id = if self.endpoint_name_editor == *editor { Some(ENDPOINT_NAME_SCROLL_POSITION_ID.to_string()) } else if self.endpoint_url_editor == *editor { Some(ENDPOINT_URL_SCROLL_POSITION_ID.to_string()) } else if self.api_key_editor == *editor { Some(API_KEY_SCROLL_POSITION_ID.to_string()) } else { self.model_rows .iter() .position(|row| row.name_editor == *editor || row.alias_editor == *editor) .map(model_row_scroll_position_id) }; if let Some(position_id) = position_id { self.scroll_state.scroll_to_position(ScrollTarget { position_id, mode: ScrollToPositionMode::FullyIntoView, }); ctx.notify(); } } fn focus_next_editor(&self, current: &ViewHandle, ctx: &mut ViewContext) { let mut editors: Vec<&ViewHandle> = vec![ &self.endpoint_name_editor, &self.endpoint_url_editor, &self.api_key_editor, ]; for row in &self.model_rows { editors.push(&row.name_editor); editors.push(&row.alias_editor); } if let Some(pos) = editors.iter().position(|e| *e == current) { let next = (pos + 1) % editors.len(); self.focus_editor(editors[next], ctx); } } fn focus_prev_editor(&self, current: &ViewHandle, ctx: &mut ViewContext) { let mut editors: Vec<&ViewHandle> = vec![ &self.endpoint_name_editor, &self.endpoint_url_editor, &self.api_key_editor, ]; for row in &self.model_rows { editors.push(&row.name_editor); editors.push(&row.alias_editor); } if let Some(pos) = editors.iter().position(|e| *e == current) { let prev = if pos == 0 { editors.len() - 1 } else { pos - 1 }; self.focus_editor(editors[prev], ctx); } } fn handle_endpoint_name_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext) { match event { EditorEvent::Navigate(NavigationKey::Tab) => { self.focus_editor(&self.endpoint_url_editor, ctx); } EditorEvent::Navigate(NavigationKey::ShiftTab) => { self.focus_prev_editor(&self.endpoint_name_editor, ctx); } EditorEvent::Enter => { self.focus_editor(&self.endpoint_url_editor, ctx); } EditorEvent::Escape => { self.cancel(ctx); } EditorEvent::Edited(_) => { ctx.notify(); } _ => {} } } fn handle_endpoint_url_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext) { match event { EditorEvent::Navigate(NavigationKey::Tab) => { self.validate_url_field(ctx); self.focus_editor(&self.api_key_editor, ctx); } EditorEvent::Navigate(NavigationKey::ShiftTab) => { self.validate_url_field(ctx); self.focus_editor(&self.endpoint_name_editor, ctx); } EditorEvent::Enter => { self.validate_url_field(ctx); self.focus_editor(&self.api_key_editor, ctx); } EditorEvent::Escape => { self.cancel(ctx); } EditorEvent::Edited(_) if !self.validate_url_field(ctx) => { ctx.notify(); } _ => {} } } fn validate_url_field(&mut self, ctx: &mut ViewContext) -> bool { let url = self.endpoint_url_editor.as_ref(ctx).buffer_text(ctx); let had_error = self.url_has_error; self.url_has_error = !url.trim().is_empty() && validate_url(&url).is_err(); let changed = self.url_has_error != had_error; if changed { ctx.notify(); } changed } fn handle_api_key_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext) { match event { EditorEvent::Navigate(NavigationKey::Tab) => { if let Some(first_row) = self.model_rows.first() { self.focus_editor(&first_row.name_editor, ctx); } } EditorEvent::Navigate(NavigationKey::ShiftTab) => { self.focus_editor(&self.endpoint_url_editor, ctx); } EditorEvent::Enter => { if let Some(first_row) = self.model_rows.first() { self.focus_editor(&first_row.name_editor, ctx); } } EditorEvent::Escape => { self.cancel(ctx); } EditorEvent::Edited(_) => { ctx.notify(); } _ => {} } } fn handle_model_editor_event( &mut self, editor: &ViewHandle, event: &EditorEvent, ctx: &mut ViewContext, ) { match event { EditorEvent::Navigate(NavigationKey::Tab) | EditorEvent::Enter => { self.focus_next_editor(editor, ctx); } EditorEvent::Navigate(NavigationKey::ShiftTab) => { self.focus_prev_editor(editor, ctx); } EditorEvent::Escape => { self.cancel(ctx); } EditorEvent::Edited(_) => { ctx.notify(); } _ => {} } } } impl Entity for CustomEndpointModal { type Event = CustomEndpointModalEvent; } impl View for CustomEndpointModal { fn ui_name() -> &'static str { "CustomEndpointModal" } fn render(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let is_valid = self.is_valid(app); let is_editing = self.editing_index.is_some(); let label_font_family = appearance.ui_font_family(); let label_text_color = theme.active_ui_text_color().into(); let label = move |text: &'static str| { Text::new(text, label_font_family, LABEL_FONT_SIZE) .with_color(label_text_color) .finish() }; let input_style = UiComponentStyles { width: Some(INPUT_WIDTH), ..Default::default() }; let button_style = UiComponentStyles { font_size: Some(14.), padding: Some(Coords::uniform(8.).left(12.).right(12.)), ..Default::default() }; let mut column = Flex::column(); // Description column.add_child( Container::new( Text::new( "Provide your endpoint details below. You can add as many models from the endpoint as you'd like and can also provide aliases for the model picker in your input.", appearance.ui_font_family(), LABEL_FONT_SIZE, ) .with_color(theme.nonactive_ui_text_color().into()) .soft_wrap(true) .finish(), ) .with_margin_bottom(16.) .finish(), ); // Endpoint name column.add_child( Container::new(label("Endpoint name")) .with_margin_bottom(4.) .finish(), ); column.add_child( SavePosition::new( Container::new( appearance .ui_builder() .text_input(self.endpoint_name_editor.clone()) .with_style(input_style) .build() .finish(), ) .with_margin_bottom(16.) .finish(), ENDPOINT_NAME_SCROLL_POSITION_ID, ) .finish(), ); // Endpoint URL column.add_child( Container::new(label("Endpoint URL")) .with_margin_bottom(4.) .finish(), ); let url_border_fill = if self.url_has_error { theme.ui_error_color().into() } else { theme.outline() }; column.add_child( SavePosition::new( Container::new( appearance .ui_builder() .text_input(self.endpoint_url_editor.clone()) .with_style(input_style) .build() .finish(), ) .with_border(Border::all(1.).with_border_fill(url_border_fill)) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) .with_margin_bottom(16.) .finish(), ENDPOINT_URL_SCROLL_POSITION_ID, ) .finish(), ); // API key column.add_child( Container::new(label("API key")) .with_margin_bottom(4.) .finish(), ); column.add_child( SavePosition::new( Container::new( appearance .ui_builder() .text_input(self.api_key_editor.clone()) .with_style(input_style) .build() .finish(), ) .with_margin_bottom(16.) .finish(), API_KEY_SCROLL_POSITION_ID, ) .finish(), ); // Model rows let has_remove_model_button = self.model_rows.len() > 1; let mut model_labels = Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_spacing(REMOVE_MODEL_BUTTON_SPACING) .with_child( Flex::row() .with_spacing(MODEL_ROW_SPACING) .with_child( ConstrainedBox::new(label("Model name")) .with_width(MODEL_INPUT_WIDTH) .finish(), ) .with_child( ConstrainedBox::new(label("Model alias (optional)")) .with_width(MODEL_INPUT_WIDTH) .finish(), ) .finish(), ); if has_remove_model_button { model_labels.add_child( ConstrainedBox::new(Empty::new().finish()) .with_width(REMOVE_MODEL_BUTTON_COL_WIDTH) .finish(), ); } // Model column labels column.add_child( Container::new(model_labels.finish()) .with_margin_bottom(4.) .finish(), ); for (i, row) in self.model_rows.iter().enumerate() { let name_input = appearance .ui_builder() .text_input(row.name_editor.clone()) .with_style(UiComponentStyles { width: Some(MODEL_INPUT_WIDTH), ..Default::default() }) .build() .finish(); let alias_input = appearance .ui_builder() .text_input(row.alias_editor.clone()) .with_style(UiComponentStyles { width: Some(MODEL_INPUT_WIDTH), ..Default::default() }) .build() .finish(); let remove_button = if self.model_rows.len() > 1 { appearance .ui_builder() .close_button(20., row.remove_mouse_state.clone()) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(CustomEndpointModalAction::RemoveModel(i)); }) .finish() } else { Empty::new().finish() }; let model_inputs = Flex::row() .with_spacing(MODEL_ROW_SPACING) .with_child(name_input) .with_child(alias_input) .finish(); let mut row = Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_spacing(REMOVE_MODEL_BUTTON_SPACING) .with_child(model_inputs); if has_remove_model_button { row.add_child( ConstrainedBox::new(remove_button) .with_width(REMOVE_MODEL_BUTTON_COL_WIDTH) .finish(), ); } let row = row.finish(); column.add_child( SavePosition::new( Container::new(row).with_margin_bottom(12.).finish(), &model_row_scroll_position_id(i), ) .finish(), ); } let add_model_button = appearance .ui_builder() .button( ButtonVariant::Secondary, self.add_model_button_mouse_state.clone(), ) .with_text_label("+ Add model".to_string()) .with_style(UiComponentStyles { font_size: Some(14.), padding: Some(Coords::uniform(6.).left(8.).right(8.)), ..Default::default() }) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(CustomEndpointModalAction::AddModel); }) .finish(); column.add_child( Container::new(add_model_button) .with_margin_bottom(24.) .finish(), ); // Bottom buttons row let mut buttons_row = Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_cross_axis_alignment(CrossAxisAlignment::Center); // Remove button (only when editing) if is_editing { buttons_row.add_child(ChildView::new(&self.remove_endpoint_button).finish()); } buttons_row.add_child(Expanded::new(1., Empty::new().finish()).finish()); buttons_row.add_child( appearance .ui_builder() .button( ButtonVariant::Secondary, self.cancel_button_mouse_state.clone(), ) .with_text_label("Cancel".to_string()) .with_style(button_style) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(CustomEndpointModalAction::Cancel); }) .finish(), ); let mut save_button = appearance .ui_builder() .button(ButtonVariant::Accent, self.save_button_mouse_state.clone()) .with_text_label(if is_editing { "Save".to_string() } else { "Add endpoint".to_string() }) .with_style(button_style); if !is_valid { save_button = save_button.disabled(); } buttons_row.add_child( Container::new( save_button .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(CustomEndpointModalAction::Save); }) .finish(), ) .with_margin_left(12.) .finish(), ); let scrollable_content = ClippedScrollable::vertical( self.scroll_state.clone(), Container::new(column.finish()) .with_margin_right(SCROLL_CONTENT_RIGHT_MARGIN) .finish(), ScrollbarWidth::Custom(MODAL_SCROLLBAR_WIDTH), theme.nonactive_ui_text_color().into(), theme.active_ui_text_color().into(), warpui::elements::Fill::None, ) .with_overlayed_scrollbar() .with_padding_start(0.) .with_padding_end(0.) .finish(); let buttons_row = SavePosition::new( Container::new(buttons_row.finish()) .with_margin_right(SCROLL_CONTENT_RIGHT_MARGIN) .finish(), ACTIONS_POSITION_ID, ) .for_single_frame() .finish(); Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_child(Shrinkable::new(1., scrollable_content).finish()) .with_child(buttons_row) .finish() } } fn validate_url(url: &str) -> Result<(), &'static str> { if url.trim().is_empty() { return Ok(()); } let parsed = Url::parse(url).map_err(|_| "Invalid URL")?; if parsed.scheme() != "https" { return Err("URL must use HTTPS"); } let Some(host) = parsed.host_str().filter(|h| !h.is_empty()) else { return Err("URL must include a host"); }; if is_restricted_host(host) { return Err("URL must not use a local or private host"); } Ok(()) } fn is_endpoint_form_valid(name: &str, url: &str, api_key: &str, has_models: bool) -> bool { !name.trim().is_empty() && !url.trim().is_empty() && !api_key.trim().is_empty() && has_models && validate_url(url).is_ok() } fn is_restricted_host(host: &str) -> bool { let host = host .strip_prefix('[') .and_then(|host| host.strip_suffix(']')) .unwrap_or(host); if host.eq_ignore_ascii_case("localhost") { return true; } host.parse::().is_ok_and(is_restricted_ip) } fn is_restricted_ip(ip: IpAddr) -> bool { match ip { IpAddr::V4(ip) => is_restricted_ipv4(ip), IpAddr::V6(ip) => is_restricted_ipv6(ip), } } fn is_restricted_ipv4(ip: Ipv4Addr) -> bool { ip.is_loopback() || ip.is_unspecified() || ip.is_private() || ip.is_link_local() } fn is_restricted_ipv6(ip: Ipv6Addr) -> bool { if ip.is_loopback() || ip.is_unspecified() || is_ipv6_unique_local(ip) || is_ipv6_link_local(ip) { return true; } if let Some(ipv4) = ip.to_ipv4_mapped() { return is_restricted_ipv4(ipv4); } false } fn is_ipv6_unique_local(ip: Ipv6Addr) -> bool { ip.segments()[0] & 0xfe00 == 0xfc00 } fn is_ipv6_link_local(ip: Ipv6Addr) -> bool { ip.segments()[0] & 0xffc0 == 0xfe80 } impl TypedActionView for CustomEndpointModal { type Action = CustomEndpointModalAction; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { match action { CustomEndpointModalAction::Cancel => self.cancel(ctx), CustomEndpointModalAction::Save => self.save(ctx), CustomEndpointModalAction::AddModel => self.add_model(ctx), CustomEndpointModalAction::RemoveModel(index) => self.remove_model(*index, ctx), CustomEndpointModalAction::RemoveEndpoint => { if let Some(index) = self.editing_index { ctx.emit(CustomEndpointModalEvent::RemoveEndpoint { index }); } } } } } #[cfg(test)] #[path = "custom_inference_modal_tests.rs"] mod tests; pub struct CustomEndpointModalViewState { state: ModalViewState>, } impl CustomEndpointModalViewState { pub fn new(state: ModalViewState>) -> Self { Self { state } } pub fn is_open(&self) -> bool { self.state.is_open() } pub fn render(&self) -> Box { self.state.render() } pub fn set_title(&mut self, title: Option, ctx: &mut ViewContext) { self.state.view.update(ctx, |modal, ctx| { modal.set_title(title); ctx.notify(); }); } pub fn prefill( &mut self, endpoint: Option<&CustomEndpoint>, editing_index: Option, ctx: &mut ViewContext, ) { self.state.view.update(ctx, |modal, ctx| { modal.body().update(ctx, |body, ctx| { body.prefill(endpoint, editing_index, ctx); }); }); } pub fn open(&mut self, ctx: &mut ViewContext) { self.state.open(); self.state.view.update(ctx, |modal, ctx| { modal.body().update(ctx, |body, ctx| { body.on_open(ctx); }); }); } pub fn close(&mut self, ctx: &mut ViewContext) { self.state.close(); self.state.view.update(ctx, |modal, ctx| { modal.body().update(ctx, |body, ctx| { body.on_close(ctx); }); }); } }