Add Rig native model providers
This commit is contained in:
Generated
+385
-8
@@ -1495,6 +1495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"untrusted 0.7.1",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -5986,6 +5987,7 @@ dependencies = [
|
||||
"galaxy_agent_core",
|
||||
"rig-bedrock",
|
||||
"rig-core",
|
||||
"rig-vertexai",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"uuid",
|
||||
@@ -7230,6 +7232,241 @@ dependencies = [
|
||||
"gl_generator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-aiplatform-v1"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c4b46c4e50f6a09b85dae39be560c263a20ec06c2b0d5a96d65423936bf238f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"google-cloud-api",
|
||||
"google-cloud-gax",
|
||||
"google-cloud-gax-internal",
|
||||
"google-cloud-iam-v1",
|
||||
"google-cloud-location",
|
||||
"google-cloud-longrunning",
|
||||
"google-cloud-lro",
|
||||
"google-cloud-rpc",
|
||||
"google-cloud-type",
|
||||
"google-cloud-wkt",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with 3.21.0",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-api"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19dd5722ba4d24fbc19f6a44b88c335852c9a98d058bc0d6073c9a730c026cad"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"google-cloud-wkt",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with 3.21.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-auth"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f54aab44c16b8463ae11b165a87c3d484780231f157bb1ed65843d591beb5abd"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"aws-lc-rs",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"google-cloud-gax",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"http 1.5.0",
|
||||
"jsonwebtoken",
|
||||
"reqwest 0.13.4",
|
||||
"rustc_version",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"thiserror 2.0.19",
|
||||
"time",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-gax"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9a46dd0fd026bbc4a5d84e6ab0c941cee6e3b057976a0bb107fdb5238ce598f"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
"google-cloud-rpc",
|
||||
"google-cloud-wkt",
|
||||
"http 1.5.0",
|
||||
"pin-project",
|
||||
"rand 0.10.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-gax-internal"
|
||||
version = "0.7.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb04c54317ace06d489213f761797240b3046142a9b7ce6b9a82a9d134e193d1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
"google-cloud-auth",
|
||||
"google-cloud-gax",
|
||||
"google-cloud-rpc",
|
||||
"google-cloud-wkt",
|
||||
"h2",
|
||||
"http 1.5.0",
|
||||
"http-body 1.1.0",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"lazy_static",
|
||||
"opentelemetry",
|
||||
"opentelemetry-semantic-conventions",
|
||||
"opentelemetry_sdk",
|
||||
"percent-encoding",
|
||||
"pin-project",
|
||||
"prost",
|
||||
"prost-types",
|
||||
"reqwest 0.13.4",
|
||||
"rustc_version",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tonic",
|
||||
"tonic-prost",
|
||||
"tower",
|
||||
"tracing",
|
||||
"tracing-opentelemetry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-iam-v1"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34cdf5acc7ef946ee2db7a7f62bd436d8395a6543b4beef110cdc061fcf578bb"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"google-cloud-gax",
|
||||
"google-cloud-gax-internal",
|
||||
"google-cloud-type",
|
||||
"google-cloud-wkt",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with 3.21.0",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-location"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "280d5acdba8fcb1232c0719ed788d85b7e362b82cbb425b7050d3ce46f075ede"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"google-cloud-gax",
|
||||
"google-cloud-gax-internal",
|
||||
"google-cloud-wkt",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with 3.21.0",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-longrunning"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6ce05df0aea2c08472983ce2bbbed9483cbb637b89ff69a7c4ef94371fe4f2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"google-cloud-gax",
|
||||
"google-cloud-gax-internal",
|
||||
"google-cloud-rpc",
|
||||
"google-cloud-wkt",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with 3.21.0",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-lro"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd7cca2b991d619525d72a170ca7f413cb520872702442da22ac9af650a8e786"
|
||||
dependencies = [
|
||||
"google-cloud-gax",
|
||||
"google-cloud-gax-internal",
|
||||
"google-cloud-longrunning",
|
||||
"google-cloud-rpc",
|
||||
"google-cloud-wkt",
|
||||
"serde",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-rpc"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2162c08a89118130979ba261080e960e44cdcb2d6e2ab8ca9b1da245285d353"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"google-cloud-wkt",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with 3.21.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-type"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63acc3a92a85f96bab021c3a3e29b53bbacc97651e1b524d4c2991960a63eb82"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"google-cloud-wkt",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with 3.21.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-wkt"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7fccf98cfd5481a5f5a285181ab0c62123d7d47cd2bb7299448440649349e4e7"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with 3.21.0",
|
||||
"thiserror 2.0.19",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpu-allocator"
|
||||
version = "0.28.0"
|
||||
@@ -7816,6 +8053,19 @@ dependencies = [
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-timeout"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
|
||||
dependencies = [
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
@@ -8745,6 +8995,22 @@ dependencies = [
|
||||
"uuid-simd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken"
|
||||
version = "10.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"base64 0.22.1",
|
||||
"getrandom 0.2.17",
|
||||
"js-sys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"signature",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kasuari"
|
||||
version = "0.4.12"
|
||||
@@ -10878,6 +11144,42 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry"
|
||||
version = "0.32.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"js-sys",
|
||||
"pin-project-lite",
|
||||
"thiserror 2.0.19",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-semantic-conventions"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c913ac17a6c451661ee255f4625d143e51647ae78ebd969b75e41c4442f4fe47"
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry_sdk"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-executor",
|
||||
"futures-util",
|
||||
"opentelemetry",
|
||||
"percent-encoding",
|
||||
"portable-atomic",
|
||||
"rand 0.9.5",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -12876,14 +13178,14 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "rig-bedrock"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a40dec6d2833da0f482f97f105b846b46b82b8ab904b78f2365e263e016f09d"
|
||||
source = "git+https://github.com/0xPlaygrounds/rig.git?rev=1f9547774edb4c269be991ac42eb043fd7b6e87f#1f9547774edb4c269be991ac42eb043fd7b6e87f"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"aws-config",
|
||||
"aws-sdk-bedrockruntime",
|
||||
"aws-smithy-types",
|
||||
"base64 0.22.1",
|
||||
"futures",
|
||||
"rig-core",
|
||||
"rig-derive",
|
||||
"schemars 1.2.2",
|
||||
@@ -12898,8 +13200,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "rig-core"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35f5520515ae8f6851adcbc6fde9eea8e96f657418c062e16c82cd81cce44e8e"
|
||||
source = "git+https://github.com/0xPlaygrounds/rig.git?rev=1f9547774edb4c269be991ac42eb043fd7b6e87f#1f9547774edb4c269be991ac42eb043fd7b6e87f"
|
||||
dependencies = [
|
||||
"as-any",
|
||||
"async-stream",
|
||||
@@ -12932,8 +13233,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "rig-derive"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb868fcebdf3ba425e3afad2e4926bb6d9e1188a856843b00bcee2e15c07424f"
|
||||
source = "git+https://github.com/0xPlaygrounds/rig.git?rev=1f9547774edb4c269be991ac42eb043fd7b6e87f#1f9547774edb4c269be991ac42eb043fd7b6e87f"
|
||||
dependencies = [
|
||||
"convert_case 0.11.0",
|
||||
"proc-macro-crate 3.5.0",
|
||||
@@ -12942,6 +13242,22 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rig-vertexai"
|
||||
version = "0.41.0"
|
||||
source = "git+https://github.com/0xPlaygrounds/rig.git?rev=1f9547774edb4c269be991ac42eb043fd7b6e87f#1f9547774edb4c269be991ac42eb043fd7b6e87f"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"google-cloud-aiplatform-v1",
|
||||
"google-cloud-auth",
|
||||
"rig-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -12952,7 +13268,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"untrusted 0.9.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
@@ -13401,7 +13717,7 @@ dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
"untrusted 0.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -15617,6 +15933,44 @@ version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
|
||||
|
||||
[[package]]
|
||||
name = "tonic"
|
||||
version = "0.14.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"http 1.5.0",
|
||||
"http-body 1.1.0",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-timeout",
|
||||
"hyper-util",
|
||||
"percent-encoding",
|
||||
"pin-project",
|
||||
"rustls-native-certs",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-stream",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tonic-prost"
|
||||
version = "0.14.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"prost",
|
||||
"tonic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
@@ -15625,9 +15979,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"indexmap 2.14.0",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -15730,6 +16087,20 @@ dependencies = [
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-opentelemetry"
|
||||
version = "0.33.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adbc64cba7137545b8044cb1fe9814f7aacf3c6b5f9b45be8bb5db538befdb26"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"opentelemetry",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-subscriber",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.23"
|
||||
@@ -16127,6 +16498,12 @@ version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
||||
+3
-2
@@ -262,8 +262,9 @@ reqwest = { version = "0.13", features = [
|
||||
"stream",
|
||||
] }
|
||||
reqwest-eventsource = { package = "aha-reqwest-eventsource", version = "0.1" }
|
||||
rig-core = "=0.41.0"
|
||||
rig-bedrock = "=0.41.0"
|
||||
rig-core = { git = "https://github.com/0xPlaygrounds/rig.git", rev = "1f9547774edb4c269be991ac42eb043fd7b6e87f", package = "rig-core" }
|
||||
rig-bedrock = { git = "https://github.com/0xPlaygrounds/rig.git", rev = "1f9547774edb4c269be991ac42eb043fd7b6e87f", package = "rig-bedrock" }
|
||||
rig-vertexai = { git = "https://github.com/0xPlaygrounds/rig.git", rev = "1f9547774edb4c269be991ac42eb043fd7b6e87f", package = "rig-vertexai" }
|
||||
resvg = "0.47.0"
|
||||
rust-embed = { version = "8.7.0", features = ["include-exclude"] }
|
||||
rustc-hash = "2.1.1"
|
||||
|
||||
@@ -684,9 +684,11 @@ impl ShellCommandExecutor {
|
||||
.force_refresh_senders
|
||||
.keys()
|
||||
.find(|selector| {
|
||||
selector
|
||||
.get_block(&terminal_model)
|
||||
.is_some_and(|block| block.id() == block_id)
|
||||
selector.get_block(&terminal_model).is_some_and(|block| {
|
||||
block.id() == block_id
|
||||
&& block.is_active_and_long_running()
|
||||
&& !block.finished()
|
||||
})
|
||||
})
|
||||
.cloned();
|
||||
drop(terminal_model);
|
||||
|
||||
@@ -103,11 +103,14 @@ fn force_refresh_block_reports_and_resolves_matching_poll() {
|
||||
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
|
||||
});
|
||||
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
|
||||
terminal_model
|
||||
.lock()
|
||||
.simulate_long_running_block("sleep 120", "still running");
|
||||
let block_id = terminal_model.lock().active_block_id().clone();
|
||||
let executor = app.add_model(|ctx| {
|
||||
ShellCommandExecutor::new(
|
||||
active_session,
|
||||
terminal_model,
|
||||
terminal_model.clone(),
|
||||
&model_event_dispatcher,
|
||||
terminal_view_id,
|
||||
ctx,
|
||||
@@ -124,6 +127,17 @@ fn force_refresh_block_reports_and_resolves_matching_poll() {
|
||||
});
|
||||
|
||||
assert!(matches!(rx.try_recv(), Ok(Some(()))));
|
||||
|
||||
let (tx, _rx) = oneshot::channel();
|
||||
executor.update(&mut app, |executor, _| {
|
||||
executor
|
||||
.force_refresh_senders
|
||||
.insert(BlockSelector::Id(block_id.clone()), tx);
|
||||
});
|
||||
terminal_model.lock().finish_block();
|
||||
assert!(executor.update(&mut app, |executor, _| {
|
||||
!executor.force_refresh_block(&block_id)
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -265,6 +265,8 @@ impl CLISubagentController {
|
||||
let block_id = block.id().clone();
|
||||
let conversation_id = block.ai_conversation_id();
|
||||
let requested_command_action_id = block.requested_command_action_id().cloned();
|
||||
let should_skip_completion_assessment =
|
||||
!should_request_completion_assessment(block.long_running_control_state());
|
||||
let completion = match (&block_completed_event.block_type, conversation_id) {
|
||||
(BlockType::User(completed), Some(conversation_id)) => {
|
||||
let command = if completed.command_with_obfuscated_secrets.is_empty() {
|
||||
@@ -310,17 +312,49 @@ impl CLISubagentController {
|
||||
};
|
||||
drop(terminal_model);
|
||||
|
||||
let Some(subagent_state) = me.active_subagents_by_block.get_mut(&block_id) else {
|
||||
let Some(has_last_snapshot) = me
|
||||
.active_subagents_by_block
|
||||
.get(&block_id)
|
||||
.map(|state| state.last_snapshot_at.is_some())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if subagent_state.last_snapshot_at.is_some() {
|
||||
if has_last_snapshot {
|
||||
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
|
||||
}
|
||||
|
||||
// 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;
|
||||
if subagent_state.completion.is_none() {
|
||||
subagent_state.completion.is_some()
|
||||
};
|
||||
if !has_completion {
|
||||
log::warn!(
|
||||
"CLI monitor block {block_id:?} completed without final command metadata"
|
||||
);
|
||||
me.finish_subagent(
|
||||
&block_id,
|
||||
conversation_id,
|
||||
requested_command_action_id,
|
||||
ctx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
me.advance_completed_subagent(&block_id, ctx);
|
||||
@@ -380,7 +414,12 @@ impl CLISubagentController {
|
||||
}
|
||||
|
||||
if completion.final_turn_started {
|
||||
self.finish_completed_subagent(block_id, ctx);
|
||||
self.finish_subagent(
|
||||
block_id,
|
||||
Some(completion.conversation_id),
|
||||
completion.initial_requested_command_action_id,
|
||||
ctx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -404,38 +443,55 @@ impl CLISubagentController {
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_completed_subagent(&mut self, block_id: &BlockId, ctx: &mut ModelContext<Self>) {
|
||||
fn finish_subagent(
|
||||
&mut self,
|
||||
block_id: &BlockId,
|
||||
conversation_id: Option<AIConversationId>,
|
||||
initial_requested_command_action_id: Option<AIAgentActionId>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(state) = self.active_subagents_by_block.remove(block_id) else {
|
||||
return;
|
||||
};
|
||||
let Some(completion) = state.completion else {
|
||||
return;
|
||||
};
|
||||
let conversation_id = conversation_id.or_else(|| {
|
||||
state
|
||||
.completion
|
||||
.as_ref()
|
||||
.map(|completion| completion.conversation_id)
|
||||
});
|
||||
let initial_requested_command_action_id = initial_requested_command_action_id
|
||||
.or_else(|| {
|
||||
state
|
||||
.completion
|
||||
.as_ref()
|
||||
.and_then(|completion| completion.initial_requested_command_action_id.clone())
|
||||
})
|
||||
.or(state.initial_requested_command_action_id);
|
||||
|
||||
if let Some(conversation_id) = conversation_id {
|
||||
let deactivate_result =
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| {
|
||||
history_model.deactivate_cli_subagent_task_for_conversation(
|
||||
block_id,
|
||||
completion.conversation_id,
|
||||
)
|
||||
history_model
|
||||
.deactivate_cli_subagent_task_for_conversation(block_id, conversation_id)
|
||||
});
|
||||
if let Err(error) = deactivate_result {
|
||||
log::error!(
|
||||
"Failed to deactivate completed CLI monitor for block {block_id:?}: {error:?}"
|
||||
);
|
||||
log::error!("Failed to deactivate CLI monitor for block {block_id:?}: {error:?}");
|
||||
}
|
||||
}
|
||||
|
||||
ctx.emit(CLISubagentEvent::FinishedSubagent {
|
||||
block_id: block_id.clone(),
|
||||
conversation_id: Some(completion.conversation_id),
|
||||
initial_requested_command_action_id: completion.initial_requested_command_action_id,
|
||||
conversation_id,
|
||||
initial_requested_command_action_id,
|
||||
});
|
||||
|
||||
if let Some(agent_view_controller) = &self.agent_view_controller {
|
||||
if let (Some(agent_view_controller), Some(conversation_id)) =
|
||||
(&self.agent_view_controller, conversation_id)
|
||||
{
|
||||
agent_view_controller.update(ctx, |controller, ctx| {
|
||||
let is_this_inline_conversation = controller.is_inline()
|
||||
&& controller.agent_view_state().active_conversation_id()
|
||||
== Some(completion.conversation_id);
|
||||
== Some(conversation_id);
|
||||
if is_this_inline_conversation {
|
||||
controller.exit_agent_view(ctx);
|
||||
}
|
||||
@@ -919,3 +975,42 @@ fn command_finished_block_id(result: &AIAgentActionResultType) -> Option<&BlockI
|
||||
| AIAgentActionResultType::WaitForEvents(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn should_request_completion_assessment(
|
||||
control_state: Option<&LongRunningCommandControlState>,
|
||||
) -> bool {
|
||||
!control_state
|
||||
.and_then(LongRunningCommandControlState::user_take_over_reason)
|
||||
.is_some_and(UserTakeOverReason::is_stop)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stop_takeover_does_not_request_a_completion_assessment() {
|
||||
let state = LongRunningCommandControlState::User {
|
||||
reason: UserTakeOverReason::Stop,
|
||||
};
|
||||
|
||||
assert!(!should_request_completion_assessment(Some(&state)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_stop_control_states_can_request_a_completion_assessment() {
|
||||
let agent_state = LongRunningCommandControlState::Agent {
|
||||
is_blocked: false,
|
||||
should_hide_responses: false,
|
||||
};
|
||||
let transfer_state = LongRunningCommandControlState::User {
|
||||
reason: UserTakeOverReason::TransferFromAgent {
|
||||
reason: "needs user input".to_owned(),
|
||||
},
|
||||
};
|
||||
|
||||
assert!(should_request_completion_assessment(None));
|
||||
assert!(should_request_completion_assessment(Some(&agent_state)));
|
||||
assert!(should_request_completion_assessment(Some(&transfer_state)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +230,8 @@ impl ResponseStream {
|
||||
kind: client_config.kind,
|
||||
base_url: client_config.base_url.clone(),
|
||||
api_key: client_config.api_key.clone(),
|
||||
project_id: client_config.project_id.clone(),
|
||||
location: client_config.location.clone(),
|
||||
model: client_config
|
||||
.model
|
||||
.clone()
|
||||
|
||||
@@ -167,6 +167,8 @@ impl CrosscheckReviewer {
|
||||
kind: client_config.kind,
|
||||
base_url: client_config.base_url.clone(),
|
||||
api_key: client_config.api_key.clone(),
|
||||
project_id: client_config.project_id.clone(),
|
||||
location: client_config.location.clone(),
|
||||
model: client_config
|
||||
.model
|
||||
.clone()
|
||||
|
||||
+105
-8
@@ -5,6 +5,11 @@ use std::sync::{Arc, OnceLock};
|
||||
|
||||
use ai::api_keys::ApiKeyManager;
|
||||
pub use ai::LLMId;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use galaxy_agent_rig::{
|
||||
discover_anthropic_models, discover_gemini_models, validate_vertex_ai_credentials,
|
||||
vertex_ai_model_catalog, RigModelInfo,
|
||||
};
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::ui::icons::Icon;
|
||||
use galaxy_core::user_preferences::GetUserPreferences;
|
||||
@@ -1005,6 +1010,8 @@ impl LLMPreferences {
|
||||
bool,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Vec<OpenAIModelConfig>,
|
||||
);
|
||||
let mut provider_entries: Vec<OpenAIProviderEntry> = Vec::new();
|
||||
@@ -1037,6 +1044,8 @@ impl LLMPreferences {
|
||||
true,
|
||||
base_url,
|
||||
api_key,
|
||||
None,
|
||||
None,
|
||||
single_provider_models,
|
||||
));
|
||||
}
|
||||
@@ -1047,11 +1056,19 @@ impl LLMPreferences {
|
||||
.value()
|
||||
.iter()
|
||||
.filter_map(|provider| {
|
||||
if !provider.enabled
|
||||
|| (provider.kind == OpenAIProviderKind::OpenAICompatible
|
||||
&& provider.base_url.trim().is_empty())
|
||||
|| provider.models.is_empty()
|
||||
{
|
||||
let missing_credentials = match provider.kind {
|
||||
OpenAIProviderKind::OpenAICompatible => provider.base_url.trim().is_empty(),
|
||||
OpenAIProviderKind::Anthropic | OpenAIProviderKind::Gemini => provider
|
||||
.api_key
|
||||
.as_deref()
|
||||
.is_none_or(|key| key.trim().is_empty()),
|
||||
OpenAIProviderKind::VertexAI => provider
|
||||
.project_id
|
||||
.as_deref()
|
||||
.is_none_or(|project| project.trim().is_empty()),
|
||||
OpenAIProviderKind::ChatGPTSubscription => false,
|
||||
};
|
||||
if !provider.enabled || missing_credentials || provider.models.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
@@ -1060,6 +1077,8 @@ impl LLMPreferences {
|
||||
provider.enabled,
|
||||
provider.base_url.clone(),
|
||||
provider.api_key.clone(),
|
||||
provider.project_id.clone(),
|
||||
provider.location.clone(),
|
||||
provider.models.clone(),
|
||||
))
|
||||
}),
|
||||
@@ -1071,8 +1090,16 @@ impl LLMPreferences {
|
||||
|
||||
let mut total_injected = 0;
|
||||
let mut seen_model_ids: HashSet<String> = HashSet::new();
|
||||
for (provider_name, provider_kind, provider_enabled, base_url, api_key, models) in
|
||||
provider_entries
|
||||
for (
|
||||
provider_name,
|
||||
provider_kind,
|
||||
provider_enabled,
|
||||
base_url,
|
||||
api_key,
|
||||
provider_project_id,
|
||||
provider_location,
|
||||
models,
|
||||
) in provider_entries
|
||||
{
|
||||
if !provider_enabled {
|
||||
continue;
|
||||
@@ -1109,12 +1136,14 @@ impl LLMPreferences {
|
||||
kind: provider_kind,
|
||||
base_url: base_url.clone(),
|
||||
api_key: api_key.clone(),
|
||||
project_id: provider_project_id.clone(),
|
||||
location: provider_location.clone(),
|
||||
model: Some(model.model_id.clone()),
|
||||
reasoning_effort: reasoning_effort.clone(),
|
||||
max_input_tokens: Some(openai_model_context_size(model)),
|
||||
max_output_tokens: model.max_output_tokens,
|
||||
use_rig: model.use_rig
|
||||
|| provider_kind == OpenAIProviderKind::ChatGPTSubscription,
|
||||
|| !matches!(provider_kind, OpenAIProviderKind::OpenAICompatible),
|
||||
supports_system_messages: model.supports_system_messages(),
|
||||
};
|
||||
self.openai_provider_routing
|
||||
@@ -1557,6 +1586,54 @@ impl LLMPreferences {
|
||||
pub(crate) async fn discover_openai_provider_models(
|
||||
provider: OpenAIProviderConfig,
|
||||
) -> Result<Vec<OpenAIModelConfig>, String> {
|
||||
let native_models = match provider.kind {
|
||||
OpenAIProviderKind::Anthropic => {
|
||||
let api_key = provider
|
||||
.api_key
|
||||
.as_deref()
|
||||
.filter(|key| !key.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
"Enter an Anthropic API key before testing the connection.".to_string()
|
||||
})?;
|
||||
Some(discover_anthropic_models(api_key).await?)
|
||||
}
|
||||
OpenAIProviderKind::Gemini => {
|
||||
let api_key = provider
|
||||
.api_key
|
||||
.as_deref()
|
||||
.filter(|key| !key.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
"Enter a Gemini API key before testing the connection.".to_string()
|
||||
})?;
|
||||
Some(discover_gemini_models(api_key).await?)
|
||||
}
|
||||
OpenAIProviderKind::VertexAI => {
|
||||
if provider
|
||||
.project_id
|
||||
.as_deref()
|
||||
.is_none_or(|project| project.trim().is_empty())
|
||||
{
|
||||
return Err(
|
||||
"Enter a Google Cloud project ID before testing the connection."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
validate_vertex_ai_credentials(
|
||||
provider.project_id.as_deref().unwrap_or_default(),
|
||||
provider.location.as_deref().unwrap_or("global"),
|
||||
)?;
|
||||
Some(vertex_ai_model_catalog())
|
||||
}
|
||||
OpenAIProviderKind::OpenAICompatible | OpenAIProviderKind::ChatGPTSubscription => None,
|
||||
};
|
||||
|
||||
if let Some(models) = native_models {
|
||||
if models.is_empty() {
|
||||
return Err("The provider responded, but no models were found.".to_string());
|
||||
}
|
||||
return Ok(Self::rig_models_to_openai_models(models));
|
||||
}
|
||||
|
||||
if provider.base_url.trim().is_empty() {
|
||||
return Err("Enter a provider URL before testing the connection.".to_string());
|
||||
}
|
||||
@@ -1586,6 +1663,26 @@ impl LLMPreferences {
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn rig_models_to_openai_models(models: Vec<RigModelInfo>) -> Vec<OpenAIModelConfig> {
|
||||
models
|
||||
.into_iter()
|
||||
.map(|model| OpenAIModelConfig {
|
||||
model_id: model.id,
|
||||
display_name: model.display_name,
|
||||
vision_supported: false,
|
||||
context_size: model.context_size.unwrap_or(128_000),
|
||||
max_input_tokens: model.context_size,
|
||||
max_output_tokens: None,
|
||||
provider: None,
|
||||
use_rig: true,
|
||||
supports_system_messages: Some(true),
|
||||
reasoning_efforts: Vec::new(),
|
||||
enabled: true,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the `LLMInfo` for the base LLM to be used for an Agent Mode request.
|
||||
pub fn get_active_base_model<'a>(
|
||||
&'a self,
|
||||
|
||||
@@ -11,6 +11,8 @@ pub struct OpenAIClientConfig {
|
||||
pub kind: OpenAIProviderKind,
|
||||
pub base_url: String,
|
||||
pub api_key: Option<String>,
|
||||
pub project_id: Option<String>,
|
||||
pub location: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub max_input_tokens: Option<u32>,
|
||||
|
||||
@@ -8,8 +8,9 @@ use galaxy_agent_core::{
|
||||
ToolCallDecision, ToolEvent, ToolPolicy, ToolResult, TurnCommand,
|
||||
};
|
||||
use galaxy_agent_rig::{
|
||||
ChatGPTSubscriptionRuntime, ChatGPTSubscriptionRuntimeConfig, OpenAICompatibleRuntime,
|
||||
OpenAICompatibleRuntimeConfig,
|
||||
AnthropicRuntime, AnthropicRuntimeConfig, ChatGPTSubscriptionRuntime,
|
||||
ChatGPTSubscriptionRuntimeConfig, GeminiRuntime, GeminiRuntimeConfig, OpenAICompatibleRuntime,
|
||||
OpenAICompatibleRuntimeConfig, VertexAiRuntime, VertexAiRuntimeConfig,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
use warp_multi_agent_api::ToolType;
|
||||
@@ -73,6 +74,52 @@ pub(crate) fn rig_openai_response_stream(
|
||||
cancellation_rx,
|
||||
)
|
||||
}
|
||||
OpenAIProviderKind::Anthropic => {
|
||||
let runtime = AnthropicRuntime::new(AnthropicRuntimeConfig {
|
||||
api_key: config.api_key.unwrap_or_default(),
|
||||
model: model_id,
|
||||
max_output_tokens: config.max_output_tokens.map(u64::from),
|
||||
});
|
||||
rig_response_stream(
|
||||
runtime,
|
||||
prepared,
|
||||
skill_path_origin,
|
||||
config.max_input_tokens,
|
||||
"rig_anthropic",
|
||||
cancellation_rx,
|
||||
)
|
||||
}
|
||||
OpenAIProviderKind::Gemini => {
|
||||
let runtime = GeminiRuntime::new(GeminiRuntimeConfig {
|
||||
api_key: config.api_key.unwrap_or_default(),
|
||||
model: model_id,
|
||||
max_output_tokens: config.max_output_tokens.map(u64::from),
|
||||
});
|
||||
rig_response_stream(
|
||||
runtime,
|
||||
prepared,
|
||||
skill_path_origin,
|
||||
config.max_input_tokens,
|
||||
"rig_gemini",
|
||||
cancellation_rx,
|
||||
)
|
||||
}
|
||||
OpenAIProviderKind::VertexAI => {
|
||||
let runtime = VertexAiRuntime::new(VertexAiRuntimeConfig {
|
||||
project_id: config.project_id.unwrap_or_default(),
|
||||
location: config.location.unwrap_or_else(|| "global".to_string()),
|
||||
model: model_id,
|
||||
max_output_tokens: config.max_output_tokens.map(u64::from),
|
||||
});
|
||||
rig_response_stream(
|
||||
runtime,
|
||||
prepared,
|
||||
skill_path_origin,
|
||||
config.max_input_tokens,
|
||||
"rig_vertex_ai",
|
||||
cancellation_rx,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ fn config() -> OpenAIClientConfig {
|
||||
kind: crate::settings::OpenAIProviderKind::OpenAICompatible,
|
||||
base_url: "http://localhost:4000/v1".to_string(),
|
||||
api_key: None,
|
||||
project_id: None,
|
||||
location: None,
|
||||
model: Some("provider-model".to_string()),
|
||||
reasoning_effort: None,
|
||||
max_input_tokens: Some(128_000),
|
||||
|
||||
@@ -1,32 +1,12 @@
|
||||
use std::env::current_dir;
|
||||
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxyui::App;
|
||||
|
||||
use super::expand_dirs;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::network::NetworkStatus;
|
||||
use crate::server::cloud_objects::update_manager::UpdateManager;
|
||||
use crate::server::sync_queue::SyncQueue;
|
||||
use crate::workspaces::team_tester::TeamTesterStatus;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::{GlobalResourceHandles, GlobalResourceHandlesProvider};
|
||||
|
||||
#[test]
|
||||
fn test_expand_directories() {
|
||||
App::test((), |mut app| async move {
|
||||
app.update(crate::settings::init_and_register_user_preferences);
|
||||
|
||||
let global_resource_handles = GlobalResourceHandles::mock(&mut app);
|
||||
app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resource_handles));
|
||||
app.add_singleton_model(CloudModel::mock);
|
||||
app.add_singleton_model(UserWorkspaces::default_mock);
|
||||
app.add_singleton_model(|_| Appearance::mock());
|
||||
app.add_singleton_model(|_| NetworkStatus::new());
|
||||
app.add_singleton_model(SyncQueue::mock);
|
||||
app.add_singleton_model(TeamTesterStatus::mock);
|
||||
app.add_singleton_model(UpdateManager::mock);
|
||||
|
||||
App::test((), |_| async move {
|
||||
let directory = current_dir()
|
||||
.expect("current directory should exist")
|
||||
.parent()
|
||||
|
||||
@@ -18,6 +18,7 @@ use super::modal_body::{ImportModalBody, ImportModalBodyAction, ImportModalBodyE
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::{CloudObject, Owner};
|
||||
use crate::local_object_repository::local_owner;
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::server::sync_queue::SyncQueue;
|
||||
use crate::themes::theme::GalaxyTheme;
|
||||
@@ -88,7 +89,8 @@ impl ImportModal {
|
||||
let window_id = ctx.window_id();
|
||||
let import_body_id = self.import_modal.id();
|
||||
|
||||
let sync_queue_is_dequeueing = SyncQueue::as_ref(ctx).is_dequeueing();
|
||||
let sync_queue_is_dequeueing =
|
||||
self.owner != Some(local_owner()) && SyncQueue::as_ref(ctx).is_dequeueing();
|
||||
|
||||
let allowed_file_types = vec![FileType::Yaml, FileType::Markdown];
|
||||
|
||||
@@ -98,7 +100,7 @@ impl ImportModal {
|
||||
|
||||
// Files under a folder could only be uploaded when the folder is created on the server.
|
||||
// When sync queue is not dequeueing, disable folder upload in the import modal.
|
||||
if sync_queue_is_dequeueing {
|
||||
if sync_queue_is_dequeueing || self.owner == Some(local_owner()) {
|
||||
file_picker_config = file_picker_config.allow_folder();
|
||||
}
|
||||
|
||||
@@ -165,9 +167,13 @@ impl ImportModal {
|
||||
// Convert to a Space for display, in case we're importing into a shared folder.
|
||||
self.owner
|
||||
.map(|owner| {
|
||||
if owner == local_owner() {
|
||||
"Personal".to_string()
|
||||
} else {
|
||||
UserWorkspaces::as_ref(app)
|
||||
.owner_to_space(owner, app)
|
||||
.name(app)
|
||||
}
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
0,
|
||||
|
||||
@@ -21,6 +21,7 @@ use super::nodes::{
|
||||
use super::queue::{ImportQueue, ImportQueueArgs, ImportQueueEvent, ParentId, RequestContent};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::Owner;
|
||||
use crate::local_object_repository::local_owner;
|
||||
use crate::server::ids::{ClientId, SyncId};
|
||||
use crate::server::sync_queue::SyncQueue;
|
||||
use crate::ui_components::icons::Icon;
|
||||
@@ -96,7 +97,7 @@ pub struct ImportModalBody {
|
||||
|
||||
impl ImportModalBody {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let import_queue = ctx.add_model(ImportQueue::new);
|
||||
let import_queue = ctx.add_model(|_| ImportQueue::new());
|
||||
ctx.subscribe_to_model(&import_queue, |me, _, event, ctx| {
|
||||
me.handle_import_queue_event(event, ctx)
|
||||
});
|
||||
@@ -152,7 +153,8 @@ impl ImportModalBody {
|
||||
}
|
||||
}
|
||||
|
||||
let sync_queue_dequeueing = SyncQueue::as_ref(ctx).is_dequeueing();
|
||||
let sync_queue_dequeueing =
|
||||
self.owner != Some(local_owner()) && SyncQueue::as_ref(ctx).is_dequeueing();
|
||||
|
||||
if !sync_queue_dequeueing && state.all_files_saved_locally() {
|
||||
ctx.emit(ImportModalBodyEvent::AllFileSavedLocally);
|
||||
@@ -177,7 +179,8 @@ impl ImportModalBody {
|
||||
// Whether there is an active upload in progress (If all uploads are completed,
|
||||
// we don't consider the import modal upload to be in progress).
|
||||
pub fn upload_in_progress(&self, app: &AppContext) -> bool {
|
||||
let sync_queue_dequeueing = SyncQueue::as_ref(app).is_dequeueing();
|
||||
let sync_queue_dequeueing =
|
||||
self.owner != Some(local_owner()) && SyncQueue::as_ref(app).is_dequeueing();
|
||||
|
||||
match &self.state {
|
||||
ImportState::Upload => false,
|
||||
@@ -498,7 +501,8 @@ impl View for ImportModalBody {
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let sync_queue_dequeueing = SyncQueue::as_ref(app).is_dequeueing();
|
||||
let sync_queue_dequeueing =
|
||||
self.owner != Some(local_owner()) && SyncQueue::as_ref(app).is_dequeueing();
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
match &self.state {
|
||||
|
||||
+109
-21
@@ -5,7 +5,7 @@ use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
use super::nodes::{self, FileId};
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::{CloudObjectEventEntrypoint, Owner};
|
||||
use crate::drive::folders::FolderId;
|
||||
use crate::local_object_repository::{local_owner, LocalObjectRepository};
|
||||
use crate::notebooks::CloudNotebookModel;
|
||||
use crate::server::cloud_objects::update_manager::{
|
||||
InitiatedBy, ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
|
||||
@@ -94,31 +94,40 @@ impl FileCompletionCounter {
|
||||
|
||||
pub(super) struct ImportQueue {
|
||||
queue: Vec<ImportQueueArgs>,
|
||||
client_to_server_id: HashMap<ClientId, Option<FolderId>>,
|
||||
client_to_folder_id: HashMap<ClientId, Option<SyncId>>,
|
||||
client_to_node_folder_id: HashMap<ClientId, nodes::FolderId>,
|
||||
file_completion: FileCompletionCounter,
|
||||
remote_subscription_initialized: bool,
|
||||
}
|
||||
|
||||
impl ImportQueue {
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
queue: Vec::new(),
|
||||
client_to_folder_id: HashMap::default(),
|
||||
file_completion: Default::default(),
|
||||
client_to_node_folder_id: HashMap::default(),
|
||||
remote_subscription_initialized: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_remote_subscription(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if self.remote_subscription_initialized {
|
||||
return;
|
||||
}
|
||||
|
||||
let update_manager = UpdateManager::handle(ctx);
|
||||
ctx.subscribe_to_model(&update_manager, |me, _, event, ctx| {
|
||||
me.handle_update_manager_event(event, ctx);
|
||||
});
|
||||
|
||||
Self {
|
||||
queue: Vec::new(),
|
||||
client_to_server_id: HashMap::default(),
|
||||
file_completion: Default::default(),
|
||||
client_to_node_folder_id: HashMap::default(),
|
||||
}
|
||||
self.remote_subscription_initialized = true;
|
||||
}
|
||||
|
||||
// Whether all dependencies of an item has been sync-ed.
|
||||
fn dependency_synced(&self, item: &ImportQueueArgs) -> bool {
|
||||
match &item.parent_id {
|
||||
ParentId::FolderToUpload(id) => self
|
||||
.client_to_server_id
|
||||
.client_to_folder_id
|
||||
.get(id)
|
||||
.map(|item| item.is_some())
|
||||
.unwrap_or(false),
|
||||
@@ -128,6 +137,11 @@ impl ImportQueue {
|
||||
|
||||
// Enqueue a new request to the import queue.
|
||||
pub fn enqueue(&mut self, arg: ImportQueueArgs, ctx: &mut ModelContext<Self>) {
|
||||
let is_local = arg.owner == local_owner();
|
||||
if !is_local {
|
||||
self.ensure_remote_subscription(ctx);
|
||||
}
|
||||
|
||||
// Update internal tracker of the object.
|
||||
match &arg.content {
|
||||
RequestContent::Folder {
|
||||
@@ -135,20 +149,26 @@ impl ImportQueue {
|
||||
folder_id,
|
||||
..
|
||||
} => {
|
||||
self.client_to_server_id.insert(*client_id, None);
|
||||
self.client_to_folder_id.insert(*client_id, None);
|
||||
self.client_to_node_folder_id.insert(*client_id, *folder_id);
|
||||
}
|
||||
RequestContent::Notebook {
|
||||
client_id, file_id, ..
|
||||
} => self.file_completion.add_entry(*client_id, *file_id),
|
||||
} => {
|
||||
if !is_local {
|
||||
self.file_completion.add_entry(*client_id, *file_id);
|
||||
}
|
||||
}
|
||||
RequestContent::Workflow {
|
||||
workflows, file_id, ..
|
||||
} => {
|
||||
if !is_local {
|
||||
for (_, client_id) in workflows {
|
||||
self.file_completion.add_entry(*client_id, *file_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.queue.push(arg);
|
||||
self.dequeue(ctx);
|
||||
@@ -167,20 +187,35 @@ impl ImportQueue {
|
||||
{
|
||||
let dequeued_item = self.queue.remove(idx);
|
||||
let parent_id = match dequeued_item.parent_id {
|
||||
ParentId::FolderToUpload(client_id) => Some(SyncId::ServerId(
|
||||
self.client_to_server_id
|
||||
ParentId::FolderToUpload(client_id) => Some(
|
||||
self.client_to_folder_id
|
||||
.get(&client_id)
|
||||
.expect("Client id entry should exist")
|
||||
.expect("Server id entry should exist")
|
||||
.into(),
|
||||
)),
|
||||
.expect("Folder id entry should exist"),
|
||||
),
|
||||
ParentId::InitialFolder(folder_id) => folder_id,
|
||||
};
|
||||
|
||||
let is_local = dequeued_item.owner == local_owner();
|
||||
match dequeued_item.content {
|
||||
RequestContent::Folder {
|
||||
name, client_id, ..
|
||||
} => {
|
||||
if is_local {
|
||||
let local_id = SyncId::ClientId(client_id);
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.create_folder_with_id(local_id, name, parent_id, ctx);
|
||||
});
|
||||
self.client_to_folder_id.insert(client_id, Some(local_id));
|
||||
ctx.emit(ImportQueueEvent::FolderCompleted {
|
||||
folder_id: self
|
||||
.client_to_node_folder_id
|
||||
.get(&client_id)
|
||||
.copied()
|
||||
.expect("Folder node id should exist"),
|
||||
server_id: Some(local_id.uid()),
|
||||
});
|
||||
} else {
|
||||
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
||||
update_manager.create_folder(
|
||||
name,
|
||||
@@ -193,12 +228,33 @@ impl ImportQueue {
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
RequestContent::Notebook {
|
||||
title,
|
||||
data,
|
||||
client_id,
|
||||
file_id,
|
||||
} => {
|
||||
if is_local {
|
||||
let local_id = SyncId::ClientId(client_id);
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.create_notebook_with_id(
|
||||
local_id,
|
||||
parent_id,
|
||||
CloudNotebookModel {
|
||||
title,
|
||||
data,
|
||||
ai_document_id: None,
|
||||
conversation_id: None,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
ctx.emit(ImportQueueEvent::FileCompleted {
|
||||
file_id,
|
||||
server_id: Some(local_id.uid()),
|
||||
});
|
||||
} else {
|
||||
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
||||
update_manager.create_notebook(
|
||||
client_id,
|
||||
@@ -217,11 +273,38 @@ impl ImportQueue {
|
||||
});
|
||||
ctx.emit(ImportQueueEvent::FileSavedLocally(file_id));
|
||||
}
|
||||
}
|
||||
RequestContent::Workflow {
|
||||
workflows,
|
||||
workflow_enums,
|
||||
file_id,
|
||||
} => {
|
||||
if is_local {
|
||||
let local_object_id = workflows
|
||||
.first()
|
||||
.map(|(_, client_id)| SyncId::ClientId(*client_id).uid());
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
for (client_id, workflow_enum) in workflow_enums {
|
||||
repository.create_workflow_enum_with_id(
|
||||
SyncId::ClientId(client_id),
|
||||
workflow_enum,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
for (workflow, client_id) in workflows {
|
||||
repository.create_workflow_with_id(
|
||||
SyncId::ClientId(client_id),
|
||||
parent_id,
|
||||
workflow,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
});
|
||||
ctx.emit(ImportQueueEvent::FileCompleted {
|
||||
file_id,
|
||||
server_id: local_object_id,
|
||||
});
|
||||
} else {
|
||||
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
||||
// Create any new workflow enums
|
||||
for (client_id, workflow_enum) in workflow_enums {
|
||||
@@ -251,6 +334,7 @@ impl ImportQueue {
|
||||
ctx.emit(ImportQueueEvent::FileSavedLocally(file_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.dequeue(ctx);
|
||||
}
|
||||
}
|
||||
@@ -294,14 +378,14 @@ impl ImportQueue {
|
||||
|
||||
let Some(folder_id) = cloud_model
|
||||
.get_folder_by_uid(&result.server_id.expect("Expect id").uid())
|
||||
.and_then(|folder| folder.id.into_server())
|
||||
.map(|folder| folder.id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let replaced = match self.client_to_server_id.get_mut(&client_id) {
|
||||
let replaced = match self.client_to_folder_id.get_mut(&client_id) {
|
||||
Some(value) if value.is_none() => {
|
||||
*value = Some(folder_id.into());
|
||||
*value = Some(folder_id);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
@@ -323,3 +407,7 @@ impl ImportQueue {
|
||||
impl Entity for ImportQueue {
|
||||
type Event = ImportQueueEvent;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "queue_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -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(¬ebook_id)
|
||||
.unwrap()
|
||||
.metadata
|
||||
.folder_id,
|
||||
Some(child_id)
|
||||
);
|
||||
assert_eq!(
|
||||
cloud_model
|
||||
.get_workflow(&workflow_id)
|
||||
.unwrap()
|
||||
.metadata
|
||||
.folder_id,
|
||||
Some(child_id)
|
||||
);
|
||||
assert_eq!(
|
||||
cloud_model
|
||||
.get_workflow_enum(&workflow_enum_id)
|
||||
.unwrap()
|
||||
.model()
|
||||
.string_model
|
||||
.name,
|
||||
"Environment"
|
||||
);
|
||||
});
|
||||
|
||||
let events = events.borrow();
|
||||
assert!(events.contains(&EventKind::Folder {
|
||||
folder_id: nodes::FolderId::from(1),
|
||||
object_id: Some(parent_id.uid()),
|
||||
}));
|
||||
assert!(events.contains(&EventKind::Folder {
|
||||
folder_id: nodes::FolderId::from(2),
|
||||
object_id: Some(child_id.uid()),
|
||||
}));
|
||||
assert!(events.contains(&EventKind::File {
|
||||
file_id: FileId(0),
|
||||
object_id: Some(notebook_id.uid()),
|
||||
}));
|
||||
assert!(events.contains(&EventKind::File {
|
||||
file_id: FileId(1),
|
||||
object_id: Some(workflow_id.uid()),
|
||||
}));
|
||||
});
|
||||
}
|
||||
+82
-3
@@ -68,7 +68,7 @@ use crate::drive::panel::DrivePanelAction;
|
||||
use crate::editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions};
|
||||
use crate::env_vars::CloudEnvVarCollection;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::local_object_repository::LocalObjectRepository;
|
||||
use crate::local_object_repository::{local_owner, LocalObjectRepository};
|
||||
use crate::menu::{Event, Menu, MenuItem, MenuItemFields};
|
||||
use crate::network::NetworkStatus;
|
||||
use crate::notebooks::CloudNotebookModel;
|
||||
@@ -1064,6 +1064,12 @@ impl DriveIndex {
|
||||
NetworkStatus::as_ref(app).is_online()
|
||||
}
|
||||
|
||||
fn is_local_folder(folder_id: &SyncId, app: &AppContext) -> bool {
|
||||
CloudModel::as_ref(app)
|
||||
.get_folder(folder_id)
|
||||
.is_some_and(|folder| folder.permissions.owner == local_owner())
|
||||
}
|
||||
|
||||
pub fn scroll_item_into_view(&mut self, item_id: WarpDriveItemId, ctx: &mut ViewContext<Self>) {
|
||||
self.clipped_scroll_state.scroll_to_position(ScrollTarget {
|
||||
position_id: item_id.drive_row_position_id(),
|
||||
@@ -3330,10 +3336,16 @@ impl DriveIndex {
|
||||
match new_location {
|
||||
CloudObjectLocation::Space(space) => self.open_section_of_space(space),
|
||||
CloudObjectLocation::Folder(folder_id) => {
|
||||
if Self::is_local_folder(&folder_id, ctx) {
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.open_folder(folder_id, ctx);
|
||||
});
|
||||
} else {
|
||||
cloud_model.update(ctx, |cloud_model, ctx| {
|
||||
cloud_model.open_folder(folder_id, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
// If location is the trash, then the above move_[object]_to_location call already trashed the object
|
||||
CloudObjectLocation::Trash => {}
|
||||
}
|
||||
@@ -3508,9 +3520,15 @@ impl DriveIndex {
|
||||
if !new_name.is_empty() {
|
||||
self.reset_menus(ctx);
|
||||
|
||||
if Self::is_local_folder(&folder_id, ctx) {
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.update_folder_name(folder_id, new_name, ctx);
|
||||
});
|
||||
} else {
|
||||
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
|
||||
update_manager.rename_folder(folder_id, new_name, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
self.cloud_object_naming_dialog.close(ctx);
|
||||
ctx.notify();
|
||||
@@ -3542,6 +3560,11 @@ impl DriveIndex {
|
||||
repository.set_env_var_collection_trashed(id, true, ctx);
|
||||
});
|
||||
}
|
||||
CloudObjectTypeAndId::Folder(id) if Self::is_local_folder(&id, ctx) => {
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.set_folder_trashed(id, true, ctx);
|
||||
});
|
||||
}
|
||||
CloudObjectTypeAndId::Folder(_) | CloudObjectTypeAndId::GenericStringObject { .. } => {
|
||||
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
|
||||
update_manager.trash_object(cloud_object_type_and_id, ctx);
|
||||
@@ -3585,6 +3608,14 @@ impl DriveIndex {
|
||||
ctx.notify();
|
||||
return;
|
||||
}
|
||||
CloudObjectTypeAndId::Folder(id) if Self::is_local_folder(id, ctx) => {
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.set_folder_trashed(*id, false, ctx);
|
||||
});
|
||||
self.reset_menus(ctx);
|
||||
ctx.notify();
|
||||
return;
|
||||
}
|
||||
CloudObjectTypeAndId::Folder(_) | CloudObjectTypeAndId::GenericStringObject { .. } => {}
|
||||
}
|
||||
|
||||
@@ -3736,6 +3767,11 @@ impl DriveIndex {
|
||||
repository.delete_env_var_collection(*id, ctx);
|
||||
});
|
||||
}
|
||||
CloudObjectTypeAndId::Folder(id) if Self::is_local_folder(id, ctx) => {
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.delete_folder(*id, ctx);
|
||||
});
|
||||
}
|
||||
CloudObjectTypeAndId::Folder(_) | CloudObjectTypeAndId::GenericStringObject { .. } => {
|
||||
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
|
||||
update_manager.delete_object_by_user(*cloud_object_type_and_id, ctx);
|
||||
@@ -5088,15 +5124,36 @@ impl DriveIndex {
|
||||
}
|
||||
}
|
||||
CloudObjectTypeAndId::Folder(id) => {
|
||||
if Self::is_local_folder(id, ctx) {
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
match key {
|
||||
DriveIndexAction::EnterKey => {
|
||||
repository.toggle_folder_open(*id, ctx);
|
||||
}
|
||||
DriveIndexAction::LeftArrowKey => {
|
||||
repository.close_folder(*id, ctx)
|
||||
}
|
||||
DriveIndexAction::RightArrowKey => {
|
||||
repository.open_folder(*id, ctx)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| match key {
|
||||
DriveIndexAction::EnterKey => {
|
||||
cloud_model.toggle_folder_open(*id, ctx);
|
||||
}
|
||||
DriveIndexAction::LeftArrowKey => cloud_model.close_folder(*id, ctx),
|
||||
DriveIndexAction::RightArrowKey => cloud_model.open_folder(*id, ctx),
|
||||
DriveIndexAction::LeftArrowKey => {
|
||||
cloud_model.close_folder(*id, ctx)
|
||||
}
|
||||
DriveIndexAction::RightArrowKey => {
|
||||
cloud_model.open_folder(*id, ctx)
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
}
|
||||
CloudObjectTypeAndId::GenericStringObject { object_type, id: _ } => {
|
||||
if let GenericStringObjectFormat::Json(JsonObjectType::EnvVarCollection) =
|
||||
object_type
|
||||
@@ -5556,15 +5613,37 @@ impl TypedActionView for DriveIndex {
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
if Self::is_local_folder(id, ctx) {
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.toggle_folder_open(*id, ctx);
|
||||
});
|
||||
} else {
|
||||
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
|
||||
cloud_model.toggle_folder_open(*id, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
DriveIndexAction::CollapseAllInLocation(location) => {
|
||||
if let CloudObjectLocation::Folder(folder_id) = location {
|
||||
if Self::is_local_folder(folder_id, ctx) {
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.collapse_local_folders_in_location(*location, ctx);
|
||||
});
|
||||
} else {
|
||||
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
|
||||
cloud_model.collapse_all_in_location(
|
||||
*location,
|
||||
self.index_variant,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
|
||||
cloud_model.collapse_all_in_location(*location, self.index_variant, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
DriveIndexAction::TrashObject {
|
||||
cloud_object_type_and_id,
|
||||
} => {
|
||||
|
||||
@@ -178,6 +178,16 @@ impl DrivePanel {
|
||||
} => match Self::new_object_owner(*space, initial_folder_id.as_ref(), ctx) {
|
||||
Some(owner) => {
|
||||
let client_id = ClientId::default();
|
||||
if owner == local_owner() {
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.create_folder_with_id(
|
||||
SyncId::ClientId(client_id),
|
||||
title.clone(),
|
||||
*initial_folder_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
||||
update_manager.create_folder(
|
||||
title.clone(),
|
||||
@@ -190,6 +200,7 @@ impl DrivePanel {
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
None => {
|
||||
log::error!("Cannot identify a folder owner from {space:?}");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use settings::macros::define_settings_group;
|
||||
use settings::{RespectUserSyncSetting, Setting, SupportedPlatforms, SyncToCloud};
|
||||
|
||||
@@ -37,14 +36,8 @@ define_settings_group!(WarpDriveSettings, settings: [
|
||||
|
||||
impl WarpDriveSettings {
|
||||
/// Returns whether Warp Drive should be considered enabled.
|
||||
/// Returns `false` when the user is anonymous or fully logged out,
|
||||
/// regardless of the user setting.
|
||||
pub fn is_warp_drive_enabled(app: &galaxyui::AppContext) -> bool {
|
||||
use galaxyui::SingletonEntity as _;
|
||||
let is_anonymous_or_logged_out = FeatureFlag::SkipFirebaseAnonymousUser.is_enabled()
|
||||
&& crate::auth::AuthStateProvider::as_ref(app)
|
||||
.get()
|
||||
.is_anonymous_or_logged_out();
|
||||
*Self::as_ref(app).enable_warp_drive && !is_anonymous_or_logged_out
|
||||
*Self::as_ref(app).enable_warp_drive
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,16 +15,18 @@ use crate::auth::UserUid;
|
||||
use crate::cloud_object::model::generic_string_model::GenericStringObjectId;
|
||||
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
|
||||
use crate::cloud_object::{
|
||||
CloudObject, CloudObjectMetadata, CloudObjectPermissions, CloudObjectStatuses,
|
||||
CloudObjectSyncStatus, GenericCloudObject, GenericStringObjectFormat, JsonObjectType,
|
||||
ObjectIdType, Owner, Revision,
|
||||
CloudObject, CloudObjectLocation, CloudObjectMetadata, CloudObjectPermissions,
|
||||
CloudObjectStatuses, CloudObjectSyncStatus, GenericCloudObject, GenericStringObjectFormat,
|
||||
JsonObjectType, ObjectIdType, Owner, Revision, Space,
|
||||
};
|
||||
use crate::drive::folders::{CloudFolder, CloudFolderModel};
|
||||
use crate::drive::CloudObjectTypeAndId;
|
||||
use crate::env_vars::{CloudEnvVarCollection, CloudEnvVarCollectionModel, EnvVarCollection};
|
||||
use crate::notebooks::{CloudNotebook, CloudNotebookModel};
|
||||
use crate::persistence::ModelEvent;
|
||||
use crate::server::ids::{ClientId, SyncId};
|
||||
use crate::workflows::workflow::Workflow;
|
||||
use crate::workflows::workflow_enum::{CloudWorkflowEnum, CloudWorkflowEnumModel, WorkflowEnum};
|
||||
use crate::workflows::{CloudWorkflow, CloudWorkflowModel};
|
||||
|
||||
const LOCAL_OWNER_ID: &str = "local-galaxy-user";
|
||||
@@ -213,6 +215,140 @@ impl LocalObjectRepository {
|
||||
CloudModel::as_ref(app).get_notebook(id).cloned()
|
||||
}
|
||||
|
||||
pub fn folder(&self, id: &SyncId, app: &AppContext) -> Option<CloudFolder> {
|
||||
CloudModel::as_ref(app).get_folder(id).cloned()
|
||||
}
|
||||
|
||||
pub fn create_folder_with_id(
|
||||
&mut self,
|
||||
id: SyncId,
|
||||
name: String,
|
||||
parent_folder_id: Option<SyncId>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.upsert_folder(
|
||||
new_local_folder(id, parent_folder_id, CloudFolderModel::new(&name, false)),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn update_folder_name(
|
||||
&mut self,
|
||||
id: SyncId,
|
||||
name: String,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> bool {
|
||||
let Some(mut folder) = self.folder(&id, ctx) else {
|
||||
return false;
|
||||
};
|
||||
folder.set_model(CloudFolderModel {
|
||||
name,
|
||||
is_open: folder.model().is_open,
|
||||
is_warp_pack: folder.model().is_warp_pack,
|
||||
});
|
||||
set_locally_saved_metadata(&mut folder.metadata);
|
||||
self.upsert_folder(folder, ctx);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn set_folder_trashed(
|
||||
&mut self,
|
||||
id: SyncId,
|
||||
trashed: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> bool {
|
||||
let Some(mut folder) = self.folder(&id, ctx) else {
|
||||
return false;
|
||||
};
|
||||
folder.metadata.trashed_ts = trashed.then(|| ServerTimestamp::new(Utc::now()));
|
||||
set_locally_saved_metadata(&mut folder.metadata);
|
||||
self.upsert_folder(folder, ctx);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn delete_folder(&mut self, id: SyncId, ctx: &mut ModelContext<Self>) -> bool {
|
||||
if self.folder(&id, ctx).is_none() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let ids = CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
|
||||
cloud_model.delete_object_and_descendants(id.uid(), ctx)
|
||||
});
|
||||
if ids.is_empty() {
|
||||
return false;
|
||||
}
|
||||
self.save(ModelEvent::DeleteObjects { ids });
|
||||
true
|
||||
}
|
||||
|
||||
pub fn open_folder(&mut self, id: SyncId, ctx: &mut ModelContext<Self>) {
|
||||
self.set_folder_open_state(id, true, ctx);
|
||||
}
|
||||
|
||||
pub fn close_folder(&mut self, id: SyncId, ctx: &mut ModelContext<Self>) {
|
||||
self.set_folder_open_state(id, false, ctx);
|
||||
}
|
||||
|
||||
pub fn toggle_folder_open(&mut self, id: SyncId, ctx: &mut ModelContext<Self>) {
|
||||
let Some(folder) = self.folder(&id, ctx) else {
|
||||
return;
|
||||
};
|
||||
self.set_folder_open_state(id, !folder.model().is_open, ctx);
|
||||
}
|
||||
|
||||
pub fn collapse_local_folders_in_location(
|
||||
&mut self,
|
||||
location: CloudObjectLocation,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let folder_ids = {
|
||||
let cloud_model = CloudModel::as_ref(ctx);
|
||||
cloud_model
|
||||
.get_all_active_and_inactive_folders()
|
||||
.filter(|folder| folder.permissions.owner == local_owner())
|
||||
.filter(|folder| match location {
|
||||
CloudObjectLocation::Folder(parent_id) => {
|
||||
if folder.id == parent_id {
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut ancestor_id = folder.metadata.folder_id;
|
||||
while let Some(id) = ancestor_id {
|
||||
if id == parent_id {
|
||||
return true;
|
||||
}
|
||||
ancestor_id = cloud_model
|
||||
.get_folder(&id)
|
||||
.and_then(|ancestor| ancestor.metadata.folder_id);
|
||||
}
|
||||
false
|
||||
}
|
||||
CloudObjectLocation::Space(Space::Personal) => true,
|
||||
CloudObjectLocation::Space(Space::Shared)
|
||||
| CloudObjectLocation::Space(Space::Team { .. })
|
||||
| CloudObjectLocation::Trash => false,
|
||||
})
|
||||
.map(|folder| folder.id)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
for folder_id in folder_ids {
|
||||
self.close_folder(folder_id, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_folder_open_state(&self, id: SyncId, is_open: bool, ctx: &mut ModelContext<Self>) {
|
||||
let Some(mut folder) = self.folder(&id, ctx) else {
|
||||
return;
|
||||
};
|
||||
folder.set_model(CloudFolderModel {
|
||||
name: folder.model().name.clone(),
|
||||
is_open,
|
||||
is_warp_pack: folder.model().is_warp_pack,
|
||||
});
|
||||
self.upsert_folder(folder, ctx);
|
||||
}
|
||||
|
||||
pub fn create_notebook_with_id(
|
||||
&mut self,
|
||||
id: SyncId,
|
||||
@@ -480,6 +616,23 @@ impl LocalObjectRepository {
|
||||
self.upsert_workflow(new_local_workflow(id, folder_id, workflow), ctx);
|
||||
}
|
||||
|
||||
pub fn create_workflow_enum_with_id(
|
||||
&mut self,
|
||||
id: SyncId,
|
||||
workflow_enum: WorkflowEnum,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.upsert_workflow_enum(
|
||||
GenericCloudObject::new(
|
||||
id,
|
||||
CloudWorkflowEnumModel::new(workflow_enum),
|
||||
locally_saved_metadata(None),
|
||||
local_permissions(),
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn update_workflow(
|
||||
&mut self,
|
||||
id: SyncId,
|
||||
@@ -591,6 +744,13 @@ impl LocalObjectRepository {
|
||||
self.save(ModelEvent::UpsertNotebook { notebook });
|
||||
}
|
||||
|
||||
fn upsert_folder(&self, folder: CloudFolder, ctx: &mut ModelContext<Self>) {
|
||||
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
|
||||
cloud_model.upsert_local_object(folder.clone(), ctx);
|
||||
});
|
||||
self.save(ModelEvent::UpsertFolder { folder });
|
||||
}
|
||||
|
||||
fn upsert_workflow(&self, workflow: CloudWorkflow, ctx: &mut ModelContext<Self>) {
|
||||
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
|
||||
cloud_model.upsert_local_object(workflow.clone(), ctx);
|
||||
@@ -598,6 +758,15 @@ impl LocalObjectRepository {
|
||||
self.save(ModelEvent::UpsertWorkflow { workflow });
|
||||
}
|
||||
|
||||
fn upsert_workflow_enum(&self, workflow_enum: CloudWorkflowEnum, ctx: &mut ModelContext<Self>) {
|
||||
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
|
||||
cloud_model.upsert_local_object(workflow_enum.clone(), ctx);
|
||||
});
|
||||
self.save(ModelEvent::UpsertGenericStringObject {
|
||||
object: Box::new(workflow_enum),
|
||||
});
|
||||
}
|
||||
|
||||
fn delete_local_object(
|
||||
&self,
|
||||
id: SyncId,
|
||||
@@ -658,6 +827,19 @@ pub(crate) fn new_local_notebook(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn new_local_folder(
|
||||
id: SyncId,
|
||||
folder_id: Option<SyncId>,
|
||||
folder: CloudFolderModel,
|
||||
) -> CloudFolder {
|
||||
CloudFolder::new(
|
||||
id,
|
||||
folder,
|
||||
locally_saved_metadata(folder_id),
|
||||
local_permissions(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn new_local_workflow(
|
||||
id: SyncId,
|
||||
folder_id: Option<SyncId>,
|
||||
|
||||
@@ -172,6 +172,71 @@ fn create_update_and_delete_notebook_are_local_and_persisted() {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_update_open_trash_and_delete_folder_are_local_and_persisted() {
|
||||
App::test((), |mut app| async move {
|
||||
let receiver = initialize_app(&mut app);
|
||||
let repository = LocalObjectRepository::handle(&app);
|
||||
let parent_id = SyncId::ClientId(ClientId::new());
|
||||
let child_id = SyncId::ClientId(ClientId::new());
|
||||
|
||||
repository.update(&mut app, |repository, ctx| {
|
||||
repository.create_folder_with_id(parent_id, "Projects".to_string(), None, ctx);
|
||||
});
|
||||
let ModelEvent::UpsertFolder { folder } = receiver.recv().unwrap() else {
|
||||
panic!("expected a local folder upsert");
|
||||
};
|
||||
assert_eq!(folder.id, parent_id);
|
||||
assert_eq!(folder.model().name, "Projects");
|
||||
assert!(!folder.model().is_open);
|
||||
|
||||
repository.update(&mut app, |repository, ctx| {
|
||||
repository.create_folder_with_id(child_id, "Rust".to_string(), Some(parent_id), ctx);
|
||||
});
|
||||
assert!(
|
||||
matches!(receiver.recv().unwrap(), ModelEvent::UpsertFolder { folder } if folder.id == child_id && folder.metadata.folder_id == Some(parent_id))
|
||||
);
|
||||
|
||||
assert!(repository.update(&mut app, |repository, ctx| {
|
||||
repository.update_folder_name(parent_id, "Projects 2026".to_string(), ctx)
|
||||
}));
|
||||
assert!(
|
||||
matches!(receiver.recv().unwrap(), ModelEvent::UpsertFolder { folder } if folder.model().name == "Projects 2026")
|
||||
);
|
||||
|
||||
repository.update(&mut app, |repository, ctx| {
|
||||
repository.open_folder(parent_id, ctx);
|
||||
});
|
||||
assert!(
|
||||
matches!(receiver.recv().unwrap(), ModelEvent::UpsertFolder { folder } if folder.model().is_open)
|
||||
);
|
||||
|
||||
assert!(repository.update(&mut app, |repository, ctx| {
|
||||
repository.set_folder_trashed(parent_id, true, ctx)
|
||||
}));
|
||||
assert!(
|
||||
matches!(receiver.recv().unwrap(), ModelEvent::UpsertFolder { folder } if folder.metadata.trashed_ts.is_some())
|
||||
);
|
||||
|
||||
assert!(repository.update(&mut app, |repository, ctx| {
|
||||
repository.set_folder_trashed(parent_id, false, ctx)
|
||||
}));
|
||||
assert!(
|
||||
matches!(receiver.recv().unwrap(), ModelEvent::UpsertFolder { folder } if folder.metadata.trashed_ts.is_none())
|
||||
);
|
||||
|
||||
assert!(repository.update(&mut app, |repository, ctx| {
|
||||
repository.delete_folder(parent_id, ctx)
|
||||
}));
|
||||
assert!(matches!(
|
||||
receiver.recv().unwrap(),
|
||||
ModelEvent::DeleteObjects { ids }
|
||||
if ids.contains(&(parent_id, ObjectIdType::Folder))
|
||||
&& ids.contains(&(child_id, ObjectIdType::Folder))
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_update_and_delete_workflow_are_local_and_persisted() {
|
||||
App::test((), |mut app| async move {
|
||||
|
||||
@@ -926,6 +926,13 @@ pub enum OpenAIProviderKind {
|
||||
OpenAICompatible,
|
||||
/// The ChatGPT subscription backend, authenticated with ChatGPT OAuth.
|
||||
ChatGPTSubscription,
|
||||
/// Anthropic's native Messages API.
|
||||
Anthropic,
|
||||
/// Google's Gemini API.
|
||||
Gemini,
|
||||
/// Google's Gemini models hosted through Vertex AI.
|
||||
#[serde(rename = "vertex_ai", alias = "vertex_a_i")]
|
||||
VertexAI,
|
||||
}
|
||||
|
||||
/// Configuration for a single OpenAI-compatible provider endpoint.
|
||||
@@ -951,6 +958,12 @@ pub struct OpenAIProviderConfig {
|
||||
#[schemars(description = "API key for this endpoint (optional if the proxy handles auth).")]
|
||||
pub api_key: Option<String>,
|
||||
#[serde(default)]
|
||||
#[schemars(description = "Google Cloud project ID for Vertex AI providers.")]
|
||||
pub project_id: Option<String>,
|
||||
#[serde(default)]
|
||||
#[schemars(description = "Google Cloud location for Vertex AI providers.")]
|
||||
pub location: Option<String>,
|
||||
#[serde(default)]
|
||||
#[schemars(description = "Models available from this provider.")]
|
||||
pub models: Vec<OpenAIModelConfig>,
|
||||
}
|
||||
@@ -1021,6 +1034,8 @@ pub(crate) fn default_chatgpt_provider() -> OpenAIProviderConfig {
|
||||
name: "ChatGPT Subscription".to_string(),
|
||||
base_url: String::new(),
|
||||
api_key: None,
|
||||
project_id: None,
|
||||
location: None,
|
||||
models: default_chatgpt_models(),
|
||||
}
|
||||
}
|
||||
@@ -1035,6 +1050,8 @@ fn default_openai_providers() -> Vec<OpenAIProviderConfig> {
|
||||
// Credentials are deliberately never committed. Set this locally in
|
||||
// ~/.galaxy/settings.toml before sending a request.
|
||||
api_key: None,
|
||||
project_id: None,
|
||||
location: None,
|
||||
models: vec![OpenAIModelConfig {
|
||||
model_id: INITIAL_RIG_MODEL_ID.to_string(),
|
||||
display_name: "Codex GPT-5.6 SOL (xhigh)".to_string(),
|
||||
|
||||
@@ -423,6 +423,34 @@ fn initial_litellm_provider_maps_codex_model_to_rig_without_a_committed_key() {
|
||||
assert!(instant.reasoning_efforts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_provider_settings_roundtrip_with_vertex_configuration() {
|
||||
let provider: OpenAIProviderConfig = serde_json::from_value(serde_json::json!({
|
||||
"kind": "vertex_ai",
|
||||
"enabled": true,
|
||||
"name": "Vertex production",
|
||||
"base_url": "",
|
||||
"project_id": "galaxy-project",
|
||||
"location": "us-central1",
|
||||
"models": []
|
||||
}))
|
||||
.expect("Vertex provider settings should deserialize");
|
||||
|
||||
assert_eq!(provider.kind, OpenAIProviderKind::VertexAI);
|
||||
assert_eq!(provider.project_id.as_deref(), Some("galaxy-project"));
|
||||
assert_eq!(provider.location.as_deref(), Some("us-central1"));
|
||||
|
||||
let legacy: OpenAIProviderConfig = serde_json::from_value(serde_json::json!({
|
||||
"name": "Legacy provider",
|
||||
"base_url": "http://localhost:4000/v1",
|
||||
"models": []
|
||||
}))
|
||||
.expect("Legacy provider settings should remain compatible");
|
||||
assert_eq!(legacy.kind, OpenAIProviderKind::OpenAICompatible);
|
||||
assert_eq!(legacy.project_id, None);
|
||||
assert_eq!(legacy.location, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_litellm_model_infers_missing_system_message_capability() {
|
||||
let mut model = default_openai_providers().remove(0).models.remove(0);
|
||||
|
||||
@@ -7564,13 +7564,13 @@ impl SettingsWidget for ModelsOverviewWidget {
|
||||
.with_spacing(8.)
|
||||
.with_child(build_sub_header(appearance, "Models", None).finish())
|
||||
.with_child(render_ai_setting_description(
|
||||
"Configure Galaxy's direct model providers and agent runtimes in one place. OpenAI-compatible endpoints and Bedrock models run through Rig. ACP coding agents use the same Galaxy runtime boundary while retaining their own model, login, session, and tool loop.",
|
||||
"Configure Galaxy's direct model providers and agent runtimes in one place. OpenAI-compatible, Anthropic, Gemini, Vertex AI, and Bedrock models run through Rig. ACP coding agents use the same Galaxy runtime boundary while retaining their own model, login, session, and tool loop.",
|
||||
true,
|
||||
app,
|
||||
))
|
||||
.with_child(render_ai_setting_description(
|
||||
format!(
|
||||
"{endpoint_count} OpenAI-compatible provider(s) with {endpoint_model_count} model(s); {bedrock_model_count} Bedrock model(s); {agent_runtime_count} enabled agent runtime(s)."
|
||||
"{endpoint_count} configured provider(s) with {endpoint_model_count} model(s); {bedrock_model_count} Bedrock model(s); {agent_runtime_count} enabled agent runtime(s)."
|
||||
),
|
||||
true,
|
||||
app,
|
||||
@@ -7680,6 +7680,9 @@ impl OpenAIProviderSettingsWidget {
|
||||
match provider.kind {
|
||||
OpenAIProviderKind::OpenAICompatible => "OpenAI-compatible API",
|
||||
OpenAIProviderKind::ChatGPTSubscription => "ChatGPT subscription",
|
||||
OpenAIProviderKind::Anthropic => "Anthropic",
|
||||
OpenAIProviderKind::Gemini => "Google Gemini",
|
||||
OpenAIProviderKind::VertexAI => "Google Vertex AI",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7905,11 +7908,12 @@ impl SettingsWidget for OpenAIProviderSettingsWidget {
|
||||
app,
|
||||
));
|
||||
column.add_child(render_ai_setting_description(
|
||||
"Connect a ChatGPT subscription, OpenAI-compatible endpoint, AWS Bedrock account, or ACP agent runtime. Each provider can be enabled independently.",
|
||||
"Connect a ChatGPT subscription, OpenAI-compatible endpoint, Anthropic, Gemini, Vertex AI, AWS Bedrock account, or ACP agent runtime. Each provider can be enabled independently.",
|
||||
true,
|
||||
app,
|
||||
));
|
||||
|
||||
if !settings.bedrock_models.value().is_empty() {
|
||||
column.add_child(self.render_builtin_provider_card(
|
||||
settings.bedrock_connection_name.value().as_str(),
|
||||
"Use AWS credentials to access Bedrock foundation models directly.",
|
||||
@@ -7920,7 +7924,9 @@ impl SettingsWidget for OpenAIProviderSettingsWidget {
|
||||
&self.bedrock_remove_button,
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
if cfg!(unix) && FeatureFlag::AgentClientProtocol.is_enabled() {
|
||||
if *settings.acp_enabled.value() {
|
||||
column.add_child(self.render_builtin_provider_card(
|
||||
settings.acp_connection_name.value().as_str(),
|
||||
"Use a local session-oriented agent that owns its model and authentication.",
|
||||
@@ -7932,6 +7938,7 @@ impl SettingsWidget for OpenAIProviderSettingsWidget {
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if providers.is_empty() {
|
||||
column.add_child(render_ai_setting_description(
|
||||
|
||||
@@ -1167,9 +1167,6 @@ impl SettingsView {
|
||||
// Warp Drive page
|
||||
let warp_drive_page_handle =
|
||||
ctx.add_typed_action_view(warp_drive_page::WarpDriveSettingsPageView::new);
|
||||
ctx.subscribe_to_view(&warp_drive_page_handle, |me, _, event, ctx| {
|
||||
me.handle_warp_drive_page_event(event, ctx);
|
||||
});
|
||||
|
||||
let platform_page_handle = ctx.add_typed_action_view(platform_page::PlatformPageView::new);
|
||||
ctx.subscribe_to_view(&platform_page_handle, |me, _, event, ctx| {
|
||||
@@ -1738,18 +1735,6 @@ impl SettingsView {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_warp_drive_page_event(
|
||||
&mut self,
|
||||
event: &warp_drive_page::WarpDriveSettingsPageEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
warp_drive_page::WarpDriveSettingsPageEvent::SignUp => {
|
||||
ctx.emit(SettingsViewEvent::SignupAnonymousUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_ai_page_event(&mut self, event: &AISettingsPageEvent, ctx: &mut ViewContext<Self>) {
|
||||
match event {
|
||||
AISettingsPageEvent::FocusModal => ctx.focus(&self.search_editor),
|
||||
|
||||
@@ -46,10 +46,51 @@ enum ProviderSetupStep {
|
||||
pub enum ProviderSetupProviderType {
|
||||
ChatGPTSubscription,
|
||||
OpenAICompatible,
|
||||
Anthropic,
|
||||
Gemini,
|
||||
VertexAI,
|
||||
Bedrock,
|
||||
Acp,
|
||||
}
|
||||
|
||||
const PROVIDER_TYPE_OPTIONS: &[(ProviderSetupProviderType, &str, &str)] = &[
|
||||
(
|
||||
ProviderSetupProviderType::ChatGPTSubscription,
|
||||
"ChatGPT subscription",
|
||||
"Use your ChatGPT Plus or Pro subscription with native OAuth.",
|
||||
),
|
||||
(
|
||||
ProviderSetupProviderType::OpenAICompatible,
|
||||
"OpenAI-compatible API",
|
||||
"Connect LiteLLM, Ollama, vLLM, or another compatible endpoint.",
|
||||
),
|
||||
(
|
||||
ProviderSetupProviderType::Anthropic,
|
||||
"Anthropic",
|
||||
"Connect directly to Anthropic's native Messages API with an API key.",
|
||||
),
|
||||
(
|
||||
ProviderSetupProviderType::Gemini,
|
||||
"Google Gemini",
|
||||
"Connect directly to Google's Gemini API with an API key.",
|
||||
),
|
||||
(
|
||||
ProviderSetupProviderType::VertexAI,
|
||||
"Google Vertex AI",
|
||||
"Use Google Cloud Application Default Credentials for Vertex-hosted Gemini models.",
|
||||
),
|
||||
(
|
||||
ProviderSetupProviderType::Bedrock,
|
||||
"AWS Bedrock",
|
||||
"Use the AWS Bedrock credentials and model configuration already managed by Galaxy.",
|
||||
),
|
||||
(
|
||||
ProviderSetupProviderType::Acp,
|
||||
"ACP agent runtime",
|
||||
"Use a session-oriented ACP agent that owns its model and authentication.",
|
||||
),
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BedrockProviderDraft {
|
||||
pub name: String,
|
||||
@@ -114,6 +155,8 @@ pub struct ProviderSetupModalBody {
|
||||
draft_name: String,
|
||||
draft_base_url: String,
|
||||
draft_api_key: Option<String>,
|
||||
draft_project_id: String,
|
||||
draft_location: String,
|
||||
draft_models: Vec<OpenAIModelConfig>,
|
||||
draft_bedrock: BedrockProviderDraft,
|
||||
draft_acp: AcpProviderDraft,
|
||||
@@ -122,6 +165,8 @@ pub struct ProviderSetupModalBody {
|
||||
name_editor: ViewHandle<EditorView>,
|
||||
base_url_editor: ViewHandle<EditorView>,
|
||||
api_key_editor: ViewHandle<EditorView>,
|
||||
project_id_editor: ViewHandle<EditorView>,
|
||||
location_editor: ViewHandle<EditorView>,
|
||||
bedrock_profile_editor: ViewHandle<EditorView>,
|
||||
bedrock_region_editor: ViewHandle<EditorView>,
|
||||
bedrock_refresh_command_editor: ViewHandle<EditorView>,
|
||||
@@ -134,6 +179,7 @@ pub struct ProviderSetupModalBody {
|
||||
bedrock_cross_region_toggle: SwitchStateHandle,
|
||||
bedrock_auto_login_toggle: SwitchStateHandle,
|
||||
model_switches: Vec<SwitchStateHandle>,
|
||||
provider_type_scroll_state: ClippedScrollStateHandle,
|
||||
models_scroll_state: ClippedScrollStateHandle,
|
||||
back_button: ViewHandle<ActionButton>,
|
||||
cancel_button: ViewHandle<ActionButton>,
|
||||
@@ -142,27 +188,18 @@ pub struct ProviderSetupModalBody {
|
||||
|
||||
impl ProviderSetupModalBody {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let provider_type_buttons = [
|
||||
(
|
||||
ProviderSetupProviderType::ChatGPTSubscription,
|
||||
"ChatGPT subscription",
|
||||
),
|
||||
(
|
||||
ProviderSetupProviderType::OpenAICompatible,
|
||||
"OpenAI-compatible API",
|
||||
),
|
||||
(ProviderSetupProviderType::Bedrock, "AWS Bedrock"),
|
||||
(ProviderSetupProviderType::Acp, "ACP agent runtime"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(kind, label)| {
|
||||
let provider_type_buttons = PROVIDER_TYPE_OPTIONS
|
||||
.iter()
|
||||
.map(|(kind, label, _)| {
|
||||
let kind = *kind;
|
||||
let label = *label;
|
||||
ctx.add_typed_action_view(move |_| {
|
||||
ActionButton::new(label, NakedTheme)
|
||||
.with_full_width(true)
|
||||
.on_click(move |ctx| {
|
||||
ctx.dispatch_typed_action(ProviderSetupModalBodyAction::SelectProvider(
|
||||
kind,
|
||||
));
|
||||
ctx.dispatch_typed_action(
|
||||
ProviderSetupModalBodyAction::SelectProvider(kind),
|
||||
);
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -171,6 +208,8 @@ impl ProviderSetupModalBody {
|
||||
let name_editor = Self::create_editor("Connection name", false, ctx);
|
||||
let base_url_editor = Self::create_editor("https://api.example.com/v1", false, ctx);
|
||||
let api_key_editor = Self::create_editor("sk-... (optional)", true, ctx);
|
||||
let project_id_editor = Self::create_editor("my-google-cloud-project", false, ctx);
|
||||
let location_editor = Self::create_editor("global", false, ctx);
|
||||
let bedrock_profile_editor = Self::create_editor("default", false, ctx);
|
||||
let bedrock_region_editor = Self::create_editor("us-east-1", false, ctx);
|
||||
let bedrock_refresh_command_editor = Self::create_editor("aws sso login", false, ctx);
|
||||
@@ -218,6 +257,19 @@ impl ProviderSetupModalBody {
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
ctx.subscribe_to_view(&project_id_editor, |me, editor, event, ctx| {
|
||||
if matches!(event, EditorEvent::Edited(_)) {
|
||||
me.draft_project_id = editor.as_ref(ctx).buffer_text(ctx);
|
||||
me.update_next_button(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
ctx.subscribe_to_view(&location_editor, |me, editor, event, ctx| {
|
||||
if matches!(event, EditorEvent::Edited(_)) {
|
||||
me.draft_location = editor.as_ref(ctx).buffer_text(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
for (editor, update) in [
|
||||
(bedrock_profile_editor.clone(), 0),
|
||||
(bedrock_region_editor.clone(), 1),
|
||||
@@ -286,6 +338,8 @@ impl ProviderSetupModalBody {
|
||||
draft_name: String::new(),
|
||||
draft_base_url: String::new(),
|
||||
draft_api_key: None,
|
||||
draft_project_id: String::new(),
|
||||
draft_location: "global".to_string(),
|
||||
draft_models: Vec::new(),
|
||||
draft_bedrock: BedrockProviderDraft {
|
||||
name: String::new(),
|
||||
@@ -310,6 +364,8 @@ impl ProviderSetupModalBody {
|
||||
name_editor,
|
||||
base_url_editor,
|
||||
api_key_editor,
|
||||
project_id_editor,
|
||||
location_editor,
|
||||
bedrock_profile_editor,
|
||||
bedrock_region_editor,
|
||||
bedrock_refresh_command_editor,
|
||||
@@ -322,6 +378,7 @@ impl ProviderSetupModalBody {
|
||||
bedrock_cross_region_toggle: SwitchStateHandle::default(),
|
||||
bedrock_auto_login_toggle: SwitchStateHandle::default(),
|
||||
model_switches: Vec::new(),
|
||||
provider_type_scroll_state: ClippedScrollStateHandle::default(),
|
||||
models_scroll_state: ClippedScrollStateHandle::default(),
|
||||
back_button,
|
||||
cancel_button,
|
||||
@@ -363,6 +420,8 @@ impl ProviderSetupModalBody {
|
||||
self.draft_name.clear();
|
||||
self.draft_base_url.clear();
|
||||
self.draft_api_key = None;
|
||||
self.draft_project_id.clear();
|
||||
self.draft_location = "global".to_string();
|
||||
self.draft_models.clear();
|
||||
self.draft_bedrock = BedrockProviderDraft {
|
||||
name: String::new(),
|
||||
@@ -405,10 +464,15 @@ impl ProviderSetupModalBody {
|
||||
ProviderSetupProviderType::ChatGPTSubscription
|
||||
}
|
||||
OpenAIProviderKind::OpenAICompatible => ProviderSetupProviderType::OpenAICompatible,
|
||||
OpenAIProviderKind::Anthropic => ProviderSetupProviderType::Anthropic,
|
||||
OpenAIProviderKind::Gemini => ProviderSetupProviderType::Gemini,
|
||||
OpenAIProviderKind::VertexAI => ProviderSetupProviderType::VertexAI,
|
||||
};
|
||||
self.draft_name = provider.name;
|
||||
self.draft_base_url = provider.base_url;
|
||||
self.draft_api_key = provider.api_key;
|
||||
self.draft_project_id = provider.project_id.unwrap_or_default();
|
||||
self.draft_location = provider.location.unwrap_or_else(|| "global".to_string());
|
||||
self.draft_models = provider.models;
|
||||
self.discovery_state = DiscoveryState::Idle;
|
||||
self.sync_editors(ctx);
|
||||
@@ -483,6 +547,12 @@ impl ProviderSetupModalBody {
|
||||
self.api_key_editor.update(ctx, |editor, ctx| {
|
||||
editor.system_reset_buffer_text(self.draft_api_key.as_deref().unwrap_or_default(), ctx);
|
||||
});
|
||||
self.project_id_editor.update(ctx, |editor, ctx| {
|
||||
editor.system_reset_buffer_text(&self.draft_project_id, ctx);
|
||||
});
|
||||
self.location_editor.update(ctx, |editor, ctx| {
|
||||
editor.system_reset_buffer_text(&self.draft_location, ctx);
|
||||
});
|
||||
self.bedrock_profile_editor.update(ctx, |editor, ctx| {
|
||||
editor.system_reset_buffer_text(&self.draft_bedrock.profile, ctx);
|
||||
});
|
||||
@@ -513,15 +583,12 @@ impl ProviderSetupModalBody {
|
||||
}
|
||||
|
||||
fn sync_provider_type_buttons(&self, ctx: &mut ViewContext<Self>) {
|
||||
for (index, button) in self.provider_type_buttons.iter().enumerate() {
|
||||
let button_kind = match index {
|
||||
0 => ProviderSetupProviderType::ChatGPTSubscription,
|
||||
1 => ProviderSetupProviderType::OpenAICompatible,
|
||||
2 => ProviderSetupProviderType::Bedrock,
|
||||
_ => ProviderSetupProviderType::Acp,
|
||||
};
|
||||
for ((button_kind, _, _), button) in PROVIDER_TYPE_OPTIONS
|
||||
.iter()
|
||||
.zip(self.provider_type_buttons.iter())
|
||||
{
|
||||
button.update(ctx, |button, ctx| {
|
||||
button.set_active(button_kind == self.provider_type, ctx);
|
||||
button.set_active(*button_kind == self.provider_type, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -555,6 +622,12 @@ impl ProviderSetupModalBody {
|
||||
ProviderSetupProviderType::OpenAICompatible => {
|
||||
self.draft_base_url.trim().is_empty()
|
||||
}
|
||||
ProviderSetupProviderType::Anthropic | ProviderSetupProviderType::Gemini => {
|
||||
self.draft_api_key
|
||||
.as_deref()
|
||||
.is_none_or(|key| key.trim().is_empty())
|
||||
}
|
||||
ProviderSetupProviderType::VertexAI => self.draft_project_id.trim().is_empty(),
|
||||
ProviderSetupProviderType::Acp => self.draft_acp.agent_id.trim().is_empty(),
|
||||
ProviderSetupProviderType::ChatGPTSubscription
|
||||
| ProviderSetupProviderType::Bedrock => false,
|
||||
@@ -571,7 +644,10 @@ impl ProviderSetupModalBody {
|
||||
),
|
||||
ProviderSetupStep::Models => match self.provider_type {
|
||||
ProviderSetupProviderType::OpenAICompatible
|
||||
| ProviderSetupProviderType::ChatGPTSubscription => (
|
||||
| ProviderSetupProviderType::ChatGPTSubscription
|
||||
| ProviderSetupProviderType::Anthropic
|
||||
| ProviderSetupProviderType::Gemini
|
||||
| ProviderSetupProviderType::VertexAI => (
|
||||
"Save",
|
||||
self.draft_name.trim().is_empty()
|
||||
|| !self.draft_models.iter().any(|model| model.enabled),
|
||||
@@ -601,19 +677,48 @@ impl ProviderSetupModalBody {
|
||||
ProviderSetupProviderType::OpenAICompatible
|
||||
| ProviderSetupProviderType::Bedrock
|
||||
| ProviderSetupProviderType::Acp => OpenAIProviderKind::OpenAICompatible,
|
||||
ProviderSetupProviderType::Anthropic => OpenAIProviderKind::Anthropic,
|
||||
ProviderSetupProviderType::Gemini => OpenAIProviderKind::Gemini,
|
||||
ProviderSetupProviderType::VertexAI => OpenAIProviderKind::VertexAI,
|
||||
},
|
||||
enabled: true,
|
||||
name: self.draft_name.trim().to_string(),
|
||||
base_url: if self.provider_type == ProviderSetupProviderType::ChatGPTSubscription {
|
||||
base_url: if matches!(
|
||||
self.provider_type,
|
||||
ProviderSetupProviderType::ChatGPTSubscription
|
||||
| ProviderSetupProviderType::Anthropic
|
||||
| ProviderSetupProviderType::Gemini
|
||||
| ProviderSetupProviderType::VertexAI
|
||||
) {
|
||||
String::new()
|
||||
} else {
|
||||
self.draft_base_url.trim().trim_end_matches('/').to_string()
|
||||
},
|
||||
api_key: self
|
||||
.draft_api_key
|
||||
api_key: matches!(
|
||||
self.provider_type,
|
||||
ProviderSetupProviderType::OpenAICompatible
|
||||
| ProviderSetupProviderType::Anthropic
|
||||
| ProviderSetupProviderType::Gemini
|
||||
)
|
||||
.then(|| {
|
||||
self.draft_api_key
|
||||
.as_deref()
|
||||
.filter(|key| !key.trim().is_empty())
|
||||
.map(str::to_string),
|
||||
.map(str::to_string)
|
||||
})
|
||||
.flatten(),
|
||||
project_id: matches!(self.provider_type, ProviderSetupProviderType::VertexAI)
|
||||
.then(|| self.draft_project_id.trim().to_string()),
|
||||
location: matches!(self.provider_type, ProviderSetupProviderType::VertexAI).then(
|
||||
|| {
|
||||
let location = self.draft_location.trim();
|
||||
if location.is_empty() {
|
||||
"global".to_string()
|
||||
} else {
|
||||
location.to_string()
|
||||
}
|
||||
},
|
||||
),
|
||||
models: self.draft_models.clone(),
|
||||
}
|
||||
}
|
||||
@@ -668,7 +773,10 @@ impl ProviderSetupModalBody {
|
||||
));
|
||||
return;
|
||||
}
|
||||
ProviderSetupProviderType::OpenAICompatible => {}
|
||||
ProviderSetupProviderType::OpenAICompatible
|
||||
| ProviderSetupProviderType::Anthropic
|
||||
| ProviderSetupProviderType::Gemini
|
||||
| ProviderSetupProviderType::VertexAI => {}
|
||||
}
|
||||
|
||||
let provider = self.draft_provider();
|
||||
@@ -776,34 +884,17 @@ impl ProviderSetupModalBody {
|
||||
.with_color(appearance.theme().nonactive_ui_text_color().into())
|
||||
.finish();
|
||||
|
||||
let cards = [
|
||||
(
|
||||
"ChatGPT subscription",
|
||||
"Use your ChatGPT Plus or Pro subscription with native OAuth.",
|
||||
),
|
||||
(
|
||||
"OpenAI-compatible API",
|
||||
"Connect LiteLLM, Ollama, vLLM, or another compatible endpoint.",
|
||||
),
|
||||
(
|
||||
"AWS Bedrock",
|
||||
"Use the AWS Bedrock credentials and model configuration already managed by Galaxy.",
|
||||
),
|
||||
(
|
||||
"ACP agent runtime",
|
||||
"Use a session-oriented ACP agent that owns its model and authentication.",
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
let cards = PROVIDER_TYPE_OPTIONS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (label, description))| {
|
||||
.map(|(index, (_, _, description))| {
|
||||
let button = ChildView::new(&self.provider_type_buttons[index]).finish();
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_spacing(8.)
|
||||
.with_child(button)
|
||||
.with_child(
|
||||
Text::new(description, appearance.ui_font_family(), INPUT_FONT_SIZE)
|
||||
Text::new(*description, appearance.ui_font_family(), INPUT_FONT_SIZE)
|
||||
.with_color(appearance.theme().nonactive_ui_text_color().into())
|
||||
.soft_wrap(true)
|
||||
.finish(),
|
||||
@@ -817,10 +908,29 @@ impl ProviderSetupModalBody {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let list = Flex::column()
|
||||
.with_spacing(10.)
|
||||
.with_children(cards)
|
||||
.finish();
|
||||
let scrollable = ClippedScrollable::vertical(
|
||||
self.provider_type_scroll_state.clone(),
|
||||
list,
|
||||
ScrollbarWidth::Auto,
|
||||
appearance.theme().nonactive_ui_detail().into(),
|
||||
appearance.theme().active_ui_detail().into(),
|
||||
appearance.theme().surface_1().into(),
|
||||
)
|
||||
.with_overlayed_scrollbar()
|
||||
.finish();
|
||||
|
||||
Flex::column()
|
||||
.with_spacing(16.)
|
||||
.with_child(description)
|
||||
.with_children(cards)
|
||||
.with_child(
|
||||
ConstrainedBox::new(scrollable)
|
||||
.with_max_height(360.)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
@@ -989,6 +1099,50 @@ impl ProviderSetupModalBody {
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
ProviderSetupProviderType::Anthropic => {
|
||||
children.push(self.render_input(appearance, "API key", &self.api_key_editor));
|
||||
children.push(
|
||||
Text::new(
|
||||
"The key is stored locally and is never synced to the cloud. Models will be discovered from Anthropic after the connection test.",
|
||||
appearance.ui_font_family(),
|
||||
INPUT_FONT_SIZE,
|
||||
)
|
||||
.with_color(appearance.theme().nonactive_ui_text_color().into())
|
||||
.soft_wrap(true)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
ProviderSetupProviderType::Gemini => {
|
||||
children.push(self.render_input(appearance, "API key", &self.api_key_editor));
|
||||
children.push(
|
||||
Text::new(
|
||||
"The key is stored locally and is never synced to the cloud. Models will be discovered from Google's Gemini API after the connection test.",
|
||||
appearance.ui_font_family(),
|
||||
INPUT_FONT_SIZE,
|
||||
)
|
||||
.with_color(appearance.theme().nonactive_ui_text_color().into())
|
||||
.soft_wrap(true)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
ProviderSetupProviderType::VertexAI => {
|
||||
children.push(self.render_input(
|
||||
appearance,
|
||||
"Google Cloud project ID",
|
||||
&self.project_id_editor,
|
||||
));
|
||||
children.push(self.render_input(appearance, "Location", &self.location_editor));
|
||||
children.push(
|
||||
Text::new(
|
||||
"Vertex AI uses Google Application Default Credentials. Run `gcloud auth application-default login` before testing the connection.",
|
||||
appearance.ui_font_family(),
|
||||
INPUT_FONT_SIZE,
|
||||
)
|
||||
.with_color(appearance.theme().nonactive_ui_text_color().into())
|
||||
.soft_wrap(true)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
ProviderSetupProviderType::Bedrock => {
|
||||
children.push(Self::render_label(appearance, "Authentication method"));
|
||||
children.push(
|
||||
@@ -1422,7 +1576,10 @@ impl TypedActionView for ProviderSetupModalBody {
|
||||
}
|
||||
ProviderSetupStep::Models => match self.provider_type {
|
||||
ProviderSetupProviderType::OpenAICompatible
|
||||
| ProviderSetupProviderType::ChatGPTSubscription => {
|
||||
| ProviderSetupProviderType::ChatGPTSubscription
|
||||
| ProviderSetupProviderType::Anthropic
|
||||
| ProviderSetupProviderType::Gemini
|
||||
| ProviderSetupProviderType::VertexAI => {
|
||||
if self.draft_name.trim().is_empty()
|
||||
|| !self.draft_models.iter().any(|model| model.enabled)
|
||||
{
|
||||
@@ -1529,6 +1686,9 @@ fn provider_type_label(kind: ProviderSetupProviderType) -> &'static str {
|
||||
match kind {
|
||||
ProviderSetupProviderType::OpenAICompatible => "OpenAI-compatible API",
|
||||
ProviderSetupProviderType::ChatGPTSubscription => "ChatGPT subscription",
|
||||
ProviderSetupProviderType::Anthropic => "Anthropic",
|
||||
ProviderSetupProviderType::Gemini => "Google Gemini",
|
||||
ProviderSetupProviderType::VertexAI => "Google Vertex AI",
|
||||
ProviderSetupProviderType::Bedrock => "AWS Bedrock",
|
||||
ProviderSetupProviderType::Acp => "ACP agent runtime",
|
||||
}
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::report_if_error;
|
||||
use galaxy_core::settings::ToggleableSetting as _;
|
||||
use galaxyui::fonts::Weight;
|
||||
use galaxyui::keymap::ContextPredicate;
|
||||
use galaxyui::ui_components::button::ButtonVariant;
|
||||
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use galaxyui::ui_components::components::UiComponent;
|
||||
use galaxyui::ui_components::switch::SwitchStateHandle;
|
||||
use galaxyui::{
|
||||
id, Action, AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
use warpui::elements::{
|
||||
Container, Element, Flex, MouseStateHandle, ParentElement, Shrinkable, Text,
|
||||
};
|
||||
use warpui::elements::{Element, MouseStateHandle};
|
||||
|
||||
use super::settings_page::{
|
||||
render_body_item, AdditionalInfo, MatchData, PageType, SettingsPageMeta,
|
||||
@@ -22,13 +18,11 @@ use super::{
|
||||
SettingsAction, SettingsSection, ToggleSettingActionPair, ToggleState,
|
||||
};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::drive::settings::WarpDriveSettings;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum WarpDriveSettingsPageAction {
|
||||
ToggleShowWarpDrive,
|
||||
SignUp,
|
||||
OpenUrl(String),
|
||||
}
|
||||
|
||||
@@ -44,8 +38,8 @@ pub fn init_actions_from_parent_view<T: Action + Clone>(
|
||||
WarpDriveSettingsPageAction::ToggleShowWarpDrive,
|
||||
)),
|
||||
SettingActionPairContexts::new(
|
||||
context.clone() & !id!(flags::ENABLE_WARP_DRIVE) & !id!("IsAnonymousUser"),
|
||||
context.clone() & id!(flags::ENABLE_WARP_DRIVE) & !id!("IsAnonymousUser"),
|
||||
context.clone() & !id!(flags::ENABLE_WARP_DRIVE),
|
||||
context.clone() & id!(flags::ENABLE_WARP_DRIVE),
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -54,10 +48,6 @@ pub fn init_actions_from_parent_view<T: Action + Clone>(
|
||||
);
|
||||
}
|
||||
|
||||
pub enum WarpDriveSettingsPageEvent {
|
||||
SignUp,
|
||||
}
|
||||
|
||||
pub struct WarpDriveSettingsPageView {
|
||||
page: PageType<Self>,
|
||||
}
|
||||
@@ -66,10 +56,7 @@ impl WarpDriveSettingsPageView {
|
||||
pub fn new(_ctx: &mut ViewContext<Self>) -> Self {
|
||||
Self {
|
||||
page: PageType::new_uncategorized(
|
||||
vec![
|
||||
Box::new(WarpDriveHeaderWidget::default()),
|
||||
Box::new(WarpDriveToggleWidget::default()),
|
||||
],
|
||||
vec![Box::new(WarpDriveToggleWidget::default())],
|
||||
None,
|
||||
),
|
||||
}
|
||||
@@ -77,7 +64,7 @@ impl WarpDriveSettingsPageView {
|
||||
}
|
||||
|
||||
impl Entity for WarpDriveSettingsPageView {
|
||||
type Event = WarpDriveSettingsPageEvent;
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl TypedActionView for WarpDriveSettingsPageView {
|
||||
@@ -91,9 +78,6 @@ impl TypedActionView for WarpDriveSettingsPageView {
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
WarpDriveSettingsPageAction::SignUp => {
|
||||
ctx.emit(WarpDriveSettingsPageEvent::SignUp);
|
||||
}
|
||||
WarpDriveSettingsPageAction::OpenUrl(url) => {
|
||||
ctx.open_url(url.as_str());
|
||||
}
|
||||
@@ -139,88 +123,6 @@ impl From<ViewHandle<WarpDriveSettingsPageView>> for SettingsPageViewHandle {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct WarpDriveHeaderWidget {
|
||||
sign_up_button: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl SettingsWidget for WarpDriveHeaderWidget {
|
||||
type View = WarpDriveSettingsPageView;
|
||||
|
||||
fn search_terms(&self) -> &str {
|
||||
"warp drive sign up"
|
||||
}
|
||||
|
||||
fn should_render(&self, app: &AppContext) -> bool {
|
||||
FeatureFlag::SkipFirebaseAnonymousUser.is_enabled()
|
||||
&& AuthStateProvider::as_ref(app)
|
||||
.get()
|
||||
.is_anonymous_or_logged_out()
|
||||
}
|
||||
|
||||
fn render(
|
||||
&self,
|
||||
_view: &Self::View,
|
||||
appearance: &Appearance,
|
||||
_app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let ui_builder = appearance.ui_builder();
|
||||
|
||||
let message = Container::new(
|
||||
Text::new_inline(
|
||||
"To use Galaxy Drive, please create an account.".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
14.,
|
||||
)
|
||||
.with_color(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().surface_2())
|
||||
.into_solid(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(16.)
|
||||
.finish();
|
||||
|
||||
let button = Container::new(
|
||||
ui_builder
|
||||
.button(ButtonVariant::Accent, self.sign_up_button.clone())
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(14.),
|
||||
font_weight: Some(Weight::Semibold),
|
||||
border_radius: Some(galaxyui::elements::CornerRadius::with_all(
|
||||
galaxyui::elements::Radius::Pixels(4.),
|
||||
)),
|
||||
padding: Some(Coords {
|
||||
top: 8.,
|
||||
bottom: 8.,
|
||||
left: 24.,
|
||||
right: 24.,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.with_text_label("Sign up".to_owned())
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(WarpDriveSettingsPageAction::SignUp);
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(galaxyui::elements::CrossAxisAlignment::Center)
|
||||
.with_child(Shrinkable::new(1., message).finish())
|
||||
.with_child(button)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_bottom(15.)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct WarpDriveToggleWidget {
|
||||
switch_state: SwitchStateHandle,
|
||||
@@ -241,10 +143,6 @@ impl SettingsWidget for WarpDriveToggleWidget {
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let settings = WarpDriveSettings::as_ref(app);
|
||||
let is_anonymous_or_logged_out = FeatureFlag::SkipFirebaseAnonymousUser.is_enabled()
|
||||
&& AuthStateProvider::as_ref(app)
|
||||
.get()
|
||||
.is_anonymous_or_logged_out();
|
||||
|
||||
render_body_item::<WarpDriveSettingsPageAction>(
|
||||
"Galaxy Drive".into(),
|
||||
@@ -257,24 +155,15 @@ impl SettingsWidget for WarpDriveToggleWidget {
|
||||
tooltip_override_text: None,
|
||||
}),
|
||||
LocalOnlyIconState::Hidden,
|
||||
if is_anonymous_or_logged_out {
|
||||
ToggleState::Disabled
|
||||
} else {
|
||||
ToggleState::Enabled
|
||||
},
|
||||
ToggleState::Enabled,
|
||||
appearance,
|
||||
appearance
|
||||
.ui_builder()
|
||||
.switch(self.switch_state.clone())
|
||||
.check(*settings.enable_warp_drive && !is_anonymous_or_logged_out)
|
||||
.with_disabled(is_anonymous_or_logged_out)
|
||||
.check(*settings.enable_warp_drive)
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
if !is_anonymous_or_logged_out {
|
||||
ctx.dispatch_typed_action(
|
||||
WarpDriveSettingsPageAction::ToggleShowWarpDrive,
|
||||
);
|
||||
}
|
||||
ctx.dispatch_typed_action(WarpDriveSettingsPageAction::ToggleShowWarpDrive);
|
||||
})
|
||||
.finish(),
|
||||
Some("Galaxy Drive is a workspace in your terminal where you can save Workflows, Notebooks, Prompts, and Environment Variables for personal use or to share with a team.".into()),
|
||||
|
||||
@@ -14,6 +14,7 @@ futures.workspace = true
|
||||
galaxy_agent_core.workspace = true
|
||||
rig-core.workspace = true
|
||||
rig-bedrock.workspace = true
|
||||
rig-vertexai.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use galaxy_agent_core::{
|
||||
StopReason, ToolDefinition, TurnCommand, TurnRequest, Usage,
|
||||
};
|
||||
use rig_bedrock::streaming::{BedrockStreamingResponse, BedrockUsage};
|
||||
use rig_core::completion::{AssistantContent, CompletionError, GetTokenUsage, Message};
|
||||
use rig_core::completion::{AssistantContent, CompletionError, Message};
|
||||
use rig_core::message::{DocumentSourceKind, ToolResultContent, UserContent};
|
||||
|
||||
use super::*;
|
||||
@@ -78,9 +78,10 @@ fn normalizes_bedrock_usage_and_max_token_stop() {
|
||||
cache_read_input_tokens: Some(40),
|
||||
cache_write_input_tokens: Some(10),
|
||||
}),
|
||||
stop_reason: None,
|
||||
};
|
||||
assert_eq!(
|
||||
map_usage(response.token_usage()),
|
||||
map_usage((&response).into()),
|
||||
Usage {
|
||||
input_tokens: 100,
|
||||
output_tokens: 25,
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
mod bedrock;
|
||||
mod chatgpt;
|
||||
mod native;
|
||||
mod openai_compatible;
|
||||
mod request;
|
||||
mod stream;
|
||||
|
||||
pub use bedrock::*;
|
||||
pub use chatgpt::*;
|
||||
pub use native::*;
|
||||
pub use openai_compatible::*;
|
||||
|
||||
@@ -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>
|
||||
where
|
||||
M: CompletionModel + Send + Sync + 'static,
|
||||
M::StreamingResponse: Send + Sync + 'static,
|
||||
{
|
||||
let max_output_tokens = request.max_output_tokens.or(configured_max_output_tokens);
|
||||
let completion_request = build_completion_request(
|
||||
|
||||
@@ -3,7 +3,7 @@ use galaxy_agent_core::{
|
||||
AgentError, AgentErrorKind, AgentEvent, AgentEventStream, StopReason, ToolCall, TurnCommand,
|
||||
TurnControl, Usage,
|
||||
};
|
||||
use rig_core::completion::{CompletionError, CompletionModel, CompletionRequest, GetTokenUsage};
|
||||
use rig_core::completion::{CompletionError, CompletionModel, CompletionRequest};
|
||||
use rig_core::streaming::StreamedAssistantContent;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -15,7 +15,6 @@ pub(crate) async fn start_model_turn<M>(
|
||||
) -> Result<AgentEventStream, AgentError>
|
||||
where
|
||||
M: CompletionModel + Send + Sync + 'static,
|
||||
M::StreamingResponse: Send + Sync + 'static,
|
||||
{
|
||||
let runtime_request_id = Uuid::new_v4().to_string();
|
||||
let stream_future = model.stream(completion_request).fuse();
|
||||
@@ -112,7 +111,7 @@ where
|
||||
// is the canonical event Galaxy consumes.
|
||||
}
|
||||
Ok(StreamedAssistantContent::Final(response)) => {
|
||||
let mapped_usage = map_usage(response.token_usage());
|
||||
let mapped_usage = map_usage(response.usage);
|
||||
last_output_tokens = mapped_usage.output_tokens;
|
||||
yield Ok(AgentEvent::UsageUpdated {
|
||||
usage: mapped_usage,
|
||||
@@ -142,6 +141,82 @@ where
|
||||
Ok(Box::pin(events))
|
||||
}
|
||||
|
||||
pub(crate) async fn start_model_completion<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 {
|
||||
ToolCall {
|
||||
// OpenAI Responses uses a separate `call_id` for function-call output
|
||||
|
||||
@@ -123,8 +123,9 @@ The current UI can initially be kept alive with a temporary adapter from `AgentE
|
||||
### `galaxy_agent_rig`
|
||||
|
||||
Rig becomes the implementation for provider-backed conversations. The version evaluated for this
|
||||
plan is Rig 0.40.0. When introduced, it must be pinned exactly until its documented breaking-change
|
||||
cadence settles for Galaxy.
|
||||
plan is Rig 0.41.0 from the pinned upstream revision
|
||||
`1f9547774edb4c269be991ac42eb043fd7b6e87f`. When introduced, it must be pinned exactly until its
|
||||
documented breaking-change cadence settles for Galaxy.
|
||||
|
||||
Rig is a good fit for the provider side because it already defines a canonical completion request,
|
||||
provider implementations, streaming content/tool events, model history, typed tools, hooks, MCP via
|
||||
@@ -137,6 +138,9 @@ Provider coverage for the first migration:
|
||||
|---|---|---|
|
||||
| LiteLLM / generic OpenAI-compatible | Rig OpenAI-compatible client | Custom base URL and key; preserve per-model endpoint routing. |
|
||||
| Ollama / LM Studio | OpenAI-compatible or Rig provider adapter | Treat as explicit local/LAN endpoints. |
|
||||
| Anthropic | Rig native Anthropic client | Discover models through Rig's native model-listing API and stream completions. |
|
||||
| Google Gemini | Rig native Gemini client | Discover models through Rig's native model-listing API and stream completions. |
|
||||
| Google Vertex AI | `rig-vertexai` companion crate | Use ADC, project/location configuration, and a bounded catalog because this Rig integration has no model-listing endpoint. |
|
||||
| AWS Bedrock | `rig-bedrock` through the Rig facade | Preserve profile, static credential, SSO, region, and inference-profile behavior through a focused compatibility audit. |
|
||||
| MCP tools | Rig `rmcp` tool server/client support | Reuse existing Galaxy MCP lifecycle where it is stronger; bridge tools at one boundary. |
|
||||
|
||||
@@ -258,7 +262,8 @@ Bedrock/OpenAI/ACP configuration.
|
||||
|
||||
### Phase 2 — First Rig vertical slice: OpenAI-compatible streaming
|
||||
|
||||
- [x] Pin `rig-core` 0.40.0 and implement one explicit OpenAI-compatible provider.
|
||||
- [x] Pin Rig 0.41.0 to one upstream revision and implement the explicit OpenAI-compatible,
|
||||
ChatGPT subscription, Anthropic, Gemini, and Vertex AI provider runtimes.
|
||||
- [x] Support text, reasoning where available, cancellation, stop reason, usage, and persisted
|
||||
history.
|
||||
- [x] Route any model entry with `use_rig = true` through Rig while leaving unmarked models on the
|
||||
@@ -334,9 +339,9 @@ Galaxy's action model owns permission and execution lifecycle events; normalized
|
||||
Rig directly. The legacy Warp response envelope remains only around transcript/init/finished UI
|
||||
rendering and non-Rig compatibility runtimes, not in Rig's executable tool path.
|
||||
|
||||
### Phase 4 — Bedrock through Rig
|
||||
### Phase 4 — Bedrock and native cloud providers through Rig
|
||||
|
||||
- [x] Pin `rig-bedrock` 0.40.0 and construct it from Galaxy's already-resolved AWS SDK client so
|
||||
- [x] Pin `rig-bedrock` 0.41.0 and construct it from Galaxy's already-resolved AWS SDK client so
|
||||
profile, SSO, static-key, region, and egress ownership stay at Galaxy's explicit boundary.
|
||||
- [x] Resolve context markers, ARNs, existing inference profiles, and regional inference-profile
|
||||
prefixes before passing a model ID to Rig.
|
||||
@@ -348,12 +353,17 @@ rendering and non-Rig compatibility runtimes, not in Rig's executable tool path.
|
||||
normalization without contacting AWS.
|
||||
- [x] Preserve signed Bedrock reasoning blocks in Galaxy conversation history so adaptive-thinking
|
||||
tool-call turns can be replayed without losing their signatures.
|
||||
- [x] Define the Rig 0.40 parity policy: Galaxy retains structured tool-result error state locally
|
||||
- [x] Define the Rig 0.41 parity policy: Galaxy retains structured tool-result error state locally
|
||||
and sends an explicit `[ERROR]` result prefix because Rig core has no Bedrock status field;
|
||||
Rig owns system/message cache checkpoints, tool-schema caching is treated as an optimization,
|
||||
and one-hour cache-TTL requests stay on the compatibility runtime.
|
||||
- [x] Add a model-by-model Rig switch to the unified Models page and route opted-in Bedrock models
|
||||
through the same request, event, permission, history, and UI adapter as OpenAI-compatible models.
|
||||
- [x] Add native Anthropic and Gemini providers with Rig-backed model discovery and streaming.
|
||||
- [x] Add Vertex AI configuration with project/location and ADC validation, a bounded Rig-supported
|
||||
Gemini catalog, and a non-streaming completion adapter for the current `rig-vertexai` integration.
|
||||
- [x] Make the provider setup wizard's provider selector data-driven and independently scrollable so
|
||||
adding the remaining Rig integrations does not expand the modal beyond the window.
|
||||
- [ ] Run opt-in live semantic comparisons for system prompts, images, tools, reasoning, usage, and
|
||||
context limits before selecting the Rig runtime for any configured Bedrock model.
|
||||
- Keep a short-lived compatibility fallback for unsupported Bedrock behavior, measured by tests.
|
||||
@@ -390,6 +400,14 @@ metadata.
|
||||
persistence to the local service while keeping process lifecycle and credentials separate.
|
||||
- [x] Make the OSS channel expose only the local Personal scope and resolve it to the stable local
|
||||
owner; remote-capable channels retain their existing workspace/shared-space behavior.
|
||||
- [x] Move personal Galaxy Drive folder creation, rename, trash/untrash, deletion, and open-state
|
||||
persistence behind `LocalObjectRepository`, including recursive local-folder deletion and nested
|
||||
collapse behavior; shared/team folders retain the existing remote path.
|
||||
- [x] Remove the account/signup gate from Galaxy Drive visibility and Settings controls so local
|
||||
Drive remains usable while logged out; team-only actions retain their separate restrictions.
|
||||
- [x] Move the retained Drive import flow onto local persistence for personal targets, including
|
||||
local folder/notebook/workflow creation and progress reporting without remote `UpdateManager`
|
||||
or `SyncQueue` dependencies; shared/team imports retain their remote path.
|
||||
- [ ] Replace remaining account/workspace ownership in kept content flows with local scopes.
|
||||
- [ ] Remove auth, teams, billing, referral, cloud sync, GraphQL, RTC, sharing, and remote-control UI.
|
||||
|
||||
@@ -406,6 +424,10 @@ assigned to the phase that owns the affected flow before the related work is con
|
||||
output refresh and scrolling, failure/timeout handling, and restore behavior. Add deterministic
|
||||
unit coverage and a hermetic integration flow for a command that remains active while the agent
|
||||
continues running.
|
||||
- [x] Stop takeover no longer starts a completion-assessment turn after the user cancels the
|
||||
monitor.
|
||||
- [x] Monitor teardown now clears orphaned in-memory state when completion metadata is missing.
|
||||
- [x] Refresh requests ignore completed or no-longer-long-running blocks.
|
||||
- [ ] Open-source project presentation: structure the About page around Galaxy’s local-first
|
||||
identity, audit the repository’s license and third-party notices, and make the root metadata,
|
||||
contribution guidance, and license files agree on the final permissive license (currently the
|
||||
@@ -460,12 +482,10 @@ contract is what the UI and persistence observe.
|
||||
| Telemetry/remote logs/remote flags | Remove, not merely default-off. |
|
||||
| SSH and remote Git | Keep as explicit user-initiated remote development boundaries. |
|
||||
| Web/network agent tools | Disabled by default and permission-visible. |
|
||||
| Rig dependency | Exact version pin with upgrade contract tests. |
|
||||
| Rig dependency | Exact upstream revision pin with upgrade contract tests. |
|
||||
|
||||
## Immediate next vertical slice
|
||||
|
||||
Continue Phase 6 by replacing remaining account/workspace ownership with local scopes and removing
|
||||
cloud identity UI from kept flows. The next bounded implementation is to move the remaining Galaxy
|
||||
Drive folder mutations (create, rename, trash, and open-state persistence) behind
|
||||
`LocalObjectRepository` for OSS. Live Phase 4 Bedrock semantic comparisons remain an explicit
|
||||
cloud identity UI from kept flows. Live Phase 4 Bedrock semantic comparisons remain an explicit
|
||||
opt-in validation task because they require configured AWS access.
|
||||
|
||||
Reference in New Issue
Block a user