feat: complete stage 1 infrastructure

This commit is contained in:
Jason
2025-10-16 10:00:22 +08:00
parent 95e2d84655
commit cc0b7053aa
31 changed files with 2350 additions and 9 deletions

6
src/lib/api/index.ts Normal file
View File

@@ -0,0 +1,6 @@
export type { AppType } from "./types";
export { providersApi } from "./providers";
export { settingsApi } from "./settings";
export { mcpApi } from "./mcp";
export { usageApi } from "./usage";
export { vscodeApi } from "./vscode";

69
src/lib/api/mcp.ts Normal file
View File

@@ -0,0 +1,69 @@
import { invoke } from "@tauri-apps/api/core";
import type {
McpConfigResponse,
McpServer,
McpServerSpec,
McpStatus,
} from "@/types";
import type { AppType } from "./types";
export const mcpApi = {
async getStatus(): Promise<McpStatus> {
return await invoke("get_claude_mcp_status");
},
async readConfig(): Promise<string | null> {
return await invoke("read_claude_mcp_config");
},
async upsertServer(
id: string,
spec: McpServerSpec | Record<string, any>
): Promise<boolean> {
return await invoke("upsert_claude_mcp_server", { id, spec });
},
async deleteServer(id: string): Promise<boolean> {
return await invoke("delete_claude_mcp_server", { id });
},
async validateCommand(cmd: string): Promise<boolean> {
return await invoke("validate_mcp_command", { cmd });
},
async getConfig(app: AppType = "claude"): Promise<McpConfigResponse> {
return await invoke("get_mcp_config", { app });
},
async upsertServerInConfig(
app: AppType,
id: string,
spec: McpServer,
options?: { syncOtherSide?: boolean }
): Promise<boolean> {
const payload = {
app,
id,
spec,
...(options?.syncOtherSide !== undefined
? { syncOtherSide: options.syncOtherSide }
: {}),
};
return await invoke("upsert_mcp_server_in_config", payload);
},
async deleteServerInConfig(
app: AppType,
id: string,
options?: { syncOtherSide?: boolean }
): Promise<boolean> {
const payload = {
app,
id,
...(options?.syncOtherSide !== undefined
? { syncOtherSide: options.syncOtherSide }
: {}),
};
return await invoke("delete_mcp_server_in_config", payload);
},
};

75
src/lib/api/providers.ts Normal file
View File

@@ -0,0 +1,75 @@
import { invoke } from "@tauri-apps/api/core";
import type { Provider } from "@/types";
import type { AppType } from "./types";
export interface ProviderSortUpdate {
id: string;
sortIndex: number;
}
export const providersApi = {
async getAll(appType: AppType): Promise<Record<string, Provider>> {
return await invoke("get_providers", { app_type: appType, app: appType });
},
async getCurrent(appType: AppType): Promise<string> {
return await invoke("get_current_provider", {
app_type: appType,
app: appType,
});
},
async add(provider: Provider, appType: AppType): Promise<boolean> {
return await invoke("add_provider", {
provider,
app_type: appType,
app: appType,
});
},
async update(provider: Provider, appType: AppType): Promise<boolean> {
return await invoke("update_provider", {
provider,
app_type: appType,
app: appType,
});
},
async delete(id: string, appType: AppType): Promise<boolean> {
return await invoke("delete_provider", {
id,
app_type: appType,
app: appType,
});
},
async switch(id: string, appType: AppType): Promise<boolean> {
return await invoke("switch_provider", {
id,
app_type: appType,
app: appType,
});
},
async importDefault(appType: AppType): Promise<boolean> {
return await invoke("import_default_config", {
app_type: appType,
app: appType,
});
},
async updateTrayMenu(): Promise<boolean> {
return await invoke("update_tray_menu");
},
async updateSortOrder(
updates: ProviderSortUpdate[],
appType: AppType
): Promise<boolean> {
return await invoke("update_providers_sort_order", {
updates,
app_type: appType,
app: appType,
});
},
};

