mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(desktop): remote agent backend providers in channel Add Agent dialog (#150)
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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<ChannelRole, "owner">;
|
||||
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,
|
||||
|
||||
@@ -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<string, string>,
|
||||
schema: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> {
|
||||
if (!schema) return { ...config };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const properties = (schema as any)?.properties ?? {};
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
const prop = properties[key] as Record<string, unknown> | 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<string, unknown>;
|
||||
config: Record<string, string>;
|
||||
onChange: (config: Record<string, string>) => 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<string>((schema as any)?.required ?? []);
|
||||
|
||||
const entries = Object.entries(properties) as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
][];
|
||||
|
||||
if (entries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{entries.map(([key, prop]) => (
|
||||
<div key={key} className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor={`provider-cfg-${key}`}
|
||||
>
|
||||
{typeof prop.title === "string" ? prop.title : key}
|
||||
{required.has(key) ? (
|
||||
<span className="ml-1 text-destructive">*</span>
|
||||
) : null}
|
||||
</label>
|
||||
<Input
|
||||
id={`provider-cfg-${key}`}
|
||||
onChange={(e) => 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" ? (
|
||||
<p className="text-xs text-muted-foreground">{prop.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import {
|
||||
coerceConfigValues,
|
||||
ProviderConfigFields,
|
||||
} from "./ProviderConfigFields";
|
||||
|
||||
// ── Dialog ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
85
desktop/src/features/agents/ui/ProviderConfigFields.tsx
Normal file
85
desktop/src/features/agents/ui/ProviderConfigFields.tsx
Normal file
@@ -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<string, string>,
|
||||
schema: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> {
|
||||
if (!schema) return { ...config };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const properties = (schema as any)?.properties ?? {};
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
const prop = properties[key] as Record<string, unknown> | 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<string, unknown>;
|
||||
config: Record<string, string>;
|
||||
onChange: (config: Record<string, string>) => 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<string>((schema as any)?.required ?? []);
|
||||
|
||||
const entries = Object.entries(properties) as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
][];
|
||||
|
||||
if (entries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{entries.map(([key, prop]) => (
|
||||
<div key={key} className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor={`provider-cfg-${key}`}
|
||||
>
|
||||
{typeof prop.title === "string" ? prop.title : key}
|
||||
{required.has(key) ? (
|
||||
<span className="ml-1 text-destructive">*</span>
|
||||
) : null}
|
||||
</label>
|
||||
<Input
|
||||
id={`provider-cfg-${key}`}
|
||||
onChange={(e) => 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" ? (
|
||||
<p className="text-xs text-muted-foreground">{prop.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string>
|
||||
>({});
|
||||
const [probedProvider, setProbedProvider] =
|
||||
React.useState<BackendProviderProbeResult | null>(null);
|
||||
const [probeError, setProbeError] = React.useState<string | null>(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<string, string> = {};
|
||||
for (const [key, prop] of Object.entries(props) as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
][]) {
|
||||
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({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 px-6 py-5">
|
||||
{resolvedBackendProviders.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium">Run on</div>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm"
|
||||
disabled={createBotsMutation.isPending}
|
||||
onChange={(e) => handleRunOnChange(e.target.value)}
|
||||
value={runOn}
|
||||
>
|
||||
<option value="local">This computer</option>
|
||||
{resolvedBackendProviders.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isProviderMode && selectedBackendProvider ? (
|
||||
<div className="flex gap-3 rounded-2xl border border-amber-400/60 bg-amber-50/60 px-4 py-3 dark:border-amber-500/40 dark:bg-amber-950/30">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-300">
|
||||
This provider at{" "}
|
||||
<span className="font-mono font-medium">
|
||||
{selectedBackendProvider.binaryPath}
|
||||
</span>{" "}
|
||||
will receive your agent's private key. Only use providers
|
||||
from trusted sources.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{probeError ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
Could not probe provider: {probeError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isProviderMode && probedProvider?.config_schema ? (
|
||||
<ProviderConfigFields
|
||||
config={providerConfig}
|
||||
onChange={setProviderConfig}
|
||||
schema={probedProvider.config_schema}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium">Runtime</div>
|
||||
<DropdownMenu modal={false}>
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
|
||||
<AddChannelBotDialog
|
||||
backendProviders={backendProvidersQuery.data ?? []}
|
||||
backendProvidersLoading={backendProvidersQuery.isLoading}
|
||||
channelId={channel.id}
|
||||
onOpenChange={setIsAddBotOpen}
|
||||
open={isAddBotOpen}
|
||||
|
||||
Reference in New Issue
Block a user