CrossRelay stage 2: Codex /v1/responses relay, config.toml manager, Codex page

This commit is contained in:
kate522 committed 2026-10-18 18:35:01 +08:00
1 parent 404704171d
commit 5112f7ca06
30 files changed
+3539 -72

No files matched your search

+2
View File
@@ -7,6 +7,7 @@ import { ToastProvider } from "./components/toast";
import { Layout } from "./components/Layout";
import Dashboard from "./pages/Dashboard";
import Providers from "./pages/Providers";
import Codex from "./pages/Codex";
import SettingsPage from "./pages/SettingsPage";
const queryClient = new QueryClient({
@@ -31,6 +32,7 @@ export default function App({ initialLanguage }: { initialLanguage?: string }) {
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/providers" element={<Providers />} />
<Route path="/codex" element={<Codex />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Routes>
+2 -1
View File
@@ -1,6 +1,6 @@
import { NavLink, Outlet } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Gauge, Server, Settings as SettingsIcon } from "lucide-react";
import { Gauge, Server, Settings as SettingsIcon, Terminal } from "lucide-react";
import { cn } from "@/lib/utils";
import { useSettingsMutation, useSettings } from "@/lib/queries";
import i18n from "@/i18n";
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
const navItems = [
{ to: "/", key: "nav.dashboard", icon: Gauge },
{ to: "/providers", key: "nav.providers", icon: Server },
{ to: "/codex", key: "nav.codex", icon: Terminal },
{ to: "/settings", key: "nav.settings", icon: SettingsIcon },
];
+42
View File
@@ -8,6 +8,7 @@ export const zhCN = {
dashboard: "仪表盘",
providers: "供应商",
settings: "设置",
codex: "Codex",
},
common: {
save: "保存",
@@ -73,6 +74,7 @@ export const zhCN = {
extraKey: "请求的模型名",
extraValue: "上游模型名",
addMapping: "添加一行",
extraHint: "Codex 等客户端会把模型名原样发给网关,在此把入站模型名映射到上游模型名。",
activated: "已激活",
activate: "激活并写入",
empty: "还没有供应商,先添加一个",
@@ -87,6 +89,20 @@ export const zhCN = {
keyHint: "留空表示保持原有 Key 不变",
enabledClaude: "对 Claude Code 启用",
},
codex: {
title: "Codex",
status: "注入状态",
injected: "已注入",
notInjected: "未注入",
configPath: "配置文件",
portHint: "auto 端口模式下网关重启后端口会变,需重新激活",
preview: "已写入 config.toml 的托管配置",
activate: "激活并写入",
activated: "已激活",
restore: "还原官方配置",
restoreDone: "已还原 Codex 官方配置",
empty: "还没有供应商,请先到供应商页添加",
},
settings: {
title: "设置",
gateway: "网关",
@@ -100,6 +116,11 @@ export const zhCN = {
backupsEmpty: "暂无备份",
restoreBackup: "还原",
restored: "已还原",
backupTarget: "备份目标",
targetClaude: "Claude Code",
targetCodex: "Codex",
rollback: "回滚",
rolledBack: "已回滚",
saveFailed: "保存失败",
},
},
@@ -115,6 +136,7 @@ export const enUS = {
dashboard: "Dashboard",
providers: "Providers",
settings: "Settings",
codex: "Codex",
},
common: {
save: "Save",
@@ -180,6 +202,7 @@ export const enUS = {
extraKey: "Requested model",
extraValue: "Upstream model",
addMapping: "Add row",
extraHint: "Clients like Codex send their model names as-is; map inbound names onto upstream models here.",
activated: "Active",
activate: "Activate & apply",
empty: "No providers yet",
@@ -194,6 +217,20 @@ export const enUS = {
keyHint: "Leave empty to keep the current key",
enabledClaude: "Enabled for Claude Code",
},
codex: {
title: "Codex",
status: "Injection status",
injected: "Injected",
notInjected: "Not injected",
configPath: "Config file",
portHint: "With an automatic port the gateway picks a new port on restart; activate again afterwards.",
preview: "Managed keys written to config.toml",
activate: "Activate & apply",
activated: "Active",
restore: "Restore official config",
restoreDone: "Codex official config restored",
empty: "No providers yet, add one on the Providers page",
},
settings: {
title: "Settings",
gateway: "Gateway",
@@ -207,6 +244,11 @@ export const enUS = {
backupsEmpty: "No backups yet",
restoreBackup: "Restore",
restored: "Restored",
backupTarget: "Backup target",
targetClaude: "Claude Code",
targetCodex: "Codex",
rollback: "Roll back",
rolledBack: "Rolled back",
saveFailed: "Save failed",
},
},
+19 -4
View File
@@ -61,12 +61,27 @@ export const api = {
request<Provider>(`/api/providers/${id}`, { method: "PUT", body: JSON.stringify(input) }),
deleteProvider: (id: string) =>
request<{ deleted: string }>(`/api/providers/${id}`, { method: "DELETE" }),
applyProvider: (id: string) =>
request<ApplyResult>(`/api/providers/${id}/apply`, { method: "POST", body: "{}" }),
restore: () => request<{ restored_from: string }>("/api/restore", { method: "POST", body: "{}" }),
listBackups: () => request<{ items: BackupInfo[] }>("/api/backups"),
applyProvider: (id: string, target: ApplyTarget = "claude") =>
request<ApplyResult>(`/api/providers/${id}/apply`, {
method: "POST",
body: JSON.stringify({ target }),
}),
restore: (input: RestoreInput = {}) =>
request<{ restored_from: string }>("/api/restore", {
method: "POST",
body: JSON.stringify(input),
}),
listBackups: (target: ApplyTarget = "claude") =>
request<{ items: BackupInfo[] }>(`/api/backups?target=${target}`),
getSettings: () => request<Settings>("/api/settings"),
updateSettings: (patch: Partial<Settings>) =>
request<Settings>("/api/settings", { method: "PUT", body: JSON.stringify(patch) }),
listLogs: () => request<{ items: LogEntry[] }>("/api/logs"),
};
export type ApplyTarget = "claude" | "codex";
export interface RestoreInput {
target?: ApplyTarget;
backup?: string;
}
+7 -6
View File
@@ -5,7 +5,7 @@ export const queryKeys = {
status: ["status"] as const,
providers: ["providers"] as const,
logs: ["logs"] as const,
backups: ["backups"] as const,
backups: (target: string) => ["backups", target] as const,
settings: ["settings"] as const,
};
@@ -21,8 +21,8 @@ export function useLogs() {
return useQuery({ queryKey: queryKeys.logs, queryFn: api.listLogs, refetchInterval: 5000 });
}
export function useBackups() {
return useQuery({ queryKey: queryKeys.backups, queryFn: api.listBackups });
export function useBackups(target: "claude" | "codex") {
return useQuery({ queryKey: queryKeys.backups(target), queryFn: () => api.listBackups(target) });
}
export function useSettings() {
@@ -33,7 +33,7 @@ function invalidateAll(queryClient: ReturnType<typeof useQueryClient>) {
return Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.providers }),
queryClient.invalidateQueries({ queryKey: queryKeys.status }),
queryClient.invalidateQueries({ queryKey: queryKeys.backups }),
queryClient.invalidateQueries({ queryKey: ["backups"] }),
queryClient.invalidateQueries({ queryKey: queryKeys.settings }),
]);
}
@@ -53,7 +53,8 @@ export function useProviderMutations() {
onSuccess: () => invalidateAll(queryClient),
});
const apply = useMutation({
mutationFn: (id: string) => api.applyProvider(id),
mutationFn: ({ id, target }: { id: string; target: "claude" | "codex" }) =>
api.applyProvider(id, target),
onSuccess: () => invalidateAll(queryClient),
});
return { create, update, remove, apply };
@@ -62,7 +63,7 @@ export function useProviderMutations() {
export function useRestore() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => api.restore(),
mutationFn: (input: Parameters<typeof api.restore>[0]) => api.restore(input),
onSuccess: () => invalidateAll(queryClient),
});
}
+40 -9
View File
@@ -14,6 +14,7 @@ export interface Provider {
api_key: string;
models: Models;
enabled_for_claude: boolean;
enabled_for_codex: boolean;
created_at: string;
}
@@ -37,6 +38,7 @@ export interface Status {
port: number;
uptime_sec: number;
active_claude_provider: ActiveProvider | null;
active_codex_provider: ActiveProvider | null;
stats: Stats;
language: string;
port_mode: string;
@@ -69,7 +71,14 @@ export interface Settings {
export interface ApplyResult {
provider: Provider;
env: Record<string, string>;
env?: Record<string, string>;
injection?: Injection;
}
export interface Injection {
model_provider: string;
table: Record<string, string>;
prior_provider?: string;
}
export interface Preset {
@@ -78,25 +87,47 @@ export interface Preset {
models: Models;
}
const CODEX_EXTRAS = ["gpt-5-codex", "gpt-5", "codex-mini-latest"];
export const PRESETS: Preset[] = [
{
key: "deepseek",
base_url: "https://api.deepseek.com/v1",
models: { default: "deepseek-chat", sonnet: "deepseek-chat", haiku: "deepseek-chat", opus: "deepseek-chat" },
models: withCodexExtras({
default: "deepseek-chat",
sonnet: "deepseek-chat",
haiku: "deepseek-chat",
opus: "deepseek-chat",
}, "deepseek-chat"),
},
{
key: "moonshot",
base_url: "https://api.moonshot.cn/v1",
models: {
default: "kimi-k2-0905-preview",
sonnet: "kimi-k2-0905-preview",
haiku: "kimi-k2-0905-preview",
opus: "kimi-k2-0905-preview",
},
models: withCodexExtras(
{
default: "kimi-k2-0905-preview",
sonnet: "kimi-k2-0905-preview",
haiku: "kimi-k2-0905-preview",
opus: "kimi-k2-0905-preview",
},
"kimi-k2-0905-preview",
),
},
{
key: "zhipu",
base_url: "https://open.bigmodel.cn/api/paas/v4",
models: { default: "glm-4.6", sonnet: "glm-4.6", haiku: "glm-4.6", opus: "glm-4.6" },
models: withCodexExtras(
{
default: "glm-4.6",
sonnet: "glm-4.6",
haiku: "glm-4.6",
opus: "glm-4.6",
},
"glm-4.6",
),
},
];
function withCodexExtras(models: Models, upstream: string): Models {
return { ...models, extra: Object.fromEntries(CODEX_EXTRAS.map((m) => [m, upstream])) };
}
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { renderWithProviders } from "@/test/utils";
import userEvent from "@testing-library/user-event";
const listProviders = vi.fn();
const applyProvider = vi.fn();
const restore = vi.fn();
const getStatus = vi.fn();
vi.mock("@/lib/api", () => ({
api: {
listProviders: () => listProviders(),
createProvider: vi.fn(),
updateProvider: vi.fn(),
deleteProvider: vi.fn(),
applyProvider: (id: string, target: string) => applyProvider(id, target),
restore: (input: unknown) => restore(input),
getStatus: () => getStatus(),
getSettings: vi.fn().mockResolvedValue({ language: "zh-CN", port_mode: "auto", fixed_port: 0 }),
updateSettings: vi.fn(),
listLogs: vi.fn().mockResolvedValue({ items: [] }),
listBackups: vi.fn().mockResolvedValue({ items: [] }),
},
}));
import Codex from "./Codex";
const provider = {
id: "p_1",
name: "DeepSeek",
preset: "deepseek",
base_url: "https://api.deepseek.com/v1",
api_key: "sk-x",
models: { default: "deepseek-chat", sonnet: "deepseek-chat", haiku: "deepseek-chat", opus: "deepseek-chat" },
enabled_for_claude: false,
enabled_for_codex: false,
created_at: "2026-10-18T00:00:00Z",
};
beforeEach(() => {
vi.clearAllMocks();
listProviders.mockResolvedValue({ items: [provider] });
getStatus.mockResolvedValue({
running: true,
version: "0.1.0",
port: 52917,
uptime_sec: 95,
active_claude_provider: null,
active_codex_provider: null,
stats: { requests_total: 0, requests_failed: 0, input_tokens: 0, output_tokens: 0 },
language: "zh-CN",
port_mode: "auto",
fixed_port: 0,
});
applyProvider.mockResolvedValue({
provider,
injection: {
model_provider: "crossrelay",
table: {
name: "crossrelay",
wire_api: "responses",
base_url: "http://127.0.0.1:52917",
experimental_bearer_token: "crl_x",
requires_openai_auth: "false",
},
},
});
restore.mockResolvedValue({ restored_from: "" });
});
describe("Codex page", () => {
it("shows the injection status, config path and provider list", async () => {
renderWithProviders(<Codex />);
expect(await screen.findByText("未注入")).toBeInTheDocument();
expect(screen.getByText(/config\.toml/)).toBeInTheDocument();
expect(screen.getByText("DeepSeek")).toBeInTheDocument();
});
it("activates a provider for Codex and previews the injected keys", async () => {
const user = userEvent.setup();
renderWithProviders(<Codex />);
await user.click(await screen.findByRole("button", { name: /激活并写入|Activate & apply/ }));
await waitFor(() => expect(applyProvider).toHaveBeenCalledWith("p_1", "codex"));
expect(await screen.findByText(/experimental_bearer_token/)).toBeInTheDocument();
expect(screen.getByText(/wire_api/)).toBeInTheDocument();
});
it("restores the official config", async () => {
const user = userEvent.setup();
renderWithProviders(<Codex />);
await user.click(await screen.findByRole("button", { name: /还原官方配置|Restore official config/ }));
await waitFor(() => expect(restore).toHaveBeenCalledWith({ target: "codex" }));
});
});
+137
View File
@@ -0,0 +1,137 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { RotateCcw, 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";
export default function Codex() {
const { t } = useTranslation();
const status = useStatus();
const { data, isLoading, isError } = useProviders();
const mutations = useProviderMutations();
const restore = useRestore();
const { show } = useToast();
const [injection, setInjection] = useState<Record<string, string> | null>(null);
const providers = data?.items ?? [];
const active = status.data?.active_codex_provider ?? null;
const apply = (provider: Provider) => {
mutations.apply.mutate(
{ id: provider.id, target: "codex" },
{
onSuccess: (result) => {
setInjection(result.injection?.table ?? null);
show(`${t("codex.activated")}: ${provider.name}`);
},
onError: (err: Error) => show(err.message, "error"),
},
);
};
const restoreConfig = () => {
restore.mutate(
{ target: "codex" },
{
onSuccess: () => {
setInjection(null);
show(t("codex.restoreDone"));
},
onError: (err: Error) => show(err.message, "error"),
},
);
};
if (isLoading) return <div className="text-sm text-zinc-500">{t("common.loading")}</div>;
if (isError) return <div className="text-sm text-red-600">{t("common.error")}</div>;
return (
<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>
<Card>
<CardHeader>
<CardTitle>{t("codex.status")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Badge tone={active ? "success" : "danger"}>
{active ? t("codex.injected") : t("codex.notInjected")}
</Badge>
{active && <span className="text-sm font-medium">{active.name}</span>}
</div>
<div className="text-xs text-zinc-500">
{t("codex.configPath")}: ~/.codex/config.toml
</div>
<div className="text-xs text-zinc-500">{t("codex.portHint")}</div>
</CardContent>
</Card>
{injection && (
<Card>
<CardHeader>
<CardTitle>{t("codex.preview")}</CardTitle>
</CardHeader>
<CardContent>
<ul className="grid grid-cols-2 gap-1 font-mono text-xs text-zinc-600">
<li>
<span className="text-zinc-400">model_provider</span> = crossrelay
</li>
{Object.entries(injection)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => (
<li key={key}>
<span className="text-zinc-400">{key}</span> = {value}
</li>
))}
</ul>
</CardContent>
</Card>
)}
{providers.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-zinc-500">
{t("codex.empty")}
</CardContent>
</Card>
) : (
<div className="flex flex-col gap-3">
{providers.map((provider) => (
<Card key={provider.id}>
<CardContent className="flex items-center gap-4 py-4">
<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_codex && <Badge tone="success">{t("codex.activated")}</Badge>}
{provider.preset && <Badge>{provider.preset}</Badge>}
</div>
<div className="truncate text-xs text-zinc-500">{provider.base_url}</div>
</div>
<div className="flex shrink-0 gap-2">
<Button variant="outline" size="sm" onClick={() => apply(provider)}>
{t("codex.activate")}
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
+5 -2
View File
@@ -118,10 +118,13 @@ export default function Dashboard() {
size="sm"
disabled={restore.isPending}
onClick={() =>
restore.mutate(undefined, {
restore.mutate(
{ target: "claude" },
{
onSuccess: () => show(t("dashboard.restoreDone")),
onError: (err) => show(err.message, "error"),
})
},
)
}
>
{restore.isPending ? (
+19 -3
View File
@@ -21,8 +21,8 @@ vi.mock("@/lib/api", () => ({
createProvider: (input: unknown) => createProvider(input),
updateProvider: (id: string, input: unknown) => updateProvider(id, input),
deleteProvider: (id: string) => deleteProvider(id),
applyProvider: (id: string) => applyProvider(id),
restore: () => restore(),
applyProvider: (id: string, target: string) => applyProvider(id, target),
restore: (input: unknown) => restore(input),
getStatus: () => getStatus(),
getSettings: () => getSettings(),
updateSettings: (patch: unknown) => updateSettings(patch),
@@ -94,7 +94,23 @@ describe("Providers page", () => {
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole("button", { name: /激活并写入|Activate & apply/ }));
await waitFor(() => expect(applyProvider).toHaveBeenCalledWith("p_1"));
await waitFor(() => expect(applyProvider).toHaveBeenCalledWith("p_1", "claude"));
expect(await screen.findByText(/ANTHROPIC_BASE_URL/)).toBeInTheDocument();
});
it("submits exact model mappings from the mapping editor", async () => {
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole("button", { name: /新增供应商|Add provider/ }));
await user.click(screen.getByRole("button", { name: /添加一行|Add row/ }));
await user.type(screen.getByPlaceholderText(/请求的模型名|Requested model/), "gpt-5-codex");
await user.type(screen.getByPlaceholderText(/上游模型名|Upstream model/), "deepseek-chat");
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));
const input = createProvider.mock.calls[0][0];
expect(input.models.extra).toEqual({ "gpt-5-codex": "deepseek-chat" });
});
});
+91 -14
View File
@@ -17,7 +17,7 @@ interface FormState {
preset: string;
base_url: string;
api_key: string;
models: Models;
models: Omit<Models, "extra"> & { extra: { key: string; value: string }[] };
}
const emptyForm: FormState = {
@@ -25,9 +25,19 @@ const emptyForm: FormState = {
preset: "",
base_url: "",
api_key: "",
models: { default: "", sonnet: "", haiku: "", opus: "", extra: {} },
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()]),
);
}
export default function Providers() {
const { t } = useTranslation();
const { data, isLoading, isError } = useProviders();
@@ -47,7 +57,9 @@ export default function Providers() {
preset: presetKey,
name: presetKey ? presetKey : "",
base_url: preset?.base_url ?? "",
models: preset ? { ...preset.models } : { ...emptyForm.models },
models: preset
? { ...preset.models, extra: toRows(preset.models.extra) }
: { ...emptyForm.models },
});
};
@@ -64,7 +76,7 @@ export default function Providers() {
sonnet: provider.models.sonnet ?? "",
haiku: provider.models.haiku ?? "",
opus: provider.models.opus ?? "",
extra: { ...(provider.models.extra ?? {}) },
extra: toRows(provider.models.extra),
},
});
};
@@ -91,9 +103,7 @@ export default function Providers() {
sonnet: form.models.sonnet?.trim(),
haiku: form.models.haiku?.trim(),
opus: form.models.opus?.trim(),
extra: Object.fromEntries(
Object.entries(form.models.extra ?? {}).filter(([k, v]) => k.trim() && v.trim()),
),
extra: toMap(form.models.extra),
},
};
const onSuccess = () => {
@@ -109,13 +119,16 @@ export default function Providers() {
};
const apply = (provider: Provider) => {
mutations.apply.mutate(provider.id, {
onSuccess: (result) => {
setLastAppliedEnv(result.env);
show(`${t("providers.activated")}: ${provider.name}`);
mutations.apply.mutate(
{ id: provider.id, target: "claude" },
{
onSuccess: (result) => {
setLastAppliedEnv(result.env ?? null);
show(`${t("providers.activated")}: ${provider.name}`);
},
onError: (err) => show(err.message, "error"),
},
onError: (err) => show(err.message, "error"),
});
);
};
if (isLoading) return <div className="text-sm text-zinc-500">{t("common.loading")}</div>;
@@ -158,7 +171,9 @@ export default function Providers() {
...form,
preset: key,
base_url: preset?.base_url ?? form.base_url,
models: preset ? { ...preset.models } : form.models,
models: preset
? { ...preset.models, extra: toRows(preset.models.extra) }
: form.models,
});
}}
>
@@ -209,6 +224,68 @@ export default function Providers() {
</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">
+41 -8
View File
@@ -11,7 +11,8 @@ import { Table, TBody, TD, TH, THead, TR } from "@/components/ui/table";
export default function SettingsPage() {
const { t, i18n } = useTranslation();
const { data: settings } = useSettings();
const { data: backups } = useBackups();
const [backupTarget, setBackupTarget] = useState<"claude" | "codex">("claude");
const { data: backups } = useBackups(backupTarget);
const mutation = useSettingsMutation();
const restore = useRestore();
const { show } = useToast();
@@ -93,16 +94,29 @@ export default function SettingsPage() {
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle>{t("settings.backups")}</CardTitle>
<Select
value={backupTarget}
onChange={(e) => setBackupTarget(e.target.value as "claude" | "codex")}
className="max-w-40"
>
<option value="claude">{t("settings.targetClaude")}</option>
<option value="codex">{t("settings.targetCodex")}</option>
</Select>
<Button
variant="outline"
size="sm"
disabled={restore.isPending}
onClick={() =>
restore.mutate(undefined, {
onSuccess: (result) => show(`${t("settings.restored")}: ${result.restored_from}`),
onError: (err) => show(err.message, "error"),
})
}
disabled={restore.isPending || !backups?.items?.length}
onClick={() => {
const newest = backups?.items?.[0]?.name;
if (!newest) return;
restore.mutate(
{ target: backupTarget, backup: newest },
{
onSuccess: () => show(`${t("settings.restored")}: ${newest}`),
onError: (err) => show(err.message, "error"),
},
);
}}
>
{t("settings.restoreBackup")}
</Button>
@@ -115,6 +129,7 @@ export default function SettingsPage() {
<TH>{t("settings.backups")}</TH>
<TH>Size</TH>
<TH>Modified</TH>
<TH>{t("common.actions")}</TH>
</TR>
</THead>
<TBody>
@@ -123,6 +138,24 @@ export default function SettingsPage() {
<TD className="font-mono text-xs">{item.name}</TD>
<TD className="text-xs">{item.size} B</TD>
<TD className="text-xs">{new Date(item.modified).toLocaleString()}</TD>
<TD>
<Button
variant="ghost"
size="sm"
disabled={restore.isPending}
onClick={() =>
restore.mutate(
{ target: backupTarget, backup: item.name },
{
onSuccess: () => show(`${t("settings.rolledBack")}: ${item.name}`),
onError: (err) => show(err.message, "error"),
},
)
}
>
{t("settings.rollback")}
</Button>
</TD>
</TR>
))}
</TBody>