feat(settings): drive providers panel from pi-mono catalog
ProvidersPanel reads all 26 CONNECTOR_CATALOG entries and groups them by auth kind (OAuth / API key / Azure / AWS / Vertex). ProviderRow dispatches to the matching dedicated form component per auth kind. AWS, Azure, Vertex forms rewritten with Tailwind classes and a shared field style module so the Settings pane renders with the same aesthetic as the rest of the app.
This commit is contained in:
1 parent
7fdd3c16bf
commit
4a120f4fc6
8 files changed
+842
-60
No files matched your search
@@ -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<string | null>(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 (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleSubmit();
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
<input
|
||||
type={isEnv ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={(event) => 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 ? (
|
||||
<div role="alert" className="text-[11px] text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSource}
|
||||
disabled={busy}
|
||||
className="text-[11px] text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
{isEnv ? "Paste key directly" : "Use environment variable instead"}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSave}
|
||||
className="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"
|
||||
>
|
||||
{busy ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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<AuthMode>("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<string | null>(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 (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleSubmit();
|
||||
}}
|
||||
className={FORM_FIELDSET}
|
||||
>
|
||||
<div>
|
||||
<label className={FIELD_LABEL}>Auth mode</label>
|
||||
<select
|
||||
value={authMode}
|
||||
onChange={(event) => setAuthMode(event.target.value as AuthMode)}
|
||||
disabled={busy}
|
||||
className={FIELD_SELECT}
|
||||
>
|
||||
<option value="profile">AWS profile (~/.aws/credentials)</option>
|
||||
<option value="iam">IAM access key + secret</option>
|
||||
<option value="bearer">Bedrock bearer token</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={FIELD_LABEL}>AWS region</label>
|
||||
<input
|
||||
type="text"
|
||||
value={region}
|
||||
onChange={(event) => setRegion(event.target.value)}
|
||||
disabled={busy}
|
||||
className={FIELD_INPUT}
|
||||
placeholder="us-east-1"
|
||||
/>
|
||||
</div>
|
||||
{authMode === "profile" ? (
|
||||
<div>
|
||||
<label className={FIELD_LABEL}>Profile name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={awsProfile}
|
||||
onChange={(event) => setAwsProfile(event.target.value)}
|
||||
disabled={busy}
|
||||
className={FIELD_INPUT}
|
||||
placeholder="default"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{authMode === "iam" ? (
|
||||
<>
|
||||
<div>
|
||||
<label className={FIELD_LABEL}>Access key id</label>
|
||||
<input
|
||||
type="text"
|
||||
value={accessKeyId}
|
||||
onChange={(event) => setAccessKeyId(event.target.value)}
|
||||
disabled={busy}
|
||||
className={FIELD_INPUT}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={FIELD_LABEL}>Secret access key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={secretAccessKey}
|
||||
onChange={(event) => setSecretAccessKey(event.target.value)}
|
||||
disabled={busy}
|
||||
className={FIELD_INPUT}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={FIELD_LABEL}>Session token (optional)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={sessionToken}
|
||||
onChange={(event) => setSessionToken(event.target.value)}
|
||||
disabled={busy}
|
||||
className={FIELD_INPUT}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{authMode === "bearer" ? (
|
||||
<div>
|
||||
<label className={FIELD_LABEL}>Bearer token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={bearerToken}
|
||||
onChange={(event) => setBearerToken(event.target.value)}
|
||||
disabled={busy}
|
||||
className={FIELD_INPUT}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<details className="rounded-md border border-border/50 px-3 py-2 text-[12px]">
|
||||
<summary className="cursor-pointer select-none text-muted-foreground">Advanced</summary>
|
||||
<div className="mt-3 space-y-2">
|
||||
<label className="flex items-center gap-2 text-[12px] text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={forceCache}
|
||||
onChange={(event) => setForceCache(event.target.checked)}
|
||||
disabled={busy}
|
||||
/>
|
||||
Force prompt cache (AWS_BEDROCK_FORCE_CACHE)
|
||||
</label>
|
||||
<div>
|
||||
<label className={FIELD_LABEL}>Endpoint URL (optional)</label>
|
||||
<input
|
||||
type="url"
|
||||
value={endpointUrl}
|
||||
onChange={(event) => setEndpointUrl(event.target.value)}
|
||||
disabled={busy}
|
||||
className={FIELD_INPUT}
|
||||
placeholder="https://bedrock-runtime.your-region.amazonaws.com"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-[12px] text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skipAuth}
|
||||
onChange={(event) => setSkipAuth(event.target.checked)}
|
||||
disabled={busy}
|
||||
/>
|
||||
Skip auth (AWS_BEDROCK_SKIP_AUTH)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-[12px] text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={forceHttp1}
|
||||
onChange={(event) => setForceHttp1(event.target.checked)}
|
||||
disabled={busy}
|
||||
/>
|
||||
Force HTTP/1.1 (AWS_BEDROCK_FORCE_HTTP1)
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
{error ? <div className={FORM_ERROR}>{error}</div> : null}
|
||||
<button type="submit" disabled={busy} className={FORM_BUTTON}>
|
||||
{busy ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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<DeploymentEntry[]>([
|
||||
{ modelId: "", deploymentName: "" },
|
||||
]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleSubmit();
|
||||
}}
|
||||
className={FORM_FIELDSET}
|
||||
>
|
||||
<div>
|
||||
<label className={FIELD_LABEL}>API key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
disabled={busy}
|
||||
className={FIELD_INPUT}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={FIELD_LABEL}>Base URL (optional)</label>
|
||||
<input
|
||||
type="url"
|
||||
value={baseUrl}
|
||||
onChange={(event) => setBaseUrl(event.target.value)}
|
||||
disabled={busy}
|
||||
className={FIELD_INPUT}
|
||||
placeholder="https://YOUR-RESOURCE.openai.azure.com/"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={FIELD_LABEL}>Resource name (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={resourceName}
|
||||
onChange={(event) => setResourceName(event.target.value)}
|
||||
disabled={busy}
|
||||
className={FIELD_INPUT}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={FIELD_LABEL}>API version (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={apiVersion}
|
||||
onChange={(event) => setApiVersion(event.target.value)}
|
||||
disabled={busy}
|
||||
className={FIELD_INPUT}
|
||||
placeholder="2024-08-01-preview"
|
||||
/>
|
||||
</div>
|
||||
<fieldset className="rounded-md border border-border/50 px-3 py-2">
|
||||
<legend className="px-1 text-[11px] font-medium text-muted-foreground">
|
||||
Deployment map (model id → deployment name)
|
||||
</legend>
|
||||
<div className="space-y-2">
|
||||
{deployments.map((entry, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={entry.modelId}
|
||||
placeholder="gpt-4o"
|
||||
disabled={busy}
|
||||
className={FIELD_INPUT}
|
||||
onChange={(event) =>
|
||||
setDeployments((prev) =>
|
||||
prev.map((p, i) => (i === index ? { ...p, modelId: event.target.value } : p)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={entry.deploymentName}
|
||||
placeholder="my-gpt4-deployment"
|
||||
disabled={busy}
|
||||
className={FIELD_INPUT}
|
||||
onChange={(event) =>
|
||||
setDeployments((prev) =>
|
||||
prev.map((p, i) =>
|
||||
i === index ? { ...p, deploymentName: event.target.value } : p,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
setDeployments((prev) => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
className={FORM_BUTTON_SECONDARY}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
setDeployments((prev) => [...prev, { modelId: "", deploymentName: "" }])
|
||||
}
|
||||
className={FORM_BUTTON_SECONDARY}
|
||||
>
|
||||
Add mapping
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
{error ? <div className={FORM_ERROR}>{error}</div> : null}
|
||||
<button type="submit" disabled={busy} className={FORM_BUTTON}>
|
||||
{busy ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleSubmit();
|
||||
}}
|
||||
className={FORM_FIELDSET}
|
||||
>
|
||||
<div>
|
||||
<label className={FIELD_LABEL}>GCP project id</label>
|
||||
<input
|
||||
type="text"
|
||||
value={project}
|
||||
onChange={(event) => setProject(event.target.value)}
|
||||
disabled={busy}
|
||||
className={FIELD_INPUT}
|
||||
placeholder="my-gcp-project"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={FIELD_LABEL}>Region / location</label>
|
||||
<input
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={(event) => setLocation(event.target.value)}
|
||||
disabled={busy}
|
||||
className={FIELD_INPUT}
|
||||
placeholder="us-central1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={FIELD_LABEL}>Credentials JSON path (optional — leave blank for ADC)</label>
|
||||
<div className="mt-1 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={credentialsPath}
|
||||
onChange={(event) => setCredentialsPath(event.target.value)}
|
||||
disabled={busy}
|
||||
className={FIELD_INPUT}
|
||||
placeholder="/path/to/service-account.json"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void pickCredentialsFile()}
|
||||
disabled={busy}
|
||||
className={FORM_BUTTON_SECONDARY}
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <div className={FORM_ERROR}>{error}</div> : null}
|
||||
<button type="submit" disabled={busy} className={FORM_BUTTON}>
|
||||
{busy ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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<ConnectorCatalogEntry["auth"]["type"], string> = {
|
||||
oauth: "OAuth",
|
||||
api_key: "API key",
|
||||
azure: "Azure",
|
||||
aws: "AWS",
|
||||
gcp_adc: "Vertex (ADC)",
|
||||
};
|
||||
|
||||
const STATUS_STYLE: Record<string, { dot: string; text: string }> = {
|
||||
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<string, string> = {
|
||||
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<string | null>(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 <ApiKeyForm connectorId={entry.id} defaultEnvVar={apiKeyEnv} onSaved={onSaved} />;
|
||||
if (entry.auth.type === "azure")
|
||||
return <AzureForm connectorId={entry.id} onSaved={onSaved} />;
|
||||
if (entry.auth.type === "aws")
|
||||
return <AwsForm connectorId={entry.id} onSaved={onSaved} />;
|
||||
if (entry.auth.type === "gcp_adc")
|
||||
return <VertexForm connectorId={entry.id} onSaved={onSaved} />;
|
||||
return null;
|
||||
};
|
||||
|
||||
const disabled = busy !== null;
|
||||
const isOAuth = entry.auth.type === "oauth";
|
||||
|
||||
return (
|
||||
<li className="px-4 py-3 transition-colors hover:bg-muted/30">
|
||||
<div className="flex items-center gap-4">
|
||||
<span
|
||||
aria-label={`status: ${statusLabel}`}
|
||||
className={`block h-2 w-2 flex-shrink-0 rounded-full ${statusStyle.dot}`}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<div className="truncate text-[13px] font-semibold text-foreground">
|
||||
{entry.label}
|
||||
</div>
|
||||
<div className="text-[10px] uppercase tracking-[0.1em] text-muted-foreground/70">
|
||||
{KIND_LABEL[entry.auth.type]}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`truncate text-[11px] ${statusStyle.text}`}>
|
||||
{hasCredential ? (credential.displayHint ?? statusLabel) : statusLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-1">
|
||||
{isOAuth ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => void handleOAuthLogin()}
|
||||
className={hasCredential ? ACTION_SECONDARY : ACTION_PRIMARY}
|
||||
>
|
||||
{busy === "login" ? "Opening…" : hasCredential ? "Re-login" : "Login"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setEditing((current) => !current)}
|
||||
className={hasCredential || editing ? ACTION_SECONDARY : ACTION_PRIMARY}
|
||||
>
|
||||
{editing ? "Close" : hasCredential ? "Edit" : "Configure"}
|
||||
</button>
|
||||
)}
|
||||
{hasCredential ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => void handleTest()}
|
||||
className={ACTION_SECONDARY}
|
||||
>
|
||||
{busy === "test" ? "Testing…" : "Test"}
|
||||
</button>
|
||||
) : null}
|
||||
{hasCredential ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => void handleRemove()}
|
||||
className={ACTION_SECONDARY}
|
||||
>
|
||||
{busy === "remove" ? "Removing…" : "Remove"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{feedback ? (
|
||||
<div className="mt-2 pl-6 text-[11px] text-muted-foreground">{feedback}</div>
|
||||
) : null}
|
||||
|
||||
{editing && !isOAuth ? (
|
||||
<div className="mt-3 rounded-md border border-border bg-background/60 p-3">
|
||||
{renderForm()}
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -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<Map<string, CredentialSummary>>(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<ProvidersPanelEntry["kind"], ProvidersPanelEntry[]>();
|
||||
const byKind = new Map<ConnectorAuthKind, ConnectorCatalogEntry[]>();
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ProvidersPanel } from "./ProvidersPanel";
|
||||
export { ProviderRow } from "./ProviderRow";
|
||||
Reference in new issue
Block a user