Isolate providers per client: add provider targets and a Codex-side form
This commit is contained in:
1 parent
adc8234cc9
commit
d759fe5e5d
13 files changed
+595
-309
No files matched your search
@@ -0,0 +1,258 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { PRESETS } from "@/lib/types";
|
||||
import type { Models } from "@/lib/types";
|
||||
import type { ApplyTarget } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input, Label } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
|
||||
export interface ProviderSubmitInput {
|
||||
name: string;
|
||||
preset: string;
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
models: Models;
|
||||
target: ApplyTarget;
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
preset: string;
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
models: Omit<Models, "extra"> & { extra: { key: string; value: string }[] };
|
||||
}
|
||||
|
||||
const emptyForm: FormState = {
|
||||
name: "",
|
||||
preset: "",
|
||||
base_url: "",
|
||||
api_key: "",
|
||||
models: { default: "", sonnet: "", haiku: "", opus: "", extra: [] },
|
||||
};
|
||||
|
||||
function toRows(extra: Record<string, string> | undefined): { key: string; value: string }[] {
|
||||
return Object.entries(extra ?? {}).map(([key, value]) => ({ key, value }));
|
||||
}
|
||||
|
||||
function toMap(rows: { key: string; value: string }[]): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
rows.filter((r) => r.key.trim() && r.value.trim()).map((r) => [r.key.trim(), r.value.trim()]),
|
||||
);
|
||||
}
|
||||
|
||||
interface ProviderFormProps {
|
||||
target: ApplyTarget;
|
||||
editing: { id: string; name: string; preset?: string; base_url: string; models: Models } | null;
|
||||
pending: boolean;
|
||||
onSubmit: (input: ProviderSubmitInput) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ProviderForm({ target, editing, pending, onSubmit, onCancel }: ProviderFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState<FormState>(() =>
|
||||
editing
|
||||
? {
|
||||
name: editing.name,
|
||||
preset: editing.preset ?? "",
|
||||
base_url: editing.base_url,
|
||||
api_key: "",
|
||||
models: {
|
||||
default: editing.models.default ?? "",
|
||||
sonnet: editing.models.sonnet ?? "",
|
||||
haiku: editing.models.haiku ?? "",
|
||||
opus: editing.models.opus ?? "",
|
||||
extra: toRows(editing.models.extra),
|
||||
},
|
||||
}
|
||||
: { ...emptyForm },
|
||||
);
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
|
||||
|
||||
const validate = (): boolean => {
|
||||
const next: Partial<Record<keyof FormState, string>> = {};
|
||||
if (!form.name.trim()) next.name = t("providers.validationName");
|
||||
if (!/^https?:\/\/.+/.test(form.base_url.trim())) next.base_url = t("providers.validationBaseUrl");
|
||||
if (!editing && !form.api_key.trim()) next.api_key = t("providers.validationApiKey");
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (!validate()) return;
|
||||
onSubmit({
|
||||
name: form.name.trim(),
|
||||
preset: form.preset,
|
||||
base_url: form.base_url.trim(),
|
||||
api_key: form.api_key.trim(),
|
||||
models: {
|
||||
default: form.models.default?.trim(),
|
||||
sonnet: form.models.sonnet?.trim(),
|
||||
haiku: form.models.haiku?.trim(),
|
||||
opus: form.models.opus?.trim(),
|
||||
extra: toMap(form.models.extra),
|
||||
},
|
||||
target,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{editing ? t("providers.edit") : t("providers.add")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<Label>{t("providers.name")}</Label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="DeepSeek"
|
||||
/>
|
||||
{errors.name && <span className="text-xs text-red-600">{errors.name}</span>}
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<Label>{t("providers.preset")}</Label>
|
||||
<Select
|
||||
value={form.preset}
|
||||
onChange={(e) => {
|
||||
const key = e.target.value;
|
||||
const preset = PRESETS.find((p) => p.key === key);
|
||||
setForm({
|
||||
...form,
|
||||
preset: key,
|
||||
base_url: preset?.base_url ?? form.base_url,
|
||||
models: preset
|
||||
? { ...preset.models, extra: toRows(preset.models.extra) }
|
||||
: form.models,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">{t("providers.presetCustom")}</option>
|
||||
{PRESETS.map((p) => (
|
||||
<option key={p.key} value={p.key}>
|
||||
{p.key}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<Label>{t("providers.baseUrl")}</Label>
|
||||
<Input
|
||||
value={form.base_url}
|
||||
onChange={(e) => setForm({ ...form, base_url: e.target.value })}
|
||||
placeholder="https://api.deepseek.com/v1"
|
||||
/>
|
||||
{errors.base_url && <span className="text-xs text-red-600">{errors.base_url}</span>}
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<Label>
|
||||
{t("providers.apiKey")}
|
||||
{editing && <span className="ml-1 text-zinc-400">({t("providers.keyHint")})</span>}
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.api_key}
|
||||
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
{errors.api_key && <span className="text-xs text-red-600">{errors.api_key}</span>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-xs font-medium text-zinc-600">{t("providers.models")}</legend>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{(["default", "sonnet", "haiku", "opus"] as const).map((role) => (
|
||||
<label key={role} className="flex flex-col gap-1">
|
||||
<Label>{t(`providers.${role}Model`)}</Label>
|
||||
<Input
|
||||
value={form.models[role] ?? ""}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, models: { ...form.models, [role]: e.target.value } })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-zinc-600">{t("providers.extraMappings")}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setForm({
|
||||
...form,
|
||||
models: {
|
||||
...form.models,
|
||||
extra: [...form.models.extra, { key: "", value: "" }],
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus size={12} aria-hidden />
|
||||
{t("providers.addMapping")}
|
||||
</Button>
|
||||
</div>
|
||||
{form.models.extra.map((row, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={row.key}
|
||||
placeholder={t("providers.extraKey")}
|
||||
onChange={(e) => {
|
||||
const extra = form.models.extra.map((r, i) =>
|
||||
i === index ? { ...r, key: e.target.value } : r,
|
||||
);
|
||||
setForm({ ...form, models: { ...form.models, extra } });
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
value={row.value}
|
||||
placeholder={t("providers.extraValue")}
|
||||
onChange={(e) => {
|
||||
const extra = form.models.extra.map((r, i) =>
|
||||
i === index ? { ...r, value: e.target.value } : r,
|
||||
);
|
||||
setForm({ ...form, models: { ...form.models, extra } });
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("common.delete")}
|
||||
onClick={() =>
|
||||
setForm({
|
||||
...form,
|
||||
models: {
|
||||
...form.models,
|
||||
extra: form.models.extra.filter((_, i) => i !== index),
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-xs text-zinc-500">{t("providers.extraHint")}</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={submit} disabled={pending}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+3
-1
@@ -50,11 +50,13 @@ export interface ProviderInput {
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
models: Models;
|
||||
target: ApplyTarget;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getStatus: () => request<Status>("/api/status"),
|
||||
listProviders: () => request<{ items: Provider[] }>("/api/providers"),
|
||||
listProviders: (target?: ApplyTarget) =>
|
||||
request<{ items: Provider[] }>(target ? `/api/providers?target=${target}` : "/api/providers"),
|
||||
createProvider: (input: ProviderInput) =>
|
||||
request<Provider>("/api/providers", { method: "POST", body: JSON.stringify(input) }),
|
||||
updateProvider: (id: string, input: ProviderInput) =>
|
||||
|
||||
+7
-4
@@ -3,7 +3,7 @@ import { api, type ProviderInput } from "./api";
|
||||
|
||||
export const queryKeys = {
|
||||
status: ["status"] as const,
|
||||
providers: ["providers"] as const,
|
||||
providers: (target: string) => ["providers", target] as const,
|
||||
logs: ["logs"] as const,
|
||||
backups: (target: string) => ["backups", target] as const,
|
||||
settings: ["settings"] as const,
|
||||
@@ -13,8 +13,11 @@ export function useStatus() {
|
||||
return useQuery({ queryKey: queryKeys.status, queryFn: api.getStatus, refetchInterval: 5000 });
|
||||
}
|
||||
|
||||
export function useProviders() {
|
||||
return useQuery({ queryKey: queryKeys.providers, queryFn: api.listProviders });
|
||||
export function useProviders(target: "claude" | "codex") {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.providers(target),
|
||||
queryFn: () => api.listProviders(target),
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogs() {
|
||||
@@ -31,7 +34,7 @@ export function useSettings() {
|
||||
|
||||
function invalidateAll(queryClient: ReturnType<typeof useQueryClient>) {
|
||||
return Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.providers }),
|
||||
queryClient.invalidateQueries({ queryKey: ["providers"] }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.status }),
|
||||
queryClient.invalidateQueries({ queryKey: ["backups"] }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.settings }),
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface Provider {
|
||||
models: Models;
|
||||
enabled_for_claude: boolean;
|
||||
enabled_for_codex: boolean;
|
||||
target: "claude" | "codex";
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,15 @@ import { renderWithProviders } from "@/test/utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
const listProviders = vi.fn();
|
||||
const createProvider = vi.fn();
|
||||
const applyProvider = vi.fn();
|
||||
const restore = vi.fn();
|
||||
const getStatus = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
listProviders: () => listProviders(),
|
||||
createProvider: vi.fn(),
|
||||
listProviders: (target: string) => listProviders(target),
|
||||
createProvider: (input: unknown) => createProvider(input),
|
||||
updateProvider: vi.fn(),
|
||||
deleteProvider: vi.fn(),
|
||||
applyProvider: (id: string, target: string) => applyProvider(id, target),
|
||||
@@ -35,6 +36,7 @@ const provider = {
|
||||
models: { default: "deepseek-chat", sonnet: "deepseek-chat", haiku: "deepseek-chat", opus: "deepseek-chat" },
|
||||
enabled_for_claude: false,
|
||||
enabled_for_codex: false,
|
||||
target: "codex",
|
||||
created_at: "2026-10-18T00:00:00Z",
|
||||
};
|
||||
|
||||
@@ -53,6 +55,7 @@ beforeEach(() => {
|
||||
port_mode: "auto",
|
||||
fixed_port: 0,
|
||||
});
|
||||
createProvider.mockResolvedValue({ ...provider, id: "p_2" });
|
||||
applyProvider.mockResolvedValue({
|
||||
provider,
|
||||
injection: {
|
||||
@@ -77,6 +80,28 @@ describe("Codex page", () => {
|
||||
expect(screen.getByText("DeepSeek")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("requests only Codex providers", async () => {
|
||||
renderWithProviders(<Codex />);
|
||||
await screen.findByText("DeepSeek");
|
||||
expect(listProviders).toHaveBeenCalledWith("codex");
|
||||
});
|
||||
|
||||
it("creates a provider scoped to Codex", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<Codex />);
|
||||
await user.click(await screen.findByRole("button", { name: /新增供应商|Add provider/ }));
|
||||
await user.type(screen.getByLabelText(/名称|Name/), "Kimi");
|
||||
await user.type(screen.getByLabelText(/Base URL/), "https://api.moonshot.cn/v1");
|
||||
await user.type(screen.getByLabelText(/API Key/), "sk-kimi");
|
||||
await user.click(screen.getByRole("button", { name: /保存|Save/ }));
|
||||
await waitFor(() => expect(createProvider).toHaveBeenCalledTimes(1));
|
||||
expect(createProvider.mock.calls[0][0]).toMatchObject({
|
||||
name: "Kimi",
|
||||
base_url: "https://api.moonshot.cn/v1",
|
||||
target: "codex",
|
||||
});
|
||||
});
|
||||
|
||||
it("activates a provider for Codex and previews the injected keys", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<Codex />);
|
||||
|
||||
+65
-10
@@ -1,25 +1,41 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RotateCcw, Zap } from "lucide-react";
|
||||
import { Plus, RotateCcw, Trash2, Pencil, Zap } from "lucide-react";
|
||||
import type { Provider } from "@/lib/types";
|
||||
import { useProviders, useProviderMutations, useRestore, useStatus } from "@/lib/queries";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ProviderForm, type ProviderSubmitInput } from "@/components/ProviderForm";
|
||||
|
||||
export default function Codex() {
|
||||
const { t } = useTranslation();
|
||||
const status = useStatus();
|
||||
const { data, isLoading, isError } = useProviders();
|
||||
const { data, isLoading, isError } = useProviders("codex");
|
||||
const mutations = useProviderMutations();
|
||||
const restore = useRestore();
|
||||
const { show } = useToast();
|
||||
const [injection, setInjection] = useState<Record<string, string> | null>(null);
|
||||
const [editing, setEditing] = useState<{ provider: Provider | null } | null>(null);
|
||||
|
||||
const providers = data?.items ?? [];
|
||||
const active = status.data?.active_codex_provider ?? null;
|
||||
|
||||
const submitForm = (input: ProviderSubmitInput) => {
|
||||
const target = editing?.provider ?? null;
|
||||
const onSuccess = () => {
|
||||
show(target ? t("providers.updated") : t("providers.created"));
|
||||
setEditing(null);
|
||||
};
|
||||
const onError = (err: Error) => show(err.message, "error");
|
||||
if (target) {
|
||||
mutations.update.mutate({ id: target.id, input }, { onSuccess, onError });
|
||||
} else {
|
||||
mutations.create.mutate(input, { onSuccess, onError });
|
||||
}
|
||||
};
|
||||
|
||||
const apply = (provider: Provider) => {
|
||||
mutations.apply.mutate(
|
||||
{ id: provider.id, target: "codex" },
|
||||
@@ -53,16 +69,32 @@ export default function Codex() {
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{t("codex.title")}</h2>
|
||||
<Button variant="outline" size="sm" disabled={restore.isPending} onClick={restoreConfig}>
|
||||
{restore.isPending ? (
|
||||
<RotateCcw size={14} className="animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Zap size={14} aria-hidden />
|
||||
)}
|
||||
{restore.isPending ? t("common.restoring") : t("codex.restore")}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => setEditing({ provider: null })}>
|
||||
<Plus size={14} aria-hidden />
|
||||
{t("providers.add")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={restore.isPending} onClick={restoreConfig}>
|
||||
{restore.isPending ? (
|
||||
<RotateCcw size={14} className="animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Zap size={14} aria-hidden />
|
||||
)}
|
||||
{restore.isPending ? t("common.restoring") : t("codex.restore")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<ProviderForm
|
||||
target="codex"
|
||||
editing={editing.provider}
|
||||
pending={mutations.create.isPending || mutations.update.isPending}
|
||||
onSubmit={submitForm}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("codex.status")}</CardTitle>
|
||||
@@ -126,6 +158,29 @@ export default function Codex() {
|
||||
<Button variant="outline" size="sm" onClick={() => apply(provider)}>
|
||||
{t("codex.activate")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("common.edit")}
|
||||
onClick={() => setEditing({ provider })}
|
||||
>
|
||||
<Pencil size={14} aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("common.delete")}
|
||||
onClick={() => {
|
||||
if (window.confirm(t("providers.deleteConfirm"))) {
|
||||
mutations.remove.mutate(provider.id, {
|
||||
onSuccess: () => show(t("providers.deleted")),
|
||||
onError: (err: Error) => show(err.message, "error"),
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -41,6 +41,7 @@ const provider = {
|
||||
api_key: "sk-x",
|
||||
models: { default: "deepseek-chat", sonnet: "deepseek-chat", haiku: "deepseek-chat", opus: "deepseek-chat" },
|
||||
enabled_for_claude: false,
|
||||
target: "claude",
|
||||
created_at: "2026-10-18T00:00:00Z",
|
||||
};
|
||||
|
||||
@@ -87,6 +88,7 @@ describe("Providers page", () => {
|
||||
name: "Kimi",
|
||||
base_url: "https://api.moonshot.cn/v1",
|
||||
api_key: "sk-kimi",
|
||||
target: "claude",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+30
-254
@@ -1,118 +1,33 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Trash2, Pencil } from "lucide-react";
|
||||
import { PRESETS } from "@/lib/types";
|
||||
import type { Models, Provider } from "@/lib/types";
|
||||
import type { Provider } from "@/lib/types";
|
||||
import { useProviders, useProviderMutations } from "@/lib/queries";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input, Label } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
|
||||
interface FormState {
|
||||
id?: string;
|
||||
name: string;
|
||||
preset: string;
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
models: Omit<Models, "extra"> & { extra: { key: string; value: string }[] };
|
||||
}
|
||||
|
||||
const emptyForm: FormState = {
|
||||
name: "",
|
||||
preset: "",
|
||||
base_url: "",
|
||||
api_key: "",
|
||||
models: { default: "", sonnet: "", haiku: "", opus: "", extra: [] },
|
||||
};
|
||||
|
||||
function toRows(extra: Record<string, string> | undefined): { key: string; value: string }[] {
|
||||
return Object.entries(extra ?? {}).map(([key, value]) => ({ key, value }));
|
||||
}
|
||||
|
||||
function toMap(rows: { key: string; value: string }[]): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
rows.filter((r) => r.key.trim() && r.value.trim()).map((r) => [r.key.trim(), r.value.trim()]),
|
||||
);
|
||||
}
|
||||
import { ProviderForm, type ProviderSubmitInput } from "@/components/ProviderForm";
|
||||
|
||||
export default function Providers() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isError } = useProviders();
|
||||
const { data, isLoading, isError } = useProviders("claude");
|
||||
const mutations = useProviderMutations();
|
||||
const { show } = useToast();
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
|
||||
const [editing, setEditing] = useState<{ provider: Provider | null } | null>(null);
|
||||
const [lastAppliedEnv, setLastAppliedEnv] = useState<Record<string, string> | null>(null);
|
||||
|
||||
const providers = data?.items ?? [];
|
||||
|
||||
const openCreate = (presetKey = "") => {
|
||||
const preset = PRESETS.find((p) => p.key === presetKey);
|
||||
setErrors({});
|
||||
setForm({
|
||||
...emptyForm,
|
||||
preset: presetKey,
|
||||
name: presetKey ? presetKey : "",
|
||||
base_url: preset?.base_url ?? "",
|
||||
models: preset
|
||||
? { ...preset.models, extra: toRows(preset.models.extra) }
|
||||
: { ...emptyForm.models },
|
||||
});
|
||||
};
|
||||
|
||||
const openEdit = (provider: Provider) => {
|
||||
setErrors({});
|
||||
setForm({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
preset: provider.preset ?? "",
|
||||
base_url: provider.base_url,
|
||||
api_key: "",
|
||||
models: {
|
||||
default: provider.models.default ?? "",
|
||||
sonnet: provider.models.sonnet ?? "",
|
||||
haiku: provider.models.haiku ?? "",
|
||||
opus: provider.models.opus ?? "",
|
||||
extra: toRows(provider.models.extra),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const validate = (): boolean => {
|
||||
if (!form) return false;
|
||||
const next: Partial<Record<keyof FormState, string>> = {};
|
||||
if (!form.name.trim()) next.name = t("providers.validationName");
|
||||
if (!/^https?:\/\/.+/.test(form.base_url.trim())) next.base_url = t("providers.validationBaseUrl");
|
||||
if (!form.id && !form.api_key.trim()) next.api_key = t("providers.validationApiKey");
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (!form || !validate()) return;
|
||||
const input = {
|
||||
name: form.name.trim(),
|
||||
preset: form.preset,
|
||||
base_url: form.base_url.trim(),
|
||||
api_key: form.api_key.trim(),
|
||||
models: {
|
||||
default: form.models.default?.trim(),
|
||||
sonnet: form.models.sonnet?.trim(),
|
||||
haiku: form.models.haiku?.trim(),
|
||||
opus: form.models.opus?.trim(),
|
||||
extra: toMap(form.models.extra),
|
||||
},
|
||||
};
|
||||
const submitForm = (input: ProviderSubmitInput) => {
|
||||
const target = editing?.provider ?? null;
|
||||
const onSuccess = () => {
|
||||
show(form.id ? t("providers.updated") : t("providers.created"));
|
||||
setForm(null);
|
||||
show(target ? t("providers.updated") : t("providers.created"));
|
||||
setEditing(null);
|
||||
};
|
||||
const onError = (err: Error) => show(err.message, "error");
|
||||
if (form.id) {
|
||||
mutations.update.mutate({ id: form.id, input }, { onSuccess, onError });
|
||||
if (target) {
|
||||
mutations.update.mutate({ id: target.id, input }, { onSuccess, onError });
|
||||
} else {
|
||||
mutations.create.mutate(input, { onSuccess, onError });
|
||||
}
|
||||
@@ -126,7 +41,7 @@ export default function Providers() {
|
||||
setLastAppliedEnv(result.env ?? null);
|
||||
show(`${t("providers.activated")}: ${provider.name}`);
|
||||
},
|
||||
onError: (err) => show(err.message, "error"),
|
||||
onError: (err: Error) => show(err.message, "error"),
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -138,166 +53,20 @@ export default function Providers() {
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{t("providers.title")}</h2>
|
||||
<Button size="sm" onClick={() => openCreate()}>
|
||||
<Button size="sm" onClick={() => setEditing({ provider: null })}>
|
||||
<Plus size={14} aria-hidden />
|
||||
{t("providers.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{form && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{form.id ? t("providers.edit") : t("providers.add")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<Label>{t("providers.name")}</Label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="DeepSeek"
|
||||
/>
|
||||
{errors.name && <span className="text-xs text-red-600">{errors.name}</span>}
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<Label>{t("providers.preset")}</Label>
|
||||
<Select
|
||||
value={form.preset}
|
||||
onChange={(e) => {
|
||||
const key = e.target.value;
|
||||
const preset = PRESETS.find((p) => p.key === key);
|
||||
setForm({
|
||||
...form,
|
||||
preset: key,
|
||||
base_url: preset?.base_url ?? form.base_url,
|
||||
models: preset
|
||||
? { ...preset.models, extra: toRows(preset.models.extra) }
|
||||
: form.models,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">{t("providers.presetCustom")}</option>
|
||||
{PRESETS.map((p) => (
|
||||
<option key={p.key} value={p.key}>
|
||||
{p.key}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<Label>{t("providers.baseUrl")}</Label>
|
||||
<Input
|
||||
value={form.base_url}
|
||||
onChange={(e) => setForm({ ...form, base_url: e.target.value })}
|
||||
placeholder="https://api.deepseek.com/v1"
|
||||
/>
|
||||
{errors.base_url && <span className="text-xs text-red-600">{errors.base_url}</span>}
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<Label>
|
||||
{t("providers.apiKey")}
|
||||
{form.id && <span className="ml-1 text-zinc-400">({t("providers.keyHint")})</span>}
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.api_key}
|
||||
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
{errors.api_key && <span className="text-xs text-red-600">{errors.api_key}</span>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-xs font-medium text-zinc-600">{t("providers.models")}</legend>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{(["default", "sonnet", "haiku", "opus"] as const).map((role) => (
|
||||
<label key={role} className="flex flex-col gap-1">
|
||||
<Label>{t(`providers.${role}Model`)}</Label>
|
||||
<Input
|
||||
value={form.models[role] ?? ""}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, models: { ...form.models, [role]: e.target.value } })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-zinc-600">{t("providers.extraMappings")}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setForm({
|
||||
...form,
|
||||
models: {
|
||||
...form.models,
|
||||
extra: [...form.models.extra, { key: "", value: "" }],
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus size={12} aria-hidden />
|
||||
{t("providers.addMapping")}
|
||||
</Button>
|
||||
</div>
|
||||
{form.models.extra.map((row, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={row.key}
|
||||
placeholder={t("providers.extraKey")}
|
||||
onChange={(e) => {
|
||||
const extra = form.models.extra.map((r, i) =>
|
||||
i === index ? { ...r, key: e.target.value } : r,
|
||||
);
|
||||
setForm({ ...form, models: { ...form.models, extra } });
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
value={row.value}
|
||||
placeholder={t("providers.extraValue")}
|
||||
onChange={(e) => {
|
||||
const extra = form.models.extra.map((r, i) =>
|
||||
i === index ? { ...r, value: e.target.value } : r,
|
||||
);
|
||||
setForm({ ...form, models: { ...form.models, extra } });
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("common.delete")}
|
||||
onClick={() =>
|
||||
setForm({
|
||||
...form,
|
||||
models: {
|
||||
...form.models,
|
||||
extra: form.models.extra.filter((_, i) => i !== index),
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-xs text-zinc-500">{t("providers.extraHint")}</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setForm(null)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={submit}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{editing && (
|
||||
<ProviderForm
|
||||
target="claude"
|
||||
editing={editing.provider}
|
||||
pending={mutations.create.isPending || mutations.update.isPending}
|
||||
onSubmit={submitForm}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{lastAppliedEnv && (
|
||||
@@ -334,7 +103,9 @@ export default function Providers() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{provider.name}</span>
|
||||
{provider.enabled_for_claude && <Badge tone="success">{t("providers.activated")}</Badge>}
|
||||
{provider.enabled_for_claude && (
|
||||
<Badge tone="success">{t("providers.activated")}</Badge>
|
||||
)}
|
||||
{provider.preset && <Badge>{provider.preset}</Badge>}
|
||||
</div>
|
||||
<div className="truncate text-xs text-zinc-500">{provider.base_url}</div>
|
||||
@@ -343,7 +114,12 @@ export default function Providers() {
|
||||
<Button variant="outline" size="sm" onClick={() => apply(provider)}>
|
||||
{t("providers.activate")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" aria-label={t("common.edit")} onClick={() => openEdit(provider)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("common.edit")}
|
||||
onClick={() => setEditing({ provider })}
|
||||
>
|
||||
<Pencil size={14} aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
@@ -354,7 +130,7 @@ export default function Providers() {
|
||||
if (window.confirm(t("providers.deleteConfirm"))) {
|
||||
mutations.remove.mutate(provider.id, {
|
||||
onSuccess: () => show(t("providers.deleted")),
|
||||
onError: (err) => show(err.message, "error"),
|
||||
onError: (err: Error) => show(err.message, "error"),
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
||||
Reference in new issue
Block a user