mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
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.
This commit is contained in:
@@ -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<SetupPhase>("idle");
|
||||
const [setupOutput, setSetupOutput] = useState<OutputLine[]>([]);
|
||||
const [setupError, setSetupError] = useState<string | null>(null);
|
||||
const [isInstalled, setIsInstalled] = useState<boolean | null>(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||
const outputRef = useRef<HTMLDivElement>(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 (
|
||||
<div className="flex h-6 flex-shrink-0 items-center">
|
||||
<IconCheck className="size-4 text-success" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (setupError) {
|
||||
return (
|
||||
<div className="flex h-6 flex-shrink-0 items-center">
|
||||
<IconAlertTriangle className="size-4 text-danger" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (needsAuth && !isActive) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => void handleConnect()}
|
||||
className="flex-shrink-0 text-muted-foreground"
|
||||
aria-label={t("providers.agents.signInLabel", {
|
||||
name: provider.displayName,
|
||||
})}
|
||||
>
|
||||
{t("providers.agents.signIn")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (needsInstall && !isActive) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => void handleConnect()}
|
||||
disabled={isInstalled === null && hasBinary}
|
||||
className="flex-shrink-0 text-muted-foreground"
|
||||
aria-label={t("providers.agents.installLabel", {
|
||||
name: provider.displayName,
|
||||
})}
|
||||
>
|
||||
<IconPlus className="size-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Button type="button" variant="outline" size="xs" onClick={handleRetry}>
|
||||
{t("common:actions.retry")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mt-3 space-y-2 border-t pt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner className="size-3.5 text-accent" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-xs font-medium">{phaseLabel}</span>
|
||||
{stepInfo && (
|
||||
<span className="ml-2 text-xxs text-muted-foreground">
|
||||
{stepInfo}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{setupOutput.length > 0 && (
|
||||
<div
|
||||
ref={outputRef}
|
||||
className="max-h-24 overflow-y-auto rounded-md bg-muted px-2 py-1.5 font-mono text-xxs leading-relaxed text-muted-foreground"
|
||||
>
|
||||
{setupOutput.map((entry) => (
|
||||
<div key={entry.id}>{entry.text || "\u00A0"}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col rounded-lg border bg-background p-3 transition-colors",
|
||||
isActive && "border-accent/50",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex size-6 items-center justify-center [&>*]:size-6">
|
||||
{icon}
|
||||
</div>
|
||||
<span className="mt-2 block text-sm">{provider.displayName}</span>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{provider.description}
|
||||
</p>
|
||||
</div>
|
||||
{renderStatusIndicator()}
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const statusText = renderStatusText();
|
||||
const action = renderAction();
|
||||
if (!statusText && !action) return null;
|
||||
return (
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{statusText && (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
setupError
|
||||
? "bg-danger"
|
||||
: isActive
|
||||
? "bg-accent animate-pulse"
|
||||
: "bg-muted-foreground/40",
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{statusText}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{renderSetupProgress()}
|
||||
|
||||
{setupError && !isActive && (
|
||||
<div className="mt-3 space-y-2 border-t pt-3">
|
||||
<p className="text-xs text-danger">{setupError}</p>
|
||||
{setupOutput.length > 0 && (
|
||||
<div className="max-h-24 overflow-y-auto rounded-md bg-muted px-2 py-1.5 font-mono text-xxs leading-relaxed text-muted-foreground">
|
||||
{setupOutput.map((entry) => (
|
||||
<div key={entry.id}>{entry.text || "\u00A0"}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement | null>;
|
||||
fields: ProviderField[];
|
||||
fieldValueMap: Map<string, ProviderFieldValue>;
|
||||
editingKey: string | null;
|
||||
draftValues: Record<string, string>;
|
||||
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 (
|
||||
<div
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
className="focus-override mx-3 space-y-3 rounded-b-lg border-x border-b px-3 py-3 outline-none"
|
||||
>
|
||||
{fields.map((field) => {
|
||||
const isEditing = editingKey === field.key;
|
||||
return (
|
||||
<div
|
||||
key={field.key}
|
||||
className="space-y-2 rounded-md border border-border px-3 py-2.5"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm">{field.label}</p>
|
||||
{!isEditing && (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{getDisplayValue(field, fieldValueMap, t)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => onStartEdit(field.key)}
|
||||
disabled={saving}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
{resolveFieldValue(field, fieldValueMap).isSet
|
||||
? "Edit"
|
||||
: "Add"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type={field.secret ? "password" : "text"}
|
||||
value={draftValues[field.key] ?? ""}
|
||||
placeholder={
|
||||
field.secret &&
|
||||
resolveFieldValue(field, fieldValueMap).isSet
|
||||
? getDisplayValue(field, fieldValueMap, t)
|
||||
: field.placeholder
|
||||
}
|
||||
onChange={(event) =>
|
||||
onDraftChange(field.key, event.target.value)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
onSaveField(field);
|
||||
}
|
||||
}}
|
||||
disabled={saving}
|
||||
className="h-8 flex-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => onSaveField(field)}
|
||||
disabled={saving || !(draftValues[field.key]?.trim() ?? "")}
|
||||
className="h-8"
|
||||
>
|
||||
{saving ? (
|
||||
<IconLoader2 className="size-3 animate-spin" />
|
||||
) : null}
|
||||
{t("common:actions.save")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onCancelEdit(field)}
|
||||
disabled={saving}
|
||||
className="h-8"
|
||||
>
|
||||
{t("common:actions.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{showSavedState ? (
|
||||
<Button type="button" variant="secondary" size="sm" disabled>
|
||||
{t("providers.saved")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onRemove()}
|
||||
disabled={saving}
|
||||
className="text-danger hover:text-danger"
|
||||
>
|
||||
{saving ? <IconLoader2 className="size-3 animate-spin" /> : null}
|
||||
{t("providers.disconnect")}
|
||||
</Button>
|
||||
</div>
|
||||
{renderSetupMessage(setupMessage)}
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SetupFieldsPanelProps {
|
||||
panelRef: RefObject<HTMLDivElement | null>;
|
||||
fields: ProviderField[];
|
||||
fieldValueMap: Map<string, ProviderFieldValue>;
|
||||
draftValues: Record<string, string>;
|
||||
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 (
|
||||
<div
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
className="focus-override mx-3 space-y-3 rounded-b-lg border-x border-b px-3 py-3 outline-none"
|
||||
>
|
||||
{!isConnected && fieldSetupDescription ? (
|
||||
<p className="text-xs text-muted-foreground">{fieldSetupDescription}</p>
|
||||
) : null}
|
||||
{fields.map((field) => {
|
||||
const fieldValue = resolveFieldValue(field, fieldValueMap);
|
||||
return (
|
||||
<div key={field.key} className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{field.label}
|
||||
</span>
|
||||
{field.required && (
|
||||
<span className="text-xxs text-muted-foreground">
|
||||
{t("common:labels.required")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
type={field.secret ? "password" : "text"}
|
||||
value={draftValues[field.key] ?? ""}
|
||||
placeholder={
|
||||
field.secret && fieldValue.isSet
|
||||
? getDisplayValue(field, fieldValueMap, t)
|
||||
: field.placeholder
|
||||
}
|
||||
onChange={(event) => onDraftChange(field.key, event.target.value)}
|
||||
disabled={saving}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<AsyncButton
|
||||
type="button"
|
||||
state={saving ? "pending" : showSavedState ? "success" : "idle"}
|
||||
idleLabel="Save"
|
||||
pendingLabel="Saving..."
|
||||
successLabel="Saved"
|
||||
pendingVisual="text"
|
||||
pendingDelayMs={250}
|
||||
size="sm"
|
||||
onClick={() => onSaveSetup()}
|
||||
disabled={saving || showSavedState}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
{setupMethod === "host_with_oauth_fallback"
|
||||
? renderInlineCodeMessage(
|
||||
t("providers.models.setup.hostWithOauthFallbackTerminal"),
|
||||
)
|
||||
: null}
|
||||
{setupMethod === "cloud_credentials" && setupMessage
|
||||
? renderSetupMessage(setupMessage)
|
||||
: null}
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ProviderFieldValue[]>;
|
||||
onSaveField: (key: string, value: string, isSecret: boolean) => Promise<void>;
|
||||
onRemoveConfig?: () => Promise<void>;
|
||||
onCompleteNativeSetup: () => Promise<void>;
|
||||
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<ProviderFieldValue[]>([]);
|
||||
const [draftValues, setDraftValues] = useState<Record<string, string>>({});
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [loadingConfig, setLoadingConfig] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [authenticating, setAuthenticating] = useState(false);
|
||||
const [setupOutput, setSetupOutput] = useState<SetupOutputLine[]>([]);
|
||||
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<HTMLDivElement | null>(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 (
|
||||
<div
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
className="focus-override mx-3 space-y-3 rounded-b-lg border-x border-b px-3 py-3 outline-none"
|
||||
>
|
||||
<Skeleton className="h-12 w-full rounded-md" />
|
||||
<Skeleton className="h-12 w-full rounded-md" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (supportsNativeConnect && !hasFields) {
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
className="focus-override mx-3 space-y-3 rounded-b-lg border-x border-b px-3 py-3 outline-none"
|
||||
>
|
||||
{!isConnected && nativeConnectDescription ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{nativeConnectDescription}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => void runNativeConnect()}
|
||||
disabled={authenticating}
|
||||
className="shrink-0"
|
||||
>
|
||||
{authenticating ? (
|
||||
<Spinner className="size-3.5 text-current" />
|
||||
) : null}
|
||||
{setupError ? "Retry" : "Connect"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
renderSetupMessage(setupMessage)
|
||||
)}
|
||||
{authenticating ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Spinner className="size-3.5 text-accent" />
|
||||
<span>{t("providers.waitingForSignIn")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{setupOutput.length > 0 ? (
|
||||
<div className="space-y-1 rounded-md bg-muted px-3 py-2 font-mono text-xxs text-muted-foreground">
|
||||
{setupOutput.map((line) => (
|
||||
<p key={line.id}>{line.text}</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{setupError ? (
|
||||
<p className="text-xs text-danger">{setupError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasFields && isConnected && !preserveSetupLayout) {
|
||||
return (
|
||||
<ConnectedFieldsPanel
|
||||
panelRef={panelRef}
|
||||
fields={fields}
|
||||
fieldValueMap={fieldValueMap}
|
||||
editingKey={editingKey}
|
||||
draftValues={draftValues}
|
||||
saving={saving}
|
||||
showSavedState={showSavedState}
|
||||
error={error}
|
||||
setupMessage={setupMessage}
|
||||
onStartEdit={handleStartEdit}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onDraftChange={handleDraftChange}
|
||||
onSaveField={(field) => void handleSaveField(field)}
|
||||
onRemove={() => void handleRemove()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasFields) {
|
||||
return (
|
||||
<SetupFieldsPanel
|
||||
panelRef={panelRef}
|
||||
fields={fields}
|
||||
fieldValueMap={fieldValueMap}
|
||||
draftValues={draftValues}
|
||||
saving={saving}
|
||||
showSavedState={showSavedState}
|
||||
error={error}
|
||||
setupMethod={provider.setupMethod}
|
||||
setupMessage={setupMessage}
|
||||
fieldSetupDescription={fieldSetupDescription}
|
||||
isConnected={isConnected}
|
||||
onDraftChange={handleDraftChange}
|
||||
onSaveSetup={() => void handleSaveSetup()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
className="focus-override mx-3 space-y-2 rounded-b-lg border-x border-b px-3 py-3 outline-none"
|
||||
>
|
||||
{renderSetupMessage(setupMessage)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
aria-expanded={expanded}
|
||||
disabled={authenticating}
|
||||
className="flex w-full items-center gap-3 rounded-lg border border-border px-3 py-2.5 text-left transition-colors hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:hover:bg-transparent"
|
||||
>
|
||||
<div className="flex size-6 flex-shrink-0 items-center justify-center">
|
||||
{icon || (
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{formatProviderLabel(provider.id).charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="min-w-0 flex-1 text-sm">{provider.displayName}</span>
|
||||
|
||||
{isConnected ? (
|
||||
<IconCheck className="size-4 flex-shrink-0 text-success" />
|
||||
) : null}
|
||||
{!isConnected && authenticating ? (
|
||||
<Spinner className="size-3.5 flex-shrink-0 text-accent" />
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{renderExpandedContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string>,
|
||||
): 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<string>,
|
||||
): 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<string[] | null>(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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="mt-2 h-4 w-64" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Skeleton className="h-28" />
|
||||
<Skeleton className="h-28" />
|
||||
<Skeleton className="h-28" />
|
||||
<Skeleton className="h-28" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold font-display tracking-tight">
|
||||
{t("providers.title")}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("providers.description")}
|
||||
</p>
|
||||
|
||||
{needsRestart && (
|
||||
<div className="mt-3 flex items-center gap-3 rounded-lg border border-accent bg-background-accent/30 px-3 py-2.5">
|
||||
<p className="flex-1 text-sm">{t("providers.restartMessage")}</p>
|
||||
<Button type="button" size="sm" onClick={() => void restart()}>
|
||||
<IconRefresh className="size-3.5" />
|
||||
{t("providers.restartButton")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<section>
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("providers.agents.title")}
|
||||
</h4>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("providers.agents.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{agents.map((agent) => (
|
||||
<AgentProviderCard key={agent.id} provider={agent} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator className="my-6" />
|
||||
|
||||
<section>
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("providers.models.title")}
|
||||
</h4>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("providers.models.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{visibleModels.map((model) => (
|
||||
<ModelProviderRow
|
||||
key={model.id}
|
||||
provider={model}
|
||||
onGetConfig={getConfig}
|
||||
onSaveField={save}
|
||||
onRemoveConfig={() => remove(model.id)}
|
||||
onCompleteNativeSetup={completeNativeSetup}
|
||||
saving={saving}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!showAllModels && advancedModels.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAllModels(true)}
|
||||
className="mt-2 w-full text-muted-foreground"
|
||||
>
|
||||
{t("providers.showMore", { count: advancedModels.length })}
|
||||
<IconChevronDown className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showAllModels && advancedModels.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAllModels(false)}
|
||||
className="mt-2 w-full text-muted-foreground"
|
||||
>
|
||||
{t("providers.showFewer")}
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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" && <AppearanceSettings />}
|
||||
{activeSection === "providers" && <ProvidersSettings />}
|
||||
{activeSection === "doctor" && <DoctorSettings />}
|
||||
{activeSection === "general" && (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -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<string, ProviderFieldValue>,
|
||||
): ProviderFieldValue {
|
||||
return fieldValueMap.get(field.key) ?? getDefaultFieldValue(field);
|
||||
}
|
||||
|
||||
export function getDisplayValue(
|
||||
field: ProviderField,
|
||||
fieldValueMap: Map<string, ProviderFieldValue>,
|
||||
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<string, string> {
|
||||
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 <p className="text-xs text-muted-foreground">{message}</p>;
|
||||
}
|
||||
|
||||
const [before, after] = message.split(command);
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{before}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-xxs">
|
||||
goose configure
|
||||
</code>
|
||||
{after}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderSetupMessage(message: string | null) {
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (message.includes("`goose configure`")) {
|
||||
return renderInlineCodeMessage(message);
|
||||
}
|
||||
|
||||
return <p className="text-xs text-muted-foreground">{message}</p>;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user