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