diff --git a/src/renderer/components/settings/forms/ApiKeyForm.tsx b/src/renderer/components/settings/forms/ApiKeyForm.tsx new file mode 100644 index 00000000..49521304 --- /dev/null +++ b/src/renderer/components/settings/forms/ApiKeyForm.tsx @@ -0,0 +1,93 @@ +import { useState } from "react"; + +const desktop = (typeof window !== "undefined" ? window.agentCompany : null) as + | (Window["agentCompany"] | null); + +interface Props { + connectorId: string; + defaultEnvVar?: string; + onSaved?: () => void; +} + +export function ApiKeyForm({ connectorId, defaultEnvVar, onSaved }: Props) { + const [source, setSource] = useState<"literal" | "env">("literal"); + const [value, setValue] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const isEnv = source === "env"; + const canSave = value.trim().length > 0 && !busy && Boolean(desktop); + + const handleSubmit = async () => { + if (!desktop || !canSave) return; + setBusy(true); + setError(null); + const result = await desktop.saveCredential({ + connectorId, + payload: { + kind: "api_key", + value: { source, value: value.trim() }, + }, + }); + setBusy(false); + if (!result.ok) { + setError(result.error.message); + return; + } + setValue(""); + onSaved?.(); + }; + + const toggleSource = () => { + setSource(isEnv ? "literal" : "env"); + setValue(""); + setError(null); + }; + + return ( +
{ + event.preventDefault(); + void handleSubmit(); + }} + className="space-y-3" + > + setValue(event.target.value)} + placeholder={isEnv ? (defaultEnvVar ?? "PROVIDER_API_KEY") : "Paste your API key"} + autoComplete="off" + spellCheck={false} + disabled={busy} + autoFocus + aria-label={isEnv ? "Environment variable name" : "API key"} + className="block w-full rounded-md border border-border bg-background px-3 py-2.5 font-mono text-[12px] tracking-tight text-foreground outline-none transition-colors placeholder:font-sans placeholder:tracking-normal placeholder:text-muted-foreground/50 focus:border-foreground/50 disabled:opacity-50" + /> + + {error ? ( +
+ {error} +
+ ) : null} + +
+ + +
+
+ ); +} diff --git a/src/renderer/components/settings/forms/AwsForm.tsx b/src/renderer/components/settings/forms/AwsForm.tsx new file mode 100644 index 00000000..cba2c7be --- /dev/null +++ b/src/renderer/components/settings/forms/AwsForm.tsx @@ -0,0 +1,225 @@ +import { useState } from "react"; +import { + FIELD_INPUT, + FIELD_LABEL, + FIELD_SELECT, + FORM_BUTTON, + FORM_ERROR, + FORM_FIELDSET, +} from "./form-shared"; + +interface Props { + connectorId: string; + onSaved?: () => void; +} + +type AuthMode = "profile" | "iam" | "bearer"; + +export function AwsForm({ connectorId, onSaved }: Props) { + const [authMode, setAuthMode] = useState("profile"); + const [region, setRegion] = useState("us-east-1"); + const [awsProfile, setAwsProfile] = useState(""); + const [accessKeyId, setAccessKeyId] = useState(""); + const [secretAccessKey, setSecretAccessKey] = useState(""); + const [sessionToken, setSessionToken] = useState(""); + const [bearerToken, setBearerToken] = useState(""); + const [forceCache, setForceCache] = useState(false); + const [endpointUrl, setEndpointUrl] = useState(""); + const [skipAuth, setSkipAuth] = useState(false); + const [forceHttp1, setForceHttp1] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async () => { + const desktop = window.agentCompany; + if (!desktop) return; + if (authMode === "profile" && awsProfile.trim().length === 0) { + setError("AWS profile name required."); + return; + } + if ( + authMode === "iam" && + (accessKeyId.trim().length === 0 || secretAccessKey.trim().length === 0) + ) { + setError("Access key id and secret access key required for IAM auth."); + return; + } + if (authMode === "bearer" && bearerToken.trim().length === 0) { + setError("Bearer token required."); + return; + } + setBusy(true); + setError(null); + const result = await desktop.saveCredential({ + connectorId, + payload: { + kind: "aws", + value: { + authMode, + region: region.trim() || "us-east-1", + awsProfile: authMode === "profile" ? awsProfile.trim() : undefined, + accessKeyId: authMode === "iam" ? accessKeyId.trim() : undefined, + secretAccessKey: authMode === "iam" ? secretAccessKey.trim() : undefined, + sessionToken: + authMode === "iam" && sessionToken.trim() ? sessionToken.trim() : undefined, + bearerToken: authMode === "bearer" ? bearerToken.trim() : undefined, + forceCache, + endpointUrl: endpointUrl.trim() || undefined, + skipAuth, + forceHttp1, + }, + }, + }); + setBusy(false); + if (!result.ok) { + setError(result.error.message); + return; + } + onSaved?.(); + }; + + return ( +
{ + event.preventDefault(); + void handleSubmit(); + }} + className={FORM_FIELDSET} + > +
+ + +
+
+ + setRegion(event.target.value)} + disabled={busy} + className={FIELD_INPUT} + placeholder="us-east-1" + /> +
+ {authMode === "profile" ? ( +
+ + setAwsProfile(event.target.value)} + disabled={busy} + className={FIELD_INPUT} + placeholder="default" + /> +
+ ) : null} + {authMode === "iam" ? ( + <> +
+ + setAccessKeyId(event.target.value)} + disabled={busy} + className={FIELD_INPUT} + autoComplete="off" + /> +
+
+ + setSecretAccessKey(event.target.value)} + disabled={busy} + className={FIELD_INPUT} + autoComplete="off" + /> +
+
+ + setSessionToken(event.target.value)} + disabled={busy} + className={FIELD_INPUT} + autoComplete="off" + /> +
+ + ) : null} + {authMode === "bearer" ? ( +
+ + setBearerToken(event.target.value)} + disabled={busy} + className={FIELD_INPUT} + autoComplete="off" + /> +
+ ) : null} +
+ Advanced +
+ +
+ + setEndpointUrl(event.target.value)} + disabled={busy} + className={FIELD_INPUT} + placeholder="https://bedrock-runtime.your-region.amazonaws.com" + /> +
+ + +
+
+ {error ?
{error}
: null} + +
+ ); +} diff --git a/src/renderer/components/settings/forms/AzureForm.tsx b/src/renderer/components/settings/forms/AzureForm.tsx new file mode 100644 index 00000000..5fc27624 --- /dev/null +++ b/src/renderer/components/settings/forms/AzureForm.tsx @@ -0,0 +1,181 @@ +import { useState } from "react"; +import { + FIELD_INPUT, + FIELD_LABEL, + FORM_BUTTON, + FORM_BUTTON_SECONDARY, + FORM_ERROR, + FORM_FIELDSET, +} from "./form-shared"; + +interface Props { + connectorId: string; + onSaved?: () => void; +} + +interface DeploymentEntry { + modelId: string; + deploymentName: string; +} + +export function AzureForm({ connectorId, onSaved }: Props) { + const [apiKey, setApiKey] = useState(""); + const [baseUrl, setBaseUrl] = useState(""); + const [resourceName, setResourceName] = useState(""); + const [apiVersion, setApiVersion] = useState(""); + const [deployments, setDeployments] = useState([ + { modelId: "", deploymentName: "" }, + ]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async () => { + const desktop = window.agentCompany; + if (!desktop) return; + if (apiKey.trim().length === 0) { + setError("API key required."); + return; + } + setBusy(true); + setError(null); + const deploymentMap = Object.fromEntries( + deployments + .map((entry) => [entry.modelId.trim(), entry.deploymentName.trim()]) + .filter(([k, v]) => k.length > 0 && v.length > 0), + ); + const result = await desktop.saveCredential({ + connectorId, + payload: { + kind: "azure", + value: { + apiKey: apiKey.trim(), + baseUrl: baseUrl.trim() || undefined, + resourceName: resourceName.trim() || undefined, + apiVersion: apiVersion.trim() || undefined, + deploymentMap: Object.keys(deploymentMap).length > 0 ? deploymentMap : undefined, + }, + }, + }); + setBusy(false); + if (!result.ok) { + setError(result.error.message); + return; + } + onSaved?.(); + }; + + return ( +
{ + event.preventDefault(); + void handleSubmit(); + }} + className={FORM_FIELDSET} + > +
+ + setApiKey(event.target.value)} + disabled={busy} + className={FIELD_INPUT} + autoComplete="off" + /> +
+
+ + setBaseUrl(event.target.value)} + disabled={busy} + className={FIELD_INPUT} + placeholder="https://YOUR-RESOURCE.openai.azure.com/" + /> +
+
+ + setResourceName(event.target.value)} + disabled={busy} + className={FIELD_INPUT} + /> +
+
+ + setApiVersion(event.target.value)} + disabled={busy} + className={FIELD_INPUT} + placeholder="2024-08-01-preview" + /> +
+
+ + Deployment map (model id → deployment name) + +
+ {deployments.map((entry, index) => ( +
+ + setDeployments((prev) => + prev.map((p, i) => (i === index ? { ...p, modelId: event.target.value } : p)), + ) + } + /> + + setDeployments((prev) => + prev.map((p, i) => + i === index ? { ...p, deploymentName: event.target.value } : p, + ), + ) + } + /> + +
+ ))} + +
+
+ {error ?
{error}
: null} + +
+ ); +} diff --git a/src/renderer/components/settings/forms/VertexForm.tsx b/src/renderer/components/settings/forms/VertexForm.tsx new file mode 100644 index 00000000..d26d5e93 --- /dev/null +++ b/src/renderer/components/settings/forms/VertexForm.tsx @@ -0,0 +1,115 @@ +import { useState } from "react"; +import { + FIELD_INPUT, + FIELD_LABEL, + FORM_BUTTON, + FORM_BUTTON_SECONDARY, + FORM_ERROR, + FORM_FIELDSET, +} from "./form-shared"; + +interface Props { + connectorId: string; + onSaved?: () => void; +} + +export function VertexForm({ connectorId, onSaved }: Props) { + const [project, setProject] = useState(""); + const [location, setLocation] = useState(""); + const [credentialsPath, setCredentialsPath] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async () => { + const desktop = window.agentCompany; + if (!desktop) return; + if (project.trim().length === 0 || location.trim().length === 0) { + setError("Project and location are required."); + return; + } + setBusy(true); + setError(null); + const result = await desktop.saveCredential({ + connectorId, + payload: { + kind: "gcp_adc", + value: { + project: project.trim(), + location: location.trim(), + credentialsPath: credentialsPath.trim() || undefined, + }, + }, + }); + setBusy(false); + if (!result.ok) { + setError(result.error.message); + return; + } + onSaved?.(); + }; + + const pickCredentialsFile = async () => { + const desktop = window.agentCompany; + if (!desktop) return; + const result = await desktop.pickDirectory(); + if (result.ok && result.data) setCredentialsPath(result.data); + }; + + return ( +
{ + event.preventDefault(); + void handleSubmit(); + }} + className={FORM_FIELDSET} + > +
+ + setProject(event.target.value)} + disabled={busy} + className={FIELD_INPUT} + placeholder="my-gcp-project" + /> +
+
+ + setLocation(event.target.value)} + disabled={busy} + className={FIELD_INPUT} + placeholder="us-central1" + /> +
+
+ +
+ setCredentialsPath(event.target.value)} + disabled={busy} + className={FIELD_INPUT} + placeholder="/path/to/service-account.json" + /> + +
+
+ {error ?
{error}
: null} + +
+ ); +} diff --git a/src/renderer/components/settings/forms/form-shared.ts b/src/renderer/components/settings/forms/form-shared.ts new file mode 100644 index 00000000..34249e65 --- /dev/null +++ b/src/renderer/components/settings/forms/form-shared.ts @@ -0,0 +1,18 @@ +export const FIELD_LABEL = + "block text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground"; + +export const FIELD_INPUT = + "mt-1 block w-full rounded-md border border-border bg-background px-3 py-2 text-[13px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/60 focus:border-foreground/40"; + +export const FIELD_SELECT = FIELD_INPUT + " appearance-none"; + +export const FORM_BUTTON = + "rounded px-3 py-1.5 text-[11px] font-medium bg-foreground text-background transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-30"; + +export const FORM_BUTTON_SECONDARY = + "rounded px-2.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"; + +export const FORM_FIELDSET = "space-y-3"; + +export const FORM_ERROR = + "rounded-md border border-[color:var(--destructive)] bg-destructive-muted px-3 py-2 text-[11px] text-destructive"; diff --git a/src/renderer/components/settings/providers/ProviderRow.tsx b/src/renderer/components/settings/providers/ProviderRow.tsx new file mode 100644 index 00000000..b33bdd9b --- /dev/null +++ b/src/renderer/components/settings/providers/ProviderRow.tsx @@ -0,0 +1,182 @@ +import { useState } from "react"; +import type { CredentialSummary } from "@shared/contracts/schemas-credentials"; +import type { ConnectorCatalogEntry } from "@shared/connector-catalog"; +import { ApiKeyForm } from "../forms/ApiKeyForm"; +import { AzureForm } from "../forms/AzureForm"; +import { AwsForm } from "../forms/AwsForm"; +import { VertexForm } from "../forms/VertexForm"; + +interface Props { + entry: ConnectorCatalogEntry; + credential: CredentialSummary | null; +} + +const KIND_LABEL: Record = { + oauth: "OAuth", + api_key: "API key", + azure: "Azure", + aws: "AWS", + gcp_adc: "Vertex (ADC)", +}; + +const STATUS_STYLE: Record = { + active: { dot: "bg-emerald-400", text: "text-emerald-400" }, + expired: { dot: "bg-amber-400", text: "text-amber-400" }, + error: { dot: "bg-red-400", text: "text-red-400" }, + not_configured: { dot: "bg-muted-foreground/30", text: "text-muted-foreground" }, + revoked: { dot: "bg-muted-foreground/30", text: "text-muted-foreground" }, +}; + +const STATUS_LABEL: Record = { + active: "Connected", + expired: "Expired", + error: "Error", + not_configured: "Not configured", + revoked: "Revoked", +}; + +const ACTION_BASE = + "rounded px-2.5 py-1 text-[11px] font-medium transition-colors disabled:opacity-40"; +const ACTION_PRIMARY = ACTION_BASE + " bg-foreground text-background hover:opacity-90"; +const ACTION_SECONDARY = ACTION_BASE + " text-muted-foreground hover:text-foreground"; + +export function ProviderRow({ entry, credential }: Props) { + const [busy, setBusy] = useState<"login" | "remove" | "test" | null>(null); + const [feedback, setFeedback] = useState(null); + const [editing, setEditing] = useState(false); + const status = credential?.status ?? "not_configured"; + const statusStyle = STATUS_STYLE[status] ?? STATUS_STYLE.not_configured; + const statusLabel = STATUS_LABEL[status] ?? status; + const hasCredential = credential !== null; + + const handleOAuthLogin = async () => { + const desktop = window.agentCompany; + if (!desktop) return; + setBusy("login"); + setFeedback(null); + const result = await desktop.startOAuth({ connectorId: entry.id }); + setBusy(null); + if (!result.ok) setFeedback(result.error.message); + }; + + const handleRemove = async () => { + const desktop = window.agentCompany; + if (!desktop) return; + setBusy("remove"); + const result = await desktop.removeCredential({ connectorId: entry.id }); + setBusy(null); + if (!result.ok) setFeedback(result.error.message); + }; + + const handleTest = async () => { + const desktop = window.agentCompany; + if (!desktop) return; + setBusy("test"); + setFeedback(null); + const result = await desktop.testCredential({ connectorId: entry.id }); + setBusy(null); + if (!result.ok) { + setFeedback(result.error.message); + return; + } + setFeedback( + result.data.ok ? `OK — ${result.data.message}` : `Failed — ${result.data.message}`, + ); + }; + + const renderForm = () => { + if (!editing || entry.auth.type === "oauth") return null; + const onSaved = () => { + setEditing(false); + setFeedback("Saved."); + }; + const apiKeyEnv = entry.auth.type === "api_key" ? entry.auth.envKey : undefined; + if (entry.auth.type === "api_key") + return ; + if (entry.auth.type === "azure") + return ; + if (entry.auth.type === "aws") + return ; + if (entry.auth.type === "gcp_adc") + return ; + return null; + }; + + const disabled = busy !== null; + const isOAuth = entry.auth.type === "oauth"; + + return ( +
  • +
    + +
    +
    +
    + {entry.label} +
    +
    + {KIND_LABEL[entry.auth.type]} +
    +
    +
    + {hasCredential ? (credential.displayHint ?? statusLabel) : statusLabel} +
    +
    +
    + {isOAuth ? ( + + ) : ( + + )} + {hasCredential ? ( + + ) : null} + {hasCredential ? ( + + ) : null} +
    +
    + + {feedback ? ( +
    {feedback}
    + ) : null} + + {editing && !isOAuth ? ( +
    + {renderForm()} +
    + ) : null} +
  • + ); +} diff --git a/src/renderer/components/settings/providers/ProvidersPanel.tsx b/src/renderer/components/settings/providers/ProvidersPanel.tsx index d573c37d..95132093 100644 --- a/src/renderer/components/settings/providers/ProvidersPanel.tsx +++ b/src/renderer/components/settings/providers/ProvidersPanel.tsx @@ -1,56 +1,20 @@ import { useEffect, useMemo, useState } from "react"; import { MagnifyingGlass } from "../../ui/icons"; import type { CredentialSummary } from "@shared/contracts/schemas-credentials"; +import { + CONNECTOR_CATALOG, + type ConnectorAuthKind, + type ConnectorCatalogEntry, +} from "@shared/connector-catalog"; import { ProviderRow } from "./ProviderRow"; -const desktop = (typeof window !== "undefined" ? window.agentCompany : null) as - | (Window["agentCompany"] | null); - -/** Provider catalog mirrored from the main process for renderer-only use. - * Keep in sync with src/main/connector-definitions.ts when the catalog grows. - * Each entry is referenced by its connectorId in IPC traffic. */ -export interface ProvidersPanelEntry { - id: string; - label: string; - kind: "oauth" | "api_key" | "azure" | "aws" | "gcp_adc"; -} - -const DEFAULT_CATALOG: ProvidersPanelEntry[] = [ - { id: "claude-pro", label: "Claude Pro / Max", kind: "oauth" }, - { id: "chatgpt-codex", label: "ChatGPT Plus / Pro (Codex)", kind: "oauth" }, - { id: "github-copilot", label: "GitHub Copilot", kind: "oauth" }, - { id: "gemini-cli", label: "Google Gemini CLI", kind: "oauth" }, - { id: "antigravity", label: "Google Antigravity", kind: "oauth" }, - { id: "anthropic", label: "Anthropic API", kind: "api_key" }, - { id: "openai", label: "OpenAI API", kind: "api_key" }, - { id: "google", label: "Google Gemini API", kind: "api_key" }, - { id: "mistral", label: "Mistral", kind: "api_key" }, - { id: "groq", label: "Groq", kind: "api_key" }, - { id: "cerebras", label: "Cerebras", kind: "api_key" }, - { id: "xai", label: "xAI", kind: "api_key" }, - { id: "openrouter", label: "OpenRouter", kind: "api_key" }, - { id: "vercel-ai-gateway", label: "Vercel AI Gateway", kind: "api_key" }, - { id: "zai", label: "ZAI", kind: "api_key" }, - { id: "opencode", label: "OpenCode Zen", kind: "api_key" }, - { id: "opencode-go", label: "OpenCode Go", kind: "api_key" }, - { id: "huggingface", label: "Hugging Face", kind: "api_key" }, - { id: "kimi-coding", label: "Kimi For Coding", kind: "api_key" }, - { id: "minimax", label: "MiniMax", kind: "api_key" }, - { id: "minimax-cn", label: "MiniMax (China)", kind: "api_key" }, - { id: "fireworks", label: "Fireworks AI", kind: "api_key" }, - { id: "fireworks-firepass", label: "Fireworks Fire Pass", kind: "api_key" }, - { id: "azure-openai", label: "Azure OpenAI", kind: "azure" }, - { id: "amazon-bedrock", label: "AWS Bedrock", kind: "aws" }, - { id: "google-vertex", label: "Google Vertex AI", kind: "gcp_adc" }, -]; - const FILTERS: Array<{ id: "all" | "configured" | "available"; label: string }> = [ { id: "all", label: "All" }, { id: "configured", label: "Connected" }, { id: "available", label: "Available" }, ]; -const GROUP_ORDER: Array<{ id: ProvidersPanelEntry["kind"]; label: string }> = [ +const GROUP_ORDER: Array<{ id: ConnectorAuthKind; label: string }> = [ { id: "oauth", label: "Sign in" }, { id: "api_key", label: "API key" }, { id: "azure", label: "Azure" }, @@ -58,29 +22,29 @@ const GROUP_ORDER: Array<{ id: ProvidersPanelEntry["kind"]; label: string }> = [ { id: "gcp_adc", label: "Google Cloud" }, ]; -interface Props { - catalog?: ProvidersPanelEntry[]; +interface ProvidersPanelProps { + catalog?: ConnectorCatalogEntry[]; } -export function ProvidersPanel({ catalog }: Props) { - const entries = catalog ?? DEFAULT_CATALOG; +export function ProvidersPanel({ catalog }: ProvidersPanelProps = {}) { + const entries = catalog ?? CONNECTOR_CATALOG; const [credentials, setCredentials] = useState>(new Map()); const [filter, setFilter] = useState<"all" | "configured" | "available">("all"); const [search, setSearch] = useState(""); useEffect(() => { + const desktop = window.agentCompany; if (!desktop) return; let cancelled = false; - void desktop.listCredentials().then((result) => { + const refresh = async () => { + const result = await desktop.listCredentials(); if (cancelled || !result.ok) return; setCredentials(new Map(result.data.map((row) => [row.connectorId, row]))); - }); + }; + void refresh(); const unsubscribe = desktop.subscribe((event) => { if (event.type !== "credentials-changed") return; - void desktop.listCredentials().then((result) => { - if (!result.ok) return; - setCredentials(new Map(result.data.map((row) => [row.connectorId, row]))); - }); + void refresh(); }); return () => { cancelled = true; @@ -89,24 +53,26 @@ export function ProvidersPanel({ catalog }: Props) { }, []); const filtered = useMemo(() => { + const needle = search.trim().toLowerCase(); return entries.filter((entry) => { const hasCredential = credentials.has(entry.id); if (filter === "configured" && !hasCredential) return false; if (filter === "available" && hasCredential) return false; - if (search.trim()) { - const needle = search.trim().toLowerCase(); - return entry.label.toLowerCase().includes(needle) || entry.id.includes(needle); - } - return true; + if (needle.length === 0) return true; + return ( + entry.label.toLowerCase().includes(needle) || + entry.id.includes(needle) || + entry.provider.toLowerCase().includes(needle) + ); }); }, [entries, credentials, filter, search]); const grouped = useMemo(() => { - const byKind = new Map(); + const byKind = new Map(); for (const entry of filtered) { - const list = byKind.get(entry.kind) ?? []; + const list = byKind.get(entry.auth.type) ?? []; list.push(entry); - byKind.set(entry.kind, list); + byKind.set(entry.auth.type, list); } return GROUP_ORDER.map((group) => ({ ...group, diff --git a/src/renderer/components/settings/providers/index.ts b/src/renderer/components/settings/providers/index.ts new file mode 100644 index 00000000..9f21b392 --- /dev/null +++ b/src/renderer/components/settings/providers/index.ts @@ -0,0 +1,2 @@ +export { ProvidersPanel } from "./ProvidersPanel"; +export { ProviderRow } from "./ProviderRow";