Add Rig native model providers

This commit is contained in:
2026-08-06 15:03:00 -05:00
parent 634ce7ba00
commit 3fda5d414b
34 changed files with 2134 additions and 452 deletions
Generated
+385 -8
View File
@@ -1495,6 +1495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1"
dependencies = [ dependencies = [
"aws-lc-sys", "aws-lc-sys",
"untrusted 0.7.1",
"zeroize", "zeroize",
] ]
@@ -5986,6 +5987,7 @@ dependencies = [
"galaxy_agent_core", "galaxy_agent_core",
"rig-bedrock", "rig-bedrock",
"rig-core", "rig-core",
"rig-vertexai",
"serde_json", "serde_json",
"tokio", "tokio",
"uuid", "uuid",
@@ -7230,6 +7232,241 @@ dependencies = [
"gl_generator", "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]] [[package]]
name = "gpu-allocator" name = "gpu-allocator"
version = "0.28.0" version = "0.28.0"
@@ -7816,6 +8053,19 @@ dependencies = [
"tower-service", "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]] [[package]]
name = "hyper-tls" name = "hyper-tls"
version = "0.6.0" version = "0.6.0"
@@ -8745,6 +8995,22 @@ dependencies = [
"uuid-simd", "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]] [[package]]
name = "kasuari" name = "kasuari"
version = "0.4.12" version = "0.4.12"
@@ -10878,6 +11144,42 @@ dependencies = [
"vcpkg", "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]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@@ -12876,14 +13178,14 @@ dependencies = [
[[package]] [[package]]
name = "rig-bedrock" name = "rig-bedrock"
version = "0.41.0" version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/0xPlaygrounds/rig.git?rev=1f9547774edb4c269be991ac42eb043fd7b6e87f#1f9547774edb4c269be991ac42eb043fd7b6e87f"
checksum = "9a40dec6d2833da0f482f97f105b846b46b82b8ab904b78f2365e263e016f09d"
dependencies = [ dependencies = [
"async-stream", "async-stream",
"aws-config", "aws-config",
"aws-sdk-bedrockruntime", "aws-sdk-bedrockruntime",
"aws-smithy-types", "aws-smithy-types",
"base64 0.22.1", "base64 0.22.1",
"futures",
"rig-core", "rig-core",
"rig-derive", "rig-derive",
"schemars 1.2.2", "schemars 1.2.2",
@@ -12898,8 +13200,7 @@ dependencies = [
[[package]] [[package]]
name = "rig-core" name = "rig-core"
version = "0.41.0" version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/0xPlaygrounds/rig.git?rev=1f9547774edb4c269be991ac42eb043fd7b6e87f#1f9547774edb4c269be991ac42eb043fd7b6e87f"
checksum = "35f5520515ae8f6851adcbc6fde9eea8e96f657418c062e16c82cd81cce44e8e"
dependencies = [ dependencies = [
"as-any", "as-any",
"async-stream", "async-stream",
@@ -12932,8 +13233,7 @@ dependencies = [
[[package]] [[package]]
name = "rig-derive" name = "rig-derive"
version = "0.41.0" version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/0xPlaygrounds/rig.git?rev=1f9547774edb4c269be991ac42eb043fd7b6e87f#1f9547774edb4c269be991ac42eb043fd7b6e87f"
checksum = "eb868fcebdf3ba425e3afad2e4926bb6d9e1188a856843b00bcee2e15c07424f"
dependencies = [ dependencies = [
"convert_case 0.11.0", "convert_case 0.11.0",
"proc-macro-crate 3.5.0", "proc-macro-crate 3.5.0",
@@ -12942,6 +13242,22 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -12952,7 +13268,7 @@ dependencies = [
"cfg-if", "cfg-if",
"getrandom 0.2.17", "getrandom 0.2.17",
"libc", "libc",
"untrusted", "untrusted 0.9.0",
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
@@ -13401,7 +13717,7 @@ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
"untrusted", "untrusted 0.9.0",
] ]
[[package]] [[package]]
@@ -15617,6 +15933,44 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" 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]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.3"
@@ -15625,9 +15979,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-util", "futures-util",
"indexmap 2.14.0",
"pin-project-lite", "pin-project-lite",
"slab",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-util",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing", "tracing",
@@ -15730,6 +16087,20 @@ dependencies = [
"tracing-core", "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]] [[package]]
name = "tracing-subscriber" name = "tracing-subscriber"
version = "0.3.23" version = "0.3.23"
@@ -16127,6 +16498,12 @@ version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
+3 -2
View File
@@ -262,8 +262,9 @@ reqwest = { version = "0.13", features = [
"stream", "stream",
] } ] }
reqwest-eventsource = { package = "aha-reqwest-eventsource", version = "0.1" } reqwest-eventsource = { package = "aha-reqwest-eventsource", version = "0.1" }
rig-core = "=0.41.0" rig-core = { git = "https://github.com/0xPlaygrounds/rig.git", rev = "1f9547774edb4c269be991ac42eb043fd7b6e87f", package = "rig-core" }
rig-bedrock = "=0.41.0" 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" resvg = "0.47.0"
rust-embed = { version = "8.7.0", features = ["include-exclude"] } rust-embed = { version = "8.7.0", features = ["include-exclude"] }
rustc-hash = "2.1.1" rustc-hash = "2.1.1"
@@ -684,9 +684,11 @@ impl ShellCommandExecutor {
.force_refresh_senders .force_refresh_senders
.keys() .keys()
.find(|selector| { .find(|selector| {
selector selector.get_block(&terminal_model).is_some_and(|block| {
.get_block(&terminal_model) block.id() == block_id
.is_some_and(|block| block.id() == block_id) && block.is_active_and_long_running()
&& !block.finished()
})
}) })
.cloned(); .cloned();
drop(terminal_model); drop(terminal_model);
@@ -103,11 +103,14 @@ fn force_refresh_block_reports_and_resolves_matching_poll() {
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx) ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
}); });
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None))); 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 block_id = terminal_model.lock().active_block_id().clone();
let executor = app.add_model(|ctx| { let executor = app.add_model(|ctx| {
ShellCommandExecutor::new( ShellCommandExecutor::new(
active_session, active_session,
terminal_model, terminal_model.clone(),
&model_event_dispatcher, &model_event_dispatcher,
terminal_view_id, terminal_view_id,
ctx, ctx,
@@ -124,6 +127,17 @@ fn force_refresh_block_reports_and_resolves_matching_poll() {
}); });
assert!(matches!(rx.try_recv(), Ok(Some(())))); assert!(matches!(rx.try_recv(), Ok(Some(()))));
let (tx, _rx) = oneshot::channel();
executor.update(&mut app, |executor, _| {
executor
.force_refresh_senders
.insert(BlockSelector::Id(block_id.clone()), tx);
});
terminal_model.lock().finish_block();
assert!(executor.update(&mut app, |executor, _| {
!executor.force_refresh_block(&block_id)
}));
}); });
} }
+119 -24
View File
@@ -265,6 +265,8 @@ impl CLISubagentController {
let block_id = block.id().clone(); let block_id = block.id().clone();
let conversation_id = block.ai_conversation_id(); let conversation_id = block.ai_conversation_id();
let requested_command_action_id = block.requested_command_action_id().cloned(); 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) { let completion = match (&block_completed_event.block_type, conversation_id) {
(BlockType::User(completed), Some(conversation_id)) => { (BlockType::User(completed), Some(conversation_id)) => {
let command = if completed.command_with_obfuscated_secrets.is_empty() { let command = if completed.command_with_obfuscated_secrets.is_empty() {
@@ -310,17 +312,49 @@ impl CLISubagentController {
}; };
drop(terminal_model); 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; return;
}; };
if subagent_state.last_snapshot_at.is_some() { if has_last_snapshot {
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot); 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!( log::warn!(
"CLI monitor block {block_id:?} completed without final command metadata" "CLI monitor block {block_id:?} completed without final command metadata"
); );
me.finish_subagent(
&block_id,
conversation_id,
requested_command_action_id,
ctx,
);
return; return;
} }
me.advance_completed_subagent(&block_id, ctx); me.advance_completed_subagent(&block_id, ctx);
@@ -380,7 +414,12 @@ impl CLISubagentController {
} }
if completion.final_turn_started { 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; return;
} }
@@ -404,38 +443,55 @@ impl CLISubagentController {
} }
} }
fn finish_completed_subagent(&mut self, block_id: &BlockId, ctx: &mut ModelContext<Self>) { fn finish_subagent(
&mut self,
block_id: &BlockId,
conversation_id: Option<AIConversationId>,
initial_requested_command_action_id: Option<AIAgentActionId>,
ctx: &mut ModelContext<Self>,
) {
let Some(state) = self.active_subagents_by_block.remove(block_id) else { let Some(state) = self.active_subagents_by_block.remove(block_id) else {
return; return;
}; };
let Some(completion) = state.completion else { let conversation_id = conversation_id.or_else(|| {
return; 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 = if let Some(conversation_id) = conversation_id {
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| { let deactivate_result =
history_model.deactivate_cli_subagent_task_for_conversation( BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| {
block_id, history_model
completion.conversation_id, .deactivate_cli_subagent_task_for_conversation(block_id, conversation_id)
) });
}); if let Err(error) = deactivate_result {
if let Err(error) = deactivate_result { log::error!("Failed to deactivate CLI monitor for block {block_id:?}: {error:?}");
log::error!( }
"Failed to deactivate completed CLI monitor for block {block_id:?}: {error:?}"
);
} }
ctx.emit(CLISubagentEvent::FinishedSubagent { ctx.emit(CLISubagentEvent::FinishedSubagent {
block_id: block_id.clone(), block_id: block_id.clone(),
conversation_id: Some(completion.conversation_id), conversation_id,
initial_requested_command_action_id: completion.initial_requested_command_action_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| { agent_view_controller.update(ctx, |controller, ctx| {
let is_this_inline_conversation = controller.is_inline() let is_this_inline_conversation = controller.is_inline()
&& controller.agent_view_state().active_conversation_id() && controller.agent_view_state().active_conversation_id()
== Some(completion.conversation_id); == Some(conversation_id);
if is_this_inline_conversation { if is_this_inline_conversation {
controller.exit_agent_view(ctx); controller.exit_agent_view(ctx);
} }
@@ -919,3 +975,42 @@ fn command_finished_block_id(result: &AIAgentActionResultType) -> Option<&BlockI
| AIAgentActionResultType::WaitForEvents(_) => None, | AIAgentActionResultType::WaitForEvents(_) => None,
} }
} }
fn should_request_completion_assessment(
control_state: Option<&LongRunningCommandControlState>,
) -> bool {
!control_state
.and_then(LongRunningCommandControlState::user_take_over_reason)
.is_some_and(UserTakeOverReason::is_stop)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stop_takeover_does_not_request_a_completion_assessment() {
let state = LongRunningCommandControlState::User {
reason: UserTakeOverReason::Stop,
};
assert!(!should_request_completion_assessment(Some(&state)));
}
#[test]
fn non_stop_control_states_can_request_a_completion_assessment() {
let agent_state = LongRunningCommandControlState::Agent {
is_blocked: false,
should_hide_responses: false,
};
let transfer_state = LongRunningCommandControlState::User {
reason: UserTakeOverReason::TransferFromAgent {
reason: "needs user input".to_owned(),
},
};
assert!(should_request_completion_assessment(None));
assert!(should_request_completion_assessment(Some(&agent_state)));
assert!(should_request_completion_assessment(Some(&transfer_state)));
}
}
@@ -230,6 +230,8 @@ impl ResponseStream {
kind: client_config.kind, kind: client_config.kind,
base_url: client_config.base_url.clone(), base_url: client_config.base_url.clone(),
api_key: client_config.api_key.clone(), api_key: client_config.api_key.clone(),
project_id: client_config.project_id.clone(),
location: client_config.location.clone(),
model: client_config model: client_config
.model .model
.clone() .clone()
+2
View File
@@ -167,6 +167,8 @@ impl CrosscheckReviewer {
kind: client_config.kind, kind: client_config.kind,
base_url: client_config.base_url.clone(), base_url: client_config.base_url.clone(),
api_key: client_config.api_key.clone(), api_key: client_config.api_key.clone(),
project_id: client_config.project_id.clone(),
location: client_config.location.clone(),
model: client_config model: client_config
.model .model
.clone() .clone()
+105 -8
View File
@@ -5,6 +5,11 @@ use std::sync::{Arc, OnceLock};
use ai::api_keys::ApiKeyManager; use ai::api_keys::ApiKeyManager;
pub use ai::LLMId; 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::features::FeatureFlag;
use galaxy_core::ui::icons::Icon; use galaxy_core::ui::icons::Icon;
use galaxy_core::user_preferences::GetUserPreferences; use galaxy_core::user_preferences::GetUserPreferences;
@@ -1005,6 +1010,8 @@ impl LLMPreferences {
bool, bool,
String, String,
Option<String>, Option<String>,
Option<String>,
Option<String>,
Vec<OpenAIModelConfig>, Vec<OpenAIModelConfig>,
); );
let mut provider_entries: Vec<OpenAIProviderEntry> = Vec::new(); let mut provider_entries: Vec<OpenAIProviderEntry> = Vec::new();
@@ -1037,6 +1044,8 @@ impl LLMPreferences {
true, true,
base_url, base_url,
api_key, api_key,
None,
None,
single_provider_models, single_provider_models,
)); ));
} }
@@ -1047,11 +1056,19 @@ impl LLMPreferences {
.value() .value()
.iter() .iter()
.filter_map(|provider| { .filter_map(|provider| {
if !provider.enabled let missing_credentials = match provider.kind {
|| (provider.kind == OpenAIProviderKind::OpenAICompatible OpenAIProviderKind::OpenAICompatible => provider.base_url.trim().is_empty(),
&& provider.base_url.trim().is_empty()) OpenAIProviderKind::Anthropic | OpenAIProviderKind::Gemini => provider
|| provider.models.is_empty() .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; return None;
} }
Some(( Some((
@@ -1060,6 +1077,8 @@ impl LLMPreferences {
provider.enabled, provider.enabled,
provider.base_url.clone(), provider.base_url.clone(),
provider.api_key.clone(), provider.api_key.clone(),
provider.project_id.clone(),
provider.location.clone(),
provider.models.clone(), provider.models.clone(),
)) ))
}), }),
@@ -1071,8 +1090,16 @@ impl LLMPreferences {
let mut total_injected = 0; let mut total_injected = 0;
let mut seen_model_ids: HashSet<String> = HashSet::new(); let mut seen_model_ids: HashSet<String> = HashSet::new();
for (provider_name, provider_kind, provider_enabled, base_url, api_key, models) in for (
provider_entries provider_name,
provider_kind,
provider_enabled,
base_url,
api_key,
provider_project_id,
provider_location,
models,
) in provider_entries
{ {
if !provider_enabled { if !provider_enabled {
continue; continue;
@@ -1109,12 +1136,14 @@ impl LLMPreferences {
kind: provider_kind, kind: provider_kind,
base_url: base_url.clone(), base_url: base_url.clone(),
api_key: api_key.clone(), api_key: api_key.clone(),
project_id: provider_project_id.clone(),
location: provider_location.clone(),
model: Some(model.model_id.clone()), model: Some(model.model_id.clone()),
reasoning_effort: reasoning_effort.clone(), reasoning_effort: reasoning_effort.clone(),
max_input_tokens: Some(openai_model_context_size(model)), max_input_tokens: Some(openai_model_context_size(model)),
max_output_tokens: model.max_output_tokens, max_output_tokens: model.max_output_tokens,
use_rig: model.use_rig use_rig: model.use_rig
|| provider_kind == OpenAIProviderKind::ChatGPTSubscription, || !matches!(provider_kind, OpenAIProviderKind::OpenAICompatible),
supports_system_messages: model.supports_system_messages(), supports_system_messages: model.supports_system_messages(),
}; };
self.openai_provider_routing self.openai_provider_routing
@@ -1557,6 +1586,54 @@ impl LLMPreferences {
pub(crate) async fn discover_openai_provider_models( pub(crate) async fn discover_openai_provider_models(
provider: OpenAIProviderConfig, provider: OpenAIProviderConfig,
) -> Result<Vec<OpenAIModelConfig>, String> { ) -> Result<Vec<OpenAIModelConfig>, String> {
let native_models = match provider.kind {
OpenAIProviderKind::Anthropic => {
let api_key = provider
.api_key
.as_deref()
.filter(|key| !key.trim().is_empty())
.ok_or_else(|| {
"Enter an Anthropic API key before testing the connection.".to_string()
})?;
Some(discover_anthropic_models(api_key).await?)
}
OpenAIProviderKind::Gemini => {
let api_key = provider
.api_key
.as_deref()
.filter(|key| !key.trim().is_empty())
.ok_or_else(|| {
"Enter a Gemini API key before testing the connection.".to_string()
})?;
Some(discover_gemini_models(api_key).await?)
}
OpenAIProviderKind::VertexAI => {
if provider
.project_id
.as_deref()
.is_none_or(|project| project.trim().is_empty())
{
return Err(
"Enter a Google Cloud project ID before testing the connection."
.to_string(),
);
}
validate_vertex_ai_credentials(
provider.project_id.as_deref().unwrap_or_default(),
provider.location.as_deref().unwrap_or("global"),
)?;
Some(vertex_ai_model_catalog())
}
OpenAIProviderKind::OpenAICompatible | OpenAIProviderKind::ChatGPTSubscription => None,
};
if let Some(models) = native_models {
if models.is_empty() {
return Err("The provider responded, but no models were found.".to_string());
}
return Ok(Self::rig_models_to_openai_models(models));
}
if provider.base_url.trim().is_empty() { if provider.base_url.trim().is_empty() {
return Err("Enter a provider URL before testing the connection.".to_string()); return Err("Enter a provider URL before testing the connection.".to_string());
} }
@@ -1586,6 +1663,26 @@ impl LLMPreferences {
Ok(models) Ok(models)
} }
#[cfg(not(target_family = "wasm"))]
fn rig_models_to_openai_models(models: Vec<RigModelInfo>) -> Vec<OpenAIModelConfig> {
models
.into_iter()
.map(|model| OpenAIModelConfig {
model_id: model.id,
display_name: model.display_name,
vision_supported: false,
context_size: model.context_size.unwrap_or(128_000),
max_input_tokens: model.context_size,
max_output_tokens: None,
provider: None,
use_rig: true,
supports_system_messages: Some(true),
reasoning_efforts: Vec::new(),
enabled: true,
})
.collect()
}
/// Returns the `LLMInfo` for the base LLM to be used for an Agent Mode request. /// Returns the `LLMInfo` for the base LLM to be used for an Agent Mode request.
pub fn get_active_base_model<'a>( pub fn get_active_base_model<'a>(
&'a self, &'a self,
+2
View File
@@ -11,6 +11,8 @@ pub struct OpenAIClientConfig {
pub kind: OpenAIProviderKind, pub kind: OpenAIProviderKind,
pub base_url: String, pub base_url: String,
pub api_key: Option<String>, pub api_key: Option<String>,
pub project_id: Option<String>,
pub location: Option<String>,
pub model: Option<String>, pub model: Option<String>,
pub reasoning_effort: Option<String>, pub reasoning_effort: Option<String>,
pub max_input_tokens: Option<u32>, pub max_input_tokens: Option<u32>,
+49 -2
View File
@@ -8,8 +8,9 @@ use galaxy_agent_core::{
ToolCallDecision, ToolEvent, ToolPolicy, ToolResult, TurnCommand, ToolCallDecision, ToolEvent, ToolPolicy, ToolResult, TurnCommand,
}; };
use galaxy_agent_rig::{ use galaxy_agent_rig::{
ChatGPTSubscriptionRuntime, ChatGPTSubscriptionRuntimeConfig, OpenAICompatibleRuntime, AnthropicRuntime, AnthropicRuntimeConfig, ChatGPTSubscriptionRuntime,
OpenAICompatibleRuntimeConfig, ChatGPTSubscriptionRuntimeConfig, GeminiRuntime, GeminiRuntimeConfig, OpenAICompatibleRuntime,
OpenAICompatibleRuntimeConfig, VertexAiRuntime, VertexAiRuntimeConfig,
}; };
use uuid::Uuid; use uuid::Uuid;
use warp_multi_agent_api::ToolType; use warp_multi_agent_api::ToolType;
@@ -73,6 +74,52 @@ pub(crate) fn rig_openai_response_stream(
cancellation_rx, cancellation_rx,
) )
} }
OpenAIProviderKind::Anthropic => {
let runtime = AnthropicRuntime::new(AnthropicRuntimeConfig {
api_key: config.api_key.unwrap_or_default(),
model: model_id,
max_output_tokens: config.max_output_tokens.map(u64::from),
});
rig_response_stream(
runtime,
prepared,
skill_path_origin,
config.max_input_tokens,
"rig_anthropic",
cancellation_rx,
)
}
OpenAIProviderKind::Gemini => {
let runtime = GeminiRuntime::new(GeminiRuntimeConfig {
api_key: config.api_key.unwrap_or_default(),
model: model_id,
max_output_tokens: config.max_output_tokens.map(u64::from),
});
rig_response_stream(
runtime,
prepared,
skill_path_origin,
config.max_input_tokens,
"rig_gemini",
cancellation_rx,
)
}
OpenAIProviderKind::VertexAI => {
let runtime = VertexAiRuntime::new(VertexAiRuntimeConfig {
project_id: config.project_id.unwrap_or_default(),
location: config.location.unwrap_or_else(|| "global".to_string()),
model: model_id,
max_output_tokens: config.max_output_tokens.map(u64::from),
});
rig_response_stream(
runtime,
prepared,
skill_path_origin,
config.max_input_tokens,
"rig_vertex_ai",
cancellation_rx,
)
}
} }
} }
+2
View File
@@ -23,6 +23,8 @@ fn config() -> OpenAIClientConfig {
kind: crate::settings::OpenAIProviderKind::OpenAICompatible, kind: crate::settings::OpenAIProviderKind::OpenAICompatible,
base_url: "http://localhost:4000/v1".to_string(), base_url: "http://localhost:4000/v1".to_string(),
api_key: None, api_key: None,
project_id: None,
location: None,
model: Some("provider-model".to_string()), model: Some("provider-model".to_string()),
reasoning_effort: None, reasoning_effort: None,
max_input_tokens: Some(128_000), max_input_tokens: Some(128_000),
+1 -21
View File
@@ -1,32 +1,12 @@
use std::env::current_dir; use std::env::current_dir;
use galaxy_core::ui::appearance::Appearance;
use galaxyui::App; use galaxyui::App;
use super::expand_dirs; 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] #[test]
fn test_expand_directories() { fn test_expand_directories() {
App::test((), |mut app| async move { App::test((), |_| 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);
let directory = current_dir() let directory = current_dir()
.expect("current directory should exist") .expect("current directory should exist")
.parent() .parent()
+11 -5
View File
@@ -18,6 +18,7 @@ use super::modal_body::{ImportModalBody, ImportModalBodyAction, ImportModalBodyE
use crate::appearance::Appearance; use crate::appearance::Appearance;
use crate::cloud_object::model::persistence::CloudModel; use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::{CloudObject, Owner}; use crate::cloud_object::{CloudObject, Owner};
use crate::local_object_repository::local_owner;
use crate::server::ids::SyncId; use crate::server::ids::SyncId;
use crate::server::sync_queue::SyncQueue; use crate::server::sync_queue::SyncQueue;
use crate::themes::theme::GalaxyTheme; use crate::themes::theme::GalaxyTheme;
@@ -88,7 +89,8 @@ impl ImportModal {
let window_id = ctx.window_id(); let window_id = ctx.window_id();
let import_body_id = self.import_modal.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]; 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. // 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. // 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(); 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. // Convert to a Space for display, in case we're importing into a shared folder.
self.owner self.owner
.map(|owner| { .map(|owner| {
UserWorkspaces::as_ref(app) if owner == local_owner() {
.owner_to_space(owner, app) "Personal".to_string()
.name(app) } else {
UserWorkspaces::as_ref(app)
.owner_to_space(owner, app)
.name(app)
}
}) })
.unwrap_or_default(), .unwrap_or_default(),
0, 0,
+8 -4
View File
@@ -21,6 +21,7 @@ use super::nodes::{
use super::queue::{ImportQueue, ImportQueueArgs, ImportQueueEvent, ParentId, RequestContent}; use super::queue::{ImportQueue, ImportQueueArgs, ImportQueueEvent, ParentId, RequestContent};
use crate::appearance::Appearance; use crate::appearance::Appearance;
use crate::cloud_object::Owner; use crate::cloud_object::Owner;
use crate::local_object_repository::local_owner;
use crate::server::ids::{ClientId, SyncId}; use crate::server::ids::{ClientId, SyncId};
use crate::server::sync_queue::SyncQueue; use crate::server::sync_queue::SyncQueue;
use crate::ui_components::icons::Icon; use crate::ui_components::icons::Icon;
@@ -96,7 +97,7 @@ pub struct ImportModalBody {
impl ImportModalBody { impl ImportModalBody {
pub fn new(ctx: &mut ViewContext<Self>) -> Self { pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let import_queue = ctx.add_model(ImportQueue::new); let import_queue = ctx.add_model(|_| ImportQueue::new());
ctx.subscribe_to_model(&import_queue, |me, _, event, ctx| { ctx.subscribe_to_model(&import_queue, |me, _, event, ctx| {
me.handle_import_queue_event(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() { if !sync_queue_dequeueing && state.all_files_saved_locally() {
ctx.emit(ImportModalBodyEvent::AllFileSavedLocally); ctx.emit(ImportModalBodyEvent::AllFileSavedLocally);
@@ -177,7 +179,8 @@ impl ImportModalBody {
// Whether there is an active upload in progress (If all uploads are completed, // 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). // we don't consider the import modal upload to be in progress).
pub fn upload_in_progress(&self, app: &AppContext) -> bool { 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 { match &self.state {
ImportState::Upload => false, ImportState::Upload => false,
@@ -498,7 +501,8 @@ impl View for ImportModalBody {
} }
fn render(&self, app: &AppContext) -> Box<dyn Element> { fn render(&self, app: &AppContext) -> Box<dyn Element> {
let sync_queue_dequeueing = SyncQueue::as_ref(app).is_dequeueing(); let sync_queue_dequeueing =
self.owner != Some(local_owner()) && SyncQueue::as_ref(app).is_dequeueing();
let appearance = Appearance::as_ref(app); let appearance = Appearance::as_ref(app);
match &self.state { match &self.state {
+165 -77
View File
@@ -5,7 +5,7 @@ use galaxyui::{Entity, ModelContext, SingletonEntity};
use super::nodes::{self, FileId}; use super::nodes::{self, FileId};
use crate::cloud_object::model::persistence::CloudModel; use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::{CloudObjectEventEntrypoint, Owner}; 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::notebooks::CloudNotebookModel;
use crate::server::cloud_objects::update_manager::{ use crate::server::cloud_objects::update_manager::{
InitiatedBy, ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent, InitiatedBy, ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
@@ -94,31 +94,40 @@ impl FileCompletionCounter {
pub(super) struct ImportQueue { pub(super) struct ImportQueue {
queue: Vec<ImportQueueArgs>, queue: Vec<ImportQueueArgs>,
client_to_server_id: HashMap<ClientId, Option<FolderId>>, client_to_folder_id: HashMap<ClientId, Option<SyncId>>,
client_to_node_folder_id: HashMap<ClientId, nodes::FolderId>, client_to_node_folder_id: HashMap<ClientId, nodes::FolderId>,
file_completion: FileCompletionCounter, file_completion: FileCompletionCounter,
remote_subscription_initialized: bool,
} }
impl ImportQueue { impl ImportQueue {
pub fn new(ctx: &mut ModelContext<Self>) -> Self { pub fn new() -> Self {
Self {
queue: Vec::new(),
client_to_folder_id: HashMap::default(),
file_completion: Default::default(),
client_to_node_folder_id: HashMap::default(),
remote_subscription_initialized: false,
}
}
fn ensure_remote_subscription(&mut self, ctx: &mut ModelContext<Self>) {
if self.remote_subscription_initialized {
return;
}
let update_manager = UpdateManager::handle(ctx); let update_manager = UpdateManager::handle(ctx);
ctx.subscribe_to_model(&update_manager, |me, _, event, ctx| { ctx.subscribe_to_model(&update_manager, |me, _, event, ctx| {
me.handle_update_manager_event(event, ctx); me.handle_update_manager_event(event, ctx);
}); });
self.remote_subscription_initialized = true;
Self {
queue: Vec::new(),
client_to_server_id: HashMap::default(),
file_completion: Default::default(),
client_to_node_folder_id: HashMap::default(),
}
} }
// Whether all dependencies of an item has been sync-ed. // Whether all dependencies of an item has been sync-ed.
fn dependency_synced(&self, item: &ImportQueueArgs) -> bool { fn dependency_synced(&self, item: &ImportQueueArgs) -> bool {
match &item.parent_id { match &item.parent_id {
ParentId::FolderToUpload(id) => self ParentId::FolderToUpload(id) => self
.client_to_server_id .client_to_folder_id
.get(id) .get(id)
.map(|item| item.is_some()) .map(|item| item.is_some())
.unwrap_or(false), .unwrap_or(false),
@@ -128,6 +137,11 @@ impl ImportQueue {
// Enqueue a new request to the import queue. // Enqueue a new request to the import queue.
pub fn enqueue(&mut self, arg: ImportQueueArgs, ctx: &mut ModelContext<Self>) { pub fn enqueue(&mut self, arg: ImportQueueArgs, ctx: &mut ModelContext<Self>) {
let is_local = arg.owner == local_owner();
if !is_local {
self.ensure_remote_subscription(ctx);
}
// Update internal tracker of the object. // Update internal tracker of the object.
match &arg.content { match &arg.content {
RequestContent::Folder { RequestContent::Folder {
@@ -135,17 +149,23 @@ impl ImportQueue {
folder_id, 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); self.client_to_node_folder_id.insert(*client_id, *folder_id);
} }
RequestContent::Notebook { RequestContent::Notebook {
client_id, file_id, .. 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 { RequestContent::Workflow {
workflows, file_id, .. workflows, file_id, ..
} => { } => {
for (_, client_id) in workflows { if !is_local {
self.file_completion.add_entry(*client_id, *file_id); 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 dequeued_item = self.queue.remove(idx);
let parent_id = match dequeued_item.parent_id { let parent_id = match dequeued_item.parent_id {
ParentId::FolderToUpload(client_id) => Some(SyncId::ServerId( ParentId::FolderToUpload(client_id) => Some(
self.client_to_server_id self.client_to_folder_id
.get(&client_id) .get(&client_id)
.expect("Client id entry should exist") .expect("Client id entry should exist")
.expect("Server id entry should exist") .expect("Folder id entry should exist"),
.into(), ),
)),
ParentId::InitialFolder(folder_id) => folder_id, ParentId::InitialFolder(folder_id) => folder_id,
}; };
let is_local = dequeued_item.owner == local_owner();
match dequeued_item.content { match dequeued_item.content {
RequestContent::Folder { RequestContent::Folder {
name, client_id, .. name, client_id, ..
} => { } => {
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| { if is_local {
update_manager.create_folder( let local_id = SyncId::ClientId(client_id);
name, LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
dequeued_item.owner, repository.create_folder_with_id(local_id, name, parent_id, ctx);
client_id, });
parent_id, self.client_to_folder_id.insert(client_id, Some(local_id));
false, ctx.emit(ImportQueueEvent::FolderCompleted {
InitiatedBy::User, folder_id: self
ctx, .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 { RequestContent::Notebook {
title, title,
@@ -199,56 +235,104 @@ impl ImportQueue {
client_id, client_id,
file_id, file_id,
} => { } => {
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| { if is_local {
update_manager.create_notebook( let local_id = SyncId::ClientId(client_id);
client_id, LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
dequeued_item.owner, repository.create_notebook_with_id(
parent_id, local_id,
CloudNotebookModel { parent_id,
title, CloudNotebookModel {
data, title,
ai_document_id: None, data,
conversation_id: None, ai_document_id: None,
}, conversation_id: None,
CloudObjectEventEntrypoint::ImportModal, },
false, ctx,
ctx, );
); });
}); ctx.emit(ImportQueueEvent::FileCompleted {
ctx.emit(ImportQueueEvent::FileSavedLocally(file_id)); 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 { RequestContent::Workflow {
workflows, workflows,
workflow_enums, workflow_enums,
file_id, file_id,
} => { } => {
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| { if is_local {
// Create any new workflow enums let local_object_id = workflows
for (client_id, workflow_enum) in workflow_enums { .first()
update_manager.create_workflow_enum( .map(|(_, client_id)| SyncId::ClientId(*client_id).uid());
workflow_enum, LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
dequeued_item.owner, for (client_id, workflow_enum) in workflow_enums {
client_id, repository.create_workflow_enum_with_id(
CloudObjectEventEntrypoint::ImportModal, SyncId::ClientId(client_id),
false, workflow_enum,
ctx, 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 // Create the workflow
for (workflow, client_id) in workflows { for (workflow, client_id) in workflows {
update_manager.create_workflow( update_manager.create_workflow(
workflow, workflow,
dequeued_item.owner, dequeued_item.owner,
parent_id, parent_id,
client_id, client_id,
CloudObjectEventEntrypoint::ImportModal, CloudObjectEventEntrypoint::ImportModal,
false, false,
ctx, ctx,
); );
} }
}); });
ctx.emit(ImportQueueEvent::FileSavedLocally(file_id)); ctx.emit(ImportQueueEvent::FileSavedLocally(file_id));
}
} }
} }
self.dequeue(ctx); self.dequeue(ctx);
@@ -294,14 +378,14 @@ impl ImportQueue {
let Some(folder_id) = cloud_model let Some(folder_id) = cloud_model
.get_folder_by_uid(&result.server_id.expect("Expect id").uid()) .get_folder_by_uid(&result.server_id.expect("Expect id").uid())
.and_then(|folder| folder.id.into_server()) .map(|folder| folder.id)
else { else {
return; 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() => { Some(value) if value.is_none() => {
*value = Some(folder_id.into()); *value = Some(folder_id);
true true
} }
_ => false, _ => false,
@@ -323,3 +407,7 @@ impl ImportQueue {
impl Entity for ImportQueue { impl Entity for ImportQueue {
type Event = ImportQueueEvent; type Event = ImportQueueEvent;
} }
#[cfg(test)]
#[path = "queue_tests.rs"]
mod tests;
+193
View File
@@ -0,0 +1,193 @@
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use galaxyui::{App, SingletonEntity};
use super::*;
use crate::cloud_object::model::persistence::CloudModel;
use crate::local_object_repository::local_owner;
use crate::server::ids::ClientId;
use crate::workflows::workflow_enum::{EnumVariants, WorkflowEnum};
#[derive(Debug, PartialEq, Eq)]
enum EventKind {
Folder {
folder_id: nodes::FolderId,
object_id: Option<String>,
},
File {
file_id: FileId,
object_id: Option<String>,
},
FileSavedLocally(FileId),
}
#[test]
fn local_import_queue_persists_nested_content_and_reports_completion() {
App::test((), |mut app| async move {
app.add_singleton_model(|_| CloudModel::new(None, Vec::new(), None));
app.add_singleton_model(|ctx| {
crate::local_object_repository::LocalObjectRepository::new(None, None, ctx)
});
let queue = app.add_model(|_| ImportQueue::new());
let events = Rc::new(RefCell::new(Vec::new()));
let events_for_subscription = events.clone();
app.update(|ctx| {
ctx.subscribe_to_model(&queue, move |_, event: &ImportQueueEvent, _| {
let event = match event {
ImportQueueEvent::FolderCompleted {
folder_id,
server_id,
} => EventKind::Folder {
folder_id: *folder_id,
object_id: server_id.clone(),
},
ImportQueueEvent::FileCompleted { file_id, server_id } => EventKind::File {
file_id: *file_id,
object_id: server_id.clone(),
},
ImportQueueEvent::FileSavedLocally(file_id) => {
EventKind::FileSavedLocally(*file_id)
}
};
events_for_subscription.borrow_mut().push(event);
});
});
let parent_client_id = ClientId::new();
let child_client_id = ClientId::new();
let notebook_client_id = ClientId::new();
let workflow_client_id = ClientId::new();
let workflow_enum_client_id = ClientId::new();
queue.update(&mut app, |queue, ctx| {
queue.enqueue(
ImportQueueArgs {
owner: local_owner(),
parent_id: ParentId::InitialFolder(None),
content: RequestContent::Folder {
name: "Imported".to_string(),
client_id: parent_client_id,
folder_id: nodes::FolderId::from(1),
},
},
ctx,
);
queue.enqueue(
ImportQueueArgs {
owner: local_owner(),
parent_id: ParentId::FolderToUpload(parent_client_id),
content: RequestContent::Folder {
name: "Nested".to_string(),
client_id: child_client_id,
folder_id: nodes::FolderId::from(2),
},
},
ctx,
);
queue.enqueue(
ImportQueueArgs {
owner: local_owner(),
parent_id: ParentId::FolderToUpload(child_client_id),
content: RequestContent::Notebook {
title: "Imported notes".to_string(),
data: "hello".to_string(),
client_id: notebook_client_id,
file_id: FileId(0),
},
},
ctx,
);
queue.enqueue(
ImportQueueArgs {
owner: local_owner(),
parent_id: ParentId::FolderToUpload(child_client_id),
content: RequestContent::Workflow {
workflows: vec![(
crate::workflows::workflow::Workflow::new(
"Imported workflow",
"echo imported",
),
workflow_client_id,
)],
workflow_enums: HashMap::from([(
workflow_enum_client_id,
WorkflowEnum {
name: "Environment".to_string(),
is_shared: false,
variants: EnumVariants::Static(vec!["dev".to_string()]),
},
)]),
file_id: FileId(1),
},
},
ctx,
);
});
let parent_id = SyncId::ClientId(parent_client_id);
let child_id = SyncId::ClientId(child_client_id);
let notebook_id = SyncId::ClientId(notebook_client_id);
let workflow_id = SyncId::ClientId(workflow_client_id);
let workflow_enum_id = SyncId::ClientId(workflow_enum_client_id);
app.update(|ctx| {
let cloud_model = CloudModel::as_ref(ctx);
let parent = cloud_model.get_folder(&parent_id).expect("parent folder");
assert_eq!(parent.permissions.owner, local_owner());
assert_eq!(
cloud_model
.get_folder(&child_id)
.unwrap()
.metadata
.folder_id,
Some(parent_id)
);
assert_eq!(
cloud_model
.get_notebook(&notebook_id)
.unwrap()
.metadata
.folder_id,
Some(child_id)
);
assert_eq!(
cloud_model
.get_workflow(&workflow_id)
.unwrap()
.metadata
.folder_id,
Some(child_id)
);
assert_eq!(
cloud_model
.get_workflow_enum(&workflow_enum_id)
.unwrap()
.model()
.string_model
.name,
"Environment"
);
});
let events = events.borrow();
assert!(events.contains(&EventKind::Folder {
folder_id: nodes::FolderId::from(1),
object_id: Some(parent_id.uid()),
}));
assert!(events.contains(&EventKind::Folder {
folder_id: nodes::FolderId::from(2),
object_id: Some(child_id.uid()),
}));
assert!(events.contains(&EventKind::File {
file_id: FileId(0),
object_id: Some(notebook_id.uid()),
}));
assert!(events.contains(&EventKind::File {
file_id: FileId(1),
object_id: Some(workflow_id.uid()),
}));
});
}
+100 -21
View File
@@ -68,7 +68,7 @@ use crate::drive::panel::DrivePanelAction;
use crate::editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions}; use crate::editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions};
use crate::env_vars::CloudEnvVarCollection; use crate::env_vars::CloudEnvVarCollection;
use crate::features::FeatureFlag; 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::menu::{Event, Menu, MenuItem, MenuItemFields};
use crate::network::NetworkStatus; use crate::network::NetworkStatus;
use crate::notebooks::CloudNotebookModel; use crate::notebooks::CloudNotebookModel;
@@ -1064,6 +1064,12 @@ impl DriveIndex {
NetworkStatus::as_ref(app).is_online() 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>) { pub fn scroll_item_into_view(&mut self, item_id: WarpDriveItemId, ctx: &mut ViewContext<Self>) {
self.clipped_scroll_state.scroll_to_position(ScrollTarget { self.clipped_scroll_state.scroll_to_position(ScrollTarget {
position_id: item_id.drive_row_position_id(), position_id: item_id.drive_row_position_id(),
@@ -3330,9 +3336,15 @@ impl DriveIndex {
match new_location { match new_location {
CloudObjectLocation::Space(space) => self.open_section_of_space(space), CloudObjectLocation::Space(space) => self.open_section_of_space(space),
CloudObjectLocation::Folder(folder_id) => { CloudObjectLocation::Folder(folder_id) => {
cloud_model.update(ctx, |cloud_model, ctx| { if Self::is_local_folder(&folder_id, ctx) {
cloud_model.open_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 // If location is the trash, then the above move_[object]_to_location call already trashed the object
CloudObjectLocation::Trash => {} CloudObjectLocation::Trash => {}
@@ -3508,9 +3520,15 @@ impl DriveIndex {
if !new_name.is_empty() { if !new_name.is_empty() {
self.reset_menus(ctx); self.reset_menus(ctx);
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| { if Self::is_local_folder(&folder_id, ctx) {
update_manager.rename_folder(folder_id, new_name, 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); self.cloud_object_naming_dialog.close(ctx);
ctx.notify(); ctx.notify();
@@ -3542,6 +3560,11 @@ impl DriveIndex {
repository.set_env_var_collection_trashed(id, true, ctx); 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 { .. } => { CloudObjectTypeAndId::Folder(_) | CloudObjectTypeAndId::GenericStringObject { .. } => {
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| { UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
update_manager.trash_object(cloud_object_type_and_id, ctx); update_manager.trash_object(cloud_object_type_and_id, ctx);
@@ -3585,6 +3608,14 @@ impl DriveIndex {
ctx.notify(); ctx.notify();
return; 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 { .. } => {} CloudObjectTypeAndId::Folder(_) | CloudObjectTypeAndId::GenericStringObject { .. } => {}
} }
@@ -3736,6 +3767,11 @@ impl DriveIndex {
repository.delete_env_var_collection(*id, ctx); 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 { .. } => { CloudObjectTypeAndId::Folder(_) | CloudObjectTypeAndId::GenericStringObject { .. } => {
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| { UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
update_manager.delete_object_by_user(*cloud_object_type_and_id, ctx); update_manager.delete_object_by_user(*cloud_object_type_and_id, ctx);
@@ -5088,14 +5124,35 @@ impl DriveIndex {
} }
} }
CloudObjectTypeAndId::Folder(id) => { CloudObjectTypeAndId::Folder(id) => {
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| match key { if Self::is_local_folder(id, ctx) {
DriveIndexAction::EnterKey => { LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
cloud_model.toggle_folder_open(*id, ctx); match key {
} DriveIndexAction::EnterKey => {
DriveIndexAction::LeftArrowKey => cloud_model.close_folder(*id, ctx), repository.toggle_folder_open(*id, ctx);
DriveIndexAction::RightArrowKey => cloud_model.open_folder(*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: _ } => { CloudObjectTypeAndId::GenericStringObject { object_type, id: _ } => {
if let GenericStringObjectFormat::Json(JsonObjectType::EnvVarCollection) = if let GenericStringObjectFormat::Json(JsonObjectType::EnvVarCollection) =
@@ -5556,14 +5613,36 @@ impl TypedActionView for DriveIndex {
ctx, ctx,
); );
} }
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| { if Self::is_local_folder(id, ctx) {
cloud_model.toggle_folder_open(*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) => { DriveIndexAction::CollapseAllInLocation(location) => {
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| { if let CloudObjectLocation::Folder(folder_id) = location {
cloud_model.collapse_all_in_location(*location, self.index_variant, ctx); 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 { DriveIndexAction::TrashObject {
cloud_object_type_and_id, cloud_object_type_and_id,
+22 -11
View File
@@ -178,17 +178,28 @@ impl DrivePanel {
} => match Self::new_object_owner(*space, initial_folder_id.as_ref(), ctx) { } => match Self::new_object_owner(*space, initial_folder_id.as_ref(), ctx) {
Some(owner) => { Some(owner) => {
let client_id = ClientId::default(); let client_id = ClientId::default();
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| { if owner == local_owner() {
update_manager.create_folder( LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
title.clone(), repository.create_folder_with_id(
owner, SyncId::ClientId(client_id),
client_id, title.clone(),
*initial_folder_id, *initial_folder_id,
true, ctx,
InitiatedBy::User, );
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 => { None => {
log::error!("Cannot identify a folder owner from {space:?}"); log::error!("Cannot identify a folder owner from {space:?}");
+1 -8
View File
@@ -1,4 +1,3 @@
use galaxy_core::features::FeatureFlag;
use settings::macros::define_settings_group; use settings::macros::define_settings_group;
use settings::{RespectUserSyncSetting, Setting, SupportedPlatforms, SyncToCloud}; use settings::{RespectUserSyncSetting, Setting, SupportedPlatforms, SyncToCloud};
@@ -37,14 +36,8 @@ define_settings_group!(WarpDriveSettings, settings: [
impl WarpDriveSettings { impl WarpDriveSettings {
/// Returns whether Warp Drive should be considered enabled. /// 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 { pub fn is_warp_drive_enabled(app: &galaxyui::AppContext) -> bool {
use galaxyui::SingletonEntity as _; use galaxyui::SingletonEntity as _;
let is_anonymous_or_logged_out = FeatureFlag::SkipFirebaseAnonymousUser.is_enabled() *Self::as_ref(app).enable_warp_drive
&& crate::auth::AuthStateProvider::as_ref(app)
.get()
.is_anonymous_or_logged_out();
*Self::as_ref(app).enable_warp_drive && !is_anonymous_or_logged_out
} }
} }
+185 -3
View File
@@ -15,16 +15,18 @@ use crate::auth::UserUid;
use crate::cloud_object::model::generic_string_model::GenericStringObjectId; use crate::cloud_object::model::generic_string_model::GenericStringObjectId;
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent}; use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
use crate::cloud_object::{ use crate::cloud_object::{
CloudObject, CloudObjectMetadata, CloudObjectPermissions, CloudObjectStatuses, CloudObject, CloudObjectLocation, CloudObjectMetadata, CloudObjectPermissions,
CloudObjectSyncStatus, GenericCloudObject, GenericStringObjectFormat, JsonObjectType, CloudObjectStatuses, CloudObjectSyncStatus, GenericCloudObject, GenericStringObjectFormat,
ObjectIdType, Owner, Revision, JsonObjectType, ObjectIdType, Owner, Revision, Space,
}; };
use crate::drive::folders::{CloudFolder, CloudFolderModel};
use crate::drive::CloudObjectTypeAndId; use crate::drive::CloudObjectTypeAndId;
use crate::env_vars::{CloudEnvVarCollection, CloudEnvVarCollectionModel, EnvVarCollection}; use crate::env_vars::{CloudEnvVarCollection, CloudEnvVarCollectionModel, EnvVarCollection};
use crate::notebooks::{CloudNotebook, CloudNotebookModel}; use crate::notebooks::{CloudNotebook, CloudNotebookModel};
use crate::persistence::ModelEvent; use crate::persistence::ModelEvent;
use crate::server::ids::{ClientId, SyncId}; use crate::server::ids::{ClientId, SyncId};
use crate::workflows::workflow::Workflow; use crate::workflows::workflow::Workflow;
use crate::workflows::workflow_enum::{CloudWorkflowEnum, CloudWorkflowEnumModel, WorkflowEnum};
use crate::workflows::{CloudWorkflow, CloudWorkflowModel}; use crate::workflows::{CloudWorkflow, CloudWorkflowModel};
const LOCAL_OWNER_ID: &str = "local-galaxy-user"; const LOCAL_OWNER_ID: &str = "local-galaxy-user";
@@ -213,6 +215,140 @@ impl LocalObjectRepository {
CloudModel::as_ref(app).get_notebook(id).cloned() CloudModel::as_ref(app).get_notebook(id).cloned()
} }
pub fn folder(&self, id: &SyncId, app: &AppContext) -> Option<CloudFolder> {
CloudModel::as_ref(app).get_folder(id).cloned()
}
pub fn create_folder_with_id(
&mut self,
id: SyncId,
name: String,
parent_folder_id: Option<SyncId>,
ctx: &mut ModelContext<Self>,
) {
self.upsert_folder(
new_local_folder(id, parent_folder_id, CloudFolderModel::new(&name, false)),
ctx,
);
}
pub fn update_folder_name(
&mut self,
id: SyncId,
name: String,
ctx: &mut ModelContext<Self>,
) -> bool {
let Some(mut folder) = self.folder(&id, ctx) else {
return false;
};
folder.set_model(CloudFolderModel {
name,
is_open: folder.model().is_open,
is_warp_pack: folder.model().is_warp_pack,
});
set_locally_saved_metadata(&mut folder.metadata);
self.upsert_folder(folder, ctx);
true
}
pub fn set_folder_trashed(
&mut self,
id: SyncId,
trashed: bool,
ctx: &mut ModelContext<Self>,
) -> bool {
let Some(mut folder) = self.folder(&id, ctx) else {
return false;
};
folder.metadata.trashed_ts = trashed.then(|| ServerTimestamp::new(Utc::now()));
set_locally_saved_metadata(&mut folder.metadata);
self.upsert_folder(folder, ctx);
true
}
pub fn delete_folder(&mut self, id: SyncId, ctx: &mut ModelContext<Self>) -> bool {
if self.folder(&id, ctx).is_none() {
return false;
}
let ids = CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
cloud_model.delete_object_and_descendants(id.uid(), ctx)
});
if ids.is_empty() {
return false;
}
self.save(ModelEvent::DeleteObjects { ids });
true
}
pub fn open_folder(&mut self, id: SyncId, ctx: &mut ModelContext<Self>) {
self.set_folder_open_state(id, true, ctx);
}
pub fn close_folder(&mut self, id: SyncId, ctx: &mut ModelContext<Self>) {
self.set_folder_open_state(id, false, ctx);
}
pub fn toggle_folder_open(&mut self, id: SyncId, ctx: &mut ModelContext<Self>) {
let Some(folder) = self.folder(&id, ctx) else {
return;
};
self.set_folder_open_state(id, !folder.model().is_open, ctx);
}
pub fn collapse_local_folders_in_location(
&mut self,
location: CloudObjectLocation,
ctx: &mut ModelContext<Self>,
) {
let folder_ids = {
let cloud_model = CloudModel::as_ref(ctx);
cloud_model
.get_all_active_and_inactive_folders()
.filter(|folder| folder.permissions.owner == local_owner())
.filter(|folder| match location {
CloudObjectLocation::Folder(parent_id) => {
if folder.id == parent_id {
return true;
}
let mut ancestor_id = folder.metadata.folder_id;
while let Some(id) = ancestor_id {
if id == parent_id {
return true;
}
ancestor_id = cloud_model
.get_folder(&id)
.and_then(|ancestor| ancestor.metadata.folder_id);
}
false
}
CloudObjectLocation::Space(Space::Personal) => true,
CloudObjectLocation::Space(Space::Shared)
| CloudObjectLocation::Space(Space::Team { .. })
| CloudObjectLocation::Trash => false,
})
.map(|folder| folder.id)
.collect::<Vec<_>>()
};
for folder_id in folder_ids {
self.close_folder(folder_id, ctx);
}
}
fn set_folder_open_state(&self, id: SyncId, is_open: bool, ctx: &mut ModelContext<Self>) {
let Some(mut folder) = self.folder(&id, ctx) else {
return;
};
folder.set_model(CloudFolderModel {
name: folder.model().name.clone(),
is_open,
is_warp_pack: folder.model().is_warp_pack,
});
self.upsert_folder(folder, ctx);
}
pub fn create_notebook_with_id( pub fn create_notebook_with_id(
&mut self, &mut self,
id: SyncId, id: SyncId,
@@ -480,6 +616,23 @@ impl LocalObjectRepository {
self.upsert_workflow(new_local_workflow(id, folder_id, workflow), ctx); self.upsert_workflow(new_local_workflow(id, folder_id, workflow), ctx);
} }
pub fn create_workflow_enum_with_id(
&mut self,
id: SyncId,
workflow_enum: WorkflowEnum,
ctx: &mut ModelContext<Self>,
) {
self.upsert_workflow_enum(
GenericCloudObject::new(
id,
CloudWorkflowEnumModel::new(workflow_enum),
locally_saved_metadata(None),
local_permissions(),
),
ctx,
);
}
pub fn update_workflow( pub fn update_workflow(
&mut self, &mut self,
id: SyncId, id: SyncId,
@@ -591,6 +744,13 @@ impl LocalObjectRepository {
self.save(ModelEvent::UpsertNotebook { notebook }); self.save(ModelEvent::UpsertNotebook { notebook });
} }
fn upsert_folder(&self, folder: CloudFolder, ctx: &mut ModelContext<Self>) {
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
cloud_model.upsert_local_object(folder.clone(), ctx);
});
self.save(ModelEvent::UpsertFolder { folder });
}
fn upsert_workflow(&self, workflow: CloudWorkflow, ctx: &mut ModelContext<Self>) { fn upsert_workflow(&self, workflow: CloudWorkflow, ctx: &mut ModelContext<Self>) {
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| { CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
cloud_model.upsert_local_object(workflow.clone(), ctx); cloud_model.upsert_local_object(workflow.clone(), ctx);
@@ -598,6 +758,15 @@ impl LocalObjectRepository {
self.save(ModelEvent::UpsertWorkflow { workflow }); self.save(ModelEvent::UpsertWorkflow { workflow });
} }
fn upsert_workflow_enum(&self, workflow_enum: CloudWorkflowEnum, ctx: &mut ModelContext<Self>) {
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
cloud_model.upsert_local_object(workflow_enum.clone(), ctx);
});
self.save(ModelEvent::UpsertGenericStringObject {
object: Box::new(workflow_enum),
});
}
fn delete_local_object( fn delete_local_object(
&self, &self,
id: SyncId, id: SyncId,
@@ -658,6 +827,19 @@ pub(crate) fn new_local_notebook(
) )
} }
pub(crate) fn new_local_folder(
id: SyncId,
folder_id: Option<SyncId>,
folder: CloudFolderModel,
) -> CloudFolder {
CloudFolder::new(
id,
folder,
locally_saved_metadata(folder_id),
local_permissions(),
)
}
pub(crate) fn new_local_workflow( pub(crate) fn new_local_workflow(
id: SyncId, id: SyncId,
folder_id: Option<SyncId>, folder_id: Option<SyncId>,
+65
View File
@@ -172,6 +172,71 @@ fn create_update_and_delete_notebook_are_local_and_persisted() {
}); });
} }
#[test]
fn create_update_open_trash_and_delete_folder_are_local_and_persisted() {
App::test((), |mut app| async move {
let receiver = initialize_app(&mut app);
let repository = LocalObjectRepository::handle(&app);
let parent_id = SyncId::ClientId(ClientId::new());
let child_id = SyncId::ClientId(ClientId::new());
repository.update(&mut app, |repository, ctx| {
repository.create_folder_with_id(parent_id, "Projects".to_string(), None, ctx);
});
let ModelEvent::UpsertFolder { folder } = receiver.recv().unwrap() else {
panic!("expected a local folder upsert");
};
assert_eq!(folder.id, parent_id);
assert_eq!(folder.model().name, "Projects");
assert!(!folder.model().is_open);
repository.update(&mut app, |repository, ctx| {
repository.create_folder_with_id(child_id, "Rust".to_string(), Some(parent_id), ctx);
});
assert!(
matches!(receiver.recv().unwrap(), ModelEvent::UpsertFolder { folder } if folder.id == child_id && folder.metadata.folder_id == Some(parent_id))
);
assert!(repository.update(&mut app, |repository, ctx| {
repository.update_folder_name(parent_id, "Projects 2026".to_string(), ctx)
}));
assert!(
matches!(receiver.recv().unwrap(), ModelEvent::UpsertFolder { folder } if folder.model().name == "Projects 2026")
);
repository.update(&mut app, |repository, ctx| {
repository.open_folder(parent_id, ctx);
});
assert!(
matches!(receiver.recv().unwrap(), ModelEvent::UpsertFolder { folder } if folder.model().is_open)
);
assert!(repository.update(&mut app, |repository, ctx| {
repository.set_folder_trashed(parent_id, true, ctx)
}));
assert!(
matches!(receiver.recv().unwrap(), ModelEvent::UpsertFolder { folder } if folder.metadata.trashed_ts.is_some())
);
assert!(repository.update(&mut app, |repository, ctx| {
repository.set_folder_trashed(parent_id, false, ctx)
}));
assert!(
matches!(receiver.recv().unwrap(), ModelEvent::UpsertFolder { folder } if folder.metadata.trashed_ts.is_none())
);
assert!(repository.update(&mut app, |repository, ctx| {
repository.delete_folder(parent_id, ctx)
}));
assert!(matches!(
receiver.recv().unwrap(),
ModelEvent::DeleteObjects { ids }
if ids.contains(&(parent_id, ObjectIdType::Folder))
&& ids.contains(&(child_id, ObjectIdType::Folder))
));
});
}
#[test] #[test]
fn create_update_and_delete_workflow_are_local_and_persisted() { fn create_update_and_delete_workflow_are_local_and_persisted() {
App::test((), |mut app| async move { App::test((), |mut app| async move {
+17
View File
@@ -926,6 +926,13 @@ pub enum OpenAIProviderKind {
OpenAICompatible, OpenAICompatible,
/// The ChatGPT subscription backend, authenticated with ChatGPT OAuth. /// The ChatGPT subscription backend, authenticated with ChatGPT OAuth.
ChatGPTSubscription, 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. /// 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).")] #[schemars(description = "API key for this endpoint (optional if the proxy handles auth).")]
pub api_key: Option<String>, pub api_key: Option<String>,
#[serde(default)] #[serde(default)]
#[schemars(description = "Google Cloud project ID for Vertex AI providers.")]
pub project_id: Option<String>,
#[serde(default)]
#[schemars(description = "Google Cloud location for Vertex AI providers.")]
pub location: Option<String>,
#[serde(default)]
#[schemars(description = "Models available from this provider.")] #[schemars(description = "Models available from this provider.")]
pub models: Vec<OpenAIModelConfig>, pub models: Vec<OpenAIModelConfig>,
} }
@@ -1021,6 +1034,8 @@ pub(crate) fn default_chatgpt_provider() -> OpenAIProviderConfig {
name: "ChatGPT Subscription".to_string(), name: "ChatGPT Subscription".to_string(),
base_url: String::new(), base_url: String::new(),
api_key: None, api_key: None,
project_id: None,
location: None,
models: default_chatgpt_models(), models: default_chatgpt_models(),
} }
} }
@@ -1035,6 +1050,8 @@ fn default_openai_providers() -> Vec<OpenAIProviderConfig> {
// Credentials are deliberately never committed. Set this locally in // Credentials are deliberately never committed. Set this locally in
// ~/.galaxy/settings.toml before sending a request. // ~/.galaxy/settings.toml before sending a request.
api_key: None, api_key: None,
project_id: None,
location: None,
models: vec![OpenAIModelConfig { models: vec![OpenAIModelConfig {
model_id: INITIAL_RIG_MODEL_ID.to_string(), model_id: INITIAL_RIG_MODEL_ID.to_string(),
display_name: "Codex GPT-5.6 SOL (xhigh)".to_string(), display_name: "Codex GPT-5.6 SOL (xhigh)".to_string(),
+28
View File
@@ -423,6 +423,34 @@ fn initial_litellm_provider_maps_codex_model_to_rig_without_a_committed_key() {
assert!(instant.reasoning_efforts.is_empty()); 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] #[test]
fn codex_litellm_model_infers_missing_system_message_capability() { fn codex_litellm_model_infers_missing_system_message_capability() {
let mut model = default_openai_providers().remove(0).models.remove(0); let mut model = default_openai_providers().remove(0).models.remove(0);
+28 -21
View File
@@ -7564,13 +7564,13 @@ impl SettingsWidget for ModelsOverviewWidget {
.with_spacing(8.) .with_spacing(8.)
.with_child(build_sub_header(appearance, "Models", None).finish()) .with_child(build_sub_header(appearance, "Models", None).finish())
.with_child(render_ai_setting_description( .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, true,
app, app,
)) ))
.with_child(render_ai_setting_description( .with_child(render_ai_setting_description(
format!( 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, true,
app, app,
@@ -7680,6 +7680,9 @@ impl OpenAIProviderSettingsWidget {
match provider.kind { match provider.kind {
OpenAIProviderKind::OpenAICompatible => "OpenAI-compatible API", OpenAIProviderKind::OpenAICompatible => "OpenAI-compatible API",
OpenAIProviderKind::ChatGPTSubscription => "ChatGPT subscription", 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, app,
)); ));
column.add_child(render_ai_setting_description( 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, true,
app, app,
)); ));
column.add_child(self.render_builtin_provider_card( if !settings.bedrock_models.value().is_empty() {
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() {
column.add_child(self.render_builtin_provider_card( column.add_child(self.render_builtin_provider_card(
settings.acp_connection_name.value().as_str(), settings.bedrock_connection_name.value().as_str(),
"Use a local session-oriented agent that owns its model and authentication.", "Use AWS credentials to access Bedrock foundation models directly.",
*settings.acp_enabled.value(), *settings.bedrock_enabled.value(),
self.acp_enabled_toggle.clone(), self.bedrock_enabled_toggle.clone(),
AISettingsPageAction::ToggleAcpEnabled, AISettingsPageAction::ToggleBedrockEnabled,
&self.acp_edit_button, &self.bedrock_edit_button,
&self.acp_remove_button, &self.bedrock_remove_button,
appearance, 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() { if providers.is_empty() {
column.add_child(render_ai_setting_description( column.add_child(render_ai_setting_description(
-15
View File
@@ -1167,9 +1167,6 @@ impl SettingsView {
// Warp Drive page // Warp Drive page
let warp_drive_page_handle = let warp_drive_page_handle =
ctx.add_typed_action_view(warp_drive_page::WarpDriveSettingsPageView::new); 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); let platform_page_handle = ctx.add_typed_action_view(platform_page::PlatformPageView::new);
ctx.subscribe_to_view(&platform_page_handle, |me, _, event, ctx| { ctx.subscribe_to_view(&platform_page_handle, |me, _, event, ctx| {
@@ -1738,18 +1735,6 @@ impl SettingsView {
} }
} }
fn handle_warp_drive_page_event(
&mut self,
event: &warp_drive_page::WarpDriveSettingsPageEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
warp_drive_page::WarpDriveSettingsPageEvent::SignUp => {
ctx.emit(SettingsViewEvent::SignupAnonymousUser)
}
}
}
fn handle_ai_page_event(&mut self, event: &AISettingsPageEvent, ctx: &mut ViewContext<Self>) { fn handle_ai_page_event(&mut self, event: &AISettingsPageEvent, ctx: &mut ViewContext<Self>) {
match event { match event {
AISettingsPageEvent::FocusModal => ctx.focus(&self.search_editor), AISettingsPageEvent::FocusModal => ctx.focus(&self.search_editor),
+242 -82
View File
@@ -46,10 +46,51 @@ enum ProviderSetupStep {
pub enum ProviderSetupProviderType { pub enum ProviderSetupProviderType {
ChatGPTSubscription, ChatGPTSubscription,
OpenAICompatible, OpenAICompatible,
Anthropic,
Gemini,
VertexAI,
Bedrock, Bedrock,
Acp, 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)] #[derive(Clone, Debug)]
pub struct BedrockProviderDraft { pub struct BedrockProviderDraft {
pub name: String, pub name: String,
@@ -114,6 +155,8 @@ pub struct ProviderSetupModalBody {
draft_name: String, draft_name: String,
draft_base_url: String, draft_base_url: String,
draft_api_key: Option<String>, draft_api_key: Option<String>,
draft_project_id: String,
draft_location: String,
draft_models: Vec<OpenAIModelConfig>, draft_models: Vec<OpenAIModelConfig>,
draft_bedrock: BedrockProviderDraft, draft_bedrock: BedrockProviderDraft,
draft_acp: AcpProviderDraft, draft_acp: AcpProviderDraft,
@@ -122,6 +165,8 @@ pub struct ProviderSetupModalBody {
name_editor: ViewHandle<EditorView>, name_editor: ViewHandle<EditorView>,
base_url_editor: ViewHandle<EditorView>, base_url_editor: ViewHandle<EditorView>,
api_key_editor: ViewHandle<EditorView>, api_key_editor: ViewHandle<EditorView>,
project_id_editor: ViewHandle<EditorView>,
location_editor: ViewHandle<EditorView>,
bedrock_profile_editor: ViewHandle<EditorView>, bedrock_profile_editor: ViewHandle<EditorView>,
bedrock_region_editor: ViewHandle<EditorView>, bedrock_region_editor: ViewHandle<EditorView>,
bedrock_refresh_command_editor: ViewHandle<EditorView>, bedrock_refresh_command_editor: ViewHandle<EditorView>,
@@ -134,6 +179,7 @@ pub struct ProviderSetupModalBody {
bedrock_cross_region_toggle: SwitchStateHandle, bedrock_cross_region_toggle: SwitchStateHandle,
bedrock_auto_login_toggle: SwitchStateHandle, bedrock_auto_login_toggle: SwitchStateHandle,
model_switches: Vec<SwitchStateHandle>, model_switches: Vec<SwitchStateHandle>,
provider_type_scroll_state: ClippedScrollStateHandle,
models_scroll_state: ClippedScrollStateHandle, models_scroll_state: ClippedScrollStateHandle,
back_button: ViewHandle<ActionButton>, back_button: ViewHandle<ActionButton>,
cancel_button: ViewHandle<ActionButton>, cancel_button: ViewHandle<ActionButton>,
@@ -142,35 +188,28 @@ pub struct ProviderSetupModalBody {
impl ProviderSetupModalBody { impl ProviderSetupModalBody {
pub fn new(ctx: &mut ViewContext<Self>) -> Self { pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let provider_type_buttons = [ let provider_type_buttons = PROVIDER_TYPE_OPTIONS
( .iter()
ProviderSetupProviderType::ChatGPTSubscription, .map(|(kind, label, _)| {
"ChatGPT subscription", let kind = *kind;
), let label = *label;
( ctx.add_typed_action_view(move |_| {
ProviderSetupProviderType::OpenAICompatible, ActionButton::new(label, NakedTheme)
"OpenAI-compatible API", .with_full_width(true)
), .on_click(move |ctx| {
(ProviderSetupProviderType::Bedrock, "AWS Bedrock"), ctx.dispatch_typed_action(
(ProviderSetupProviderType::Acp, "ACP agent runtime"), ProviderSetupModalBodyAction::SelectProvider(kind),
] );
.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,
));
})
}) })
}) .collect();
.collect();
let name_editor = Self::create_editor("Connection name", false, ctx); 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 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 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_profile_editor = Self::create_editor("default", false, ctx);
let bedrock_region_editor = Self::create_editor("us-east-1", 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); let bedrock_refresh_command_editor = Self::create_editor("aws sso login", false, ctx);
@@ -218,6 +257,19 @@ impl ProviderSetupModalBody {
ctx.notify(); 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 [ for (editor, update) in [
(bedrock_profile_editor.clone(), 0), (bedrock_profile_editor.clone(), 0),
(bedrock_region_editor.clone(), 1), (bedrock_region_editor.clone(), 1),
@@ -286,6 +338,8 @@ impl ProviderSetupModalBody {
draft_name: String::new(), draft_name: String::new(),
draft_base_url: String::new(), draft_base_url: String::new(),
draft_api_key: None, draft_api_key: None,
draft_project_id: String::new(),
draft_location: "global".to_string(),
draft_models: Vec::new(), draft_models: Vec::new(),
draft_bedrock: BedrockProviderDraft { draft_bedrock: BedrockProviderDraft {
name: String::new(), name: String::new(),
@@ -310,6 +364,8 @@ impl ProviderSetupModalBody {
name_editor, name_editor,
base_url_editor, base_url_editor,
api_key_editor, api_key_editor,
project_id_editor,
location_editor,
bedrock_profile_editor, bedrock_profile_editor,
bedrock_region_editor, bedrock_region_editor,
bedrock_refresh_command_editor, bedrock_refresh_command_editor,
@@ -322,6 +378,7 @@ impl ProviderSetupModalBody {
bedrock_cross_region_toggle: SwitchStateHandle::default(), bedrock_cross_region_toggle: SwitchStateHandle::default(),
bedrock_auto_login_toggle: SwitchStateHandle::default(), bedrock_auto_login_toggle: SwitchStateHandle::default(),
model_switches: Vec::new(), model_switches: Vec::new(),
provider_type_scroll_state: ClippedScrollStateHandle::default(),
models_scroll_state: ClippedScrollStateHandle::default(), models_scroll_state: ClippedScrollStateHandle::default(),
back_button, back_button,
cancel_button, cancel_button,
@@ -363,6 +420,8 @@ impl ProviderSetupModalBody {
self.draft_name.clear(); self.draft_name.clear();
self.draft_base_url.clear(); self.draft_base_url.clear();
self.draft_api_key = None; self.draft_api_key = None;
self.draft_project_id.clear();
self.draft_location = "global".to_string();
self.draft_models.clear(); self.draft_models.clear();
self.draft_bedrock = BedrockProviderDraft { self.draft_bedrock = BedrockProviderDraft {
name: String::new(), name: String::new(),
@@ -405,10 +464,15 @@ impl ProviderSetupModalBody {
ProviderSetupProviderType::ChatGPTSubscription ProviderSetupProviderType::ChatGPTSubscription
} }
OpenAIProviderKind::OpenAICompatible => ProviderSetupProviderType::OpenAICompatible, OpenAIProviderKind::OpenAICompatible => ProviderSetupProviderType::OpenAICompatible,
OpenAIProviderKind::Anthropic => ProviderSetupProviderType::Anthropic,
OpenAIProviderKind::Gemini => ProviderSetupProviderType::Gemini,
OpenAIProviderKind::VertexAI => ProviderSetupProviderType::VertexAI,
}; };
self.draft_name = provider.name; self.draft_name = provider.name;
self.draft_base_url = provider.base_url; self.draft_base_url = provider.base_url;
self.draft_api_key = provider.api_key; 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.draft_models = provider.models;
self.discovery_state = DiscoveryState::Idle; self.discovery_state = DiscoveryState::Idle;
self.sync_editors(ctx); self.sync_editors(ctx);
@@ -483,6 +547,12 @@ impl ProviderSetupModalBody {
self.api_key_editor.update(ctx, |editor, ctx| { self.api_key_editor.update(ctx, |editor, ctx| {
editor.system_reset_buffer_text(self.draft_api_key.as_deref().unwrap_or_default(), 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| { self.bedrock_profile_editor.update(ctx, |editor, ctx| {
editor.system_reset_buffer_text(&self.draft_bedrock.profile, ctx); editor.system_reset_buffer_text(&self.draft_bedrock.profile, ctx);
}); });
@@ -513,15 +583,12 @@ impl ProviderSetupModalBody {
} }
fn sync_provider_type_buttons(&self, ctx: &mut ViewContext<Self>) { fn sync_provider_type_buttons(&self, ctx: &mut ViewContext<Self>) {
for (index, button) in self.provider_type_buttons.iter().enumerate() { for ((button_kind, _, _), button) in PROVIDER_TYPE_OPTIONS
let button_kind = match index { .iter()
0 => ProviderSetupProviderType::ChatGPTSubscription, .zip(self.provider_type_buttons.iter())
1 => ProviderSetupProviderType::OpenAICompatible, {
2 => ProviderSetupProviderType::Bedrock,
_ => ProviderSetupProviderType::Acp,
};
button.update(ctx, |button, ctx| { 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 => { ProviderSetupProviderType::OpenAICompatible => {
self.draft_base_url.trim().is_empty() 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::Acp => self.draft_acp.agent_id.trim().is_empty(),
ProviderSetupProviderType::ChatGPTSubscription ProviderSetupProviderType::ChatGPTSubscription
| ProviderSetupProviderType::Bedrock => false, | ProviderSetupProviderType::Bedrock => false,
@@ -571,7 +644,10 @@ impl ProviderSetupModalBody {
), ),
ProviderSetupStep::Models => match self.provider_type { ProviderSetupStep::Models => match self.provider_type {
ProviderSetupProviderType::OpenAICompatible ProviderSetupProviderType::OpenAICompatible
| ProviderSetupProviderType::ChatGPTSubscription => ( | ProviderSetupProviderType::ChatGPTSubscription
| ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
| ProviderSetupProviderType::VertexAI => (
"Save", "Save",
self.draft_name.trim().is_empty() self.draft_name.trim().is_empty()
|| !self.draft_models.iter().any(|model| model.enabled), || !self.draft_models.iter().any(|model| model.enabled),
@@ -601,19 +677,48 @@ impl ProviderSetupModalBody {
ProviderSetupProviderType::OpenAICompatible ProviderSetupProviderType::OpenAICompatible
| ProviderSetupProviderType::Bedrock | ProviderSetupProviderType::Bedrock
| ProviderSetupProviderType::Acp => OpenAIProviderKind::OpenAICompatible, | ProviderSetupProviderType::Acp => OpenAIProviderKind::OpenAICompatible,
ProviderSetupProviderType::Anthropic => OpenAIProviderKind::Anthropic,
ProviderSetupProviderType::Gemini => OpenAIProviderKind::Gemini,
ProviderSetupProviderType::VertexAI => OpenAIProviderKind::VertexAI,
}, },
enabled: true, enabled: true,
name: self.draft_name.trim().to_string(), 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() String::new()
} else { } else {
self.draft_base_url.trim().trim_end_matches('/').to_string() self.draft_base_url.trim().trim_end_matches('/').to_string()
}, },
api_key: self api_key: matches!(
.draft_api_key self.provider_type,
.as_deref() ProviderSetupProviderType::OpenAICompatible
.filter(|key| !key.trim().is_empty()) | ProviderSetupProviderType::Anthropic
.map(str::to_string), | 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(), models: self.draft_models.clone(),
} }
} }
@@ -668,7 +773,10 @@ impl ProviderSetupModalBody {
)); ));
return; return;
} }
ProviderSetupProviderType::OpenAICompatible => {} ProviderSetupProviderType::OpenAICompatible
| ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
| ProviderSetupProviderType::VertexAI => {}
} }
let provider = self.draft_provider(); let provider = self.draft_provider();
@@ -776,51 +884,53 @@ impl ProviderSetupModalBody {
.with_color(appearance.theme().nonactive_ui_text_color().into()) .with_color(appearance.theme().nonactive_ui_text_color().into())
.finish(); .finish();
let cards = [ let cards = PROVIDER_TYPE_OPTIONS
( .iter()
"ChatGPT subscription", .enumerate()
"Use your ChatGPT Plus or Pro subscription with native OAuth.", .map(|(index, (_, _, description))| {
), let button = ChildView::new(&self.provider_type_buttons[index]).finish();
( Container::new(
"OpenAI-compatible API", Flex::column()
"Connect LiteLLM, Ollama, vLLM, or another compatible endpoint.", .with_spacing(8.)
), .with_child(button)
( .with_child(
"AWS Bedrock", Text::new(*description, appearance.ui_font_family(), INPUT_FONT_SIZE)
"Use the AWS Bedrock credentials and model configuration already managed by Galaxy.", .with_color(appearance.theme().nonactive_ui_text_color().into())
), .soft_wrap(true)
( .finish(),
"ACP agent runtime", )
"Use a session-oriented ACP agent that owns its model and authentication.", .finish(),
), )
] .with_padding(Padding::uniform(12.))
.into_iter() .with_border(Border::all(1.).with_border_fill(appearance.theme().outline()))
.enumerate() .with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
.map(|(index, (label, description))| { .finish()
let button = ChildView::new(&self.provider_type_buttons[index]).finish(); })
Container::new( .collect::<Vec<_>>();
Flex::column()
.with_spacing(8.) let list = Flex::column()
.with_child(button) .with_spacing(10.)
.with_child( .with_children(cards)
Text::new(description, appearance.ui_font_family(), INPUT_FONT_SIZE) .finish();
.with_color(appearance.theme().nonactive_ui_text_color().into()) let scrollable = ClippedScrollable::vertical(
.soft_wrap(true) self.provider_type_scroll_state.clone(),
.finish(), list,
) ScrollbarWidth::Auto,
.finish(), appearance.theme().nonactive_ui_detail().into(),
) appearance.theme().active_ui_detail().into(),
.with_padding(Padding::uniform(12.)) appearance.theme().surface_1().into(),
.with_border(Border::all(1.).with_border_fill(appearance.theme().outline())) )
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.))) .with_overlayed_scrollbar()
.finish() .finish();
})
.collect::<Vec<_>>();
Flex::column() Flex::column()
.with_spacing(16.) .with_spacing(16.)
.with_child(description) .with_child(description)
.with_children(cards) .with_child(
ConstrainedBox::new(scrollable)
.with_max_height(360.)
.finish(),
)
.finish() .finish()
} }
@@ -989,6 +1099,50 @@ impl ProviderSetupModalBody {
.finish(), .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 => { ProviderSetupProviderType::Bedrock => {
children.push(Self::render_label(appearance, "Authentication method")); children.push(Self::render_label(appearance, "Authentication method"));
children.push( children.push(
@@ -1422,7 +1576,10 @@ impl TypedActionView for ProviderSetupModalBody {
} }
ProviderSetupStep::Models => match self.provider_type { ProviderSetupStep::Models => match self.provider_type {
ProviderSetupProviderType::OpenAICompatible ProviderSetupProviderType::OpenAICompatible
| ProviderSetupProviderType::ChatGPTSubscription => { | ProviderSetupProviderType::ChatGPTSubscription
| ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
| ProviderSetupProviderType::VertexAI => {
if self.draft_name.trim().is_empty() if self.draft_name.trim().is_empty()
|| !self.draft_models.iter().any(|model| model.enabled) || !self.draft_models.iter().any(|model| model.enabled)
{ {
@@ -1529,6 +1686,9 @@ fn provider_type_label(kind: ProviderSetupProviderType) -> &'static str {
match kind { match kind {
ProviderSetupProviderType::OpenAICompatible => "OpenAI-compatible API", ProviderSetupProviderType::OpenAICompatible => "OpenAI-compatible API",
ProviderSetupProviderType::ChatGPTSubscription => "ChatGPT subscription", ProviderSetupProviderType::ChatGPTSubscription => "ChatGPT subscription",
ProviderSetupProviderType::Anthropic => "Anthropic",
ProviderSetupProviderType::Gemini => "Google Gemini",
ProviderSetupProviderType::VertexAI => "Google Vertex AI",
ProviderSetupProviderType::Bedrock => "AWS Bedrock", ProviderSetupProviderType::Bedrock => "AWS Bedrock",
ProviderSetupProviderType::Acp => "ACP agent runtime", ProviderSetupProviderType::Acp => "ACP agent runtime",
} }
+9 -120
View File
@@ -1,17 +1,13 @@
use galaxy_core::features::FeatureFlag; use galaxy_core::features::FeatureFlag;
use galaxy_core::report_if_error; use galaxy_core::report_if_error;
use galaxy_core::settings::ToggleableSetting as _; use galaxy_core::settings::ToggleableSetting as _;
use galaxyui::fonts::Weight;
use galaxyui::keymap::ContextPredicate; use galaxyui::keymap::ContextPredicate;
use galaxyui::ui_components::button::ButtonVariant; use galaxyui::ui_components::components::UiComponent;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::ui_components::switch::SwitchStateHandle; use galaxyui::ui_components::switch::SwitchStateHandle;
use galaxyui::{ use galaxyui::{
id, Action, AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, id, Action, AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
}; };
use warpui::elements::{ use warpui::elements::{Element, MouseStateHandle};
Container, Element, Flex, MouseStateHandle, ParentElement, Shrinkable, Text,
};
use super::settings_page::{ use super::settings_page::{
render_body_item, AdditionalInfo, MatchData, PageType, SettingsPageMeta, render_body_item, AdditionalInfo, MatchData, PageType, SettingsPageMeta,
@@ -22,13 +18,11 @@ use super::{
SettingsAction, SettingsSection, ToggleSettingActionPair, ToggleState, SettingsAction, SettingsSection, ToggleSettingActionPair, ToggleState,
}; };
use crate::appearance::Appearance; use crate::appearance::Appearance;
use crate::auth::AuthStateProvider;
use crate::drive::settings::WarpDriveSettings; use crate::drive::settings::WarpDriveSettings;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum WarpDriveSettingsPageAction { pub enum WarpDriveSettingsPageAction {
ToggleShowWarpDrive, ToggleShowWarpDrive,
SignUp,
OpenUrl(String), OpenUrl(String),
} }
@@ -44,8 +38,8 @@ pub fn init_actions_from_parent_view<T: Action + Clone>(
WarpDriveSettingsPageAction::ToggleShowWarpDrive, WarpDriveSettingsPageAction::ToggleShowWarpDrive,
)), )),
SettingActionPairContexts::new( SettingActionPairContexts::new(
context.clone() & !id!(flags::ENABLE_WARP_DRIVE) & !id!("IsAnonymousUser"), context.clone() & !id!(flags::ENABLE_WARP_DRIVE),
context.clone() & id!(flags::ENABLE_WARP_DRIVE) & !id!("IsAnonymousUser"), context.clone() & id!(flags::ENABLE_WARP_DRIVE),
), ),
None, None,
) )
@@ -54,10 +48,6 @@ pub fn init_actions_from_parent_view<T: Action + Clone>(
); );
} }
pub enum WarpDriveSettingsPageEvent {
SignUp,
}
pub struct WarpDriveSettingsPageView { pub struct WarpDriveSettingsPageView {
page: PageType<Self>, page: PageType<Self>,
} }
@@ -66,10 +56,7 @@ impl WarpDriveSettingsPageView {
pub fn new(_ctx: &mut ViewContext<Self>) -> Self { pub fn new(_ctx: &mut ViewContext<Self>) -> Self {
Self { Self {
page: PageType::new_uncategorized( page: PageType::new_uncategorized(
vec![ vec![Box::new(WarpDriveToggleWidget::default())],
Box::new(WarpDriveHeaderWidget::default()),
Box::new(WarpDriveToggleWidget::default()),
],
None, None,
), ),
} }
@@ -77,7 +64,7 @@ impl WarpDriveSettingsPageView {
} }
impl Entity for WarpDriveSettingsPageView { impl Entity for WarpDriveSettingsPageView {
type Event = WarpDriveSettingsPageEvent; type Event = ();
} }
impl TypedActionView for WarpDriveSettingsPageView { impl TypedActionView for WarpDriveSettingsPageView {
@@ -91,9 +78,6 @@ impl TypedActionView for WarpDriveSettingsPageView {
}); });
ctx.notify(); ctx.notify();
} }
WarpDriveSettingsPageAction::SignUp => {
ctx.emit(WarpDriveSettingsPageEvent::SignUp);
}
WarpDriveSettingsPageAction::OpenUrl(url) => { WarpDriveSettingsPageAction::OpenUrl(url) => {
ctx.open_url(url.as_str()); ctx.open_url(url.as_str());
} }
@@ -139,88 +123,6 @@ impl From<ViewHandle<WarpDriveSettingsPageView>> for SettingsPageViewHandle {
} }
} }
#[derive(Default)]
struct WarpDriveHeaderWidget {
sign_up_button: MouseStateHandle,
}
impl SettingsWidget for WarpDriveHeaderWidget {
type View = WarpDriveSettingsPageView;
fn search_terms(&self) -> &str {
"warp drive sign up"
}
fn should_render(&self, app: &AppContext) -> bool {
FeatureFlag::SkipFirebaseAnonymousUser.is_enabled()
&& AuthStateProvider::as_ref(app)
.get()
.is_anonymous_or_logged_out()
}
fn render(
&self,
_view: &Self::View,
appearance: &Appearance,
_app: &AppContext,
) -> Box<dyn Element> {
let ui_builder = appearance.ui_builder();
let message = Container::new(
Text::new_inline(
"To use Galaxy Drive, please create an account.".to_string(),
appearance.ui_font_family(),
14.,
)
.with_color(
appearance
.theme()
.sub_text_color(appearance.theme().surface_2())
.into_solid(),
)
.finish(),
)
.with_margin_right(16.)
.finish();
let button = Container::new(
ui_builder
.button(ButtonVariant::Accent, self.sign_up_button.clone())
.with_style(UiComponentStyles {
font_size: Some(14.),
font_weight: Some(Weight::Semibold),
border_radius: Some(galaxyui::elements::CornerRadius::with_all(
galaxyui::elements::Radius::Pixels(4.),
)),
padding: Some(Coords {
top: 8.,
bottom: 8.,
left: 24.,
right: 24.,
}),
..Default::default()
})
.with_text_label("Sign up".to_owned())
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(WarpDriveSettingsPageAction::SignUp);
})
.finish(),
)
.finish();
Container::new(
Flex::row()
.with_cross_axis_alignment(galaxyui::elements::CrossAxisAlignment::Center)
.with_child(Shrinkable::new(1., message).finish())
.with_child(button)
.finish(),
)
.with_padding_bottom(15.)
.finish()
}
}
#[derive(Default)] #[derive(Default)]
struct WarpDriveToggleWidget { struct WarpDriveToggleWidget {
switch_state: SwitchStateHandle, switch_state: SwitchStateHandle,
@@ -241,10 +143,6 @@ impl SettingsWidget for WarpDriveToggleWidget {
app: &AppContext, app: &AppContext,
) -> Box<dyn Element> { ) -> Box<dyn Element> {
let settings = WarpDriveSettings::as_ref(app); let settings = WarpDriveSettings::as_ref(app);
let is_anonymous_or_logged_out = FeatureFlag::SkipFirebaseAnonymousUser.is_enabled()
&& AuthStateProvider::as_ref(app)
.get()
.is_anonymous_or_logged_out();
render_body_item::<WarpDriveSettingsPageAction>( render_body_item::<WarpDriveSettingsPageAction>(
"Galaxy Drive".into(), "Galaxy Drive".into(),
@@ -257,24 +155,15 @@ impl SettingsWidget for WarpDriveToggleWidget {
tooltip_override_text: None, tooltip_override_text: None,
}), }),
LocalOnlyIconState::Hidden, LocalOnlyIconState::Hidden,
if is_anonymous_or_logged_out { ToggleState::Enabled,
ToggleState::Disabled
} else {
ToggleState::Enabled
},
appearance, appearance,
appearance appearance
.ui_builder() .ui_builder()
.switch(self.switch_state.clone()) .switch(self.switch_state.clone())
.check(*settings.enable_warp_drive && !is_anonymous_or_logged_out) .check(*settings.enable_warp_drive)
.with_disabled(is_anonymous_or_logged_out)
.build() .build()
.on_click(move |ctx, _, _| { .on_click(move |ctx, _, _| {
if !is_anonymous_or_logged_out { ctx.dispatch_typed_action(WarpDriveSettingsPageAction::ToggleShowWarpDrive);
ctx.dispatch_typed_action(
WarpDriveSettingsPageAction::ToggleShowWarpDrive,
);
}
}) })
.finish(), .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()), 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()),
+1
View File
@@ -14,6 +14,7 @@ futures.workspace = true
galaxy_agent_core.workspace = true galaxy_agent_core.workspace = true
rig-core.workspace = true rig-core.workspace = true
rig-bedrock.workspace = true rig-bedrock.workspace = true
rig-vertexai.workspace = true
serde_json.workspace = true serde_json.workspace = true
uuid.workspace = true uuid.workspace = true
+3 -2
View File
@@ -6,7 +6,7 @@ use galaxy_agent_core::{
StopReason, ToolDefinition, TurnCommand, TurnRequest, Usage, StopReason, ToolDefinition, TurnCommand, TurnRequest, Usage,
}; };
use rig_bedrock::streaming::{BedrockStreamingResponse, BedrockUsage}; 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 rig_core::message::{DocumentSourceKind, ToolResultContent, UserContent};
use super::*; use super::*;
@@ -78,9 +78,10 @@ fn normalizes_bedrock_usage_and_max_token_stop() {
cache_read_input_tokens: Some(40), cache_read_input_tokens: Some(40),
cache_write_input_tokens: Some(10), cache_write_input_tokens: Some(10),
}), }),
stop_reason: None,
}; };
assert_eq!( assert_eq!(
map_usage(response.token_usage()), map_usage((&response).into()),
Usage { Usage {
input_tokens: 100, input_tokens: 100,
output_tokens: 25, output_tokens: 25,
+2
View File
@@ -2,10 +2,12 @@
mod bedrock; mod bedrock;
mod chatgpt; mod chatgpt;
mod native;
mod openai_compatible; mod openai_compatible;
mod request; mod request;
mod stream; mod stream;
pub use bedrock::*; pub use bedrock::*;
pub use chatgpt::*; pub use chatgpt::*;
pub use native::*;
pub use openai_compatible::*; pub use openai_compatible::*;
+256
View File
@@ -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<u32>,
}
pub async fn discover_anthropic_models(api_key: &str) -> Result<Vec<RigModelInfo>, 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<Vec<RigModelInfo>, 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<RigModelInfo> {
[
(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<u64>,
}
#[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<AgentEventStream, AgentError> {
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<u64>,
}
#[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<AgentEventStream, AgentError> {
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<u64>,
}
#[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<AgentEventStream, AgentError> {
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");
}
}
@@ -76,7 +76,6 @@ async fn start_model_turn<M>(
) -> Result<AgentEventStream, AgentError> ) -> Result<AgentEventStream, AgentError>
where where
M: CompletionModel + Send + Sync + 'static, M: CompletionModel + Send + Sync + 'static,
M::StreamingResponse: Send + Sync + 'static,
{ {
let max_output_tokens = request.max_output_tokens.or(configured_max_output_tokens); let max_output_tokens = request.max_output_tokens.or(configured_max_output_tokens);
let completion_request = build_completion_request( let completion_request = build_completion_request(
+78 -3
View File
@@ -3,7 +3,7 @@ use galaxy_agent_core::{
AgentError, AgentErrorKind, AgentEvent, AgentEventStream, StopReason, ToolCall, TurnCommand, AgentError, AgentErrorKind, AgentEvent, AgentEventStream, StopReason, ToolCall, TurnCommand,
TurnControl, Usage, TurnControl, Usage,
}; };
use rig_core::completion::{CompletionError, CompletionModel, CompletionRequest, GetTokenUsage}; use rig_core::completion::{CompletionError, CompletionModel, CompletionRequest};
use rig_core::streaming::StreamedAssistantContent; use rig_core::streaming::StreamedAssistantContent;
use uuid::Uuid; use uuid::Uuid;
@@ -15,7 +15,6 @@ pub(crate) async fn start_model_turn<M>(
) -> Result<AgentEventStream, AgentError> ) -> Result<AgentEventStream, AgentError>
where where
M: CompletionModel + Send + Sync + 'static, M: CompletionModel + Send + Sync + 'static,
M::StreamingResponse: Send + Sync + 'static,
{ {
let runtime_request_id = Uuid::new_v4().to_string(); let runtime_request_id = Uuid::new_v4().to_string();
let stream_future = model.stream(completion_request).fuse(); let stream_future = model.stream(completion_request).fuse();
@@ -112,7 +111,7 @@ where
// is the canonical event Galaxy consumes. // is the canonical event Galaxy consumes.
} }
Ok(StreamedAssistantContent::Final(response)) => { 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; last_output_tokens = mapped_usage.output_tokens;
yield Ok(AgentEvent::UsageUpdated { yield Ok(AgentEvent::UsageUpdated {
usage: mapped_usage, usage: mapped_usage,
@@ -142,6 +141,82 @@ where
Ok(Box::pin(events)) Ok(Box::pin(events))
} }
pub(crate) async fn start_model_completion<M>(
model: M,
completion_request: CompletionRequest,
control: TurnControl,
max_output_tokens: Option<u64>,
) -> Result<AgentEventStream, AgentError>
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 { fn domain_tool_call(tool_call: rig_core::message::ToolCall) -> ToolCall {
ToolCall { ToolCall {
// OpenAI Responses uses a separate `call_id` for function-call output // OpenAI Responses uses a separate `call_id` for function-call output
+30 -10
View File
@@ -123,8 +123,9 @@ The current UI can initially be kept alive with a temporary adapter from `AgentE
### `galaxy_agent_rig` ### `galaxy_agent_rig`
Rig becomes the implementation for provider-backed conversations. The version evaluated for this 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 plan is Rig 0.41.0 from the pinned upstream revision
cadence settles for Galaxy. `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, 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 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. | | 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. | | 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. | | 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. | | 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 ### 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 - [x] Support text, reasoning where available, cancellation, stop reason, usage, and persisted
history. history.
- [x] Route any model entry with `use_rig = true` through Rig while leaving unmarked models on the - [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 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. 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. 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 - [x] Resolve context markers, ARNs, existing inference profiles, and regional inference-profile
prefixes before passing a model ID to Rig. 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. normalization without contacting AWS.
- [x] Preserve signed Bedrock reasoning blocks in Galaxy conversation history so adaptive-thinking - [x] Preserve signed Bedrock reasoning blocks in Galaxy conversation history so adaptive-thinking
tool-call turns can be replayed without losing their signatures. 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; 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, 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. 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 - [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. 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 - [ ] 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. 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. - 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. 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 - [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. 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. - [ ] 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. - [ ] 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 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 unit coverage and a hermetic integration flow for a command that remains active while the agent
continues running. 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 Galaxys local-first - [ ] Open-source project presentation: structure the About page around Galaxys local-first
identity, audit the repositorys license and third-party notices, and make the root metadata, identity, audit the repositorys license and third-party notices, and make the root metadata,
contribution guidance, and license files agree on the final permissive license (currently the 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. | | Telemetry/remote logs/remote flags | Remove, not merely default-off. |
| SSH and remote Git | Keep as explicit user-initiated remote development boundaries. | | SSH and remote Git | Keep as explicit user-initiated remote development boundaries. |
| Web/network agent tools | Disabled by default and permission-visible. | | 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 ## Immediate next vertical slice
Continue Phase 6 by replacing remaining account/workspace ownership with local scopes and removing 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 cloud identity UI from kept flows. Live Phase 4 Bedrock semantic comparisons remain an explicit
Drive folder mutations (create, rename, trash, and open-state persistence) behind
`LocalObjectRepository` for OSS. Live Phase 4 Bedrock semantic comparisons remain an explicit
opt-in validation task because they require configured AWS access. opt-in validation task because they require configured AWS access.