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 {
|
import {
|
||||||
PROVIDER_AUTH_TYPE,
|
PROVIDER_AUTH_TYPE,
|
||||||
type AppSnapshot,
|
type AppSnapshot,
|
||||||
type ProviderCatalogEntry,
|
type ProviderCatalogEntry,
|
||||||
} from "../../../shared/domain";
|
} from "../../../shared/domain";
|
||||||
import { Button, Field, Icon, IconButton, Pill } from "../shared/ui";
|
import { Button, Field, Icon, IconButton, Pill } from "../shared/ui";
|
||||||
import { ProviderOAuthConnect } from "../shared/ProviderOAuthConnect";
|
|
||||||
import type { SnapshotActions } from "./useSnapshot";
|
import type { SnapshotActions } from "./useSnapshot";
|
||||||
|
import { ProviderStep } from "./OnboardingProviderStep";
|
||||||
|
|
||||||
type Step = "welcome" | "provider" | "company" | "mode" | "mission";
|
type Step = "welcome" | "provider" | "company" | "mode" | "mission";
|
||||||
|
|
||||||
interface Draft {
|
export interface Draft {
|
||||||
companyName: string;
|
companyName: string;
|
||||||
companyDescription: string;
|
companyDescription: string;
|
||||||
employeeName: 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) {
|
function CompanyStep({ draft, onChange, onBack, onNext }: StepProps) {
|
||||||
const chooseWorkspace = () => {
|
const chooseWorkspace = () => {
|
||||||
void window.agentCompany.pickDirectory().then((result) => {
|
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 { useState, type FormEvent } from "react";
|
||||||
import { Button, Field, Pill } from "../../shared/ui";
|
import { Button, Pill } from "../../shared/ui";
|
||||||
import { Dialog } from "../../shared/Dialog";
|
import { Dialog } from "../../shared/Dialog";
|
||||||
import { ProviderOAuthConnect } from "../../shared/ProviderOAuthConnect";
|
|
||||||
import type {
|
import type {
|
||||||
AgentRecord,
|
AgentRecord,
|
||||||
ProviderCatalogEntry,
|
ProviderCatalogEntry,
|
||||||
@@ -14,6 +13,8 @@ import type {
|
|||||||
import { PROVIDER_DATA_POLICY } from "../../../../shared/domain";
|
import { PROVIDER_DATA_POLICY } from "../../../../shared/domain";
|
||||||
import { ProviderLogo } from "./ProviderLogo";
|
import { ProviderLogo } from "./ProviderLogo";
|
||||||
import { ProviderRequestPanel } from "./ProviderRequestPanel";
|
import { ProviderRequestPanel } from "./ProviderRequestPanel";
|
||||||
|
import { ProviderConnectFields, ProviderOAuthFields } from "./ProviderConnectionDialog";
|
||||||
|
import { composeSecret } from "./provider-secret";
|
||||||
import type { SnapshotActions } from "../../app/useSnapshot";
|
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||||
|
|
||||||
interface ProvidersPanelProps {
|
interface ProvidersPanelProps {
|
||||||
@@ -29,14 +30,6 @@ interface ProvidersPanelProps {
|
|||||||
actions: SnapshotActions;
|
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";
|
const POLICY_GRID = "minmax(150px, 1.2fr) minmax(118px, 0.8fr) minmax(140px, 1fr) 112px 124px 92px";
|
||||||
type WorkspaceScope = "organization" | "workspace";
|
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({
|
function ProviderRow({
|
||||||
provider,
|
provider,
|
||||||
authProfile,
|
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