feat(renderer): rewrite providers UI on pi-mono catalog shape

Onboarding ProvidersStep now reads the 26-entry CONNECTOR_CATALOG and
renders featured connectors (claude-pro, openai-codex, anthropic, openai,
amazon-bedrock) up front with a collapsible advanced list for the
remaining 21. Each auth kind gets a dedicated card component: OAuth
button flow, inline API-key form, and a cloud deep-link to Settings.
Delete legacy Claude/Codex/Gemini hardcoded cards and the compatible-API
slot rotation. Swap ModelManagement Settings panel for the new
ProvidersPanel driven by credentialsList IPC.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-17 02:16:35 -04:00
1 parent 6a25379995
commit 7fdd3c16bf
17 files changed
+692 -1422

No files matched your search

@@ -1,328 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { ArrowSquareOut, FloppyDisk, Plus, Trash } from "../ui/icons";
import type { ConnectorRecord } from "@shared/types";
import { unwrap } from "../../lib/desktop";
import {
COMPATIBLE_PROVIDERS,
INPUT_CLS,
ModelToggleList,
ProviderCard,
StatusBadge,
parseEnvValue,
type ModelEntry,
} from "./ModelManagementShared";
/* ------------------------------------------------------------------ */
/* Compatible provider card */
/* ------------------------------------------------------------------ */
export function CompatProviderCard({
slotId,
connector,
zh,
onSaved,
expanded,
onToggle,
onCleared,
}: {
slotId: string;
connector: ConnectorRecord | null;
zh: boolean;
onSaved: () => Promise<void>;
expanded: boolean;
onToggle: () => void;
onCleared?: () => void;
}) {
const [selectedProvider, setSelectedProvider] = useState<string>(() => {
const saved = connector?.notes ?? "";
const match = COMPATIBLE_PROVIDERS.find((p) => p.label === saved);
return match?.id ?? COMPATIBLE_PROVIDERS[0].id;
});
const [apiKey, setApiKey] = useState(() =>
parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_API_KEY"),
);
const [baseUrl, setBaseUrl] = useState<string>(
() =>
parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_BASE_URL") ||
COMPATIBLE_PROVIDERS[0].baseUrl,
);
const [models, setModels] = useState<ModelEntry[]>([]);
const [newModel, setNewModel] = useState("");
const [saving, setSaving] = useState(false);
const status = connector?.status ?? "not_installed";
const providerLabel =
COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider)?.label ??
"Compatible API";
const displayTitle = connector?.notes || providerLabel;
// Sync form state when connector changes externally (e.g. after save + refresh)
useEffect(() => {
setApiKey(parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_API_KEY"));
const savedBase = parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_BASE_URL");
if (savedBase) setBaseUrl(savedBase);
const saved = connector?.notes ?? "";
const match = COMPATIBLE_PROVIDERS.find((p) => p.label === saved);
if (match) setSelectedProvider(match.id);
}, [connector?.envBindingText, connector?.notes]);
const loadModels = useCallback(async () => {
if (!connector) {
setModels([]);
return;
}
try {
const raw = connector.model ?? "";
const allModelIds = raw
.split(",")
.map((m) => m.trim())
.filter(Boolean);
const disabledResult = await window.agentCompany.getConnectorDisabledModels(
connector.id,
);
const disabledSet = new Set(
(disabledResult.ok ? disabledResult.data : []).map((m) => m.id),
);
setModels(
allModelIds.map((id) => ({
id,
label: id,
enabled: !disabledSet.has(id),
})),
);
} catch {
/* non-fatal */
}
}, [connector]);
useEffect(() => {
void loadModels();
}, [loadModels]);
function handleProviderChange(providerId: string) {
setSelectedProvider(providerId);
const provider = COMPATIBLE_PROVIDERS.find((p) => p.id === providerId);
if (provider) setBaseUrl(provider.baseUrl);
}
function addModel() {
const trimmed = newModel.trim();
if (!trimmed || models.some((m) => m.id === trimmed)) return;
setModels((prev) => [...prev, { id: trimmed, label: trimmed, enabled: true }]);
setNewModel("");
}
function removeModel(modelId: string) {
setModels((prev) => prev.filter((m) => m.id !== modelId));
}
async function handleToggleModel(modelId: string) {
if (!connector) return;
const prev = models;
const updated = models.map((m) =>
m.id === modelId ? { ...m, enabled: !m.enabled } : m,
);
setModels(updated);
const newDisabled = updated
.filter((m) => !m.enabled)
.map((m) => ({ id: m.id, label: m.label }));
try {
unwrap(
await window.agentCompany.setConnectorDisabledModels({
connectorId: connector.id,
disabledModels: newDisabled,
}),
);
await onSaved();
} catch {
setModels(prev);
}
}
async function handleSave() {
setSaving(true);
try {
const lines: string[] = [];
if (apiKey.trim()) lines.push(`ANTHROPIC_API_KEY=${apiKey.trim()}`);
if (baseUrl.trim()) lines.push(`ANTHROPIC_BASE_URL=${baseUrl.trim()}`);
const label =
COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider)?.label ?? "";
const modelsCsv =
models.map((m) => m.id).join(",") || null;
unwrap(
await window.agentCompany.saveConnector({
id: slotId as ConnectorRecord["id"],
command: connector?.command ?? "claude",
model: modelsCsv,
envBindingText: lines.join("\n"),
notes: label,
}),
);
await window.agentCompany.testConnector(slotId as ConnectorRecord["id"]);
await onSaved();
} finally {
setSaving(false);
}
}
async function handleClear() {
if (!connector) return;
try {
unwrap(
await window.agentCompany.saveConnector({
id: connector.id,
command: connector.command,
model: null,
envBindingText: "",
notes: "",
}),
);
unwrap(
await window.agentCompany.setConnectorDisabledModels({
connectorId: connector.id,
disabledModels: [],
}),
);
await onSaved();
onCleared?.();
} catch {
/* non-fatal */
}
}
const selectedProviderData = COMPATIBLE_PROVIDERS.find(
(p) => p.id === selectedProvider,
);
const isConfigured =
connector &&
(connector.envBindingText.includes("ANTHROPIC_BASE_URL") ||
connector.envBindingText.includes("ANTHROPIC_API_KEY"));
return (
<ProviderCard
title={displayTitle}
badge={<StatusBadge status={status} zh={zh} />}
expanded={expanded}
onToggle={onToggle}
>
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{zh ? "选择提供商" : "Select provider"}
</label>
<select
className={INPUT_CLS}
value={selectedProvider}
onChange={(e) => handleProviderChange(e.target.value)}
>
{COMPATIBLE_PROVIDERS.map((p) => (
<option key={p.id} value={p.id} className="bg-card text-foreground">
{p.label}
</option>
))}
</select>
</div>
{selectedProviderData?.docUrl ? (
<button
type="button"
onClick={() => window.open(selectedProviderData.docUrl, "_blank")}
className="inline-flex items-center gap-1 text-[12px] text-primary transition hover:underline"
>
<ArrowSquareOut size={12} />
{zh ? "查看文档" : "View docs"}
</button>
) : null}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
ANTHROPIC_API_KEY
</label>
<input
type="password"
className={INPUT_CLS}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
/>
</div>
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
ANTHROPIC_BASE_URL
</label>
<input
className={INPUT_CLS}
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://api.example.com/v1"
/>
</div>
<ModelToggleList
models={models}
onToggle={(id) => void handleToggleModel(id)}
onRemove={removeModel}
canRemove
zh={zh}
/>
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{zh ? "添加模型" : "Add model"}
</label>
<div className="flex gap-2">
<input
className={INPUT_CLS}
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
placeholder={zh ? "输入模型名称" : "model-name"}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addModel();
}
}}
/>
<button
type="button"
onClick={addModel}
disabled={!newModel.trim()}
className="shrink-0 rounded-lg border border-border bg-card px-3 py-2 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
aria-label={zh ? "添加模型" : "Add model"}
>
<Plus size={14} />
</button>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void handleSave()}
disabled={saving}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
>
<FloppyDisk size={14} />
{saving
? zh
? "保存中..."
: "Saving..."
: zh
? "保存并检查"
: "Save & Check"}
</button>
{isConfigured ? (
<button
type="button"
onClick={() => void handleClear()}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-destructive transition hover:bg-muted"
>
<Trash size={14} />
{zh ? "清除配置" : "Clear"}
</button>
) : null}
</div>
</ProviderCard>
);
}
@@ -1,94 +0,0 @@
import { useState } from "react";
import type { ProfileSnapshot } from "@shared/types";
import type { AppLocale } from "@shared/locale";
import { COMPAT_SLOTS } from "./ModelManagementShared";
import { NativeProviderCard } from "./NativeProviderCard";
import { CompatProviderCard } from "./CompatProviderCard";
/* ------------------------------------------------------------------ */
/* Main component */
/* ------------------------------------------------------------------ */
interface ModelManagementProps {
connectors: ProfileSnapshot["connectors"];
locale: AppLocale;
onSaved: () => Promise<void>;
}
export function ModelManagement({
connectors,
locale,
onSaved,
}: ModelManagementProps) {
const zh = locale === "zh";
const [expandedCard, setExpandedCard] = useState<string | null>(null);
const claudeConnector = connectors.find((c) => c.id === "claude_local");
const codexConnector = connectors.find((c) => c.id === "codex_local");
const geminiConnector = connectors.find((c) => c.id === "gemini_local");
const activeCompatSlots = COMPAT_SLOTS.filter((id) => {
const c = connectors.find((conn) => conn.id === id);
return (
c &&
(c.envBindingText.includes("ANTHROPIC_BASE_URL") ||
c.envBindingText.includes("ANTHROPIC_API_KEY"))
);
});
const nextAvailableSlot =
COMPAT_SLOTS.find((id) => !activeCompatSlots.includes(id)) ?? null;
const visibleCompatSlots = [...activeCompatSlots];
if (nextAvailableSlot && !visibleCompatSlots.includes(nextAvailableSlot)) {
visibleCompatSlots.push(nextAvailableSlot);
}
const toggle = (card: string) =>
setExpandedCard((prev) => (prev === card ? null : card));
return (
<div className="space-y-3">
{claudeConnector ? (
<NativeProviderCard
connector={claudeConnector}
zh={zh}
onSaved={onSaved}
expanded={expandedCard === "claude_local"}
onToggle={() => toggle("claude_local")}
/>
) : null}
{codexConnector ? (
<NativeProviderCard
connector={codexConnector}
zh={zh}
onSaved={onSaved}
expanded={expandedCard === "codex_local"}
onToggle={() => toggle("codex_local")}
/>
) : null}
{geminiConnector ? (
<NativeProviderCard
connector={geminiConnector}
zh={zh}
onSaved={onSaved}
expanded={expandedCard === "gemini_local"}
onToggle={() => toggle("gemini_local")}
/>
) : null}
{visibleCompatSlots.map((slotId) => (
<CompatProviderCard
key={slotId}
slotId={slotId}
connector={connectors.find((c) => c.id === slotId) ?? null}
zh={zh}
onSaved={onSaved}
expanded={expandedCard === slotId}
onToggle={() => toggle(slotId)}
onCleared={() => setExpandedCard(null)}
/>
))}
</div>
);
}
@@ -1,13 +1,5 @@
import { CaretDown, CaretRight } from "../ui/icons";
import type { ProfileSnapshot } from "@shared/types";
import { COMPATIBLE_PROVIDERS } from "../company-onboarding/ProvidersStepShared";
export { COMPATIBLE_PROVIDERS };
export const COMPAT_SLOTS = [
"claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5",
"claude_compat_6", "claude_compat_7", "claude_compat_8", "claude_compat_9", "claude_compat_10",
] as const;
/* ------------------------------------------------------------------ */
/* Shared */
@@ -1,168 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { FloppyDisk, SignIn } from "../ui/icons";
import type { ConnectorRecord } from "@shared/types";
import { unwrap } from "../../lib/desktop";
import {
INPUT_CLS,
ModelToggleList,
ProviderCard,
StatusBadge,
parseEnvValue,
type ModelEntry,
} from "./ModelManagementShared";
/* ------------------------------------------------------------------ */
/* Native provider card (Claude Code / Codex / Gemini) */
/* ------------------------------------------------------------------ */
export function NativeProviderCard({
connector,
zh,
onSaved,
expanded,
onToggle,
}: {
connector: ConnectorRecord;
zh: boolean;
onSaved: () => Promise<void>;
expanded: boolean;
onToggle: () => void;
}) {
const [models, setModels] = useState<ModelEntry[]>([]);
const [saving, setSaving] = useState(false);
const envKeyName = connector.id === "codex_local" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY";
const [apiKey, setApiKey] = useState(() => parseEnvValue(connector.envBindingText, envKeyName));
useEffect(() => {
setApiKey(parseEnvValue(connector.envBindingText, envKeyName));
}, [connector.envBindingText, envKeyName]);
const loadModels = useCallback(async () => {
try {
const [enabledResult, disabledResult] = await Promise.all([
window.agentCompany.listConnectorModels(connector.id),
window.agentCompany.getConnectorDisabledModels(connector.id),
]);
const enabled = enabledResult.ok ? enabledResult.data : [];
const disabled = disabledResult.ok ? disabledResult.data : [];
setModels([
...enabled.map((m) => ({ ...m, enabled: true })),
...disabled.map((m) => ({ ...m, enabled: false })),
]);
} catch {
/* non-fatal */
}
}, [connector.id]);
useEffect(() => {
void loadModels();
}, [loadModels]);
async function handleToggleModel(modelId: string) {
const prev = models;
const updated = models.map((m) =>
m.id === modelId ? { ...m, enabled: !m.enabled } : m,
);
setModels(updated);
const newDisabled = updated
.filter((m) => !m.enabled)
.map((m) => ({ id: m.id, label: m.label }));
try {
unwrap(
await window.agentCompany.setConnectorDisabledModels({
connectorId: connector.id,
disabledModels: newDisabled,
}),
);
await onSaved();
} catch {
setModels(prev);
}
}
async function handleSave() {
setSaving(true);
try {
const lines: string[] = [];
if (apiKey.trim()) lines.push(`${envKeyName}=${apiKey.trim()}`);
unwrap(
await window.agentCompany.saveConnector({
id: connector.id,
command: connector.command,
model: connector.model,
envBindingText: lines.join("\n"),
notes: connector.notes,
}),
);
await window.agentCompany.testConnector(connector.id);
await onSaved();
void loadModels();
} finally {
setSaving(false);
}
}
async function handleLogin() {
await window.agentCompany.openConnectorAuthTerminal(connector.id);
}
const showApiKey = connector.id === "claude_local" || connector.id === "codex_local";
return (
<ProviderCard
title={connector.label}
badge={<StatusBadge status={connector.status} zh={zh} />}
expanded={expanded}
onToggle={onToggle}
>
<button
type="button"
onClick={() => void handleLogin()}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
>
<SignIn size={14} />
{zh ? "登录" : "Login"}
</button>
{showApiKey ? (
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{envKeyName}{" "}
<span className="font-normal">({zh ? "可选" : "optional"})</span>
</label>
<input
type="password"
className={INPUT_CLS}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={connector.id === "codex_local" ? "sk-..." : "sk-ant-..."}
/>
</div>
) : null}
<ModelToggleList
models={models}
onToggle={(id) => void handleToggleModel(id)}
canRemove={false}
zh={zh}
/>
<button
type="button"
onClick={() => void handleSave()}
disabled={saving}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
>
<FloppyDisk size={14} />
{saving
? zh
? "保存中..."
: "Saving..."
: zh
? "保存并检查"
: "Save & Check"}
</button>
</ProviderCard>
);
}