52
src/lib/api/settings.ts Normal file
View File

@@ -0,0 +1,52 @@
import { invoke } from "@tauri-apps/api/core";
import type { Settings } from "@/types";
import type { AppType } from "./types";
export const settingsApi = {
async get(): Promise<Settings> {
return await invoke("get_settings");
},
async save(settings: Settings): Promise<boolean> {
return await invoke("save_settings", { settings });
},
async restart(): Promise<boolean> {
return await invoke("restart_app");
},
async checkUpdates(): Promise<void> {
await invoke("check_for_updates");
},
async isPortable(): Promise<boolean> {
return await invoke("is_portable_mode");
},
async getConfigDir(appType: AppType): Promise<string> {
return await invoke("get_config_dir", {
app_type: appType,
app: appType,
});
},
async openConfigFolder(appType: AppType): Promise<void> {
await invoke("open_config_folder", { app_type: appType, app: appType });
},
async selectConfigDirectory(defaultPath?: string): Promise<string | null> {
return await invoke("pick_directory", { default_path: defaultPath });
},
async getClaudeCodeConfigPath(): Promise<string> {
return await invoke("get_claude_code_config_path");
},
async getAppConfigPath(): Promise<string> {
return await invoke("get_app_config_path");
},
async openAppConfigFolder(): Promise<void> {
await invoke("open_app_config_folder");
},
};

1
src/lib/api/types.ts Normal file
View File

@@ -0,0 +1 @@
export type AppType = "claude" | "codex";

15
src/lib/api/usage.ts Normal file
View File

@@ -0,0 +1,15 @@
import { invoke } from "@tauri-apps/api/core";
import type { UsageResult } from "@/types";
import type { AppType } from "./types";
export const usageApi = {
async query(providerId: string, appType: AppType): Promise<UsageResult> {
return await invoke("query_provider_usage", {
provider_id: providerId,
providerId: providerId,
app_type: appType,
app: appType,
appType,
});
},
};

113
src/lib/api/vscode.ts Normal file
View File

@@ -0,0 +1,113 @@
import { invoke } from "@tauri-apps/api/core";
import type { CustomEndpoint } from "@/types";
import type { AppType } from "./types";
export interface EndpointLatencyResult {
url: string;
latency: number | null;
status?: number;
error?: string;
}
export const vscodeApi = {
async getLiveProviderSettings(appType: AppType) {
return await invoke("read_live_provider_settings", {
app_type: appType,
app: appType,
appType,
});
},
async testApiEndpoints(
urls: string[],
options?: { timeoutSecs?: number }
): Promise<EndpointLatencyResult[]> {
return await invoke("test_api_endpoints", {
urls,
timeout_secs: options?.timeoutSecs,
});
},
async getCustomEndpoints(
appType: AppType,
providerId: string
): Promise<CustomEndpoint[]> {
return await invoke("get_custom_endpoints", {
app_type: appType,
app: appType,
appType,
provider_id: providerId,
providerId,
});
},
async addCustomEndpoint(
appType: AppType,
providerId: string,
url: string
): Promise<void> {
await invoke("add_custom_endpoint", {
app_type: appType,
app: appType,
appType,
provider_id: providerId,
providerId,
url,
});
},
async removeCustomEndpoint(
appType: AppType,
providerId: string,
url: string
): Promise<void> {
await invoke("remove_custom_endpoint", {
app_type: appType,
app: appType,
appType,
provider_id: providerId,
providerId,
url,
});
},
async updateEndpointLastUsed(
appType: AppType,
providerId: string,
url: string
): Promise<void> {
await invoke("update_endpoint_last_used", {
app_type: appType,
app: appType,
appType,
provider_id: providerId,
providerId,
url,
});
},
async exportConfigToFile(filePath: string) {
return await invoke("export_config_to_file", {
file_path: filePath,
filePath,
});
},
async importConfigFromFile(filePath: string) {
return await invoke("import_config_from_file", {
file_path: filePath,
filePath,
});
},
async saveFileDialog(defaultName: string): Promise<string | null> {
return await invoke("save_file_dialog", {
default_name: defaultName,
defaultName,
});
},
async openFileDialog(): Promise<string | null> {
return await invoke("open_file_dialog");
},
};

