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

1131 lines
40 KiB
Rust

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<String>, Option<String>)>,
},
SaveEndpoint {
index: usize,
name: String,
url: String,
api_key: String,
models: Vec<(String, Option<String>, Option<String>)>,
},
RemoveEndpoint {
index: usize,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CustomEndpointModalAction {
Cancel,
Save,
AddModel,
RemoveModel(usize),
RemoveEndpoint,
}
struct ModelRow {
name_editor: ViewHandle<EditorView>,
alias_editor: ViewHandle<EditorView>,
remove_mouse_state: MouseStateHandle,
config_key: Option<String>,
}
pub struct CustomEndpointModal {
endpoint_name_editor: ViewHandle<EditorView>,
endpoint_url_editor: ViewHandle<EditorView>,
api_key_editor: ViewHandle<EditorView>,
model_rows: Vec<ModelRow>,
cancel_button_mouse_state: MouseStateHandle,
save_button_mouse_state: MouseStateHandle,
add_model_button_mouse_state: MouseStateHandle,
remove_endpoint_button: ViewHandle<ActionButton>,
editing_index: Option<usize>,
url_has_error: bool,
scroll_state: ClippedScrollStateHandle,
}
impl CustomEndpointModal {
pub fn new(
endpoint: Option<&CustomEndpoint>,
editing_index: Option<usize>,
ctx: &mut ViewContext<Self>,
) -> 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<String>,
font_family: FamilyId,
text_colors: &crate::editor::TextColors,
ctx: &mut ViewContext<Self>,
) -> 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<usize>,
ctx: &mut ViewContext<Self>,
) {
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>) {
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>) {
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<Self>) {
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>) {
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<String>, Option<String>)> = 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<Self>) {
ctx.emit(CustomEndpointModalEvent::Close);
}
fn add_model(&mut self, ctx: &mut ViewContext<Self>) {
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<Self>) {
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<EditorView>, ctx: &mut ViewContext<Self>) {
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<EditorView>, ctx: &mut ViewContext<Self>) {
let mut editors: Vec<&ViewHandle<EditorView>> = 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<EditorView>, ctx: &mut ViewContext<Self>) {
let mut editors: Vec<&ViewHandle<EditorView>> = 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<Self>) {
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<Self>) {
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<Self>) -> 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<Self>) {
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<EditorView>,
event: &EditorEvent,
ctx: &mut ViewContext<Self>,
) {
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<dyn Element> {
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::<IpAddr>().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<Self>) {
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<Modal<CustomEndpointModal>>,
}
impl CustomEndpointModalViewState {
pub fn new(state: ModalViewState<Modal<CustomEndpointModal>>) -> Self {
Self { state }
}
pub fn is_open(&self) -> bool {
self.state.is_open()
}
pub fn render(&self) -> Box<dyn Element> {
self.state.render()
}
pub fn set_title<T: View>(&mut self, title: Option<String>, ctx: &mut ViewContext<T>) {
self.state.view.update(ctx, |modal, ctx| {
modal.set_title(title);
ctx.notify();
});
}
pub fn prefill<T: View>(
&mut self,
endpoint: Option<&CustomEndpoint>,
editing_index: Option<usize>,
ctx: &mut ViewContext<T>,
) {
self.state.view.update(ctx, |modal, ctx| {
modal.body().update(ctx, |body, ctx| {
body.prefill(endpoint, editing_index, ctx);
});
});
}
pub fn open<T: View>(&mut self, ctx: &mut ViewContext<T>) {
self.state.open();
self.state.view.update(ctx, |modal, ctx| {
modal.body().update(ctx, |body, ctx| {
body.on_open(ctx);
});
});
}
pub fn close<T: View>(&mut self, ctx: &mut ViewContext<T>) {
self.state.close();
self.state.view.update(ctx, |modal, ctx| {
modal.body().update(ctx, |body, ctx| {
body.on_close(ctx);
});
});
}
}