feat: redesign Provider settings UI with brand icons and dialog-based quick-add
Completely rewrites the Provider management page in Settings to follow the OpenCode reference design with two clear sections. ProviderManager.tsx — full rewrite: - Top section "Connected Providers": shows all configured providers as a clean list with brand icons from @lobehub/icons, name, badge, edit and disconnect buttons. Environment variables also shown here. - Bottom section "Add Provider": preset list with icon + name + description + Connect button. Clicking Connect opens a dialog with only the fields needed for that preset. - First 3 presets: Custom API (all fields), Anthropic Third-party API (name + URL + key), Anthropic Official (key only). - Other presets (GLM, Kimi, Moonshot, MiniMax, etc.) simplified to key-only. - Bedrock/Vertex show environment variable config. - Removed the top-right "Add Provider" button entirely. - Added PresetConnectDialog component: shows brand icon in title, only relevant fields, advanced options fold for extra_env, Cancel/Connect footer. package.json: - Add @lobehub/icons dependency for AI brand icons - Bump version to 0.15.0 i18n (en.ts, zh.ts): - Add translations: connectedProviders, noConnected, connect, disconnect, disconnectProvider, disconnectConfirm, addProviderSection, addProviderDesc, disconnecting Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
448738d4f3
commit
d203b9802d
5 files changed
+4056
-241
No files matched your search
Generated
+3477
-40
File diff suppressed because it is too large.
Load diff
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codepilot",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0",
|
||||
"private": true,
|
||||
"author": {
|
||||
"name": "op7418",
|
||||
@@ -24,6 +24,7 @@
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.33",
|
||||
"@hugeicons/core-free-icons": "^3.1.1",
|
||||
"@hugeicons/react": "^1.1.4",
|
||||
"@lobehub/icons": "^4.6.0",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@streamdown/cjk": "^1.0.1",
|
||||
"@streamdown/code": "^1.0.1",
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -15,43 +26,441 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
PlusSignIcon,
|
||||
Loading02Icon,
|
||||
Delete02Icon,
|
||||
PencilEdit01Icon,
|
||||
ArrowDown01Icon,
|
||||
ArrowUp01Icon,
|
||||
ServerStack01Icon,
|
||||
Settings02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { ProviderForm } from "./ProviderForm";
|
||||
import type { ProviderFormData } from "./ProviderForm";
|
||||
import type { ApiProvider } from "@/types";
|
||||
import { useTranslation } from "@/hooks/useTranslation";
|
||||
import Anthropic from "@lobehub/icons/es/Anthropic";
|
||||
import OpenRouter from "@lobehub/icons/es/OpenRouter";
|
||||
import Zhipu from "@lobehub/icons/es/Zhipu";
|
||||
import Kimi from "@lobehub/icons/es/Kimi";
|
||||
import Moonshot from "@lobehub/icons/es/Moonshot";
|
||||
import Minimax from "@lobehub/icons/es/Minimax";
|
||||
import Aws from "@lobehub/icons/es/Aws";
|
||||
import Bedrock from "@lobehub/icons/es/Bedrock";
|
||||
import Google from "@lobehub/icons/es/Google";
|
||||
|
||||
const QUICK_PRESETS = [
|
||||
{ name: "Anthropic", provider_type: "anthropic", base_url: "https://api.anthropic.com" },
|
||||
{ name: "OpenRouter", provider_type: "openrouter", base_url: "https://openrouter.ai/api" },
|
||||
{ name: "GLM (CN)", provider_type: "custom", base_url: "https://open.bigmodel.cn/api/anthropic", extra_env: '{"API_TIMEOUT_MS":"3000000","ANTHROPIC_API_KEY":""}' },
|
||||
{ name: "GLM (Global)", provider_type: "custom", base_url: "https://api.z.ai/api/anthropic", extra_env: '{"API_TIMEOUT_MS":"3000000","ANTHROPIC_API_KEY":""}' },
|
||||
{ name: "Kimi Coding Plan", provider_type: "custom", base_url: "https://api.kimi.com/coding/", extra_env: '{"ANTHROPIC_AUTH_TOKEN":""}' },
|
||||
{ name: "Moonshot", provider_type: "custom", base_url: "https://api.moonshot.cn/anthropic", extra_env: '{"ANTHROPIC_API_KEY":""}' },
|
||||
{ name: "MiniMax (CN)", provider_type: "custom", base_url: "https://api.minimaxi.com/anthropic", extra_env: '{"API_TIMEOUT_MS":"3000000","CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":"1","ANTHROPIC_API_KEY":""}' },
|
||||
{ name: "MiniMax (Global)", provider_type: "custom", base_url: "https://api.minimax.io/anthropic", extra_env: '{"API_TIMEOUT_MS":"3000000","CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":"1","ANTHROPIC_API_KEY":""}' },
|
||||
{ name: "AWS Bedrock", provider_type: "bedrock", base_url: "" },
|
||||
{ name: "Google Vertex", provider_type: "vertex", base_url: "" },
|
||||
{ name: "LiteLLM", provider_type: "custom", base_url: "http://localhost:4000" },
|
||||
// ---------------------------------------------------------------------------
|
||||
// Brand icon resolver
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Map a provider name / base_url to a brand icon */
|
||||
function getProviderIcon(name: string, baseUrl: string): ReactNode {
|
||||
const lower = name.toLowerCase();
|
||||
const url = baseUrl.toLowerCase();
|
||||
|
||||
if (lower.includes("openrouter")) return <OpenRouter size={18} />;
|
||||
if (url.includes("bigmodel.cn") || url.includes("z.ai") || lower.includes("glm") || lower.includes("zhipu") || lower.includes("chatglm"))
|
||||
return <Zhipu size={18} />;
|
||||
if (url.includes("kimi.com") || lower.includes("kimi")) return <Kimi size={18} />;
|
||||
if (url.includes("moonshot") || lower.includes("moonshot")) return <Moonshot size={18} />;
|
||||
if (url.includes("minimax") || lower.includes("minimax")) return <Minimax size={18} />;
|
||||
if (lower.includes("bedrock")) return <Bedrock size={18} />;
|
||||
if (lower.includes("vertex") || lower.includes("google")) return <Google size={18} />;
|
||||
if (lower.includes("aws")) return <Aws size={18} />;
|
||||
if (lower.includes("anthropic") || url.includes("anthropic")) return <Anthropic size={18} />;
|
||||
|
||||
return <HugeiconsIcon icon={ServerStack01Icon} className="h-[18px] w-[18px] text-muted-foreground" />;
|
||||
}
|
||||
|
||||
/** Resolve the key field name from extra_env JSON (e.g. ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN) */
|
||||
function getKeyFieldFromExtraEnv(extraEnv: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(extraEnv);
|
||||
if ("ANTHROPIC_AUTH_TOKEN" in parsed) return "ANTHROPIC_AUTH_TOKEN";
|
||||
if ("ANTHROPIC_API_KEY" in parsed) return "ANTHROPIC_API_KEY";
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quick-add preset definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface QuickPreset {
|
||||
key: string; // unique key
|
||||
name: string;
|
||||
description: string;
|
||||
descriptionZh: string;
|
||||
icon: ReactNode;
|
||||
// Pre-filled provider data
|
||||
provider_type: string;
|
||||
base_url: string;
|
||||
extra_env: string;
|
||||
// Which fields user must fill
|
||||
fields: ("name" | "api_key" | "base_url" | "extra_env" | "model_names")[];
|
||||
}
|
||||
|
||||
const QUICK_PRESETS: QuickPreset[] = [
|
||||
{
|
||||
key: "custom-api",
|
||||
name: "Custom API",
|
||||
description: "Custom API endpoint — fill in all fields",
|
||||
descriptionZh: "自定义 API 端点 — 填写所有信息",
|
||||
icon: <HugeiconsIcon icon={Settings02Icon} className="h-[18px] w-[18px] text-muted-foreground" />,
|
||||
provider_type: "custom",
|
||||
base_url: "",
|
||||
extra_env: "{}",
|
||||
fields: ["name", "api_key", "base_url", "extra_env"],
|
||||
},
|
||||
{
|
||||
key: "anthropic-thirdparty",
|
||||
name: "Anthropic Third-party API",
|
||||
description: "Anthropic-compatible API — provide URL and Key",
|
||||
descriptionZh: "Anthropic 兼容第三方 API — 填写地址和密钥",
|
||||
icon: <Anthropic size={18} />,
|
||||
provider_type: "custom",
|
||||
base_url: "",
|
||||
extra_env: '{"ANTHROPIC_API_KEY":""}',
|
||||
fields: ["name", "api_key", "base_url", "model_names"],
|
||||
},
|
||||
{
|
||||
key: "anthropic-official",
|
||||
name: "Anthropic",
|
||||
description: "Official Anthropic API",
|
||||
descriptionZh: "Anthropic 官方 API",
|
||||
icon: <Anthropic size={18} />,
|
||||
provider_type: "anthropic",
|
||||
base_url: "https://api.anthropic.com",
|
||||
extra_env: "{}",
|
||||
fields: ["api_key"],
|
||||
},
|
||||
{
|
||||
key: "openrouter",
|
||||
name: "OpenRouter",
|
||||
description: "Use OpenRouter to access multiple models",
|
||||
descriptionZh: "通过 OpenRouter 访问多种模型",
|
||||
icon: <OpenRouter size={18} />,
|
||||
provider_type: "openrouter",
|
||||
base_url: "https://openrouter.ai/api",
|
||||
extra_env: '{"ANTHROPIC_API_KEY":""}',
|
||||
fields: ["api_key"],
|
||||
},
|
||||
{
|
||||
key: "glm-cn",
|
||||
name: "GLM (CN)",
|
||||
description: "Zhipu GLM — China region",
|
||||
descriptionZh: "智谱 GLM — 中国区",
|
||||
icon: <Zhipu size={18} />,
|
||||
provider_type: "custom",
|
||||
base_url: "https://open.bigmodel.cn/api/anthropic",
|
||||
extra_env: '{"API_TIMEOUT_MS":"3000000","ANTHROPIC_API_KEY":""}',
|
||||
fields: ["api_key"],
|
||||
},
|
||||
{
|
||||
key: "glm-global",
|
||||
name: "GLM (Global)",
|
||||
description: "Zhipu GLM — Global region",
|
||||
descriptionZh: "智谱 GLM — 国际区",
|
||||
icon: <Zhipu size={18} />,
|
||||
provider_type: "custom",
|
||||
base_url: "https://api.z.ai/api/anthropic",
|
||||
extra_env: '{"API_TIMEOUT_MS":"3000000","ANTHROPIC_API_KEY":""}',
|
||||
fields: ["api_key"],
|
||||
},
|
||||
{
|
||||
key: "kimi",
|
||||
name: "Kimi Coding Plan",
|
||||
description: "Kimi Coding Plan API",
|
||||
descriptionZh: "Kimi 编程计划 API",
|
||||
icon: <Kimi size={18} />,
|
||||
provider_type: "custom",
|
||||
base_url: "https://api.kimi.com/coding/",
|
||||
extra_env: '{"ANTHROPIC_AUTH_TOKEN":""}',
|
||||
fields: ["api_key"],
|
||||
},
|
||||
{
|
||||
key: "moonshot",
|
||||
name: "Moonshot",
|
||||
description: "Moonshot AI API",
|
||||
descriptionZh: "月之暗面 API",
|
||||
icon: <Moonshot size={18} />,
|
||||
provider_type: "custom",
|
||||
base_url: "https://api.moonshot.cn/anthropic",
|
||||
extra_env: '{"ANTHROPIC_API_KEY":""}',
|
||||
fields: ["api_key"],
|
||||
},
|
||||
{
|
||||
key: "minimax-cn",
|
||||
name: "MiniMax (CN)",
|
||||
description: "MiniMax API — China region",
|
||||
descriptionZh: "MiniMax API — 中国区",
|
||||
icon: <Minimax size={18} />,
|
||||
provider_type: "custom",
|
||||
base_url: "https://api.minimaxi.com/anthropic",
|
||||
extra_env: '{"API_TIMEOUT_MS":"3000000","CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":"1","ANTHROPIC_API_KEY":""}',
|
||||
fields: ["api_key"],
|
||||
},
|
||||
{
|
||||
key: "minimax-global",
|
||||
name: "MiniMax (Global)",
|
||||
description: "MiniMax API — Global region",
|
||||
descriptionZh: "MiniMax API — 国际区",
|
||||
icon: <Minimax size={18} />,
|
||||
provider_type: "custom",
|
||||
base_url: "https://api.minimax.io/anthropic",
|
||||
extra_env: '{"API_TIMEOUT_MS":"3000000","CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":"1","ANTHROPIC_API_KEY":""}',
|
||||
fields: ["api_key"],
|
||||
},
|
||||
{
|
||||
key: "bedrock",
|
||||
name: "AWS Bedrock",
|
||||
description: "Amazon Bedrock — requires AWS credentials",
|
||||
descriptionZh: "Amazon Bedrock — 需要 AWS 凭证",
|
||||
icon: <Bedrock size={18} />,
|
||||
provider_type: "bedrock",
|
||||
base_url: "",
|
||||
extra_env: '{"CLAUDE_CODE_USE_BEDROCK":"1","AWS_REGION":"us-east-1","CLAUDE_CODE_SKIP_BEDROCK_AUTH":"1"}',
|
||||
fields: ["extra_env"],
|
||||
},
|
||||
{
|
||||
key: "vertex",
|
||||
name: "Google Vertex",
|
||||
description: "Google Vertex AI — requires GCP credentials",
|
||||
descriptionZh: "Google Vertex AI — 需要 GCP 凭证",
|
||||
icon: <Google size={18} />,
|
||||
provider_type: "vertex",
|
||||
base_url: "",
|
||||
extra_env: '{"CLAUDE_CODE_USE_VERTEX":"1","CLOUD_ML_REGION":"us-east5","CLAUDE_CODE_SKIP_VERTEX_AUTH":"1"}',
|
||||
fields: ["extra_env"],
|
||||
},
|
||||
{
|
||||
key: "litellm",
|
||||
name: "LiteLLM",
|
||||
description: "LiteLLM proxy — local or remote",
|
||||
descriptionZh: "LiteLLM 代理 — 本地或远程",
|
||||
icon: <HugeiconsIcon icon={ServerStack01Icon} className="h-[18px] w-[18px] text-muted-foreground" />,
|
||||
provider_type: "custom",
|
||||
base_url: "http://localhost:4000",
|
||||
extra_env: "{}",
|
||||
fields: ["api_key", "base_url"],
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preset connect dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PresetConnectDialog({
|
||||
preset,
|
||||
open,
|
||||
onOpenChange,
|
||||
onAdd,
|
||||
}: {
|
||||
preset: QuickPreset | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAdd: (data: ProviderFormData) => Promise<void>;
|
||||
}) {
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [extraEnv, setExtraEnv] = useState("{}");
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const isZh = t('nav.chats') === '对话';
|
||||
|
||||
// Reset form when dialog opens with a new preset
|
||||
useEffect(() => {
|
||||
if (!open || !preset) return;
|
||||
setApiKey("");
|
||||
setBaseUrl(preset.base_url);
|
||||
setName(preset.name);
|
||||
setExtraEnv(preset.extra_env);
|
||||
setError(null);
|
||||
setSaving(false);
|
||||
setShowAdvanced(false);
|
||||
}, [open, preset]);
|
||||
|
||||
if (!preset) return null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
// Inject key into extra_env if needed
|
||||
let finalExtraEnv = extraEnv;
|
||||
const keyField = getKeyFieldFromExtraEnv(preset.extra_env);
|
||||
if (keyField && apiKey) {
|
||||
try {
|
||||
const envObj = JSON.parse(finalExtraEnv);
|
||||
envObj[keyField] = apiKey;
|
||||
finalExtraEnv = JSON.stringify(envObj);
|
||||
} catch { /* use as-is */ }
|
||||
}
|
||||
|
||||
// Validate extra_env JSON
|
||||
try {
|
||||
JSON.parse(finalExtraEnv);
|
||||
} catch {
|
||||
setError("Extra environment variables must be valid JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await onAdd({
|
||||
name: name.trim() || preset.name,
|
||||
provider_type: preset.provider_type,
|
||||
base_url: baseUrl.trim(),
|
||||
api_key: keyField ? "" : apiKey,
|
||||
extra_env: finalExtraEnv,
|
||||
notes: "",
|
||||
});
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to add provider");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[28rem] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2.5">
|
||||
{preset.icon}
|
||||
{t('provider.connect')} {preset.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isZh ? preset.descriptionZh : preset.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 min-w-0">
|
||||
{/* Name field — custom/thirdparty */}
|
||||
{preset.fields.includes("name") && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">{t('provider.name')}</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={preset.name}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Base URL */}
|
||||
{preset.fields.includes("base_url") && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">{t('provider.baseUrl')}</Label>
|
||||
<Input
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="https://api.example.com"
|
||||
className="text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key */}
|
||||
{preset.fields.includes("api_key") && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">API Key</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
className="text-sm font-mono"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extra env — bedrock/vertex/custom always shown */}
|
||||
{preset.fields.includes("extra_env") && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">{t('provider.extraEnvVars')} (JSON)</Label>
|
||||
<Textarea
|
||||
value={extraEnv}
|
||||
onChange={(e) => setExtraEnv(e.target.value)}
|
||||
className="text-sm font-mono min-h-[80px]"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced options — for presets that don't normally show extra_env */}
|
||||
{!preset.fields.includes("extra_env") && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={showAdvanced ? ArrowUp01Icon : ArrowDown01Icon}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
{t('provider.advancedOptions')}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="space-y-2 border-t border-border/50 pt-3">
|
||||
<Label className="text-xs text-muted-foreground">{t('provider.extraEnvVars')} (JSON)</Label>
|
||||
<Textarea
|
||||
value={extraEnv}
|
||||
onChange={(e) => setExtraEnv(e.target.value)}
|
||||
className="text-sm font-mono min-h-[60px]"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving} className="gap-2">
|
||||
{saving && <HugeiconsIcon icon={Loading02Icon} className="h-4 w-4 animate-spin" />}
|
||||
{saving ? t('provider.saving') : t('provider.connect')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ProviderManager() {
|
||||
const [providers, setProviders] = useState<ApiProvider[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [envDetected, setEnvDetected] = useState<Record<string, string>>({});
|
||||
const { t } = useTranslation();
|
||||
const isZh = t('nav.chats') === '对话';
|
||||
|
||||
// Form dialog state
|
||||
// Edit dialog state (reuse existing ProviderForm for full editing)
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formMode, setFormMode] = useState<"create" | "edit">("create");
|
||||
const [editingProvider, setEditingProvider] = useState<ApiProvider | null>(null);
|
||||
const [initialPreset, setInitialPreset] = useState<{ name: string; provider_type: string; base_url: string } | null>(null);
|
||||
|
||||
// Preset connect dialog state
|
||||
const [connectPreset, setConnectPreset] = useState<QuickPreset | null>(null);
|
||||
const [connectDialogOpen, setConnectDialogOpen] = useState(false);
|
||||
|
||||
// Delete confirmation state
|
||||
const [deleteTarget, setDeleteTarget] = useState<ApiProvider | null>(null);
|
||||
@@ -61,9 +470,7 @@ export function ProviderManager() {
|
||||
try {
|
||||
setError(null);
|
||||
const res = await fetch("/api/providers");
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to load providers");
|
||||
}
|
||||
if (!res.ok) throw new Error("Failed to load providers");
|
||||
const data = await res.json();
|
||||
setProviders(data.providers || []);
|
||||
setEnvDetected(data.env_detected || {});
|
||||
@@ -74,77 +481,59 @@ export function ProviderManager() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProviders();
|
||||
}, [fetchProviders]);
|
||||
|
||||
const handleAdd = () => {
|
||||
setFormMode("create");
|
||||
setEditingProvider(null);
|
||||
setInitialPreset(null);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const handlePresetAdd = (preset: typeof QUICK_PRESETS[number]) => {
|
||||
setFormMode("create");
|
||||
setEditingProvider(null);
|
||||
setInitialPreset(preset);
|
||||
setFormOpen(true);
|
||||
};
|
||||
useEffect(() => { fetchProviders(); }, [fetchProviders]);
|
||||
|
||||
const handleEdit = (provider: ApiProvider) => {
|
||||
setFormMode("edit");
|
||||
setEditingProvider(provider);
|
||||
setInitialPreset(null);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async (data: ProviderFormData) => {
|
||||
if (formMode === "edit" && editingProvider) {
|
||||
const res = await fetch(`/api/providers/${editingProvider.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData.error || "Failed to update provider");
|
||||
}
|
||||
const result = await res.json();
|
||||
setProviders((prev) =>
|
||||
prev.map((p) => (p.id === editingProvider.id ? result.provider : p))
|
||||
);
|
||||
window.dispatchEvent(new Event('provider-changed'));
|
||||
} else {
|
||||
const res = await fetch("/api/providers", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData.error || "Failed to create provider");
|
||||
}
|
||||
const result = await res.json();
|
||||
setProviders((prev) => [...prev, result.provider]);
|
||||
window.dispatchEvent(new Event('provider-changed'));
|
||||
const handleEditSave = async (data: ProviderFormData) => {
|
||||
if (!editingProvider) return;
|
||||
const res = await fetch(`/api/providers/${editingProvider.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData.error || "Failed to update provider");
|
||||
}
|
||||
const result = await res.json();
|
||||
setProviders((prev) => prev.map((p) => (p.id === editingProvider.id ? result.provider : p)));
|
||||
window.dispatchEvent(new Event("provider-changed"));
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const handlePresetAdd = async (data: ProviderFormData) => {
|
||||
const res = await fetch("/api/providers", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData.error || "Failed to create provider");
|
||||
}
|
||||
const result = await res.json();
|
||||
setProviders((prev) => [...prev, result.provider]);
|
||||
window.dispatchEvent(new Event("provider-changed"));
|
||||
};
|
||||
|
||||
const handleOpenPresetDialog = (preset: QuickPreset) => {
|
||||
setConnectPreset(preset);
|
||||
setConnectDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${deleteTarget.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const res = await fetch(`/api/providers/${deleteTarget.id}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
setProviders((prev) => prev.filter((p) => p.id !== deleteTarget.id));
|
||||
window.dispatchEvent(new Event('provider-changed'));
|
||||
window.dispatchEvent(new Event("provider-changed"));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
} catch { /* ignore */ } finally {
|
||||
setDeleting(false);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
@@ -153,28 +542,7 @@ export function ProviderManager() {
|
||||
const sorted = [...providers].sort((a, b) => a.sort_order - b.sort_order);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/50 p-4 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium">API Providers</h3>
|
||||
{providers.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({providers.length})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Configure API providers. Select provider and model from the chat input.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" className="gap-1" onClick={handleAdd}>
|
||||
<HugeiconsIcon icon={PlusSignIcon} className="h-3.5 w-3.5" />
|
||||
{t('provider.addProvider')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3">
|
||||
@@ -182,91 +550,52 @@ export function ProviderManager() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Environment variable detection banner */}
|
||||
{!loading && Object.keys(envDetected).length > 0 && (
|
||||
<div className="rounded-md border p-3 border-green-500/30 bg-green-500/5">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className="text-xs font-medium text-green-700 dark:text-green-400">
|
||||
{t('provider.envDetected')}
|
||||
</p>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-green-600 dark:text-green-400 border-green-500/30">
|
||||
{t('provider.configured')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{Object.entries(envDetected).map(([key, value]) => (
|
||||
<p key={key} className="text-xs font-mono text-muted-foreground">
|
||||
{key}={value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-4 w-4 animate-spin" />
|
||||
<p className="text-sm">Loading providers...</p>
|
||||
<p className="text-sm">{t('common.loading')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && providers.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-muted-foreground">
|
||||
<HugeiconsIcon icon={ServerStack01Icon} className="h-10 w-10 opacity-30" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium">{t('provider.noProviders')}</p>
|
||||
<p className="text-xs mt-0.5">
|
||||
{Object.keys(envDetected).length > 0
|
||||
? "Using environment variables. Add a provider below to override."
|
||||
: "Add a provider to use a custom API endpoint with Claude Code."}
|
||||
</p>
|
||||
</div>
|
||||
{/* Quick preset buttons */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-1.5 mt-2 max-w-md">
|
||||
{QUICK_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.name}
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="gap-1"
|
||||
onClick={() => handlePresetAdd(preset)}
|
||||
{/* ─── Section 1: Connected Providers ─── */}
|
||||
{!loading && (
|
||||
<div className="rounded-lg border border-border/50 p-4 space-y-2">
|
||||
<h3 className="text-sm font-medium mb-1">{t('provider.connectedProviders')}</h3>
|
||||
|
||||
{/* Environment variable detection */}
|
||||
{Object.keys(envDetected).length > 0 && (
|
||||
<div className="flex items-center gap-3 py-2.5 px-1 border-b border-border/30">
|
||||
<div className="shrink-0 w-[22px] flex justify-center">
|
||||
<Anthropic size={18} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">{t('provider.environment')}</span>
|
||||
<Badge variant="outline" className="ml-2 text-[10px] px-1.5 py-0">
|
||||
ENV
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connected provider list */}
|
||||
{sorted.length > 0 ? (
|
||||
sorted.map((provider) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="flex items-center gap-3 py-2.5 px-1 border-b border-border/30 last:border-b-0"
|
||||
>
|
||||
<HugeiconsIcon icon={PlusSignIcon} className="h-3 w-3" />
|
||||
{preset.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provider list */}
|
||||
{!loading && sorted.length > 0 && (
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto min-h-0">
|
||||
{sorted.map((provider) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="rounded-lg border p-3 transition-colors border-border/50 hover:border-border"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="shrink-0 w-[22px] flex justify-center">
|
||||
{getProviderIcon(provider.name, provider.base_url)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{provider.name}
|
||||
</span>
|
||||
<span className="text-sm font-medium truncate">{provider.name}</span>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
{provider.provider_type}
|
||||
{provider.api_key ? "API Key" : t('provider.configured')}
|
||||
</Badge>
|
||||
</div>
|
||||
{provider.base_url && (
|
||||
<p className="text-xs text-muted-foreground font-mono truncate mt-0.5">
|
||||
{provider.base_url}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -278,65 +607,95 @@ export function ProviderManager() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
title="Delete"
|
||||
size="xs"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(provider)}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="h-3 w-3 text-destructive" />
|
||||
{t('provider.disconnect')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
) : (
|
||||
Object.keys(envDetected).length === 0 && (
|
||||
<p className="text-xs text-muted-foreground py-4 text-center">
|
||||
{t('provider.noConnected')}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick presets row (when providers exist) */}
|
||||
{!loading && providers.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground mr-1">{t('provider.quickPresets')}:</span>
|
||||
{QUICK_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.name}
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="gap-1 text-[11px]"
|
||||
onClick={() => handlePresetAdd(preset)}
|
||||
>
|
||||
<HugeiconsIcon icon={PlusSignIcon} className="h-2.5 w-2.5" />
|
||||
{preset.name}
|
||||
</Button>
|
||||
))}
|
||||
{/* ─── Section 2: Add Provider (Quick Presets) ─── */}
|
||||
{!loading && (
|
||||
<div className="rounded-lg border border-border/50 p-4">
|
||||
<h3 className="text-sm font-medium mb-1">{t('provider.addProviderSection')}</h3>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
{t('provider.addProviderDesc')}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
{QUICK_PRESETS.map((preset) => (
|
||||
<div
|
||||
key={preset.key}
|
||||
className="flex items-center gap-3 py-2.5 px-1 border-b border-border/30 last:border-b-0"
|
||||
>
|
||||
<div className="shrink-0 w-[22px] flex justify-center">{preset.icon}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">{preset.name}</span>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{isZh ? preset.descriptionZh : preset.description}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="shrink-0 gap-1"
|
||||
onClick={() => handleOpenPresetDialog(preset)}
|
||||
>
|
||||
+ {t('provider.connect')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form dialog */}
|
||||
{/* Edit dialog (full form for editing existing providers) */}
|
||||
<ProviderForm
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
mode={formMode}
|
||||
mode="edit"
|
||||
provider={editingProvider}
|
||||
onSave={handleSave}
|
||||
initialPreset={initialPreset}
|
||||
onSave={handleEditSave}
|
||||
initialPreset={null}
|
||||
/>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
{/* Preset connect dialog */}
|
||||
<PresetConnectDialog
|
||||
preset={connectPreset}
|
||||
open={connectDialogOpen}
|
||||
onOpenChange={setConnectDialogOpen}
|
||||
onAdd={handlePresetAdd}
|
||||
/>
|
||||
|
||||
{/* Disconnect confirmation */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('provider.deleteProvider')}</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('provider.disconnectProvider')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('provider.deleteConfirm', { name: deleteTarget?.name ?? '' })}
|
||||
{t('provider.disconnectConfirm', { name: deleteTarget?.name ?? '' })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
onClick={handleDisconnect}
|
||||
disabled={deleting}
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
>
|
||||
{deleting ? t('provider.deleting') : t('common.delete')}
|
||||
{deleting ? t('provider.disconnecting') : t('provider.disconnect')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -138,6 +138,15 @@ const en = {
|
||||
'provider.default': 'Default',
|
||||
'provider.setDefault': 'Set as Default',
|
||||
'provider.environment': 'Environment',
|
||||
'provider.connectedProviders': 'Connected Providers',
|
||||
'provider.noConnected': 'No providers connected yet',
|
||||
'provider.connect': 'Connect',
|
||||
'provider.disconnect': 'Disconnect',
|
||||
'provider.disconnecting': 'Disconnecting...',
|
||||
'provider.disconnectProvider': 'Disconnect Provider',
|
||||
'provider.disconnectConfirm': 'Are you sure you want to disconnect "{name}"? This action cannot be undone.',
|
||||
'provider.addProviderSection': 'Add Provider',
|
||||
'provider.addProviderDesc': 'Select a provider to connect. Most presets only require an API key.',
|
||||
|
||||
// ── Right panel / Files ─────────────────────────────────────
|
||||
'panel.files': 'Files',
|
||||
|
||||
@@ -135,6 +135,15 @@ const zh: Record<TranslationKey, string> = {
|
||||
'provider.default': '默认',
|
||||
'provider.setDefault': '设为默认',
|
||||
'provider.environment': '环境变量',
|
||||
'provider.connectedProviders': '已连接的提供商',
|
||||
'provider.noConnected': '尚未连接任何提供商',
|
||||
'provider.connect': '连接',
|
||||
'provider.disconnect': '断开连接',
|
||||
'provider.disconnecting': '断开中...',
|
||||
'provider.disconnectProvider': '断开提供商',
|
||||
'provider.disconnectConfirm': '确定要断开"{name}"吗?此操作无法撤销。',
|
||||
'provider.addProviderSection': '添加提供商',
|
||||
'provider.addProviderDesc': '选择要连接的提供商。大多数预设只需填写 API 密钥。',
|
||||
|
||||
// ── Right panel / Files ─────────────────────────────────────
|
||||
'panel.files': '文件',
|
||||
|
||||
Reference in new issue
Block a user