3
src/lib/query/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from "./queryClient";
export * from "./queries";
export * from "./mutations";

155
src/lib/query/mutations.ts Normal file
View File

@@ -0,0 +1,155 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
providersApi,
settingsApi,
type AppType,
} from "@/lib/api";
import type { Provider, Settings } from "@/types";
export const useAddProviderMutation = (appType: AppType) => {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: async (providerInput: Omit<Provider, "id">) => {
const newProvider: Provider = {
...providerInput,
id: crypto.randomUUID(),
createdAt: Date.now(),
};
await providersApi.add(newProvider, appType);
return newProvider;
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["providers", appType] });
await providersApi.updateTrayMenu();
toast.success(
t("notifications.providerAdded", {
defaultValue: "供应商已添加",
})
);
},
onError: (error: Error) => {
toast.error(
t("notifications.addFailed", {
defaultValue: "添加供应商失败: {{error}}",
error: error.message,
})
);
},
});
};
export const useUpdateProviderMutation = (appType: AppType) => {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: async (provider: Provider) => {
await providersApi.update(provider, appType);
return provider;
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["providers", appType] });
toast.success(
t("notifications.updateSuccess", {
defaultValue: "供应商更新成功",
})
);
},
onError: (error: Error) => {
toast.error(
t("notifications.updateFailed", {
defaultValue: "更新供应商失败: {{error}}",
error: error.message,
})
);
},
});
};
export const useDeleteProviderMutation = (appType: AppType) => {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: async (providerId: string) => {
await providersApi.delete(providerId, appType);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["providers", appType] });
await providersApi.updateTrayMenu();
toast.success(
t("notifications.deleteSuccess", {
defaultValue: "供应商已删除",
})
);
},
onError: (error: Error) => {
toast.error(
t("notifications.deleteFailed", {
defaultValue: "删除供应商失败: {{error}}",
error: error.message,
})
);
},
});
};
export const useSwitchProviderMutation = (appType: AppType) => {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: async (providerId: string) => {
return await providersApi.switch(providerId, appType);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["providers", appType] });
await providersApi.updateTrayMenu();
toast.success(
t("notifications.switchSuccess", {
defaultValue: "切换供应商成功",
appName: t(`apps.${appType}`, { defaultValue: appType }),
})
);
},
onError: (error: Error) => {
toast.error(
t("notifications.switchFailed", {
defaultValue: "切换供应商失败: {{error}}",
error: error.message,
})
);
},
});
};
export const useSaveSettingsMutation = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: async (settings: Settings) => {
await settingsApi.save(settings);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["settings"] });
toast.success(
t("notifications.settingsSaved", {
defaultValue: "设置已保存",
})
);
},
onError: (error: Error) => {
toast.error(
t("notifications.settingsSaveFailed", {
defaultValue: "保存设置失败: {{error}}",
error: error.message,
})
);
},
});
};

79
src/lib/query/queries.ts Normal file
View File

