Split oversized renderer components
This commit is contained in:
1 parent
91d9b76281
commit
67d2cf5649
5 files changed
+392
-393
No files matched your search
@@ -1,16 +1,16 @@
|
||||
import { Fragment, useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import {
|
||||
PROVIDER_AUTH_TYPE,
|
||||
type AppSnapshot,
|
||||
type ProviderCatalogEntry,
|
||||
} from "../../../shared/domain";
|
||||
import { Button, Field, Icon, IconButton, Pill } from "../shared/ui";
|
||||
import { ProviderOAuthConnect } from "../shared/ProviderOAuthConnect";
|
||||
import type { SnapshotActions } from "./useSnapshot";
|
||||
import { ProviderStep } from "./OnboardingProviderStep";
|
||||
|
||||
type Step = "welcome" | "provider" | "company" | "mode" | "mission";
|
||||
|
||||
interface Draft {
|
||||
export interface Draft {
|
||||
companyName: string;
|
||||
companyDescription: string;
|
||||
employeeName: string;
|
||||
@@ -234,202 +234,6 @@ function WelcomeStep({ onNext }: { onNext: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderStep({
|
||||
providers,
|
||||
draft,
|
||||
onChange,
|
||||
onBack,
|
||||
onNext,
|
||||
}: {
|
||||
providers: ProviderCatalogEntry[];
|
||||
draft: Draft;
|
||||
onChange: (patch: Partial<Draft>) => void;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
const selected = providers.find((entry) => entry.key === draft.providerKey) ?? providers[0];
|
||||
if (!selected) throw new Error("No provider available for onboarding.");
|
||||
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<h2 className="page-h1">Provider</h2>
|
||||
<p style={{ color: "var(--text-3)", fontSize: 13 }}>
|
||||
Pick the runtime for the founding CEO. You can attach more providers in Settings later.
|
||||
</p>
|
||||
<div className="option-grid">
|
||||
{providers.map((provider) => (
|
||||
<Fragment key={provider.key}>
|
||||
<button
|
||||
type="button"
|
||||
className={`option-card ${draft.providerKey === provider.key ? "active" : ""}`.trim()}
|
||||
onClick={() => onChange({
|
||||
providerKey: provider.key,
|
||||
providerModel: "",
|
||||
providerSecret: "",
|
||||
providerBaseUrl: "",
|
||||
})}
|
||||
>
|
||||
<div className="row">
|
||||
<div className="opt-title" style={{ flex: 1 }}>{provider.label}</div>
|
||||
<Pill tone="violet">{authBadge(provider.authType)}</Pill>
|
||||
</div>
|
||||
<div className="opt-sub">{provider.capabilityTags.join(" · ") || authBadge(provider.authType)}</div>
|
||||
</button>
|
||||
{provider.key === selected.key && (
|
||||
<ProviderCredentialFields selected={selected} draft={draft} onChange={onChange} />
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<Actions onBack={onBack} onNext={onNext} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderCredentialFields({
|
||||
selected,
|
||||
draft,
|
||||
onChange,
|
||||
}: {
|
||||
selected: ProviderCatalogEntry;
|
||||
draft: Draft;
|
||||
onChange: (patch: Partial<Draft>) => void;
|
||||
}) {
|
||||
const isOAuthLike = selected.authType === "oauth_pkce" || selected.authType === "account_subscription";
|
||||
const isCustom = Boolean(selected.customEndpoint);
|
||||
const live = selected.supportsLiveModels;
|
||||
const [fetched, setFetched] = useState<ProviderCatalogEntry["models"] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const hasCredential = isOAuthLike
|
||||
? Boolean(draft.providerSecret.trim())
|
||||
: isCustom
|
||||
? Boolean(draft.providerBaseUrl.trim() && draft.providerSecret.trim())
|
||||
: Boolean(draft.providerSecret.trim());
|
||||
|
||||
const loadModels = async (secret: string) => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
const result = await window.agentCompany.listProviderModels({ providerKey: selected.key, secret });
|
||||
setLoading(false);
|
||||
if (!result.ok) {
|
||||
setLoadError(result.error.message);
|
||||
return;
|
||||
}
|
||||
setFetched(result.data);
|
||||
if (result.data[0]) onChange({ providerModel: result.data[0].id });
|
||||
};
|
||||
|
||||
const loadTypedModels = () => {
|
||||
const secret = isCustom
|
||||
? JSON.stringify({ baseUrl: draft.providerBaseUrl.trim(), secret: draft.providerSecret.trim() })
|
||||
: draft.providerSecret.trim();
|
||||
void loadModels(secret);
|
||||
};
|
||||
|
||||
// Curated providers (no live endpoint) show their fixed dropdown once a
|
||||
// credential is present; seed the selection with the provider default.
|
||||
const showCuratedList = !live && hasCredential && selected.models.length > 0;
|
||||
useEffect(() => {
|
||||
if (showCuratedList && !draft.providerModel) {
|
||||
onChange({ providerModel: selected.defaultModel ?? selected.models[0].id });
|
||||
}
|
||||
}, [showCuratedList, draft.providerModel, selected, onChange]);
|
||||
|
||||
return (
|
||||
<div className="provider-inline-form">
|
||||
{selected.credentialHelp && (
|
||||
<div style={{ fontSize: 12.5, color: "var(--text-3)", lineHeight: 1.5 }}>{selected.credentialHelp}</div>
|
||||
)}
|
||||
{isOAuthLike ? (
|
||||
<ProviderOAuthConnect
|
||||
key={selected.key}
|
||||
providerKey={selected.key}
|
||||
label={selected.label}
|
||||
buildInput={() => ({ providerKey: selected.key })}
|
||||
onComplete={(secret) => {
|
||||
onChange({ providerSecret: secret ?? "" });
|
||||
if (secret && live) void loadModels(secret);
|
||||
}}
|
||||
/>
|
||||
) : isCustom ? (
|
||||
<>
|
||||
<Field label="Base URL">
|
||||
<input
|
||||
className="input"
|
||||
value={draft.providerBaseUrl}
|
||||
placeholder={selected.customEndpoint === "openai" ? "https://host/v1" : "https://host"}
|
||||
onChange={(event) => onChange({ providerBaseUrl: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="API key">
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={draft.providerSecret}
|
||||
onChange={(event) => onChange({ providerSecret: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<Field label="API key">
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={draft.providerSecret}
|
||||
onChange={(event) => onChange({ providerSecret: event.target.value, providerModel: "" })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{!isOAuthLike && live && (
|
||||
<Button tone="ghost" onClick={loadTypedModels} disabled={!hasCredential || loading}>
|
||||
{loading ? "Getting models…" : fetched ? "Reload models" : "Get models"}
|
||||
</Button>
|
||||
)}
|
||||
{loadError && <div style={{ color: "var(--red)", fontSize: 12 }}>{loadError}</div>}
|
||||
{(hasCredential || isCustom) && (
|
||||
<Field label="Model">
|
||||
{fetched || showCuratedList ? (
|
||||
<select
|
||||
className="input"
|
||||
value={draft.providerModel}
|
||||
onChange={(event) => onChange({ providerModel: event.target.value })}
|
||||
>
|
||||
{(fetched ?? selected.models).map((model) => (
|
||||
<option key={model.id} value={model.id}>{model.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className="input"
|
||||
value={draft.providerModel}
|
||||
placeholder={modelPlaceholder(live, isOAuthLike, loading)}
|
||||
onChange={(event) => onChange({ providerModel: event.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function modelPlaceholder(live: boolean, isOAuthLike: boolean, loading: boolean): string {
|
||||
if (!live) return "Enter the model id";
|
||||
if (loading || isOAuthLike) return "Loading…";
|
||||
return "Click “Get models”, or type a model id";
|
||||
}
|
||||
|
||||
function authBadge(authType: string): string {
|
||||
if (authType === "api_key") return "API key";
|
||||
if (authType === "oauth_pkce") return "OAuth";
|
||||
if (authType === "account_subscription") return "Account";
|
||||
if (authType === "cloud") return "Cloud";
|
||||
return authType;
|
||||
}
|
||||
|
||||
function CompanyStep({ draft, onChange, onBack, onNext }: StepProps) {
|
||||
const chooseWorkspace = () => {
|
||||
void window.agentCompany.pickDirectory().then((result) => {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import type { ProviderCatalogEntry } from "../../../shared/domain";
|
||||
import { ProviderOAuthConnect } from "../shared/ProviderOAuthConnect";
|
||||
import { Button, Field, Pill } from "../shared/ui";
|
||||
import type { Draft } from "./Onboarding";
|
||||
|
||||
export function ProviderStep({
|
||||
providers,
|
||||
draft,
|
||||
onChange,
|
||||
onBack,
|
||||
onNext,
|
||||
}: {
|
||||
providers: ProviderCatalogEntry[];
|
||||
draft: Draft;
|
||||
onChange: (patch: Partial<Draft>) => void;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
const selected = providers.find((entry) => entry.key === draft.providerKey) ?? providers[0];
|
||||
if (!selected) throw new Error("No provider available for onboarding.");
|
||||
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<h2 className="page-h1">Provider</h2>
|
||||
<p style={{ color: "var(--text-3)", fontSize: 13 }}>
|
||||
Pick the runtime for the founding CEO. You can attach more providers in Settings later.
|
||||
</p>
|
||||
<div className="option-grid">
|
||||
{providers.map((provider) => (
|
||||
<Fragment key={provider.key}>
|
||||
<button
|
||||
type="button"
|
||||
className={`option-card ${draft.providerKey === provider.key ? "active" : ""}`.trim()}
|
||||
onClick={() => onChange({
|
||||
providerKey: provider.key,
|
||||
providerModel: "",
|
||||
providerSecret: "",
|
||||
providerBaseUrl: "",
|
||||
})}
|
||||
>
|
||||
<div className="row">
|
||||
<div className="opt-title" style={{ flex: 1 }}>{provider.label}</div>
|
||||
<Pill tone="violet">{authBadge(provider.authType)}</Pill>
|
||||
</div>
|
||||
<div className="opt-sub">{provider.capabilityTags.join(" · ") || authBadge(provider.authType)}</div>
|
||||
</button>
|
||||
{provider.key === selected.key && (
|
||||
<ProviderCredentialFields selected={selected} draft={draft} onChange={onChange} />
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<Actions onBack={onBack} onNext={onNext} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderCredentialFields({
|
||||
selected,
|
||||
draft,
|
||||
onChange,
|
||||
}: {
|
||||
selected: ProviderCatalogEntry;
|
||||
draft: Draft;
|
||||
onChange: (patch: Partial<Draft>) => void;
|
||||
}) {
|
||||
const isOAuthLike = selected.authType === "oauth_pkce" || selected.authType === "account_subscription";
|
||||
const isCustom = Boolean(selected.customEndpoint);
|
||||
const live = selected.supportsLiveModels;
|
||||
const [fetched, setFetched] = useState<ProviderCatalogEntry["models"] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const hasCredential = isOAuthLike
|
||||
? Boolean(draft.providerSecret.trim())
|
||||
: isCustom
|
||||
? Boolean(draft.providerBaseUrl.trim() && draft.providerSecret.trim())
|
||||
: Boolean(draft.providerSecret.trim());
|
||||
|
||||
const loadModels = async (secret: string) => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
const result = await window.agentCompany.listProviderModels({ providerKey: selected.key, secret });
|
||||
setLoading(false);
|
||||
if (!result.ok) {
|
||||
setLoadError(result.error.message);
|
||||
return;
|
||||
}
|
||||
setFetched(result.data);
|
||||
if (result.data[0]) onChange({ providerModel: result.data[0].id });
|
||||
};
|
||||
|
||||
const loadTypedModels = () => {
|
||||
const secret = isCustom
|
||||
? JSON.stringify({ baseUrl: draft.providerBaseUrl.trim(), secret: draft.providerSecret.trim() })
|
||||
: draft.providerSecret.trim();
|
||||
void loadModels(secret);
|
||||
};
|
||||
|
||||
const showCuratedList = !live && hasCredential && selected.models.length > 0;
|
||||
useEffect(() => {
|
||||
if (showCuratedList && !draft.providerModel) {
|
||||
onChange({ providerModel: selected.defaultModel ?? selected.models[0].id });
|
||||
}
|
||||
}, [showCuratedList, draft.providerModel, selected, onChange]);
|
||||
|
||||
return (
|
||||
<div className="provider-inline-form">
|
||||
{selected.credentialHelp && (
|
||||
<div style={{ fontSize: 12.5, color: "var(--text-3)", lineHeight: 1.5 }}>{selected.credentialHelp}</div>
|
||||
)}
|
||||
{isOAuthLike ? (
|
||||
<ProviderOAuthConnect
|
||||
key={selected.key}
|
||||
providerKey={selected.key}
|
||||
label={selected.label}
|
||||
buildInput={() => ({ providerKey: selected.key })}
|
||||
onComplete={(secret) => {
|
||||
onChange({ providerSecret: secret ?? "" });
|
||||
if (secret && live) void loadModels(secret);
|
||||
}}
|
||||
/>
|
||||
) : isCustom ? (
|
||||
<>
|
||||
<Field label="Base URL">
|
||||
<input
|
||||
className="input"
|
||||
value={draft.providerBaseUrl}
|
||||
placeholder={selected.customEndpoint === "openai" ? "https://host/v1" : "https://host"}
|
||||
onChange={(event) => onChange({ providerBaseUrl: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="API key">
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={draft.providerSecret}
|
||||
onChange={(event) => onChange({ providerSecret: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<Field label="API key">
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={draft.providerSecret}
|
||||
onChange={(event) => onChange({ providerSecret: event.target.value, providerModel: "" })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{!isOAuthLike && live && (
|
||||
<Button tone="ghost" onClick={loadTypedModels} disabled={!hasCredential || loading}>
|
||||
{loading ? "Getting models..." : fetched ? "Reload models" : "Get models"}
|
||||
</Button>
|
||||
)}
|
||||
{loadError && <div style={{ color: "var(--red)", fontSize: 12 }}>{loadError}</div>}
|
||||
{(hasCredential || isCustom) && (
|
||||
<Field label="Model">
|
||||
{fetched || showCuratedList ? (
|
||||
<select
|
||||
className="input"
|
||||
value={draft.providerModel}
|
||||
onChange={(event) => onChange({ providerModel: event.target.value })}
|
||||
>
|
||||
{(fetched ?? selected.models).map((model) => (
|
||||
<option key={model.id} value={model.id}>{model.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className="input"
|
||||
value={draft.providerModel}
|
||||
placeholder={modelPlaceholder(live, isOAuthLike, loading)}
|
||||
onChange={(event) => onChange({ providerModel: event.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Actions({ onBack, onNext }: { onBack: () => void; onNext: () => void }) {
|
||||
return (
|
||||
<div className="modal-actions" style={{ marginTop: 4 }}>
|
||||
<Button onClick={onBack}>Back</Button>
|
||||
<Button tone="primary" onClick={onNext}>Continue</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function modelPlaceholder(live: boolean, isOAuthLike: boolean, loading: boolean): string {
|
||||
if (!live) return "Enter the model id";
|
||||
if (loading || isOAuthLike) return "Loading...";
|
||||
return "Click Get models, or type a model id";
|
||||
}
|
||||
|
||||
function authBadge(authType: string): string {
|
||||
if (authType === "api_key") return "API key";
|
||||
if (authType === "oauth_pkce") return "OAuth";
|
||||
if (authType === "account_subscription") return "Account";
|
||||
if (authType === "cloud") return "Cloud";
|
||||
return authType;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState } from "react";
|
||||
import type { ProviderCatalogEntry, ProviderRecord } from "../../../../shared/domain";
|
||||
import { ProviderOAuthConnect } from "../../shared/ProviderOAuthConnect";
|
||||
import { Field } from "../../shared/ui";
|
||||
|
||||
interface CloudFields {
|
||||
baseUrl: string;
|
||||
region: string;
|
||||
projectId: string;
|
||||
deployment: string;
|
||||
}
|
||||
|
||||
const CLOUD_DEFAULTS: CloudFields = { baseUrl: "", region: "", projectId: "", deployment: "" };
|
||||
|
||||
export function ProviderConnectFields({
|
||||
provider,
|
||||
entry,
|
||||
}: {
|
||||
provider: ProviderRecord;
|
||||
entry: ProviderCatalogEntry;
|
||||
}) {
|
||||
if (entry.authType === "api_key") {
|
||||
return (
|
||||
<>
|
||||
<p style={{ fontSize: 12.5, color: "var(--text-3)", lineHeight: 1.5, margin: 0 }}>{entry.credentialHelp}</p>
|
||||
<Field label="API key"><input className="input" name="secret" type="password" required autoFocus /></Field>
|
||||
<ModelPicker entry={entry} provider={provider} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (entry.customEndpoint) {
|
||||
return <CompatFieldsForm entry={entry} provider={provider} />;
|
||||
}
|
||||
return <CloudFieldsForm entry={entry} provider={provider} />;
|
||||
}
|
||||
|
||||
export function ProviderOAuthFields({
|
||||
orgId,
|
||||
employeeId,
|
||||
provider,
|
||||
entry,
|
||||
onConnected,
|
||||
}: {
|
||||
orgId: string;
|
||||
employeeId: string;
|
||||
provider: ProviderRecord;
|
||||
entry: ProviderCatalogEntry;
|
||||
onConnected: () => void;
|
||||
}) {
|
||||
const [model, setModel] = useState(provider.model ?? entry.defaultModel ?? entry.models[0]?.id ?? "");
|
||||
return (
|
||||
<>
|
||||
<p style={{ fontSize: 12.5, color: "var(--text-3)", lineHeight: 1.5, margin: 0 }}>{entry.credentialHelp}</p>
|
||||
<Field label="Default model">
|
||||
{entry.models.length ? (
|
||||
<select className="input" value={model} onChange={(event) => setModel(event.target.value)}>
|
||||
{entry.models.map((option) => (
|
||||
<option key={option.id} value={option.id}>{option.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input className="input" value={model} onChange={(event) => setModel(event.target.value)} />
|
||||
)}
|
||||
</Field>
|
||||
<ProviderOAuthConnect
|
||||
key={entry.key}
|
||||
providerKey={entry.key}
|
||||
label={entry.label}
|
||||
buildInput={() => ({ providerKey: entry.key, orgId, providerId: provider.id, employeeId, model })}
|
||||
onComplete={() => onConnected()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CompatFieldsForm({ entry, provider }: { entry: ProviderCatalogEntry; provider: ProviderRecord }) {
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
return (
|
||||
<>
|
||||
<p style={{ fontSize: 12.5, color: "var(--text-3)", lineHeight: 1.5, margin: 0 }}>{entry.credentialHelp}</p>
|
||||
<Field label="Base URL">
|
||||
<input
|
||||
className="input"
|
||||
name="cloudBaseUrl"
|
||||
value={baseUrl}
|
||||
placeholder={entry.customEndpoint === "openai" ? "https://host/v1" : "https://host"}
|
||||
onChange={(event) => setBaseUrl(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="API key"><input className="input" name="secret" type="password" required /></Field>
|
||||
<ModelPicker entry={entry} provider={provider} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelPicker({ entry, provider }: { entry: ProviderCatalogEntry; provider: ProviderRecord }) {
|
||||
const [fetched, setFetched] = useState<ProviderCatalogEntry["models"] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [value, setValue] = useState(provider.model ?? entry.defaultModel ?? entry.models[0]?.id ?? "");
|
||||
const models = fetched ?? entry.models;
|
||||
const connected = entry.supportsLiveModels && Boolean(provider.credentialId) && provider.enabled && provider.authState === "configured";
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const result = await window.agentCompany.listProviderModels({ providerKey: entry.key, orgId: provider.orgId, providerId: provider.id });
|
||||
setLoading(false);
|
||||
if (!result.ok) {
|
||||
setError(result.error.message);
|
||||
return;
|
||||
}
|
||||
setFetched(result.data);
|
||||
if (result.data[0] && !result.data.some((model) => model.id === value)) setValue(result.data[0].id);
|
||||
};
|
||||
|
||||
return (
|
||||
<Field label="Default model">
|
||||
{models.length ? (
|
||||
<select className="input" name="model" value={value} onChange={(event) => setValue(event.target.value)}>
|
||||
{models.map((model) => (
|
||||
<option key={model.id} value={model.id}>{model.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input className="input" name="model" value={value} onChange={(event) => setValue(event.target.value)} />
|
||||
)}
|
||||
{connected && (
|
||||
<button type="button" className="button ghost" style={{ marginTop: 6 }} disabled={loading} onClick={refresh}>
|
||||
{loading ? "Loading models..." : "Refresh models"}
|
||||
</button>
|
||||
)}
|
||||
{error && <div style={{ color: "var(--red)", fontSize: 12, marginTop: 4 }}>{error}</div>}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudFieldsForm({ entry, provider }: { entry: ProviderCatalogEntry; provider: ProviderRecord }) {
|
||||
const [state, setState] = useState<CloudFields>(CLOUD_DEFAULTS);
|
||||
const update = (patch: Partial<CloudFields>) => setState((current) => ({ ...current, ...patch }));
|
||||
return (
|
||||
<>
|
||||
<p style={{ fontSize: 12.5, color: "var(--text-3)", lineHeight: 1.5, margin: 0 }}>{entry.credentialHelp}</p>
|
||||
<Field label="Base URL">
|
||||
<input className="input" name="cloudBaseUrl" value={state.baseUrl} onChange={(event) => update({ baseUrl: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="Region">
|
||||
<input className="input" name="cloudRegion" value={state.region} onChange={(event) => update({ region: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="Project / resource">
|
||||
<input className="input" name="cloudProject" value={state.projectId} onChange={(event) => update({ projectId: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="Deployment / model id">
|
||||
<input className="input" name="cloudDeployment" value={state.deployment} onChange={(event) => update({ deployment: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="Credential">
|
||||
<input className="input" name="secret" type="password" required autoFocus />
|
||||
</Field>
|
||||
<Field label="Default model">
|
||||
<input className="input" name="model" defaultValue={provider.model ?? entry.defaultModel ?? ""} />
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Button, Field, Pill } from "../../shared/ui";
|
||||
import { Button, Pill } from "../../shared/ui";
|
||||
import { Dialog } from "../../shared/Dialog";
|
||||
import { ProviderOAuthConnect } from "../../shared/ProviderOAuthConnect";
|
||||
import type {
|
||||
AgentRecord,
|
||||
ProviderCatalogEntry,
|
||||
@@ -14,6 +13,8 @@ import type {
|
||||
import { PROVIDER_DATA_POLICY } from "../../../../shared/domain";
|
||||
import { ProviderLogo } from "./ProviderLogo";
|
||||
import { ProviderRequestPanel } from "./ProviderRequestPanel";
|
||||
import { ProviderConnectFields, ProviderOAuthFields } from "./ProviderConnectionDialog";
|
||||
import { composeSecret } from "./provider-secret";
|
||||
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||
|
||||
interface ProvidersPanelProps {
|
||||
@@ -29,14 +30,6 @@ interface ProvidersPanelProps {
|
||||
actions: SnapshotActions;
|
||||
}
|
||||
|
||||
interface CloudFields {
|
||||
baseUrl: string;
|
||||
region: string;
|
||||
projectId: string;
|
||||
deployment: string;
|
||||
}
|
||||
|
||||
const CLOUD_DEFAULTS: CloudFields = { baseUrl: "", region: "", projectId: "", deployment: "" };
|
||||
const POLICY_GRID = "minmax(150px, 1.2fr) minmax(118px, 0.8fr) minmax(140px, 1fr) 112px 124px 92px";
|
||||
type WorkspaceScope = "organization" | "workspace";
|
||||
|
||||
@@ -272,190 +265,6 @@ function ProviderPolicyRow({
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderConnectFields({
|
||||
provider,
|
||||
entry,
|
||||
}: {
|
||||
provider: ProviderRecord;
|
||||
entry: ProviderCatalogEntry;
|
||||
}) {
|
||||
if (entry.authType === "api_key") {
|
||||
return (
|
||||
<>
|
||||
<p style={{ fontSize: 12.5, color: "var(--text-3)", lineHeight: 1.5, margin: 0 }}>{entry.credentialHelp}</p>
|
||||
<Field label="API key"><input className="input" name="secret" type="password" required autoFocus /></Field>
|
||||
<ModelPicker entry={entry} provider={provider} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (entry.customEndpoint) {
|
||||
return <CompatFieldsForm entry={entry} provider={provider} />;
|
||||
}
|
||||
return <CloudFieldsForm entry={entry} provider={provider} />;
|
||||
}
|
||||
|
||||
function CompatFieldsForm({ entry, provider }: { entry: ProviderCatalogEntry; provider: ProviderRecord }) {
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
return (
|
||||
<>
|
||||
<p style={{ fontSize: 12.5, color: "var(--text-3)", lineHeight: 1.5, margin: 0 }}>{entry.credentialHelp}</p>
|
||||
<Field label="Base URL">
|
||||
<input
|
||||
className="input"
|
||||
name="cloudBaseUrl"
|
||||
value={baseUrl}
|
||||
placeholder={entry.customEndpoint === "openai" ? "https://host/v1" : "https://host"}
|
||||
onChange={(event) => setBaseUrl(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="API key"><input className="input" name="secret" type="password" required /></Field>
|
||||
<ModelPicker entry={entry} provider={provider} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderOAuthFields({
|
||||
orgId,
|
||||
employeeId,
|
||||
provider,
|
||||
entry,
|
||||
onConnected,
|
||||
}: {
|
||||
orgId: string;
|
||||
employeeId: string;
|
||||
provider: ProviderRecord;
|
||||
entry: ProviderCatalogEntry;
|
||||
onConnected: () => void;
|
||||
}) {
|
||||
const [model, setModel] = useState(provider.model ?? entry.defaultModel ?? entry.models[0]?.id ?? "");
|
||||
return (
|
||||
<>
|
||||
<p style={{ fontSize: 12.5, color: "var(--text-3)", lineHeight: 1.5, margin: 0 }}>{entry.credentialHelp}</p>
|
||||
<Field label="Default model">
|
||||
{entry.models.length ? (
|
||||
<select className="input" value={model} onChange={(event) => setModel(event.target.value)}>
|
||||
{entry.models.map((option) => (
|
||||
<option key={option.id} value={option.id}>{option.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input className="input" value={model} onChange={(event) => setModel(event.target.value)} />
|
||||
)}
|
||||
</Field>
|
||||
<ProviderOAuthConnect
|
||||
key={entry.key}
|
||||
providerKey={entry.key}
|
||||
label={entry.label}
|
||||
buildInput={() => ({ providerKey: entry.key, orgId, providerId: provider.id, employeeId, model })}
|
||||
onComplete={() => onConnected()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelPicker({ entry, provider }: { entry: ProviderCatalogEntry; provider: ProviderRecord }) {
|
||||
const [fetched, setFetched] = useState<ProviderCatalogEntry["models"] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [value, setValue] = useState(provider.model ?? entry.defaultModel ?? entry.models[0]?.id ?? "");
|
||||
const models = fetched ?? entry.models;
|
||||
const connected = entry.supportsLiveModels && Boolean(provider.credentialId) && provider.enabled && provider.authState === "configured";
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const result = await window.agentCompany.listProviderModels({ providerKey: entry.key, orgId: provider.orgId, providerId: provider.id });
|
||||
setLoading(false);
|
||||
if (!result.ok) {
|
||||
setError(result.error.message);
|
||||
return;
|
||||
}
|
||||
setFetched(result.data);
|
||||
if (result.data[0] && !result.data.some((model) => model.id === value)) setValue(result.data[0].id);
|
||||
};
|
||||
|
||||
return (
|
||||
<Field label="Default model">
|
||||
{models.length ? (
|
||||
<select className="input" name="model" value={value} onChange={(event) => setValue(event.target.value)}>
|
||||
{models.map((model) => (
|
||||
<option key={model.id} value={model.id}>{model.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input className="input" name="model" value={value} onChange={(event) => setValue(event.target.value)} />
|
||||
)}
|
||||
{connected && (
|
||||
<button type="button" className="button ghost" style={{ marginTop: 6 }} disabled={loading} onClick={refresh}>
|
||||
{loading ? "Loading models…" : "Refresh models"}
|
||||
</button>
|
||||
)}
|
||||
{error && <div style={{ color: "var(--red)", fontSize: 12, marginTop: 4 }}>{error}</div>}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudFieldsForm({ entry, provider }: { entry: ProviderCatalogEntry; provider: ProviderRecord }) {
|
||||
const [state, setState] = useState<CloudFields>(CLOUD_DEFAULTS);
|
||||
const update = (patch: Partial<CloudFields>) => setState((current) => ({ ...current, ...patch }));
|
||||
return (
|
||||
<>
|
||||
<p style={{ fontSize: 12.5, color: "var(--text-3)", lineHeight: 1.5, margin: 0 }}>{entry.credentialHelp}</p>
|
||||
<Field label="Base URL">
|
||||
<input
|
||||
className="input"
|
||||
name="cloudBaseUrl"
|
||||
value={state.baseUrl}
|
||||
onChange={(event) => update({ baseUrl: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Region">
|
||||
<input
|
||||
className="input"
|
||||
name="cloudRegion"
|
||||
value={state.region}
|
||||
onChange={(event) => update({ region: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Project / resource">
|
||||
<input
|
||||
className="input"
|
||||
name="cloudProject"
|
||||
value={state.projectId}
|
||||
onChange={(event) => update({ projectId: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Deployment / model id">
|
||||
<input
|
||||
className="input"
|
||||
name="cloudDeployment"
|
||||
value={state.deployment}
|
||||
onChange={(event) => update({ deployment: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Credential">
|
||||
<input className="input" name="secret" type="password" required autoFocus />
|
||||
</Field>
|
||||
<Field label="Default model">
|
||||
<input className="input" name="model" defaultValue={provider.model ?? entry.defaultModel ?? ""} />
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function composeSecret(entry: ProviderCatalogEntry, form: FormData): string {
|
||||
const raw = String(form.get("secret") ?? "").trim();
|
||||
if (entry.authType !== "cloud") return raw;
|
||||
const payload = {
|
||||
baseUrl: String(form.get("cloudBaseUrl") ?? "").trim(),
|
||||
region: String(form.get("cloudRegion") ?? "").trim(),
|
||||
projectId: String(form.get("cloudProject") ?? "").trim(),
|
||||
deployment: String(form.get("cloudDeployment") ?? "").trim(),
|
||||
secret: raw,
|
||||
};
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
function ProviderRow({
|
||||
provider,
|
||||
authProfile,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ProviderCatalogEntry } from "../../../../shared/domain";
|
||||
|
||||
export function composeSecret(entry: ProviderCatalogEntry, form: FormData): string {
|
||||
const raw = String(form.get("secret") ?? "").trim();
|
||||
if (entry.authType !== "cloud") return raw;
|
||||
const payload = {
|
||||
baseUrl: String(form.get("cloudBaseUrl") ?? "").trim(),
|
||||
region: String(form.get("cloudRegion") ?? "").trim(),
|
||||
projectId: String(form.get("cloudProject") ?? "").trim(),
|
||||
deployment: String(form.get("cloudDeployment") ?? "").trim(),
|
||||
secret: raw,
|
||||
};
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
Reference in new issue
Block a user