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

+72
View File
@@ -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"),
};
+76
View File
@@ -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),
});
}
+102
View File
@@ -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" },
},
];
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}