@@ -0,0 +1,79 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { providersApi, settingsApi, type AppType } from "@/lib/api";
import type { Provider, Settings } from "@/types";
const sortProviders = (
providers: Record<string, Provider>
): Record<string, Provider> => {
const sortedEntries = Object.values(providers)
.sort((a, b) => {
const indexA = a.sortIndex ?? Number.MAX_SAFE_INTEGER;
const indexB = b.sortIndex ?? Number.MAX_SAFE_INTEGER;
if (indexA !== indexB) {
return indexA - indexB;
}
const timeA = a.createdAt ?? 0;
const timeB = b.createdAt ?? 0;
if (timeA === timeB) {
return a.name.localeCompare(b.name, "zh-CN");
}
return timeA - timeB;
})
.map((provider) => [provider.id, provider] as const);
return Object.fromEntries(sortedEntries);
};
export interface ProvidersQueryData {
providers: Record<string, Provider>;
currentProviderId: string;
}
export const useProvidersQuery = (
appType: AppType
): UseQueryResult<ProvidersQueryData> => {
return useQuery({
queryKey: ["providers", appType],
queryFn: async () => {
let providers: Record<string, Provider> = {};
let currentProviderId = "";
try {
providers = await providersApi.getAll(appType);
} catch (error) {
console.error("获取供应商列表失败:", error);
}
try {
currentProviderId = await providersApi.getCurrent(appType);
} catch (error) {
console.error("获取当前供应商失败:", error);
}
if (Object.keys(providers).length === 0) {
try {
const success = await providersApi.importDefault(appType);
if (success) {
providers = await providersApi.getAll(appType);
currentProviderId = await providersApi.getCurrent(appType);
}
} catch (error) {
console.error("导入默认配置失败:", error);
}
}
return {
providers: sortProviders(providers),
currentProviderId,
};
},
});
};
export const useSettingsQuery = (): UseQueryResult<Settings> => {
return useQuery({
queryKey: ["settings"],
queryFn: async () => settingsApi.get(),
});
};

View File

@@ -0,0 +1,14 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 1000 * 60 * 5,
},
mutations: {
retry: false,
},
},
});

24
src/lib/schemas/mcp.ts Normal file
View File

@@ -0,0 +1,24 @@
import { z } from "zod";
const mcpServerSpecSchema = z.object({
type: z.enum(["stdio", "http"]).optional(),
command: z.string().trim().min(1, "请输入可执行命令").optional(),
args: z.array(z.string()).optional(),
env: z.record(z.string(), z.string()).optional(),
cwd: z.string().optional(),
url: z.string().url("请输入有效的 URL").optional(),
headers: z.record(z.string(), z.string()).optional(),
});
export const mcpServerSchema = z.object({
id: z.string().min(1, "请输入服务器 ID"),
name: z.string().optional(),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
homepage: z.string().url().optional(),
docs: z.string().url().optional(),
enabled: z.boolean().optional(),
server: mcpServerSpecSchema,
});
export type McpServerFormData = z.infer<typeof mcpServerSchema>;

View File

@@ -0,0 +1,23 @@
import { z } from "zod";
export const providerSchema = z.object({
name: z.string().min(1, "请填写供应商名称"),
websiteUrl: z
.string()
.url("请输入有效的网址")
.optional()
.or(z.literal("")),
settingsConfig: z
.string()
.min(1, "请填写配置内容")
.refine((value) => {
try {
JSON.parse(value);
return true;
} catch {
return false;
}
}, "配置 JSON 格式错误"),
});
export type ProviderFormData = z.infer<typeof providerSchema>;

View File

@@ -0,0 +1,21 @@
import { z } from "zod";
const directorySchema = z
.string()
.trim()
.min(1, "路径不能为空")
.optional()
.or(z.literal(""));
export const settingsSchema = z.object({
showInTray: z.boolean(),
minimizeToTrayOnClose: z.boolean(),
enableClaudePluginIntegration: z.boolean().optional(),
claudeConfigDir: directorySchema.nullable().optional(),
codexConfigDir: directorySchema.nullable().optional(),
language: z.enum(["en", "zh"]).optional(),
customEndpointsClaude: z.record(z.string(), z.unknown()).optional(),
customEndpointsCodex: z.record(z.string(), z.unknown()).optional(),
});
export type SettingsFormData = z.infer<typeof settingsSchema>;

6
src/lib/utils.ts Normal file
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));
}