From 3fda5d414b864bfc0b38b0d73063670027255098 Mon Sep 17 00:00:00 2001 From: Ryan Ward Date: Thu, 6 Aug 2026 15:03:00 -0500 Subject: [PATCH] Add Rig native model providers --- Cargo.lock | 393 +++++++++++++++++- Cargo.toml | 5 +- .../action_model/execute/shell_command.rs | 8 +- .../execute/shell_command_tests.rs | 16 +- app/src/ai/blocklist/block/cli_controller.rs | 143 +++++-- .../blocklist/controller/response_stream.rs | 2 + app/src/ai/crosscheck/reviewer.rs | 2 + app/src/ai/llms.rs | 113 ++++- app/src/ai/openai/client.rs | 2 + app/src/ai/runtime/rig.rs | 51 ++- app/src/ai/runtime/rig_request_tests.rs | 2 + app/src/drive/import/import_tests.rs | 22 +- app/src/drive/import/modal.rs | 16 +- app/src/drive/import/modal_body.rs | 12 +- app/src/drive/import/queue.rs | 242 +++++++---- app/src/drive/import/queue_tests.rs | 193 +++++++++ app/src/drive/index.rs | 121 +++++- app/src/drive/panel.rs | 33 +- app/src/drive/settings.rs | 9 +- app/src/local_object_repository.rs | 188 ++++++++- app/src/local_object_repository_tests.rs | 65 +++ app/src/settings/ai.rs | 17 + app/src/settings/ai_tests.rs | 28 ++ app/src/settings_view/ai_page.rs | 49 ++- app/src/settings_view/mod.rs | 15 - app/src/settings_view/provider_setup_modal.rs | 324 +++++++++++---- app/src/settings_view/warp_drive_page.rs | 129 +----- crates/galaxy_agent_rig/Cargo.toml | 1 + crates/galaxy_agent_rig/src/bedrock_tests.rs | 5 +- crates/galaxy_agent_rig/src/lib.rs | 2 + crates/galaxy_agent_rig/src/native.rs | 256 ++++++++++++ .../galaxy_agent_rig/src/openai_compatible.rs | 1 - crates/galaxy_agent_rig/src/stream.rs | 81 +++- plans/galaxy-local-first-rig.md | 40 +- 34 files changed, 2134 insertions(+), 452 deletions(-) create mode 100644 app/src/drive/import/queue_tests.rs create mode 100644 crates/galaxy_agent_rig/src/native.rs diff --git a/Cargo.lock b/Cargo.lock index 18e4a71d..202d006d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1495,6 +1495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -5986,6 +5987,7 @@ dependencies = [ "galaxy_agent_core", "rig-bedrock", "rig-core", + "rig-vertexai", "serde_json", "tokio", "uuid", @@ -7230,6 +7232,241 @@ dependencies = [ "gl_generator", ] +[[package]] +name = "google-cloud-aiplatform-v1" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c4b46c4e50f6a09b85dae39be560c263a20ec06c2b0d5a96d65423936bf238f" +dependencies = [ + "async-trait", + "bytes", + "google-cloud-api", + "google-cloud-gax", + "google-cloud-gax-internal", + "google-cloud-iam-v1", + "google-cloud-location", + "google-cloud-longrunning", + "google-cloud-lro", + "google-cloud-rpc", + "google-cloud-type", + "google-cloud-wkt", + "serde", + "serde_json", + "serde_with 3.21.0", + "tracing", +] + +[[package]] +name = "google-cloud-api" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19dd5722ba4d24fbc19f6a44b88c335852c9a98d058bc0d6073c9a730c026cad" +dependencies = [ + "bytes", + "google-cloud-wkt", + "serde", + "serde_json", + "serde_with 3.21.0", +] + +[[package]] +name = "google-cloud-auth" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f54aab44c16b8463ae11b165a87c3d484780231f157bb1ed65843d591beb5abd" +dependencies = [ + "async-trait", + "aws-lc-rs", + "base64 0.22.1", + "bytes", + "chrono", + "google-cloud-gax", + "hex", + "hmac 0.13.0", + "http 1.5.0", + "jsonwebtoken", + "reqwest 0.13.4", + "rustc_version", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.19", + "time", + "tokio", + "url", +] + +[[package]] +name = "google-cloud-gax" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9a46dd0fd026bbc4a5d84e6ab0c941cee6e3b057976a0bb107fdb5238ce598f" +dependencies = [ + "bytes", + "futures", + "google-cloud-rpc", + "google-cloud-wkt", + "http 1.5.0", + "pin-project", + "rand 0.10.2", + "serde", + "serde_json", + "thiserror 2.0.19", + "tokio", +] + +[[package]] +name = "google-cloud-gax-internal" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb04c54317ace06d489213f761797240b3046142a9b7ce6b9a82a9d134e193d1" +dependencies = [ + "bytes", + "futures", + "google-cloud-auth", + "google-cloud-gax", + "google-cloud-rpc", + "google-cloud-wkt", + "h2", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "hyper", + "lazy_static", + "opentelemetry", + "opentelemetry-semantic-conventions", + "opentelemetry_sdk", + "percent-encoding", + "pin-project", + "prost", + "prost-types", + "reqwest 0.13.4", + "rustc_version", + "serde", + "serde_json", + "thiserror 2.0.19", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tower", + "tracing", + "tracing-opentelemetry", +] + +[[package]] +name = "google-cloud-iam-v1" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34cdf5acc7ef946ee2db7a7f62bd436d8395a6543b4beef110cdc061fcf578bb" +dependencies = [ + "async-trait", + "bytes", + "google-cloud-gax", + "google-cloud-gax-internal", + "google-cloud-type", + "google-cloud-wkt", + "serde", + "serde_json", + "serde_with 3.21.0", + "tracing", +] + +[[package]] +name = "google-cloud-location" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "280d5acdba8fcb1232c0719ed788d85b7e362b82cbb425b7050d3ce46f075ede" +dependencies = [ + "async-trait", + "bytes", + "google-cloud-gax", + "google-cloud-gax-internal", + "google-cloud-wkt", + "serde", + "serde_json", + "serde_with 3.21.0", + "tracing", +] + +[[package]] +name = "google-cloud-longrunning" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6ce05df0aea2c08472983ce2bbbed9483cbb637b89ff69a7c4ef94371fe4f2" +dependencies = [ + "async-trait", + "bytes", + "google-cloud-gax", + "google-cloud-gax-internal", + "google-cloud-rpc", + "google-cloud-wkt", + "serde", + "serde_json", + "serde_with 3.21.0", + "tracing", +] + +[[package]] +name = "google-cloud-lro" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd7cca2b991d619525d72a170ca7f413cb520872702442da22ac9af650a8e786" +dependencies = [ + "google-cloud-gax", + "google-cloud-gax-internal", + "google-cloud-longrunning", + "google-cloud-rpc", + "google-cloud-wkt", + "serde", + "tokio", + "tracing", +] + +[[package]] +name = "google-cloud-rpc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2162c08a89118130979ba261080e960e44cdcb2d6e2ab8ca9b1da245285d353" +dependencies = [ + "bytes", + "google-cloud-wkt", + "serde", + "serde_json", + "serde_with 3.21.0", +] + +[[package]] +name = "google-cloud-type" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63acc3a92a85f96bab021c3a3e29b53bbacc97651e1b524d4c2991960a63eb82" +dependencies = [ + "bytes", + "google-cloud-wkt", + "serde", + "serde_json", + "serde_with 3.21.0", +] + +[[package]] +name = "google-cloud-wkt" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fccf98cfd5481a5f5a285181ab0c62123d7d47cd2bb7299448440649349e4e7" +dependencies = [ + "base64 0.22.1", + "bytes", + "serde", + "serde_json", + "serde_with 3.21.0", + "thiserror 2.0.19", + "time", + "url", +] + [[package]] name = "gpu-allocator" version = "0.28.0" @@ -7816,6 +8053,19 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-tls" version = "0.6.0" @@ -8745,6 +8995,22 @@ dependencies = [ "uuid-simd", ] +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "getrandom 0.2.17", + "js-sys", + "serde", + "serde_json", + "signature", + "zeroize", +] + [[package]] name = "kasuari" version = "0.4.12" @@ -10878,6 +11144,42 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "opentelemetry" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 2.0.19", + "tracing", +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c913ac17a6c451661ee255f4625d143e51647ae78ebd969b75e41c4442f4fe47" + +[[package]] +name = "opentelemetry_sdk" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "portable-atomic", + "rand 0.9.5", + "thiserror 2.0.19", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -12876,14 +13178,14 @@ dependencies = [ [[package]] name = "rig-bedrock" version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a40dec6d2833da0f482f97f105b846b46b82b8ab904b78f2365e263e016f09d" +source = "git+https://github.com/0xPlaygrounds/rig.git?rev=1f9547774edb4c269be991ac42eb043fd7b6e87f#1f9547774edb4c269be991ac42eb043fd7b6e87f" dependencies = [ "async-stream", "aws-config", "aws-sdk-bedrockruntime", "aws-smithy-types", "base64 0.22.1", + "futures", "rig-core", "rig-derive", "schemars 1.2.2", @@ -12898,8 +13200,7 @@ dependencies = [ [[package]] name = "rig-core" version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35f5520515ae8f6851adcbc6fde9eea8e96f657418c062e16c82cd81cce44e8e" +source = "git+https://github.com/0xPlaygrounds/rig.git?rev=1f9547774edb4c269be991ac42eb043fd7b6e87f#1f9547774edb4c269be991ac42eb043fd7b6e87f" dependencies = [ "as-any", "async-stream", @@ -12932,8 +13233,7 @@ dependencies = [ [[package]] name = "rig-derive" version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb868fcebdf3ba425e3afad2e4926bb6d9e1188a856843b00bcee2e15c07424f" +source = "git+https://github.com/0xPlaygrounds/rig.git?rev=1f9547774edb4c269be991ac42eb043fd7b6e87f#1f9547774edb4c269be991ac42eb043fd7b6e87f" dependencies = [ "convert_case 0.11.0", "proc-macro-crate 3.5.0", @@ -12942,6 +13242,22 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "rig-vertexai" +version = "0.41.0" +source = "git+https://github.com/0xPlaygrounds/rig.git?rev=1f9547774edb4c269be991ac42eb043fd7b6e87f#1f9547774edb4c269be991ac42eb043fd7b6e87f" +dependencies = [ + "base64 0.22.1", + "google-cloud-aiplatform-v1", + "google-cloud-auth", + "rig-core", + "serde", + "serde_json", + "thiserror 2.0.19", + "tokio", + "tracing", +] + [[package]] name = "ring" version = "0.17.14" @@ -12952,7 +13268,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -13401,7 +13717,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -15617,6 +15933,44 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "base64 0.22.1", + "bytes", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "rustls-native-certs", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + [[package]] name = "tower" version = "0.5.3" @@ -15625,9 +15979,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap 2.14.0", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -15730,6 +16087,20 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-opentelemetry" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adbc64cba7137545b8044cb1fe9814f7aacf3c6b5f9b45be8bb5db538befdb26" +dependencies = [ + "js-sys", + "opentelemetry", + "tracing", + "tracing-core", + "tracing-subscriber", + "web-time", +] + [[package]] name = "tracing-subscriber" version = "0.3.23" @@ -16127,6 +16498,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index df0520b2..465fdd4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -262,8 +262,9 @@ reqwest = { version = "0.13", features = [ "stream", ] } reqwest-eventsource = { package = "aha-reqwest-eventsource", version = "0.1" } -rig-core = "=0.41.0" -rig-bedrock = "=0.41.0" +rig-core = { git = "https://github.com/0xPlaygrounds/rig.git", rev = "1f9547774edb4c269be991ac42eb043fd7b6e87f", package = "rig-core" } +rig-bedrock = { git = "https://github.com/0xPlaygrounds/rig.git", rev = "1f9547774edb4c269be991ac42eb043fd7b6e87f", package = "rig-bedrock" } +rig-vertexai = { git = "https://github.com/0xPlaygrounds/rig.git", rev = "1f9547774edb4c269be991ac42eb043fd7b6e87f", package = "rig-vertexai" } resvg = "0.47.0" rust-embed = { version = "8.7.0", features = ["include-exclude"] } rustc-hash = "2.1.1" diff --git a/app/src/ai/blocklist/action_model/execute/shell_command.rs b/app/src/ai/blocklist/action_model/execute/shell_command.rs index 8c011369..7cab2294 100644 --- a/app/src/ai/blocklist/action_model/execute/shell_command.rs +++ b/app/src/ai/blocklist/action_model/execute/shell_command.rs @@ -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); diff --git a/app/src/ai/blocklist/action_model/execute/shell_command_tests.rs b/app/src/ai/blocklist/action_model/execute/shell_command_tests.rs index 2120a296..9c5b63bc 100644 --- a/app/src/ai/blocklist/action_model/execute/shell_command_tests.rs +++ b/app/src/ai/blocklist/action_model/execute/shell_command_tests.rs @@ -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) + })); }); } diff --git a/app/src/ai/blocklist/block/cli_controller.rs b/app/src/ai/blocklist/block/cli_controller.rs index 62cbe7f1..7a7e8354 100644 --- a/app/src/ai/blocklist/block/cli_controller.rs +++ b/app/src/ai/blocklist/block/cli_controller.rs @@ -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) { + fn finish_subagent( + &mut self, + block_id: &BlockId, + conversation_id: Option, + initial_requested_command_action_id: Option, + ctx: &mut ModelContext, + ) { 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))); + } +} diff --git a/app/src/ai/blocklist/controller/response_stream.rs b/app/src/ai/blocklist/controller/response_stream.rs index f6395947..8c1f2c49 100644 --- a/app/src/ai/blocklist/controller/response_stream.rs +++ b/app/src/ai/blocklist/controller/response_stream.rs @@ -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() diff --git a/app/src/ai/crosscheck/reviewer.rs b/app/src/ai/crosscheck/reviewer.rs index 36b314ba..f40112c1 100644 --- a/app/src/ai/crosscheck/reviewer.rs +++ b/app/src/ai/crosscheck/reviewer.rs @@ -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() diff --git a/app/src/ai/llms.rs b/app/src/ai/llms.rs index c6a4ac42..2a0d25b1 100644 --- a/app/src/ai/llms.rs +++ b/app/src/ai/llms.rs @@ -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, + Option, + Option, Vec, ); let mut provider_entries: Vec = 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 = 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, 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) -> Vec { + 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, diff --git a/app/src/ai/openai/client.rs b/app/src/ai/openai/client.rs index c4d00501..80771a54 100644 --- a/app/src/ai/openai/client.rs +++ b/app/src/ai/openai/client.rs @@ -11,6 +11,8 @@ pub struct OpenAIClientConfig { pub kind: OpenAIProviderKind, pub base_url: String, pub api_key: Option, + pub project_id: Option, + pub location: Option, pub model: Option, pub reasoning_effort: Option, pub max_input_tokens: Option, diff --git a/app/src/ai/runtime/rig.rs b/app/src/ai/runtime/rig.rs index 61edc6a5..ac49776d 100644 --- a/app/src/ai/runtime/rig.rs +++ b/app/src/ai/runtime/rig.rs @@ -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, + ) + } } } diff --git a/app/src/ai/runtime/rig_request_tests.rs b/app/src/ai/runtime/rig_request_tests.rs index 66077cab..5f0af4a2 100644 --- a/app/src/ai/runtime/rig_request_tests.rs +++ b/app/src/ai/runtime/rig_request_tests.rs @@ -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), diff --git a/app/src/drive/import/import_tests.rs b/app/src/drive/import/import_tests.rs index 3a677b8f..c061e8af 100644 --- a/app/src/drive/import/import_tests.rs +++ b/app/src/drive/import/import_tests.rs @@ -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() diff --git a/app/src/drive/import/modal.rs b/app/src/drive/import/modal.rs index 694582f2..d66bd180 100644 --- a/app/src/drive/import/modal.rs +++ b/app/src/drive/import/modal.rs @@ -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, diff --git a/app/src/drive/import/modal_body.rs b/app/src/drive/import/modal_body.rs index 15aa3fbd..f8f55753 100644 --- a/app/src/drive/import/modal_body.rs +++ b/app/src/drive/import/modal_body.rs @@ -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 { - 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 { - 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 { diff --git a/app/src/drive/import/queue.rs b/app/src/drive/import/queue.rs index 94e4be3b..5f9356ec 100644 --- a/app/src/drive/import/queue.rs +++ b/app/src/drive/import/queue.rs @@ -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, - client_to_server_id: HashMap>, + client_to_folder_id: HashMap>, client_to_node_folder_id: HashMap, file_completion: FileCompletionCounter, + remote_subscription_initialized: bool, } impl ImportQueue { - pub fn new(ctx: &mut ModelContext) -> 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) { + 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) { + 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; diff --git a/app/src/drive/import/queue_tests.rs b/app/src/drive/import/queue_tests.rs new file mode 100644 index 00000000..2e35c354 --- /dev/null +++ b/app/src/drive/import/queue_tests.rs @@ -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, + }, + File { + file_id: FileId, + object_id: Option, + }, + 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(¬ebook_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()), + })); + }); +} diff --git a/app/src/drive/index.rs b/app/src/drive/index.rs index fade691e..21819dec 100644 --- a/app/src/drive/index.rs +++ b/app/src/drive/index.rs @@ -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.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, diff --git a/app/src/drive/panel.rs b/app/src/drive/panel.rs index 4451c57a..ba5af756 100644 --- a/app/src/drive/panel.rs +++ b/app/src/drive/panel.rs @@ -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:?}"); diff --git a/app/src/drive/settings.rs b/app/src/drive/settings.rs index b2f047c1..36e8c40b 100644 --- a/app/src/drive/settings.rs +++ b/app/src/drive/settings.rs @@ -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 } } diff --git a/app/src/local_object_repository.rs b/app/src/local_object_repository.rs index 484bc9a7..90fd0a52 100644 --- a/app/src/local_object_repository.rs +++ b/app/src/local_object_repository.rs @@ -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 { + CloudModel::as_ref(app).get_folder(id).cloned() + } + + pub fn create_folder_with_id( + &mut self, + id: SyncId, + name: String, + parent_folder_id: Option, + ctx: &mut ModelContext, + ) { + 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, + ) -> 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, + ) -> 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) -> 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.set_folder_open_state(id, true, ctx); + } + + pub fn close_folder(&mut self, id: SyncId, ctx: &mut ModelContext) { + self.set_folder_open_state(id, false, ctx); + } + + pub fn toggle_folder_open(&mut self, id: SyncId, ctx: &mut ModelContext) { + 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, + ) { + 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::>() + }; + + 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) { + 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.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) { + 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) { 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) { + 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, + 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, diff --git a/app/src/local_object_repository_tests.rs b/app/src/local_object_repository_tests.rs index 8d43f2f6..6e25e0b5 100644 --- a/app/src/local_object_repository_tests.rs +++ b/app/src/local_object_repository_tests.rs @@ -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 { diff --git a/app/src/settings/ai.rs b/app/src/settings/ai.rs index e7e3ffa4..bf223a0c 100644 --- a/app/src/settings/ai.rs +++ b/app/src/settings/ai.rs @@ -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, #[serde(default)] + #[schemars(description = "Google Cloud project ID for Vertex AI providers.")] + pub project_id: Option, + #[serde(default)] + #[schemars(description = "Google Cloud location for Vertex AI providers.")] + pub location: Option, + #[serde(default)] #[schemars(description = "Models available from this provider.")] pub models: Vec, } @@ -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 { // 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(), diff --git a/app/src/settings/ai_tests.rs b/app/src/settings/ai_tests.rs index 47c65c33..21b0c215 100644 --- a/app/src/settings/ai_tests.rs +++ b/app/src/settings/ai_tests.rs @@ -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); diff --git a/app/src/settings_view/ai_page.rs b/app/src/settings_view/ai_page.rs index e02e4e38..5452c43d 100644 --- a/app/src/settings_view/ai_page.rs +++ b/app/src/settings_view/ai_page.rs @@ -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( diff --git a/app/src/settings_view/mod.rs b/app/src/settings_view/mod.rs index b841405a..31b07e90 100644 --- a/app/src/settings_view/mod.rs +++ b/app/src/settings_view/mod.rs @@ -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, - ) { - match event { - warp_drive_page::WarpDriveSettingsPageEvent::SignUp => { - ctx.emit(SettingsViewEvent::SignupAnonymousUser) - } - } - } - fn handle_ai_page_event(&mut self, event: &AISettingsPageEvent, ctx: &mut ViewContext) { match event { AISettingsPageEvent::FocusModal => ctx.focus(&self.search_editor), diff --git a/app/src/settings_view/provider_setup_modal.rs b/app/src/settings_view/provider_setup_modal.rs index 6a9d18d1..7e1736c7 100644 --- a/app/src/settings_view/provider_setup_modal.rs +++ b/app/src/settings_view/provider_setup_modal.rs @@ -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, + draft_project_id: String, + draft_location: String, draft_models: Vec, draft_bedrock: BedrockProviderDraft, draft_acp: AcpProviderDraft, @@ -122,6 +165,8 @@ pub struct ProviderSetupModalBody { name_editor: ViewHandle, base_url_editor: ViewHandle, api_key_editor: ViewHandle, + project_id_editor: ViewHandle, + location_editor: ViewHandle, bedrock_profile_editor: ViewHandle, bedrock_region_editor: ViewHandle, bedrock_refresh_command_editor: ViewHandle, @@ -134,6 +179,7 @@ pub struct ProviderSetupModalBody { bedrock_cross_region_toggle: SwitchStateHandle, bedrock_auto_login_toggle: SwitchStateHandle, model_switches: Vec, + provider_type_scroll_state: ClippedScrollStateHandle, models_scroll_state: ClippedScrollStateHandle, back_button: ViewHandle, cancel_button: ViewHandle, @@ -142,35 +188,28 @@ pub struct ProviderSetupModalBody { impl ProviderSetupModalBody { pub fn new(ctx: &mut ViewContext) -> 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) { - 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::>(); + 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::>(); + + 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", } diff --git a/app/src/settings_view/warp_drive_page.rs b/app/src/settings_view/warp_drive_page.rs index 4ab5705c..21a1d24c 100644 --- a/app/src/settings_view/warp_drive_page.rs +++ b/app/src/settings_view/warp_drive_page.rs @@ -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( 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( ); } -pub enum WarpDriveSettingsPageEvent { - SignUp, -} - pub struct WarpDriveSettingsPageView { page: PageType, } @@ -66,10 +56,7 @@ impl WarpDriveSettingsPageView { pub fn new(_ctx: &mut ViewContext) -> 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> 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 { - 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 { 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::( "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()), diff --git a/crates/galaxy_agent_rig/Cargo.toml b/crates/galaxy_agent_rig/Cargo.toml index f7687ed8..796bc5e5 100644 --- a/crates/galaxy_agent_rig/Cargo.toml +++ b/crates/galaxy_agent_rig/Cargo.toml @@ -14,6 +14,7 @@ futures.workspace = true galaxy_agent_core.workspace = true rig-core.workspace = true rig-bedrock.workspace = true +rig-vertexai.workspace = true serde_json.workspace = true uuid.workspace = true diff --git a/crates/galaxy_agent_rig/src/bedrock_tests.rs b/crates/galaxy_agent_rig/src/bedrock_tests.rs index ac5428a7..7c314918 100644 --- a/crates/galaxy_agent_rig/src/bedrock_tests.rs +++ b/crates/galaxy_agent_rig/src/bedrock_tests.rs @@ -6,7 +6,7 @@ use galaxy_agent_core::{ StopReason, ToolDefinition, TurnCommand, TurnRequest, Usage, }; use rig_bedrock::streaming::{BedrockStreamingResponse, BedrockUsage}; -use rig_core::completion::{AssistantContent, CompletionError, GetTokenUsage, Message}; +use rig_core::completion::{AssistantContent, CompletionError, Message}; use rig_core::message::{DocumentSourceKind, ToolResultContent, UserContent}; use super::*; @@ -78,9 +78,10 @@ fn normalizes_bedrock_usage_and_max_token_stop() { cache_read_input_tokens: Some(40), cache_write_input_tokens: Some(10), }), + stop_reason: None, }; assert_eq!( - map_usage(response.token_usage()), + map_usage((&response).into()), Usage { input_tokens: 100, output_tokens: 25, diff --git a/crates/galaxy_agent_rig/src/lib.rs b/crates/galaxy_agent_rig/src/lib.rs index 2ce918d7..db457f79 100644 --- a/crates/galaxy_agent_rig/src/lib.rs +++ b/crates/galaxy_agent_rig/src/lib.rs @@ -2,10 +2,12 @@ mod bedrock; mod chatgpt; +mod native; mod openai_compatible; mod request; mod stream; pub use bedrock::*; pub use chatgpt::*; +pub use native::*; pub use openai_compatible::*; diff --git a/crates/galaxy_agent_rig/src/native.rs b/crates/galaxy_agent_rig/src/native.rs new file mode 100644 index 00000000..f20ab763 --- /dev/null +++ b/crates/galaxy_agent_rig/src/native.rs @@ -0,0 +1,256 @@ +use async_trait::async_trait; +use galaxy_agent_core::{ + AgentError, AgentErrorKind, AgentEventStream, AgentRuntime, RuntimeCapabilities, + RuntimeDescriptor, RuntimeKind, TurnControl, TurnRequest, +}; +use rig_core::client::{CompletionClient, ModelListingClient}; +use rig_core::providers::{anthropic, gemini}; + +use crate::request::build_completion_request; +use crate::stream::{start_model_completion, start_model_turn}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RigModelInfo { + pub id: String, + pub display_name: String, + pub context_size: Option, +} + +pub async fn discover_anthropic_models(api_key: &str) -> Result, String> { + let client = anthropic::Client::new(api_key).map_err(|error| error.to_string())?; + let models = client + .list_models() + .await + .map_err(|error| error.to_string())?; + Ok(models + .into_iter() + .map(|model| RigModelInfo { + display_name: model.display_name().to_string(), + id: model.id, + context_size: model.context_length, + }) + .collect()) +} + +pub async fn discover_gemini_models(api_key: &str) -> Result, String> { + let client = gemini::Client::new(api_key).map_err(|error| error.to_string())?; + let models = client + .list_models() + .await + .map_err(|error| error.to_string())?; + Ok(models + .into_iter() + .filter(|model| !model.id.contains("embedding")) + .map(|model| RigModelInfo { + display_name: model.display_name().to_string(), + id: model.id, + context_size: model.context_length, + }) + .collect()) +} + +pub fn vertex_ai_model_catalog() -> Vec { + [ + (rig_vertexai::completion::GEMINI_2_5_PRO, "Gemini 2.5 Pro"), + ( + rig_vertexai::completion::GEMINI_2_5_FLASH, + "Gemini 2.5 Flash", + ), + ( + rig_vertexai::completion::GEMINI_2_5_FLASH_LITE, + "Gemini 2.5 Flash Lite", + ), + (rig_vertexai::completion::GEMINI_1_5_PRO, "Gemini 1.5 Pro"), + ( + rig_vertexai::completion::GEMINI_1_5_FLASH, + "Gemini 1.5 Flash", + ), + ] + .into_iter() + .map(|(id, display_name)| RigModelInfo { + id: id.to_string(), + display_name: display_name.to_string(), + context_size: None, + }) + .collect() +} + +pub fn validate_vertex_ai_credentials(project_id: &str, location: &str) -> Result<(), String> { + rig_vertexai::Client::builder() + .with_project(project_id) + .with_location(location) + .build() + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +#[derive(Clone, Debug)] +pub struct AnthropicRuntimeConfig { + pub api_key: String, + pub model: String, + pub max_output_tokens: Option, +} + +#[derive(Clone, Debug)] +pub struct AnthropicRuntime { + config: AnthropicRuntimeConfig, + descriptor: RuntimeDescriptor, +} + +impl AnthropicRuntime { + pub fn new(config: AnthropicRuntimeConfig) -> Self { + let descriptor = native_descriptor("anthropic", &config.model); + Self { config, descriptor } + } +} + +#[async_trait] +impl AgentRuntime for AnthropicRuntime { + fn descriptor(&self) -> &RuntimeDescriptor { + &self.descriptor + } + + async fn start_turn( + &self, + request: TurnRequest, + control: TurnControl, + ) -> Result { + if self.config.api_key.trim().is_empty() { + return Err(AgentError::new( + AgentErrorKind::Configuration, + "An Anthropic API key is required", + )); + } + let client = anthropic::Client::new(&self.config.api_key) + .map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?; + let model = client.completion_model(&self.config.model); + let max_output_tokens = request.max_output_tokens.or(self.config.max_output_tokens); + let completion_request = + build_completion_request(request, max_output_tokens, true, true, None)?; + start_model_turn(model, completion_request, control, max_output_tokens).await + } +} + +#[derive(Clone, Debug)] +pub struct GeminiRuntimeConfig { + pub api_key: String, + pub model: String, + pub max_output_tokens: Option, +} + +#[derive(Clone, Debug)] +pub struct GeminiRuntime { + config: GeminiRuntimeConfig, + descriptor: RuntimeDescriptor, +} + +impl GeminiRuntime { + pub fn new(config: GeminiRuntimeConfig) -> Self { + let descriptor = native_descriptor("gemini", &config.model); + Self { config, descriptor } + } +} + +#[async_trait] +impl AgentRuntime for GeminiRuntime { + fn descriptor(&self) -> &RuntimeDescriptor { + &self.descriptor + } + + async fn start_turn( + &self, + request: TurnRequest, + control: TurnControl, + ) -> Result { + if self.config.api_key.trim().is_empty() { + return Err(AgentError::new( + AgentErrorKind::Configuration, + "A Gemini API key is required", + )); + } + let client = gemini::Client::new(&self.config.api_key) + .map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?; + let model = client.completion_model(&self.config.model); + let max_output_tokens = request.max_output_tokens.or(self.config.max_output_tokens); + let completion_request = + build_completion_request(request, max_output_tokens, true, true, None)?; + start_model_turn(model, completion_request, control, max_output_tokens).await + } +} + +#[derive(Clone, Debug)] +pub struct VertexAiRuntimeConfig { + pub project_id: String, + pub location: String, + pub model: String, + pub max_output_tokens: Option, +} + +#[derive(Clone, Debug)] +pub struct VertexAiRuntime { + config: VertexAiRuntimeConfig, + descriptor: RuntimeDescriptor, +} + +impl VertexAiRuntime { + pub fn new(config: VertexAiRuntimeConfig) -> Self { + let descriptor = native_descriptor("vertex-ai", &config.model); + Self { config, descriptor } + } +} + +#[async_trait] +impl AgentRuntime for VertexAiRuntime { + fn descriptor(&self) -> &RuntimeDescriptor { + &self.descriptor + } + + async fn start_turn( + &self, + request: TurnRequest, + control: TurnControl, + ) -> Result { + if self.config.project_id.trim().is_empty() { + return Err(AgentError::new( + AgentErrorKind::Configuration, + "A Google Cloud project ID is required for Vertex AI", + )); + } + let client = rig_vertexai::Client::builder() + .with_project(&self.config.project_id) + .with_location(if self.config.location.trim().is_empty() { + "global" + } else { + &self.config.location + }) + .build() + .map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?; + let model = client.completion_model(&self.config.model); + let max_output_tokens = request.max_output_tokens.or(self.config.max_output_tokens); + let completion_request = + build_completion_request(request, max_output_tokens, true, true, None)?; + start_model_completion(model, completion_request, control, max_output_tokens).await + } +} + +fn native_descriptor(provider: &str, model: &str) -> RuntimeDescriptor { + RuntimeDescriptor { + id: format!("rig-{provider}:{model}"), + display_name: format!("Rig / {provider} / {model}"), + kind: RuntimeKind::Provider, + capabilities: RuntimeCapabilities::provider(), + } +} + +#[cfg(test)] +mod tests { + use super::native_descriptor; + + #[test] + fn native_descriptors_are_provider_specific() { + let descriptor = native_descriptor("anthropic", "claude-sonnet"); + + assert_eq!(descriptor.id, "rig-anthropic:claude-sonnet"); + assert_eq!(descriptor.display_name, "Rig / anthropic / claude-sonnet"); + } +} diff --git a/crates/galaxy_agent_rig/src/openai_compatible.rs b/crates/galaxy_agent_rig/src/openai_compatible.rs index 7f06854d..9dfc9dee 100644 --- a/crates/galaxy_agent_rig/src/openai_compatible.rs +++ b/crates/galaxy_agent_rig/src/openai_compatible.rs @@ -76,7 +76,6 @@ async fn start_model_turn( ) -> Result where M: CompletionModel + Send + Sync + 'static, - M::StreamingResponse: Send + Sync + 'static, { let max_output_tokens = request.max_output_tokens.or(configured_max_output_tokens); let completion_request = build_completion_request( diff --git a/crates/galaxy_agent_rig/src/stream.rs b/crates/galaxy_agent_rig/src/stream.rs index 0f6f0b9b..53ccacc8 100644 --- a/crates/galaxy_agent_rig/src/stream.rs +++ b/crates/galaxy_agent_rig/src/stream.rs @@ -3,7 +3,7 @@ use galaxy_agent_core::{ AgentError, AgentErrorKind, AgentEvent, AgentEventStream, StopReason, ToolCall, TurnCommand, TurnControl, Usage, }; -use rig_core::completion::{CompletionError, CompletionModel, CompletionRequest, GetTokenUsage}; +use rig_core::completion::{CompletionError, CompletionModel, CompletionRequest}; use rig_core::streaming::StreamedAssistantContent; use uuid::Uuid; @@ -15,7 +15,6 @@ pub(crate) async fn start_model_turn( ) -> Result where M: CompletionModel + Send + Sync + 'static, - M::StreamingResponse: Send + Sync + 'static, { let runtime_request_id = Uuid::new_v4().to_string(); let stream_future = model.stream(completion_request).fuse(); @@ -112,7 +111,7 @@ where // is the canonical event Galaxy consumes. } Ok(StreamedAssistantContent::Final(response)) => { - let mapped_usage = map_usage(response.token_usage()); + let mapped_usage = map_usage(response.usage); last_output_tokens = mapped_usage.output_tokens; yield Ok(AgentEvent::UsageUpdated { usage: mapped_usage, @@ -142,6 +141,82 @@ where Ok(Box::pin(events)) } +pub(crate) async fn start_model_completion( + model: M, + completion_request: CompletionRequest, + control: TurnControl, + max_output_tokens: Option, +) -> Result +where + M: CompletionModel + Send + Sync + 'static, +{ + let runtime_request_id = Uuid::new_v4().to_string(); + let completion_future = model.completion(completion_request).fuse(); + let initial_control = control.clone(); + let control_future = initial_control.receive().fuse(); + futures::pin_mut!(completion_future, control_future); + + let response = futures::select_biased! { + command = control_future => match command { + Ok(TurnCommand::Cancel) => { + return Ok(stopped_before_stream(runtime_request_id)); + } + Ok(TurnCommand::Steer { .. }) | Err(_) => { + completion_future.await.map_err(map_completion_error)? + } + }, + result = completion_future => result.map_err(map_completion_error)?, + }; + + let events = async_stream::stream! { + yield Ok(AgentEvent::TurnStarted { runtime_request_id }); + + for content in response.choice { + match content { + rig_core::completion::AssistantContent::Text(text) => { + if !text.text.is_empty() { + yield Ok(AgentEvent::TextDelta { text: text.text }); + } + } + rig_core::completion::AssistantContent::Reasoning(reasoning) => { + yield Ok(AgentEvent::ReasoningCompleted { + text: reasoning.display_text(), + signature: reasoning.first_signature().map(str::to_string), + }); + } + rig_core::completion::AssistantContent::ToolCall(tool_call) => { + yield Ok(AgentEvent::Tool { + event: galaxy_agent_core::ToolEvent::Proposed { + call: domain_tool_call(tool_call), + }, + }); + } + rig_core::completion::AssistantContent::Image(_) => { + yield Err(AgentError::new( + AgentErrorKind::Protocol, + "Rig returned an unsupported image completion content block", + )); + return; + } + } + } + + let usage = map_usage(response.usage); + let reached_max_tokens = max_output_tokens + .is_some_and(|max| usage.output_tokens >= max); + yield Ok(AgentEvent::UsageUpdated { usage }); + yield Ok(AgentEvent::TurnStopped { + reason: if reached_max_tokens { + StopReason::MaxTokens + } else { + StopReason::Completed + }, + }); + }; + + Ok(Box::pin(events)) +} + fn domain_tool_call(tool_call: rig_core::message::ToolCall) -> ToolCall { ToolCall { // OpenAI Responses uses a separate `call_id` for function-call output diff --git a/plans/galaxy-local-first-rig.md b/plans/galaxy-local-first-rig.md index ed9c3992..3e173ffc 100644 --- a/plans/galaxy-local-first-rig.md +++ b/plans/galaxy-local-first-rig.md @@ -123,8 +123,9 @@ The current UI can initially be kept alive with a temporary adapter from `AgentE ### `galaxy_agent_rig` Rig becomes the implementation for provider-backed conversations. The version evaluated for this -plan is Rig 0.40.0. When introduced, it must be pinned exactly until its documented breaking-change -cadence settles for Galaxy. +plan is Rig 0.41.0 from the pinned upstream revision +`1f9547774edb4c269be991ac42eb043fd7b6e87f`. When introduced, it must be pinned exactly until its +documented breaking-change cadence settles for Galaxy. Rig is a good fit for the provider side because it already defines a canonical completion request, provider implementations, streaming content/tool events, model history, typed tools, hooks, MCP via @@ -137,6 +138,9 @@ Provider coverage for the first migration: |---|---|---| | LiteLLM / generic OpenAI-compatible | Rig OpenAI-compatible client | Custom base URL and key; preserve per-model endpoint routing. | | Ollama / LM Studio | OpenAI-compatible or Rig provider adapter | Treat as explicit local/LAN endpoints. | +| Anthropic | Rig native Anthropic client | Discover models through Rig's native model-listing API and stream completions. | +| Google Gemini | Rig native Gemini client | Discover models through Rig's native model-listing API and stream completions. | +| Google Vertex AI | `rig-vertexai` companion crate | Use ADC, project/location configuration, and a bounded catalog because this Rig integration has no model-listing endpoint. | | AWS Bedrock | `rig-bedrock` through the Rig facade | Preserve profile, static credential, SSO, region, and inference-profile behavior through a focused compatibility audit. | | MCP tools | Rig `rmcp` tool server/client support | Reuse existing Galaxy MCP lifecycle where it is stronger; bridge tools at one boundary. | @@ -258,7 +262,8 @@ Bedrock/OpenAI/ACP configuration. ### Phase 2 — First Rig vertical slice: OpenAI-compatible streaming -- [x] Pin `rig-core` 0.40.0 and implement one explicit OpenAI-compatible provider. +- [x] Pin Rig 0.41.0 to one upstream revision and implement the explicit OpenAI-compatible, + ChatGPT subscription, Anthropic, Gemini, and Vertex AI provider runtimes. - [x] Support text, reasoning where available, cancellation, stop reason, usage, and persisted history. - [x] Route any model entry with `use_rig = true` through Rig while leaving unmarked models on the @@ -334,9 +339,9 @@ Galaxy's action model owns permission and execution lifecycle events; normalized Rig directly. The legacy Warp response envelope remains only around transcript/init/finished UI rendering and non-Rig compatibility runtimes, not in Rig's executable tool path. -### Phase 4 — Bedrock through Rig +### Phase 4 — Bedrock and native cloud providers through Rig -- [x] Pin `rig-bedrock` 0.40.0 and construct it from Galaxy's already-resolved AWS SDK client so +- [x] Pin `rig-bedrock` 0.41.0 and construct it from Galaxy's already-resolved AWS SDK client so profile, SSO, static-key, region, and egress ownership stay at Galaxy's explicit boundary. - [x] Resolve context markers, ARNs, existing inference profiles, and regional inference-profile prefixes before passing a model ID to Rig. @@ -348,12 +353,17 @@ rendering and non-Rig compatibility runtimes, not in Rig's executable tool path. normalization without contacting AWS. - [x] Preserve signed Bedrock reasoning blocks in Galaxy conversation history so adaptive-thinking tool-call turns can be replayed without losing their signatures. -- [x] Define the Rig 0.40 parity policy: Galaxy retains structured tool-result error state locally +- [x] Define the Rig 0.41 parity policy: Galaxy retains structured tool-result error state locally and sends an explicit `[ERROR]` result prefix because Rig core has no Bedrock status field; Rig owns system/message cache checkpoints, tool-schema caching is treated as an optimization, and one-hour cache-TTL requests stay on the compatibility runtime. - [x] Add a model-by-model Rig switch to the unified Models page and route opted-in Bedrock models through the same request, event, permission, history, and UI adapter as OpenAI-compatible models. +- [x] Add native Anthropic and Gemini providers with Rig-backed model discovery and streaming. +- [x] Add Vertex AI configuration with project/location and ADC validation, a bounded Rig-supported + Gemini catalog, and a non-streaming completion adapter for the current `rig-vertexai` integration. +- [x] Make the provider setup wizard's provider selector data-driven and independently scrollable so + adding the remaining Rig integrations does not expand the modal beyond the window. - [ ] Run opt-in live semantic comparisons for system prompts, images, tools, reasoning, usage, and context limits before selecting the Rig runtime for any configured Bedrock model. - Keep a short-lived compatibility fallback for unsupported Bedrock behavior, measured by tests. @@ -390,6 +400,14 @@ metadata. persistence to the local service while keeping process lifecycle and credentials separate. - [x] Make the OSS channel expose only the local Personal scope and resolve it to the stable local owner; remote-capable channels retain their existing workspace/shared-space behavior. +- [x] Move personal Galaxy Drive folder creation, rename, trash/untrash, deletion, and open-state + persistence behind `LocalObjectRepository`, including recursive local-folder deletion and nested + collapse behavior; shared/team folders retain the existing remote path. +- [x] Remove the account/signup gate from Galaxy Drive visibility and Settings controls so local + Drive remains usable while logged out; team-only actions retain their separate restrictions. +- [x] Move the retained Drive import flow onto local persistence for personal targets, including + local folder/notebook/workflow creation and progress reporting without remote `UpdateManager` + or `SyncQueue` dependencies; shared/team imports retain their remote path. - [ ] Replace remaining account/workspace ownership in kept content flows with local scopes. - [ ] Remove auth, teams, billing, referral, cloud sync, GraphQL, RTC, sharing, and remote-control UI. @@ -406,6 +424,10 @@ assigned to the phase that owns the affected flow before the related work is con output refresh and scrolling, failure/timeout handling, and restore behavior. Add deterministic unit coverage and a hermetic integration flow for a command that remains active while the agent continues running. + - [x] Stop takeover no longer starts a completion-assessment turn after the user cancels the + monitor. + - [x] Monitor teardown now clears orphaned in-memory state when completion metadata is missing. + - [x] Refresh requests ignore completed or no-longer-long-running blocks. - [ ] Open-source project presentation: structure the About page around Galaxy’s local-first identity, audit the repository’s license and third-party notices, and make the root metadata, contribution guidance, and license files agree on the final permissive license (currently the @@ -460,12 +482,10 @@ contract is what the UI and persistence observe. | Telemetry/remote logs/remote flags | Remove, not merely default-off. | | SSH and remote Git | Keep as explicit user-initiated remote development boundaries. | | Web/network agent tools | Disabled by default and permission-visible. | -| Rig dependency | Exact version pin with upgrade contract tests. | +| Rig dependency | Exact upstream revision pin with upgrade contract tests. | ## Immediate next vertical slice Continue Phase 6 by replacing remaining account/workspace ownership with local scopes and removing -cloud identity UI from kept flows. The next bounded implementation is to move the remaining Galaxy -Drive folder mutations (create, rename, trash, and open-state persistence) behind -`LocalObjectRepository` for OSS. Live Phase 4 Bedrock semantic comparisons remain an explicit +cloud identity UI from kept flows. Live Phase 4 Bedrock semantic comparisons remain an explicit opt-in validation task because they require configured AWS access.