From f3668fd77787b626e17c6145bcb0b8b71d4adb54 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Sat, 21 Mar 2026 10:55:20 -0400 Subject: [PATCH] feat(desktop): remote agent backend providers in channel Add Agent dialog (#150) --- desktop/scripts/check-file-sizes.mjs | 1 + desktop/src/features/agents/channelAgents.ts | 14 +- .../features/agents/ui/CreateAgentDialog.tsx | 89 +--------- .../agents/ui/ProviderConfigFields.tsx | 85 +++++++++ .../channels/ui/AddChannelBotDialog.tsx | 162 +++++++++++++++++- .../channels/ui/ChannelMembersBar.tsx | 4 + 6 files changed, 267 insertions(+), 88 deletions(-) create mode 100644 desktop/src/features/agents/ui/ProviderConfigFields.tsx diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 6fdf65212d..8b53baa967 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -45,6 +45,7 @@ const overrides = new Map([ ["src-tauri/src/managed_agents/backend.rs", 530], // provider IPC, validation, discovery, binary resolution + tests ["src/features/agents/ui/AgentsView.tsx", 730], // remote agent stop/delete + channel UUID resolution + presence-aware delete guard + persona import ["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes + ["src/features/channels/ui/AddChannelBotDialog.tsx", 600], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement ]); async function walkFiles(directory) { diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index 5dd742c007..aaa91abc7e 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -12,6 +12,7 @@ import type { AcpProvider, ChannelRole, ManagedAgent, + ManagedAgentBackend, } from "@/shared/api/types"; type ChannelAgentProvider = Pick< @@ -52,6 +53,7 @@ export type CreateChannelManagedAgentInput = { personaId?: string | null; role?: Exclude; ensureRunning?: boolean; + backend?: ManagedAgentBackend; }; export type CreateChannelManagedAgentResult = @@ -295,6 +297,8 @@ export async function createChannelManagedAgent( } } + const isProviderMode = input.backend?.type === "provider"; + const created = await createManagedAgent({ name: trimmedName, acpCommand: "sprout-acp", @@ -307,8 +311,16 @@ export async function createChannelManagedAgent( personaId: input.personaId ?? undefined, systemPrompt: input.systemPrompt?.trim() || undefined, avatarUrl: resolvedAvatarUrl, - spawnAfterCreate: false, + spawnAfterCreate: isProviderMode, + startOnAppLaunch: isProviderMode ? false : undefined, + backend: input.backend, }); + + // Tauri returns Ok() even on deploy failure — spawnError carries the message. + if (created.spawnError) { + throw new Error(created.spawnError); + } + const attached = await attachManagedAgentToChannel(channelId, { agent: created.agent, role, diff --git a/desktop/src/features/agents/ui/CreateAgentDialog.tsx b/desktop/src/features/agents/ui/CreateAgentDialog.tsx index 719e85f452..fd2106faea 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialog.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialog.tsx @@ -23,7 +23,6 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; -import { Input } from "@/shared/ui/input"; import { CreateAgentBasicsFields, CreateAgentOptionToggles, @@ -31,90 +30,10 @@ import { CreateAgentRuntimeFields, CreateAgentTokenSection, } from "./CreateAgentDialogSections"; - -/// Coerce string config values to their schema-declared types (number, boolean). -/// Providers receive JSON — sending "3" instead of 3 for an integer field breaks -/// typed config parsing on the provider side. -function coerceConfigValues( - config: Record, - schema: Record | undefined, -): Record { - if (!schema) return { ...config }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const properties = (schema as any)?.properties ?? {}; - const result: Record = {}; - for (const [key, value] of Object.entries(config)) { - const prop = properties[key] as Record | undefined; - const schemaType = prop?.type; - if ((schemaType === "integer" || schemaType === "number") && value !== "") { - const num = Number(value); - result[key] = Number.isNaN(num) ? value : num; - } else if (schemaType === "boolean") { - result[key] = value === "true"; - } else { - result[key] = value; - } - } - return result; -} - -// ── Provider config form ────────────────────────────────────────────────────── - -function ProviderConfigFields({ - schema, - config, - onChange, -}: { - schema: Record; - config: Record; - onChange: (config: Record) => void; -}) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const properties = (schema as any)?.properties ?? {}; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const required = new Set((schema as any)?.required ?? []); - - const entries = Object.entries(properties) as [ - string, - Record, - ][]; - - if (entries.length === 0) { - return null; - } - - return ( -
- {entries.map(([key, prop]) => ( -
- - onChange({ ...config, [key]: e.target.value })} - placeholder={ - typeof prop.description === "string" ? prop.description : "" - } - value={ - config[key] ?? - (typeof prop.default === "string" ? prop.default : "") - } - /> - {typeof prop.description === "string" ? ( -

{prop.description}

- ) : null} -
- ))} -
- ); -} +import { + coerceConfigValues, + ProviderConfigFields, +} from "./ProviderConfigFields"; // ── Dialog ──────────────────────────────────────────────────────────────────── diff --git a/desktop/src/features/agents/ui/ProviderConfigFields.tsx b/desktop/src/features/agents/ui/ProviderConfigFields.tsx new file mode 100644 index 0000000000..7ec7d17590 --- /dev/null +++ b/desktop/src/features/agents/ui/ProviderConfigFields.tsx @@ -0,0 +1,85 @@ +import { Input } from "@/shared/ui/input"; + +/// Coerce string config values to their schema-declared types (number, boolean). +/// Providers receive JSON — sending "3" instead of 3 for an integer field breaks +/// typed config parsing on the provider side. +export function coerceConfigValues( + config: Record, + schema: Record | undefined, +): Record { + if (!schema) return { ...config }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const properties = (schema as any)?.properties ?? {}; + const result: Record = {}; + for (const [key, value] of Object.entries(config)) { + const prop = properties[key] as Record | undefined; + const schemaType = prop?.type; + if ((schemaType === "integer" || schemaType === "number") && value !== "") { + const num = Number(value); + result[key] = Number.isNaN(num) ? value : num; + } else if (schemaType === "boolean") { + result[key] = value === "true"; + } else { + result[key] = value; + } + } + return result; +} + +// ── Provider config form ────────────────────────────────────────────────────── + +export function ProviderConfigFields({ + schema, + config, + onChange, +}: { + schema: Record; + config: Record; + onChange: (config: Record) => void; +}) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const properties = (schema as any)?.properties ?? {}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const required = new Set((schema as any)?.required ?? []); + + const entries = Object.entries(properties) as [ + string, + Record, + ][]; + + if (entries.length === 0) { + return null; + } + + return ( +
+ {entries.map(([key, prop]) => ( +
+ + onChange({ ...config, [key]: e.target.value })} + placeholder={ + typeof prop.description === "string" ? prop.description : "" + } + value={ + config[key] ?? + (typeof prop.default === "string" ? prop.default : "") + } + /> + {typeof prop.description === "string" ? ( +

{prop.description}

+ ) : null} +
+ ))} +
+ ); +} diff --git a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx index 18fadc6325..93e74d336c 100644 --- a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx @@ -1,4 +1,4 @@ -import { ChevronDown } from "lucide-react"; +import { AlertTriangle, ChevronDown } from "lucide-react"; import * as React from "react"; import { @@ -10,7 +10,13 @@ import { import { AddChannelBotGenericSection } from "@/features/channels/ui/AddChannelBotGenericSection"; import { AddChannelBotPersonasSection } from "@/features/channels/ui/AddChannelBotPersonasSection"; import { AddChannelBotTeamsSection } from "@/features/channels/ui/AddChannelBotTeamsSection"; -import type { AcpProvider } from "@/shared/api/types"; +import { probeBackendProvider } from "@/shared/api/tauri"; +import type { + AcpProvider, + BackendProviderCandidate, + BackendProviderProbeResult, + ManagedAgentBackend, +} from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; import { Dialog, @@ -26,8 +32,14 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; +import { + coerceConfigValues, + ProviderConfigFields, +} from "@/features/agents/ui/ProviderConfigFields"; type AddChannelBotDialogProps = { + backendProviders?: BackendProviderCandidate[]; + backendProvidersLoading?: boolean; channelId: string | null; open: boolean; providers: AcpProvider[]; @@ -74,6 +86,8 @@ function formatBatchFailureSummary( } export function AddChannelBotDialog({ + backendProviders, + backendProvidersLoading, channelId, open, providers, @@ -102,6 +116,17 @@ export function AddChannelBotDialog({ null, ); + const resolvedBackendProviders = backendProviders ?? []; + const resolvedBackendProvidersLoading = backendProvidersLoading ?? false; + + const [runOn, setRunOn] = React.useState<"local" | string>("local"); + const [providerConfig, setProviderConfig] = React.useState< + Record + >({}); + const [probedProvider, setProbedProvider] = + React.useState(null); + const [probeError, setProbeError] = React.useState(null); + const selectedProvider = React.useMemo( () => providers.find((provider) => provider.id === selectedProviderId) ?? @@ -115,6 +140,21 @@ export function AddChannelBotDialog({ ); const selectedCount = selectedPersonas.length + (includeGeneric ? 1 : 0); + const isProviderMode = runOn !== "local"; + const selectedBackendProvider = React.useMemo( + () => resolvedBackendProviders.find((p) => p.id === runOn) ?? null, + [resolvedBackendProviders, runOn], + ); + const providerConfigComplete = React.useMemo(() => { + if (!isProviderMode || !probedProvider?.config_schema) return true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const schema = probedProvider.config_schema as any; + const required: string[] = schema?.required ?? []; + return required.every( + (key) => (providerConfig[key] ?? "").trim().length > 0, + ); + }, [isProviderMode, probedProvider, providerConfig]); + React.useEffect(() => { if (!open) { return; @@ -139,6 +179,48 @@ export function AddChannelBotDialog({ ); }, [personas]); + React.useEffect(() => { + if (!isProviderMode || !selectedBackendProvider) { + setProbedProvider(null); + setProbeError(null); + return; + } + + let cancelled = false; + setProbeError(null); + setProbedProvider(null); + + probeBackendProvider(selectedBackendProvider.binaryPath) + .then((result) => { + if (!cancelled) { + setProbedProvider(result); + if (result.config_schema) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const props = (result.config_schema as any)?.properties ?? {}; + const defaults: Record = {}; + for (const [key, prop] of Object.entries(props) as [ + string, + Record, + ][]) { + if (prop.default != null) { + defaults[key] = String(prop.default); + } + } + setProviderConfig(defaults); + } + } + }) + .catch((err: unknown) => { + if (!cancelled) { + setProbeError(err instanceof Error ? err.message : String(err)); + } + }); + + return () => { + cancelled = true; + }; + }, [isProviderMode, selectedBackendProvider]); + function reset() { setSelectedProviderId(providers[0]?.id ?? ""); setSelectedPersonaIds([]); @@ -148,6 +230,10 @@ export function AddChannelBotDialog({ setHasEditedCustomName(false); setSubmissionNotice(null); setSubmissionError(null); + setRunOn("local"); + setProviderConfig({}); + setProbedProvider(null); + setProbeError(null); createBotsMutation.reset(); } @@ -172,11 +258,31 @@ export function AddChannelBotDialog({ setSubmissionError(null); } + function handleRunOnChange(value: string) { + setRunOn(value); + setProviderConfig({}); + setProbedProvider(null); + setProbeError(null); + setSubmissionNotice(null); + setSubmissionError(null); + } + async function handleSubmit() { if (!selectedProvider || selectedCount === 0) { return; } + const backend: ManagedAgentBackend = isProviderMode + ? { + type: "provider", + id: runOn, + config: coerceConfigValues( + providerConfig, + probedProvider?.config_schema, + ), + } + : { type: "local" }; + const inputs = [ ...(includeGeneric ? [ @@ -185,6 +291,7 @@ export function AddChannelBotDialog({ name: customName, systemPrompt: customPrompt, role: "bot" as const, + backend, }, ] : []), @@ -195,6 +302,7 @@ export function AddChannelBotDialog({ systemPrompt: persona.systemPrompt, avatarUrl: persona.avatarUrl ?? undefined, role: "bot" as const, + backend, })), ]; @@ -242,7 +350,10 @@ export function AddChannelBotDialog({ selectedProvider !== null && selectedCount > 0 && (!includeGeneric || customName.trim().length > 0) && + !(isProviderMode && !probedProvider) && + providerConfigComplete && !providersLoading && + !(isProviderMode && resolvedBackendProvidersLoading) && !createBotsMutation.isPending; const canChooseProvider = providers.length > 0 && !providersLoading && !createBotsMutation.isPending; @@ -271,6 +382,53 @@ export function AddChannelBotDialog({
+ {resolvedBackendProviders.length > 0 ? ( +
+
Run on
+ +
+ ) : null} + + {isProviderMode && selectedBackendProvider ? ( +
+ +

+ This provider at{" "} + + {selectedBackendProvider.binaryPath} + {" "} + will receive your agent's private key. Only use providers + from trusted sources. +

+
+ ) : null} + + {probeError ? ( +

+ Could not probe provider: {probeError} +

+ ) : null} + + {isProviderMode && probedProvider?.config_schema ? ( + + ) : null} +
Runtime
diff --git a/desktop/src/features/channels/ui/ChannelMembersBar.tsx b/desktop/src/features/channels/ui/ChannelMembersBar.tsx index 53c72fe4de..f4bf99f2df 100644 --- a/desktop/src/features/channels/ui/ChannelMembersBar.tsx +++ b/desktop/src/features/channels/ui/ChannelMembersBar.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { useAcpProvidersQuery, + useBackendProvidersQuery, useManagedAgentsQuery, useRelayAgentsQuery, } from "@/features/agents/hooks"; @@ -51,6 +52,7 @@ export function ChannelMembersBar({ const [isAddBotOpen, setIsAddBotOpen] = React.useState(false); const membersQuery = useChannelMembersQuery(channel.id); const providersQuery = useAcpProvidersQuery(); + const backendProvidersQuery = useBackendProvidersQuery(); const managedAgentsQuery = useManagedAgentsQuery(); const relayAgentsQuery = useRelayAgentsQuery(); const members = membersQuery.data ?? []; @@ -167,6 +169,8 @@ export function ChannelMembersBar({