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
@@ -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} />;
|
||||
}
|
||||
Reference in new issue
Block a user