From 29bb56f7704f28b989e04057527bd384166ba599 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:20:52 -0700 Subject: [PATCH] add providers settings UI (#131) * feat: add providers settings UI Add the settings screens and localized copy for configuring agent and model providers on top of the provider infrastructure. * fix: align agent provider card with merged setup API Update the settings UI to use provider-based agent setup commands so the rebased providers PR matches the merged backend contract. * fix: localize provider setup messaging Move provider setup and status copy onto settings translation keys so the providers UI stays localized across supported languages. --- .../settings/ui/AgentProviderCard.tsx | 427 ++++++++++++++++ .../settings/ui/ModelProviderPanels.tsx | 263 ++++++++++ .../features/settings/ui/ModelProviderRow.tsx | 463 ++++++++++++++++++ .../settings/ui/ProvidersSettings.tsx | 223 +++++++++ .../features/settings/ui/SettingsModal.tsx | 4 + .../settings/ui/modelProviderHelpers.tsx | 152 ++++++ .../src/shared/i18n/locales/en/settings.json | 48 +- .../src/shared/i18n/locales/es/settings.json | 48 +- 8 files changed, 1626 insertions(+), 2 deletions(-) create mode 100644 ui/goose2/src/features/settings/ui/AgentProviderCard.tsx create mode 100644 ui/goose2/src/features/settings/ui/ModelProviderPanels.tsx create mode 100644 ui/goose2/src/features/settings/ui/ModelProviderRow.tsx create mode 100644 ui/goose2/src/features/settings/ui/ProvidersSettings.tsx create mode 100644 ui/goose2/src/features/settings/ui/modelProviderHelpers.tsx diff --git a/ui/goose2/src/features/settings/ui/AgentProviderCard.tsx b/ui/goose2/src/features/settings/ui/AgentProviderCard.tsx new file mode 100644 index 0000000000..d4df5e0363 --- /dev/null +++ b/ui/goose2/src/features/settings/ui/AgentProviderCard.tsx @@ -0,0 +1,427 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Spinner } from "@/shared/ui/spinner"; +import { getProviderIcon } from "@/shared/ui/icons/ProviderIcons"; +import { IconCheck, IconAlertTriangle, IconPlus } from "@tabler/icons-react"; +import { + checkAgentInstalled, + checkAgentAuth, + installAgent, + authenticateAgent, + onAgentSetupOutput, +} from "@/features/providers/api/agentSetup"; +import type { ProviderDisplayInfo } from "@/shared/types/providers"; + +type SetupPhase = "idle" | "checking" | "installing" | "authenticating"; + +interface OutputLine { + id: number; + text: string; +} + +const MAX_OUTPUT_LINES = 50; + +interface AgentProviderCardProps { + provider: ProviderDisplayInfo; +} + +export function AgentProviderCard({ provider }: AgentProviderCardProps) { + const { t } = useTranslation(["settings", "common"]); + const [setupPhase, setSetupPhase] = useState("idle"); + const [setupOutput, setSetupOutput] = useState([]); + const [setupError, setSetupError] = useState(null); + const [isInstalled, setIsInstalled] = useState(null); + const [isAuthenticated, setIsAuthenticated] = useState(null); + const outputRef = useRef(null); + const outputLengthRef = useRef(0); + const lineCounterRef = useRef(0); + const isMountedRef = useRef(true); + const unlistenRef = useRef<(() => void) | null>(null); + + const icon = getProviderIcon(provider.id, "size-6"); + const isBuiltIn = provider.status === "built_in"; + const isActive = setupPhase !== "idle"; + const hasInstallCommand = !!provider.installCommand; + const hasAuthCommand = !!provider.authCommand; + const hasAuthStatusCheck = !!provider.authStatusCommand; + const hasBinary = !!provider.binaryName; + const authStorageKey = `agent-provider-auth:${provider.id}`; + + const setAuthHint = useCallback( + (value: boolean) => { + if (value) { + localStorage.setItem(authStorageKey, "true"); + } else { + localStorage.removeItem(authStorageKey); + } + }, + [authStorageKey], + ); + + const getAuthHint = useCallback(() => { + return localStorage.getItem(authStorageKey) === "true"; + }, [authStorageKey]); + + const clearListener = useCallback(() => { + unlistenRef.current?.(); + unlistenRef.current = null; + }, []); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + clearListener(); + }; + }, [clearListener]); + + useEffect(() => { + if (!hasBinary || isBuiltIn || !provider.binaryName) return; + + checkAgentInstalled(provider.id) + .then((installed) => { + if (!isMountedRef.current) return; + setIsInstalled(installed); + if (installed && provider.authStatusCommand) { + return checkAgentAuth(provider.id).then((authenticated) => { + if (!isMountedRef.current) return; + setIsAuthenticated(authenticated); + }); + } + if (installed && !provider.authStatusCommand) { + setIsAuthenticated(getAuthHint() ? true : null); + } + if (!installed) { + setIsAuthenticated(null); + setAuthHint(false); + } + }) + .catch(() => { + if (!isMountedRef.current) return; + setIsInstalled(false); + }); + }, [ + getAuthHint, + hasBinary, + isBuiltIn, + provider.id, + provider.binaryName, + provider.authStatusCommand, + setAuthHint, + ]); + + useEffect(() => { + if (outputRef.current && outputLengthRef.current !== setupOutput.length) { + outputLengthRef.current = setupOutput.length; + outputRef.current.scrollTop = outputRef.current.scrollHeight; + } + }); + + const appendOutput = useCallback((line: string) => { + lineCounterRef.current += 1; + const entry: OutputLine = { id: lineCounterRef.current, text: line }; + setSetupOutput((prev) => { + const next = [...prev, entry]; + return next.length > MAX_OUTPUT_LINES + ? next.slice(-MAX_OUTPUT_LINES) + : next; + }); + }, []); + + async function handleConnect() { + setSetupError(null); + setSetupOutput([]); + lineCounterRef.current = 0; + + if (hasInstallCommand && isInstalled === false) { + await runInstall(); + } else if (hasAuthCommand) { + await runAuth(); + } + } + + async function runInstall() { + if (!provider.installCommand) return; + setSetupPhase("installing"); + + clearListener(); + const unlisten = await onAgentSetupOutput(provider.id, appendOutput); + if (!isMountedRef.current) { + unlisten(); + return; + } + unlistenRef.current = unlisten; + + try { + await installAgent(provider.id); + clearListener(); + if (!isMountedRef.current) return; + + if (hasBinary && provider.binaryName) { + setSetupPhase("checking"); + const installed = await checkAgentInstalled(provider.binaryName); + if (!isMountedRef.current) return; + setIsInstalled(installed); + if (!installed) { + setAuthHint(false); + setSetupError( + "Install finished but the CLI was not found on PATH. You may need to restart your terminal.", + ); + setSetupPhase("idle"); + return; + } + } + + if (hasAuthCommand) { + await runAuth(); + } else { + if (!isMountedRef.current) return; + setSetupPhase("idle"); + } + } catch (err) { + clearListener(); + if (!isMountedRef.current) return; + setSetupError(err instanceof Error ? err.message : String(err)); + setSetupPhase("idle"); + } + } + + async function runAuth() { + if (!provider.authCommand) return; + setSetupPhase("authenticating"); + setSetupOutput([]); + + clearListener(); + const unlisten = await onAgentSetupOutput(provider.id, appendOutput); + if (!isMountedRef.current) { + unlisten(); + return; + } + unlistenRef.current = unlisten; + + try { + await authenticateAgent(provider.id); + clearListener(); + if (!isMountedRef.current) return; + setAuthHint(true); + setIsAuthenticated(true); + setSetupPhase("idle"); + } catch (err) { + clearListener(); + if (!isMountedRef.current) return; + setSetupError(err instanceof Error ? err.message : String(err)); + setSetupPhase("idle"); + } + } + + function handleRetry() { + setSetupError(null); + void handleConnect(); + } + + if (provider.showOnlyWhenInstalled && isInstalled !== true) return null; + + const isReady = + isBuiltIn || + (isInstalled === true && !hasAuthCommand) || + (isInstalled === true && isAuthenticated === true); + const needsAuth = + isInstalled === true && hasAuthCommand && isAuthenticated !== true; + const needsInstall = isInstalled === false && hasInstallCommand; + + function renderStatusIndicator() { + if (isBuiltIn || isReady) { + return ( +
+ +
+ ); + } + + if (setupError) { + return ( +
+ +
+ ); + } + + if (needsAuth && !isActive) { + return ( + + ); + } + + if (needsInstall && !isActive) { + return ( + + ); + } + + return null; + } + + function renderStatusText() { + if (isBuiltIn || isReady) return null; + if (setupError) return "Setup failed"; + + if (isInstalled === null && hasBinary) return "Checking..."; + if (isInstalled === true && hasAuthStatusCheck && isAuthenticated === null) + return "Checking..."; + + return null; + } + + function renderAction() { + if (isBuiltIn || isReady) return null; + + if (isActive) return null; + + if (setupError) { + return ( + + ); + } + + return null; + } + + function renderSetupProgress() { + if (!isActive) return null; + + const phaseLabel = + setupPhase === "installing" + ? `Installing ${provider.displayName}...` + : setupPhase === "authenticating" + ? "Waiting for sign-in..." + : "Verifying installation..."; + + const stepInfo = + setupPhase === "installing" && hasAuthCommand + ? "Step 1 of 2" + : setupPhase === "authenticating" && hasInstallCommand + ? "Step 2 of 2" + : null; + + return ( +
+
+ +
+ {phaseLabel} + {stepInfo && ( + + {stepInfo} + + )} +
+
+ + {setupOutput.length > 0 && ( +
+ {setupOutput.map((entry) => ( +
{entry.text || "\u00A0"}
+ ))} +
+ )} +
+ ); + } + + return ( +
+
+
+
+ {icon} +
+ {provider.displayName} +

+ {provider.description} +

+
+ {renderStatusIndicator()} +
+ + {(() => { + const statusText = renderStatusText(); + const action = renderAction(); + if (!statusText && !action) return null; + return ( +
+
+ {statusText && ( + <> + + + {statusText} + + + )} +
+ {action} +
+ ); + })()} + + {renderSetupProgress()} + + {setupError && !isActive && ( +
+

{setupError}

+ {setupOutput.length > 0 && ( +
+ {setupOutput.map((entry) => ( +
{entry.text || "\u00A0"}
+ ))} +
+ )} +
+ )} +
+ ); +} diff --git a/ui/goose2/src/features/settings/ui/ModelProviderPanels.tsx b/ui/goose2/src/features/settings/ui/ModelProviderPanels.tsx new file mode 100644 index 0000000000..48adadb37f --- /dev/null +++ b/ui/goose2/src/features/settings/ui/ModelProviderPanels.tsx @@ -0,0 +1,263 @@ +import type { RefObject } from "react"; +import { useTranslation } from "react-i18next"; +import { AsyncButton } from "@/shared/ui/async-button"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { IconLoader2 } from "@tabler/icons-react"; +import type { + ProviderField, + ProviderFieldValue, + ProviderSetupMethod, +} from "@/shared/types/providers"; +import { + resolveFieldValue, + getDisplayValue, + renderSetupMessage, + renderInlineCodeMessage, +} from "./modelProviderHelpers"; + +interface ConnectedFieldsPanelProps { + panelRef: RefObject; + fields: ProviderField[]; + fieldValueMap: Map; + editingKey: string | null; + draftValues: Record; + saving: boolean; + showSavedState: boolean; + error: string; + setupMessage: string | null; + onStartEdit: (key: string) => void; + onCancelEdit: (field: ProviderField) => void; + onDraftChange: (key: string, value: string) => void; + onSaveField: (field: ProviderField) => void; + onRemove: () => void; +} + +export function ConnectedFieldsPanel({ + panelRef, + fields, + fieldValueMap, + editingKey, + draftValues, + saving, + showSavedState, + error, + setupMessage, + onStartEdit, + onCancelEdit, + onDraftChange, + onSaveField, + onRemove, +}: ConnectedFieldsPanelProps) { + const { t } = useTranslation(["settings", "common"]); + return ( +
+ {fields.map((field) => { + const isEditing = editingKey === field.key; + return ( +
+
+
+

{field.label}

+ {!isEditing && ( +

+ {getDisplayValue(field, fieldValueMap, t)} +

+ )} +
+ + {!isEditing && ( + + )} +
+ + {isEditing && ( +
+ + onDraftChange(field.key, event.target.value) + } + onKeyDown={(event) => { + if (event.key === "Enter") { + onSaveField(field); + } + }} + disabled={saving} + className="h-8 flex-1 text-xs" + /> + + +
+ )} +
+ ); + })} + +
+ {showSavedState ? ( + + ) : null} + +
+ {renderSetupMessage(setupMessage)} + {error &&

{error}

} +
+ ); +} + +interface SetupFieldsPanelProps { + panelRef: RefObject; + fields: ProviderField[]; + fieldValueMap: Map; + draftValues: Record; + saving: boolean; + showSavedState: boolean; + error: string; + setupMethod: ProviderSetupMethod; + setupMessage: string | null; + fieldSetupDescription: string | null; + isConnected: boolean; + onDraftChange: (key: string, value: string) => void; + onSaveSetup: () => void; +} + +export function SetupFieldsPanel({ + panelRef, + fields, + fieldValueMap, + draftValues, + saving, + showSavedState, + error, + setupMethod, + setupMessage, + fieldSetupDescription, + isConnected, + onDraftChange, + onSaveSetup, +}: SetupFieldsPanelProps) { + const { t } = useTranslation(["settings", "common"]); + return ( +
+ {!isConnected && fieldSetupDescription ? ( +

{fieldSetupDescription}

+ ) : null} + {fields.map((field) => { + const fieldValue = resolveFieldValue(field, fieldValueMap); + return ( +
+
+ + {field.label} + + {field.required && ( + + {t("common:labels.required")} + + )} +
+ onDraftChange(field.key, event.target.value)} + disabled={saving} + className="h-8 text-xs" + /> +
+ ); + })} + +
+ onSaveSetup()} + disabled={saving || showSavedState} + className="h-8" + /> +
+ {setupMethod === "host_with_oauth_fallback" + ? renderInlineCodeMessage( + t("providers.models.setup.hostWithOauthFallbackTerminal"), + ) + : null} + {setupMethod === "cloud_credentials" && setupMessage + ? renderSetupMessage(setupMessage) + : null} + {error &&

{error}

} +
+ ); +} diff --git a/ui/goose2/src/features/settings/ui/ModelProviderRow.tsx b/ui/goose2/src/features/settings/ui/ModelProviderRow.tsx new file mode 100644 index 0000000000..7a6751dbb5 --- /dev/null +++ b/ui/goose2/src/features/settings/ui/ModelProviderRow.tsx @@ -0,0 +1,463 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/shared/ui/button"; +import { Skeleton } from "@/shared/ui/skeleton"; +import { Spinner } from "@/shared/ui/spinner"; +import { + getProviderIcon, + formatProviderLabel, +} from "@/shared/ui/icons/ProviderIcons"; +import { IconCheck } from "@tabler/icons-react"; +import { + authenticateModelProvider, + onModelSetupOutput, +} from "@/features/providers/api/modelSetup"; +import type { + ProviderDisplayInfo, + ProviderField, + ProviderFieldValue, +} from "@/shared/types/providers"; +import { + MAX_SETUP_OUTPUT_LINES, + type SetupOutputLine, + resolveFieldValue, + createDraftValues, + getSetupMessage, + getNativeConnectDescription, + getFieldSetupDescription, + renderSetupMessage, +} from "./modelProviderHelpers"; +import { ConnectedFieldsPanel, SetupFieldsPanel } from "./ModelProviderPanels"; + +interface ModelProviderRowProps { + provider: ProviderDisplayInfo; + onGetConfig: (providerId: string) => Promise; + onSaveField: (key: string, value: string, isSecret: boolean) => Promise; + onRemoveConfig?: () => Promise; + onCompleteNativeSetup: () => Promise; + saving?: boolean; +} + +export function ModelProviderRow({ + provider, + onGetConfig, + onSaveField, + onRemoveConfig, + onCompleteNativeSetup, + saving = false, +}: ModelProviderRowProps) { + const { t } = useTranslation("settings"); + const [expanded, setExpanded] = useState(false); + const [configValues, setConfigValues] = useState([]); + const [draftValues, setDraftValues] = useState>({}); + const [editingKey, setEditingKey] = useState(null); + const [loadingConfig, setLoadingConfig] = useState(false); + const [error, setError] = useState(""); + const [authenticating, setAuthenticating] = useState(false); + const [setupOutput, setSetupOutput] = useState([]); + const [setupError, setSetupError] = useState(""); + const [showSavedState, setShowSavedState] = useState(false); + const [preserveSetupLayout, setPreserveSetupLayout] = useState(false); + const setupLineCounter = useRef(0); + const hasLoadedConfig = useRef(false); + const shouldRestorePanelFocus = useRef(false); + const panelRef = useRef(null); + + const icon = getProviderIcon(provider.id, "size-4"); + const fields = provider.fields ?? []; + const hasFields = fields.length > 0; + const supportsNativeConnect = !!provider.nativeConnectQuery; + const isConnected = + provider.status === "connected" || provider.status === "built_in"; + const fieldValueMap = useMemo( + () => new Map(configValues.map((value) => [value.key, value])), + [configValues], + ); + + const loadConfig = useCallback( + async ({ showSkeleton = false }: { showSkeleton?: boolean } = {}) => { + if (!hasFields) return; + if (showSkeleton) { + setLoadingConfig(true); + } + try { + const nextValues = await onGetConfig(provider.id); + hasLoadedConfig.current = true; + setConfigValues(nextValues); + setDraftValues(createDraftValues(fields, nextValues)); + setError(""); + } catch (nextError) { + setError( + nextError instanceof Error + ? nextError.message + : "Failed to load provider settings", + ); + } finally { + if (showSkeleton) { + setLoadingConfig(false); + } + } + }, + [fields, hasFields, onGetConfig, provider.id], + ); + + useEffect(() => { + if (expanded && hasFields) { + void loadConfig({ showSkeleton: !hasLoadedConfig.current }); + } + }, [expanded, hasFields, loadConfig]); + + useEffect(() => { + if (isConnected) { + setAuthenticating(false); + setSetupError(""); + } + }, [isConnected]); + + useLayoutEffect(() => { + if (!shouldRestorePanelFocus.current) { + return; + } + + shouldRestorePanelFocus.current = false; + panelRef.current?.focus({ preventScroll: true }); + }); + + function appendSetupOutput(line: string) { + setupLineCounter.current += 1; + setSetupOutput((current) => + [ + ...current, + { + id: setupLineCounter.current, + text: line, + }, + ].slice(-MAX_SETUP_OUTPUT_LINES), + ); + } + + async function runNativeConnect() { + if (!provider.nativeConnectQuery) { + return; + } + + setExpanded(true); + setAuthenticating(true); + setSetupError(""); + setSetupOutput([]); + setupLineCounter.current = 0; + setEditingKey(null); + setError(""); + setShowSavedState(false); + setPreserveSetupLayout(false); + + const unlisten = await onModelSetupOutput(provider.id, appendSetupOutput); + + try { + await authenticateModelProvider(provider.id, provider.nativeConnectQuery); + await onCompleteNativeSetup(); + } catch (nextError) { + setSetupError( + nextError instanceof Error + ? nextError.message + : "Failed to complete sign-in", + ); + } finally { + unlisten(); + setAuthenticating(false); + } + } + + function handleToggle() { + setExpanded((current) => { + if (current) { + setShowSavedState(false); + setPreserveSetupLayout(false); + } + return !current; + }); + setEditingKey(null); + setError(""); + setSetupError(""); + } + + function handleStartEdit(key: string) { + setEditingKey(key); + setError(""); + setShowSavedState(false); + } + + function handleCancelEdit(field: ProviderField) { + setDraftValues((current) => ({ + ...current, + [field.key]: field.secret + ? "" + : (resolveFieldValue(field, fieldValueMap).value ?? ""), + })); + setEditingKey(null); + setError(""); + } + + function handleDraftChange(key: string, value: string) { + setShowSavedState(false); + setDraftValues((current) => ({ ...current, [key]: value })); + } + + async function handleSaveField(field: ProviderField) { + const nextValue = draftValues[field.key]?.trim() ?? ""; + if (!nextValue) { + setError(`Enter a value for ${field.label}`); + return; + } + setError(""); + try { + shouldRestorePanelFocus.current = true; + await onSaveField(field.key, nextValue, field.secret); + await loadConfig(); + setEditingKey(null); + setShowSavedState(true); + } catch (nextError) { + setError( + nextError instanceof Error ? nextError.message : "Failed to save", + ); + } + } + + async function handleSaveSetup() { + const missingLabels = fields + .filter((field) => { + if (!field.required) { + return false; + } + const currentValue = resolveFieldValue(field, fieldValueMap); + const nextValue = draftValues[field.key]?.trim() ?? ""; + return !currentValue.isSet && !nextValue; + }) + .map((field) => field.label); + + if (missingLabels.length > 0) { + setError(`Fill in ${missingLabels.join(", ")}`); + return; + } + + const fieldsToSave = fields.filter((field) => { + const currentValue = resolveFieldValue(field, fieldValueMap); + const nextValue = draftValues[field.key]?.trim() ?? ""; + + if (!nextValue) { + return false; + } + + if (field.secret) { + return true; + } + + return nextValue !== (currentValue.value ?? ""); + }); + + if (fieldsToSave.length === 0) { + setError(""); + return; + } + + setError(""); + try { + for (const field of fieldsToSave) { + const nextValue = draftValues[field.key]?.trim() ?? ""; + await onSaveField(field.key, nextValue, field.secret); + } + await loadConfig(); + setShowSavedState(true); + setPreserveSetupLayout(true); + } catch (nextError) { + setError( + nextError instanceof Error ? nextError.message : "Failed to save", + ); + } + } + + async function handleRemove() { + try { + shouldRestorePanelFocus.current = true; + await onRemoveConfig?.(); + await loadConfig(); + setEditingKey(null); + setError(""); + setShowSavedState(false); + setPreserveSetupLayout(false); + } catch (nextError) { + setError( + nextError instanceof Error ? nextError.message : "Failed to remove", + ); + } + } + + function renderExpandedContent() { + if (!expanded) return null; + + const setupMessage = getSetupMessage( + provider.setupMethod, + isConnected, + supportsNativeConnect, + t, + ); + const nativeConnectDescription = getNativeConnectDescription( + provider.setupMethod, + t, + ); + const fieldSetupDescription = getFieldSetupDescription( + provider.setupMethod, + t, + ); + + if (loadingConfig && hasFields) { + return ( +
+ + +
+ ); + } + + if (supportsNativeConnect && !hasFields) { + return ( +
+ {!isConnected && nativeConnectDescription ? ( +
+

+ {nativeConnectDescription} +

+ +
+ ) : ( + renderSetupMessage(setupMessage) + )} + {authenticating ? ( +
+ + {t("providers.waitingForSignIn")} +
+ ) : null} + {setupOutput.length > 0 ? ( +
+ {setupOutput.map((line) => ( +

{line.text}

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

{setupError}

+ ) : null} +
+ ); + } + + if (hasFields && isConnected && !preserveSetupLayout) { + return ( + void handleSaveField(field)} + onRemove={() => void handleRemove()} + /> + ); + } + + if (hasFields) { + return ( + void handleSaveSetup()} + /> + ); + } + + return ( +
+ {renderSetupMessage(setupMessage)} +
+ ); + } + + return ( +
+ + + {renderExpandedContent()} +
+ ); +} diff --git a/ui/goose2/src/features/settings/ui/ProvidersSettings.tsx b/ui/goose2/src/features/settings/ui/ProvidersSettings.tsx new file mode 100644 index 0000000000..38d34ca5f8 --- /dev/null +++ b/ui/goose2/src/features/settings/ui/ProvidersSettings.tsx @@ -0,0 +1,223 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/shared/ui/button"; +import { Separator } from "@/shared/ui/separator"; +import { Skeleton } from "@/shared/ui/skeleton"; +import { IconChevronDown, IconRefresh } from "@tabler/icons-react"; +import { + getAgentProviders, + getModelProviders, +} from "@/features/providers/providerCatalog"; +import { useCredentials } from "@/features/providers/hooks/useCredentials"; +import { AgentProviderCard } from "./AgentProviderCard"; +import { ModelProviderRow } from "./ModelProviderRow"; +import type { + ProviderDisplayInfo, + ProviderSetupStatus, + ProviderCatalogEntry, +} from "@/shared/types/providers"; + +function resolveStatus( + entry: ProviderCatalogEntry, + configuredIds: Set, +): ProviderSetupStatus { + if (entry.id === "goose") return "built_in"; + if (entry.category === "agent") return "not_installed"; + if (configuredIds.has(entry.id)) return "connected"; + return "not_configured"; +} + +function toDisplayInfo( + entries: ProviderCatalogEntry[], + configuredIds: Set, +): ProviderDisplayInfo[] { + return entries.map((entry) => ({ + ...entry, + status: resolveStatus(entry, configuredIds), + })); +} + +export function ProvidersSettings() { + const { t } = useTranslation(["settings", "common"]); + const [showAllModels, setShowAllModels] = useState(false); + const [modelOrder, setModelOrder] = useState(null); + + const { + configuredIds, + loading, + saving, + needsRestart, + getConfig, + save, + remove, + restart, + completeNativeSetup, + } = useCredentials(); + + const agents = useMemo( + () => toDisplayInfo(getAgentProviders(), configuredIds), + [configuredIds], + ); + + const allModels = useMemo( + () => toDisplayInfo(getModelProviders(), configuredIds), + [configuredIds], + ); + + const sortedModels = useMemo(() => { + return [...allModels].sort((a, b) => { + const connected = (p: ProviderDisplayInfo) => + p.status === "connected" || p.status === "built_in"; + if (connected(a) && !connected(b)) return -1; + if (!connected(a) && connected(b)) return 1; + return 0; + }); + }, [allModels]); + + useEffect(() => { + if (!loading && modelOrder === null) { + setModelOrder(sortedModels.map((model) => model.id)); + } + }, [loading, modelOrder, sortedModels]); + + const orderedModels = useMemo(() => { + if (!modelOrder) { + return sortedModels; + } + + const orderIndex = new Map( + modelOrder.map((modelId, index) => [modelId, index]), + ); + + return [...allModels].sort((a, b) => { + const aIndex = orderIndex.get(a.id); + const bIndex = orderIndex.get(b.id); + + if (aIndex !== undefined && bIndex !== undefined) { + return aIndex - bIndex; + } + if (aIndex !== undefined) { + return -1; + } + if (bIndex !== undefined) { + return 1; + } + return a.displayName.localeCompare(b.displayName); + }); + }, [allModels, modelOrder, sortedModels]); + + const promotedModels = orderedModels.filter( + (m) => m.tier === "promoted" || m.tier === "standard", + ); + const advancedModels = orderedModels.filter((m) => m.tier === "advanced"); + const visibleModels = showAllModels ? orderedModels : promotedModels; + + if (loading) { + return ( +
+
+ + +
+
+ + + + +
+
+ ); + } + + return ( +
+

+ {t("providers.title")} +

+

+ {t("providers.description")} +

+ + {needsRestart && ( +
+

{t("providers.restartMessage")}

+ +
+ )} + + + +
+
+

+ {t("providers.agents.title")} +

+

+ {t("providers.agents.description")} +

+
+ +
+ {agents.map((agent) => ( + + ))} +
+
+ + + +
+
+

+ {t("providers.models.title")} +

+

+ {t("providers.models.description")} +

+
+ +
+ {visibleModels.map((model) => ( + remove(model.id)} + onCompleteNativeSetup={completeNativeSetup} + saving={saving} + /> + ))} +
+ + {!showAllModels && advancedModels.length > 0 && ( + + )} + + {showAllModels && advancedModels.length > 0 && ( + + )} +
+
+ ); +} diff --git a/ui/goose2/src/features/settings/ui/SettingsModal.tsx b/ui/goose2/src/features/settings/ui/SettingsModal.tsx index 8aae2a4dd1..4cb90cd546 100644 --- a/ui/goose2/src/features/settings/ui/SettingsModal.tsx +++ b/ui/goose2/src/features/settings/ui/SettingsModal.tsx @@ -33,8 +33,10 @@ import { Stethoscope, X, } from "lucide-react"; +import { IconPlug } from "@tabler/icons-react"; import { AppearanceSettings } from "./AppearanceSettings"; import { DoctorSettings } from "./DoctorSettings"; +import { ProvidersSettings } from "./ProvidersSettings"; import { listArchivedProjects, restoreProject, @@ -49,6 +51,7 @@ import type { Session } from "@/shared/types/chat"; const NAV_ITEMS = [ { id: "appearance", labelKey: "nav.appearance", icon: Palette }, + { id: "providers", labelKey: "nav.providers", icon: IconPlug }, { id: "general", labelKey: "nav.general", icon: Settings2 }, { id: "projects", labelKey: "nav.projects", icon: FolderKanban }, { id: "chats", labelKey: "nav.chats", icon: MessageSquare }, @@ -238,6 +241,7 @@ export function SettingsModal({ onClose }: SettingsModalProps) { }} > {activeSection === "appearance" && } + {activeSection === "providers" && } {activeSection === "doctor" && } {activeSection === "general" && (
diff --git a/ui/goose2/src/features/settings/ui/modelProviderHelpers.tsx b/ui/goose2/src/features/settings/ui/modelProviderHelpers.tsx new file mode 100644 index 0000000000..d8cbe424ca --- /dev/null +++ b/ui/goose2/src/features/settings/ui/modelProviderHelpers.tsx @@ -0,0 +1,152 @@ +import type { + ProviderField, + ProviderFieldValue, + ProviderSetupMethod, +} from "@/shared/types/providers"; + +export const MAX_SETUP_OUTPUT_LINES = 8; + +export interface SetupOutputLine { + id: number; + text: string; +} + +export function getDefaultFieldValue(field: ProviderField): ProviderFieldValue { + return { + key: field.key, + value: null, + isSet: false, + isSecret: field.secret, + required: field.required, + }; +} + +export function resolveFieldValue( + field: ProviderField, + fieldValueMap: Map, +): ProviderFieldValue { + return fieldValueMap.get(field.key) ?? getDefaultFieldValue(field); +} + +export function getDisplayValue( + field: ProviderField, + fieldValueMap: Map, + t: (key: string) => string, +): string { + const fieldValue = resolveFieldValue(field, fieldValueMap); + if (!fieldValue.isSet) return t("providers.models.notSet"); + return fieldValue.value ?? t("providers.saved"); +} + +export function createDraftValues( + fields: ProviderField[], + values: ProviderFieldValue[], +): Record { + const valueMap = new Map(values.map((value) => [value.key, value])); + return Object.fromEntries( + fields.map((field) => { + const currentValue = valueMap.get(field.key); + if (field.secret) { + return [field.key, ""]; + } + return [field.key, currentValue?.value ?? ""]; + }), + ); +} + +export function getSetupMessage( + setupMethod: ProviderSetupMethod, + isConnected: boolean, + supportsNativeConnect: boolean, + t: (key: string) => string, +): string | null { + if (isConnected) { + switch (setupMethod) { + case "oauth_device_code": + return t("providers.models.setup.connected.oauthDeviceCode"); + case "oauth_browser": + return t("providers.models.setup.connected.oauthBrowser"); + case "cloud_credentials": + return t("providers.models.setup.connected.cloudCredentials"); + case "local": + return t("providers.models.setup.connected.local"); + default: + return null; + } + } + + switch (setupMethod) { + case "oauth_browser": + case "oauth_device_code": + return supportsNativeConnect + ? t("providers.models.setup.pending.oauthGuided") + : t("providers.models.setup.pending.oauthTerminal"); + case "cloud_credentials": + return t("providers.models.setup.pending.cloudCredentials"); + case "local": + return t("providers.models.setup.pending.local"); + default: + return null; + } +} + +export function getNativeConnectDescription( + setupMethod: ProviderSetupMethod, + t: (key: string) => string, +): string | null { + switch (setupMethod) { + case "oauth_device_code": + case "oauth_browser": + return t("providers.models.setup.nativeConnectDescription"); + default: + return null; + } +} + +export function getFieldSetupDescription( + setupMethod: ProviderSetupMethod, + t: (key: string) => string, +): string | null { + switch (setupMethod) { + case "single_api_key": + return t("providers.models.setup.fieldDescription.singleApiKey"); + case "config_fields": + return t("providers.models.setup.fieldDescription.configFields"); + case "host_with_oauth_fallback": + return t("providers.models.setup.fieldDescription.hostWithOauthFallback"); + case "cloud_credentials": + return t("providers.models.setup.fieldDescription.cloudCredentials"); + default: + return null; + } +} + +export function renderInlineCodeMessage(message: string) { + const command = "`goose configure`"; + if (!message.includes(command)) { + return

{message}

; + } + + const [before, after] = message.split(command); + return ( +

+ {before} + + goose configure + + {after} +

+ ); +} + +export function renderSetupMessage(message: string | null) { + if (!message) { + return null; + } + + if (message.includes("`goose configure`")) { + return renderInlineCodeMessage(message); + } + + return

{message}

; +} diff --git a/ui/goose2/src/shared/i18n/locales/en/settings.json b/ui/goose2/src/shared/i18n/locales/en/settings.json index a1579c4101..85a6648371 100644 --- a/ui/goose2/src/shared/i18n/locales/en/settings.json +++ b/ui/goose2/src/shared/i18n/locales/en/settings.json @@ -84,7 +84,8 @@ "chats": "Chats", "doctor": "Doctor", "general": "General", - "projects": "Projects" + "projects": "Projects", + "providers": "Providers" }, "projects": { "description": "Manage your projects.", @@ -92,5 +93,50 @@ "sectionTitle": "Archived Projects", "title": "Projects" }, + "providers": { + "agents": { + "description": "Agents handle your requests using their own tools and models", + "installLabel": "Install {{name}}", + "signIn": "Sign in", + "signInLabel": "Sign in to {{name}}", + "title": "Agents" + }, + "description": "Connect agents and AI models to use with Goose", + "disconnect": "Disconnect", + "models": { + "description": "AI models that power Goose. Expand a provider to review what it needs. Connect signs in with an existing account, while Set up saves API keys or other provider settings.", + "notSet": "Not set", + "setup": { + "connected": { + "cloudCredentials": "Connected through your cloud credentials.", + "local": "Running locally.", + "oauthBrowser": "Connected through Goose sign-in.", + "oauthDeviceCode": "Connected through Goose device-code sign-in." + }, + "fieldDescription": { + "cloudCredentials": "Set up saves any provider details Goose needs here. Your actual authentication still comes from your cloud credentials.", + "configFields": "Set up saves the provider details Goose needs, such as an API key, endpoint, or model settings.", + "hostWithOauthFallback": "Set up saves the host details Goose needs here. You can add a token now, or leave it blank and sign in afterward from Goose.", + "singleApiKey": "Set up saves the API key Goose needs for this provider." + }, + "hostWithOauthFallbackTerminal": "Leave Access Token blank, save your host URL, then run `goose configure` in your terminal to sign in.", + "nativeConnectDescription": "Sign in with your existing account.", + "pending": { + "cloudCredentials": "Configure your cloud credentials in your terminal environment before using this provider.", + "local": "This provider runs locally and does not need saved settings here.", + "oauthGuided": "Goose will guide you through sign-in from here.", + "oauthTerminal": "Run `goose configure` in your terminal to finish sign-in." + } + }, + "title": "Models" + }, + "restartButton": "Restart now", + "restartMessage": "Restart to apply credential changes.", + "saved": "Saved", + "showFewer": "Show fewer", + "showMore": "Show {{count}} more providers", + "title": "Providers", + "waitingForSignIn": "Waiting for sign-in..." + }, "title": "Settings" } diff --git a/ui/goose2/src/shared/i18n/locales/es/settings.json b/ui/goose2/src/shared/i18n/locales/es/settings.json index d5f1bafb4b..8d325d6b68 100644 --- a/ui/goose2/src/shared/i18n/locales/es/settings.json +++ b/ui/goose2/src/shared/i18n/locales/es/settings.json @@ -84,7 +84,8 @@ "chats": "Chats", "doctor": "Diagnóstico", "general": "General", - "projects": "Proyectos" + "projects": "Proyectos", + "providers": "Proveedores" }, "projects": { "description": "Administra tus proyectos.", @@ -92,5 +93,50 @@ "sectionTitle": "Proyectos archivados", "title": "Proyectos" }, + "providers": { + "agents": { + "description": "Los agentes manejan tus solicitudes usando sus propias herramientas y modelos", + "installLabel": "Instalar {{name}}", + "signIn": "Iniciar sesión", + "signInLabel": "Iniciar sesión en {{name}}", + "title": "Agentes" + }, + "description": "Conecta agentes y modelos de IA para usar con Goose", + "disconnect": "Desconectar", + "models": { + "description": "Modelos de IA que alimentan a Goose. Expande un proveedor para revisar lo que necesita. Conectar inicia sesión con una cuenta existente, mientras que Configurar guarda claves API u otros ajustes del proveedor.", + "notSet": "No configurado", + "setup": { + "connected": { + "cloudCredentials": "Conectado mediante tus credenciales de nube.", + "local": "Ejecutándose localmente.", + "oauthBrowser": "Conectado mediante el inicio de sesión de Goose.", + "oauthDeviceCode": "Conectado mediante el inicio de sesión por código de dispositivo de Goose." + }, + "fieldDescription": { + "cloudCredentials": "La configuración guarda aquí cualquier detalle del proveedor que Goose necesite. Tu autenticación real sigue viniendo de tus credenciales de nube.", + "configFields": "La configuración guarda los detalles del proveedor que Goose necesita, como una clave API, un endpoint o ajustes del modelo.", + "hostWithOauthFallback": "La configuración guarda aquí los detalles del host que Goose necesita. Puedes añadir un token ahora o dejarlo en blanco e iniciar sesión después desde Goose.", + "singleApiKey": "La configuración guarda la clave API que Goose necesita para este proveedor." + }, + "hostWithOauthFallbackTerminal": "Deja en blanco el token de acceso, guarda la URL del host y luego ejecuta `goose configure` en tu terminal para iniciar sesión.", + "nativeConnectDescription": "Inicia sesión con tu cuenta existente.", + "pending": { + "cloudCredentials": "Configura tus credenciales de nube en el entorno de tu terminal antes de usar este proveedor.", + "local": "Este proveedor se ejecuta localmente y no necesita ajustes guardados aquí.", + "oauthGuided": "Goose te guiará por el inicio de sesión desde aquí.", + "oauthTerminal": "Ejecuta `goose configure` en tu terminal para terminar de iniciar sesión." + } + }, + "title": "Modelos" + }, + "restartButton": "Reiniciar ahora", + "restartMessage": "Reinicia para aplicar los cambios de credenciales.", + "saved": "Guardado", + "showFewer": "Mostrar menos", + "showMore": "Mostrar {{count}} proveedores más", + "title": "Proveedores", + "waitingForSignIn": "Esperando inicio de sesión..." + }, "title": "Configuración" }