Add Rig native model providers

This commit is contained in:
2026-08-06 15:03:00 -05:00
parent 634ce7ba00
commit 3fda5d414b
34 changed files with 2134 additions and 452 deletions
@@ -684,9 +684,11 @@ impl ShellCommandExecutor {
.force_refresh_senders
.keys()
.find(|selector| {
selector
.get_block(&terminal_model)
.is_some_and(|block| block.id() == block_id)
selector.get_block(&terminal_model).is_some_and(|block| {
block.id() == block_id
&& block.is_active_and_long_running()
&& !block.finished()
})
})
.cloned();
drop(terminal_model);
@@ -103,11 +103,14 @@ fn force_refresh_block_reports_and_resolves_matching_poll() {
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
});
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
terminal_model
.lock()
.simulate_long_running_block("sleep 120", "still running");
let block_id = terminal_model.lock().active_block_id().clone();
let executor = app.add_model(|ctx| {
ShellCommandExecutor::new(
active_session,
terminal_model,
terminal_model.clone(),
&model_event_dispatcher,
terminal_view_id,
ctx,
@@ -124,6 +127,17 @@ fn force_refresh_block_reports_and_resolves_matching_poll() {
});
assert!(matches!(rx.try_recv(), Ok(Some(()))));
let (tx, _rx) = oneshot::channel();
executor.update(&mut app, |executor, _| {
executor
.force_refresh_senders
.insert(BlockSelector::Id(block_id.clone()), tx);
});
terminal_model.lock().finish_block();
assert!(executor.update(&mut app, |executor, _| {
!executor.force_refresh_block(&block_id)
}));
});
}
+119 -24
View File
@@ -265,6 +265,8 @@ impl CLISubagentController {
let block_id = block.id().clone();
let conversation_id = block.ai_conversation_id();
let requested_command_action_id = block.requested_command_action_id().cloned();
let should_skip_completion_assessment =
!should_request_completion_assessment(block.long_running_control_state());
let completion = match (&block_completed_event.block_type, conversation_id) {
(BlockType::User(completed), Some(conversation_id)) => {
let command = if completed.command_with_obfuscated_secrets.is_empty() {
@@ -310,17 +312,49 @@ impl CLISubagentController {
};
drop(terminal_model);
let Some(subagent_state) = me.active_subagents_by_block.get_mut(&block_id) else {
let Some(has_last_snapshot) = me
.active_subagents_by_block
.get(&block_id)
.map(|state| state.last_snapshot_at.is_some())
else {
return;
};
if subagent_state.last_snapshot_at.is_some() {
if has_last_snapshot {
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
}
subagent_state.completion = completion;
if subagent_state.completion.is_none() {
// A Stop takeover intentionally cancels the subagent. The command may still
// finish later, but that completion must not start a new assessment turn. Also
// clean up the in-memory monitor state so the stopped subagent cannot linger in
// the UI or intercept later refreshes.
if should_skip_completion_assessment {
me.finish_subagent(
&block_id,
conversation_id,
requested_command_action_id,
ctx,
);
return;
}
let has_completion = {
let Some(subagent_state) = me.active_subagents_by_block.get_mut(&block_id)
else {
return;
};
subagent_state.completion = completion;
subagent_state.completion.is_some()
};
if !has_completion {
log::warn!(
"CLI monitor block {block_id:?} completed without final command metadata"
);
me.finish_subagent(
&block_id,
conversation_id,
requested_command_action_id,
ctx,
);
return;
}
me.advance_completed_subagent(&block_id, ctx);
@@ -380,7 +414,12 @@ impl CLISubagentController {
}
if completion.final_turn_started {
self.finish_completed_subagent(block_id, ctx);
self.finish_subagent(
block_id,
Some(completion.conversation_id),
completion.initial_requested_command_action_id,
ctx,
);
return;
}
@@ -404,38 +443,55 @@ impl CLISubagentController {
}
}
fn finish_completed_subagent(&mut self, block_id: &BlockId, ctx: &mut ModelContext<Self>) {
fn finish_subagent(
&mut self,
block_id: &BlockId,
conversation_id: Option<AIConversationId>,
initial_requested_command_action_id: Option<AIAgentActionId>,
ctx: &mut ModelContext<Self>,
) {
let Some(state) = self.active_subagents_by_block.remove(block_id) else {
return;
};
let Some(completion) = state.completion else {
return;
};
let conversation_id = conversation_id.or_else(|| {
state
.completion
.as_ref()
.map(|completion| completion.conversation_id)
});
let initial_requested_command_action_id = initial_requested_command_action_id
.or_else(|| {
state
.completion
.as_ref()
.and_then(|completion| completion.initial_requested_command_action_id.clone())
})
.or(state.initial_requested_command_action_id);
let deactivate_result =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| {
history_model.deactivate_cli_subagent_task_for_conversation(
block_id,
completion.conversation_id,
)
});
if let Err(error) = deactivate_result {
log::error!(
"Failed to deactivate completed CLI monitor for block {block_id:?}: {error:?}"
);
if let Some(conversation_id) = conversation_id {
let deactivate_result =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| {
history_model
.deactivate_cli_subagent_task_for_conversation(block_id, conversation_id)
});
if let Err(error) = deactivate_result {
log::error!("Failed to deactivate CLI monitor for block {block_id:?}: {error:?}");
}
}
ctx.emit(CLISubagentEvent::FinishedSubagent {
block_id: block_id.clone(),
conversation_id: Some(completion.conversation_id),
initial_requested_command_action_id: completion.initial_requested_command_action_id,
conversation_id,
initial_requested_command_action_id,
});
if let Some(agent_view_controller) = &self.agent_view_controller {
if let (Some(agent_view_controller), Some(conversation_id)) =
(&self.agent_view_controller, conversation_id)
{
agent_view_controller.update(ctx, |controller, ctx| {
let is_this_inline_conversation = controller.is_inline()
&& controller.agent_view_state().active_conversation_id()
== Some(completion.conversation_id);
== Some(conversation_id);
if is_this_inline_conversation {
controller.exit_agent_view(ctx);
}
@@ -919,3 +975,42 @@ fn command_finished_block_id(result: &AIAgentActionResultType) -> Option<&BlockI
| AIAgentActionResultType::WaitForEvents(_) => None,
}
}
fn should_request_completion_assessment(
control_state: Option<&LongRunningCommandControlState>,
) -> bool {
!control_state
.and_then(LongRunningCommandControlState::user_take_over_reason)
.is_some_and(UserTakeOverReason::is_stop)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stop_takeover_does_not_request_a_completion_assessment() {
let state = LongRunningCommandControlState::User {
reason: UserTakeOverReason::Stop,
};
assert!(!should_request_completion_assessment(Some(&state)));
}
#[test]
fn non_stop_control_states_can_request_a_completion_assessment() {
let agent_state = LongRunningCommandControlState::Agent {
is_blocked: false,
should_hide_responses: false,
};
let transfer_state = LongRunningCommandControlState::User {
reason: UserTakeOverReason::TransferFromAgent {
reason: "needs user input".to_owned(),
},
};
assert!(should_request_completion_assessment(None));
assert!(should_request_completion_assessment(Some(&agent_state)));
assert!(should_request_completion_assessment(Some(&transfer_state)));
}
}
@@ -230,6 +230,8 @@ impl ResponseStream {
kind: client_config.kind,
base_url: client_config.base_url.clone(),
api_key: client_config.api_key.clone(),
project_id: client_config.project_id.clone(),
location: client_config.location.clone(),
model: client_config
.model
.clone()
+2
View File
@@ -167,6 +167,8 @@ impl CrosscheckReviewer {
kind: client_config.kind,
base_url: client_config.base_url.clone(),
api_key: client_config.api_key.clone(),
project_id: client_config.project_id.clone(),
location: client_config.location.clone(),
model: client_config
.model
.clone()
+105 -8
View File
@@ -5,6 +5,11 @@ use std::sync::{Arc, OnceLock};
use ai::api_keys::ApiKeyManager;
pub use ai::LLMId;
#[cfg(not(target_family = "wasm"))]
use galaxy_agent_rig::{
discover_anthropic_models, discover_gemini_models, validate_vertex_ai_credentials,
vertex_ai_model_catalog, RigModelInfo,
};
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::icons::Icon;
use galaxy_core::user_preferences::GetUserPreferences;
@@ -1005,6 +1010,8 @@ impl LLMPreferences {
bool,
String,
Option<String>,
Option<String>,
Option<String>,
Vec<OpenAIModelConfig>,
);
let mut provider_entries: Vec<OpenAIProviderEntry> = Vec::new();
@@ -1037,6 +1044,8 @@ impl LLMPreferences {
true,
base_url,
api_key,
None,
None,
single_provider_models,
));
}
@@ -1047,11 +1056,19 @@ impl LLMPreferences {
.value()
.iter()
.filter_map(|provider| {
if !provider.enabled
|| (provider.kind == OpenAIProviderKind::OpenAICompatible
&& provider.base_url.trim().is_empty())
|| provider.models.is_empty()
{
let missing_credentials = match provider.kind {
OpenAIProviderKind::OpenAICompatible => provider.base_url.trim().is_empty(),
OpenAIProviderKind::Anthropic | OpenAIProviderKind::Gemini => provider
.api_key
.as_deref()
.is_none_or(|key| key.trim().is_empty()),
OpenAIProviderKind::VertexAI => provider
.project_id
.as_deref()
.is_none_or(|project| project.trim().is_empty()),
OpenAIProviderKind::ChatGPTSubscription => false,
};
if !provider.enabled || missing_credentials || provider.models.is_empty() {
return None;
}
Some((
@@ -1060,6 +1077,8 @@ impl LLMPreferences {
provider.enabled,
provider.base_url.clone(),
provider.api_key.clone(),
provider.project_id.clone(),
provider.location.clone(),
provider.models.clone(),
))
}),
@@ -1071,8 +1090,16 @@ impl LLMPreferences {
let mut total_injected = 0;
let mut seen_model_ids: HashSet<String> = HashSet::new();
for (provider_name, provider_kind, provider_enabled, base_url, api_key, models) in
provider_entries
for (
provider_name,
provider_kind,
provider_enabled,
base_url,
api_key,
provider_project_id,
provider_location,
models,
) in provider_entries
{
if !provider_enabled {
continue;
@@ -1109,12 +1136,14 @@ impl LLMPreferences {
kind: provider_kind,
base_url: base_url.clone(),
api_key: api_key.clone(),
project_id: provider_project_id.clone(),
location: provider_location.clone(),
model: Some(model.model_id.clone()),
reasoning_effort: reasoning_effort.clone(),
max_input_tokens: Some(openai_model_context_size(model)),
max_output_tokens: model.max_output_tokens,
use_rig: model.use_rig
|| provider_kind == OpenAIProviderKind::ChatGPTSubscription,
|| !matches!(provider_kind, OpenAIProviderKind::OpenAICompatible),
supports_system_messages: model.supports_system_messages(),
};
self.openai_provider_routing
@@ -1557,6 +1586,54 @@ impl LLMPreferences {
pub(crate) async fn discover_openai_provider_models(
provider: OpenAIProviderConfig,
) -> Result<Vec<OpenAIModelConfig>, String> {
let native_models = match provider.kind {
OpenAIProviderKind::Anthropic => {
let api_key = provider
.api_key
.as_deref()
.filter(|key| !key.trim().is_empty())
.ok_or_else(|| {
"Enter an Anthropic API key before testing the connection.".to_string()
})?;
Some(discover_anthropic_models(api_key).await?)
}
OpenAIProviderKind::Gemini => {
let api_key = provider
.api_key
.as_deref()
.filter(|key| !key.trim().is_empty())
.ok_or_else(|| {
"Enter a Gemini API key before testing the connection.".to_string()
})?;
Some(discover_gemini_models(api_key).await?)
}
OpenAIProviderKind::VertexAI => {
if provider
.project_id
.as_deref()
.is_none_or(|project| project.trim().is_empty())
{
return Err(
"Enter a Google Cloud project ID before testing the connection."
.to_string(),
);
}
validate_vertex_ai_credentials(
provider.project_id.as_deref().unwrap_or_default(),
provider.location.as_deref().unwrap_or("global"),
)?;
Some(vertex_ai_model_catalog())
}
OpenAIProviderKind::OpenAICompatible | OpenAIProviderKind::ChatGPTSubscription => None,
};
if let Some(models) = native_models {
if models.is_empty() {
return Err("The provider responded, but no models were found.".to_string());
}
return Ok(Self::rig_models_to_openai_models(models));
}
if provider.base_url.trim().is_empty() {
return Err("Enter a provider URL before testing the connection.".to_string());
}
@@ -1586,6 +1663,26 @@ impl LLMPreferences {
Ok(models)
}
#[cfg(not(target_family = "wasm"))]
fn rig_models_to_openai_models(models: Vec<RigModelInfo>) -> Vec<OpenAIModelConfig> {
models
.into_iter()
.map(|model| OpenAIModelConfig {
model_id: model.id,
display_name: model.display_name,
vision_supported: false,
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),
reasoning_efforts: Vec::new(),
enabled: true,
})
.collect()
}
/// Returns the `LLMInfo` for the base LLM to be used for an Agent Mode request.
pub fn get_active_base_model<'a>(
&'a self,
+2
View File
@@ -11,6 +11,8 @@ pub struct OpenAIClientConfig {
pub kind: OpenAIProviderKind,
pub base_url: String,
pub api_key: Option<String>,
pub project_id: Option<String>,
pub location: Option<String>,
pub model: Option<String>,
pub reasoning_effort: Option<String>,
pub max_input_tokens: Option<u32>,
+49 -2
View File
@@ -8,8 +8,9 @@ use galaxy_agent_core::{
ToolCallDecision, ToolEvent, ToolPolicy, ToolResult, TurnCommand,
};
use galaxy_agent_rig::{
ChatGPTSubscriptionRuntime, ChatGPTSubscriptionRuntimeConfig, OpenAICompatibleRuntime,
OpenAICompatibleRuntimeConfig,
AnthropicRuntime, AnthropicRuntimeConfig, ChatGPTSubscriptionRuntime,
ChatGPTSubscriptionRuntimeConfig, GeminiRuntime, GeminiRuntimeConfig, OpenAICompatibleRuntime,
OpenAICompatibleRuntimeConfig, VertexAiRuntime, VertexAiRuntimeConfig,
};
use uuid::Uuid;
use warp_multi_agent_api::ToolType;
@@ -73,6 +74,52 @@ pub(crate) fn rig_openai_response_stream(
cancellation_rx,
)
}
OpenAIProviderKind::Anthropic => {
let runtime = AnthropicRuntime::new(AnthropicRuntimeConfig {
api_key: config.api_key.unwrap_or_default(),
model: model_id,
max_output_tokens: config.max_output_tokens.map(u64::from),
});
rig_response_stream(
runtime,
prepared,
skill_path_origin,
config.max_input_tokens,
"rig_anthropic",
cancellation_rx,
)
}
OpenAIProviderKind::Gemini => {
let runtime = GeminiRuntime::new(GeminiRuntimeConfig {
api_key: config.api_key.unwrap_or_default(),
model: model_id,
max_output_tokens: config.max_output_tokens.map(u64::from),
});
rig_response_stream(
runtime,
prepared,
skill_path_origin,
config.max_input_tokens,
"rig_gemini",
cancellation_rx,
)
}
OpenAIProviderKind::VertexAI => {
let runtime = VertexAiRuntime::new(VertexAiRuntimeConfig {
project_id: config.project_id.unwrap_or_default(),
location: config.location.unwrap_or_else(|| "global".to_string()),
model: model_id,
max_output_tokens: config.max_output_tokens.map(u64::from),
});
rig_response_stream(
runtime,
prepared,
skill_path_origin,
config.max_input_tokens,
"rig_vertex_ai",
cancellation_rx,
)
}
}
}
+2
View File
@@ -23,6 +23,8 @@ fn config() -> OpenAIClientConfig {
kind: crate::settings::OpenAIProviderKind::OpenAICompatible,
base_url: "http://localhost:4000/v1".to_string(),
api_key: None,
project_id: None,
location: None,
model: Some("provider-model".to_string()),
reasoning_effort: None,
max_input_tokens: Some(128_000),
+1 -21
View File
@@ -1,32 +1,12 @@
use std::env::current_dir;
use galaxy_core::ui::appearance::Appearance;
use galaxyui::App;
use super::expand_dirs;
use crate::cloud_object::model::persistence::CloudModel;
use crate::network::NetworkStatus;
use crate::server::cloud_objects::update_manager::UpdateManager;
use crate::server::sync_queue::SyncQueue;
use crate::workspaces::team_tester::TeamTesterStatus;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::{GlobalResourceHandles, GlobalResourceHandlesProvider};
#[test]
fn test_expand_directories() {
App::test((), |mut app| async move {
app.update(crate::settings::init_and_register_user_preferences);
let global_resource_handles = GlobalResourceHandles::mock(&mut app);
app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resource_handles));
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(UserWorkspaces::default_mock);
app.add_singleton_model(|_| Appearance::mock());
app.add_singleton_model(|_| NetworkStatus::new());
app.add_singleton_model(SyncQueue::mock);
app.add_singleton_model(TeamTesterStatus::mock);
app.add_singleton_model(UpdateManager::mock);
App::test((), |_| async move {
let directory = current_dir()
.expect("current directory should exist")
.parent()
+11 -5
View File
@@ -18,6 +18,7 @@ use super::modal_body::{ImportModalBody, ImportModalBodyAction, ImportModalBodyE
use crate::appearance::Appearance;
use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::{CloudObject, Owner};
use crate::local_object_repository::local_owner;
use crate::server::ids::SyncId;
use crate::server::sync_queue::SyncQueue;
use crate::themes::theme::GalaxyTheme;
@@ -88,7 +89,8 @@ impl ImportModal {
let window_id = ctx.window_id();
let import_body_id = self.import_modal.id();
let sync_queue_is_dequeueing = SyncQueue::as_ref(ctx).is_dequeueing();
let sync_queue_is_dequeueing =
self.owner != Some(local_owner()) && SyncQueue::as_ref(ctx).is_dequeueing();
let allowed_file_types = vec![FileType::Yaml, FileType::Markdown];
@@ -98,7 +100,7 @@ impl ImportModal {
// Files under a folder could only be uploaded when the folder is created on the server.
// When sync queue is not dequeueing, disable folder upload in the import modal.
if sync_queue_is_dequeueing {
if sync_queue_is_dequeueing || self.owner == Some(local_owner()) {
file_picker_config = file_picker_config.allow_folder();
}
@@ -165,9 +167,13 @@ impl ImportModal {
// Convert to a Space for display, in case we're importing into a shared folder.
self.owner
.map(|owner| {
UserWorkspaces::as_ref(app)
.owner_to_space(owner, app)
.name(app)
if owner == local_owner() {
"Personal".to_string()
} else {
UserWorkspaces::as_ref(app)
.owner_to_space(owner, app)
.name(app)
}
})
.unwrap_or_default(),
0,
+8 -4
View File
@@ -21,6 +21,7 @@ use super::nodes::{
use super::queue::{ImportQueue, ImportQueueArgs, ImportQueueEvent, ParentId, RequestContent};
use crate::appearance::Appearance;
use crate::cloud_object::Owner;
use crate::local_object_repository::local_owner;
use crate::server::ids::{ClientId, SyncId};
use crate::server::sync_queue::SyncQueue;
use crate::ui_components::icons::Icon;
@@ -96,7 +97,7 @@ pub struct ImportModalBody {
impl ImportModalBody {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let import_queue = ctx.add_model(ImportQueue::new);
let import_queue = ctx.add_model(|_| ImportQueue::new());
ctx.subscribe_to_model(&import_queue, |me, _, event, ctx| {
me.handle_import_queue_event(event, ctx)
});
@@ -152,7 +153,8 @@ impl ImportModalBody {
}
}
let sync_queue_dequeueing = SyncQueue::as_ref(ctx).is_dequeueing();
let sync_queue_dequeueing =
self.owner != Some(local_owner()) && SyncQueue::as_ref(ctx).is_dequeueing();
if !sync_queue_dequeueing && state.all_files_saved_locally() {
ctx.emit(ImportModalBodyEvent::AllFileSavedLocally);
@@ -177,7 +179,8 @@ impl ImportModalBody {
// Whether there is an active upload in progress (If all uploads are completed,
// we don't consider the import modal upload to be in progress).
pub fn upload_in_progress(&self, app: &AppContext) -> bool {
let sync_queue_dequeueing = SyncQueue::as_ref(app).is_dequeueing();
let sync_queue_dequeueing =
self.owner != Some(local_owner()) && SyncQueue::as_ref(app).is_dequeueing();
match &self.state {
ImportState::Upload => false,
@@ -498,7 +501,8 @@ impl View for ImportModalBody {
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let sync_queue_dequeueing = SyncQueue::as_ref(app).is_dequeueing();
let sync_queue_dequeueing =
self.owner != Some(local_owner()) && SyncQueue::as_ref(app).is_dequeueing();
let appearance = Appearance::as_ref(app);
match &self.state {
+165 -77
View File
@@ -5,7 +5,7 @@ use galaxyui::{Entity, ModelContext, SingletonEntity};
use super::nodes::{self, FileId};
use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::{CloudObjectEventEntrypoint, Owner};
use crate::drive::folders::FolderId;
use crate::local_object_repository::{local_owner, LocalObjectRepository};
use crate::notebooks::CloudNotebookModel;
use crate::server::cloud_objects::update_manager::{
InitiatedBy, ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
@@ -94,31 +94,40 @@ impl FileCompletionCounter {
pub(super) struct ImportQueue {
queue: Vec<ImportQueueArgs>,
client_to_server_id: HashMap<ClientId, Option<FolderId>>,
client_to_folder_id: HashMap<ClientId, Option<SyncId>>,
client_to_node_folder_id: HashMap<ClientId, nodes::FolderId>,
file_completion: FileCompletionCounter,
remote_subscription_initialized: bool,
}
impl ImportQueue {
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
pub fn new() -> Self {
Self {
queue: Vec::new(),
client_to_folder_id: HashMap::default(),
file_completion: Default::default(),
client_to_node_folder_id: HashMap::default(),
remote_subscription_initialized: false,
}
}
fn ensure_remote_subscription(&mut self, ctx: &mut ModelContext<Self>) {
if self.remote_subscription_initialized {
return;
}
let update_manager = UpdateManager::handle(ctx);
ctx.subscribe_to_model(&update_manager, |me, _, event, ctx| {
me.handle_update_manager_event(event, ctx);
});
Self {
queue: Vec::new(),
client_to_server_id: HashMap::default(),
file_completion: Default::default(),
client_to_node_folder_id: HashMap::default(),
}
self.remote_subscription_initialized = true;
}
// Whether all dependencies of an item has been sync-ed.
fn dependency_synced(&self, item: &ImportQueueArgs) -> bool {
match &item.parent_id {
ParentId::FolderToUpload(id) => self
.client_to_server_id
.client_to_folder_id
.get(id)
.map(|item| item.is_some())
.unwrap_or(false),
@@ -128,6 +137,11 @@ impl ImportQueue {
// Enqueue a new request to the import queue.
pub fn enqueue(&mut self, arg: ImportQueueArgs, ctx: &mut ModelContext<Self>) {
let is_local = arg.owner == local_owner();
if !is_local {
self.ensure_remote_subscription(ctx);
}
// Update internal tracker of the object.
match &arg.content {
RequestContent::Folder {
@@ -135,17 +149,23 @@ impl ImportQueue {
folder_id,
..
} => {
self.client_to_server_id.insert(*client_id, None);
self.client_to_folder_id.insert(*client_id, None);
self.client_to_node_folder_id.insert(*client_id, *folder_id);
}
RequestContent::Notebook {
client_id, file_id, ..
} => self.file_completion.add_entry(*client_id, *file_id),
} => {
if !is_local {
self.file_completion.add_entry(*client_id, *file_id);
}
}
RequestContent::Workflow {
workflows, file_id, ..
} => {
for (_, client_id) in workflows {
self.file_completion.add_entry(*client_id, *file_id);
if !is_local {
for (_, client_id) in workflows {
self.file_completion.add_entry(*client_id, *file_id);
}
}
}
}
@@ -167,31 +187,47 @@ impl ImportQueue {
{
let dequeued_item = self.queue.remove(idx);
let parent_id = match dequeued_item.parent_id {
ParentId::FolderToUpload(client_id) => Some(SyncId::ServerId(
self.client_to_server_id
ParentId::FolderToUpload(client_id) => Some(
self.client_to_folder_id
.get(&client_id)
.expect("Client id entry should exist")
.expect("Server id entry should exist")
.into(),
)),
.expect("Folder id entry should exist"),
),
ParentId::InitialFolder(folder_id) => folder_id,
};
let is_local = dequeued_item.owner == local_owner();
match dequeued_item.content {
RequestContent::Folder {
name, client_id, ..
} => {
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
update_manager.create_folder(
name,
dequeued_item.owner,
client_id,
parent_id,
false,
InitiatedBy::User,
ctx,
);
});
if is_local {
let local_id = SyncId::ClientId(client_id);
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.create_folder_with_id(local_id, name, parent_id, ctx);
});
self.client_to_folder_id.insert(client_id, Some(local_id));
ctx.emit(ImportQueueEvent::FolderCompleted {
folder_id: self
.client_to_node_folder_id
.get(&client_id)
.copied()
.expect("Folder node id should exist"),
server_id: Some(local_id.uid()),
});
} else {
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
update_manager.create_folder(
name,
dequeued_item.owner,
client_id,
parent_id,
false,
InitiatedBy::User,
ctx,
);
});
}
}
RequestContent::Notebook {
title,
@@ -199,56 +235,104 @@ impl ImportQueue {
client_id,
file_id,
} => {
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
update_manager.create_notebook(
client_id,
dequeued_item.owner,
parent_id,
CloudNotebookModel {
title,
data,
ai_document_id: None,
conversation_id: None,
},
CloudObjectEventEntrypoint::ImportModal,
false,
ctx,
);
});
ctx.emit(ImportQueueEvent::FileSavedLocally(file_id));
if is_local {
let local_id = SyncId::ClientId(client_id);
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.create_notebook_with_id(
local_id,
parent_id,
CloudNotebookModel {
title,
data,
ai_document_id: None,
conversation_id: None,
},
ctx,
);
});
ctx.emit(ImportQueueEvent::FileCompleted {
file_id,
server_id: Some(local_id.uid()),
});
} else {
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
update_manager.create_notebook(
client_id,
dequeued_item.owner,
parent_id,
CloudNotebookModel {
title,
data,
ai_document_id: None,
conversation_id: None,
},
CloudObjectEventEntrypoint::ImportModal,
false,
ctx,
);
});
ctx.emit(ImportQueueEvent::FileSavedLocally(file_id));
}
}
RequestContent::Workflow {
workflows,
workflow_enums,
file_id,
} => {
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
// Create any new workflow enums
for (client_id, workflow_enum) in workflow_enums {
update_manager.create_workflow_enum(
workflow_enum,
dequeued_item.owner,
client_id,
CloudObjectEventEntrypoint::ImportModal,
false,
ctx,
);
}
if is_local {
let local_object_id = workflows
.first()
.map(|(_, client_id)| SyncId::ClientId(*client_id).uid());
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
for (client_id, workflow_enum) in workflow_enums {
repository.create_workflow_enum_with_id(
SyncId::ClientId(client_id),
workflow_enum,
ctx,
);
}
for (workflow, client_id) in workflows {
repository.create_workflow_with_id(
SyncId::ClientId(client_id),
parent_id,
workflow,
ctx,
);
}
});
ctx.emit(ImportQueueEvent::FileCompleted {
file_id,
server_id: local_object_id,
});
} else {
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
// Create any new workflow enums
for (client_id, workflow_enum) in workflow_enums {
update_manager.create_workflow_enum(
workflow_enum,
dequeued_item.owner,
client_id,
CloudObjectEventEntrypoint::ImportModal,
false,
ctx,
);
}
// Create the workflow
for (workflow, client_id) in workflows {
update_manager.create_workflow(
workflow,
dequeued_item.owner,
parent_id,
client_id,
CloudObjectEventEntrypoint::ImportModal,
false,
ctx,
);
}
});
ctx.emit(ImportQueueEvent::FileSavedLocally(file_id));
// Create the workflow
for (workflow, client_id) in workflows {
update_manager.create_workflow(
workflow,
dequeued_item.owner,
parent_id,
client_id,
CloudObjectEventEntrypoint::ImportModal,
false,
ctx,
);
}
});
ctx.emit(ImportQueueEvent::FileSavedLocally(file_id));
}
}
}
self.dequeue(ctx);
@@ -294,14 +378,14 @@ impl ImportQueue {
let Some(folder_id) = cloud_model
.get_folder_by_uid(&result.server_id.expect("Expect id").uid())
.and_then(|folder| folder.id.into_server())
.map(|folder| folder.id)
else {
return;
};
let replaced = match self.client_to_server_id.get_mut(&client_id) {
let replaced = match self.client_to_folder_id.get_mut(&client_id) {
Some(value) if value.is_none() => {
*value = Some(folder_id.into());
*value = Some(folder_id);
true
}
_ => false,
@@ -323,3 +407,7 @@ impl ImportQueue {
impl Entity for ImportQueue {
type Event = ImportQueueEvent;
}
#[cfg(test)]
#[path = "queue_tests.rs"]
mod tests;
+193
View File
@@ -0,0 +1,193 @@
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use galaxyui::{App, SingletonEntity};
use super::*;
use crate::cloud_object::model::persistence::CloudModel;
use crate::local_object_repository::local_owner;
use crate::server::ids::ClientId;
use crate::workflows::workflow_enum::{EnumVariants, WorkflowEnum};
#[derive(Debug, PartialEq, Eq)]
enum EventKind {
Folder {
folder_id: nodes::FolderId,
object_id: Option<String>,
},
File {
file_id: FileId,
object_id: Option<String>,
},
FileSavedLocally(FileId),
}
#[test]
fn local_import_queue_persists_nested_content_and_reports_completion() {
App::test((), |mut app| async move {
app.add_singleton_model(|_| CloudModel::new(None, Vec::new(), None));
app.add_singleton_model(|ctx| {
crate::local_object_repository::LocalObjectRepository::new(None, None, ctx)
});
let queue = app.add_model(|_| ImportQueue::new());
let events = Rc::new(RefCell::new(Vec::new()));
let events_for_subscription = events.clone();
app.update(|ctx| {
ctx.subscribe_to_model(&queue, move |_, event: &ImportQueueEvent, _| {
let event = match event {
ImportQueueEvent::FolderCompleted {
folder_id,
server_id,
} => EventKind::Folder {
folder_id: *folder_id,
object_id: server_id.clone(),
},
ImportQueueEvent::FileCompleted { file_id, server_id } => EventKind::File {
file_id: *file_id,
object_id: server_id.clone(),
},
ImportQueueEvent::FileSavedLocally(file_id) => {
EventKind::FileSavedLocally(*file_id)
}
};
events_for_subscription.borrow_mut().push(event);
});
});
let parent_client_id = ClientId::new();
let child_client_id = ClientId::new();
let notebook_client_id = ClientId::new();
let workflow_client_id = ClientId::new();
let workflow_enum_client_id = ClientId::new();
queue.update(&mut app, |queue, ctx| {
queue.enqueue(
ImportQueueArgs {
owner: local_owner(),
parent_id: ParentId::InitialFolder(None),
content: RequestContent::Folder {
name: "Imported".to_string(),
client_id: parent_client_id,
folder_id: nodes::FolderId::from(1),
},
},
ctx,
);
queue.enqueue(
ImportQueueArgs {
owner: local_owner(),
parent_id: ParentId::FolderToUpload(parent_client_id),
content: RequestContent::Folder {
name: "Nested".to_string(),
client_id: child_client_id,
folder_id: nodes::FolderId::from(2),
},
},
ctx,
);
queue.enqueue(
ImportQueueArgs {
owner: local_owner(),
parent_id: ParentId::FolderToUpload(child_client_id),
content: RequestContent::Notebook {
title: "Imported notes".to_string(),
data: "hello".to_string(),
client_id: notebook_client_id,
file_id: FileId(0),
},
},
ctx,
);
queue.enqueue(
ImportQueueArgs {
owner: local_owner(),
parent_id: ParentId::FolderToUpload(child_client_id),
content: RequestContent::Workflow {
workflows: vec![(
crate::workflows::workflow::Workflow::new(
"Imported workflow",
"echo imported",
),
workflow_client_id,
)],
workflow_enums: HashMap::from([(
workflow_enum_client_id,
WorkflowEnum {
name: "Environment".to_string(),
is_shared: false,
variants: EnumVariants::Static(vec!["dev".to_string()]),
},
)]),
file_id: FileId(1),
},
},
ctx,
);
});
let parent_id = SyncId::ClientId(parent_client_id);
let child_id = SyncId::ClientId(child_client_id);
let notebook_id = SyncId::ClientId(notebook_client_id);
let workflow_id = SyncId::ClientId(workflow_client_id);
let workflow_enum_id = SyncId::ClientId(workflow_enum_client_id);
app.update(|ctx| {
let cloud_model = CloudModel::as_ref(ctx);
let parent = cloud_model.get_folder(&parent_id).expect("parent folder");
assert_eq!(parent.permissions.owner, local_owner());
assert_eq!(
cloud_model
.get_folder(&child_id)
.unwrap()
.metadata
.folder_id,
Some(parent_id)
);
assert_eq!(
cloud_model
.get_notebook(&notebook_id)
.unwrap()
.metadata
.folder_id,
Some(child_id)
);
assert_eq!(
cloud_model
.get_workflow(&workflow_id)
.unwrap()
.metadata
.folder_id,
Some(child_id)
);
assert_eq!(
cloud_model
.get_workflow_enum(&workflow_enum_id)
.unwrap()
.model()
.string_model
.name,
"Environment"
);
});
let events = events.borrow();
assert!(events.contains(&EventKind::Folder {
folder_id: nodes::FolderId::from(1),
object_id: Some(parent_id.uid()),
}));
assert!(events.contains(&EventKind::Folder {
folder_id: nodes::FolderId::from(2),
object_id: Some(child_id.uid()),
}));
assert!(events.contains(&EventKind::File {
file_id: FileId(0),
object_id: Some(notebook_id.uid()),
}));
assert!(events.contains(&EventKind::File {
file_id: FileId(1),
object_id: Some(workflow_id.uid()),
}));
});
}
+100 -21
View File
@@ -68,7 +68,7 @@ use crate::drive::panel::DrivePanelAction;
use crate::editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions};
use crate::env_vars::CloudEnvVarCollection;
use crate::features::FeatureFlag;
use crate::local_object_repository::LocalObjectRepository;
use crate::local_object_repository::{local_owner, LocalObjectRepository};
use crate::menu::{Event, Menu, MenuItem, MenuItemFields};
use crate::network::NetworkStatus;
use crate::notebooks::CloudNotebookModel;
@@ -1064,6 +1064,12 @@ impl DriveIndex {
NetworkStatus::as_ref(app).is_online()
}
fn is_local_folder(folder_id: &SyncId, app: &AppContext) -> bool {
CloudModel::as_ref(app)
.get_folder(folder_id)
.is_some_and(|folder| folder.permissions.owner == local_owner())
}
pub fn scroll_item_into_view(&mut self, item_id: WarpDriveItemId, ctx: &mut ViewContext<Self>) {
self.clipped_scroll_state.scroll_to_position(ScrollTarget {
position_id: item_id.drive_row_position_id(),
@@ -3330,9 +3336,15 @@ impl DriveIndex {
match new_location {
CloudObjectLocation::Space(space) => self.open_section_of_space(space),
CloudObjectLocation::Folder(folder_id) => {
cloud_model.update(ctx, |cloud_model, ctx| {
cloud_model.open_folder(folder_id, ctx);
});
if Self::is_local_folder(&folder_id, ctx) {
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.open_folder(folder_id, ctx);
});
} else {
cloud_model.update(ctx, |cloud_model, ctx| {
cloud_model.open_folder(folder_id, ctx);
});
}
}
// If location is the trash, then the above move_[object]_to_location call already trashed the object
CloudObjectLocation::Trash => {}
@@ -3508,9 +3520,15 @@ impl DriveIndex {
if !new_name.is_empty() {
self.reset_menus(ctx);
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
update_manager.rename_folder(folder_id, new_name, ctx);
});
if Self::is_local_folder(&folder_id, ctx) {
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.update_folder_name(folder_id, new_name, ctx);
});
} else {
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
update_manager.rename_folder(folder_id, new_name, ctx);
});
}
self.cloud_object_naming_dialog.close(ctx);
ctx.notify();
@@ -3542,6 +3560,11 @@ impl DriveIndex {
repository.set_env_var_collection_trashed(id, true, ctx);
});
}
CloudObjectTypeAndId::Folder(id) if Self::is_local_folder(&id, ctx) => {
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.set_folder_trashed(id, true, ctx);
});
}
CloudObjectTypeAndId::Folder(_) | CloudObjectTypeAndId::GenericStringObject { .. } => {
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
update_manager.trash_object(cloud_object_type_and_id, ctx);
@@ -3585,6 +3608,14 @@ impl DriveIndex {
ctx.notify();
return;
}
CloudObjectTypeAndId::Folder(id) if Self::is_local_folder(id, ctx) => {
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.set_folder_trashed(*id, false, ctx);
});
self.reset_menus(ctx);
ctx.notify();
return;
}
CloudObjectTypeAndId::Folder(_) | CloudObjectTypeAndId::GenericStringObject { .. } => {}
}
@@ -3736,6 +3767,11 @@ impl DriveIndex {
repository.delete_env_var_collection(*id, ctx);
});
}
CloudObjectTypeAndId::Folder(id) if Self::is_local_folder(id, ctx) => {
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.delete_folder(*id, ctx);
});
}
CloudObjectTypeAndId::Folder(_) | CloudObjectTypeAndId::GenericStringObject { .. } => {
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
update_manager.delete_object_by_user(*cloud_object_type_and_id, ctx);
@@ -5088,14 +5124,35 @@ impl DriveIndex {
}
}
CloudObjectTypeAndId::Folder(id) => {
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| match key {
DriveIndexAction::EnterKey => {
cloud_model.toggle_folder_open(*id, ctx);
}
DriveIndexAction::LeftArrowKey => cloud_model.close_folder(*id, ctx),
DriveIndexAction::RightArrowKey => cloud_model.open_folder(*id, ctx),
_ => {}
});
if Self::is_local_folder(id, ctx) {
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
match key {
DriveIndexAction::EnterKey => {
repository.toggle_folder_open(*id, ctx);
}
DriveIndexAction::LeftArrowKey => {
repository.close_folder(*id, ctx)
}
DriveIndexAction::RightArrowKey => {
repository.open_folder(*id, ctx)
}
_ => {}
}
});
} else {
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| match key {
DriveIndexAction::EnterKey => {
cloud_model.toggle_folder_open(*id, ctx);
}
DriveIndexAction::LeftArrowKey => {
cloud_model.close_folder(*id, ctx)
}
DriveIndexAction::RightArrowKey => {
cloud_model.open_folder(*id, ctx)
}
_ => {}
});
}
}
CloudObjectTypeAndId::GenericStringObject { object_type, id: _ } => {
if let GenericStringObjectFormat::Json(JsonObjectType::EnvVarCollection) =
@@ -5556,14 +5613,36 @@ impl TypedActionView for DriveIndex {
ctx,
);
}
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
cloud_model.toggle_folder_open(*id, ctx);
});
if Self::is_local_folder(id, ctx) {
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.toggle_folder_open(*id, ctx);
});
} else {
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
cloud_model.toggle_folder_open(*id, ctx);
});
}
}
DriveIndexAction::CollapseAllInLocation(location) => {
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
cloud_model.collapse_all_in_location(*location, self.index_variant, ctx);
});
if let CloudObjectLocation::Folder(folder_id) = location {
if Self::is_local_folder(folder_id, ctx) {
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.collapse_local_folders_in_location(*location, ctx);
});
} else {
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
cloud_model.collapse_all_in_location(
*location,
self.index_variant,
ctx,
);
});
}
} else {
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
cloud_model.collapse_all_in_location(*location, self.index_variant, ctx);
});
}
}
DriveIndexAction::TrashObject {
cloud_object_type_and_id,
+22 -11
View File
@@ -178,17 +178,28 @@ impl DrivePanel {
} => match Self::new_object_owner(*space, initial_folder_id.as_ref(), ctx) {
Some(owner) => {
let client_id = ClientId::default();
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
update_manager.create_folder(
title.clone(),
owner,
client_id,
*initial_folder_id,
true,
InitiatedBy::User,
ctx,
);
});
if owner == local_owner() {
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.create_folder_with_id(
SyncId::ClientId(client_id),
title.clone(),
*initial_folder_id,
ctx,
);
});
} else {
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
update_manager.create_folder(
title.clone(),
owner,
client_id,
*initial_folder_id,
true,
InitiatedBy::User,
ctx,
);
});
}
}
None => {
log::error!("Cannot identify a folder owner from {space:?}");
+1 -8
View File
@@ -1,4 +1,3 @@
use galaxy_core::features::FeatureFlag;
use settings::macros::define_settings_group;
use settings::{RespectUserSyncSetting, Setting, SupportedPlatforms, SyncToCloud};
@@ -37,14 +36,8 @@ define_settings_group!(WarpDriveSettings, settings: [
impl WarpDriveSettings {
/// Returns whether Warp Drive should be considered enabled.
/// Returns `false` when the user is anonymous or fully logged out,
/// regardless of the user setting.
pub fn is_warp_drive_enabled(app: &galaxyui::AppContext) -> bool {
use galaxyui::SingletonEntity as _;
let is_anonymous_or_logged_out = FeatureFlag::SkipFirebaseAnonymousUser.is_enabled()
&& crate::auth::AuthStateProvider::as_ref(app)
.get()
.is_anonymous_or_logged_out();
*Self::as_ref(app).enable_warp_drive && !is_anonymous_or_logged_out
*Self::as_ref(app).enable_warp_drive
}
}
+185 -3
View File
@@ -15,16 +15,18 @@ use crate::auth::UserUid;
use crate::cloud_object::model::generic_string_model::GenericStringObjectId;
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
use crate::cloud_object::{
CloudObject, CloudObjectMetadata, CloudObjectPermissions, CloudObjectStatuses,
CloudObjectSyncStatus, GenericCloudObject, GenericStringObjectFormat, JsonObjectType,
ObjectIdType, Owner, Revision,
CloudObject, CloudObjectLocation, CloudObjectMetadata, CloudObjectPermissions,
CloudObjectStatuses, CloudObjectSyncStatus, GenericCloudObject, GenericStringObjectFormat,
JsonObjectType, ObjectIdType, Owner, Revision, Space,
};
use crate::drive::folders::{CloudFolder, CloudFolderModel};
use crate::drive::CloudObjectTypeAndId;
use crate::env_vars::{CloudEnvVarCollection, CloudEnvVarCollectionModel, EnvVarCollection};
use crate::notebooks::{CloudNotebook, CloudNotebookModel};
use crate::persistence::ModelEvent;
use crate::server::ids::{ClientId, SyncId};
use crate::workflows::workflow::Workflow;
use crate::workflows::workflow_enum::{CloudWorkflowEnum, CloudWorkflowEnumModel, WorkflowEnum};
use crate::workflows::{CloudWorkflow, CloudWorkflowModel};
const LOCAL_OWNER_ID: &str = "local-galaxy-user";
@@ -213,6 +215,140 @@ impl LocalObjectRepository {
CloudModel::as_ref(app).get_notebook(id).cloned()
}
pub fn folder(&self, id: &SyncId, app: &AppContext) -> Option<CloudFolder> {
CloudModel::as_ref(app).get_folder(id).cloned()
}
pub fn create_folder_with_id(
&mut self,
id: SyncId,
name: String,
parent_folder_id: Option<SyncId>,
ctx: &mut ModelContext<Self>,
) {
self.upsert_folder(
new_local_folder(id, parent_folder_id, CloudFolderModel::new(&name, false)),
ctx,
);
}
pub fn update_folder_name(
&mut self,
id: SyncId,
name: String,
ctx: &mut ModelContext<Self>,
) -> bool {
let Some(mut folder) = self.folder(&id, ctx) else {
return false;
};
folder.set_model(CloudFolderModel {
name,
is_open: folder.model().is_open,
is_warp_pack: folder.model().is_warp_pack,
});
set_locally_saved_metadata(&mut folder.metadata);
self.upsert_folder(folder, ctx);
true
}
pub fn set_folder_trashed(
&mut self,
id: SyncId,
trashed: bool,
ctx: &mut ModelContext<Self>,
) -> bool {
let Some(mut folder) = self.folder(&id, ctx) else {
return false;
};
folder.metadata.trashed_ts = trashed.then(|| ServerTimestamp::new(Utc::now()));
set_locally_saved_metadata(&mut folder.metadata);
self.upsert_folder(folder, ctx);
true
}
pub fn delete_folder(&mut self, id: SyncId, ctx: &mut ModelContext<Self>) -> bool {
if self.folder(&id, ctx).is_none() {
return false;
}
let ids = CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
cloud_model.delete_object_and_descendants(id.uid(), ctx)
});
if ids.is_empty() {
return false;
}
self.save(ModelEvent::DeleteObjects { ids });
true
}
pub fn open_folder(&mut self, id: SyncId, ctx: &mut ModelContext<Self>) {
self.set_folder_open_state(id, true, ctx);
}
pub fn close_folder(&mut self, id: SyncId, ctx: &mut ModelContext<Self>) {
self.set_folder_open_state(id, false, ctx);
}
pub fn toggle_folder_open(&mut self, id: SyncId, ctx: &mut ModelContext<Self>) {
let Some(folder) = self.folder(&id, ctx) else {
return;
};
self.set_folder_open_state(id, !folder.model().is_open, ctx);
}
pub fn collapse_local_folders_in_location(
&mut self,
location: CloudObjectLocation,
ctx: &mut ModelContext<Self>,
) {
let folder_ids = {
let cloud_model = CloudModel::as_ref(ctx);
cloud_model
.get_all_active_and_inactive_folders()
.filter(|folder| folder.permissions.owner == local_owner())
.filter(|folder| match location {
CloudObjectLocation::Folder(parent_id) => {
if folder.id == parent_id {
return true;
}
let mut ancestor_id = folder.metadata.folder_id;
while let Some(id) = ancestor_id {
if id == parent_id {
return true;
}
ancestor_id = cloud_model
.get_folder(&id)
.and_then(|ancestor| ancestor.metadata.folder_id);
}
false
}
CloudObjectLocation::Space(Space::Personal) => true,
CloudObjectLocation::Space(Space::Shared)
| CloudObjectLocation::Space(Space::Team { .. })
| CloudObjectLocation::Trash => false,
})
.map(|folder| folder.id)
.collect::<Vec<_>>()
};
for folder_id in folder_ids {
self.close_folder(folder_id, ctx);
}
}
fn set_folder_open_state(&self, id: SyncId, is_open: bool, ctx: &mut ModelContext<Self>) {
let Some(mut folder) = self.folder(&id, ctx) else {
return;
};
folder.set_model(CloudFolderModel {
name: folder.model().name.clone(),
is_open,
is_warp_pack: folder.model().is_warp_pack,
});
self.upsert_folder(folder, ctx);
}
pub fn create_notebook_with_id(
&mut self,
id: SyncId,
@@ -480,6 +616,23 @@ impl LocalObjectRepository {
self.upsert_workflow(new_local_workflow(id, folder_id, workflow), ctx);
}
pub fn create_workflow_enum_with_id(
&mut self,
id: SyncId,
workflow_enum: WorkflowEnum,
ctx: &mut ModelContext<Self>,
) {
self.upsert_workflow_enum(
GenericCloudObject::new(
id,
CloudWorkflowEnumModel::new(workflow_enum),
locally_saved_metadata(None),
local_permissions(),
),
ctx,
);
}
pub fn update_workflow(
&mut self,
id: SyncId,
@@ -591,6 +744,13 @@ impl LocalObjectRepository {
self.save(ModelEvent::UpsertNotebook { notebook });
}
fn upsert_folder(&self, folder: CloudFolder, ctx: &mut ModelContext<Self>) {
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
cloud_model.upsert_local_object(folder.clone(), ctx);
});
self.save(ModelEvent::UpsertFolder { folder });
}
fn upsert_workflow(&self, workflow: CloudWorkflow, ctx: &mut ModelContext<Self>) {
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
cloud_model.upsert_local_object(workflow.clone(), ctx);
@@ -598,6 +758,15 @@ impl LocalObjectRepository {
self.save(ModelEvent::UpsertWorkflow { workflow });
}
fn upsert_workflow_enum(&self, workflow_enum: CloudWorkflowEnum, ctx: &mut ModelContext<Self>) {
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
cloud_model.upsert_local_object(workflow_enum.clone(), ctx);
});
self.save(ModelEvent::UpsertGenericStringObject {
object: Box::new(workflow_enum),
});
}
fn delete_local_object(
&self,
id: SyncId,
@@ -658,6 +827,19 @@ pub(crate) fn new_local_notebook(
)
}
pub(crate) fn new_local_folder(
id: SyncId,
folder_id: Option<SyncId>,
folder: CloudFolderModel,
) -> CloudFolder {
CloudFolder::new(
id,
folder,
locally_saved_metadata(folder_id),
local_permissions(),
)
}
pub(crate) fn new_local_workflow(
id: SyncId,
folder_id: Option<SyncId>,
+65
View File
@@ -172,6 +172,71 @@ fn create_update_and_delete_notebook_are_local_and_persisted() {
});
}
#[test]
fn create_update_open_trash_and_delete_folder_are_local_and_persisted() {
App::test((), |mut app| async move {
let receiver = initialize_app(&mut app);
let repository = LocalObjectRepository::handle(&app);
let parent_id = SyncId::ClientId(ClientId::new());
let child_id = SyncId::ClientId(ClientId::new());
repository.update(&mut app, |repository, ctx| {
repository.create_folder_with_id(parent_id, "Projects".to_string(), None, ctx);
});
let ModelEvent::UpsertFolder { folder } = receiver.recv().unwrap() else {
panic!("expected a local folder upsert");
};
assert_eq!(folder.id, parent_id);
assert_eq!(folder.model().name, "Projects");
assert!(!folder.model().is_open);
repository.update(&mut app, |repository, ctx| {
repository.create_folder_with_id(child_id, "Rust".to_string(), Some(parent_id), ctx);
});
assert!(
matches!(receiver.recv().unwrap(), ModelEvent::UpsertFolder { folder } if folder.id == child_id && folder.metadata.folder_id == Some(parent_id))
);
assert!(repository.update(&mut app, |repository, ctx| {
repository.update_folder_name(parent_id, "Projects 2026".to_string(), ctx)
}));
assert!(
matches!(receiver.recv().unwrap(), ModelEvent::UpsertFolder { folder } if folder.model().name == "Projects 2026")
);
repository.update(&mut app, |repository, ctx| {
repository.open_folder(parent_id, ctx);
});
assert!(
matches!(receiver.recv().unwrap(), ModelEvent::UpsertFolder { folder } if folder.model().is_open)
);
assert!(repository.update(&mut app, |repository, ctx| {
repository.set_folder_trashed(parent_id, true, ctx)
}));
assert!(
matches!(receiver.recv().unwrap(), ModelEvent::UpsertFolder { folder } if folder.metadata.trashed_ts.is_some())
);
assert!(repository.update(&mut app, |repository, ctx| {
repository.set_folder_trashed(parent_id, false, ctx)
}));
assert!(
matches!(receiver.recv().unwrap(), ModelEvent::UpsertFolder { folder } if folder.metadata.trashed_ts.is_none())
);
assert!(repository.update(&mut app, |repository, ctx| {
repository.delete_folder(parent_id, ctx)
}));
assert!(matches!(
receiver.recv().unwrap(),
ModelEvent::DeleteObjects { ids }
if ids.contains(&(parent_id, ObjectIdType::Folder))
&& ids.contains(&(child_id, ObjectIdType::Folder))
));
});
}
#[test]
fn create_update_and_delete_workflow_are_local_and_persisted() {
App::test((), |mut app| async move {
+17
View File
@@ -926,6 +926,13 @@ pub enum OpenAIProviderKind {
OpenAICompatible,
/// The ChatGPT subscription backend, authenticated with ChatGPT OAuth.
ChatGPTSubscription,
/// Anthropic's native Messages API.
Anthropic,
/// Google's Gemini API.
Gemini,
/// Google's Gemini models hosted through Vertex AI.
#[serde(rename = "vertex_ai", alias = "vertex_a_i")]
VertexAI,
}
/// Configuration for a single OpenAI-compatible provider endpoint.
@@ -951,6 +958,12 @@ pub struct OpenAIProviderConfig {
#[schemars(description = "API key for this endpoint (optional if the proxy handles auth).")]
pub api_key: Option<String>,
#[serde(default)]
#[schemars(description = "Google Cloud project ID for Vertex AI providers.")]
pub project_id: Option<String>,
#[serde(default)]
#[schemars(description = "Google Cloud location for Vertex AI providers.")]
pub location: Option<String>,
#[serde(default)]
#[schemars(description = "Models available from this provider.")]
pub models: Vec<OpenAIModelConfig>,
}
@@ -1021,6 +1034,8 @@ pub(crate) fn default_chatgpt_provider() -> OpenAIProviderConfig {
name: "ChatGPT Subscription".to_string(),
base_url: String::new(),
api_key: None,
project_id: None,
location: None,
models: default_chatgpt_models(),
}
}
@@ -1035,6 +1050,8 @@ fn default_openai_providers() -> Vec<OpenAIProviderConfig> {
// Credentials are deliberately never committed. Set this locally in
// ~/.galaxy/settings.toml before sending a request.
api_key: None,
project_id: None,
location: None,
models: vec![OpenAIModelConfig {
model_id: INITIAL_RIG_MODEL_ID.to_string(),
display_name: "Codex GPT-5.6 SOL (xhigh)".to_string(),
+28
View File
@@ -423,6 +423,34 @@ fn initial_litellm_provider_maps_codex_model_to_rig_without_a_committed_key() {
assert!(instant.reasoning_efforts.is_empty());
}
#[test]
fn native_provider_settings_roundtrip_with_vertex_configuration() {
let provider: OpenAIProviderConfig = serde_json::from_value(serde_json::json!({
"kind": "vertex_ai",
"enabled": true,
"name": "Vertex production",
"base_url": "",
"project_id": "galaxy-project",
"location": "us-central1",
"models": []
}))
.expect("Vertex provider settings should deserialize");
assert_eq!(provider.kind, OpenAIProviderKind::VertexAI);
assert_eq!(provider.project_id.as_deref(), Some("galaxy-project"));
assert_eq!(provider.location.as_deref(), Some("us-central1"));
let legacy: OpenAIProviderConfig = serde_json::from_value(serde_json::json!({
"name": "Legacy provider",
"base_url": "http://localhost:4000/v1",
"models": []
}))
.expect("Legacy provider settings should remain compatible");
assert_eq!(legacy.kind, OpenAIProviderKind::OpenAICompatible);
assert_eq!(legacy.project_id, None);
assert_eq!(legacy.location, None);
}
#[test]
fn codex_litellm_model_infers_missing_system_message_capability() {
let mut model = default_openai_providers().remove(0).models.remove(0);
+28 -21
View File
@@ -7564,13 +7564,13 @@ impl SettingsWidget for ModelsOverviewWidget {
.with_spacing(8.)
.with_child(build_sub_header(appearance, "Models", None).finish())
.with_child(render_ai_setting_description(
"Configure Galaxy's direct model providers and agent runtimes in one place. OpenAI-compatible endpoints and Bedrock models run through Rig. ACP coding agents use the same Galaxy runtime boundary while retaining their own model, login, session, and tool loop.",
"Configure Galaxy's direct model providers and agent runtimes in one place. OpenAI-compatible, Anthropic, Gemini, Vertex AI, and Bedrock models run through Rig. ACP coding agents use the same Galaxy runtime boundary while retaining their own model, login, session, and tool loop.",
true,
app,
))
.with_child(render_ai_setting_description(
format!(
"{endpoint_count} OpenAI-compatible provider(s) with {endpoint_model_count} model(s); {bedrock_model_count} Bedrock model(s); {agent_runtime_count} enabled agent runtime(s)."
"{endpoint_count} configured provider(s) with {endpoint_model_count} model(s); {bedrock_model_count} Bedrock model(s); {agent_runtime_count} enabled agent runtime(s)."
),
true,
app,
@@ -7680,6 +7680,9 @@ impl OpenAIProviderSettingsWidget {
match provider.kind {
OpenAIProviderKind::OpenAICompatible => "OpenAI-compatible API",
OpenAIProviderKind::ChatGPTSubscription => "ChatGPT subscription",
OpenAIProviderKind::Anthropic => "Anthropic",
OpenAIProviderKind::Gemini => "Google Gemini",
OpenAIProviderKind::VertexAI => "Google Vertex AI",
}
}
@@ -7905,33 +7908,37 @@ impl SettingsWidget for OpenAIProviderSettingsWidget {
app,
));
column.add_child(render_ai_setting_description(
"Connect a ChatGPT subscription, OpenAI-compatible endpoint, AWS Bedrock account, or ACP agent runtime. Each provider can be enabled independently.",
"Connect a ChatGPT subscription, OpenAI-compatible endpoint, Anthropic, Gemini, Vertex AI, AWS Bedrock account, or ACP agent runtime. Each provider can be enabled independently.",
true,
app,
));
column.add_child(self.render_builtin_provider_card(
settings.bedrock_connection_name.value().as_str(),
"Use AWS credentials to access Bedrock foundation models directly.",
*settings.bedrock_enabled.value(),
self.bedrock_enabled_toggle.clone(),
AISettingsPageAction::ToggleBedrockEnabled,
&self.bedrock_edit_button,
&self.bedrock_remove_button,
appearance,
));
if cfg!(unix) && FeatureFlag::AgentClientProtocol.is_enabled() {
if !settings.bedrock_models.value().is_empty() {
column.add_child(self.render_builtin_provider_card(
settings.acp_connection_name.value().as_str(),
"Use a local session-oriented agent that owns its model and authentication.",
*settings.acp_enabled.value(),
self.acp_enabled_toggle.clone(),
AISettingsPageAction::ToggleAcpEnabled,
&self.acp_edit_button,
&self.acp_remove_button,
settings.bedrock_connection_name.value().as_str(),
"Use AWS credentials to access Bedrock foundation models directly.",
*settings.bedrock_enabled.value(),
self.bedrock_enabled_toggle.clone(),
AISettingsPageAction::ToggleBedrockEnabled,
&self.bedrock_edit_button,
&self.bedrock_remove_button,
appearance,
));
}
if cfg!(unix) && FeatureFlag::AgentClientProtocol.is_enabled() {
if *settings.acp_enabled.value() {
column.add_child(self.render_builtin_provider_card(
settings.acp_connection_name.value().as_str(),
"Use a local session-oriented agent that owns its model and authentication.",
*settings.acp_enabled.value(),
self.acp_enabled_toggle.clone(),
AISettingsPageAction::ToggleAcpEnabled,
&self.acp_edit_button,
&self.acp_remove_button,
appearance,
));
}
}
if providers.is_empty() {
column.add_child(render_ai_setting_description(
-15
View File
@@ -1167,9 +1167,6 @@ impl SettingsView {
// Warp Drive page
let warp_drive_page_handle =
ctx.add_typed_action_view(warp_drive_page::WarpDriveSettingsPageView::new);
ctx.subscribe_to_view(&warp_drive_page_handle, |me, _, event, ctx| {
me.handle_warp_drive_page_event(event, ctx);
});
let platform_page_handle = ctx.add_typed_action_view(platform_page::PlatformPageView::new);
ctx.subscribe_to_view(&platform_page_handle, |me, _, event, ctx| {
@@ -1738,18 +1735,6 @@ impl SettingsView {
}
}
fn handle_warp_drive_page_event(
&mut self,
event: &warp_drive_page::WarpDriveSettingsPageEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
warp_drive_page::WarpDriveSettingsPageEvent::SignUp => {
ctx.emit(SettingsViewEvent::SignupAnonymousUser)
}
}
}
fn handle_ai_page_event(&mut self, event: &AISettingsPageEvent, ctx: &mut ViewContext<Self>) {
match event {
AISettingsPageEvent::FocusModal => ctx.focus(&self.search_editor),
+242 -82
View File
@@ -46,10 +46,51 @@ enum ProviderSetupStep {
pub enum ProviderSetupProviderType {
ChatGPTSubscription,
OpenAICompatible,
Anthropic,
Gemini,
VertexAI,
Bedrock,
Acp,
}
const PROVIDER_TYPE_OPTIONS: &[(ProviderSetupProviderType, &str, &str)] = &[
(
ProviderSetupProviderType::ChatGPTSubscription,
"ChatGPT subscription",
"Use your ChatGPT Plus or Pro subscription with native OAuth.",
),
(
ProviderSetupProviderType::OpenAICompatible,
"OpenAI-compatible API",
"Connect LiteLLM, Ollama, vLLM, or another compatible endpoint.",
),
(
ProviderSetupProviderType::Anthropic,
"Anthropic",
"Connect directly to Anthropic's native Messages API with an API key.",
),
(
ProviderSetupProviderType::Gemini,
"Google Gemini",
"Connect directly to Google's Gemini API with an API key.",
),
(
ProviderSetupProviderType::VertexAI,
"Google Vertex AI",
"Use Google Cloud Application Default Credentials for Vertex-hosted Gemini models.",
),
(
ProviderSetupProviderType::Bedrock,
"AWS Bedrock",
"Use the AWS Bedrock credentials and model configuration already managed by Galaxy.",
),
(
ProviderSetupProviderType::Acp,
"ACP agent runtime",
"Use a session-oriented ACP agent that owns its model and authentication.",
),
];
#[derive(Clone, Debug)]
pub struct BedrockProviderDraft {
pub name: String,
@@ -114,6 +155,8 @@ pub struct ProviderSetupModalBody {
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,
@@ -122,6 +165,8 @@ pub struct ProviderSetupModalBody {
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>,
@@ -134,6 +179,7 @@ pub struct ProviderSetupModalBody {
bedrock_cross_region_toggle: SwitchStateHandle,
bedrock_auto_login_toggle: SwitchStateHandle,
model_switches: Vec<SwitchStateHandle>,
provider_type_scroll_state: ClippedScrollStateHandle,
models_scroll_state: ClippedScrollStateHandle,
back_button: ViewHandle<ActionButton>,
cancel_button: ViewHandle<ActionButton>,
@@ -142,35 +188,28 @@ pub struct ProviderSetupModalBody {
impl ProviderSetupModalBody {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let provider_type_buttons = [
(
ProviderSetupProviderType::ChatGPTSubscription,
"ChatGPT subscription",
),
(
ProviderSetupProviderType::OpenAICompatible,
"OpenAI-compatible API",
),
(ProviderSetupProviderType::Bedrock, "AWS Bedrock"),
(ProviderSetupProviderType::Acp, "ACP agent runtime"),
]
.into_iter()
.map(|(kind, label)| {
ctx.add_typed_action_view(move |_| {
ActionButton::new(label, NakedTheme)
.with_full_width(true)
.on_click(move |ctx| {
ctx.dispatch_typed_action(ProviderSetupModalBodyAction::SelectProvider(
kind,
));
})
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(
ProviderSetupModalBodyAction::SelectProvider(kind),
);
})
})
})
})
.collect();
.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);
@@ -218,6 +257,19 @@ impl ProviderSetupModalBody {
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),
@@ -286,6 +338,8 @@ impl ProviderSetupModalBody {
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(),
@@ -310,6 +364,8 @@ impl ProviderSetupModalBody {
name_editor,
base_url_editor,
api_key_editor,
project_id_editor,
location_editor,
bedrock_profile_editor,
bedrock_region_editor,
bedrock_refresh_command_editor,
@@ -322,6 +378,7 @@ impl ProviderSetupModalBody {
bedrock_cross_region_toggle: SwitchStateHandle::default(),
bedrock_auto_login_toggle: SwitchStateHandle::default(),
model_switches: Vec::new(),
provider_type_scroll_state: ClippedScrollStateHandle::default(),
models_scroll_state: ClippedScrollStateHandle::default(),
back_button,
cancel_button,
@@ -363,6 +420,8 @@ impl ProviderSetupModalBody {
self.draft_name.clear();
self.draft_base_url.clear();
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(),
@@ -405,10 +464,15 @@ impl ProviderSetupModalBody {
ProviderSetupProviderType::ChatGPTSubscription
}
OpenAIProviderKind::OpenAICompatible => ProviderSetupProviderType::OpenAICompatible,
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);
@@ -483,6 +547,12 @@ impl ProviderSetupModalBody {
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);
});
@@ -513,15 +583,12 @@ impl ProviderSetupModalBody {
}
fn sync_provider_type_buttons(&self, ctx: &mut ViewContext<Self>) {
for (index, button) in self.provider_type_buttons.iter().enumerate() {
let button_kind = match index {
0 => ProviderSetupProviderType::ChatGPTSubscription,
1 => ProviderSetupProviderType::OpenAICompatible,
2 => ProviderSetupProviderType::Bedrock,
_ => ProviderSetupProviderType::Acp,
};
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);
button.set_active(*button_kind == self.provider_type, ctx);
});
}
}
@@ -555,6 +622,12 @@ impl ProviderSetupModalBody {
ProviderSetupProviderType::OpenAICompatible => {
self.draft_base_url.trim().is_empty()
}
ProviderSetupProviderType::Anthropic | ProviderSetupProviderType::Gemini => {
self.draft_api_key
.as_deref()
.is_none_or(|key| key.trim().is_empty())
}
ProviderSetupProviderType::VertexAI => self.draft_project_id.trim().is_empty(),
ProviderSetupProviderType::Acp => self.draft_acp.agent_id.trim().is_empty(),
ProviderSetupProviderType::ChatGPTSubscription
| ProviderSetupProviderType::Bedrock => false,
@@ -571,7 +644,10 @@ impl ProviderSetupModalBody {
),
ProviderSetupStep::Models => match self.provider_type {
ProviderSetupProviderType::OpenAICompatible
| ProviderSetupProviderType::ChatGPTSubscription => (
| ProviderSetupProviderType::ChatGPTSubscription
| ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
| ProviderSetupProviderType::VertexAI => (
"Save",
self.draft_name.trim().is_empty()
|| !self.draft_models.iter().any(|model| model.enabled),
@@ -601,19 +677,48 @@ impl ProviderSetupModalBody {
ProviderSetupProviderType::OpenAICompatible
| ProviderSetupProviderType::Bedrock
| ProviderSetupProviderType::Acp => OpenAIProviderKind::OpenAICompatible,
ProviderSetupProviderType::Anthropic => OpenAIProviderKind::Anthropic,
ProviderSetupProviderType::Gemini => OpenAIProviderKind::Gemini,
ProviderSetupProviderType::VertexAI => OpenAIProviderKind::VertexAI,
},
enabled: true,
name: self.draft_name.trim().to_string(),
base_url: if self.provider_type == ProviderSetupProviderType::ChatGPTSubscription {
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: self
.draft_api_key
.as_deref()
.filter(|key| !key.trim().is_empty())
.map(str::to_string),
api_key: matches!(
self.provider_type,
ProviderSetupProviderType::OpenAICompatible
| 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(),
}
}
@@ -668,7 +773,10 @@ impl ProviderSetupModalBody {
));
return;
}
ProviderSetupProviderType::OpenAICompatible => {}
ProviderSetupProviderType::OpenAICompatible
| ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
| ProviderSetupProviderType::VertexAI => {}
}
let provider = self.draft_provider();
@@ -776,51 +884,53 @@ impl ProviderSetupModalBody {
.with_color(appearance.theme().nonactive_ui_text_color().into())
.finish();
let cards = [
(
"ChatGPT subscription",
"Use your ChatGPT Plus or Pro subscription with native OAuth.",
),
(
"OpenAI-compatible API",
"Connect LiteLLM, Ollama, vLLM, or another compatible endpoint.",
),
(
"AWS Bedrock",
"Use the AWS Bedrock credentials and model configuration already managed by Galaxy.",
),
(
"ACP agent runtime",
"Use a session-oriented ACP agent that owns its model and authentication.",
),
]
.into_iter()
.enumerate()
.map(|(index, (label, 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 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_children(cards)
.with_child(
ConstrainedBox::new(scrollable)
.with_max_height(360.)
.finish(),
)
.finish()
}
@@ -989,6 +1099,50 @@ impl ProviderSetupModalBody {
.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(
@@ -1422,7 +1576,10 @@ impl TypedActionView for ProviderSetupModalBody {
}
ProviderSetupStep::Models => match self.provider_type {
ProviderSetupProviderType::OpenAICompatible
| ProviderSetupProviderType::ChatGPTSubscription => {
| ProviderSetupProviderType::ChatGPTSubscription
| ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
| ProviderSetupProviderType::VertexAI => {
if self.draft_name.trim().is_empty()
|| !self.draft_models.iter().any(|model| model.enabled)
{
@@ -1529,6 +1686,9 @@ fn provider_type_label(kind: ProviderSetupProviderType) -> &'static str {
match kind {
ProviderSetupProviderType::OpenAICompatible => "OpenAI-compatible API",
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",
}
+9 -120
View File
@@ -1,17 +1,13 @@
use galaxy_core::features::FeatureFlag;
use galaxy_core::report_if_error;
use galaxy_core::settings::ToggleableSetting as _;
use galaxyui::fonts::Weight;
use galaxyui::keymap::ContextPredicate;
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::ui_components::components::UiComponent;
use galaxyui::ui_components::switch::SwitchStateHandle;
use galaxyui::{
id, Action, AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use warpui::elements::{
Container, Element, Flex, MouseStateHandle, ParentElement, Shrinkable, Text,
};
use warpui::elements::{Element, MouseStateHandle};
use super::settings_page::{
render_body_item, AdditionalInfo, MatchData, PageType, SettingsPageMeta,
@@ -22,13 +18,11 @@ use super::{
SettingsAction, SettingsSection, ToggleSettingActionPair, ToggleState,
};
use crate::appearance::Appearance;
use crate::auth::AuthStateProvider;
use crate::drive::settings::WarpDriveSettings;
#[derive(Debug, Clone)]
pub enum WarpDriveSettingsPageAction {
ToggleShowWarpDrive,
SignUp,
OpenUrl(String),
}
@@ -44,8 +38,8 @@ pub fn init_actions_from_parent_view<T: Action + Clone>(
WarpDriveSettingsPageAction::ToggleShowWarpDrive,
)),
SettingActionPairContexts::new(
context.clone() & !id!(flags::ENABLE_WARP_DRIVE) & !id!("IsAnonymousUser"),
context.clone() & id!(flags::ENABLE_WARP_DRIVE) & !id!("IsAnonymousUser"),
context.clone() & !id!(flags::ENABLE_WARP_DRIVE),
context.clone() & id!(flags::ENABLE_WARP_DRIVE),
),
None,
)
@@ -54,10 +48,6 @@ pub fn init_actions_from_parent_view<T: Action + Clone>(
);
}
pub enum WarpDriveSettingsPageEvent {
SignUp,
}
pub struct WarpDriveSettingsPageView {
page: PageType<Self>,
}
@@ -66,10 +56,7 @@ impl WarpDriveSettingsPageView {
pub fn new(_ctx: &mut ViewContext<Self>) -> Self {
Self {
page: PageType::new_uncategorized(
vec![
Box::new(WarpDriveHeaderWidget::default()),
Box::new(WarpDriveToggleWidget::default()),
],
vec![Box::new(WarpDriveToggleWidget::default())],
None,
),
}
@@ -77,7 +64,7 @@ impl WarpDriveSettingsPageView {
}
impl Entity for WarpDriveSettingsPageView {
type Event = WarpDriveSettingsPageEvent;
type Event = ();
}
impl TypedActionView for WarpDriveSettingsPageView {
@@ -91,9 +78,6 @@ impl TypedActionView for WarpDriveSettingsPageView {
});
ctx.notify();
}
WarpDriveSettingsPageAction::SignUp => {
ctx.emit(WarpDriveSettingsPageEvent::SignUp);
}
WarpDriveSettingsPageAction::OpenUrl(url) => {
ctx.open_url(url.as_str());
}
@@ -139,88 +123,6 @@ impl From<ViewHandle<WarpDriveSettingsPageView>> for SettingsPageViewHandle {
}
}
#[derive(Default)]
struct WarpDriveHeaderWidget {
sign_up_button: MouseStateHandle,
}
impl SettingsWidget for WarpDriveHeaderWidget {
type View = WarpDriveSettingsPageView;
fn search_terms(&self) -> &str {
"warp drive sign up"
}
fn should_render(&self, app: &AppContext) -> bool {
FeatureFlag::SkipFirebaseAnonymousUser.is_enabled()
&& AuthStateProvider::as_ref(app)
.get()
.is_anonymous_or_logged_out()
}
fn render(
&self,
_view: &Self::View,
appearance: &Appearance,
_app: &AppContext,
) -> Box<dyn Element> {
let ui_builder = appearance.ui_builder();
let message = Container::new(
Text::new_inline(
"To use Galaxy Drive, please create an account.".to_string(),
appearance.ui_font_family(),
14.,
)
.with_color(
appearance
.theme()
.sub_text_color(appearance.theme().surface_2())
.into_solid(),
)
.finish(),
)
.with_margin_right(16.)
.finish();
let button = Container::new(
ui_builder
.button(ButtonVariant::Accent, self.sign_up_button.clone())
.with_style(UiComponentStyles {
font_size: Some(14.),
font_weight: Some(Weight::Semibold),
border_radius: Some(galaxyui::elements::CornerRadius::with_all(
galaxyui::elements::Radius::Pixels(4.),
)),
padding: Some(Coords {
top: 8.,
bottom: 8.,
left: 24.,
right: 24.,
}),
..Default::default()
})
.with_text_label("Sign up".to_owned())
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(WarpDriveSettingsPageAction::SignUp);
})
.finish(),
)
.finish();
Container::new(
Flex::row()
.with_cross_axis_alignment(galaxyui::elements::CrossAxisAlignment::Center)
.with_child(Shrinkable::new(1., message).finish())
.with_child(button)
.finish(),
)
.with_padding_bottom(15.)
.finish()
}
}
#[derive(Default)]
struct WarpDriveToggleWidget {
switch_state: SwitchStateHandle,
@@ -241,10 +143,6 @@ impl SettingsWidget for WarpDriveToggleWidget {
app: &AppContext,
) -> Box<dyn Element> {
let settings = WarpDriveSettings::as_ref(app);
let is_anonymous_or_logged_out = FeatureFlag::SkipFirebaseAnonymousUser.is_enabled()
&& AuthStateProvider::as_ref(app)
.get()
.is_anonymous_or_logged_out();
render_body_item::<WarpDriveSettingsPageAction>(
"Galaxy Drive".into(),
@@ -257,24 +155,15 @@ impl SettingsWidget for WarpDriveToggleWidget {
tooltip_override_text: None,
}),
LocalOnlyIconState::Hidden,
if is_anonymous_or_logged_out {
ToggleState::Disabled
} else {
ToggleState::Enabled
},
ToggleState::Enabled,
appearance,
appearance
.ui_builder()
.switch(self.switch_state.clone())
.check(*settings.enable_warp_drive && !is_anonymous_or_logged_out)
.with_disabled(is_anonymous_or_logged_out)
.check(*settings.enable_warp_drive)
.build()
.on_click(move |ctx, _, _| {
if !is_anonymous_or_logged_out {
ctx.dispatch_typed_action(
WarpDriveSettingsPageAction::ToggleShowWarpDrive,
);
}
ctx.dispatch_typed_action(WarpDriveSettingsPageAction::ToggleShowWarpDrive);
})
.finish(),
Some("Galaxy Drive is a workspace in your terminal where you can save Workflows, Notebooks, Prompts, and Environment Variables for personal use or to share with a team.".into()),