mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Simplify create agent setup flow (#78)
This commit is contained in:
@@ -15,7 +15,10 @@ use crate::{
|
||||
RelayAgentInfo, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_ARG, DEFAULT_AGENT_COMMAND,
|
||||
DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, DEFAULT_MCP_COMMAND,
|
||||
},
|
||||
relay::{build_authed_request, managed_agent_owner_pubkey, relay_ws_url, send_json_request},
|
||||
relay::{
|
||||
build_authed_request, managed_agent_owner_pubkey, relay_ws_url, send_json_request,
|
||||
sync_managed_agent_profile_display_name,
|
||||
},
|
||||
util::now_iso,
|
||||
};
|
||||
|
||||
@@ -98,145 +101,176 @@ pub async fn create_managed_agent(
|
||||
None
|
||||
};
|
||||
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut records = load_managed_agents(&app)?;
|
||||
let mut runtimes = state
|
||||
.managed_agent_processes
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let (agent, private_key_nsec, api_token, pubkey, resolved_relay_url, spawn_error, token_scopes) =
|
||||
{
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut records = load_managed_agents(&app)?;
|
||||
let mut runtimes = state
|
||||
.managed_agent_processes
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
if sync_managed_agent_processes(&mut records, &mut runtimes) {
|
||||
save_managed_agents(&app, &records)?;
|
||||
}
|
||||
if sync_managed_agent_processes(&mut records, &mut runtimes) {
|
||||
save_managed_agents(&app, &records)?;
|
||||
}
|
||||
|
||||
let keys = Keys::generate();
|
||||
let pubkey = keys.public_key().to_hex();
|
||||
if records.iter().any(|record| record.pubkey == pubkey) {
|
||||
return Err(format!("agent {pubkey} already exists"));
|
||||
}
|
||||
let keys = Keys::generate();
|
||||
let pubkey = keys.public_key().to_hex();
|
||||
if records.iter().any(|record| record.pubkey == pubkey) {
|
||||
return Err(format!("agent {pubkey} already exists"));
|
||||
}
|
||||
|
||||
let private_key_nsec = keys
|
||||
.secret_key()
|
||||
.to_bech32()
|
||||
.map_err(|error| format!("failed to encode private key: {error}"))?;
|
||||
let token_scopes = if input.mint_token {
|
||||
let requested = input
|
||||
.token_scopes
|
||||
.into_iter()
|
||||
.map(|scope| scope.trim().to_string())
|
||||
.filter(|scope| !scope.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if requested.is_empty() {
|
||||
default_token_scopes()
|
||||
} else {
|
||||
requested
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let minted = if input.mint_token {
|
||||
let token_name = input
|
||||
.token_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(name);
|
||||
Some(run_sprout_admin_mint_token(
|
||||
&app,
|
||||
&pubkey,
|
||||
owner_pubkey
|
||||
let private_key_nsec = keys
|
||||
.secret_key()
|
||||
.to_bech32()
|
||||
.map_err(|error| format!("failed to encode private key: {error}"))?;
|
||||
let token_scopes = if input.mint_token {
|
||||
let requested = input
|
||||
.token_scopes
|
||||
.into_iter()
|
||||
.map(|scope| scope.trim().to_string())
|
||||
.filter(|scope| !scope.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if requested.is_empty() {
|
||||
default_token_scopes()
|
||||
} else {
|
||||
requested
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let api_token = if input.mint_token {
|
||||
let token_name = input
|
||||
.token_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(name);
|
||||
Some(
|
||||
run_sprout_admin_mint_token(
|
||||
&app,
|
||||
&pubkey,
|
||||
owner_pubkey.as_deref().ok_or_else(|| {
|
||||
"managed agent owner pubkey was not resolved".to_string()
|
||||
})?,
|
||||
token_name,
|
||||
&token_scopes,
|
||||
)?
|
||||
.api_token,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let resolved_relay_url = input
|
||||
.relay_url
|
||||
.as_deref()
|
||||
.ok_or_else(|| "managed agent owner pubkey was not resolved".to_string())?,
|
||||
token_name,
|
||||
&token_scopes,
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(relay_ws_url);
|
||||
|
||||
let mut record = crate::managed_agents::ManagedAgentRecord {
|
||||
pubkey: pubkey.clone(),
|
||||
name: name.to_string(),
|
||||
private_key_nsec: private_key_nsec.clone(),
|
||||
api_token: api_token.clone(),
|
||||
relay_url: resolved_relay_url.clone(),
|
||||
acp_command: input
|
||||
.acp_command
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_ACP_COMMAND)
|
||||
.to_string(),
|
||||
agent_command: input
|
||||
.agent_command
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_AGENT_COMMAND)
|
||||
.to_string(),
|
||||
agent_args: input
|
||||
.agent_args
|
||||
.into_iter()
|
||||
.map(|arg| arg.trim().to_string())
|
||||
.filter(|arg| !arg.is_empty())
|
||||
.collect::<Vec<_>>(),
|
||||
mcp_command: input
|
||||
.mcp_command
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_MCP_COMMAND)
|
||||
.to_string(),
|
||||
turn_timeout_seconds: input
|
||||
.turn_timeout_seconds
|
||||
.filter(|seconds| *seconds > 0)
|
||||
.unwrap_or(DEFAULT_AGENT_TURN_TIMEOUT_SECONDS),
|
||||
created_at: now_iso(),
|
||||
updated_at: now_iso(),
|
||||
last_started_at: None,
|
||||
last_stopped_at: None,
|
||||
last_exit_code: None,
|
||||
last_error: None,
|
||||
};
|
||||
|
||||
if record.agent_args.is_empty() {
|
||||
record.agent_args.push(DEFAULT_AGENT_ARG.to_string());
|
||||
}
|
||||
|
||||
records.push(record);
|
||||
|
||||
let mut spawn_error = None;
|
||||
if input.spawn_after_create {
|
||||
let record = find_managed_agent_mut(&mut records, &pubkey)?;
|
||||
if let Err(error) = start_managed_agent_process(&app, record, &mut runtimes) {
|
||||
record.updated_at = now_iso();
|
||||
record.last_error = Some(error.clone());
|
||||
spawn_error = Some(error);
|
||||
}
|
||||
}
|
||||
|
||||
save_managed_agents(&app, &records)?;
|
||||
|
||||
let record = records
|
||||
.iter()
|
||||
.find(|record| record.pubkey == pubkey)
|
||||
.ok_or_else(|| "created agent disappeared unexpectedly".to_string())?;
|
||||
let agent = build_managed_agent_summary(&app, record, &runtimes)?;
|
||||
|
||||
Ok::<_, String>((
|
||||
agent,
|
||||
private_key_nsec,
|
||||
api_token,
|
||||
pubkey,
|
||||
resolved_relay_url,
|
||||
spawn_error,
|
||||
token_scopes,
|
||||
))
|
||||
}?;
|
||||
|
||||
let profile_sync_error = match sync_managed_agent_profile_display_name(
|
||||
&state,
|
||||
&resolved_relay_url,
|
||||
&pubkey,
|
||||
api_token.as_deref(),
|
||||
&token_scopes,
|
||||
name,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => None,
|
||||
Err(error) => Some(error),
|
||||
};
|
||||
let resolved_relay_url = input
|
||||
.relay_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(relay_ws_url);
|
||||
|
||||
let mut record = crate::managed_agents::ManagedAgentRecord {
|
||||
pubkey: pubkey.clone(),
|
||||
name: name.to_string(),
|
||||
private_key_nsec: private_key_nsec.clone(),
|
||||
api_token: minted.as_ref().map(|output| output.api_token.clone()),
|
||||
relay_url: resolved_relay_url,
|
||||
acp_command: input
|
||||
.acp_command
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_ACP_COMMAND)
|
||||
.to_string(),
|
||||
agent_command: input
|
||||
.agent_command
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_AGENT_COMMAND)
|
||||
.to_string(),
|
||||
agent_args: input
|
||||
.agent_args
|
||||
.into_iter()
|
||||
.map(|arg| arg.trim().to_string())
|
||||
.filter(|arg| !arg.is_empty())
|
||||
.collect::<Vec<_>>(),
|
||||
mcp_command: input
|
||||
.mcp_command
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_MCP_COMMAND)
|
||||
.to_string(),
|
||||
turn_timeout_seconds: input
|
||||
.turn_timeout_seconds
|
||||
.filter(|seconds| *seconds > 0)
|
||||
.unwrap_or(DEFAULT_AGENT_TURN_TIMEOUT_SECONDS),
|
||||
created_at: now_iso(),
|
||||
updated_at: now_iso(),
|
||||
last_started_at: None,
|
||||
last_stopped_at: None,
|
||||
last_exit_code: None,
|
||||
last_error: None,
|
||||
};
|
||||
|
||||
if record.agent_args.is_empty() {
|
||||
record.agent_args.push(DEFAULT_AGENT_ARG.to_string());
|
||||
}
|
||||
|
||||
records.push(record);
|
||||
|
||||
let mut spawn_error = None;
|
||||
if input.spawn_after_create {
|
||||
let record = find_managed_agent_mut(&mut records, &pubkey)?;
|
||||
if let Err(error) = start_managed_agent_process(&app, record, &mut runtimes) {
|
||||
record.updated_at = now_iso();
|
||||
record.last_error = Some(error.clone());
|
||||
spawn_error = Some(error);
|
||||
}
|
||||
}
|
||||
|
||||
save_managed_agents(&app, &records)?;
|
||||
|
||||
let record = records
|
||||
.iter()
|
||||
.find(|record| record.pubkey == pubkey)
|
||||
.ok_or_else(|| "created agent disappeared unexpectedly".to_string())?;
|
||||
let agent = build_managed_agent_summary(&app, record, &runtimes)?;
|
||||
|
||||
Ok(CreateManagedAgentResponse {
|
||||
agent,
|
||||
private_key_nsec,
|
||||
api_token: minted.map(|output| output.api_token),
|
||||
api_token,
|
||||
profile_sync_error,
|
||||
spawn_error,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ pub struct CreateManagedAgentResponse {
|
||||
pub agent: ManagedAgentSummary,
|
||||
pub private_key_nsec: String,
|
||||
pub api_token: Option<String>,
|
||||
pub profile_sync_error: Option<String>,
|
||||
pub spawn_error: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -4,20 +4,35 @@ use reqwest::Method;
|
||||
use serde::de::DeserializeOwned;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{app_state::AppState, models::ProfileInfo};
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
models::{ProfileInfo, UpdateProfileBody},
|
||||
};
|
||||
|
||||
pub fn relay_ws_url() -> String {
|
||||
std::env::var("SPROUT_RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
|
||||
}
|
||||
|
||||
pub fn relay_api_base_url() -> String {
|
||||
if let Ok(base) = std::env::var("SPROUT_RELAY_HTTP") {
|
||||
return base;
|
||||
pub fn relay_http_base_url(relay_url: &str) -> String {
|
||||
let trimmed = relay_url.trim().trim_end_matches('/');
|
||||
|
||||
if let Some(suffix) = trimmed.strip_prefix("wss://") {
|
||||
return format!("https://{suffix}");
|
||||
}
|
||||
|
||||
relay_ws_url()
|
||||
.replace("wss://", "https://")
|
||||
.replace("ws://", "http://")
|
||||
if let Some(suffix) = trimmed.strip_prefix("ws://") {
|
||||
return format!("http://{suffix}");
|
||||
}
|
||||
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
pub fn relay_api_base_url() -> String {
|
||||
if let Ok(base) = std::env::var("SPROUT_RELAY_HTTP") {
|
||||
return base.trim().trim_end_matches('/').to_string();
|
||||
}
|
||||
|
||||
relay_http_base_url(&relay_ws_url())
|
||||
}
|
||||
|
||||
pub fn build_authed_request(
|
||||
@@ -62,6 +77,54 @@ pub async fn managed_agent_owner_pubkey(state: &AppState) -> Result<String, Stri
|
||||
Ok(profile.pubkey)
|
||||
}
|
||||
|
||||
fn token_supports_scope(scopes: &[String], required_scope: &str) -> bool {
|
||||
scopes.iter().any(|scope| scope == required_scope)
|
||||
}
|
||||
|
||||
pub async fn sync_managed_agent_profile_display_name(
|
||||
state: &AppState,
|
||||
relay_url: &str,
|
||||
pubkey: &str,
|
||||
api_token: Option<&str>,
|
||||
token_scopes: &[String],
|
||||
display_name: &str,
|
||||
) -> Result<(), String> {
|
||||
let url = format!(
|
||||
"{}{}",
|
||||
relay_http_base_url(relay_url),
|
||||
"/api/users/me/profile"
|
||||
);
|
||||
let use_bearer_token = api_token.is_some() && token_supports_scope(token_scopes, "users:write");
|
||||
let mut request = state.http_client.request(Method::PUT, url);
|
||||
|
||||
if let Some(token) = api_token.filter(|_| use_bearer_token) {
|
||||
request = request.header("Authorization", format!("Bearer {token}"));
|
||||
} else {
|
||||
request = request.header("X-Pubkey", pubkey);
|
||||
}
|
||||
|
||||
let request = request.json(&UpdateProfileBody {
|
||||
display_name: Some(display_name),
|
||||
avatar_url: None,
|
||||
about: None,
|
||||
nip05_handle: None,
|
||||
});
|
||||
|
||||
send_empty_request(request).await.map_err(|error| {
|
||||
if api_token.is_some() && !use_bearer_token {
|
||||
format!(
|
||||
"Created the agent, but could not sync its profile display name. The minted token does not include `users:write`, and the relay rejected dev-mode pubkey auth: {error}"
|
||||
)
|
||||
} else if api_token.is_some() {
|
||||
format!("Created the agent, but could not sync its profile display name: {error}")
|
||||
} else {
|
||||
format!(
|
||||
"Created the agent, but could not sync its profile display name without a token: {error}"
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn session_api_token(state: &AppState) -> Result<Option<String>, String> {
|
||||
let token = state
|
||||
.session_token
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
@@ -5,6 +6,7 @@ import {
|
||||
useCreateManagedAgentMutation,
|
||||
useManagedAgentPrereqsQuery,
|
||||
} from "@/features/agents/hooks";
|
||||
import { DEFAULT_MANAGED_AGENT_SCOPES } from "@/features/tokens/lib/scopeOptions";
|
||||
import type {
|
||||
CreateManagedAgentInput,
|
||||
CreateManagedAgentResponse,
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
CreateAgentBasicsFields,
|
||||
CreateAgentOptionToggles,
|
||||
CreateAgentPrerequisitesCard,
|
||||
CreateAgentRuntimeProviderField,
|
||||
CreateAgentRuntimeFields,
|
||||
CreateAgentTokenSection,
|
||||
} from "./CreateAgentDialogSections";
|
||||
@@ -48,20 +51,14 @@ export function CreateAgentDialog({
|
||||
const [spawnAfterCreate, setSpawnAfterCreate] = React.useState(true);
|
||||
const [tokenName, setTokenName] = React.useState("");
|
||||
const [selectedScopes, setSelectedScopes] = React.useState<Set<TokenScope>>(
|
||||
() =>
|
||||
new Set<TokenScope>([
|
||||
"messages:read",
|
||||
"messages:write",
|
||||
"channels:read",
|
||||
"users:read",
|
||||
"users:write",
|
||||
]),
|
||||
() => new Set<TokenScope>(DEFAULT_MANAGED_AGENT_SCOPES),
|
||||
);
|
||||
const [turnTimeoutSeconds, setTurnTimeoutSeconds] = React.useState("300");
|
||||
const [selectedProviderId, setSelectedProviderId] =
|
||||
React.useState<string>("custom");
|
||||
const [hasSyncedProviderSelection, setHasSyncedProviderSelection] =
|
||||
React.useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = React.useState(false);
|
||||
const providers = providersQuery.data ?? [];
|
||||
const prereqs = prereqsQuery.data ?? null;
|
||||
const selectedProvider = React.useMemo(
|
||||
@@ -137,21 +134,22 @@ export function CreateAgentDialog({
|
||||
setSpawnAfterCreate(false);
|
||||
}, [prereqs, spawnAfterCreate]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
providersQuery.error instanceof Error ||
|
||||
prereqsQuery.error instanceof Error
|
||||
) {
|
||||
setShowAdvanced(true);
|
||||
}
|
||||
}, [prereqsQuery.error, providersQuery.error]);
|
||||
|
||||
function reset() {
|
||||
setName("");
|
||||
setRelayUrl("");
|
||||
setMintToken(true);
|
||||
setSpawnAfterCreate(true);
|
||||
setTokenName("");
|
||||
setSelectedScopes(
|
||||
new Set<TokenScope>([
|
||||
"messages:read",
|
||||
"messages:write",
|
||||
"channels:read",
|
||||
"users:read",
|
||||
"users:write",
|
||||
]),
|
||||
);
|
||||
setSelectedScopes(new Set<TokenScope>(DEFAULT_MANAGED_AGENT_SCOPES));
|
||||
setAcpCommand("sprout-acp");
|
||||
setAgentCommand("goose");
|
||||
setAgentArgs("acp");
|
||||
@@ -159,6 +157,7 @@ export function CreateAgentDialog({
|
||||
setTurnTimeoutSeconds("300");
|
||||
setSelectedProviderId("custom");
|
||||
setHasSyncedProviderSelection(false);
|
||||
setShowAdvanced(false);
|
||||
createMutation.reset();
|
||||
}
|
||||
|
||||
@@ -186,6 +185,7 @@ export function CreateAgentDialog({
|
||||
setSelectedProviderId(nextProviderId);
|
||||
|
||||
if (nextProviderId === "custom") {
|
||||
setShowAdvanced(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -244,55 +244,23 @@ export function CreateAgentDialog({
|
||||
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
|
||||
<DialogTitle>Create agent</DialogTitle>
|
||||
<DialogDescription>
|
||||
This creates a local agent identity, optionally mints a relay
|
||||
token, and can spawn `sprout-acp` immediately.
|
||||
This creates a local agent identity, syncs its display name when
|
||||
possible, optionally mints a relay token, and can spawn
|
||||
`sprout-acp` immediately.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-5">
|
||||
<CreateAgentBasicsFields
|
||||
name={name}
|
||||
onNameChange={setName}
|
||||
onRelayUrlChange={setRelayUrl}
|
||||
relayUrl={relayUrl}
|
||||
/>
|
||||
<CreateAgentBasicsFields name={name} onNameChange={setName} />
|
||||
|
||||
<CreateAgentRuntimeFields
|
||||
acpCommand={acpCommand}
|
||||
agentArgs={agentArgs}
|
||||
agentCommand={agentCommand}
|
||||
mcpCommand={mcpCommand}
|
||||
onAcpCommandChange={setAcpCommand}
|
||||
onAgentArgsChange={setAgentArgs}
|
||||
onAgentCommandChange={setAgentCommand}
|
||||
onMcpCommandChange={setMcpCommand}
|
||||
<CreateAgentRuntimeProviderField
|
||||
onProviderChange={handleProviderChange}
|
||||
onTurnTimeoutChange={setTurnTimeoutSeconds}
|
||||
providers={providers}
|
||||
providersLoading={providersQuery.isLoading}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedProviderId={selectedProviderId}
|
||||
turnTimeoutSeconds={turnTimeoutSeconds}
|
||||
/>
|
||||
|
||||
<CreateAgentPrerequisitesCard
|
||||
isLoading={prereqsQuery.isLoading}
|
||||
prereqs={prereqs}
|
||||
prerequisiteCards={prerequisiteCards}
|
||||
/>
|
||||
|
||||
{providersQuery.error instanceof Error ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{providersQuery.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{prereqsQuery.error instanceof Error ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{prereqsQuery.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<CreateAgentOptionToggles
|
||||
isMintSupported={isMintSupported}
|
||||
isSpawnSupported={isSpawnSupported}
|
||||
@@ -322,6 +290,70 @@ export function CreateAgentDialog({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/20">
|
||||
<button
|
||||
aria-expanded={showAdvanced}
|
||||
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left"
|
||||
onClick={() => setShowAdvanced((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-semibold tracking-tight">
|
||||
Advanced setup
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Relay overrides, raw commands, timeout, and local binary
|
||||
checks.
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 self-center text-muted-foreground">
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${showAdvanced ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{showAdvanced ? (
|
||||
<div className="overflow-hidden">
|
||||
<div className="space-y-5 border-t border-border/60 px-4 py-4">
|
||||
<CreateAgentRuntimeFields
|
||||
acpCommand={acpCommand}
|
||||
agentArgs={agentArgs}
|
||||
agentCommand={agentCommand}
|
||||
mcpCommand={mcpCommand}
|
||||
onAcpCommandChange={setAcpCommand}
|
||||
onAgentArgsChange={setAgentArgs}
|
||||
onAgentCommandChange={setAgentCommand}
|
||||
onMcpCommandChange={setMcpCommand}
|
||||
onRelayUrlChange={setRelayUrl}
|
||||
onTurnTimeoutChange={setTurnTimeoutSeconds}
|
||||
relayUrl={relayUrl}
|
||||
selectedProviderId={selectedProviderId}
|
||||
turnTimeoutSeconds={turnTimeoutSeconds}
|
||||
/>
|
||||
|
||||
<CreateAgentPrerequisitesCard
|
||||
isLoading={prereqsQuery.isLoading}
|
||||
prereqs={prereqs}
|
||||
prerequisiteCards={prerequisiteCards}
|
||||
/>
|
||||
|
||||
{providersQuery.error instanceof Error ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{providersQuery.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{prereqsQuery.error instanceof Error ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{prereqsQuery.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{createMutation.error instanceof Error ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{createMutation.error.message}
|
||||
|
||||
@@ -4,9 +4,10 @@ import type {
|
||||
ManagedAgentPrereqs,
|
||||
TokenScope,
|
||||
} from "@/shared/api/types";
|
||||
import { MANAGED_AGENT_SCOPE_OPTIONS } from "@/features/tokens/lib/scopeOptions";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { AGENT_SCOPE_OPTIONS, describeResolvedCommand } from "./agentUi";
|
||||
import { describeResolvedCommand } from "./agentUi";
|
||||
|
||||
export type PrerequisiteCard = {
|
||||
id: string;
|
||||
@@ -17,41 +18,85 @@ export type PrerequisiteCard = {
|
||||
|
||||
export function CreateAgentBasicsFields({
|
||||
name,
|
||||
relayUrl,
|
||||
onNameChange,
|
||||
onRelayUrlChange,
|
||||
}: {
|
||||
name: string;
|
||||
relayUrl: string;
|
||||
onNameChange: (value: string) => void;
|
||||
onRelayUrlChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="agent-name">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
data-testid="agent-name-input"
|
||||
id="agent-name"
|
||||
onChange={(event) => onNameChange(event.target.value)}
|
||||
placeholder="alice"
|
||||
value={name}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="agent-name">
|
||||
Agent name
|
||||
</label>
|
||||
<Input
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
data-testid="agent-name-input"
|
||||
id="agent-name"
|
||||
onChange={(event) => onNameChange(event.target.value)}
|
||||
placeholder="Support bot"
|
||||
spellCheck={false}
|
||||
value={name}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used as the local label and synced to the agent profile display name
|
||||
when the relay accepts the create-time auth.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="agent-relay-url">
|
||||
Relay URL
|
||||
</label>
|
||||
<Input
|
||||
id="agent-relay-url"
|
||||
onChange={(event) => onRelayUrlChange(event.target.value)}
|
||||
placeholder="Leave blank to use the desktop relay"
|
||||
value={relayUrl}
|
||||
/>
|
||||
</div>
|
||||
export function CreateAgentRuntimeProviderField({
|
||||
providers,
|
||||
providersLoading,
|
||||
selectedProvider,
|
||||
selectedProviderId,
|
||||
onProviderChange,
|
||||
}: {
|
||||
providers: AcpProvider[];
|
||||
providersLoading: boolean;
|
||||
selectedProvider: AcpProvider | null;
|
||||
selectedProviderId: string;
|
||||
onProviderChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="agent-provider">
|
||||
Agent runtime
|
||||
</label>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm"
|
||||
id="agent-provider"
|
||||
onChange={(event) => onProviderChange(event.target.value)}
|
||||
value={selectedProviderId}
|
||||
>
|
||||
{providers.map((provider) => (
|
||||
<option key={provider.id} value={provider.id}>
|
||||
{provider.label}
|
||||
</option>
|
||||
))}
|
||||
<option value="custom">Custom command</option>
|
||||
</select>
|
||||
{selectedProvider ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Detected via{" "}
|
||||
<span className="font-medium">
|
||||
{describeResolvedCommand(
|
||||
selectedProvider.command,
|
||||
selectedProvider.binaryPath,
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
) : providersLoading ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Looking for installed ACP runtimes...
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No known ACP runtime was detected. You can still enter a custom
|
||||
command in Advanced setup.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -61,37 +106,45 @@ export function CreateAgentRuntimeFields({
|
||||
agentArgs,
|
||||
agentCommand,
|
||||
mcpCommand,
|
||||
providers,
|
||||
providersLoading,
|
||||
selectedProvider,
|
||||
relayUrl,
|
||||
selectedProviderId,
|
||||
turnTimeoutSeconds,
|
||||
onAcpCommandChange,
|
||||
onAgentArgsChange,
|
||||
onAgentCommandChange,
|
||||
onMcpCommandChange,
|
||||
onProviderChange,
|
||||
onRelayUrlChange,
|
||||
onTurnTimeoutChange,
|
||||
}: {
|
||||
acpCommand: string;
|
||||
agentArgs: string;
|
||||
agentCommand: string;
|
||||
mcpCommand: string;
|
||||
providers: AcpProvider[];
|
||||
providersLoading: boolean;
|
||||
selectedProvider: AcpProvider | null;
|
||||
relayUrl: string;
|
||||
selectedProviderId: string;
|
||||
turnTimeoutSeconds: string;
|
||||
onAcpCommandChange: (value: string) => void;
|
||||
onAgentArgsChange: (value: string) => void;
|
||||
onAgentCommandChange: (value: string) => void;
|
||||
onMcpCommandChange: (value: string) => void;
|
||||
onProviderChange: (value: string) => void;
|
||||
onRelayUrlChange: (value: string) => void;
|
||||
onTurnTimeoutChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="agent-relay-url">
|
||||
Relay URL
|
||||
</label>
|
||||
<Input
|
||||
id="agent-relay-url"
|
||||
onChange={(event) => onRelayUrlChange(event.target.value)}
|
||||
placeholder="Leave blank to use the desktop relay"
|
||||
value={relayUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="agent-acp-command">
|
||||
ACP command
|
||||
@@ -102,45 +155,6 @@ export function CreateAgentRuntimeFields({
|
||||
value={acpCommand}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="agent-provider">
|
||||
Agent runtime
|
||||
</label>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm"
|
||||
id="agent-provider"
|
||||
onChange={(event) => onProviderChange(event.target.value)}
|
||||
value={selectedProviderId}
|
||||
>
|
||||
{providers.map((provider) => (
|
||||
<option key={provider.id} value={provider.id}>
|
||||
{provider.label}
|
||||
</option>
|
||||
))}
|
||||
<option value="custom">Custom command</option>
|
||||
</select>
|
||||
{selectedProvider ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Detected via{" "}
|
||||
<span className="font-medium">
|
||||
{describeResolvedCommand(
|
||||
selectedProvider.command,
|
||||
selectedProvider.binaryPath,
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
) : providersLoading ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Looking for installed ACP runtimes...
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No known ACP runtime was detected. You can still enter a custom
|
||||
command below.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedProviderId === "custom" ? (
|
||||
@@ -365,17 +379,17 @@ export function CreateAgentTokenSection({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Token scopes</p>
|
||||
<div className="grid gap-2 md:grid-cols-3">
|
||||
{AGENT_SCOPE_OPTIONS.map((scope) => {
|
||||
<p className="text-sm font-medium">Scopes</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{MANAGED_AGENT_SCOPE_OPTIONS.map((scope) => {
|
||||
const selected = selectedScopes.has(scope.value);
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"rounded-xl border px-3 py-2 text-left text-sm transition-colors",
|
||||
"rounded-lg border px-3 py-2 text-left text-sm transition-colors",
|
||||
selected
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border/70 bg-background/80 hover:bg-accent",
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border/60 text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
key={scope.value}
|
||||
onClick={() => onScopeToggle(scope.value)}
|
||||
|
||||
@@ -71,6 +71,12 @@ export function SecretRevealDialog({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{created.profileSyncError ? (
|
||||
<p className="rounded-2xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
|
||||
{created.profileSyncError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{created.spawnError ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{created.spawnError}
|
||||
|
||||
@@ -1,17 +1,3 @@
|
||||
import type { TokenScope } from "@/shared/api/types";
|
||||
|
||||
export const AGENT_SCOPE_OPTIONS: Array<{ value: TokenScope; label: string }> =
|
||||
[
|
||||
{ value: "messages:read", label: "Messages read" },
|
||||
{ value: "messages:write", label: "Messages write" },
|
||||
{ value: "channels:read", label: "Channels read" },
|
||||
{ value: "channels:write", label: "Channels write" },
|
||||
{ value: "users:read", label: "Users read" },
|
||||
{ value: "users:write", label: "Users write" },
|
||||
{ value: "files:read", label: "Files read" },
|
||||
{ value: "files:write", label: "Files write" },
|
||||
];
|
||||
|
||||
export function formatTimestamp(value: string | null) {
|
||||
if (!value) {
|
||||
return "Never";
|
||||
|
||||
@@ -226,12 +226,15 @@ function StreamsSection({
|
||||
>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
className="h-8 bg-background/80"
|
||||
data-testid="create-stream-name"
|
||||
disabled={isCreatingChannel}
|
||||
onChange={(event) => onChangeName(event.target.value)}
|
||||
placeholder="release-notes"
|
||||
ref={createInputRef}
|
||||
spellCheck={false}
|
||||
value={draftName}
|
||||
/>
|
||||
<Input
|
||||
|
||||
31
desktop/src/features/tokens/lib/scopeOptions.ts
Normal file
31
desktop/src/features/tokens/lib/scopeOptions.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { TokenScope } from "@/shared/api/types";
|
||||
|
||||
export const TOKEN_SCOPE_OPTIONS: Array<{
|
||||
value: TokenScope;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "messages:read", label: "Messages: Read" },
|
||||
{ value: "messages:write", label: "Messages: Write" },
|
||||
{ value: "channels:read", label: "Channels: Read" },
|
||||
{ value: "channels:write", label: "Channels: Write" },
|
||||
{ value: "users:read", label: "Users: Read" },
|
||||
{ value: "files:read", label: "Files: Read" },
|
||||
{ value: "files:write", label: "Files: Write" },
|
||||
];
|
||||
|
||||
export const MANAGED_AGENT_SCOPE_OPTIONS: Array<{
|
||||
value: TokenScope;
|
||||
label: string;
|
||||
}> = [
|
||||
...TOKEN_SCOPE_OPTIONS.slice(0, 5),
|
||||
{ value: "users:write", label: "Users: Write" },
|
||||
...TOKEN_SCOPE_OPTIONS.slice(5),
|
||||
];
|
||||
|
||||
export const DEFAULT_MANAGED_AGENT_SCOPES: TokenScope[] = [
|
||||
"messages:read",
|
||||
"messages:write",
|
||||
"channels:read",
|
||||
"users:read",
|
||||
"users:write",
|
||||
];
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
useRevokeTokenMutation,
|
||||
useTokensQuery,
|
||||
} from "@/features/tokens/hooks";
|
||||
import { TOKEN_SCOPE_OPTIONS } from "@/features/tokens/lib/scopeOptions";
|
||||
import { getChannelMembers } from "@/shared/api/tauri";
|
||||
import type { Channel, Token, TokenScope } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
@@ -29,16 +30,6 @@ import {
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
|
||||
const ALL_SCOPES: { value: TokenScope; label: string }[] = [
|
||||
{ value: "messages:read", label: "Messages: Read" },
|
||||
{ value: "messages:write", label: "Messages: Write" },
|
||||
{ value: "channels:read", label: "Channels: Read" },
|
||||
{ value: "channels:write", label: "Channels: Write" },
|
||||
{ value: "users:read", label: "Users: Read" },
|
||||
{ value: "files:read", label: "Files: Read" },
|
||||
{ value: "files:write", label: "Files: Write" },
|
||||
];
|
||||
|
||||
const EXPIRY_OPTIONS = [
|
||||
{ value: 7, label: "7 days" },
|
||||
{ value: 30, label: "30 days" },
|
||||
@@ -375,7 +366,7 @@ function CreateTokenDialog({
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm font-medium">Scopes</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ALL_SCOPES.map(({ value, label }) => {
|
||||
{TOKEN_SCOPE_OPTIONS.map(({ value, label }) => {
|
||||
const isSelected = selectedScopes.has(value);
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -230,6 +230,7 @@ type RawCreateManagedAgentResponse = {
|
||||
agent: RawManagedAgent;
|
||||
private_key_nsec: string;
|
||||
api_token: string | null;
|
||||
profile_sync_error: string | null;
|
||||
spawn_error: string | null;
|
||||
};
|
||||
|
||||
@@ -815,6 +816,7 @@ export async function createManagedAgent(
|
||||
agent: fromRawManagedAgent(response.agent),
|
||||
privateKeyNsec: response.private_key_nsec,
|
||||
apiToken: response.api_token,
|
||||
profileSyncError: response.profile_sync_error,
|
||||
spawnError: response.spawn_error,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -283,6 +283,7 @@ export type CreateManagedAgentResponse = {
|
||||
agent: ManagedAgent;
|
||||
privateKeyNsec: string;
|
||||
apiToken: string | null;
|
||||
profileSyncError: string | null;
|
||||
spawnError: string | null;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user