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:
kate522 committed 2026-10-18 17:19:13 +08:00
1 parent 8ee5e98ea3
commit f8acf31ebc
71 files changed
+15727 -1

No files matched your search

+67
View File
@@ -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>
);
}
+56
View File
@@ -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;
}
+28
View File
@@ -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}
/>
);
}
+41
View File
@@ -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";
+27
View File
@@ -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} />;
}
+25
View File
@@ -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";
+18
View File
@@ -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";
+32
View File
@@ -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>
);
}
+28
View File
@@ -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} />;
}