CrossRelay stage 0+1: Go gateway with Anthropic<->Chat Completions relay, Tauri shell, bilingual management UI
- Go gateway (stdlib only): /v1/messages non-stream + SSE, /v1/messages/count_tokens, management API /api/* (providers CRUD, apply/restore, settings, backups, logs) - Protocol conversion: Anthropic Messages <-> Chat Completions with a streaming state machine keyed by content block index (text, tool_use, parallel calls) - ~/.claude/settings.json guarded read/write: timestamped backups, managed env keys only, one-click restore - Tauri 2 shell: spawns the gateway sidecar, parses the random port from stdout, stdin pipe watchdog prevents orphans, kills the child on exit - React frontend: Dashboard/Providers/Settings, zh-CN + en-US, TanStack Query - Tests: Go table-driven + httptest integration (all green), vitest + RTL
This commit is contained in:
1 parent
8ee5e98ea3
commit
f8acf31ebc
71 files changed
+15727
-1
No files matched your search
+42
@@ -0,0 +1,42 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import i18n from "./i18n";
|
||||
import { ToastProvider } from "./components/toast";
|
||||
import { Layout } from "./components/Layout";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Providers from "./pages/Providers";
|
||||
import SettingsPage from "./pages/SettingsPage";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: 1, staleTime: 2000, refetchOnWindowFocus: false },
|
||||
},
|
||||
});
|
||||
|
||||
export default function App({ initialLanguage }: { initialLanguage?: string }) {
|
||||
useEffect(() => {
|
||||
if (initialLanguage && i18n.language !== initialLanguage) {
|
||||
void i18n.changeLanguage(initialLanguage);
|
||||
}
|
||||
}, [initialLanguage]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<ToastProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/providers" element={<Providers />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ToastProvider>
|
||||
</I18nextProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { fetch } from "@tauri-apps/plugin-http";
|
||||
import { SUPPORTED_LANGUAGES, type Language } from "./i18n";
|
||||
|
||||
export interface Bootstrap {
|
||||
port: number | null;
|
||||
language: Language;
|
||||
}
|
||||
|
||||
// Resolves the gateway port from the Tauri shell and loads the saved UI
|
||||
// language before the first render.
|
||||
export async function bootstrap(): Promise<Bootstrap> {
|
||||
let port: number | null = null;
|
||||
try {
|
||||
port = await invoke<number | null>("gateway_port");
|
||||
} catch {
|
||||
port = null;
|
||||
}
|
||||
let language: Language = "zh-CN";
|
||||
if (port) {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/settings`);
|
||||
if (response.ok) {
|
||||
const text = await response.text();
|
||||
const parsed = text ? JSON.parse(text) : {};
|
||||
if (SUPPORTED_LANGUAGES.includes(parsed.language)) {
|
||||
language = parsed.language;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Gateway unreachable: fall back to the default language.
|
||||
}
|
||||
}
|
||||
return { port, language };
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Gauge, Server, Settings as SettingsIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSettingsMutation, useSettings } from "@/lib/queries";
|
||||
import i18n from "@/i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const navItems = [
|
||||
{ to: "/", key: "nav.dashboard", icon: Gauge },
|
||||
{ to: "/providers", key: "nav.providers", icon: Server },
|
||||
{ to: "/settings", key: "nav.settings", icon: SettingsIcon },
|
||||
];
|
||||
|
||||
export function Layout() {
|
||||
const { t } = useTranslation();
|
||||
const { data: settings } = useSettings();
|
||||
const mutation = useSettingsMutation();
|
||||
const language = settings?.language ?? i18n.language;
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const next = language === "zh-CN" ? "en-US" : "zh-CN";
|
||||
void i18n.changeLanguage(next);
|
||||
mutation.mutate({ language: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-zinc-50 text-zinc-900">
|
||||
<aside className="flex w-52 shrink-0 flex-col border-r border-zinc-200 bg-white">
|
||||
<div className="px-4 py-4">
|
||||
<div className="text-base font-semibold tracking-tight">CrossRelay</div>
|
||||
<div className="text-xs text-zinc-500">{t("app.tagline")}</div>
|
||||
</div>
|
||||
<nav className="flex flex-col gap-0.5 px-2">
|
||||
{navItems.map(({ to, key, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === "/"}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-teal-50 text-teal-800"
|
||||
: "text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon size={16} aria-hidden />
|
||||
{t(key)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="mt-auto p-3">
|
||||
<Button variant="outline" size="sm" className="w-full" onClick={toggleLanguage}>
|
||||
{language === "zh-CN" ? "中文 / EN" : "EN / 中文"}
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="mx-auto max-w-5xl p-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
tone: "success" | "error";
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toasts: Toast[];
|
||||
show: (message: string, tone?: Toast["tone"]) => void;
|
||||
}
|
||||
|
||||
const ToastContext = React.createContext<ToastContextValue | null>(null);
|
||||
|
||||
let nextId = 1;
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = React.useState<Toast[]>([]);
|
||||
const show = React.useCallback((message: string, tone: Toast["tone"] = "success") => {
|
||||
const id = nextId++;
|
||||
setToasts((current) => [...current, { id, message, tone }]);
|
||||
window.setTimeout(() => {
|
||||
setToasts((current) => current.filter((t) => t.id !== id));
|
||||
}, 4000);
|
||||
}, []);
|
||||
const value = React.useMemo(() => ({ toasts, show }), [toasts, show]);
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-72 flex-col gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
role="status"
|
||||
className={cn(
|
||||
"pointer-events-auto rounded-md px-3 py-2 text-sm shadow-md",
|
||||
toast.tone === "success"
|
||||
? "bg-emerald-600 text-white"
|
||||
: "bg-red-600 text-white",
|
||||
)}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const ctx = React.useContext(ToastContext);
|
||||
if (!ctx) throw new Error("useToast must be used inside ToastProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Tone = "neutral" | "success" | "warning" | "danger";
|
||||
|
||||
const tones: Record<Tone, string> = {
|
||||
neutral: "bg-zinc-100 text-zinc-700",
|
||||
success: "bg-emerald-50 text-emerald-700",
|
||||
warning: "bg-amber-50 text-amber-700",
|
||||
danger: "bg-red-50 text-red-700",
|
||||
};
|
||||
|
||||
export function Badge({
|
||||
tone = "neutral",
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement> & { tone?: Tone }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
tones[tone],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Variant = "default" | "outline" | "ghost" | "destructive";
|
||||
type Size = "sm" | "md" | "icon";
|
||||
|
||||
const variants: Record<Variant, string> = {
|
||||
default: "bg-teal-700 text-white hover:bg-teal-800",
|
||||
outline: "border border-zinc-300 bg-white text-zinc-800 hover:bg-zinc-50",
|
||||
ghost: "text-zinc-700 hover:bg-zinc-100",
|
||||
destructive: "bg-red-600 text-white hover:bg-red-700",
|
||||
};
|
||||
|
||||
const sizes: Record<Size, string> = {
|
||||
sm: "h-8 px-3 text-sm",
|
||||
md: "h-9 px-4 text-sm",
|
||||
icon: "h-9 w-9",
|
||||
};
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = "default", size = "md", ...props }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 focus-visible:ring-offset-1",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("rounded-lg border border-zinc-200 bg-white shadow-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex flex-col gap-1 p-5 pb-3", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return <h3 className={cn("text-sm font-semibold text-zinc-900", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return <p className={cn("text-xs text-zinc-500", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("p-5 pt-0", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-9 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900",
|
||||
"placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600",
|
||||
"disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export const Label = React.forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<label ref={ref} className={cn("text-xs font-medium text-zinc-600", className)} {...props} />
|
||||
),
|
||||
);
|
||||
Label.displayName = "Label";
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Select = React.forwardRef<
|
||||
HTMLSelectElement,
|
||||
React.SelectHTMLAttributes<HTMLSelectElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<select
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-9 w-full rounded-md border border-zinc-300 bg-white px-2 text-sm text-zinc-900",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Select.displayName = "Select";
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SwitchProps {
|
||||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
label: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function Switch({ checked, onCheckedChange, label, id }: SwitchProps) {
|
||||
return (
|
||||
<button
|
||||
id={id}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
onClick={() => onCheckedChange(!checked)}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
|
||||
checked ? "bg-teal-700" : "bg-zinc-300",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
|
||||
checked ? "translate-x-4" : "translate-x-0.5",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Table({ className, ...props }: React.TableHTMLAttributes<HTMLTableElement>) {
|
||||
return <table className={cn("w-full text-sm", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function THead({ className, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) {
|
||||
return <thead className={cn("border-b border-zinc-200", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function TBody({ className, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) {
|
||||
return <tbody className={cn("divide-y divide-zinc-100", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function TR({ className, ...props }: React.HTMLAttributes<HTMLTableRowElement>) {
|
||||
return <tr className={cn("text-left", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function TH({ className, ...props }: React.ThHTMLAttributes<HTMLTableCellElement>) {
|
||||
return (
|
||||
<th className={cn("px-3 py-2 text-xs font-medium text-zinc-500", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export function TD({ className, ...props }: React.TdHTMLAttributes<HTMLTableCellElement>) {
|
||||
return <td className={cn("px-3 py-2 text-zinc-800", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { enUS, zhCN } from "./resources";
|
||||
|
||||
function flatten(value: unknown, prefix = ""): string[] {
|
||||
if (typeof value !== "object" || value === null) return [prefix];
|
||||
return Object.entries(value as Record<string, unknown>).flatMap(([key, child]) =>
|
||||
flatten(child, prefix ? `${prefix}.${key}` : key),
|
||||
);
|
||||
}
|
||||
|
||||
describe("i18n resources", () => {
|
||||
it("zh-CN and en-US define the same keys", () => {
|
||||
const zhKeys = flatten(zhCN.translation).sort();
|
||||
const enKeys = flatten(enUS.translation).sort();
|
||||
expect(zhKeys).toEqual(enKeys);
|
||||
});
|
||||
|
||||
it("has no empty strings", () => {
|
||||
for (const key of flatten(zhCN.translation)) {
|
||||
const value = key
|
||||
.split(".")
|
||||
.reduce<unknown>((acc, part) => (acc as Record<string, unknown>)?.[part], zhCN.translation);
|
||||
expect(typeof value).toBe("string");
|
||||
expect((value as string).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { enUS, zhCN } from "./resources";
|
||||
|
||||
export const SUPPORTED_LANGUAGES = ["zh-CN", "en-US"] as const;
|
||||
export type Language = (typeof SUPPORTED_LANGUAGES)[number];
|
||||
|
||||
void i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
"zh-CN": zhCN,
|
||||
"en-US": enUS,
|
||||
},
|
||||
lng: "zh-CN",
|
||||
fallbackLng: "en-US",
|
||||
interpolation: { escapeValue: false },
|
||||
returnNull: false,
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,213 @@
|
||||
export const zhCN = {
|
||||
translation: {
|
||||
app: {
|
||||
name: "CrossRelay",
|
||||
tagline: "Claude Code 与 Codex 的本地协议网关",
|
||||
},
|
||||
nav: {
|
||||
dashboard: "仪表盘",
|
||||
providers: "供应商",
|
||||
settings: "设置",
|
||||
},
|
||||
common: {
|
||||
save: "保存",
|
||||
cancel: "取消",
|
||||
delete: "删除",
|
||||
edit: "编辑",
|
||||
add: "新增",
|
||||
activate: "激活",
|
||||
restoring: "还原中",
|
||||
restore: "还原官方配置",
|
||||
loading: "加载中",
|
||||
error: "出错了",
|
||||
retry: "重试",
|
||||
none: "无",
|
||||
optional: "选填",
|
||||
language: "语言",
|
||||
actions: "操作",
|
||||
saved: "已保存",
|
||||
copyFailed: "操作失败",
|
||||
yes: "是",
|
||||
no: "否",
|
||||
},
|
||||
dashboard: {
|
||||
title: "仪表盘",
|
||||
gateway: "网关",
|
||||
running: "运行中",
|
||||
stopped: "已停止",
|
||||
port: "端口",
|
||||
uptime: "运行时长",
|
||||
version: "版本",
|
||||
activeProvider: "当前供应商",
|
||||
noActiveProvider: "尚未激活任何供应商",
|
||||
requests: "请求总数",
|
||||
failed: "失败请求",
|
||||
tokensIn: "输入 Token",
|
||||
tokensOut: "输出 Token",
|
||||
recentRequests: "最近请求",
|
||||
quickApply: "写入 Claude 配置",
|
||||
restoreDone: "已还原为官方配置",
|
||||
tableTime: "时间",
|
||||
tableModel: "模型",
|
||||
tableStatus: "状态",
|
||||
tableDuration: "耗时",
|
||||
tableTokens: "Token",
|
||||
emptyLogs: "暂无请求记录",
|
||||
statsTitle: "统计",
|
||||
},
|
||||
providers: {
|
||||
title: "供应商",
|
||||
add: "新增供应商",
|
||||
edit: "编辑供应商",
|
||||
name: "名称",
|
||||
baseUrl: "Base URL",
|
||||
apiKey: "API Key",
|
||||
preset: "预设",
|
||||
presetCustom: "自定义",
|
||||
models: "模型映射",
|
||||
defaultModel: "默认模型",
|
||||
sonnetModel: "Sonnet 模型",
|
||||
haikuModel: "Haiku 模型",
|
||||
opusModel: "Opus 模型",
|
||||
extraMappings: "精确映射",
|
||||
extraKey: "请求的模型名",
|
||||
extraValue: "上游模型名",
|
||||
addMapping: "添加一行",
|
||||
activated: "已激活",
|
||||
activate: "激活并写入",
|
||||
empty: "还没有供应商,先添加一个",
|
||||
validationName: "请填写名称",
|
||||
validationBaseUrl: "请填写 http(s) Base URL",
|
||||
validationApiKey: "请填写 API Key",
|
||||
appliedEnv: "已写入 ~/.claude/settings.json 的环境变量",
|
||||
created: "供应商已创建",
|
||||
updated: "供应商已更新",
|
||||
deleted: "供应商已删除",
|
||||
deleteConfirm: "确定删除这个供应商吗?",
|
||||
keyHint: "留空表示保持原有 Key 不变",
|
||||
enabledClaude: "对 Claude Code 启用",
|
||||
},
|
||||
settings: {
|
||||
title: "设置",
|
||||
gateway: "网关",
|
||||
portMode: "端口模式",
|
||||
portAuto: "自动(随机端口)",
|
||||
portFixed: "固定端口",
|
||||
fixedPort: "固定端口号",
|
||||
portRestartHint: "端口模式在网关重启后生效",
|
||||
language: "界面语言",
|
||||
backups: "配置备份",
|
||||
backupsEmpty: "暂无备份",
|
||||
restoreBackup: "还原",
|
||||
restored: "已还原",
|
||||
saveFailed: "保存失败",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const enUS = {
|
||||
translation: {
|
||||
app: {
|
||||
name: "CrossRelay",
|
||||
tagline: "Local protocol gateway for Claude Code and Codex",
|
||||
},
|
||||
nav: {
|
||||
dashboard: "Dashboard",
|
||||
providers: "Providers",
|
||||
settings: "Settings",
|
||||
},
|
||||
common: {
|
||||
save: "Save",
|
||||
cancel: "Cancel",
|
||||
delete: "Delete",
|
||||
edit: "Edit",
|
||||
add: "Add",
|
||||
activate: "Activate",
|
||||
restoring: "Restoring",
|
||||
restore: "Restore official config",
|
||||
loading: "Loading",
|
||||
error: "Something went wrong",
|
||||
retry: "Retry",
|
||||
none: "None",
|
||||
optional: "optional",
|
||||
language: "Language",
|
||||
actions: "Actions",
|
||||
saved: "Saved",
|
||||
copyFailed: "Action failed",
|
||||
yes: "Yes",
|
||||
no: "No",
|
||||
},
|
||||
dashboard: {
|
||||
title: "Dashboard",
|
||||
gateway: "Gateway",
|
||||
running: "Running",
|
||||
stopped: "Stopped",
|
||||
port: "Port",
|
||||
uptime: "Uptime",
|
||||
version: "Version",
|
||||
activeProvider: "Active provider",
|
||||
noActiveProvider: "No provider activated yet",
|
||||
requests: "Requests",
|
||||
failed: "Failed",
|
||||
tokensIn: "Input tokens",
|
||||
tokensOut: "Output tokens",
|
||||
recentRequests: "Recent requests",
|
||||
quickApply: "Write to Claude config",
|
||||
restoreDone: "Official config restored",
|
||||
tableTime: "Time",
|
||||
tableModel: "Model",
|
||||
tableStatus: "Status",
|
||||
tableDuration: "Duration",
|
||||
tableTokens: "Tokens",
|
||||
emptyLogs: "No requests yet",
|
||||
statsTitle: "Stats",
|
||||
},
|
||||
providers: {
|
||||
title: "Providers",
|
||||
add: "Add provider",
|
||||
edit: "Edit provider",
|
||||
name: "Name",
|
||||
baseUrl: "Base URL",
|
||||
apiKey: "API key",
|
||||
preset: "Preset",
|
||||
presetCustom: "Custom",
|
||||
models: "Model mapping",
|
||||
defaultModel: "Default model",
|
||||
sonnetModel: "Sonnet model",
|
||||
haikuModel: "Haiku model",
|
||||
opusModel: "Opus model",
|
||||
extraMappings: "Exact mappings",
|
||||
extraKey: "Requested model",
|
||||
extraValue: "Upstream model",
|
||||
addMapping: "Add row",
|
||||
activated: "Active",
|
||||
activate: "Activate & apply",
|
||||
empty: "No providers yet",
|
||||
validationName: "Name is required",
|
||||
validationBaseUrl: "An http(s) base URL is required",
|
||||
validationApiKey: "API key is required",
|
||||
appliedEnv: "Environment written to ~/.claude/settings.json",
|
||||
created: "Provider created",
|
||||
updated: "Provider updated",
|
||||
deleted: "Provider deleted",
|
||||
deleteConfirm: "Delete this provider?",
|
||||
keyHint: "Leave empty to keep the current key",
|
||||
enabledClaude: "Enabled for Claude Code",
|
||||
},
|
||||
settings: {
|
||||
title: "Settings",
|
||||
gateway: "Gateway",
|
||||
portMode: "Port mode",
|
||||
portAuto: "Automatic (random port)",
|
||||
portFixed: "Fixed port",
|
||||
fixedPort: "Fixed port number",
|
||||
portRestartHint: "Port mode applies after the gateway restarts",
|
||||
language: "Language",
|
||||
backups: "Config backups",
|
||||
backupsEmpty: "No backups yet",
|
||||
restoreBackup: "Restore",
|
||||
restored: "Restored",
|
||||
saveFailed: "Save failed",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Cascadia Code", ui-monospace, monospace;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-zinc-50 text-zinc-900 antialiased;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { fetch } from "@tauri-apps/plugin-http";
|
||||
import type {
|
||||
ApplyResult,
|
||||
BackupInfo,
|
||||
LogEntry,
|
||||
Models,
|
||||
Provider,
|
||||
Settings,
|
||||
Status,
|
||||
} from "./types";
|
||||
|
||||
const FALLBACK_PORT = 8787;
|
||||
|
||||
function baseUrl(): string {
|
||||
const port =
|
||||
(window as unknown as { __CROSSRELAY_PORT__?: number }).__CROSSRELAY_PORT__ ?? FALLBACK_PORT;
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(baseUrl() + path, {
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
||||
});
|
||||
} catch (err) {
|
||||
throw new ApiError(`${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
let message = `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const body = JSON.parse(text);
|
||||
if (body?.error?.message) message = body.error.message;
|
||||
else if (typeof body?.error === "string") message = body.error;
|
||||
} catch {
|
||||
if (text) message = text;
|
||||
}
|
||||
throw new ApiError(message);
|
||||
}
|
||||
return (text ? JSON.parse(text) : null) as T;
|
||||
}
|
||||
|
||||
export interface ProviderInput {
|
||||
name: string;
|
||||
preset?: string;
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
models: Models;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getStatus: () => request<Status>("/api/status"),
|
||||
listProviders: () => request<{ items: Provider[] }>("/api/providers"),
|
||||
createProvider: (input: ProviderInput) =>
|
||||
request<Provider>("/api/providers", { method: "POST", body: JSON.stringify(input) }),
|
||||
updateProvider: (id: string, input: ProviderInput) =>
|
||||
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"),
|
||||
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"),
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, type ProviderInput } from "./api";
|
||||
|
||||
export const queryKeys = {
|
||||
status: ["status"] as const,
|
||||
providers: ["providers"] as const,
|
||||
logs: ["logs"] as const,
|
||||
backups: ["backups"] as const,
|
||||
settings: ["settings"] as const,
|
||||
};
|
||||
|
||||
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 useLogs() {
|
||||
return useQuery({ queryKey: queryKeys.logs, queryFn: api.listLogs, refetchInterval: 5000 });
|
||||
}
|
||||
|
||||
export function useBackups() {
|
||||
return useQuery({ queryKey: queryKeys.backups, queryFn: api.listBackups });
|
||||
}
|
||||
|
||||
export function useSettings() {
|
||||
return useQuery({ queryKey: queryKeys.settings, queryFn: api.getSettings });
|
||||
}
|
||||
|
||||
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: queryKeys.settings }),
|
||||
]);
|
||||
}
|
||||
|
||||
export function useProviderMutations() {
|
||||
const queryClient = useQueryClient();
|
||||
const create = useMutation({
|
||||
mutationFn: (input: ProviderInput) => api.createProvider(input),
|
||||
onSuccess: () => invalidateAll(queryClient),
|
||||
});
|
||||
const update = useMutation({
|
||||
mutationFn: ({ id, input }: { id: string; input: ProviderInput }) => api.updateProvider(id, input),
|
||||
onSuccess: () => invalidateAll(queryClient),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => api.deleteProvider(id),
|
||||
onSuccess: () => invalidateAll(queryClient),
|
||||
});
|
||||
const apply = useMutation({
|
||||
mutationFn: (id: string) => api.applyProvider(id),
|
||||
onSuccess: () => invalidateAll(queryClient),
|
||||
});
|
||||
return { create, update, remove, apply };
|
||||
}
|
||||
|
||||
export function useRestore() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => api.restore(),
|
||||
onSuccess: () => invalidateAll(queryClient),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSettingsMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (patch: Parameters<typeof api.updateSettings>[0]) => api.updateSettings(patch),
|
||||
onSuccess: () => invalidateAll(queryClient),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
export interface Models {
|
||||
default?: string;
|
||||
sonnet?: string;
|
||||
haiku?: string;
|
||||
opus?: string;
|
||||
extra?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Provider {
|
||||
id: string;
|
||||
name: string;
|
||||
preset?: string;
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
models: Models;
|
||||
enabled_for_claude: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
requests_total: number;
|
||||
requests_failed: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
}
|
||||
|
||||
export interface ActiveProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
base_url: string;
|
||||
models: Models;
|
||||
}
|
||||
|
||||
export interface Status {
|
||||
running: boolean;
|
||||
version: string;
|
||||
port: number;
|
||||
uptime_sec: number;
|
||||
active_claude_provider: ActiveProvider | null;
|
||||
stats: Stats;
|
||||
language: string;
|
||||
port_mode: string;
|
||||
fixed_port: number;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
time: string;
|
||||
method: string;
|
||||
path: string;
|
||||
model?: string;
|
||||
status: number;
|
||||
duration_ms: number;
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface BackupInfo {
|
||||
name: string;
|
||||
size: number;
|
||||
modified: string;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
language: string;
|
||||
port_mode: string;
|
||||
fixed_port: number;
|
||||
}
|
||||
|
||||
export interface ApplyResult {
|
||||
provider: Provider;
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Preset {
|
||||
key: string;
|
||||
base_url: string;
|
||||
models: Models;
|
||||
}
|
||||
|
||||
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" },
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
},
|
||||
{
|
||||
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" },
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
import "./i18n";
|
||||
import { bootstrap } from "./bootstrap";
|
||||
|
||||
bootstrap().then(({ port, language }) => {
|
||||
(window as unknown as { __CROSSRELAY_PORT__?: number }).__CROSSRELAY_PORT__ = port ?? undefined;
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App initialLanguage={language} />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { renderWithProviders } from "@/test/utils";
|
||||
|
||||
const getStatus = vi.fn();
|
||||
const listLogs = vi.fn();
|
||||
const restore = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
listProviders: vi.fn().mockResolvedValue({ items: [] }),
|
||||
createProvider: vi.fn(),
|
||||
updateProvider: vi.fn(),
|
||||
deleteProvider: vi.fn(),
|
||||
applyProvider: vi.fn(),
|
||||
restore: () => restore(),
|
||||
getStatus: () => getStatus(),
|
||||
getSettings: vi.fn().mockResolvedValue({ language: "zh-CN", port_mode: "auto", fixed_port: 0 }),
|
||||
updateSettings: vi.fn(),
|
||||
listLogs: () => listLogs(),
|
||||
listBackups: vi.fn().mockResolvedValue({ items: [] }),
|
||||
},
|
||||
}));
|
||||
|
||||
import Dashboard from "./Dashboard";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getStatus.mockResolvedValue({
|
||||
running: true,
|
||||
version: "0.1.0",
|
||||
port: 52917,
|
||||
uptime_sec: 95,
|
||||
active_claude_provider: { id: "p_1", name: "DeepSeek", base_url: "https://api.deepseek.com/v1", models: {} },
|
||||
stats: { requests_total: 7, requests_failed: 1, input_tokens: 100, output_tokens: 200 },
|
||||
language: "zh-CN",
|
||||
port_mode: "auto",
|
||||
fixed_port: 0,
|
||||
});
|
||||
listLogs.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
time: "2026-10-18T08:00:00Z",
|
||||
method: "POST",
|
||||
path: "/v1/messages",
|
||||
model: "deepseek-chat",
|
||||
status: 200,
|
||||
duration_ms: 820,
|
||||
input_tokens: 40,
|
||||
output_tokens: 12,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dashboard", () => {
|
||||
it("renders gateway status, active provider and stats", async () => {
|
||||
renderWithProviders(<Dashboard />);
|
||||
expect(await screen.findByText("运行中")).toBeInTheDocument();
|
||||
expect(screen.getByText(/52917/)).toBeInTheDocument();
|
||||
expect(screen.getByText("DeepSeek")).toBeInTheDocument();
|
||||
expect(screen.getByText("7")).toBeInTheDocument(); // requests total
|
||||
expect(screen.getByText("deepseek-chat")).toBeInTheDocument(); // log model
|
||||
// 200 shows up both as output tokens and as the log row status.
|
||||
expect(screen.getAllByText("200").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RotateCcw, Zap } from "lucide-react";
|
||||
import { useRestore, useStatus, useLogs } 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 { Table, TBody, TD, TH, THead, TR } from "@/components/ui/table";
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
return date.toLocaleTimeString();
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { t } = useTranslation();
|
||||
const status = useStatus();
|
||||
const logs = useLogs();
|
||||
const restore = useRestore();
|
||||
const { show } = useToast();
|
||||
|
||||
if (status.isPending) {
|
||||
return <div className="text-sm text-zinc-500">{t("common.loading")}</div>;
|
||||
}
|
||||
if (status.isError || !status.data) {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<span className="text-sm text-red-600">{t("common.error")}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => status.refetch()}>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const s = status.data;
|
||||
const active = s.active_claude_provider;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("dashboard.gateway")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge tone={s.running ? "success" : "danger"}>
|
||||
{s.running ? t("dashboard.running") : t("dashboard.stopped")}
|
||||
</Badge>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{t("dashboard.port")} {s.port}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{t("dashboard.uptime")} {formatUptime(s.uptime_sec)} · {t("dashboard.version")}{" "}
|
||||
{s.version}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("dashboard.activeProvider")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{active ? (
|
||||
<>
|
||||
<div className="text-sm font-medium">{active.name}</div>
|
||||
<div className="truncate text-xs text-zinc-500" title={active.base_url}>
|
||||
{active.base_url}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-zinc-500">{t("dashboard.noActiveProvider")}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("dashboard.statsTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<dt className="text-zinc-500">{t("dashboard.requests")}</dt>
|
||||
<dd className="text-base font-semibold">{s.stats.requests_total}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-zinc-500">{t("dashboard.failed")}</dt>
|
||||
<dd className="text-base font-semibold">{s.stats.requests_failed}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-zinc-500">{t("dashboard.tokensIn")}</dt>
|
||||
<dd className="text-base font-semibold">{s.stats.input_tokens}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-zinc-500">{t("dashboard.tokensOut")}</dt>
|
||||
<dd className="text-base font-semibold">{s.stats.output_tokens}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between">
|
||||
<CardTitle>{t("dashboard.recentRequests")}</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={restore.isPending}
|
||||
onClick={() =>
|
||||
restore.mutate(undefined, {
|
||||
onSuccess: () => show(t("dashboard.restoreDone")),
|
||||
onError: (err) => show(err.message, "error"),
|
||||
})
|
||||
}
|
||||
>
|
||||
{restore.isPending ? (
|
||||
<RotateCcw size={14} className="animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Zap size={14} aria-hidden />
|
||||
)}
|
||||
{restore.isPending ? t("common.restoring") : t("common.restore")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{logs.data && logs.data.items.length > 0 ? (
|
||||
<Table>
|
||||
<THead>
|
||||
<TR>
|
||||
<TH>{t("dashboard.tableTime")}</TH>
|
||||
<TH>{t("dashboard.tableModel")}</TH>
|
||||
<TH>{t("dashboard.tableStatus")}</TH>
|
||||
<TH>{t("dashboard.tableDuration")}</TH>
|
||||
<TH>{t("dashboard.tableTokens")}</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{logs.data.items.slice(0, 12).map((entry, index) => (
|
||||
<TR key={`${entry.time}-${index}`}>
|
||||
<TD className="text-xs text-zinc-500">{formatTime(entry.time)}</TD>
|
||||
<TD className="font-mono text-xs">{entry.model ?? entry.path}</TD>
|
||||
<TD>
|
||||
<Badge tone={entry.status < 400 ? "success" : "danger"}>{entry.status}</Badge>
|
||||
</TD>
|
||||
<TD className="text-xs">{entry.duration_ms} ms</TD>
|
||||
<TD className="text-xs">
|
||||
{entry.input_tokens ?? 0} / {entry.output_tokens ?? 0}
|
||||
</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-sm text-zinc-500">{t("dashboard.emptyLogs")}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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 createProvider = vi.fn();
|
||||
const deleteProvider = vi.fn();
|
||||
const applyProvider = vi.fn();
|
||||
const restore = vi.fn();
|
||||
const getStatus = vi.fn();
|
||||
const getSettings = vi.fn();
|
||||
const listLogs = vi.fn();
|
||||
const listBackups = vi.fn();
|
||||
const updateProvider = vi.fn();
|
||||
const updateSettings = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
listProviders: () => listProviders(),
|
||||
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(),
|
||||
getStatus: () => getStatus(),
|
||||
getSettings: () => getSettings(),
|
||||
updateSettings: (patch: unknown) => updateSettings(patch),
|
||||
listLogs: () => listLogs(),
|
||||
listBackups: () => listBackups(),
|
||||
},
|
||||
}));
|
||||
|
||||
import Providers from "./Providers";
|
||||
|
||||
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,
|
||||
created_at: "2026-10-18T00:00:00Z",
|
||||
};
|
||||
|
||||
function renderPage() {
|
||||
return renderWithProviders(<Providers />);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listProviders.mockResolvedValue({ items: [provider] });
|
||||
createProvider.mockResolvedValue({ ...provider, id: "p_2" });
|
||||
deleteProvider.mockResolvedValue({ deleted: "p_1" });
|
||||
applyProvider.mockResolvedValue({ provider, env: { ANTHROPIC_BASE_URL: "http://127.0.0.1:1234" } });
|
||||
});
|
||||
|
||||
describe("Providers page", () => {
|
||||
it("renders the provider list", async () => {
|
||||
renderPage();
|
||||
expect(await screen.findByText("DeepSeek")).toBeInTheDocument();
|
||||
expect(screen.getByText("https://api.deepseek.com/v1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("validates the create form before submitting", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await user.click(await screen.findByRole("button", { name: /新增供应商|Add provider/ }));
|
||||
await user.click(screen.getByRole("button", { name: /保存|Save/ }));
|
||||
expect(await screen.findByText(/请填写名称|Name is required/)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/请填写 http|http\(s\) base URL is required/)).toBeInTheDocument();
|
||||
expect(createProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates a provider with the preset defaults", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
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));
|
||||
const input = createProvider.mock.calls[0][0];
|
||||
expect(input).toMatchObject({
|
||||
name: "Kimi",
|
||||
base_url: "https://api.moonshot.cn/v1",
|
||||
api_key: "sk-kimi",
|
||||
});
|
||||
});
|
||||
|
||||
it("activates a provider and shows the applied env", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await user.click(await screen.findByRole("button", { name: /激活并写入|Activate & apply/ }));
|
||||
await waitFor(() => expect(applyProvider).toHaveBeenCalledWith("p_1"));
|
||||
expect(await screen.findByText(/ANTHROPIC_BASE_URL/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
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 { 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: Models;
|
||||
}
|
||||
|
||||
const emptyForm: FormState = {
|
||||
name: "",
|
||||
preset: "",
|
||||
base_url: "",
|
||||
api_key: "",
|
||||
models: { default: "", sonnet: "", haiku: "", opus: "", extra: {} },
|
||||
};
|
||||
|
||||
export default function Providers() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isError } = useProviders();
|
||||
const mutations = useProviderMutations();
|
||||
const { show } = useToast();
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
|
||||
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 } : { ...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: { ...(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: Object.fromEntries(
|
||||
Object.entries(form.models.extra ?? {}).filter(([k, v]) => k.trim() && v.trim()),
|
||||
),
|
||||
},
|
||||
};
|
||||
const onSuccess = () => {
|
||||
show(form.id ? t("providers.updated") : t("providers.created"));
|
||||
setForm(null);
|
||||
};
|
||||
const onError = (err: Error) => show(err.message, "error");
|
||||
if (form.id) {
|
||||
mutations.update.mutate({ id: form.id, input }, { onSuccess, onError });
|
||||
} else {
|
||||
mutations.create.mutate(input, { onSuccess, onError });
|
||||
}
|
||||
};
|
||||
|
||||
const apply = (provider: Provider) => {
|
||||
mutations.apply.mutate(provider.id, {
|
||||
onSuccess: (result) => {
|
||||
setLastAppliedEnv(result.env);
|
||||
show(`${t("providers.activated")}: ${provider.name}`);
|
||||
},
|
||||
onError: (err) => 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("providers.title")}</h2>
|
||||
<Button size="sm" onClick={() => openCreate()}>
|
||||
<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 } : 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>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{lastAppliedEnv && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("providers.appliedEnv")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="grid grid-cols-2 gap-1 font-mono text-xs text-zinc-600">
|
||||
{Object.entries(lastAppliedEnv)
|
||||
.filter(([key]) => key.startsWith("ANTHROPIC_"))
|
||||
.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("providers.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_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>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<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)}>
|
||||
<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) => show(err.message, "error"),
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useBackups, useRestore, useSettings, useSettingsMutation } from "@/lib/queries";
|
||||
import { useToast } from "@/components/toast";
|
||||
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";
|
||||
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 mutation = useSettingsMutation();
|
||||
const restore = useRestore();
|
||||
const { show } = useToast();
|
||||
|
||||
const [portMode, setPortMode] = useState("auto");
|
||||
const [fixedPort, setFixedPort] = useState("8787");
|
||||
const [language, setLanguage] = useState(i18n.language);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setPortMode(settings.port_mode);
|
||||
setFixedPort(String(settings.fixed_port || 8787));
|
||||
setLanguage(settings.language);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const save = () => {
|
||||
mutation.mutate(
|
||||
{
|
||||
port_mode: portMode,
|
||||
fixed_port: portMode === "fixed" ? Number(fixedPort) || 0 : 0,
|
||||
language,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
void i18n.changeLanguage(language);
|
||||
show(t("common.saved"));
|
||||
},
|
||||
onError: (err) => show(err.message, "error"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<h2 className="text-lg font-semibold">{t("settings.title")}</h2>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("settings.gateway")}</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("settings.portMode")}</Label>
|
||||
<Select value={portMode} onChange={(e) => setPortMode(e.target.value)}>
|
||||
<option value="auto">{t("settings.portAuto")}</option>
|
||||
<option value="fixed">{t("settings.portFixed")}</option>
|
||||
</Select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<Label>{t("settings.fixedPort")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={fixedPort}
|
||||
disabled={portMode !== "fixed"}
|
||||
onChange={(e) => setFixedPort(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">{t("settings.portRestartHint")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("settings.language")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Select value={language} onChange={(e) => setLanguage(e.target.value)} className="max-w-48">
|
||||
<option value="zh-CN">中文</option>
|
||||
<option value="en-US">English</option>
|
||||
</Select>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between">
|
||||
<CardTitle>{t("settings.backups")}</CardTitle>
|
||||
<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"),
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("settings.restoreBackup")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{backups && backups.items && backups.items.length > 0 ? (
|
||||
<Table>
|
||||
<THead>
|
||||
<TR>
|
||||
<TH>{t("settings.backups")}</TH>
|
||||
<TH>Size</TH>
|
||||
<TH>Modified</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{backups.items.map((item) => (
|
||||
<TR key={item.name}>
|
||||
<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>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-sm text-zinc-500">{t("settings.backupsEmpty")}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={save} disabled={mutation.isPending}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import "../i18n";
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as React from "react";
|
||||
import { render, type RenderOptions } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ToastProvider } from "@/components/toast";
|
||||
|
||||
export function renderWithProviders(ui: React.ReactElement, options?: RenderOptions) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>{ui}</ToastProvider>
|
||||
</QueryClientProvider>,
|
||||
options,
|
||||
);
|
||||
}
|
||||
export { render };
|
||||
Reference in new issue
Block a user