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:
1 parent
6a25379995
commit
7fdd3c16bf
17 files changed
+692
-1422
No files matched your search
@@ -1,243 +1,128 @@
|
||||
import { useState } from "react";
|
||||
import { ArrowRight, FloppyDisk, SignIn } from "../ui/icons";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowRight, CaretDown, CaretRight } from "../ui/icons";
|
||||
import { ActionButton } from "../ui";
|
||||
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
|
||||
import {
|
||||
CompatProviderCard,
|
||||
InstallBlock,
|
||||
ProviderCard,
|
||||
StatusBadge,
|
||||
INPUT_CLS,
|
||||
} from "./ProvidersStepShared";
|
||||
CONNECTOR_CATALOG,
|
||||
listFeaturedOnboardingConnectors,
|
||||
} from "@shared/connector-catalog";
|
||||
import { OnboardingStep } from "./OnboardingStep";
|
||||
import { useOnboarding } from "./OnboardingContext";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Props */
|
||||
/* ------------------------------------------------------------------ */
|
||||
import { OnboardingProviderDispatch } from "./provider-cards/ProviderCard";
|
||||
|
||||
interface ProvidersStepProps {
|
||||
onContinue: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
const FEATURED = listFeaturedOnboardingConnectors();
|
||||
const FEATURED_IDS = new Set(FEATURED.map((entry) => entry.id));
|
||||
const ADVANCED = CONNECTOR_CATALOG.filter((entry) => !FEATURED_IDS.has(entry.id));
|
||||
|
||||
export function ProvidersStep({ onContinue, onBack }: ProvidersStepProps) {
|
||||
const {
|
||||
connectors,
|
||||
onTestConnector,
|
||||
onSaveConnector,
|
||||
onLoginConnector,
|
||||
onInstallCommand,
|
||||
hasAtLeastOneReady,
|
||||
error,
|
||||
t,
|
||||
} = useOnboarding();
|
||||
|
||||
const [expandedCard, setExpandedCard] = useState<string | null>("claude");
|
||||
|
||||
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");
|
||||
|
||||
function parseEnvValue(envText: string, key: string): string {
|
||||
const line = envText.split(/\r?\n/).find((l) => l.startsWith(`${key}=`));
|
||||
return line ? line.slice(key.length + 1) : "";
|
||||
}
|
||||
|
||||
const [claudeApiKey, setClaudeApiKey] = useState(() =>
|
||||
parseEnvValue(claudeConnector?.envBindingText ?? "", "ANTHROPIC_API_KEY"),
|
||||
const { t, error } = useOnboarding();
|
||||
const [credentialsMap, setCredentialsMap] = useState<Map<string, CredentialSummary>>(
|
||||
new Map(),
|
||||
);
|
||||
const [codexApiKey, setCodexApiKey] = useState(() =>
|
||||
parseEnvValue(codexConnector?.envBindingText ?? "", "OPENAI_API_KEY"),
|
||||
const [expandedId, setExpandedId] = useState<string | null>(FEATURED[0]?.id ?? null);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const desktop = window.agentCompany;
|
||||
if (!desktop) return;
|
||||
let cancelled = false;
|
||||
|
||||
const refresh = async () => {
|
||||
const result = await desktop.listCredentials();
|
||||
if (cancelled || !result.ok) return;
|
||||
setCredentialsMap(new Map(result.data.map((row) => [row.connectorId, row])));
|
||||
};
|
||||
|
||||
void refresh();
|
||||
const unsubscribe = desktop.subscribe((event) => {
|
||||
if (event.type !== "credentials-changed") return;
|
||||
void refresh();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const readyCount = useMemo(
|
||||
() => Array.from(credentialsMap.values()).filter((c) => c.status === "active").length,
|
||||
[credentialsMap],
|
||||
);
|
||||
const canContinue = readyCount > 0;
|
||||
|
||||
const compatSlots = COMPAT_SLOTS.map((id) => connectors.find((c) => c.id === id));
|
||||
const activeCompatSlots = COMPAT_SLOTS.filter((id) => {
|
||||
const c = connectors.find((conn) => conn.id === id);
|
||||
return c && c.envBindingText.includes("ANTHROPIC_BASE_URL");
|
||||
});
|
||||
const nextAvailableSlot = COMPAT_SLOTS.find((id) => !activeCompatSlots.includes(id)) ?? null;
|
||||
const visibleCompatSlots = [...activeCompatSlots];
|
||||
if (nextAvailableSlot && !visibleCompatSlots.includes(nextAvailableSlot)) {
|
||||
visibleCompatSlots.push(nextAvailableSlot);
|
||||
}
|
||||
|
||||
const claudeStatus = claudeConnector?.status ?? "not_installed";
|
||||
const codexStatus = codexConnector?.status ?? "not_installed";
|
||||
const geminiStatus = geminiConnector?.status ?? "not_installed";
|
||||
|
||||
const toggle = (card: string) => setExpandedCard((prev) => (prev === card ? null : card));
|
||||
|
||||
function handleClaudeSave() {
|
||||
const lines: string[] = [];
|
||||
if (claudeApiKey.trim()) lines.push(`ANTHROPIC_API_KEY=${claudeApiKey.trim()}`);
|
||||
onSaveConnector("claude_local", lines.join("\n"), null);
|
||||
}
|
||||
|
||||
function handleCodexSave() {
|
||||
const lines: string[] = [];
|
||||
if (codexApiKey.trim()) lines.push(`OPENAI_API_KEY=${codexApiKey.trim()}`);
|
||||
onSaveConnector("codex_local", lines.join("\n"), null);
|
||||
}
|
||||
const toggle = (id: string) =>
|
||||
setExpandedId((current) => (current === id ? null : id));
|
||||
|
||||
return (
|
||||
<OnboardingStep
|
||||
step="providers"
|
||||
eyebrow={t("onboarding.stepLabelProviders")}
|
||||
title={t("onboarding.providersTitle")}
|
||||
subtitle={t("onboarding.providersSubtitle")}
|
||||
title={t("onboarding.providers.title")}
|
||||
subtitle={t("onboarding.providers.subtitle")}
|
||||
>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* ---- Card 1: Claude Code (Official) ---- */}
|
||||
<ProviderCard
|
||||
title={t("onboarding.claudeCodeLabel")}
|
||||
badge={<StatusBadge status={claudeStatus} t={t} />}
|
||||
expanded={expandedCard === "claude"}
|
||||
onToggle={() => toggle("claude")}
|
||||
>
|
||||
{claudeStatus === "not_installed" && (
|
||||
<InstallBlock
|
||||
macLinuxCmd="curl -fsSL https://claude.ai/install.sh | bash"
|
||||
windowsCmd="irm https://claude.ai/install.ps1 | iex"
|
||||
commandKeyUnix="install_claude_unix"
|
||||
commandKeyWin32="install_claude_win32"
|
||||
onInstall={onInstallCommand}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLoginConnector("claude_local")}
|
||||
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} />
|
||||
{t("onboarding.providerLogin")}
|
||||
</button>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{t("onboarding.providerApiKey")} <span className="font-normal">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className={INPUT_CLS}
|
||||
value={claudeApiKey}
|
||||
onChange={(e) => setClaudeApiKey(e.target.value)}
|
||||
placeholder="sk-ant-..."
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClaudeSave}
|
||||
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"
|
||||
>
|
||||
<FloppyDisk size={14} />
|
||||
{t("onboarding.providerSaveCheck")}
|
||||
</button>
|
||||
</ProviderCard>
|
||||
|
||||
{/* ---- Card 2: Codex (Official) ---- */}
|
||||
<ProviderCard
|
||||
title={t("onboarding.codexLabel")}
|
||||
badge={<StatusBadge status={codexStatus} t={t} />}
|
||||
expanded={expandedCard === "codex"}
|
||||
onToggle={() => toggle("codex")}
|
||||
>
|
||||
{codexStatus === "not_installed" && (
|
||||
<InstallBlock
|
||||
macLinuxCmd="npm i -g @openai/codex"
|
||||
windowsCmd={null}
|
||||
commandKeyUnix="install_codex"
|
||||
commandKeyWin32={null}
|
||||
onInstall={onInstallCommand}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLoginConnector("codex_local")}
|
||||
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} />
|
||||
{t("onboarding.providerLogin")}
|
||||
</button>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{t("onboarding.providerApiKey")} (OPENAI_API_KEY)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className={INPUT_CLS}
|
||||
value={codexApiKey}
|
||||
onChange={(e) => setCodexApiKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCodexSave}
|
||||
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"
|
||||
>
|
||||
<FloppyDisk size={14} />
|
||||
{t("onboarding.providerSaveCheck")}
|
||||
</button>
|
||||
</ProviderCard>
|
||||
|
||||
{/* ---- Card 3: Gemini CLI (Official) ---- */}
|
||||
<ProviderCard
|
||||
title={t("onboarding.geminiLabel")}
|
||||
badge={<StatusBadge status={geminiStatus} t={t} />}
|
||||
expanded={expandedCard === "gemini"}
|
||||
onToggle={() => toggle("gemini")}
|
||||
>
|
||||
{geminiStatus === "not_installed" && (
|
||||
<InstallBlock
|
||||
macLinuxCmd="npm install -g @google/gemini-cli"
|
||||
windowsCmd={null}
|
||||
commandKeyUnix="install_gemini"
|
||||
commandKeyWin32={null}
|
||||
onInstall={onInstallCommand}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLoginConnector("gemini_local")}
|
||||
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} />
|
||||
{t("onboarding.providerLogin")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTestConnector("gemini_local")}
|
||||
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"
|
||||
>
|
||||
<FloppyDisk size={14} />
|
||||
{t("onboarding.providerCheck")}
|
||||
</button>
|
||||
</ProviderCard>
|
||||
|
||||
{/* ---- Compatible API providers ---- */}
|
||||
{visibleCompatSlots.map((slotId) => (
|
||||
<CompatProviderCard
|
||||
key={slotId}
|
||||
slotId={slotId}
|
||||
connector={compatSlots[COMPAT_SLOTS.indexOf(slotId)] ?? null}
|
||||
claudeInstalled={claudeStatus !== "not_installed"}
|
||||
onSaveConnector={onSaveConnector}
|
||||
onInstallCommand={onInstallCommand}
|
||||
expanded={expandedCard === slotId}
|
||||
onToggle={() => toggle(slotId)}
|
||||
<section aria-label={t("onboarding.providers.featuredHeading")} className="space-y-3">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h3 className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{t("onboarding.providers.featuredHeading")}
|
||||
</h3>
|
||||
<span className="text-[11px] text-muted-foreground/70">
|
||||
{readyCount > 0
|
||||
? `${readyCount} ${t("onboarding.providers.ready")}`
|
||||
: t("onboarding.providers.noneConfigured")}
|
||||
</span>
|
||||
</div>
|
||||
{FEATURED.map((entry) => (
|
||||
<OnboardingProviderDispatch
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
credential={credentialsMap.get(entry.id) ?? null}
|
||||
expanded={expandedId === entry.id}
|
||||
onToggle={() => toggle(entry.id)}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-label={t("onboarding.providers.advancedHeading")} className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen((value) => !value)}
|
||||
className="flex w-full items-center justify-between rounded-md border border-border bg-card px-4 py-2.5 text-left transition hover:bg-muted/40"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{advancedOpen ? (
|
||||
<CaretDown size={13} className="text-muted-foreground" />
|
||||
) : (
|
||||
<CaretRight size={13} className="text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-[12px] font-semibold text-foreground">
|
||||
{t("onboarding.providers.advancedHeading")}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground/70">
|
||||
{`${ADVANCED.length} ${t("onboarding.providers.providersLabel")}`}
|
||||
</span>
|
||||
</button>
|
||||
{advancedOpen ? (
|
||||
<div className="mt-3 space-y-3">
|
||||
{ADVANCED.map((entry) => (
|
||||
<OnboardingProviderDispatch
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
credential={credentialsMap.get(entry.id) ?? null}
|
||||
expanded={expandedId === entry.id}
|
||||
onToggle={() => toggle(entry.id)}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-5 rounded-lg border border-[color:var(--destructive)] bg-destructive-muted px-4 py-3 text-[13px] text-destructive">
|
||||
@@ -254,17 +139,17 @@ export function ProvidersStep({ onContinue, onBack }: ProvidersStepProps) {
|
||||
{t("onboarding.back")}
|
||||
</button>
|
||||
<div className="flex items-center gap-4">
|
||||
{!hasAtLeastOneReady && (
|
||||
{!canContinue ? (
|
||||
<span className="text-[12px] text-muted-foreground">
|
||||
{t("onboarding.providerAtLeastOne")}
|
||||
{t("onboarding.providers.atLeastOne")}
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<ActionButton
|
||||
label={t("onboarding.continue")}
|
||||
onClick={onContinue}
|
||||
icon={ArrowRight}
|
||||
tone="accent"
|
||||
disabled={!hasAtLeastOneReady}
|
||||
disabled={!canContinue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ArrowSquareOut,
|
||||
CaretDown,
|
||||
CaretRight,
|
||||
FloppyDisk,
|
||||
Terminal,
|
||||
} from "../ui/icons";
|
||||
import type { ProfileSnapshot } from "@shared/types";
|
||||
import type { TerminalCommandKey } from "@shared/contracts";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Compatible-API provider presets */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const COMPATIBLE_PROVIDERS = [
|
||||
{ id: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" },
|
||||
{ id: "minimax_cn", label: "MiniMax (CN)", baseUrl: "https://api.minimaxi.com/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" },
|
||||
{ id: "glm", label: "GLM (ZhiPu)", baseUrl: "https://api.z.ai/api/anthropic", docUrl: "https://docs.z.ai/devpack/tool/claude" },
|
||||
{ id: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api", docUrl: "https://openrouter.ai/docs/guides/coding-agents/claude-code-integration" },
|
||||
{ id: "tencent", label: "Tencent Coding Plan", baseUrl: "https://api.lkeap.cloud.tencent.com/coding/anthropic", docUrl: "https://cloud.tencent.com/document/product/1772/128951" },
|
||||
{ id: "poe", label: "POE", baseUrl: "https://api.poe.com", docUrl: "https://creator.poe.com/docs/external-applications/claude-code" },
|
||||
{ id: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/anthropic", docUrl: "https://api-docs.deepseek.com/guides/anthropic_api" },
|
||||
{ id: "alibaba", label: "Alibaba Coding Plan", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan" },
|
||||
{ id: "alibaba_cn", label: "Alibaba Coding Plan (CN)", baseUrl: "https://coding.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://help.aliyun.com/zh/model-studio/claude-code-coding-plan" },
|
||||
{ id: "bailing", label: "BaiLing", baseUrl: "https://api.tbox.cn/api/anthropic", docUrl: "https://alipaytbox.yuque.com/sxs0ba/ling/best_practices_claudecode" },
|
||||
{ id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", docUrl: "https://docs.nano-gpt.com/integrations/claude-code#manual-configuration-all-platforms" },
|
||||
{ id: "zhipu_cn", label: "ZhiPu (CN)", baseUrl: "https://open.bigmodel.cn/api/anthropic", docUrl: "https://docs.bigmodel.cn/cn/coding-plan/tool/claude" },
|
||||
{ id: "ollama", label: "Ollama (Local)", baseUrl: "http://localhost:11434", docUrl: "https://docs.ollama.com/integrations/claude-code" },
|
||||
{ id: "custom", label: "Custom", baseUrl: "", docUrl: "" },
|
||||
] as const;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shared input class */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const INPUT_CLS =
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type ConnectorStatus = ProfileSnapshot["connectors"][number]["status"];
|
||||
|
||||
export interface ProvidersStepSaveConnector {
|
||||
(connectorId: string, envBindingText: string, model: string | null, notes?: string): void;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Status badge */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function StatusBadge({ status, t }: { status: ConnectorStatus; t: (key: string) => string }) {
|
||||
if (status === "ready") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-success-muted px-2 py-0.5 text-[11px] font-medium text-success">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-success" />
|
||||
{t("onboarding.providerReady")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "not_installed") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground" />
|
||||
{t("onboarding.providerNotInstalled")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "auth_required") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-warning-muted px-2 py-0.5 text-[11px] font-medium text-warning">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-warning" />
|
||||
{t("onboarding.providerAuthRequired")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "detected" || status === "checking" as string) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
<span className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-muted-foreground border-t-primary" />
|
||||
{t("onboarding.providerChecking")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-caution-muted px-2 py-0.5 text-[11px] font-medium text-caution">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-caution" />
|
||||
{status.replaceAll("_", " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Provider card wrapper */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function ProviderCard({
|
||||
title,
|
||||
badge,
|
||||
expanded,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
badge: React.ReactNode;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex w-full cursor-pointer items-center justify-between px-4 py-3 transition hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{expanded ? <CaretDown size={14} className="text-muted-foreground" /> : <CaretRight size={14} className="text-muted-foreground" />}
|
||||
<span className="text-[13px] font-semibold text-foreground">{title}</span>
|
||||
</div>
|
||||
{badge}
|
||||
</button>
|
||||
{expanded && <div className="space-y-3 px-4 pb-4">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Install snippet */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function InstallBlock({
|
||||
macLinuxCmd,
|
||||
windowsCmd,
|
||||
commandKeyUnix,
|
||||
commandKeyWin32,
|
||||
onInstall,
|
||||
t,
|
||||
}: {
|
||||
macLinuxCmd: string;
|
||||
windowsCmd: string | null;
|
||||
commandKeyUnix: TerminalCommandKey;
|
||||
commandKeyWin32: TerminalCommandKey | null;
|
||||
onInstall: (commandKey: TerminalCommandKey) => void;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const platform = window.agentCompany?.platform ?? "linux";
|
||||
const isWindows = platform === "win32";
|
||||
const commandKey = isWindows && commandKeyWin32 ? commandKeyWin32 : commandKeyUnix;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[12px] font-medium text-muted-foreground">{t("onboarding.providerInstall")}</div>
|
||||
{!isWindows && (
|
||||
<div className="rounded-lg border border-border bg-muted px-3 py-2 font-mono text-[12px] text-foreground">
|
||||
{macLinuxCmd}
|
||||
</div>
|
||||
)}
|
||||
{isWindows && windowsCmd && (
|
||||
<div className="rounded-lg border border-border bg-muted px-3 py-2 font-mono text-[12px] text-foreground">
|
||||
{windowsCmd}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onInstall(commandKey)}
|
||||
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"
|
||||
>
|
||||
<Terminal size={14} />
|
||||
{t("onboarding.providerInstall")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Compatible provider card (one per slot) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function CompatProviderCard({
|
||||
slotId,
|
||||
connector,
|
||||
claudeInstalled,
|
||||
onSaveConnector,
|
||||
onInstallCommand,
|
||||
expanded,
|
||||
onToggle,
|
||||
t,
|
||||
}: {
|
||||
slotId: string;
|
||||
connector: ProfileSnapshot["connectors"][number] | null;
|
||||
claudeInstalled: boolean;
|
||||
onSaveConnector: ProvidersStepSaveConnector;
|
||||
onInstallCommand: (commandKey: TerminalCommandKey) => void;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
function parseEnv(text: string, key: string): string {
|
||||
const line = text.split(/\r?\n/).find((l) => l.startsWith(`${key}=`));
|
||||
return line ? line.slice(key.length + 1) : "";
|
||||
}
|
||||
|
||||
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(() => parseEnv(connector?.envBindingText ?? "", "ANTHROPIC_API_KEY"));
|
||||
const [baseUrl, setBaseUrl] = useState<string>(() =>
|
||||
parseEnv(connector?.envBindingText ?? "", "ANTHROPIC_BASE_URL") || COMPATIBLE_PROVIDERS[0].baseUrl,
|
||||
);
|
||||
const [models, setModels] = useState<string[]>(() => {
|
||||
const raw = connector?.model ?? "";
|
||||
return raw.split(",").map((m) => m.trim()).filter(Boolean);
|
||||
});
|
||||
const [newModel, setNewModel] = useState("");
|
||||
|
||||
const status = connector?.status ?? "not_installed";
|
||||
const providerLabel = COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider)?.label ?? t("onboarding.claudeCompatibleLabel");
|
||||
const displayTitle = connector?.notes || providerLabel;
|
||||
|
||||
function handleProviderChange(providerId: string) {
|
||||
setSelectedProvider(providerId);
|
||||
const provider = COMPATIBLE_PROVIDERS.find((p) => p.id === providerId);
|
||||
if (provider) setBaseUrl(provider.baseUrl);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
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 allModels = [...models];
|
||||
const pending = newModel.trim();
|
||||
if (pending && !allModels.includes(pending)) {
|
||||
allModels.push(pending);
|
||||
setModels(allModels);
|
||||
setNewModel("");
|
||||
}
|
||||
const modelsCsv = allModels.join(",") || null;
|
||||
onSaveConnector(slotId, lines.join("\n"), modelsCsv, label);
|
||||
}
|
||||
|
||||
function addModel() {
|
||||
const trimmed = newModel.trim();
|
||||
if (!trimmed) return;
|
||||
setModels((prev) => {
|
||||
if (prev.includes(trimmed)) return prev;
|
||||
return [...prev, trimmed];
|
||||
});
|
||||
setNewModel("");
|
||||
}
|
||||
|
||||
const selectedProviderData = COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider);
|
||||
|
||||
return (
|
||||
<ProviderCard
|
||||
title={displayTitle}
|
||||
badge={<StatusBadge status={status} t={t} />}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
{!claudeInstalled && (
|
||||
<InstallBlock
|
||||
macLinuxCmd="curl -fsSL https://claude.ai/install.sh | bash"
|
||||
windowsCmd="irm https://claude.ai/install.ps1 | iex"
|
||||
commandKeyUnix="install_claude_unix"
|
||||
commandKeyWin32="install_claude_win32"
|
||||
onInstall={onInstallCommand}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{t("onboarding.providerSelectProvider")}
|
||||
</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} />
|
||||
{t("onboarding.providerViewDocs")}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("onboarding.providerApiKey")}</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">{t("onboarding.providerBaseUrl")}</label>
|
||||
<input className={INPUT_CLS} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://api.example.com/v1" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t("onboarding.providerModel")}</label>
|
||||
{models.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{models.map((m) => (
|
||||
<span key={m} className="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-[12px] text-foreground">
|
||||
{m}
|
||||
<button type="button" onClick={() => setModels(models.filter((x) => x !== m))} className="ml-0.5 text-muted-foreground hover:text-destructive">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className={INPUT_CLS}
|
||||
value={newModel}
|
||||
onChange={(e) => setNewModel(e.target.value)}
|
||||
placeholder="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"
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
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"
|
||||
>
|
||||
<FloppyDisk size={14} />
|
||||
{t("onboarding.providerSaveCheck")}
|
||||
</button>
|
||||
</ProviderCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ConnectorCatalogEntry } from "@shared/connector-catalog";
|
||||
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
|
||||
import type { TranslationFn } from "../../../i18n";
|
||||
import { OAuthProviderCard } from "./oauth-card";
|
||||
import { ApiKeyProviderCard } from "./api-key-card";
|
||||
import { CloudProviderCard } from "./cloud-card";
|
||||
|
||||
export interface ProviderCardProps {
|
||||
entry: ConnectorCatalogEntry;
|
||||
credential: CredentialSummary | null;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
t: TranslationFn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch based on the catalog auth descriptor. Five auth kinds live behind
|
||||
* three dedicated cards — cloud variants share a card because their onboarding
|
||||
* affordance is identical (deep link to Settings). A universal form generator
|
||||
* is intentionally avoided so each auth flow stays legible end-to-end.
|
||||
*/
|
||||
export function OnboardingProviderDispatch(props: ProviderCardProps) {
|
||||
const { entry } = props;
|
||||
switch (entry.auth.type) {
|
||||
case "oauth":
|
||||
return <OAuthProviderCard {...props} />;
|
||||
case "api_key":
|
||||
return <ApiKeyProviderCard {...props} />;
|
||||
case "aws":
|
||||
case "azure":
|
||||
case "gcp_adc":
|
||||
return <CloudProviderCard {...props} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useState } from "react";
|
||||
import { FloppyDisk } from "../../ui/icons";
|
||||
import type { ConnectorCatalogEntry } from "@shared/connector-catalog";
|
||||
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
|
||||
import type { TranslationFn } from "../../../i18n";
|
||||
import {
|
||||
INPUT_CLS,
|
||||
InlineFeedback,
|
||||
OnboardingProviderCard,
|
||||
OnboardingStatusBadge,
|
||||
type OnboardingCardStatus,
|
||||
} from "./shared";
|
||||
|
||||
interface ApiKeyCardProps {
|
||||
entry: ConnectorCatalogEntry;
|
||||
credential: CredentialSummary | null;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
t: TranslationFn;
|
||||
}
|
||||
|
||||
export function ApiKeyProviderCard({
|
||||
entry,
|
||||
credential,
|
||||
expanded,
|
||||
onToggle,
|
||||
t,
|
||||
}: ApiKeyCardProps) {
|
||||
if (entry.auth.type !== "api_key") {
|
||||
throw new Error(`ApiKeyProviderCard received non-api_key connector: ${entry.id}`);
|
||||
}
|
||||
const envKey = entry.auth.envKey;
|
||||
const [value, setValue] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
|
||||
const status: OnboardingCardStatus = busy
|
||||
? "checking"
|
||||
: credential?.status ?? "not_configured";
|
||||
const connected = credential?.status === "active";
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!window.agentCompany) return;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) {
|
||||
setFeedback(t("onboarding.providers.apiKeyRequired"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setFeedback(null);
|
||||
const result = await window.agentCompany.saveCredential({
|
||||
connectorId: entry.id,
|
||||
payload: {
|
||||
kind: "api_key",
|
||||
value: { source: "literal", value: trimmed },
|
||||
},
|
||||
});
|
||||
setBusy(false);
|
||||
if (!result.ok) {
|
||||
setFeedback(result.error.message);
|
||||
return;
|
||||
}
|
||||
setValue("");
|
||||
setFeedback(t("onboarding.providers.saved"));
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
if (!window.agentCompany) return;
|
||||
setBusy(true);
|
||||
setFeedback(null);
|
||||
const result = await window.agentCompany.removeCredential({ connectorId: entry.id });
|
||||
setBusy(false);
|
||||
if (!result.ok) setFeedback(result.error.message);
|
||||
};
|
||||
|
||||
return (
|
||||
<OnboardingProviderCard
|
||||
label={entry.label}
|
||||
description={entry.description}
|
||||
badge={<OnboardingStatusBadge status={status} t={t} />}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
{entry.notes ? (
|
||||
<div className="text-[12px] leading-relaxed text-muted-foreground">{entry.notes}</div>
|
||||
) : null}
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleSave();
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{t("onboarding.providers.apiKeyLabel")}{" "}
|
||||
<span className="font-mono text-[11px] text-muted-foreground/70">({envKey})</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className={INPUT_CLS}
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
placeholder={connected ? t("onboarding.providers.apiKeyReplacePlaceholder") : ""}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || value.trim().length === 0}
|
||||
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} />
|
||||
{busy ? t("onboarding.providers.saving") : t("onboarding.providers.save")}
|
||||
</button>
|
||||
{connected ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRemove()}
|
||||
disabled={busy}
|
||||
className="text-[12px] text-muted-foreground transition hover:text-destructive disabled:opacity-40"
|
||||
>
|
||||
{t("onboarding.providers.disconnect")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
{credential?.displayHint ? (
|
||||
<div className="text-[11px] text-muted-foreground">{credential.displayHint}</div>
|
||||
) : null}
|
||||
<InlineFeedback message={feedback} />
|
||||
</OnboardingProviderCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ArrowSquareOut } from "../../ui/icons";
|
||||
import type { ConnectorCatalogEntry } from "@shared/connector-catalog";
|
||||
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
|
||||
import type { TranslationFn } from "../../../i18n";
|
||||
import {
|
||||
InlineFeedback,
|
||||
OnboardingProviderCard,
|
||||
OnboardingStatusBadge,
|
||||
type OnboardingCardStatus,
|
||||
} from "./shared";
|
||||
|
||||
interface CloudCardProps {
|
||||
entry: ConnectorCatalogEntry;
|
||||
credential: CredentialSummary | null;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
t: TranslationFn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloud connectors (AWS / Azure / GCP ADC) need multi-field forms. During
|
||||
* onboarding we show a short primer and route the user to Settings where the
|
||||
* dedicated forms live. Status reflects an existing credential if one was
|
||||
* already set up outside onboarding.
|
||||
*/
|
||||
export function CloudProviderCard({
|
||||
entry,
|
||||
credential,
|
||||
expanded,
|
||||
onToggle,
|
||||
onOpenSettings,
|
||||
t,
|
||||
}: CloudCardProps) {
|
||||
const status: OnboardingCardStatus = credential?.status ?? "not_configured";
|
||||
|
||||
return (
|
||||
<OnboardingProviderCard
|
||||
label={entry.label}
|
||||
description={entry.description}
|
||||
badge={<OnboardingStatusBadge status={status} t={t} />}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
{entry.notes ? (
|
||||
<div className="text-[12px] leading-relaxed text-muted-foreground">{entry.notes}</div>
|
||||
) : null}
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/30 px-3 py-2.5 text-[12px] leading-relaxed text-muted-foreground">
|
||||
{t("onboarding.providers.cloudPrimer")}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
disabled={!onOpenSettings}
|
||||
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"
|
||||
>
|
||||
<ArrowSquareOut size={14} />
|
||||
{t("onboarding.providers.configureInSettings")}
|
||||
</button>
|
||||
{credential?.displayHint ? (
|
||||
<div className="text-[11px] text-muted-foreground">{credential.displayHint}</div>
|
||||
) : null}
|
||||
<InlineFeedback message={null} />
|
||||
</OnboardingProviderCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from "react";
|
||||
import { SignIn } from "../../ui/icons";
|
||||
import type { ConnectorCatalogEntry } from "@shared/connector-catalog";
|
||||
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
|
||||
import type { TranslationFn } from "../../../i18n";
|
||||
import {
|
||||
InlineFeedback,
|
||||
OnboardingProviderCard,
|
||||
OnboardingStatusBadge,
|
||||
type OnboardingCardStatus,
|
||||
} from "./shared";
|
||||
|
||||
interface OAuthCardProps {
|
||||
entry: ConnectorCatalogEntry;
|
||||
credential: CredentialSummary | null;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
t: TranslationFn;
|
||||
}
|
||||
|
||||
export function OAuthProviderCard({
|
||||
entry,
|
||||
credential,
|
||||
expanded,
|
||||
onToggle,
|
||||
t,
|
||||
}: OAuthCardProps) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
|
||||
const status: OnboardingCardStatus = busy
|
||||
? "checking"
|
||||
: credential?.status ?? "not_configured";
|
||||
const connected = credential?.status === "active";
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!window.agentCompany) return;
|
||||
setBusy(true);
|
||||
setFeedback(null);
|
||||
const result = await window.agentCompany.startOAuth({ connectorId: entry.id });
|
||||
setBusy(false);
|
||||
if (!result.ok) setFeedback(result.error.message);
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
if (!window.agentCompany) return;
|
||||
setBusy(true);
|
||||
setFeedback(null);
|
||||
const result = await window.agentCompany.removeCredential({ connectorId: entry.id });
|
||||
setBusy(false);
|
||||
if (!result.ok) setFeedback(result.error.message);
|
||||
};
|
||||
|
||||
return (
|
||||
<OnboardingProviderCard
|
||||
label={entry.label}
|
||||
description={entry.description}
|
||||
badge={<OnboardingStatusBadge status={status} t={t} />}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
{entry.notes ? (
|
||||
<div className="text-[12px] leading-relaxed text-muted-foreground">{entry.notes}</div>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleLogin()}
|
||||
disabled={busy}
|
||||
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"
|
||||
>
|
||||
<SignIn size={14} />
|
||||
{busy
|
||||
? t("onboarding.providers.signingIn")
|
||||
: connected
|
||||
? t("onboarding.providers.reconnect")
|
||||
: t("onboarding.providers.signIn")}
|
||||
</button>
|
||||
{connected ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRemove()}
|
||||
disabled={busy}
|
||||
className="text-[12px] text-muted-foreground transition hover:text-destructive disabled:opacity-40"
|
||||
>
|
||||
{t("onboarding.providers.disconnect")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{credential?.displayHint ? (
|
||||
<div className="text-[11px] text-muted-foreground">{credential.displayHint}</div>
|
||||
) : null}
|
||||
<InlineFeedback message={feedback} />
|
||||
</OnboardingProviderCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { CaretDown, CaretRight } from "../../ui/icons";
|
||||
import type { ReactNode } from "react";
|
||||
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
|
||||
import type { TranslationFn } from "../../../i18n";
|
||||
|
||||
export const INPUT_CLS =
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground";
|
||||
|
||||
export type OnboardingCardStatus = CredentialSummary["status"] | "checking" | "not_configured";
|
||||
|
||||
export function OnboardingProviderCard({
|
||||
label,
|
||||
description,
|
||||
badge,
|
||||
expanded,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
badge: ReactNode;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex w-full cursor-pointer items-start justify-between gap-3 px-4 py-3 text-left transition hover:bg-muted/40"
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
{expanded ? (
|
||||
<CaretDown size={14} className="mt-1 flex-shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<CaretRight size={14} className="mt-1 flex-shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13px] font-semibold text-foreground">{label}</div>
|
||||
<div className="mt-0.5 text-[12px] leading-snug text-muted-foreground line-clamp-2">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 pt-0.5">{badge}</div>
|
||||
</button>
|
||||
{expanded ? <div className="space-y-3 px-4 pb-4">{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OnboardingStatusBadge({
|
||||
status,
|
||||
t,
|
||||
}: {
|
||||
status: OnboardingCardStatus;
|
||||
t: TranslationFn;
|
||||
}) {
|
||||
if (status === "active") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-success-muted px-2 py-0.5 text-[11px] font-medium text-success">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-success" />
|
||||
{t("onboarding.providers.statusReady")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "expired") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-warning-muted px-2 py-0.5 text-[11px] font-medium text-warning">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-warning" />
|
||||
{t("onboarding.providers.statusExpired")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "error" || status === "revoked") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-caution-muted px-2 py-0.5 text-[11px] font-medium text-caution">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-caution" />
|
||||
{t("onboarding.providers.statusError")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "checking") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
<span className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-muted-foreground border-t-primary" />
|
||||
{t("onboarding.providers.statusChecking")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground" />
|
||||
{t("onboarding.providers.statusNotConfigured")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function InlineFeedback({ message }: { message: string | null }) {
|
||||
if (!message) return null;
|
||||
return <div className="pt-1 text-[11px] leading-relaxed text-muted-foreground">{message}</div>;
|
||||
}
|
||||
@@ -1,251 +1,103 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CheckCircle, SignIn } from "./ui/icons";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ConnectorRecord } from "@shared/types";
|
||||
import { unwrap } from "../lib/desktop";
|
||||
import { ActionButton, InlineNotice, Input, Select, StatusPill, TextArea } from "./ui";
|
||||
import { useT } from "../i18n";
|
||||
import { findConnectorCatalogEntry } from "@shared/connector-catalog";
|
||||
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
|
||||
import { InlineNotice, StatusPill } from "./ui";
|
||||
|
||||
function getAuthActionCopy(connector: ConnectorRecord) {
|
||||
switch (connector.id) {
|
||||
case "codex_local":
|
||||
return {
|
||||
title: "Codex login required",
|
||||
message: "Codex is installed, but the CLI needs an authenticated session before AgentCompany can dispatch runs.",
|
||||
buttonLabel: "Open Terminal To Sign In",
|
||||
};
|
||||
case "claude_local":
|
||||
return {
|
||||
title: "Claude Code login required",
|
||||
message: "Claude Code is installed, but the local CLI is not authenticated for this desktop runtime.",
|
||||
buttonLabel: "Open Terminal To Sign In",
|
||||
};
|
||||
case "gemini_local":
|
||||
return {
|
||||
title: "Gemini authentication required",
|
||||
message: "Gemini CLI is available, but the runtime still needs an authenticated session. AgentCompany will open Gemini in a terminal and you can complete its interactive auth flow there.",
|
||||
buttonLabel: "Open Terminal",
|
||||
};
|
||||
}
|
||||
}
|
||||
const AUTH_KIND_LABEL: Record<string, string> = {
|
||||
oauth: "OAuth",
|
||||
api_key: "API key",
|
||||
aws: "AWS",
|
||||
azure: "Azure",
|
||||
gcp_adc: "Google Vertex (ADC)",
|
||||
};
|
||||
|
||||
export function ConnectorEditor({ connector, onSaved }: { connector: ConnectorRecord; onSaved: () => Promise<void> }) {
|
||||
const t = useT();
|
||||
const [model, setModel] = useState(connector.model ?? "");
|
||||
const [envBindingText, setEnvBindingText] = useState(connector.envBindingText);
|
||||
const [notes, setNotes] = useState(connector.notes);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [openingAuthTerminal, setOpeningAuthTerminal] = useState(false);
|
||||
const [autoChecking, setAutoChecking] = useState(false);
|
||||
const [modelOptions, setModelOptions] = useState<Array<{ id: string; label: string }>>([]);
|
||||
const [loadingModelOptions, setLoadingModelOptions] = useState(false);
|
||||
const [modelOptionsError, setModelOptionsError] = useState<string | null>(null);
|
||||
const authPollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const authActionCopy = getAuthActionCopy(connector);
|
||||
const needsLogin = connector.authState === "required" || connector.status === "auth_required";
|
||||
const shouldLoadModelOptions = connector.capabilityMatrix.modelDiscovery;
|
||||
|
||||
useEffect(() => {
|
||||
setModel(connector.model ?? "");
|
||||
setEnvBindingText(connector.envBindingText);
|
||||
setNotes(connector.notes);
|
||||
setSubmitError(null);
|
||||
}, [connector]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (authPollTimeoutRef.current) {
|
||||
clearTimeout(authPollTimeoutRef.current);
|
||||
authPollTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
/**
|
||||
* Legacy connector detail view. Real configuration happens in
|
||||
* Settings → Providers. Here we only surface catalog metadata and the current
|
||||
* credential status pulled from the pi-mono credential store.
|
||||
*/
|
||||
export function ConnectorEditor({
|
||||
connector,
|
||||
}: {
|
||||
connector: ConnectorRecord;
|
||||
onSaved: () => Promise<void>;
|
||||
}) {
|
||||
const [credential, setCredential] = useState<CredentialSummary | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const catalog = findConnectorCatalogEntry(connector.id);
|
||||
|
||||
useEffect(() => {
|
||||
const desktop = window.agentCompany;
|
||||
if (!desktop) return;
|
||||
let cancelled = false;
|
||||
if (!shouldLoadModelOptions) {
|
||||
setModelOptions([]);
|
||||
setModelOptionsError(null);
|
||||
setLoadingModelOptions(false);
|
||||
return;
|
||||
}
|
||||
setLoadingModelOptions(true);
|
||||
setModelOptionsError(null);
|
||||
void window.agentCompany.listConnectorModels(connector.id).then((result) => {
|
||||
const refresh = async () => {
|
||||
const result = await desktop.listCredentials();
|
||||
if (cancelled) return;
|
||||
if (!result.ok) {
|
||||
setModelOptions([]);
|
||||
setModelOptionsError(result.error.message);
|
||||
setLoadingModelOptions(false);
|
||||
setError(result.error.message);
|
||||
return;
|
||||
}
|
||||
setModelOptions(result.data);
|
||||
setLoadingModelOptions(false);
|
||||
setCredential(result.data.find((row) => row.connectorId === connector.id) ?? null);
|
||||
};
|
||||
void refresh();
|
||||
const unsubscribe = desktop.subscribe((event) => {
|
||||
if (event.type !== "credentials-changed") return;
|
||||
void refresh();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [connector.id, shouldLoadModelOptions]);
|
||||
|
||||
const modelSelectOptions = useMemo(() => {
|
||||
const options = [{ value: "", label: "Use connector default" }];
|
||||
const hasCurrentModel = modelOptions.some((option) => option.id === model);
|
||||
if (model && !hasCurrentModel) {
|
||||
options.push({ value: model, label: `${model} (saved)` });
|
||||
}
|
||||
options.push(...modelOptions.map((option) => ({ value: option.id, label: option.label || option.id })));
|
||||
return options;
|
||||
}, [model, modelOptions]);
|
||||
|
||||
const runHealthCheck = useCallback(async (quiet = false) => {
|
||||
setAutoChecking(true);
|
||||
try {
|
||||
unwrap(await window.agentCompany.testConnector(connector.id));
|
||||
await onSaved();
|
||||
if (!quiet) {
|
||||
setSubmitError(null);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!quiet) {
|
||||
setSubmitError(error instanceof Error ? error.message : "Automatic connector health check failed.");
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
setAutoChecking(false);
|
||||
}
|
||||
}, [connector.id, onSaved]);
|
||||
|
||||
const scheduleAuthPolling = useCallback((remainingAttempts: number) => {
|
||||
if (authPollTimeoutRef.current) {
|
||||
clearTimeout(authPollTimeoutRef.current);
|
||||
authPollTimeoutRef.current = null;
|
||||
}
|
||||
if (remainingAttempts <= 0) {
|
||||
return;
|
||||
}
|
||||
authPollTimeoutRef.current = setTimeout(() => {
|
||||
void (async () => {
|
||||
const ok = await runHealthCheck(true);
|
||||
if (!ok) {
|
||||
scheduleAuthPolling(remainingAttempts - 1);
|
||||
}
|
||||
})();
|
||||
}, 5000);
|
||||
}, [runHealthCheck]);
|
||||
}, [connector.id]);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{submitError ? <InlineNotice message={submitError} /> : null}
|
||||
{needsLogin && authActionCopy ? (
|
||||
<div className="rounded-lg border border-[color:var(--destructive)] bg-warning-muted px-4 py-4 text-[12px] text-warning">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em]">{authActionCopy.title}</div>
|
||||
<div className="mt-1.5 leading-relaxed">{authActionCopy.message}</div>
|
||||
<div className="mt-3">
|
||||
<ActionButton
|
||||
label={authActionCopy.buttonLabel}
|
||||
icon={SignIn}
|
||||
onClick={() => void (async () => {
|
||||
setOpeningAuthTerminal(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
unwrap(await window.agentCompany.openConnectorAuthTerminal(connector.id));
|
||||
scheduleAuthPolling(12);
|
||||
} catch (error) {
|
||||
setSubmitError(error instanceof Error ? error.message : "Could not open a terminal for connector login.");
|
||||
} finally {
|
||||
setOpeningAuthTerminal(false);
|
||||
}
|
||||
})()}
|
||||
loading={openingAuthTerminal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? <InlineNotice message={error} /> : null}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">{t("conn.status")}</div>
|
||||
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">Status</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusPill status={connector.status} />
|
||||
<span className="text-[12px] text-muted-foreground">{connector.version ?? "version unknown"}</span>
|
||||
<span className="text-[12px] text-muted-foreground">
|
||||
{credential ? credential.status : "not configured"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">{t("conn.capabilityMatrix")}</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(connector.capabilityMatrix).map(([key, value]) => (
|
||||
<div key={key} className={`rounded-lg border px-3 py-2 text-[12px] ${value ? "border-[color:var(--primary)] border-opacity-20 bg-accent text-primary" : "border-border bg-muted text-muted-foreground"}`}>
|
||||
{key}
|
||||
</div>
|
||||
))}
|
||||
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">Auth</div>
|
||||
<div className="text-[13px] text-foreground">
|
||||
{catalog ? AUTH_KIND_LABEL[catalog.auth.type] ?? catalog.auth.type : "Unknown"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Input label="Command" value={connector.command} onChange={() => undefined} readOnly />
|
||||
<div className="mt-1.5 text-[12px] leading-relaxed text-muted-foreground">
|
||||
AgentCompany pins connector execution to the audited binary for this runtime. Command overrides are not accepted from the renderer.
|
||||
</div>
|
||||
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">Provider id</div>
|
||||
<div className="font-mono text-[12px] text-foreground">{catalog?.provider ?? connector.id}</div>
|
||||
</div>
|
||||
{shouldLoadModelOptions ? (
|
||||
<div className="grid gap-2">
|
||||
<Select
|
||||
label="Model"
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
options={modelSelectOptions}
|
||||
/>
|
||||
{loadingModelOptions ? (
|
||||
<div className="text-[12px] text-muted-foreground">Loading available models...</div>
|
||||
) : null}
|
||||
{modelOptionsError ? (
|
||||
<InlineNotice message={modelOptionsError} />
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<Input label="Model" value={model} onChange={setModel} placeholder="Connector default" />
|
||||
)}
|
||||
<TextArea label="Secret bindings" value={envBindingText} onChange={setEnvBindingText} rows={4} placeholder={"OPENAI_API_KEY=my-vault-key\nANTHROPIC_API_KEY=my-anthropic-key"} />
|
||||
<TextArea label="Operational notes" value={notes} onChange={setNotes} rows={4} />
|
||||
<div className="rounded-lg border border-border bg-muted px-4 py-3 text-[12px] text-muted-foreground">
|
||||
Connector health checks run automatically on app launch, after save, and after terminal login is opened.
|
||||
{autoChecking ? " Refreshing connector state..." : ""}
|
||||
</div>
|
||||
{connector.lastError ? (
|
||||
<div className="rounded-lg border border-border bg-muted px-4 py-3 text-[12px] leading-relaxed text-muted-foreground">
|
||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Latest health-check detail</div>
|
||||
<div>{connector.lastError}</div>
|
||||
{catalog?.description ? (
|
||||
<div>
|
||||
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">Description</div>
|
||||
<div className="text-[12px] leading-relaxed text-muted-foreground">{catalog.description}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex gap-3">
|
||||
<ActionButton
|
||||
label={t("conn.saveConnector")}
|
||||
tone="accent"
|
||||
onClick={() => void (async () => {
|
||||
setSaving(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
unwrap(await window.agentCompany.saveConnector({
|
||||
id: connector.id,
|
||||
command: connector.command,
|
||||
model: model || null,
|
||||
envBindingText,
|
||||
notes,
|
||||
}));
|
||||
await onSaved();
|
||||
await runHealthCheck(true);
|
||||
} catch (error) {
|
||||
setSubmitError(error instanceof Error ? error.message : "Connector could not be saved.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
})()}
|
||||
icon={CheckCircle}
|
||||
loading={saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-muted px-4 py-4 text-[12px] leading-relaxed text-muted-foreground">
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Configuration doc</div>
|
||||
<pre className="font-mono whitespace-pre-wrap">{connector.configurationDoc}</pre>
|
||||
{catalog?.notes ? (
|
||||
<div>
|
||||
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">Notes</div>
|
||||
<div className="text-[12px] leading-relaxed text-muted-foreground">{catalog.notes}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="rounded-lg border border-border bg-muted px-4 py-3 text-[12px] leading-relaxed text-muted-foreground">
|
||||
Manage credentials in Settings → Providers. Status and OAuth flows are handled by the pi-mono credential store.
|
||||
</div>
|
||||
{credential?.lastError ? (
|
||||
<div className="rounded-lg border border-border bg-muted px-4 py-3 text-[12px] leading-relaxed text-muted-foreground">
|
||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||
Latest credential error
|
||||
</div>
|
||||
<div>{credential.lastError}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,6 @@ export { WorkspaceList } from "./execution/WorkspaceList"
|
||||
export { WorkspaceInspector } from "./execution/WorkspaceInspector"
|
||||
export { WorkspaceCreator } from "./execution/WorkspaceCreator"
|
||||
export { SettingsPane } from "./execution/SettingsPane"
|
||||
export { ModelManagement } from "./execution/ModelManagement"
|
||||
export { PendingReviewList } from "./execution/PendingReviewList"
|
||||
export { PolicyList } from "./execution/PolicyList"
|
||||
export { PolicyFormModal } from "./execution/PolicyFormModal"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -6,9 +6,25 @@ import type {
|
||||
DepartmentId,
|
||||
} from "@shared/types";
|
||||
import { isConnectorExecutionReady } from "@shared/connector-policy";
|
||||
import { findConnectorCatalogEntry } from "@shared/connector-catalog";
|
||||
import { safeJson } from "../../../lib/safe-json";
|
||||
import { getBudgetPolicyAmountDollars } from "../../../lib/budget-policy-input";
|
||||
|
||||
/**
|
||||
* Connectors whose models accept a thinking-effort hint. Derived from the
|
||||
* pi-mono catalog provider id so renderer stays in sync with Agent A's data
|
||||
* without hard-coding hyphen-vs-underscore id literals.
|
||||
*/
|
||||
function connectorSupportsThinkingEffort(connectorId: string): boolean {
|
||||
const entry = findConnectorCatalogEntry(connectorId);
|
||||
if (!entry) return false;
|
||||
return (
|
||||
entry.provider === "anthropic" ||
|
||||
entry.provider === "openai" ||
|
||||
entry.provider === "openai-codex"
|
||||
);
|
||||
}
|
||||
|
||||
export type AgentFormMode = "create" | "edit";
|
||||
|
||||
export interface UseAgentFormArgs {
|
||||
@@ -90,10 +106,7 @@ export function useAgentForm({
|
||||
typeof existingMeta.thinkingEffort === "string" ? existingMeta.thinkingEffort : "",
|
||||
);
|
||||
|
||||
const supportsEffort =
|
||||
connectorId === "claude_local" ||
|
||||
connectorId === "codex_local" ||
|
||||
(mode === "edit" && typeof connectorId === "string" && connectorId.startsWith("claude_compat"));
|
||||
const supportsEffort = connectorSupportsThinkingEffort(connectorId);
|
||||
|
||||
const selectedConnectorRecord =
|
||||
(mode === "edit" ? connectors : executionReadyConnectors).find(
|
||||
@@ -115,40 +128,22 @@ export function useAgentForm({
|
||||
setModel(result.data[0]?.id ?? "");
|
||||
}
|
||||
})();
|
||||
if (connectorId !== "claude_local" && connectorId !== "codex_local") {
|
||||
if (!connectorSupportsThinkingEffort(connectorId)) {
|
||||
setThinkingEffort("");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof connectorId === "string" && connectorId.startsWith("claude_compat")) {
|
||||
const compat = connectors.find((c) => c.id === connectorId);
|
||||
const raw = compat?.model ?? "";
|
||||
const models = raw
|
||||
.split(",")
|
||||
.map((m) => m.trim())
|
||||
.filter(Boolean)
|
||||
.map((m) => ({ id: m, label: m }));
|
||||
setAvailableModels(models);
|
||||
if (persistedConnectorId && connectorId !== persistedConnectorId && models.length > 0) {
|
||||
setModel(models[0].id);
|
||||
}
|
||||
} else {
|
||||
void (async () => {
|
||||
const result = await window.agentCompany.listConnectorModels(connectorId);
|
||||
if (result.ok) {
|
||||
setAvailableModels(result.data);
|
||||
if (persistedConnectorId && connectorId !== persistedConnectorId) {
|
||||
setModel(result.data[0]?.id ?? "");
|
||||
}
|
||||
void (async () => {
|
||||
const result = await window.agentCompany.listConnectorModels(connectorId);
|
||||
if (result.ok) {
|
||||
setAvailableModels(result.data);
|
||||
if (persistedConnectorId && connectorId !== persistedConnectorId) {
|
||||
setModel(result.data[0]?.id ?? "");
|
||||
}
|
||||
})();
|
||||
}
|
||||
if (
|
||||
connectorId !== "claude_local" &&
|
||||
!(typeof connectorId === "string" && connectorId.startsWith("claude_compat")) &&
|
||||
connectorId !== "codex_local"
|
||||
) {
|
||||
}
|
||||
})();
|
||||
if (!connectorSupportsThinkingEffort(connectorId)) {
|
||||
setThinkingEffort("");
|
||||
}
|
||||
}, [connectorId, mode, persistedConnectorId, connectors]);
|
||||
|
||||
@@ -174,6 +174,31 @@ export const en: Record<string, string> = {
|
||||
"onboarding.codexLabel": "Codex",
|
||||
"onboarding.geminiLabel": "Gemini CLI",
|
||||
"onboarding.claudeCompatibleLabel": "Claude Code Compatible API",
|
||||
"onboarding.providers.title": "Connect a provider",
|
||||
"onboarding.providers.subtitle": "Connect at least one provider to continue. You can add more later in Settings.",
|
||||
"onboarding.providers.featuredHeading": "Featured",
|
||||
"onboarding.providers.advancedHeading": "Advanced providers",
|
||||
"onboarding.providers.atLeastOne": "Connect at least one provider to continue.",
|
||||
"onboarding.providers.providersLabel": "providers",
|
||||
"onboarding.providers.ready": "ready",
|
||||
"onboarding.providers.noneConfigured": "None configured yet",
|
||||
"onboarding.providers.statusReady": "Connected",
|
||||
"onboarding.providers.statusExpired": "Expired",
|
||||
"onboarding.providers.statusError": "Error",
|
||||
"onboarding.providers.statusChecking": "Working",
|
||||
"onboarding.providers.statusNotConfigured": "Not configured",
|
||||
"onboarding.providers.signIn": "Sign in",
|
||||
"onboarding.providers.signingIn": "Opening browser…",
|
||||
"onboarding.providers.reconnect": "Sign in again",
|
||||
"onboarding.providers.disconnect": "Disconnect",
|
||||
"onboarding.providers.apiKeyLabel": "API key",
|
||||
"onboarding.providers.apiKeyRequired": "Enter an API key before saving.",
|
||||
"onboarding.providers.apiKeyReplacePlaceholder": "Leave empty to keep the current key",
|
||||
"onboarding.providers.save": "Save",
|
||||
"onboarding.providers.saving": "Saving…",
|
||||
"onboarding.providers.saved": "Saved.",
|
||||
"onboarding.providers.cloudPrimer": "Cloud providers need a multi-step credential form. Finish configuration from Settings.",
|
||||
"onboarding.providers.configureInSettings": "Configure in Settings",
|
||||
"agent.name": "Agent name",
|
||||
"agent.role": "Role",
|
||||
"agent.title": "Title",
|
||||
|
||||
@@ -174,6 +174,31 @@ export const zh: Record<string, string> = {
|
||||
"onboarding.codexLabel": "Codex",
|
||||
"onboarding.geminiLabel": "Gemini CLI",
|
||||
"onboarding.claudeCompatibleLabel": "Claude Code 兼容 API",
|
||||
"onboarding.providers.title": "连接一个 Provider",
|
||||
"onboarding.providers.subtitle": "至少连接一个 Provider 才能继续,以后可以在设置里加更多。",
|
||||
"onboarding.providers.featuredHeading": "推荐",
|
||||
"onboarding.providers.advancedHeading": "更多 Provider",
|
||||
"onboarding.providers.atLeastOne": "至少连接一个 Provider 才能继续。",
|
||||
"onboarding.providers.providersLabel": "个",
|
||||
"onboarding.providers.ready": "已连接",
|
||||
"onboarding.providers.noneConfigured": "尚未配置",
|
||||
"onboarding.providers.statusReady": "已连接",
|
||||
"onboarding.providers.statusExpired": "已过期",
|
||||
"onboarding.providers.statusError": "出错",
|
||||
"onboarding.providers.statusChecking": "处理中",
|
||||
"onboarding.providers.statusNotConfigured": "未配置",
|
||||
"onboarding.providers.signIn": "登录",
|
||||
"onboarding.providers.signingIn": "打开浏览器中…",
|
||||
"onboarding.providers.reconnect": "重新登录",
|
||||
"onboarding.providers.disconnect": "断开连接",
|
||||
"onboarding.providers.apiKeyLabel": "API Key",
|
||||
"onboarding.providers.apiKeyRequired": "请输入 API Key 后再保存。",
|
||||
"onboarding.providers.apiKeyReplacePlaceholder": "保持现有 Key 请留空",
|
||||
"onboarding.providers.save": "保存",
|
||||
"onboarding.providers.saving": "保存中…",
|
||||
"onboarding.providers.saved": "已保存。",
|
||||
"onboarding.providers.cloudPrimer": "Cloud 类 Provider 需要多字段凭据,请到设置里完成配置。",
|
||||
"onboarding.providers.configureInSettings": "在设置中配置",
|
||||
"agent.name": "名称",
|
||||
"agent.role": "角色",
|
||||
"agent.title": "职位",
|
||||
|
||||
@@ -4,11 +4,12 @@ import { Dashboard } from "../../components/dashboard";
|
||||
import { TwoPaneLayout } from "../../components/TwoPaneLayout";
|
||||
import {
|
||||
SettingsPane,
|
||||
ModelManagement,
|
||||
WorkspaceCreator,
|
||||
WorkspaceInspector,
|
||||
WorkspaceList,
|
||||
} from "../../components/execution";
|
||||
import { ProvidersPanel } from "../../components/settings/providers";
|
||||
import { OAuthPromptDialog } from "../../components/settings/dialogs/OAuthPromptDialog";
|
||||
import { SocialAccountsPanel } from "../../components/social";
|
||||
import { AutomationPanel } from "../../components/automation";
|
||||
import { CommunicationHub } from "../../components/communication";
|
||||
@@ -272,9 +273,10 @@ export function OperationsSections({ section }: OperationsSectionsProps) {
|
||||
<Panel title={locale === "zh" ? "配置" : "Profile and release"}>
|
||||
<SettingsPane snapshot={snapshot} secrets={companySecrets} companyId={currentCompany.id} onSaved={refresh} />
|
||||
</Panel>
|
||||
<Panel title={locale === "zh" ? "模型管理" : "Model Management"}>
|
||||
<ModelManagement connectors={snapshot.connectors} locale={locale} onSaved={refresh} />
|
||||
<Panel title={locale === "zh" ? "Providers" : "Providers"}>
|
||||
<ProvidersPanel />
|
||||
</Panel>
|
||||
<OAuthPromptDialog />
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
Reference in new issue
Block a user